From 1b025b3fad3d6467ed74704c19d16f3323bcdfc1 Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 08:58:47 +0800 Subject: [PATCH] =?UTF-8?q?gateway:=20ClinePass's=20DeepSeek=20models=20ca?= =?UTF-8?q?n=20be=20pinned=20to=20DeepSeek's=20own=20API=20(White=20Immort?= =?UTF-8?q?al=20on=20Discord:=20clinepass=20=E9=92=88=E5=AF=B9deepseek?= =?UTF-8?q?=E7=9A=84=E6=A8=A1=E5=9E=8B=E8=83=BD=E5=90=A6=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E9=92=89=E4=BD=8Fdeepseek=E5=AE=98=E6=96=B9?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8)=20=E2=80=94=20Cline's=20AI=20gateway=20serv?= =?UTF-8?q?es=20a=20model=20from=20whichever=20host=20it=20picks,=20so=20a?= =?UTF-8?q?=20DeepSeek=20model's=20prompt=20cache=20was=20hit=20or=20misse?= =?UTF-8?q?d=20by=20chance.=20A=20Cline=20provider's=20editor=20now=20has?= =?UTF-8?q?=20Upstream:=20DeepSeek=20models=20only=20from=20DeepSeek's=20o?= =?UTF-8?q?wn=20API=20(provider.PinUpstream,=20off=20by=20default;=20zh,?= =?UTF-8?q?=20ja,=20de=20strings);=20on,=20every=20Chat=20request=20to=20i?= =?UTF-8?q?t=20for=20a=20DeepSeek=20model=20(cline-pass/deepseek-=E2=80=A6?= =?UTF-8?q?,=20not=20Cline's=20free=20ones)=20carries=20providerOptions.ga?= =?UTF-8?q?teway.only=20["deepseek"],=20as=20ClinePass=20+=20ProviderPin?= =?UTF-8?q?=20sets=20it=20(linux.do/t/topic/2918145),=20added=20at=20the?= =?UTF-8?q?=20body's=20end=20so=20the=20cached=20prefix=20doesn't=20change?= =?UTF-8?q?,=20also=20when=20built=20from=20a=20Messages=20or=20Responses?= =?UTF-8?q?=20request.=20A=20body=20naming=20providerOptions=20itself=20go?= =?UTF-8?q?es=20as=20sent.=20TestClinePinsDeepSeek=20fails=20on=20the=20ol?= =?UTF-8?q?d=20code=20(no=20providerOptions=20on=20chat,=20messages,=20res?= =?UTF-8?q?ponses);=20clinepass-pin.test.cjs=20(WebKit,=20en=20and=20zh).?= =?UTF-8?q?=20provider-price-rate=20and=20click-scroll=20pass=20on=20WebKi?= =?UTF-8?q?t.=20go=20vet,=20builds=20(darwin,=20GOOS=3Dlinux=20nogui,=20GO?= =?UTF-8?q?OS=3Dwindows)=20and=20go=20test=20-tags=20nogui=20-count=3D1=20?= =?UTF-8?q?./...=20under=20a=20temp=20HOME=20pass.=20Not=20tried=20with=20?= =?UTF-8?q?a=20real=20ClinePass=20key=20(none=20on=20this=20machine).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/cline_pin.go | 39 ++++++++++ internal/gateway/cline_pin_test.go | 80 ++++++++++++++++++++ internal/gateway/gateway.go | 1 + internal/gui/assets/app.js | 13 +++- internal/gui/assets/i18n.js | 9 +++ internal/gui/providers.go | 7 +- internal/gui/tests/clinepass-pin.test.cjs | 89 +++++++++++++++++++++++ internal/provider/cline.go | 14 ++++ internal/provider/provider.go | 6 ++ 9 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 internal/gateway/cline_pin.go create mode 100644 internal/gateway/cline_pin_test.go create mode 100644 internal/gui/tests/clinepass-pin.test.cjs diff --git a/internal/gateway/cline_pin.go b/internal/gateway/cline_pin.go new file mode 100644 index 00000000..8c665e16 --- /dev/null +++ b/internal/gateway/cline_pin.go @@ -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:]...) +} diff --git a/internal/gateway/cline_pin_test.go b/internal/gateway/cline_pin_test.go new file mode 100644 index 00000000..95b7bb41 --- /dev/null +++ b/internal/gateway/cline_pin_test.go @@ -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) + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 0edf15ce..75fc2d38 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -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) diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index f5a98300..2dae6342 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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; diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 05847ff6..e2326db0 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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.", diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 558239c2..baabf7a0 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -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 } diff --git a/internal/gui/tests/clinepass-pin.test.cjs b/internal/gui/tests/clinepass-pin.test.cjs new file mode 100644 index 00000000..eed3f660 --- /dev/null +++ b/internal/gui/tests/clinepass-pin.test.cjs @@ -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, []); + }); + } +} diff --git a/internal/provider/cline.go b/internal/provider/cline.go index e1d20a4b..4df7a9ab 100644 --- a/internal/provider/cline.go +++ b/internal/provider/cline.go @@ -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 "" +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0ddce0e0..27dc7f52 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -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),