provider: a decision model has its window and whether it takes images, and the Gateway list shows them with a tooltip saying what it is (ARNO on Discord: decision模型也是需要拉取正确的上下文长度以及是否支持视觉功能的,在网关的模型列表里也应该显示这些信息以及相关的tooltip) — a decision provider's list (OpenRouter's output_modalities=decisions, Vercel's) is now read for its context_length / context_window and its input modalities; a model the list says nothing of (TypeSafe's, OpenCode Zen's, Workers AI's and Bailian's presets) takes its vendor's documented values: Jev 32,000 and text, Clef and Clef Flash 65,536 and images, Bailian's decision-model-preview 65,536 and text. A gateway's Jev among its chat models has them too; its chat models are left as they were. The Gateway list's row of a decision model shows its context and image chips as other models' do, and its tooltip begins Decision model: routing groups ask it, agents never see it (zh, ja, de) in place of Reasoning levels: none known. /v1/models still leaves decision models out: agents can't use them. TestDecideModelsKnowWindowAndImages and gateway-decide-info.test.cjs (WebKit, en and zh) fail on the old code; gateway-model-info passes on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass (TestClientGoneRecordedAsCanceled, TestProvidersAnswerWhileListsComeIn and TestClaudeSignedOut flaked under load, pass alone).

This commit is contained in:
yetone committed 2026-10-05 01:20:06 +08:00
1 parent 5daba301ca
commit d1c19131a6
6 files changed
+271 -13

No files matched your search

