Skip to content

Commit 3e263d7

Browse files
committed
🤖 fix: gate direct Copilot model entries
Apply the authoritative Copilot catalog check to direct provider model strings so hidden and custom model lists do not surface Copilot-only entries that the signed-in account cannot use. Also keep explicit gateway routing, route lists, and compaction suggestions on the same availability rules, and stabilize the useRouting hook test so it waits for loaded routing state without tearing down the shared DOM between Bun test files. --- _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 2fd1440 commit 3e263d7

File tree

8 files changed

+261
-31
lines changed

8 files changed

+261
-31
lines changed

src/browser/hooks/useModelsFromSettings.ts

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +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 { isGatewayModelAccessibleFromAuthoritativeCatalog } from "@/common/utils/providers/gatewayModelCatalog";
24+
import {
25+
isGatewayModelAccessibleFromAuthoritativeCatalog,
26+
isProviderModelAccessibleFromAuthoritativeCatalog,
27+
} from "@/common/utils/providers/gatewayModelCatalog";
2528
import { getProviderModelEntryId } from "@/common/utils/providers/modelEntries";
2629

2730
const BUILT_IN_MODELS: string[] = Object.values(KNOWN_MODELS).map((m) => m.id);
@@ -162,10 +165,30 @@ export function useModelsFromSettings() {
162165
[config]
163166
);
164167

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

170193
const openaiApiKeySet = config === null ? null : config.openai?.apiKeySet === true;
171194
const codexOauthSet = config === null ? null : config.openai?.codexOauthSet === true;
@@ -186,6 +209,10 @@ export function useModelsFromSettings() {
186209
return false;
187210
}
188211

