diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 46aaffb606..9d3c854412 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1832,11 +1832,6 @@ "count": 1 } }, - "tests/unit/provider-validation-specialty.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, "tests/unit/providers-route-managed-catalog.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 4 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 9653ffa19a..229016bdd7 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -183,10 +183,11 @@ "open-sse/services/rateLimitManager.ts": 1035, "_rebaseline_2026_06_29_4038_cas_guard": "PR (#4038) own growth: tokenRefresh.ts 2103->2181 (+78 = the compare-and-swap guard on the refresh persist — runWithCasGuard/getActiveCasGuard AsyncLocalStorage pair mirroring runWithOnPersist, casGuardShouldSkipPersist that rereads the row right before persisting and skips the write when a concurrent writer already rotated the refresh_token past the one presented, plus getCasGuardStats counters). Fixes the sibling-rotation-revert → token-family-revocation storm. Gated behind an active guard (opt-in; no guard => byte-identical). Wiring lives at the two persist chokepoints inside getAccessToken; the comparison reuses wasRefreshTokenRotated from refreshSerializer. Not extractable without splitting the refresh hot path.", "_rebaseline_2026_07_09_6126_clinepass_dual_auth": "PR #6126 (@hajilok, dual-auth ClinePass) own growth: tokenRefresh.ts 2181->2182 (+1 = a single `case \"clinepass\":` fallthrough label added to the existing `case \"cline\":` in _getAccessTokenInternal's provider switch, so clinepass token refresh dispatches to the already-shared refreshClineToken() instead of silently falling through to the generic OAuth refresh). Irreducible 1-line switch-case wiring at the existing chokepoint; the header-building logic for the same feature was extracted to a new leaf src/shared/utils/clineAuth.ts::buildClinepassHeaders() (well under cap) to avoid growing open-sse/executors/default.ts. Covered by tests/unit/clinepass-provider.test.ts.", - "open-sse/services/tokenRefresh.ts": 2182, + "_rebaseline_2026_07_09_6363_kiro_external_idp": "PR #6363 (@artickc, Kiro external IdP) own growth: tokenRefresh.ts 2182->2249 (+67 = the external_idp refresh branch inside refreshKiroToken — standard public-client OAuth2 refresh_token grant against the org IdP tokenEndpoint via buildExternalIdpRefreshParams/isExternalIdpAuthMethod from the new leaf open-sse/services/kiroExternalIdp.ts, with invalid_grant/invalid_client -> unrecoverable_refresh_error mapping). Cohesive addition at the existing refreshKiroToken chokepoint. Covered by tests/unit/kiro-external-idp.test.ts.", + "open-sse/services/tokenRefresh.ts": 2249, "open-sse/services/usage.ts": 3454, "open-sse/translator/request/openai-to-gemini.ts": 906, - "open-sse/translator/request/openai-to-kiro.ts": 890, + "open-sse/translator/request/openai-to-kiro.ts": 912, "open-sse/translator/response/openai-responses.ts": 1092, "open-sse/utils/cursorAgentProtobuf.ts": 1521, "open-sse/utils/stream.ts": 2792, @@ -247,7 +248,7 @@ "src/lib/resilience/settings.ts": 841, "src/lib/tailscaleTunnel.ts": 1202, "src/lib/usage/callLogs.ts": 997, - "src/lib/usage/providerLimits.ts": 998, + "src/lib/usage/providerLimits.ts": 1000, "src/lib/usage/usageHistory.ts": 988, "_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.", "src/shared/components/OAuthModal.tsx": 993, @@ -269,11 +270,12 @@ "open-sse/executors/kiro.ts": 944, "open-sse/translator/request/openai-to-claude.ts": 823, "tests/unit/account-fallback-service.test.ts": 1572, - "tests/unit/provider-validation-specialty.test.ts": 2843, + "tests/unit/provider-validation-specialty.test.ts": 2980, "open-sse/executors/huggingchat.ts": 813, "_rebaseline_2026_07_01_v3843_release_5609": "Rebaseline v3.8.43 (PR #5609 release reconciliation). DRIFT dos 109 commits do ciclo: 8 god-files existentes cresceram (ApiManagerPageClient 2983->3017, combos/page 4594->4608, AddApiKeyModal 868->869, providerPageHelpers 974->996, chat.ts 1635->1647, auth.ts 2401->2403, batchProcessor 828->915, combo.ts 3368->3387) + 2 novos acima do cap (huggingchat.ts 813, tests web-cookie-providers-new 827) + 4 test files cresceram. Modularizacao deferida (blast-radius mid-release); congelado no estado atual p/ o proximo ciclo ratchetar daqui.", "src/lib/providers/validation/webProvidersA.ts": 809, - "src/lib/tokenHealthCheck.ts": 830 + "src/lib/tokenHealthCheck.ts": 830, + "_rebaseline_2026_07_09_6587_kiro_api_key_auth": "PR #6587 (@strangersp) own growth for Kiro long-lived API-key auth, merged onto v3.8.47 tip: openai-to-kiro.ts 890->912 (+22, auth-header selection for API-key-vs-OAuth-token connections), providerLimits.ts 998->1000 (+2, API-key auth-type branch), translator-openai-to-kiro.test.ts 1234->1257 (+23), providers-page-utils.test.ts 1109->1107 (net -2 after merging with parallel release drift; connectionMatchesProviderCard api_key coverage added), provider-validation-specialty.test.ts 2856->2980 (+124 net after merge with parallel release drift; this PR also removed the file's `@typescript-eslint/no-explicit-any` eslint-suppression entry by fixing all `any` usages, adding typed replacements). Cohesive additive feature growth, well tested; not extractable without splitting the existing chokepoints mid-merge." }, "testCap": 800, "testFrozen": { @@ -310,8 +312,8 @@ "tests/unit/oauth-providers-config.test.ts": 845, "tests/unit/perplexity-web.test.ts": 999, "tests/unit/provider-models-route.test.ts": 1752, - "tests/unit/provider-validation-specialty.test.ts": 2856, - "tests/unit/providers-page-utils.test.ts": 1109, + "tests/unit/provider-validation-specialty.test.ts": 2980, + "tests/unit/providers-page-utils.test.ts": 1107, "tests/unit/reasoning-cache.test.ts": 980, "tests/unit/response-sanitizer.test.ts": 1063, "tests/unit/route-edge-coverage.test.ts": 1241, @@ -323,7 +325,7 @@ "tests/unit/translator-helper-branches.test.ts": 870, "tests/unit/translator-openai-responses-req.test.ts": 1172, "tests/unit/translator-openai-to-gemini.test.ts": 1541, - "tests/unit/translator-openai-to-kiro.test.ts": 1234, + "tests/unit/translator-openai-to-kiro.test.ts": 1257, "tests/unit/translator-resp-gemini-to-openai.test.ts": 1234, "tests/unit/usage-service-hardening.test.ts": 1503, "tests/unit/vscode-token-routes.test.ts": 1285, diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index 377b90bfab..c8d6cc4016 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -120,7 +120,8 @@ "_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle." }, "cognitiveComplexity": { - "value": 884, + "value": 885, + "_rebaseline_2026_07_09_6587_kiro_api_key_auth": "884->885 (+1). PR #6587 (@strangersp) own growth: open-sse/services/usage/kiro.ts gains ONE new over-threshold function — getKiroUsage grew from a single fetch to a 3-endpoint fallback chain (codewhisperer-get / codewhisperer-post / q-get) with per-attempt auth-header selection (tokentype: API_KEY vs Bearer-only), needed so usage/quota lookups work for the new long-lived-API-key auth path in addition to the existing OAuth path (measured: 0 violations on release tip -> 1 violation, complexity 33, at open-sse/services/usage/kiro.ts). Covered by tests/unit/kiro-iam-profilearn-usage.test.ts (tokentype header selection, friendly auth-expired/rejected-token messages). Cohesive multi-endpoint-fallback logic at an existing usage chokepoint; not extractable without splitting the fallback loop mid-merge. Structural shrink tracked in #3501.", "_rebaseline_2026_07_07_v3846_release_close": "877->882 (+5). v3.8.46 release close (generate-release Phase 0 pre-flight): drift herdado do merge burst do ciclo. Trust-but-verify: os fixes de base-red do captain (agentSkills path.resolve #6366, catalogo cache #6408, tipagem de teste, MitmProxyTab suppression) sao cognitive-net-zero — check:cognitive-complexity mede 882 identico com e sem os fixes (a catraca NAO roda no fast-path PR->release). Tighten via --update next cycle.", "_rebaseline_2026_07_03_v3844_ipfilter_release_green": "861->867 (+6). v3.8.44 cycle drift measured on release tip 32e4c906e during the #6131/#5975 release-green rebaseline. Inherited from the merge burst (Quality Ratchet does not run on PR->release fast-gates). route-edge-coverage +7 is my #5975 test comment; the rest is parallel-session drift. Tighten via --update next cycle.", "_rebaseline_2026_07_03_v3844_review_prs_fix_batch": "860->861 (+1). Inherited v3.8.44 cycle drift surfaced by the release-green pre-flight during the /review-prs fix-batch round; check:cognitive-complexity measures 861 on the release tip 72ee80649. Negligible +1 from the round's / parallel-session merge burst (cognitive-complexity does NOT run on PR->release fast-gates). Structural shrink tracked in #3501. Tighten via --update next cycle.", diff --git a/docs/guides/KIRO_SETUP.md b/docs/guides/KIRO_SETUP.md index 737ff8c2e7..1730076647 100644 --- a/docs/guides/KIRO_SETUP.md +++ b/docs/guides/KIRO_SETUP.md @@ -29,7 +29,8 @@ Kiro connection import. This gives each OmniRoute connection its own dedicated O client registration. Because each client registration is independent, refreshing or re-authenticating one account does not affect any other account's refresh token. -The isolation applies to all three import methods: +The isolation applies to the refresh-token import methods, and API-key auth avoids +OIDC refresh sessions entirely: | Import method | Isolation status | | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | @@ -37,6 +38,7 @@ The isolation applies to all three import methods: | **Import Token** (manual refresh token paste) | Isolated from v3.8.0 | | **Google / GitHub social login** | Isolated from v3.8.0 | | **Auto-Import** (kiro-cli SQLite) | Isolated from v3.8.0 (SQLite path was already isolated; SSO-cache fallback is now also isolated) | +| **API Key** (long-lived CodeWhisperer key) | No refresh session; the key is validated and stored as a bearer credential | --- @@ -66,10 +68,12 @@ receive their own client registration automatically. 1. Open **Dashboard → Providers → Add Provider → Kiro**. 2. Choose one of: - **Import Token** — paste a refresh token starting with `aorAAAAAG`. + - **API Key** — paste a long-lived Kiro / CodeWhisperer API key. - **Google / GitHub login** — complete the OAuth flow in the browser. - **Auto-Import** — click the button; OmniRoute reads credentials from the local kiro-cli database or `~/.aws/sso/cache`. -3. The connection is saved. OmniRoute automatically registers a dedicated OIDC client for it. +3. The connection is saved. Refresh-token flows automatically register a dedicated + OIDC client. API-key flows validate the key with AWS and do not store a refresh token. ### Step 2: Import the second account @@ -112,6 +116,52 @@ The `region` field defaults to `us-east-1` when omitted. --- +## API-Key Import Flow + +API-key auth is for long-lived Kiro / AWS CodeWhisperer bearer credentials. It does +not use OAuth refresh, so it avoids shared OIDC session invalidation. + +### Dashboard + +1. Open **Dashboard -> Providers -> Kiro**. +2. Choose **API Key**. +3. Paste the API key and optional AWS region (`us-east-1` by default). +4. OmniRoute validates the key and saves the connection. + +### API + +```bash +curl -X POST http://localhost:20128/api/oauth/kiro/api-key \ + -H "Content-Type: application/json" \ + -d '{"apiKey": "kiro_or_codewhisperer_key", "region": "us-east-1"}' +``` + +### Internal Contract + +The API route validates the key by calling `KiroService.validateApiKey()`, which +uses `ListAvailableProfiles` against the region-matched CodeWhisperer/Amazon Q +endpoint and resolves a `profileArn`. + +The saved connection uses: + +```json +{ + "authType": "apikey", + "providerSpecificData": { + "authMethod": "api_key", + "region": "us-east-1", + "profileArn": "arn:aws:codewhisperer:..." + } +} +``` + +At runtime, `KiroExecutor.buildHeaders()` sends the key as +`Authorization: Bearer ` and adds `tokentype: API_KEY`. Quota/profile calls +use the same marker so AWS treats the bearer as a long-lived API key rather than +an OIDC or social access token. + +--- + ## OIDC Client Expiry AWS SSO OIDC public clients typically expire after 90 days @@ -120,6 +170,9 @@ for observability. If a connection stops refreshing after ~90 days, re-import th connection to obtain a fresh OIDC client registration. Automatic re-registration on expiry is tracked as a future improvement. +API-key connections do not have OIDC client expiry because they do not refresh +through AWS SSO OIDC. + --- ## Troubleshooting @@ -137,4 +190,11 @@ expiry is tracked as a future improvement. region). If you are behind a corporate proxy, set a provider-level proxy in **Dashboard → Settings → Proxies**. +### API-key import fails + +- Confirm the key is a Kiro / CodeWhisperer API key, not a refresh token. +- Confirm the AWS region matches the key/account. `us-east-1` is the default. +- The key must be able to call `ListAvailableProfiles`; otherwise OmniRoute cannot + resolve the required `profileArn`. + For other issues, see the main [TROUBLESHOOTING.md](./TROUBLESHOOTING.md). diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index 7def296e39..d5623aacdd 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -8,6 +8,11 @@ import { import { PROVIDERS } from "../config/constants.ts"; import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.ts"; +import { + isExternalIdpAuthMethod, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE, +} from "../services/kiroExternalIdp.ts"; import { splitInlineThinking, flushPendingThinking, @@ -196,8 +201,29 @@ export class KiroExecutor extends BaseExecutor { "anthropic-beta": "prompt-caching-2024-07-31", }; - if (credentials.accessToken) { - headers["Authorization"] = `Bearer ${credentials.accessToken}`; + const authMethod = + typeof credentials.providerSpecificData?.authMethod === "string" + ? credentials.providerSpecificData.authMethod + : undefined; + const isApiKey = authMethod === "api_key"; + const token = isApiKey + ? credentials.apiKey || credentials.accessToken + : credentials.accessToken; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + // Long-lived Kiro/CodeWhisperer API keys authenticate with `tokentype: API_KEY`. + if (isApiKey) headers["tokentype"] = "API_KEY"; + + // Enterprise / Microsoft Entra "Your organization" (external_idp) logins send an + // org-IdP-issued access token. CodeWhisperer only binds it to the Amazon Q Developer + // profile when the request carries `TokenType: EXTERNAL_IDP`; without it every call + // returns `ValidationException: Invalid ARN ` (the service falls back to the + // token's client id as the resource ARN). AWS SSO (Builder ID / IDC) and social tokens + // must NOT send this header, so it is gated on the persisted authMethod. + if (isExternalIdpAuthMethod(authMethod)) { + headers[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE; + } } return headers; @@ -780,6 +806,7 @@ export class KiroExecutor extends BaseExecutor { } async refreshCredentials(credentials: ProviderCredentials, log?: ExecutorLog | null) { + if (credentials.providerSpecificData?.authMethod === "api_key") return null; if (!credentials.refreshToken) return null; try { diff --git a/open-sse/services/kiroExternalIdp.ts b/open-sse/services/kiroExternalIdp.ts new file mode 100644 index 0000000000..3e27c8120d --- /dev/null +++ b/open-sse/services/kiroExternalIdp.ts @@ -0,0 +1,175 @@ +/** + * kiroExternalIdp.ts — shared helpers for Kiro / Amazon Q **External IdP** + * (enterprise "Your organization" SSO) accounts. + * + * Unlike AWS Builder ID / IAM Identity Center (which mint AWS SSO-OIDC tokens + * refreshed at `oidc.{region}.amazonaws.com` and whose refresh token starts with + * `aorAAAAAG`) or the Google/GitHub social flow (refreshed at the Kiro auth + * service), an **External IdP** login federates through the organization's own + * identity provider (most commonly Microsoft Entra ID). Its Kiro token file + * (`~/.aws/sso/cache/kiro-auth-token.json`) looks like: + * + * { + * "accessToken": "", + * "refreshToken": "", + * "authMethod": "external_idp", + * "provider": "ExternalIdp", + * "clientId": "", + * "tokenEndpoint":"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", + * "issuerUrl": "https://login.microsoftonline.com/{tenant}/v2.0", + * "scopes": "api://{clientId}/codewhisperer:conversations … offline_access" + * } + * + * Two consequences this module encodes (both verified against a live org token): + * 1. The token is refreshed with a **standard public-client OAuth2 + * `refresh_token` grant against `tokenEndpoint`** (form-encoded + * client_id + refresh_token + scope, NO client_secret) — see + * {@link buildExternalIdpRefreshParams}. + * 2. At runtime the access token is sent to CodeWhisperer as a normal bearer + * but MUST carry the header `TokenType: EXTERNAL_IDP` so the service binds + * it to the Amazon Q Developer profile (without it every call returns + * `ValidationException: Invalid ARN `). The profileArn itself is + * NOT discoverable via `ListAvailableProfiles` (it returns an empty list + * for these tokens); it is read from the Kiro IDE `profile.json` at import. + */ + +/** authMethod marker persisted on External IdP connections. */ +export const KIRO_EXTERNAL_IDP_AUTH_METHOD = "external_idp"; + +/** Header CodeWhisperer requires to bind an External IdP bearer to its profile. */ +export const KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER = "TokenType"; +export const KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE = "EXTERNAL_IDP"; + +/** + * Allowlist of enterprise IdP token-endpoint host suffixes. The refresh token is + * POSTed to this endpoint, so we constrain it to well-known identity providers + * (SSRF guard — the value ultimately originates from an on-disk token file). + * Microsoft Entra is by far the most common Kiro org IdP; the others cover the + * major enterprise SSO vendors an org might federate Kiro through. + */ +const ALLOWED_IDP_HOST_SUFFIXES: readonly string[] = [ + "login.microsoftonline.com", + "login.microsoftonline.us", + "login.partner.microsoftonline.cn", + "login.microsoft.com", + "login.windows.net", + "sts.windows.net", + ".okta.com", + ".oktapreview.com", + ".okta-emea.com", + ".auth0.com", + ".onelogin.com", + ".pingidentity.com", + ".pingone.com", + "accounts.google.com", + "oauth2.googleapis.com", + ".amazoncognito.com", +]; + +function normalizeString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +/** True when a connection's providerSpecificData marks it as an External IdP login. */ +export function isExternalIdpAuthMethod(authMethod: unknown): boolean { + return normalizeString(authMethod).toLowerCase() === KIRO_EXTERNAL_IDP_AUTH_METHOD; +} + +/** + * Validate the IdP token endpoint before it is used as a fetch target. Requires + * https and a host on {@link ALLOWED_IDP_HOST_SUFFIXES}. Returns the normalized + * URL string; throws on anything unexpected. + */ +export function validateExternalIdpTokenEndpoint(rawEndpoint: unknown): string { + const tokenEndpoint = normalizeString(rawEndpoint); + if (!tokenEndpoint) throw new Error("tokenEndpoint is required for external_idp"); + let parsed: URL; + try { + parsed = new URL(tokenEndpoint); + } catch { + throw new Error("tokenEndpoint must be a valid URL"); + } + if (parsed.protocol !== "https:") { + throw new Error("tokenEndpoint must use https"); + } + const host = parsed.hostname.toLowerCase(); + const allowed = ALLOWED_IDP_HOST_SUFFIXES.some((suffix) => + suffix.startsWith(".") ? host.endsWith(suffix) : host === suffix + ); + if (!allowed) { + throw new Error(`tokenEndpoint host is not an allowed identity provider: ${host}`); + } + return parsed.toString(); +} + +/** Collapse an array-or-space-delimited scope value into a single space-delimited string. */ +export function normalizeScope(scopes: unknown): string { + if (Array.isArray(scopes)) { + return scopes.map(normalizeString).filter(Boolean).join(" "); + } + return normalizeString(scopes); +} + +/** Best-effort base64url JWT payload decode (no signature verification). */ +export function decodeJwtPayload(jwt: unknown): Record | null { + try { + if (typeof jwt !== "string") return null; + const parts = jwt.split("."); + if (parts.length !== 3) return null; + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padding = (4 - (base64.length % 4)) % 4; + const json = Buffer.from(`${base64}${"=".repeat(padding)}`, "base64").toString("utf8"); + return JSON.parse(json) as Record; + } catch { + return null; + } +} + +/** + * Extract the login identity (email) from an External IdP access token. Org IdP + * tokens carry it as `preferred_username`/`upn`/`email` rather than the AWS + * `email` claim — otherwise the connection surfaces as the opaque "ExternalIdp". + */ +export function emailFromExternalIdpToken(accessToken: unknown): string | null { + const claims = decodeJwtPayload(accessToken); + if (!claims) return null; + const pick = (k: string): string | undefined => + typeof claims[k] === "string" ? (claims[k] as string) : undefined; + return pick("email") || pick("preferred_username") || pick("upn") || null; +} + +export interface ExternalIdpRefreshRequest { + tokenEndpoint: string; + body: URLSearchParams; +} + +/** + * Build the public-client `refresh_token` grant for an External IdP token. The + * IdP application is a PUBLIC client (no secret), so the body is exactly + * `grant_type=refresh_token&client_id&refresh_token&scope`. Throws when any + * required field is missing/invalid so callers can fail closed. + */ +export function buildExternalIdpRefreshParams( + refreshToken: string, + providerSpecificData: Record | null | undefined +): ExternalIdpRefreshRequest { + const psd = providerSpecificData || {}; + const clientId = normalizeString(psd.clientId ?? (psd as Record).client_id); + const tokenEndpoint = validateExternalIdpTokenEndpoint( + psd.tokenEndpoint ?? (psd as Record).token_endpoint + ); + const scope = normalizeScope(psd.scope ?? psd.scopes); + + if (!refreshToken) throw new Error("refresh token is required for external_idp refresh"); + if (!clientId) throw new Error("clientId is required for external_idp refresh"); + if (!scope) throw new Error("scope is required for external_idp refresh"); + + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: refreshToken, + scope, + }); + + return { tokenEndpoint, body }; +} diff --git a/open-sse/services/kiroModels.ts b/open-sse/services/kiroModels.ts index 24bc13d810..89680655d4 100644 --- a/open-sse/services/kiroModels.ts +++ b/open-sse/services/kiroModels.ts @@ -23,8 +23,21 @@ * never breaks when the account is offline / unauthenticated / token-expired. */ +import { createHash } from "node:crypto"; + +import { v4 as uuidv4 } from "uuid"; + type RawRecord = Record; +const KIRO_RUNTIME_SDK_VERSION = "1.0.0"; +const KIRO_AGENT_OS = "windows"; +const KIRO_AGENT_OS_VERSION = "10.0.26200"; +const KIRO_NODE_VERSION = "22.21.1"; +const KIRO_IDE_VERSION = "0.10.32"; +const CACHE_TTL_MS = 5 * 60 * 1000; + +const catalogCache = new Map(); + function asRecord(value: unknown): RawRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as RawRecord) : {}; } @@ -39,6 +52,14 @@ export type KiroModel = { id: string; name: string; owned_by: string; + capabilities?: { + thinking: boolean; + agentic: boolean; + }; + contextLength?: number; + rateMultiplier?: number; + upstreamModelId?: string; + description?: string; }; export type KiroModelsResult = { @@ -75,6 +96,94 @@ export function parseKiroModels(data: unknown): KiroModel[] { return models; } +function stripSyntheticSuffixes(id: string): string { + let out = id; + if (out.endsWith("-agentic")) out = out.slice(0, -"-agentic".length); + if (out.endsWith("-thinking")) out = out.slice(0, -"-thinking".length); + return out; +} + +function formatDisplayName(modelName: unknown, modelId: string, rateMultiplier: unknown): string { + const base = toNonEmptyString(modelName) || modelId; + const rate = Number(rateMultiplier); + if (!Number.isFinite(rate) || Math.abs(rate - 1.0) < 1e-9 || rate <= 0) { + return `Kiro ${base}`; + } + return `Kiro ${base} (${rate.toFixed(1)}x credit)`; +} + +function buildVariants(upstream: string, displayName: string): KiroModel[] { + const safeUpstream = stripSyntheticSuffixes(upstream); + const display = displayName || `Kiro ${safeUpstream}`; + const isAuto = safeUpstream === "auto" || safeUpstream === "auto-kiro"; + const variants: KiroModel[] = [ + { + id: safeUpstream, + name: display, + owned_by: "kiro", + capabilities: { thinking: false, agentic: false }, + }, + { + id: `${safeUpstream}-thinking`, + name: `${display} (Thinking)`, + owned_by: "kiro", + capabilities: { thinking: true, agentic: false }, + }, + ]; + + if (!isAuto) { + variants.push({ + id: `${safeUpstream}-agentic`, + name: `${display} (Agentic)`, + owned_by: "kiro", + capabilities: { thinking: false, agentic: true }, + }); + variants.push({ + id: `${safeUpstream}-thinking-agentic`, + name: `${display} (Thinking + Agentic)`, + owned_by: "kiro", + capabilities: { thinking: true, agentic: true }, + }); + } + + return variants; +} + +function expandKiroModels(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 expanded: KiroModel[] = []; + const seen = new Set(); + + for (const value of items) { + const item = asRecord(value); + const upstreamId = toNonEmptyString(item.modelId) || toNonEmptyString(item.id); + if (!upstreamId) continue; + const display = formatDisplayName(item.modelName || item.name, upstreamId, item.rateMultiplier); + const tokenLimits = asRecord(item.tokenLimits); + const contextLength = Number(tokenLimits.maxInputTokens) || 200000; + const rateMultiplier = Number(item.rateMultiplier); + + for (const variant of buildVariants(upstreamId, display)) { + if (seen.has(variant.id)) continue; + seen.add(variant.id); + expanded.push({ + ...variant, + contextLength, + rateMultiplier: Number.isFinite(rateMultiplier) ? rateMultiplier : 1.0, + upstreamModelId: upstreamId, + description: toNonEmptyString(item.description) || "", + }); + } + } + + return expanded; +} + /** * 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. @@ -124,28 +233,69 @@ function toFallbackResult( .map((model) => { const id = toNonEmptyString(model.id); if (!id) return null; - return { id, name: toNonEmptyString(model.name) || id, owned_by: "kiro" }; + return { + id, + name: toNonEmptyString(model.name) || id, + owned_by: "kiro", + }; }) .filter((model): model is KiroModel => Boolean(model)); return { models, source: "fallback" }; } +function buildKiroFingerprintHeaders(providerSpecificData: unknown, accessToken: string) { + const psd = asRecord(providerSpecificData); + const seed = + toNonEmptyString(psd.clientId) || + toNonEmptyString(psd.profileArn) || + accessToken || + "kiro-anonymous"; + const machineId = createHash("sha256").update(String(seed)).digest("hex"); + const userAgent = + `aws-sdk-js/${KIRO_RUNTIME_SDK_VERSION} ua/2.1 ` + + `os/${KIRO_AGENT_OS}#${KIRO_AGENT_OS_VERSION} ` + + `lang/js md/nodejs#${KIRO_NODE_VERSION} ` + + `api/codewhispererruntime#${KIRO_RUNTIME_SDK_VERSION} m/N,E ` + + `KiroIDE-${KIRO_IDE_VERSION}-${machineId}`; + + return { + "User-Agent": userAgent, + "x-amz-user-agent": `aws-sdk-js/${KIRO_RUNTIME_SDK_VERSION} KiroIDE-${KIRO_IDE_VERSION}-${machineId}`, + "x-amzn-kiro-agent-mode": "vibe", + "x-amzn-codewhisperer-optout": "true", + "amz-sdk-request": "attempt=1; max=1", + "amz-sdk-invocation-id": uuidv4(), + Accept: "application/json", + }; +} + +function cacheKey(accessToken: string, providerSpecificData: unknown): string { + const psd = asRecord(providerSpecificData); + const seed = + toNonEmptyString(psd.profileArn) || + toNonEmptyString(psd.clientId) || + accessToken || + "anonymous"; + return createHash("sha256").update(`kiro:${seed}`).digest("hex"); +} + async function tryFetchModels( fetchImpl: typeof fetch, url: string, - accessToken: string + accessToken: string, + providerSpecificData: unknown ): Promise { try { const response = await fetchImpl(url, { method: "GET", headers: { + ...buildKiroFingerprintHeaders(providerSpecificData, accessToken), Authorization: `Bearer ${accessToken}`, - Accept: "application/json", }, }); if (!response.ok) return null; const data = await response.json(); - const models = parseKiroModels(data); + const models = expandKiroModels(data); return models.length > 0 ? models : null; } catch { return null; @@ -172,14 +322,28 @@ export async function fetchKiroAvailableModels( return toFallbackResult(fallbackModels); } + const key = cacheKey(token, providerSpecificData); + const cached = catalogCache.get(key); + if (cached && cached.expiresAt > Date.now()) { + return { models: cached.models, source: "api" }; + } + 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" }; + const models = await tryFetchModels( + fetchImpl, + `${base}?origin=AI_EDITOR`, + token, + providerSpecificData + ); + if (models) { + catalogCache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, models }); + return { models, source: "api" }; + } } // Pass 2: retry with profileArn (desktop accounts that require it) on the @@ -187,9 +351,16 @@ export async function fetchKiroAvailableModels( // 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" }; + const models = await tryFetchModels(fetchImpl, url, token, providerSpecificData); + if (models) { + catalogCache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, models }); + return { models, source: "api" }; + } } return toFallbackResult(fallbackModels); } + +export function clearKiroModelCache(): void { + catalogCache.clear(); +} diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 4d9841342d..b479daf278 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -5,6 +5,10 @@ import { getGitHubCopilotRefreshHeaders } from "../config/providerHeaderProfiles import { pbkdf2Sync } from "node:crypto"; import { runWithProxyContext } from "../utils/proxyFetch.ts"; import { serializeRefresh, wasRefreshTokenRotated } from "./refreshSerializer.ts"; +import { + buildExternalIdpRefreshParams, + isExternalIdpAuthMethod, +} from "./kiroExternalIdp.ts"; import { WINDSURF_CONFIG } from "@/lib/oauth/constants/oauth"; import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oauth/gitlab"; @@ -1209,6 +1213,69 @@ export async function refreshKiroToken( const clientSecret = providerSpecificData?.clientSecret; const region = providerSpecificData?.region; + // Enterprise / Microsoft Entra "Your organization" (external_idp) logins refresh with a + // standard PUBLIC-client OAuth2 refresh_token grant against the org IdP's own tokenEndpoint + // (form-encoded client_id + refresh_token + scope, no client_secret) — NOT the AWS SSO OIDC + // or Kiro social endpoints. The rotated refresh_token is persisted by the caller. + if (isExternalIdpAuthMethod(authMethod)) { + let refreshRequest; + try { + refreshRequest = buildExternalIdpRefreshParams(refreshToken, providerSpecificData); + } catch (cfgErr) { + log?.error?.( + "TOKEN_REFRESH", + `Invalid Kiro external_idp refresh config: ${cfgErr instanceof Error ? cfgErr.message : String(cfgErr)}` + ); + return null; + } + + const response = await runWithProxyContext(proxyConfig, () => + fetch(refreshRequest.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: refreshRequest.body, + }) + ); + + if (!response.ok) { + const errorText = await response.text(); + let oauthErr: string | undefined; + try { + oauthErr = JSON.parse(errorText)?.error; + } catch { + /* not JSON */ + } + if (oauthErr === "invalid_grant" || oauthErr === "invalid_client") { + log?.error?.( + "TOKEN_REFRESH", + "Kiro external_idp refresh token expired/invalid. Re-authentication required.", + { oauthErr } + ); + return { error: "unrecoverable_refresh_error", code: oauthErr }; + } + log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro external_idp token", { + status: response.status, + error: errorText.slice(0, 200), + }); + return null; + } + + const tokens = await response.json(); + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro external_idp token", { + hasNewAccessToken: !!tokens.access_token, + hasNewRefreshToken: !!tokens.refresh_token, + expiresIn: tokens.expires_in, + }); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + expiresIn: tokens.expires_in || 3600, + }; + } + // AWS SSO OIDC (Builder ID or IDC) // If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified). // Exception: imported social tokens (authMethod === "imported") carry a freshly-registered diff --git a/open-sse/services/usage/kiro.ts b/open-sse/services/usage/kiro.ts index 9b85579103..b96b5041ca 100644 --- a/open-sse/services/usage/kiro.ts +++ b/open-sse/services/usage/kiro.ts @@ -13,6 +13,11 @@ import { toRecord, toNumber } from "./scalars.ts"; import { type UsageQuota, parseResetTime } from "./quota.ts"; +import { + isExternalIdpAuthMethod, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE, +} from "../kiroExternalIdp.ts"; type JsonRecord = Record; @@ -113,17 +118,24 @@ export function buildKiroUsageResult( export async function discoverKiroProfileArn( accessToken: string, usageBaseUrl: string, - region: string + region: string, + authMethod?: string ): Promise { try { + const isApiKey = authMethod === "api_key"; + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles", + Accept: "application/json", + }; + if (isApiKey) { + headers.tokentype = "API_KEY"; + } + const response = await fetch(usageBaseUrl, { method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/x-amz-json-1.0", - "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles", - Accept: "application/json", - }, + headers, body: JSON.stringify({ maxResults: 10 }), // Don't let a hung profile lookup block the usage/quota refresh indefinitely. signal: AbortSignal.timeout(10000), @@ -145,84 +157,237 @@ export async function discoverKiroProfileArn( } } +/** + * The three GetUsageLimits attempts (regional GET, CodeWhisperer POST, Q GET) tried in + * order by getKiroUsage — extracted so the auth-method header variants (api_key + * `tokentype`, external_idp `TokenType`) stay in one authHeaders object and the parent + * function stays under the function-length gate. + */ +function buildKiroUsageAttempts(opts: { + authHeaders: Record; + usageParams: URLSearchParams; + qParams: URLSearchParams; + payload: Record; + usageBaseUrl: string; + qBaseUrl: string; +}): Array<{ name: string; run: () => Promise }> { + const { authHeaders, usageParams, qParams, payload, usageBaseUrl, qBaseUrl } = opts; + return [ + { + name: "codewhisperer-get", + run: () => + fetch(`${CODEWHISPERER_BASE_URL}/getUsageLimits?${usageParams.toString()}`, { + method: "GET", + headers: { + ...authHeaders, + "x-amz-user-agent": "aws-sdk-js/1.0.0 KiroIDE", + "user-agent": "aws-sdk-js/1.0.0 KiroIDE", + }, + }), + }, + { + name: "codewhisperer-post", + run: () => + fetch(usageBaseUrl, { + method: "POST", + headers: { + ...authHeaders, + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits", + }, + body: JSON.stringify(payload), + }), + }, + { + name: "q-get", + run: () => + fetch(`${qBaseUrl}/getUsageLimits?${qParams.toString()}`, { + method: "GET", + headers: authHeaders, + }), + }, + ]; +} + +/** + * Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and + * endpoint must all match the region. Derive the region from the stored region (preferred) + * or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the + * legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions). + */ +function resolveKiroUsageEndpoints(providerSpecificData?: JsonRecord, profileArn?: string) { + const regionFromArn = profileArn + ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] + : undefined; + const region = + (typeof providerSpecificData?.region === "string" && + providerSpecificData.region.trim().toLowerCase()) || + regionFromArn || + "us-east-1"; + const usageBaseUrl = + region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`; + const qBaseUrl = `https://q.${region}.amazonaws.com`; + return { region, usageBaseUrl, qBaseUrl }; +} + +/** + * Base auth headers for the usage endpoints, per auth method: long-lived API keys add + * `tokentype: API_KEY`; enterprise / Microsoft Entra (external_idp) org accounts require + * `TokenType: EXTERNAL_IDP` for CodeWhisperer to bind the bearer to the profile (without + * it GetUsageLimits returns `ValidationException: Invalid ARN`). + */ +function buildKiroAuthHeaders( + accessToken: string | undefined, + isApiKey: boolean, + providerSpecificData?: JsonRecord +): Record { + const authHeaders: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }; + if (isApiKey) { + authHeaders.tokentype = "API_KEY"; + } + if (isExternalIdpAuthMethod(providerSpecificData?.authMethod)) { + authHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE; + } + return authHeaders; +} + +/** + * Runs the GetUsageLimits attempts in order until one succeeds. Collects per-attempt + * errors and whether any endpoint rejected the token (401/403) so getKiroUsage can + * pick the right user-facing message — extracted for the function-length gate. + */ +async function runKiroUsageAttempts( + attempts: Array<{ name: string; run: () => Promise }> +): Promise<{ + data?: JsonRecord; + sawAuthError: boolean; + errors: string[]; + lastHttpFailure?: string; +}> { + let sawAuthError = false; + let lastHttpFailure: string | undefined; + const errors: string[] = []; + for (const attempt of attempts) { + let response: Response; + try { + response = await attempt.run(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`${attempt.name}:${message}`); + continue; + } + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + if (response.status === 401 || response.status === 403) { + sawAuthError = true; + } + lastHttpFailure = `Kiro API error (${response.status}): ${errorText}`; + errors.push(`${attempt.name}:${response.status}${errorText ? `:${errorText}` : ""}`); + continue; + } + return { data: toRecord(await response.json()), sawAuthError, errors, lastHttpFailure }; + } + return { sawAuthError, errors, lastHttpFailure }; +} + /** * Kiro (AWS CodeWhisperer) Usage */ export async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRecord) { try { + const authMethod = + typeof providerSpecificData?.authMethod === "string" + ? providerSpecificData.authMethod + : undefined; + const isApiKey = authMethod === "api_key"; let profileArn = typeof providerSpecificData?.profileArn === "string" ? providerSpecificData.profileArn : undefined; - // Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and - // endpoint must all match the region. Derive the region from the stored region (preferred) - // or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the - // legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions). - const regionFromArn = profileArn - ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] - : undefined; - const region = - (typeof providerSpecificData?.region === "string" && - providerSpecificData.region.trim().toLowerCase()) || - regionFromArn || - "us-east-1"; - const usageBaseUrl = - region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`; + const { region, usageBaseUrl, qBaseUrl } = resolveKiroUsageEndpoints( + providerSpecificData, + profileArn + ); // IAM Identity Center logins and kiro-cli imports frequently don't persist a profileArn, which // previously caused the quota card to show nothing ("0 used"). Discover it on demand from // ListAvailableProfiles (region-matched) so usage still resolves for those accounts. if (!profileArn && accessToken) { - profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region); + profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region, authMethod); } - if (!profileArn) { + if (!profileArn && !isApiKey) { return { message: "Kiro connected. Profile ARN not available for quota tracking." }; } - // Kiro uses AWS CodeWhisperer GetUsageLimits API + const authHeaders = buildKiroAuthHeaders(accessToken, isApiKey, providerSpecificData); + + const usageParams = new URLSearchParams({ + isEmailRequired: "true", + origin: "AI_EDITOR", + resourceType: "AGENTIC_REQUEST", + }); + const qParams = new URLSearchParams({ + origin: "AI_EDITOR", + ...(profileArn ? { profileArn } : {}), + resourceType: "AGENTIC_REQUEST", + }); const payload = { origin: "AI_EDITOR", - profileArn: profileArn, + ...(profileArn ? { profileArn } : {}), resourceType: "AGENTIC_REQUEST", }; - const response = await fetch(usageBaseUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/x-amz-json-1.0", - "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits", - Accept: "application/json", - }, - body: JSON.stringify(payload), +const attempts = buildKiroUsageAttempts({ + authHeaders, + usageParams, + qParams, + payload, + usageBaseUrl, + qBaseUrl, }); - if (!response.ok) { + const outcome = await runKiroUsageAttempts(attempts); + if (outcome.data) { + return buildKiroUsageResult(outcome.data); + } + const { sawAuthError, errors } = outcome; + + if (sawAuthError) { // Social-auth Kiro accounts (added via /api/oauth/kiro/social-exchange with provider // Google or GitHub) use a different token format that AWS CodeWhisperer's GetUsageLimits // routinely rejects with 401/403, even when /messages still works. Surface a clear // "auth expired, chat may still work" message instead of a generic upstream-error blob // so the quota card matches what users with legacy social-auth accounts already see. // Inspired by https://github.com/decolua/9router/pull/620. - if ( - (response.status === 401 || response.status === 403) && - isSocialAuthKiroAccount(providerSpecificData) - ) { + if (isSocialAuthKiroAccount(providerSpecificData)) { return { message: "Kiro quota API authentication expired. Chat may still work.", quotas: {}, }; } - const errorText = await response.text(); - throw new Error(`Kiro API error (${response.status}): ${errorText}`); + return { + message: "Kiro quota API rejected the current token. Chat may still work.", + quotas: {}, + }; } - const data = toRecord(await response.json()); - return buildKiroUsageResult(data); + // Hard (non-auth) failure keeps the pre-#6587 reject semantics — callers and + // tests/unit/usage-service-hardening.test.ts rely on the rejection; prefer the last + // HTTP-status failure (most informative) over a network-level error. + throw new Error( + outcome.lastHttpFailure || + (errors.length > 0 + ? errors[errors.length - 1] + : "no usage endpoint responded") + ); } catch (error) { - throw new Error(`Failed to fetch Kiro usage: ${error.message}`); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to fetch Kiro usage: ${message}`); } } diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 8508fe54d0..d5d6d7f825 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -579,6 +579,26 @@ function convertMessages(messages, tools, model) { /** Kiro's accepted reasoning-effort levels (`output_config.effort`). */ const KIRO_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]; +function resolveKiroModelAlias(model: string): { upstream: string; thinking: boolean } { + let upstream = String(model || ""); + let thinking = false; + + if (upstream.endsWith("-agentic")) { + upstream = upstream.slice(0, -"-agentic".length); + } + if (upstream.endsWith("-thinking")) { + upstream = upstream.slice(0, -"-thinking".length); + thinking = true; + } + if (upstream === "auto-kiro") { + upstream = "auto"; + } + + upstream = upstream.replace(/^(claude-(?:opus|sonnet|haiku|3-\d+)-\d+)-(\d{1,2})$/, "$1.$2"); + + return { upstream, thinking }; +} + /** * Resolve the Kiro effort level for a request, or "" when no reasoning was asked * for. Effort sources, in priority order: @@ -665,10 +685,11 @@ export function buildKiroPayload(model, body, stream, credentials) { // The minor group is bounded to 1-2 digits so date-suffixed ids (e.g. // claude-opus-4-20250514) are never mistaken for a dash-separated minor // version and corrupted into claude-opus-4.20250514 (upstream 9router #2270). - const normalizedModel = model.replace( - /^(claude-(?:opus|sonnet|haiku|3-\d+)-\d+)-(\d{1,2})$/, - "$1.$2" - ); + // Synthetic Kiro selector variants (`-thinking`, `-agentic`) are local aliases: + // strip them before the request leaves OmniRoute so Kiro only receives real + // upstream model IDs. We intentionally do not inject an agentic system prompt here. + const { upstream: normalizedModel, thinking: modelRequestsThinking } = + resolveKiroModelAlias(model); const messages = body.messages || []; let tools = body.tools || []; const maxTokens = body.max_tokens ?? body.max_completion_tokens ?? 32000; @@ -844,7 +865,8 @@ export function buildKiroPayload(model, body, stream, credentials) { // thinking:{type:"adaptive"} + a clamped max_tokens), forwarded to AWS by // the Kiro executor's transformRequest allowlist — this is the graded // effort lever. Gated on models that advertise thinking support. - const kiroEffort = supportsReasoning(normalizedModel) ? resolveKiroEffort(body) : ""; + const requestedEffort = resolveKiroEffort(body) || (modelRequestsThinking ? "high" : ""); + const kiroEffort = supportsReasoning(normalizedModel) ? requestedEffort : ""; if (kiroEffort) { // `` / `` are Kiro/CodeWhisperer prompt // conventions (NOT Anthropic API params); the length is a soft hint (the hard diff --git a/public/providers/360ai.svg b/public/providers/360ai.svg index caeb6eb2c9..dc393ef67b 100644 --- a/public/providers/360ai.svg +++ b/public/providers/360ai.svg @@ -1,5 +1 @@ - - - - 360 - +AI360 \ No newline at end of file diff --git a/public/providers/alibaba.svg b/public/providers/alibaba.svg new file mode 100644 index 0000000000..f6b764f99d --- /dev/null +++ b/public/providers/alibaba.svg @@ -0,0 +1,6 @@ + + Alibaba + + \ No newline at end of file diff --git a/public/providers/anthropic.svg b/public/providers/anthropic.svg new file mode 100644 index 0000000000..f31bfac298 --- /dev/null +++ b/public/providers/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/public/providers/arcee-ai.svg b/public/providers/arcee-ai.svg index b63a13ab3f..dc809fc95e 100644 --- a/public/providers/arcee-ai.svg +++ b/public/providers/arcee-ai.svg @@ -1,5 +1 @@ - - - - AR - +Arcee \ No newline at end of file diff --git a/public/providers/arcee.svg b/public/providers/arcee.svg new file mode 100644 index 0000000000..df3fd30356 --- /dev/null +++ b/public/providers/arcee.svg @@ -0,0 +1,5 @@ + + Arcee + + \ No newline at end of file diff --git a/public/providers/assemblyai.svg b/public/providers/assemblyai.svg new file mode 100644 index 0000000000..c6d4cffee7 --- /dev/null +++ b/public/providers/assemblyai.svg @@ -0,0 +1,7 @@ + + AssemblyAI + + + \ No newline at end of file diff --git a/public/providers/aws.svg b/public/providers/aws.svg new file mode 100644 index 0000000000..0e1a90a7b0 --- /dev/null +++ b/public/providers/aws.svg @@ -0,0 +1,6 @@ + + AWS + + + \ No newline at end of file diff --git a/public/providers/azure.svg b/public/providers/azure.svg new file mode 100644 index 0000000000..038645c7e0 --- /dev/null +++ b/public/providers/azure.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/azureai.svg b/public/providers/azureai.svg new file mode 100644 index 0000000000..b9ae255923 --- /dev/null +++ b/public/providers/azureai.svg @@ -0,0 +1,82 @@ + + AzureAI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/baichuan.svg b/public/providers/baichuan.svg index fa3789f62e..11b27e7913 100644 --- a/public/providers/baichuan.svg +++ b/public/providers/baichuan.svg @@ -1,5 +1 @@ - - - - BC - +Baichuan \ No newline at end of file diff --git a/public/providers/baidu.svg b/public/providers/baidu.svg index 0c74817895..ead7f89822 100644 --- a/public/providers/baidu.svg +++ b/public/providers/baidu.svg @@ -1,5 +1 @@ - - - - BD - +Baidu \ No newline at end of file diff --git a/public/providers/bailian.svg b/public/providers/bailian.svg new file mode 100644 index 0000000000..7618aaad1c --- /dev/null +++ b/public/providers/bailian.svg @@ -0,0 +1,16 @@ + + Bailian (阿里云百炼) + + + + + + + + \ No newline at end of file diff --git a/public/providers/baseten.svg b/public/providers/baseten.svg new file mode 100644 index 0000000000..c6a5fb32c1 --- /dev/null +++ b/public/providers/baseten.svg @@ -0,0 +1,4 @@ + + Baseten + + \ No newline at end of file diff --git a/public/providers/cerebras.svg b/public/providers/cerebras.svg new file mode 100644 index 0000000000..88b7cb5632 --- /dev/null +++ b/public/providers/cerebras.svg @@ -0,0 +1,8 @@ + + Cerebras + + + \ No newline at end of file diff --git a/public/providers/claude-web.svg b/public/providers/claude-web.svg index 882c6d2245..62dc0db12d 100644 --- a/public/providers/claude-web.svg +++ b/public/providers/claude-web.svg @@ -1,5 +1 @@ - - - - CW - +Claude \ No newline at end of file diff --git a/public/providers/claude.svg b/public/providers/claude.svg index 8bc1fd43ba..62dc0db12d 100644 --- a/public/providers/claude.svg +++ b/public/providers/claude.svg @@ -1 +1 @@ - \ No newline at end of file +Claude \ No newline at end of file diff --git a/public/providers/cline.svg b/public/providers/cline.svg new file mode 100644 index 0000000000..8dcc05786c --- /dev/null +++ b/public/providers/cline.svg @@ -0,0 +1 @@ +Cline \ No newline at end of file diff --git a/public/providers/cloudflare.svg b/public/providers/cloudflare.svg new file mode 100644 index 0000000000..ac59cd621a --- /dev/null +++ b/public/providers/cloudflare.svg @@ -0,0 +1,7 @@ + + Cloudflare + + + \ No newline at end of file diff --git a/public/providers/cohere.svg b/public/providers/cohere.svg new file mode 100644 index 0000000000..62aec42312 --- /dev/null +++ b/public/providers/cohere.svg @@ -0,0 +1,13 @@ + + Cohere + + + + \ No newline at end of file diff --git a/public/providers/comfyui.svg b/public/providers/comfyui.svg new file mode 100644 index 0000000000..85e93cd06b --- /dev/null +++ b/public/providers/comfyui.svg @@ -0,0 +1,5 @@ + + ComfyUI + + \ No newline at end of file diff --git a/public/providers/continue.png b/public/providers/continue.png deleted file mode 100644 index f54685be8d..0000000000 Binary files a/public/providers/continue.png and /dev/null differ diff --git a/public/providers/continue.svg b/public/providers/continue.svg new file mode 100644 index 0000000000..e9f56c259e --- /dev/null +++ b/public/providers/continue.svg @@ -0,0 +1 @@ + diff --git a/public/providers/copilot.png b/public/providers/copilot.png deleted file mode 100644 index 9907963e40..0000000000 Binary files a/public/providers/copilot.png and /dev/null differ diff --git a/public/providers/copilot.svg b/public/providers/copilot.svg new file mode 100644 index 0000000000..5426933b61 --- /dev/null +++ b/public/providers/copilot.svg @@ -0,0 +1 @@ +Copilot \ No newline at end of file diff --git a/public/providers/cursor.png b/public/providers/cursor.png deleted file mode 100644 index ec02b070ba..0000000000 Binary files a/public/providers/cursor.png and /dev/null differ diff --git a/public/providers/cursor.svg b/public/providers/cursor.svg new file mode 100644 index 0000000000..10d50ca847 --- /dev/null +++ b/public/providers/cursor.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/public/providers/deepgram.png b/public/providers/deepgram.png deleted file mode 100644 index 6bee16b860..0000000000 --- a/public/providers/deepgram.png +++ /dev/null @@ -1,52 +0,0 @@ - - - - 404 - - - -
-

