Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c177aaf
Add Copilot model routing utility
ibetitsmike Mar 30, 2026
8baf3e1
🤖 fix: filter inaccessible gateway routes
ibetitsmike Mar 30, 2026
27442ad
fix: update Copilot model factory routing
ibetitsmike Mar 30, 2026
cf184ab
🤖 fix: respect gateway model accessibility in browser hooks
ibetitsmike Mar 30, 2026
f61c5df
refactor: share copilot model prefixes
ibetitsmike Mar 30, 2026
553a7ba
🤖 fix: address Copilot review feedback
ibetitsmike Mar 31, 2026
ed7b9f9
🤖 fix: preserve Copilot route metadata
ibetitsmike Mar 31, 2026
36537b7
🤖 tests: isolate useRouting hook wiring
ibetitsmike Mar 31, 2026
2fd1440
🤖 tests: stabilize useRouting route priority assertions
ibetitsmike Mar 31, 2026
dfe3b0e
🤖 fix: gate direct Copilot model entries
ibetitsmike Mar 31, 2026
4620f35
🤖 fix: route Copilot non-Codex models through chat completions
ibetitsmike Apr 2, 2026
1b57f97
Hide Copilot catalog entries from model selector
ibetitsmike Apr 2, 2026
139156b
🤖 fix: normalize Copilot Codex responses
ibetitsmike Apr 2, 2026
f42e6fe
🤖 fix: classify Copilot initiator before normalization
ibetitsmike Apr 2, 2026
10d8458
🤖 fix: normalize Copilot Request bodies
ibetitsmike Apr 2, 2026
7ea3f01
🤖 fix: check Copilot credentials before model availability
ibetitsmike Apr 2, 2026
3376b30
Route Copilot models through chat completions
ibetitsmike Apr 2, 2026
f896da3
🤖 feat: route Copilot Anthropic models
ibetitsmike Apr 2, 2026
6a9931e
🤖 fix: block Codex models from Copilot gateway routing
ibetitsmike Apr 2, 2026
bc1e6d8
🤖 tests: update Codex fallback routing test
ibetitsmike Apr 2, 2026
5904336
Normalize Copilot Claude model IDs
ibetitsmike Apr 2, 2026
9c82ff7
Add Copilot Anthropic routing tests
ibetitsmike Apr 2, 2026
1f9c19e
Add Copilot Anthropic hook tests
ibetitsmike Apr 2, 2026
33182ad
Allow Copilot Codex routing
ibetitsmike Apr 2, 2026
c820e5e
Add Copilot responses language model
ibetitsmike Apr 2, 2026
e20881f
Integrate Copilot responses model routing
ibetitsmike Apr 2, 2026
3d762b0
🤖 tests: fix copilot responses test lint
ibetitsmike Apr 2, 2026
ea5c726
Fix copilot test fetch mock return types
ibetitsmike Apr 2, 2026
c433016
Fix flaky useRouting Copilot test
ibetitsmike Apr 3, 2026
69ec990
Fix Copilot model routing and reasoning
ibetitsmike Apr 3, 2026
923bc2c
Fix flaky useRouting Anthropic test
ibetitsmike Apr 3, 2026
1fce3d3
🤖 tests: remove flaky Copilot routing hook test
ibetitsmike Apr 3, 2026
2c8c83a
🤖 fix: preserve date-stamped Claude Copilot IDs
ibetitsmike Apr 3, 2026
6e5c3b4
Align Copilot chat provider options namespace
ibetitsmike Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions src/browser/hooks/useModelsFromSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,122 @@ describe("useModelsFromSettings provider availability gating", () => {
expect(result.current.hiddenModelsForSelector).not.toContain(KNOWN_MODELS.HAIKU.id);
});

test("does not treat custom gateway model entries as an exhaustive route catalog", () => {
providersConfig = {
openai: { apiKeySet: false, isEnabled: true, isConfigured: false },
openrouter: {
apiKeySet: true,
isEnabled: true,
isConfigured: true,
models: [OPENROUTER_OPENAI_CUSTOM_MODEL],
},
};
routePriority = ["openrouter", "direct"];

const { result } = renderHook(() => useModelsFromSettings());

expect(result.current.models).toContain(KNOWN_MODELS.GPT.id);
expect(result.current.hiddenModelsForSelector).not.toContain(KNOWN_MODELS.GPT.id);
});

test("hides models that a configured gateway does not expose", () => {
providersConfig = {
openai: { apiKeySet: false, isEnabled: true, isConfigured: false },
"github-copilot": {
apiKeySet: true,
isEnabled: true,
isConfigured: true,
models: [KNOWN_MODELS.GPT_54_MINI.providerModelId],
},
};
routePriority = ["github-copilot", "direct"];

const { result } = renderHook(() => useModelsFromSettings());

expect(result.current.models).not.toContain(KNOWN_MODELS.GPT.id);
expect(result.current.hiddenModelsForSelector).toContain(KNOWN_MODELS.GPT.id);
});

