From 68aab298d39f458562ef09a7db2f6512ceb545ca Mon Sep 17 00:00:00 2001 From: yetone Date: Fri, 9 Oct 2026 14:16:50 +0800 Subject: [PATCH] gateway, codex: a provider the user added by its address is sent the service_tier Codex asks for, and offered Fast and Ultrafast (hsiangron on X) Codex's Fast (service_tier=priority) and Ultrafast (ultrafast) were dropped on the way to a custom provider: the gateway only passed a tier on to chatgpt.com and Cursor, and magpie's models.json entries for such a provider's models offered no tiers, so the user's own picks were written over. - Request.Tier keeps the client's service_tier as sent; for a provider with no preset and no account (OwnTier) both buildResponses and buildChat send it as it is. One that refuses the field by name is asked again without it and remembered as unfit for it, as the other optional fields are. - codexcat offers such a provider's model the tiers Codex's own entry gives the same slug (dated snapshot and provider prefix stripped), Fast on another GPT model, none otherwise. Presets and groups are unchanged. Run: TestOwnProviderGetsTier (Responses/Chat x priority/ultrafast/flex), TestPresetProviderGetsNoTier, TestOwnProviderRefusingTier, TestOwnProviderTiers; the gateway ones fail without the gateway change and TestOwnProviderTiers fails without the codexcat change. go test gateway, codexcat, catalog, provider; go vet darwin/linux/windows; gofmt -l clean. Not tried against the reporter's relay. --- internal/catalog/catalog.go | 5 ++ internal/codexcat/codexcat.go | 37 ++++++++++++ internal/codexcat/own_tier_test.go | 49 ++++++++++++++++ internal/gateway/chat.go | 5 +- internal/gateway/gateway.go | 21 ++++++- internal/gateway/ir.go | 9 +++ internal/gateway/own_tier_test.go | 91 ++++++++++++++++++++++++++++++ internal/gateway/responses.go | 5 +- internal/provider/codex_models.go | 3 + 9 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 internal/codexcat/own_tier_test.go create mode 100644 internal/gateway/own_tier_test.go 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-") {