feat(connections): per-connection disable-cooldown opt-out (#2997) (#3852)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-14 10:32:26 -03:00
committed by GitHub
parent 2670a0a819
commit 7c080941d1
13 changed files with 803 additions and 562 deletions

View File

@@ -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 `<omniModel>`-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)

View File

@@ -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.)"
}

View File

@@ -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 <accessToken>
* → { 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<string, unknown>;
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<string>();
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<KiroModel[] | null> {
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<KiroModelsResult> {
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 <accessToken>
* → { 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<string, unknown>;
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<string>();
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<KiroModel[] | null> {
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<KiroModelsResult> {
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);
}

View File

@@ -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({
/>
</div>
)}
{/* #2997: per-connection transient-cooldown opt-out (provider-agnostic) */}
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
<Toggle
checked={formData.disableCooling}
onChange={(checked) => setFormData({ ...formData, disableCooling: checked })}
label={t("disableCoolingLabel")}
description={t("disableCoolingDescription")}
/>
</div>
{supportsGoogleProjectId && (
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
{isAntigravity && (

View File

@@ -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<Record<string, unknown>>
)
.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;
}

View File

@@ -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.",

View File

@@ -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<string, string> = {
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<string, unknown>) => {
const methods: string[] = Array.isArray(m.supportedGenerationMethods)
? (m.supportedGenerationMethods as string[])
: [];
const endpoints = new Set<string>(
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<string, string> = {
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<string, unknown>) => {
const methods: string[] = Array.isArray(m.supportedGenerationMethods)
? (m.supportedGenerationMethods as string[])
: [];
const endpoints = new Set<string>(
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;
});
}

View File

@@ -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;
}

View File

@@ -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),

View File

@@ -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"
);
}
});

View File

@@ -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",

View File

@@ -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"]);
});

View File

@@ -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"]
);
});