gateway: ClinePass's DeepSeek models can be pinned to DeepSeek's own API (White Immortal on Discord: clinepass 针对deepseek的模型能否提供一键钉住deepseek官方上游) — Cline's AI gateway serves a model from whichever host it picks, so a DeepSeek model's prompt cache was hit or missed by chance. A Cline provider's editor now has Upstream: DeepSeek models only from DeepSeek's own API (provider.PinUpstream, off by default; zh, ja, de strings); on, every Chat request to it for a DeepSeek model (cline-pass/deepseek-…, not Cline's free ones) carries providerOptions.gateway.only ["deepseek"], as ClinePass + ProviderPin sets it (linux.do/t/topic/2918145), added at the body's end so the cached prefix doesn't change, also when built from a Messages or Responses request. A body naming providerOptions itself goes as sent. TestClinePinsDeepSeek fails on the old code (no providerOptions on chat, messages, responses); clinepass-pin.test.cjs (WebKit, en and zh). provider-price-rate and click-scroll pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried with a real ClinePass key (none on this machine).
This commit is contained in:
1 parent
3ec6b4da27
commit
1b025b3fad
9 files changed
+256
-2
No files matched your search
@@ -0,0 +1,39 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// clinePin is body with its DeepSeek model pinned to DeepSeek's own API
|
||||
// on the Cline API, when the provider asks it (provider.PinUpstream):
|
||||
// providerOptions.gateway.only, which Cline's AI gateway reads to serve
|
||||
// the model from that host alone, as ClinePass + ProviderPin set it
|
||||
// (linux.do/t/topic/2918145). A body naming providerOptions itself is
|
||||
// left as the client sent it. The field goes at the end, so the prefix a
|
||||
// cache keys on doesn't change.
|
||||
func clinePin(p provider.Provider, to provider.Protocol, body []byte) []byte {
|
||||
if to != provider.Chat {
|
||||
return body
|
||||
}
|
||||
up := p.ClinePin(gjson.GetBytes(body, "model").String())
|
||||
if up == "" || gjson.GetBytes(body, "providerOptions").Exists() {
|
||||
return body
|
||||
}
|
||||
end := bytes.LastIndexByte(body, '}')
|
||||
if end < 0 {
|
||||
return body
|
||||
}
|
||||
opts, _ := json.Marshal(map[string]any{"gateway": map[string]any{"only": []string{up}}})
|
||||
out := make([]byte, 0, len(body)+len(opts)+24)
|
||||
out = append(out, body[:end]...)
|
||||
if len(bytes.TrimSpace(body[bytes.IndexByte(body, '{')+1:end])) > 0 {
|
||||
out = append(out, ',')
|
||||
}
|
||||
out = append(out, `"providerOptions":`...)
|
||||
out = append(out, opts...)
|
||||
return append(out, body[end:]...)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// ClinePass's DeepSeek models are served by DeepSeek's own API alone when
|
||||
// the provider pins them (White Immortal on Discord, as ClinePass +
|
||||
// ProviderPin does): each request for one carries
|
||||
// providerOptions.gateway.only ["deepseek"], on Chat and when an Anthropic
|
||||
// or Responses request is built into one. Its other models, Cline's free
|
||||
// ones, a provider that doesn't pin, and a body naming providerOptions
|
||||
// itself go as they were.
|
||||
func TestClinePinsDeepSeek(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
cline, err := provider.FromPreset("clinepass")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cline.ID, cline.Key, cline.PinUpstream = "cline", "clp_key", true
|
||||
cline.Models = []string{"cline-pass/deepseek-v4-pro", "cline-pass/glm-5.3", "cline-free/deepseek-v4.1-flash"}
|
||||
plain := cline
|
||||
plain.ID, plain.Name, plain.PinUpstream = "cline2", "ClinePass 2", false
|
||||
for _, p := range []provider.Provider{cline, plain} {
|
||||
if err := provider.Save(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var sent []byte
|
||||
s := New()
|
||||
s.client = &http.Client{Transport: countTransport(func(r *http.Request) (*http.Response, error) {
|
||||
sent, _ = io.ReadAll(r.Body)
|
||||
if gjson.GetBytes(sent, "stream").Bool() {
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader("data: {\"id\":\"c\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"))}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"c","object":"chat.completion","model":"m","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}]}`))}, nil
|
||||
})}
|
||||
ask := func(path, body string) string {
|
||||
t.Helper()
|
||||
sent = nil
|
||||
req := httptest.NewRequest("POST", path, strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("%s %s: %d %s", path, body, rec.Code, rec.Body)
|
||||
}
|
||||
return gjson.GetBytes(sent, "providerOptions").Raw
|
||||
}
|
||||
chat := func(model, extra string) string {
|
||||
return ask("/v1/chat/completions", `{"model":"`+model+`","messages":[{"role":"user","content":"Say pong"}]`+extra+`}`)
|
||||
}
|
||||
const pinned = `{"gateway":{"only":["deepseek"]}}`
|
||||
if got := chat("cline/cline-pass/deepseek-v4-pro", ""); got != pinned {
|
||||
t.Errorf("chat: providerOptions %s, want %s (sent %s)", got, pinned, sent)
|
||||
}
|
||||
if got := ask("/v1/messages", `{"model":"cline/cline-pass/deepseek-v4-pro","max_tokens":64,"messages":[{"role":"user","content":"Say pong"}]}`); got != pinned {
|
||||
t.Errorf("messages: providerOptions %s, want %s", got, pinned)
|
||||
}
|
||||
if got := ask("/v1/responses", `{"model":"cline/cline-pass/deepseek-v4-pro","input":"Say pong","stream":false}`); got != pinned {
|
||||
t.Errorf("responses: providerOptions %s, want %s", got, pinned)
|
||||
}
|
||||
for _, m := range []string{"cline/cline-pass/glm-5.3", "cline/cline-free/deepseek-v4.1-flash", "cline2/cline-pass/deepseek-v4-pro"} {
|
||||
if got := chat(m, ""); got != "" {
|
||||
t.Errorf("%s: providerOptions %s, want none", m, got)
|
||||
}
|
||||
}
|
||||
if got := chat("cline/cline-pass/deepseek-v4-pro", `,"providerOptions":{"gateway":{"order":["novita"]}}`); got != `{"gateway":{"order":["novita"]}}` {
|
||||
t.Errorf("the client's own providerOptions became %s", got)
|
||||
}
|
||||
}
|
||||
@@ -2225,6 +2225,7 @@ func (s *Server) forwardOnce(ctx context.Context, p provider.Provider, to provid
|
||||
ctx = p.Via(ctx)
|
||||
body = deepseekToolPatterns(p, to, body)
|
||||
body = toolOneOfAsAnyOf(p, to, body)
|
||||
body = clinePin(p, to, body)
|
||||
if to == provider.Anthropic {
|
||||
body = s.bodyBetas(p, body)
|
||||
body = s.withoutRefusedShapes(p, body)
|
||||
|
||||
@@ -6486,7 +6486,7 @@ function pickedOf(p) {
|
||||
|
||||
// draftOf is a saved provider as its editor's form holds it.
|
||||
function draftOf(p) {
|
||||
return { id: p.id, name: p.name, preset: p.preset, chat: p.chat, responses: p.responses, anthropic: p.anthropic, catalog: p.catalog, decide: p.decide || "", key: "", api: p.chat ? "openai" : p.anthropic ? "anthropic" : p.responses ? "responses" : p.decide ? "decide" : "openai", chosen: pickedOf(p), extra: [], headers: headerRows(p.headers), icon: p.icon || "", fallback: [...(p.fallback || [])], unlisted: !!p.unlisted, searches: !!p.searches, balanceURL: p.balanceURL || "", balancePath: p.balancePath || "", modelsURL: p.modelsURL || "", contexts: contextsText(p.contexts), outputs: contextsText(p.outputs), keysUrl: p.keysUrl || "", ...proxyDraft(p), ...concurrencyDraft(p) };
|
||||
return { id: p.id, name: p.name, preset: p.preset, chat: p.chat, responses: p.responses, anthropic: p.anthropic, catalog: p.catalog, decide: p.decide || "", key: "", api: p.chat ? "openai" : p.anthropic ? "anthropic" : p.responses ? "responses" : p.decide ? "decide" : "openai", chosen: pickedOf(p), extra: [], headers: headerRows(p.headers), icon: p.icon || "", fallback: [...(p.fallback || [])], unlisted: !!p.unlisted, searches: !!p.searches, pinUpstream: !!p.pinUpstream, balanceURL: p.balanceURL || "", balancePath: p.balancePath || "", modelsURL: p.modelsURL || "", contexts: contextsText(p.contexts), outputs: contextsText(p.outputs), keysUrl: p.keysUrl || "", ...proxyDraft(p), ...concurrencyDraft(p) };
|
||||
}
|
||||
|
||||
// duplicateProvider opens the Add form on a copy of p (#268): its URLs,
|
||||
@@ -6804,6 +6804,16 @@ function drawEditor(p, presetID) {
|
||||
ed.append(...concurrencyField(p));
|
||||
ed.append(...priceRateField());
|
||||
|
||||
// the Cline API serves a model from whichever host its AI gateway picks;
|
||||
// pinned, its DeepSeek models are served by DeepSeek's own API alone,
|
||||
// which keeps their prompt cache (White Immortal on Discord)
|
||||
if (p?.cline || (isNew && pr?.id === "clinepass")) {
|
||||
const [ptk, pcb] = tick(t("DeepSeek models only from DeepSeek's own API"), !!draft.pinUpstream);
|
||||
ptk.classList.add("pin-upstream");
|
||||
pcb.onchange = () => { draft.pinUpstream = pcb.checked; };
|
||||
ed.append(...field(t("Upstream"), ptk, t("Cline serves a model from any host its gateway picks; pinned, a request for a DeepSeek model goes only to DeepSeek, which keeps its prompt cache, and fails when DeepSeek can't take it")));
|
||||
}
|
||||
|
||||
// a relay in front of Anthropic's or OpenAI's API searches the web as
|
||||
// they do, which magpie can't tell from its host (#359): a client's web
|
||||
// search goes to it as sent, rather than through magpie's own. Only its
|
||||
@@ -7056,6 +7066,7 @@ function drawEditor(p, presetID) {
|
||||
if (custom) { body.icon = draft.icon || "generic"; body.balanceURL = (draft.balanceURL || "").trim(); body.balancePath = (draft.balancePath || "").trim(); body.modelsURL = (draft.modelsURL || "").trim(); }
|
||||
if (p) { body.fallback = draft.fallback; body.unlisted = draft.unlisted; body.modelPrefs = modelPrefsOfDraft(); Object.assign(body, routingOfDraft(p)); }
|
||||
body.searches = !!draft.searches && searchable();
|
||||
body.pinUpstream = !!draft.pinUpstream;
|
||||
const cx = parseContexts(draft.contexts || "");
|
||||
if (cx.error) return editorError(t("Context window: {v} is not a length like 128k or 1m", { v: cx.error }), "warn");
|
||||
body.contexts = cx.map;
|
||||
|
||||
@@ -2682,6 +2682,9 @@ const I18N = {
|
||||
"Only through routing groups": "只通过路由分组使用",
|
||||
"Web search": "联网搜索",
|
||||
"Searches the web by itself": "自己能联网搜索",
|
||||
"DeepSeek models only from DeepSeek's own API": "DeepSeek 模型只走 DeepSeek 官方上游",
|
||||
"Upstream": "上游",
|
||||
"Cline serves a model from any host its gateway picks; pinned, a request for a DeepSeek model goes only to DeepSeek, which keeps its prompt cache, and fails when DeepSeek can't take it": "Cline 的网关会把一个模型发给它挑的任意上游;钉住后,DeepSeek 模型的请求只发给 DeepSeek 官方,缓存能命中;DeepSeek 官方接不了时请求会失败,不会换别家",
|
||||
"For a relay in front of Anthropic's or OpenAI's own API: Claude Code's WebSearch and Codex's web_search go to it as they were sent, not through magpie's search. magpie doesn't automatically search with it for other models, but you can name it as the searcher.": "适用于转发 Anthropic 或 OpenAI 官方 API 的中转:Claude Code 的 WebSearch、Codex 的 web_search 原样发给它,不经过 magpie 的搜索。magpie 不会自动用它给其他模型搜索,但你可以在代搜供应商里手动选择它。",
|
||||
"Its models leave the list agents pick from; the routing groups they are in still use them": "它的模型不出现在 Agent 的可选列表里;所在的路由分组仍会使用它们",
|
||||
"Agents don't see them: only the routing groups they are in use them.": "Agent 看不到这些模型,只有它们所在的路由分组会用到。",
|
||||
@@ -5730,6 +5733,9 @@ const I18N = {
|
||||
"Drop the list fetched from the vendor; the models.dev one is used until Refresh": "ベンダーから取得したリストを破棄します。更新するまでは models.dev のリストを使います",
|
||||
"Only through routing groups": "ルーティンググループ経由のみ",
|
||||
"Searches the web by itself": "自分でウェブ検索する",
|
||||
"DeepSeek models only from DeepSeek's own API": "DeepSeek モデルは DeepSeek 公式 API のみ",
|
||||
"Upstream": "上流",
|
||||
"Cline serves a model from any host its gateway picks; pinned, a request for a DeepSeek model goes only to DeepSeek, which keeps its prompt cache, and fails when DeepSeek can't take it": "Cline のゲートウェイはモデルを任意の上流に送ります。固定すると DeepSeek モデルへのリクエストは DeepSeek 公式だけに送られ、プロンプトキャッシュが効きます。DeepSeek が受けられないときは失敗します",
|
||||
"For a relay in front of Anthropic's or OpenAI's own API: Claude Code's WebSearch and Codex's web_search go to it as they were sent, not through magpie's search. magpie doesn't automatically search with it for other models, but you can name it as the searcher.": "Anthropic または OpenAI 公式 API の前段にあるリレー向け:Claude Code の WebSearch と Codex の web_search は magpie の検索を通さず、送られたままこのプロバイダに届きます。magpie は他のモデルのためにこれで自動検索はしませんが、検索役として指定することはできます。",
|
||||
"Its models leave the list agents pick from; the routing groups they are in still use them": "そのモデルはエージェントが選ぶリストから外れます。所属するルーティンググループでは引き続き使われます",
|
||||
"Agents don't see them: only the routing groups they are in use them.": "エージェントには見えません。所属するルーティンググループだけが使います。",
|
||||
@@ -8781,6 +8787,9 @@ const I18N = {
|
||||
"Drop the list fetched from the vendor; the models.dev one is used until Refresh": "Die vom Anbieter abgerufene Liste verwerfen; bis zum Aktualisieren gilt die von models.dev",
|
||||
"Only through routing groups": "Nur über Weiterleitungsgruppen",
|
||||
"Searches the web by itself": "Sucht selbst im Web",
|
||||
"DeepSeek models only from DeepSeek's own API": "DeepSeek-Modelle nur über DeepSeeks eigene API",
|
||||
"Upstream": "Upstream",
|
||||
"Cline serves a model from any host its gateway picks; pinned, a request for a DeepSeek model goes only to DeepSeek, which keeps its prompt cache, and fails when DeepSeek can't take it": "Cline lässt ein Modell von einem beliebigen Anbieter seines Gateways bedienen; angeheftet geht eine Anfrage an ein DeepSeek-Modell nur an DeepSeek, das seinen Prompt-Cache behält, und schlägt fehl, wenn DeepSeek sie nicht annimmt",
|
||||
"For a relay in front of Anthropic's or OpenAI's own API: Claude Code's WebSearch and Codex's web_search go to it as they were sent, not through magpie's search. magpie doesn't automatically search with it for other models, but you can name it as the searcher.": "Für ein Relay vor der eigenen API von Anthropic oder OpenAI: WebSearch von Claude Code und web_search von Codex gehen unverändert dorthin, nicht über die Suche von magpie. magpie sucht damit nicht automatisch für andere Modelle, aber Sie können es als Suchdienst festlegen.",
|
||||
"Its models leave the list agents pick from; the routing groups they are in still use them": "Seine Modelle verschwinden aus der Auswahlliste der Agenten; die Weiterleitungsgruppen, in denen sie sind, nutzen sie weiterhin",
|
||||
"Agents don't see them: only the routing groups they are in use them.": "Agenten sehen sie nicht: Nur die Weiterleitungsgruppen, in denen sie sind, nutzen sie.",
|
||||
|
||||
@@ -63,6 +63,10 @@ type providerJSON struct {
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
// the vendor searches the web by itself (provider.Searches)
|
||||
Searches bool `json:"searches"`
|
||||
// on the Cline API: its DeepSeek models are served by DeepSeek's own
|
||||
// API alone (provider.PinUpstream), which only it offers
|
||||
Cline bool `json:"cline,omitempty"`
|
||||
PinUpstream bool `json:"pinUpstream"`
|
||||
// the proxy its requests go through: "" the global one, "direct"
|
||||
// none, or an address (#237)
|
||||
Proxy string `json:"proxy"`
|
||||
@@ -372,7 +376,7 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON {
|
||||
ID: p.ID, Name: p.Name, Icon: p.Icon, Preset: p.Preset, Host: p.Host(),
|
||||
Chat: p.Chat, Responses: p.Responses, Anthropic: p.Anthropic, Decide: p.Decide, ModelTest: p.ModelTest(), DecideTest: p.AsksDecideModels(),
|
||||
Catalog: p.Catalog, Website: p.Website, KeysURL: p.KeysURL,
|
||||
Proxy: p.Proxy, AccountProxies: p.AccountProxies, AccountModels: p.AccountModels, AccountCaps: p.AccountCaps, Headers: p.Headers, Searches: p.Searches, BalanceURL: p.BalanceURL, BalancePath: p.BalancePath, ModelsURL: p.ModelsURL,
|
||||
Proxy: p.Proxy, AccountProxies: p.AccountProxies, AccountModels: p.AccountModels, AccountCaps: p.AccountCaps, Headers: p.Headers, Searches: p.Searches, Cline: p.IsCline(), PinUpstream: p.PinUpstream, BalanceURL: p.BalanceURL, BalancePath: p.BalancePath, ModelsURL: p.ModelsURL,
|
||||
Ready: p.Ready(), Chosen: p.Models, Models: []modelJSON{}, Agents: []providerAgent{},
|
||||
Fallback: p.Fallback, Routing: p.Routing, Sink: p.Sink, Affinity: p.Affinity, KeepLogin: p.KeepLogin, KeepLoginAs: p.KeepLoginAs, Unlisted: p.Unlisted, Off: p.Off, Contexts: p.Contexts,
|
||||
MaxConcurrency: p.MaxConcurrency, PluginConcurrency: p.PluginConcurrency(), PriceRate: p.PriceRate,
|
||||
@@ -879,6 +883,7 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
|
||||
pr.Key, pr.Models, pr.Fallback, pr.Headers, pr.BalanceToken, pr.Contexts = in.Key, in.Models, in.Fallback, in.Headers, in.BalanceToken, in.Contexts
|
||||
pr.ZhipuTeam = in.ZhipuTeam
|
||||
pr.Searches = in.Searches
|
||||
pr.PinUpstream = in.PinUpstream
|
||||
if in.Name != "" {
|
||||
pr.Name = in.Name
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// ClinePass's DeepSeek models pinned to DeepSeek's own API (White Immortal
|
||||
// on Discord): the editor of a Cline provider has the tick, as saved; Save
|
||||
// posts pinUpstream as ticked. Another provider's editor has no such tick.
|
||||
// In English and Chinese.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const cline = {
|
||||
id: "clinepass", name: "ClinePass", icon: "cline", preset: "clinepass", cline: true, pinUpstream: false,
|
||||
chat: "https://api.cline.bot/api/v1", responses: "", anthropic: "", catalog: "",
|
||||
models: [{ id: "cline-pass/deepseek-v4-pro", name: "DeepSeek V4 Pro", on: true }], agents: [], fallback: [], headers: {},
|
||||
key: { set: true, masked: "clp_…one" }, keyList: [], balanceToken: { takes: false, set: false }, proxy: "", maxConcurrency: null,
|
||||
};
|
||||
const relay = {
|
||||
id: "relay", name: "Relay", icon: "generic", chat: "https://relay.example.com/v1", responses: "", anthropic: "", catalog: "",
|
||||
models: [{ id: "model-a", name: "Model A", on: true }], agents: [], fallback: [], headers: {},
|
||||
key: { set: true, masked: "sk-…one" }, keyList: [], balanceToken: { takes: false, set: false }, proxy: "", maxConcurrency: null,
|
||||
};
|
||||
|
||||
function serve(lang, posts) {
|
||||
const providers = { providers: [cline, relay], presets: [], excluded: [], gateway: { running: true, window: true } };
|
||||
const state = { agents: [], profiles: [], settings: { lang, theme: "light" } };
|
||||
return async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const json = (data) => route.fulfill({ json: data });
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json(state);
|
||||
if (url.pathname === "/api/providers") return json(providers);
|
||||
if (url.pathname === "/api/groups") return json({ groups: [] });
|
||||
if (url.pathname.startsWith("/api/provider/") && route.request().method() === "POST") {
|
||||
posts.push({ action: url.pathname.slice("/api/provider/".length), body: route.request().postDataJSON() });
|
||||
return json(providers);
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
await route.fulfill({ body: await fs.readFile(file), contentType });
|
||||
};
|
||||
}
|
||||
|
||||
const words = { en: { tick: "DeepSeek models only from DeepSeek's own API", save: "Save" }, zh: { save: "保存" } };
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of ["en", "zh"]) {
|
||||
const w = words[lang];
|
||||
test(`${engine} ${lang}: a Cline provider's DeepSeek models are pinned from its editor`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
const page = await (await browser.newContext({ viewport: { width: 900, height: 760 }, reducedMotion: "reduce" })).newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
const posts = [];
|
||||
await page.route("**/*", serve(lang, posts));
|
||||
await page.goto("http://magpie.test/?view=providers");
|
||||
const zh = await page.evaluate(() => [
|
||||
"DeepSeek models only from DeepSeek's own API", "Upstream",
|
||||
"Cline serves a model from any host its gateway picks; pinned, a request for a DeepSeek model goes only to DeepSeek, which keeps its prompt cache, and fails when DeepSeek can't take it",
|
||||
].map((k) => I18N.zh[k]));
|
||||
assert(zh.every(Boolean), "every string has its Chinese");
|
||||
if (lang === "zh") w.tick = zh[0];
|
||||
|
||||
await page.locator(".row.provider", { hasText: "ClinePass" }).click();
|
||||
const tick = page.locator(".editor label.pin-upstream");
|
||||
await tick.waitFor();
|
||||
assert.equal((await tick.textContent()).trim(), w.tick);
|
||||
assert.equal(await tick.locator("input").isChecked(), false, "not pinned as saved");
|
||||
await tick.locator("input").check();
|
||||
const n = posts.length;
|
||||
await page.locator(".editor .bar").getByRole("button", { name: w.save, exact: true }).click();
|
||||
for (let i = 0; i < 50 && posts.length === n; i++) await page.waitForTimeout(50);
|
||||
const saved = posts.at(-1);
|
||||
assert.equal(saved.action, "save");
|
||||
assert.equal(saved.body.id, "clinepass");
|
||||
assert.equal(saved.body.pinUpstream, true);
|
||||
|
||||
await page.locator(".row.provider", { hasText: "Relay" }).click();
|
||||
await page.locator(".editor input.price-rate").waitFor();
|
||||
assert.equal(await page.locator(".editor label.pin-upstream").count(), 0, "only a Cline provider has it");
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -169,3 +169,17 @@ func (p Provider) clineFreeModel(model string) bool {
|
||||
}
|
||||
return isClineFree(model)
|
||||
}
|
||||
|
||||
// ClinePin is the upstream a request for model is pinned to on p
|
||||
// (PinUpstream): "deepseek" for a DeepSeek model of the Cline API, ""
|
||||
// for anything else. Cline's free models are served where Cline puts
|
||||
// them, so they never are.
|
||||
func (p Provider) ClinePin(model string) string {
|
||||
if !p.PinUpstream || !p.IsCline() || strings.HasPrefix(model, clineFreePrefix) {
|
||||
return ""
|
||||
}
|
||||
if name := model[strings.LastIndex(model, "/")+1:]; strings.HasPrefix(strings.ToLower(name), "deepseek") {
|
||||
return "deepseek"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -145,6 +145,12 @@ type Provider struct {
|
||||
// it as the client sent it, rather than given magpie's search (#359).
|
||||
Searches bool `json:"searches,omitempty"`
|
||||
|
||||
// PinUpstream, on the Cline API, has its DeepSeek models answered only
|
||||
// by DeepSeek's own API (White Immortal on Discord): Cline routes a
|
||||
// model through an AI gateway that may serve it from any host, and
|
||||
// DeepSeek's own keeps its prompt cache. See ClinePin.
|
||||
PinUpstream bool `json:"pinUpstream,omitempty"`
|
||||
|
||||
// Proxy is the proxy magpie's requests to this provider go through
|
||||
// (#237: Codex through one, a vendor at home without): "" follows
|
||||
// the global one (Settings' Proxy, the environment's, the system's),
|
||||
|
||||
Reference in new issue
Block a user