diff --git a/internal/agent/t3code.go b/internal/agent/t3code.go index 1ac9aa81..3145503e 100644 --- a/internal/agent/t3code.go +++ b/internal/agent/t3code.go @@ -9,7 +9,8 @@ package agent // // {"providerInstances":{"":{"driver":"claudeAgent","displayName":…, // "enabled":true,"environment":[{"name":…,"value":…,"sensitive":false}], -// "config":{"customModels":[{"slug":…,"name":…}]}}}} +// "config":{"customModels":[{"slug":…,"name":…,"capabilities": +// {"optionDescriptors":[{"id":"effort","type":"select",…}]}}]}}}} // // A claudeAgent instance runs Claude Code (Anthropic's Agent SDK, the // user's ~/.claude settings read as Claude Code reads them) with the @@ -18,7 +19,8 @@ package agent // own, "magpie": Claude Code pointed at the gateway (ANTHROPIC_BASE_URL and // the token Claude Code is routed with, so its requests are Claude Code's // to the gateway, tiers and all), every magpie model one of its custom -// models, a 1M one marked [1m] as magpie marks it for Claude Code. It is +// models, a 1M one marked [1m] as magpie marks it for Claude Code, with +// a Reasoning pick of the levels it takes (t3Capabilities). It is // magpie's alone: the user's own instances, their custom models and T3's // other keys stay as they are, and off takes only it out. A binary path or // Claude home the user gave T3's own Claude is carried over, so it runs the @@ -107,7 +109,11 @@ func t3InstanceJSON(path, gw string) map[string]any { mark := claude1MFor("t3code") models := []map[string]any{} for _, m := range magpieModels("t3code") { - models = append(models, map[string]any{"slug": mark(m.ID), "name": m.Name}) + e := map[string]any{"slug": mark(m.ID), "name": m.Name} + if c := t3Capabilities(m.ID, m.Efforts); c != nil { + e["capabilities"] = c + } + models = append(models, e) } config := map[string]any{"customModels": models} for _, k := range []string{"binaryPath", "homePath"} { @@ -130,3 +136,36 @@ func t3InstanceJSON(path, gw string) map[string]any { "config": config, } } + +// t3EffortLabels are T3 Code's names for Claude Code's levels, as its own +// Claude models show them. +var t3EffortLabels = map[string]string{"low": "Low", "medium": "Medium", "high": "High", "xhigh": "Extra High", "max": "Max"} + +// t3Capabilities is the pick T3 Code shows beside a custom model: without +// one it shows none and sends no effort, so every magpie model ran at +// whatever Claude Code took (KevinXC on Discord). It is Reasoning, the +// levels of Claude Code's the model takes, medium chosen first as on T3's +// own Claude models (else the lowest); T3 hands the level to Claude Code +// as it is, which sends it to the gateway. nil for a model with no level +// Claude Code can send. +func t3Capabilities(id string, efforts []string) map[string]any { + var options []map[string]any + for _, l := range claudeEffortsFor(id) { + if contains(efforts, l) { + options = append(options, map[string]any{"id": l, "label": t3EffortLabels[l]}) + } + } + if len(options) == 0 { + return nil + } + def := options[0] + for _, o := range options { + if o["id"] == "medium" { + def = o + } + } + def["isDefault"] = true + return map[string]any{"optionDescriptors": []map[string]any{ + {"id": "effort", "label": "Reasoning", "type": "select", "options": options}, + }} +} diff --git a/internal/agent/t3code_test.go b/internal/agent/t3code_test.go index 20ce26fd..4cc78c60 100644 --- a/internal/agent/t3code_test.go +++ b/internal/agent/t3code_test.go @@ -46,8 +46,20 @@ type t3Envelope struct { BinaryPath string `json:"binaryPath"` HomePath string `json:"homePath"` CustomModels []struct { - Slug string `json:"slug"` - Name string `json:"name"` + Slug string `json:"slug"` + Name string `json:"name"` + Capabilities *struct { + OptionDescriptors []struct { + ID string `json:"id"` + Label string `json:"label"` + Type string `json:"type"` + Options []struct { + ID string `json:"id"` + Label string `json:"label"` + IsDefault *bool `json:"isDefault"` + } `json:"options"` + } `json:"optionDescriptors"` + } `json:"capabilities"` } `json:"customModels"` } `json:"config"` } @@ -78,6 +90,23 @@ func t3Format(t *testing.T, raw []byte) t3Envelope { if m.Slug == "" || m.Name == "" { t.Errorf("custom model: %s", raw) } + if m.Capabilities == nil { + continue + } + for _, d := range m.Capabilities.OptionDescriptors { + defaults := 0 + for _, o := range d.Options { + if o.ID == "" || o.Label == "" { + t.Errorf("option: %s", raw) + } + if o.IsDefault != nil && *o.IsDefault { + defaults++ + } + } + if d.ID == "" || d.Label == "" || d.Type != "select" || len(d.Options) == 0 || defaults != 1 { + t.Errorf("descriptor: %s", raw) + } + } } return e } @@ -230,3 +259,58 @@ func TestT3CodeHomeAndInstance(t *testing.T) { t.Error("the user's Claude instance is gone") } } + +// Each magpie model in T3 Code has a Reasoning pick of the levels it takes +// that Claude Code can send, medium chosen first, else the lowest (KevinXC +// on Discord: every model ran at medium with no way to change it); none +// for a model with no such level. +func TestT3CodeEfforts(t *testing.T) { + type opt struct { + id string + def bool + } + pick := func(id string, efforts []string) []opt { + c := t3Capabilities(id, efforts) + if c == nil { + return nil + } + raw, _ := json.Marshal(c) + var caps struct { + OptionDescriptors []struct { + ID, Label, Type string + Options []struct { + ID, Label string + IsDefault bool + } + } + } + json.Unmarshal(raw, &caps) + if len(caps.OptionDescriptors) != 1 || caps.OptionDescriptors[0].ID != "effort" || caps.OptionDescriptors[0].Type != "select" { + t.Fatalf("%s: %s", id, raw) + } + var out []opt + for _, o := range caps.OptionDescriptors[0].Options { + if o.Label == "" { + t.Errorf("%s: no label: %s", id, raw) + } + out = append(out, opt{o.ID, o.IsDefault}) + } + return out + } + for _, c := range []struct { + id string + efforts []string + want []opt + }{ + {"codex/gpt-6", []string{"none", "minimal", "low", "medium", "high", "xhigh"}, []opt{{"low", false}, {"medium", true}, {"high", false}, {"xhigh", false}}}, + {"ds/deepseek-v4", []string{"high", "max"}, []opt{{"high", true}, {"max", false}}}, + {"claude/claude-opus-4-6", []string{"low", "medium", "high", "xhigh", "max"}, []opt{{"low", false}, {"medium", true}, {"high", false}, {"max", false}}}, + {"claude/claude-haiku-4-5", []string{"low", "medium", "high"}, nil}, + {"glm/glm-4.6", []string{"none", "minimal"}, nil}, + {"glm/glm-4.6", nil, nil}, + } { + if got := pick(c.id, c.efforts); !slices.Equal(got, c.want) { + t.Errorf("%s %v: %v, want %v", c.id, c.efforts, got, c.want) + } + } +}