404

-

Not Found

-
- - - \ No newline at end of file diff --git a/public/providers/deepgram.svg b/public/providers/deepgram.svg new file mode 100644 index 0000000000..988a37743f --- /dev/null +++ b/public/providers/deepgram.svg @@ -0,0 +1 @@ +Deepgram \ No newline at end of file diff --git a/public/providers/deepinfra.svg b/public/providers/deepinfra.svg new file mode 100644 index 0000000000..458107109c --- /dev/null +++ b/public/providers/deepinfra.svg @@ -0,0 +1,7 @@ + + DeepInfra + + + \ No newline at end of file diff --git a/public/providers/deepseek.svg b/public/providers/deepseek.svg new file mode 100644 index 0000000000..abf56cfecb --- /dev/null +++ b/public/providers/deepseek.svg @@ -0,0 +1,5 @@ + + DeepSeek + + \ No newline at end of file diff --git a/public/providers/dify.svg b/public/providers/dify.svg index 80be2e3447..4cea903478 100644 --- a/public/providers/dify.svg +++ b/public/providers/dify.svg @@ -1,5 +1 @@ - - - - DF - +Dify \ No newline at end of file diff --git a/public/providers/doubao.svg b/public/providers/doubao.svg index 4317871d38..e2511454cf 100644 --- a/public/providers/doubao.svg +++ b/public/providers/doubao.svg @@ -1,5 +1 @@ - - - - DB - +Doubao \ No newline at end of file diff --git a/public/providers/elevenlabs.svg b/public/providers/elevenlabs.svg new file mode 100644 index 0000000000..d44617dbde --- /dev/null +++ b/public/providers/elevenlabs.svg @@ -0,0 +1 @@ +ElevenLabs \ No newline at end of file diff --git a/public/providers/exa.svg b/public/providers/exa.svg new file mode 100644 index 0000000000..bc9f73e106 --- /dev/null +++ b/public/providers/exa.svg @@ -0,0 +1,7 @@ + + Exa + + \ No newline at end of file diff --git a/public/providers/fal.svg b/public/providers/fal.svg new file mode 100644 index 0000000000..0e54aaf865 --- /dev/null +++ b/public/providers/fal.svg @@ -0,0 +1,7 @@ + + Fal + + \ No newline at end of file diff --git a/public/providers/fireworks.svg b/public/providers/fireworks.svg new file mode 100644 index 0000000000..946ee079af --- /dev/null +++ b/public/providers/fireworks.svg @@ -0,0 +1,7 @@ + + Fireworks + + \ No newline at end of file diff --git a/public/providers/friendli.svg b/public/providers/friendli.svg new file mode 100644 index 0000000000..7da5293002 --- /dev/null +++ b/public/providers/friendli.svg @@ -0,0 +1,6 @@ + + Friendli + + + + \ No newline at end of file diff --git a/public/providers/gemini.svg b/public/providers/gemini.svg new file mode 100644 index 0000000000..f8d7189dc6 --- /dev/null +++ b/public/providers/gemini.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/google.svg b/public/providers/google.svg new file mode 100644 index 0000000000..2f69df7189 --- /dev/null +++ b/public/providers/google.svg @@ -0,0 +1,11 @@ + + Google + + + + + \ No newline at end of file diff --git a/public/providers/grok.svg b/public/providers/grok.svg new file mode 100644 index 0000000000..7057a3bfa6 --- /dev/null +++ b/public/providers/grok.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/providers/groq.svg b/public/providers/groq.svg new file mode 100644 index 0000000000..cf90d287b1 --- /dev/null +++ b/public/providers/groq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/heroku.png b/public/providers/heroku.png deleted file mode 100644 index b96cb3f6a7..0000000000 Binary files a/public/providers/heroku.png and /dev/null differ diff --git a/public/providers/heroku.svg b/public/providers/heroku.svg new file mode 100644 index 0000000000..6089d6f2c2 --- /dev/null +++ b/public/providers/heroku.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/providers/huggingchat.svg b/public/providers/huggingchat.svg index 30a8cef613..dc1cf3ffb7 100644 --- a/public/providers/huggingchat.svg +++ b/public/providers/huggingchat.svg @@ -1,5 +1 @@ - - - - HC - +HuggingFace \ No newline at end of file diff --git a/public/providers/huggingface.svg b/public/providers/huggingface.svg new file mode 100644 index 0000000000..c141ab811a --- /dev/null +++ b/public/providers/huggingface.svg @@ -0,0 +1,15 @@ + + HuggingFace + + + + + + + \ No newline at end of file diff --git a/public/providers/hyperbolic.svg b/public/providers/hyperbolic.svg new file mode 100644 index 0000000000..591eb157ba --- /dev/null +++ b/public/providers/hyperbolic.svg @@ -0,0 +1,5 @@ + + Hyperbolic + + \ No newline at end of file diff --git a/public/providers/ibm.svg b/public/providers/ibm.svg new file mode 100644 index 0000000000..36fe5889dd --- /dev/null +++ b/public/providers/ibm.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/providers/iflytek.svg b/public/providers/iflytek.svg index bad262efe4..4962d9e161 100644 --- a/public/providers/iflytek.svg +++ b/public/providers/iflytek.svg @@ -1,5 +1 @@ - - - - IF - +iFlyTekCloud \ No newline at end of file diff --git a/public/providers/inference.svg b/public/providers/inference.svg new file mode 100644 index 0000000000..b53e8e4616 --- /dev/null +++ b/public/providers/inference.svg @@ -0,0 +1,4 @@ + + Inference + + \ No newline at end of file diff --git a/public/providers/kimi.svg b/public/providers/kimi.svg new file mode 100644 index 0000000000..ec12852549 --- /dev/null +++ b/public/providers/kimi.svg @@ -0,0 +1,7 @@ + + Kimi + + + \ No newline at end of file diff --git a/public/providers/kiro.svg b/public/providers/kiro.svg index 83e2845fbf..0c651b9747 100644 --- a/public/providers/kiro.svg +++ b/public/providers/kiro.svg @@ -1 +1 @@ - \ No newline at end of file +Kiro \ No newline at end of file diff --git a/public/providers/lambda.svg b/public/providers/lambda.svg new file mode 100644 index 0000000000..be9061029b --- /dev/null +++ b/public/providers/lambda.svg @@ -0,0 +1,4 @@ + + Lambda + + \ No newline at end of file diff --git a/public/providers/longcat.svg b/public/providers/longcat.svg new file mode 100644 index 0000000000..6c05c8ecc2 --- /dev/null +++ b/public/providers/longcat.svg @@ -0,0 +1,8 @@ + + LongCat + + + \ No newline at end of file diff --git a/public/providers/meta.svg b/public/providers/meta.svg new file mode 100644 index 0000000000..45c34b10e5 --- /dev/null +++ b/public/providers/meta.svg @@ -0,0 +1,121 @@ + + Meta + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/metaai.svg b/public/providers/metaai.svg new file mode 100644 index 0000000000..4d623976fd --- /dev/null +++ b/public/providers/metaai.svg @@ -0,0 +1,49 @@ + + MetaAI + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/minimax.svg b/public/providers/minimax.svg new file mode 100644 index 0000000000..b1b0a6a2df --- /dev/null +++ b/public/providers/minimax.svg @@ -0,0 +1 @@ +MiniMax \ No newline at end of file diff --git a/public/providers/mistral.svg b/public/providers/mistral.svg new file mode 100644 index 0000000000..605b629a38 --- /dev/null +++ b/public/providers/mistral.svg @@ -0,0 +1,10 @@ + + Mistral + + + + + + \ No newline at end of file diff --git a/public/providers/moonshot.svg b/public/providers/moonshot.svg new file mode 100644 index 0000000000..b36d4e7e0f --- /dev/null +++ b/public/providers/moonshot.svg @@ -0,0 +1,4 @@ + + Moonshot (月之暗面) + + \ No newline at end of file diff --git a/public/providers/morph.svg b/public/providers/morph.svg new file mode 100644 index 0000000000..474c521785 --- /dev/null +++ b/public/providers/morph.svg @@ -0,0 +1,5 @@ + + Morph + + \ No newline at end of file diff --git a/public/providers/nebius.svg b/public/providers/nebius.svg new file mode 100644 index 0000000000..719baf1544 --- /dev/null +++ b/public/providers/nebius.svg @@ -0,0 +1,5 @@ + + Nebius + + + \ No newline at end of file diff --git a/public/providers/novita.svg b/public/providers/novita.svg new file mode 100644 index 0000000000..7c53907792 --- /dev/null +++ b/public/providers/novita.svg @@ -0,0 +1,7 @@ + + Novita + + \ No newline at end of file diff --git a/public/providers/nvidia.svg b/public/providers/nvidia.svg new file mode 100644 index 0000000000..48c7d75d26 --- /dev/null +++ b/public/providers/nvidia.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/ollama.svg b/public/providers/ollama.svg new file mode 100644 index 0000000000..96e9e86624 --- /dev/null +++ b/public/providers/ollama.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/providers/openai.svg b/public/providers/openai.svg new file mode 100644 index 0000000000..b6d542d099 --- /dev/null +++ b/public/providers/openai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/openclaw.png b/public/providers/openclaw.png deleted file mode 100644 index 7ef77ac754..0000000000 Binary files a/public/providers/openclaw.png and /dev/null differ diff --git a/public/providers/openclaw.svg b/public/providers/openclaw.svg new file mode 100644 index 0000000000..bf6acb9f51 --- /dev/null +++ b/public/providers/openclaw.svg @@ -0,0 +1 @@ +OpenClaw \ No newline at end of file diff --git a/public/providers/openrouter.svg b/public/providers/openrouter.svg new file mode 100644 index 0000000000..61033bfc67 --- /dev/null +++ b/public/providers/openrouter.svg @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/public/providers/ovhcloud.png b/public/providers/ovhcloud.png deleted file mode 100644 index a88d1b448d..0000000000 Binary files a/public/providers/ovhcloud.png and /dev/null differ diff --git a/public/providers/ovhcloud.svg b/public/providers/ovhcloud.svg new file mode 100644 index 0000000000..71ec8e5b13 --- /dev/null +++ b/public/providers/ovhcloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/perplexity.svg b/public/providers/perplexity.svg new file mode 100644 index 0000000000..b6e121bbc3 --- /dev/null +++ b/public/providers/perplexity.svg @@ -0,0 +1,6 @@ + + Perplexity + + \ No newline at end of file diff --git a/public/providers/picoclaw.svg b/public/providers/picoclaw.svg new file mode 100644 index 0000000000..c05d253215 --- /dev/null +++ b/public/providers/picoclaw.svg @@ -0,0 +1,30 @@ + + PicoClaw + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/providers/poe.svg b/public/providers/poe.svg new file mode 100644 index 0000000000..cbd9ec4c7d --- /dev/null +++ b/public/providers/poe.svg @@ -0,0 +1 @@ +Poe \ No newline at end of file diff --git a/public/providers/pollinations.svg b/public/providers/pollinations.svg new file mode 100644 index 0000000000..83b569419b --- /dev/null +++ b/public/providers/pollinations.svg @@ -0,0 +1,4 @@ + + Pollinations + + \ No newline at end of file diff --git a/public/providers/qwen.svg b/public/providers/qwen.svg new file mode 100644 index 0000000000..3a2f756e66 --- /dev/null +++ b/public/providers/qwen.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/public/providers/recraft.svg b/public/providers/recraft.svg new file mode 100644 index 0000000000..e5bb701575 --- /dev/null +++ b/public/providers/recraft.svg @@ -0,0 +1,5 @@ + + Recraft + + + \ No newline at end of file diff --git a/public/providers/replicate.svg b/public/providers/replicate.svg new file mode 100644 index 0000000000..4637f35378 --- /dev/null +++ b/public/providers/replicate.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/providers/roocode.svg b/public/providers/roocode.svg new file mode 100644 index 0000000000..3ce8ece8d3 --- /dev/null +++ b/public/providers/roocode.svg @@ -0,0 +1,4 @@ + + RooCode + + \ No newline at end of file diff --git a/public/providers/runway.svg b/public/providers/runway.svg new file mode 100644 index 0000000000..5dc14b1fdf --- /dev/null +++ b/public/providers/runway.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/providers/sambanova.svg b/public/providers/sambanova.svg new file mode 100644 index 0000000000..38afe1b9ee --- /dev/null +++ b/public/providers/sambanova.svg @@ -0,0 +1,9 @@ + + SambaNova + + + + \ No newline at end of file diff --git a/public/providers/searchapi.svg b/public/providers/searchapi.svg new file mode 100644 index 0000000000..fe1da13449 --- /dev/null +++ b/public/providers/searchapi.svg @@ -0,0 +1,4 @@ + + SearchApi + + \ No newline at end of file diff --git a/public/providers/sensenova.svg b/public/providers/sensenova.svg index 9bd7ebda11..17f1d82c53 100644 --- a/public/providers/sensenova.svg +++ b/public/providers/sensenova.svg @@ -1,5 +1 @@ - - - - SN - +SenseNova \ No newline at end of file diff --git a/public/providers/snowflake.svg b/public/providers/snowflake.svg new file mode 100644 index 0000000000..f62ab5f446 --- /dev/null +++ b/public/providers/snowflake.svg @@ -0,0 +1,7 @@ + + Snowflake + + \ No newline at end of file diff --git a/public/providers/stepfun.svg b/public/providers/stepfun.svg index 2a7196ea47..920e8a607a 100644 --- a/public/providers/stepfun.svg +++ b/public/providers/stepfun.svg @@ -1,5 +1 @@ - - - - SF - +Stepfun \ No newline at end of file diff --git a/public/providers/suno.svg b/public/providers/suno.svg new file mode 100644 index 0000000000..e2ddb78425 --- /dev/null +++ b/public/providers/suno.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/tavily.svg b/public/providers/tavily.svg new file mode 100644 index 0000000000..c2475ed2be --- /dev/null +++ b/public/providers/tavily.svg @@ -0,0 +1,15 @@ + + Tavily + + + + + + + \ No newline at end of file diff --git a/public/providers/tencent.svg b/public/providers/tencent.svg index d1dff98da9..98da272a26 100644 --- a/public/providers/tencent.svg +++ b/public/providers/tencent.svg @@ -1,5 +1 @@ - - - - TC - +Tencent \ No newline at end of file diff --git a/public/providers/topazlabs.svg b/public/providers/topazlabs.svg new file mode 100644 index 0000000000..c141a9a36b --- /dev/null +++ b/public/providers/topazlabs.svg @@ -0,0 +1,4 @@ + + TopazLabs + + \ No newline at end of file diff --git a/public/providers/trae.svg b/public/providers/trae.svg new file mode 100644 index 0000000000..3d048c0c25 --- /dev/null +++ b/public/providers/trae.svg @@ -0,0 +1,5 @@ + + TRAE + + \ No newline at end of file diff --git a/public/providers/udio.svg b/public/providers/udio.svg new file mode 100644 index 0000000000..7bb9b62c3b --- /dev/null +++ b/public/providers/udio.svg @@ -0,0 +1,5 @@ + + Udio + + \ No newline at end of file diff --git a/public/providers/upstage.svg b/public/providers/upstage.svg new file mode 100644 index 0000000000..d9db3f1838 --- /dev/null +++ b/public/providers/upstage.svg @@ -0,0 +1,14 @@ + + Upstage + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/v0.svg b/public/providers/v0.svg new file mode 100644 index 0000000000..32fcc405fd --- /dev/null +++ b/public/providers/v0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/vercel.svg b/public/providers/vercel.svg new file mode 100644 index 0000000000..75968916c4 --- /dev/null +++ b/public/providers/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/vllm.svg b/public/providers/vllm.svg new file mode 100644 index 0000000000..3adec97b6b --- /dev/null +++ b/public/providers/vllm.svg @@ -0,0 +1,5 @@ + + vLLM + + + \ No newline at end of file diff --git a/public/providers/volcengine.svg b/public/providers/volcengine.svg new file mode 100644 index 0000000000..1423adbc65 --- /dev/null +++ b/public/providers/volcengine.svg @@ -0,0 +1,11 @@ + + Volcengine (火山引擎) + + + + + \ No newline at end of file diff --git a/public/providers/voyage.svg b/public/providers/voyage.svg new file mode 100644 index 0000000000..76861e9f57 --- /dev/null +++ b/public/providers/voyage.svg @@ -0,0 +1,5 @@ + + Voyage + + \ No newline at end of file diff --git a/public/providers/windsurf.svg b/public/providers/windsurf.svg new file mode 100644 index 0000000000..8f4b214454 --- /dev/null +++ b/public/providers/windsurf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/xai.svg b/public/providers/xai.svg new file mode 100644 index 0000000000..6f6057d454 --- /dev/null +++ b/public/providers/xai.svg @@ -0,0 +1,4 @@ + + xAI + + \ No newline at end of file diff --git a/public/providers/xinference.svg b/public/providers/xinference.svg new file mode 100644 index 0000000000..d97033d417 --- /dev/null +++ b/public/providers/xinference.svg @@ -0,0 +1,53 @@ + + Xinference + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/yi.svg b/public/providers/yi.svg index 8ddc60de6e..8d0c6470b4 100644 --- a/public/providers/yi.svg +++ b/public/providers/yi.svg @@ -1,5 +1 @@ - - - - YI - +Yi \ No newline at end of file diff --git a/public/providers/zhipu.svg b/public/providers/zhipu.svg new file mode 100644 index 0000000000..a92eaff077 --- /dev/null +++ b/public/providers/zhipu.svg @@ -0,0 +1,6 @@ + + Zhipu (智谱) + + \ No newline at end of file diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index c5e09dbc1d..67e0c1e43e 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -25,6 +25,8 @@ import { SELF_ACCOUNT_QUOTA_SCOPE, SELF_USAGE_SCOPE } from "@/shared/constants/s import { extractApiErrorMessage } from "@/shared/http/apiErrorMessage"; import { hasProviderQuotaBypassScope } from "@/shared/constants/apiKeyPolicyScopes"; import { UsageLimitSettings } from "./components/UsageLimitSettings"; +import { ChaosModeAccessToggle } from "./components/ChaosModeAccessToggle"; +import { BypassProviderQuotaToggle } from "./components/BypassProviderQuotaToggle"; // Constants for validation const MAX_KEY_NAME_LENGTH = 200; @@ -125,6 +127,7 @@ interface ApiKey { streamDefaultMode?: StreamDefaultMode; disableNonPublicModels?: boolean; allowUsageCommand?: boolean; + chaosModeEnabled?: boolean; usageLimitEnabled?: boolean; dailyUsageLimitUsd?: number | null; weeklyUsageLimitUsd?: number | null; @@ -518,7 +521,8 @@ export default function ApiManagerPageClient() { const res = await fetch(`/api/keys/${encodeURIComponent(key.id)}/devices`); if (!res.ok) return [key.id, 0] as const; const data = await res.json(); - const count = typeof data?.count === "number" && Number.isFinite(data.count) ? data.count : 0; + const count = + typeof data?.count === "number" && Number.isFinite(data.count) ? data.count : 0; return [key.id, count] as const; } catch { return [key.id, 0] as const; @@ -791,7 +795,8 @@ export default function ApiManagerPageClient() { usageLimitEnabled: boolean, dailyUsageLimitUsd: number | null, weeklyUsageLimitUsd: number | null, - blockedModels: string[] + blockedModels: string[], + chaosModeEnabled: boolean ) => { if (!editingKey || !editingKey.id) return; @@ -862,6 +867,7 @@ export default function ApiManagerPageClient() { usageLimitEnabled, dailyUsageLimitUsd, weeklyUsageLimitUsd, + chaosModeEnabled, }), }); @@ -1645,7 +1651,8 @@ const PermissionsModal = memo(function PermissionsModal({ usageLimitEnabled: boolean, dailyUsageLimitUsd: number | null, weeklyUsageLimitUsd: number | null, - blockedModels: string[] + blockedModels: string[], + chaosModeEnabled: boolean ) => void; }) { const t = useTranslations("apiManager"); @@ -1731,6 +1738,7 @@ const PermissionsModal = memo(function PermissionsModal({ const [usageCommandEnabled, setUsageCommandEnabled] = useState( apiKey?.allowUsageCommand === true ); + const [chaosModeEnabled, setChaosModeEnabled] = useState(apiKey?.chaosModeEnabled === true); const [usageLimitEnabled, setUsageLimitEnabled] = useState(apiKey?.usageLimitEnabled === true); const [dailyUsageLimitUsd, setDailyUsageLimitUsd] = useState( typeof apiKey?.dailyUsageLimitUsd === "number" && apiKey.dailyUsageLimitUsd > 0 @@ -1935,7 +1943,8 @@ const PermissionsModal = memo(function PermissionsModal({ usageLimitEnabled, parseUsdLimitInput(dailyUsageLimitUsd), parseUsdLimitInput(weeklyUsageLimitUsd), - blockedModels + blockedModels, + chaosModeEnabled ); }, [ onSave, @@ -1974,6 +1983,7 @@ const PermissionsModal = memo(function PermissionsModal({ parseUsdLimitInput, blockedClaudeCodeFamilies, initialBlockedModels, + chaosModeEnabled, apiKey?.scopes, t, ]); @@ -2563,30 +2573,17 @@ const PermissionsModal = memo(function PermissionsModal({ /> + {/* Chaos Mode Access Toggle */} + setChaosModeEnabled((prev) => !prev)} + /> + {/* Advanced Provider Quota Policy Override */} -
-
-

