diff --git a/src/app/api/providers/[id]/models/discovery/normalizers.ts b/src/app/api/providers/[id]/models/discovery/normalizers.ts index 50e5d3dcb6..cc28604605 100644 --- a/src/app/api/providers/[id]/models/discovery/normalizers.ts +++ b/src/app/api/providers/[id]/models/discovery/normalizers.ts @@ -29,8 +29,32 @@ type AntigravityDiscoveryModel = { id: string; name: string; isInternal?: boolean; + /** Token window advertised by the upstream discovery payload, when present. */ + inputTokenLimit?: number; + outputTokenLimit?: number; }; +/** + * Forward discovery-advertised token windows when the upstream payload carries + * them. Field names are probed defensively (payload shape is not contractual); + * absent/non-numeric fields yield no entry, so nothing downstream changes. + */ +function extractDiscoveryTokenLimits(item: Record): { + inputTokenLimit?: number; + outputTokenLimit?: number; +} { + const limits: { inputTokenLimit?: number; outputTokenLimit?: number } = {}; + const input = item.inputTokenLimit ?? item.contextWindow; + if (typeof input === "number" && Number.isFinite(input) && input > 0) { + limits.inputTokenLimit = input; + } + const output = item.outputTokenLimit ?? item.maxOutputTokens; + if (typeof output === "number" && Number.isFinite(output) && output > 0) { + limits.outputTokenLimit = output; + } + return limits; +} + export function normalizeAntigravityModelsResponse(data: unknown): AntigravityDiscoveryModel[] { const payload = asRecord(data).models; @@ -52,7 +76,14 @@ export function normalizeAntigravityModelsResponse(data: unknown): AntigravityDi : typeof item.name === "string" ? item.name : id; - return id ? { id, name, ...(item.isInternal === true ? { isInternal: true } : {}) } : null; + return id + ? { + id, + name, + ...extractDiscoveryTokenLimits(item), + ...(item.isInternal === true ? { isInternal: true } : {}), + } + : null; }) .filter((value): value is AntigravityDiscoveryModel => Boolean(value)); } @@ -67,7 +98,14 @@ export function normalizeAntigravityModelsResponse(data: unknown): AntigravityDi : typeof item.name === "string" ? item.name : id; - return id ? { id, name, ...(item.isInternal === true ? { isInternal: true } : {}) } : null; + return id + ? { + id, + name, + ...extractDiscoveryTokenLimits(item), + ...(item.isInternal === true ? { isInternal: true } : {}), + } + : null; }) .filter((value): value is AntigravityDiscoveryModel => Boolean(value)); } @@ -86,11 +124,13 @@ export function filterUserCallableAntigravityModels( } export function mapAntigravityModelForClient( - model: { id: string; name: string }, + model: { id: string; name: string; inputTokenLimit?: number; outputTokenLimit?: number }, provider: "antigravity" | "agy" = "antigravity" ): { id: string; name: string; + inputTokenLimit?: number; + outputTokenLimit?: number; } { const clientId = toClientAntigravityModelId(model.id); return { @@ -99,6 +139,12 @@ export function mapAntigravityModelForClient( provider === "agy" ? getClientVisibleAgyModelName(clientId, model.name) : getClientVisibleAntigravityModelName(clientId, model.name), + ...(typeof model.inputTokenLimit === "number" + ? { inputTokenLimit: model.inputTokenLimit } + : {}), + ...(typeof model.outputTokenLimit === "number" + ? { outputTokenLimit: model.outputTokenLimit } + : {}), }; } @@ -108,7 +154,9 @@ export async function fetchAntigravityDiscoveryModelsCached( proxy: unknown, providerSpecificData?: unknown, provider: "antigravity" | "agy" = "antigravity" -): Promise> { +): Promise< + Array<{ id: string; name: string; inputTokenLimit?: number; outputTokenLimit?: number }> +> { const profile = normalizeAntigravityClientProfile(asRecord(providerSpecificData).clientProfile); const cacheKey = `${provider}:${connectionId}:${accessToken.substring(0, 16)}:${profile}`; const inflight = antigravityDiscoveryInflight.get(cacheKey); diff --git a/src/lib/providerModels/syncedAutoAliases.ts b/src/lib/providerModels/syncedAutoAliases.ts new file mode 100644 index 0000000000..535507ae5f --- /dev/null +++ b/src/lib/providerModels/syncedAutoAliases.ts @@ -0,0 +1,85 @@ +/** + * Auto-aliases derived from synced (discovered) Antigravity-family models. + * + * Bare model names only resolve when something maps them; every new upstream + * model used to need a hand-written alias (Gemini 3.7 Flash shipped with none, + * so bare names mis-routed until a catalog refresh). The Antigravity backend + * advertises tiered ids (…-high/-medium/-low) while users naturally ask for + * the bare base name, so derive: bare base → default tier (high > medium > + * low), mirroring the backend's own High default. + * + * Derived in memory from the synced store — no persistence, always consistent + * with the latest discovery, and merged at the LOWEST precedence so explicit + * human aliases (DB namespace, settings, wildcards) always win. + */ + +import { getAllActiveSyncedModels } from "@/lib/db/models/activeSyncedCatalog"; + +/** Providers whose synced catalogs participate in auto-alias derivation. */ +const AUTO_ALIAS_FAMILY_PROVIDERS = ["agy", "antigravity"] as const; + +/** Preferred default tier when a bare base name is aliased onto a group. */ +const TIER_PREFERENCE = ["-high", "-medium", "-low"] as const; + +/** + * Pure derivation (exported for tests): given per-provider synced model id + * lists, produce bare-base → "provider/defaultTierId" alias entries. + * + * Rules: + * - only tiered groups (ids carrying a -high/-medium/-low suffix) participate + * - a base that is itself a callable synced id gets no alias (nothing to map) + * - within one provider the first eligible group per base wins; across + * providers the AUTO_ALIAS_FAMILY_PROVIDERS order wins (agy first) + */ +export function deriveSyncedTierAliases( + syncedIdsByProvider: Record +): Record { + const aliases: Record = {}; + + for (const provider of AUTO_ALIAS_FAMILY_PROVIDERS) { + const ids = syncedIdsByProvider[provider]; + if (!Array.isArray(ids) || ids.length === 0) continue; + + const idSet = new Set(ids); + const groups = new Map(); + for (const id of ids) { + if (typeof id !== "string" || !id) continue; + const suffix = TIER_PREFERENCE.find((candidate) => id.endsWith(candidate)); + if (!suffix) continue; + const base = id.slice(0, -suffix.length); + if (!base) continue; + const bucket = groups.get(base) ?? []; + bucket.push(id); + groups.set(base, bucket); + } + + for (const [base, variants] of groups) { + if (aliases[base]) continue; // earlier provider already claimed it + if (idSet.has(base)) continue; // bare base itself is callable — no alias needed + const chosen = TIER_PREFERENCE.map((suffix) => base + suffix).find((id) => + variants.includes(id) + ); + if (chosen) aliases[base] = `${provider}/${chosen}`; + } + } + + return aliases; +} + +/** Auto-aliases from the live synced catalog (empty on any read failure). */ +export async function getSyncedAutoAliases(): Promise> { + try { + const syncedByProvider = await getAllActiveSyncedModels(); + const picked: Record = {}; + for (const provider of AUTO_ALIAS_FAMILY_PROVIDERS) { + const rows = syncedByProvider[provider]; + if (!Array.isArray(rows)) continue; + picked[provider] = rows + .map((row) => (row && typeof row.id === "string" ? row.id : "")) + .filter(Boolean); + } + return deriveSyncedTierAliases(picked); + } catch { + return {}; + } +} diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index 792bcaeeb2..b28e3e5d2f 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -7,6 +7,8 @@ import { getCachedProviderNodes, getCustomModels, } from "@/lib/localDb"; + +import { getSyncedAutoAliases } from "@/lib/providerModels/syncedAutoAliases.ts"; import { getCachedSettings } from "@/lib/localDb"; import { getActiveSyncedCatalog } from "@/lib/db/models/activeSyncedCatalog"; import { getModelCompatOverrides } from "@/lib/db/models/compat"; @@ -63,6 +65,8 @@ function buildWildcardAliasMap(settings: Record): Record): Record> { - const [dbAliases, settings] = await Promise.all([ + const [dbAliases, settings, autoAliases] = await Promise.all([ getModelAliases().catch(() => ({})), getCachedSettings().catch(() => ({}) as Record), + getSyncedAutoAliases().catch(() => ({}) as Record), ]); const settingsAliases = @@ -92,7 +97,9 @@ async function getCombinedModelAliases(): Promise> { const wildcardMap = buildWildcardAliasMap(settings); - return { ...dbAliases, ...settingsAliases, ...wildcardMap }; + // Auto-aliases (derived from synced discovery) merge first — lowest + // precedence: any explicit DB/settings/wildcard alias always wins. + return { ...autoAliases, ...dbAliases, ...settingsAliases, ...wildcardMap }; } /** diff --git a/tests/unit/synced-auto-aliases.test.ts b/tests/unit/synced-auto-aliases.test.ts new file mode 100644 index 0000000000..109639b933 --- /dev/null +++ b/tests/unit/synced-auto-aliases.test.ts @@ -0,0 +1,128 @@ +/** + * Auto-aliases derived from synced Antigravity-family discovery: bare base + * names map onto the default tier (high > medium > low) at the lowest alias + * precedence, so freshly shipped tiered models resolve without hand-written + * aliases (Gemini 3.7 Flash shipped with none). + */ + +import test 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-auto-aliases-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { deriveSyncedTierAliases, getSyncedAutoAliases } = + await import("../../src/lib/providerModels/syncedAutoAliases.ts"); +const { normalizeAntigravityModelsResponse, mapAntigravityModelForClient } = + await import("../../src/app/api/providers/[id]/models/discovery/normalizers.ts"); + +test("tiered group aliases the bare base onto the high tier", () => { + const aliases = deriveSyncedTierAliases({ + agy: ["gemini-3.7-flash-high", "gemini-3.7-flash-medium", "gemini-3.7-flash-low"], + }); + assert.deepEqual(aliases, { "gemini-3.7-flash": "agy/gemini-3.7-flash-high" }); +}); + +test("missing high tier falls back to medium, then low", () => { + assert.deepEqual(deriveSyncedTierAliases({ agy: ["x-model-medium", "x-model-low"] }), { + "x-model": "agy/x-model-medium", + }); + assert.deepEqual(deriveSyncedTierAliases({ agy: ["y-model-low"] }), { + "y-model": "agy/y-model-low", + }); +}); + +test("a callable bare base gets no alias", () => { + const aliases = deriveSyncedTierAliases({ + agy: ["gemini-3.7-flash", "gemini-3.7-flash-high", "gemini-3.7-flash-low"], + }); + assert.equal(aliases["gemini-3.7-flash"], undefined); +}); + +test("non-tiered model ids are ignored", () => { + assert.deepEqual(deriveSyncedTierAliases({ agy: ["gemini-pro-agent", "claude-sonnet-4-6"] }), {}); +}); + +test("agy provider wins over antigravity for the same base", () => { + const aliases = deriveSyncedTierAliases({ + antigravity: ["gemini-3.7-flash-high"], + agy: ["gemini-3.7-flash-medium"], + }); + assert.deepEqual(aliases, { "gemini-3.7-flash": "agy/gemini-3.7-flash-medium" }); +}); + +test("empty or unknown providers produce nothing", () => { + assert.deepEqual(deriveSyncedTierAliases({}), {}); + assert.deepEqual(deriveSyncedTierAliases({ openai: ["gpt-x-high"] }), {}); + assert.deepEqual(deriveSyncedTierAliases({ agy: [] }), {}); +}); + +test("discovery normalization carries numeric token limits when present", () => { + const models = normalizeAntigravityModelsResponse({ + models: { + "gemini-3.7-flash-high": { + displayName: "Gemini 3.7 Flash (High)", + inputTokenLimit: 1048576, + outputTokenLimit: 65536, + }, + }, + }); + assert.equal(models.length, 1); + assert.equal(models[0].inputTokenLimit, 1048576); + assert.equal(models[0].outputTokenLimit, 65536); +}); + +test("normalization accepts contextWindow/maxOutputTokens spellings and drops junk", () => { + const models = normalizeAntigravityModelsResponse({ + models: { + "model-a": { displayName: "A", contextWindow: 262144, maxOutputTokens: 32768 }, + "model-b": { displayName: "B", inputTokenLimit: "huge", outputTokenLimit: -1 }, + "model-c": { displayName: "C" }, + }, + }); + const byId = Object.fromEntries(models.map((m) => [m.id, m])); + assert.equal(byId["model-a"].inputTokenLimit, 262144); + assert.equal(byId["model-a"].outputTokenLimit, 32768); + assert.equal(byId["model-b"].inputTokenLimit, undefined); + assert.equal(byId["model-b"].outputTokenLimit, undefined); + assert.equal(byId["model-c"].inputTokenLimit, undefined); +}); + +test("client mapping preserves token limits", () => { + const mapped = mapAntigravityModelForClient( + { id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)", inputTokenLimit: 1048576 }, + "agy" + ); + assert.equal(mapped.inputTokenLimit, 1048576); + assert.equal(typeof mapped.id, "string"); +}); + +test("getSyncedAutoAliases reads the live synced catalog", async () => { + const { replaceSyncedAvailableModelsForConnection } = await import("../../src/lib/db/models"); + const { createProviderConnection } = await import("../../src/lib/db/providers.ts"); + const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + resetDbInstance(); + + // Only ACTIVE connections' synced rows count — seed a real agy connection. + const connection = await createProviderConnection({ + provider: "agy", + authType: "oauth", + name: "auto-alias-fixture", + accessToken: "fixture-access-token", + refreshToken: "fixture-refresh-token", + isActive: true, + testStatus: "active", + }); + const connectionId = String((connection as Record).id); + + await replaceSyncedAvailableModelsForConnection("agy", connectionId, [ + { id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)" }, + { id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)" }, + ]); + + const aliases = await getSyncedAutoAliases(); + assert.equal(aliases["gemini-3.7-flash"], "agy/gemini-3.7-flash-high"); +});