From 2ea22bb71d26429ccbe1d7fe7a956ec7ed1f59bf Mon Sep 17 00:00:00 2001 From: backryun Date: Thu, 6 Aug 2026 10:30:01 +0900 Subject: [PATCH] fix(providers): suppress retired Copilot Gemini models (#9103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...03-remove-retired-copilot-gemini-models.md | 1 + .../registry/github/retiredModels.ts | 12 ++ src/lib/db/models.ts | 123 +++--------------- src/lib/db/models/synced.ts | 106 +++++++++++++++ .../github-copilot-retired-models.test.ts | 95 ++++++++++++++ 5 files changed, 231 insertions(+), 106 deletions(-) create mode 100644 changelog.d/fixes/9103-remove-retired-copilot-gemini-models.md create mode 100644 open-sse/config/providers/registry/github/retiredModels.ts create mode 100644 src/lib/db/models/synced.ts create mode 100644 tests/unit/github-copilot-retired-models.test.ts diff --git a/changelog.d/fixes/9103-remove-retired-copilot-gemini-models.md b/changelog.d/fixes/9103-remove-retired-copilot-gemini-models.md new file mode 100644 index 0000000000..73087690c4 --- /dev/null +++ b/changelog.d/fixes/9103-remove-retired-copilot-gemini-models.md @@ -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)) diff --git a/open-sse/config/providers/registry/github/retiredModels.ts b/open-sse/config/providers/registry/github/retiredModels.ts new file mode 100644 index 0000000000..cc412401f9 --- /dev/null +++ b/open-sse/config/providers/registry/github/retiredModels.ts @@ -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()); +} diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 168a71d47a..58f6457b5a 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -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 & { - 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(); - 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; diff --git a/src/lib/db/models/synced.ts b/src/lib/db/models/synced.ts new file mode 100644 index 0000000000..92f2c4eb7a --- /dev/null +++ b/src/lib/db/models/synced.ts @@ -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 & { + 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(); + 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()); +} diff --git a/tests/unit/github-copilot-retired-models.test.ts b/tests/unit/github-copilot-retired-models.test.ts new file mode 100644 index 0000000000..6ce66556c0 --- /dev/null +++ b/tests/unit/github-copilot-retired-models.test.ts @@ -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"]); +});