detached 6abb410

fix(provider): correct Copilot routing and model availability

commit
6abb410abab34496f437d1032b89ce533772833a
author
patriceckhart · · 2026-09-08 15:41 UTC
parent
a98ca1a
signature
unsigned
refs
tag: v0.3.69
18 files · +733 -51
M docs/providers.md +49 -0
@@ -90,6 +90,55 @@ These providers support subscription login:
9090 OAuth tokens are stored in `$ZOT_HOME/auth.json` and refreshed when refresh is
9191 available.
9292
93+## GitHub Copilot model availability
94+
95+The built-in Copilot catalog excludes retired models and utility-only models
96+that are not selectable for chat. The default is `claude-sonnet-5`.
97+Availability still depends on your plan and organization policies. At startup,
98+zot queries Copilot's authenticated `/models` endpoint in the background and
99+filters the runtime catalog using picker visibility, policy state, and tool-call
100+support. Individual accounts with no eligible picker entries can fall back to
101+explicitly enabled model policies; other account types cannot. zot never enables
102+policies automatically.
103+
104+Account availability stays in memory, not the shared model cache. Until discovery
105+succeeds, the static catalog remains visible; failed refreshes retain the previous
106+snapshot. Command-backed credentials are not executed for background discovery.
107+Restart zot after changing accounts to refresh the snapshot. This filter is a
108+selection aid, not an authorization boundary. The website displays the public
109+catalog, not any individual account's availability.
110+
111+Claude uses the Anthropic Messages API with Copilot Bearer authentication, without
112+Claude Code identity injection. GPT, Grok, and MAI use Responses. Gemini and Kimi
113+use Chat Completions without `reasoning_effort`; their reasoning is managed by the
114+endpoint. All three routes send user/agent initiator metadata and image-request
115+headers where appropriate.
116+
117+As of September 8, 2026, the catalog includes 27 public chat models: Claude
118+Fable 5/5.1, Haiku 4.5, Opus 4.7/4.8/4.8 Fast/5, Sonnet 5, Gemini
119+3.5/3.6/3.7/3.8 Flash, GPT-5 mini, GPT-5.3-Codex, GPT-5.4/5.4 mini/5.5,
120+GPT-5.6 Luna/Sol/Terra, GPT-6 Astra, Grok 4.5/4.6, Kimi K2.7 Code/K3,
121+and MAI-Code-1-Flash/1.1-Flash. MAI-Code-1-Flash uses the public picker ID
122+`mai-code-1-flash-picker` and is scheduled to retire on September 10, 2026.
123+
124+Internal search, execution, and compaction models are not chat choices.
125+GPT-5.4 nano is excluded because GitHub offers it in the Codex VS Code
126+extension, not Copilot Chat. New entries use Copilot-specific limits and token
127+prices from [models.dev](https://models.dev); these are not a quote for your
128+subscription bill. Existing zero-priced entries do not imply unlimited free use.
129+
130+Claude Fable models have different data-retention terms: Anthropic retains
131+prompts and outputs by default for safety classifiers. Eligible enterprises
132+can request a time-limited zero-data-retention exception from GitHub; enabling
133+a model does not itself grant that exception. Review
134+[GitHub's model availability and data-retention notes](https://docs.github.com/en/copilot/reference/ai-models/supported-models)
135+before using Fable with sensitive data.
136+
137+Sonnet 4.6 is also excluded from the general catalog: GitHub retired it on
138+September 1, 2026, except for individual annual Copilot Pro and Pro+ subscribers.
139+Eligible subscribers can add it through `$ZOT_HOME/models.json`.
140+See [GitHub's model retirement history and plan exceptions](https://docs.github.com/en/copilot/reference/ai-models/supported-models#model-retirement-history).
141+
93142 ## GPT-6 Astra
94143
95144 Select `gpt-6-astra` with provider `openai`, `openai-responses`,
@@ -90,6 +90,55 @@ These providers support subscription login:
90 OAuth tokens are stored in `$ZOT_HOME/auth.json` and refreshed when refresh is90 OAuth tokens are stored in `$ZOT_HOME/auth.json` and refreshed when refresh is
91 available.91 available.
92 92
93+## GitHub Copilot model availability
94+
95+The built-in Copilot catalog excludes retired models and utility-only models
96+that are not selectable for chat. The default is `claude-sonnet-5`.
97+Availability still depends on your plan and organization policies. At startup,
98+zot queries Copilot's authenticated `/models` endpoint in the background and
99+filters the runtime catalog using picker visibility, policy state, and tool-call
100+support. Individual accounts with no eligible picker entries can fall back to
101+explicitly enabled model policies; other account types cannot. zot never enables
102+policies automatically.
103+
104+Account availability stays in memory, not the shared model cache. Until discovery
105+succeeds, the static catalog remains visible; failed refreshes retain the previous
106+snapshot. Command-backed credentials are not executed for background discovery.
107+Restart zot after changing accounts to refresh the snapshot. This filter is a
108+selection aid, not an authorization boundary. The website displays the public
109+catalog, not any individual account's availability.
110+
111+Claude uses the Anthropic Messages API with Copilot Bearer authentication, without
112+Claude Code identity injection. GPT, Grok, and MAI use Responses. Gemini and Kimi
113+use Chat Completions without `reasoning_effort`; their reasoning is managed by the
114+endpoint. All three routes send user/agent initiator metadata and image-request
115+headers where appropriate.
116+
117+As of September 8, 2026, the catalog includes 27 public chat models: Claude
118+Fable 5/5.1, Haiku 4.5, Opus 4.7/4.8/4.8 Fast/5, Sonnet 5, Gemini
119+3.5/3.6/3.7/3.8 Flash, GPT-5 mini, GPT-5.3-Codex, GPT-5.4/5.4 mini/5.5,
120+GPT-5.6 Luna/Sol/Terra, GPT-6 Astra, Grok 4.5/4.6, Kimi K2.7 Code/K3,
121+and MAI-Code-1-Flash/1.1-Flash. MAI-Code-1-Flash uses the public picker ID
122+`mai-code-1-flash-picker` and is scheduled to retire on September 10, 2026.
123+
124+Internal search, execution, and compaction models are not chat choices.
125+GPT-5.4 nano is excluded because GitHub offers it in the Codex VS Code
126+extension, not Copilot Chat. New entries use Copilot-specific limits and token
127+prices from [models.dev](https://models.dev); these are not a quote for your
128+subscription bill. Existing zero-priced entries do not imply unlimited free use.
129+
130+Claude Fable models have different data-retention terms: Anthropic retains
131+prompts and outputs by default for safety classifiers. Eligible enterprises
132+can request a time-limited zero-data-retention exception from GitHub; enabling
133+a model does not itself grant that exception. Review
134+[GitHub's model availability and data-retention notes](https://docs.github.com/en/copilot/reference/ai-models/supported-models)
135+before using Fable with sensitive data.
136+
137+Sonnet 4.6 is also excluded from the general catalog: GitHub retired it on
138+September 1, 2026, except for individual annual Copilot Pro and Pro+ subscribers.
139+Eligible subscribers can add it through `$ZOT_HOME/models.json`.
140+See [GitHub's model retirement history and plan exceptions](https://docs.github.com/en/copilot/reference/ai-models/supported-models#model-retirement-history).
141+
93 ## GPT-6 Astra142 ## GPT-6 Astra
94 143
95 Select `gpt-6-astra` with provider `openai`, `openai-responses`,144 Select `gpt-6-astra` with provider `openai`, `openai-responses`,
M packages/agent/build.go +1 -1
@@ -215,7 +215,7 @@ func defaultModelForProvider(prov string) string {
215215 case "azure-openai-responses":
216216 return "gpt-5"
217217 case "github-copilot":
218- return "claude-sonnet-4.5"
218+ return "claude-sonnet-5"
219219 default:
220220 // Custom providers: pick the first model from the catalog for
221221 // that provider, or fall back to the global default.
@@ -215,7 +215,7 @@ func defaultModelForProvider(prov string) string {
215 case "azure-openai-responses":215 case "azure-openai-responses":
216 return "gpt-5"216 return "gpt-5"
217 case "github-copilot":217 case "github-copilot":
218- return "claude-sonnet-4.5"218+ return "claude-sonnet-5"
219 default:219 default:
220 // Custom providers: pick the first model from the catalog for220 // Custom providers: pick the first model from the catalog for
221 // that provider, or fall back to the global default.221 // that provider, or fall back to the global default.
A packages/agent/copilot_availability_test.go +19 -0
@@ -0,0 +1,19 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/provider"
7+)
8+
9+func TestRefreshCopilotAvailabilityWithoutCredentials(t *testing.T) {
10+ t.Setenv("ZOT_HOME", t.TempDir())
11+ t.Setenv("COPILOT_GITHUB_TOKEN", "")
12+ t.Setenv("GITHUB_COPILOT_TOKEN", "")
13+ provider.SetModelAvailability("github-copilot", []string{})
14+ t.Cleanup(func() { provider.SetModelAvailability("github-copilot", nil) })
15+ refreshCopilotModelAvailability()
16+ if len(provider.ModelsForProvider("github-copilot")) == 0 {
17+ t.Fatal("account restriction was retained after credentials disappeared")
18+ }
19+}
@@ -0,0 +1,19 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/provider"
7+)
8+
9+func TestRefreshCopilotAvailabilityWithoutCredentials(t *testing.T) {
10+ t.Setenv("ZOT_HOME", t.TempDir())
11+ t.Setenv("COPILOT_GITHUB_TOKEN", "")
12+ t.Setenv("GITHUB_COPILOT_TOKEN", "")
13+ provider.SetModelAvailability("github-copilot", []string{})
14+ t.Cleanup(func() { provider.SetModelAvailability("github-copilot", nil) })
15+ refreshCopilotModelAvailability()
16+ if len(provider.ModelsForProvider("github-copilot")) == 0 {
17+ t.Fatal("account restriction was retained after credentials disappeared")
18+ }
19+}
A packages/agent/copilot_default_test.go +17 -0
@@ -0,0 +1,17 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/provider"
7+)
8+
9+func TestCopilotDefaultUsesSonnet5(t *testing.T) {
10+ id := defaultModelForProvider("github-copilot")
11+ if id != "claude-sonnet-5" {
12+ t.Fatalf("Copilot default = %q, want claude-sonnet-5", id)
13+ }
14+ if _, err := provider.FindModel("github-copilot", id); err != nil {
15+ t.Fatalf("Copilot default missing from catalog: %v", err)
16+ }
17+}
@@ -0,0 +1,17 @@
1+package agent
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/provider"
7+)
8+
9+func TestCopilotDefaultUsesSonnet5(t *testing.T) {
10+ id := defaultModelForProvider("github-copilot")
11+ if id != "claude-sonnet-5" {
12+ t.Fatalf("Copilot default = %q, want claude-sonnet-5", id)
13+ }
14+ if _, err := provider.FindModel("github-copilot", id); err != nil {
15+ t.Fatalf("Copilot default missing from catalog: %v", err)
16+ }
17+}
M packages/agent/modelsync.go +17 -0
@@ -141,6 +141,7 @@ func ValidateAndRepairConfig() {
141141 // the baked-in catalog if this fails.
142142 func RefreshModelsAsync() {
143143 go refreshModels()
144+ go refreshCopilotModelAvailability()
144145 go func() {
145146 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
146147 defer cancel()
@@ -148,6 +149,22 @@ func RefreshModelsAsync() {
148149 }()
149150 }
150151
152+// Copilot availability is account-specific and must not use the shared disk cache.
153+func refreshCopilotModelAvailability() {
154+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
155+ defer cancel()
156+ cred, _, err := resolveCredentialForBackground(ctx, "github-copilot")
157+ if err != nil {
158+ provider.SetModelAvailability("github-copilot", nil)
159+ return
160+ }
161+ ids, err := provider.DiscoverCopilotAvailableModels(ctx, cred)
162+ if err != nil {
163+ return // Keep the previous snapshot, or the catalog on first-load failure.
164+ }
165+ provider.SetModelAvailability("github-copilot", ids)
166+}
167+
151168 // RefreshLlamaCPPModels adds the router's currently loaded models to the
152169 // active catalog. Unloaded models remain in the management UI and cannot be
153170 // selected for inference until they are loaded.
@@ -141,6 +141,7 @@ func ValidateAndRepairConfig() {
141 // the baked-in catalog if this fails.141 // the baked-in catalog if this fails.
142 func RefreshModelsAsync() {142 func RefreshModelsAsync() {
143 go refreshModels()143 go refreshModels()
144+ go refreshCopilotModelAvailability()
144 go func() {145 go func() {
145 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)146 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
146 defer cancel()147 defer cancel()
@@ -148,6 +149,22 @@ func RefreshModelsAsync() {
148 }()149 }()
149 }150 }
150 151
152+// Copilot availability is account-specific and must not use the shared disk cache.
153+func refreshCopilotModelAvailability() {
154+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
155+ defer cancel()
156+ cred, _, err := resolveCredentialForBackground(ctx, "github-copilot")
157+ if err != nil {
158+ provider.SetModelAvailability("github-copilot", nil)
159+ return
160+ }
161+ ids, err := provider.DiscoverCopilotAvailableModels(ctx, cred)
162+ if err != nil {
163+ return // Keep the previous snapshot, or the catalog on first-load failure.
164+ }
165+ provider.SetModelAvailability("github-copilot", ids)
166+}
167+
151 // RefreshLlamaCPPModels adds the router's currently loaded models to the168 // RefreshLlamaCPPModels adds the router's currently loaded models to the
152 // active catalog. Unloaded models remain in the management UI and cannot be169 // active catalog. Unloaded models remain in the management UI and cannot be
153 // selected for inference until they are loaded.170 // selected for inference until they are loaded.
M packages/provider/catalog_builtin.go +25 -13
@@ -12,7 +12,14 @@ package provider
1212 // the small seed list for kimi / deepseek / google) are not duplicated
1313 // here; they take precedence on (provider, id) match.
1414
15-func init() { Catalog = append(Catalog, builtinCatalog...) }
15+func init() {
16+ for i := range builtinCatalog {
17+ if builtinCatalog[i].Provider == "github-copilot" {
18+ configureCopilotModel(&builtinCatalog[i])
19+ }
20+ }
21+ Catalog = append(Catalog, builtinCatalog...)
22+}
1623
1724 var builtinCatalog = []Model{
1825 // ----- amazon-bedrock -----
@@ -139,23 +146,29 @@ var builtinCatalog = []Model{
139146 {Provider: "fireworks", ID: "accounts/fireworks/routers/glm-5p1-fast", DisplayName: "GLM 5.1 Fast", ContextWindow: 202800, MaxOutput: 131072, Reasoning: true, PriceInput: 2.8, PriceOutput: 8.8, PriceCacheRead: 0.52, BaseURL: "https://api.fireworks.ai/inference"},
140147 {Provider: "fireworks", ID: "accounts/fireworks/routers/kimi-k2p6-turbo", DisplayName: "Kimi K2.6 Turbo", ContextWindow: 262000, MaxOutput: 262000, Reasoning: true, PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.3, BaseURL: "https://api.fireworks.ai/inference"},
141148 // ----- github-copilot -----
149+ // Public chat models checked against GitHub's supported-model list on 2026-09-08.
150+ // New model limits and token prices: https://models.dev/api.json (github-copilot).
151+ // Copilot's fast Opus model ID comes from the inference API's available-model list.
152+ {Provider: "github-copilot", ID: "claude-fable-5", DisplayName: "Claude Fable 5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5, BaseURL: "https://api.individual.githubcopilot.com"},
153+ {Provider: "github-copilot", ID: "claude-fable-5.1", DisplayName: "Claude Fable 5.1", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 0.25, PriceCacheWrite: 12.5, BaseURL: "https://api.individual.githubcopilot.com"},
154+ {Provider: "github-copilot", ID: "claude-opus-4.8-fast", DisplayName: "Claude Opus 4.8 (fast mode)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, AdaptiveThinking: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5, BaseURL: "https://api.individual.githubcopilot.com"},
155+ {Provider: "github-copilot", ID: "claude-opus-5", DisplayName: "Claude Opus 5", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, AdaptiveThinking: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://api.individual.githubcopilot.com"},
156+ {Provider: "github-copilot", ID: "gemini-3.6-flash", DisplayName: "Gemini 3.6 Flash", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0.75, PriceOutput: 3.75, PriceCacheRead: 0.075, BaseURL: "https://api.individual.githubcopilot.com"},
157+ {Provider: "github-copilot", ID: "gemini-3.7-flash", DisplayName: "Gemini 3.7 Flash", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0.75, PriceOutput: 3.75, PriceCacheRead: 0.075, BaseURL: "https://api.individual.githubcopilot.com"},
158+ {Provider: "github-copilot", ID: "gemini-3.8-flash", DisplayName: "Gemini 3.8 Flash", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0.75, PriceOutput: 3.75, PriceCacheRead: 0.075, BaseURL: "https://api.individual.githubcopilot.com"},
159+ {Provider: "github-copilot", ID: "grok-4.5", DisplayName: "Grok 4.5", ContextWindow: 500000, MaxOutput: 128000, Reasoning: true, PriceInput: 2, PriceOutput: 6, PriceCacheRead: 0.5, PriceTierInputTokens: 200000, PriceInputAbove: 4, PriceOutputAbove: 12, PriceCacheReadAbove: 1, BaseURL: "https://api.individual.githubcopilot.com"},
160+ {Provider: "github-copilot", ID: "grok-4.6", DisplayName: "Grok 4.6", ContextWindow: 500000, MaxOutput: 128000, Reasoning: true, PriceInput: 2, PriceOutput: 6, PriceCacheRead: 0.5, PriceTierInputTokens: 200000, PriceInputAbove: 4, PriceOutputAbove: 12, PriceCacheReadAbove: 1, BaseURL: "https://api.individual.githubcopilot.com"},
161+ {Provider: "github-copilot", ID: "kimi-k2.7-code", DisplayName: "Kimi K2.7 Code", ContextWindow: 256000, MaxOutput: 32000, Reasoning: true, PriceInput: 0.95, PriceOutput: 4, PriceCacheRead: 0.19, BaseURL: "https://api.individual.githubcopilot.com"},
162+ {Provider: "github-copilot", ID: "kimi-k3", DisplayName: "Kimi K3", ContextWindow: 1048576, MaxOutput: 131072, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, BaseURL: "https://api.individual.githubcopilot.com"},
163+ // MAI-Code-1-Flash remains offered until its scheduled retirement on 2026-09-10.
164+ {Provider: "github-copilot", ID: "mai-code-1-flash-picker", DisplayName: "MAI-Code-1-Flash", ContextWindow: 256000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075, BaseURL: "https://api.individual.githubcopilot.com"},
165+ {Provider: "github-copilot", ID: "mai-code-1.1-flash", DisplayName: "MAI-Code-1.1-Flash", ContextWindow: 256000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.2, PriceOutput: 1.2, PriceCacheRead: 0.02, BaseURL: "https://api.individual.githubcopilot.com"},
142166 {Provider: "github-copilot", ID: "claude-haiku-4.5", DisplayName: "Claude Haiku 4.5", ContextWindow: 144000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
143- {Provider: "github-copilot", ID: "claude-opus-4.5", DisplayName: "Claude Opus 4.5", ContextWindow: 160000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
144- {Provider: "github-copilot", ID: "claude-opus-4.6", DisplayName: "Claude Opus 4.6", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
145167 {Provider: "github-copilot", ID: "claude-opus-4.7", DisplayName: "Claude Opus 4.7", ContextWindow: 144000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
146168 {Provider: "github-copilot", ID: "claude-opus-4.8", DisplayName: "Claude Opus 4.8", ContextWindow: 144000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
147- {Provider: "github-copilot", ID: "claude-sonnet-4.5", DisplayName: "Claude Sonnet 4.5", ContextWindow: 144000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
148- {Provider: "github-copilot", ID: "claude-sonnet-4.6", DisplayName: "Claude Sonnet 4.6", ContextWindow: 1000000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
149169 {Provider: "github-copilot", ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
150- {Provider: "github-copilot", ID: "gemini-2.5-pro", DisplayName: "Gemini 2.5 Pro", ContextWindow: 128000, MaxOutput: 64000, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
151- {Provider: "github-copilot", ID: "gemini-3-flash-preview", DisplayName: "Gemini 3 Flash", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
152- {Provider: "github-copilot", ID: "gemini-3.1-pro-preview", DisplayName: "Gemini 3.1 Pro Preview", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
153170 {Provider: "github-copilot", ID: "gemini-3.5-flash", DisplayName: "Gemini 3.5 Flash", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
154- {Provider: "github-copilot", ID: "gpt-4.1", DisplayName: "GPT-4.1", ContextWindow: 128000, MaxOutput: 16384, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
155- {Provider: "github-copilot", ID: "gpt-4o", DisplayName: "GPT-4o", ContextWindow: 128000, MaxOutput: 4096, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
156171 {Provider: "github-copilot", ID: "gpt-5-mini", DisplayName: "GPT-5-mini", ContextWindow: 264000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
157- {Provider: "github-copilot", ID: "gpt-5.2", DisplayName: "GPT-5.2", ContextWindow: 264000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
158- {Provider: "github-copilot", ID: "gpt-5.2-codex", DisplayName: "GPT-5.2-Codex", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
159172 {Provider: "github-copilot", ID: "gpt-5.3-codex", DisplayName: "GPT-5.3-Codex", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
160173 {Provider: "github-copilot", ID: "gpt-5.4", DisplayName: "GPT-5.4", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
161174 {Provider: "github-copilot", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 Mini", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
@@ -164,7 +177,6 @@ var builtinCatalog = []Model{
164177 {Provider: "github-copilot", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", API: APIResponses, ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
165178 {Provider: "github-copilot", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses, ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
166179 {Provider: "github-copilot", ID: "gpt-6-astra", DisplayName: "GPT-6 Astra", API: APIResponses, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5, PriceTierInputTokens: 272000, PriceInputAbove: 20, PriceOutputAbove: 75, PriceCacheReadAbove: 2, PriceCacheWriteAbove: 25, BaseURL: "https://api.individual.githubcopilot.com"},
167- {Provider: "github-copilot", ID: "grok-code-fast-1", DisplayName: "Grok Code Fast 1", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
168180 // ----- google -----
169181 {Provider: "google", ID: "gemini-3-flash-preview", DisplayName: "Gemini 3 Flash Preview", ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, PriceInput: 0.5, PriceOutput: 3, PriceCacheRead: 0.05, BaseURL: "https://generativelanguage.googleapis.com/v1beta"},
170182 {Provider: "google", ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, PriceInput: 2, PriceOutput: 12, PriceCacheRead: 0.2, BaseURL: "https://generativelanguage.googleapis.com/v1beta"},
@@ -12,7 +12,14 @@ package provider
12 // the small seed list for kimi / deepseek / google) are not duplicated12 // the small seed list for kimi / deepseek / google) are not duplicated
13 // here; they take precedence on (provider, id) match.13 // here; they take precedence on (provider, id) match.
14 14
15-func init() { Catalog = append(Catalog, builtinCatalog...) }15+func init() {
16+ for i := range builtinCatalog {
17+ if builtinCatalog[i].Provider == "github-copilot" {
18+ configureCopilotModel(&builtinCatalog[i])
19+ }
20+ }
21+ Catalog = append(Catalog, builtinCatalog...)
22+}
16 23
17 var builtinCatalog = []Model{24 var builtinCatalog = []Model{
18 // ----- amazon-bedrock -----25 // ----- amazon-bedrock -----
@@ -139,23 +146,29 @@ var builtinCatalog = []Model{
139 {Provider: "fireworks", ID: "accounts/fireworks/routers/glm-5p1-fast", DisplayName: "GLM 5.1 Fast", ContextWindow: 202800, MaxOutput: 131072, Reasoning: true, PriceInput: 2.8, PriceOutput: 8.8, PriceCacheRead: 0.52, BaseURL: "https://api.fireworks.ai/inference"},146 {Provider: "fireworks", ID: "accounts/fireworks/routers/glm-5p1-fast", DisplayName: "GLM 5.1 Fast", ContextWindow: 202800, MaxOutput: 131072, Reasoning: true, PriceInput: 2.8, PriceOutput: 8.8, PriceCacheRead: 0.52, BaseURL: "https://api.fireworks.ai/inference"},
140 {Provider: "fireworks", ID: "accounts/fireworks/routers/kimi-k2p6-turbo", DisplayName: "Kimi K2.6 Turbo", ContextWindow: 262000, MaxOutput: 262000, Reasoning: true, PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.3, BaseURL: "https://api.fireworks.ai/inference"},147 {Provider: "fireworks", ID: "accounts/fireworks/routers/kimi-k2p6-turbo", DisplayName: "Kimi K2.6 Turbo", ContextWindow: 262000, MaxOutput: 262000, Reasoning: true, PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.3, BaseURL: "https://api.fireworks.ai/inference"},
141 // ----- github-copilot -----148 // ----- github-copilot -----
149+ // Public chat models checked against GitHub's supported-model list on 2026-09-08.
150+ // New model limits and token prices: https://models.dev/api.json (github-copilot).
151+ // Copilot's fast Opus model ID comes from the inference API's available-model list.
152+ {Provider: "github-copilot", ID: "claude-fable-5", DisplayName: "Claude Fable 5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5, BaseURL: "https://api.individual.githubcopilot.com"},
153+ {Provider: "github-copilot", ID: "claude-fable-5.1", DisplayName: "Claude Fable 5.1", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 0.25, PriceCacheWrite: 12.5, BaseURL: "https://api.individual.githubcopilot.com"},
154+ {Provider: "github-copilot", ID: "claude-opus-4.8-fast", DisplayName: "Claude Opus 4.8 (fast mode)", ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, AdaptiveThinking: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5, BaseURL: "https://api.individual.githubcopilot.com"},
155+ {Provider: "github-copilot", ID: "claude-opus-5", DisplayName: "Claude Opus 5", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, AdaptiveThinking: true, PriceInput: 5, PriceOutput: 25, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://api.individual.githubcopilot.com"},
156+ {Provider: "github-copilot", ID: "gemini-3.6-flash", DisplayName: "Gemini 3.6 Flash", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0.75, PriceOutput: 3.75, PriceCacheRead: 0.075, BaseURL: "https://api.individual.githubcopilot.com"},
157+ {Provider: "github-copilot", ID: "gemini-3.7-flash", DisplayName: "Gemini 3.7 Flash", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0.75, PriceOutput: 3.75, PriceCacheRead: 0.075, BaseURL: "https://api.individual.githubcopilot.com"},
158+ {Provider: "github-copilot", ID: "gemini-3.8-flash", DisplayName: "Gemini 3.8 Flash", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0.75, PriceOutput: 3.75, PriceCacheRead: 0.075, BaseURL: "https://api.individual.githubcopilot.com"},
159+ {Provider: "github-copilot", ID: "grok-4.5", DisplayName: "Grok 4.5", ContextWindow: 500000, MaxOutput: 128000, Reasoning: true, PriceInput: 2, PriceOutput: 6, PriceCacheRead: 0.5, PriceTierInputTokens: 200000, PriceInputAbove: 4, PriceOutputAbove: 12, PriceCacheReadAbove: 1, BaseURL: "https://api.individual.githubcopilot.com"},
160+ {Provider: "github-copilot", ID: "grok-4.6", DisplayName: "Grok 4.6", ContextWindow: 500000, MaxOutput: 128000, Reasoning: true, PriceInput: 2, PriceOutput: 6, PriceCacheRead: 0.5, PriceTierInputTokens: 200000, PriceInputAbove: 4, PriceOutputAbove: 12, PriceCacheReadAbove: 1, BaseURL: "https://api.individual.githubcopilot.com"},
161+ {Provider: "github-copilot", ID: "kimi-k2.7-code", DisplayName: "Kimi K2.7 Code", ContextWindow: 256000, MaxOutput: 32000, Reasoning: true, PriceInput: 0.95, PriceOutput: 4, PriceCacheRead: 0.19, BaseURL: "https://api.individual.githubcopilot.com"},
162+ {Provider: "github-copilot", ID: "kimi-k3", DisplayName: "Kimi K3", ContextWindow: 1048576, MaxOutput: 131072, Reasoning: true, PriceInput: 3, PriceOutput: 15, PriceCacheRead: 0.3, BaseURL: "https://api.individual.githubcopilot.com"},
163+ // MAI-Code-1-Flash remains offered until its scheduled retirement on 2026-09-10.
164+ {Provider: "github-copilot", ID: "mai-code-1-flash-picker", DisplayName: "MAI-Code-1-Flash", ContextWindow: 256000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.75, PriceOutput: 4.5, PriceCacheRead: 0.075, BaseURL: "https://api.individual.githubcopilot.com"},
165+ {Provider: "github-copilot", ID: "mai-code-1.1-flash", DisplayName: "MAI-Code-1.1-Flash", ContextWindow: 256000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.2, PriceOutput: 1.2, PriceCacheRead: 0.02, BaseURL: "https://api.individual.githubcopilot.com"},
142 {Provider: "github-copilot", ID: "claude-haiku-4.5", DisplayName: "Claude Haiku 4.5", ContextWindow: 144000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},166 {Provider: "github-copilot", ID: "claude-haiku-4.5", DisplayName: "Claude Haiku 4.5", ContextWindow: 144000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
143- {Provider: "github-copilot", ID: "claude-opus-4.5", DisplayName: "Claude Opus 4.5", ContextWindow: 160000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
144- {Provider: "github-copilot", ID: "claude-opus-4.6", DisplayName: "Claude Opus 4.6", ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
145 {Provider: "github-copilot", ID: "claude-opus-4.7", DisplayName: "Claude Opus 4.7", ContextWindow: 144000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},167 {Provider: "github-copilot", ID: "claude-opus-4.7", DisplayName: "Claude Opus 4.7", ContextWindow: 144000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
146 {Provider: "github-copilot", ID: "claude-opus-4.8", DisplayName: "Claude Opus 4.8", ContextWindow: 144000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},168 {Provider: "github-copilot", ID: "claude-opus-4.8", DisplayName: "Claude Opus 4.8", ContextWindow: 144000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
147- {Provider: "github-copilot", ID: "claude-sonnet-4.5", DisplayName: "Claude Sonnet 4.5", ContextWindow: 144000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
148- {Provider: "github-copilot", ID: "claude-sonnet-4.6", DisplayName: "Claude Sonnet 4.6", ContextWindow: 1000000, MaxOutput: 32000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
149 {Provider: "github-copilot", ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},169 {Provider: "github-copilot", ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
150- {Provider: "github-copilot", ID: "gemini-2.5-pro", DisplayName: "Gemini 2.5 Pro", ContextWindow: 128000, MaxOutput: 64000, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
151- {Provider: "github-copilot", ID: "gemini-3-flash-preview", DisplayName: "Gemini 3 Flash", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
152- {Provider: "github-copilot", ID: "gemini-3.1-pro-preview", DisplayName: "Gemini 3.1 Pro Preview", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
153 {Provider: "github-copilot", ID: "gemini-3.5-flash", DisplayName: "Gemini 3.5 Flash", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},170 {Provider: "github-copilot", ID: "gemini-3.5-flash", DisplayName: "Gemini 3.5 Flash", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
154- {Provider: "github-copilot", ID: "gpt-4.1", DisplayName: "GPT-4.1", ContextWindow: 128000, MaxOutput: 16384, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
155- {Provider: "github-copilot", ID: "gpt-4o", DisplayName: "GPT-4o", ContextWindow: 128000, MaxOutput: 4096, Reasoning: false, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
156 {Provider: "github-copilot", ID: "gpt-5-mini", DisplayName: "GPT-5-mini", ContextWindow: 264000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},171 {Provider: "github-copilot", ID: "gpt-5-mini", DisplayName: "GPT-5-mini", ContextWindow: 264000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
157- {Provider: "github-copilot", ID: "gpt-5.2", DisplayName: "GPT-5.2", ContextWindow: 264000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
158- {Provider: "github-copilot", ID: "gpt-5.2-codex", DisplayName: "GPT-5.2-Codex", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
159 {Provider: "github-copilot", ID: "gpt-5.3-codex", DisplayName: "GPT-5.3-Codex", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},172 {Provider: "github-copilot", ID: "gpt-5.3-codex", DisplayName: "GPT-5.3-Codex", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
160 {Provider: "github-copilot", ID: "gpt-5.4", DisplayName: "GPT-5.4", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},173 {Provider: "github-copilot", ID: "gpt-5.4", DisplayName: "GPT-5.4", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
161 {Provider: "github-copilot", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 Mini", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},174 {Provider: "github-copilot", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 Mini", ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
@@ -164,7 +177,6 @@ var builtinCatalog = []Model{
164 {Provider: "github-copilot", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", API: APIResponses, ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},177 {Provider: "github-copilot", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", API: APIResponses, ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
165 {Provider: "github-copilot", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses, ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},178 {Provider: "github-copilot", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses, ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
166 {Provider: "github-copilot", ID: "gpt-6-astra", DisplayName: "GPT-6 Astra", API: APIResponses, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5, PriceTierInputTokens: 272000, PriceInputAbove: 20, PriceOutputAbove: 75, PriceCacheReadAbove: 2, PriceCacheWriteAbove: 25, BaseURL: "https://api.individual.githubcopilot.com"},179 {Provider: "github-copilot", ID: "gpt-6-astra", DisplayName: "GPT-6 Astra", API: APIResponses, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 10, PriceOutput: 50, PriceCacheRead: 1, PriceCacheWrite: 12.5, PriceTierInputTokens: 272000, PriceInputAbove: 20, PriceOutputAbove: 75, PriceCacheReadAbove: 2, PriceCacheWriteAbove: 25, BaseURL: "https://api.individual.githubcopilot.com"},
167- {Provider: "github-copilot", ID: "grok-code-fast-1", DisplayName: "Grok Code Fast 1", ContextWindow: 128000, MaxOutput: 64000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://api.individual.githubcopilot.com"},
168 // ----- google -----180 // ----- google -----
169 {Provider: "google", ID: "gemini-3-flash-preview", DisplayName: "Gemini 3 Flash Preview", ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, PriceInput: 0.5, PriceOutput: 3, PriceCacheRead: 0.05, BaseURL: "https://generativelanguage.googleapis.com/v1beta"},181 {Provider: "google", ID: "gemini-3-flash-preview", DisplayName: "Gemini 3 Flash Preview", ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, PriceInput: 0.5, PriceOutput: 3, PriceCacheRead: 0.05, BaseURL: "https://generativelanguage.googleapis.com/v1beta"},
170 {Provider: "google", ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, PriceInput: 2, PriceOutput: 12, PriceCacheRead: 0.2, BaseURL: "https://generativelanguage.googleapis.com/v1beta"},182 {Provider: "google", ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, PriceInput: 2, PriceOutput: 12, PriceCacheRead: 0.2, BaseURL: "https://generativelanguage.googleapis.com/v1beta"},
A packages/provider/copilot_catalog_test.go +72 -0
@@ -0,0 +1,72 @@
1+package provider
2+
3+import (
4+ "context"
5+ "testing"
6+)
7+
8+func TestCopilotCatalogCoversPublicChatModels(t *testing.T) {
9+ groups := map[string][]string{
10+ APIAnthropicMessages: {"claude-fable-5", "claude-fable-5.1", "claude-haiku-4.5", "claude-opus-4.7", "claude-opus-4.8", "claude-opus-4.8-fast", "claude-opus-5", "claude-sonnet-5"},
11+ APIResponses: {"gpt-5-mini", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", "gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-6-astra", "grok-4.5", "grok-4.6", "mai-code-1-flash-picker", "mai-code-1.1-flash"},
12+ APICompletions: {"gemini-3.5-flash", "gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash", "kimi-k2.7-code", "kimi-k3"},
13+ }
14+ for api, ids := range groups {
15+ for _, id := range ids {
16+ t.Run(id, func(t *testing.T) {
17+ m, err := FindModel("github-copilot", id)
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ if m.API != api {
22+ t.Fatalf("API = %q, want %q", m.API, api)
23+ }
24+ if m.ContextWindow <= 0 || m.MaxOutput <= 0 || m.MaxOutput >= m.ContextWindow {
25+ t.Fatalf("invalid context/output limits: %d/%d", m.ContextWindow, m.MaxOutput)
26+ }
27+ if m.BaseURL != copilotDefaultBaseURL || !m.Reasoning {
28+ t.Fatal("invalid endpoint or reasoning metadata")
29+ }
30+ client := NewGithubCopilotClient("synthetic-token").(*copilotClient)
31+ captures := make(map[string]*routeCaptureClient)
32+ for _, key := range []string{APIAnthropicMessages, APIResponses, APICompletions} {
33+ capture := &routeCaptureClient{name: "github-copilot"}
34+ captures[key] = capture
35+ client.router.byAPI[key] = capture
36+ }
37+ stream, err := client.Stream(context.Background(), Request{Model: id})
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ for range stream {
42+ }
43+ for key, capture := range captures {
44+ want := 0
45+ if key == api {
46+ want = 1
47+ }
48+ if len(capture.models) != want {
49+ t.Errorf("%s routed to %s incorrectly", id, key)
50+ }
51+ }
52+ })
53+ }
54+ }
55+}
56+
57+func TestCopilotCatalogExcludesRetiredAndUtilityModels(t *testing.T) {
58+ // https://docs.github.com/en/copilot/reference/ai-models/supported-models
59+ // Sonnet 4.6 is excluded from the general catalog despite its annual-plan exception.
60+ excluded := map[string]bool{
61+ "claude-opus-4.5": true, "claude-opus-4.6": true,
62+ "claude-sonnet-4.5": true, "claude-sonnet-4.6": true,
63+ "gemini-2.5-pro": true, "gemini-3-flash-preview": true, "gemini-3.1-pro-preview": true,
64+ "gpt-4.1": true, "gpt-4o": true, "gpt-5.2": true, "gpt-5.2-codex": true,
65+ "grok-code-fast-1": true, "gpt-5.4-nano": true,
66+ }
67+ for _, m := range Catalog {
68+ if m.Provider == "github-copilot" && excluded[m.ID] {
69+ t.Errorf("Copilot catalog still includes %q", m.ID)
70+ }
71+ }
72+}
@@ -0,0 +1,72 @@
1+package provider
2+
3+import (
4+ "context"
5+ "testing"
6+)
7+
8+func TestCopilotCatalogCoversPublicChatModels(t *testing.T) {
9+ groups := map[string][]string{
10+ APIAnthropicMessages: {"claude-fable-5", "claude-fable-5.1", "claude-haiku-4.5", "claude-opus-4.7", "claude-opus-4.8", "claude-opus-4.8-fast", "claude-opus-5", "claude-sonnet-5"},
11+ APIResponses: {"gpt-5-mini", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", "gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-6-astra", "grok-4.5", "grok-4.6", "mai-code-1-flash-picker", "mai-code-1.1-flash"},
12+ APICompletions: {"gemini-3.5-flash", "gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash", "kimi-k2.7-code", "kimi-k3"},
13+ }
14+ for api, ids := range groups {
15+ for _, id := range ids {
16+ t.Run(id, func(t *testing.T) {
17+ m, err := FindModel("github-copilot", id)
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ if m.API != api {
22+ t.Fatalf("API = %q, want %q", m.API, api)
23+ }
24+ if m.ContextWindow <= 0 || m.MaxOutput <= 0 || m.MaxOutput >= m.ContextWindow {
25+ t.Fatalf("invalid context/output limits: %d/%d", m.ContextWindow, m.MaxOutput)
26+ }
27+ if m.BaseURL != copilotDefaultBaseURL || !m.Reasoning {
28+ t.Fatal("invalid endpoint or reasoning metadata")
29+ }
30+ client := NewGithubCopilotClient("synthetic-token").(*copilotClient)
31+ captures := make(map[string]*routeCaptureClient)
32+ for _, key := range []string{APIAnthropicMessages, APIResponses, APICompletions} {
33+ capture := &routeCaptureClient{name: "github-copilot"}
34+ captures[key] = capture
35+ client.router.byAPI[key] = capture
36+ }
37+ stream, err := client.Stream(context.Background(), Request{Model: id})
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ for range stream {
42+ }
43+ for key, capture := range captures {
44+ want := 0
45+ if key == api {
46+ want = 1
47+ }
48+ if len(capture.models) != want {
49+ t.Errorf("%s routed to %s incorrectly", id, key)
50+ }
51+ }
52+ })
53+ }
54+ }
55+}
56+
57+func TestCopilotCatalogExcludesRetiredAndUtilityModels(t *testing.T) {
58+ // https://docs.github.com/en/copilot/reference/ai-models/supported-models
59+ // Sonnet 4.6 is excluded from the general catalog despite its annual-plan exception.
60+ excluded := map[string]bool{
61+ "claude-opus-4.5": true, "claude-opus-4.6": true,
62+ "claude-sonnet-4.5": true, "claude-sonnet-4.6": true,
63+ "gemini-2.5-pro": true, "gemini-3-flash-preview": true, "gemini-3.1-pro-preview": true,
64+ "gpt-4.1": true, "gpt-4o": true, "gpt-5.2": true, "gpt-5.2-codex": true,
65+ "grok-code-fast-1": true, "gpt-5.4-nano": true,
66+ }
67+ for _, m := range Catalog {
68+ if m.Provider == "github-copilot" && excluded[m.ID] {
69+ t.Errorf("Copilot catalog still includes %q", m.ID)
70+ }
71+ }
72+}
A packages/provider/discover_copilot.go +83 -0
@@ -0,0 +1,83 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+)
10+
11+// DiscoverCopilotAvailableModels lists the account's selectable tool-capable
12+// models. It does not enable model policies or persist account-specific data.
13+func DiscoverCopilotAvailableModels(ctx context.Context, pat string) ([]string, error) {
14+ token, err := copilotCache.get(ctx, pat)
15+ if err != nil {
16+ return nil, err
17+ }
18+ baseURL := token.baseURL
19+ if baseURL == "" {
20+ baseURL = copilotDefaultBaseURL
21+ }
22+ client := &http.Client{Transport: &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat}}
23+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil)
24+ if err != nil {
25+ return nil, err
26+ }
27+ req.Header.Set("Accept", "application/json")
28+ req.Header.Set("X-GitHub-Api-Version", "2026-06-01")
29+ resp, err := client.Do(req)
30+ if err != nil {
31+ return nil, fmt.Errorf("copilot model discovery: %w", err)
32+ }
33+ defer resp.Body.Close()
34+ if resp.StatusCode != http.StatusOK {
35+ return nil, fmt.Errorf("copilot model discovery: HTTP %d", resp.StatusCode)
36+ }
37+ body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
38+ if err != nil {
39+ return nil, fmt.Errorf("copilot model discovery: %w", err)
40+ }
41+ // The fallback is deliberately restricted to the individual endpoint.
42+ individual := baseURL == copilotDefaultBaseURL
43+ return parseCopilotAvailableModels(body, individual)
44+}
45+
46+func parseCopilotAvailableModels(body []byte, allowPolicyFallback bool) ([]string, error) {
47+ var catalog struct {
48+ Data []struct {
49+ ID string `json:"id"`
50+ Picker bool `json:"model_picker_enabled"`
51+ Policy struct {
52+ State string `json:"state"`
53+ } `json:"policy"`
54+ Capabilities struct {
55+ Supports struct {
56+ ToolCalls *bool `json:"tool_calls"`
57+ } `json:"supports"`
58+ } `json:"capabilities"`
59+ } `json:"data"`
60+ }
61+ if err := json.Unmarshal(body, &catalog); err != nil || catalog.Data == nil {
62+ return nil, fmt.Errorf("copilot model discovery: invalid models response")
63+ }
64+ picker := make([]string, 0)
65+ enabled := make([]string, 0)
66+ seen := make(map[string]bool)
67+ for _, m := range catalog.Data {
68+ if m.ID == "" || seen[m.ID] || (m.Capabilities.Supports.ToolCalls != nil && !*m.Capabilities.Supports.ToolCalls) {
69+ continue
70+ }
71+ seen[m.ID] = true
72+ if m.Picker && m.Policy.State != "disabled" {
73+ picker = append(picker, m.ID)
74+ }
75+ if m.Policy.State == "enabled" {
76+ enabled = append(enabled, m.ID)
77+ }
78+ }
79+ if len(picker) == 0 && allowPolicyFallback {
80+ return enabled, nil
81+ }
82+ return picker, nil
83+}
@@ -0,0 +1,83 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+)
10+
11+// DiscoverCopilotAvailableModels lists the account's selectable tool-capable
12+// models. It does not enable model policies or persist account-specific data.
13+func DiscoverCopilotAvailableModels(ctx context.Context, pat string) ([]string, error) {
14+ token, err := copilotCache.get(ctx, pat)
15+ if err != nil {
16+ return nil, err
17+ }
18+ baseURL := token.baseURL
19+ if baseURL == "" {
20+ baseURL = copilotDefaultBaseURL
21+ }
22+ client := &http.Client{Transport: &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat}}
23+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil)
24+ if err != nil {
25+ return nil, err
26+ }
27+ req.Header.Set("Accept", "application/json")
28+ req.Header.Set("X-GitHub-Api-Version", "2026-06-01")
29+ resp, err := client.Do(req)
30+ if err != nil {
31+ return nil, fmt.Errorf("copilot model discovery: %w", err)
32+ }
33+ defer resp.Body.Close()
34+ if resp.StatusCode != http.StatusOK {
35+ return nil, fmt.Errorf("copilot model discovery: HTTP %d", resp.StatusCode)
36+ }
37+ body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
38+ if err != nil {
39+ return nil, fmt.Errorf("copilot model discovery: %w", err)
40+ }
41+ // The fallback is deliberately restricted to the individual endpoint.
42+ individual := baseURL == copilotDefaultBaseURL
43+ return parseCopilotAvailableModels(body, individual)
44+}
45+
46+func parseCopilotAvailableModels(body []byte, allowPolicyFallback bool) ([]string, error) {
47+ var catalog struct {
48+ Data []struct {
49+ ID string `json:"id"`
50+ Picker bool `json:"model_picker_enabled"`
51+ Policy struct {
52+ State string `json:"state"`
53+ } `json:"policy"`
54+ Capabilities struct {
55+ Supports struct {
56+ ToolCalls *bool `json:"tool_calls"`
57+ } `json:"supports"`
58+ } `json:"capabilities"`
59+ } `json:"data"`
60+ }
61+ if err := json.Unmarshal(body, &catalog); err != nil || catalog.Data == nil {
62+ return nil, fmt.Errorf("copilot model discovery: invalid models response")
63+ }
64+ picker := make([]string, 0)
65+ enabled := make([]string, 0)
66+ seen := make(map[string]bool)
67+ for _, m := range catalog.Data {
68+ if m.ID == "" || seen[m.ID] || (m.Capabilities.Supports.ToolCalls != nil && !*m.Capabilities.Supports.ToolCalls) {
69+ continue
70+ }
71+ seen[m.ID] = true
72+ if m.Picker && m.Policy.State != "disabled" {
73+ picker = append(picker, m.ID)
74+ }
75+ if m.Policy.State == "enabled" {
76+ enabled = append(enabled, m.ID)
77+ }
78+ }
79+ if len(picker) == 0 && allowPolicyFallback {
80+ return enabled, nil
81+ }
82+ return picker, nil
83+}
A packages/provider/discover_copilot_test.go +103 -0
@@ -0,0 +1,103 @@
1+package provider
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "net/http/httptest"
8+ "reflect"
9+ "strings"
10+ "testing"
11+)
12+
13+func TestParseCopilotAvailableModels(t *testing.T) {
14+ for _, tc := range []struct {
15+ name, body string
16+ fallback bool
17+ want []string
18+ invalid bool
19+ }{
20+ {"picker", `{"data":[{"id":"yes","model_picker_enabled":true},{"id":"disabled","model_picker_enabled":true,"policy":{"state":"disabled"}},{"id":"hidden","policy":{"state":"enabled"}},{"id":"no-tools","model_picker_enabled":true,"capabilities":{"supports":{"tool_calls":false}}}]}`, true, []string{"yes"}, false},
21+ {"individual fallback", `{"data":[{"id":"enabled","policy":{"state":"enabled"}},{"id":"unconfigured","policy":{"state":"unconfigured"}},{"id":"disabled","policy":{"state":"disabled"}}]}`, true, []string{"enabled"}, false},
22+ {"enterprise no fallback", `{"data":[{"id":"enabled","policy":{"state":"enabled"}}]}`, false, []string{}, false},
23+ {"empty", `{"data":[]}`, true, []string{}, false},
24+ {"duplicate", `{"data":[{"id":"yes","model_picker_enabled":true},{"id":"yes","model_picker_enabled":true}]}`, false, []string{"yes"}, false},
25+ {"missing", `{}`, false, nil, true},
26+ {"null", `{"data":null}`, false, nil, true},
27+ {"invalid", `{"data":{}}`, false, nil, true},
28+ } {
29+ t.Run(tc.name, func(t *testing.T) {
30+ got, err := parseCopilotAvailableModels([]byte(tc.body), tc.fallback)
31+ if (err != nil) != tc.invalid {
32+ t.Fatalf("error = %v", err)
33+ }
34+ if !tc.invalid && !reflect.DeepEqual(got, tc.want) {
35+ t.Fatalf("got %v, want %v", got, tc.want)
36+ }
37+ })
38+ }
39+}
40+
41+func TestCopilotDiscoveryHTTP(t *testing.T) {
42+ for _, status := range []int{200, 403, 429} {
43+ t.Run(fmt.Sprint(status), func(t *testing.T) {
44+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45+ if r.Method != "GET" || r.URL.Path != "/models" {
46+ t.Error("incorrect discovery request")
47+ }
48+ if r.Header.Get("Authorization") != "Bearer synthetic-session-token" || r.Header.Get("Copilot-Integration-Id") != "vscode-chat" || r.Header.Get("X-GitHub-Api-Version") != "2026-06-01" {
49+ t.Error("incorrect discovery headers")
50+ }
51+ w.WriteHeader(status)
52+ if status != 200 {
53+ fmt.Fprint(w, "sensitive-response-body")
54+ return
55+ }
56+ fmt.Fprint(w, `{"data":[{"id":"claude-sonnet-5","model_picker_enabled":true}]}`)
57+ }))
58+ defer srv.Close()
59+ pat := seedCopilotTestToken(t, srv.URL)
60+ got, err := DiscoverCopilotAvailableModels(context.Background(), pat)
61+ if status == 200 {
62+ if err != nil || !reflect.DeepEqual(got, []string{"claude-sonnet-5"}) {
63+ t.Fatalf("got %v, %v", got, err)
64+ }
65+ } else if err == nil || strings.Contains(err.Error(), "sensitive-response-body") {
66+ t.Fatalf("unexpected error %v", err)
67+ }
68+ ctx, cancel := context.WithCancel(context.Background())
69+ cancel()
70+ if _, err := DiscoverCopilotAvailableModels(ctx, pat); err == nil {
71+ t.Error("cancellation ignored")
72+ }
73+ })
74+ }
75+}
76+
77+func TestModelAvailabilityFiltersWithoutDeletingCatalog(t *testing.T) {
78+ t.Cleanup(func() { SetModelAvailability("github-copilot", nil) })
79+ before := len(ModelsForProvider("anthropic"))
80+ ids := []string{"claude-sonnet-5"}
81+ SetModelAvailability("github-copilot", ids)
82+ ids[0] = "mutated"
83+ models := ModelsForProvider("github-copilot")
84+ if len(models) != 1 || models[0].ID != "claude-sonnet-5" {
85+ t.Fatalf("models = %v", models)
86+ }
87+ if len(ModelsForProvider("anthropic")) != before {
88+ t.Error("changed another provider")
89+ }
90+ SetModelAvailability("github-copilot", []string{})
91+ // Hiding a model must not change wire metadata for a session already using it.
92+ model, err := FindModel("github-copilot", "gpt-5.4")
93+ if err != nil || model.API != APIResponses {
94+ t.Fatalf("availability refresh lost routing metadata: %+v, %v", model, err)
95+ }
96+ if len(ModelsForProvider("github-copilot")) != 0 {
97+ t.Error("empty availability did not hide models")
98+ }
99+ SetModelAvailability("github-copilot", nil)
100+ if len(ModelsForProvider("github-copilot")) != 27 {
101+ t.Error("clearing restriction did not restore catalog")
102+ }
103+}
@@ -0,0 +1,103 @@
1+package provider
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "net/http/httptest"
8+ "reflect"
9+ "strings"
10+ "testing"
11+)
12+
13+func TestParseCopilotAvailableModels(t *testing.T) {
14+ for _, tc := range []struct {
15+ name, body string
16+ fallback bool
17+ want []string
18+ invalid bool
19+ }{
20+ {"picker", `{"data":[{"id":"yes","model_picker_enabled":true},{"id":"disabled","model_picker_enabled":true,"policy":{"state":"disabled"}},{"id":"hidden","policy":{"state":"enabled"}},{"id":"no-tools","model_picker_enabled":true,"capabilities":{"supports":{"tool_calls":false}}}]}`, true, []string{"yes"}, false},
21+ {"individual fallback", `{"data":[{"id":"enabled","policy":{"state":"enabled"}},{"id":"unconfigured","policy":{"state":"unconfigured"}},{"id":"disabled","policy":{"state":"disabled"}}]}`, true, []string{"enabled"}, false},
22+ {"enterprise no fallback", `{"data":[{"id":"enabled","policy":{"state":"enabled"}}]}`, false, []string{}, false},
23+ {"empty", `{"data":[]}`, true, []string{}, false},
24+ {"duplicate", `{"data":[{"id":"yes","model_picker_enabled":true},{"id":"yes","model_picker_enabled":true}]}`, false, []string{"yes"}, false},
25+ {"missing", `{}`, false, nil, true},
26+ {"null", `{"data":null}`, false, nil, true},
27+ {"invalid", `{"data":{}}`, false, nil, true},
28+ } {
29+ t.Run(tc.name, func(t *testing.T) {
30+ got, err := parseCopilotAvailableModels([]byte(tc.body), tc.fallback)
31+ if (err != nil) != tc.invalid {
32+ t.Fatalf("error = %v", err)
33+ }
34+ if !tc.invalid && !reflect.DeepEqual(got, tc.want) {
35+ t.Fatalf("got %v, want %v", got, tc.want)
36+ }
37+ })
38+ }
39+}
40+
41+func TestCopilotDiscoveryHTTP(t *testing.T) {
42+ for _, status := range []int{200, 403, 429} {
43+ t.Run(fmt.Sprint(status), func(t *testing.T) {
44+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45+ if r.Method != "GET" || r.URL.Path != "/models" {
46+ t.Error("incorrect discovery request")
47+ }
48+ if r.Header.Get("Authorization") != "Bearer synthetic-session-token" || r.Header.Get("Copilot-Integration-Id") != "vscode-chat" || r.Header.Get("X-GitHub-Api-Version") != "2026-06-01" {
49+ t.Error("incorrect discovery headers")
50+ }
51+ w.WriteHeader(status)
52+ if status != 200 {
53+ fmt.Fprint(w, "sensitive-response-body")
54+ return
55+ }
56+ fmt.Fprint(w, `{"data":[{"id":"claude-sonnet-5","model_picker_enabled":true}]}`)
57+ }))
58+ defer srv.Close()
59+ pat := seedCopilotTestToken(t, srv.URL)
60+ got, err := DiscoverCopilotAvailableModels(context.Background(), pat)
61+ if status == 200 {
62+ if err != nil || !reflect.DeepEqual(got, []string{"claude-sonnet-5"}) {
63+ t.Fatalf("got %v, %v", got, err)
64+ }
65+ } else if err == nil || strings.Contains(err.Error(), "sensitive-response-body") {
66+ t.Fatalf("unexpected error %v", err)
67+ }
68+ ctx, cancel := context.WithCancel(context.Background())
69+ cancel()
70+ if _, err := DiscoverCopilotAvailableModels(ctx, pat); err == nil {
71+ t.Error("cancellation ignored")
72+ }
73+ })
74+ }
75+}
76+
77+func TestModelAvailabilityFiltersWithoutDeletingCatalog(t *testing.T) {
78+ t.Cleanup(func() { SetModelAvailability("github-copilot", nil) })
79+ before := len(ModelsForProvider("anthropic"))
80+ ids := []string{"claude-sonnet-5"}
81+ SetModelAvailability("github-copilot", ids)
82+ ids[0] = "mutated"
83+ models := ModelsForProvider("github-copilot")
84+ if len(models) != 1 || models[0].ID != "claude-sonnet-5" {
85+ t.Fatalf("models = %v", models)
86+ }
87+ if len(ModelsForProvider("anthropic")) != before {
88+ t.Error("changed another provider")
89+ }
90+ SetModelAvailability("github-copilot", []string{})
91+ // Hiding a model must not change wire metadata for a session already using it.
92+ model, err := FindModel("github-copilot", "gpt-5.4")
93+ if err != nil || model.API != APIResponses {
94+ t.Fatalf("availability refresh lost routing metadata: %+v, %v", model, err)
95+ }
96+ if len(ModelsForProvider("github-copilot")) != 0 {
97+ t.Error("empty availability did not hide models")
98+ }
99+ SetModelAvailability("github-copilot", nil)
100+ if len(ModelsForProvider("github-copilot")) != 27 {
101+ t.Error("clearing restriction did not restore catalog")
102+ }
103+}
M packages/provider/extra_providers.go +2 -1
@@ -337,7 +337,8 @@ func NewCloudflareAIGateway(apiKey, baseURL string) Client {
337337 // access enabled; zot trades it for a short-lived Copilot token on
338338 // every inference request (cached in memory until ~5min before expiry).
339339 //
340-// Wire format: OpenAI Chat Completions. Copilot-specific headers
340+// Wire formats: Anthropic Messages, OpenAI Responses, and Chat Completions,
341+// selected by model family. Copilot-specific headers
341342 // (X-Initiator, Openai-Intent, Editor-Version, Editor-Plugin-Version,
342343 // Copilot-Integration-Id, User-Agent) are added by the refresh
343344 // transport. The model id passes through unchanged.
@@ -337,7 +337,8 @@ func NewCloudflareAIGateway(apiKey, baseURL string) Client {
337 // access enabled; zot trades it for a short-lived Copilot token on337 // access enabled; zot trades it for a short-lived Copilot token on
338 // every inference request (cached in memory until ~5min before expiry).338 // every inference request (cached in memory until ~5min before expiry).
339 //339 //
340-// Wire format: OpenAI Chat Completions. Copilot-specific headers340+// Wire formats: Anthropic Messages, OpenAI Responses, and Chat Completions,
341+// selected by model family. Copilot-specific headers
341 // (X-Initiator, Openai-Intent, Editor-Version, Editor-Plugin-Version,342 // (X-Initiator, Openai-Intent, Editor-Version, Editor-Plugin-Version,
342 // Copilot-Integration-Id, User-Agent) are added by the refresh343 // Copilot-Integration-Id, User-Agent) are added by the refresh
343 // transport. The model id passes through unchanged.344 // transport. The model id passes through unchanged.
M packages/provider/github_copilot.go +26 -16
@@ -14,12 +14,11 @@ package provider
1414 // User-Agent). The response carries `{ "token": "...", "expires_at": <unix> }`.
1515 // 3. The short-lived token's value embeds a `proxy-ep=<host>` field that
1616 // tells us the real API host (individual users: api.individual.githubcopilot.com).
17-// 4. Inference requests go to `<host>/chat/completions` with the
17+// 4. Inference requests use Messages, Responses, or Chat Completions with the
1818 // short-lived token in `Authorization: Bearer` plus extras:
1919 // - X-Initiator: user|agent
2020 // - Openai-Intent: conversation-edits
21-// - Copilot-Vision-Request: true (when images present; not wired
22-// here because the zot openai client currently sends images inline)
21+// - Copilot-Vision-Request: true (when images are present)
2322 //
2423 // Token caching: short-lived tokens last ~30min. We cache one per PAT in
2524 // memory for the process lifetime and refresh on demand. No disk cache.
@@ -162,12 +161,21 @@ func (t *copilotRefreshTransport) RoundTrip(req *http.Request) (*http.Response,
162161 }
163162 clone := req.Clone(req.Context())
164163 clone.Header.Set("Authorization", "Bearer "+tok.value)
164+ // Messages uses Copilot Bearer auth, never an Anthropic API key.
165+ clone.Header.Del("x-api-key")
165166 // Identity headers also required on inference requests.
166167 for k, v := range copilotIdentityHeaders {
167168 clone.Header.Set(k, v)
168169 }
169- clone.Header.Set("X-Initiator", "agent")
170- clone.Header.Set("Openai-Intent", "conversation-edits")
170+ if metadata, ok := req.Context().Value(copilotRequestKey{}).(copilotRequestMetadata); ok {
171+ clone.Header.Set("X-Initiator", metadata.initiator)
172+ if metadata.vision {
173+ clone.Header.Set("Copilot-Vision-Request", "true")
174+ } else {
175+ clone.Header.Del("Copilot-Vision-Request")
176+ }
177+ clone.Header.Set("Openai-Intent", "conversation-edits")
178+ }
171179 // If the request URL host doesn't match the token's proxy-ep, rewrite
172180 // it. The openaiClient pinned a static host at construction time, but
173181 // the canonical host comes from the token.
@@ -199,14 +207,8 @@ func (t *copilotResponsesStripTransport) RoundTrip(req *http.Request) (*http.Res
199207
200208 const copilotDefaultBaseURL = "https://api.individual.githubcopilot.com"
201209
202-// NewGithubCopilotClient returns a Copilot-pinned OpenAI-compat client.
203-// The pat must be a GitHub Personal Access Token with Copilot access.
204-//
205-// Copilot exposes two wire protocols: most models use Chat Completions
206-// (/chat/completions), while newer GPT models use the Responses API
207-// (/responses), including GPT-5.6 (sol/terra/luna) and GPT-6 Astra.
208-// A model router dispatches each request to the matching wire client
209-// based on the model's catalog API tag.
210+// NewGithubCopilotClient uses Messages for Claude, Responses for GPT/Grok/MAI,
211+// and Chat Completions for Gemini/Kimi. The PAT is exchanged for Copilot tokens.
210212 func NewGithubCopilotClient(pat string) Client {
211213 refresh := &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat}
212214 completionsHTTP := &http.Client{Transport: refresh, Timeout: 0}
@@ -233,7 +235,15 @@ func NewGithubCopilotClient(pat string) Client {
233235 http: responsesHTTP,
234236 }
235237
236- return NewModelRouter("github-copilot", completions, map[string]Client{
237- APIResponses: &renamedClient{inner: responses, name: "github-copilot"},
238- })
238+ messages := &anthropicClient{
239+ name: "github-copilot",
240+ baseURL: copilotDefaultBaseURL,
241+ // Do not set oauthTok: that would inject Claude Code identity and rename tools.
242+ http: &http.Client{Transport: refresh},
243+ }
244+ return &copilotClient{router: NewModelRouter("github-copilot", completions, map[string]Client{
245+ APICompletions: completions,
246+ APIResponses: responses,
247+ APIAnthropicMessages: messages,
248+ }).(*modelRouter)}
239249 }
@@ -14,12 +14,11 @@ package provider
14 // User-Agent). The response carries `{ "token": "...", "expires_at": <unix> }`.14 // User-Agent). The response carries `{ "token": "...", "expires_at": <unix> }`.
15 // 3. The short-lived token's value embeds a `proxy-ep=<host>` field that15 // 3. The short-lived token's value embeds a `proxy-ep=<host>` field that
16 // tells us the real API host (individual users: api.individual.githubcopilot.com).16 // tells us the real API host (individual users: api.individual.githubcopilot.com).
17-// 4. Inference requests go to `<host>/chat/completions` with the17+// 4. Inference requests use Messages, Responses, or Chat Completions with the
18 // short-lived token in `Authorization: Bearer` plus extras:18 // short-lived token in `Authorization: Bearer` plus extras:
19 // - X-Initiator: user|agent19 // - X-Initiator: user|agent
20 // - Openai-Intent: conversation-edits20 // - Openai-Intent: conversation-edits
21-// - Copilot-Vision-Request: true (when images present; not wired21+// - Copilot-Vision-Request: true (when images are present)
22-// here because the zot openai client currently sends images inline)
23 //22 //
24 // Token caching: short-lived tokens last ~30min. We cache one per PAT in23 // Token caching: short-lived tokens last ~30min. We cache one per PAT in
25 // memory for the process lifetime and refresh on demand. No disk cache.24 // memory for the process lifetime and refresh on demand. No disk cache.
@@ -162,12 +161,21 @@ func (t *copilotRefreshTransport) RoundTrip(req *http.Request) (*http.Response,
162 }161 }
163 clone := req.Clone(req.Context())162 clone := req.Clone(req.Context())
164 clone.Header.Set("Authorization", "Bearer "+tok.value)163 clone.Header.Set("Authorization", "Bearer "+tok.value)
164+ // Messages uses Copilot Bearer auth, never an Anthropic API key.
165+ clone.Header.Del("x-api-key")
165 // Identity headers also required on inference requests.166 // Identity headers also required on inference requests.
166 for k, v := range copilotIdentityHeaders {167 for k, v := range copilotIdentityHeaders {
167 clone.Header.Set(k, v)168 clone.Header.Set(k, v)
168 }169 }
169- clone.Header.Set("X-Initiator", "agent")170+ if metadata, ok := req.Context().Value(copilotRequestKey{}).(copilotRequestMetadata); ok {
170- clone.Header.Set("Openai-Intent", "conversation-edits")171+ clone.Header.Set("X-Initiator", metadata.initiator)
172+ if metadata.vision {
173+ clone.Header.Set("Copilot-Vision-Request", "true")
174+ } else {
175+ clone.Header.Del("Copilot-Vision-Request")
176+ }
177+ clone.Header.Set("Openai-Intent", "conversation-edits")
178+ }
171 // If the request URL host doesn't match the token's proxy-ep, rewrite179 // If the request URL host doesn't match the token's proxy-ep, rewrite
172 // it. The openaiClient pinned a static host at construction time, but180 // it. The openaiClient pinned a static host at construction time, but
173 // the canonical host comes from the token.181 // the canonical host comes from the token.
@@ -199,14 +207,8 @@ func (t *copilotResponsesStripTransport) RoundTrip(req *http.Request) (*http.Res
199 207
200 const copilotDefaultBaseURL = "https://api.individual.githubcopilot.com"208 const copilotDefaultBaseURL = "https://api.individual.githubcopilot.com"
201 209
202-// NewGithubCopilotClient returns a Copilot-pinned OpenAI-compat client.210+// NewGithubCopilotClient uses Messages for Claude, Responses for GPT/Grok/MAI,
203-// The pat must be a GitHub Personal Access Token with Copilot access.211+// and Chat Completions for Gemini/Kimi. The PAT is exchanged for Copilot tokens.
204-//
205-// Copilot exposes two wire protocols: most models use Chat Completions
206-// (/chat/completions), while newer GPT models use the Responses API
207-// (/responses), including GPT-5.6 (sol/terra/luna) and GPT-6 Astra.
208-// A model router dispatches each request to the matching wire client
209-// based on the model's catalog API tag.
210 func NewGithubCopilotClient(pat string) Client {212 func NewGithubCopilotClient(pat string) Client {
211 refresh := &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat}213 refresh := &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat}
212 completionsHTTP := &http.Client{Transport: refresh, Timeout: 0}214 completionsHTTP := &http.Client{Transport: refresh, Timeout: 0}
@@ -233,7 +235,15 @@ func NewGithubCopilotClient(pat string) Client {
233 http: responsesHTTP,235 http: responsesHTTP,
234 }236 }
235 237
236- return NewModelRouter("github-copilot", completions, map[string]Client{238+ messages := &anthropicClient{
237- APIResponses: &renamedClient{inner: responses, name: "github-copilot"},239+ name: "github-copilot",
238- })240+ baseURL: copilotDefaultBaseURL,
241+ // Do not set oauthTok: that would inject Claude Code identity and rename tools.
242+ http: &http.Client{Transport: refresh},
243+ }
244+ return &copilotClient{router: NewModelRouter("github-copilot", completions, map[string]Client{
245+ APICompletions: completions,
246+ APIResponses: responses,
247+ APIAnthropicMessages: messages,
248+ }).(*modelRouter)}
239 }249 }
A packages/provider/github_copilot_request.go +83 -0
@@ -0,0 +1,83 @@
1+package provider
2+
3+import (
4+ "context"
5+ "strings"
6+)
7+
8+type copilotRequestKey struct{}
9+type copilotRequestMetadata struct {
10+ initiator string
11+ vision bool
12+}
13+
14+type copilotClient struct {
15+ router *modelRouter
16+}
17+
18+func (c *copilotClient) Name() string { return "github-copilot" }
19+
20+func (c *copilotClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
21+ metadata := copilotRequestMetadata{initiator: "user"}
22+ if len(req.Messages) > 0 && req.Messages[len(req.Messages)-1].Role != RoleUser {
23+ metadata.initiator = "agent"
24+ }
25+ for _, message := range req.Messages {
26+ if message.Role == RoleUser || message.Role == RoleTool {
27+ metadata.vision = metadata.vision || copilotHasImages(message.Content)
28+ }
29+ }
30+ // These endpoints perform their own thinking; they reject reasoning_effort.
31+ api := copilotModelAPI(req.Model)
32+ if model, err := FindModel(c.Name(), req.Model); err == nil && model.API != "" {
33+ api = model.API
34+ }
35+ if api == APICompletions {
36+ req.Reasoning = ""
37+ }
38+ ctx = context.WithValue(ctx, copilotRequestKey{}, metadata)
39+ // Use family routing for custom models that have not supplied an API tag.
40+ if model, err := FindModel(c.Name(), req.Model); err != nil || model.API == "" {
41+ return c.router.byAPI[api].Stream(ctx, req)
42+ }
43+ return c.router.Stream(ctx, req)
44+}
45+
46+func copilotHasImages(blocks []Content) bool {
47+ for _, block := range blocks {
48+ switch b := block.(type) {
49+ case ImageBlock:
50+ return true
51+ case ToolResultBlock:
52+ if copilotHasImages(b.Content) {
53+ return true
54+ }
55+ }
56+ }
57+ return false
58+}
59+
60+func copilotModelAPI(id string) string {
61+ switch {
62+ case strings.HasPrefix(id, "claude-"):
63+ return APIAnthropicMessages
64+ case strings.HasPrefix(id, "gpt-"), strings.HasPrefix(id, "grok-"), strings.HasPrefix(id, "mai-"), strings.HasPrefix(id, "oswe"):
65+ return APIResponses
66+ default:
67+ return APICompletions
68+ }
69+}
70+
71+func configureCopilotModel(m *Model) {
72+ m.API = copilotModelAPI(m.ID)
73+ if m.API == APIAnthropicMessages && !strings.HasPrefix(m.ID, "claude-haiku-") {
74+ m.AdaptiveThinking = true
75+ }
76+ if m.API == APICompletions {
77+ m.ReasoningLevelMap = map[string]string{"minimum": "", "low": "", "medium": "", "high": "", "xhigh": "", "max": ""}
78+ }
79+ // Responses defaults expose xhigh, but these models only support low/medium/high.
80+ if m.ID == "gpt-5-mini" || m.ID == "grok-4.5" || strings.HasPrefix(m.ID, "mai-") {
81+ m.ReasoningLevelMap = map[string]string{"xhigh": "", "max": ""}
82+ }
83+}
@@ -0,0 +1,83 @@
1+package provider
2+
3+import (
4+ "context"
5+ "strings"
6+)
7+
8+type copilotRequestKey struct{}
9+type copilotRequestMetadata struct {
10+ initiator string
11+ vision bool
12+}
13+
14+type copilotClient struct {
15+ router *modelRouter
16+}
17+
18+func (c *copilotClient) Name() string { return "github-copilot" }
19+
20+func (c *copilotClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
21+ metadata := copilotRequestMetadata{initiator: "user"}
22+ if len(req.Messages) > 0 && req.Messages[len(req.Messages)-1].Role != RoleUser {
23+ metadata.initiator = "agent"
24+ }
25+ for _, message := range req.Messages {
26+ if message.Role == RoleUser || message.Role == RoleTool {
27+ metadata.vision = metadata.vision || copilotHasImages(message.Content)
28+ }
29+ }
30+ // These endpoints perform their own thinking; they reject reasoning_effort.
31+ api := copilotModelAPI(req.Model)
32+ if model, err := FindModel(c.Name(), req.Model); err == nil && model.API != "" {
33+ api = model.API
34+ }
35+ if api == APICompletions {
36+ req.Reasoning = ""
37+ }
38+ ctx = context.WithValue(ctx, copilotRequestKey{}, metadata)
39+ // Use family routing for custom models that have not supplied an API tag.
40+ if model, err := FindModel(c.Name(), req.Model); err != nil || model.API == "" {
41+ return c.router.byAPI[api].Stream(ctx, req)
42+ }
43+ return c.router.Stream(ctx, req)
44+}
45+
46+func copilotHasImages(blocks []Content) bool {
47+ for _, block := range blocks {
48+ switch b := block.(type) {
49+ case ImageBlock:
50+ return true
51+ case ToolResultBlock:
52+ if copilotHasImages(b.Content) {
53+ return true
54+ }
55+ }
56+ }
57+ return false
58+}
59+
60+func copilotModelAPI(id string) string {
61+ switch {
62+ case strings.HasPrefix(id, "claude-"):
63+ return APIAnthropicMessages
64+ case strings.HasPrefix(id, "gpt-"), strings.HasPrefix(id, "grok-"), strings.HasPrefix(id, "mai-"), strings.HasPrefix(id, "oswe"):
65+ return APIResponses
66+ default:
67+ return APICompletions
68+ }
69+}
70+
71+func configureCopilotModel(m *Model) {
72+ m.API = copilotModelAPI(m.ID)
73+ if m.API == APIAnthropicMessages && !strings.HasPrefix(m.ID, "claude-haiku-") {
74+ m.AdaptiveThinking = true
75+ }
76+ if m.API == APICompletions {
77+ m.ReasoningLevelMap = map[string]string{"minimum": "", "low": "", "medium": "", "high": "", "xhigh": "", "max": ""}
78+ }
79+ // Responses defaults expose xhigh, but these models only support low/medium/high.
80+ if m.ID == "gpt-5-mini" || m.ID == "grok-4.5" || strings.HasPrefix(m.ID, "mai-") {
81+ m.ReasoningLevelMap = map[string]string{"xhigh": "", "max": ""}
82+ }
83+}
M packages/provider/github_copilot_test.go +8 -7
@@ -31,6 +31,11 @@ func TestCopilotHostRewrite(t *testing.T) {
3131 baseURL: "https://api.enterprise.copilot.proxy",
3232 }
3333 copilotCache.mu.Unlock()
34+ t.Cleanup(func() {
35+ copilotCache.mu.Lock()
36+ delete(copilotCache.tokens, pat)
37+ copilotCache.mu.Unlock()
38+ })
3439
3540 mockRT := &mockRoundTripper{}
3641 transport := &copilotRefreshTransport{
@@ -70,15 +75,11 @@ func TestCopilotHostRewrite(t *testing.T) {
7075 }
7176
7277 func TestCopilotRoutesModernGPTToResponses(t *testing.T) {
73- router, ok := NewGithubCopilotClient("pat").(*modelRouter)
78+ client, ok := NewGithubCopilotClient("pat").(*copilotClient)
7479 if !ok {
75- t.Fatal("NewGithubCopilotClient did not return a modelRouter")
80+ t.Fatal("NewGithubCopilotClient did not return a copilotClient")
7681 }
77- responses, ok := router.byAPI[APIResponses].(*renamedClient)
78- if !ok {
79- t.Fatal("github-copilot router has no Responses client")
80- }
81- codex, ok := responses.inner.(*codexClient)
82+ codex, ok := client.router.byAPI[APIResponses].(*codexClient)
8283 if !ok {
8384 t.Fatal("github-copilot Responses client is not a codexClient")
8485 }
@@ -31,6 +31,11 @@ func TestCopilotHostRewrite(t *testing.T) {
31 baseURL: "https://api.enterprise.copilot.proxy",31 baseURL: "https://api.enterprise.copilot.proxy",
32 }32 }
33 copilotCache.mu.Unlock()33 copilotCache.mu.Unlock()
34+ t.Cleanup(func() {
35+ copilotCache.mu.Lock()
36+ delete(copilotCache.tokens, pat)
37+ copilotCache.mu.Unlock()
38+ })
34 39
35 mockRT := &mockRoundTripper{}40 mockRT := &mockRoundTripper{}
36 transport := &copilotRefreshTransport{41 transport := &copilotRefreshTransport{
@@ -70,15 +75,11 @@ func TestCopilotHostRewrite(t *testing.T) {
70 }75 }
71 76
72 func TestCopilotRoutesModernGPTToResponses(t *testing.T) {77 func TestCopilotRoutesModernGPTToResponses(t *testing.T) {
73- router, ok := NewGithubCopilotClient("pat").(*modelRouter)78+ client, ok := NewGithubCopilotClient("pat").(*copilotClient)
74 if !ok {79 if !ok {
75- t.Fatal("NewGithubCopilotClient did not return a modelRouter")80+ t.Fatal("NewGithubCopilotClient did not return a copilotClient")
76 }81 }
77- responses, ok := router.byAPI[APIResponses].(*renamedClient)82+ codex, ok := client.router.byAPI[APIResponses].(*codexClient)
78- if !ok {
79- t.Fatal("github-copilot router has no Responses client")
80- }
81- codex, ok := responses.inner.(*codexClient)
82 if !ok {83 if !ok {
83 t.Fatal("github-copilot Responses client is not a codexClient")84 t.Fatal("github-copilot Responses client is not a codexClient")
84 }85 }
A packages/provider/github_copilot_wire_test.go +174 -0
@@ -0,0 +1,174 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "testing"
11+ "time"
12+)
13+
14+func seedCopilotTestToken(t *testing.T, baseURL string) string {
15+ t.Helper()
16+ pat := "synthetic-" + t.Name()
17+ copilotCache.mu.Lock()
18+ copilotCache.tokens[pat] = copilotToken{value: "synthetic-session-token", baseURL: baseURL, expiresAt: time.Now().Add(time.Hour)}
19+ copilotCache.mu.Unlock()
20+ t.Cleanup(func() {
21+ copilotCache.mu.Lock()
22+ delete(copilotCache.tokens, pat)
23+ copilotCache.mu.Unlock()
24+ })
25+ return pat
26+}
27+
28+func TestCopilotWireProtocols(t *testing.T) {
29+ for _, tc := range []struct{ model, path string }{
30+ {"claude-sonnet-5", "/v1/messages"},
31+ {"claude-haiku-4.5", "/v1/messages"},
32+ {"gpt-5.4", "/responses"},
33+ {"grok-4.6", "/responses"},
34+ {"mai-code-1.1-flash", "/responses"},
35+ {"gemini-3.8-flash", "/chat/completions"},
36+ {"kimi-k3", "/chat/completions"},
37+ } {
38+ t.Run(tc.model, func(t *testing.T) {
39+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40+ if r.URL.Path != tc.path {
41+ t.Errorf("path = %q, want %q", r.URL.Path, tc.path)
42+ }
43+ if r.Header.Get("Authorization") != "Bearer synthetic-session-token" {
44+ t.Error("missing session Bearer auth")
45+ }
46+ if r.Header.Get("Copilot-Integration-Id") != "vscode-chat" || r.Header.Get("X-Initiator") != "user" || r.Header.Get("Openai-Intent") != "conversation-edits" {
47+ t.Error("missing Copilot identity headers")
48+ }
49+ for _, header := range []string{"x-api-key", "anthropic-beta", "x-app", "chatgpt-account-id", "openai-beta", "originator"} {
50+ if r.Header.Get(header) != "" {
51+ t.Errorf("unexpected identity header %s", header)
52+ }
53+ }
54+ var body map[string]json.RawMessage
55+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
56+ t.Error(err)
57+ w.WriteHeader(400)
58+ return
59+ }
60+ if string(body["model"]) != fmt.Sprintf("%q", tc.model) {
61+ t.Error("model ID changed on wire")
62+ }
63+ w.Header().Set("Content-Type", "text/event-stream")
64+ switch tc.path {
65+ case "/v1/messages":
66+ if len(body["messages"]) == 0 || len(body["max_tokens"]) == 0 {
67+ t.Error("missing Messages fields")
68+ }
69+ if strings.Contains(string(body["system"]), "Claude Code") {
70+ t.Error("injected Claude Code system identity")
71+ }
72+ if tc.model == "claude-sonnet-5" {
73+ if string(body["thinking"]) != `{"type":"adaptive"}` {
74+ t.Errorf("thinking = %s", body["thinking"])
75+ }
76+ if len(body["output_config"]) == 0 || len(body["temperature"]) != 0 {
77+ t.Error("invalid adaptive thinking payload")
78+ }
79+ }
80+ fmt.Fprint(w, "event: message_start\ndata: {\"message\":{\"id\":\"test\",\"usage\":{\"input_tokens\":1}}}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\nevent: message_stop\ndata: {}\n\n")
81+ case "/responses":
82+ if len(body["input"]) == 0 || len(body["reasoning"]) == 0 {
83+ t.Error("missing Responses fields")
84+ }
85+ fmt.Fprint(w, "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
86+ default:
87+ if len(body["reasoning_effort"]) != 0 {
88+ t.Error("unsupported reasoning_effort sent")
89+ }
90+ if len(body["messages"]) == 0 {
91+ t.Error("missing chat messages")
92+ }
93+ fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
94+ }
95+ }))
96+ defer srv.Close()
97+ client := NewGithubCopilotClient(seedCopilotTestToken(t, srv.URL))
98+ stream, err := client.Stream(context.Background(), Request{Model: tc.model, Reasoning: "high", System: "Test system", Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "Hello"}}}}})
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ for event := range stream {
103+ if e, ok := event.(EventDone); ok && e.Err != nil {
104+ t.Fatalf("stream error: %v", e.Err)
105+ }
106+ if e, ok := event.(EventStart); ok && e.Provider != "github-copilot" {
107+ t.Errorf("event provider = %s", e.Provider)
108+ }
109+ }
110+ })
111+ }
112+}
113+
114+type copilotMetadataCapture struct {
115+ metadata copilotRequestMetadata
116+ reasoning string
117+}
118+
119+func (c *copilotMetadataCapture) Name() string { return "github-copilot" }
120+func (c *copilotMetadataCapture) Stream(ctx context.Context, req Request) (<-chan Event, error) {
121+ c.metadata = ctx.Value(copilotRequestKey{}).(copilotRequestMetadata)
122+ c.reasoning = req.Reasoning
123+ ch := make(chan Event)
124+ close(ch)
125+ return ch, nil
126+}
127+
128+func TestCopilotDynamicRequestMetadata(t *testing.T) {
129+ client := NewGithubCopilotClient("unused").(*copilotClient)
130+ capture := &copilotMetadataCapture{}
131+ client.router.byAPI[APICompletions] = capture
132+ for _, tc := range []struct {
133+ messages []Message
134+ initiator string
135+ vision bool
136+ }{
137+ {nil, "user", false},
138+ {[]Message{{Role: RoleUser, Content: []Content{ImageBlock{}}}}, "user", true},
139+ {[]Message{{Role: RoleTool, Content: []Content{ToolResultBlock{Content: []Content{ImageBlock{}}}}}}, "agent", true},
140+ {[]Message{{Role: RoleAssistant}}, "agent", false},
141+ {[]Message{{Role: RoleUser}}, "user", false},
142+ } {
143+ _, err := client.Stream(context.Background(), Request{Model: "kimi-k3", Reasoning: "high", Messages: tc.messages})
144+ if err != nil {
145+ t.Fatal(err)
146+ }
147+ if capture.metadata.initiator != tc.initiator || capture.metadata.vision != tc.vision || capture.reasoning != "" {
148+ t.Errorf("unexpected metadata: %+v, reasoning %q", capture.metadata, capture.reasoning)
149+ }
150+ }
151+ // The transport must propagate vision metadata and remove it on the next text-only request.
152+ mock := &mockRoundTripper{}
153+ transport := &copilotRefreshTransport{inner: mock, pat: seedCopilotTestToken(t, copilotDefaultBaseURL)}
154+ for _, vision := range []bool{true, false} {
155+ ctx := context.WithValue(context.Background(), copilotRequestKey{}, copilotRequestMetadata{initiator: "agent", vision: vision})
156+ req, _ := http.NewRequestWithContext(ctx, "POST", copilotDefaultBaseURL+"/responses", nil)
157+ req.Header.Set("Copilot-Vision-Request", "stale")
158+ resp, err := transport.RoundTrip(req)
159+ if err != nil {
160+ t.Fatal(err)
161+ }
162+ resp.Body.Close()
163+ want := ""
164+ if vision {
165+ want = "true"
166+ }
167+ if mock.lastReq.Header.Get("Copilot-Vision-Request") != want || mock.lastReq.Header.Get("X-Initiator") != "agent" {
168+ t.Error("incorrect dynamic transport headers")
169+ }
170+ if req.Header.Get("Copilot-Vision-Request") != "stale" {
171+ t.Error("transport mutated caller headers")
172+ }
173+ }
174+}
@@ -0,0 +1,174 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "testing"
11+ "time"
12+)
13+
14+func seedCopilotTestToken(t *testing.T, baseURL string) string {
15+ t.Helper()
16+ pat := "synthetic-" + t.Name()
17+ copilotCache.mu.Lock()
18+ copilotCache.tokens[pat] = copilotToken{value: "synthetic-session-token", baseURL: baseURL, expiresAt: time.Now().Add(time.Hour)}
19+ copilotCache.mu.Unlock()
20+ t.Cleanup(func() {
21+ copilotCache.mu.Lock()
22+ delete(copilotCache.tokens, pat)
23+ copilotCache.mu.Unlock()
24+ })
25+ return pat
26+}
27+
28+func TestCopilotWireProtocols(t *testing.T) {
29+ for _, tc := range []struct{ model, path string }{
30+ {"claude-sonnet-5", "/v1/messages"},
31+ {"claude-haiku-4.5", "/v1/messages"},
32+ {"gpt-5.4", "/responses"},
33+ {"grok-4.6", "/responses"},
34+ {"mai-code-1.1-flash", "/responses"},
35+ {"gemini-3.8-flash", "/chat/completions"},
36+ {"kimi-k3", "/chat/completions"},
37+ } {
38+ t.Run(tc.model, func(t *testing.T) {
39+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40+ if r.URL.Path != tc.path {
41+ t.Errorf("path = %q, want %q", r.URL.Path, tc.path)
42+ }
43+ if r.Header.Get("Authorization") != "Bearer synthetic-session-token" {
44+ t.Error("missing session Bearer auth")
45+ }
46+ if r.Header.Get("Copilot-Integration-Id") != "vscode-chat" || r.Header.Get("X-Initiator") != "user" || r.Header.Get("Openai-Intent") != "conversation-edits" {
47+ t.Error("missing Copilot identity headers")
48+ }
49+ for _, header := range []string{"x-api-key", "anthropic-beta", "x-app", "chatgpt-account-id", "openai-beta", "originator"} {
50+ if r.Header.Get(header) != "" {
51+ t.Errorf("unexpected identity header %s", header)
52+ }
53+ }
54+ var body map[string]json.RawMessage
55+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
56+ t.Error(err)
57+ w.WriteHeader(400)
58+ return
59+ }
60+ if string(body["model"]) != fmt.Sprintf("%q", tc.model) {
61+ t.Error("model ID changed on wire")
62+ }
63+ w.Header().Set("Content-Type", "text/event-stream")
64+ switch tc.path {
65+ case "/v1/messages":
66+ if len(body["messages"]) == 0 || len(body["max_tokens"]) == 0 {
67+ t.Error("missing Messages fields")
68+ }
69+ if strings.Contains(string(body["system"]), "Claude Code") {
70+ t.Error("injected Claude Code system identity")
71+ }
72+ if tc.model == "claude-sonnet-5" {
73+ if string(body["thinking"]) != `{"type":"adaptive"}` {
74+ t.Errorf("thinking = %s", body["thinking"])
75+ }
76+ if len(body["output_config"]) == 0 || len(body["temperature"]) != 0 {
77+ t.Error("invalid adaptive thinking payload")
78+ }
79+ }
80+ fmt.Fprint(w, "event: message_start\ndata: {\"message\":{\"id\":\"test\",\"usage\":{\"input_tokens\":1}}}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\nevent: message_stop\ndata: {}\n\n")
81+ case "/responses":
82+ if len(body["input"]) == 0 || len(body["reasoning"]) == 0 {
83+ t.Error("missing Responses fields")
84+ }
85+ fmt.Fprint(w, "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
86+ default:
87+ if len(body["reasoning_effort"]) != 0 {
88+ t.Error("unsupported reasoning_effort sent")
89+ }
90+ if len(body["messages"]) == 0 {
91+ t.Error("missing chat messages")
92+ }
93+ fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
94+ }
95+ }))
96+ defer srv.Close()
97+ client := NewGithubCopilotClient(seedCopilotTestToken(t, srv.URL))
98+ stream, err := client.Stream(context.Background(), Request{Model: tc.model, Reasoning: "high", System: "Test system", Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "Hello"}}}}})
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ for event := range stream {
103+ if e, ok := event.(EventDone); ok && e.Err != nil {
104+ t.Fatalf("stream error: %v", e.Err)
105+ }
106+ if e, ok := event.(EventStart); ok && e.Provider != "github-copilot" {
107+ t.Errorf("event provider = %s", e.Provider)
108+ }
109+ }
110+ })
111+ }
112+}
113+
114+type copilotMetadataCapture struct {
115+ metadata copilotRequestMetadata
116+ reasoning string
117+}
118+
119+func (c *copilotMetadataCapture) Name() string { return "github-copilot" }
120+func (c *copilotMetadataCapture) Stream(ctx context.Context, req Request) (<-chan Event, error) {
121+ c.metadata = ctx.Value(copilotRequestKey{}).(copilotRequestMetadata)
122+ c.reasoning = req.Reasoning
123+ ch := make(chan Event)
124+ close(ch)
125+ return ch, nil
126+}
127+
128+func TestCopilotDynamicRequestMetadata(t *testing.T) {
129+ client := NewGithubCopilotClient("unused").(*copilotClient)
130+ capture := &copilotMetadataCapture{}
131+ client.router.byAPI[APICompletions] = capture
132+ for _, tc := range []struct {
133+ messages []Message
134+ initiator string
135+ vision bool
136+ }{
137+ {nil, "user", false},
138+ {[]Message{{Role: RoleUser, Content: []Content{ImageBlock{}}}}, "user", true},
139+ {[]Message{{Role: RoleTool, Content: []Content{ToolResultBlock{Content: []Content{ImageBlock{}}}}}}, "agent", true},
140+ {[]Message{{Role: RoleAssistant}}, "agent", false},
141+ {[]Message{{Role: RoleUser}}, "user", false},
142+ } {
143+ _, err := client.Stream(context.Background(), Request{Model: "kimi-k3", Reasoning: "high", Messages: tc.messages})
144+ if err != nil {
145+ t.Fatal(err)
146+ }
147+ if capture.metadata.initiator != tc.initiator || capture.metadata.vision != tc.vision || capture.reasoning != "" {
148+ t.Errorf("unexpected metadata: %+v, reasoning %q", capture.metadata, capture.reasoning)
149+ }
150+ }
151+ // The transport must propagate vision metadata and remove it on the next text-only request.
152+ mock := &mockRoundTripper{}
153+ transport := &copilotRefreshTransport{inner: mock, pat: seedCopilotTestToken(t, copilotDefaultBaseURL)}
154+ for _, vision := range []bool{true, false} {
155+ ctx := context.WithValue(context.Background(), copilotRequestKey{}, copilotRequestMetadata{initiator: "agent", vision: vision})
156+ req, _ := http.NewRequestWithContext(ctx, "POST", copilotDefaultBaseURL+"/responses", nil)
157+ req.Header.Set("Copilot-Vision-Request", "stale")
158+ resp, err := transport.RoundTrip(req)
159+ if err != nil {
160+ t.Fatal(err)
161+ }
162+ resp.Body.Close()
163+ want := ""
164+ if vision {
165+ want = "true"
166+ }
167+ if mock.lastReq.Header.Get("Copilot-Vision-Request") != want || mock.lastReq.Header.Get("X-Initiator") != "agent" {
168+ t.Error("incorrect dynamic transport headers")
169+ }
170+ if req.Header.Get("Copilot-Vision-Request") != "stale" {
171+ t.Error("transport mutated caller headers")
172+ }
173+ }
174+}
M packages/provider/gpt6_astra_test.go +3 -3
@@ -35,12 +35,12 @@ func TestGPT6AstraCatalog(t *testing.T) {
3535 }
3636
3737 func TestGPT6AstraResponsesReasoning(t *testing.T) {
38- copilot := NewGithubCopilotClient("test-token").(*modelRouter)
38+ copilot := NewGithubCopilotClient("test-token").(*copilotClient).router
3939 clients := map[string]*codexClient{
4040 "openai": NewOpenAIResponsesNamed("test-token", "", "openai").(*renamedClient).inner.(*codexClient),
4141 "openai-responses": NewOpenAIResponsesNamed("test-token", "", "openai-responses").(*renamedClient).inner.(*codexClient),
4242 "openai-codex": NewOpenAICodex("test-token", "test-account", "").(*codexClient),
43- "github-copilot": copilot.byAPI[APIResponses].(*renamedClient).inner.(*codexClient),
43+ "github-copilot": copilot.byAPI[APIResponses].(*codexClient),
4444 }
4545 for name, client := range clients {
4646 t.Run(name, func(t *testing.T) {
@@ -65,7 +65,7 @@ func TestGPT6AstraResponsesReasoning(t *testing.T) {
6565 }
6666
6767 func TestGPT6AstraCopilotDispatch(t *testing.T) {
68- router := NewGithubCopilotClient("test-token").(*modelRouter)
68+ router := NewGithubCopilotClient("test-token").(*copilotClient).router
6969 completions := &routeCaptureClient{name: "github-copilot"}
7070 responses := &routeCaptureClient{name: "github-copilot"}
7171 router.fallback = completions
@@ -35,12 +35,12 @@ func TestGPT6AstraCatalog(t *testing.T) {
35 }35 }
36 36
37 func TestGPT6AstraResponsesReasoning(t *testing.T) {37 func TestGPT6AstraResponsesReasoning(t *testing.T) {
38- copilot := NewGithubCopilotClient("test-token").(*modelRouter)38+ copilot := NewGithubCopilotClient("test-token").(*copilotClient).router
39 clients := map[string]*codexClient{39 clients := map[string]*codexClient{
40 "openai": NewOpenAIResponsesNamed("test-token", "", "openai").(*renamedClient).inner.(*codexClient),40 "openai": NewOpenAIResponsesNamed("test-token", "", "openai").(*renamedClient).inner.(*codexClient),
41 "openai-responses": NewOpenAIResponsesNamed("test-token", "", "openai-responses").(*renamedClient).inner.(*codexClient),41 "openai-responses": NewOpenAIResponsesNamed("test-token", "", "openai-responses").(*renamedClient).inner.(*codexClient),
42 "openai-codex": NewOpenAICodex("test-token", "test-account", "").(*codexClient),42 "openai-codex": NewOpenAICodex("test-token", "test-account", "").(*codexClient),
43- "github-copilot": copilot.byAPI[APIResponses].(*renamedClient).inner.(*codexClient),43+ "github-copilot": copilot.byAPI[APIResponses].(*codexClient),
44 }44 }
45 for name, client := range clients {45 for name, client := range clients {
46 t.Run(name, func(t *testing.T) {46 t.Run(name, func(t *testing.T) {
@@ -65,7 +65,7 @@ func TestGPT6AstraResponsesReasoning(t *testing.T) {
65 }65 }
66 66
67 func TestGPT6AstraCopilotDispatch(t *testing.T) {67 func TestGPT6AstraCopilotDispatch(t *testing.T) {
68- router := NewGithubCopilotClient("test-token").(*modelRouter)68+ router := NewGithubCopilotClient("test-token").(*copilotClient).router
69 completions := &routeCaptureClient{name: "github-copilot"}69 completions := &routeCaptureClient{name: "github-copilot"}
70 responses := &routeCaptureClient{name: "github-copilot"}70 responses := &routeCaptureClient{name: "github-copilot"}
71 router.fallback = completions71 router.fallback = completions
M packages/provider/model_router.go +2 -0
@@ -6,6 +6,8 @@ import (
66 )
77
88 const (
9+ // APIAnthropicMessages identifies the Anthropic Messages wire API.
10+ APIAnthropicMessages = "anthropic-messages"
911 // APICompletions identifies the OpenAI Chat Completions wire API.
1012 APICompletions = "openai-completions"
1113 // APIResponses identifies the OpenAI Responses wire API.
@@ -6,6 +6,8 @@ import (
6 )6 )
7 7
8 const (8 const (
9+ // APIAnthropicMessages identifies the Anthropic Messages wire API.
10+ APIAnthropicMessages = "anthropic-messages"
9 // APICompletions identifies the OpenAI Chat Completions wire API.11 // APICompletions identifies the OpenAI Chat Completions wire API.
10 APICompletions = "openai-completions"12 APICompletions = "openai-completions"
11 // APIResponses identifies the OpenAI Responses wire API.13 // APIResponses identifies the OpenAI Responses wire API.
M packages/provider/models.go +46 -7
@@ -449,10 +449,11 @@ var DefaultModel = Catalog[0] // claude-sonnet-4-5
449449 // models loaded via SetLiveModels.
450450
451451 var (
452- activeMu sync.RWMutex
453- active []Model // live overlay merged in via SetLiveModels; nil = none yet
454- activeSet bool // true once SetLiveModels has run (even with empty live)
455- managedModels []Model // ephemeral models exposed by local model managers
452+ activeMu sync.RWMutex
453+ active []Model // live overlay merged in via SetLiveModels; nil = none yet
454+ activeSet bool // true once SetLiveModels has run (even with empty live)
455+ managedModels []Model // ephemeral models exposed by local model managers
456+ modelAvailability = map[string]map[string]bool{} // account-specific, memory only
456457 )
457458
458459 // SetLiveModels replaces the "live" overlay used by the active catalog.
@@ -479,7 +480,9 @@ func SetLiveModels(live []Model) {
479480 // Snapshotting Catalog at var-init time would freeze the picker to the
480481 // curated seed list and drop every extra provider (openrouter, groq,
481482 // xai, ...). Deferring the read to call time avoids that ordering trap.
482-func Active() []Model {
483+func Active() []Model { return activeModels(true) }
484+
485+func activeModels(availableOnly bool) []Model {
483486 activeMu.RLock()
484487 defer activeMu.RUnlock()
485488 src := active
@@ -492,6 +495,9 @@ func Active() []Model {
492495 out[i].ReasoningLevelMap = maps.Clone(out[i].ReasoningLevelMap)
493496 }
494497 if len(managedModels) == 0 {
498+ if availableOnly {
499+ return filterAvailableModels(out)
500+ }
495501 return out
496502 }
497503 index := make(map[string]int, len(out))
@@ -508,6 +514,38 @@ func Active() []Model {
508514 index[key] = len(out)
509515 out = append(out, model)
510516 }
517+ if availableOnly {
518+ return filterAvailableModels(out)
519+ }
520+ return out
521+}
522+
523+// SetModelAvailability restricts a provider's visible catalog to the supplied
524+// account model IDs. A nil slice clears the restriction; an empty non-nil slice
525+// hides all models. This snapshot is not persisted in the shared model cache.
526+func SetModelAvailability(provider string, ids []string) {
527+ activeMu.Lock()
528+ defer activeMu.Unlock()
529+ if ids == nil {
530+ delete(modelAvailability, provider)
531+ return
532+ }
533+ allowed := make(map[string]bool, len(ids))
534+ for _, id := range ids {
535+ allowed[id] = true
536+ }
537+ modelAvailability[provider] = allowed
538+}
539+
540+// Caller holds activeMu. Models is already a private copy.
541+func filterAvailableModels(models []Model) []Model {
542+ out := models[:0]
543+ for _, m := range models {
544+ allowed, restricted := modelAvailability[m.Provider]
545+ if !restricted || allowed[m.ID] {
546+ out = append(out, m)
547+ }
548+ }
511549 return out
512550 }
513551
@@ -522,9 +560,10 @@ func SetManagedModels(models []Model) {
522560
523561 // FindModel returns a Model by id, optionally constrained by provider.
524562 // If provider is empty, the first matching id is returned. Looks up
525-// against the merged active catalog.
563+// against the merged catalog, including models hidden by account availability.
564+// Existing sessions still need stable wire and pricing metadata after a refresh.
526565 func FindModel(provider, id string) (Model, error) {
527- for _, m := range Active() {
566+ for _, m := range activeModels(false) {
528567 if m.ID == id && (provider == "" || m.Provider == provider) {
529568 return m, nil
530569 }
@@ -449,10 +449,11 @@ var DefaultModel = Catalog[0] // claude-sonnet-4-5
449 // models loaded via SetLiveModels.449 // models loaded via SetLiveModels.
450 450
451 var (451 var (
452- activeMu sync.RWMutex452+ activeMu sync.RWMutex
453- active []Model // live overlay merged in via SetLiveModels; nil = none yet453+ active []Model // live overlay merged in via SetLiveModels; nil = none yet
454- activeSet bool // true once SetLiveModels has run (even with empty live)454+ activeSet bool // true once SetLiveModels has run (even with empty live)
455- managedModels []Model // ephemeral models exposed by local model managers455+ managedModels []Model // ephemeral models exposed by local model managers
456+ modelAvailability = map[string]map[string]bool{} // account-specific, memory only
456 )457 )
457 458
458 // SetLiveModels replaces the "live" overlay used by the active catalog.459 // SetLiveModels replaces the "live" overlay used by the active catalog.
@@ -479,7 +480,9 @@ func SetLiveModels(live []Model) {
479 // Snapshotting Catalog at var-init time would freeze the picker to the480 // Snapshotting Catalog at var-init time would freeze the picker to the
480 // curated seed list and drop every extra provider (openrouter, groq,481 // curated seed list and drop every extra provider (openrouter, groq,
481 // xai, ...). Deferring the read to call time avoids that ordering trap.482 // xai, ...). Deferring the read to call time avoids that ordering trap.
482-func Active() []Model {483+func Active() []Model { return activeModels(true) }
484+
485+func activeModels(availableOnly bool) []Model {
483 activeMu.RLock()486 activeMu.RLock()
484 defer activeMu.RUnlock()487 defer activeMu.RUnlock()
485 src := active488 src := active
@@ -492,6 +495,9 @@ func Active() []Model {
492 out[i].ReasoningLevelMap = maps.Clone(out[i].ReasoningLevelMap)495 out[i].ReasoningLevelMap = maps.Clone(out[i].ReasoningLevelMap)
493 }496 }
494 if len(managedModels) == 0 {497 if len(managedModels) == 0 {
498+ if availableOnly {
499+ return filterAvailableModels(out)
500+ }
495 return out501 return out
496 }502 }
497 index := make(map[string]int, len(out))503 index := make(map[string]int, len(out))
@@ -508,6 +514,38 @@ func Active() []Model {
508 index[key] = len(out)514 index[key] = len(out)
509 out = append(out, model)515 out = append(out, model)
510 }516 }
517+ if availableOnly {
518+ return filterAvailableModels(out)
519+ }
520+ return out
521+}
522+
523+// SetModelAvailability restricts a provider's visible catalog to the supplied
524+// account model IDs. A nil slice clears the restriction; an empty non-nil slice
525+// hides all models. This snapshot is not persisted in the shared model cache.
526+func SetModelAvailability(provider string, ids []string) {
527+ activeMu.Lock()
528+ defer activeMu.Unlock()
529+ if ids == nil {
530+ delete(modelAvailability, provider)
531+ return
532+ }
533+ allowed := make(map[string]bool, len(ids))
534+ for _, id := range ids {
535+ allowed[id] = true
536+ }
537+ modelAvailability[provider] = allowed
538+}
539+
540+// Caller holds activeMu. Models is already a private copy.
541+func filterAvailableModels(models []Model) []Model {
542+ out := models[:0]
543+ for _, m := range models {
544+ allowed, restricted := modelAvailability[m.Provider]
545+ if !restricted || allowed[m.ID] {
546+ out = append(out, m)
547+ }
548+ }
511 return out549 return out
512 }550 }
513 551
@@ -522,9 +560,10 @@ func SetManagedModels(models []Model) {
522 560
523 // FindModel returns a Model by id, optionally constrained by provider.561 // FindModel returns a Model by id, optionally constrained by provider.
524 // If provider is empty, the first matching id is returned. Looks up562 // If provider is empty, the first matching id is returned. Looks up
525-// against the merged active catalog.563+// against the merged catalog, including models hidden by account availability.
564+// Existing sessions still need stable wire and pricing metadata after a refresh.
526 func FindModel(provider, id string) (Model, error) {565 func FindModel(provider, id string) (Model, error) {
527- for _, m := range Active() {566+ for _, m := range activeModels(false) {
528 if m.ID == id && (provider == "" || m.Provider == provider) {567 if m.ID == id && (provider == "" || m.Provider == provider) {
529 return m, nil568 return m, nil
530 }569 }
M packages/provider/openai_url_test.go +3 -3
@@ -40,11 +40,11 @@ func TestChatCompletionsURL(t *testing.T) {
4040 }
4141
4242 func TestGithubCopilotChatCompletionsURL(t *testing.T) {
43- router, ok := NewGithubCopilotClient("test-token").(*modelRouter)
43+ copilot, ok := NewGithubCopilotClient("test-token").(*copilotClient)
4444 if !ok {
45- t.Fatal("NewGithubCopilotClient did not return a modelRouter")
45+ t.Fatal("NewGithubCopilotClient did not return a copilotClient")
4646 }
47- client, ok := router.fallback.(*openaiClient)
47+ client, ok := copilot.router.fallback.(*openaiClient)
4848 if !ok {
4949 t.Fatal("GitHub Copilot router fallback is not an openaiClient")
5050 }
@@ -40,11 +40,11 @@ func TestChatCompletionsURL(t *testing.T) {
40 }40 }
41 41
42 func TestGithubCopilotChatCompletionsURL(t *testing.T) {42 func TestGithubCopilotChatCompletionsURL(t *testing.T) {
43- router, ok := NewGithubCopilotClient("test-token").(*modelRouter)43+ copilot, ok := NewGithubCopilotClient("test-token").(*copilotClient)
44 if !ok {44 if !ok {
45- t.Fatal("NewGithubCopilotClient did not return a modelRouter")45+ t.Fatal("NewGithubCopilotClient did not return a copilotClient")
46 }46 }
47- client, ok := router.fallback.(*openaiClient)47+ client, ok := copilot.router.fallback.(*openaiClient)
48 if !ok {48 if !ok {
49 t.Fatal("GitHub Copilot router fallback is not an openaiClient")49 t.Fatal("GitHub Copilot router fallback is not an openaiClient")
50 }50 }

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