fix(providers): suppress retired Copilot Gemini models (#9103)

Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates: typecheck/complexity/cognitive/changelog/vitest — only pre-existing audit.test.ts flake). Evidence: /home/diegosouzapw/dev/proxys/OmniRoute/.claude/worktrees/merge-train-20260805-222248-suite.log
This commit is contained in:
backryun
2026-08-06 10:30:01 +09:00
committed by GitHub
parent a174ea6d33
commit 2ea22bb71d
5 changed files with 231 additions and 106 deletions

View File

@@ -0,0 +1 @@
- **fix(providers):** GitHub Copilot no longer re-imports or routes cached Gemini 2.5 Pro and Gemini 3 Flash model IDs after their retirement ([#9103](https://github.com/diegosouzapw/OmniRoute/pull/9103))

View File

@@ -0,0 +1,12 @@
const RETIRED_GITHUB_COPILOT_MODEL_IDS = new Set([
"gemini-2.5-pro",
"gemini-3-flash",
"gemini-3-flash-preview",
]);
export function isRetiredGitHubCopilotModelId(providerId: unknown, modelId: unknown): boolean {
const provider = typeof providerId === "string" ? providerId.trim().toLowerCase() : "";
if (provider !== "github" && provider !== "gh") return false;
if (typeof modelId !== "string") return false;
return RETIRED_GITHUB_COPILOT_MODEL_IDS.has(modelId.trim().toLowerCase());
}

View File

@@ -4,10 +4,17 @@
* models/; this file re-exports their public APIs for backward compatibility.
*/
import { isRetiredGitHubCopilotModelId } from "@omniroute/open-sse/config/providers/registry/github/retiredModels.ts";
import { getDbInstance } from "./core";
import { backupDbFile } from "./backup";
import { getProviderConnectionsCount } from "./providers";
import { type JsonRecord, asRecord, toNonEmptyString, getKeyValue } from "./models/shared";
import { type JsonRecord, getKeyValue } from "./models/shared";
import {
normalizeSyncedAvailableModels,
type SyncedAvailableModel,
type SyncedAvailableModelInput,
} from "./models/synced";
import {
readCompatList,
writeCompatList,
@@ -40,6 +47,7 @@ export {
deleteModelAliasesForProvider,
} from "./models/aliases";
export { getMitmAlias, setMitmAliasAll } from "./models/mitmAlias";
export type { SyncedAvailableModel } from "./models/synced";
// ──────────────── Custom Models ────────────────
@@ -318,104 +326,6 @@ export async function removeCustomModel(providerId: string, modelId: string) {
// Each connection stores its own model list. Reads union across all connections
// for a provider. Deleting a connection removes only its models.
export interface SyncedAvailableModel {
id: string;
name: string;
source: "imported";
apiFormat?: string;
targetFormat?: string;
upstreamProtocol?: string;
supportedEndpoints?: string[];
supportedThinkingEfforts?: string[];
defaultThinkingEffort?: string;
inputTokenLimit?: number;
outputTokenLimit?: number;
description?: string;
supportsThinking?: boolean;
alwaysThinking?: boolean;
supportsTools?: boolean;
supportsVideo?: boolean;
// #4264: image-input capability captured at sync time (e.g. OpenRouter
// `architecture.input_modalities`/`modality`) so the catalog can surface vision.
supportsVision?: boolean;
}
type SyncedAvailableModelInput = Omit<SyncedAvailableModel, "source"> & {
source?: string;
};
function normalizeSyncedAvailableModel(model: unknown): SyncedAvailableModel | null {
const record = asRecord(model);
const id =
toNonEmptyString(record.id) || toNonEmptyString(record.name) || toNonEmptyString(record.model);
if (!id) return null;
const name =
toNonEmptyString(record.name) ||
toNonEmptyString(record.displayName) ||
toNonEmptyString(record.model) ||
id;
const supportedEndpoints = Array.isArray(record.supportedEndpoints)
? Array.from(
new Set(
record.supportedEndpoints
.map((endpoint) => toNonEmptyString(endpoint))
.filter((endpoint): endpoint is string => Boolean(endpoint))
)
).sort()
: undefined;
return {
id,
name,
source: "imported",
...(toNonEmptyString(record.apiFormat)
? { apiFormat: toNonEmptyString(record.apiFormat)! }
: {}),
...(toNonEmptyString(record.targetFormat)
? { targetFormat: toNonEmptyString(record.targetFormat)! }
: {}),
...(toNonEmptyString(record.upstreamProtocol)
? { upstreamProtocol: toNonEmptyString(record.upstreamProtocol)! }
: {}),
...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}),
...(Array.isArray(record.supportedThinkingEfforts)
? {
supportedThinkingEfforts: record.supportedThinkingEfforts.filter(
(effort): effort is string => typeof effort === "string" && effort.length > 0
),
}
: {}),
...(toNonEmptyString(record.defaultThinkingEffort)
? { defaultThinkingEffort: toNonEmptyString(record.defaultThinkingEffort)! }
: {}),
...(typeof record.inputTokenLimit === "number"
? { inputTokenLimit: record.inputTokenLimit }
: {}),
...(typeof record.outputTokenLimit === "number"
? { outputTokenLimit: record.outputTokenLimit }
: {}),
...(typeof record.description === "string" ? { description: record.description } : {}),
...(typeof record.supportsThinking === "boolean"
? { supportsThinking: record.supportsThinking }
: {}),
...(record.alwaysThinking === true ? { alwaysThinking: true } : {}),
...(typeof record.supportsTools === "boolean" ? { supportsTools: record.supportsTools } : {}),
...(typeof record.supportsVideo === "boolean" ? { supportsVideo: record.supportsVideo } : {}),
...(record.supportsVision === true ? { supportsVision: true } : {}),
};
}
function normalizeSyncedAvailableModels(models: unknown): SyncedAvailableModel[] {
if (!Array.isArray(models)) return [];
const deduped = new Map<string, SyncedAvailableModel>();
for (const model of models) {
const normalized = normalizeSyncedAvailableModel(model);
if (normalized) deduped.set(normalized.id, normalized);
}
return Array.from(deduped.values());
}
/**
* Get synced available models for a specific provider connection.
*/
@@ -432,7 +342,7 @@ export async function getSyncedAvailableModelsForConnection(
if (!value) return [];
try {
const models = JSON.parse(value);
return normalizeSyncedAvailableModels(models);
return normalizeSyncedAvailableModels(models, providerId);
} catch {
return [];
}
@@ -454,7 +364,7 @@ export async function getSyncedAvailableModels(
for (const row of rows) {
const { key, value } = getKeyValue(row);
if (!key || value === null) continue;
const models = normalizeSyncedAvailableModels(JSON.parse(value));
const models = normalizeSyncedAvailableModels(JSON.parse(value), providerId);
for (const m of models) {
if (m.id) map.set(m.id, m);
}
@@ -481,7 +391,7 @@ export async function getSyncedAvailableModelsByConnection(
if (!key || value === null || !key.startsWith(prefix)) continue;
try {
const connectionId = key.slice(prefix.length);
result[connectionId] = normalizeSyncedAvailableModels(JSON.parse(value));
result[connectionId] = normalizeSyncedAvailableModels(JSON.parse(value), providerId);
} catch {
// Ignore malformed legacy entries.
}
@@ -506,7 +416,7 @@ export async function getAllSyncedAvailableModels(): Promise<
if (!key || value === null) continue;
const providerId = key.split(":")[0];
if (!byProvider.has(providerId)) byProvider.set(providerId, new Map());
const models = normalizeSyncedAvailableModels(JSON.parse(value));
const models = normalizeSyncedAvailableModels(JSON.parse(value), providerId);
const map = byProvider.get(providerId)!;
for (const m of models) {
if (m.id) map.set(m.id, m);
@@ -549,7 +459,8 @@ export async function getActiveProvidersWithSyncedModel(modelId: string): Promis
return rows
.map((row) => row.provider)
.filter((provider): provider is string => typeof provider === "string" && provider.length > 0);
.filter((provider): provider is string => typeof provider === "string" && provider.length > 0)
.filter((provider) => !isRetiredGitHubCopilotModelId(provider, modelId));
}
/**
@@ -570,7 +481,7 @@ export async function replaceSyncedAvailableModelsForConnection(
// the synced store so they remain listed-but-hidden across re-syncs instead of
// churning back on through the managed-alias path ("Auto Sync Enabling all
// Models"). See getModelIsDeleted for the legacy-row caveat.
const normalizedModels = normalizeSyncedAvailableModels(models).filter(
const normalizedModels = normalizeSyncedAvailableModels(models, providerId).filter(
(m) => !getModelIsDeleted(providerId, m.id)
);
if (normalizedModels.length === 0) {
@@ -617,7 +528,7 @@ export async function removeSyncedAvailableModel(
continue;
}
const models = normalizeSyncedAvailableModels(parsedModels);
const models = normalizeSyncedAvailableModels(parsedModels, providerId);
const filtered = models.filter((m) => m.id !== modelId);
if (filtered.length !== models.length) {
removedAny = true;

106
src/lib/db/models/synced.ts Normal file
View File

@@ -0,0 +1,106 @@
import { isRetiredGitHubCopilotModelId } from "@omniroute/open-sse/config/providers/registry/github/retiredModels.ts";
import { asRecord, toNonEmptyString } from "./shared";
export interface SyncedAvailableModel {
id: string;
name: string;
source: "imported";
apiFormat?: string;
targetFormat?: string;
upstreamProtocol?: string;
supportedEndpoints?: string[];
supportedThinkingEfforts?: string[];
defaultThinkingEffort?: string;
inputTokenLimit?: number;
outputTokenLimit?: number;
description?: string;
supportsThinking?: boolean;
alwaysThinking?: boolean;
supportsTools?: boolean;
supportsVideo?: boolean;
// #4264: image-input capability captured at sync time (e.g. OpenRouter
// `architecture.input_modalities`/`modality`) so the catalog can surface vision.
supportsVision?: boolean;
}
export type SyncedAvailableModelInput = Omit<SyncedAvailableModel, "source"> & {
source?: string;
};
function normalizeSyncedAvailableModel(model: unknown): SyncedAvailableModel | null {
const record = asRecord(model);
const id =
toNonEmptyString(record.id) || toNonEmptyString(record.name) || toNonEmptyString(record.model);
if (!id) return null;
const name =
toNonEmptyString(record.name) ||
toNonEmptyString(record.displayName) ||
toNonEmptyString(record.model) ||
id;
const supportedEndpoints = Array.isArray(record.supportedEndpoints)
? Array.from(
new Set(
record.supportedEndpoints
.map((endpoint) => toNonEmptyString(endpoint))
.filter((endpoint): endpoint is string => Boolean(endpoint))
)
).sort()
: undefined;
return {
id,
name,
source: "imported",
...(toNonEmptyString(record.apiFormat)
? { apiFormat: toNonEmptyString(record.apiFormat)! }
: {}),
...(toNonEmptyString(record.targetFormat)
? { targetFormat: toNonEmptyString(record.targetFormat)! }
: {}),
...(toNonEmptyString(record.upstreamProtocol)
? { upstreamProtocol: toNonEmptyString(record.upstreamProtocol)! }
: {}),
...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}),
...(Array.isArray(record.supportedThinkingEfforts)
? {
supportedThinkingEfforts: record.supportedThinkingEfforts.filter(
(effort): effort is string => typeof effort === "string" && effort.length > 0
),
}
: {}),
...(toNonEmptyString(record.defaultThinkingEffort)
? { defaultThinkingEffort: toNonEmptyString(record.defaultThinkingEffort)! }
: {}),
...(typeof record.inputTokenLimit === "number"
? { inputTokenLimit: record.inputTokenLimit }
: {}),
...(typeof record.outputTokenLimit === "number"
? { outputTokenLimit: record.outputTokenLimit }
: {}),
...(typeof record.description === "string" ? { description: record.description } : {}),
...(typeof record.supportsThinking === "boolean"
? { supportsThinking: record.supportsThinking }
: {}),
...(record.alwaysThinking === true ? { alwaysThinking: true } : {}),
...(typeof record.supportsTools === "boolean" ? { supportsTools: record.supportsTools } : {}),
...(typeof record.supportsVideo === "boolean" ? { supportsVideo: record.supportsVideo } : {}),
...(record.supportsVision === true ? { supportsVision: true } : {}),
};
}
export function normalizeSyncedAvailableModels(
models: unknown,
providerId?: string
): SyncedAvailableModel[] {
if (!Array.isArray(models)) return [];
const deduped = new Map<string, SyncedAvailableModel>();
for (const model of models) {
const normalized = normalizeSyncedAvailableModel(model);
if (normalized && !isRetiredGitHubCopilotModelId(providerId, normalized.id)) {
deduped.set(normalized.id, normalized);
}
}
return Array.from(deduped.values());
}

View File

@@ -0,0 +1,95 @@
import test, { after, before } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-copilot-retired-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
before(() => {
core.resetDbInstance();
});
after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("GitHub Copilot sync rejects retired Gemini models", async () => {
await modelsDb.replaceSyncedAvailableModelsForConnection("github", "copilot-current", [
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-3-flash", name: "Gemini 3 Flash" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
{ id: "gemini-3.6-flash", name: "Gemini 3.6 Flash" },
]);
const ids = (await modelsDb.getSyncedAvailableModels("github")).map((model) => model.id);
assert.deepEqual(ids, ["gemini-3.6-flash"]);
});
test("GitHub Copilot readers hide retired models from legacy synced caches", async () => {
const db = core.getDbInstance();
db.prepare(
"INSERT INTO key_value (namespace, key, value) VALUES ('syncedAvailableModels', ?, ?)"
).run(
"github:copilot-legacy",
JSON.stringify([
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash" },
{ id: "gemini-3.6-flash", name: "Gemini 3.6 Flash" },
])
);
const connectionIds = (
await modelsDb.getSyncedAvailableModelsForConnection("github", "copilot-legacy")
).map((model) => model.id);
const providerIds = (await modelsDb.getSyncedAvailableModels("github")).map((model) => model.id);
const allProviderIds = (await modelsDb.getAllSyncedAvailableModels()).github.map(
(model) => model.id
);
assert.deepEqual(connectionIds, ["gemini-3.6-flash"]);
assert.deepEqual(providerIds, ["gemini-3.6-flash"]);
assert.deepEqual(allProviderIds, ["gemini-3.6-flash"]);
});
test("provider inference does not route retired Gemini models to GitHub Copilot", async () => {
const connection = await providersDb.createProviderConnection({
provider: "github",
authType: "oauth",
name: "copilot-retired-routing",
accessToken: "github-test-token",
isActive: true,
testStatus: "active",
});
const db = core.getDbInstance();
db.prepare(
"INSERT INTO key_value (namespace, key, value) VALUES ('syncedAvailableModels', ?, ?)"
).run(
`github:${connection.id}`,
JSON.stringify([
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-3.6-flash", name: "Gemini 3.6 Flash" },
])
);
assert.deepEqual(await modelsDb.getActiveProvidersWithSyncedModel("gemini-2.5-pro"), []);
assert.deepEqual(await modelsDb.getActiveProvidersWithSyncedModel("gemini-3.6-flash"), [
"github",
]);
});
test("retirement remains scoped to GitHub Copilot", async () => {
await modelsDb.replaceSyncedAvailableModelsForConnection("gemini", "gemini-direct", [
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-3-flash", name: "Gemini 3 Flash" },
]);
const ids = (await modelsDb.getSyncedAvailableModels("gemini")).map((model) => model.id);
assert.deepEqual(ids, ["gemini-2.5-pro", "gemini-3-flash"]);
});