provider: OpenRouter's decision models are listed with their window and image input, on the built-in OpenRouter too (ARNO on Discord: 手动添加的 systemone provider 填 openrouter,模型列表没有图像和上下文信息;默认的 openrouter provider 列表里又没有全部的 decision 模型) — three causes. A System One provider at OpenRouter whose list was fetched before v0.1.895 kept that list, without windows or input, since lists are fetched again only on Save or Refresh: FetchNew now fetches such a list again (decideListDue: on, at OpenRouter, missing or with no model's facts), once. One added with no list URL got typesafe/jev-router only, which is OpenRouter's chat router named like Jev: it now fetches OpenRouter's /models?output_modalities=decisions. And the built-in OpenRouter had no decision API at all: its preset (and a saved one, in Normalize as Zen's) gets Decide at https://openrouter.ai/api/v1, its decision list is fetched beside its chat list (kept at <id>.decisions, renamed with it), shown in its model list with context and image input (/api/providers deciders), offered to routing groups' classifier picker and asked at /systemone with the same key; Jev Router stays a chat model for agents (DecidesModel, app.js decidesModel). With the owner's real OpenRouter key in a sandbox HOME, FetchNew listed all 13 decision models with their facts (pplx-decider 262144 + images, solar-decide 524288, …) and the gateway's POST /v1/systemone with openrouter/inception/mercury-decide:free answered 200 from Inception. TestOpenRouterListsDecisionModels and TestOpenRouterDecisionListRefreshed fail on the old code ("OpenRouter has no decision API", "decision models: [typesafe/jev-router]"). system-one, gateway-systemone, gateway-decide-info, group-found-off, routing-reset-deadline pass on WebKit; group-from-model-add fails 2 the same on origin/main. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-05 07:24:08 +08:00
1 parent 60b301a9cd
commit 372316260d
8 files changed
+331 -8

No files matched your search

+3 -1
View File
@@ -6457,7 +6457,9 @@ const PROTOS = [["chat", "OpenAI", "Chat Completions — most agents"], ["respon
// apiLabel: the name an API (a protocol) goes by in the editor
const apiLabel = (proto) => (PROTOS.find(([k]) => k === proto) || [])[1] || proto;
const decideOnly = (p) => !!p?.decide && !(p.chat || p.responses || p.anthropic);
const decidesModel = (p, id) => !!p.decide && (decideOnly(p) || id.split("/").some((s) => /^jev(?:-|$)/i.test(s)));
// a provider that lists its decision models apart (OpenRouter) says which
// they are: its Jev Router (typesafe/jev-router) is a chat model
const decidesModel = (p, id) => !!p.decide && (decideOnly(p) || (p.deciders ? p.deciders.includes(id) : id.split("/").some((s) => /^jev(?:-|$)/i.test(s))));
// modelAPIs: the APIs one of p's models can be asked on alone, those it has
// a URL for when it has more than one — a custom provider's, a preset's or
// a subscription's alike (01huadalang on Discord: 一个 api 里有很多模型但是不同协议;
+12
View File
@@ -99,6 +99,9 @@ type providerJSON struct {
// System One question (provider.AsksDecideModels): a mixed
// provider's Jev too, beside its conversation models
DecideTest bool `json:"decideTest,omitempty"`
// Deciders are its decision models when it lists them apart from its
// chat models (OpenRouter's): those, and no Jev-named chat model
Deciders []string `json:"deciders,omitempty"`
Key struct {
Set bool `json:"set"`
@@ -482,6 +485,15 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON {
seen[m.ID] = true
out.Models = append(out.Models, named(m, exposed[m.ID]))
}
// OpenRouter's decision models, listed apart from its chat models
// (ARNO on Discord), after them
for _, m := range p.DecisionModels() {
out.Deciders = append(out.Deciders, m.ID)
if !seen[m.ID] {
seen[m.ID] = true
out.Models = append(out.Models, named(m, exposed[m.ID]))
}
}
// picks the vendor list does not know go first, so they are visible
for _, m := range p.Exposed() {
if !seen[m.ID] {
+88 -2
View File
@@ -72,7 +72,70 @@ func (p Provider) DecideOnly() bool {
// DecidesModel distinguishes Jev from the conversation models a gateway
// also serves. A dedicated decision API may use any model name.
func (p Provider) DecidesModel(model string) bool {
return p.Decides() && (p.DecideOnly() || jevID(model) || p.DecideVia() == ViaCloudflare && CloudflareClef(model))
if !p.Decides() {
return false
}
if p.DecideOnly() {
return true
}
if p.listsDecisions() {
// OpenRouter's Jev Router (typesafe/jev-router) is a chat model
return slices.ContainsFunc(p.decideListed(), func(m catalog.Model) bool { return m.ID == model })
}
return jevID(model) || p.DecideVia() == ViaCloudflare && CloudflareClef(model)
}
// OpenRouter lists its decision models apart from its chat models (ARNO
// on Discord): its /models has the chat ones alone, Jev Router among them
// (typesafe/jev-router, a chat model that picks one), and
// /models?output_modalities=decisions the ones that answer System One's
// questions at /systemone (liquid/d1, cloudflare/clef, ~typesafe/jev-latest
// …), each with its context_length and input_modalities.
func openRouterDecisions(base string) string {
if HostOf(base) != "openrouter.ai" {
return ""
}
return strings.TrimRight(base, "/") + "/models?output_modalities=decisions"
}
// listsDecisions reports whether p's decision models are a list of their
// own, OpenRouter's, rather than Jev's ids among its chat models.
func (p Provider) listsDecisions() bool { return openRouterDecisions(p.Decide) != "" }
// decisionsID is where a provider that serves conversations too keeps the
// list of its decision models, beside its chat models' list.
func decisionsID(id string) string { return id + ".decisions" }
// DecisionModels are the decision models a provider that serves
// conversations too lists apart from them (OpenRouter's), for its editor
// to show beside its chat models; nil for any other provider.
func (p Provider) DecisionModels() []catalog.Model {
if p.DecideOnly() || !p.listsDecisions() {
return nil
}
return p.decideModels()
}
// decideListDue reports whether p's list of decision models at OpenRouter
// is still to be fetched, or was fetched before magpie kept each model's
// window and input (they show on the Providers page and the Gateway list
// only once it is fetched again).
func (p Provider) decideListDue() bool {
if !p.On() || !p.Decides() {
return false
}
id := p.ID
if p.DecideOnly() {
if !p.listsDecisions() && HostOf(strings.TrimSpace(p.ModelsURL)) != "openrouter.ai" {
return false
}
} else if p.listsDecisions() {
id = decisionsID(p.ID)
} else {
return false
}
live, _, ok := catalog.Live(id)
return !ok || !slices.ContainsFunc(live, func(m catalog.Model) bool { return m.Context > 0 || m.ImageInput != nil })
}
// Cloudflare's own decision models on Workers AI (ARNO on Discord): Clef
@@ -80,6 +143,9 @@ func (p Provider) DecidesModel(model string) bool {
// run at its own address (…/ai/run/@cf/cloudflare/clef) and takes the
// request as it is, its model named "clef" or "clef-flash", where Jev is
// a run of typesafe/jev with the questions as its input.
// OpenRouterJev is Jev as OpenRouter names it, the latest one.
const OpenRouterJev = "~typesafe/jev-latest"
const (
CloudflareJev = "typesafe/jev"
CloudflareClefModel = "@cf/cloudflare/clef"
@@ -140,6 +206,15 @@ func (p Provider) Jev() string {
if own := p.ownDecideModels(); len(own) > 0 {
return own[0].ID
}
// OpenRouter's Jev is ~typesafe/jev-latest, in its list of decision
// models; its Jev Router is a chat model
if p.listsDecisions() || p.DecideOnly() && HostOf(strings.TrimSpace(p.ModelsURL)) == "openrouter.ai" {
ms := p.decideListed()
if i := slices.IndexFunc(ms, func(m catalog.Model) bool { return jevID(m.ID) }); i >= 0 {
return ms[i].ID
}
return OpenRouterJev
}
// a gateway that serves conversations too names its Jev its own way
// (OpenCode Zen's jev-1.13): the one its list has, the free one where
// it is asked with no key of the user's
@@ -217,6 +292,12 @@ func withDecideFacts(ms []catalog.Model) []catalog.Model {
}
func (p Provider) decideListed() []catalog.Model {
if p.listsDecisions() && !p.DecideOnly() {
if live, _, ok := catalog.Live(decisionsID(p.ID)); ok && len(live) > 0 {
return live
}
return []catalog.Model{{ID: OpenRouterJev, Name: "Jev"}}
}
if live, _, ok := catalog.Live(p.ID); ok && len(live) > 0 {
if p.DecideOnly() {
return live
@@ -374,7 +455,7 @@ func Deciders() []Entry {
continue
}
ms := p.Exposed()
if !p.DecideOnly() && len(p.Models) == 0 {
if !p.DecideOnly() && (len(p.Models) == 0 || p.listsDecisions()) {
ms = p.decideModels() // the conversation picker's limit does not hide Jev
}
for _, m := range ms {
@@ -580,6 +661,8 @@ func (p Provider) fetchDecide(ctx context.Context) ([]catalog.Model, error) {
list, all := p.Decide+"/models", false
if u := strings.TrimSpace(p.ModelsURL); u != "" && p.DecideOnly() {
list, all = u, true
} else if u := openRouterDecisions(p.Decide); u != "" {
list, all = u, true
} else if bailianDecides(p.Decide) {
// Bailian lists its chat models only: its decision model is asked
return p.decideAsked(ctx)
@@ -617,6 +700,9 @@ func (p Provider) fetchDecide(ctx context.Context) ([]catalog.Model, error) {
return nil, fmt.Errorf("%s lists no models", p.Name)
}
if !p.DecideOnly() {
if p.listsDecisions() {
return ms, catalog.SaveLive(decisionsID(p.ID), p.Decide, ms)
}
return ms, nil // do not replace a mixed provider's full model list
}
return ms, catalog.SaveLive(p.ID, p.Decide, ms)
+202
View File
@@ -0,0 +1,202 @@
package provider
import (
"context"
"crypto/tls"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"slices"
"sync"
"testing"
"time"
"github.com/yetone/magpie/internal/catalog"
)
// fakeOpenRouter answers for openrouter.ai: its chat models at /models,
// Jev Router among them, its decision models at
// /models?output_modalities=decisions, and System One at /systemone.
func fakeOpenRouter(t *testing.T) (asked func() []string, lists func() int) {
t.Helper()
var mu sync.Mutex
var models []string
decisions := 0
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/models":
if r.URL.Query().Get("output_modalities") == "decisions" {
mu.Lock()
decisions++
mu.Unlock()
w.Write([]byte(`{"data":[{"id":"perplexity/pplx-decider-v1-27b","context_length":262144,"architecture":{"input_modalities":["text","image"],"output_modalities":["decisions"]}},{"id":"liquid/d1","context_length":65536,"architecture":{"input_modalities":["text"]}},{"id":"~typesafe/jev-latest","context_length":32000,"architecture":{"input_modalities":["text"]}}]}`))
return
}
w.Write([]byte(`{"data":[{"id":"openai/gpt-5","context_length":400000},{"id":"typesafe/jev-router","context_length":1000000}]}`))
case "/api/v1/systemone":
var q struct{ Model string }
json.NewDecoder(r.Body).Decode(&q)
mu.Lock()
models = append(models, q.Model)
mu.Unlock()
w.Write([]byte(`{"answers":{"ok":{"type":"noul","noul":0.9}}}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
addr := srv.Listener.Addr().String()
tr := &http.Transport{
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, addr)
},
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
old := http.DefaultClient.Transport
http.DefaultClient.Transport = tr
t.Cleanup(func() { http.DefaultClient.Transport = old })
return func() []string {
mu.Lock()
defer mu.Unlock()
return slices.Clone(models)
}, func() int {
mu.Lock()
defer mu.Unlock()
return decisions
}
}
// OpenRouter's decision models (ARNO on Discord: the OpenRouter
// provider's list didn't have them): they are listed apart from its chat
// models, so the built-in OpenRouter fetches that list too and offers them
// to routing groups, each with its window and input, asked at /systemone
// with the same key. Its Jev Router, a chat model named like Jev, stays a
// model agents talk to.
func TestOpenRouterListsDecisionModels(t *testing.T) {
h := t.TempDir()
t.Setenv("HOME", h)
t.Setenv("USERPROFILE", h)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
asked, _ := fakeOpenRouter(t)
// an OpenRouter provider saved before its preset had decisions
if err := Save(Provider{ID: "openrouter", Name: "OpenRouter", Preset: "openrouter", Key: "k",
Chat: "https://openrouter.ai/api/v1", Models: []string{"openai/gpt-5", "typesafe/jev-router"}}); err != nil {
t.Fatal(err)
}
p, err := Find("openrouter")
if err != nil || !p.Decides() || p.DecideOnly() {
t.Fatalf("OpenRouter has no decision API: %+v", p)
}
if _, err := p.Fetch(context.Background()); err != nil {
t.Fatal(err)
}
p, _ = Find("openrouter")
if p == nil {
t.Fatal("gone")
}
if p.DecidesModel("typesafe/jev-router") || !p.DecidesModel("liquid/d1") || !p.DecidesModel("~typesafe/jev-latest") {
t.Fatal("decision models are not OpenRouter's decision list")
}
var deciders []string
for _, e := range Deciders() {
deciders = append(deciders, e.ID)
}
if !slices.Equal(deciders, []string{"openrouter/perplexity/pplx-decider-v1-27b", "openrouter/liquid/d1", "openrouter/~typesafe/jev-latest"}) {
t.Fatalf("deciders: %v", deciders)
}
var agents []string
for _, e := range providerEntries() {
agents = append(agents, e.Model)
}
if !slices.Contains(agents, "typesafe/jev-router") || !slices.Contains(agents, "openai/gpt-5") || slices.Contains(agents, "liquid/d1") {
t.Fatalf("agents' models: %v", agents)
}
facts := map[string][2]any{}
for _, m := range p.DecisionModels() {
facts[m.ID] = [2]any{ListedWindow(m), m.ImageInput != nil && *m.ImageInput}
}
if facts["perplexity/pplx-decider-v1-27b"] != [2]any{262144, true} || facts["liquid/d1"] != [2]any{65536, false} {
t.Fatalf("facts: %v", facts)
}
// a group classified by one is asked at OpenRouter's /systemone
q, m, err := RouteDecider("openrouter/liquid/d1")
if err != nil || q.ID != "openrouter" || m != "liquid/d1" {
t.Fatalf("routed to %s %s: %v", q.ID, m, err)
}
if err := q.AskSystemOne(context.Background(), m); err != nil {
t.Fatal(err)
}
if _, m, _ := RouteDecider("openrouter"); m != "~typesafe/jev-latest" {
t.Fatalf("OpenRouter's Jev: %s", m)
}
if got := asked(); !slices.Equal(got, []string{"liquid/d1"}) {
t.Fatalf("asked: %v", got)
}
}
// A System One provider at OpenRouter added by hand, with no list URL,
// gets OpenRouter's decision models, not Jev Router alone; and one whose
// list was fetched before magpie kept windows and input is fetched again
// when lists are (ARNO: they showed neither).
func TestOpenRouterDecisionListRefreshed(t *testing.T) {
h := t.TempDir()
t.Setenv("HOME", h)
t.Setenv("USERPROFILE", h)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, lists := fakeOpenRouter(t)
bare := Provider{ID: "or-s1", Name: "OpenRouter S1", Key: "k", Decide: "https://openrouter.ai/api/v1"}
if err := Save(bare); err != nil {
t.Fatal(err)
}
ms, err := bare.Fetch(context.Background())
if err != nil {
t.Fatal(err)
}
var ids []string
for _, m := range ms {
ids = append(ids, m.ID)
}
if !slices.Equal(ids, []string{"perplexity/pplx-decider-v1-27b", "liquid/d1", "~typesafe/jev-latest"}) {
t.Fatalf("decision models: %v", ids)
}
old := Provider{ID: "or-s2", Name: "OpenRouter S2", Key: "k", Decide: "https://openrouter.ai/api/v1",
ModelsURL: "https://openrouter.ai/api/v1/models?output_modalities=decisions"}
if err := Save(old); err != nil {
t.Fatal(err)
}
if err := catalog.SaveLive("or-s2", old.Decide, []catalog.Model{{ID: "perplexity/pplx-decider-v1-27b"}, {ID: "liquid/d1"}}); err != nil {
t.Fatal(err)
}
before := lists()
FetchNew(10 * time.Second)
if lists() == before {
t.Fatal("the old list wasn't fetched again")
}
p, err := Find("or-s2")
if err != nil {
t.Fatal(err)
}
for _, m := range p.Available() {
if m.ID == "perplexity/pplx-decider-v1-27b" && (ListedWindow(m) != 262144 || !m.Images) {
t.Fatalf("pplx-decider: %+v", m)
}
}
// fetched now, it isn't fetched again at the next start
before = lists()
newFetches.Lock()
clear(newFetches.m)
newFetches.Unlock()
FetchNew(10 * time.Second)
if lists() != before {
t.Fatal("a list with its facts was fetched again")
}
}
+18 -3
View File
@@ -163,6 +163,12 @@ func (p Provider) Refetch(ctx context.Context) ([]catalog.Model, []string, error
}
before := liveIDs(p.ID)
ms, err := p.fetch(ctx)
if err == nil && p.listsDecisions() {
// OpenRouter's decision models, listed apart from its chat ones
if _, derr := p.fetchDecide(p.Via(ctx)); derr != nil {
log.Println(p.ID + ": " + derr.Error())
}
}
if err != nil || len(before) == 0 {
return ms, nil, err
}
@@ -366,11 +372,14 @@ func FetchNew(timeout time.Duration) {
newFetches.Lock()
defer newFetches.Unlock()
for _, p := range All() {
if p.Account == nil || !p.Ready() {
// a list of decision models fetched before magpie kept their
// windows and input, or not fetched yet (ARNO on Discord)
decide := p.decideListDue()
if !decide && (p.Account == nil || !p.Ready()) {
continue
}
// a plugin's accounts were listed with the plugin's providers
if _, ok := p.Listed(); ok {
if _, ok := p.Listed(); ok && !decide {
continue
}
if t, ok := newFetches.m[p.ID]; ok && time.Since(t) < newFetchRetry {
@@ -378,7 +387,13 @@ func FetchNew(timeout time.Duration) {
}
newFetches.m[p.ID] = time.Now()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
if _, err := p.Fetch(ctx); err != nil {
var err error
if decide && p.Account == nil {
_, err = p.fetchDecide(p.Via(ctx))
} else {
_, err = p.Fetch(ctx)
}
if err != nil {
log.Println(p.ID + ": " + err.Error())
}
cancel()
+4
View File
@@ -339,6 +339,10 @@ var presets = []PresetDef{
{ID: "openrouter", Name: "OpenRouter", Icon: "openrouter", Kind: KindRelay, Catalog: "openrouter",
Chat: "https://openrouter.ai/api/v1", Anthropic: "https://openrouter.ai/api",
// its decision models (liquid/d1, cloudflare/clef, Jev …), listed
// apart from its chat models, answer System One at /systemone, so
// a routing group can be classified by one with the same key
Decide: "https://openrouter.ai/api/v1",
Website: "https://openrouter.ai", KeysURL: "https://openrouter.ai/keys",
// app attribution, for OpenRouter's rankings and analytics
HeaderHints: []string{"HTTP-Referer", "X-OpenRouter-Title"}},
+3 -2
View File
@@ -828,8 +828,9 @@ func normalize(p Provider) Provider {
p.Key = OpenCodeAnonymousKey
}
// a Zen provider saved before its preset had System One for Jev
// (01huadalang: its Jev was asked as a chat model, and failed)
if p.Preset == "opencode-zen" && p.Decide == "" && p.Chat != "" {
// (01huadalang: its Jev was asked as a chat model, and failed), an
// OpenRouter one before its decision models were listed (ARNO)
if (p.Preset == "opencode-zen" || p.Preset == "openrouter") && p.Decide == "" && p.Chat != "" {
if pr := Preset(p.Preset); pr != nil {
p.Decide = pr.Decide
}
+1
View File
@@ -96,6 +96,7 @@ func Rename(from, to string) error {
}
// the vendor's list last fetched goes with it
os.Rename(catalog.LivePath(from), catalog.LivePath(to))
os.Rename(catalog.LivePath(decisionsID(from)), catalog.LivePath(decisionsID(to)))
if err := store(f); err != nil {
return err
}