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:
backryun
2026-07-05 14:33:32 +09:00
committed by GitHub
parent b1e27258c0
commit e755c5ac68
17 changed files with 405 additions and 203 deletions

View File

@@ -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

View File

@@ -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)."
}

View File

@@ -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,
},
],
};

View File

@@ -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));

View File

@@ -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");

View File

@@ -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") ||

View File

@@ -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") ||

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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 ───────────────────────────────────────────

View File

@@ -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;
}
});
});

View File

@@ -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", () => {

View File

@@ -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"]
);
});

View File

@@ -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", () => {

View File

@@ -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");
});

View File

@@ -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"
);
});

View File

@@ -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`);