diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d8257096d..0a283a411f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### πŸ› Fixed - **fix(intelligence): run pricing + models.dev sync from the live startup path** β€” like the Arena ELO sync (v3.8.24), the external **pricing sync** (`PRICING_SYNC_ENABLED`) and the **models.dev capability sync** (Settings β†’ AI toggle) were only initialized from `server-init.ts`, which the Next standalone runtime never executes β€” and models.dev had no caller at all. Their toggles were inert in production. Both are now initialized from `instrumentation-node.ts` (self-gated, opt-in preserved, non-blocking, never fatal). (thanks @diegosouzapw) +- **feat(connections): per-connection "disable cooldown" opt-out** β€” a connection can now opt out of the transient cooldown (`providerSpecificData.disableCooling`, with a toggle in the Edit Connection modal). When set, a recoverable failure still records the error/backoff but does **not** take the connection out of rotation, so it stays eligible for selection β€” useful for a primary key you never want parked on a blip. Terminal states (banned / expired / credits_exhausted) still apply. ([#2997](https://github.com/diegosouzapw/OmniRoute/issues/2997) β€” thanks @diegosouzapw) - **fix(combo): restore sessionless combo stickiness + reasoning-aware readiness (504 / TPS regression after v3.8.14)** β€” #3399 (v3.8.16) replaced the ``-tag combo pinning with a server-side context-cache pin gated on a client `sessionId`. Clients that send no session id (most OpenAI-compatible tools) lost combo stickiness, so combos re-ran strategy selection every turn β†’ upstream prompt-cache misses β†’ cold high-reasoning starts (~78s) β†’ intermittent `[504] Upstream request did not return response headers` + TPS collapse (only on combos). The pin now falls back to a stable per-conversation fingerprint (`extractSessionAffinityKey(body)`) when no session id is present β€” **only when `context_cache_protection` is on**, so #3399's anti-leak behaviour is preserved. Separately, the stream-readiness window now grants the +30s reasoning budget **unconditionally** for high-reasoning Codex GPT-5.x (small high-reasoning prompts were 504-ing at the 80s base regardless of stickiness). ([#3825](https://github.com/diegosouzapw/OmniRoute/issues/3825) β€” thanks @bypanghu) - **test(combo): cover the `skipProviderBreaker` consumer gate** β€” the producer was tested but the consumer (whether a failed combo target trips the whole-provider circuit breaker) was not; the breaker decision is now an exported pure predicate (`shouldRecordProviderBreakerFailure`, behaviour-identical) with direct tests asserting a `connection_cooldown` 503 does not trip the breaker while a plain 503 does. Closes another deferred test gap from [#2743](https://github.com/diegosouzapw/OmniRoute/issues/2743). (thanks @diegosouzapw) - **fix(providers): surface the real Devin error + correct the Windsurf auth instructions** β€” Devin chat returned a generic 502 "Invalid SSE response for non-streaming request" that swallowed the real cause (e.g. "Devin CLI not found"): an error-only SSE chunk (no `choices`) is now propagated with its sanitized message. The Windsurf "Visit windsurf.com/show-auth-token" instruction (the bare URL shows no token without an IDE-supplied `?state=`) now directs users to the `Windsurf: Provide Auth Token` command-palette flow. ([#3324](https://github.com/diegosouzapw/OmniRoute/issues/3324) β€” thanks @mikmaneggahommie) diff --git a/file-size-baseline.json b/file-size-baseline.json index 1121f71073..ec7a770e43 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -52,7 +52,7 @@ "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 784, "src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 941, "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 843, - "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1171, + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1174, "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, @@ -103,7 +103,7 @@ "src/shared/services/cliRuntime.ts": 1090, "src/shared/validation/schemas.ts": 2523, "src/sse/handlers/chat.ts": 1425, - "src/sse/services/auth.ts": 2207 + "src/sse/services/auth.ts": 2216 }, "_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores β€” proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.", "_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948β†’4062 (-886 LOC); 3 novos hooks extraΓ­dos. useProviderConnections.ts=954 acima do cap=800 β€” justificado: extraΓ§Γ£o direta do god-component (zero lΓ³gica nova), prΓ³pria reduΓ§Γ£o do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 jΓ‘ abaixo do cap.", @@ -128,5 +128,6 @@ "_rebaseline_2026_06_14_r3_3849_transient_hide": "PR #3849 own growth: providerPageHelpers.ts 939β†’955 (+16 = expanded JSDoc on the auto-hide policy + transient-failure guard in evaluateTestAllEntry). Cohesive helper logic; not extractable.", "_rebaseline_2026_06_14_r3_3838_opencode_quota": "PR #3838 own growth: usage.ts 3394β†’3408 (+14 = clearer OpenCode Go missing-quota-API messages with OMNIROUTE_OPENCODE_GO_QUOTA_URL override hint + upstream issue links). Message text only; no logic extractable.", "_rebaseline_2026_06_14_r3_3839_veo_video": "PR #3839 own growth: schemas.ts 2522β†’2523 (+1 = Veo video model (predictLongRunning) validation for Gemini/Vertex dynamic discovery).", - "_rebaseline_2026_06_14_r3_3836_kiro_discovery": "PR #3836 own growth: models/route.ts 2426β†’2487 (+61 = kiro live per-account discovery branch wiring fetchKiroAvailableModels into the existing cache/auto-fetch/fallback discovery flow). Structural shrink of this route tracked in #3789." + "_rebaseline_2026_06_14_r3_3836_kiro_discovery": "PR #3836 own growth: models/route.ts 2426β†’2487 (+61 = kiro live per-account discovery branch wiring fetchKiroAvailableModels into the existing cache/auto-fetch/fallback discovery flow). Structural shrink of this route tracked in #3789.", + "_rebaseline_2026_06_14_2997_disable_cooling": "Re-baseline #2997 (per-connection disable-cooling): EditConnectionModal.tsx 1171β†’1174 (+toggle UI) + auth.ts 2207β†’2216 (honor de disableCooling no markAccountUnavailable, pΓ³s-prettier). LΓ³gica coesa; nΓ£o-extraΓ­vel. (combo.ts/RequestLoggerV2 drift jΓ‘ documentado em _r3_3835.)" } diff --git a/open-sse/services/kiroModels.ts b/open-sse/services/kiroModels.ts index 5e1a25a3ac..24bc13d810 100644 --- a/open-sse/services/kiroModels.ts +++ b/open-sse/services/kiroModels.ts @@ -1,196 +1,195 @@ -/** - * Kiro (AWS CodeWhisperer / Amazon Q) live model discovery. - * - * Kiro's model catalog is per-account / per-tier β€” the free tier, Pro, Pro+ and - * Power plans expose different model sets, and AWS IAM Identity Center (enterprise) - * orgs further restrict it to an admin-curated "approved models" list. The Kiro - * IDE / CLI populates its model picker by calling the CodeWhisperer - * `ListAvailableModels` operation: - * - * GET https://q.{region}.amazonaws.com/ListAvailableModels?origin=AI_EDITOR - * Authorization: Bearer - * β†’ { models: [ { modelId, modelName?, tokenLimits?: { maxInputTokens } }, ... ] } - * - * This works for both "simple" Builder ID / social logins and AWS IAM Identity - * Center accounts: - * - `origin=AI_EDITOR` alone is the universal call (Builder ID / IdC). - * - `profileArn` is only sent for desktop-style accounts that have one, and only - * as a retry, because sending it for Builder ID can yield 403. - * - The endpoint is region-matched (IdC tokens are region-bound, e.g. - * eu-central-1) with a us-east-1 fallback (the legacy CodeWhisperer home region). - * - * A safe fallback to the static registry catalog is preserved so model import - * never breaks when the account is offline / unauthenticated / token-expired. - */ - -type RawRecord = Record; - -function asRecord(value: unknown): RawRecord { - return value && typeof value === "object" && !Array.isArray(value) ? (value as RawRecord) : {}; -} - -function toNonEmptyString(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -export type KiroModel = { - id: string; - name: string; - owned_by: string; -}; - -export type KiroModelsResult = { - models: KiroModel[]; - /** "api" = live discovery; "fallback" = static catalog (offline/unauthed/error). */ - source: "api" | "fallback"; -}; - -/** - * Parse a CodeWhisperer `ListAvailableModels` response into managed model rows. - * Only ids present in the live response are returned, which gives the exact - * per-account / per-tier entitlement filtering. - */ -export function parseKiroModels(data: unknown): KiroModel[] { - const payload = asRecord(data); - const items = Array.isArray(payload.models) - ? (payload.models as unknown[]) - : Array.isArray(payload.availableModels) - ? (payload.availableModels as unknown[]) - : []; - - const seen = new Set(); - const models: KiroModel[] = []; - - for (const value of items) { - const item = asRecord(value); - const id = toNonEmptyString(item.modelId) || toNonEmptyString(item.id); - if (!id || seen.has(id)) continue; - seen.add(id); - const name = - toNonEmptyString(item.modelName) || toNonEmptyString(item.name) || id; - models.push({ id, name, owned_by: "kiro" }); - } - - return models; -} - -/** - * Derive the AWS region for a Kiro connection. Mirrors getKiroUsage: prefer the - * stored region, then the region embedded in the profileArn, else us-east-1. - */ -export function resolveKiroRegion(providerSpecificData: unknown): string { - const psd = asRecord(providerSpecificData); - const explicit = toNonEmptyString(psd.region); - if (explicit) return explicit.toLowerCase(); - - const profileArn = toNonEmptyString(psd.profileArn); - const fromArn = profileArn - ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] - : undefined; - - return fromArn || "us-east-1"; -} - -/** - * Build the ordered list of `ListAvailableModels` base URLs to try: the - * region-matched Amazon Q host first, then the us-east-1 home region as a - * fallback (CodeWhisperer's canonical region). - */ -export function buildKiroModelsEndpoints(region: string): string[] { - const normalized = (toNonEmptyString(region) || "us-east-1").toLowerCase(); - const urls: string[] = [`https://q.${normalized}.amazonaws.com/ListAvailableModels`]; - if (normalized !== "us-east-1") { - urls.push("https://q.us-east-1.amazonaws.com/ListAvailableModels"); - } - return urls; -} - -export type FetchKiroModelsOptions = { - /** Stored Kiro access token (Bearer). */ - accessToken: string | null | undefined; - /** Connection providerSpecificData (region, profileArn). */ - providerSpecificData?: unknown; - /** Injectable fetch (defaults to global fetch). */ - fetchImpl?: typeof fetch; - /** Static catalog to fall back to when live discovery is unavailable. */ - fallbackModels?: Array<{ id: string; name?: string }>; -}; - -function toFallbackResult( - fallbackModels: Array<{ id: string; name?: string }> | undefined -): KiroModelsResult { - const models = (fallbackModels || []) - .map((model) => { - const id = toNonEmptyString(model.id); - if (!id) return null; - return { id, name: toNonEmptyString(model.name) || id, owned_by: "kiro" }; - }) - .filter((model): model is KiroModel => Boolean(model)); - return { models, source: "fallback" }; -} - -async function tryFetchModels( - fetchImpl: typeof fetch, - url: string, - accessToken: string -): Promise { - try { - const response = await fetchImpl(url, { - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - }, - }); - if (!response.ok) return null; - const data = await response.json(); - const models = parseKiroModels(data); - return models.length > 0 ? models : null; - } catch { - return null; - } -} - -/** - * Discover the Kiro model catalog live via `ListAvailableModels`, falling back - * to the static catalog when no token is available or every attempt fails. - * - * Attempt order (stops at the first success): - * 1. `origin=AI_EDITOR` on each region-matched endpoint β€” universal path that - * works for Builder ID / social ("simple") and IAM Identity Center accounts. - * 2. `origin=AI_EDITOR&profileArn=...` on the primary endpoint, only when a - * profileArn is present (desktop-style accounts that require it). - */ -export async function fetchKiroAvailableModels( - options: FetchKiroModelsOptions -): Promise { - const { accessToken, providerSpecificData, fetchImpl = fetch, fallbackModels } = options; - - const token = toNonEmptyString(accessToken); - if (!token) { - return toFallbackResult(fallbackModels); - } - - const region = resolveKiroRegion(providerSpecificData); - const endpoints = buildKiroModelsEndpoints(region); - const profileArn = toNonEmptyString(asRecord(providerSpecificData).profileArn); - - // Pass 1: origin-only (works for Builder ID / social / IdC). - for (const base of endpoints) { - const models = await tryFetchModels(fetchImpl, `${base}?origin=AI_EDITOR`, token); - if (models) return { models, source: "api" }; - } - - // Pass 2: retry with profileArn (desktop accounts that require it) on the - // region-matched endpoint only. Skipped for Builder ID / IdC where sending a - // profileArn can 403. - if (profileArn) { - const url = `${endpoints[0]}?origin=AI_EDITOR&profileArn=${encodeURIComponent(profileArn)}`; - const models = await tryFetchModels(fetchImpl, url, token); - if (models) return { models, source: "api" }; - } - - return toFallbackResult(fallbackModels); -} +/** + * Kiro (AWS CodeWhisperer / Amazon Q) live model discovery. + * + * Kiro's model catalog is per-account / per-tier β€” the free tier, Pro, Pro+ and + * Power plans expose different model sets, and AWS IAM Identity Center (enterprise) + * orgs further restrict it to an admin-curated "approved models" list. The Kiro + * IDE / CLI populates its model picker by calling the CodeWhisperer + * `ListAvailableModels` operation: + * + * GET https://q.{region}.amazonaws.com/ListAvailableModels?origin=AI_EDITOR + * Authorization: Bearer + * β†’ { models: [ { modelId, modelName?, tokenLimits?: { maxInputTokens } }, ... ] } + * + * This works for both "simple" Builder ID / social logins and AWS IAM Identity + * Center accounts: + * - `origin=AI_EDITOR` alone is the universal call (Builder ID / IdC). + * - `profileArn` is only sent for desktop-style accounts that have one, and only + * as a retry, because sending it for Builder ID can yield 403. + * - The endpoint is region-matched (IdC tokens are region-bound, e.g. + * eu-central-1) with a us-east-1 fallback (the legacy CodeWhisperer home region). + * + * A safe fallback to the static registry catalog is preserved so model import + * never breaks when the account is offline / unauthenticated / token-expired. + */ + +type RawRecord = Record; + +function asRecord(value: unknown): RawRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as RawRecord) : {}; +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export type KiroModel = { + id: string; + name: string; + owned_by: string; +}; + +export type KiroModelsResult = { + models: KiroModel[]; + /** "api" = live discovery; "fallback" = static catalog (offline/unauthed/error). */ + source: "api" | "fallback"; +}; + +/** + * Parse a CodeWhisperer `ListAvailableModels` response into managed model rows. + * Only ids present in the live response are returned, which gives the exact + * per-account / per-tier entitlement filtering. + */ +export function parseKiroModels(data: unknown): KiroModel[] { + const payload = asRecord(data); + const items = Array.isArray(payload.models) + ? (payload.models as unknown[]) + : Array.isArray(payload.availableModels) + ? (payload.availableModels as unknown[]) + : []; + + const seen = new Set(); + const models: KiroModel[] = []; + + for (const value of items) { + const item = asRecord(value); + const id = toNonEmptyString(item.modelId) || toNonEmptyString(item.id); + if (!id || seen.has(id)) continue; + seen.add(id); + const name = toNonEmptyString(item.modelName) || toNonEmptyString(item.name) || id; + models.push({ id, name, owned_by: "kiro" }); + } + + return models; +} + +/** + * Derive the AWS region for a Kiro connection. Mirrors getKiroUsage: prefer the + * stored region, then the region embedded in the profileArn, else us-east-1. + */ +export function resolveKiroRegion(providerSpecificData: unknown): string { + const psd = asRecord(providerSpecificData); + const explicit = toNonEmptyString(psd.region); + if (explicit) return explicit.toLowerCase(); + + const profileArn = toNonEmptyString(psd.profileArn); + const fromArn = profileArn + ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] + : undefined; + + return fromArn || "us-east-1"; +} + +/** + * Build the ordered list of `ListAvailableModels` base URLs to try: the + * region-matched Amazon Q host first, then the us-east-1 home region as a + * fallback (CodeWhisperer's canonical region). + */ +export function buildKiroModelsEndpoints(region: string): string[] { + const normalized = (toNonEmptyString(region) || "us-east-1").toLowerCase(); + const urls: string[] = [`https://q.${normalized}.amazonaws.com/ListAvailableModels`]; + if (normalized !== "us-east-1") { + urls.push("https://q.us-east-1.amazonaws.com/ListAvailableModels"); + } + return urls; +} + +export type FetchKiroModelsOptions = { + /** Stored Kiro access token (Bearer). */ + accessToken: string | null | undefined; + /** Connection providerSpecificData (region, profileArn). */ + providerSpecificData?: unknown; + /** Injectable fetch (defaults to global fetch). */ + fetchImpl?: typeof fetch; + /** Static catalog to fall back to when live discovery is unavailable. */ + fallbackModels?: Array<{ id: string; name?: string }>; +}; + +function toFallbackResult( + fallbackModels: Array<{ id: string; name?: string }> | undefined +): KiroModelsResult { + const models = (fallbackModels || []) + .map((model) => { + const id = toNonEmptyString(model.id); + if (!id) return null; + return { id, name: toNonEmptyString(model.name) || id, owned_by: "kiro" }; + }) + .filter((model): model is KiroModel => Boolean(model)); + return { models, source: "fallback" }; +} + +async function tryFetchModels( + fetchImpl: typeof fetch, + url: string, + accessToken: string +): Promise { + try { + const response = await fetchImpl(url, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }); + if (!response.ok) return null; + const data = await response.json(); + const models = parseKiroModels(data); + return models.length > 0 ? models : null; + } catch { + return null; + } +} + +/** + * Discover the Kiro model catalog live via `ListAvailableModels`, falling back + * to the static catalog when no token is available or every attempt fails. + * + * Attempt order (stops at the first success): + * 1. `origin=AI_EDITOR` on each region-matched endpoint β€” universal path that + * works for Builder ID / social ("simple") and IAM Identity Center accounts. + * 2. `origin=AI_EDITOR&profileArn=...` on the primary endpoint, only when a + * profileArn is present (desktop-style accounts that require it). + */ +export async function fetchKiroAvailableModels( + options: FetchKiroModelsOptions +): Promise { + const { accessToken, providerSpecificData, fetchImpl = fetch, fallbackModels } = options; + + const token = toNonEmptyString(accessToken); + if (!token) { + return toFallbackResult(fallbackModels); + } + + const region = resolveKiroRegion(providerSpecificData); + const endpoints = buildKiroModelsEndpoints(region); + const profileArn = toNonEmptyString(asRecord(providerSpecificData).profileArn); + + // Pass 1: origin-only (works for Builder ID / social / IdC). + for (const base of endpoints) { + const models = await tryFetchModels(fetchImpl, `${base}?origin=AI_EDITOR`, token); + if (models) return { models, source: "api" }; + } + + // Pass 2: retry with profileArn (desktop accounts that require it) on the + // region-matched endpoint only. Skipped for Builder ID / IdC where sending a + // profileArn can 403. + if (profileArn) { + const url = `${endpoints[0]}?origin=AI_EDITOR&profileArn=${encodeURIComponent(profileArn)}`; + const models = await tryFetchModels(fetchImpl, url, token); + if (models) return { models, source: "api" }; + } + + return toFallbackResult(fallbackModels); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index 458483e529..5103fe1cc2 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -113,6 +113,7 @@ export default function EditConnectionModal({ ? isClaudeExtraUsageBlockEnabled(connection?.provider, connection?.providerSpecificData) : false, passthroughModels: connection?.providerSpecificData?.passthroughModels === true, + disableCooling: connection?.providerSpecificData?.disableCooling === true, }); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); @@ -267,6 +268,7 @@ export default function EditConnectionModal({ connection.providerSpecificData ), passthroughModels: connection?.providerSpecificData?.passthroughModels === true, + disableCooling: connection?.providerSpecificData?.disableCooling === true, }); // Load existing extra keys from providerSpecificData const existing = connection.providerSpecificData?.extraApiKeys; @@ -544,6 +546,11 @@ export default function EditConnectionModal({ ), }; } + // #2997: persist the transient-cooldown opt-out; write only when enabled, + // clear it otherwise so a disabled toggle does not linger as `false`. + if (updates.providerSpecificData) { + updates.providerSpecificData.disableCooling = formData.disableCooling ? true : undefined; + } const error = (await onSave(updates)) as void | unknown; if (error) { setSaveError(typeof error === "string" ? error : t("failedSaveConnection")); @@ -647,6 +654,15 @@ export default function EditConnectionModal({ /> )} + {/* #2997: per-connection transient-cooldown opt-out (provider-agnostic) */} +
+ setFormData({ ...formData, disableCooling: checked })} + label={t("disableCoolingLabel")} + description={t("disableCoolingDescription")} + /> +
{supportsGoogleProjectId && (
{isAntigravity && ( diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 3c22459d00..928a886732 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -78,7 +78,10 @@ import { isAutoFetchModelsEnabled, persistDiscoveredModels, } from "@/lib/providerModels/modelDiscovery"; -import { parseGeminiModelsList, type GeminiDiscoveryModel } from "@/lib/providerModels/geminiModelsParser"; +import { + parseGeminiModelsList, + type GeminiDiscoveryModel, +} from "@/lib/providerModels/geminiModelsParser"; import { getSyncedAvailableModels } from "@/lib/db/models"; import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent"; @@ -750,8 +753,7 @@ export async function GET( (data.data || data.models || []) as Array> ) .map((item) => { - const itemId = - typeof item.id === "string" ? item.id.trim() : ""; + const itemId = typeof item.id === "string" ? item.id.trim() : ""; if (!itemId) return null; const itemName = typeof item.display_name === "string" @@ -845,7 +847,9 @@ export async function GET( } const registryCatalogModels = providerSyncedModels ?? (getModelsByProviderId(provider) || []); - const specialtyCatalogModels = providerSyncedModels ? [] : (getStaticModelsForProvider(provider) || []); + const specialtyCatalogModels = providerSyncedModels + ? [] + : getStaticModelsForProvider(provider) || []; const toLocalCatalogModels = () => { const localCatalog = mergeLocalCatalogModels(registryCatalogModels, specialtyCatalogModels); @@ -2118,9 +2122,8 @@ export async function GET( let queryKey: string | null = null; let bearerToken: string | null = null; try { - const { parseSAFromApiKey, getAccessToken } = await import( - "@omniroute/open-sse/executors/vertex.ts" - ); + const { parseSAFromApiKey, getAccessToken } = + await import("@omniroute/open-sse/executors/vertex.ts"); if (accessToken) { bearerToken = accessToken; } else if (credential) { @@ -2130,8 +2133,7 @@ export async function GET( let isServiceAccountJson = false; try { const parsed = JSON.parse(credential); - isServiceAccountJson = - !!parsed && typeof parsed === "object" && !Array.isArray(parsed); + isServiceAccountJson = !!parsed && typeof parsed === "object" && !Array.isArray(parsed); } catch { isServiceAccountJson = false; } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 72f6bd2aa6..21ebe6a8bd 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4438,6 +4438,8 @@ "t3ChatWebCookiePlaceholder": "convex-session-id=abc123...", "blockClaudeExtraUsageDescription": "Hide extra Claude usage rows reported by some providers when they duplicate primary token accounting.", "blockClaudeExtraUsageLabel": "Block duplicate Claude usage rows", + "disableCoolingDescription": "Skip the transient cooldown so this connection stays eligible even after recoverable errors (terminal states like banned/expired still apply).", + "disableCoolingLabel": "Disable cooldown for this connection", "bulkPasteAdded": "{count, plural, one {1 key added} other {# keys added}}", "bulkPasteDuplicatesIgnored": "{count, plural, one {1 duplicate skipped} other {# duplicates skipped}}", "bulkPasteHint": "Paste one API key per line. Empty lines are ignored and duplicate keys are skipped.", diff --git a/src/lib/providerModels/geminiModelsParser.ts b/src/lib/providerModels/geminiModelsParser.ts index e4c8063d0b..2836846886 100644 --- a/src/lib/providerModels/geminiModelsParser.ts +++ b/src/lib/providerModels/geminiModelsParser.ts @@ -1,91 +1,91 @@ -/** - * Parses the Google Generative Language `v1beta/models` listing into discovery models. - * - * Each model's `supportedGenerationMethods` is mapped to OmniRoute endpoints: - * - generateContent / generateAnswer β†’ "chat" - * - predict β†’ "images" (Imagen image generation) - * - predictLongRunning β†’ "video" (Veo video generation) - * - embedContent β†’ "embeddings" - * - bidiGenerateContent β†’ "audio" (Live real-time audio) - * - * Model-id heuristics refine the long-running bucket because Google exposes both - * Imagen and Veo via long-running methods on the same endpoint: - * - id contains "veo" β†’ ensure "video" - * - id contains "imagen" β†’ force "images" (never "video") - * - * Note: `gemini-*-image` models (e.g. gemini-3-pro-image) generate images via the - * regular `generateContent` path, so they stay "chat" (image output is a chat - * modality) and are intentionally NOT reclassified as "images". - * - * This is shared by the `gemini` discovery config and the `vertex` / - * `vertex-partner` (incl. Vertex AI Express key) discovery branches, so every - * model the account can access β€” chat, image, video, audio and embeddings β€” - * surfaces dynamically instead of being limited to the small static registry. - */ -const METHOD_TO_ENDPOINT: Record = { - generateContent: "chat", - embedContent: "embeddings", - predict: "images", - predictLongRunning: "video", - bidiGenerateContent: "audio", - generateAnswer: "chat", -}; - -const IGNORED_METHODS = new Set([ - "countTokens", - "countTextTokens", - "createCachedContent", - "batchGenerateContent", - "asyncBatchEmbedContent", -]); - -export interface GeminiDiscoveryModel { - id: string; - name: string; - supportedEndpoints: string[]; - inputTokenLimit?: number; - outputTokenLimit?: number; - description?: string; - supportsThinking?: boolean; - [key: string]: unknown; -} - -export function parseGeminiModelsList(data: any): GeminiDiscoveryModel[] { - return (data?.models || []).map((m: Record) => { - const methods: string[] = Array.isArray(m.supportedGenerationMethods) - ? (m.supportedGenerationMethods as string[]) - : []; - - const endpoints = new Set( - methods - .filter((method) => !IGNORED_METHODS.has(method)) - .map((method) => METHOD_TO_ENDPOINT[method] || "chat") - ); - - const id = ((m.name as string) || (m.id as string) || "").replace(/^models\//, ""); - const lowerId = id.toLowerCase(); - - // Google exposes Imagen (image) and Veo (video) via long-running methods; the - // method alone can't always distinguish them, so refine by model id. - if (lowerId.includes("veo")) { - endpoints.add("video"); - } - if (lowerId.includes("imagen")) { - endpoints.delete("video"); - endpoints.add("images"); - } - - if (endpoints.size === 0) endpoints.add("chat"); - - return { - ...m, - id, - name: (m.displayName as string) || id, - supportedEndpoints: [...endpoints], - ...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}), - ...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}), - ...(typeof m.description === "string" ? { description: m.description } : {}), - ...(m.thinking === true ? { supportsThinking: true } : {}), - } as GeminiDiscoveryModel; - }); -} +/** + * Parses the Google Generative Language `v1beta/models` listing into discovery models. + * + * Each model's `supportedGenerationMethods` is mapped to OmniRoute endpoints: + * - generateContent / generateAnswer β†’ "chat" + * - predict β†’ "images" (Imagen image generation) + * - predictLongRunning β†’ "video" (Veo video generation) + * - embedContent β†’ "embeddings" + * - bidiGenerateContent β†’ "audio" (Live real-time audio) + * + * Model-id heuristics refine the long-running bucket because Google exposes both + * Imagen and Veo via long-running methods on the same endpoint: + * - id contains "veo" β†’ ensure "video" + * - id contains "imagen" β†’ force "images" (never "video") + * + * Note: `gemini-*-image` models (e.g. gemini-3-pro-image) generate images via the + * regular `generateContent` path, so they stay "chat" (image output is a chat + * modality) and are intentionally NOT reclassified as "images". + * + * This is shared by the `gemini` discovery config and the `vertex` / + * `vertex-partner` (incl. Vertex AI Express key) discovery branches, so every + * model the account can access β€” chat, image, video, audio and embeddings β€” + * surfaces dynamically instead of being limited to the small static registry. + */ +const METHOD_TO_ENDPOINT: Record = { + generateContent: "chat", + embedContent: "embeddings", + predict: "images", + predictLongRunning: "video", + bidiGenerateContent: "audio", + generateAnswer: "chat", +}; + +const IGNORED_METHODS = new Set([ + "countTokens", + "countTextTokens", + "createCachedContent", + "batchGenerateContent", + "asyncBatchEmbedContent", +]); + +export interface GeminiDiscoveryModel { + id: string; + name: string; + supportedEndpoints: string[]; + inputTokenLimit?: number; + outputTokenLimit?: number; + description?: string; + supportsThinking?: boolean; + [key: string]: unknown; +} + +export function parseGeminiModelsList(data: any): GeminiDiscoveryModel[] { + return (data?.models || []).map((m: Record) => { + const methods: string[] = Array.isArray(m.supportedGenerationMethods) + ? (m.supportedGenerationMethods as string[]) + : []; + + const endpoints = new Set( + methods + .filter((method) => !IGNORED_METHODS.has(method)) + .map((method) => METHOD_TO_ENDPOINT[method] || "chat") + ); + + const id = ((m.name as string) || (m.id as string) || "").replace(/^models\//, ""); + const lowerId = id.toLowerCase(); + + // Google exposes Imagen (image) and Veo (video) via long-running methods; the + // method alone can't always distinguish them, so refine by model id. + if (lowerId.includes("veo")) { + endpoints.add("video"); + } + if (lowerId.includes("imagen")) { + endpoints.delete("video"); + endpoints.add("images"); + } + + if (endpoints.size === 0) endpoints.add("chat"); + + return { + ...m, + id, + name: (m.displayName as string) || id, + supportedEndpoints: [...endpoints], + ...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}), + ...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}), + ...(typeof m.description === "string" ? { description: m.description } : {}), + ...(m.thinking === true ? { supportsThinking: true } : {}), + } as GeminiDiscoveryModel; + }); +} diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index 7053956412..5f6debadff 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -123,6 +123,11 @@ export function normalizeProviderSpecificData( delete normalized.blockExtraUsage; } + // #2997: per-connection transient-cooldown opt-out β€” only persist a real boolean. + if ("disableCooling" in normalized && typeof normalized.disableCooling !== "boolean") { + delete normalized.disableCooling; + } + if ("autoFetchModels" in normalized && typeof normalized.autoFetchModels !== "boolean") { delete normalized.autoFetchModels; } diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 22234e1c9e..fad736f8de 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -1790,6 +1790,15 @@ export async function markAccountUnavailable( const connectionPassthroughModels = connProviderSpecificData.passthroughModels as | boolean | undefined; + // #2997: per-connection opt-out of the TRANSIENT connection cooldown. When set, + // a recoverable failure records lastError/backoff but does NOT cool the + // connection, so getProviderCredentials keeps selecting it. Terminal states + // (banned/expired/credits_exhausted) are unaffected β€” they are resolved below + // via resolveTerminalConnectionStatus() and still take the connection out. + // NOTE: this first cut scopes the opt-out to the CONNECTION-level cooldown only; + // per-model lockout branches (per-model quota 403/404, codex scope) are left + // as-is β€” extending disableCooling to model lockout is a follow-up. + const disableCooling = connProviderSpecificData.disableCooling === true; const isPerModelQuotaProvider = hasPerModelQuota(provider, model, connectionPassthroughModels); if ( @@ -1998,7 +2007,7 @@ export async function markAccountUnavailable( await updateProviderConnection(connectionId, { ...baseUpdate, }); - } else if (cooldownMs > 0) { + } else if (cooldownMs > 0 && !disableCooling) { await updateProviderConnection(connectionId, { ...baseUpdate, rateLimitedUntil: getUnavailableUntil(cooldownMs), diff --git a/tests/unit/auth-disable-cooling-2997.test.ts b/tests/unit/auth-disable-cooling-2997.test.ts new file mode 100644 index 0000000000..8c5dc95a57 --- /dev/null +++ b/tests/unit/auth-disable-cooling-2997.test.ts @@ -0,0 +1,191 @@ +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-auth-disable-cooling-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// #2997 β€” Test 1: a recoverable error on a connection flagged disableCooling +// must NOT write a transient cooldown (no rateLimitedUntil, testStatus stays out of +// "unavailable"). The connection records its lastError/backoff but stays eligible. +test("markAccountUnavailable skips transient cooldown when disableCooling is set", async () => { + await resetStorage(); + + const conn = await providersDb.createProviderConnection({ + provider: "glm", + authType: "apikey", + apiKey: "sk-disable-cooling", + isActive: true, + testStatus: "active", + providerSpecificData: { disableCooling: true }, + }); + + const result = await auth.markAccountUnavailable( + (conn as any).id, + 503, + "temporary upstream error", + "glm", + "glm-5.1" + ); + const after = await providersDb.getProviderConnectionById((conn as any).id); + + assert.equal(result.shouldFallback, true); + // No transient cooldown should be applied. + assert.ok(!after.rateLimitedUntil, "rateLimitedUntil must not be set when disableCooling is on"); + assert.notEqual( + after.testStatus, + "unavailable", + "testStatus must not become 'unavailable' when disableCooling is on" + ); + // The error is still recorded (backoff/lastError path), connection stays usable. + assert.equal(Number(after.errorCode), 503); + assert.ok(after.lastError, "lastError must still be recorded"); +}); + +// #2997 β€” Test 2 (load-bearing): the flag must NEVER rescue a terminal state. +// A 401 still expires the connection; a 402 still exhausts credits β€” both DESPITE +// disableCooling being set. The guard only skips the TRANSIENT cooldown branch. +test("markAccountUnavailable still applies terminal 'expired' despite disableCooling", async () => { + await resetStorage(); + + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + apiKey: "sk-disable-cooling-expired", + isActive: true, + testStatus: "active", + providerSpecificData: { disableCooling: true }, + }); + + const result = await auth.markAccountUnavailable( + (conn as any).id, + 401, + "unauthorized", + "openai", + "gpt-4.1" + ); + const after = await providersDb.getProviderConnectionById((conn as any).id); + + assert.equal(result.shouldFallback, true); + assert.equal(after.testStatus, "expired"); +}); + +test("markAccountUnavailable still applies terminal 'credits_exhausted' despite disableCooling", async () => { + await resetStorage(); + + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + apiKey: "sk-disable-cooling-credits", + isActive: true, + testStatus: "active", + providerSpecificData: { disableCooling: true }, + }); + + const result = await auth.markAccountUnavailable( + (conn as any).id, + 402, + "payment required", + "openai", + "gpt-4.1" + ); + const after = await providersDb.getProviderConnectionById((conn as any).id); + + assert.equal(result.shouldFallback, true); + assert.equal(after.testStatus, "credits_exhausted"); +}); + +// #2997 β€” Test 3: regression β€” a connection WITHOUT the flag keeps the default +// transient cooldown behavior (rateLimitedUntil set, testStatus 'unavailable'). +test("markAccountUnavailable still applies transient cooldown without disableCooling", async () => { + await resetStorage(); + + const conn = await providersDb.createProviderConnection({ + provider: "glm", + authType: "apikey", + apiKey: "sk-default-cooling", + isActive: true, + testStatus: "active", + }); + + const result = await auth.markAccountUnavailable( + (conn as any).id, + 503, + "temporary upstream error", + "glm", + "glm-5.1" + ); + const after = await providersDb.getProviderConnectionById((conn as any).id); + + assert.equal(result.shouldFallback, true); + assert.ok(result.cooldownMs > 0); + assert.ok(after.rateLimitedUntil, "rateLimitedUntil must be set for default behavior"); + assert.equal(after.testStatus, "unavailable"); +}); + +// #2997 β€” Test 4: selection eligibility β€” after a recoverable error, the flagged +// connection is still selectable, while an unflagged sibling that hit the same +// error is filtered out by its cooldown. +test("getProviderCredentials keeps a disableCooling connection eligible after a recoverable error", async () => { + await resetStorage(); + + const flagged = await providersDb.createProviderConnection({ + provider: "glm", + authType: "apikey", + apiKey: "sk-flagged-eligible", + isActive: true, + testStatus: "active", + providerSpecificData: { disableCooling: true }, + }); + + const unflagged = await providersDb.createProviderConnection({ + provider: "glm", + authType: "apikey", + apiKey: "sk-unflagged-cooled", + isActive: true, + testStatus: "active", + }); + + await auth.markAccountUnavailable( + (flagged as any).id, + 503, + "temporary upstream error", + "glm", + "glm-5.1" + ); + await auth.markAccountUnavailable( + (unflagged as any).id, + 503, + "temporary upstream error", + "glm", + "glm-5.1" + ); + + // Selection must keep returning the flagged connection (never the cooled sibling). + for (let i = 0; i < 8; i++) { + const selected = await auth.getProviderCredentials("glm"); + assert.ok(selected, "expected a selectable connection"); + assert.equal( + selected.connectionId, + (flagged as any).id, + "disableCooling connection must stay eligible while the unflagged sibling is cooled" + ); + } +}); diff --git a/tests/unit/catalog-updates-v3x.test.ts b/tests/unit/catalog-updates-v3x.test.ts index 5c7b0ea318..12489c8351 100644 --- a/tests/unit/catalog-updates-v3x.test.ts +++ b/tests/unit/catalog-updates-v3x.test.ts @@ -89,9 +89,10 @@ test("Every Kiro registry model resolves a non-zero pricing row (no $0.00 usage) assert.ok(models.length > 0, "kiro must expose models"); for (const model of models) { - const pricing = getPricingForModel("kiro", model.id) as - | { input?: number; output?: number } - | null; + const pricing = getPricingForModel("kiro", model.id) as { + input?: number; + output?: number; + } | null; assert.ok(pricing, `kiro pricing must include "${model.id}"`); assert.equal( typeof pricing?.input === "number" && typeof pricing?.output === "number", diff --git a/tests/unit/gemini-models-parser.test.ts b/tests/unit/gemini-models-parser.test.ts index 79b6ddf216..83007103c5 100644 --- a/tests/unit/gemini-models-parser.test.ts +++ b/tests/unit/gemini-models-parser.test.ts @@ -1,112 +1,110 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { parseGeminiModelsList } from "../../src/lib/providerModels/geminiModelsParser"; - -// A representative slice of the live generativelanguage v1beta/models response β€” including the -// image models (gemini-*-image via generateContent, imagen-* via predict) that the Vertex catalog -// must surface dynamically. -const SAMPLE = { - models: [ - { - name: "models/gemini-2.5-flash", - displayName: "Gemini 2.5 Flash", - inputTokenLimit: 1048576, - outputTokenLimit: 65536, - supportedGenerationMethods: ["generateContent", "countTokens", "batchGenerateContent"], - thinking: true, - }, - { - name: "models/gemini-3-pro-image-preview", - displayName: "Gemini 3 Pro Image Preview", - supportedGenerationMethods: ["generateContent", "countTokens"], - }, - { - name: "models/imagen-4.0-generate-001", - displayName: "Imagen 4.0", - supportedGenerationMethods: ["predict"], - }, - { - name: "models/text-embedding-004", - displayName: "Text Embedding 004", - supportedGenerationMethods: ["embedContent"], - }, - { - name: "models/gemini-live-2.5-flash", - displayName: "Gemini Live", - supportedGenerationMethods: ["bidiGenerateContent"], - }, - { - name: "models/veo-3.0-generate-001", - displayName: "Veo 3.0", - supportedGenerationMethods: ["predictLongRunning"], - }, - { - // Defensive: an Imagen model exposed via a long-running method must stay - // "images", never "video". - name: "models/imagen-future-preview", - displayName: "Imagen Future", - supportedGenerationMethods: ["predictLongRunning"], - }, - ], -}; - -test("parseGeminiModelsList strips the models/ prefix and maps display name", () => { - const models = parseGeminiModelsList(SAMPLE); - const flash = models.find((m) => m.id === "gemini-2.5-flash"); - assert.ok(flash, "gemini-2.5-flash should be present"); - assert.equal(flash!.name, "Gemini 2.5 Flash"); - assert.equal(flash!.inputTokenLimit, 1048576); - assert.equal(flash!.outputTokenLimit, 65536); - assert.equal(flash!.supportsThinking, true); - assert.deepEqual(flash!.supportedEndpoints, ["chat"]); -}); - -test("parseGeminiModelsList maps generateContent image models to the chat endpoint", () => { - const models = parseGeminiModelsList(SAMPLE); - const proImage = models.find((m) => m.id === "gemini-3-pro-image-preview"); - assert.ok(proImage, "gemini-3-pro-image-preview should be present"); - // gemini-*-image models generate images via generateContent (chat-with-modalities path). - assert.deepEqual(proImage!.supportedEndpoints, ["chat"]); -}); - -test("parseGeminiModelsList maps Imagen predict models to the images endpoint", () => { - const models = parseGeminiModelsList(SAMPLE); - const imagen = models.find((m) => m.id === "imagen-4.0-generate-001"); - assert.ok(imagen, "imagen-4.0-generate-001 should be present"); - assert.deepEqual(imagen!.supportedEndpoints, ["images"]); -}); - -test("parseGeminiModelsList maps embedContent and bidiGenerateContent", () => { - const models = parseGeminiModelsList(SAMPLE); - assert.deepEqual( - models.find((m) => m.id === "text-embedding-004")!.supportedEndpoints, - ["embeddings"] - ); - assert.deepEqual( - models.find((m) => m.id === "gemini-live-2.5-flash")!.supportedEndpoints, - ["audio"] - ); -}); - -test("parseGeminiModelsList maps Veo predictLongRunning models to the video endpoint", () => { - const models = parseGeminiModelsList(SAMPLE); - const veo = models.find((m) => m.id === "veo-3.0-generate-001"); - assert.ok(veo, "veo-3.0-generate-001 should be present"); - assert.deepEqual(veo!.supportedEndpoints, ["video"]); -}); - -test("parseGeminiModelsList keeps Imagen as images even via a long-running method", () => { - const models = parseGeminiModelsList(SAMPLE); - const imagen = models.find((m) => m.id === "imagen-future-preview"); - assert.ok(imagen, "imagen-future-preview should be present"); - assert.deepEqual(imagen!.supportedEndpoints, ["images"]); -}); - -test("parseGeminiModelsList defaults to chat and tolerates empty/missing input", () => { - assert.deepEqual(parseGeminiModelsList({}), []); - assert.deepEqual(parseGeminiModelsList(null), []); - const [m] = parseGeminiModelsList({ models: [{ name: "models/mystery" }] }); - assert.equal(m.id, "mystery"); - assert.deepEqual(m.supportedEndpoints, ["chat"]); -}); +import test from "node:test"; +import assert from "node:assert/strict"; + +import { parseGeminiModelsList } from "../../src/lib/providerModels/geminiModelsParser"; + +// A representative slice of the live generativelanguage v1beta/models response β€” including the +// image models (gemini-*-image via generateContent, imagen-* via predict) that the Vertex catalog +// must surface dynamically. +const SAMPLE = { + models: [ + { + name: "models/gemini-2.5-flash", + displayName: "Gemini 2.5 Flash", + inputTokenLimit: 1048576, + outputTokenLimit: 65536, + supportedGenerationMethods: ["generateContent", "countTokens", "batchGenerateContent"], + thinking: true, + }, + { + name: "models/gemini-3-pro-image-preview", + displayName: "Gemini 3 Pro Image Preview", + supportedGenerationMethods: ["generateContent", "countTokens"], + }, + { + name: "models/imagen-4.0-generate-001", + displayName: "Imagen 4.0", + supportedGenerationMethods: ["predict"], + }, + { + name: "models/text-embedding-004", + displayName: "Text Embedding 004", + supportedGenerationMethods: ["embedContent"], + }, + { + name: "models/gemini-live-2.5-flash", + displayName: "Gemini Live", + supportedGenerationMethods: ["bidiGenerateContent"], + }, + { + name: "models/veo-3.0-generate-001", + displayName: "Veo 3.0", + supportedGenerationMethods: ["predictLongRunning"], + }, + { + // Defensive: an Imagen model exposed via a long-running method must stay + // "images", never "video". + name: "models/imagen-future-preview", + displayName: "Imagen Future", + supportedGenerationMethods: ["predictLongRunning"], + }, + ], +}; + +test("parseGeminiModelsList strips the models/ prefix and maps display name", () => { + const models = parseGeminiModelsList(SAMPLE); + const flash = models.find((m) => m.id === "gemini-2.5-flash"); + assert.ok(flash, "gemini-2.5-flash should be present"); + assert.equal(flash!.name, "Gemini 2.5 Flash"); + assert.equal(flash!.inputTokenLimit, 1048576); + assert.equal(flash!.outputTokenLimit, 65536); + assert.equal(flash!.supportsThinking, true); + assert.deepEqual(flash!.supportedEndpoints, ["chat"]); +}); + +test("parseGeminiModelsList maps generateContent image models to the chat endpoint", () => { + const models = parseGeminiModelsList(SAMPLE); + const proImage = models.find((m) => m.id === "gemini-3-pro-image-preview"); + assert.ok(proImage, "gemini-3-pro-image-preview should be present"); + // gemini-*-image models generate images via generateContent (chat-with-modalities path). + assert.deepEqual(proImage!.supportedEndpoints, ["chat"]); +}); + +test("parseGeminiModelsList maps Imagen predict models to the images endpoint", () => { + const models = parseGeminiModelsList(SAMPLE); + const imagen = models.find((m) => m.id === "imagen-4.0-generate-001"); + assert.ok(imagen, "imagen-4.0-generate-001 should be present"); + assert.deepEqual(imagen!.supportedEndpoints, ["images"]); +}); + +test("parseGeminiModelsList maps embedContent and bidiGenerateContent", () => { + const models = parseGeminiModelsList(SAMPLE); + assert.deepEqual(models.find((m) => m.id === "text-embedding-004")!.supportedEndpoints, [ + "embeddings", + ]); + assert.deepEqual(models.find((m) => m.id === "gemini-live-2.5-flash")!.supportedEndpoints, [ + "audio", + ]); +}); + +test("parseGeminiModelsList maps Veo predictLongRunning models to the video endpoint", () => { + const models = parseGeminiModelsList(SAMPLE); + const veo = models.find((m) => m.id === "veo-3.0-generate-001"); + assert.ok(veo, "veo-3.0-generate-001 should be present"); + assert.deepEqual(veo!.supportedEndpoints, ["video"]); +}); + +test("parseGeminiModelsList keeps Imagen as images even via a long-running method", () => { + const models = parseGeminiModelsList(SAMPLE); + const imagen = models.find((m) => m.id === "imagen-future-preview"); + assert.ok(imagen, "imagen-future-preview should be present"); + assert.deepEqual(imagen!.supportedEndpoints, ["images"]); +}); + +test("parseGeminiModelsList defaults to chat and tolerates empty/missing input", () => { + assert.deepEqual(parseGeminiModelsList({}), []); + assert.deepEqual(parseGeminiModelsList(null), []); + const [m] = parseGeminiModelsList({ models: [{ name: "models/mystery" }] }); + assert.equal(m.id, "mystery"); + assert.deepEqual(m.supportedEndpoints, ["chat"]); +}); diff --git a/tests/unit/kiro-available-models.test.ts b/tests/unit/kiro-available-models.test.ts index d1187b9608..4f57ec1a92 100644 --- a/tests/unit/kiro-available-models.test.ts +++ b/tests/unit/kiro-available-models.test.ts @@ -1,147 +1,163 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { - parseKiroModels, - resolveKiroRegion, - buildKiroModelsEndpoints, - fetchKiroAvailableModels, -} from "../../open-sse/services/kiroModels.ts"; - -const FALLBACK = [{ id: "auto-kiro", name: "Auto" }, { id: "claude-sonnet-4.6" }]; - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -test("parseKiroModels reads CodeWhisperer ListAvailableModels shape", () => { - const models = parseKiroModels({ - models: [ - { modelId: "auto", modelName: "Auto" }, - { modelId: "claude-sonnet-4.6", modelName: "Claude Sonnet 4.6" }, - { modelId: "claude-sonnet-4.6" }, // duplicate id is ignored - { modelName: "no id" }, // missing id is skipped - ], - }); - - assert.deepEqual( - models.map((m) => m.id), - ["auto", "claude-sonnet-4.6"] - ); - assert.equal(models[1].name, "Claude Sonnet 4.6"); - assert.equal(models[0].owned_by, "kiro"); -}); - -test("resolveKiroRegion prefers stored region, then profileArn, else us-east-1", () => { - assert.equal(resolveKiroRegion({ region: "eu-central-1" }), "eu-central-1"); - assert.equal( - resolveKiroRegion({ profileArn: "arn:aws:codewhisperer:eu-central-1:123:profile/X" }), - "eu-central-1" - ); - assert.equal(resolveKiroRegion({}), "us-east-1"); - assert.equal(resolveKiroRegion(null), "us-east-1"); -}); - -test("buildKiroModelsEndpoints is region-matched with a us-east-1 fallback", () => { - assert.deepEqual(buildKiroModelsEndpoints("us-east-1"), [ - "https://q.us-east-1.amazonaws.com/ListAvailableModels", - ]); - assert.deepEqual(buildKiroModelsEndpoints("eu-central-1"), [ - "https://q.eu-central-1.amazonaws.com/ListAvailableModels", - "https://q.us-east-1.amazonaws.com/ListAvailableModels", - ]); -}); - -test("fetchKiroAvailableModels: simple (Builder ID) account, us-east-1, origin-only", async () => { - const calls: string[] = []; - const fetchImpl = (async (url: string) => { - calls.push(url); - return jsonResponse({ models: [{ modelId: "claude-sonnet-4.6" }, { modelId: "auto" }] }); - }) as unknown as typeof fetch; - - const result = await fetchKiroAvailableModels({ - accessToken: "tok", - providerSpecificData: {}, // no region, no profileArn β†’ us-east-1, origin-only - fetchImpl, - fallbackModels: FALLBACK, - }); - - assert.equal(result.source, "api"); - assert.deepEqual(result.models.map((m) => m.id).sort(), ["auto", "claude-sonnet-4.6"]); - assert.deepEqual(calls, [ - "https://q.us-east-1.amazonaws.com/ListAvailableModels?origin=AI_EDITOR", - ]); -}); - -test("fetchKiroAvailableModels: IAM Identity Center account, region-matched endpoint", async () => { - const calls: string[] = []; - const fetchImpl = (async (url: string) => { - calls.push(url); - // First (region-matched) endpoint succeeds. - return jsonResponse({ models: [{ modelId: "claude-opus-4.8", modelName: "Opus 4.8" }] }); - }) as unknown as typeof fetch; - - const result = await fetchKiroAvailableModels({ - accessToken: "tok", - providerSpecificData: { region: "eu-central-1" }, - fetchImpl, - fallbackModels: FALLBACK, - }); - - assert.equal(result.source, "api"); - assert.deepEqual(result.models.map((m) => m.id), ["claude-opus-4.8"]); - assert.equal(calls[0], "https://q.eu-central-1.amazonaws.com/ListAvailableModels?origin=AI_EDITOR"); -}); - -test("fetchKiroAvailableModels: retries with profileArn when origin-only fails", async () => { - const calls: string[] = []; - const fetchImpl = (async (url: string) => { - calls.push(url); - if (url.includes("profileArn=")) { - return jsonResponse({ models: [{ modelId: "claude-sonnet-4.6" }] }); - } - return jsonResponse({ message: "forbidden" }, 403); - }) as unknown as typeof fetch; - - const result = await fetchKiroAvailableModels({ - accessToken: "tok", - providerSpecificData: { - region: "us-east-1", - profileArn: "arn:aws:codewhisperer:us-east-1:123:profile/ABC", - }, - fetchImpl, - fallbackModels: FALLBACK, - }); - - assert.equal(result.source, "api"); - assert.deepEqual(result.models.map((m) => m.id), ["claude-sonnet-4.6"]); - // origin-only attempted first, then profileArn retry. - assert.equal(calls.length, 2); - assert.ok(calls[0].endsWith("?origin=AI_EDITOR")); - assert.ok(calls[1].includes("profileArn=arn%3Aaws%3Acodewhisperer")); -}); - -test("fetchKiroAvailableModels: falls back to static catalog when no token", async () => { - const result = await fetchKiroAvailableModels({ - accessToken: "", - providerSpecificData: {}, - fallbackModels: FALLBACK, - }); - assert.equal(result.source, "fallback"); - assert.deepEqual(result.models.map((m) => m.id), ["auto-kiro", "claude-sonnet-4.6"]); -}); - -test("fetchKiroAvailableModels: falls back when every upstream attempt fails", async () => { - const fetchImpl = (async () => jsonResponse({ message: "expired" }, 403)) as unknown as typeof fetch; - const result = await fetchKiroAvailableModels({ - accessToken: "stale", - providerSpecificData: { region: "us-east-1" }, - fetchImpl, - fallbackModels: FALLBACK, - }); - assert.equal(result.source, "fallback"); - assert.deepEqual(result.models.map((m) => m.id), ["auto-kiro", "claude-sonnet-4.6"]); -}); +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + parseKiroModels, + resolveKiroRegion, + buildKiroModelsEndpoints, + fetchKiroAvailableModels, +} from "../../open-sse/services/kiroModels.ts"; + +const FALLBACK = [{ id: "auto-kiro", name: "Auto" }, { id: "claude-sonnet-4.6" }]; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +test("parseKiroModels reads CodeWhisperer ListAvailableModels shape", () => { + const models = parseKiroModels({ + models: [ + { modelId: "auto", modelName: "Auto" }, + { modelId: "claude-sonnet-4.6", modelName: "Claude Sonnet 4.6" }, + { modelId: "claude-sonnet-4.6" }, // duplicate id is ignored + { modelName: "no id" }, // missing id is skipped + ], + }); + + assert.deepEqual( + models.map((m) => m.id), + ["auto", "claude-sonnet-4.6"] + ); + assert.equal(models[1].name, "Claude Sonnet 4.6"); + assert.equal(models[0].owned_by, "kiro"); +}); + +test("resolveKiroRegion prefers stored region, then profileArn, else us-east-1", () => { + assert.equal(resolveKiroRegion({ region: "eu-central-1" }), "eu-central-1"); + assert.equal( + resolveKiroRegion({ profileArn: "arn:aws:codewhisperer:eu-central-1:123:profile/X" }), + "eu-central-1" + ); + assert.equal(resolveKiroRegion({}), "us-east-1"); + assert.equal(resolveKiroRegion(null), "us-east-1"); +}); + +test("buildKiroModelsEndpoints is region-matched with a us-east-1 fallback", () => { + assert.deepEqual(buildKiroModelsEndpoints("us-east-1"), [ + "https://q.us-east-1.amazonaws.com/ListAvailableModels", + ]); + assert.deepEqual(buildKiroModelsEndpoints("eu-central-1"), [ + "https://q.eu-central-1.amazonaws.com/ListAvailableModels", + "https://q.us-east-1.amazonaws.com/ListAvailableModels", + ]); +}); + +test("fetchKiroAvailableModels: simple (Builder ID) account, us-east-1, origin-only", async () => { + const calls: string[] = []; + const fetchImpl = (async (url: string) => { + calls.push(url); + return jsonResponse({ models: [{ modelId: "claude-sonnet-4.6" }, { modelId: "auto" }] }); + }) as unknown as typeof fetch; + + const result = await fetchKiroAvailableModels({ + accessToken: "tok", + providerSpecificData: {}, // no region, no profileArn β†’ us-east-1, origin-only + fetchImpl, + fallbackModels: FALLBACK, + }); + + assert.equal(result.source, "api"); + assert.deepEqual(result.models.map((m) => m.id).sort(), ["auto", "claude-sonnet-4.6"]); + assert.deepEqual(calls, [ + "https://q.us-east-1.amazonaws.com/ListAvailableModels?origin=AI_EDITOR", + ]); +}); + +test("fetchKiroAvailableModels: IAM Identity Center account, region-matched endpoint", async () => { + const calls: string[] = []; + const fetchImpl = (async (url: string) => { + calls.push(url); + // First (region-matched) endpoint succeeds. + return jsonResponse({ models: [{ modelId: "claude-opus-4.8", modelName: "Opus 4.8" }] }); + }) as unknown as typeof fetch; + + const result = await fetchKiroAvailableModels({ + accessToken: "tok", + providerSpecificData: { region: "eu-central-1" }, + fetchImpl, + fallbackModels: FALLBACK, + }); + + assert.equal(result.source, "api"); + assert.deepEqual( + result.models.map((m) => m.id), + ["claude-opus-4.8"] + ); + assert.equal( + calls[0], + "https://q.eu-central-1.amazonaws.com/ListAvailableModels?origin=AI_EDITOR" + ); +}); + +test("fetchKiroAvailableModels: retries with profileArn when origin-only fails", async () => { + const calls: string[] = []; + const fetchImpl = (async (url: string) => { + calls.push(url); + if (url.includes("profileArn=")) { + return jsonResponse({ models: [{ modelId: "claude-sonnet-4.6" }] }); + } + return jsonResponse({ message: "forbidden" }, 403); + }) as unknown as typeof fetch; + + const result = await fetchKiroAvailableModels({ + accessToken: "tok", + providerSpecificData: { + region: "us-east-1", + profileArn: "arn:aws:codewhisperer:us-east-1:123:profile/ABC", + }, + fetchImpl, + fallbackModels: FALLBACK, + }); + + assert.equal(result.source, "api"); + assert.deepEqual( + result.models.map((m) => m.id), + ["claude-sonnet-4.6"] + ); + // origin-only attempted first, then profileArn retry. + assert.equal(calls.length, 2); + assert.ok(calls[0].endsWith("?origin=AI_EDITOR")); + assert.ok(calls[1].includes("profileArn=arn%3Aaws%3Acodewhisperer")); +}); + +test("fetchKiroAvailableModels: falls back to static catalog when no token", async () => { + const result = await fetchKiroAvailableModels({ + accessToken: "", + providerSpecificData: {}, + fallbackModels: FALLBACK, + }); + assert.equal(result.source, "fallback"); + assert.deepEqual( + result.models.map((m) => m.id), + ["auto-kiro", "claude-sonnet-4.6"] + ); +}); + +test("fetchKiroAvailableModels: falls back when every upstream attempt fails", async () => { + const fetchImpl = (async () => + jsonResponse({ message: "expired" }, 403)) as unknown as typeof fetch; + const result = await fetchKiroAvailableModels({ + accessToken: "stale", + providerSpecificData: { region: "us-east-1" }, + fetchImpl, + fallbackModels: FALLBACK, + }); + assert.equal(result.source, "fallback"); + assert.deepEqual( + result.models.map((m) => m.id), + ["auto-kiro", "claude-sonnet-4.6"] + ); +});