+3 -1
View File
@@ -5041,7 +5041,9 @@ function modelInfo(m) {
const box = el("span", "minfo");
const lines = [];
const levels = m.efforts || [];
if (levels.length) {
// a decision model answers a routing group's questions, it doesn't reason
if (decideEntry(m)) lines.push(t("Decision model: routing groups ask it, agents never see it"));
else if (levels.length) {
box.append(el("span", "badge mi-effort", effortSpan(levels)));
lines.push(t("Reasoning: {levels}", { levels: levels.map((l) => t(l)).join(", ") }));
} else lines.push(t("Reasoning levels: none known"));
+3
View File
@@ -644,6 +644,7 @@ const I18N = {
"Words match a model's id, name, provider or a group's models": "按模型 ID、名称、供应商或模型组里的模型查找",
"Reasoning: {levels}": "推理强度:{levels}",
"Reasoning levels: none known": "推理强度:未知",
"Decision model: routing groups ask it, agents never see it": "决策模型:路由组会问它,agent 看不到它",
"Accepts images (every model in it does)": "支持图片输入(组内每个模型都支持)",
"Text only": "仅文本",
"Context: {n} tokens": "上下文:{n} tokens",
@@ -3624,6 +3625,7 @@ const I18N = {
"Words match a model's id, name, provider or a group's models": "モデルの ID、名前、プロバイダ、またはグループのモデルに一致する語で検索します",
"Reasoning: {levels}": "推論:{levels}",
"Reasoning levels: none known": "推論レベル:不明",
"Decision model: routing groups ask it, agents never see it": "決定モデル:ルーティンググループが問い合わせ、エージェントには見えません",
"Accepts images (every model in it does)": "画像入力に対応(含まれるすべてのモデルが対応)",
"Text only": "テキストのみ",
"Context: {n} tokens": "コンテキスト:{n} トークン",
@@ -6594,6 +6596,7 @@ const I18N = {
"Words match a model's id, name, provider or a group's models": "Suchbegriffe passen auf ID, Name oder Anbieter eines Modells oder auf die Modelle einer Gruppe",
"Reasoning: {levels}": "Reasoning: {levels}",
"Reasoning levels: none known": "Reasoning-Stufen: keine bekannt",
"Decision model: routing groups ask it, agents never see it": "Entscheidungsmodell: Routing-Gruppen fragen es, Agents sehen es nie",
"Accepts images (every model in it does)": "Akzeptiert Bilder (jedes enthaltene Modell)",
"Text only": "Nur Text",
"Context: {n} tokens": "Kontext: {n} Token",
@@ -0,0 +1,84 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// A decision model on the Gateway list (ARNO on Discord: decision模型也是需要
// 拉取正确的上下文长度以及是否支持视觉功能的): its row says its context and
// whether it takes images, as any model's does, and its tooltip says it is a
// decision model routing groups ask, rather than "Reasoning levels: none known".
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 providers = (lang) => ({
providers: [
{ id: "workers", name: "Workers AI", icon: "generic", agents: [], decide: "https://api.cloudflare.com/client/v4/accounts/a/ai/run", models: [
{ id: "@cf/typesafe/jev", name: "Jev", on: true, images: false, context: 32000 },
{ id: "@cf/cloudflare/clef", name: "Clef", on: true, images: true, context: 65536 },
] },
{ id: "acme", name: "Acme", icon: "generic", agents: [], chat: "https://acme.test/v1", models: [
{ id: "text-only", name: "Text Only", on: true, images: false, context: 128000 },
] },
],
gateway: { running: true, window: true, mine: true, url: "http://127.0.0.1:3999", models: 3, calls: [], groups: [] },
});
function server(lang) {
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(lang));
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/gateway/trace") {
if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3));
return json({ mine: true, now: new Date().toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] });
}
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 rowOf = (page, id) => page.locator("#gwModels .row.model").filter({ has: page.locator(".name", { hasText: new RegExp("^" + id.replace(/[./@]/g, "\\$&") + "$") }) });
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
test(`${engine} ${lang}: a decision model's row says its context and images`, async (t) => {
assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
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: 700 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server(lang));
await page.goto("http://magpie.test/?view=gateway");
await rowOf(page, "workers/@cf/cloudflare/clef").waitFor();
const info = async (id) => {
const box = rowOf(page, id).locator(".minfo");
return { chips: (await box.locator(".badge").allTextContents()).map((s) => s.trim()), img: await box.locator(".mi-img").count(), tip: await box.getAttribute("title") };
};
const said = lang === "zh" ? "决策模型:路由组会问它,agent 看不到它" : "Decision model: routing groups ask it, agents never see it";
let i = await info("workers/@cf/cloudflare/clef");
assert.equal(i.img, 1, "Clef takes images");
assert.deepEqual(i.chips, ["", "66K"]);
assert.equal(i.tip.split("\n")[0], said);
assert.match(i.tip, /65,536/);
i = await info("workers/@cf/typesafe/jev");
assert.equal(i.img, 0, "Jev is text only");
assert.deepEqual(i.chips, ["32K"]);
assert.equal(i.tip.split("\n")[0], said);
assert.match(i.tip, /32,000/);
// a chat model's tooltip is as it was
i = await info("acme/text-only");
assert.notEqual(i.tip.split("\n")[0], said);
assert.deepEqual(errors, []);
});
}
}
+86 -12
View File
@@ -10,6 +10,7 @@ package provider
// decision models are excluded from agents' lists and group members.
import (
"cmp"
"context"
"encoding/json"
"errors"
@@ -164,8 +165,58 @@ func (p Provider) Jev() string {
}
// decideModels are the models a decision provider offers: the vendor's
// list when fetched, else Jev's aliases (a gateway's one Jev).
// list when fetched, else Jev's aliases (a gateway's one Jev), each with
// its window and whether it takes images where its vendor's docs say.
func (p Provider) decideModels() []catalog.Model {
return withDecideFacts(p.decideListed())
}
// A decision model's window and whether it reads images, as its vendor's
// docs give them where its list doesn't (ARNO on Discord: decision models
// showed neither on the Gateway list): Jev holds 32k tokens of state and
// reads text alone (TypeSafe's docs; OpenRouter's and Vercel's lists say
// 32000 too), Cloudflare's Clef and Clef Flash 65,536 and up to 4 images,
// Bailian's decision model 65,536 and text alone.
type decideFact struct {
context int
images bool
}
func decideFactOf(id string) (decideFact, bool) {
last := strings.ToLower(id[strings.LastIndex(id, "/")+1:])
last = strings.TrimPrefix(last, "~")
switch {
case jevID(id):
return decideFact{context: 32_000}, true
case last == "clef" || last == "clef-flash":
return decideFact{context: 65_536, images: true}, true
case last == BailianDecision:
return decideFact{context: 65_536}, true
}
return decideFact{}, false
}
// withDecideFacts fills in the window and images of the decision models
// it knows that a list left unsaid; what a list said is kept.
func withDecideFacts(ms []catalog.Model) []catalog.Model {
ms = slices.Clone(ms)
for i, m := range ms {
f, ok := decideFactOf(m.ID)
if !ok {
continue
}
if m.Context == 0 {
ms[i].Context = f.context
}
if m.ImageInput == nil {
in := f.images
ms[i].ImageInput, ms[i].Images = &in, in
}
}
return ms
}
func (p Provider) decideListed() []catalog.Model {
if live, _, ok := catalog.Live(p.ID); ok && len(live) > 0 {
if p.DecideOnly() {
return live
@@ -637,11 +688,9 @@ func listedDecide(b []byte, all bool) ([]catalog.Model, error) {
var out struct {
Models []struct {
Name string `json:"name"`
ID string `json:"id"`
listedFacts
} `json:"models"`
Data []struct {
ID string `json:"id"`
} `json:"data"`
Data []listedFacts `json:"data"`
}
if err := json.Unmarshal(b, &out); err != nil {
return nil, fmt.Errorf("not a model list")
@@ -649,29 +698,54 @@ func listedDecide(b []byte, all bool) ([]catalog.Model, error) {
var ms []catalog.Model
for _, m := range out.Models {
if m.Name != "" {
ms = append(ms, catalog.Model{ID: m.Name, Name: m.Name})
ms = append(ms, m.model(m.Name))
}
}
if len(ms) > 0 {
return ms, nil
}
seen := map[string]bool{}
take := func(id string) {
if id == "" || seen[id] || !all && !jevID(id) {
take := func(m listedFacts) {
if m.ID == "" || seen[m.ID] || !all && !jevID(m.ID) {
return
}
seen[id] = true
ms = append(ms, catalog.Model{ID: id, Name: id})
seen[m.ID] = true
ms = append(ms, m.model(m.ID))
}
for _, m := range out.Data {
take(m.ID)
take(m)
}
for _, m := range out.Models {
take(m.ID)
take(m.listedFacts)
}
return ms, nil
}
// listedFacts is a decision model as an OpenAI-shaped list gives it, with
// its window and input as OpenRouter's (context_length,
// architecture.input_modalities) or Vercel's (context_window,
// modalities.input) say them, where they do.
type listedFacts struct {
ID string `json:"id"`
ContextLength int `json:"context_length"`
ContextWindow int `json:"context_window"`
Architecture struct {
InputModalities []string `json:"input_modalities"`
} `json:"architecture"`
Modalities struct {
Input []string `json:"input"`
} `json:"modalities"`
}
func (f listedFacts) model(name string) catalog.Model {
m := catalog.Model{ID: name, Name: name, Context: cmp.Or(f.ContextLength, f.ContextWindow)}
if in := cmp.Or(len(f.Architecture.InputModalities), len(f.Modalities.Input)); in > 0 {
images := slices.Contains(f.Architecture.InputModalities, "image") || slices.Contains(f.Modalities.Input, "image")
m.ImageInput, m.Images = &images, images
}
return m
}
// jevID reports whether id names Jev: a path segment that is "jev" or
// "jev-…" (jev-latest, typesafe/jev, typesafe-ai/jev), not jevons or jevx.
func jevID(id string) bool {
+81
View File
@@ -0,0 +1,81 @@
package provider
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/yetone/magpie/internal/catalog"
)
// A decision model's window and whether it takes images (ARNO on Discord):
// read from a list that says them (OpenRouter's context_length and
// architecture.input_modalities), else what its vendor's docs say — Jev
// 32k and text, Clef 65,536 and images, Bailian's 65,536 and text — so
// the Providers page and the Gateway list show them.
func TestDecideModelsKnowWindowAndImages(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":[{"id":"perplexity/pplx-decider-v1-27b","context_length":262144,"architecture":{"input_modalities":["text","image"]}},{"id":"liquid/d1","context_length":65536,"architecture":{"input_modalities":["text"]}},{"id":"typesafe/jev-1.13"}]}`))
}))
defer up.Close()
p := Provider{ID: "openrouter-s1", Name: "OpenRouter", Key: "k", Decide: up.URL + "/api/v1", ModelsURL: up.URL + "/api/v1/models?output_modalities=decisions"}
if err := Save(p); err != nil {
t.Fatal(err)
}
if _, err := p.Fetch(context.Background()); err != nil {
t.Fatal(err)
}
want := map[string][2]any{
"perplexity/pplx-decider-v1-27b": {262144, true},
"liquid/d1": {65536, false},
"typesafe/jev-1.13": {32000, false}, // the list said nothing: Jev's docs
}
check := func(who string, ms []catalog.Model, want map[string][2]any) {
t.Helper()
seen := 0
for _, m := range ms {
w, ok := want[m.ID]
if !ok {
continue
}
seen++
if ListedWindow(m) != w[0] || m.ImageInput == nil || *m.ImageInput != w[1] || m.Images != w[1] {
t.Errorf("%s %s: window %d, images %v (%v), want %v", who, m.ID, ListedWindow(m), m.Images, m.ImageInput, w)
}
}
if seen != len(want) {
t.Errorf("%s: %d of %d models listed: %v", who, seen, len(want), ms)
}
}
check("openrouter", p.Available(), want)
// presets, before any list: Workers AI's Jev and Clefs, Bailian's
cf, err := FromPreset("cloudflare-jev")
if err != nil {
t.Fatal(err)
}
cf.Decide = "https://api.cloudflare.com/client/v4/accounts/acc7/ai/run"
check("cloudflare", cf.Available(), map[string][2]any{CloudflareJev: {32000, false}, CloudflareClefModel: {65536, true}, CloudflareClefFlash: {65536, true}})
bl := Provider{ID: "bailian-s1", Decide: "https://ws-1.cn-beijing.maas.aliyuncs.com/api/v1"}
check("bailian", bl.Available(), map[string][2]any{BailianDecision: {65536, false}})
ts := Provider{ID: "typesafe", Decide: "https://api.typesafe.ai/v1"}
check("typesafe", ts.Available(), map[string][2]any{JevLatest: {32000, false}, "jev-preview": {32000, false}})
// a gateway's Jev among its chat models has them too; its chat models
// are left as they are
if err := catalog.SaveLive("zen", "https://opencode.ai/zen/v1", []catalog.Model{{ID: "jev-1.13"}, {ID: "chat-x", Context: 1000}}); err != nil {
t.Fatal(err)
}
zen := Provider{ID: "zen", Chat: "https://opencode.ai/zen/v1", Decide: "https://opencode.ai/zen/v1"}
check("zen", zen.Available(), map[string][2]any{"jev-1.13": {32000, false}})
for _, m := range zen.Available() {
if m.ID == "chat-x" && (m.Context != 1000 || m.ImageInput != nil) {
t.Errorf("a chat model changed: %+v", m)
}
}
}
+14
View File
@@ -29,6 +29,20 @@ func (p Provider) Available() []catalog.Model {
if p.DecideOnly() {
return p.decideModels()
}
ms := p.available()
if p.Decides() {
// a gateway's Jev among its chat models, with its window and input
ms = slices.Clone(ms)
for i, m := range ms {
if p.DecidesModel(m.ID) {
ms[i] = withDecideFacts([]catalog.Model{m})[0]
}
}
}
return ms
}
func (p Provider) available() []catalog.Model {
signedIn := p.Account != nil && p.Account.models != nil
var known []catalog.Model
if signedIn {