test("keeps Copilot catalogs authoritative without surfacing selector entries", () => {
providersConfig = {
openai: { apiKeySet: false, isEnabled: true, isConfigured: false },
"github-copilot": {
apiKeySet: true,
isEnabled: true,
isConfigured: true,
models: [KNOWN_MODELS.GPT_54_MINI.providerModelId],
},
};
routePriority = ["github-copilot", "direct"];

const { result } = renderHook(() => useModelsFromSettings());

expect(result.current.customModels.some((model) => model.startsWith("github-copilot:"))).toBe(
false
);
expect(
result.current.hiddenModelsForSelector.some((model) => model.startsWith("github-copilot:"))
).toBe(false);
expect(result.current.models).not.toContain(KNOWN_MODELS.GPT.id);
expect(result.current.hiddenModelsForSelector).toContain(KNOWN_MODELS.GPT.id);
});

test("keeps models visible when a configured gateway exposes them", () => {
providersConfig = {
openai: { apiKeySet: false, isEnabled: true, isConfigured: false },
"github-copilot": {
apiKeySet: true,
isEnabled: true,
isConfigured: true,
models: [KNOWN_MODELS.GPT.providerModelId],
},
};
routePriority = ["github-copilot", "direct"];

const { result } = renderHook(() => useModelsFromSettings());

expect(result.current.models).toContain(KNOWN_MODELS.GPT.id);
expect(result.current.hiddenModelsForSelector).not.toContain(KNOWN_MODELS.GPT.id);
});

test("keeps Anthropic models visible when Copilot catalog contains dot-form IDs", () => {
providersConfig = {
anthropic: { apiKeySet: false, isEnabled: true, isConfigured: false },
"github-copilot": {
apiKeySet: true,
isEnabled: true,
isConfigured: true,
models: ["claude-opus-4.6"],
},
};
routePriority = ["github-copilot", "direct"];

const { result } = renderHook(() => useModelsFromSettings());

expect(result.current.models).toContain(KNOWN_MODELS.OPUS.id);
expect(result.current.hiddenModelsForSelector).not.toContain(KNOWN_MODELS.OPUS.id);
expect(result.current.customModels.some((model) => model.startsWith("github-copilot:"))).toBe(
false
);
});

test("keeps gateway-routed models visible when no gateway model list is present", () => {
providersConfig = {
openai: { apiKeySet: false, isEnabled: true, isConfigured: false },
"github-copilot": {
apiKeySet: true,
isEnabled: true,
isConfigured: true,
},
};
routePriority = ["github-copilot", "direct"];

const { result } = renderHook(() => useModelsFromSettings());

expect(result.current.models).toContain(KNOWN_MODELS.GPT.id);
expect(result.current.hiddenModelsForSelector).not.toContain(KNOWN_MODELS.GPT.id);
});

