Skip to content

Commit 2662aed

Browse files
authored
🤖 fix: align GitHub Copilot model routing (#3104)
## Summary This PR aligns GitHub Copilot model routing with the account's model catalog. All non-Codex models use chat completions. Codex models use a custom Responses language model that handles Copilot's SSE stream format. Copilot can now route Anthropic and Google models (with dot-vs-dash ID normalization). Catalog entries are hidden from the selector. ## Background Copilot auth persisted the signed-in user's model list, but routing ignored it and only routed OpenAI models. Non-OpenAI Copilot models (Claude, Gemini) were unreachable. Codex models were stuck: `/chat/completions` rejects them, and the standard AI SDK Responses parser can't handle Copilot's SSE stream format. Additionally, Copilot's catalog uses dot-form Claude IDs (`claude-opus-4.6`) while Mux's built-in models use dashes (`claude-opus-4-6`). ## Implementation - add a custom `CopilotResponsesLanguageModel` that handles Copilot's Responses API SSE stream directly, bypassing the AI SDK's broken parser (following opencode's approach) - route Codex models through the custom Responses model, all other Copilot models through chat completions - expand Copilot gateway routes to include `anthropic` and `google` - normalize Claude model IDs (dot-vs-dash: `4.6` matches `4-6`) so Copilot catalog entries match built-in models - hide `github-copilot:*` entries from the model selector while keeping the catalog for route gating - check credentials before catalog availability for correct error messages - classify initiator from pre-normalization body for correct billing headers - treat malformed catalog entries as absent for self-healing ## Validation - `make static-check` - `bun test src/common/utils/copilot/modelRouting.test.ts src/node/services/providerModelFactory.test.ts src/node/services/copilot/copilotResponsesLanguageModel.test.ts src/browser/hooks/useRouting.test.ts src/browser/hooks/useModelsFromSettings.test.ts src/common/routing/resolve.test.ts src/browser/utils/compaction/suggestion.test.ts src/common/utils/providers/gatewayModelCatalog.test.ts` ## Risks Medium. The custom `CopilotResponsesLanguageModel` is a new language model implementation that directly parses Copilot's SSE stream. It's scoped to Codex-family models only to minimize blast radius. Tool call streaming is not yet covered (text-only). Adding Anthropic/Google to Copilot routes means users with both Copilot and direct API keys will route through Copilot when it's prioritized (the priority system is user-controlled and defaults to `["direct"]`). --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$23.81`_ <!-- mux-attribution: model=openai:gpt-5.4 thinking=xhigh costs=23.81 -->
1 parent fb25a63 commit 2662aed

26 files changed

Lines changed: 2999 additions & 200 deletions

src/browser/hooks/useModelsFromSettings.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,122 @@ describe("useModelsFromSettings provider availability gating", () => {
432432
expect(result.current.hiddenModelsForSelector).not.toContain(KNOWN_MODELS.HAIKU.id);
433433
});
434434

435+
test("does not treat custom gateway model entries as an exhaustive route catalog", () => {
436+
providersConfig = {
437+
openai: { apiKeySet: false, isEnabled: true, isConfigured: false },
438+
openrouter: {
439+
apiKeySet: true,
440+
isEnabled: true,
441+
isConfigured: true,
442+
models: [OPENROUTER_OPENAI_CUSTOM_MODEL],
443+
},
444+
};
445+
routePriority = ["openrouter", "direct"];
446+
447+
const { result } = renderHook(() => useModelsFromSettings());
448+
449+
expect(result.current.models).toContain(KNOWN_MODELS.GPT.id);
450+
expect(result.current.hiddenModelsForSelector).not.toContain(KNOWN_MODELS.GPT.id);
451+
});
452+
453+
test("hides models that a configured gateway does not expose", () => {
454+
providersConfig = {
455+
openai: { apiKeySet: false, isEnabled: true, isConfigured: false },
456+
"github-copilot": {
457+
apiKeySet: true,
458+
isEnabled: true,
459+
isConfigured: true,
460+
models: [KNOWN_MODELS.GPT_54_MINI.providerModelId],
461+
},
462+
};
463+
routePriority = ["github-copilot", "direct"];
464+
465+
const { result } = renderHook(() => useModelsFromSettings());
466+
467+
expect(result.current.models).not.toContain(KNOWN_MODELS.GPT.id);
468+
expect(result.current.hiddenModelsForSelector).toContain(KNOWN_MODELS.GPT.id);
469+
});
470+
471+
test("keeps Copilot catalogs authoritative without surfacing selector entries", () => {
472+
providersConfig = {
473+
openai: { apiKeySet: false, isEnabled: true, isConfigured: false },
474+
"github-copilot": {
475+
apiKeySet: true,
476+
isEnabled: true,
477+
isConfigured: true,
478+
models: [KNOWN_MODELS.GPT_54_MINI.providerModelId],
479+
},
480+
};
481+
routePriority = ["github-copilot", "direct"];
482+
483+
const { result } = renderHook(() => useModelsFromSettings());
484+
485+
expect(result.current.customModels.some((model) => model.startsWith("github-copilot:"))).toBe(
486+
false
487+
);
488+
expect(
489+
result.current.hiddenModelsForSelector.some((model) => model.startsWith("github-copilot:"))
490+
).toBe(false);
491+
expect(result.current.models).not.toContain(KNOWN_MODELS.GPT.id);
492+
expect(result.current.hiddenModelsForSelector).toContain(KNOWN_MODELS.GPT.id);
493+
});
494+
495+
test("keeps models visible when a configured gateway exposes them", () => {
496+
providersConfig = {
497+
openai: { apiKeySet: false, isEnabled: true, isConfigured: false },
498+
"github-copilot": {
499+
apiKeySet: true,
500+
isEnabled: true,
501+
isConfigured: true,
502+
models: [KNOWN_MODELS.GPT.providerModelId],
503+
},
504+
};
505+
routePriority = ["github-copilot", "direct"];
506+
507+
const { result } = renderHook(() => useModelsFromSettings());
508+
509+
expect(result.current.models).toContain(KNOWN_MODELS.GPT.id);
510+
expect(result.current.hiddenModelsForSelector).not.toContain(KNOWN_MODELS.GPT.id);
511+
});
512+
513+
test("keeps Anthropic models visible when Copilot catalog contains dot-form IDs", () => {
514+
providersConfig = {
515+
anthropic: { apiKeySet: false, isEnabled: true, isConfigured: false },
516+
"github-copilot": {
517+
apiKeySet: true,
518+
isEnabled: true,
519+
isConfigured: true,
520+
models: ["claude-opus-4.6"],
521+
},
522+
};
523+
routePriority = ["github-copilot", "direct"];
524+
525+
const { result } = renderHook(() => useModelsFromSettings());
526+
527+
expect(result.current.models).toContain(KNOWN_MODELS.OPUS.id);
528+
expect(result.current.hiddenModelsForSelector).not.toContain(KNOWN_MODELS.OPUS.id);
529+
expect(result.current.customModels.some((model) => model.startsWith("github-copilot:"))).toBe(
530+
false
531+
);
532+
});
533+
534+
test("keeps gateway-routed models visible when no gateway model list is present", () => {
535+
providersConfig = {
536+
openai: { apiKeySet: false, isEnabled: true, isConfigured: false },
537+
"github-copilot": {
538+
apiKeySet: true,
539+
isEnabled: true,
540+
isConfigured: true,
541+
},
542+
};
543+
routePriority = ["github-copilot", "direct"];
544+
545+
const { result } = renderHook(() => useModelsFromSettings());
546+
547+
expect(result.current.models).toContain(KNOWN_MODELS.GPT.id);
548+
expect(result.current.hiddenModelsForSelector).not.toContain(KNOWN_MODELS.GPT.id);
549+
});
550+
435551
test("excludes OAuth-gated OpenAI models from hidden bucket when unconfigured", () => {
436552
// OpenAI is unconfigured and neither API key nor OAuth is set.
437553
providersConfig = {

src/browser/hooks/useModelsFromSettings.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ import { isModelAvailable } from "@/common/routing";
2121
import type { ProviderModelEntry, ProvidersConfigMap } from "@/common/orpc/types";
2222
import { DEFAULT_MODEL_KEY, HIDDEN_MODELS_KEY } from "@/common/constants/storage";
2323

24+
import {
25+
isGatewayModelAccessibleFromAuthoritativeCatalog,
26+
isProviderModelAccessibleFromAuthoritativeCatalog,
27+
} from "@/common/utils/providers/gatewayModelCatalog";
2428
import { getProviderModelEntryId } from "@/common/utils/providers/modelEntries";
2529

2630
const BUILT_IN_MODELS: string[] = Object.values(KNOWN_MODELS).map((m) => m.id);
@@ -32,6 +36,8 @@ function getCustomModels(config: ProvidersConfigMap | null): string[] {
3236
for (const [provider, info] of Object.entries(config)) {
3337
// Skip mux-gateway - those models are accessed via the cloud toggle, not listed separately
3438
if (provider === "mux-gateway") continue;
39+
// Keep github-copilot's persisted catalog for authoritative model gating, not direct selector entries.
40+
if (provider === "github-copilot") continue;
3541
// Only surface custom models from enabled providers
3642
if (!info.isEnabled) continue;
3743
if (!info.models) continue;
@@ -50,6 +56,8 @@ function getAllCustomModels(config: ProvidersConfigMap | null): string[] {
5056
for (const [provider, info] of Object.entries(config)) {
5157
// Skip mux-gateway - those models are accessed via the cloud toggle, not listed separately
5258
if (provider === "mux-gateway") continue;
59+
// Keep github-copilot's persisted catalog for authoritative model gating, not direct selector entries.
60+
if (provider === "github-copilot") continue;
5361
if (!info.models) continue;
5462

5563
for (const modelEntry of info.models) {
@@ -155,10 +163,36 @@ export function useModelsFromSettings() {
155163
[config]
156164
);
157165

166+
const isGatewayModelAccessible = useCallback(
167+
(gateway: string, modelId: string) =>
168+
isGatewayModelAccessibleFromAuthoritativeCatalog(gateway, modelId, config?.[gateway]?.models),
169+
[config]
170+
);
171+
172+
const isAuthoritativeProviderModelAccessible = useCallback(
173+
(modelString: string) => {
174+
const colonIndex = modelString.indexOf(":");
175+
if (colonIndex <= 0 || colonIndex >= modelString.length - 1) {
176+
return true;
177+
}
178+
179+
const provider = modelString.slice(0, colonIndex);
180+
const providerModelId = modelString.slice(colonIndex + 1);
181+
return isProviderModelAccessibleFromAuthoritativeCatalog(
182+
provider,
183+
providerModelId,
184+
config?.[provider]?.models
185+
);
186+
},
187+
[config]
188+
);
189+
158190
const customModels = useMemo(() => {
159-
const next = filterHiddenModels(getCustomModels(config), hiddenModels);
191+
const next = filterHiddenModels(getCustomModels(config), hiddenModels).filter(
192+
isAuthoritativeProviderModelAccessible
193+
);
160194
return effectivePolicy ? next.filter((m) => isModelAllowedByPolicy(effectivePolicy, m)) : next;
161-
}, [config, hiddenModels, effectivePolicy]);
195+
}, [config, hiddenModels, effectivePolicy, isAuthoritativeProviderModelAccessible]);
162196

163197
const openaiApiKeySet = config === null ? null : config.openai?.apiKeySet === true;
164198
const codexOauthSet = config === null ? null : config.openai?.codexOauthSet === true;
@@ -179,7 +213,19 @@ export function useModelsFromSettings() {
179213
return false;
180214
}
181215

182-
if (isModelAvailable(modelId, routePriority, routeOverrides, isConfigured)) {
216+
if (!isAuthoritativeProviderModelAccessible(modelId)) {
217+
return true;
218+
}
219+
220+
if (
221+
isModelAvailable(
222+
modelId,
223+
routePriority,
224+
routeOverrides,
225+
isConfigured,
226+
isGatewayModelAccessible
227+
)
228+
) {
183229
return false;
184230
}
185231

@@ -205,6 +251,8 @@ export function useModelsFromSettings() {
205251
hiddenModels,
206252
effectivePolicy,
207253
isConfigured,
254+
isGatewayModelAccessible,
255+
isAuthoritativeProviderModelAccessible,
208256
routePriority,
209257
routeOverrides,
210258
openaiApiKeySet,
@@ -224,8 +272,16 @@ export function useModelsFromSettings() {
224272
const providerFiltered =
225273
config == null
226274
? suggested
227-
: suggested.filter((modelId) =>
228-
isModelAvailable(modelId, routePriority, routeOverrides, isConfigured)
275+
: suggested.filter(
276+
(modelId) =>
277+
isAuthoritativeProviderModelAccessible(modelId) &&
278+
isModelAvailable(
279+
modelId,
280+
routePriority,
281+
routeOverrides,
282+
isConfigured,
283+
isGatewayModelAccessible
284+
)
229285
);
230286

231287
if (config == null) {
@@ -263,6 +319,8 @@ export function useModelsFromSettings() {
263319
hiddenModels,
264320
effectivePolicy,
265321
isConfigured,
322+
isGatewayModelAccessible,
323+
isAuthoritativeProviderModelAccessible,
266324
routePriority,
267325
routeOverrides,
268326
openaiApiKeySet,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2+
import { cleanup, renderHook, waitFor } from "@testing-library/react";
3+
import { GlobalWindow } from "happy-dom";
4+
import React from "react";
5+
6+
import { APIProvider, type APIClient } from "@/browser/contexts/API";
7+
import { KNOWN_MODELS } from "@/common/constants/knownModels";
8+
import type { ProvidersConfigMap } from "@/common/orpc/types";
9+
10+
import { useRouting } from "./useRouting";
11+
12+
let providersConfig: ProvidersConfigMap | null = null;
13+
let routePriority: string[] = ["direct"];
14+
let routeOverrides: Record<string, string> = {};
15+
let configGetConfig: () => Promise<{
16+
routePriority: string[];
17+
routeOverrides: Record<string, string>;
18+
}>;
19+
20+
async function* emptyStream() {
21+
await Promise.resolve();
22+
for (const item of [] as unknown[]) {
23+
yield item;
24+
}
25+
}
26+
27+
function createStubApiClient(): APIClient {
28+
return {
29+
providers: {
30+
getConfig: () => Promise.resolve(providersConfig),
31+
onConfigChanged: () => Promise.resolve(emptyStream()),
32+
},
33+
config: {
34+
getConfig: () => configGetConfig(),
35+
onConfigChanged: () => Promise.resolve(emptyStream()),
36+
updateRoutePreferences: () => Promise.resolve(undefined),
37+
},
38+
} as unknown as APIClient;
39+
}
40+
41+
const stubClient = createStubApiClient();
42+
43+
const wrapper: React.FC<{ children: React.ReactNode }> = (props) =>
44+
React.createElement(
45+
APIProvider,
46+
{ client: stubClient } as React.ComponentProps<typeof APIProvider>,
47+
props.children
48+
);
49+
50+
describe("useRouting", () => {
51+
beforeEach(() => {
52+
globalThis.window = new GlobalWindow({ url: "https://mux.example.com/" }) as unknown as Window &
53+
typeof globalThis;
54+
globalThis.document = globalThis.window.document;
55+
providersConfig = null;
56+
routePriority = ["direct"];
57+
routeOverrides = {};
58+
configGetConfig = () => Promise.resolve({ routePriority, routeOverrides });
59+
});
60+
61+
afterEach(() => {
62+
cleanup();
63+
});
64+
65+
test("resolveRoute and availableRoutes honor gateway model accessibility", async () => {
66+
providersConfig = {
67+
openai: { apiKeySet: true, isEnabled: true, isConfigured: true },
68+
"github-copilot": {
69+
apiKeySet: true,
70+
isEnabled: true,
71+
isConfigured: true,
72+
models: [KNOWN_MODELS.GPT_54_MINI.providerModelId],
73+
},
74+
};
75+
76+
const { result } = renderHook(() => useRouting(), { wrapper });
77+
78+
await waitFor(() => {
79+
expect(
80+
result.current
81+
.availableRoutes(KNOWN_MODELS.GPT.id)
82+
.some((route) => route.route === "github-copilot")
83+
).toBe(false);
84+
});
85+
86+
expect(result.current.resolveRoute(KNOWN_MODELS.GPT.id)).toEqual({
87+
route: "direct",
88+
isAuto: true,
89+
displayName: "Direct",
90+
});
91+
});
92+
});

0 commit comments

Comments
 (0)