detached 6a0d7ca

Route mixed-API models from catalog metadata

commit
6a0d7caf59bb385a1bb5e6f7f8c3c0dbe81e7898
author
patriceckhart · · 2026-07-17 16:30 UTC
parent
eddf12c
signature
unsigned
refs
tag: v0.2.90
11 files · +166 -32
M packages/agent/build.go +15 -14
@@ -758,10 +758,11 @@ func (r Resolved) NewClient() provider.Client {
758758 case "deepseek":
759759 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
760760 case "openai":
761- if strings.HasPrefix(r.Model, "gpt-5.6-") {
762- return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai"))
763- }
764- return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
761+ return provider.NewModelRouter("openai",
762+ wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
763+ map[string]provider.Client{
764+ provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
765+ })
765766 case "openai-codex":
766767 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
767768 return r.wrapWithRefresh(inner)
@@ -777,12 +778,7 @@ func (r Resolved) NewClient() provider.Client {
777778 case "groq":
778779 return wrap(provider.NewGroq(r.Credential, r.BaseURL))
779780 case "xai":
780- var inner provider.Client
781- if r.Model == "grok-4.5" {
782- inner = wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "xai"))
783- } else {
784- inner = wrap(provider.NewXAI(r.Credential, r.BaseURL))
785- }
781+ inner := r.newXAIClient(r.Credential)
786782 if r.AuthMethod == "oauth" {
787783 return r.wrapWithRefresh(inner)
788784 }
@@ -854,6 +850,14 @@ func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
854850 return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
855851 }
856852
853+func (r Resolved) newXAIClient(token string) provider.Client {
854+ return provider.NewModelRouter("xai",
855+ r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
856+ map[string]provider.Client{
857+ provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
858+ })
859+}
860+
857861 // wrapWithRefresh wraps an OAuth client so the access token is
858862 // refreshed automatically before each API call. Without this, long
859863 // sessions (hours) silently fail when the 1-hour token expires.
@@ -882,10 +886,7 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
882886 // anthropic-messages on api.kimi.com/coding.
883887 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
884888 case "xai":
885- if r.Model == "grok-4.5" {
886- return r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, baseURL, "xai"))
887- }
888- return r.withHTTPClient(provider.NewXAI(token, baseURL))
889+ return r.newXAIClient(token)
889890 default:
890891 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
891892 }
@@ -758,10 +758,11 @@ func (r Resolved) NewClient() provider.Client {
758 case "deepseek":758 case "deepseek":
759 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))759 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
760 case "openai":760 case "openai":
761- if strings.HasPrefix(r.Model, "gpt-5.6-") {761+ return provider.NewModelRouter("openai",
762- return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai"))762+ wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
763- }763+ map[string]provider.Client{
764- return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))764+ provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
765+ })
765 case "openai-codex":766 case "openai-codex":
766 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))767 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
767 return r.wrapWithRefresh(inner)768 return r.wrapWithRefresh(inner)
@@ -777,12 +778,7 @@ func (r Resolved) NewClient() provider.Client {
777 case "groq":778 case "groq":
778 return wrap(provider.NewGroq(r.Credential, r.BaseURL))779 return wrap(provider.NewGroq(r.Credential, r.BaseURL))
779 case "xai":780 case "xai":
780- var inner provider.Client781+ inner := r.newXAIClient(r.Credential)
781- if r.Model == "grok-4.5" {
782- inner = wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "xai"))
783- } else {
784- inner = wrap(provider.NewXAI(r.Credential, r.BaseURL))
785- }
786 if r.AuthMethod == "oauth" {782 if r.AuthMethod == "oauth" {
787 return r.wrapWithRefresh(inner)783 return r.wrapWithRefresh(inner)
788 }784 }
@@ -854,6 +850,14 @@ func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
854 return provider.WithHTTPClient(c, provider.NewHTTPClient(true))850 return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
855 }851 }
856 852
853+func (r Resolved) newXAIClient(token string) provider.Client {
854+ return provider.NewModelRouter("xai",
855+ r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
856+ map[string]provider.Client{
857+ provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
858+ })
859+}
860+
857 // wrapWithRefresh wraps an OAuth client so the access token is861 // wrapWithRefresh wraps an OAuth client so the access token is
858 // refreshed automatically before each API call. Without this, long862 // refreshed automatically before each API call. Without this, long
859 // sessions (hours) silently fail when the 1-hour token expires.863 // sessions (hours) silently fail when the 1-hour token expires.
@@ -882,10 +886,7 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
882 // anthropic-messages on api.kimi.com/coding.886 // anthropic-messages on api.kimi.com/coding.
883 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))887 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
884 case "xai":888 case "xai":
885- if r.Model == "grok-4.5" {889+ return r.newXAIClient(token)
886- return r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, baseURL, "xai"))
887- }
888- return r.withHTTPClient(provider.NewXAI(token, baseURL))
889 default:890 default:
890 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))891 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
891 }892 }
M packages/agent/modes/interactive.go +2 -2
@@ -4654,8 +4654,8 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string)
46544654 return
46554655 }
46564656 // Same provider AND not a rescue retry: just swap the model on
4657- // the existing agent — no rebuild needed because the underlying
4658- // client is reusable. Rescue retries always rebuild so a stale
4657+ // the existing agent. Mixed-API providers dispatch from model metadata,
4658+ // so the client remains reusable. Rescue retries always rebuild so a stale
46594659 // auth header / base URL can't carry over.
46604660 if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
46614661 i.mu.Lock()
@@ -4654,8 +4654,8 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string)
4654 return4654 return
4655 }4655 }
4656 // Same provider AND not a rescue retry: just swap the model on4656 // Same provider AND not a rescue retry: just swap the model on
4657- // the existing agent — no rebuild needed because the underlying4657+ // the existing agent. Mixed-API providers dispatch from model metadata,
4658- // client is reusable. Rescue retries always rebuild so a stale4658+ // so the client remains reusable. Rescue retries always rebuild so a stale
4659 // auth header / base URL can't carry over.4659 // auth header / base URL can't carry over.
4660 if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {4660 if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
4661 i.mu.Lock()4661 i.mu.Lock()
M packages/provider/deferred_tools_test.go +1 -1
@@ -87,7 +87,7 @@ func TestKimiK3AndGrok45CatalogMetadata(t *testing.T) {
8787 if err != nil {
8888 t.Fatal(err)
8989 }
90- if !m.Reasoning || m.MaxOutput != 30000 {
90+ if !m.Reasoning || m.MaxOutput != 30000 || m.API != APIResponses {
9191 t.Fatalf("xai/grok-4.5 metadata = %+v", m)
9292 }
9393 }
@@ -87,7 +87,7 @@ func TestKimiK3AndGrok45CatalogMetadata(t *testing.T) {
87 if err != nil {87 if err != nil {
88 t.Fatal(err)88 t.Fatal(err)
89 }89 }
90- if !m.Reasoning || m.MaxOutput != 30000 {90+ if !m.Reasoning || m.MaxOutput != 30000 || m.API != APIResponses {
91 t.Fatalf("xai/grok-4.5 metadata = %+v", m)91 t.Fatalf("xai/grok-4.5 metadata = %+v", m)
92 }92 }
93 }93 }
M packages/provider/extra_models.go +1 -1
@@ -42,7 +42,7 @@ var supplementCatalog = []Model{
4242 BaseURL: "https://ai-gateway.vercel.sh"},
4343
4444 // ----- xAI Responses API -----
45- {Provider: "xai", ID: "grok-4.5", DisplayName: "Grok 4.5",
45+ {Provider: "xai", ID: "grok-4.5", DisplayName: "Grok 4.5", API: APIResponses,
4646 ContextWindow: 2000000, MaxOutput: 30000, Reasoning: true,
4747 PriceInput: 1.25, PriceOutput: 2.5, PriceCacheRead: 0.2,
4848 BaseURL: "https://api.x.ai/v1"},
@@ -42,7 +42,7 @@ var supplementCatalog = []Model{
42 BaseURL: "https://ai-gateway.vercel.sh"},42 BaseURL: "https://ai-gateway.vercel.sh"},
43 43
44 // ----- xAI Responses API -----44 // ----- xAI Responses API -----
45- {Provider: "xai", ID: "grok-4.5", DisplayName: "Grok 4.5",45+ {Provider: "xai", ID: "grok-4.5", DisplayName: "Grok 4.5", API: APIResponses,
46 ContextWindow: 2000000, MaxOutput: 30000, Reasoning: true,46 ContextWindow: 2000000, MaxOutput: 30000, Reasoning: true,
47 PriceInput: 1.25, PriceOutput: 2.5, PriceCacheRead: 0.2,47 PriceInput: 1.25, PriceOutput: 2.5, PriceCacheRead: 0.2,
48 BaseURL: "https://api.x.ai/v1"},48 BaseURL: "https://api.x.ai/v1"},
M packages/provider/httpclient.go +5 -0
@@ -45,6 +45,11 @@ func WithHTTPClient(c Client, httpClient *http.Client) Client {
4545 v.http = httpClient
4646 case *renamedClient:
4747 v.inner = WithHTTPClient(v.inner, httpClient)
48+ case *modelRouter:
49+ v.fallback = WithHTTPClient(v.fallback, httpClient)
50+ for api, inner := range v.byAPI {
51+ v.byAPI[api] = WithHTTPClient(inner, httpClient)
52+ }
4853 }
4954 return c
5055 }
@@ -45,6 +45,11 @@ func WithHTTPClient(c Client, httpClient *http.Client) Client {
45 v.http = httpClient45 v.http = httpClient
46 case *renamedClient:46 case *renamedClient:
47 v.inner = WithHTTPClient(v.inner, httpClient)47 v.inner = WithHTTPClient(v.inner, httpClient)
48+ case *modelRouter:
49+ v.fallback = WithHTTPClient(v.fallback, httpClient)
50+ for api, inner := range v.byAPI {
51+ v.byAPI[api] = WithHTTPClient(inner, httpClient)
52+ }
48 }53 }
49 return c54 return c
50 }55 }
A packages/provider/model_router.go +42 -0
@@ -0,0 +1,42 @@
1+package provider
2+
3+import (
4+ "context"
5+ "fmt"
6+)
7+
8+const (
9+ // APICompletions identifies the OpenAI Chat Completions wire API.
10+ APICompletions = "openai-completions"
11+ // APIResponses identifies the OpenAI Responses wire API.
12+ APIResponses = "openai-responses"
13+)
14+
15+// modelRouter dispatches each request according to the selected model's API.
16+// This lets one provider expose models that use different wire protocols while
17+// remaining reusable across model switches.
18+type modelRouter struct {
19+ name string
20+ fallback Client
21+ byAPI map[string]Client
22+}
23+
24+// NewModelRouter creates a client that dispatches requests using Model.API.
25+// Models with no API override, and models absent from the catalog, use fallback.
26+func NewModelRouter(name string, fallback Client, byAPI map[string]Client) Client {
27+ return &modelRouter{name: name, fallback: fallback, byAPI: byAPI}
28+}
29+
30+func (c *modelRouter) Name() string { return c.name }
31+
32+func (c *modelRouter) Stream(ctx context.Context, req Request) (<-chan Event, error) {
33+ client := c.fallback
34+ if model, err := FindModel(c.name, req.Model); err == nil && model.API != "" {
35+ routed := c.byAPI[model.API]
36+ if routed == nil {
37+ return nil, fmt.Errorf("provider %q has no client for model API %q", c.name, model.API)
38+ }
39+ client = routed
40+ }
41+ return client.Stream(ctx, req)
42+}
@@ -0,0 +1,42 @@
1+package provider
2+
3+import (
4+ "context"
5+ "fmt"
6+)
7+
8+const (
9+ // APICompletions identifies the OpenAI Chat Completions wire API.
10+ APICompletions = "openai-completions"
11+ // APIResponses identifies the OpenAI Responses wire API.
12+ APIResponses = "openai-responses"
13+)
14+
15+// modelRouter dispatches each request according to the selected model's API.
16+// This lets one provider expose models that use different wire protocols while
17+// remaining reusable across model switches.
18+type modelRouter struct {
19+ name string
20+ fallback Client
21+ byAPI map[string]Client
22+}
23+
24+// NewModelRouter creates a client that dispatches requests using Model.API.
25+// Models with no API override, and models absent from the catalog, use fallback.
26+func NewModelRouter(name string, fallback Client, byAPI map[string]Client) Client {
27+ return &modelRouter{name: name, fallback: fallback, byAPI: byAPI}
28+}
29+
30+func (c *modelRouter) Name() string { return c.name }
31+
32+func (c *modelRouter) Stream(ctx context.Context, req Request) (<-chan Event, error) {
33+ client := c.fallback
34+ if model, err := FindModel(c.name, req.Model); err == nil && model.API != "" {
35+ routed := c.byAPI[model.API]
36+ if routed == nil {
37+ return nil, fmt.Errorf("provider %q has no client for model API %q", c.name, model.API)
38+ }
39+ client = routed
40+ }
41+ return client.Stream(ctx, req)
42+}
A packages/provider/model_router_test.go +50 -0
@@ -0,0 +1,50 @@
1+package provider
2+
3+import (
4+ "context"
5+ "testing"
6+)
7+
8+type routeCaptureClient struct {
9+ name string
10+ models []string
11+}
12+
13+func (c *routeCaptureClient) Name() string { return c.name }
14+func (c *routeCaptureClient) Stream(_ context.Context, req Request) (<-chan Event, error) {
15+ c.models = append(c.models, req.Model)
16+ out := make(chan Event)
17+ close(out)
18+ return out, nil
19+}
20+
21+func TestModelRouterDispatchesByModelAPI(t *testing.T) {
22+ completions := &routeCaptureClient{name: "xai"}
23+ responses := &routeCaptureClient{name: "xai"}
24+ router := NewModelRouter("xai", completions, map[string]Client{
25+ APIResponses: responses,
26+ })
27+
28+ for _, model := range []string{"grok-4.5", "grok-build-0.1"} {
29+ stream, err := router.Stream(context.Background(), Request{Model: model})
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ for range stream {
34+ }
35+ }
36+
37+ if len(responses.models) != 1 || responses.models[0] != "grok-4.5" {
38+ t.Fatalf("Responses models = %v", responses.models)
39+ }
40+ if len(completions.models) != 1 || completions.models[0] != "grok-build-0.1" {
41+ t.Fatalf("Completions models = %v", completions.models)
42+ }
43+}
44+
45+func TestModelRouterRejectsMissingAPIClient(t *testing.T) {
46+ router := NewModelRouter("xai", &routeCaptureClient{name: "xai"}, nil)
47+ if _, err := router.Stream(context.Background(), Request{Model: "grok-4.5"}); err == nil {
48+ t.Fatal("expected missing API client error")
49+ }
50+}
@@ -0,0 +1,50 @@
1+package provider
2+
3+import (
4+ "context"
5+ "testing"
6+)
7+
8+type routeCaptureClient struct {
9+ name string
10+ models []string
11+}
12+
13+func (c *routeCaptureClient) Name() string { return c.name }
14+func (c *routeCaptureClient) Stream(_ context.Context, req Request) (<-chan Event, error) {
15+ c.models = append(c.models, req.Model)
16+ out := make(chan Event)
17+ close(out)
18+ return out, nil
19+}
20+
21+func TestModelRouterDispatchesByModelAPI(t *testing.T) {
22+ completions := &routeCaptureClient{name: "xai"}
23+ responses := &routeCaptureClient{name: "xai"}
24+ router := NewModelRouter("xai", completions, map[string]Client{
25+ APIResponses: responses,
26+ })
27+
28+ for _, model := range []string{"grok-4.5", "grok-build-0.1"} {
29+ stream, err := router.Stream(context.Background(), Request{Model: model})
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ for range stream {
34+ }
35+ }
36+
37+ if len(responses.models) != 1 || responses.models[0] != "grok-4.5" {
38+ t.Fatalf("Responses models = %v", responses.models)
39+ }
40+ if len(completions.models) != 1 || completions.models[0] != "grok-build-0.1" {
41+ t.Fatalf("Completions models = %v", completions.models)
42+ }
43+}
44+
45+func TestModelRouterRejectsMissingAPIClient(t *testing.T) {
46+ router := NewModelRouter("xai", &routeCaptureClient{name: "xai"}, nil)
47+ if _, err := router.Stream(context.Background(), Request{Model: "grok-4.5"}); err == nil {
48+ t.Fatal("expected missing API client error")
49+ }
50+}
M packages/provider/models.go +4 -3
@@ -10,6 +10,7 @@ type Model struct {
1010 Provider string // "anthropic" | "openai"
1111 ID string // API id
1212 DisplayName string
13+ API string // wire API override for providers that support multiple protocols
1314 ContextWindow int
1415 MaxOutput int
1516 Reasoning bool // supports reasoning/thinking
@@ -317,19 +318,19 @@ var Catalog = []Model{
317318 Speculative: true,
318319 },
319320 {
320- Provider: "openai", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
321+ Provider: "openai", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses,
321322 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
322323 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
323324 Speculative: true,
324325 },
325326 {
326- Provider: "openai", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
327+ Provider: "openai", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", API: APIResponses,
327328 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
328329 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
329330 Speculative: true,
330331 },
331332 {
332- Provider: "openai", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
333+ Provider: "openai", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", API: APIResponses,
333334 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
334335 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
335336 Speculative: true,
@@ -10,6 +10,7 @@ type Model struct {
10 Provider string // "anthropic" | "openai"10 Provider string // "anthropic" | "openai"
11 ID string // API id11 ID string // API id
12 DisplayName string12 DisplayName string
13+ API string // wire API override for providers that support multiple protocols
13 ContextWindow int14 ContextWindow int
14 MaxOutput int15 MaxOutput int
15 Reasoning bool // supports reasoning/thinking16 Reasoning bool // supports reasoning/thinking
@@ -317,19 +318,19 @@ var Catalog = []Model{
317 Speculative: true,318 Speculative: true,
318 },319 },
319 {320 {
320- Provider: "openai", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",321+ Provider: "openai", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses,
321 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,322 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
322 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,323 PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
323 Speculative: true,324 Speculative: true,
324 },325 },
325 {326 {
326- Provider: "openai", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",327+ Provider: "openai", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", API: APIResponses,
327 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,328 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
328 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,329 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
329 Speculative: true,330 Speculative: true,
330 },331 },
331 {332 {
332- Provider: "openai", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",333+ Provider: "openai", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", API: APIResponses,
333 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,334 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
334 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,335 PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
335 Speculative: true,336 Speculative: true,
M packages/provider/openai_codex.go +14 -8
@@ -159,11 +159,20 @@ type codexRequest struct {
159159
160160 // ---- Request building ----
161161
162-func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
163- m, err := FindModel("openai-codex", req.Model)
164- if err != nil {
165- m, err = FindModel("openai", req.Model)
162+func (c *codexClient) findModel(id string) (Model, error) {
163+ if c.providerName != "" && c.providerName != "openai-codex" {
164+ if m, err := FindModel(c.providerName, id); err == nil {
165+ return m, nil
166+ }
167+ }
168+ if m, err := FindModel("openai-codex", id); err == nil {
169+ return m, nil
166170 }
171+ return FindModel("openai", id)
172+}
173+
174+func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
175+ m, err := c.findModel(req.Model)
167176 if err != nil {
168177 return nil, err
169178 }
@@ -405,10 +414,7 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
405414 defer close(out)
406415 defer resp.Body.Close()
407416
408- model, _ := FindModel("openai-codex", req.Model)
409- if model.ID == "" {
410- model, _ = FindModel("openai", req.Model)
411- }
417+ model, _ := c.findModel(req.Model)
412418 providerName := c.providerName
413419 if providerName == "" {
414420 providerName = "openai-codex"
@@ -159,11 +159,20 @@ type codexRequest struct {
159 159
160 // ---- Request building ----160 // ---- Request building ----
161 161
162-func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {162+func (c *codexClient) findModel(id string) (Model, error) {
163- m, err := FindModel("openai-codex", req.Model)163+ if c.providerName != "" && c.providerName != "openai-codex" {
164- if err != nil {164+ if m, err := FindModel(c.providerName, id); err == nil {
165- m, err = FindModel("openai", req.Model)165+ return m, nil
166+ }
167+ }
168+ if m, err := FindModel("openai-codex", id); err == nil {
169+ return m, nil
166 }170 }
171+ return FindModel("openai", id)
172+}
173+
174+func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
175+ m, err := c.findModel(req.Model)
167 if err != nil {176 if err != nil {
168 return nil, err177 return nil, err
169 }178 }
@@ -405,10 +414,7 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
405 defer close(out)414 defer close(out)
406 defer resp.Body.Close()415 defer resp.Body.Close()
407 416
408- model, _ := FindModel("openai-codex", req.Model)417+ model, _ := c.findModel(req.Model)
409- if model.ID == "" {
410- model, _ = FindModel("openai", req.Model)
411- }
412 providerName := c.providerName418 providerName := c.providerName
413 if providerName == "" {419 if providerName == "" {
414 providerName = "openai-codex"420 providerName = "openai-codex"
M packages/provider/openai_codex_test.go +14 -0
@@ -125,6 +125,20 @@ func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
125125 }
126126 }
127127
128+func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) {
129+ named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient)
130+ c := named.inner.(*codexClient)
131+
132+ if c.baseURL != "https://api.x.ai/v1/responses" {
133+ t.Fatalf("base URL = %q", c.baseURL)
134+ }
135+ for _, model := range []string{"grok-4.5", "grok-build-0.1"} {
136+ if _, err := c.buildRequest(Request{Model: model}); err != nil {
137+ t.Errorf("build request for %s: %v", model, err)
138+ }
139+ }
140+}
141+
128142 func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
129143 named := NewOpenAIResponsesNamed("token", "https://example.test/v1/responses", "openai").(*renamedClient)
130144 c := named.inner.(*codexClient)
@@ -125,6 +125,20 @@ func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
125 }125 }
126 }126 }
127 127
128+func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) {
129+ named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient)
130+ c := named.inner.(*codexClient)
131+
132+ if c.baseURL != "https://api.x.ai/v1/responses" {
133+ t.Fatalf("base URL = %q", c.baseURL)
134+ }
135+ for _, model := range []string{"grok-4.5", "grok-build-0.1"} {
136+ if _, err := c.buildRequest(Request{Model: model}); err != nil {
137+ t.Errorf("build request for %s: %v", model, err)
138+ }
139+ }
140+}
141+
128 func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {142 func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
129 named := NewOpenAIResponsesNamed("token", "https://example.test/v1/responses", "openai").(*renamedClient)143 named := NewOpenAIResponsesNamed("token", "https://example.test/v1/responses", "openai").(*renamedClient)
130 c := named.inner.(*codexClient)144 c := named.inner.(*codexClient)
M packages/provider/openai_responses.go +18 -3
@@ -55,9 +55,7 @@ func NewOpenAIResponses(apiKey, baseURL string) Client {
5555 // reporting the supplied provider name. This lets the `openai` provider
5656 // route Responses-only preview models without changing the visible provider.
5757 func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client {
58- if baseURL == "" {
59- baseURL = openaiResponsesDefaultBaseURL
60- }
58+ baseURL = responsesURL(baseURL)
6159 httpClient := &http.Client{
6260 Transport: &openaiResponsesTransport{inner: http.DefaultTransport},
6361 Timeout: 0,
@@ -72,3 +70,20 @@ func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client {
7270 }
7371 return &renamedClient{inner: inner, name: name}
7472 }
73+
74+// responsesURL accepts either an API root or a complete Responses endpoint.
75+// Catalog model entries generally carry API roots, while explicit overrides
76+// may already include /responses.
77+func responsesURL(baseURL string) string {
78+ baseURL = strings.TrimRight(baseURL, "/")
79+ if baseURL == "" {
80+ return openaiResponsesDefaultBaseURL
81+ }
82+ if strings.HasSuffix(baseURL, "/responses") {
83+ return baseURL
84+ }
85+ if versionSegmentSuffix.MatchString(baseURL) {
86+ return baseURL + "/responses"
87+ }
88+ return baseURL + "/v1/responses"
89+}
@@ -55,9 +55,7 @@ func NewOpenAIResponses(apiKey, baseURL string) Client {
55 // reporting the supplied provider name. This lets the `openai` provider55 // reporting the supplied provider name. This lets the `openai` provider
56 // route Responses-only preview models without changing the visible provider.56 // route Responses-only preview models without changing the visible provider.
57 func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client {57 func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client {
58- if baseURL == "" {58+ baseURL = responsesURL(baseURL)
59- baseURL = openaiResponsesDefaultBaseURL
60- }
61 httpClient := &http.Client{59 httpClient := &http.Client{
62 Transport: &openaiResponsesTransport{inner: http.DefaultTransport},60 Transport: &openaiResponsesTransport{inner: http.DefaultTransport},
63 Timeout: 0,61 Timeout: 0,
@@ -72,3 +70,20 @@ func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client {
72 }70 }
73 return &renamedClient{inner: inner, name: name}71 return &renamedClient{inner: inner, name: name}
74 }72 }
73+
74+// responsesURL accepts either an API root or a complete Responses endpoint.
75+// Catalog model entries generally carry API roots, while explicit overrides
76+// may already include /responses.
77+func responsesURL(baseURL string) string {
78+ baseURL = strings.TrimRight(baseURL, "/")
79+ if baseURL == "" {
80+ return openaiResponsesDefaultBaseURL
81+ }
82+ if strings.HasSuffix(baseURL, "/responses") {
83+ return baseURL
84+ }
85+ if versionSegmentSuffix.MatchString(baseURL) {
86+ return baseURL + "/responses"
87+ }
88+ return baseURL + "/v1/responses"
89+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close