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 { | |||
| 758 | 758 | case "deepseek": | |
| 759 | 759 | return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL)) | |
| 760 | 760 | 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 | + | }) | |
| 765 | 766 | case "openai-codex": | |
| 766 | 767 | inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)) | |
| 767 | 768 | return r.wrapWithRefresh(inner) | |
| @@ -777,12 +778,7 @@ func (r Resolved) NewClient() provider.Client { | |||
| 777 | 778 | case "groq": | |
| 778 | 779 | return wrap(provider.NewGroq(r.Credential, r.BaseURL)) | |
| 779 | 780 | 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) | |
| 786 | 782 | if r.AuthMethod == "oauth" { | |
| 787 | 783 | return r.wrapWithRefresh(inner) | |
| 788 | 784 | } | |
| @@ -854,6 +850,14 @@ func (r Resolved) withHTTPClient(c provider.Client) provider.Client { | |||
| 854 | 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 | 861 | // wrapWithRefresh wraps an OAuth client so the access token is | |
| 858 | 862 | // refreshed automatically before each API call. Without this, long | |
| 859 | 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 | 886 | // anthropic-messages on api.kimi.com/coding. | |
| 883 | 887 | return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())) | |
| 884 | 888 | 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) | |
| 889 | 890 | default: | |
| 890 | 891 | return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL)) | |
| 891 | 892 | } | |
| @@ -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.Client | 781 | + | 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 is | 861 | // wrapWithRefresh wraps an OAuth client so the access token is | ||
| 858 | // refreshed automatically before each API call. Without this, long | 862 | // 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) | |||
| 4654 | 4654 | return | |
| 4655 | 4655 | } | |
| 4656 | 4656 | // 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 | |
| 4659 | 4659 | // auth header / base URL can't carry over. | |
| 4660 | 4660 | if !rescue && i.agent != nil && m.Provider == i.cfg.Provider { | |
| 4661 | 4661 | i.mu.Lock() | |
| @@ -4654,8 +4654,8 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string) | |||||
| 4654 | return | 4654 | return | ||
| 4655 | } | 4655 | } | ||
| 4656 | // Same provider AND not a rescue retry: just swap the model on | 4656 | // Same provider AND not a rescue retry: just swap the model on | ||
| 4657 | - | // the existing agent — no rebuild needed because the underlying | 4657 | + | // the existing agent. Mixed-API providers dispatch from model metadata, |
| 4658 | - | // client is reusable. Rescue retries always rebuild so a stale | 4658 | + | // 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) { | |||
| 87 | 87 | if err != nil { | |
| 88 | 88 | t.Fatal(err) | |
| 89 | 89 | } | |
| 90 | - | if !m.Reasoning || m.MaxOutput != 30000 { | |
| 90 | + | if !m.Reasoning || m.MaxOutput != 30000 || m.API != APIResponses { | |
| 91 | 91 | t.Fatalf("xai/grok-4.5 metadata = %+v", m) | |
| 92 | 92 | } | |
| 93 | 93 | } | |
| @@ -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{ | |||
| 42 | 42 | BaseURL: "https://ai-gateway.vercel.sh"}, | |
| 43 | 43 | ||
| 44 | 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 | 46 | ContextWindow: 2000000, MaxOutput: 30000, Reasoning: true, | |
| 47 | 47 | PriceInput: 1.25, PriceOutput: 2.5, PriceCacheRead: 0.2, | |
| 48 | 48 | 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 { | |||
| 45 | 45 | v.http = httpClient | |
| 46 | 46 | case *renamedClient: | |
| 47 | 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 | 54 | return c | |
| 50 | 55 | } | |
| @@ -45,6 +45,11 @@ func WithHTTPClient(c Client, httpClient *http.Client) Client { | |||||
| 45 | v.http = httpClient | 45 | 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 c | 54 | 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 { | |||
| 10 | 10 | Provider string // "anthropic" | "openai" | |
| 11 | 11 | ID string // API id | |
| 12 | 12 | DisplayName string | |
| 13 | + | API string // wire API override for providers that support multiple protocols | |
| 13 | 14 | ContextWindow int | |
| 14 | 15 | MaxOutput int | |
| 15 | 16 | Reasoning bool // supports reasoning/thinking | |
| @@ -317,19 +318,19 @@ var Catalog = []Model{ | |||
| 317 | 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 | 322 | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 322 | 323 | PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, | |
| 323 | 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 | 328 | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 328 | 329 | PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, | |
| 329 | 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 | 334 | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 334 | 335 | PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125, | |
| 335 | 336 | Speculative: true, | |
| @@ -10,6 +10,7 @@ type Model struct { | |||||
| 10 | Provider string // "anthropic" | "openai" | 10 | Provider string // "anthropic" | "openai" | ||
| 11 | ID string // API id | 11 | ID string // API id | ||
| 12 | DisplayName string | 12 | DisplayName string | ||
| 13 | + | API string // wire API override for providers that support multiple protocols | |||
| 13 | ContextWindow int | 14 | ContextWindow int | ||
| 14 | MaxOutput int | 15 | MaxOutput int | ||
| 15 | Reasoning bool // supports reasoning/thinking | 16 | 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 { | |||
| 159 | 159 | ||
| 160 | 160 | // ---- Request building ---- | |
| 161 | 161 | ||
| 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 | |
| 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 | 176 | if err != nil { | |
| 168 | 177 | return nil, err | |
| 169 | 178 | } | |
| @@ -405,10 +414,7 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||
| 405 | 414 | defer close(out) | |
| 406 | 415 | defer resp.Body.Close() | |
| 407 | 416 | ||
| 408 | - | model, _ := FindModel("openai-codex", req.Model) | |
| 409 | - | if model.ID == "" { | |
| 410 | - | model, _ = FindModel("openai", req.Model) | |
| 411 | - | } | |
| 417 | + | model, _ := c.findModel(req.Model) | |
| 412 | 418 | providerName := c.providerName | |
| 413 | 419 | if providerName == "" { | |
| 414 | 420 | 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, err | 177 | 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.providerName | 418 | 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) { | |||
| 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 | 142 | func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |
| 129 | 143 | named := NewOpenAIResponsesNamed("token", "https://example.test/v1/responses", "openai").(*renamedClient) | |
| 130 | 144 | 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 { | |||
| 55 | 55 | // reporting the supplied provider name. This lets the `openai` provider | |
| 56 | 56 | // route Responses-only preview models without changing the visible provider. | |
| 57 | 57 | func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client { | |
| 58 | - | if baseURL == "" { | |
| 59 | - | baseURL = openaiResponsesDefaultBaseURL | |
| 60 | - | } | |
| 58 | + | baseURL = responsesURL(baseURL) | |
| 61 | 59 | httpClient := &http.Client{ | |
| 62 | 60 | Transport: &openaiResponsesTransport{inner: http.DefaultTransport}, | |
| 63 | 61 | Timeout: 0, | |
| @@ -72,3 +70,20 @@ func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client { | |||
| 72 | 70 | } | |
| 73 | 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 | + | } | |
| @@ -55,9 +55,7 @@ func NewOpenAIResponses(apiKey, baseURL string) Client { | |||||
| 55 | // reporting the supplied provider name. This lets the `openai` provider | 55 | // 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 | + | } | |||