212+
if (!isAuthoritativeProviderModelAccessible(modelId)) {
213+
return true;
214+
}
215+
189216
if (
190217
isModelAvailable(
191218
modelId,
@@ -221,6 +248,7 @@ export function useModelsFromSettings() {
221248
effectivePolicy,
222249
isConfigured,
223250
isGatewayModelAccessible,
251+
isAuthoritativeProviderModelAccessible,
224252
routePriority,
225253
routeOverrides,
226254
openaiApiKeySet,
@@ -240,14 +268,16 @@ export function useModelsFromSettings() {
240268
const providerFiltered =
241269
config == null
242270
? suggested
243-
: suggested.filter((modelId) =>
244-
isModelAvailable(
245-
modelId,
246-
routePriority,
247-
routeOverrides,
248-
isConfigured,
249-
isGatewayModelAccessible
250-
)
271+
: suggested.filter(
272+
(modelId) =>
273+
isAuthoritativeProviderModelAccessible(modelId) &&
274+
isModelAvailable(
275+
modelId,
276+
routePriority,
277+
routeOverrides,
278+
isConfigured,
279+
isGatewayModelAccessible
280+
)
251281
);
252282

253283
if (config == null) {
@@ -286,6 +316,7 @@ export function useModelsFromSettings() {
286316
effectivePolicy,
287317
isConfigured,
288318
isGatewayModelAccessible,
319+
isAuthoritativeProviderModelAccessible,
289320
routePriority,
290321
routeOverrides,
291322
openaiApiKeySet,

src/browser/hooks/useRouting.test.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ const wrapper: React.FC<{ children: React.ReactNode }> = (props) =>
4545

4646
describe("useRouting", () => {
4747
beforeEach(() => {
48-
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
48+
globalThis.window = new GlobalWindow({ url: "https://mux.example.com/" }) as unknown as Window &
49+
typeof globalThis;
4950
globalThis.document = globalThis.window.document;
5051
providersConfig = null;
5152
routePriority = ["direct"];
@@ -54,8 +55,6 @@ describe("useRouting", () => {
5455

5556
afterEach(() => {
5657
cleanup();
57-
globalThis.window = undefined as unknown as Window & typeof globalThis;
58-
globalThis.document = undefined as unknown as Document;
5958
});
6059

6160
test("resolveRoute and resolveAutoRoute honor gateway model accessibility", async () => {
@@ -68,18 +67,18 @@ describe("useRouting", () => {
6867
models: [KNOWN_MODELS.GPT_54_MINI.providerModelId],
6968
},
7069
};
70+
routePriority = ["github-copilot", "direct"];
7171

7272
const { result } = renderHook(() => useRouting(), { wrapper });
7373

74-
await waitFor(() =>
74+
await waitFor(() => {
75+
expect(result.current.routePriority).toEqual(["github-copilot", "direct"]);
7576
expect(
7677
result.current
7778
.availableRoutes(KNOWN_MODELS.GPT.id)
7879
.some((route) => route.route === "github-copilot")
79-
).toBe(true)
80-
);
81-
82-
result.current.setRoutePreferences(["github-copilot", "direct"], {});
80+
).toBe(false);
81+
});
8382

8483
expect(result.current.resolveRoute(KNOWN_MODELS.GPT.id)).toEqual({
8584
route: "direct",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { KNOWN_MODELS } from "@/common/constants/knownModels";
4+
import type { ProvidersConfigMap } from "@/common/orpc/types";
5+
6+
import { getExplicitCompactionSuggestion } from "./suggestion";
7+
8+
const COPILOT_ONLY_PROVIDERS_CONFIG: ProvidersConfigMap = {
9+
"github-copilot": {
10+
apiKeySet: true,
11+
isEnabled: true,
12+
isConfigured: true,
13+
models: [KNOWN_MODELS.GPT_54_MINI.providerModelId],
14+
},
15+
};
16+
17+
const COPILOT_ONLY_OPTIONS = {
18+
providersConfig: COPILOT_ONLY_PROVIDERS_CONFIG,
19+
policy: null,
20+
routePriority: ["direct"],
21+
routeOverrides: {},
22+
};
23+
24+
describe("getExplicitCompactionSuggestion", () => {
25+
test("rejects explicit Copilot models missing from the authoritative catalog", () => {
26+
expect(
27+
getExplicitCompactionSuggestion({
28+
...COPILOT_ONLY_OPTIONS,
29+
modelId: `github-copilot:${KNOWN_MODELS.GPT.providerModelId}`,
30+
})
31+
).toBeNull();
32+
});
33+
34+
test("keeps explicit Copilot models that the authoritative catalog exposes", () => {
35+
expect(
36+
getExplicitCompactionSuggestion({
37+
...COPILOT_ONLY_OPTIONS,
38+
modelId: `github-copilot:${KNOWN_MODELS.GPT_54_MINI.providerModelId}`,
39+
})
40+
).toMatchObject({
41+
kind: "preferred",
42+
modelId: `github-copilot:${KNOWN_MODELS.GPT_54_MINI.providerModelId}`,
43+
});
44+
});
45+
});

src/browser/utils/compaction/suggestion.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import { isModelAvailable } from "@/common/routing";
1010
import type { EffectivePolicy, ProvidersConfigMap } from "@/common/orpc/types";
1111
import { normalizeToCanonical } from "@/common/utils/ai/models";
1212
import { formatModelDisplayName } from "@/common/utils/ai/modelDisplay";
13-
import { isGatewayModelAccessibleFromAuthoritativeCatalog } from "@/common/utils/providers/gatewayModelCatalog";
13+
import {
14+
isGatewayModelAccessibleFromAuthoritativeCatalog,
15+
isProviderModelAccessibleFromAuthoritativeCatalog,
16+
} from "@/common/utils/providers/gatewayModelCatalog";
1417
import { getModelStats } from "@/common/utils/tokens/modelStats";
1518

1619
export interface CompactionSuggestion {
@@ -47,6 +50,26 @@ function buildIsGatewayModelAccessible(
4750
);
4851
}
4952

53+
function buildIsAuthoritativeProviderModelAccessible(
54+
providersConfig: ProvidersConfigMap | null
55+
): (modelString: string) => boolean {
56+
return (modelString: string) => {
57+
const normalized = normalizeToCanonical(modelString);
58+
const colonIndex = normalized.indexOf(":");
59+
if (colonIndex <= 0 || colonIndex >= normalized.length - 1) {
60+
return true;
61+
}
62+
63+
const provider = normalized.slice(0, colonIndex);
64+
const providerModelId = normalized.slice(colonIndex + 1);
65+
return isProviderModelAccessibleFromAuthoritativeCatalog(
66+
provider,
67+
providerModelId,
68+
providersConfig?.[provider]?.models
69+
);
70+
};
71+
}
72+
5073
export interface CompactionRouteOptions {
5174
routePriority: string[];
5275
routeOverrides: Record<string, string>;
@@ -70,6 +93,13 @@ export function getExplicitCompactionSuggestion(
7093
const normalized = normalizeToCanonical(modelId);
7194
const isConfigured = buildIsConfigured(options.providersConfig);
7295
const isGatewayModelAccessible = buildIsGatewayModelAccessible(options.providersConfig);
96+
const isAuthoritativeProviderModelAccessible = buildIsAuthoritativeProviderModelAccessible(
97+
options.providersConfig
98+
);
99+
if (!isAuthoritativeProviderModelAccessible(normalized)) {
100+
return null;
101+
}
102+
73103
if (
74104
!isModelAvailable(
75105
normalized,

src/common/routing/resolve.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const MODEL = "anthropic:claude-opus-4-6";
66
const OPENAI_MODEL = "openai:gpt-5.4";
77

88
const EXPLICIT_GATEWAY_MODEL = "openrouter:openai/gpt-5";
9+
const EXPLICIT_COPILOT_MODEL = "github-copilot:gpt-5.4";
910

1011
function createIsConfigured(configuredProviders: string[]): (provider: string) => boolean {
1112
const configuredSet = new Set(configuredProviders);
@@ -157,6 +158,19 @@ describe("resolveRoute", () => {
157158
expect(resolved.routeModelId).toBe("gpt-5");
158159
});
159160

161+
test("falls back from explicit gateway input when the explicit route rejects the model", () => {
162+
const resolved = resolveRoute(
163+
EXPLICIT_GATEWAY_MODEL,
164+
["direct"],
165+
{},
166+
createIsConfigured(["openrouter", "openai"]),
167+
createIsGatewayModelAccessible([["openrouter", "openai/gpt-5"]])
168+
);
169+
170+
expect(resolved.routeProvider).toBe("openai");
171+
expect(resolved.routeModelId).toBe("gpt-5");
172+
});
173+
160174
test("supports per-model override to specific gateway", () => {
161175
const resolved = resolveRoute(
162176
MODEL,
@@ -334,6 +348,17 @@ describe("isModelAvailable", () => {
334348
).toBe(true);
335349
});
336350

351+
test("returns false for explicit gateway models when the configured gateway rejects the model", () => {
352+
expect(
353+
isModelAvailableForRoutes({
354+
modelId: EXPLICIT_COPILOT_MODEL,
355+
configuredProviders: ["github-copilot"],
356+
routePriority: ["direct"],
357+
isGatewayModelAccessible: createIsGatewayModelAccessible([["github-copilot", "gpt-5.4"]]),
358+
})
359+
).toBe(false);
360+
});
361+
337362
test("returns true when gateway route is configured", () => {
338363
expect(
339364
isModelAvailableForRoutes({
@@ -503,6 +528,16 @@ describe("availableRoutes", () => {
503528
]);
504529
});
505530

531+
test("excludes direct routes for explicit gateway models that the catalog rejects", () => {
532+
const routes = availableRoutes(
533+
EXPLICIT_COPILOT_MODEL,
534+
createIsConfigured(["github-copilot"]),
535+
createIsGatewayModelAccessible([["github-copilot", "gpt-5.4"]])
536+
);
537+
538+
expect(routes).toEqual([]);
539+
});
540+
506541
test("excludes gateway routes that cannot access the model", () => {
507542
const routes = availableRoutes(
508543
OPENAI_MODEL,

0 commit comments

Comments
 (0)