mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
This commit is contained in:
committed by
GitHub
parent
2670a0a819
commit
7c080941d1
@@ -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)
|
||||
|
||||
@@ -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.)"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
191
tests/unit/auth-disable-cooling-2997.test.ts
Normal file
191
tests/unit/auth-disable-cooling-2997.test.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user