Bypass provider quota cutoffs

-

- Allows this key to ignore upstream provider/account cutoff policy during routing. API - key USD quotas still apply. -

-
- -
+ setBypassProviderQuotaPolicyEnabled((prev) => !prev)} + /> {/* Disable Non-Public Models Toggle */}
diff --git a/src/app/(dashboard)/dashboard/api-manager/components/BypassProviderQuotaToggle.tsx b/src/app/(dashboard)/dashboard/api-manager/components/BypassProviderQuotaToggle.tsx new file mode 100644 index 0000000000..76ccbd25d4 --- /dev/null +++ b/src/app/(dashboard)/dashboard/api-manager/components/BypassProviderQuotaToggle.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +/** + * "Bypass provider quota cutoffs" toggle for the API Key permissions modal. + * Extracted out of ApiManagerPageClient.tsx (frozen god-file — see + * config/quality/file-size-baseline.json) following the same pattern as + * UsageLimitSettings.tsx — pure UI move, no behavior change. + */ +export function BypassProviderQuotaToggle({ + enabled, + onToggle, +}: { + enabled: boolean; + onToggle: () => void; +}) { + const tc = useTranslations("common"); + + return ( +
+
+

Bypass provider quota cutoffs

+

+ Allows this key to ignore upstream provider/account cutoff policy during routing. API key + USD quotas still apply. +

+
+ +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/api-manager/components/ChaosModeAccessToggle.tsx b/src/app/(dashboard)/dashboard/api-manager/components/ChaosModeAccessToggle.tsx new file mode 100644 index 0000000000..30cf2d228e --- /dev/null +++ b/src/app/(dashboard)/dashboard/api-manager/components/ChaosModeAccessToggle.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +/** + * Chaos Mode access toggle for the API Key permissions modal — gates a single + * API key's ability to call the Chaos Mode dispatch endpoints + * (`POST /api/chaos/run`, `POST /api/skills/collect/chaos`) via the + * `chaosModeEnabled` permission. Extracted out of ApiManagerPageClient.tsx + * (frozen god-file — see config/quality/file-size-baseline.json) following the + * same pattern as UsageLimitSettings.tsx. + */ +export function ChaosModeAccessToggle({ + enabled, + onToggle, +}: { + enabled: boolean; + onToggle: () => void; +}) { + const tChaos = useTranslations("chaosConfig"); + const tc = useTranslations("common"); + + return ( +
+
+

{tChaos("keyPermission")}

+

{tChaos("keyPermissionDesc")}

+
+ +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/chaos/ChaosConfigPageClient.tsx b/src/app/(dashboard)/dashboard/chaos/ChaosConfigPageClient.tsx new file mode 100644 index 0000000000..ef74806adc --- /dev/null +++ b/src/app/(dashboard)/dashboard/chaos/ChaosConfigPageClient.tsx @@ -0,0 +1,107 @@ +/** + * /dashboard/chaos — Chaos Mode Configuration Page + * + * Allows users to: + * - Enable/disable chaos mode globally + * - Set default mode (parallel/collaborative) + * - Override provider models for chaos mode + * - Set custom system prompt and max tokens + * - Configure timeout + * - Test chaos mode with a simple task + * + * State + handlers live in useChaosConfigPage.ts and the JSX sections are + * split into ./components/* — this file stays a thin composition/render + * function under the complexity/size ratchet + * (config/quality/complexity-baseline.json). + */ +"use client"; + +import { useChaosConfigPage } from "./useChaosConfigPage"; +import { ChaosModeSelector } from "./components/ChaosModeSelector"; +import { ChaosTestResultsPanel } from "./components/ChaosTestResultsPanel"; +import { ChaosProviderOverridesPanel } from "./components/ChaosProviderOverridesPanel"; +import { ChaosBasicSettingsFields } from "./components/ChaosBasicSettingsFields"; +import { ChaosConfigActionsBar } from "./components/ChaosConfigActionsBar"; +import { ChaosStatusMessage } from "./components/ChaosStatusMessage"; + +export default function ChaosConfigPage() { + const { + t, + config, + setConfig, + availableProviders, + loading, + saving, + testing, + testResult, + message, + saveConfig, + resetConfig, + testChaos, + addOverride, + updateOverride, + removeOverride, + } = useChaosConfigPage(); + + if (loading) { + return ( +
+
{t("loadingProviderModels")}
+
+ ); + } + + return ( +
+ {/* Header */} +
+

{t("pageTitle")}

+

{t("pageSubtitle")}

+
+ + {/* Status Message */} + + + {/* Enable toggle + timeout + max tokens + system prompt */} + setConfig((prev) => ({ ...prev, ...patch }))} + /> + + {/* Default Mode Selector */} + setConfig((prev) => ({ ...prev, defaultMode }))} + label={t("mode")} + parallelLabel={t("modeParallel")} + parallelDesc={t("modeParallelDesc")} + collaborativeLabel={t("modeCollaborative")} + collaborativeDesc={t("modeCollaborativeDesc")} + /> + + + + {/* Test Results */} + {testResult && } + + {/* Provider Overrides */} + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/chaos/chaosPageTypes.ts b/src/app/(dashboard)/dashboard/chaos/chaosPageTypes.ts new file mode 100644 index 0000000000..66e6006ed3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/chaos/chaosPageTypes.ts @@ -0,0 +1,28 @@ +import type { ChaosProviderOverride } from "./components/ChaosProviderOverridesPanel"; + +export interface ChaosProviderInfo { + id: string; + name: string; + provider: string; + defaultModel: string | null; +} + +export interface ChaosPageConfig { + enabled: boolean; + defaultMode: "parallel" | "collaborative"; + providerOverrides: ChaosProviderOverride[]; + systemPrompt?: string; + timeoutMs: number; + maxTokens: number; +} + +export const DEFAULT_CHAOS_PAGE_CONFIG: ChaosPageConfig = { + enabled: false, + defaultMode: "parallel", + providerOverrides: [], + systemPrompt: "", + timeoutMs: 120_000, + maxTokens: 4096, +}; + +export type ChaosPageMessage = { type: "success" | "error"; text: string } | null; diff --git a/src/app/(dashboard)/dashboard/chaos/components/ChaosBasicSettingsFields.tsx b/src/app/(dashboard)/dashboard/chaos/components/ChaosBasicSettingsFields.tsx new file mode 100644 index 0000000000..33f84d7ac8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/chaos/components/ChaosBasicSettingsFields.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +export interface ChaosBasicSettings { + enabled: boolean; + timeoutMs: number; + maxTokens: number; + systemPrompt?: string; +} + +function ChaosSystemPromptField({ + value, + onChange, +}: { + value: string | undefined; + onChange: (value: string) => void; +}) { + const t = useTranslations("chaosConfig"); + return ( +
+

{t("systemPrompt")}

+

{t("systemPromptDesc")}

+