mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 23:32:12 +03:00
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:
@@ -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))
|
||||
12
open-sse/config/providers/registry/github/retiredModels.ts
Normal file
12
open-sse/config/providers/registry/github/retiredModels.ts
Normal 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());
|
||||
}
|
||||
@@ -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
106
src/lib/db/models/synced.ts
Normal 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());
|
||||
}
|
||||
95
tests/unit/github-copilot-retired-models.test.ts
Normal file
95
tests/unit/github-copilot-retired-models.test.ts
Normal 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"]);
|
||||
});
|
||||
Reference in New Issue
Block a user