routing: Jev (TypeSafe's System One) as a group's classifier — asked once as a turn begins which of the rules' intents the message is, with how sure it is, and, for a group with effort auto, how hard the turn is to think about; the turn's requests then ask their model for that reasoning, where the agent asked for any. TypeSafe is a decision provider: it serves no conversation and is no group's member

This commit is contained in:
yetone committed 2026-09-27 01:53:04 +08:00
1 parent c576153dd7
commit 6fd85894ea
25 files changed
+953 -76

No files matched your search

+28 -3
View File
@@ -26,7 +26,9 @@ const groupUsage = `usage:
its shortest model's), family (a tag: magpie visible shows agents
families, not each group),
id (what agents pick it as: id=gpt-6-astra drops auto-; the groups
it is in follow; an agent set to the old id needs setting again)
it is in follow; an agent set to the old id needs setting again),
effort=auto (Jev picks each turn's reasoning; needs classifier=),
effort=agent (the agent's again), classifier=<provider/model>
magpie group rm <id> remove a group (one magpie found is hidden instead)
magpie group restore <id> bring back a group magpie found that you removed
magpie group rule add|rm|mv <id> … rules: which model a turn goes to first, by its length, an image,
@@ -47,11 +49,16 @@ const groupUsage = `usage:
session for the whole session
turn within a turn only; routing decides afresh when you speak again
off every request routed afresh
effort agent (default) each request reasons as much as the agent asked
auto as a turn begins, the group's classifier — Jev, from a TypeSafe provider
(magpie provider add typesafe key=…) — rates how hard it is, and the turn's requests
reason at low, medium, high or xhigh; only those the agent asked to reason
e.g. magpie group add "Opus anywhere" models=claude/claude-opus-5-5,copilot/claude-opus-5.5 routing=order
magpie group set opus-anywhere stays=session models+=openrouter/anthropic/claude-opus-5.5
magpie group set auto-gpt-6-astra id=gpt-6-astra
magpie group add Everything models=group/opus-anywhere,deepseek/deepseek-v4-flash routing=order
magpie group set opus-anywhere effort=auto classifier=typesafe/jev-latest
magpie claude group/opus-anywhere`
// routingNames: each routing's value in the file, what the CLI calls it,
@@ -293,8 +300,19 @@ func applyGroupPairs(g *provider.Group, pairs []string, resolve func(string) (st
}
case "family", "tag":
g.Family = strings.TrimSpace(v)
case "effort", "reasoning":
switch strings.ToLower(strings.TrimSpace(v)) {
case "auto", "jev":
g.Effort = provider.EffortAuto
case "", "agent", "off":
g.Effort = ""
default:
err = fmt.Errorf("effort=auto (Jev picks each turn's) or effort=agent (the agent's), not %q", v)
}
case "classifier", "classify":
g.Classifier = strings.TrimPrefix(strings.TrimSpace(v), "magpie/")
default:
return fmt.Errorf("unknown field %q (fields: name, models, models+, models-, routing, stays, context, family; magpie group help)", k)
return fmt.Errorf("unknown field %q (fields: name, models, models+, models-, routing, stays, context, family, effort, classifier; magpie group help)", k)
}
if err != nil {
return err
@@ -700,8 +718,15 @@ func showGroup(g provider.Group) error {
}
kv(k, fmt.Sprintf("%d %s", i+1, ruleLine(r)))
}
if g.Effort == provider.EffortAuto {
kv("effort", "auto"+muted.Render(" the classifier picks each turn's reasoning"))
}
if g.Classifier != "" {
kv("classifier", g.Classifier+muted.Render(" tells which intent a message is"))
what := " tells which intent a message is"
if !slices.ContainsFunc(g.Rules, func(r provider.Rule) bool { return r.Intent != "" }) {
what = " rates how hard each turn is"
}
kv("classifier", g.Classifier+muted.Render(what))
}
if u := groupUses()[g.ID]; len(u) > 0 {
kv("used by", green.Render(strings.Join(u, ", ")))
+48 -16
View File
@@ -37,10 +37,28 @@ var (
classifyRest = 30 * time.Second // after a failure, before the classifier is asked again
)
// classifier asks a model which of intents text is: the intent, or "" for
// none of them. prev is what the conversation's turn before was said to
// be, "" when nothing: a message that only carries on from it is the same.
type classifier func(model string, intents []string, prev, text string) (string, error)
// classifier asks a model which of intents text is — the intent, or ""
// for none of them — and, when effort, how much reasoning the turn wants
// (only a decision provider's model says; see decide.go). prev is what
// the conversation's turn before was said to be: a message that only
// carries on from it is the same.
type classifier func(model string, intents []string, prev before, effort bool, text string) (verdict, error)
// before is what the classifier said of a conversation's turn before.
type before struct {
Intent string // one of the intents asked about now, or ""
Effort string // the reasoning picked for it, or ""
}
// verdict is what a classifier said.
type verdict struct {
Intent string // "" for none
Effort string // the reasoning the turn wants; "" when not asked
Score float64 // how hard Jev took the turn to be, from 0 (low) to 3 (xhigh)
Sure float64 // how confident Jev was of the intent, from 0 to 1
in int // the tokens it read, for the usage
out int
}
var classified = struct {
sync.Mutex
@@ -49,8 +67,8 @@ var classified = struct {
}{m: map[string]classifiedAs{}, failed: map[string]classifyFailure{}}
type classifiedAs struct {
intent string
at time.Time
v verdict
at time.Time
}
type classifyFailure struct {
@@ -66,32 +84,32 @@ type answerError struct{ error }
// when it can be: the same message and intents within classifyKeep. A
// classifier that failed is left to rest for classifyRest rather than
// making every turn wait out its timeout.
func classify(ask classifier, model string, intents []string, prev, text string) (intent string, cached bool, err error) {
func classify(ask classifier, model string, intents []string, prev before, effort bool, text string) (v verdict, cached bool, err error) {
h := sha256.New()
h.Write([]byte(model + "\x00" + strings.ToLower(strings.Join(intents, "\x00")) + "\x00" + prev + "\x00" + text))
h.Write([]byte(model + "\x00" + strings.ToLower(strings.Join(intents, "\x00")) + "\x00" + prev.Intent + "\x00" + prev.Effort + "\x00" + strconv.FormatBool(effort) + "\x00" + text))
key := hex.EncodeToString(h.Sum(nil))
now := time.Now()
classified.Lock()
if c, ok := classified.m[key]; ok && now.Sub(c.at) < classifyKeep {
classified.Unlock()
return c.intent, true, nil
return c.v, true, nil
}
if f, ok := classified.failed[model]; ok && now.Sub(f.at) < classifyRest {
classified.Unlock()
return "", false, fmt.Errorf("%s failed %s ago (%s); not asked again for now", model, now.Sub(f.at).Round(time.Second), f.err)
return verdict{}, false, fmt.Errorf("%s failed %s ago (%s); not asked again for now", model, now.Sub(f.at).Round(time.Second), f.err)
}
classified.Unlock()
intent, err = ask(model, intents, prev, text)
v, err = ask(model, intents, prev, effort, text)
classified.Lock()
defer classified.Unlock()
if err != nil {
if !errors.As(err, new(answerError)) {
classified.failed[model] = classifyFailure{err: err.Error(), at: time.Now()}
}
return "", false, err
return verdict{}, false, err
}
delete(classified.failed, model)
classified.m[key] = classifiedAs{intent: intent, at: time.Now()}
classified.m[key] = classifiedAs{v: v, at: time.Now()}
if len(classified.m) > 4096 {
for k, c := range classified.m {
if time.Since(c.at) > classifyKeep {
@@ -99,7 +117,7 @@ func classify(ask classifier, model string, intents []string, prev, text string)
}
}
}
return intent, false, nil
return v, false, nil
}
var reminders = regexp.MustCompile(`(?s)<system-reminder>.*?</system-reminder>`)
@@ -205,8 +223,22 @@ func classifyEffort(model string) string {
return fitEffort("none", levels)
}
// askClassifier asks model through the gateway itself, as a client would.
func (s *Server) askClassifier(model string, intents []string, prev, text string) (string, error) {
// askClassifier asks a decision provider's model through its own API, and
// any other model through the gateway itself, as a client would. Only the
// former says what effort a turn wants.
func (s *Server) askClassifier(model string, intents []string, prev before, effort bool, text string) (verdict, error) {
if p, m, ok := provider.Resolve(model); ok && p.Decides() {
return s.askJev(p, m, intents, prev, effort, text)
}
if len(intents) == 0 {
return verdict{}, nil
}
intent, err := s.askChat(model, intents, prev.Intent, text)
return verdict{Intent: intent}, err
}
// askChat asks a chat model which of intents text is.
func (s *Server) askChat(model string, intents []string, prev, text string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), classifyTimeout)
defer cancel()
r, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://magpie/v1/chat/completions", nil)
+4 -1
View File
@@ -216,7 +216,10 @@ func TestIntentNotAskedWhenItCantMatter(t *testing.T) {
g, ms, _ := provider.FindGroup("group/r")
img := &Request{Messages: []Message{{Role: "user", Parts: []Part{{Kind: Image}}}}}
asked := 0
hit := ruleFor("img", g, ms, img, "claude", func(string, []string, string, string) (string, error) { asked++; return "debugging", nil })
hit := ruleFor("img", g, ms, img, "claude", func(string, []string, before, bool, string) (verdict, error) {
asked++
return verdict{Intent: "debugging"}, nil
})
if hit.N != 0 || hit.Classified == nil || !strings.Contains(hit.Classified.Error, "no words") || asked != 0 {
t.Fatalf("image: %+v %+v", hit, hit.Classified)
}
+247
View File
@@ -0,0 +1,247 @@
package gateway
// A group whose classifier is a decision provider's model (Jev, from
// TypeSafe) asks it in one call, as a user's turn begins, what the
// classifier model would be asked — which of the rules' intents the
// message is — and, for a group with Effort "auto", how hard the turn is
// to think about. Jev answers each with how likely every option was
// rather than with words: an intent it isn't sure of counts as none, and
// the effort is the level the likelihoods weigh out to. The turn's
// requests then ask their model for that effort, where the agent asked
// for reasoning at all.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"time"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/usage"
)
// jevSure is how confident Jev must be of an intent for it to count.
// Confidence runs from 0, every option as likely, to 1, one certain.
const jevSure = 0.4
// noIntent is the option Jev picks for a message that is none of the
// intents.
const noIntent = "none of these"
// jevLevels are the efforts Jev chooses among, with what each is for,
// lowest first.
var jevLevels = []struct{ effort, what string }{
{"low", "Little to think about: a greeting, a question answered from what is already known, a mechanical or one-line change, running a command"},
{"medium", "Some thought: an ordinary bug fix or a small feature in code already understood"},
{"high", "Careful thought: a change across several files, a bug whose cause is not known yet, a design choice with trade-offs"},
{"xhigh", "Deep thought: a subtle bug (concurrency, performance, security), an architecture or algorithm to design, a long multi-step plan"},
}
// jevBody is the System One request asking which of intents text is
// (when there are any) and, when effort, how hard it is. What was said of
// the turn before (prev) is in the state: a message that only carries on
// from it is of its kind and wants its reasoning.
func jevBody(model string, intents []string, prev before, effort bool, text string) []byte {
qs := map[string]any{}
if len(intents) > 0 {
criteria := map[string]any{noIntent: "The message is none of the other kinds"}
for _, in := range intents {
criteria[in] = nil
}
qs["intent"] = map[string]any{
"type": "choice",
"instructions": intentAsk(prev),
"criteria": criteria,
}
}
if effort {
levels := make([]string, len(jevLevels))
for i, l := range jevLevels {
levels[i] = l.what
}
qs["effort"] = map[string]any{
"type": "score",
"instructions": effortAsk(prev),
"criteria": levels,
}
}
b, _ := json.Marshal(map[string]any{
"model": model,
"state": jevState(prev, effort, text),
"questions": qs,
})
return b
}
func jevState(prev before, effort bool, text string) map[string]string {
st := map[string]string{"message": text}
if prev.Intent != "" {
st["previous_message_kind"] = prev.Intent
}
if effort && prev.Effort != "" {
st["previous_message_reasoning"] = prev.Effort
}
return st
}
func intentAsk(prev before) string {
q := "The `message` is what a user asked a coding assistant. Which kind of request is it?"
if prev.Intent != "" {
q += " A message that only carries on from the user's message before it (go on, yes, do it, fix that) is of `previous_message_kind`; one that asks for something of its own is of the kind that fits it."
}
return q
}
func effortAsk(prev before) string {
q := "How much reasoning does a coding assistant need to handle the `message` well?"
if prev.Effort != "" {
q += " A message that only carries on from the user's message before it (go on, yes, do it) needs what that one did, `previous_message_reasoning` (low, medium, high or xhigh)."
}
return q
}
// readJev is the verdict in a System One reply.
func readJev(b []byte, intents []string) (verdict, error) {
var out struct {
Model string `json:"model"`
Answers map[string]struct {
Choice string `json:"choice"`
Score float64 `json:"score"`
Confidence float64 `json:"confidence"`
} `json:"answers"`
Usage struct {
Input int `json:"input_tokens"`
Output int `json:"output_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal(b, &out); err != nil {
return verdict{}, answerError{fmt.Errorf("not a System One answer")}
}
var v verdict
v.in, v.out = out.Usage.Input, out.Usage.Output
if a, ok := out.Answers["intent"]; ok && len(intents) > 0 {
v.Sure = a.Confidence
if a.Choice != noIntent && a.Confidence >= jevSure {
for _, in := range intents {
if in == a.Choice {
v.Intent = in
}
}
}
}
if a, ok := out.Answers["effort"]; ok {
i := int(math.Round(a.Score))
i = max(0, min(i, len(jevLevels)-1))
v.Effort, v.Score = jevLevels[i].effort, a.Score
}
return v, nil
}
// askJev asks a decision provider's model through its System One API.
func (s *Server) askJev(p provider.Provider, model string, intents []string, prev before, effort bool, text string) (verdict, error) {
ctx, cancel := context.WithTimeout(context.Background(), classifyTimeout)
defer cancel()
if model == "" {
model = provider.JevLatest
}
start := time.Now()
body := jevBody(model, intents, prev, effort, text)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.Decide+"/systemone", bytes.NewReader(body))
if err != nil {
return verdict{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", RouterAgent)
if err := p.Sign(ctx, req, provider.Chat, body); err != nil {
return verdict{}, err
}
res, err := s.client.Do(req)
if err != nil {
if ctx.Err() != nil {
return verdict{}, fmt.Errorf("%s gave no answer in %s", p.Name, classifyTimeout)
}
return verdict{}, fmt.Errorf("%s: %v", p.Name, err)
}
defer res.Body.Close()
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode >= 300 {
return verdict{}, fmt.Errorf("%s: %s", p.Name, provider.APIError(b, res.Status))
}
v, err := readJev(b, intents)
if err != nil {
return v, err
}
usage.Append(usage.Record{Time: start, Agent: usage.AgentOf(RouterAgent), Provider: p.ID, Host: p.Where(), Model: model,
Input: v.in, Output: v.out, Millis: time.Since(start).Milliseconds(), Status: res.StatusCode})
return v, nil
}
// withEffort asks a request, in the client's own API, for reasoning at
// effort, in place of what the agent asked. Only a request that asked for
// reasoning is changed: one that didn't (a session title) stays without.
func withEffort(proto provider.Protocol, body []byte, effort string) []byte {
var v struct {
ReasoningEffort string `json:"reasoning_effort"`
Reasoning *struct {
Effort string `json:"effort"`
Summary string `json:"summary"`
} `json:"reasoning"`
Thinking *struct {
Type string `json:"type"`
} `json:"thinking"`
OutputConfig map[string]any `json:"output_config"`
MaxTokens int `json:"max_tokens"`
}
if effort == "" || json.Unmarshal(body, &v) != nil {
return body
}
switch proto {
case provider.Chat:
if v.ReasoningEffort == "" || v.ReasoningEffort == "none" {
return body
}
return withFields(body, map[string]any{"reasoning_effort": effort})
case provider.Responses:
if v.Reasoning == nil || v.Reasoning.Effort == "" || v.Reasoning.Effort == "none" {
return body
}
r := map[string]any{"effort": effort}
if v.Reasoning.Summary != "" {
r["summary"] = v.Reasoning.Summary
}
return withFields(body, map[string]any{"reasoning": r})
case provider.Anthropic:
if v.Thinking == nil {
return body
}
switch v.Thinking.Type {
case "adaptive":
oc := v.OutputConfig
if oc == nil {
oc = map[string]any{}
}
oc["effort"] = effort
return withFields(body, map[string]any{"output_config": oc})
case "enabled":
// the budget must leave room for the answer
budget := budgetOf(effort)
if v.MaxTokens > 0 {
budget = min(budget, v.MaxTokens-1)
}
if budget < 1024 {
return body
}
fields := map[string]any{"thinking": map[string]any{"type": "enabled", "budget_tokens": budget}}
if oc := v.OutputConfig; oc != nil && oc["effort"] != nil {
oc["effort"] = effort
fields["output_config"] = oc
}
return withFields(body, fields)
}
}
return body
}
+188
View File
@@ -0,0 +1,188 @@
package gateway
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// jevUp is a System One API: it answers each question it is asked with
// what choice and score say, and keeps what it was asked.
type jevUp struct {
mu sync.Mutex
choice string
sure float64
score float64
asked []map[string]any
auth string
}
func (u *jevUp) ServeHTTP(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
var q map[string]any
json.Unmarshal(b, &q)
u.mu.Lock()
defer u.mu.Unlock()
u.asked, u.auth = append(u.asked, q), r.Header.Get("Authorization")
if r.URL.Path != "/v1/systemone" {
http.NotFound(w, r)
return
}
answers := map[string]any{}
qs, _ := q["questions"].(map[string]any)
if _, ok := qs["intent"]; ok {
answers["intent"] = map[string]any{"type": "choice", "choice": u.choice, "confidence": u.sure}
}
if _, ok := qs["effort"]; ok {
answers["effort"] = map[string]any{"type": "score", "score": u.score, "confidence": 0.8}
}
json.NewEncoder(w).Encode(map[string]any{"model": "jev-1.13.0", "answers": answers, "usage": map[string]int{"input_tokens": 120, "output_tokens": 0}})
}
func (u *jevUp) n() int {
u.mu.Lock()
defer u.mu.Unlock()
return len(u.asked)
}
// jevved is ruled's group with Jev as its classifier, the rules given and
// the effort.
func jevved(t *testing.T, effort string, rules ...provider.Rule) (*Server, *ruleUp, *ruleUp, *jevUp) {
t.Helper()
s, a, b := ruled(t)
j := &jevUp{choice: noIntent, sure: 0.9, score: 0}
srv := httptest.NewServer(j)
t.Cleanup(srv.Close)
if err := provider.Save(provider.Provider{ID: "ts", Name: "TypeSafe", Key: "kts", Decide: srv.URL + "/v1"}); err != nil {
t.Fatal(err)
}
g, _, _ := provider.FindGroup("group/r")
g.Rules, g.Classifier, g.Effort = rules, "ts/jev-latest", effort
if err := provider.SaveGroup(g); err != nil {
t.Fatal(err)
}
return s, a, b, j
}
// Jev picks the intent in one call: the rule goes by its choice, and a
// choice it isn't sure of is none.
func TestJevPicksTheIntent(t *testing.T) {
s, _, _, j := jevved(t, "", provider.Rule{Use: "b/big", Intent: "debugging"})
j.choice, j.sure = "debugging", 0.7
out, r := postOK(t, s, "s1", chat("why does this crash?", nil, 0, ""))
if !strings.Contains(out, "from kb") || r.Rule.N != 1 || r.Rule.Classified.Intent != "debugging" || r.Rule.Classified.Sure != 0.7 {
t.Fatalf("%s %+v", out, r.Rule.Classified)
}
if j.auth != "Bearer kts" || j.asked[0]["model"] != "jev-latest" {
t.Fatalf("asked %v with %q", j.asked[0], j.auth)
}
qs := j.asked[0]["questions"].(map[string]any)
crit := qs["intent"].(map[string]any)["criteria"].(map[string]any)
if _, ok := crit["debugging"]; !ok || qs["effort"] != nil {
t.Fatalf("questions %v", qs)
}
if st := j.asked[0]["state"].(map[string]any); st["message"] != "why does this crash?" {
t.Fatalf("state %v", st)
}
// not sure enough: no rule
j.choice, j.sure = "debugging", 0.2
_, r = postOK(t, s, "s2", chat("hmm, what now?", nil, 0, ""))
if r.Rule.N != 0 || r.Rule.Classified.Intent != "" || r.Rule.Classified.Error != "" {
t.Fatalf("unsure: %+v", r.Rule.Classified)
}
// Jev's call is magpie's own; it holds no conversation itself
if code, out := postAs(t, s, "s3", `{"model":"ts/jev-latest","messages":[{"role":"user","content":"hi"}]}`); code != 400 || !strings.Contains(out, "only decides") {
t.Fatalf("chat to Jev: %d %s", code, out)
}
}
// A group whose effort is auto has Jev pick each turn's reasoning, where
// the agent asked for some; the turn keeps it, and a request that asked
// for none stays without.
func TestJevPicksTheEffort(t *testing.T) {
s, a, _, j := jevved(t, provider.EffortAuto)
j.score = 2.4 // high
_, r := postOK(t, s, "s1", chat("redesign the scheduler", nil, 0, `,"reasoning_effort":"low"`))
if r.Rule == nil || r.Rule.Pick != "high" || r.Rule.Classified.Effort != "high" || r.Rule.Classified.Intents != nil {
t.Fatalf("%+v", r.Rule)
}
if !strings.Contains(a.last, `"reasoning_effort":"high"`) {
t.Fatalf("sent %s", a.last)
}
if qs := j.asked[0]["questions"].(map[string]any); qs["intent"] != nil || qs["effort"] == nil {
t.Fatalf("questions %v", qs)
}
// the turn's tool rounds keep it, without asking again
_, r = postOK(t, s, "s1", chat("redesign the scheduler", nil, 2, `,"reasoning_effort":"low"`))
if r.Rule.Pick != "high" || j.n() != 1 || !strings.Contains(a.last, `"reasoning_effort":"high"`) {
t.Fatalf("within: %+v %d %s", r.Rule, j.n(), a.last)
}
// a request without reasoning (a title) isn't asked about, nor given any
_, r = postOK(t, s, "s2", chat("title this", nil, 0, ""))
if r.Rule.Pick != "" || r.Rule.Classified != nil || j.n() != 1 || strings.Contains(a.last, "reasoning_effort") {
t.Fatalf("no reasoning: %+v %s", r.Rule, a.last)
}
}
// withEffort changes the effort in each API's own words, only where the
// request asked for reasoning.
func TestWithEffort(t *testing.T) {
for _, c := range []struct {
proto provider.Protocol
in, effort string
want []string
}{
{provider.Chat, `{"reasoning_effort":"low"}`, "high", []string{`"reasoning_effort":"high"`}},
{provider.Chat, `{"model":"m"}`, "high", []string{`{"model":"m"}`}},
{provider.Responses, `{"reasoning":{"effort":"medium","summary":"auto"}}`, "xhigh", []string{`"effort":"xhigh"`, `"summary":"auto"`}},
{provider.Responses, `{"reasoning":{"effort":"none"}}`, "high", []string{`"effort":"none"`}},
{provider.Anthropic, `{"max_tokens":32000,"thinking":{"type":"adaptive"}}`, "low", []string{`"output_config":{"effort":"low"}`}},
{provider.Anthropic, `{"max_tokens":32000,"thinking":{"type":"enabled","budget_tokens":4096}}`, "high", []string{`"budget_tokens":24000`}},
{provider.Anthropic, `{"max_tokens":8000,"thinking":{"type":"enabled","budget_tokens":4096}}`, "xhigh", []string{`"budget_tokens":7999`}},
{provider.Anthropic, `{"max_tokens":8000,"thinking":{"type":"disabled"}}`, "high", []string{`"type":"disabled"`}},
} {
got := string(withEffort(c.proto, []byte(c.in), c.effort))
for _, w := range c.want {
if !strings.Contains(got, w) {
t.Errorf("%s %s at %s: %s, want %s", c.proto, c.in, c.effort, got, w)
}
}
}
}
// Jev is told what it said of the conversation's turn before, so that
// "go on" keeps that turn's kind and reasoning.
func TestJevIsToldTheTurnBefore(t *testing.T) {
var got struct {
State map[string]string `json:"state"`
Questions map[string]struct {
Instructions string `json:"instructions"`
} `json:"questions"`
}
intents := []string{"simple task", "complex task"}
if err := json.Unmarshal(jevBody("jev-latest", intents, before{Intent: "complex task", Effort: "xhigh"}, true, "go on"), &got); err != nil {
t.Fatal(err)
}
if got.State["previous_message_kind"] != "complex task" || got.State["previous_message_reasoning"] != "xhigh" {
t.Errorf("state = %v", got.State)
}
for _, q := range []string{"intent", "effort"} {
if !strings.Contains(got.Questions[q].Instructions, "carries on") {
t.Errorf("%s: %q", q, got.Questions[q].Instructions)
}
}
// a first turn has nothing of the kind
got.State, got.Questions = nil, nil
if err := json.Unmarshal(jevBody("jev-latest", intents, before{}, true, "hi"), &got); err != nil {
t.Fatal(err)
}
if len(got.State) != 1 || strings.Contains(got.Questions["intent"].Instructions, "carries on") {
t.Errorf("first turn: %v %q", got.State, got.Questions["intent"].Instructions)
}
}
+36 -3
View File
@@ -429,6 +429,14 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
s.record(call)
return
}
if p.Decides() {
// Jev answers questions about a message, not the message
call.Status, call.Error = 400, "a decision model"
writeError(w, from, 400, fmt.Sprintf("%s only decides a routing group's model and effort; it holds no conversation", call.Model))
finishCapture()
s.record(call)
return
}
// a routing group's rules pick the member that goes first, looked at
// before any image is taken out of the request: one may be for images
g, ms, isGroup := provider.FindGroup(asked)
@@ -437,13 +445,13 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
var ruleAt, words string
var ruleReq *Request // parsed for the rules of the group or a group in it
if isGroup && slices.ContainsFunc(ms, func(m provider.Member) bool {
return len(g.Rules) > 0 || slices.ContainsFunc(m.Via, func(v provider.Group) bool { return len(v.Rules) > 0 })
return g.Ruled() || slices.ContainsFunc(m.Via, provider.Group.Ruled)
}) {
if req, err := parse(from, body); err == nil {
ruleReq, ruleAt, words = req, ruleKey(g, r.Header, req), firstWords(req)
}
}
if ruleReq != nil && len(g.Rules) > 0 {
if ruleReq != nil && g.Ruled() {
hit = ruleFor(ruleAt, g, ms, ruleReq, call.Agent, s.askClassifier)
ruled = ruleMembers(hit, ms)
}
@@ -536,6 +544,17 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
if ruleReq != nil {
nested, nestedAt, cands, pl = s.nestedRules(ruleAt, ruleReq, call.Agent, ms, cands, pl, aff)
}
// the effort a group's decision model picked for the turn: the
// outermost group's that did
effort := ""
if hit != nil {
effort = hit.Pick
}
for _, n := range nested {
if effort == "" && n.Rule != nil {
effort = n.Rule.Pick
}
}
// A vision rule may choose a vision model even when the group has
// text-only fallbacks. A failure must not send a current user image to
// one of them. Historical images and tool results are omitted per
@@ -587,11 +606,25 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
attemptBody, _ = textOnlyBody(from, body) // omit images in prior turns and tool results
}
}
sent := "" // the effort asked for in place of the agent's
if effort != "" {
// the level this model has nearest to the one picked; one whose
// levels aren't known isn't asked for more than high, which
// every vendor with levels takes
level := fitEffort(effort, c.p.Efforts(c.model))
if len(c.p.Efforts(c.model)) == 0 && level == "xhigh" {
level = "high"
}
if b := withEffort(from, attemptBody, level); !bytes.Equal(b, attemptBody) {
attemptBody, sent = b, level
s.trace.update(tr, func(t *Route) { t.Tries[len(t.Tries)-1].Effort = level })
}
}
call.Status, call.Error = s.attempt(hw, r, from, c.p, c.model, attemptBody, &call)
if hw.failure != 0 { // the stream failed before any of it was sent
call.Status, call.Error = hw.failure, c.p.Name+": "+hw.failMsg
}
try := Try{ID: c.rest, Model: c.model, Start: began, Done: true, Status: call.Status, Millis: time.Since(began).Milliseconds(), Error: call.Error}
try := Try{ID: c.rest, Model: c.model, Effort: sent, Start: began, Done: true, Status: call.Status, Millis: time.Since(began).Milliseconds(), Error: call.Error}
if r.Context().Err() != nil && !hw.ended {
// the agent went away: nobody failed, and nobody else is asked
call.Status, call.Error = 499, "the agent canceled the request"
+43 -12
View File
@@ -41,6 +41,11 @@ type RuleHit struct {
// Classified: the group's classifier was asked which intent the
// turn's first message is
Classified *Classified `json:"classified,omitempty"`
// Pick: the reasoning the group's decision model picked for the turn,
// which its requests ask their model for (provider.EffortAuto)
Pick string `json:"pick,omitempty"`
// Bare: the group has no rules, only its effort picked
Bare bool `json:"bare,omitempty"`
}
// Classified is what the classifier was asked as a turn began, and said.
@@ -49,9 +54,15 @@ type Classified struct {
Intents []string `json:"intents"` // what it chose among
Intent string `json:"intent,omitempty"` // what it said the message is; "" for none
After string `json:"after,omitempty"` // what it said the turn before was, which it was told
Effort string `json:"effort,omitempty"` // the reasoning it said the turn wants
Score float64 `json:"score,omitempty"` // how hard it took the turn to be, 0 (low) to 3 (xhigh)
Sure float64 `json:"sure,omitempty"` // how confident it was of the intent, 0 to 1
Cached bool `json:"cached,omitempty"` // said before, for the same message
Ms int `json:"ms,omitempty"` // how long asking it took
Error string `json:"error,omitempty"` // why it couldn't say: no intent matches then
// AfterEffort: the reasoning picked for the turn before, which it was told
AfterEffort string `json:"afterEffort,omitempty"`
}
type turnRule struct {
@@ -62,6 +73,7 @@ type turnRule struct {
input int // the tokens the vendor counted the conversation's last request as
// intent is what the classifier said the turn's first message is
intent string
effort string // the reasoning it picked for the turn
}
var turnRules = struct {
@@ -100,10 +112,12 @@ func firstWords(req *Request) string {
// first when a rule that may match has an intent; within the turn — the
// agent handing tool results back — what was decided when it began, unless
// the conversation has grown past what that model can take and a rule
// sends it to one with more room. It returns nil for a group without
// rules.
// sends it to one with more room. A group whose decision model picks the
// effort (provider.EffortAuto) has it pick as the turn begins, when the
// agent asked for reasoning, and the turn keeps it. It returns nil for a
// group that decides nothing.
func ruleFor(key string, g provider.Group, ms []provider.Member, req *Request, agent string, ask classifier) *RuleHit {
if len(g.Rules) == 0 || req == nil {
if !g.Ruled() || req == nil {
return nil
}
turn, within := turnIn(req)
@@ -124,7 +138,7 @@ func ruleFor(key string, g provider.Group, ms []provider.Member, req *Request, a
if had && tr.input > q.Tokens {
q.Tokens = tr.input // what the vendor counted last, a floor: the conversation only grew since
}
hit := &RuleHit{Turn: turn, Tokens: q.Tokens, Images: q.Images, Effort: q.Effort}
hit := &RuleHit{Turn: turn, Tokens: q.Tokens, Images: q.Images, Effort: q.Effort, Bare: len(g.Rules) == 0}
if hit.Effort == "" && q.Thinking {
hit.Effort = "on"
}
@@ -147,9 +161,12 @@ func ruleFor(key string, g provider.Group, ms []provider.Member, req *Request, a
default:
hit.Waits = true
}
if !hit.Waits && hit.Effort != "" {
hit.Pick = tr.effort
}
if grown, ok := outgrown(g, ctx, hit, q); ok {
hit = grown
turnRules.m[key] = turnRule{turn: turn, use: hit.Use, n: hit.N, at: now, input: tr.input, intent: q.Intent}
turnRules.m[key] = turnRule{turn: turn, use: hit.Use, n: hit.N, at: now, input: tr.input, intent: q.Intent, effort: tr.effort}
return hit
}
if had {
@@ -161,12 +178,20 @@ func ruleFor(key string, g provider.Group, ms []provider.Member, req *Request, a
return hit
}
// a new turn: the classifier is asked only when a rule that could be
// the first to match waits on its intent
if intents := provider.Intents(g.Rules, q); len(intents) > 0 {
// the first to match waits on its intent, or it picks the effort of a
// turn the agent asked to reason in
intents := provider.Intents(g.Rules, q)
effort := g.Effort == provider.EffortAuto && hit.Effort != ""
if len(intents) > 0 || effort {
c := &Classified{By: g.Classifier, Intents: intents}
if had && slices.Contains(intents, tr.intent) {
c.After = tr.intent // a message that only carries on is of its kind
}
prev := before{Intent: c.After}
if had && effort {
prev.Effort = tr.effort // and needs the reasoning it had
c.AfterEffort = tr.effort
}
text := userText(req)
switch {
case g.Classifier == "":
@@ -177,14 +202,20 @@ func ruleFor(key string, g provider.Group, ms []provider.Member, req *Request, a
c.Error = "the message has no words to classify"
default:
t0 := time.Now()
intent, cached, err := classify(ask, g.Classifier, intents, c.After, text)
c.Intent, c.Cached, c.Ms = intent, cached, int(time.Since(t0).Milliseconds())
v, cached, err := classify(ask, g.Classifier, intents, prev, effort, text)
c.Intent, c.Cached, c.Ms = v.Intent, cached, int(time.Since(t0).Milliseconds())
if effort {
c.Effort, c.Score = v.Effort, v.Score
}
if len(intents) > 0 {
c.Sure = v.Sure
}
if err != nil {
c.Error = err.Error()
}
}
q.Intent = c.Intent
hit.Classified = c
hit.Classified, hit.Pick = c, c.Effort
}
if i := provider.MatchRule(g.Rules, q); i >= 0 {
hit.N, hit.Use, hit.When = i+1, g.Rules[i].Use, g.Rules[i].Conditions()
@@ -195,7 +226,7 @@ func ruleFor(key string, g provider.Group, ms []provider.Member, req *Request, a
if cur, ok := turnRules.m[key]; ok {
input = cur.input // answered while the classifier was asked
}
turnRules.m[key] = turnRule{turn: turn, use: hit.Use, n: hit.N, at: now, input: input, intent: q.Intent}
turnRules.m[key] = turnRule{turn: turn, use: hit.Use, n: hit.N, at: now, input: input, intent: q.Intent, effort: hit.Pick}
if len(turnRules.m) > 4096 {
for k, tr := range turnRules.m {
if now.Sub(tr.at) > stickKeep {
@@ -283,7 +314,7 @@ func (s *Server) nestedRules(at string, req *Request, agent string, ms []provide
lead := ms[i]
sub := lead.Via[depth]
at += ">" + sub.ID
if len(sub.Rules) == 0 {
if !sub.Ruled() {
continue
}
var subMs []provider.Member
+2
View File
@@ -22,6 +22,7 @@ type ruleUp struct {
fail int
calls []string // "stream" or "json"
seen int // requests that carried an image
last string // the last request's body
}
func (u *ruleUp) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -29,6 +30,7 @@ func (u *ruleUp) ServeHTTP(w http.ResponseWriter, r *http.Request) {
stream := strings.Contains(string(b), `"stream":true`)
u.mu.Lock()
u.calls = append(u.calls, map[bool]string{true: "stream", false: "json"}[stream])
u.last = string(b)
if strings.Contains(string(b), "image_url") {
u.seen++
}
+2 -1
View File
@@ -132,7 +132,8 @@ type Weighed struct {
// Try is one candidate trying the request.
type Try struct {
ID string `json:"id"`
Model string `json:"model,omitempty"` // the model it was asked for: a group's members may share a provider's keys
Model string `json:"model,omitempty"` // the model it was asked for: a group's members may share a provider's keys
Effort string `json:"effort,omitempty"` // the reasoning it was asked for in place of the agent's, as its model takes the turn's pick
Start time.Time `json:"start"`
Done bool `json:"done"`
Status int `json:"status,omitempty"`
+12 -11
View File
@@ -102,9 +102,9 @@ function icon(name) {
return e;
}
if (name) {
if (name.endsWith("-color") || name === "crush" || name === "zcode" || name === "alma") {
if (name.endsWith("-color") || name === "crush" || name === "zcode" || name === "alma" || name === "typesafe") {
const img = el("img");
img.src = `icons/${name}.${name === "crush" || name === "zcode" || name === "alma" ? "png" : "svg"}`;
img.src = `icons/${name}.${name === "crush" || name === "zcode" || name === "alma" || name === "typesafe" ? "png" : "svg"}`;
img.alt = "";
img.draggable = false;
e.append(img);
@@ -2099,12 +2099,12 @@ function slide(box, key) {
thumbs.set(key, { ...to, at: performance.now(), from: from || to });
}
const PROTOS = [["chat", "OpenAI", "Chat Completions — most agents"], ["responses", "Responses", "OpenAI Responses — what Codex speaks"], ["anthropic", "Anthropic", "Anthropic Messages — what Claude Code speaks"]];
const PROTOS = [["chat", "OpenAI", "Chat Completions — most agents"], ["responses", "Responses", "OpenAI Responses — what Codex speaks"], ["anthropic", "Anthropic", "Anthropic Messages — what Claude Code speaks"], ["decide", "Jev", "TypeSafe's decision API — what a routing group asks as a turn begins"]];
// renderEditor: an existing provider (p), a new preset (presetID), or custom.
function renderEditor(p, presetID) {
const pr = presetID ? providers.presets.find((x) => x.id === presetID) : p?.preset ? providers.presets.find((x) => x.id === p.preset) : null;
const isNew = !p, custom = !pr && !p?.account;
const isNew = !p, custom = !pr && !p?.account, decides = !!(p?.decide || pr?.decide);
// a preset already added is added again only through "Add another": one
// more provider of it, under a name and id of its own
const another = isNew && !!pr?.added;
@@ -2163,7 +2163,7 @@ function renderEditor(p, presetID) {
const hint = el("div", "hint");
const idOf = () => slug(draft.id) || p.id;
const show = () => {
hint.textContent = t("Agents pick its models as {id}", { id: idOf() + "/…" }) +
hint.textContent = t(decides ? "Routing groups name its models as {id} for their classifier" : "Agents pick its models as {id}", { id: idOf() + "/…" }) +
(idOf() !== p.id ? " · " + t("agents and routing groups on {id} move to it", { id: p.id + "/…" }) : "");
};
idIn.oninput = () => { draft.id = idIn.value; show(); };
@@ -2321,9 +2321,9 @@ function renderEditor(p, presetID) {
// models.dev says: one for all of them, and model=size for one
const cx = input(draft.contexts || "", t("e.g. 128k · or gpt-6=1m, comma separated"));
cx.oninput = () => { draft.contexts = cx.value; };
ed.append(...field(t("Context window"), cx, t("How long a request the models take, told to the agents; empty leaves it to the vendor and models.dev")));
if (!decides) ed.append(...field(t("Context window"), cx, t("How long a request the models take, told to the agents; empty leaves it to the vendor and models.dev")));
}
if (p) ed.append(...field(t("Fallback"), renderFallback(p), fallbackHint(p)));
if (p && !decides) ed.append(...field(t("Fallback"), renderFallback(p), fallbackHint(p)));
else if (custom) {
const ex = input(draft.extra.join(", "), t("model ids, comma separated · e.g. gpt-5.5, claude-sonnet-5"));
ex.oninput = () => { draft.extra = ex.value.split(/[,\s]+/).filter(Boolean); };
@@ -2334,7 +2334,7 @@ function renderEditor(p, presetID) {
const ebox = el("div");
refreshEndpoints = () => {
const base = p || pr || {};
const src = { chat: draft.chat || base.chat || "", responses: draft.responses || base.responses || "", anthropic: draft.anthropic || base.anthropic || "" };
const src = { chat: draft.chat || base.chat || "", responses: draft.responses || base.responses || "", anthropic: draft.anthropic || base.anthropic || "", decide: base.decide || "" };
ebox.replaceChildren(renderEndpoints(p, src));
};
refreshEndpoints();
@@ -2796,7 +2796,8 @@ function renderModels(p) {
}
if (!p.models.length && !draft.chosen.length) chips.append(el("span", "hint", t("The vendor's list is empty. Refresh, or type a model id.")));
drawNames();
why.textContent = draft.unlisted ? t("Agents don't see them: only the routing groups they are in use them.")
why.textContent = p.decide ? t("Agents never see them: a routing group picks one as its classifier.")
: draft.unlisted ? t("Agents don't see them: only the routing groups they are in use them.")
: t(draft.chosen.length ? "Agents see the models picked." : "None picked: agents see the vendor's list, up to {n}.", { n: 24 });
};
// the names and reasoning levels of the models agents see: saved at once,
@@ -2882,7 +2883,7 @@ function renderModels(p) {
foot.append(add, refresh, rename);
if (p.fetched) foot.append(el("span", "hint", t("vendor list · {when}", { when: p.fetched })));
// a signed-in account's list, until the vendor gives one, is magpie's own
else if (p.models.length) foot.append(el("span", "hint", t(p.account ? "magpie's list · Refresh asks the vendor" : "from models.dev · Refresh asks the vendor")));
else if (p.models.length) foot.append(el("span", "hint", t(p.account ? "magpie's list · Refresh asks the vendor" : p.decide ? "Jev's names · Refresh asks the vendor" : "from models.dev · Refresh asks the vendor")));
if (p.fetched && !p.account) {
// the fetched list stands in for the picks when none are made
const forget = el("button", "text action", t("Forget"));
@@ -2899,7 +2900,7 @@ function renderModels(p) {
const [tk, cb] = tick(t("Only through routing groups"), !!draft.unlisted);
cb.onchange = () => { draft.unlisted = cb.checked; draw(); };
tk.title = t("Its models leave the list agents pick from; the routing groups they are in still use them");
box.append(tk);
if (!p.decide) box.append(tk);
box.append(why);
draw();
return box;
+21
View File
@@ -654,6 +654,27 @@ const I18N = {
"{by} was asked which of {kinds} turn {turn} is, and said none ({took}).": "问 {by} 第 {turn} 轮属于 {kinds} 中的哪一个,它说都不是({took})。",
"{by} was asked which of {kinds} turn {turn} is, and said “{intent}” ({took}).": "问 {by} 第 {turn} 轮属于 {kinds} 中的哪一个,它说是“{intent}”({took})。",
"It was told turn {prev} was “{after}”, which a message that only carries on from it is too.": "同时告诉了它第 {prev} 轮是“{after}”,只是接着上一轮说的消息也算这一类。",
"{by} was to rate how hard turn {turn} is, but couldn't — {err} — so it reasons as the agent asked.": "{by} 本应评估第 {turn} 轮的难度,但没能给出答案({err}),所以按 agent 自己要求的推理强度。",
"{by} rated turn {turn} {score} of 3, so it picks {level} reasoning for the turn — each model gets the level it has nearest.": "{by} 给第 {turn} 轮的难度打了 {score} 分(满分 3),选了 {level} 推理强度——每个模型按它最接近的档位。",
"Turn {turn} reasons at {level}, as Jev picked when it began.": "第 {turn} 轮按 {level} 推理,这是 Jev 在这一轮开始时选的。",
", {n} sure": ",把握 {n}",
"Decided by": "决策模型",
"Agents never see them: a routing group picks one as its classifier.": "agent 看不到它们:只有路由组把其中一个选作判断模型。",
"Jev's names · Refresh asks the vendor": "Jev 的名字 · 刷新会向厂商获取",
"Routing groups name its models as {id} for their classifier": "路由组以 {id} 把它的模型选作判断模型",
"TypeSafe's decision API — what a routing group asks as a turn begins": "TypeSafe 的决策 API——每轮开始时路由组询问它",
"at {level} reasoning": "以 {level} 推理强度",
"Jev is asked once as each turn begins, with the message and what it said of the turn before. Its calls show in the usage as magpie’s own.": "每轮开始时只问 Jev 一次,给它这条消息和它对上一轮的判断。它的调用在用量里记为 magpie 自己的。",
"Effort": "推理强度",
"Agent's": "按 agent",
"Jev picks": "Jev 选择",
"Add TypeSafe Jev in Providers first": "请先在服务商里添加 TypeSafe Jev",
"Only Jev picks the effort: choose it as the group's classifier": "只有 Jev 能选推理强度:请把它设为这个分组的判断模型",
"Each request reasons as much as the agent asked.": "每个请求按 agent 自己要求的强度推理。",
"Each request reasons as much as the agent asked. Add TypeSafe Jev in Providers to have it pick each turn's effort.": "每个请求按 agent 自己要求的强度推理。在服务商里添加 TypeSafe Jev 后,可以让它为每一轮选择推理强度。",
"As a turn begins, Jev rates how hard it is and the turn's requests ask their model for low, medium, high or xhigh reasoning — the level each model has nearest. Only where the agent asked for reasoning: a request without any (a session title) stays without.": "每一轮开始时,Jev 评估这一轮的难度,这一轮的请求就按 low、medium、high 或 xhigh 推理(取每个模型支持的最接近档位)。只改 agent 本来就要求推理的请求:不带推理的请求(比如会话标题)仍然不推理。",
"As a turn begins, Jev is asked once which of the intents the message is, and how hard the turn is when it picks the effort. An intent it isn't sure of matches no rule. Its calls show in the usage as magpie’s own.": "每一轮开始时,问 Jev 一次:这条消息属于哪个意图;如果由它选推理强度,也一并问这一轮有多难。它没把握的意图不命中任何规则。它的调用在用量里显示为 magpie 自己的调用。",
"It was told turn {prev} reasoned at {level}, which a message that only carries on from it needs too.": "同时告诉了它第 {prev} 轮按 {level} 推理,只是接着上一轮说的消息也需要这么多。",
"With two models or more, a rule can send some turns to one of them first.": "组里有两个及以上模型时,可以用规则把某些轮次先发给其中一个。",
"Checked top first when you send a message: the first that matches sends that turn to its model first; the rest stay behind it if it fails. A turn under way is never moved.": "每次你发新消息时从上往下检查:第一条命中的规则把这一轮先发给它指定的模型,其余成员排在后面做故障转移。进行中的一轮不会中途换模型。",
"Rule {n} ({when}) sent turn {turn} to {use} as it began; the turn stays there.": "第 {turn} 轮开始时命中规则 {n}({when}),发给了 {use};这一轮一直留在那里。",
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

+1 -1
View File
@@ -259,7 +259,7 @@
.rt-cond.tk { cursor: text; }
.rt-cond.in { cursor: text; }
.rt-cond.in input { width: 24ch; }
.rt-classifier { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; margin-top: 8px; }
.rt-classifier { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; }
.rt-classifier[hidden] { display: none; }
.rt-classifier .w { color: var(--muted); font-size: 12px; }
.rt-classifier .hint { flex-basis: 100%; }
+58 -17
View File
@@ -278,7 +278,7 @@
// ruleWhy tells what the group's rules did with a request. With lead,
// only when a rule put the first where it is.
function ruleWhy(r, lead) {
if (!r.rule) return null;
if (!r.rule || r.rule.bare) return null;
const x = { ...r.rule, use: useName(r, r.rule.use) };
const when = (x.when || []).map(condText).join(", ");
if (x.use && !x.unready) {
@@ -337,14 +337,22 @@
// for its rule step x
function classWhy(x) {
const c = x?.classified;
if (!c) return null;
if (!c) return x?.pick ? t("Turn {turn} reasons at {level}, as Jev picked when it began.", { turn: x.turn, level: x.pick }) : null;
const r = { rule: x };
const by = c.by || t("the classifier"), kinds = (c.intents || []).map((x) => `“${x}”`).join(", ");
if (c.error) return t("{by} was to tell which of {kinds} turn {turn} is, but couldn't — {err} — so no rule with an intent matches it.", { by, kinds, turn: r.rule.turn, err: c.error });
if (c.error) return c.intents?.length
? t("{by} was to tell which of {kinds} turn {turn} is, but couldn't — {err} — so no rule with an intent matches it.", { by, kinds, turn: r.rule.turn, err: c.error })
: t("{by} was to rate how hard turn {turn} is, but couldn't — {err} — so it reasons as the agent asked.", { by, turn: r.rule.turn, err: c.error });
const when = c.cached ? t("said before, for the same message") : t("in {ms}", { ms: took(c.ms) });
const told = c.after ? " " + t("It was told turn {prev} was “{after}”, which a message that only carries on from it is too.", { prev: r.rule.turn - 1, after: c.after }) : "";
if (!c.intent) return t("{by} was asked which of {kinds} turn {turn} is, and said none ({took}).", { by, kinds, turn: r.rule.turn, took: when }) + told;
return t("{by} was asked which of {kinds} turn {turn} is, and said “{intent}” ({took}).", { by, kinds, turn: r.rule.turn, intent: c.intent, took: when }) + told;
const toldEffort = c.afterEffort ? " " + t("It was told turn {prev} reasoned at {level}, which a message that only carries on from it needs too.", { prev: r.rule.turn - 1, level: c.afterEffort }) : "";
const effort = c.effort ? t("{by} rated turn {turn} {score} of 3, so it picks {level} reasoning for the turn — each model gets the level it has nearest.", { by, turn: r.rule.turn, score: (c.score || 0).toFixed(1), level: c.effort }) + toldEffort : "";
if (!c.intents?.length) return effort ? `${effort} (${when})` : null;
const sure = c.sure ? t(", {n} sure", { n: Math.round(c.sure * 100) + "%" }) : "";
const said = (!c.intent
? t("{by} was asked which of {kinds} turn {turn} is, and said none ({took}).", { by, kinds, turn: r.rule.turn, took: when + sure })
: t("{by} was asked which of {kinds} turn {turn} is, and said “{intent}” ({took}).", { by, kinds, turn: r.rule.turn, intent: c.intent, took: when + sure })) + told;
return effort ? `${said} ${effort}` : said;
}
// a rule's condition as the gateway writes it, in the page's words
function condText(c) {
@@ -360,7 +368,8 @@
function tryWhy(r, i) {
const tr = r.tries[i], w = tried(r, tr), agent = agentName(r.agent);
const name = w ? `${who(w)} (${w.model})` : tr.id;
let name = w ? `${who(w)} (${w.model})` : tr.id;
if (tr.effort) name += " " + t("at {level} reasoning", { level: tr.effort });
if (!tr.done) return t("{who} is answering…", { who: name });
if (tr.status < 400) {
const tk = r.tokens ? " · " + t("{n} tokens", { n: tokens(r.tokens) }) : "";
@@ -1349,7 +1358,7 @@
if (!g.ready) tags.append(el("span", "tag bad", t("no member ready")));
const edit = el("button", "text", t("Edit"));
edit.onclick = (e) => { e.stopPropagation(); open(); };
const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], routing: g.routing || "", affinity: g.affinity || "", classifier: g.classifier || "", rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])] })) } }; renderGroups(); };
const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], routing: g.routing || "", affinity: g.affinity || "", classifier: g.classifier || "", effort: g.effort || "", rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])] })) } }; renderGroups(); };
row.onclick = open;
row.append(ics, main, tags, edit);
return row;
@@ -1527,26 +1536,57 @@
drawClassifier();
};
rAdd.onclick = () => { d.rules.push({ use: d.members[d.members.length - 1], tokens: 0, images: false, effort: "", agents: [], intent: "" }); drawRules(); };
// the classifier, once a rule has an intent: the model asked which
// intent a turn's message is
// the classifier, once a rule has an intent or Jev picks the effort:
// the model asked which intent a turn's message is. Jev (a decision
// provider's model) answers both in one call.
const cls = el("div", "rt-classifier");
const clabel = el("label", "");
const deciders = groups.deciders || [];
const isJev = (id) => deciders.some((x) => x.id === id);
const drawClassifier = () => {
const on = d.rules.some((r) => r.intent?.trim());
cls.hidden = !on;
const auto = d.effort === "auto";
const on = auto || d.rules.some((r) => r.intent?.trim());
cls.hidden = clabel.hidden = !on;
if (!on) return;
const m = groups.models.find((x) => x.id === d.classifier);
const intents = d.rules.some((r) => r.intent?.trim());
clabel.textContent = t(auto && !intents ? "Decided by" : "Intent told by");
const m = [...deciders, ...groups.models].find((x) => x.id === d.classifier);
const cb = el("button", "rt-cond" + (d.classifier ? " on" : ""));
if (d.classifier) cb.append(icon(m?.icon || "generic"), el("span", "", m ? `${m.name || m.id} · ${m.providerName}` : d.classifier));
else cb.append(el("span", "", t("choose a model")));
cb.onclick = (ev) => openPicker({ id: "", name: "", fields: [] }, { key: "classifier", label: "model", value: d.classifier, options: groups.models.map((x) => ({ value: x.id, label: x.name || x.id, note: x.providerName, icon: x.icon, group: x.providerName, ref: x.id })),
const opt = (x) => ({ value: x.id, label: x.name || x.id, note: x.providerName, icon: x.icon, group: x.providerName, ref: x.id });
cb.onclick = (ev) => openPicker({ id: "", name: "", fields: [] }, { key: "classifier", label: "model", value: d.classifier, options: [...deciders.map(opt), ...(auto ? [] : groups.models.map(opt))],
onPick: (id) => { if (id) d.classifier = id; drawClassifier(); } }, cb, ev);
cls.replaceChildren(el("span", "w", t("Intent told by")), cb,
el("div", "hint", t("As a turn begins, this model is asked which of the intents the message is — once; a small, fast one without reasoning is best. If it fails or can't say, no intent matches. Its calls show in the usage as magpie’s own.")));
cls.replaceChildren(cb,
el("div", "hint", t(isJev(d.classifier) && !intents
? "Jev is asked once as each turn begins, with the message and what it said of the turn before. Its calls show in the usage as magpie’s own."
: isJev(d.classifier)
? "As a turn begins, Jev is asked once which of the intents the message is, and how hard the turn is when it picks the effort. An intent it isn't sure of matches no rule. Its calls show in the usage as magpie’s own."
: "As a turn begins, this model is asked which of the intents the message is — once; a small, fast one without reasoning is best. If it fails or can't say, no intent matches. Its calls show in the usage as magpie’s own.")));
};
rbox.append(rlist, rAdd);
const rw2 = el("div");
rw2.append(rbox, cls, rHint2);
rw2.append(rbox, rHint2);
ed.append(el("label", "", t("Rules")), rw2);
// the effort: the agent's, or Jev's pick for each turn
const eHint = el("div", "hint");
const ew = el("div");
const drawEffort = () => {
eHint.textContent = t(d.effort === "auto"
? "As a turn begins, Jev rates how hard it is and the turn's requests ask their model for low, medium, high or xhigh reasoning — the level each model has nearest. Only where the agent asked for reasoning: a request without any (a session title) stays without."
: deciders.length ? "Each request reasons as much as the agent asked." : "Each request reasons as much as the agent asked. Add TypeSafe Jev in Providers to have it pick each turn's effort.");
};
ew.append(segs([["", t("Agent's")], ["auto", t("Jev picks")]], d.effort, (v) => {
if (v === "auto" && !deciders.length) { status(t("Add TypeSafe Jev in Providers first"), "warn"); v = ""; }
d.effort = v;
if (v === "auto" && !isJev(d.classifier)) d.classifier = deciders[0].id;
drawEffort(); drawClassifier();
}), eHint);
drawEffort();
ed.append(el("label", "", t("Effort")), ew);
const cw = el("div");
cw.append(cls);
ed.append(clabel, cw);
drawRules();
const bar = el("div", "bar");
@@ -1565,8 +1605,9 @@
const bare = d.rules.findIndex((r) => !r.tokens && !r.images && !r.effort && !r.agents.length && !r.intent);
if (bare >= 0) return status(t("Rule {n} needs a condition", { n: bare + 1 }), "warn");
if (d.rules.some((r) => r.intent) && !d.classifier) return status(t("Choose the model that tells which intent a message is"), "warn");
if (d.effort === "auto" && !isJev(d.classifier)) return status(t("Only Jev picks the effort: choose it as the group's classifier"), "warn");
saveBtn.classList.add("busy");
groupAction("save", { id: idOf(), from: g?.id, name: d.name.trim() || idOf(), members: d.members, routing: d.routing, affinity: d.affinity, rules: d.rules, classifier: d.rules.some((r) => r.intent) ? d.classifier : "", context: g?.context || 0 }, t(g ? "{name} saved" : "{name} added", { name: d.name.trim() || idOf() }));
groupAction("save", { id: idOf(), from: g?.id, name: d.name.trim() || idOf(), members: d.members, routing: d.routing, affinity: d.affinity, rules: d.rules, effort: d.effort, classifier: d.rules.some((r) => r.intent) || d.effort === "auto" ? d.classifier : "", context: g?.context || 0 }, t(g ? "{name} saved" : "{name} added", { name: d.name.trim() || idOf() }));
};
saveBtn.onclick = save;
bar.append(cancel, saveBtn);
+7 -1
View File
@@ -17,6 +17,9 @@ type groupsJSON struct {
Groups []groupJSON `json:"groups"`
Models []modelRef `json:"models"`
Pools []poolJSON `json:"pools"`
// Deciders: the decision providers' models (Jev), which may only be a
// group's classifier
Deciders []modelRef `json:"deciders"`
}
type groupJSON struct {
@@ -108,7 +111,10 @@ func keyPools(p provider.Provider) []poolJSON {
}
func groupsState() groupsJSON {
out := groupsJSON{Groups: []groupJSON{}, Models: []modelRef{}, Pools: []poolJSON{}}
out := groupsJSON{Groups: []groupJSON{}, Models: []modelRef{}, Pools: []poolJSON{}, Deciders: []modelRef{}}
for _, e := range provider.Deciders() {
out.Deciders = append(out.Deciders, modelRef{ID: e.ID, Name: e.Name, Provider: e.Provider.ID, PName: e.Provider.Name, Icon: e.Provider.Icon})
}
served := provider.Served()
for _, e := range served {
if e.Group == "" {
+2 -1
View File
@@ -39,6 +39,7 @@ type providerJSON struct {
Chat string `json:"chat"`
Responses string `json:"responses"`
Anthropic string `json:"anthropic"`
Decide string `json:"decide,omitempty"` // a decision API: it only routes groups
Catalog string `json:"catalog"`
Website string `json:"website"`
KeysURL string `json:"keysUrl"`
@@ -165,7 +166,7 @@ func agentUses(agents []*agent.Agent, findGroup func(string) (provider.Group, []
func providerInfo(p provider.Provider, agents []agentUse) providerJSON {
out := 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,
Chat: p.Chat, Responses: p.Responses, Anthropic: p.Anthropic, Decide: p.Decide,
Catalog: p.Catalog, Website: p.Website, KeysURL: p.KeysURL,
Headers: p.Headers, BalanceURL: p.BalanceURL, BalancePath: p.BalancePath, ModelsURL: p.ModelsURL,
Ready: p.Ready(), Chosen: p.Models, Models: []modelJSON{}, Agents: []providerAgent{},
+118
View File
@@ -0,0 +1,118 @@
package provider
// A provider with a decision API (Provider.Decide) — TypeSafe's System One,
// which Jev answers — serves no conversation. Given a message and typed
// questions it answers with a choice and how likely each option was, so a
// routing group can ask it, as a user's turn begins, which of its rules'
// intents the message is and how hard the turn is to think about (see
// gateway/decide.go). Its models are never in the list agents pick from,
// nor members of a group: they are only a group's classifier.
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/yetone/magpie/internal/catalog"
)
// JevLatest is the Jev a decision provider is asked with when the user
// picked none: TypeSafe's latest stable one.
const JevLatest = "jev-latest"
// Decides reports whether the provider is a decision API.
func (p Provider) Decides() bool { return p.Decide != "" }
// decideModels are the models a decision provider offers: the vendor's
// list when fetched, else Jev's aliases.
func (p Provider) decideModels() []catalog.Model {
if live, _, ok := catalog.Live(p.ID); ok && len(live) > 0 {
return live
}
return []catalog.Model{{ID: JevLatest, Name: "Jev"}, {ID: "jev-preview", Name: "Jev (preview)"}}
}
// Deciders are the models of the decision providers ready now, as a
// group's classifier names them.
func Deciders() []Entry {
var out []Entry
for _, p := range All() {
if !p.Decides() || !p.Ready() {
continue
}
for _, m := range p.Exposed() {
out = append(out, Entry{ID: p.ID + "/" + m.ID, Model: m.ID, Name: m.Name, Provider: p})
}
}
return out
}
// IsDecider reports whether a classifier ("provider/model") is a decision
// provider's model.
func IsDecider(id string) bool {
p, _, ok := Resolve(id)
return ok && p.Decides()
}
// fetchDecide lists the models a decision provider's key can use:
// {"models":[{"name":…,"description":…}]}.
func (p Provider) fetchDecide(ctx context.Context) ([]catalog.Model, error) {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.Decide+"/models", nil)
if err != nil {
return nil, err
}
if err := p.Sign(ctx, req, Chat, nil); err != nil {
return nil, err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: %s", p.Name, APIError(b, res.Status))
}
var out struct {
Models []struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"models"`
}
if err := json.Unmarshal(b, &out); err != nil {
return nil, fmt.Errorf("%s: not a model list", p.Name)
}
var ms []catalog.Model
for _, m := range out.Models {
if m.Name != "" {
ms = append(ms, catalog.Model{ID: m.Name, Name: m.Name})
}
}
if len(ms) == 0 {
return nil, fmt.Errorf("%s lists no models", p.Name)
}
return ms, catalog.SaveLive(p.ID, p.Decide, ms)
}
// testDecide asks the decision API for its models, the one call it
// answers that costs nothing.
func (p Provider) testDecide(ctx context.Context) []Result {
t0 := time.Now()
r := Result{Protocol: "decide", Model: JevLatest}
ms, err := p.fetchDecide(ctx)
r.Millis = time.Since(t0).Milliseconds()
if err != nil {
r.Error = err.Error()
return []Result{r}
}
r.OK, r.Status = true, http.StatusOK
if len(ms) > 0 {
r.Model = ms[0].ID
}
return []Result{r}
}
+68
View File
@@ -0,0 +1,68 @@
package provider
import (
"strings"
"testing"
)
// A decision provider (TypeSafe's Jev) is only ever a group's classifier:
// its models aren't in the catalog nor a group's members, and a group's
// effort is picked only by it.
func TestDecider(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if err := Save(Provider{ID: "a", Name: "a", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m"}}); err != nil {
t.Fatal(err)
}
ts, err := FromPreset("typesafe")
if err != nil || !ts.Decides() {
t.Fatalf("preset %+v %v", ts, err)
}
ts.Key = "kts"
if err := Save(ts); err != nil {
t.Fatal(err)
}
if p, err := Find("typesafe"); err != nil || p.Host() != "api.typesafe.ai" {
t.Fatalf("saved %+v %v", p, err)
}
for _, e := range Catalog() {
if e.Provider.ID == "typesafe" {
t.Fatalf("Jev in the catalog: %+v", e)
}
}
if ds := Deciders(); len(ds) == 0 || ds[0].ID != "typesafe/jev-latest" || !IsDecider("typesafe/jev-latest") || IsDecider("a/m") {
t.Fatalf("deciders %+v", ds)
}
if p, _, ok := Resolve("jev-latest"); ok && p.Decides() {
t.Fatal("a bare jev-latest resolves to the decider")
}
g := Group{Name: "G", Members: []string{"a/m"}}
for _, tc := range []struct {
effort, classifier, err string
}{
{"auto", "", "needs the group's classifier"},
{"auto", "a/m", "Jev"},
{"high", "typesafe/jev-latest", "not \"high\""},
{"auto", "typesafe/jev-latest", ""},
} {
g.Effort, g.Classifier = tc.effort, tc.classifier
err := SaveGroup(g)
if tc.err == "" && err != nil || tc.err != "" && (err == nil || !strings.Contains(err.Error(), tc.err)) {
t.Errorf("%s by %q: %v, want %q", tc.effort, tc.classifier, err, tc.err)
}
}
if g, _, _ := FindGroup("group/g"); g.Effort != EffortAuto || g.Classifier != "typesafe/jev-latest" || !g.Ruled() {
t.Fatalf("saved %+v", g)
}
if err := SaveGroup(Group{Name: "H", Members: []string{"typesafe/jev-latest"}}); err == nil {
t.Error("Jev saved as a group's member")
}
}
// TypeSafe's errors are FastAPI's: {"detail":{"message":…}}.
func TestAPIErrorDetail(t *testing.T) {
b := []byte(`{"detail":{"error_type":"authentication_error","message":"Must supply an API key! Check your request and try again."}}`)
if got := APIError(b, "403 Forbidden"); got != "Must supply an API key! Check your request and try again." {
t.Fatal(got)
}
}
+29 -2
View File
@@ -40,6 +40,14 @@ const (
AffinityOff = "off"
)
// EffortAuto is a group whose classifier picks each turn's reasoning
// effort (Group.Effort).
const EffortAuto = "auto"
// Ruled reports whether the group decides anything as a user's turn
// begins: a rule to put a member first, or the turn's effort.
func (g Group) Ruled() bool { return len(g.Rules) > 0 || g.Effort == EffortAuto }
// Group is a routing group.
type Group struct {
ID string `json:"id"`
@@ -54,6 +62,11 @@ type Group struct {
// of the rules' intents a user's message is. Rules with an intent need
// one; a small, fast model without reasoning does.
Classifier string `json:"classifier,omitempty"`
// Effort "auto" has the classifier — a decision provider's model, as
// Jev — judge how hard each turn is to think about as it begins, and
// the turn asks its model for that much reasoning, where the agent
// asked for some (see EffortAuto).
Effort string `json:"effort,omitempty"`
// Context is how long a request the user says the group takes, in
// tokens: agents are told it rather than its shortest member's.
Context int `json:"context,omitempty"`
@@ -360,6 +373,11 @@ func SaveGroup(g Group) error {
if err := groupsInGroup(g, groupsIn(providerEntries())); err != nil {
return err
}
for _, m := range g.Members {
if IsDecider(m) {
return fmt.Errorf("%s decides a group's model and effort; it holds no conversation, so it can only be the group's classifier", m)
}
}
if g.Routing != Ordered && g.Routing != Rotate && g.Routing != LeastUsed {
g.Routing = ""
}
@@ -372,19 +390,28 @@ func SaveGroup(g Group) error {
}
g.Rules = rules
g.Classifier = strings.TrimPrefix(strings.TrimSpace(g.Classifier), "magpie/")
g.Effort = strings.ToLower(strings.TrimSpace(g.Effort))
intents := slices.ContainsFunc(g.Rules, func(r Rule) bool { return r.Intent != "" })
switch {
case g.Effort != "" && g.Effort != EffortAuto:
return fmt.Errorf("a group's effort is %q or left to the agent, not %q", EffortAuto, g.Effort)
case strings.HasPrefix(g.Classifier, GroupPrefix):
return fmt.Errorf("the classifier is a model, not a group (%s)", g.Classifier)
case intents && g.Classifier == "":
return errors.New("a rule with an intent needs the group's classifier: the model that tells which intent a message is")
case !intents:
case g.Effort == EffortAuto && g.Classifier == "":
return errors.New("effort picked per turn needs the group's classifier to be Jev (a TypeSafe provider's model)")
case !intents && g.Effort == "":
g.Classifier = "" // nothing to ask it
}
if g.Classifier != "" {
if _, _, ok := Resolve(g.Classifier); !ok {
p, _, ok := Resolve(g.Classifier)
if !ok {
return fmt.Errorf("magpie knows no model %q to classify with", g.Classifier)
}
if g.Effort == EffortAuto && !p.Decides() {
return fmt.Errorf("effort picked per turn needs the group's classifier to be Jev (a TypeSafe provider's model), not %s", g.Classifier)
}
}
g.Auto, g.Hidden = false, false
f := load()
+1 -1
View File
@@ -235,7 +235,7 @@ func sameProvider(a, b Provider) bool {
return false
}
hosts := map[string]bool{}
for _, u := range []string{a.Chat, a.Responses, a.Anthropic} {
for _, u := range []string{a.Chat, a.Responses, a.Anthropic, a.Decide} {
if h := hostOf(u); h != "" {
hosts[h] = true
}
+8 -2
View File
@@ -21,6 +21,9 @@ const manyModels = 24
// from the vendor itself when there is one, over the models.dev catalog (or,
// for an account, whatever the agent's own sign-in can see).
func (p Provider) Available() []catalog.Model {
if p.Decides() {
return p.decideModels()
}
signedIn := p.Account != nil && p.Account.models != nil
var known []catalog.Model
if signedIn {
@@ -69,6 +72,9 @@ func (p Provider) Fetched() (time.Time, bool) {
// Fetch asks the vendor which models it serves and remembers the answer.
func (p Provider) Fetch(ctx context.Context) ([]catalog.Model, error) {
if p.Decides() {
return p.fetchDecide(ctx)
}
if p.Account != nil && p.Account.fetch != nil {
return p.Account.fetch(ctx)
}
@@ -353,7 +359,7 @@ func providerEntries() []Entry {
var out []Entry
s := settings.Load()
for _, p := range All() {
if !p.Ready() {
if !p.Ready() || p.Decides() { // a decision API only routes
continue
}
for _, m := range p.Exposed() {
@@ -428,7 +434,7 @@ func resolveIn(entries []Entry, id string) (Provider, string, bool) {
// not exposed, but some provider lists it
var found []Provider
for _, p := range All() {
if !p.Ready() {
if !p.Ready() || p.Decides() {
continue
}
for _, m := range p.Available() {
+8 -1
View File
@@ -20,6 +20,7 @@ type PresetDef struct {
Chat string `json:"chat,omitempty"`
Responses string `json:"responses,omitempty"`
Anthropic string `json:"anthropic,omitempty"`
Decide string `json:"decide,omitempty"` // a decision API: the provider only routes (see decide.go)
Catalog string `json:"catalog,omitempty"`
Website string `json:"website,omitempty"`
KeysURL string `json:"keysUrl,omitempty"`
@@ -158,6 +159,12 @@ var presets = []PresetDef{
{ID: "cn", Name: "China Mainland", Chat: "https://cn.yylx.io/v1", Anthropic: "https://cn.yylx.io"},
}},
// Jev answers no conversation: it decides which of a routing group's
// models takes a turn, and how hard it thinks
{ID: "typesafe", Name: "TypeSafe Jev", Icon: "typesafe", Kind: KindVendor,
Decide: "https://api.typesafe.ai/v1",
Note: "routes groups · picks model and effort",
Website: "https://typesafe.ai", KeysURL: "https://console.typesafe.ai/keys"},
{ID: "ollama", Name: "Ollama", Icon: "ollama", Kind: KindLocal, NoKey: true,
Chat: "http://localhost:11434/v1", Anthropic: "http://localhost:11434",
Note: "your local models", Website: "https://ollama.com"},
@@ -200,7 +207,7 @@ func FromPreset(id string) (Provider, error) {
}
return Provider{
ID: pr.ID, Name: pr.Name, Icon: pr.Icon, Preset: pr.ID,
Chat: pr.Chat, Responses: pr.Responses, Anthropic: pr.Anthropic,
Chat: pr.Chat, Responses: pr.Responses, Anthropic: pr.Anthropic, Decide: pr.Decide,
Catalog: pr.Catalog, Website: pr.Website, KeysURL: pr.KeysURL,
}, nil
}
+10 -3
View File
@@ -59,6 +59,10 @@ type Provider struct {
Chat string `json:"chat,omitempty"`
Responses string `json:"responses,omitempty"`
Anthropic string `json:"anthropic,omitempty"`
// Decide is the base of a decision API (TypeSafe's System One, which
// Jev answers): a provider with it serves no conversation, only the
// routing groups' choices of model and effort (see decide.go).
Decide string `json:"decide,omitempty"`
// Fallback is where a request goes when this provider can't take it —
// out of quota, rate limited, overloaded or down — before any of the
@@ -189,7 +193,7 @@ func All() []Provider {
var out []Provider
for _, p := range stored {
p = normalize(p)
if p.Chat == "" && p.Responses == "" && p.Anthropic == "" {
if p.Chat == "" && p.Responses == "" && p.Anthropic == "" && p.Decide == "" {
picks[p.ID] = p
continue
}
@@ -287,7 +291,7 @@ func Save(p Provider) error {
// taken, it would hide that subscription once signed in
return fmt.Errorf("%q is the id of the %s subscription; pick another name", p.ID, p.ID)
}
if p.Chat == "" && p.Responses == "" && p.Anthropic == "" {
if p.Chat == "" && p.Responses == "" && p.Anthropic == "" && p.Decide == "" {
return errors.New("a provider needs a base URL")
}
if p.Key == "" && !keyOptional(p) {
@@ -426,7 +430,7 @@ func normalize(p Provider) Provider {
p.ID = strings.ToLower(strings.TrimSpace(p.ID))
p.Name = strings.TrimSpace(p.Name)
p.Key = strings.TrimSpace(p.Key)
for _, u := range []*string{&p.Chat, &p.Responses, &p.Anthropic, &p.Website, &p.KeysURL} {
for _, u := range []*string{&p.Chat, &p.Responses, &p.Anthropic, &p.Decide, &p.Website, &p.KeysURL} {
*u = strings.TrimRight(strings.TrimSpace(*u), "/")
if *u != "" && !strings.Contains(*u, "://") {
*u = "https://" + *u
@@ -541,6 +545,9 @@ func (p Provider) Host() string {
return HostOf(u)
}
}
if p.Decide != "" {
return HostOf(p.Decide)
}
return ""
}
+7
View File
@@ -28,6 +28,9 @@ type Result struct {
// serves, signed with a key made for it and asking for a model that key
// sees, and reports what came back.
func (p Provider) Test(ctx context.Context) []Result {
if p.Decides() {
return p.testDecide(ctx)
}
p.Fetch(ctx)
var out []Result
for _, proto := range p.Speaks() {
@@ -169,8 +172,12 @@ func APIError(b []byte, fallback string) string {
var v struct {
Error json.RawMessage `json:"error"`
Message string `json:"message"`
Detail json.RawMessage `json:"detail"` // FastAPI's (TypeSafe)
}
if json.Unmarshal(b, &v) == nil {
if len(v.Detail) > 0 {
v.Error = v.Detail
}
var e struct {
Message string `json:"message"`
}
+5
View File
@@ -487,6 +487,11 @@ func announce(id string) error {
fmt.Println(green.Render("✓"), len(ms), "models from", fetchedFrom(*saved))
}
n := len(saved.Exposed())
if saved.Decides() {
fmt.Println(" it routes groups: magpie group set <id> effort=auto classifier="+saved.ID+"/"+provider.JevLatest,
muted.Render("· or a rule's intent=…"))
return nil
}
if n == 0 {
fmt.Println(amber.Render("!"), "no models exposed yet ·", "magpie provider models", saved.ID, "<ids…>")
} else {