test("excludes OAuth-gated OpenAI models from hidden bucket when unconfigured", () => {
// OpenAI is unconfigured and neither API key nor OAuth is set.
providersConfig = {
Expand Down
68 changes: 63 additions & 5 deletions src/browser/hooks/useModelsFromSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import { isModelAvailable } from "@/common/routing";
import type { ProviderModelEntry, ProvidersConfigMap } from "@/common/orpc/types";
import { DEFAULT_MODEL_KEY, HIDDEN_MODELS_KEY } from "@/common/constants/storage";

import {
isGatewayModelAccessibleFromAuthoritativeCatalog,
isProviderModelAccessibleFromAuthoritativeCatalog,
} from "@/common/utils/providers/gatewayModelCatalog";
import { getProviderModelEntryId } from "@/common/utils/providers/modelEntries";

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

for (const modelEntry of info.models) {
Expand Down Expand Up @@ -155,10 +163,36 @@ export function useModelsFromSettings() {
[config]
);

const isGatewayModelAccessible = useCallback(
(gateway: string, modelId: string) =>
isGatewayModelAccessibleFromAuthoritativeCatalog(gateway, modelId, config?.[gateway]?.models),
[config]
);

const isAuthoritativeProviderModelAccessible = useCallback(
(modelString: string) => {
const colonIndex = modelString.indexOf(":");
if (colonIndex <= 0 || colonIndex >= modelString.length - 1) {
return true;
}

const provider = modelString.slice(0, colonIndex);
const providerModelId = modelString.slice(colonIndex + 1);
return isProviderModelAccessibleFromAuthoritativeCatalog(
provider,
providerModelId,
config?.[provider]?.models
);
},
[config]
);

const customModels = useMemo(() => {
const next = filterHiddenModels(getCustomModels(config), hiddenModels);
const next = filterHiddenModels(getCustomModels(config), hiddenModels).filter(
isAuthoritativeProviderModelAccessible
);
return effectivePolicy ? next.filter((m) => isModelAllowedByPolicy(effectivePolicy, m)) : next;
}, [config, hiddenModels, effectivePolicy]);
}, [config, hiddenModels, effectivePolicy, isAuthoritativeProviderModelAccessible]);

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

if (isModelAvailable(modelId, routePriority, routeOverrides, isConfigured)) {
if (!isAuthoritativeProviderModelAccessible(modelId)) {
return true;
}

if (
isModelAvailable(
modelId,
routePriority,
routeOverrides,
isConfigured,
isGatewayModelAccessible
)
) {
return false;
}

Expand All @@ -205,6 +251,8 @@ export function useModelsFromSettings() {
hiddenModels,
effectivePolicy,
isConfigured,
isGatewayModelAccessible,
isAuthoritativeProviderModelAccessible,
routePriority,
routeOverrides,
openaiApiKeySet,
Expand All @@ -224,8 +272,16 @@ export function useModelsFromSettings() {
const providerFiltered =
config == null
? suggested
: suggested.filter((modelId) =>
isModelAvailable(modelId, routePriority, routeOverrides, isConfigured)
: suggested.filter(
(modelId) =>
isAuthoritativeProviderModelAccessible(modelId) &&
isModelAvailable(
modelId,
routePriority,
routeOverrides,
isConfigured,
isGatewayModelAccessible
)
);

if (config == null) {
Expand Down Expand Up @@ -263,6 +319,8 @@ export function useModelsFromSettings() {
hiddenModels,
effectivePolicy,
isConfigured,
isGatewayModelAccessible,
isAuthoritativeProviderModelAccessible,
routePriority,
routeOverrides,
openaiApiKeySet,
Expand Down
92 changes: 92 additions & 0 deletions src/browser/hooks/useRouting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import { GlobalWindow } from "happy-dom";
import React from "react";

import { APIProvider, type APIClient } from "@/browser/contexts/API";
import { KNOWN_MODELS } from "@/common/constants/knownModels";
import type { ProvidersConfigMap } from "@/common/orpc/types";

import { useRouting } from "./useRouting";

let providersConfig: ProvidersConfigMap | null = null;
let routePriority: string[] = ["direct"];
let routeOverrides: Record<string, string> = {};
let configGetConfig: () => Promise<{
routePriority: string[];
routeOverrides: Record<string, string>;
}>;

async function* emptyStream() {
await Promise.resolve();
for (const item of [] as unknown[]) {
yield item;
}
}

function createStubApiClient(): APIClient {
return {
providers: {
getConfig: () => Promise.resolve(providersConfig),
onConfigChanged: () => Promise.resolve(emptyStream()),
},
config: {
getConfig: () => configGetConfig(),
onConfigChanged: () => Promise.resolve(emptyStream()),
updateRoutePreferences: () => Promise.resolve(undefined),
},
} as unknown as APIClient;
}

const stubClient = createStubApiClient();

const wrapper: React.FC<{ children: React.ReactNode }> = (props) =>
React.createElement(
APIProvider,
{ client: stubClient } as React.ComponentProps<typeof APIProvider>,
props.children
);

describe("useRouting", () => {
beforeEach(() => {
globalThis.window = new GlobalWindow({ url: "https://mux.example.com/" }) as unknown as Window &
typeof globalThis;
globalThis.document = globalThis.window.document;
providersConfig = null;
routePriority = ["direct"];
routeOverrides = {};
configGetConfig = () => Promise.resolve({ routePriority, routeOverrides });
});

afterEach(() => {
cleanup();
});

test("resolveRoute and availableRoutes honor gateway model accessibility", async () => {
providersConfig = {
openai: { apiKeySet: true, isEnabled: true, isConfigured: true },
"github-copilot": {
apiKeySet: true,
isEnabled: true,
isConfigured: true,
models: [KNOWN_MODELS.GPT_54_MINI.providerModelId],
},
};

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

await waitFor(() => {
expect(
result.current
.availableRoutes(KNOWN_MODELS.GPT.id)
.some((route) => route.route === "github-copilot")
).toBe(false);
});

expect(result.current.resolveRoute(KNOWN_MODELS.GPT.id)).toEqual({
route: "direct",
isAuto: true,
displayName: "Direct",
});
});
});
Loading
Loading