mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
fix(providers): refresh GitHub Copilot catalog (#6154)
* fix(providers): refresh github copilot catalog Limit GitHub Copilot discovery to the curated supported model set and keep the provider cooldown panel client-safe by moving countdown formatting out of localDb. * chore(quality): rebaseline providerPageHelpers.ts file-size (+13, #6154 copilot catalog) The GitHub Copilot catalog refresh grows the provider-page model-section helper (1021->1034). Fast-path PR->release skips check:file-size, so the bump lands with the PR. Justification recorded in file-size-baseline.json. Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com> --------- Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
This commit is contained in:
@@ -1132,11 +1132,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"tests/unit/copilot-gemini-claude-route-no-responses.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 9
|
||||
}
|
||||
},
|
||||
"tests/unit/cursor-usage-fetcher.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
@@ -206,7 +206,7 @@
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 954,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts": 155,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1021,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1034,
|
||||
"src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 906,
|
||||
"src/app/(dashboard)/dashboard/providers/page.tsx": 1927,
|
||||
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201,
|
||||
@@ -372,5 +372,6 @@
|
||||
"_rebaseline_2026_06_25_rc17_pr_batch": "rc17 PR batch own growth (cohesive, not extractable): responseSanitizer.ts 1103->1122 (+19 = SanitizeOpenAIResponseOptions interface + stripReasoning option, #4678); tokenRefresh.ts 2070->2090 (+20 = codex 401 defense-in-depth unrecoverable-refresh guard, #4686); token-refresh-service.test.ts 1322->1353 (+31 = 401-unfamiliar-payload regression case, #4686); translator-openai-responses-req.test.ts 1047->1050 (+3 = reasoning_effort non-Copilot assertion update, #4688). All are the merged PRs own surgical additions at existing chokepoints.",
|
||||
"_rebaseline_2026_06_25_rc17b_leva2": "rc17 leva2 PR batch own growth (cohesive, not extractable): providerLimits.ts 950->955 (#4786 generalized accesstoken fallback); default.ts NEW frozen entry at 828 (#4729 anthropic-compatible Bearer + #4766 json_schema fallback + #4787 cline workos headers — three provider-specific header branches); openai-to-kiro.ts 807->814 (#4763 Claude-capability image gate); openai-responses.ts 923->937 (#4764 computeFinishReason guard); executor-default-base.test.ts 1339->1440 (#4766 json_schema fallback tests); translator-openai-to-kiro.test.ts 918->980 (#4763 non-Claude image gate tests).",
|
||||
"_rebaseline_2026_06_27_v3838_filesize_drift": "Mid-cycle drift on release/v3.8.38 — feature/fix growth from already-merged PRs that the fast-path (PR->release skips check:file-size) let accumulate without a bump. src/shared/constants/sidebarVisibility.ts 1100->1198 (+98 = #3812 colored menu-icon support, per-item accent map across the sidebar entries; #5142 then dropped one orphan settings entry, net still above the frozen). src/sse/handlers/chat.ts 1560->1575 (+15 = #5064 self-inflicted-timeout cooldown skip + #5124 long OpenAI-compatible SSE hardening + #5110 embed-WS LIVE_WS_HOST honour / early empty-message reject). Localized feature/fix code next to existing branches, each covered by its own PR tests; not extractable without hiding the chokepoint. Structural shrink of chat.ts tracked in #3501.",
|
||||
"_rebaseline_2026_07_05_6211_providerlimits_fetch_timeout": "PR #6211 own growth: src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx 1121->1127 (+6 = data-timeout guard for the quota page's two first-paint fetches — a PROVIDER_LIMITS_FETCH_TIMEOUT_MS const + fetchWithTimeout at the /api/providers/client and /api/usage/provider-limits call sites — so a never-settling connection can no longer wedge initialLoading on the skeleton, same infinite-skeleton class the PR also fixes on the providers page). Cohesive fix code at the existing fetch chokepoints (the 5-line rationale comment explains why a timeout/abort is needed where a try/catch only rescues a rejection); not extractable. Covered by tests/unit/providers-page-data-timeout.test.ts. Fast-path PR->release skips check:file-size, so this bump lands with the PR. Structural shrink of this file tracked in #3501."
|
||||
"_rebaseline_2026_07_05_6211_providerlimits_fetch_timeout": "PR #6211 own growth: src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx 1121->1127 (+6 = data-timeout guard for the quota page's two first-paint fetches — a PROVIDER_LIMITS_FETCH_TIMEOUT_MS const + fetchWithTimeout at the /api/providers/client and /api/usage/provider-limits call sites — so a never-settling connection can no longer wedge initialLoading on the skeleton, same infinite-skeleton class the PR also fixes on the providers page). Cohesive fix code at the existing fetch chokepoints (the 5-line rationale comment explains why a timeout/abort is needed where a try/catch only rescues a rejection); not extractable. Covered by tests/unit/providers-page-data-timeout.test.ts. Fast-path PR->release skips check:file-size, so this bump lands with the PR. Structural shrink of this file tracked in #3501.",
|
||||
"_rebaseline_2026_07_05_6154_copilot_catalog_helpers": "PR #6154 own growth: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts 1021->1034 (+13 = GitHub Copilot catalog refresh — model-section helper wiring for the refreshed passthrough/compatible model lists). Cohesive UI-helper growth alongside the registry/modelSpecs catalog refresh; not extractable. Covered by the PR's provider-registry-github-copilot-* unit tests. Fast-path PR->release skips check:file-size, so this bump lands with the PR (contributor backryun)."
|
||||
}
|
||||
|
||||
@@ -25,69 +25,137 @@ export const githubProvider: RegistryEntry = {
|
||||
defaultContextLength: 128000,
|
||||
headers: getGitHubCopilotChatHeaders(),
|
||||
models: [
|
||||
// Copilot still serves the original GPT-4 via chat/completions; keep it
|
||||
// alongside GPT-4o and the GPT-5.x family so apps that hard-code `gpt-4` resolve here.
|
||||
{ id: "gpt-4", name: "GPT-4", contextLength: 128000 },
|
||||
// 9router#98 — Copilot still serves GPT-4o via chat/completions; keep it
|
||||
// alongside the GPT-5.x family so apps that hard-code `gpt-4o` resolve here.
|
||||
{ id: "gpt-4o", name: "GPT-4o", contextLength: 128000 },
|
||||
// Copilot also serves the cheaper GPT-4o mini via chat/completions; keep it
|
||||
// alongside gpt-4o so apps that hard-code `gpt-4o-mini` resolve to the Copilot
|
||||
// (`gh`) provider rather than only the github-models (`ghm`) marketplace entry.
|
||||
{ id: "gpt-4o-mini", name: "GPT-4o mini", contextLength: 128000 },
|
||||
{ id: "gpt-5-mini", name: "GPT-5 Mini", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini", targetFormat: "openai-responses" },
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
targetFormat: "openai-responses",
|
||||
supportsXHighEffort: true,
|
||||
id: "claude-fable-5",
|
||||
name: "Claude Fable 5",
|
||||
contextLength: 1000000,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
{ id: "gpt-5.5", name: "GPT-5.5", ...GPT_5_5_CODEX_CAPABILITIES },
|
||||
{
|
||||
id: "claude-haiku-4.5",
|
||||
name: "Claude Haiku 4.5",
|
||||
id: "claude-opus-4.8-fast",
|
||||
name: "Claude Opus 4.8 (fast mode)",
|
||||
contextLength: 1000000,
|
||||
maxOutputTokens: 64000,
|
||||
unsupportedParams: ["temperature", "top_p", "top_k"],
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4.8",
|
||||
name: "Claude Opus 4.8",
|
||||
contextLength: 1000000,
|
||||
maxOutputTokens: 64000,
|
||||
unsupportedParams: ["temperature", "top_p", "top_k"],
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4.7",
|
||||
name: "Claude Opus 4.7",
|
||||
contextLength: 1000000,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextLength: 1000000,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4.5",
|
||||
name: "Claude Opus 4.5",
|
||||
contextLength: 200000,
|
||||
maxOutputTokens: 32000,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-5",
|
||||
name: "Claude Sonnet 5",
|
||||
contextLength: 1000000,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4.5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
contextLength: 200000,
|
||||
maxOutputTokens: 64000,
|
||||
maxOutputTokens: 32000,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
id: "claude-haiku-4.5",
|
||||
name: "Claude Haiku 4.5",
|
||||
contextLength: 200000,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
{
|
||||
// #2911: GitHub Copilot's Responses API does not serve Claude/Gemini —
|
||||
// route them via chat/completions (provider default) like claude-opus-4.6.
|
||||
id: "claude-opus-4-5-20251101",
|
||||
name: "Claude Opus 4.5 (Full ID)",
|
||||
contextLength: 200000,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4.6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextLength: 1000000,
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
{
|
||||
// #2911: Claude on Copilot must use chat/completions, not the Responses API.
|
||||
id: "claude-opus-4.7",
|
||||
name: "Claude Opus 4.7",
|
||||
contextLength: 1000000,
|
||||
maxOutputTokens: 128000,
|
||||
maxOutputTokens: 32000,
|
||||
},
|
||||
// #2911: Gemini on Copilot must use chat/completions, not the Responses API.
|
||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" },
|
||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash" },
|
||||
{ id: "oswe-vscode-prime", name: "Raptor Mini", targetFormat: "openai-responses" },
|
||||
//{ id: "?", name: "Goldeneye" },
|
||||
{
|
||||
id: "gemini-3.1-pro-preview",
|
||||
name: "Gemini 3.1 Pro",
|
||||
contextLength: 1000000,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
{
|
||||
id: "gemini-3.5-flash",
|
||||
name: "Gemini 3.5 Flash",
|
||||
contextLength: 1000000,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
{ id: "gpt-5.5", name: "GPT-5.5", ...GPT_5_5_CODEX_CAPABILITIES, maxOutputTokens: 128000 },
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
targetFormat: "openai-responses",
|
||||
supportsXHighEffort: true,
|
||||
contextLength: 1050000,
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.4-mini",
|
||||
name: "GPT-5.4 mini",
|
||||
targetFormat: "openai-responses",
|
||||
contextLength: 400000,
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.3-codex",
|
||||
name: "GPT-5.3-Codex",
|
||||
targetFormat: "openai-responses",
|
||||
contextLength: 400000,
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
{
|
||||
id: "gpt-5-mini",
|
||||
name: "GPT-5 mini",
|
||||
targetFormat: "openai-responses",
|
||||
contextLength: 264000,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
{
|
||||
id: "gpt-4o-2024-11-20",
|
||||
name: "GPT-4o",
|
||||
contextLength: 128000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
{ id: "gpt-4o-mini", name: "GPT-4o mini", contextLength: 128000, maxOutputTokens: 4096 },
|
||||
{
|
||||
id: "gpt-4-0125-preview",
|
||||
name: "GPT 4 Turbo",
|
||||
contextLength: 128000,
|
||||
maxOutputTokens: 4096,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2.7-code",
|
||||
name: "Kimi K2.7 Code",
|
||||
contextLength: 256000,
|
||||
maxOutputTokens: 32000,
|
||||
},
|
||||
{
|
||||
id: "mai-code-1-flash",
|
||||
name: "MAI-Code-1-Flash",
|
||||
targetFormat: "openai-responses",
|
||||
contextLength: 256000,
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
{
|
||||
id: "oswe-vscode-prime",
|
||||
name: "Raptor mini",
|
||||
targetFormat: "openai-responses",
|
||||
contextLength: 264000,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -20,6 +20,32 @@
|
||||
import { getGitHubCopilotChatHeaders } from "../config/providerHeaderProfiles.ts";
|
||||
|
||||
export const GITHUB_COPILOT_MODELS_URL = "https://api.githubcopilot.com/models";
|
||||
export const GITHUB_COPILOT_MODEL_ALLOWLIST = [
|
||||
"claude-fable-5",
|
||||
"claude-opus-4.8-fast",
|
||||
"claude-opus-4.8",
|
||||
"claude-opus-4.7",
|
||||
"claude-sonnet-4.6",
|
||||
"claude-opus-4.5",
|
||||
"claude-sonnet-5",
|
||||
"claude-sonnet-4.5",
|
||||
"claude-haiku-4.5",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3.5-flash",
|
||||
"gpt-5.5",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5-mini",
|
||||
"gpt-4o-2024-11-20",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4-0125-preview",
|
||||
"kimi-k2.7-code",
|
||||
"mai-code-1-flash",
|
||||
"oswe-vscode-prime",
|
||||
] as const;
|
||||
|
||||
const GITHUB_COPILOT_MODEL_ALLOWLIST_SET = new Set<string>(GITHUB_COPILOT_MODEL_ALLOWLIST);
|
||||
|
||||
export type GitHubCopilotModel = {
|
||||
id: string;
|
||||
@@ -59,6 +85,7 @@ export function parseGitHubCopilotModels(data: unknown): GitHubCopilotModel[] {
|
||||
const item = asRecord(value);
|
||||
const id = toNonEmptyString(item.id) || toNonEmptyString(item.model);
|
||||
if (!id || seen.has(id)) continue;
|
||||
if (!GITHUB_COPILOT_MODEL_ALLOWLIST_SET.has(id)) continue;
|
||||
seen.add(id);
|
||||
const name = toNonEmptyString(item.name) || toNonEmptyString(item.display_name) || id;
|
||||
models.push({ id, name, owned_by: "github" });
|
||||
@@ -89,6 +116,7 @@ function toFallbackResult(
|
||||
.map((model) => {
|
||||
const id = toNonEmptyString(model.id);
|
||||
if (!id) return null;
|
||||
if (!GITHUB_COPILOT_MODEL_ALLOWLIST_SET.has(id)) return null;
|
||||
return { id, name: toNonEmptyString(model.name) || id, owned_by: "github" };
|
||||
})
|
||||
.filter((model): model is GitHubCopilotModel => Boolean(model));
|
||||
|
||||
@@ -15,6 +15,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
buildCompatMap,
|
||||
isModelHiddenFn,
|
||||
getDisplayModelAlias,
|
||||
effectiveNormalizeForProtocol,
|
||||
effectivePreserveForProtocol,
|
||||
anyNormalizeCompatBadge,
|
||||
@@ -35,10 +36,7 @@ vi.mock("next/navigation", () => ({
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (values) {
|
||||
return Object.entries(values).reduce(
|
||||
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
|
||||
key
|
||||
);
|
||||
return Object.entries(values).reduce((acc, [k, v]) => acc.replace(`{${k}}`, String(v)), key);
|
||||
}
|
||||
return key;
|
||||
},
|
||||
@@ -85,6 +83,22 @@ describe("providerPageHelpers — model-compat pure functions", () => {
|
||||
expect(isModelHiddenFn("unknown-model", customMap, overrideMap)).toBe(false);
|
||||
});
|
||||
|
||||
it("isModelHiddenFn ignores deleted tombstones when reading visibility", () => {
|
||||
const customMap = buildCompatMap([]);
|
||||
const overrideMap = buildCompatMap([
|
||||
{ id: "gpt-4o-2024-11-20", isHidden: true, isDeleted: true },
|
||||
{ id: "gpt-5-mini", isHidden: true },
|
||||
]);
|
||||
|
||||
expect(isModelHiddenFn("gpt-4o-2024-11-20", customMap, overrideMap)).toBe(false);
|
||||
expect(isModelHiddenFn("gpt-5-mini", customMap, overrideMap)).toBe(true);
|
||||
});
|
||||
|
||||
it("getDisplayModelAlias ignores provider-scoped identity aliases", () => {
|
||||
expect(getDisplayModelAlias("gpt-4o-2024-11-20", "gpt-4o-2024-11-20")).toBeNull();
|
||||
expect(getDisplayModelAlias("gpt-5-mini", "fast-mini")).toBe("fast-mini");
|
||||
});
|
||||
|
||||
it("effectiveNormalizeForProtocol returns correct flag", () => {
|
||||
const customMap = buildCompatMap(customModels);
|
||||
const overrideMap = buildCompatMap(overrideModels);
|
||||
@@ -115,10 +129,10 @@ describe("providerPageHelpers — model-compat pure functions", () => {
|
||||
});
|
||||
|
||||
it("formatProviderModelsErrorResponse extracts error.message", async () => {
|
||||
const mockRes = new Response(
|
||||
JSON.stringify({ error: { message: "Model not found" } }),
|
||||
{ status: 422, statusText: "Unprocessable Entity" }
|
||||
);
|
||||
const mockRes = new Response(JSON.stringify({ error: { message: "Model not found" } }), {
|
||||
status: 422,
|
||||
statusText: "Unprocessable Entity",
|
||||
});
|
||||
const detail = await formatProviderModelsErrorResponse(mockRes);
|
||||
expect(detail).toBe("Model not found");
|
||||
});
|
||||
@@ -185,7 +199,9 @@ describe("PassthroughModelRow — render smoke test", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { root.unmount(); });
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
@@ -222,7 +238,9 @@ describe("ModelVisibilityToolbar — render smoke test", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { root.unmount(); });
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
@@ -259,7 +277,9 @@ describe("useModelCompatState — hook unit test via component wrapper", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { root.unmount(); });
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
@@ -267,7 +287,12 @@ describe("useModelCompatState — hook unit test via component wrapper", () => {
|
||||
const { useModelCompatState } = await import("../hooks/useModelCompatState");
|
||||
|
||||
const customModels = [
|
||||
{ id: "gpt-4o", normalizeToolCallId: true, preserveOpenAIDeveloperRole: false, isHidden: true },
|
||||
{
|
||||
id: "gpt-4o",
|
||||
normalizeToolCallId: true,
|
||||
preserveOpenAIDeveloperRole: false,
|
||||
isHidden: true,
|
||||
},
|
||||
];
|
||||
const modelCompatOverrides: any[] = [];
|
||||
|
||||
@@ -281,7 +306,9 @@ describe("useModelCompatState — hook unit test via component wrapper", () => {
|
||||
compat.effectiveModelPreserveDeveloper("gpt-4o"),
|
||||
compat.anyNormalizeCompatBadge("gpt-4o"),
|
||||
compat.anyNoPreserveCompatBadge("gpt-4o"),
|
||||
].map(String).join(",");
|
||||
]
|
||||
.map(String)
|
||||
.join(",");
|
||||
return <span data-testid="results">{results}</span>;
|
||||
}
|
||||
|
||||
@@ -291,8 +318,9 @@ describe("useModelCompatState — hook unit test via component wrapper", () => {
|
||||
|
||||
const span = container.querySelector("[data-testid='results']");
|
||||
expect(span).not.toBeNull();
|
||||
const [hidden, notHidden, normalize, preserve, anyNorm, anyNoPreserve] =
|
||||
(span!.textContent ?? "").split(",");
|
||||
const [hidden, notHidden, normalize, preserve, anyNorm, anyNoPreserve] = (
|
||||
span!.textContent ?? ""
|
||||
).split(",");
|
||||
|
||||
expect(hidden).toBe("true");
|
||||
expect(notHidden).toBe("false");
|
||||
|
||||
@@ -17,6 +17,7 @@ import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import {
|
||||
buildCompatMap,
|
||||
getDisplayModelAlias,
|
||||
providerText,
|
||||
type CompatModelRow,
|
||||
} from "../providerPageHelpers";
|
||||
@@ -57,10 +58,7 @@ export interface CompatibleModelsSectionProps {
|
||||
effectiveModelNormalize: (alias: string) => boolean;
|
||||
effectiveModelPreserveDeveloper: (alias: string) => boolean;
|
||||
getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record<string, string>;
|
||||
saveModelCompatFlags: (
|
||||
modelId: string,
|
||||
flags: CompatibleModelsSaveFlags
|
||||
) => Promise<void>;
|
||||
saveModelCompatFlags: (modelId: string, flags: CompatibleModelsSaveFlags) => Promise<void>;
|
||||
compatSavingModelId?: string;
|
||||
onModelsChanged?: () => void;
|
||||
isModelHidden: (modelId: string) => boolean;
|
||||
@@ -155,7 +153,8 @@ export default function CompatibleModelsSection({
|
||||
for (const [alias, fullModel] of providerAliases) {
|
||||
const fmStr = fullModel as string;
|
||||
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
|
||||
aliasByModelId.set(modelId, alias as string);
|
||||
const displayAlias = getDisplayModelAlias(modelId, alias as string);
|
||||
if (displayAlias) aliasByModelId.set(modelId, displayAlias);
|
||||
}
|
||||
|
||||
const addModel = (model: CompatModelRow, source: string) => {
|
||||
@@ -194,11 +193,13 @@ export default function CompatibleModelsSection({
|
||||
const fmStr = fullModel as string;
|
||||
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
|
||||
if (!modelId || seenModelIds.has(modelId)) continue;
|
||||
const displayAlias = getDisplayModelAlias(modelId, alias as string);
|
||||
if (!displayAlias) continue;
|
||||
const customModel = customModelMap.get(modelId);
|
||||
rows.push({
|
||||
modelId,
|
||||
alias: alias as string,
|
||||
displayName: alias as string,
|
||||
alias: displayAlias,
|
||||
displayName: displayAlias,
|
||||
source: customModel ? customModel.source || "custom" : "alias",
|
||||
isFree:
|
||||
modelId.endsWith(":free") ||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import {
|
||||
buildCompatMap,
|
||||
getDisplayModelAlias,
|
||||
providerText,
|
||||
testAllResultsText,
|
||||
evaluateTestAllEntry,
|
||||
@@ -228,7 +229,8 @@ export default function PassthroughModelsSection({
|
||||
for (const [alias, fullModel] of providerAliases) {
|
||||
const fmStr = fullModel as string;
|
||||
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
|
||||
aliasByModelId.set(modelId, alias as string);
|
||||
const displayAlias = getDisplayModelAlias(modelId, alias as string);
|
||||
if (displayAlias) aliasByModelId.set(modelId, displayAlias);
|
||||
fullModelByModelId.set(modelId, fmStr);
|
||||
}
|
||||
|
||||
@@ -266,12 +268,14 @@ export default function PassthroughModelsSection({
|
||||
const fmStr = fullModel as string;
|
||||
const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr;
|
||||
if (!modelId || seenModelIds.has(modelId)) continue;
|
||||
const displayAlias = getDisplayModelAlias(modelId, alias as string);
|
||||
if (!displayAlias) continue;
|
||||
const customModel = customModelMap.get(modelId);
|
||||
rows.push({
|
||||
modelId,
|
||||
fullModel: fmStr,
|
||||
alias: alias as string,
|
||||
displayName: alias as string,
|
||||
alias: displayAlias,
|
||||
displayName: displayAlias,
|
||||
source: customModel ? customModel.source || "custom" : "alias",
|
||||
isFree:
|
||||
modelId.endsWith(":free") ||
|
||||
|
||||
@@ -15,7 +15,11 @@ import { useState } from "react";
|
||||
import { Button } from "@/shared/components";
|
||||
import { matchesModelCatalogQuery } from "@/shared/utils/modelCatalogSearch";
|
||||
import { isFreeModel, sortModelsFreeFirst } from "@/shared/utils/freeModels";
|
||||
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
import {
|
||||
getDisplayModelAlias,
|
||||
providerText,
|
||||
type ProviderMessageTranslator,
|
||||
} from "../providerPageHelpers";
|
||||
import ModelRow, { ModelVisibilityToolbar } from "./ModelRow";
|
||||
import PassthroughModelsSection from "./PassthroughModelsSection";
|
||||
import CompatibleModelsSection from "./CompatibleModelsSection";
|
||||
@@ -86,11 +90,7 @@ export interface ProviderModelsSectionProps {
|
||||
setAutoHideFailed: (v: boolean) => void;
|
||||
setVisibilityFilter: (v: "all" | "visible" | "hidden") => void;
|
||||
saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => Promise<void>;
|
||||
handleToggleModelHidden: (
|
||||
providerKey: string,
|
||||
modelId: string,
|
||||
hidden: boolean
|
||||
) => Promise<void>;
|
||||
handleToggleModelHidden: (providerKey: string, modelId: string, hidden: boolean) => Promise<void>;
|
||||
handleBulkToggleModelHidden: (
|
||||
providerKey: string,
|
||||
modelIds: string[],
|
||||
@@ -187,8 +187,7 @@ export default function ProviderModelsSection({
|
||||
</button>
|
||||
);
|
||||
|
||||
const clearAllButton = (modelMeta.customModels.length > 0 ||
|
||||
providerAliasEntries.length > 0) && (
|
||||
const clearAllButton = (modelMeta.customModels.length > 0 || providerAliasEntries.length > 0) && (
|
||||
<button
|
||||
onClick={handleClearAllModels}
|
||||
disabled={clearingModels}
|
||||
@@ -251,9 +250,7 @@ export default function ProviderModelsSection({
|
||||
onModelsChanged={fetchProviderModelMeta}
|
||||
allowImport={compatibleSupportsModelImport}
|
||||
isModelHidden={effectiveModelHidden}
|
||||
onToggleHidden={(modelId, hidden) =>
|
||||
handleToggleModelHidden(providerId, modelId, hidden)
|
||||
}
|
||||
onToggleHidden={(modelId, hidden) => handleToggleModelHidden(providerId, modelId, hidden)}
|
||||
onBulkToggleHidden={(modelIds, hidden) =>
|
||||
handleBulkToggleModelHidden(providerId, modelIds, hidden)
|
||||
}
|
||||
@@ -325,9 +322,7 @@ export default function ProviderModelsSection({
|
||||
saveModelCompatFlags={saveModelCompatFlags}
|
||||
compatSavingModelId={compatSavingModelId}
|
||||
isModelHidden={effectiveModelHidden}
|
||||
onToggleHidden={(modelId, hidden) =>
|
||||
handleToggleModelHidden(providerId, modelId, hidden)
|
||||
}
|
||||
onToggleHidden={(modelId, hidden) => handleToggleModelHidden(providerId, modelId, hidden)}
|
||||
onBulkToggleHidden={(modelIds, hidden) =>
|
||||
handleBulkToggleModelHidden(providerId, modelIds, hidden)
|
||||
}
|
||||
@@ -377,7 +372,9 @@ export default function ProviderModelsSection({
|
||||
(acc, [alias, fullModel]) => {
|
||||
const prefix = `${providerDisplayAlias}/`;
|
||||
if (fullModel.startsWith(prefix)) {
|
||||
acc[fullModel.slice(prefix.length)] = alias;
|
||||
const modelId = fullModel.slice(prefix.length);
|
||||
const displayAlias = getDisplayModelAlias(modelId, alias);
|
||||
if (displayAlias) acc[modelId] = displayAlias;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
@@ -467,9 +464,7 @@ export default function ProviderModelsSection({
|
||||
onCopy={onCopy}
|
||||
onSetAlias={(a) => onSetAlias(model.id, a, providerDisplayAlias)}
|
||||
onDeleteAlias={
|
||||
aliasByModelId[model.id]
|
||||
? () => onDeleteAlias(aliasByModelId[model.id])
|
||||
: undefined
|
||||
aliasByModelId[model.id] ? () => onDeleteAlias(aliasByModelId[model.id]) : undefined
|
||||
}
|
||||
t={t}
|
||||
showDeveloperToggle
|
||||
|
||||
@@ -70,6 +70,7 @@ export type CompatModelRow = {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
isHidden?: boolean;
|
||||
isDeleted?: boolean;
|
||||
upstreamHeaders?: Record<string, string>;
|
||||
compatByProtocol?: CompatByProtocolMap;
|
||||
/** #2905: per-model upstream wire-format override. */ targetFormat?: string;
|
||||
@@ -557,19 +558,31 @@ export function buildCompatMap(rows: CompatModelRow[]): CompatModelMap {
|
||||
return m;
|
||||
}
|
||||
|
||||
export function getDisplayModelAlias(modelId: string, alias?: string | null): string | null {
|
||||
const trimmed = typeof alias === "string" ? alias.trim() : "";
|
||||
if (!trimmed || trimmed === modelId) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function readActiveHiddenFlag(row: CompatModelRow | undefined): boolean | undefined {
|
||||
if (!row || row.isDeleted === true) return undefined;
|
||||
if (Object.prototype.hasOwnProperty.call(row, "isHidden")) {
|
||||
return Boolean(row.isHidden);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isModelHiddenFn(
|
||||
modelId: string,
|
||||
customMap: CompatModelMap,
|
||||
overrideMap: CompatModelMap
|
||||
): boolean {
|
||||
const c = customMap.get(modelId);
|
||||
if (c && Object.prototype.hasOwnProperty.call(c, "isHidden")) {
|
||||
return Boolean(c.isHidden);
|
||||
}
|
||||
const o = overrideMap.get(modelId);
|
||||
if (o && Object.prototype.hasOwnProperty.call(o, "isHidden")) {
|
||||
return Boolean(o.isHidden);
|
||||
}
|
||||
const customHidden = readActiveHiddenFlag(customMap.get(modelId));
|
||||
if (customHidden !== undefined) return customHidden;
|
||||
|
||||
const overrideHidden = readActiveHiddenFlag(overrideMap.get(modelId));
|
||||
if (overrideHidden !== undefined) return overrideHidden;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -292,7 +292,7 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
|
||||
supportsTools: true,
|
||||
supportsVision: true,
|
||||
adaptiveThinkingOnly: true,
|
||||
aliases: BEDROCK_CLAUDE_ALIASES("claude-opus-4-8", "claude-opus-4.8"),
|
||||
aliases: BEDROCK_CLAUDE_ALIASES("claude-opus-4-8", "claude-opus-4.8", "claude-opus-4.8-fast"),
|
||||
},
|
||||
|
||||
// ── Claude Sonnet 4.5 ───────────────────────────────────────────
|
||||
|
||||
@@ -23,10 +23,17 @@ import assert from "node:assert/strict";
|
||||
|
||||
import { GithubExecutor } from "../../open-sse/executors/github.ts";
|
||||
import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.ts";
|
||||
import type { RegistryModel } from "../../open-sse/config/providerRegistry.ts";
|
||||
|
||||
const CHAT_URL = "https://api.githubcopilot.com/chat/completions";
|
||||
const RESPONSES_URL = "https://api.githubcopilot.com/responses";
|
||||
|
||||
function getGithubModel(modelId: string): RegistryModel {
|
||||
const model = PROVIDER_MODELS["gh"]?.find((entry) => entry.id === modelId);
|
||||
assert.ok(model, `${modelId} must be registered`);
|
||||
return model;
|
||||
}
|
||||
|
||||
describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9router#1536)", () => {
|
||||
it("routes registered Claude/Gemini Copilot models to chat/completions", () => {
|
||||
const exec = new GithubExecutor();
|
||||
@@ -34,11 +41,15 @@ describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9rout
|
||||
"claude-haiku-4.5",
|
||||
"claude-sonnet-4.5",
|
||||
"claude-sonnet-4.6",
|
||||
"claude-sonnet-5",
|
||||
"claude-fable-5",
|
||||
"claude-opus-4.6",
|
||||
"claude-opus-4.7",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-opus-4.8",
|
||||
"claude-opus-4.8-fast",
|
||||
"claude-opus-4.5",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.5-flash",
|
||||
]) {
|
||||
assert.equal(exec.buildUrl(id, false), CHAT_URL, `${id} must route to chat/completions`);
|
||||
}
|
||||
@@ -46,48 +57,51 @@ describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9rout
|
||||
|
||||
it("still uses chat/completions if a Claude/Gemini model is wrongly tagged openai-responses", () => {
|
||||
const exec = new GithubExecutor();
|
||||
const ghModels = PROVIDER_MODELS["gh"];
|
||||
assert.ok(Array.isArray(ghModels), "gh provider models registry must exist");
|
||||
|
||||
const claude = ghModels.find((m: any) => m.id === "claude-sonnet-4.6");
|
||||
const gemini = ghModels.find((m: any) => m.id === "gemini-3.1-pro-preview");
|
||||
assert.ok(claude && gemini, "claude-sonnet-4.6 and gemini-3.1-pro-preview must be registered");
|
||||
const claude = getGithubModel("claude-sonnet-4.6");
|
||||
const gemini = getGithubModel("gemini-3.1-pro-preview");
|
||||
|
||||
const originalClaude = claude.targetFormat;
|
||||
const originalGemini = gemini.targetFormat;
|
||||
try {
|
||||
// Simulate a future misconfiguration. The guard must still hold.
|
||||
(claude as any).targetFormat = "openai-responses";
|
||||
(gemini as any).targetFormat = "openai-responses";
|
||||
claude.targetFormat = "openai-responses";
|
||||
gemini.targetFormat = "openai-responses";
|
||||
|
||||
assert.equal(exec.buildUrl("claude-sonnet-4.6", false), CHAT_URL);
|
||||
assert.equal(exec.buildUrl("gemini-3.1-pro-preview", false), CHAT_URL);
|
||||
} finally {
|
||||
(claude as any).targetFormat = originalClaude;
|
||||
(gemini as any).targetFormat = originalGemini;
|
||||
claude.targetFormat = originalClaude;
|
||||
gemini.targetFormat = originalGemini;
|
||||
}
|
||||
});
|
||||
|
||||
it("still routes registered OpenAI/codex Copilot models to /responses", () => {
|
||||
const exec = new GithubExecutor();
|
||||
for (const id of ["gpt-5-mini", "gpt-5.3-codex", "gpt-5.4-mini", "gpt-5.4"]) {
|
||||
for (const id of [
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4",
|
||||
"gpt-5.5",
|
||||
"mai-code-1-flash",
|
||||
"gpt-5-mini",
|
||||
"oswe-vscode-prime",
|
||||
]) {
|
||||
assert.equal(exec.buildUrl(id, false), RESPONSES_URL, `${id} must route to /responses`);
|
||||
}
|
||||
});
|
||||
|
||||
it("is case-insensitive when guarding (defensive against upper/mixed-case ids)", () => {
|
||||
const exec = new GithubExecutor();
|
||||
const ghModels = PROVIDER_MODELS["gh"];
|
||||
const claude = ghModels.find((m: any) => m.id === "claude-sonnet-4.6");
|
||||
const claude = getGithubModel("claude-sonnet-4.6");
|
||||
const original = claude.targetFormat;
|
||||
try {
|
||||
(claude as any).targetFormat = "openai-responses";
|
||||
claude.targetFormat = "openai-responses";
|
||||
// Look up by the same id (registry is case-sensitive on lookup) but with a
|
||||
// mixed-case path through the guard. We rebuild with the registered id;
|
||||
// the guard normalizes before substring check, so it must still detect.
|
||||
assert.equal(exec.buildUrl("claude-sonnet-4.6", false), CHAT_URL);
|
||||
} finally {
|
||||
(claude as any).targetFormat = original;
|
||||
claude.targetFormat = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,20 +3,20 @@ import assert from "node:assert/strict";
|
||||
import { getRegistryEntry } from "../../open-sse/config/providerRegistry.ts";
|
||||
|
||||
// Regression guard: the GitHub Copilot (`gh`) provider must expose `gpt-4o-mini`
|
||||
// alongside `gpt-4o`. Copilot serves the cheaper mini variant via
|
||||
// alongside the curated date-pinned GPT-4o model. Copilot serves the cheaper mini variant via
|
||||
// chat/completions, so apps that hard-code `gpt-4o-mini` should resolve to the
|
||||
// Copilot provider — not only to the separate github-models (`ghm`) marketplace
|
||||
// entry, which lists it under the `openai/` prefix (`openai/gpt-4o-mini`).
|
||||
//
|
||||
// Ported from upstream decolua/9router (add GPT-4o mini to GitHub Copilot).
|
||||
|
||||
test("github (Copilot) provider exposes gpt-4o-mini next to gpt-4o", () => {
|
||||
test("github (Copilot) provider exposes gpt-4o-mini next to curated GPT-4o", () => {
|
||||
const entry = getRegistryEntry("github");
|
||||
assert.ok(entry, "github registry entry should exist");
|
||||
assert.ok(entry.models, "github registry entry should have a models array");
|
||||
|
||||
const ids = entry.models!.map((m) => m.id);
|
||||
assert.ok(ids.includes("gpt-4o"), "gpt-4o should remain registered");
|
||||
assert.ok(ids.includes("gpt-4o-2024-11-20"), "date-pinned gpt-4o should remain registered");
|
||||
assert.ok(
|
||||
ids.includes("gpt-4o-mini"),
|
||||
"gpt-4o-mini should be registered on the Copilot (gh) provider"
|
||||
@@ -25,6 +25,7 @@ test("github (Copilot) provider exposes gpt-4o-mini next to gpt-4o", () => {
|
||||
const mini = entry.models!.find((m) => m.id === "gpt-4o-mini");
|
||||
assert.equal(mini?.name, "GPT-4o mini");
|
||||
assert.equal(mini?.contextLength, 128000);
|
||||
assert.equal(ids.includes("gpt-4o"), false, "bare gpt-4o is not in the curated list");
|
||||
});
|
||||
|
||||
test("Copilot gpt-4o-mini is distinct from the github-models openai/gpt-4o-mini", () => {
|
||||
|
||||
@@ -19,13 +19,14 @@ import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
GITHUB_COPILOT_MODELS_URL,
|
||||
GITHUB_COPILOT_MODEL_ALLOWLIST,
|
||||
parseGitHubCopilotModels,
|
||||
fetchGitHubCopilotModels,
|
||||
} = await import("../../open-sse/services/githubCopilotModels.ts");
|
||||
|
||||
// A representative slice of a real Copilot /models response. Crucially it
|
||||
// includes models the account IS entitled to, and OMITS gemini previews to
|
||||
// prove non-entitled models are not advertised (#3121).
|
||||
// A representative slice of a real Copilot /models response. The upstream list
|
||||
// includes selectable chat models plus utility/legacy models; OmniRoute imports
|
||||
// only the curated allowlist.
|
||||
const MOCK_COPILOT_MODELS_RESPONSE = {
|
||||
data: [
|
||||
{
|
||||
@@ -42,7 +43,7 @@ const MOCK_COPILOT_MODELS_RESPONSE = {
|
||||
capabilities: { type: "chat" },
|
||||
},
|
||||
{
|
||||
// embeddings model — not a chat model; discovery should still surface the id
|
||||
// Embeddings model — present upstream but intentionally not in the curated chat list.
|
||||
id: "text-embedding-3-small",
|
||||
name: "Embedding V3 small",
|
||||
capabilities: { type: "embeddings" },
|
||||
@@ -53,12 +54,12 @@ const MOCK_COPILOT_MODELS_RESPONSE = {
|
||||
test("#3120 parseGitHubCopilotModels maps data[].id into managed models", () => {
|
||||
const models = parseGitHubCopilotModels(MOCK_COPILOT_MODELS_RESPONSE);
|
||||
const ids = models.map((m) => m.id);
|
||||
assert.ok(ids.includes("gpt-5.4"), "gpt-5.4 must be discovered");
|
||||
assert.ok(ids.includes("claude-sonnet-4.5"), "claude-sonnet-4.5 must be discovered");
|
||||
assert.deepEqual(ids, ["gpt-5.4", "claude-sonnet-4.5"]);
|
||||
const gpt = models.find((m) => m.id === "gpt-5.4");
|
||||
assert.ok(gpt, "gpt-5.4 entry present");
|
||||
assert.equal(gpt.name, "GPT-5.4");
|
||||
assert.equal(gpt.owned_by, "github");
|
||||
assert.ok(!ids.includes("text-embedding-3-small"), "non-allowlisted utility models are skipped");
|
||||
});
|
||||
|
||||
test("#3121 a model NOT in the live response is not advertised (entitlement filtering)", () => {
|
||||
@@ -98,16 +99,16 @@ test("#3120 fetchGitHubCopilotModels does a live fetch and returns parsed models
|
||||
assert.ok(capturedHeaders["copilot-integration-id"], "must send Copilot integration header");
|
||||
assert.equal(result.source, "api");
|
||||
const ids = result.models.map((m) => m.id);
|
||||
assert.ok(ids.includes("gpt-5.4"));
|
||||
assert.deepEqual(ids, ["gpt-5.4", "claude-sonnet-4.5"]);
|
||||
assert.ok(!ids.includes("gemini-3.1-pro-preview"));
|
||||
});
|
||||
|
||||
test("#3120/#3121 fetch falls back to static catalog when the live fetch fails", async () => {
|
||||
const fakeFetch = (async () =>
|
||||
new Response("nope", { status: 503 })) as unknown as typeof fetch;
|
||||
const fakeFetch = (async () => new Response("nope", { status: 503 })) as unknown as typeof fetch;
|
||||
const fallback = [
|
||||
{ id: "gpt-5.4", name: "GPT-5.4" },
|
||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" },
|
||||
{ id: "gpt-3.5-turbo", name: "GPT 3.5 Turbo" },
|
||||
];
|
||||
|
||||
const result = await fetchGitHubCopilotModels({
|
||||
@@ -120,7 +121,37 @@ test("#3120/#3121 fetch falls back to static catalog when the live fetch fails",
|
||||
assert.deepEqual(
|
||||
result.models.map((m) => m.id),
|
||||
["gpt-5.4", "gemini-3.1-pro-preview"],
|
||||
"offline/failed discovery must preserve the static catalog"
|
||||
"offline/failed discovery must preserve only the curated static catalog"
|
||||
);
|
||||
});
|
||||
|
||||
test("curated Copilot allowlist contains the final approved model ids only", () => {
|
||||
assert.deepEqual(
|
||||
[...GITHUB_COPILOT_MODEL_ALLOWLIST],
|
||||
[
|
||||
"claude-fable-5",
|
||||
"claude-opus-4.8-fast",
|
||||
"claude-opus-4.8",
|
||||
"claude-opus-4.7",
|
||||
"claude-sonnet-4.6",
|
||||
"claude-opus-4.5",
|
||||
"claude-sonnet-5",
|
||||
"claude-sonnet-4.5",
|
||||
"claude-haiku-4.5",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3.5-flash",
|
||||
"gpt-5.5",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5-mini",
|
||||
"gpt-4o-2024-11-20",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4-0125-preview",
|
||||
"kimi-k2.7-code",
|
||||
"mai-code-1-flash",
|
||||
"oswe-vscode-prime",
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -139,5 +170,8 @@ test("fetch falls back when no token is provided (unauthed refresh stays safe)",
|
||||
|
||||
assert.equal(called, false, "must not fetch without a token");
|
||||
assert.equal(result.source, "fallback");
|
||||
assert.deepEqual(result.models.map((m) => m.id), ["gpt-5.4"]);
|
||||
assert.deepEqual(
|
||||
result.models.map((m) => m.id),
|
||||
["gpt-5.4"]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
supportsXHighEffort,
|
||||
supportsXHighEffortForMaxNormalization,
|
||||
} from "../../open-sse/config/providerModels.ts";
|
||||
import { GITHUB_COPILOT_MODEL_ALLOWLIST } from "../../open-sse/services/githubCopilotModels.ts";
|
||||
|
||||
test("provider models helpers expose model lists and defaults", () => {
|
||||
const openaiModels = getProviderModels("openai");
|
||||
@@ -56,8 +57,7 @@ test("provider models helpers resolve provider IDs through aliases", () => {
|
||||
test("getProviderModels returns models for both the alias and the raw provider id", () => {
|
||||
// Pick a provider whose alias differs from its id (e.g. "github" → "gh").
|
||||
const aliased = Object.entries(PROVIDER_ID_TO_ALIAS).find(([id, a]) => id !== a) as
|
||||
| [string, string]
|
||||
| undefined;
|
||||
[string, string] | undefined;
|
||||
if (!aliased) return; // no aliased providers → trivially satisfied
|
||||
|
||||
const [rawId, alias] = aliased;
|
||||
@@ -85,20 +85,22 @@ test("Reka registry exposes preset models", () => {
|
||||
|
||||
test("GitHub Copilot registry reflects the current supported model lineup", () => {
|
||||
const githubModels = getProviderModels("gh");
|
||||
const ids = new Set(githubModels.map((model) => model.id));
|
||||
const ids = githubModels.map((model) => model.id);
|
||||
|
||||
assert.ok(ids.has("gpt-5.3-codex"));
|
||||
assert.ok(ids.has("gpt-5.4"));
|
||||
assert.ok(ids.has("gpt-5.4-mini"));
|
||||
assert.ok(ids.has("claude-opus-4.7"));
|
||||
assert.ok(ids.has("claude-opus-4.6"));
|
||||
assert.ok(ids.has("claude-sonnet-4.6"));
|
||||
assert.ok(ids.has("gemini-3-flash-preview"));
|
||||
assert.deepEqual(ids, [...GITHUB_COPILOT_MODEL_ALLOWLIST]);
|
||||
assert.equal(getModelTargetFormat("gh", "gpt-5.3-codex"), "openai-responses");
|
||||
assert.equal(getModelTargetFormat("gh", "claude-opus-4.6"), null);
|
||||
assert.equal(ids.has("gpt-5.1"), false);
|
||||
assert.equal(ids.has("gpt-5.1-codex"), false);
|
||||
assert.equal(ids.has("claude-opus-4.1"), false);
|
||||
assert.equal(getModelTargetFormat("gh", "claude-opus-4.8-fast"), null);
|
||||
assert.equal(getModelTargetFormat("gh", "gemini-3.5-flash"), null);
|
||||
assert.equal(getModelTargetFormat("gh", "kimi-k2.7-code"), null);
|
||||
assert.equal(ids.includes("gpt-4"), false);
|
||||
assert.equal(ids.includes("gpt-4o"), false);
|
||||
assert.equal(ids.includes("gpt-5.4-nano"), false);
|
||||
assert.equal(ids.includes("gpt-5.1"), false);
|
||||
assert.equal(ids.includes("gpt-5.1-codex"), false);
|
||||
assert.equal(ids.includes("claude-opus-4.1"), false);
|
||||
assert.equal(ids.includes("claude-opus-4-5-20251101"), false);
|
||||
assert.equal(ids.includes("gemini-3-flash-preview"), false);
|
||||
});
|
||||
|
||||
test("Kiro registry exposes the current CLI model lineup with context windows", () => {
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
/**
|
||||
* Port: add GPT-4 to the GitHub Copilot (`github`/alias `gh`) provider.
|
||||
* Curated GitHub Copilot GPT-4-family registry coverage.
|
||||
*
|
||||
* Copilot still serves the original `gpt-4` chat model via its chat/completions
|
||||
* endpoint, alongside `gpt-4o` and the GPT-5.x family. The OmniRoute registry
|
||||
* shipped GPT-4o + GPT-5.x but not plain `gpt-4`, so apps that explicitly request
|
||||
* `gpt-4` against the `gh` alias got an "unknown model" error. Adding the entry
|
||||
* restores parity with the upstream Copilot catalog without disturbing the
|
||||
* GPT-4o / GPT-5.x / Claude / Gemini lineups already curated.
|
||||
*
|
||||
* GPT-4 is a chat/completions model — it must NOT use `openai-responses`.
|
||||
* The final Copilot allowlist keeps `gpt-4-0125-preview` but intentionally drops
|
||||
* the older bare `gpt-4` alias. GPT-4 Turbo is a chat/completions model — it
|
||||
* must NOT use `openai-responses`.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
|
||||
const { getModelsByProviderId, getProviderModel, isValidModel } = await import(
|
||||
"../../open-sse/config/providerModels.ts"
|
||||
);
|
||||
const { getModelsByProviderId, getProviderModel, isValidModel } =
|
||||
await import("../../open-sse/config/providerModels.ts");
|
||||
|
||||
type ModelEntry = { id: string; name?: string; targetFormat?: string; [k: string]: unknown };
|
||||
|
||||
@@ -25,33 +19,41 @@ function githubModel(id: string): ModelEntry | undefined {
|
||||
return provider?.models?.find((m) => m.id === id);
|
||||
}
|
||||
|
||||
test("github/gpt-4 is registered under the gh provider", () => {
|
||||
const model = githubModel("gpt-4");
|
||||
assert.ok(model, "gpt-4 must be registered under the github (gh) provider");
|
||||
test("github/gpt-4-0125-preview is registered under the gh provider", () => {
|
||||
const model = githubModel("gpt-4-0125-preview");
|
||||
assert.ok(model, "gpt-4-0125-preview must be registered under the github (gh) provider");
|
||||
assert.equal(typeof model?.name, "string");
|
||||
});
|
||||
|
||||
test("github/gpt-4 routes via chat/completions (no openai-responses)", () => {
|
||||
const model = githubModel("gpt-4");
|
||||
test("github/gpt-4-0125-preview routes via chat/completions (no openai-responses)", () => {
|
||||
const model = githubModel("gpt-4-0125-preview");
|
||||
assert.ok(model);
|
||||
assert.notEqual(
|
||||
model.targetFormat,
|
||||
"openai-responses",
|
||||
"GPT-4 on GitHub Copilot is a chat/completions model — Responses API would reject it"
|
||||
"GPT-4 Turbo on GitHub Copilot is a chat/completions model — Responses API would reject it"
|
||||
);
|
||||
});
|
||||
|
||||
test("getModelsByProviderId(github) exposes gpt-4", () => {
|
||||
test("getModelsByProviderId(github) exposes gpt-4-0125-preview", () => {
|
||||
const models = getModelsByProviderId("github") as ModelEntry[];
|
||||
const gpt4 = models.find((m) => m.id === "gpt-4");
|
||||
assert.ok(gpt4, "gpt-4 resolvable via getModelsByProviderId(github)");
|
||||
const gpt4 = models.find((m) => m.id === "gpt-4-0125-preview");
|
||||
assert.ok(gpt4, "gpt-4-0125-preview resolvable via getModelsByProviderId(github)");
|
||||
});
|
||||
|
||||
test("gpt-4 resolves through both the gh alias and the github id", () => {
|
||||
test("gpt-4-0125-preview resolves through both the gh alias and the github id", () => {
|
||||
// getProviderModel keys on the public alias; isValidModel mirrors it.
|
||||
assert.ok(getProviderModel("gh", "gpt-4"), "getProviderModel('gh','gpt-4') must resolve");
|
||||
assert.equal(isValidModel("gh", "gpt-4"), true, "isValidModel('gh','gpt-4') must be true");
|
||||
assert.ok(
|
||||
getProviderModel("gh", "gpt-4-0125-preview"),
|
||||
"getProviderModel('gh','gpt-4-0125-preview') must resolve"
|
||||
);
|
||||
assert.equal(
|
||||
isValidModel("gh", "gpt-4-0125-preview"),
|
||||
true,
|
||||
"isValidModel('gh','gpt-4-0125-preview') must be true"
|
||||
);
|
||||
// Raw provider id resolves to the same entry via the alias map.
|
||||
const viaId = getModelsByProviderId("github").find((m) => m.id === "gpt-4");
|
||||
assert.ok(viaId, "gpt-4 resolvable via the raw 'github' provider id");
|
||||
const viaId = getModelsByProviderId("github").find((m) => m.id === "gpt-4-0125-preview");
|
||||
assert.ok(viaId, "gpt-4-0125-preview resolvable via the raw 'github' provider id");
|
||||
assert.equal(isValidModel("gh", "gpt-4"), false, "bare gpt-4 is not in the curated list");
|
||||
});
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
/**
|
||||
* Port of 9router PR #98 — add GPT-4o to GitHub Copilot (`github`/alias `gh`).
|
||||
* Curated GitHub Copilot GPT-4o registry coverage.
|
||||
*
|
||||
* Copilot still serves the original `gpt-4o` chat model via its chat/completions
|
||||
* endpoint. The OmniRoute registry only ships the GPT-5.x family, so apps that
|
||||
* explicitly request `gpt-4o` against the `gh` alias get an "unknown model"
|
||||
* error. Adding the entry restores parity with the upstream Copilot catalog
|
||||
* without disturbing the GPT-5.x / Claude / Gemini lineups already curated.
|
||||
*
|
||||
* GPT-4o is a chat/completions model — it must NOT use `openai-responses`.
|
||||
* The final Copilot allowlist keeps `gpt-4o-2024-11-20` but intentionally drops
|
||||
* the older bare `gpt-4o` alias. GPT-4o is a chat/completions model — it must
|
||||
* NOT use `openai-responses`.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -22,14 +18,14 @@ function githubModel(id: string): ModelEntry | undefined {
|
||||
return provider?.models?.find((m) => m.id === id);
|
||||
}
|
||||
|
||||
test("9router#98 github/gpt-4o is registered under the gh provider", () => {
|
||||
const model = githubModel("gpt-4o");
|
||||
assert.ok(model, "gpt-4o must be registered under the github (gh) provider");
|
||||
test("github/gpt-4o-2024-11-20 is registered under the gh provider", () => {
|
||||
const model = githubModel("gpt-4o-2024-11-20");
|
||||
assert.ok(model, "gpt-4o-2024-11-20 must be registered under the github (gh) provider");
|
||||
assert.equal(typeof model?.name, "string");
|
||||
});
|
||||
|
||||
test("9router#98 github/gpt-4o routes via chat/completions (no openai-responses)", () => {
|
||||
const model = githubModel("gpt-4o");
|
||||
test("github/gpt-4o-2024-11-20 routes via chat/completions (no openai-responses)", () => {
|
||||
const model = githubModel("gpt-4o-2024-11-20");
|
||||
assert.ok(model);
|
||||
assert.notEqual(
|
||||
model.targetFormat,
|
||||
@@ -38,8 +34,13 @@ test("9router#98 github/gpt-4o routes via chat/completions (no openai-responses)
|
||||
);
|
||||
});
|
||||
|
||||
test("9router#98 getModelsByProviderId(github) exposes gpt-4o", () => {
|
||||
test("getModelsByProviderId(github) exposes gpt-4o-2024-11-20", () => {
|
||||
const models = getModelsByProviderId("github") as ModelEntry[];
|
||||
const gpt4o = models.find((m) => m.id === "gpt-4o");
|
||||
assert.ok(gpt4o, "gpt-4o resolvable via getModelsByProviderId(github)");
|
||||
const gpt4o = models.find((m) => m.id === "gpt-4o-2024-11-20");
|
||||
assert.ok(gpt4o, "gpt-4o-2024-11-20 resolvable via getModelsByProviderId(github)");
|
||||
assert.equal(
|
||||
models.some((m) => m.id === "gpt-4o"),
|
||||
false,
|
||||
"bare gpt-4o is not in the curated list"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* Responses API when it sets `targetFormat: "openai-responses"`.
|
||||
*
|
||||
* GitHub Copilot's Responses API does NOT serve the Claude/Gemini models, so
|
||||
* `claude-opus-4.7`, `claude-opus-4-5-20251101`, `gemini-3.1-pro-preview` and
|
||||
* `gemini-3-flash-preview` failed with a 400. The working `claude-opus-4.6`
|
||||
* `claude-opus-4.7`, `gemini-3.1-pro-preview` and other non-OpenAI models
|
||||
* failed with a 400. The working `claude-sonnet-4.6`
|
||||
* carries no `targetFormat` and goes through chat/completions.
|
||||
*
|
||||
* Fix: drop `targetFormat: "openai-responses"` from the Claude/Gemini entries so
|
||||
@@ -29,10 +29,17 @@ function githubModel(id: string): ModelEntry | undefined {
|
||||
|
||||
// Claude/Gemini models that must NOT route through the Responses API.
|
||||
const MUST_NOT_BE_RESPONSES = [
|
||||
"claude-fable-5",
|
||||
"claude-opus-4.7",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-opus-4.8",
|
||||
"claude-opus-4.8-fast",
|
||||
"claude-opus-4.5",
|
||||
"claude-sonnet-4.6",
|
||||
"claude-sonnet-5",
|
||||
"claude-sonnet-4.5",
|
||||
"claude-haiku-4.5",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.5-flash",
|
||||
];
|
||||
|
||||
for (const id of MUST_NOT_BE_RESPONSES) {
|
||||
@@ -47,14 +54,22 @@ for (const id of MUST_NOT_BE_RESPONSES) {
|
||||
});
|
||||
}
|
||||
|
||||
test("#2911 github/claude-opus-4.6 baseline stays on chat/completions (no targetFormat)", () => {
|
||||
const model = githubModel("claude-opus-4.6");
|
||||
assert.ok(model, "claude-opus-4.6 must be registered");
|
||||
test("#2911 github/claude-sonnet-4.6 baseline stays on chat/completions (no targetFormat)", () => {
|
||||
const model = githubModel("claude-sonnet-4.6");
|
||||
assert.ok(model, "claude-sonnet-4.6 must be registered");
|
||||
assert.notEqual(model.targetFormat, "openai-responses");
|
||||
});
|
||||
|
||||
// Regression guard: native OpenAI models keep the Responses API.
|
||||
for (const id of ["gpt-5.4", "gpt-5.4-mini"]) {
|
||||
// Regression guard: models with `/responses` in the curated Copilot catalog keep the Responses API.
|
||||
for (const id of [
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4",
|
||||
"gpt-5.5",
|
||||
"mai-code-1-flash",
|
||||
"gpt-5-mini",
|
||||
"oswe-vscode-prime",
|
||||
]) {
|
||||
test(`#2911 github/${id} (OpenAI-native) still uses openai-responses`, () => {
|
||||
const model = githubModel(id);
|
||||
assert.ok(model, `${id} must be registered`);
|
||||
|
||||
Reference in New Issue
Block a user