diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index a4bfbcb7..e64e8261 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -60,6 +60,11 @@ type Model struct { // Tiers, in one ChatGPT account's own model list, are the service // tiers its plan offers on the model ("priority", "ultrafast"). Tiers []string `json:",omitempty"` + // OwnTier is set on a model of a provider the user added by its + // address, which the gateway sends the tier Codex asks for as it is + // (gateway Request.Tier): Codex offers it the tiers of its own entry + // for the same model, Fast on another GPT model. + OwnTier bool `json:",omitempty"` // AgentsV2 is set on a model Codex is told multi-agent V2 for, so its // Ultra hands work to Codex's agents: one offering Ultra that no // ChatGPT account answers for (provider.Entry's). diff --git a/internal/codexcat/codexcat.go b/internal/codexcat/codexcat.go index 973f08c6..9758e451 100644 --- a/internal/codexcat/codexcat.go +++ b/internal/codexcat/codexcat.go @@ -151,6 +151,8 @@ func Entries(ms []catalog.Model, after int) []any { // GPT models if slug, ok := strings.CutPrefix(m.ID, "codex/"); m.Fast || ok && strings.HasPrefix(slug, "gpt-") { e.Tiers = append(e.Tiers, tier{ID: "priority", Name: "Fast", Description: "1.5x speed, increased usage"}) + } else if m.OwnTier { + e.Tiers = ownTiers(own, m) } // an OpenAI model: a ChatGPT account's (codex/), or a group one is // in (Fast, see provider.codexListed) @@ -214,6 +216,41 @@ func agentsEffort(own map[string]map[string]any, m catalog.Model) string { return ef } +// gptModel is an OpenAI model's id, as a relay may serve it: gpt-6-sol, o4. +var gptModel = regexp.MustCompile(`^(gpt-|o\d)`) + +// ownTiers are the service tiers Codex's own entry gives the model a +// provider the user added by its address serves (openai/gpt-6-sol on a +// relay, a dated snapshot), Ultrafast among them where the entry has it; +// Fast on another GPT model; none on any other. +func ownTiers(own map[string]map[string]any, m catalog.Model) []tier { + slug := strings.ToLower(m.ID) + if i := strings.LastIndex(slug, "/"); i >= 0 { + slug = slug[i+1:] + } + slug = datedSuffix.ReplaceAllString(slug, "") + if raw, ok := own[slug]["service_tiers"].([]any); ok { + var ts []tier + for _, r := range raw { + t, _ := r.(map[string]any) + id, _ := t["id"].(string) + if id == "" { + continue + } + name, _ := t["name"].(string) + desc, _ := t["description"].(string) + ts = append(ts, tier{ID: id, Name: name, Description: desc}) + } + if len(ts) > 0 { + return ts + } + } + if gptModel.MatchString(slug) { + return []tier{{ID: "priority", Name: "Fast", Description: "1.5x speed, increased usage"}} + } + return []tier{} +} + // Order ranks entries — Codex's own, as the backend gives them, and // magpie's from Entries — in the order the user put them in (#855): Codex // lists its models by priority, lowest first. The ones at names go first, diff --git a/internal/codexcat/own_tier_test.go b/internal/codexcat/own_tier_test.go new file mode 100644 index 00000000..c6f0f9f7 --- /dev/null +++ b/internal/codexcat/own_tier_test.go @@ -0,0 +1,49 @@ +package codexcat + +import ( + "encoding/json" + "testing" + + "github.com/yetone/magpie/internal/catalog" +) + +// A model of a provider the user added by its address is offered the tiers +// Codex's own entry gives the same model, Ultrafast among them, and Fast on +// another GPT model; none on any other, nor on one not added by its address +// (hsiangron on X: Fast and Ultrafast weren't there to pick). +func TestOwnProviderTiers(t *testing.T) { + v1Home(t, `{"etag":"W/\"a\"","models":[{"slug":"gpt-6-sol","base_instructions":"x","service_tiers":[{"id":"priority","name":"Fast","description":"f"},{"id":"ultrafast","name":"Ultrafast","description":"u"}]}]}`) + ms := []catalog.Model{ + {ID: "relay/gpt-6-sol", Name: "a", OwnTier: true}, + {ID: "relay/gpt-6-sol-2026-09-01", Name: "b", OwnTier: true}, + {ID: "relay/gpt-5.5", Name: "c", OwnTier: true}, + {ID: "relay/glm-5", Name: "d", OwnTier: true}, + {ID: "openrouter/gpt-6-sol", Name: "e"}, + } + var got struct { + Models []struct { + Slug string `json:"slug"` + Tiers []struct { + ID string `json:"id"` + } `json:"service_tiers"` + } `json:"models"` + } + if err := json.Unmarshal(Catalog(ms), &got); err != nil { + t.Fatal(err) + } + ids := map[string]string{} + for _, m := range got.Models { + s := "" + for _, x := range m.Tiers { + s += x.ID + " " + } + ids[m.Slug] = s + } + want := map[string]string{"relay/gpt-6-sol": "priority ultrafast ", "relay/gpt-6-sol-2026-09-01": "priority ultrafast ", + "relay/gpt-5.5": "priority ", "relay/glm-5": "", "openrouter/gpt-6-sol": ""} + for k, v := range want { + if ids[k] != v { + t.Errorf("%s offered %q, want %q", k, ids[k], v) + } + } +} diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index d48a41cf..cc794a43 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -70,7 +70,7 @@ func parseChat(body []byte) (*Request, error) { return nil, fmt.Errorf("invalid request: %v", err) } r := &Request{Model: c.Model, MaxTokens: c.MaxCompletionTokens, Temp: c.Temperature, TopP: c.TopP, - Stream: c.Stream, Effort: effortOf(c.ReasoningEffort), Parallel: c.ParallelToolCalls, Fast: c.ServiceTier == "priority", + Stream: c.Stream, Effort: effortOf(c.ReasoningEffort), Parallel: c.ParallelToolCalls, Fast: c.ServiceTier == "priority", Tier: c.ServiceTier, CacheKey: c.PromptCacheKey, Format: openAIFormat(c.ResponseFormat)} if r.MaxTokens == 0 { r.MaxTokens = c.MaxTokens @@ -409,6 +409,9 @@ func buildChat(r *Request, model, host string, rejectTemp bool) []byte { if r.Fast && host == "cursor" { out["service_tier"] = "priority" } + if r.OwnTier && r.Tier != "" { + out["service_tier"] = r.Tier + } if r.MaxTokens > 0 { if strings.HasSuffix(host, "openai.com") { out["max_completion_tokens"] = r.MaxTokens diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 706c4e34..9e03c645 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -3524,6 +3524,12 @@ func (s *Server) forwardTranslated(ctx context.Context, p provider.Provider, to r.OffLevel, req = l, &r } } + // the tier the client asked for goes to a provider the user added + // by its address as it was asked (Request.Tier) + if own := p.Preset == "" && p.Account == nil && s.fits(p.ID, tierField, to); own != req.OwnTier { + r := *req + r.OwnTier, req = own, &r + } body, err := build(to, req, model, buildHost(p), p.RejectsTemperature(model)) if err != nil { return nil, to, err @@ -3640,6 +3646,15 @@ func (s *Server) forwardTranslated(ctx context.Context, p provider.Provider, to continue } } + if req.OwnTier && req.Tier != "" && slices.Contains(refusedOptional(res.StatusCode, b, body), tierField) { + // the field turned away: asked again without it, and not sent + // it again. A value it doesn't offer reaches the client as the + // upstream said it, as on a relayed request + s.markUnfit(p.ID, tierField, to) + r := *req + r.Tier, req = "", &r + continue + } if req.CacheKey != "" && badRequest(res.StatusCode) && !wrongEndpoint(res.StatusCode, b) && !provider.CopilotRefusal(b) { // a vendor that turns away fields it doesn't know is asked again // without the cache key, and not sent it again once that works — @@ -3694,10 +3709,14 @@ func refusesField(status int, body []byte, field string) bool { return badRequest(status) && bytes.Contains(body, []byte(field)) } +// tierField is the client's service tier as a request carries it upstream +// (Request.Tier); a provider that refused it is remembered under it in unfit. +const tierField = "service_tier" + // optionalFields are request fields OpenAI's APIs (or an agent's own // vendor, Kimi's and Qwen's thinking switches) take that a request does // without: an upstream that refuses one by name is asked again without it. -var optionalFields = []string{"store", "metadata", "service_tier", cacheKeyField, "prompt_cache_retention", +var optionalFields = []string{"store", "metadata", tierField, cacheKeyField, "prompt_cache_retention", "safety_identifier", "stream_options", "parallel_tool_calls", "verbosity", "thinking", "enable_thinking"} // refusedOptional lists optional fields an upstream explicitly rejects as diff --git a/internal/gateway/ir.go b/internal/gateway/ir.go index 1256ebdd..a68fd05f 100644 --- a/internal/gateway/ir.go +++ b/internal/gateway/ir.go @@ -140,6 +140,15 @@ type Request struct { // ChatGPT account on a plan with it offers on its models; Fast is set // with it, so where there is no Ultrafast the request goes fast Ultrafast bool + // Tier is the service_tier the client sent, as it sent it. It goes on + // as it is to a provider the user added by its address (OwnTier): + // a relay of theirs may serve Fast and Ultrafast where magpie can't + // tell (hsiangron on X: both were left out) + Tier string + // OwnTier is set for such a provider (gateway.go, as each attempt is + // built); one that turns the field away is asked again without it + // (optionalFields) + OwnTier bool // CacheKey is the client's prompt_cache_key (Codex sends its thread's // id), which OpenAI, and relays in front of it, route a conversation by // to where its prompt is cached. diff --git a/internal/gateway/own_tier_test.go b/internal/gateway/own_tier_test.go new file mode 100644 index 00000000..680d2a20 --- /dev/null +++ b/internal/gateway/own_tier_test.go @@ -0,0 +1,91 @@ +package gateway + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// codexFastAsk is a turn as Codex sends it in Fast mode (or Ultrafast), web +// search offered as on every turn, which has magpie translate it for a +// provider that doesn't search by itself. +func codexFastAsk(tier string) string { + return fmt.Sprintf(`{"model":"m1","instructions":"You are Codex.","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}],"tools":[{"type":"web_search","external_web_access":true}],"tool_choice":"auto","parallel_tool_calls":true,"reasoning":{"effort":"high","summary":"auto"},"store":false,"stream":true,"include":["reasoning.encrypted_content"],"prompt_cache_key":"019a0c1e-thread","service_tier":%q}`, tier) +} + +// A provider the user added by its address is sent the tier Codex asked +// for, Fast's and Ultrafast's, on its Responses API and on its Chat API +// (hsiangron on X: both were left out). +func TestOwnProviderGetsTier(t *testing.T) { + for _, proto := range []provider.Protocol{provider.Responses, provider.Chat} { + for _, tier := range []string{"priority", "ultrafast", "flex"} { + t.Run(string(proto)+"/"+tier, func(t *testing.T) { + f := &fake{t: t, reply: optionalFieldReply(proto)} + setup(t, proto, f) + code, reply := postTo(t, New(), "/v1/responses", codexFastAsk(tier)) + if code != 200 { + t.Fatalf("%d: %s", code, reply) + } + var sent map[string]any + json.Unmarshal(f.got, &sent) + if sent["service_tier"] != tier { + t.Errorf("tier %v sent, want %s: %s", sent["service_tier"], tier, f.got) + } + }) + } + } +} + +// One of a preset is sent no tier: a relay magpie knows may refuse it. +func TestPresetProviderGetsNoTier(t *testing.T) { + f := &fake{t: t, reply: optionalFieldReply(provider.Chat)} + setup(t, provider.Chat, f) + p, err := provider.Find("fake") + if err != nil { + t.Fatal(err) + } + p.Preset = "openrouter" + if err := provider.Save(*p); err != nil { + t.Fatal(err) + } + if code, reply := postTo(t, New(), "/v1/responses", codexFastAsk("priority")); code != 200 { + t.Fatalf("%d: %s", code, reply) + } + var sent map[string]any + json.Unmarshal(f.got, &sent) + if _, ok := sent["service_tier"]; ok { + t.Errorf("tier sent to a preset: %s", f.got) + } +} + +// One that turns the field away is asked again without it, and not sent +// it again. +func TestOwnProviderRefusingTier(t *testing.T) { + for _, proto := range []provider.Protocol{provider.Responses, provider.Chat} { + t.Run(string(proto), func(t *testing.T) { + f := &fake{t: t, reply: optionalFieldReply(proto)} + refused := 0 + f.refuse = func(b []byte) (int, string) { + var v map[string]json.RawMessage + json.Unmarshal(b, &v) + if _, ok := v["service_tier"]; ok { + refused++ + return 400, `{"error":{"message":"Unsupported parameter: 'service_tier' is not supported with this model.","param":"service_tier","code":"unsupported_parameter"}}` + } + return 0, "" + } + setup(t, proto, f) + srv := New() + for i := range 2 { + if code, reply := postTo(t, srv, "/v1/responses", codexFastAsk("priority")); code != 200 { + t.Fatalf("turn %d: %d: %s", i, code, reply) + } + } + if refused != 1 || f.calls != 3 { + t.Errorf("refused %d times in %d calls, want once in 3", refused, f.calls) + } + }) + } +} diff --git a/internal/gateway/responses.go b/internal/gateway/responses.go index fb98fcd3..f0dfcdec 100644 --- a/internal/gateway/responses.go +++ b/internal/gateway/responses.go @@ -255,7 +255,7 @@ func parseResponses(body []byte) (*Request, error) { return nil, fmt.Errorf("invalid request: %v", err) } r := &Request{Model: q.Model, System: q.Instructions, MaxTokens: q.MaxOutputTokens, Temp: q.Temperature, - TopP: q.TopP, Stream: q.Stream, Parallel: q.ParallelToolCalls, Fast: q.ServiceTier == "priority" || q.ServiceTier == "ultrafast", Ultrafast: q.ServiceTier == "ultrafast", CacheKey: q.PromptCacheKey, Include: q.Include, + TopP: q.TopP, Stream: q.Stream, Parallel: q.ParallelToolCalls, Fast: q.ServiceTier == "priority" || q.ServiceTier == "ultrafast", Ultrafast: q.ServiceTier == "ultrafast", Tier: q.ServiceTier, CacheKey: q.PromptCacheKey, Include: q.Include, ClientMetadata: sentRaw(q.ClientMetadata)} r.Text, r.Format = textFormat(sentRaw(q.Text)) if q.Reasoning != nil { @@ -851,6 +851,9 @@ func buildResponses(r *Request, model, host string, rejectTemp bool) []byte { if r.Ultrafast && host == "chatgpt.com" { out["service_tier"] = "ultrafast" } + if r.OwnTier && r.Tier != "" { + out["service_tier"] = r.Tier + } if r.Effort != "" { out["reasoning"] = map[string]any{"effort": r.Effort, "summary": "auto"} } else if r.Thinking { diff --git a/internal/provider/codex_models.go b/internal/provider/codex_models.go index 46ceb16a..2275881f 100644 --- a/internal/provider/codex_models.go +++ b/internal/provider/codex_models.go @@ -577,6 +577,9 @@ func codexListed(shown []Entry, members func(id string) []Member, own bool) []ca } m := catalog.Model{ID: e.ID, Name: labels[i], Efforts: e.Efforts, Images: e.Images || seen, Context: e.Context, AgentsV2: e.AgentsV2} m.Compact = compactSet(s, e.ID, find) + // a provider the user added by its address is sent the tier Codex + // asks for as it is (hsiangron on X) + m.OwnTier = e.Group == "" && e.Provider.Preset == "" && e.Provider.Account == nil && e.Provider.ID != "" if e.Group != "" { for _, mb := range members(e.ID) { if a := mb.Provider.Account; a != nil && a.Agent == "codex" && strings.HasPrefix(mb.Model, "gpt-") {