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: | |||
| 90 | 90 | OAuth tokens are stored in `$ZOT_HOME/auth.json` and refreshed when refresh is | |
| 91 | 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 | 142 | ## GPT-6 Astra | |
| 94 | 143 | ||
| 95 | 144 | 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 is | 90 | 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 Astra | 142 | ## 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 { | |||
| 215 | 215 | case "azure-openai-responses": | |
| 216 | 216 | return "gpt-5" | |
| 217 | 217 | case "github-copilot": | |
| 218 | - | return "claude-sonnet-4.5" | |
| 218 | + | return "claude-sonnet-5" | |
| 219 | 219 | default: | |
| 220 | 220 | // Custom providers: pick the first model from the catalog for | |
| 221 | 221 | // 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 for | 220 | // 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() { | |||
| 141 | 141 | // the baked-in catalog if this fails. | |
| 142 | 142 | func RefreshModelsAsync() { | |
| 143 | 143 | go refreshModels() | |
| 144 | + | go refreshCopilotModelAvailability() | |
| 144 | 145 | go func() { | |
| 145 | 146 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |
| 146 | 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 | 168 | // RefreshLlamaCPPModels adds the router's currently loaded models to the | |
| 152 | 169 | // active catalog. Unloaded models remain in the management UI and cannot be | |
| 153 | 170 | // 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 the | 168 | // RefreshLlamaCPPModels adds the router's currently loaded models to the | ||
| 152 | // active catalog. Unloaded models remain in the management UI and cannot be | 169 | // 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 | |||
| 12 | 12 | // the small seed list for kimi / deepseek / google) are not duplicated | |
| 13 | 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 | 24 | var builtinCatalog = []Model{ | |
| 18 | 25 | // ----- amazon-bedrock ----- | |
| @@ -139,23 +146,29 @@ var builtinCatalog = []Model{ | |||
| 139 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 180 | // ----- google ----- | |
| 169 | 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 | 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"}, | |
| @@ -12,7 +12,14 @@ package provider | |||||
| 12 | // the small seed list for kimi / deepseek / google) are not duplicated | 12 | // 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 { | |||
| 337 | 337 | // access enabled; zot trades it for a short-lived Copilot token on | |
| 338 | 338 | // every inference request (cached in memory until ~5min before expiry). | |
| 339 | 339 | // | |
| 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 | |
| 341 | 342 | // (X-Initiator, Openai-Intent, Editor-Version, Editor-Plugin-Version, | |
| 342 | 343 | // Copilot-Integration-Id, User-Agent) are added by the refresh | |
| 343 | 344 | // 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 on | 337 | // 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 headers | 340 | + | // 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 refresh | 343 | // 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 | |||
| 14 | 14 | // User-Agent). The response carries `{ "token": "...", "expires_at": <unix> }`. | |
| 15 | 15 | // 3. The short-lived token's value embeds a `proxy-ep=<host>` field that | |
| 16 | 16 | // 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 | |
| 18 | 18 | // short-lived token in `Authorization: Bearer` plus extras: | |
| 19 | 19 | // - X-Initiator: user|agent | |
| 20 | 20 | // - 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) | |
| 23 | 22 | // | |
| 24 | 23 | // Token caching: short-lived tokens last ~30min. We cache one per PAT in | |
| 25 | 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 | 162 | clone := req.Clone(req.Context()) | |
| 164 | 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 | 166 | // Identity headers also required on inference requests. | |
| 166 | 167 | for k, v := range copilotIdentityHeaders { | |
| 167 | 168 | clone.Header.Set(k, v) | |
| 168 | 169 | } | |
| 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 | + | } | |
| 171 | 179 | // If the request URL host doesn't match the token's proxy-ep, rewrite | |
| 172 | 180 | // it. The openaiClient pinned a static host at construction time, but | |
| 173 | 181 | // the canonical host comes from the token. | |
| @@ -199,14 +207,8 @@ func (t *copilotResponsesStripTransport) RoundTrip(req *http.Request) (*http.Res | |||
| 199 | 207 | ||
| 200 | 208 | const copilotDefaultBaseURL = "https://api.individual.githubcopilot.com" | |
| 201 | 209 | ||
| 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. | |
| 210 | 212 | func NewGithubCopilotClient(pat string) Client { | |
| 211 | 213 | refresh := &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat} | |
| 212 | 214 | completionsHTTP := &http.Client{Transport: refresh, Timeout: 0} | |
| @@ -233,7 +235,15 @@ func NewGithubCopilotClient(pat string) Client { | |||
| 233 | 235 | http: responsesHTTP, | |
| 234 | 236 | } | |
| 235 | 237 | ||
| 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)} | |
| 239 | 249 | } | |
| @@ -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 that | 15 | // 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 the | 17 | + | // 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|agent | 19 | // - X-Initiator: user|agent | ||
| 20 | // - Openai-Intent: conversation-edits | 20 | // - Openai-Intent: conversation-edits | ||
| 21 | - | // - Copilot-Vision-Request: true (when images present; not wired | 21 | + | // - 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 in | 23 | // 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, rewrite | 179 | // 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, but | 180 | // 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) { | |||
| 31 | 31 | baseURL: "https://api.enterprise.copilot.proxy", | |
| 32 | 32 | } | |
| 33 | 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 | 40 | mockRT := &mockRoundTripper{} | |
| 36 | 41 | transport := &copilotRefreshTransport{ | |
| @@ -70,15 +75,11 @@ func TestCopilotHostRewrite(t *testing.T) { | |||
| 70 | 75 | } | |
| 71 | 76 | ||
| 72 | 77 | func TestCopilotRoutesModernGPTToResponses(t *testing.T) { | |
| 73 | - | router, ok := NewGithubCopilotClient("pat").(*modelRouter) | |
| 78 | + | client, ok := NewGithubCopilotClient("pat").(*copilotClient) | |
| 74 | 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) | |
| 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) | |
| 82 | 83 | if !ok { | |
| 83 | 84 | t.Fatal("github-copilot Responses client is not a codexClient") | |
| 84 | 85 | } | |
| @@ -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) { | |||
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | 37 | func TestGPT6AstraResponsesReasoning(t *testing.T) { | |
| 38 | - | copilot := NewGithubCopilotClient("test-token").(*modelRouter) | |
| 38 | + | copilot := NewGithubCopilotClient("test-token").(*copilotClient).router | |
| 39 | 39 | clients := map[string]*codexClient{ | |
| 40 | 40 | "openai": NewOpenAIResponsesNamed("test-token", "", "openai").(*renamedClient).inner.(*codexClient), | |
| 41 | 41 | "openai-responses": NewOpenAIResponsesNamed("test-token", "", "openai-responses").(*renamedClient).inner.(*codexClient), | |
| 42 | 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 | 45 | for name, client := range clients { | |
| 46 | 46 | t.Run(name, func(t *testing.T) { | |
| @@ -65,7 +65,7 @@ func TestGPT6AstraResponsesReasoning(t *testing.T) { | |||
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | 67 | func TestGPT6AstraCopilotDispatch(t *testing.T) { | |
| 68 | - | router := NewGithubCopilotClient("test-token").(*modelRouter) | |
| 68 | + | router := NewGithubCopilotClient("test-token").(*copilotClient).router | |
| 69 | 69 | completions := &routeCaptureClient{name: "github-copilot"} | |
| 70 | 70 | responses := &routeCaptureClient{name: "github-copilot"} | |
| 71 | 71 | 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 = completions | 71 | router.fallback = completions | ||
M packages/provider/model_router.go +2 -0
| @@ -6,6 +6,8 @@ import ( | |||
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | const ( | |
| 9 | + | // APIAnthropicMessages identifies the Anthropic Messages wire API. | |
| 10 | + | APIAnthropicMessages = "anthropic-messages" | |
| 9 | 11 | // APICompletions identifies the OpenAI Chat Completions wire API. | |
| 10 | 12 | APICompletions = "openai-completions" | |
| 11 | 13 | // 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 | |||
| 449 | 449 | // models loaded via SetLiveModels. | |
| 450 | 450 | ||
| 451 | 451 | 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 | |
| 456 | 457 | ) | |
| 457 | 458 | ||
| 458 | 459 | // SetLiveModels replaces the "live" overlay used by the active catalog. | |
| @@ -479,7 +480,9 @@ func SetLiveModels(live []Model) { | |||
| 479 | 480 | // Snapshotting Catalog at var-init time would freeze the picker to the | |
| 480 | 481 | // curated seed list and drop every extra provider (openrouter, groq, | |
| 481 | 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 | 486 | activeMu.RLock() | |
| 484 | 487 | defer activeMu.RUnlock() | |
| 485 | 488 | src := active | |
| @@ -492,6 +495,9 @@ func Active() []Model { | |||
| 492 | 495 | out[i].ReasoningLevelMap = maps.Clone(out[i].ReasoningLevelMap) | |
| 493 | 496 | } | |
| 494 | 497 | if len(managedModels) == 0 { | |
| 498 | + | if availableOnly { | |
| 499 | + | return filterAvailableModels(out) | |
| 500 | + | } | |
| 495 | 501 | return out | |
| 496 | 502 | } | |
| 497 | 503 | index := make(map[string]int, len(out)) | |
| @@ -508,6 +514,38 @@ func Active() []Model { | |||
| 508 | 514 | index[key] = len(out) | |
| 509 | 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 | 549 | return out | |
| 512 | 550 | } | |
| 513 | 551 | ||
| @@ -522,9 +560,10 @@ func SetManagedModels(models []Model) { | |||
| 522 | 560 | ||
| 523 | 561 | // FindModel returns a Model by id, optionally constrained by provider. | |
| 524 | 562 | // 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 | 565 | func FindModel(provider, id string) (Model, error) { | |
| 527 | - | for _, m := range Active() { | |
| 566 | + | for _, m := range activeModels(false) { | |
| 528 | 567 | if m.ID == id && (provider == "" || m.Provider == provider) { | |
| 529 | 568 | return m, nil | |
| 530 | 569 | } | |
| @@ -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.RWMutex | 452 | + | activeMu sync.RWMutex |
| 453 | - | active []Model // live overlay merged in via SetLiveModels; nil = none yet | 453 | + | 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 managers | 455 | + | 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 the | 480 | // 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 := active | 488 | 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 out | 501 | 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 out | 549 | 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 up | 562 | // 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, nil | 568 | return m, nil | ||
| 530 | } | 569 | } | ||
M packages/provider/openai_url_test.go +3 -3
| @@ -40,11 +40,11 @@ func TestChatCompletionsURL(t *testing.T) { | |||
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | func TestGithubCopilotChatCompletionsURL(t *testing.T) { | |
| 43 | - | router, ok := NewGithubCopilotClient("test-token").(*modelRouter) | |
| 43 | + | copilot, ok := NewGithubCopilotClient("test-token").(*copilotClient) | |
| 44 | 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 | 48 | if !ok { | |
| 49 | 49 | t.Fatal("GitHub Copilot router fallback is not an openaiClient") | |
| 50 | 50 | } | |
| @@ -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 | } | ||