From ece4bf7b539dcc9c87d9f012d393f7c1ebd2b560 Mon Sep 17 00:00:00 2001 From: NOXX - Commiter Date: Fri, 10 Jul 2026 03:39:09 +0300 Subject: [PATCH 1/5] feat(kiro): support enterprise External IdP (Your organization) logins (#6363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(kiro): support enterprise External IdP ("Your organization") logins Kiro's enterprise "Your organization" sign-in federates through the org's own identity provider (e.g. Microsoft Entra ID) and produces an `external_idp` token that is fundamentally different from AWS Builder ID / IAM Identity Center (AWS SSO-OIDC, refresh token starts with `aorAAAAAG`) and the Google/GitHub social flow. Its `~/.aws/sso/cache/kiro-auth-token.json` carries an org-IdP JWT access token, an IdP refresh token, a per-tenant `tokenEndpoint`, a public `clientId` (no secret) and `scopes` (`codewhisperer:conversations …`). Before this change every import path rejected these tokens (the `aorAAAAAG` format gate + no client secret), and the runtime/quota calls would have failed even if imported, so organization accounts could not be used. This adds full external_idp support: - New `open-sse/services/kiroExternalIdp.ts`: public-client refresh_token grant builder (`buildExternalIdpRefreshParams`), a token-endpoint SSRF allowlist (`validateExternalIdpTokenEndpoint` — Microsoft/Okta/Auth0/OneLogin/Ping/ Google/Cognito, https only), scope normalization, JWT identity extraction (`preferred_username`/`upn`/`email`), and the `TokenType: EXTERNAL_IDP` header constants. - Runtime executor (`open-sse/executors/kiro.ts`): send `TokenType: EXTERNAL_IDP` for external_idp accounts. CodeWhisperer only binds the org-IdP bearer to the Amazon Q Developer profile with this header; without it every call returns `ValidationException: Invalid ARN `. - Runtime + import token refresh (`open-sse/services/tokenRefresh.ts`, `src/lib/oauth/services/kiro.ts`): refresh external_idp tokens with a form-encoded public-client `refresh_token` grant against the org IdP's `tokenEndpoint` instead of AWS OIDC / the Kiro social endpoint. - Quota (`open-sse/services/usage/kiro.ts`): send the same header on `GetUsageLimits` so organization quota resolves. - Import routes: `POST /api/oauth/kiro/import` gains an external_idp branch (skips the `aorAAAAAG` gate, refreshes via the org IdP, stores clientId/tokenEndpoint/scope/region/profileArn); `GET /auto-import` now recognizes external_idp tokens in `~/.aws/sso/cache`, reads the profile ARN from the Kiro IDE `profile.json` (org tokens can't enumerate it via `ListAvailableProfiles`), and persists the connection. The profile.json reader is factored into a shared `readKiroIdeProfileArn()` helper. - Validation schema (`kiroImportSchema`): accept `tokenEndpoint` + `scopes`. Tests: new `tests/unit/kiro-external-idp.test.ts` (endpoint allowlist, scope normalization, identity extraction, public-client refresh body, the org IdP refresh path, and the `TokenType: EXTERNAL_IDP` header gating). Also hardens `kiro-windows-auto-import-3363.test.ts` to isolate `USERPROFILE` (Windows `os.homedir()` reads it, not `HOME`) so the probe never reads a real on-host Kiro login. * fix(changelog): restore #6363 bullet after release resync (CHANGELOG-eat guard) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): re-restore #6363 bullet after release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(merge): restore #6126 clinepass files reverted by release auto-resolve + rebaseline own tokenRefresh growth The release sync's merge auto-resolve silently reverted sibling PR #6126's clinepass work (registry entry, catalog, oauth constants, clineAuth.ts, the clinepass token-refresh case, and its tests) — all outside this PR's Kiro external-IdP scope. Restored every affected file to the release version; the remaining diff is Kiro-IdP-only. Rebaselined tokenRefresh.ts 2182->2249 (+67, this PR's own external_idp refresh branch) with justification, and restored the #6126 CHANGELOG bullet (re-inserting only this PR's own). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: artickc --- CHANGELOG.md | 1 + config/quality/file-size-baseline.json | 3 +- open-sse/executors/kiro.ts | 15 ++ open-sse/services/kiroExternalIdp.ts | 175 +++++++++++++++ open-sse/services/tokenRefresh.ts | 67 ++++++ open-sse/services/usage/kiro.ts | 25 ++- src/app/api/oauth/kiro/auto-import/route.ts | 207 ++++++++++++++---- src/app/api/oauth/kiro/import/route.ts | 51 +++++ src/lib/oauth/services/kiro.ts | 30 +++ src/shared/validation/schemas/auth.ts | 5 + tests/unit/kiro-external-idp.test.ts | 138 ++++++++++++ .../kiro-windows-auto-import-3363.test.ts | 10 + 12 files changed, 680 insertions(+), 47 deletions(-) create mode 100644 open-sse/services/kiroExternalIdp.ts create mode 100644 tests/unit/kiro-external-idp.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6001d8cb1d..deaea900cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **feat(dashboard):** 9router-parity **Routing Strategy** settings card on Settings → Routing, plus a per-provider account-routing override on the provider detail page ([#6678](https://github.com/diegosouzapw/OmniRoute/pull/6678)) — surfaces the existing account round-robin / sticky-limit knobs and adds a new combo-level sticky round-robin (`comboStickyRoundRobinLimit`, resolved via `resolveComboStickyRoundRobinLimit()` — per-combo → global combo sticky → account sticky cascade) so combo targets can batch calls per target the same way account fallback already does. A new `providerStrategies` setting (Zod-validated map, `src/shared/validation/settingsSchemas.ts`) lets a specific provider override the global `fallbackStrategy`/`stickyRoundRobinLimit` without touching the account-wide default, wired into `getProviderCredentials()` (`src/sse/services/auth.ts`) ahead of the global fallback. Regression guard: `tests/unit/combo-rr-sticky-9router.test.ts`, `tests/unit/settings-ui-layout-static.test.ts`. (thanks @SeaXen) - **Skill Collector CLI detection**: new `GET /api/skills/collect/detect` + `POST /api/skills/collect/install` (and the `cli-skill-collector` agent skill) detect which coding CLIs (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.) are installed locally via `getCliRuntimeStatus()`, match them against GitHub agent-skill repos, and plan an install path per tool — replacing the standalone Skill Collector Python app. Both new routes and `GET/POST /api/github-skills` now require management auth (`requireManagementAuth()`) and are loopback-gated (`LOCAL_ONLY_API_PREFIXES` + `SPAWN_CAPABLE_PREFIXES`) since the detect route spawns a child process per candidate CLI tool (Hard Rules #15 + #17). The `omniroute_github_skills_install` MCP tool now reports the honest `action: "planned"` instead of `"installed"`, matching the REST route (#6294 — thanks @Moseyuh333) - **ClinePass dual-auth**: ClinePass now offers both sign-in methods on its dashboard page — OAuth (reusing the Cline WorkOS flow) as the primary "Connect" path, or a pasted BYOK API key via "Manual API key", instead of only the API-key-only provider shipped in #5942. The registry alias was aligned to `cp` (matching the `OAUTH_PROVIDERS` catalog alias) so `/` routing resolves correctly, the OAuth refresh dispatch now routes `clinepass` to the shared Cline refresh flow, and the duplicate API-key-only catalog entry was removed to keep ClinePass listed once. Regression guard: `tests/unit/clinepass-provider.test.ts`. (#6126 — thanks @hajilok) +- **feat(oauth):** Kiro/Amazon Q auto-import now supports enterprise **External IdP** ("Your organization") logins via Microsoft Entra/Okta/Auth0/OneLogin/Ping/Google/Cognito — these org-issued tokens are not AWS SSO tokens (no `aorAAAAAG`-prefixed refresh token) and can't refresh through the AWS OIDC/Kiro-social path, so `tryAwsSsoCache()` now detects them (`authMethod`/`provider === "externalidp"`) and refreshes via the org IdP's own `tokenEndpoint` (public-client OAuth2 refresh grant, no client secret), persisting `TokenType: EXTERNAL_IDP` gating so the runtime executor sends the header the AWS CodeWhisperer API requires for these accounts; `tokenEndpoint` is SSRF-guarded against an HTTPS + known-IdP-host-suffix allowlist. (#6363 — thanks @artickc) ### 🐛 Bug Fixes diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 9653ffa19a..0ec0532b4e 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -183,7 +183,8 @@ "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, diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index 7def296e39..9ac1a4f69b 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, @@ -198,6 +203,16 @@ export class KiroExecutor extends BaseExecutor { if (credentials.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; + + // 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(credentials.providerSpecificData?.authMethod)) { + headers[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE; + } } return headers; 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/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..4efe496de1 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; @@ -188,14 +193,22 @@ export async function getKiroUsage(accessToken?: string, providerSpecificData?: resourceType: "AGENTIC_REQUEST", }; + // Enterprise / Microsoft Entra (external_idp) org accounts require the + // `TokenType: EXTERNAL_IDP` header for CodeWhisperer to bind the bearer to the + // profile; without it GetUsageLimits returns `ValidationException: Invalid ARN`. + const usageHeaders: Record = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits", + Accept: "application/json", + }; + if (isExternalIdpAuthMethod(providerSpecificData?.authMethod)) { + usageHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE; + } + 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", - }, + headers: usageHeaders, body: JSON.stringify(payload), }); diff --git a/src/app/api/oauth/kiro/auto-import/route.ts b/src/app/api/oauth/kiro/auto-import/route.ts index 1387f06302..3c3937cc59 100755 --- a/src/app/api/oauth/kiro/auto-import/route.ts +++ b/src/app/api/oauth/kiro/auto-import/route.ts @@ -13,6 +13,11 @@ import { syncToCloud } from "@/lib/cloudSync"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { KiroService } from "@/lib/oauth/services/kiro"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; +import { + emailFromExternalIdpToken, + isExternalIdpAuthMethod, + normalizeScope, +} from "@omniroute/open-sse/services/kiroExternalIdp.ts"; /** * GET /api/oauth/kiro/auto-import @@ -202,16 +207,69 @@ async function tryKiroCliSqlite(): Promise<{ // ── ~/.aws/sso/cache fallback ───────────────────────────────────────────────── +/** + * Read the Amazon Q Developer profileArn the Kiro IDE persists in its + * `profile.json`. This is the authoritative source for the profileArn of AWS + * IAM Identity Center AND External IdP (organization) logins, since neither can + * enumerate it via ListAvailableProfiles (org tokens get an empty list). + * + * The ARN's region segment is preserved verbatim (#2314). #2059 originally + * forced every ARN's region to us-east-1, which 403s the runtime gateway for + * IDC accounts that live in a non-us-east-1 region. The OAuth device-code + * path (src/lib/oauth/providers/kiro.ts) already discovers the correct + * region-matched ARN, so this fallback now mirrors that behavior instead of + * rewriting it. + */ +async function readKiroIdeProfileArn(): Promise { + const { readFile } = await import("fs/promises"); + const kiroProfilePaths = [ + join( + process.env.APPDATA || join(homedir(), "AppData", "Roaming"), + "Kiro", + "User", + "globalStorage", + "kiro.kiroagent", + "profile.json" + ), + join(homedir(), ".config", "Kiro", "User", "globalStorage", "kiro.kiroagent", "profile.json"), + join( + homedir(), + "Library", + "Application Support", + "Kiro", + "User", + "globalStorage", + "kiro.kiroagent", + "profile.json" + ), + ]; + for (const profilePath of kiroProfilePaths) { + try { + const profileContent = await readFile(profilePath, "utf-8"); + const profileData = JSON.parse(profileContent); + if (profileData.arn) { + return profileData.arn; + } + } catch { + continue; + } + } + return null; +} + async function tryAwsSsoCache(targetProvider: string): Promise<{ found: boolean; triedPath?: string; refreshToken?: string; + accessToken?: string | null; source?: string; clientId?: string | null; clientSecret?: string | null; region?: string | null; authMethod?: string | null; profileArn?: string | null; + tokenEndpoint?: string | null; + scopes?: string | string[] | null; }> { const { readFile, readdir } = await import("fs/promises"); const cachePath = join(homedir(), ".aws/sso/cache"); @@ -235,6 +293,34 @@ async function tryAwsSsoCache(targetProvider: string): Promise<{ try { const content = await readFile(join(cachePath, file), "utf-8"); const data = JSON.parse(content); + + // Enterprise / Microsoft Entra "Your organization" (external_idp) tokens are NOT AWS SSO + // tokens — their refresh token does not start with `aorAAAAAG`. Detect them by authMethod/ + // provider and take the dedicated external_idp branch (org IdP tokenEndpoint refresh + + // profileArn read from the Kiro IDE profile.json). + const isExternalIdp = + !!data.refreshToken && + (isExternalIdpAuthMethod(data.authMethod) || + String(data.provider || "").toLowerCase() === "externalidp"); + + if (isExternalIdp) { + const region: string | null = data.region || null; + const profileArn = await readKiroIdeProfileArn(); + return { + found: true, + source: file, + refreshToken: data.refreshToken, + accessToken: data.accessToken || null, + clientId: data.clientId || null, + clientSecret: null, + region, + authMethod: "external_idp", + profileArn, + tokenEndpoint: data.tokenEndpoint || null, + scopes: data.scopes || null, + }; + } + if (data.refreshToken?.startsWith("aorAAAAAG")) { const region: string | null = data.region || null; const authMethod: string | null = data.authMethod || null; @@ -257,46 +343,9 @@ async function tryAwsSsoCache(targetProvider: string): Promise<{ } } - // Read profileArn from Kiro IDE's profile.json. - // Kiro IDC (Identity Center) accounts can live in regions other than - // us-east-1. #2059 forced every ARN's region segment to us-east-1, - // which 403s the runtime gateway for non-us-east-1 IDC accounts. The - // OAuth device-code path (src/lib/oauth/providers/kiro.ts) already - // discovers the correct region-matched ARN; mirror that here by - // preserving the profile's ARN region verbatim instead of rewriting - // it. - let profileArn: string | null = null; - const kiroProfilePaths = [ - join( - process.env.APPDATA || join(homedir(), "AppData", "Roaming"), - "Kiro", - "User", - "globalStorage", - "kiro.kiroagent", - "profile.json" - ), - join( - homedir(), - ".config", - "Kiro", - "User", - "globalStorage", - "kiro.kiroagent", - "profile.json" - ), - ]; - for (const profilePath of kiroProfilePaths) { - try { - const profileContent = await readFile(profilePath, "utf-8"); - const profileData = JSON.parse(profileContent); - if (profileData.arn) { - profileArn = profileData.arn; - break; - } - } catch { - continue; - } - } + // Read profileArn from Kiro IDE's profile.json. The region is preserved + // verbatim by readKiroIdeProfileArn() (#2314) — see its docstring for why. + const profileArn: string | null = await readKiroIdeProfileArn(); return { found: true, @@ -375,6 +424,9 @@ export function findKiroConnectionByProfileArn( type SaveAndRespondResult = Awaited> & { // Fields added by tryAwsSsoCache for IDC tokens (#2059) authMethod?: string | null; + // Fields added by tryAwsSsoCache for External IdP (organization) tokens + tokenEndpoint?: string | null; + scopes?: string | string[] | null; }; async function saveAndRespond( @@ -386,6 +438,81 @@ async function saveAndRespond( const kiroService = new KiroService(); const proxy = await resolveProxyForProvider(targetProvider); + // Enterprise / Microsoft Entra "Your organization" (external_idp) tokens: refresh via the + // org IdP tokenEndpoint (public-client OAuth2), persist the Kiro IDE profileArn, and mark + // the connection so the runtime executor sends `TokenType: EXTERNAL_IDP` and the quota + // fetch works. These tokens can't refresh via AWS OIDC / Kiro social and have no client + // secret, so they get their own path. + if (isExternalIdpAuthMethod(result.authMethod)) { + const region = result.region || "us-east-1"; + const scope = normalizeScope(result.scopes); + const externalIdpPsd = { + authMethod: "external_idp", + clientId: result.clientId || undefined, + tokenEndpoint: result.tokenEndpoint || undefined, + scope, + region, + }; + const refreshed = await runWithProxyContext(proxy, () => + kiroService.refreshToken(result.refreshToken!, externalIdpPsd) + ); + const email = + emailFromExternalIdpToken(refreshed.accessToken) || + kiroService.extractEmailFromJWT(refreshed.accessToken); + const profileArn = result.profileArn || null; + const connectionName = deriveKiroConnectionName({ + email, + profileArn: profileArn || undefined, + region, + targetProvider, + }); + const providerSpecificData: Record = { + authMethod: "external_idp", + provider: "ExternalIdp", + clientId: result.clientId || null, + tokenEndpoint: result.tokenEndpoint || null, + scope, + region, + }; + if (profileArn) providerSpecificData.profileArn = profileArn; + + const existingConnections = await getProviderConnections({ provider: targetProvider }); + const existingByArn = findKiroConnectionByProfileArn( + existingConnections, + profileArn || undefined + ); + const record = { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken || result.refreshToken!, + expiresAt: new Date(Date.now() + (refreshed.expiresIn || 3600) * 1000).toISOString(), + email: email || null, + name: connectionName, + providerSpecificData, + testStatus: "active", + }; + if (existingByArn && typeof existingByArn.id === "string") { + await updateProviderConnection(existingByArn.id, record); + } else { + await createProviderConnection({ + provider: targetProvider, + authType: "oauth", + ...record, + } as any); + } + if (isCloudEnabled()) { + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId).catch(() => {}); + } + return NextResponse.json({ + found: true, + source: result.source, + email: email || null, + profileArn: profileArn || null, + region, + message: "Kiro credentials imported successfully.", + }); + } + // If we have a refresh token but no valid access token, refresh now let accessToken = result.accessToken; let refreshToken = result.refreshToken!; diff --git a/src/app/api/oauth/kiro/import/route.ts b/src/app/api/oauth/kiro/import/route.ts index 7774908866..4e58bfcbd3 100755 --- a/src/app/api/oauth/kiro/import/route.ts +++ b/src/app/api/oauth/kiro/import/route.ts @@ -8,6 +8,11 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { + emailFromExternalIdpToken, + isExternalIdpAuthMethod, + normalizeScope, +} from "@omniroute/open-sse/services/kiroExternalIdp.ts"; /** * Build the user-facing error message for a failed Kiro/Amazon-Q token import. @@ -63,12 +68,58 @@ export async function POST(request: Request) { } const { refreshToken, region, clientId, clientSecret, authMethod, profileArn } = validation.data; + const { tokenEndpoint, scopes } = validation.data; const kiroService = new KiroService(); // Resolve proxy for this provider (provider-level → global → direct) const proxy = await resolveProxyForProvider(targetProvider); + // Enterprise / Microsoft Entra "Your organization" (external_idp) import. These tokens are + // NOT AWS SSO tokens (their refresh token does not start with `aorAAAAAG`), so the Builder + // ID / IDC path (validateImportToken) rejects them. Refresh via the org IdP's tokenEndpoint, + // persist the org profileArn (read from the Kiro IDE profile.json by the caller), and mark + // the connection so the runtime executor sends `TokenType: EXTERNAL_IDP`. + if (isExternalIdpAuthMethod(authMethod)) { + const scope = normalizeScope(scopes); + const externalIdpPsd = { + authMethod: "external_idp", + clientId, + tokenEndpoint, + scope, + region: region || "us-east-1", + }; + const refreshed = await runWithProxyContext(proxy, () => + kiroService.refreshToken(refreshToken.trim(), externalIdpPsd) + ); + const email = + emailFromExternalIdpToken(refreshed.accessToken) || + kiroService.extractEmailFromJWT(refreshed.accessToken); + const connection: any = await createProviderConnection({ + provider: targetProvider, + authType: "oauth", + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken || refreshToken.trim(), + expiresAt: new Date(Date.now() + (refreshed.expiresIn || 3600) * 1000).toISOString(), + email: email || null, + providerSpecificData: { + profileArn: profileArn || null, + authMethod: "external_idp", + provider: "ExternalIdp", + clientId, + tokenEndpoint, + scope, + region: region || "us-east-1", + }, + testStatus: "active", + } as any); + await syncToCloudIfEnabled(); + return NextResponse.json({ + success: true, + connection: { id: connection.id, provider: connection.provider, email: connection.email }, + }); + } + // For IDC tokens the client already has OIDC client credentials extracted from the // SSO cache registration file by auto-import (#2059). Refresh directly via the // regional OIDC endpoint without calling registerClient() again. For social / diff --git a/src/lib/oauth/services/kiro.ts b/src/lib/oauth/services/kiro.ts index b1357533f3..86601b3f2c 100644 --- a/src/lib/oauth/services/kiro.ts +++ b/src/lib/oauth/services/kiro.ts @@ -1,4 +1,8 @@ import { KIRO_CONFIG, assertValidAwsRegion } from "../constants/oauth"; +import { + buildExternalIdpRefreshParams, + isExternalIdpAuthMethod, +} from "@omniroute/open-sse/services/kiroExternalIdp.ts"; /** * Kiro OAuth Service @@ -187,6 +191,32 @@ export class KiroService { async refreshToken(refreshToken: string, providerSpecificData: any = {}) { const { authMethod, clientId, clientSecret, region } = providerSpecificData; + // Enterprise / Microsoft Entra "Your organization" (external_idp) login: refresh with a + // standard public-client OAuth2 refresh_token grant against the org IdP's tokenEndpoint + // (form-encoded client_id + refresh_token + scope, no client_secret). The AWS SSO OIDC and + // Kiro social endpoints cannot refresh these tokens. + if (isExternalIdpAuthMethod(authMethod)) { + const refreshRequest = buildExternalIdpRefreshParams(refreshToken, providerSpecificData); + const response = await fetch(refreshRequest.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: refreshRequest.body, + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token refresh failed: ${error}`); + } + const data = await response.json(); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || refreshToken, + expiresIn: data.expires_in || 3600, + }; + } + // AWS SSO OIDC refresh (Builder ID or IDC). // Imported social tokens (authMethod === "imported") have a registered clientId/clientSecret // but a Kiro-social refresh token the OIDC client can't refresh — use the social path (#2467). diff --git a/src/shared/validation/schemas/auth.ts b/src/shared/validation/schemas/auth.ts index 2e7e6bb1ac..1e251aa596 100644 --- a/src/shared/validation/schemas/auth.ts +++ b/src/shared/validation/schemas/auth.ts @@ -193,6 +193,11 @@ export const kiroImportSchema = z.object({ clientSecret: z.string().optional(), authMethod: z.string().optional(), profileArn: z.string().optional(), + // External IdP ("Your organization" / Microsoft Entra) token fields — present + // when authMethod === "external_idp". The token is refreshed via a public-client + // OAuth2 grant against `tokenEndpoint` using `clientId` + `scopes` (no secret). + tokenEndpoint: z.string().optional(), + scopes: z.union([z.string(), z.array(z.string())]).optional(), }); export const zedImportSchema = z.object({ diff --git a/tests/unit/kiro-external-idp.test.ts b/tests/unit/kiro-external-idp.test.ts new file mode 100644 index 0000000000..a8f5273b6e --- /dev/null +++ b/tests/unit/kiro-external-idp.test.ts @@ -0,0 +1,138 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Coverage for enterprise / Microsoft Entra "Your organization" (external_idp) Kiro accounts: +// • public-client refresh_token grant against the org IdP tokenEndpoint (no client secret), +// • the runtime `TokenType: EXTERNAL_IDP` header the CodeWhisperer service requires to bind +// the bearer to the Amazon Q Developer profile (without it every call is +// `ValidationException: Invalid ARN `), +// • tokenEndpoint SSRF allowlist, scope normalization, and JWT identity extraction. + +import { + buildExternalIdpRefreshParams, + validateExternalIdpTokenEndpoint, + normalizeScope, + isExternalIdpAuthMethod, + emailFromExternalIdpToken, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE, +} from "../../open-sse/services/kiroExternalIdp.ts"; +import { KiroExecutor } from "../../open-sse/executors/kiro.ts"; + +const MS_ENDPOINT = "https://login.microsoftonline.com/9d769d6d-e03a-442a-8ab1-a7da2037a5d4/oauth2/v2.0/token"; + +function makeJwt(payload: Record): string { + const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url"); + return `${b64({ alg: "none", typ: "JWT" })}.${b64(payload)}.sig`; +} + +test("validateExternalIdpTokenEndpoint accepts Microsoft/Okta https, rejects others", () => { + assert.equal(validateExternalIdpTokenEndpoint(MS_ENDPOINT), MS_ENDPOINT); + assert.ok(validateExternalIdpTokenEndpoint("https://dev-123.okta.com/oauth2/v1/token")); + assert.throws(() => validateExternalIdpTokenEndpoint("http://login.microsoftonline.com/x/token")); + assert.throws(() => validateExternalIdpTokenEndpoint("https://evil.example.com/token")); + assert.throws(() => validateExternalIdpTokenEndpoint("")); +}); + +test("normalizeScope handles array and space-delimited string", () => { + assert.equal(normalizeScope(["a", "b", "offline_access"]), "a b offline_access"); + assert.equal(normalizeScope("a b offline_access"), "a b offline_access"); + assert.equal(normalizeScope([" x ", "", "y"]), "x y"); + assert.equal(normalizeScope(undefined), ""); +}); + +test("isExternalIdpAuthMethod recognizes external_idp (case-insensitive)", () => { + assert.equal(isExternalIdpAuthMethod("external_idp"), true); + assert.equal(isExternalIdpAuthMethod("EXTERNAL_IDP"), true); + assert.equal(isExternalIdpAuthMethod("idc"), false); + assert.equal(isExternalIdpAuthMethod(undefined), false); +}); + +test("emailFromExternalIdpToken reads preferred_username / upn / email", () => { + assert.equal( + emailFromExternalIdpToken(makeJwt({ preferred_username: "finbar.heslin@mrdevvn.cyou" })), + "finbar.heslin@mrdevvn.cyou" + ); + assert.equal(emailFromExternalIdpToken(makeJwt({ upn: "a@b.com" })), "a@b.com"); + assert.equal(emailFromExternalIdpToken(makeJwt({ email: "c@d.com" })), "c@d.com"); + assert.equal(emailFromExternalIdpToken("not-a-jwt"), null); +}); + +test("buildExternalIdpRefreshParams builds a public-client form body", () => { + const req = buildExternalIdpRefreshParams("RT-123", { + clientId: "app-guid", + tokenEndpoint: MS_ENDPOINT, + scopes: ["api://app-guid/codewhisperer:conversations", "offline_access"], + }); + assert.equal(req.tokenEndpoint, MS_ENDPOINT); + assert.equal(req.body.get("grant_type"), "refresh_token"); + assert.equal(req.body.get("client_id"), "app-guid"); + assert.equal(req.body.get("refresh_token"), "RT-123"); + assert.equal( + req.body.get("scope"), + "api://app-guid/codewhisperer:conversations offline_access" + ); + // Public client: never a secret. + assert.equal(req.body.get("client_secret"), null); +}); + +test("buildExternalIdpRefreshParams fails closed on missing fields", () => { + assert.throws(() => buildExternalIdpRefreshParams("", { clientId: "x", tokenEndpoint: MS_ENDPOINT, scopes: "s" })); + assert.throws(() => buildExternalIdpRefreshParams("rt", { tokenEndpoint: MS_ENDPOINT, scopes: "s" })); + assert.throws(() => buildExternalIdpRefreshParams("rt", { clientId: "x", scopes: "s" })); + assert.throws(() => buildExternalIdpRefreshParams("rt", { clientId: "x", tokenEndpoint: MS_ENDPOINT })); +}); + +test("KiroService.refreshToken uses the org IdP tokenEndpoint for external_idp", async () => { + const { KiroService } = await import("../../src/lib/oauth/services/kiro.ts"); + const ORIGINAL_FETCH = globalThis.fetch; + const calls: { url: string; body: string; contentType: string | null }[] = []; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const u = String(url); + const body = init?.body instanceof URLSearchParams ? init.body.toString() : String(init?.body ?? ""); + calls.push({ url: u, body, contentType: (init?.headers as Record)?.["Content-Type"] ?? null }); + return new Response( + JSON.stringify({ access_token: "new-at", refresh_token: "rotated-rt", expires_in: 4481 }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + try { + const svc = new KiroService(); + const res = await svc.refreshToken("RT-old", { + authMethod: "external_idp", + clientId: "app-guid", + tokenEndpoint: MS_ENDPOINT, + scopes: "codewhisperer:conversations offline_access", + }); + assert.equal(res.accessToken, "new-at"); + assert.equal(res.refreshToken, "rotated-rt"); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, MS_ENDPOINT); + assert.ok(calls[0].url.includes("login.microsoftonline.com")); + // Must NOT hit AWS OIDC or the Kiro social endpoint. + assert.ok(!calls[0].url.includes("amazonaws.com")); + assert.ok(!calls[0].url.includes("desktop.kiro.dev")); + assert.ok(calls[0].body.includes("grant_type=refresh_token")); + assert.ok(calls[0].body.includes("client_id=app-guid")); + } finally { + globalThis.fetch = ORIGINAL_FETCH; + } +}); + +test("KiroExecutor.buildHeaders sends TokenType: EXTERNAL_IDP only for external_idp", () => { + const exec = new KiroExecutor(); + const idpHeaders = exec.buildHeaders({ + accessToken: "at", + providerSpecificData: { authMethod: "external_idp" }, + } as never); + assert.equal(idpHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER], KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE); + + const idcHeaders = exec.buildHeaders({ + accessToken: "at", + providerSpecificData: { authMethod: "idc" }, + } as never); + assert.equal(idcHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER], undefined); + + const builderIdHeaders = exec.buildHeaders({ accessToken: "at" } as never); + assert.equal(builderIdHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER], undefined); +}); diff --git a/tests/unit/kiro-windows-auto-import-3363.test.ts b/tests/unit/kiro-windows-auto-import-3363.test.ts index b3bbf12cfd..184a25a30a 100644 --- a/tests/unit/kiro-windows-auto-import-3363.test.ts +++ b/tests/unit/kiro-windows-auto-import-3363.test.ts @@ -24,6 +24,7 @@ const core = await import("../../src/lib/db/core.ts"); const { GET } = await import("../../src/app/api/oauth/kiro/auto-import/route.ts"); const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_USERPROFILE = process.env.USERPROFILE; const ORIGINAL_APPDATA = process.env.APPDATA; const ORIGINAL_FETCH = globalThis.fetch; @@ -37,12 +38,21 @@ test.beforeEach(() => { fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); // Override HOME so homedir() returns a temp dir where no kiro-cli DB exists. process.env.HOME = tmpHome; + // On Windows os.homedir() reads USERPROFILE (not HOME), so isolate it too — + // otherwise the probe reads the real ~/.aws/sso/cache and can find an actual + // (e.g. external_idp organization) Kiro login on the test host. + process.env.USERPROFILE = tmpHome; // Ensure APPDATA is unset by default; individual tests that need it set it. delete process.env.APPDATA; }); test.afterEach(() => { process.env.HOME = ORIGINAL_HOME; + if (ORIGINAL_USERPROFILE !== undefined) { + process.env.USERPROFILE = ORIGINAL_USERPROFILE; + } else { + delete process.env.USERPROFILE; + } if (ORIGINAL_APPDATA !== undefined) { process.env.APPDATA = ORIGINAL_APPDATA; } else { From 3a28b3b5e8e53ed74b4473dab6055d25081b3eb8 Mon Sep 17 00:00:00 2001 From: Thiago Reis Date: Thu, 9 Jul 2026 22:49:06 -0300 Subject: [PATCH 2/5] feat: add Kiro API key authentication (#6587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(oauth): add Kiro long-lived API key auth (#6587) New /api/oauth/kiro/api-key route + KiroService.validateApiKey let a Kiro account be linked with a long-lived AWS CodeWhisperer/Kiro API key instead of the interactive OAuth device flow, with live per-account model discovery (ListAvailableModels, 5-minute cache) layered over the existing static registry fallback. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): re-restore #6587 bullet after release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(merge): restore #6126 clinepass files reverted by release auto-resolve + baseline re-merge The release sync's auto-resolve reverted sibling PR #6126's clinepass work (registry, catalog, oauth constants, clineAuth.ts, token-refresh case, tests) and the file-size baseline — all outside this PR's scope. Restored to the release versions, re-applied only this PR's own baseline entries, restored the #6126 CHANGELOG bullet (re-inserting only this PR's own). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(quality): freeze public-creds FP — AWS region default in validateApiKey signature Same class as the existing minimax fn-param FPs: CRED_KEY_RE matches the apiKey: param annotation and captures the region default "us-east-1", which is not a credential. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(kiro): keep hard-failure reject semantics + kill public-creds fn-param FP at the source - getKiroUsage: exhausted non-auth attempts now REJECT with the last HTTP-status failure in the pre-#6587 format (usage-service-hardening relies on it); auth failures keep the soft social-auth message. - validateApiKey: region default moved out of the parameter list (the check-public-creds CRED_KEY_RE matches the apiKey: annotation and flags any literal in the signature); drops the brittle line-keyed allowlist entry. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: strangersp Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza --- CHANGELOG.md | 1 + config/quality/eslint-suppressions.json | 5 - config/quality/file-size-baseline.json | 15 +- config/quality/quality-baseline.json | 3 +- docs/guides/KIRO_SETUP.md | 64 ++- open-sse/executors/kiro.ts | 18 +- open-sse/services/kiroModels.ts | 187 ++++++++- open-sse/services/usage/kiro.ts | 256 ++++++++--- open-sse/translator/request/openai-to-kiro.ts | 32 +- .../dashboard/providers/providerPageUtils.ts | 11 +- src/app/api/oauth/kiro/api-key/helpers.ts | 24 ++ src/app/api/oauth/kiro/api-key/route.ts | 106 +++++ src/lib/oauth/services/kiro.ts | 74 ++++ src/lib/providers/validation.ts | 93 +++- src/lib/usage/providerLimits.ts | 4 +- src/server/authz/pipeline.ts | 32 ++ src/shared/components/KiroAuthModal.tsx | 109 +++++ src/shared/components/KiroOAuthWrapper.tsx | 5 +- .../constants/pricing/oauth-subscriptions.ts | 13 +- src/shared/validation/schemas/auth.ts | 5 + tests/unit/authz/pipeline.test.ts | 36 ++ tests/unit/executor-kiro.test.ts | 18 + tests/unit/kiro-api-key-route-helpers.test.ts | 38 ++ tests/unit/kiro-api-key-service.test.ts | 62 +++ tests/unit/kiro-available-models.test.ts | 30 +- tests/unit/kiro-iam-profilearn-usage.test.ts | 396 ++++++++++-------- tests/unit/provider-onboarding-wizard.test.ts | 3 + .../provider-validation-specialty.test.ts | 148 ++++++- tests/unit/providers-page-utils.test.ts | 4 + .../shared/components/KiroAuthModal.test.tsx | 56 +++ tests/unit/translator-openai-to-kiro.test.ts | 23 + 31 files changed, 1591 insertions(+), 280 deletions(-) create mode 100644 src/app/api/oauth/kiro/api-key/helpers.ts create mode 100644 src/app/api/oauth/kiro/api-key/route.ts create mode 100644 tests/unit/kiro-api-key-route-helpers.test.ts create mode 100644 tests/unit/kiro-api-key-service.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index deaea900cf..06bb015be9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **Skill Collector CLI detection**: new `GET /api/skills/collect/detect` + `POST /api/skills/collect/install` (and the `cli-skill-collector` agent skill) detect which coding CLIs (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.) are installed locally via `getCliRuntimeStatus()`, match them against GitHub agent-skill repos, and plan an install path per tool — replacing the standalone Skill Collector Python app. Both new routes and `GET/POST /api/github-skills` now require management auth (`requireManagementAuth()`) and are loopback-gated (`LOCAL_ONLY_API_PREFIXES` + `SPAWN_CAPABLE_PREFIXES`) since the detect route spawns a child process per candidate CLI tool (Hard Rules #15 + #17). The `omniroute_github_skills_install` MCP tool now reports the honest `action: "planned"` instead of `"installed"`, matching the REST route (#6294 — thanks @Moseyuh333) - **ClinePass dual-auth**: ClinePass now offers both sign-in methods on its dashboard page — OAuth (reusing the Cline WorkOS flow) as the primary "Connect" path, or a pasted BYOK API key via "Manual API key", instead of only the API-key-only provider shipped in #5942. The registry alias was aligned to `cp` (matching the `OAUTH_PROVIDERS` catalog alias) so `/` routing resolves correctly, the OAuth refresh dispatch now routes `clinepass` to the shared Cline refresh flow, and the duplicate API-key-only catalog entry was removed to keep ClinePass listed once. Regression guard: `tests/unit/clinepass-provider.test.ts`. (#6126 — thanks @hajilok) - **feat(oauth):** Kiro/Amazon Q auto-import now supports enterprise **External IdP** ("Your organization") logins via Microsoft Entra/Okta/Auth0/OneLogin/Ping/Google/Cognito — these org-issued tokens are not AWS SSO tokens (no `aorAAAAAG`-prefixed refresh token) and can't refresh through the AWS OIDC/Kiro-social path, so `tryAwsSsoCache()` now detects them (`authMethod`/`provider === "externalidp"`) and refreshes via the org IdP's own `tokenEndpoint` (public-client OAuth2 refresh grant, no client secret), persisting `TokenType: EXTERNAL_IDP` gating so the runtime executor sends the header the AWS CodeWhisperer API requires for these accounts; `tokenEndpoint` is SSRF-guarded against an HTTPS + known-IdP-host-suffix allowlist. (#6363 — thanks @artickc) +- **Kiro long-lived API key auth**: new `/api/oauth/kiro/api-key` route + `KiroService.validateApiKey` let a Kiro account be linked with a long-lived AWS CodeWhisperer/Kiro API key instead of the interactive OAuth device flow, with live per-account model discovery (`ListAvailableModels`, 5-minute cache) layered over the existing static registry fallback (#6587 — thanks @strangersp) ### 🐛 Bug Fixes 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 0ec0532b4e..229016bdd7 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -187,7 +187,7 @@ "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, @@ -248,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, @@ -270,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": { @@ -311,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, @@ -324,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 9ac1a4f69b..d5623aacdd 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -201,8 +201,19 @@ 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 @@ -210,7 +221,7 @@ export class KiroExecutor extends BaseExecutor { // 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(credentials.providerSpecificData?.authMethod)) { + if (isExternalIdpAuthMethod(authMethod)) { headers[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE; } } @@ -795,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/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/usage/kiro.ts b/open-sse/services/usage/kiro.ts index 4efe496de1..b96b5041ca 100644 --- a/open-sse/services/usage/kiro.ts +++ b/open-sse/services/usage/kiro.ts @@ -118,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), @@ -150,92 +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", }; - // Enterprise / Microsoft Entra (external_idp) org accounts require the - // `TokenType: EXTERNAL_IDP` header for CodeWhisperer to bind the bearer to the - // profile; without it GetUsageLimits returns `ValidationException: Invalid ARN`. - const usageHeaders: Record = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/x-amz-json-1.0", - "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits", - Accept: "application/json", - }; - if (isExternalIdpAuthMethod(providerSpecificData?.authMethod)) { - usageHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE; - } - - const response = await fetch(usageBaseUrl, { - method: "POST", - headers: usageHeaders, - 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/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index b7b9f4d354..762a9bb629 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -71,6 +71,8 @@ export function shouldShowFirstProviderHint( type ProviderRecord> = Record; +const OAUTH_CARD_API_KEY_CONNECTION_PROVIDER_IDS = new Set(["kiro", "amazon-q"]); + /** * Whether a provider connection should be counted on a provider card rendered in * the given section. Dual-auth providers (qoder, opencode, codebuddy-cn, …) are @@ -85,8 +87,11 @@ export function connectionMatchesProviderCard( ): boolean { if (!conn || conn.provider !== providerId) return false; if (cardAuthType === "free") return true; - if (supportsApiKeyOnFreeProvider(providerId)) { - return conn.authType === "oauth" || conn.authType === "apikey"; + if ( + supportsApiKeyOnFreeProvider(providerId) || + OAUTH_CARD_API_KEY_CONNECTION_PROVIDER_IDS.has(providerId) + ) { + return conn.authType === "oauth" || conn.authType === "apikey" || conn.authType === "api_key"; } return conn.authType === cardAuthType; } @@ -364,7 +369,7 @@ const PROVIDER_PAGE_FETCH_TIMEOUT_MS = 20_000; * page paints from whatever data arrived (matching the fast `/api/providers`). */ export async function loadProviderPageData( - fetchImpl: typeof fetch = (globalThis.fetch as typeof fetch), + fetchImpl: typeof fetch = globalThis.fetch as typeof fetch, timeoutMs: number = PROVIDER_PAGE_FETCH_TIMEOUT_MS ): Promise { const safeJson = async (url: string, init?: RequestInit): Promise => { diff --git a/src/app/api/oauth/kiro/api-key/helpers.ts b/src/app/api/oauth/kiro/api-key/helpers.ts new file mode 100644 index 0000000000..8a0da5b89c --- /dev/null +++ b/src/app/api/oauth/kiro/api-key/helpers.ts @@ -0,0 +1,24 @@ +import { createHash } from "node:crypto"; + +export function buildKiroApiKeyConnectionName( + targetProvider: string, + region: string, + apiKey: string +): string { + const label = targetProvider === "amazon-q" ? "Amazon Q" : "Kiro"; + const safeRegion = region || "us-east-1"; + const fingerprint = createHash("sha256").update(apiKey.trim()).digest("hex").slice(0, 8); + return `${label} API Key (${safeRegion}, ${fingerprint})`; +} + +export function isKiroApiKeyImportClientError(error: unknown): boolean { + const message = (error instanceof Error ? error.message : String(error || "")).toLowerCase(); + return ( + message.includes("api key is required") || + message.includes("invalid region") || + message.includes("invalid kiro api key") || + message.includes("invalid api key") || + message.includes("no kiro profile available") || + message.includes("failed to list profiles") + ); +} diff --git a/src/app/api/oauth/kiro/api-key/route.ts b/src/app/api/oauth/kiro/api-key/route.ts new file mode 100644 index 0000000000..0564d0937f --- /dev/null +++ b/src/app/api/oauth/kiro/api-key/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from "next/server"; +import { KiroService } from "@/lib/oauth/services/kiro"; +import { createProviderConnection, isCloudEnabled } from "@/models"; +import { syncToCloud } from "@/lib/cloudSync"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { kiroApiKeyImportSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { buildKiroImportError } from "../import/route"; +import { buildKiroApiKeyConnectionName, isKiroApiKeyImportClientError } from "./helpers"; + +async function requireKiroApiKeyImportAuth(request: Request) { + if (!(await isAuthRequired(request))) return null; + if (await isAuthenticated(request)) return null; + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +} + +/** + * POST /api/oauth/kiro/api-key + * + * Imports a long-lived Kiro / AWS CodeWhisperer API key. API-key auth has no + * refresh token; profile discovery is best-effort because AWS rejects + * ListAvailableProfiles for some API keys while still accepting generation calls. + */ +export async function POST(request: Request) { + const authResponse = await requireKiroApiKeyImportAuth(request); + if (authResponse) return authResponse; + + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }, + { status: 400 } + ); + } + + try { + const { searchParams } = new URL(request.url); + const targetProvider = searchParams.get("targetProvider") === "amazon-q" ? "amazon-q" : "kiro"; + const validation = validateBody(kiroApiKeyImportSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const { apiKey, region } = validation.data; + const kiroService = new KiroService(); + const credential = await kiroService.validateApiKey(apiKey, region || "us-east-1"); + const email = kiroService.extractEmailFromJWT(credential.accessToken); + + const connection: any = await createProviderConnection({ + provider: targetProvider, + authType: "apikey", + name: buildKiroApiKeyConnectionName(targetProvider, credential.region, apiKey), + apiKey: credential.accessToken, + accessToken: credential.accessToken, + refreshToken: null, + // Long-lived key with no scheduled refresh. Keep a future timestamp so + // health/token paths do not treat the connection as immediately expired. + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + email: email || null, + providerSpecificData: { + profileArn: credential.profileArn, + region: credential.region, + authMethod: "api_key", + provider: "API Key", + }, + testStatus: "active", + }); + + await syncToCloudIfEnabled(); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + }, + }); + } catch (error) { + console.error("Kiro API key import error:", error); + return NextResponse.json( + { error: buildKiroImportError(error) }, + { status: isKiroApiKeyImportClientError(error) ? 400 : 500 } + ); + } +} + +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing to cloud after Kiro API key import:", error); + } +} diff --git a/src/lib/oauth/services/kiro.ts b/src/lib/oauth/services/kiro.ts index 86601b3f2c..7023ddffa9 100644 --- a/src/lib/oauth/services/kiro.ts +++ b/src/lib/oauth/services/kiro.ts @@ -448,6 +448,80 @@ export class KiroService { return null; } + /** + * List available CodeWhisperer profiles for an access token or long-lived API key. + * Some long-lived API keys can call GenerateAssistantResponse but are explicitly + * denied on ListAvailableProfiles, so callers must treat an AccessDenied profile + * lookup as an optional discovery failure rather than a hard auth failure. + */ + async listAvailableProfiles(accessToken: string, region: string = "us-east-1") { + assertValidAwsRegion(region); + const endpoint = + region === "us-east-1" + ? "https://codewhisperer.us-east-1.amazonaws.com" + : `https://q.${region}.amazonaws.com`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles", + Accept: "application/json", + tokentype: "API_KEY", + }, + body: JSON.stringify({ maxResults: 10 }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to list profiles: ${error}`); + } + + const data = await response.json(); + const profiles = Array.isArray(data?.profiles) ? data.profiles : []; + const arnOf = (profile: any) => profile?.arn || profile?.profileArn || null; + const match = + profiles.find((profile: any) => String(arnOf(profile) || "").includes(`:${region}:`)) || + profiles[0]; + return arnOf(match); + } + + /** + * Normalize a long-lived Kiro/CodeWhisperer API key. + */ + async validateApiKey(apiKey: string, regionInput?: string) { + // Default kept OUT of the parameter list: check-public-creds' CRED_KEY_RE matches the + // `apiKey:` annotation and flags any string literal in the signature (fn-param FP class). + const region = regionInput || "us-east-1"; + assertValidAwsRegion(region); + const accessToken = apiKey.trim(); + if (!accessToken) { + throw new Error("API key is required"); + } + + let profileArn: string | null = null; + try { + profileArn = await this.listAvailableProfiles(accessToken, region); + } catch (error: any) { + const message = String(error?.message || error || ""); + const isApiKeyProfileDenied = + message.includes("AccessDeniedException") && + message.includes("API key authentication is not supported for this operation"); + if (!isApiKeyProfileDenied) { + throw error; + } + } + + return { + accessToken, + refreshToken: null, + profileArn, + region, + authMethod: "api_key", + }; + } + /** * Fetch user email from access token (optional, for display) */ diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 70a1dda4a5..f97d8eeb13 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -16,6 +16,7 @@ import { resolveNvidiaValidationModel } from "@/lib/providers/nvidiaValidationMo import { MODAL_DEFAULT_VALIDATION_MODEL_ID } from "@/shared/constants/modal"; import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts"; import { validateImageProviderApiKey } from "@/lib/providers/imageValidation"; +import { KiroService } from "@/lib/oauth/services/kiro"; import { OPENAI_LIKE_FORMATS, @@ -210,6 +211,74 @@ export async function validateBytezProvider({ apiKey, providerSpecificData = {} } } +async function validateKiroApiKeyRuntimeProbe({ + apiKey, + region, + profileArn, +}: { + apiKey: string; + region: string; + profileArn?: string | null; +}) { + const endpoint = + region === "us-east-1" + ? "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse" + : `https://q.${region}.amazonaws.com/generateAssistantResponse`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + try { + const body = { + ...(profileArn ? { profileArn } : {}), + conversationState: { + chatTriggerType: "MANUAL", + conversationId: crypto.randomUUID(), + currentMessage: { + userInputMessage: { + content: "ping", + modelId: "auto", + origin: "AI_EDITOR", + }, + }, + history: [], + }, + inferenceConfig: { + maxTokens: 1, + }, + }; + + const res = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + tokentype: "API_KEY", + "Content-Type": "application/x-amz-json-1.0", + "X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse", + Accept: "application/vnd.amazon.eventstream", + "Amz-Sdk-Request": "attempt=1; max=3", + "Amz-Sdk-Invocation-Id": crypto.randomUUID(), + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + await res.body?.cancel().catch(() => undefined); + + if (res.ok) { + return { valid: true, error: null, method: "kiro_generate_assistant_response" }; + } + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid Kiro API key or AWS region" }; + } + if (res.status === 400 || res.status === 422 || res.status === 429) { + return { valid: true, error: null, method: `kiro_generate_assistant_response_${res.status}` }; + } + return { valid: false, error: `Kiro validation failed: ${res.status}` }; + } finally { + clearTimeout(timeout); + } +} + export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) { const requiresApiKey = !providerAllowsOptionalApiKey(provider); const isLocal = isLocalProvider(provider); @@ -325,9 +394,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi // key to check upstream. The only meaningful validation is confirming the // `auggie` binary is installed and runnable on this machine. auggie: async () => { - const { checkAuggieCliVersion } = await import( - "@omniroute/open-sse/executors/auggie.ts" - ); + const { checkAuggieCliVersion } = await import("@omniroute/open-sse/executors/auggie.ts"); const result = await checkAuggieCliVersion(); if (!result.ok) { return { @@ -374,6 +441,26 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi return toValidationErrorResult(err); } }, + kiro: async ({ apiKey, providerSpecificData }: any) => { + try { + const region = providerSpecificData?.region || "us-east-1"; + const credential = await new KiroService().validateApiKey(apiKey, region); + if (!credential.profileArn) { + return await validateKiroApiKeyRuntimeProbe({ + apiKey: credential.accessToken, + region: credential.region, + profileArn: providerSpecificData?.profileArn, + }); + } + return { + valid: true, + error: null, + method: "kiro_list_available_profiles", + }; + } catch (error: any) { + return toValidationErrorResult(error); + } + }, "command-code": validateCommandCodeProvider, huggingface: validateHuggingFaceProvider, // #5422: auth-only probe — Bytez 404s on every chat model until the account adds it to diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 588350d523..6dcafb051f 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -75,6 +75,7 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ "vertex", "vertex-partner", "kimi-coding-apikey", + "kiro", // Qoder connections are PAT-based (authType "apikey"); the usage fetcher // exchanges the PAT for a job token and reads openapi.qoder.sh/user/status. "qoder", @@ -187,7 +188,8 @@ export function isSupportedUsageConnection(connection: ProviderConnectionLike | if (connection.authType === "oauth") return true; return ( - connection.authType === "apikey" && PROVIDER_LIMITS_APIKEY_PROVIDERS.has(connection.provider) + (connection.authType === "apikey" || connection.authType === "api_key") && + PROVIDER_LIMITS_APIKEY_PROVIDERS.has(connection.provider) ); } diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index 84b6550aa3..b440bad49f 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -38,6 +38,29 @@ const POLICIES: Record = { MANAGEMENT: managementPolicy, }; +let staleDashboardJwtWarningEmitted = false; + +function isStaleDashboardJwtError(error: unknown): boolean { + const code = + typeof error === "object" && + error !== null && + "code" in error && + typeof (error as { code?: unknown }).code === "string" + ? (error as { code: string }).code + : ""; + + if ( + code === "ERR_JWS_SIGNATURE_VERIFICATION_FAILED" || + code === "ERR_JWT_EXPIRED" || + code === "ERR_JWS_INVALID" || + code === "ERR_JWT_CLAIM_VALIDATION_FAILED" + ) { + return true; + } + + return error instanceof Error && error.message.includes("signature verification failed"); +} + function stampSubject(headers: Headers, subject: AuthSubject): void { headers.set(AUTHZ_HEADER_AUTH_KIND, subject.kind); headers.set(AUTHZ_HEADER_AUTH_ID, subject.id); @@ -144,6 +167,15 @@ async function refreshDashboardSessionIfNeeded( path: "/", }); } catch (error) { + if (isStaleDashboardJwtError(error)) { + response.cookies.delete("auth_token"); + if (!staleDashboardJwtWarningEmitted) { + staleDashboardJwtWarningEmitted = true; + console.warn("[Authz] Dropped stale dashboard session cookie during auto-refresh"); + } + return; + } + console.error("[Authz] JWT auto-refresh failed:", error); } } diff --git a/src/shared/components/KiroAuthModal.tsx b/src/shared/components/KiroAuthModal.tsx index f17d5f49e4..bf890af46c 100644 --- a/src/shared/components/KiroAuthModal.tsx +++ b/src/shared/components/KiroAuthModal.tsx @@ -28,8 +28,11 @@ export default function KiroAuthModal({ const [idcStartUrl, setIdcStartUrl] = useState(""); const [idcRegion, setIdcRegion] = useState("us-east-1"); const [refreshToken, setRefreshToken] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [apiKeyRegion, setApiKeyRegion] = useState("us-east-1"); const [error, setError] = useState(null); const [importing, setImporting] = useState(false); + const [importingApiKey, setImportingApiKey] = useState(false); const [autoDetecting, setAutoDetecting] = useState(false); const [autoDetected, setAutoDetected] = useState(false); // IDC/organization credentials returned by auto-import when the SSO cache token @@ -126,6 +129,43 @@ export default function KiroAuthModal({ } }; + const handleImportApiKey = async () => { + if (!apiKey.trim()) { + setError("Please enter a Kiro API key"); + return; + } + + setImportingApiKey(true); + setError(null); + + try { + const res = await fetch( + `/api/oauth/kiro/api-key?targetProvider=${encodeURIComponent(providerId)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: apiKey.trim(), + region: apiKeyRegion.trim() || "us-east-1", + }), + } + ); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error?.message || data.error || "API key import failed"); + } + + onMethodSelect("api-key"); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "API key import failed"); + } finally { + setImportingApiKey(false); + } + }; + const handleIdcContinue = () => { if (!idcStartUrl.trim()) { setError("Please enter your IDC start URL"); @@ -226,6 +266,23 @@ export default function KiroAuthModal({ + + {/* API Key */} + )} @@ -419,6 +476,58 @@ export default function KiroAuthModal({ )} )} + + {/* API Key Import */} + {selectedMethod === "api-key" && ( +
+
+ + setApiKey(e.target.value)} + placeholder={`Paste your ${providerLabel} API key...`} + className="font-mono text-sm" + /> +

+ Stored encrypted as a long-lived bearer credential. There is no refresh flow. +

+
+ +
+ + setApiKeyRegion(e.target.value)} + placeholder="us-east-1" + className="font-mono text-sm" + /> +

+ AWS region for the key (default: us-east-1) +

+
+ + {error && ( +
+

{error}

+
+ )} + +
+ + +
+
+ )} ); diff --git a/src/shared/components/KiroOAuthWrapper.tsx b/src/shared/components/KiroOAuthWrapper.tsx index aff30c2bdd..19b91ee85f 100644 --- a/src/shared/components/KiroOAuthWrapper.tsx +++ b/src/shared/components/KiroOAuthWrapper.tsx @@ -24,7 +24,7 @@ export default function KiroOAuthWrapper({ onClose, reauthConnection, }: KiroOAuthWrapperProps) { - const [authMethod, setAuthMethod] = useState(null); // null | "builder-id" | "idc" | "social" | "import" + const [authMethod, setAuthMethod] = useState(null); // null | "builder-id" | "idc" | "social" | "import" | "api-key" const [socialProvider, setSocialProvider] = useState(null); // "google" | "github" const [idcConfig, setIdcConfig] = useState(null); @@ -44,6 +44,9 @@ export default function KiroOAuthWrapper({ } else if (method === "import") { // Import handled in KiroAuthModal, just close onSuccess?.(); + } else if (method === "api-key") { + // API-key import is handled in KiroAuthModal. + onSuccess?.(); } }, [onSuccess] diff --git a/src/shared/constants/pricing/oauth-subscriptions.ts b/src/shared/constants/pricing/oauth-subscriptions.ts index 04792b5ea1..fcf65066dc 100644 --- a/src/shared/constants/pricing/oauth-subscriptions.ts +++ b/src/shared/constants/pricing/oauth-subscriptions.ts @@ -584,10 +584,8 @@ export const DEFAULT_PRICING_OAUTH = { reasoning: 8.0, cache_creation: 2.0, }, - // Kiro "Auto" pricing — retained as a fallback price for any legacy "auto" - // reference. The "auto-kiro" registry model was removed (Kiro's API has no - // "auto" model id — it 400'd "Invalid model"), so its dedicated price key - // was dropped with it. See kiro cluster #6112/#6113/#6099. + // Kiro "Auto" pricing — retained for both the upstream "auto" id and the + // local "auto-kiro" selector. The translator maps auto-kiro back to auto. auto: { input: 3.0, output: 15.0, @@ -595,5 +593,12 @@ export const DEFAULT_PRICING_OAUTH = { reasoning: 15.0, cache_creation: 3.0, }, + "auto-kiro": { + input: 3.0, + output: 15.0, + cached: 1.5, + reasoning: 15.0, + cache_creation: 3.0, + }, }, }; diff --git a/src/shared/validation/schemas/auth.ts b/src/shared/validation/schemas/auth.ts index 1e251aa596..0872bc890d 100644 --- a/src/shared/validation/schemas/auth.ts +++ b/src/shared/validation/schemas/auth.ts @@ -200,6 +200,11 @@ export const kiroImportSchema = z.object({ scopes: z.union([z.string(), z.array(z.string())]).optional(), }); +export const kiroApiKeyImportSchema = z.object({ + apiKey: z.string().trim().min(1, "API key is required"), + region: z.string().trim().default("us-east-1"), +}); + export const zedImportSchema = z.object({ confirmedAccounts: z.array(confirmedAccountSchema), }); diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts index 2b43287911..34703f270e 100644 --- a/tests/unit/authz/pipeline.test.ts +++ b/tests/unit/authz/pipeline.test.ts @@ -66,6 +66,7 @@ test.beforeEach(() => { }); test.after(() => { + core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); if (ORIGINAL_JWT === undefined) delete process.env.JWT_SECRET; else process.env.JWT_SECRET = ORIGINAL_JWT; @@ -564,3 +565,38 @@ test("runAuthzPipeline refreshes dashboard JWTs near expiry", async () => { assert.equal(response.status, 200); assert.match(response.headers.get("set-cookie") || "", /auth_token=/); }); + +test("runAuthzPipeline clears stale dashboard JWTs without error-stack noise", async () => { + await forceAuthRequired(); + const oldSecret = new TextEncoder().encode("old-dashboard-jwt-secret"); + const staleToken = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(oldSecret); + + const errorCalls: unknown[][] = []; + const originalError = console.error; + const originalWarn = console.warn; + console.error = (...args: unknown[]) => { + errorCalls.push(args); + }; + console.warn = () => {}; + + try { + const response = await pipeline.runAuthzPipeline( + request("http://localhost/dashboard", { + headers: { cookie: `auth_token=${staleToken}` }, + }), + { enforce: true } + ); + + assert.equal(response.status, 307); + const setCookie = response.headers.get("set-cookie") || ""; + assert.match(setCookie, /auth_token=/); + assert.match(setCookie, /Max-Age=0|Expires=/i); + assert.equal(errorCalls.length, 0); + } finally { + console.error = originalError; + console.warn = originalWarn; + } +}); diff --git a/tests/unit/executor-kiro.test.ts b/tests/unit/executor-kiro.test.ts index 61f5a4493c..8fde25b3e3 100644 --- a/tests/unit/executor-kiro.test.ts +++ b/tests/unit/executor-kiro.test.ts @@ -147,6 +147,17 @@ test("KiroExecutor.buildHeaders includes Kiro-specific auth and metadata", () => assert.ok(headers["Amz-Sdk-Invocation-Id"]); }); +test("KiroExecutor.buildHeaders marks long-lived Kiro API keys", () => { + const executor = new KiroExecutor(); + const headers = executor.buildHeaders( + { apiKey: "kiro-api-key", providerSpecificData: { authMethod: "api_key" } }, + true + ); + + assert.equal(headers.Authorization, "Bearer kiro-api-key"); + assert.equal(headers.tokentype, "API_KEY"); +}); + test("KiroExecutor.transformRequest removes the top-level model field", () => { const executor = new KiroExecutor(); const body = { @@ -382,6 +393,13 @@ test("KiroExecutor.refreshCredentials handles missing and AWS-style refresh toke try { assert.equal(await executor.refreshCredentials({}, null), null); + assert.equal( + await executor.refreshCredentials( + { refreshToken: "ignored", providerSpecificData: { authMethod: "api_key" } }, + null + ), + null + ); const result = await executor.refreshCredentials( { refreshToken: "refresh", diff --git a/tests/unit/kiro-api-key-route-helpers.test.ts b/tests/unit/kiro-api-key-route-helpers.test.ts new file mode 100644 index 0000000000..a40a6a7b9f --- /dev/null +++ b/tests/unit/kiro-api-key-route-helpers.test.ts @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildKiroApiKeyConnectionName, + isKiroApiKeyImportClientError, +} from "../../src/app/api/oauth/kiro/api-key/helpers.ts"; + +test("Kiro API key connection names differ for different keys in the same region", () => { + const first = buildKiroApiKeyConnectionName("kiro", "us-east-1", "ksk_first_key"); + const second = buildKiroApiKeyConnectionName("kiro", "us-east-1", "ksk_second_key"); + + assert.match(first, /^Kiro API Key \(us-east-1, [a-f0-9]{8}\)$/); + assert.match(second, /^Kiro API Key \(us-east-1, [a-f0-9]{8}\)$/); + assert.notEqual(first, second); +}); + +test("Kiro API key connection names are stable for the same trimmed key", () => { + const first = buildKiroApiKeyConnectionName("amazon-q", "eu-west-1", " ksk_same_key "); + const second = buildKiroApiKeyConnectionName("amazon-q", "eu-west-1", "ksk_same_key"); + + assert.equal(first, second); + assert.match(first, /^Amazon Q API Key \(eu-west-1, [a-f0-9]{8}\)$/); +}); + +test("Kiro API key import classifies client validation failures as 400-class", () => { + assert.equal(isKiroApiKeyImportClientError(new Error("API key is required")), true); + assert.equal(isKiroApiKeyImportClientError(new Error("Invalid region")), true); + assert.equal( + isKiroApiKeyImportClientError(new Error("Failed to list profiles: Invalid API key")), + true + ); +}); + +test("Kiro API key import leaves network/server failures as 500-class", () => { + assert.equal(isKiroApiKeyImportClientError(new Error("fetch failed")), false); + assert.equal(isKiroApiKeyImportClientError(new Error("ECONNRESET")), false); +}); diff --git a/tests/unit/kiro-api-key-service.test.ts b/tests/unit/kiro-api-key-service.test.ts new file mode 100644 index 0000000000..36ebe686df --- /dev/null +++ b/tests/unit/kiro-api-key-service.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { KiroService } from "../../src/lib/oauth/services/kiro.ts"; + +test("KiroService.validateApiKey validates via ListAvailableProfiles with API_KEY token type", async () => { + const service = new KiroService(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async (url: string, init?: RequestInit) => { + assert.equal(url, "https://q.eu-central-1.amazonaws.com"); + const headers = init?.headers as Record; + assert.equal(headers.Authorization, "Bearer kiro-api-key"); + assert.equal(headers.tokentype, "API_KEY"); + assert.equal(headers["x-amz-target"], "AmazonCodeWhispererService.ListAvailableProfiles"); + return new Response( + JSON.stringify({ + profiles: [ + { arn: "arn:aws:codewhisperer:us-east-1:1:profile/OTHER" }, + { arn: "arn:aws:codewhisperer:eu-central-1:1:profile/MATCH" }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + + try { + const credential = await service.validateApiKey(" kiro-api-key ", "eu-central-1"); + assert.equal(credential.accessToken, "kiro-api-key"); + assert.equal(credential.refreshToken, null); + assert.equal(credential.authMethod, "api_key"); + assert.equal(credential.region, "eu-central-1"); + assert.equal(credential.profileArn, "arn:aws:codewhisperer:eu-central-1:1:profile/MATCH"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("KiroService.validateApiKey accepts API keys when profile discovery is denied", async () => { + const service = new KiroService(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + __type: "com.amazon.aws.codewhisperer#AccessDeniedException", + message: "API key authentication is not supported for this operation.", + }), + { status: 403, headers: { "Content-Type": "application/json" } } + )) as typeof fetch; + + try { + const credential = await service.validateApiKey(" kiro-api-key ", "us-east-1"); + assert.equal(credential.accessToken, "kiro-api-key"); + assert.equal(credential.refreshToken, null); + assert.equal(credential.authMethod, "api_key"); + assert.equal(credential.region, "us-east-1"); + assert.equal(credential.profileArn, null); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/kiro-available-models.test.ts b/tests/unit/kiro-available-models.test.ts index 4f57ec1a92..58a0841f34 100644 --- a/tests/unit/kiro-available-models.test.ts +++ b/tests/unit/kiro-available-models.test.ts @@ -1,4 +1,4 @@ -import test from "node:test"; +import test, { beforeEach } from "node:test"; import assert from "node:assert/strict"; import { @@ -6,10 +6,15 @@ import { resolveKiroRegion, buildKiroModelsEndpoints, fetchKiroAvailableModels, + clearKiroModelCache, } from "../../open-sse/services/kiroModels.ts"; const FALLBACK = [{ id: "auto-kiro", name: "Auto" }, { id: "claude-sonnet-4.6" }]; +beforeEach(() => { + clearKiroModelCache(); +}); + function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -70,7 +75,14 @@ test("fetchKiroAvailableModels: simple (Builder ID) account, us-east-1, origin-o }); assert.equal(result.source, "api"); - assert.deepEqual(result.models.map((m) => m.id).sort(), ["auto", "claude-sonnet-4.6"]); + assert.deepEqual(result.models.map((m) => m.id).sort(), [ + "auto", + "auto-thinking", + "claude-sonnet-4.6", + "claude-sonnet-4.6-agentic", + "claude-sonnet-4.6-thinking", + "claude-sonnet-4.6-thinking-agentic", + ]); assert.deepEqual(calls, [ "https://q.us-east-1.amazonaws.com/ListAvailableModels?origin=AI_EDITOR", ]); @@ -94,7 +106,12 @@ test("fetchKiroAvailableModels: IAM Identity Center account, region-matched endp assert.equal(result.source, "api"); assert.deepEqual( result.models.map((m) => m.id), - ["claude-opus-4.8"] + [ + "claude-opus-4.8", + "claude-opus-4.8-thinking", + "claude-opus-4.8-agentic", + "claude-opus-4.8-thinking-agentic", + ] ); assert.equal( calls[0], @@ -125,7 +142,12 @@ test("fetchKiroAvailableModels: retries with profileArn when origin-only fails", assert.equal(result.source, "api"); assert.deepEqual( result.models.map((m) => m.id), - ["claude-sonnet-4.6"] + [ + "claude-sonnet-4.6", + "claude-sonnet-4.6-thinking", + "claude-sonnet-4.6-agentic", + "claude-sonnet-4.6-thinking-agentic", + ] ); // origin-only attempted first, then profileArn retry. assert.equal(calls.length, 2); diff --git a/tests/unit/kiro-iam-profilearn-usage.test.ts b/tests/unit/kiro-iam-profilearn-usage.test.ts index 3afd99f2f4..9bfc7f4132 100644 --- a/tests/unit/kiro-iam-profilearn-usage.test.ts +++ b/tests/unit/kiro-iam-profilearn-usage.test.ts @@ -1,169 +1,227 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { - __testing, - buildKiroUsageResult, - discoverKiroProfileArn, -} from "@omniroute/open-sse/services/usage.ts"; - -const { getKiroUsage } = __testing; - -// Real-world shape returned by GetUsageLimits for an AWS IAM Identity Center ("KIRO POWER") -// account — the usage is reported under resourceType "CREDIT" (not "AGENTIC_REQUEST"). -const IAM_CREDIT_RESPONSE = { - daysUntilReset: 0, - nextDateReset: 1.782864e9, - subscriptionInfo: { subscriptionTitle: "KIRO POWER", type: "Q_DEVELOPER_STANDALONE_POWER" }, - usageBreakdownList: [ - { - currency: "USD", - currentUsage: 3670, - currentUsageWithPrecision: 3670.9, - displayName: "Credit", - resourceType: "CREDIT", - unit: "INVOCATIONS", - usageLimit: 10000, - usageLimitWithPrecision: 10000.0, - }, - ], -}; - -test("buildKiroUsageResult parses the IAM CREDIT breakdown into non-zero usage", () => { - const result = buildKiroUsageResult(IAM_CREDIT_RESPONSE) as { - plan: string; - quotas: Record; - }; - assert.ok("quotas" in result, "must return quotas for a CREDIT breakdown"); - assert.equal(result.plan, "KIRO POWER"); - const credit = result.quotas.credit; - assert.ok(credit, "CREDIT resource should map to a 'credit' quota key"); - assert.equal(credit.used, 3670.9); - assert.equal(credit.total, 10000); - assert.equal(credit.remaining, 10000 - 3670.9); -}); - -test("discoverKiroProfileArn prefers the region-matched profile ARN", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - profiles: [ - { arn: "arn:aws:codewhisperer:us-east-1:111111111111:profile/AAAA" }, - { arn: "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ" }, - ], - }), - { status: 200, headers: { "Content-Type": "application/json" } } - )) as typeof fetch; - try { - const arn = await discoverKiroProfileArn( - "tok", - "https://q.eu-central-1.amazonaws.com", - "eu-central-1" - ); - assert.equal(arn, "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("discoverKiroProfileArn falls back to the first profile when no region match", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => - new Response( - JSON.stringify({ profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/X" }] }), - { status: 200, headers: { "Content-Type": "application/json" } } - )) as typeof fetch; - try { - const arn = await discoverKiroProfileArn("tok", "https://q.eu-west-1.amazonaws.com", "eu-west-1"); - assert.equal(arn, "arn:aws:codewhisperer:us-east-1:1:profile/X"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("discoverKiroProfileArn returns undefined for empty profiles or non-ok response", async () => { - const originalFetch = globalThis.fetch; - try { - globalThis.fetch = (async () => - new Response(JSON.stringify({ profiles: [] }), { status: 200 })) as typeof fetch; - assert.equal( - await discoverKiroProfileArn("tok", "https://q.eu-central-1.amazonaws.com", "eu-central-1"), - undefined - ); - - globalThis.fetch = (async () => new Response("nope", { status: 403 })) as typeof fetch; - assert.equal( - await discoverKiroProfileArn("tok", "https://q.eu-central-1.amazonaws.com", "eu-central-1"), - undefined - ); - } finally { - globalThis.fetch = originalFetch; - } -}); - -// Regression: when a Kiro account added via Google/GitHub social-auth (authMethod "imported" -// with provider "Google" or "Github" — set by /api/oauth/kiro/social-exchange/route.ts) has its -// token rejected by the AWS CodeWhisperer quota API (401/403), surface a clear "auth expired, -// chat may still work" message instead of throwing a generic upstream-error blob. -test("getKiroUsage returns a friendly auth-expired message for social-auth Kiro on 401/403", async () => { - const originalFetch = globalThis.fetch; - // First call (ListAvailableProfiles for ARN discovery) succeeds with an ARN so we proceed - // to GetUsageLimits, which then returns 401. The friendly branch only applies when the - // GetUsageLimits call returned an auth-shaped error. - let callIdx = 0; - globalThis.fetch = (async (_url: string, init?: RequestInit) => { - callIdx += 1; - const target = String((init?.headers as Record | undefined)?.["x-amz-target"] || ""); - if (target.endsWith("ListAvailableProfiles")) { - return new Response( - JSON.stringify({ profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/SOCIAL" }] }), - { status: 200, headers: { "Content-Type": "application/json" } } - ); - } - // GetUsageLimits → simulate the social-auth token rejection - return new Response(JSON.stringify({ __type: "AccessDeniedException" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); - }) as typeof fetch; - try { - const result = (await getKiroUsage("social-tok", { - authMethod: "imported", - provider: "Google", - })) as { message?: string; quotas?: Record }; - assert.ok(result, "should resolve, not throw"); - assert.ok( - typeof result.message === "string" && /authentication expired/i.test(result.message), - `expected an auth-expired message, got: ${JSON.stringify(result)}` - ); - assert.deepEqual(result.quotas ?? {}, {}); - assert.ok(callIdx >= 2, "GetUsageLimits should have been called after profile discovery"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -// IAM Identity Center / Builder-ID accounts must keep the existing throw-on-failure behavior so -// transient upstream errors don't get silently masked as "auth expired". -test("getKiroUsage still throws on 401/403 for non-social Kiro accounts (Builder-ID/IDC)", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (_url: string, init?: RequestInit) => { - const target = String((init?.headers as Record | undefined)?.["x-amz-target"] || ""); - if (target.endsWith("ListAvailableProfiles")) { - return new Response( - JSON.stringify({ profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/BID" }] }), - { status: 200, headers: { "Content-Type": "application/json" } } - ); - } - return new Response("denied", { status: 401 }); - }) as typeof fetch; - try { - await assert.rejects( - () => getKiroUsage("builder-tok", { authMethod: "builder-id" }), - /Failed to fetch Kiro usage/i - ); - } finally { - globalThis.fetch = originalFetch; - } -}); +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + __testing, + buildKiroUsageResult, + discoverKiroProfileArn, +} from "@omniroute/open-sse/services/usage.ts"; + +const { getKiroUsage } = __testing; + +// Real-world shape returned by GetUsageLimits for an AWS IAM Identity Center ("KIRO POWER") +// account — the usage is reported under resourceType "CREDIT" (not "AGENTIC_REQUEST"). +const IAM_CREDIT_RESPONSE = { + daysUntilReset: 0, + nextDateReset: 1.782864e9, + subscriptionInfo: { subscriptionTitle: "KIRO POWER", type: "Q_DEVELOPER_STANDALONE_POWER" }, + usageBreakdownList: [ + { + currency: "USD", + currentUsage: 3670, + currentUsageWithPrecision: 3670.9, + displayName: "Credit", + resourceType: "CREDIT", + unit: "INVOCATIONS", + usageLimit: 10000, + usageLimitWithPrecision: 10000.0, + }, + ], +}; + +test("buildKiroUsageResult parses the IAM CREDIT breakdown into non-zero usage", () => { + const result = buildKiroUsageResult(IAM_CREDIT_RESPONSE) as { + plan: string; + quotas: Record; + }; + assert.ok("quotas" in result, "must return quotas for a CREDIT breakdown"); + assert.equal(result.plan, "KIRO POWER"); + const credit = result.quotas.credit; + assert.ok(credit, "CREDIT resource should map to a 'credit' quota key"); + assert.equal(credit.used, 3670.9); + assert.equal(credit.total, 10000); + assert.equal(credit.remaining, 10000 - 3670.9); +}); + +test("discoverKiroProfileArn prefers the region-matched profile ARN", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + profiles: [ + { arn: "arn:aws:codewhisperer:us-east-1:111111111111:profile/AAAA" }, + { arn: "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ" }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + )) as typeof fetch; + try { + const arn = await discoverKiroProfileArn( + "tok", + "https://q.eu-central-1.amazonaws.com", + "eu-central-1" + ); + assert.equal(arn, "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("discoverKiroProfileArn sends tokentype for API-key auth", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + const headers = init?.headers as Record; + assert.equal(headers.tokentype, "API_KEY"); + return new Response( + JSON.stringify({ + profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/APIKEY" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + try { + const arn = await discoverKiroProfileArn( + "api-key", + "https://codewhisperer.us-east-1.amazonaws.com", + "us-east-1", + "api_key" + ); + assert.equal(arn, "arn:aws:codewhisperer:us-east-1:1:profile/APIKEY"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("discoverKiroProfileArn falls back to the first profile when no region match", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/X" }] }), + { status: 200, headers: { "Content-Type": "application/json" } } + )) as typeof fetch; + try { + const arn = await discoverKiroProfileArn( + "tok", + "https://q.eu-west-1.amazonaws.com", + "eu-west-1" + ); + assert.equal(arn, "arn:aws:codewhisperer:us-east-1:1:profile/X"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("discoverKiroProfileArn returns undefined for empty profiles or non-ok response", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async () => + new Response(JSON.stringify({ profiles: [] }), { status: 200 })) as typeof fetch; + assert.equal( + await discoverKiroProfileArn("tok", "https://q.eu-central-1.amazonaws.com", "eu-central-1"), + undefined + ); + + globalThis.fetch = (async () => new Response("nope", { status: 403 })) as typeof fetch; + assert.equal( + await discoverKiroProfileArn("tok", "https://q.eu-central-1.amazonaws.com", "eu-central-1"), + undefined + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// Regression: when a Kiro account added via Google/GitHub social-auth (authMethod "imported" +// with provider "Google" or "Github" — set by /api/oauth/kiro/social-exchange/route.ts) has its +// token rejected by the AWS CodeWhisperer quota API (401/403), surface a clear "auth expired, +// chat may still work" message instead of throwing a generic upstream-error blob. +test("getKiroUsage returns a friendly auth-expired message for social-auth Kiro on 401/403", async () => { + const originalFetch = globalThis.fetch; + // First call (ListAvailableProfiles for ARN discovery) succeeds with an ARN so we proceed + // to GetUsageLimits, which then returns 401. The friendly branch only applies when the + // GetUsageLimits call returned an auth-shaped error. + let callIdx = 0; + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + callIdx += 1; + const target = String( + (init?.headers as Record | undefined)?.["x-amz-target"] || "" + ); + if (target.endsWith("ListAvailableProfiles")) { + return new Response( + JSON.stringify({ profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/SOCIAL" }] }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + // GetUsageLimits → simulate the social-auth token rejection + return new Response(JSON.stringify({ __type: "AccessDeniedException" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + try { + const result = (await getKiroUsage("social-tok", { + authMethod: "imported", + provider: "Google", + })) as { message?: string; quotas?: Record }; + assert.ok(result, "should resolve, not throw"); + assert.ok( + typeof result.message === "string" && /authentication expired/i.test(result.message), + `expected an auth-expired message, got: ${JSON.stringify(result)}` + ); + assert.deepEqual(result.quotas ?? {}, {}); + assert.ok(callIdx >= 2, "GetUsageLimits should have been called after profile discovery"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("getKiroUsage sends tokentype for API-key quota requests", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + const headers = init?.headers as Record; + assert.equal(headers.tokentype, "API_KEY"); + return new Response( + JSON.stringify({ + usageBreakdownList: [ + { + resourceType: "AGENTIC_REQUEST", + currentUsageWithPrecision: 1, + usageLimitWithPrecision: 10, + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + try { + const result = (await getKiroUsage("api-key", { + authMethod: "api_key", + profileArn: "arn:aws:codewhisperer:us-east-1:1:profile/APIKEY", + region: "us-east-1", + })) as { quotas?: Record }; + assert.equal(result.quotas?.agentic_request.used, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("getKiroUsage returns a friendly rejected-token message on repeated 401/403", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + return new Response("denied", { status: 401 }); + }) as typeof fetch; + try { + const result = (await getKiroUsage("builder-tok", { + authMethod: "builder-id", + profileArn: "arn:aws:codewhisperer:us-east-1:1:profile/BID", + })) as { message?: string; quotas?: Record }; + assert.match( + result.message || "", + /quota API rejected the current token/i, + `expected friendly rejected-token message, got: ${JSON.stringify(result)}` + ); + assert.deepEqual(result.quotas ?? {}, {}); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/provider-onboarding-wizard.test.ts b/tests/unit/provider-onboarding-wizard.test.ts index adc78f768c..688b738022 100644 --- a/tests/unit/provider-onboarding-wizard.test.ts +++ b/tests/unit/provider-onboarding-wizard.test.ts @@ -12,8 +12,11 @@ test("provider onboarding catalog exposes API-key and OAuth providers for the wi assert.ok(apiKeyOptions.some((option) => option.id === "openai")); assert.ok(apiKeyOptions.some((option) => option.id === "openrouter")); + assert.ok(!apiKeyOptions.some((option) => option.id === "kiro")); + assert.ok(!apiKeyOptions.some((option) => option.id === "amazon-q")); assert.ok(oauthOptions.some((option) => option.id === "claude")); assert.ok(oauthOptions.some((option) => option.id === "kiro")); + assert.ok(oauthOptions.some((option) => option.id === "amazon-q")); assert.ok(oauthOptions.some((option) => option.id === "cursor")); assert.ok(!oauthOptions.some((option) => option.id === "zed")); assert.ok(!oauthOptions.some((option) => option.id === "windsurf")); diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index a97127bc32..a9317bcb38 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -26,7 +26,7 @@ test.afterEach(() => { __setGrokTlsFetchOverride(null); }); -function toPlainHeaders(headers: any) { +function toPlainHeaders(headers: HeadersInit | undefined) { if (headers instanceof Headers) return Object.fromEntries(headers.entries()); return Object.fromEntries( Object.entries(headers || {}).map(([key, value]) => [key, String(value)]) @@ -57,6 +57,129 @@ data: `; } +test("Kiro API key validator resolves profiles with bearer auth", async () => { + const calls: Array<{ url: string; headers: Record }> = []; + globalThis.fetch = async (url, init = {}) => { + const headers = toPlainHeaders(init.headers); + calls.push({ url: String(url), headers }); + + assert.equal(String(url), "https://codewhisperer.us-east-1.amazonaws.com"); + assert.equal(headers.Authorization, "Bearer ksk-valid"); + assert.equal(headers["x-amz-target"], "AmazonCodeWhispererService.ListAvailableProfiles"); + assert.equal(headers.Accept, "application/json"); + + return new Response( + JSON.stringify({ + profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:123:profile/API" }], + }), + { status: 200 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "kiro", + apiKey: "ksk-valid", + providerSpecificData: { region: "us-east-1" }, + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.equal(result.method, "kiro_list_available_profiles"); + assert.equal(calls.length, 1); +}); + +test("Kiro API key validator accepts API keys that cannot list profiles", async () => { + const calls: Array<{ + url: string; + headers: Record; + body?: Record; + }> = []; + globalThis.fetch = async () => new Response("unexpected", { status: 500 }); + + globalThis.fetch = async (url, init = {}) => { + const headers = toPlainHeaders(init.headers); + const body = init.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ url: String(url), headers, body }); + + if (calls.length === 1) { + return new Response( + JSON.stringify({ + __type: "com.amazon.aws.codewhisperer#AccessDeniedException", + message: "API key authentication is not supported for this operation.", + }), + { status: 403 } + ); + } + + assert.equal( + String(url), + "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse" + ); + assert.equal(headers.Authorization, "Bearer ksk-valid-without-profile-list"); + assert.equal(headers.tokentype, "API_KEY"); + assert.equal( + headers["X-Amz-Target"] || headers["x-amz-target"], + "AmazonCodeWhispererStreamingService.GenerateAssistantResponse" + ); + assert.equal(body.conversationState.currentMessage.userInputMessage.modelId, "auto"); + assert.equal(body.inferenceConfig.maxTokens, 1); + return new Response(new ReadableStream(), { status: 200 }); + }; + + const result = await validateProviderApiKey({ + provider: "kiro", + apiKey: "ksk-valid-without-profile-list", + providerSpecificData: { region: "us-east-1" }, + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.equal(result.method, "kiro_generate_assistant_response"); + assert.equal(calls.length, 2); +}); + +test("Kiro API key validator rejects runtime auth failures after profile lookup is unsupported", async () => { + let calls = 0; + globalThis.fetch = async () => { + calls++; + if (calls === 1) { + return new Response( + JSON.stringify({ + __type: "com.amazon.aws.codewhisperer#AccessDeniedException", + message: "API key authentication is not supported for this operation.", + }), + { status: 403 } + ); + } + return new Response(JSON.stringify({ message: "bearer token is invalid" }), { status: 403 }); + }; + + const result = await validateProviderApiKey({ + provider: "kiro", + apiKey: "ksk-runtime-invalid", + providerSpecificData: { region: "us-east-1" }, + }); + + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid Kiro API key or AWS region"); + assert.equal(calls, 2); +}); + +test("Kiro API key validator fails as invalid instead of unsupported", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ message: "Access denied" }), { status: 403 }); + + const result = await validateProviderApiKey({ + provider: "kiro", + apiKey: "ksk-invalid", + providerSpecificData: { region: "us-east-1" }, + }); + + assert.equal(result.valid, false); + assert.equal(result.unsupported, false); + assert.match(result.error || "", /Failed to list profiles/); +}); + test("specialty provider validators cover Deepgram, AssemblyAI, ElevenLabs and Inworld branches", async () => { globalThis.fetch = async (url, init = {}) => { const target = String(url); @@ -711,7 +834,7 @@ test("grok-web validator: Cloudflare challenge page is detected and reported", a const { __setTlsFetchOverrideForTesting } = await import("../../open-sse/services/chatgptTlsClient.ts"); -function makeTlsResponse(status: number, body: string, headers: Record = {}): any { +function makeTlsResponse(status: number, body: string, headers: Record = {}) { const h = new Headers(); for (const [k, v] of Object.entries(headers)) h.set(k, v); return { status, headers: h, text: body, body: null }; @@ -722,7 +845,7 @@ test.afterEach(() => { }); test("chatgpt-web validator: accepts a valid session response with accessToken", async () => { - let captured: { url: string; opts: any } | null = null; + let captured: { url: string; opts: unknown } | null = null; __setTlsFetchOverrideForTesting(async (url, opts) => { captured = { url, opts }; return makeTlsResponse( @@ -2165,7 +2288,12 @@ test("specialty validator rejects invalid Runway credentials", async () => { }); test("validateCommandCodeProvider sends Command Code probe URL, headers, and wrapper body", async () => { - const calls: any[] = []; + const calls: Array<{ + url: string; + method?: string; + headers?: HeadersInit; + body?: BodyInit | null; + }> = []; globalThis.fetch = async (url, init = {}) => { calls.push({ url: String(url), @@ -2230,18 +2358,14 @@ test("validateCommandCodeProvider rejects auth failures and provider outages", a const { __setTlsFetchOverrideForTesting: __setClaudeTlsFetchOverride } = await import("../../open-sse/services/claudeTlsClient.ts"); -function makeClaudeTlsResponse( - status: number, - body: string, - headers: Record = {} -): any { +function makeClaudeTlsResponse(status: number, body: string, headers: Record = {}) { const h = new Headers(); for (const [k, v] of Object.entries(headers)) h.set(k, v); return { status, ok: status >= 200 && status < 300, headers: h, text: body, body: null }; } test("claude-web validator: 200 from /api/organizations → valid", async () => { - let captured: { url: string; opts: any } | null = null; + let captured: { url: string; opts: unknown } | null = null; __setClaudeTlsFetchOverride(async (url, opts) => { captured = { url, opts }; return makeClaudeTlsResponse(200, JSON.stringify({ orgs: [] })); @@ -2576,7 +2700,7 @@ test("llama-cpp is classified as a self-hosted chat provider", async () => { // ─── Gitlawb Opengateway specialty validators ────────────────────────────── test("gitlawb validator: accepts valid API key via chat/completions probe", async () => { - const calls: any[] = []; + const calls: Array<{ url: string; headers?: HeadersInit; body?: BodyInit | null }> = []; globalThis.fetch = async (url, init = {}) => { calls.push({ url: String(url), headers: init.headers || {}, body: init.body }); assert.equal(String(url), "https://opengateway.gitlawb.com/v1/xiaomi-mimo/chat/completions"); @@ -2661,7 +2785,7 @@ test("gitlawb validator: accepts custom baseUrl override", async () => { // ─── Gitlawb-GMI (GMI Cloud) ───────────────────────────────────────────── test("gitlawb-gmi validator: accepts valid API key via chat/completions probe", async () => { - const calls: any[] = []; + const calls: Array<{ url: string; headers?: HeadersInit }> = []; globalThis.fetch = async (url, init = {}) => { calls.push({ url: String(url), headers: init.headers || {} }); assert.equal(String(url), "https://opengateway.gitlawb.com/v1/gmi-cloud/chat/completions"); diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index 33f4718ffe..dbc4bc1162 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -1071,6 +1071,10 @@ test("connectionMatchesProviderCard counts a dual-auth provider's PAT (apikey) c connectionMatchesProviderCard({ provider: "qoder", authType: "apikey" }, "qoder", "oauth"), true ); + assert.equal( + connectionMatchesProviderCard({ provider: "kiro", authType: "api_key" }, "kiro", "oauth"), + true + ); assert.equal( connectionMatchesProviderCard({ provider: "qoder", authType: "oauth" }, "qoder", "oauth"), true diff --git a/tests/unit/shared/components/KiroAuthModal.test.tsx b/tests/unit/shared/components/KiroAuthModal.test.tsx index 728657400c..948072d217 100644 --- a/tests/unit/shared/components/KiroAuthModal.test.tsx +++ b/tests/unit/shared/components/KiroAuthModal.test.tsx @@ -15,6 +15,12 @@ function makeContainer(): HTMLElement { return container; } +function setInputValue(input: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + describe("KiroAuthModal", () => { beforeEach(() => { ( @@ -53,4 +59,54 @@ describe("KiroAuthModal", () => { expect(onMethodSelect).toHaveBeenCalledWith("social", { provider: "google" }); }); + + it("notifies API key import success before closing the modal", async () => { + const { default: KiroAuthModal } = await import("@/shared/components/KiroAuthModal"); + const container = makeContainer(); + const root = createRoot(container); + const calls: string[] = []; + const onMethodSelect = vi.fn(() => calls.push("select")); + const onClose = vi.fn(() => calls.push("close")); + const originalFetch = globalThis.fetch; + + globalThis.fetch = vi.fn(async () => { + return new Response(JSON.stringify({ success: true, connection: { id: "conn-1" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + + try { + await act(async () => { + root.render(); + }); + + const apiKeyButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.querySelector("h3")?.textContent === "API Key" + ); + + await act(async () => { + apiKeyButton?.click(); + }); + + const apiKeyInput = container.querySelector("input") as HTMLInputElement; + const saveButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Validate and Save API Key") + ); + + await act(async () => { + setInputValue(apiKeyInput, "ksk_test_key"); + }); + + await act(async () => { + saveButton?.click(); + }); + + expect(onMethodSelect).toHaveBeenCalledWith("api-key"); + expect(onClose).toHaveBeenCalled(); + expect(calls).toEqual(["select", "close"]); + } finally { + globalThis.fetch = originalFetch; + } + }); }); diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index 3421b2563b..2b34de746b 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -1046,6 +1046,29 @@ test("buildKiroPayload accepts kr/* model ids without the [1m] suffix", () => { ); }); +test("buildKiroPayload strips local Kiro selector suffixes before upstream", () => { + const body = { messages: [{ role: "user", content: "Hello" }] }; + + const result = buildKiroPayload("claude-opus-4.8-thinking-agentic", body, true, {}); + assert.equal( + result.conversationState.currentMessage.userInputMessage.modelId, + "claude-opus-4.8", + "local -thinking/-agentic aliases must not be forwarded to Kiro" + ); + assert.equal( + result.additionalModelRequestFields?.output_config?.effort, + "high", + "the -thinking selector should still request Kiro adaptive thinking" + ); +}); + +test("buildKiroPayload maps auto-kiro selector to Kiro auto upstream id", () => { + const body = { messages: [{ role: "user", content: "Hello" }] }; + + const result = buildKiroPayload("auto-kiro", body, true, {}); + assert.equal(result.conversationState.currentMessage.userInputMessage.modelId, "auto"); +}); + // Regression for upstream decolua/9router PR #2270: the dash->dot normalization's // trailing minor-version group must be bounded (1-2 digits), otherwise a // date-suffixed Claude model id (e.g. claude-opus-4-20250514) gets corrupted into From 6103fd72396e0e0cf6145b39fdd4c540addd15e0 Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Fri, 10 Jul 2026 04:19:32 +0200 Subject: [PATCH 3/5] fix: Stabilize live dashboard WebSocket routing (#6335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): allow anonymous WS handshake + public /api/health/ping The live-dashboard WebSocket descriptor handshake (GET /api/v1/ws?handshake=1) and the lightweight GET /api/health/ping liveness probe both 401'd for unauthenticated callers, even though both are metadata-only reads intended to be public. clientApiPolicy required a bearer/dashboard-session before the WS route handler could even return its own wsAuth/protocol descriptor, and /api/health/ping was never added to PUBLIC_READONLY_API_ROUTE_PREFIXES despite its own docstring documenting it as "No auth required". clientApiPolicy.evaluate() now allows an anonymous {kind:"anonymous", id:"ws-handshake"} subject for GET/HEAD/OPTIONS on /api/v1/ws?handshake=1 — the route handler still performs its own real wsAuth/dashboard/API-key decision before opening the socket — and /api/health/ping is now in PUBLIC_READONLY_API_ROUTE_PREFIXES. Re-scoped from the original PR per review-group-prs analysis: the overlapping hardcoded /live-ws path-derivation change (useLiveDashboard.ts, ws/route.ts) is dropped here since it conflicts with #6072's different (dynamic, env-derived) approach to the same problem; only the non-overlapping auth-policy win ships in this PR. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(changelog): resync CHANGELOG.md after merging release/v3.8.47 Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: JxnLexn --- CHANGELOG.md | 1 + src/server/authz/policies/clientApi.ts | 20 +++++++++++ src/shared/constants/publicApiRoutes.ts | 1 + tests/unit/authz/classify.test.ts | 6 ++++ tests/unit/authz/client-api-policy.test.ts | 42 +++++++++++++++++++++- tests/unit/public-api-routes.test.ts | 5 +++ 6 files changed, 74 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06bb015be9..d991eaf35f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **fix(docker):** compile better-sqlite3 for the server Docker image (Dokploy/self-hosted builds) via a direct `node-gyp rebuild` inside `node_modules/better-sqlite3`, instead of `npm rebuild better-sqlite3` ([#6700](https://github.com/diegosouzapw/OmniRoute/pull/6700)) — the `builder` stage installs dependencies with `npm ci --ignore-scripts` (deliberate: closes the supply-chain surface where a transitive dep's install script runs arbitrary code) and re-enables the native build for the one package that needs it; `npm rebuild ` re-runs that indirectly through the package's own install script, which under npm 11 depends on npm's script-allowlist machinery correctly re-enabling it — some self-hosted build environments (e.g. Dokploy) hit a broken/mismatched native binding through that indirection. Invoking `node-gyp rebuild` directly bypasses npm's script-running layer entirely and is deterministic regardless of npm version. Regression guard: `tests/unit/dockerfile-better-sqlite3-node-gyp-6700.test.ts`. (thanks @nowhats-br) - **fix(providers):** the Cloudflare relay Worker deploy fix in #6416/#6618 still failed uploads in practice — it changed the multipart Content-Type but kept the emitted worker source as an ES module (`export default { fetch(...) }`) with `main_module` metadata; Cloudflare's Workers upload API parses a plain `application/javascript` script part as Service Worker syntax regardless of the `main_module` metadata field, and `main_module` requires the script to actually be an ES module (top-level `export`), so the mismatch still rejected the upload ([#6496](https://github.com/diegosouzapw/OmniRoute/pull/6496)). `buildCloudflareWorkerScript()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now emits Service Worker syntax (`addEventListener("fetch", ...)`, no top-level `export`) and the upload metadata uses `body_part` instead of `main_module`. Regression guard: `tests/unit/relay-deploy-5128.test.ts` (asserts the emitted script has no `export default`, registers a `fetch` listener, and the upload metadata carries `body_part`/omits `main_module`; also proves the inlined `isPrivateHostname()` SSRF guard still rejects bracketed IPv6 loopback/ULA hosts like `[::1]`/`[fd00::1]` after the script-body rewrite). (thanks @SeaXen) - **fix(providers):** ChatGPT Web (`chatgpt-web`) responses rendered raw ChatGPT UI citation markup — private-use marker tokens (e.g. `citeturn0search0`) and `url…` inline-link markers — instead of real Markdown links, since these only ever get resolved client-side by chatgpt.com's own JS using `message.metadata.content_references` ([#6635](https://github.com/diegosouzapw/OmniRoute/pull/6635)) — `cleanChatGptText()` now resolves `content_references` (grouped webpages, footnote sources, inline `webpage`/`url` mentions) into `[label](url)` Markdown links for both the streaming and non-streaming response builders, and for the GPT-5.5 Pro `stream_handoff` polled-answer path, falling back to stripping any marker that has no resolvable source instead of leaking the raw private-use bytes. The citation parsing/rendering logic was extracted into a new pure sibling module (`open-sse/executors/chatgpt-web/citations.ts`) to keep the executor under the frozen file-size cap. Regression guard: `tests/unit/chatgpt-web-citations.test.ts` (non-streaming citation resolution, streaming marker buffering across split SSE chunks, and the Pro-handoff polled-answer path). (thanks @Thinkscape) +- **fix(dashboard):** the live-dashboard WebSocket descriptor handshake (`GET /api/v1/ws?handshake=1`) and the lightweight `GET /api/health/ping` liveness probe both 401'd for unauthenticated callers, even though both are metadata-only reads intended to be public ([#6335](https://github.com/diegosouzapw/OmniRoute/pull/6335)) — `clientApiPolicy` required a bearer/dashboard-session before the WS route handler could even return its own `wsAuth`/protocol descriptor, and `/api/health/ping` was never added to `PUBLIC_READONLY_API_ROUTE_PREFIXES` despite its own docstring documenting it as "No auth required." `clientApiPolicy.evaluate()` now allows an anonymous `{kind:"anonymous", id:"ws-handshake"}` subject for GET/HEAD/OPTIONS on `/api/v1/ws?handshake=1` (the route handler still performs its own real wsAuth/dashboard/API-key decision before opening the socket), and `/api/health/ping` is now in `PUBLIC_READONLY_API_ROUTE_PREFIXES`. Regression guard: `tests/unit/authz/client-api-policy.test.ts` (WS handshake allowed, including relative request URLs), `tests/unit/public-api-routes.test.ts`, and `tests/unit/authz/classify.test.ts` (`/api/health/ping` classified `PUBLIC`). (thanks @JxnLexn) ### 📝 Maintenance diff --git a/src/server/authz/policies/clientApi.ts b/src/server/authz/policies/clientApi.ts index 1f2363d36c..6545b11d02 100644 --- a/src/server/authz/policies/clientApi.ts +++ b/src/server/authz/policies/clientApi.ts @@ -4,6 +4,19 @@ import { extractApiKey } from "@/sse/services/auth.ts"; import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context"; import { allow, reject } from "../context"; +const HANDSHAKE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +function isWsHandshake(ctx: PolicyContext): boolean { + if (ctx.classification.normalizedPath !== "/api/v1/ws") return false; + if (!HANDSHAKE_METHODS.has(ctx.request.method.toUpperCase())) return false; + + try { + return new URL(ctx.request.url, "http://localhost").searchParams.get("handshake") === "1"; + } catch { + return false; + } +} + function extractBearer(request: Request): string | null { const raw = request.headers.get("authorization") ?? request.headers.get("Authorization"); const xApiKey = request.headers.get("x-api-key") ?? request.headers.get("X-Api-Key"); @@ -37,6 +50,13 @@ export const clientApiPolicy: RoutePolicy = { async evaluate(ctx: PolicyContext): Promise { const bearer = extractBearer(ctx.request as Request); if (!bearer) { + // The WS descriptor handshake is a metadata read; the route handler + // performs the actual wsAuth/dashboard/API-key decision and returns the + // protocol details the browser needs before opening the socket. + if (isWsHandshake(ctx)) { + return allow({ kind: "anonymous", id: "ws-handshake" }); + } + if (await isDashboardSessionAuthenticated(ctx.request)) { return allow({ kind: "dashboard_session", id: "dashboard" }); } diff --git a/src/shared/constants/publicApiRoutes.ts b/src/shared/constants/publicApiRoutes.ts index 70853e142a..b4325ed46b 100644 --- a/src/shared/constants/publicApiRoutes.ts +++ b/src/shared/constants/publicApiRoutes.ts @@ -20,6 +20,7 @@ const PUBLIC_API_ROUTE_PREFIXES = [ ]; const PUBLIC_READONLY_API_ROUTE_PREFIXES = [ + "/api/health/ping", "/api/monitoring/health", "/api/settings/require-login", ]; diff --git a/tests/unit/authz/classify.test.ts b/tests/unit/authz/classify.test.ts index f091bb2bf9..734109ce02 100644 --- a/tests/unit/authz/classify.test.ts +++ b/tests/unit/authz/classify.test.ts @@ -135,6 +135,12 @@ const cases: Case[] = [ method: "GET", expectedClass: "PUBLIC", }, + { + name: "/api/health/ping is PUBLIC", + path: "/api/health/ping", + method: "GET", + expectedClass: "PUBLIC", + }, { name: "/api/cloud/auth POST is PUBLIC", path: "/api/cloud/auth", diff --git a/tests/unit/authz/client-api-policy.test.ts b/tests/unit/authz/client-api-policy.test.ts index fb8c5e41d1..081ab50e43 100644 --- a/tests/unit/authz/client-api-policy.test.ts +++ b/tests/unit/authz/client-api-policy.test.ts @@ -51,12 +51,30 @@ async function loadPolicy() { } function ctx(headers: Headers, method = "POST", normalizedPath = "/api/v1/chat/completions") { + const pathOnly = normalizedPath.split("?")[0]; return { request: { method, headers, url: `http://localhost${normalizedPath}` }, classification: { routeClass: "CLIENT_API" as const, reason: "client_api_v1" as const, - normalizedPath, + normalizedPath: pathOnly, + }, + requestId: "req_test", + }; +} + +function relativeUrlCtx( + headers: Headers, + method = "POST", + normalizedPath = "/api/v1/chat/completions" +) { + const pathOnly = normalizedPath.split("?")[0]; + return { + request: { method, headers, url: normalizedPath }, + classification: { + routeClass: "CLIENT_API" as const, + reason: "client_api_v1" as const, + normalizedPath: pathOnly, }, requestId: "req_test", }; @@ -82,6 +100,28 @@ test("clientApiPolicy: missing bearer is rejected with 401", async () => { } }); +test("clientApiPolicy: websocket descriptor handshake can reach the route handler", async () => { + const policy = await loadPolicy(); + const out = await policy.evaluate(ctx(new Headers(), "GET", "/api/v1/ws?handshake=1")); + + assert.equal(out.allow, true); + if (out.allow) { + assert.equal(out.subject.kind, "anonymous"); + assert.equal(out.subject.id, "ws-handshake"); + } +}); + +test("clientApiPolicy: websocket descriptor handshake accepts relative request URLs", async () => { + const policy = await loadPolicy(); + const out = await policy.evaluate(relativeUrlCtx(new Headers(), "GET", "/api/v1/ws?handshake=1")); + + assert.equal(out.allow, true); + if (out.allow) { + assert.equal(out.subject.kind, "anonymous"); + assert.equal(out.subject.id, "ws-handshake"); + } +}); + test("clientApiPolicy: REQUIRE_API_KEY DB feature flag override rejects anonymous", async () => { process.env.REQUIRE_API_KEY = "false"; featureFlagsDb.setFeatureFlagOverride("REQUIRE_API_KEY", "true"); diff --git a/tests/unit/public-api-routes.test.ts b/tests/unit/public-api-routes.test.ts index e480c528aa..1175bf2168 100644 --- a/tests/unit/public-api-routes.test.ts +++ b/tests/unit/public-api-routes.test.ts @@ -20,6 +20,11 @@ test("isPublicApiRoute keeps cloud read/auth routes public but not cloud write r }); test("isPublicApiRoute allows readonly health and require-login bootstrap routes", () => { + assert.equal(isPublicApiRoute("/api/health/ping", "GET"), true); + assert.equal(isPublicApiRoute("/api/health/ping", "HEAD"), true); + assert.equal(isPublicApiRoute("/api/health/ping", "OPTIONS"), true); + assert.equal(isPublicApiRoute("/api/health/ping", "DELETE"), false); + assert.equal(isPublicApiRoute("/api/monitoring/health", "GET"), true); assert.equal(isPublicApiRoute("/api/monitoring/health", "HEAD"), true); assert.equal(isPublicApiRoute("/api/monitoring/health", "OPTIONS"), true); From a5c555b0def126bea5a2174801cd3783c013d795 Mon Sep 17 00:00:00 2001 From: Hamsa_M <116961508+hamsa0x7@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:50:14 +0530 Subject: [PATCH 4/5] feat(icons): prioritize local SVG icons over LobeHub npm for faster rendering (#6317) * feat(icons): prioritize local SVG icons over LobeHub npm for faster rendering * docs(changelog): add #6317 local-icons New Features bullet --------- Co-authored-by: hamsa0x7 Co-authored-by: Diego Rodrigues de Sa e Souza --- CHANGELOG.md | 1 + public/providers/360ai.svg | 6 +- public/providers/alibaba.svg | 6 + public/providers/anthropic.svg | 1 + public/providers/arcee-ai.svg | 6 +- public/providers/arcee.svg | 5 + public/providers/assemblyai.svg | 7 + public/providers/aws.svg | 6 + public/providers/azure.svg | 1 + public/providers/azureai.svg | 82 +++++ public/providers/baichuan.svg | 6 +- public/providers/baidu.svg | 6 +- public/providers/bailian.svg | 16 + public/providers/baseten.svg | 4 + public/providers/cerebras.svg | 8 + public/providers/claude-web.svg | 6 +- public/providers/claude.svg | 2 +- public/providers/cline.svg | 1 + public/providers/cloudflare.svg | 7 + public/providers/cohere.svg | 13 + public/providers/comfyui.svg | 5 + public/providers/continue.png | Bin 33371 -> 0 bytes public/providers/continue.svg | 1 + public/providers/copilot.png | Bin 27346 -> 0 bytes public/providers/copilot.svg | 1 + public/providers/cursor.png | Bin 7736 -> 0 bytes public/providers/cursor.svg | 12 + public/providers/deepgram.png | 52 --- public/providers/deepgram.svg | 1 + public/providers/deepinfra.svg | 7 + public/providers/deepseek.svg | 5 + public/providers/dify.svg | 6 +- public/providers/doubao.svg | 6 +- public/providers/elevenlabs.svg | 1 + public/providers/exa.svg | 7 + public/providers/fal.svg | 7 + public/providers/fireworks.svg | 7 + public/providers/friendli.svg | 6 + public/providers/gemini.svg | 1 + public/providers/google.svg | 11 + public/providers/grok.svg | 4 + public/providers/groq.svg | 1 + public/providers/heroku.png | Bin 1787 -> 0 bytes public/providers/heroku.svg | 3 + public/providers/huggingchat.svg | 6 +- public/providers/huggingface.svg | 15 + public/providers/hyperbolic.svg | 5 + public/providers/ibm.svg | 3 + public/providers/iflytek.svg | 6 +- public/providers/inference.svg | 4 + public/providers/kimi.svg | 7 + public/providers/kiro.svg | 2 +- public/providers/lambda.svg | 4 + public/providers/longcat.svg | 8 + public/providers/meta.svg | 121 +++++++ public/providers/metaai.svg | 49 +++ public/providers/minimax.svg | 1 + public/providers/mistral.svg | 10 + public/providers/moonshot.svg | 4 + public/providers/morph.svg | 5 + public/providers/nebius.svg | 5 + public/providers/novita.svg | 7 + public/providers/nvidia.svg | 1 + public/providers/ollama.svg | 7 + public/providers/openai.svg | 1 + public/providers/openclaw.png | Bin 16740 -> 0 bytes public/providers/openclaw.svg | 1 + public/providers/openrouter.svg | 21 ++ public/providers/ovhcloud.png | Bin 1167 -> 0 bytes public/providers/ovhcloud.svg | 1 + public/providers/perplexity.svg | 6 + public/providers/picoclaw.svg | 30 ++ public/providers/poe.svg | 1 + public/providers/pollinations.svg | 4 + public/providers/qwen.svg | 1 + public/providers/recraft.svg | 5 + public/providers/replicate.svg | 12 + public/providers/roocode.svg | 4 + public/providers/runway.svg | 14 + public/providers/sambanova.svg | 9 + public/providers/searchapi.svg | 4 + public/providers/sensenova.svg | 6 +- public/providers/snowflake.svg | 7 + public/providers/stepfun.svg | 6 +- public/providers/suno.svg | 1 + public/providers/tavily.svg | 15 + public/providers/tencent.svg | 6 +- public/providers/topazlabs.svg | 4 + public/providers/trae.svg | 5 + public/providers/udio.svg | 5 + public/providers/upstage.svg | 14 + public/providers/v0.svg | 1 + public/providers/vercel.svg | 1 + public/providers/vllm.svg | 5 + public/providers/volcengine.svg | 11 + public/providers/voyage.svg | 5 + public/providers/windsurf.svg | 1 + public/providers/xai.svg | 4 + public/providers/xinference.svg | 53 +++ public/providers/yi.svg | 6 +- public/providers/zhipu.svg | 6 + src/shared/components/ProviderIcon.tsx | 328 +++++++++++++------ tests/unit/ui/ProviderIcon-icon-url.test.tsx | 39 ++- 103 files changed, 1008 insertions(+), 239 deletions(-) create mode 100644 public/providers/alibaba.svg create mode 100644 public/providers/anthropic.svg create mode 100644 public/providers/arcee.svg create mode 100644 public/providers/assemblyai.svg create mode 100644 public/providers/aws.svg create mode 100644 public/providers/azure.svg create mode 100644 public/providers/azureai.svg create mode 100644 public/providers/bailian.svg create mode 100644 public/providers/baseten.svg create mode 100644 public/providers/cerebras.svg create mode 100644 public/providers/cline.svg create mode 100644 public/providers/cloudflare.svg create mode 100644 public/providers/cohere.svg create mode 100644 public/providers/comfyui.svg delete mode 100644 public/providers/continue.png create mode 100644 public/providers/continue.svg delete mode 100644 public/providers/copilot.png create mode 100644 public/providers/copilot.svg delete mode 100644 public/providers/cursor.png create mode 100644 public/providers/cursor.svg delete mode 100644 public/providers/deepgram.png create mode 100644 public/providers/deepgram.svg create mode 100644 public/providers/deepinfra.svg create mode 100644 public/providers/deepseek.svg create mode 100644 public/providers/elevenlabs.svg create mode 100644 public/providers/exa.svg create mode 100644 public/providers/fal.svg create mode 100644 public/providers/fireworks.svg create mode 100644 public/providers/friendli.svg create mode 100644 public/providers/gemini.svg create mode 100644 public/providers/google.svg create mode 100644 public/providers/grok.svg create mode 100644 public/providers/groq.svg delete mode 100644 public/providers/heroku.png create mode 100644 public/providers/heroku.svg create mode 100644 public/providers/huggingface.svg create mode 100644 public/providers/hyperbolic.svg create mode 100644 public/providers/ibm.svg create mode 100644 public/providers/inference.svg create mode 100644 public/providers/kimi.svg create mode 100644 public/providers/lambda.svg create mode 100644 public/providers/longcat.svg create mode 100644 public/providers/meta.svg create mode 100644 public/providers/metaai.svg create mode 100644 public/providers/minimax.svg create mode 100644 public/providers/mistral.svg create mode 100644 public/providers/moonshot.svg create mode 100644 public/providers/morph.svg create mode 100644 public/providers/nebius.svg create mode 100644 public/providers/novita.svg create mode 100644 public/providers/nvidia.svg create mode 100644 public/providers/ollama.svg create mode 100644 public/providers/openai.svg delete mode 100644 public/providers/openclaw.png create mode 100644 public/providers/openclaw.svg create mode 100644 public/providers/openrouter.svg delete mode 100644 public/providers/ovhcloud.png create mode 100644 public/providers/ovhcloud.svg create mode 100644 public/providers/perplexity.svg create mode 100644 public/providers/picoclaw.svg create mode 100644 public/providers/poe.svg create mode 100644 public/providers/pollinations.svg create mode 100644 public/providers/qwen.svg create mode 100644 public/providers/recraft.svg create mode 100644 public/providers/replicate.svg create mode 100644 public/providers/roocode.svg create mode 100644 public/providers/runway.svg create mode 100644 public/providers/sambanova.svg create mode 100644 public/providers/searchapi.svg create mode 100644 public/providers/snowflake.svg create mode 100644 public/providers/suno.svg create mode 100644 public/providers/tavily.svg create mode 100644 public/providers/topazlabs.svg create mode 100644 public/providers/trae.svg create mode 100644 public/providers/udio.svg create mode 100644 public/providers/upstage.svg create mode 100644 public/providers/v0.svg create mode 100644 public/providers/vercel.svg create mode 100644 public/providers/vllm.svg create mode 100644 public/providers/volcengine.svg create mode 100644 public/providers/voyage.svg create mode 100644 public/providers/windsurf.svg create mode 100644 public/providers/xai.svg create mode 100644 public/providers/xinference.svg create mode 100644 public/providers/zhipu.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index d991eaf35f..73f92ef4b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **Provider/model param filters**: config-driven parameter denylist/allowlist per provider/model with auto-learn from upstream 400s (#6649 — thanks @ThongAccount, closes #6625) - **Per-combo reasoning token buffer toggle**: the combo builder now exposes an explicit checkbox for the `#3587` reasoning-model `max_tokens` buffer, defaulting to the existing enabled behavior, so a combo can opt out without hand-editing raw JSON config (#6702 — thanks @xz-dev) - **feat(dashboard):** 9router-parity **Routing Strategy** settings card on Settings → Routing, plus a per-provider account-routing override on the provider detail page ([#6678](https://github.com/diegosouzapw/OmniRoute/pull/6678)) — surfaces the existing account round-robin / sticky-limit knobs and adds a new combo-level sticky round-robin (`comboStickyRoundRobinLimit`, resolved via `resolveComboStickyRoundRobinLimit()` — per-combo → global combo sticky → account sticky cascade) so combo targets can batch calls per target the same way account fallback already does. A new `providerStrategies` setting (Zod-validated map, `src/shared/validation/settingsSchemas.ts`) lets a specific provider override the global `fallbackStrategy`/`stickyRoundRobinLimit` without touching the account-wide default, wired into `getProviderCredentials()` (`src/sse/services/auth.ts`) ahead of the global fallback. Regression guard: `tests/unit/combo-rr-sticky-9router.test.ts`, `tests/unit/settings-ui-layout-static.test.ts`. (thanks @SeaXen) +- **feat(icons):** provider logos now resolve local SVG assets first for faster rendering, with a 5-tier fallback chain — local SVG → `@lobehub/icons` React components → `thesvg.org` CDN (external SVG for unknown providers) → local PNG → generic AI icon — replacing the previous LobeHub-first order. Adds dozens of first-party provider SVGs and migrates several bitmap logos (continue/copilot/cursor/deepgram/heroku/openclaw/ovhcloud) from PNG to SVG. Regression guard: `tests/unit/ui/ProviderIcon-icon-url.test.tsx`. ([#6317](https://github.com/diegosouzapw/OmniRoute/pull/6317) — thanks @hamsa0x7) - **Skill Collector CLI detection**: new `GET /api/skills/collect/detect` + `POST /api/skills/collect/install` (and the `cli-skill-collector` agent skill) detect which coding CLIs (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.) are installed locally via `getCliRuntimeStatus()`, match them against GitHub agent-skill repos, and plan an install path per tool — replacing the standalone Skill Collector Python app. Both new routes and `GET/POST /api/github-skills` now require management auth (`requireManagementAuth()`) and are loopback-gated (`LOCAL_ONLY_API_PREFIXES` + `SPAWN_CAPABLE_PREFIXES`) since the detect route spawns a child process per candidate CLI tool (Hard Rules #15 + #17). The `omniroute_github_skills_install` MCP tool now reports the honest `action: "planned"` instead of `"installed"`, matching the REST route (#6294 — thanks @Moseyuh333) - **ClinePass dual-auth**: ClinePass now offers both sign-in methods on its dashboard page — OAuth (reusing the Cline WorkOS flow) as the primary "Connect" path, or a pasted BYOK API key via "Manual API key", instead of only the API-key-only provider shipped in #5942. The registry alias was aligned to `cp` (matching the `OAUTH_PROVIDERS` catalog alias) so `/` routing resolves correctly, the OAuth refresh dispatch now routes `clinepass` to the shared Cline refresh flow, and the duplicate API-key-only catalog entry was removed to keep ClinePass listed once. Regression guard: `tests/unit/clinepass-provider.test.ts`. (#6126 — thanks @hajilok) - **feat(oauth):** Kiro/Amazon Q auto-import now supports enterprise **External IdP** ("Your organization") logins via Microsoft Entra/Okta/Auth0/OneLogin/Ping/Google/Cognito — these org-issued tokens are not AWS SSO tokens (no `aorAAAAAG`-prefixed refresh token) and can't refresh through the AWS OIDC/Kiro-social path, so `tryAwsSsoCache()` now detects them (`authMethod`/`provider === "externalidp"`) and refreshes via the org IdP's own `tokenEndpoint` (public-client OAuth2 refresh grant, no client secret), persisting `TokenType: EXTERNAL_IDP` gating so the runtime executor sends the header the AWS CodeWhisperer API requires for these accounts; `tokenEndpoint` is SSRF-guarded against an HTTPS + known-IdP-host-suffix allowlist. (#6363 — thanks @artickc) 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 f54685be8de738c2df92cd94cb4b8a2cacea4304..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33371 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 QSpWb407*qoM6N<$f@{sHO8@`> 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 9907963e408e15a0d4c96018f692001bfd4f7e6f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27346 zcmZs>1ymhDvo(4+xV!t|;O+!>cMIWf@c?A|wC+fGQ^|ss68q{HGwm{W}i`n@;?zV64TI!~lT$ zB;+?!*nig)=CbNa0DvFOKU_Ef@bVx05dh%H1^}Fx008`1006#AZikxSKZB&Dj+~W} z5`f{Ki~xX&vIW5Wlc4?;qJISdfG&UnK>w?u{-Y}Z{vRu(0Q&#R{{hM-!g&9KP1tJc zc<3l8@|!z5vY1*pn_05>IJ*1?01))y|0gR;{LeR~^ieFt)`hV#Eu7p4~9v&|ItgPPN-Ynjn zEY5D$tn8mYePU(fVCCRo{)b?8_jU3x^QNi zAEqGdf5QD=;s5v8{+IUO;0hxNvi_eNC5)sc$i@f&hyvs!#Wa1O&bknDnO6+A9)DDJ zvIhDa8+-R$v|c)Mai+rA6H+y>Pqp*-4||*s(^rh0 zf)PV{!h4Oyw^=E#{%Roj1Q(9HBljdv(Y|oRNb@f!>a9*cKYIygqw4>X%u;$f?`5*l zmiU2d@4sj_80y2WcB@cfSJcwjtSg|I^u@UCE`QkAg5W)Z5>ou-APV);a4!+@HPEw!F8g=Q@*(;fE1HBWAx^bO|DVd6khi)*bNjXUrs>d#;}6 zc@;P2J2idzY1MY*f4x%BQP1}&f9+}4e^cW4>}o_dKs#y zt@F&Ti*93^1-#ZqJeGvX7qn>yWnx}5j&M*-Sag0{VQ}Fd7-+jftdJ$@XKE;ZycRI* z)2yvtXM)FN6N#FKo+I)pbMjVQ-E5t5jT3Wpp+;>Mu0md&u6~2|ZMGNp`%1ye^MF<0 zp&olSvjl>t>^8AD!_RKS?ZT+g%^l7aNg8gWxRGu`v5&kMi=Ch!GYhBaclsRYyPfU_ zkAIj(Etql?2mVm-VK#Q)>mg%aUO}=hA=}!Wh`T#p$*dP>0xo`yh~4_Rdtm=j>pU4M z9U1Y^SF?Zz%5bk{Tey0k`^sj!P7E2`qU{kRz0yrR1mXpeCacrO%DhFYWyBj(5})(C&0e_Ni`2zZAEU z79~F%-OdnEbQ(q3i`fxN{1`g)>cpiaJL4C}$dp2j4k8WrJQM5gOlm+_hhIU2Vkq2z z@Yzhr1U#L0M4)O)IIfZ?`f-aR2FL{r+IGr&5yM&mSYZ&n)q6C8oy$KM@o9OAnJb>fmy9 zt2C>%e+Uc<&s>XP5DW{$0D+cJpi)=>Jj`{)73sSegDkTsF4x(uZf6>rY_u|V%x{M* zc(tblp4yUmn3E9F0n8%N4o!x@Hp&;p|am#i!>n_BixMJ=$-saHR6?(UFPL7_9_Dg0a)Cr7l1K0D0CW(7Pb8~9Al!SO=O()@#Ft{zep~2B zCoKVzE;h>tEEfYO=&iBhGa7Y$eEaJ77vKBiemcPEFXfM%fzwEU^4p+FzH`&hZ%Ans zm)G!c91+DMp_9*Zc;rNN1aL;kOQgyCPN>PhDR*@Yx;tB~GxUatC<+tk@Pz|Kwh?@f zkUOaWf&kLeC5cw|pq?(-GlfJ+n4Knw129@#x=5unNW%^ql#EI_C6g!1paUHqb_UQF zXP{W8MfwTH8O>2Hnssi)SiP{+WwpmUUOLRG6?8tE>c4!tjb8A??@a#oux~1G&8ug6 zg}pfEE$HpT!0EEv=1H2}psj-MMB*0I^95^bw@w@J+K557U9(9Z`!}*otC(Lj6u~fX zU2uacjvNFyP6gh)OQ3ap|8)=lMwUP}@Oy{cJcV4a9pUYZ^>$2oxW`mfw7)L`OeEh3 zX6V&(e0>N^vfg&C268GwInoL&?iujqXu(c0M{h{4{i&Q=;BUa=meI@n65-9oHDv#^ zl{Dz@YZP+KZ}gV;W%$s(9rLck>F?5)JxfR$*a!G*SG&7rCBlYxL`qfX7poU9k>`$1 zI-WnmacJZjp_MbqUkpcSR-*!Ytpfrv!=~VKHBcy0VK9I&813P9-~1n_Be99z9Xb)**YC;Y}Esw(GT-(A&7{=?hD&Dj%S zzsrm+eDVyF*ABb&FSqI zypFDpM$6J)N{r`c1$w93LRgPTXY`e{AcRN6kZAHp#%=t%Fb+d1O`9fhCgVTcZCQ|@ z--oK@2|?al_i6}#wuJMaS;9Lqi?)(6#D89R!1Q`o;LFe#;bE4oszNuY-&3D~ZLBe3 z=C))b76zYR5A0P$UObU00u2#NZhHLwRU9`yz~*M-C#GK+9XmgJ9; zilP9FbgFbM?dQC{d~vjPT&NrpvTvTL!HB{*O=h$h89XmA{RSAk7sl$vKSX|3hqjxi z%vX8cA}w;p1}?e9thMuWgdOr}6 z<%ND(ahEQql4hu$y(DZuBRcY~;3=cGP*Ak-+_}9x;9cZ~>e+(-Ls-Egh@YEdQQ+e{ z@9I^AJKp69P!-suOJdR-E47xL)A(z}m+|s^*Ap}8aD*@S;*MJuacBiGk)j)Vmo)Vg ztsEeO4}>u~bl&i02+e;&CBkQcR2Z<>SZsdR`BKQNjw$XJZi8u3;E#!YLz1idW%-aC zY0R~!8n&5QMJlqCbr%Dw?ZGB$SW+;ch}kRdKFah|oN1#&TfqA+Q|SF}X`be36|+|Y zom$#2jT|9#e)@nG{cw0Q;2Ub5fH_om7X$~MK#u8@s3_=pRcp;auYUkE3&#~0;Vo8s ziGlod)SS$c$jc=yv-r20>qyojjt@LBWd1^1A{9Uqco+_^b@X<=Vb~Q6Pn!p`y$;15 ze&kZ93);gKi(g%2ClC?ZlQzt?^QaYhG%8A9=sTHg`<>mfqX7dkw)?1 z6H~LX8*P5DkL_nO8PRFWF+Qe6_fE zMB-jkbV+1$011&hNja2xdXL$^-P(MIvS13DP z7-W0VwGH1x)t4D=)K^JzU=k(a#y^3_SQy-nFliB_A^&`N@PmSA76*e{>~5+%AyNB(VTwrgBcta&1^&5o{wwsxQ}$) z(yK#pA^(L_Es)uOLoIO4mbiuLymL(&?HF^bEa>tH;zs3EYGJaVxUL-oaDp#!O+hHg zDo98Kv3t){tijB<`iY51>RS*X?R3QZlRzCQzlnOEzTR09(SlQBRoqZ ztG$f$>_`^Peh(U>Qky<;3FzAV9$8}Uf2NNinN46NwAm!h)Z{q(OMj3t7GI*q@|F?-g0^ANPlinJP3)DOb$-Fn>7SAYr$y z5ny4)A$>4uXZz>Ip?kwl{#LWruToVi_iKvIeR$$47!=aG-%gD;OKYeJ@0^3Nd7fB- zDTaV&ouawAw7hU7Ca!AdR#TZ5WG{~<6NwGm2^tFfXpLj*jgjDwBUa{Ow67KjRpC*s zm5ysn8IHE*zcL>yTXc|X;PSooaP`;Ln$8U^V8mD!7%kW$ed{s~wNFfKPmLdZiT`e3 z|8S+bZq|U>zX|hs2{Yn(p87pE5?T>w&HjOz;38A2#U1Rr5XAmQLrLXnZ?oLf+UmaVPUGRv ztw({W$d!mjq7T{T-!c7Sk}4}!GGZeF(J^*Q(Vi{htrbuZM)=*_47B!gk|GcCw>@41H40ydvnx0Z0uu8T(2mZ_8of-|GYtM5f1ko2A zN^dR}`puRM_J$L1m?ZUkeLU))Ssw0H>o7xIfnbkyFGj7`{hCiXeB8Fg6zVbH`W6fp zYi#|EV^8IqyvU7by1~=_L4wjxC>^drCmWS30+#~j8dQY#BNFO}!SJ@al1!K zbxIxD`^1n6ygE)#C(s}Nj>40zJT(X3f2B?`!Upmw>U|-q^&Xkcjwu`%$RxdhrgK#| zGsWG~Yh7M%$zzCL_BHzmgYH@sUHUhZZAqxtj9eSn8RKrNfD&k72WqVp_vbJeL=jqg ziVUFPyBSsxEz`i%8yVHdrBQiRi`dg9k~EAQd|~RIYhvo~{#*+J;k{z4n}{MwU~cx2 z&_;-UwD-KA{YJf62Uh-JlcJT8_BW_B4eGSu^e6D-zQJ5Sb28+9|z<;A0`plj4-(0Iy^@XBp$8Z-|W^=*3G zh2Nv&Ip9|s(`%or>elB2|M*+%u?gfleV#O~l#D;&p$~A3zL1UBD@t)C zLBh&hVP1>5EzX=Ctov){g?lX&20s&8j2o0mTMSM^mZN&W#gzQ?X;+;)g(2nZxH-iO zaY;3Hd1i&fhO$c#FvHW$5913)j1KN&0C$`wx+7vvu^ zBi@=GFGLRBA{cj9UDqiV7tr+vFR#+mWVgf9d7=JpnRGnWcazM-4&d{(zeu$v&YK79 ziTQd44a94>j1d&1#37+7r(ljn7z%EnNTX}0_x~7WG9jbHD_!++-Bb-?qieVIliu{}6L2F~cy3|Q%_0b?CO1RiU&r`wa za;4pCJkoc#bJJzgMgQzL!SHq0?)PF?dPZS7oLwo+SomG#z*wbNwKxoFbd{oU>|L|$ z8ZJUxioCAClI0)Cuk2sW4sIA8oEL7&BaS8B7ZdvY?M){FhP$;kEDe;138?7d*tJ?7 zXm(9svuCl51CF_Sdn|S~Hk5~GWx{mMUe%$6KDNhfk=1P#Dpbva`ua0S{kP8uPoc%C zGCG9nJ4`q!>eMKjX7i$|Qqi(30m@^gHP}8Yy)z6UasAIE%NT>-UPgRt$Qz zp{`XaksqKv<1FJ5Ai87856IJ_LtW@}cfGaip94zJU91P90U54~55ud~f8m4~ZCeeh`)#9XlB1mGZ?&s^)N4J;1iBrfbDDjFp%o+13`bX( zbXWIhs1V;f3oN4%C5d%fGut*Idt&7@%?H6E2v#@%_k%Vlu`hxtjB)FKI6ODGWi2A!#9cGlE;zIa~m-dtuBCOI_h!&nc?J*d(`y zRUvkXsZy?A!tkL4f3sjm#hh&(2Gj@GtJ0JAE!R|>jc9v;*i@#A3I*_Q-XdY@>GK5 z$ycpZLjR8g4yRh@5tePjyg70RIZJoDh&uqY?<3zopPDwyPW z>K^!w+zZIl>`CQNy-iwl_ksK`X}>7u47p`#+a(?bsW|qyGG(0~lMW>4^@)`&q-Lkw zG1m2bYhwJ`Y&FF_3O>lxlD?!>9a7;n)Zwmom{o|bHGCKlXG+uk_|jQ$w5TbXtAVke zc`QCREW=tR(K0RE03PNed@79ONkGhBCLY9L13X+1u-ufm4PDu!vVmYa+{-J%M-CN( zl{SiH8)-am9~z$o56*PiE{VSm4dJ3x!--;p8<8&xnBB`=jzKyA7tuK7O;W?QN%(O(1|y2y)faD z+Gtvs@VatlcvnBnWCW9f#L_`@_eg6~e<;T>lyo0wFr_e>8G8pu4XAYzDlDW7))>yY zRe3oS^;dnZkjBbFwP_kbh@34k^2^IL6fdr(QXb5@ICoI^?K)fa>!Z=s$Qu^3Va@{t zKdbWB45=V~-vc7;@~BGebIToecO6-nR#$hKDGmzv5TZiYNp&`&`sJFpXG2T?dX&_9 z8msB+eBwBw^6BG713sY>End`kv-)j2!=+74536dI7Vq@D1ga! zs@ux9kAeUaE&{YIeObhyKZyw29clrd==))LOeLNts6;93jx2ijxoJDb{;Bt;j>pgYxz(? zg2qa`QYg$-9V`zI_rzN=E?1w{JLUK?rSL!)*oCx%dkZpQT|d@&z4EGs}W8#n7(c+=`-{S>))}#zN3D9QXvXqp>%+nV4wa~-+}&u~4tP@A!DNAam-dX1~n>t5z5?eVwtpZR10vgxzrBs6v&X2cLv+O~bH zKpTk53T*>E(NPd>VIA~*a%Iv{DAi)Q_ty~wL5l$jNcb7nLrh8hx;|#y0qe{sq$3B#XD z#Vhc}RiY3BeV65|IuLUw09H@}r5vLfif-%4;#aNKR#7Ht;t+!VA5I;*!`6C4FpY7_(@-qN?}b#WQq-ss#c~$sa$5BH z1;NYE@C=gE>L<%-^%YdDW1TS)^SE}o8Fz}NMk_xD)qkg3BGtEFt*WBZbAm%#FaW8{Ke)%127 zREksYx1b1v=bGrmj0vsv70*;rd>DeSP@_r>Wh>OBM6wAC2v}-k35=L^V-H??d)Qdo zMj<@JI(MExE~vim0K5$O$_Z==|FWRged$@1`>0KRAcScnC5oc{DI(~Eh@MNghYZfO z5iP{5x}yUaO)r-y#+jt4Sn2jjq}p83Nyp1#9_!OCR7F)(+QNn{+1(2z#DhcZ0>%XY zYbLpQ$%X*l8E8|UFb+3nxNr}AqD9>Z&tWHbaLay#*U+r>C3E;_z(-HN*hx9}lEe-| z>tdEw;m2cwU&?2WX}DttW6B_=13|<5)o?+{ei{PccR8Wq33o_gAdBHsv6r|vwR}{K zskWb8h5|vrS!$s2wnj?RaFuRr33DopJ)WIy(^F~nLAg(@^)huUVvnC`>pvr(kAw>o)6 zArl19krlvGL(7H@#~;3-3gqU&3u{%G0Q7*PLJ!&&Po7A%l0R7iH{CHP2E%||uz3?u zhO>Yxt~OST0sM)mi$ETr(9xa{R9(F16@!;q_hNY!Ly=2f-U-P-=nbRGlC1BMzyN3* zS_;Zrz`La1iTJ>deVX!gApJs>ow!0%s~NX7EP4_#C&Qe0p^|K`N`(KW|JiBz#kKLl zi`UUTaFYjt_*<`nj^Rqv+=u{=BAQ6tc>#t~4TCKVr6>Ru43X$2kwMsQu`60rmSVT^ zChK1i_sEi|{hNxBYR5jrS*O8|$Paan@GMUir-R!UUx+4|ObS*<{>-(ukswdyv57X_ zWjA@rdsT^8khOANHe=6}54_e?Ey67fh;S+A72A3@ z+GQ9%iH;tUsma4A9tU<)edK+3zyNmd(1FSI^U@`);Q@ju+D{GjF@h6tLvr2QiMBkf z9UTTN?TQ=sj7yH!i=d?wVNs?Mxpkmrd&C0&C+KLiy!8nAf9rShF8twPu$yu3T|=v) zc|Q2@)uO3Hh0fx{?=y;KzVzz4MQZ}IIed?}786$2QloEaaYu5XnV_g{S`CcS{t1F) zB%^lKkTZZ5XM2{^2K7&~b`l0k!DFB}`8!>nkLOwy4MeAgi{$&esZ~qzD%BL)a|cj4 zmOdLkW^=?3El%?al(^$ZspRUqEz0x_f)q{XNF`m5j-!}zhXc=NNIwN%P?SM{*X;Jj z(B+jo?VF++X3x)TKwAP?GH7+jj$$t|<7sV!c*ElVz;=@oDna0#*sv>uL%7Cc!*PWc z%R=J7J*xPPPnZ{8X>g$IC5BoR{N?6{5z`gtUe~0qPNQEay@Ml?xV~Sk16)3G%BKoe zP{C(4azdED*Rhlbfk3q=DW8i`>*OD$)_Oh^&$T%9BJ;Slrl?CG$-B-_Z3nCxy+v?Z z7FdIz1k*KN3GL9Wi_z-z2ip7H0URghte zBgz2O?xyeJUG|sh(=yE0*_>C96>DzGNUMzaeFZ9oY|EmuCwZv#u9sS}nOkF}l5I zh^8e*Mf1x>KX%mwBagV_=Hy%D#@w2vwX)(W`A@#2KtMf*v}~5}O`k6el6v0)g}&W* z&8Ie%vqX6*M3j8fo?yo1YG_$$3 zX!;wXu@;eDu6DA>=X+X+PgX99dLjNL@L9Q9ViQ0aE@AP`yIn^kMeZLi69pgTPypG9 zQ20ajomhZ-yc-LkxU)xRLA7y-=QLkHSa|ZZ3e5kB2$K(?5i#s_yZ0tYrG$|tn%?wr z?YE@#*5S4=H(&F6jdZZIObC*4IzF33t(u<1NS7}#PT_M%JhJ%_Vzh+Q1eP|>*-g;r zY~vVuWZq= zY}s)eLU;rnA-PN*CB>q`cpGAwbB1hwg|Tdj1>V@Owc(c9c1ZWo9AB}~&$Xn|$fsk5 z*Q!;cW|-d3qz{M*eu$AEU@{YqH2~qyfaS4*;zj1;x(Qsx60dx#*)J!1GcASODm>8~ z*89Hm-xg@7c~&;s3oZTath**(6v{_>m1WU~hW&R-kT0nTp-=<=k`WuH2Xd?5DkD4 zpp+`dWJaXNnjW`QO%K(>+>O$0+`cX|YI2_1`WG_CiC}_C@O30J8)m6g4G7&pa!FDrP4}TymOE1>9zL;=$muqa+bo#P!ZsNLe2P$`vwXGqXZeT|c$2%hBVN z?wjqxECYfBFT9EF5tpv2-fp$9dkdq4)1AIoJ}U~y{@Wx*IHE&L)u2$urH%>tNU=z% zhhx*r{qZc&X2Gc4=a?GDldrO}u!}beo{tkKeN7K@ze(;SEon0bX+VK6LxA*pm?){D zcD<;i46(af#6%~{fscu6ck^@|0VA~rJyM*N(8(|b6x5sm>xL4=HtCqGNhT#e*k2%i zu`j6VyM*#al$qB{oE5+a8zkUz^MFj5qpk06CwAxSqu=bW-#OD3!IJN#v`Y>#S<=mN z*9ev;Q@P@y^cl=+;c0iqFvO?|76+J5q3Y|mL|`bhrwDd~pL-+O_#t2FHj(u=eI{saQ|7t{WzwHtbkdu3BAoYU(tgo^uv-rtYV>9JP_3Ap|0fecWf0_Rsj z#2ZR=^;!8nmLDe747ZEp7>IXI-PU_D=xgRG!V{d(0+C;R4~eH&WtU5yQf~yH@7^C zA`Y}!z1O@w=TNVM_}^L%og6tQ;Jn$6LQgrO6@LQ1X6C2s6GUeN|6ZMVMkmbHJDB!L!>b{?bMo-;J3V&o;0)!y zQ@~5Phyuh>{KLX$c;Zw8Pux}XT38*s+NoNAeN$}Q)xk&Mq@M!&gx{YJ%azKPBW}wO zF;Lfz3PXh+`s0N@CUYIDC%4t@xc$IDklHQWlZ*cnX9;P#=UF)MqG9=Rwno^)6#Ue( zXfP3t?edo&lbBosxI@FTcc@eYI81RRX>qWl!Gk`DoNJ?1-c$9$JD;RDE8?{~Y-xzU zi@3R2{`t&m+`;vDNF6GY0~i$~H5Rz;s_uKA4%G}8UqXpfj{UdH5SP9U`&(Efa!S6= z97Z0|L$p9L59GK~8}2w2Hgxt?$p2Zb(lhGp?0Ii8{&;}Qr)uM8y9MjIPRoiG5{)jp ztmP6=zsZNQ)M%7fJGy1Zh$b-+ZN>I`Py**%d7P|KPHcAeDe}YU+;qdD=;B4-M2;h3 zB69|@4|Fk4Z*_5as}rwN?Ml1*<8sU%E%PtWRQa#AD9S#nk`A$-&kBOAxBD5Fc+d1V z@6I#z3|oXkWtR?l&i45@v&q`X{kL^HmBX)oDdH0sJzSG+d5m8OB}sNR+i{*TrguB5 zZD{m$IhYkNsMAf-iW%gmwteK|V2&9H_&<>KPj=CPLrrnA-ae?NmwzQxo)5-lLJx8n z{JB7iOr>@*GzW`CKYgxaklv16#T`Gn5_CKE;Xp|oA+GsGDSe&T*k~_u|5mDy^x+%5 zJq?Cv&?a^><}1FbgWv^*I3=$NglW9N6V-&s7RH)Uym$8+I*C{vZhxB8^Uw+{u zwX_7zNvr$59$mh}>+AT(ZMgrHOHOZ%vrbxG*^@EYFo+RHq7+ME8(~Qn&ulmW;x=ca zP;Ua%)aLMpU>Y%|uvLVuZ!<`9_=AW9zn{m@x?D%+qH^}RIr#MY`2kvWP@(F?w0i7>fH7-C25GT&*=BEIA|6Z0B1wFZw6JLc>4|N4`{L!5s`lW8w^s0^|F>BIaV#)oWHA=_z= zQ`!Xqv#tRRo(XW=7o)Gxnus~ML`}$t!;S^Kb<-bZ)2sIrDr-XcW!oRN(b0tRlYFU| zc(bCesC7&mZO(0`E~(hhNkXTb(c`$Cr1;Y}YoK zR^3?*TH0YV8^yv$M3uijARjlOdf_|NiBjL0d`keu_|dC<#*|M3OtTC%e6HP)EETl- z_Vz}Fu~OOWf79~kJmv2e4JJ2=TTI+c{!h~9UPp(L{cTFx_J;!}J#3xwOcR?ME=kcY zSo$kKCDs$W3p~Mw$@Ea;H=W?KdXWXTw^*$0PFBNlCSHA!b6Slu^NRST&{aY0b6J01 z;=u+ZW1Ru@A(1%BsAM>0R^B!k*_&s3ZPK<+B6L!N#}my+^3Du zIAC7?jrtad0~tB*&!Ii%`lDB=W{`8ajp?vFe#0WUXc;JxM$u!YVw~Sdo8NVU*QBbI zB;TotRAyMBCEbBLc4{xbOr|y>gxgjGk_}pgt7X%=(O%POp5_`V? zb}(B6`dS_D3+n5X^00mqM-jB;1{ryx{9LFhDqq4(ljKVJ&Kxb`doKH)pl2J1< z80*n8jkICN!?y0IA@f=jT#`f5n=15J%DOiEiGX9IU3N6(|w? z?^ld={dh)`dQZKsdOoS8`o#EDiO}<8Q6ScJfT!uu=H2(=hpASw#ua;L9^Ou?Dq?#t zche2|&dFm5Ui>U+?IY7g4mhj&0US4Q<_$`Jo&?pd{(~qnv5ciTx54g4p5q$Q1fI3F z>R1_E$dik+3NMztANf?&YeYcB=a^tjh>U}%Bn7^{9F6Q`WEA?E8M_jPJ!)PYSX@e@ zHJJwfrjYaR{-xl1zsS>&N-#Cj6E@;@&6l8)gayTy9*YJm9$X7sCaKwo3e^qh@Xr;> zrIMYxtk@jXwv_0i(s9_plR6$XR#Lbbj^+|*PQ|qgBGW~*Q{WwHD%g%08wKfV0lplw zJmieD4bvNoelL}grnBfA>r*9Wddk1_T8P7x!J;!QI*g_izkH2d%)^)a&%cfF%R0V; z>xV6)Qz|?Kr|-+W8n_ocH{Ow(juC?NtXC`}`Nshd@QXfVEoKG=s+whfie6JXf(Sy) zy3sJfTD!J7)sQC~bIqUVN0{A`)OI7`%s}N}^94(|LfA)cRX2 zofr4LZv#awj|q8Xj?av%4E|caE-lvmmdDEnN7-DP=+_p) zVak7w#KN^89v&4n>OaZam=6rQL--mliCsQT@@xKQ&uj1W`}zXw$K*8A>^S+F%|XVR z_ebNmi;9t55j6imVl0SnRvY(~0#nLBGLbcl!7u}X*e$EjuL7%Tw#|k~uvFaslO+a& z4BjG}G6M$MH&}}ZeQXT%Nx!qjxQTRgE#@^UEip)OO604BX2VBM6-E(X_%h_=+J11f z3Q!F@tCe+36_J`vc53>4@@a=Xhv3p$ohFTqSJ&|D#ogTji?wmrK#m;c_}8Un^Lk^CLzQ1#g49eWvK= z4QXYS;)xW$H!3|*X9@z{kMKDCc?=pq7t}Q0Xw)2YOEf_TebZ#U$m8NnaZm2ic1=~8 zS~~px5Gc86pDvhWJS6A9SNGFz&nWYEti+#MY24VDckuU5)i#RoL^k~XNY|4_VK|iWJr22qXQ~TLWU&ku@5yXEsJ8=?7 zot`eW2jyw;0lyb06+9+U5JfR5d>Du>5eeE!eDom9Qf`pKI<&~%UNJ@EnjEt4)1W0Z zm_Qei7z%YNCEH|YOfHUlc{og?VM9l2yrpWOf}L6A!m=t)U<1saU8;&Q8+LICG3%kg z2(%3!Yl5FwB(<{}Xii}q4ZEENXumTnk@eHc?witybD-rb|E-GZ6|YL`JSq~OX)4Sr zcKL0#Q&FplKCyqd6LitbCUwTKf|v?k+&XUR?kQ7y=PG_PH|k?V*v@hLYB zEkjo0srM&$hLX4CjpW96LYHyv@=W@Rlu5Bk51bz~>c#8L{U)iJCsl8svvrC9gzg;S z94^k5_}gXRT5ktas}bc~;BTQX6(5EXZm%i8F3lk?D#sfFj`;~9pesYUUL@r!iddboLG^+zv5~a|?Va{p((6gBl$z1xIUbO{jEP#CM zr{f|j$@1|4^?+%d*906&tqX_3DhV#&TTpb*%&iFuO^vV|2=sOJG_Ap${EK1W!F(tTv$ z70!1FXJnffs;}ww-<1vrZLwr_0U43*A@0uVPlHj?G8;{2>aM73;%Rg|Y>8%rQ0dW> zg5o=dbe0HT=cSSN#v;UZpBc%hXq2Jk8ET${>_R@!(CP$HuEQ&0sCc;eBH2e2%?*UU zZIDt*K9WJ*k9g|jd032NW1JqDQ#)CN(@^TrwiE)5=~ob^c3v!fw0`|*hmF_5tF_8K zXN$30ZoKc(vM!)MyexdREQF8ci47ohH|*oMA%~jnFY{n29h_pgkK7Pi1gp2lqJmY6 zFc98PSzajFDn&>=j;Mp3>~D^zvje?~t<7$lSnS zQQVT3zhor2O&l&eJGUZkGxxkrY+PGRqg&oQQ%Ky^j0tjU&Eym3HTk0@Kp1`mWlLCP9yNdsndJJ`i1T}i8qd^xBbdafcP1yFtW{$qan(A8+~NG! zCmq^y7|0`3njcecn8olpUgbQYFHlzeo#=f}&n0;ZuBD~kqD(hw2g68`CUD)0^^y?w zPdqtJF`m9_PY>ba2i&iq{5&=3HAmnPF{x0TzN^vYrh=;mVVCmy-_`DByI@b5LoWYt#zFi9^gv$Lad!^T5QN8W*pq3L?Us9eMhuH(9Dd) z$CFKKZ0e;%=&drGc^g#lMrR_S#nd8-RG&MI8~tq$(c}w`j!99gHG*NT^)?g77>p3O;}w&v(vqNcp|`G+1DlZoq@`0 zrQ^2dsL-zVq|OD3R>|VOXL-(L^$Leb-6@3XRy$Mtx+JMy5nb-hpV7CK?i&d-KVMMO z-sH%_K3b*7s$M)^C`&?%%%_8^hl`cexg}gGAWHL;v>YR`jMgC&bSIr0JWff~jW-PT zG`7Er@s@_vD_R3bqC^CruE{@kGQ#S&XdvR=yJige3Wqq1@C1`p%@QsrFf5T)(^K0g zU|kA!CBtA*bE5P&wNfr>D3ZoAYzVg5of<-Vn^r;r7_vEh%!h)Q^8Fm8@VC+Kj_xWc z$ti6yuUKz5p_n4}l7n8&Ic-XrNqmV!s)=TPmSn!|ys!4*s98BR*An`B&Y+gox02gt zGg&MPMAZ%zznSA;{u6Kt#0Q=N76nRsq9f$fAWddSG=Psn>Yj=x28C}*Ayh4UO=|x; z$8r-psE`{EQmIB}6z1Y7`02$q}?WX1 z8peCjX3DxreI96f8bLI1|A@GNasAV!*{VONdtCZEU+v^shcW|dYR^s!$|O5B<7JpX zay#GhMKrnG04TQ`o7pS{ioZ>Lxq&N*qX52K08njIq4i)j>jP0}AsE(U8DMu9HyDkg zA<83&B>j0T0NigGPNVyiJwuX*JyXILw z>4plE22xFx{!v0g>7LllAeJuPF@BT|1={aW3f%Mkx>B7ntSLO~UskTwdj&T}Ieo20 zeI$9n;XoOoT~1X}kiIE2U6OMf7*;*%Sam#HmX_!P-aSRO6kqJUdH|D|;e%j9{n zES`x@3BWAli7%-Y=W?;ext5dEZYgyz96W#TgTSa5E&nL%C}&hL3Prk$a76-rJ?_#2Vh`AK%wIS&jebo(U3ods%o*p zm;w-r-B?N&iBu|N%{DK~r{ivf=kZ+ANq$?5d*$@Xt){Wdo-V{IRWXv+zJU$L6W(sx z7+E`N7g+0)BCMa}Qa$@8l^;nnjL&F(-=BkAY&(YlZCcne)Zh2w6;34Q=CG8TC8g_o zaSMnn*DE!RB5NJ+9e)?41#G*DYTN-_#iGNDuf1)_RA5<)B(vA>bTygFmP9M3Oq8Z@ zD(HKQSFDT%wSB{G1dx<;vx@16YsWQ91X|W$3heSEtFb8MO@&w$&hlS}nok`nkwxQMUL*VC*`$tG`@g}FseNgZWG&-n`ley`=fc-Lvu9B$=6xLW3uG093>R5FbQR91jTg_ zUlrpFaVmnkn^CWKi!SP0Lan5zzcQsowa%_HbNt3Z-%mW}foh^_tZck8jDL2=;9Mtz zzAj{TXdpv3gQpjl$$Dfl&a_F}EUI`BMDnm%9^j*zr8TMV&S&u1cmqiL5I+YjLc-N^ z!DUrYsE>41ETt4B62~ZtqU!WnGtfu57A}u2jzGh25F79bu26j2w^5Q>lhi(eE3tRQ zR@ZPTjaouE0&Bnt5Th`7mFrT4*brOTCo@q^WUwCV^OWO$v+>y2M z5{Ar!e0_F@!bt3?@kD84RG@SLI?zmc$*q({2Ph`?w}yuiFJ1Q~O}s2M1z|22WA}M# z9}p|G{eWf9w1Z@eQc0&>#~_JnXPKh+(-OIQbQT8RQVa99an~0JLJA`zW142Cd#4iM zYtj786Q(xGsp6mSY-$Sm_Ly-tmT0}jHaH2*2y^lt{Jp)k8K^~`I08Ohn!9}wm@dx~ z1sfO109b{@hlTktuH0h4arH@FUO&Z1UmbAU_7;H9=EH zNd#ri#PJ&}MFh0I|oH*s_b>7CB)OP2wzV+u;ATb`7R0I zo-NMT1GI6_@X{;gA@=U5VS#hvZRif61z(a zZR1Y49D?!)?v=MrzDAGH-qCw0@iXgnG$*9ZU_{r)+fS3F&B_HqFefaM0ud6kIE_ZPPaY~l^c z3>V2$NOwP;?&A8ge$gAVvrD9vXKiVL)wURL7b|a$j*Qv)^Ji^(@(_qpTRh{S5AH(; z4PWH|04OR+L_t&pYwD*q(0zw46tiAZ;@lT;iii1LIiZmCdVi2#K{w6MR|EO zuu@!K)~+UUc0>?PS!1}Il>Ijv8jsdq{@2f#y1;V3gHsoh4J-?<(1!xD-= zi*iHa|L6eu_o|RlGNTT)btLN%Nh-_~0f<5+E>!^HvG_rISK^1yJS2{Vs)`r7&K?0; zfN+5p_w)mfkZ45LLLF`54hnL(tNE@u_HKzdCnd!Fvii~a*bd6;yYMbX-z|zjf9*GZ zm((ngM3ClDjZ-gf)8C4{>z!}6zyH%e$vS&Xr-HkLi{j9uN&^AL!x-mjH@XK%9(Y3QOJ#x z^)5*vH%cN|!5Ufn3HkcCkwsz#zx?aJ%d)IpcJZy>V#n{f-zJWnutB1Og4@Uc<0tI; z&6^IXe+2vcqkj3I{(&ObZ&~$^J^lnC-#xqU9p7cg?|Y|>pM8@J9XUrfvdez&AQ8lcnRa_Qugi#T;auh<>WEjnB>`=1a=G`^e*dQJPat(<{U*2vLfNRK6 z8%wpp_NgRQG-=`hEL}p-m14#zsEbuMWOZUlv{4zNVmCdDqk{Kw7yzpMz|!+hlE3|VFQ(Lt=ce1g@TwHGq-H&*b$^VV*+;Slgod| zCUDH8mR@etebF;Gt-d%psWEe(dnP~VpQNlS1`&7bLcpjc=t4Xl7U8CLi~vyOnp;;< z5<;t(DQY@6dJKH^F?SK1K_CrM?}?FPwy?KM|I`lA1Jw7O+ie0+f2CJoY)fT_Fd}j` zJ(#m|$FsJ?(yY(Cpamc%0m9QX!azm#7>bEPVEIWT1Eo#Mfs-JGSjmwKapEY)Rnnpd z0|o^Ugv-)#7J-jMfS4GxLX_%c4GuboGb`ol%<(<2vVlkh=(LNN>c-lW7X%_1H5ohiVh347*w5<-N}Nt?3jD0YAh zz^^0$^c=ktFV3ppL{(&T*cMwDL z-8G5-J1}vaj2M-)tUowNzjpOXnau^e^`(y?vRP)0ZQsUoZMyI7?8|?k{o9+6)IiPk z_K(=`$+sZTsCxrI8`uPk%X?N`z06xppCAH=3Ok@9#&o;!{gxtJ!`(C}<4l{Y_1A?^ zp(enHm=B1q1ss5a!;y0T&ekFnENa2_1`3FBRHX<#22sRClmZ*!-vlqq(4yH2H4ha? z4)M6Fnp?_B%U;Lb*A)50!Zk3v0sh7M|$&CV9w#C*O6 zSuef#$+;j5qD7*iog_ru9kR$VdT9rJw5@EIB7^ekMbk1A`Kw+5NO7QoA&MHx=xZUW zg&}SMG|_#!OC;cySb@``qt3vM0-Okt^49v^`ye#gx#6ROXRQLjM|==sZp>!Ii+aMnRxwRzPI)q3O+c<@&u03TN( zB&Mq>?{aYo@k!ysI&f_&~M0Us6DK9hegCJl9MxJLd>< z1B)!&u*PK04Kj%p24A2!6PfAi^7KfC73qI#;H8LAC{s@Mi^xgi0DM8Wt~ z?4yTCDsMbDZJ(t2NClYS*lh>RIxoo)CmhT#5#kxLh}M1IhlzvFK}Yr**_RRrEjpUz za*>#bJ<{X+D%kK0$4J~0W3NH*TdXebQG5B+RU(C4R$}H?QHM9L9J zj6(#jtlMFU!8s%F<%bCv?&%I{k%*VU2v0Lab(v1;pSwmMRmNqfPgyUCEi3&mR1G`v`}%A78P6OzE z#-hty&_`k&X#g15IqrX#)NnNcG~|Lz`b|xvCVZyKmnalfT~Uo0P^v{})R@9f9b>4_ zJ=e&lAZH`&@HBTTOc!llhS5oH%kWAu<)pefV~zA4+>H^`)a%T6=>scQ<0dc=af2d* zdh56H*$N^tFo!txQjNMl`_1bs-vt5w3jk-B516p(y@mB4mvd5!XHX83A^3G9&V^)=@Tiu<|MJxd^q!oO0mx{ng}g+meRuPDi2dzGQ3+zCyP^zx_%SS zg%?q#^KOhC^1)tZlSL_s=hKFJXObBHjx8d9OP9=h`g3yb=m%1O&c!HRY*$u6E**vd z^tKHd9+BV41C&uh>OJNmEB`l!K+0QjOHi4*S9?e0FrGv`1(ZHYt0 zKl)8z5tE5f(B1oek6IB&G2@0+J~!~iRY+3VU9$#95kLo`>5v7}DxvC+p7V;4({8jK zoK>rAd=+OZil*skzE2$lc2AAyqFz_qq6)X#;9Uoti$lOp$mw~ymj{V>T=0gk;#jxy z`)$H-7wTFN3p~{~xwU$_2*n{*MxT%m*4+y;LB8-8===V89^&As0FQDrP$vd83@fo; zmH`gb09ni#6W~d0(=lK#PUk;t0I&}vfX;1#Oqh!900idh5MGqHSJ&t)_eD#qVWZ>n zxLS4U`$e6)#O(r}{^=H-a|dySb^%G(V&vgMifEi2K+oi2IofcD-oHym09&N{AppL* zjyW{yetN~=8w^mcUyJ$m8VytCAzE_y6q{6uV5+2>OWmd?>a%t|CE5;tRnIxkR}re# z8Fqw(W6Be-3lX211S5cAO~pVUy`mHQ;}w7zo9sC73c_@rcEGAEcmY5azR@@ZQC-Jx zI-$YvyLPFWDE6=G)ex}f8nvNCGo2pidZBM>DM5P%fDnVB{ zxpD=xSM{@TmL=CnqwO{U@Qi2hnRnx@c6UJ4ZRC4P7ZR&iLx7=;E9miX7kB2NC8BKY zm4q3;fY3_2u2Ccp@|E@;Asg9;rd3uMheNCLPv5r;TFLYHog z?$vL)gkK%EXsTC}Fma7i^F2J1HbtOdp1yJwBm@%UH$94iOxMbPYd}1b~6&9cSx2 zE$DY`da7G>+>wz;DGDCB`~e7~mY@4La#z&D$Vd*z%e0wXS|PVD!&K`LscML_B!X*1 zP=T$Aa0P2=SUudm!d_|NR^$5Qc<&5n#r>{XBm!Xn*`Kvj(|u3X%C(P`cXn7uxlDK9 z93+X)1W=EaJ`<{er!!99Gb*iA(aq$@fYq<(p53);PqEC}1_XiyuGPIg;?f>#|6wXb zdQ4QP!3(*9GQD&0a`#AGK%!L16S1-1wZDZ!SI?k51We&ZI4Q7kjJfERF60W6kNMUfWUQa(mg zGyE{Y}X&7{CS6@4F+PYLpatE zfPfJVO2BX;4H4=@-5uyTK$Ga?fKy!O)IrWUE(7rbkk-~acj2rZ=gp)yUwp>8cvEQq zF;?3+b<%oR8({9mXYDO-JY{dZ?<`VO#3#h7VFnNpznYFdABY|i7GsmXeb=`>X#L$O zrnN7!yd43d!-s8P`ViH}yr1yObN23cy_1#0cn2Wkidtrj-0fohe2650kR0{h^7G&*tP(#Q8KQ zLUPZR^uYLQQoYqi61HpQ|MSquz=v63RLWo7aGO&BI-#3>=pFC4`jNE%Bb`e99~B#$ z-x<$mDI%rfmQ^){Y_u$~f6_GyuG2=H2h{w!9XXM(fvH~Gy56wa=eBu6_6#-pZ5y9t z8h1Bdf^;VlDrL!dsU$>%xvZLo z{Zl{oUi&xy`q%BzFMir8EI^?ELW0ulcinT^{@V}ysO8ByNJL63I_T3*LpzfYY5Z#Tmx!m_X*{OjLpKllUR2XZ5kFC^2g zXy>yC+~E;UR9-?McJK*T>x+AuRJ)TJ&M4=!C|sULfb?|U0S#8DLD*T{VyXHal1?cb zXIY${2j0k=Ll&6;OF)igcBKN`8p`{`t5`)#-U3piP1?9w+NrQw>vEsKWa~~bju5Kv(Co2IDN$Kzi`32NStUrf5jQx zGY-^^8a?gdwKX{6WC8FraqjDOK&dH-9E8?PhB@n(oKgX=&bt1C?>mE#6 zmbH~aUW~Z~@>{r|s@af^)-w*)S#)lrf_7vEn}o7E?dp}Z*ZBFt?&kk>ZgO*4!%nH=T0rEzNw@GA!VrN}U8ML9c*`CB;1? zg5cs3iJ>NITxCbP?8L=UJHmv?gaQvO5?5|lOND}ECVyhB#>_*vA8dlXdlqRtytP1-_-)rm&i6jy}{W| zBET5>O_sbU5m)p(nYc-p_Nox3I((C=b%FlJI~VVtG2aVc8GBm!E{>0q?boU=!qU4sR4%v$D{(R0&Wtq z!k-91=k!d~?*n_@^+SI!^)+W8E>X@{6|R=pmxgrM-p8~KdZK=D(&v?rcbyP{2>=3_ zd=aUe5NePctU!&Ht5sN*UN~ZL6;eFU*(luO3_|XgiiqqT5taHb^%C_!C1ep%(+^1; z@-UJzAy%`D%RH)A9!rrG(Dddst*Wjm_Hvo!DX5QP4rNloJOoL&8lr4Bt24mjH&f}> z@1*lfw_f<-LKWn@as5eu{+I-~^P(r7xVih@_rC4PFVDOfAntg>N%`A5jplJCo#72a z_0r=sE?~P|(f|yWsUh^3Kj1UDw2M`-GKDdq!P`4`S@RxM5U-L|gnEV1Nb~Bc3``x> z$lOW+HcoTz%{RPNss7Z9Mlq zS3K{M==e|F{!{|coge=2hm}A2{P73w-Jm>7isjv{a%YlkIO{xO3&0I}?TZW#$GyZ1 zbVDcu1a2wBn7PYBNxfaewz0Hr>vP*g6|o1nM#KXo0Jgv)P5?%dg=~>(*#8Vb95OgY zqBgqTAp}I~oRBIGm0c_h3b8_>K(3qhyyy=H{jSruPu%(0U3vRj5tfkcp3C*(>K2_; zy`+%uB6YMM{myOs?aCO_XZ2;j#psLrA)$l~r8lO@j?pnrOkw!Ym}LgCwu;5Swz4u9lKRajd|034KTMrNtUs`LRW zKr@m+eMEdBATAbwqBw`t%**#j{HRA9oP+!MsK@SO&OJ)~5OayE#l;6ir?|Td2XoYV z1zO3(1%f06XvL`rT9DXxqMp9dV>n$YaGpi~WRPk$RFbMIWkHtKH&{4}DekOwq~BMm zZeCtz=@}N0(u=oUNxK^l?PK7%H?W{CG%=?`l0R&f7fAVjB9_R0xHA9z<3LP;G_*?x zV{}9Mihur00ywx+a0);25v&jU*vLIo&-!ie$C{P+4>mVigQeLZnWI?5`T!P&iX3U_ zDZP|Y$SjjY*@Bugg%KM$GGIksDf5X(KgDcbT$?e}gQhPFQNkV=kzf$&-Nr%EKoUY+ ztJ4u7M8u(-x*~Tjk53yeL`yqrL%Lg6ISIihNqucXOO$C4q7NEH&+l^>Kl7Oj*`4m@ z8Yku%mlO50?FeW8IteXT4JCwY%o?=oY(xMeIae%7U$h8?rnu>a*E2U}?eS|@t#J4d z3r2Lg^lf#MwUU^Mm%vYSl1JU(v&P>ht5@Z{MpdnP-Y8XkuW_B1O#QF@L;2sD{oe20 zg;@o`uWD0ozv>M@c_01gNBw{F`~URYTA9+{iFeiCmYpmox~7;}AM3T!HR1*&Bw~bV z87Dd}=EA0oCCTBABvi8&BV|L-}Dld-0-8OtVA^i!e0h#vnNac&8^LYH(bu z@wvq&s5zGbsft>ck=196G`Xg!gL8~Z#->~K1HuqKbgwqG>*4}@jtD>q7q?5o$esbg zTrZ-MPcD(Ksb39=T+WN@RbHiu7fn=OjicTnT!R=JNI<|g6W z*_k=JcKwF!;1cJiSUsH}R+-ABo6o;!?YUJFP0Y{aSSlZs} zbjtss5VU`B@w?yoG?Cm_1b5sZ|D1pRKM}y4Z9ni|oax>3T7TOw?EZLcpnf#PY@E(U z7m+}K&udUTO{_!&GHu^_vWM*W#Jx6o^d8Rf#*no=d+HCLv!_1$tc`RJ**$M~gB>RX z+f5rv4Lhzz1dFL6u?^>fL{;EwnzlGxZl4gUY1krwq<-i61M6L{vek9YBjBh_62sXa z`XY?7IfKm+K!i9ajQFkTh*3lU(nS#PqNI*y3d#1-izY+>b$%0(=*5zXJj$TaGh-Oa zB7*Jp9lLqunq7YBCEl3cv6FAU--eG(n*wcRjJ+>^?hCd)yI>t&AuCTx#kq0?@x0tZ z*0MDuWsr4``0?5=yz|||AN%N~uYPrKH`qJ=ylMi7+6ujMKXPKK7OVUitA_t=mV+8{ z89!zTC0S4*jfiL20U~!@xHqhv**js!51nQinjU-UnI&EtRYx-P+x+Z2>k6;fXknPe zn9rgfCYXxF((U2~{eu{UWLJU>($#r=cMDL6C=LjK^KLqpeq)!N;ratYKz}3w_9cr` zJDfcr0O@i5BkDyPLZ+mVI5E=kB7&N9Fo>7CS0j3-^^Kb%9*9;GG}SZd`5i3yx!F0p ze4PqjEct=qVQLq1cuBn4j?ewWBepcRXqTUQ-nLmS6hDj7i!uf@DP5*>t9YzI`*QJU z_@)21y>tC->n`K?IX*te$M&(~T%1drW?fa=U7E#aDT;o42FI2e1yZpYq%2tNSuFyp{{ zkHm#TsdAhSokRdI1mIH{qFMrhh?E2C!qm5{fcSldGq$l>r_6iKdiIZ;RRcR`0vqAX z*)xDWj+$en>*HfNvvTchX#!~lA(n{AF@=};oe==@I|P8&RJ@_Tm+Pfl&jv70L`pGR zI~-LmrJ!?NLLu))uQiPTNDH9)m6+i!0ch|)+Wl?bUxq=2xcwY4`^U4h49YHplbo@R zot&^nPf=#C0ouIr?BiK>{4Fy9MWtt3pof-)kxb!clXJdF51>uhS@sM$foCk}XOWEi z(`5hCe?57Rc>5kPU*CNX1we0SS(DcQzly((IlNYJeak{$H#$w zKStvxrQoRjo?$3~@XXO5X3M@t5O~oX6$pT|gzN?*9eOOL?`RRTmv#w&m@gqP9*5cK zWb-5da8E{{&V8wMkxz&q%d6{vG%i`W#buaTW*u=Wi%T~$F<}KXl1dxYIQw&pi-1r+ zwLW?DPwVmhkF9JPfCZMMMjS!Rm;w;OxE(sM>HV80h4QPI)A>arvhneQ2QN{b_}RzA zY1O-LdoBRIH$VHMv#CTqdV=nkOLpA*KAp?2(*M`zAqW9JZwghYDA(M-=dG_@ya>S< zjY?NeMK)H}IZIWV1wJ2Vr?3l0QKf=c4S=oDhRx9(yB}jyHh^w2TF}wL2TIKF!*9%K`7VfAye_w9wxu3xgYXkD)t$;;#0mw~jcFo>dz01dLfhGO`tue9u00s$Wa zW&b<3y>TzQ-riTn>+9>b?*gC?k=yvnJD0}jDLhB$>M|V-Q|!{odea^*(s4U_ob#RX z?nuB~JP@zq9vUJJBrI)CX@({Cb2RAj5Q5O}qxvaSRii@*LT@1m4F*Igp(4N8z{$W; zxqxwkErN^_7LU@|9_eG#RSfgYy7=~r4JfamRIDa>*Aiu5jS766O# zcc-9y295%cc)N1yLKRV4nV2pTD}=1Gz5(M+8$4Aw zD!f#rPk4gr6jlN(&2b}xE`f$}SQ}XfVBwk!s0J#Iri^8>oD0}0i@?n1!SJn6O5T9s zN*SxmsEx5#7d#6-eZ9QVh&C&Qgd52bz`uo6_x|zp_+S2h`_M<3O3e^O-%id_LFf z*RRKJKl|vb#J;YAK=f_m;gg_p1x)0m;f!)pRJUT~HVjr}lJEt!n{Dm@A5x^jL=iMa zAK-|Cj91SDXa+zdg8(}~Oyqak^{Nq2vygoBuH1{(U3x3bD(}64MAQlHsMu2KUmZau zT(msJuj>VD4&Bi*Mdlh*d0i#wWJlTEFqqw~D4&?F313q8E&O*Pjr>8UN*q6refkl# z6dy45?vJ^Yf~gyUWaIxIC)Rm>K+B@5%jN! zVhX~_*Lm*1SOGIcXy0O+;%aeRpaBFnA?yGbru(RTRmuub(g#?==kEnguTip6{l*P| z=}O)x%2p#-qsbmZOxq#{UxA4NTWFGaX))NTjzBayBJ^DSc-t@>BY{25ZpWAt29N9o z-VaBDg8A)c1<=u;SLryL-`AT#_HnutIo@aa(95mY$(o18kY8@r*)WCe&6Je6UH9{|5eL(3cB zvHU@!1H^p!emqeOU*3Sp4-nClYo<`JLFranRv}v13Z%L_>~#5z5$pwN0o4)WGjgY! zj?^s6`7^PG4`R0ewXH1o+lii|r1}Ct z7hk6c=rnLOBgz4^O-N9oSA+mYlPuvbX3O_ivXh{X0ztsdu=U|HLL))IaFqD#Dx*(@ zCQ7$T0NBD@vD&5HVX_VgF;{1~!qO@SQ}>kO4Wy&l?a>?p`l#Km-9p3MW-{4KeX+Eb z%K=niP3s*G&5qxp?WdQ+EC8P_k_WbR<%)CCddlpFW9fFwIZ1!wSBTM{BiQpY*~1qQ z0fs3%$u2~T!aE3op*xSIDPX)A!)CT3W+-ftJs_=M%Eqz!byGdR@etV2(gZRTO|u67 zpyUiu#!V76izuT{c*Fb1-@SMz`7jkp{de#voiH4%DHJj;SsYYmMc6}C6n5TqyDY@Dt5g4!U z?DTF~z_OamAl zhq)ei3it&*iQ9Qk&ujA>bLJh!mDxlyV(?P|-7SN-~Wbu9`i#>0n7 z${hLI7eN4?%Y@0vNpCR-e6QifqJGQs8@Atw*gogBM^{diW>2taWGi55ZgY}j+*7Gr z2ZpxRcDCzn!2cUgqCT0mYSYuxM{;*RmoVXrAq7GTgcJxV5Khw6P5=M^ 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 ec02b070bad87b6412334450a80ae435ffbff049..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7736 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 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 b96cb3f6a7d8e5a23715db2ec7cc7ed05914e9ae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1787 zcmb7Ec{H0@8^3Qtg2obS(ApZMwn)o_*ouyl9<&H*X)3DI##F~XC}ob$r;BP@>C}=b zgBnULsb!34>SJmtLy#JbVv@!(6baq=UUQ~r&iwg3_m6wd{hj+;o_o);J-$5yR45)^ z9sq&>fZzqTCqXulN1^4=D0w-woPvVhh(g8O!~iTn+LFRAQNOFAuK>( zAtV;s?gF~7K?HKg>ZgJL3LztlM#7+z3P3;zge+QKR{n1oLLg;iu_&}EvBA*hctl)$ zshZK=gRWW3+rt_M++v@oYvSDx`%!1-;Op8j?Z2quejsHKvM_|l!m@uA@{=^oOq9W@ z9x!xOJFXrP>$W`sFffR~BC)^)EOG?r=segyzyLV_#sMx1;DByHCIoK)r~s&*h_D7I z06eD*4go;63*41*wX=NL@lJp+zghI%e9*{Do$rWzr+&#qH?LPIiSW&I1h&Hb=@CAu zvDD)AMWr=St@Lh7OhjY}kio~O!1BDd#rK8N$;_EZQJPzEQK<1x1!0aEb+uI!7kxu) zhTb--b}7C5Pw>`nxg$ls^cVbo$p>dgg%r)T%6D^;RVn9JKS`|vHwf2$)>W)~_REeD zQHHi|>&7B`>$#t^MwxrP3?9U&z>%#cI$pN0g|o*b&&CwRJ}lf;pZIrv^#@+b@j zcoF`GqVvLAp*-pDTAdyJ|BcjP(jHMIiyH}qi^~ru6U?jp5*a=fUiLn5akn!BFZM5VC9V$fm;m5(vmv|^fn_SePnqFK%Jta761Z3Nn>JL?yJ4Lr+u53Pkh$gzrrzVVfj5- zGoRoj2@=21BIOq+1#aC*^}of+E1-$6N);WK>9+RK)Y795NG3Buj)foXCEp5duubAQ zd*^BwX{=-2{Dc#^++~v;pxFLJ9nEU$z??=_2@9DZud z%w6`Nb{ZwX%*#KXk|2?s=7@xLy*Dgr`5_I~8{BQcd}->}#Po2`w0*2OTDmeFJvTUe z#{PKjH5K^r+uYnw0gQ5!?$U!)4bx+-Y%*0Z{<>c1$&W(s=z~+8pqKoyi1fdEJlr1f6K36sI z{O(b0&-#hc<*0ZEz0EMiFxt7~JO4;#Ha+$~oxK{@xavr%lEr zuB@n!{Vb?}Ndzs|)XRA(Y2mh7cD3UQG1CU?BKLu#9Px@FlbQBBAaHHPM>t3gK zrb128*>ldU!KIjAG5KY#Vr{{ccueoIAo&`a_L+C|`+(e|*sL7wp$+NJ(O1;Uk4^}L zDHe+{jc?g`X;!F*R-$RAA5mi^a)38wXqXA0&D^oCLq?k}AJMVJSJ>5c3I6O^yYc=A z?Lrpc{S=*-C9iGQ>viGU07U=z=9i$x=OUM?dv8W#XO%Jz=DmotS#Xo|h$ueC*T0J1 zu()qF;`4*i?)&|!0Cd;G9RqN4Q7Fr71F$n>0VWh66S6Z64Zt(Zp)B|eg2ynf9vJ9v G|MowrC;2%5 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 7ef77ac754a1a396eb2d0655ba73a4b1c10cd663..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16740 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 CiF%L# 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 a88d1b448d6632c53ec720b66130c9e7ef24a05e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1167 zcmV;A1aSL_P)C0001uP)t-s01ll0 z|Nj60m%P2)005c*37hHa^#BH&yT9d=meBC=`34!Joub$P6QKCx2eTw}YXsn>sl#jUa2 zCoQGonaJw)Uc}mENmG>;M0G=K!HV!V9FHapuhI^HGz{vYWkR+v_n5!!QiPFbu;m z48t(Ye~duTFP;B-2Uz`z2K43urlI^NC#c5nkdF~kU?EIhZ?cFQ-kX9dLKh=&{(7j4 zrJLs)G>{YpUwEGy9>Mvem7%u`j}f^$g*Jh=#rwo7azNkQL_06oIGaGy{;#k%w2#hH zq`=VFqYi&oqs$TC($8D{{GZ- zp=*%jr}E~tnoB}A&G%iP=dkOs&Ad-Jl3>t%_CE~!g3k}o+76DyzWo|Di$Cdv- zbPof1u8NKe<%kt$xq@hDD)Rm}Uu0ydQ z1a;1qgfw)iN8;Yxyd!M@B>kvE#olVFc$)%#+UKcoUMD$WlS!wgk5>Fl0dJe^Cir== zt{5~%D}Jg#(xB2i`nI!V!nyE)%VWs7$0d3FxCFoIi!E^ijnj6H3)6$zY@(!)gL^P>RdbKs5Q% zB+hSOLkK1~m1FqwwHclBEZJ;;wUYukp;`>G93%-omh?Io1qhZji~K268#!g1#wN6E zHfM>2ZbK%GAz1Bd+L7U1$Mf?}50?o|d~tf4iEX#@M<3b&CtL+HwvJD;H`w}B80#u1 zc7dM(_D$ia0hyqwtQ2DNUQJ#eGA1Avj;=!*`~WUfgqH5a#W%3P&T&4_Qo{ 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/shared/components/ProviderIcon.tsx b/src/shared/components/ProviderIcon.tsx index 564ec6ca1d..19aad7ce1a 100644 --- a/src/shared/components/ProviderIcon.tsx +++ b/src/shared/components/ProviderIcon.tsx @@ -1,17 +1,18 @@ "use client"; /** - * ProviderIcon — Renders a provider logo using @lobehub/icons with static asset fallbacks. + * ProviderIcon — Renders a provider logo prioritizing local SVGs for speed. * * Strategy (#529): * 0. If `src` is set (operator-supplied remote icon URL, #2166), render it — this always - * wins over the @lobehub/static resolution below. On load error, falls back to + * wins over the resolution below. On load error, falls back to * `fallbackText`/`fallbackColor` (a colored text badge) if provided, otherwise falls - * through to steps 1-4. - * 1. Try @lobehub/icons direct icon components (no @lobehub/ui peer runtime) - * 2. Fall back to /providers/{id}.png (existing static assets) - * 3. Fall back to /providers/{id}.svg (SVG assets) - * 4. Fall back to a generic AI icon + * through to steps 1-5. + * 1. Try /providers/{id}.svg (local SVG assets — fastest, cached separately from JS bundle) + * 2. Try @lobehub/icons direct React components (no @lobehub/ui peer runtime) + * 3. Fall back to thesvg.org CDN (external SVG) + * 4. Fall back to /providers/{id}.png (legacy static assets) + * 5. Fall back to a generic AI icon * * Usage: * @@ -33,7 +34,7 @@ interface ProviderIconProps { /** * Optional operator-supplied remote icon URL (#2166) — e.g. a custom icon set for an * OpenAI-/Anthropic-compatible provider node. When set, this always takes priority - * over the @lobehub/static resolution. On load error, falls back to `fallbackText` + * over the resolution chain. On load error, falls back to `fallbackText` * (if provided) or the normal resolution chain below. */ src?: string; @@ -51,83 +52,168 @@ function GenericProviderIcon({ size }: { size: number }) { ); } +const KNOWN_SVGS = new Set([ + "360ai", + "alibaba", + "anthropic", + "apikey", + "arcee", + "arcee-ai", + "assemblyai", + "aws", + "azure", + "azureai", + "baichuan", + "baidu", + "bailian", + "baseten", + "bazaarlink", + "brave", + "brave-search", + "cartesia", + "cerebras", + "clarifai", + "claude", + "claude-web", + "cline", + "cloudflare", + "codex", + "cohere", + "comfyui", + "command-code", + "continue", + "copilot", + "coze", + "cursor", + "deepgram", + "deepinfra", + "deepseek", + "dify", + "docker-model-runner", + "doubao", + "droid", + "elevenlabs", + "exa", + "fal", + "fireworks", + "friendli", + "gemini", + "gitlab", + "gitlab-duo", + "google", + "grok", + "groq", + "heroku", + "huggingchat", + "huggingface", + "hyperbolic", + "ibm", + "iflytek", + "inclusionai", + "inference", + "inworld", + "kilo-gateway", + "kilocode", + "kimi", + "kiro", + "krutrim", + "lambda", + "liquid", + "longcat", + "meta", + "metaai", + "minimax", + "mistral", + "modal", + "monsterapi", + "moonshot", + "morph", + "nebius", + "nlpcloud", + "nomic", + "novita", + "nvidia", + "oauth", + "oci", + "ollama", + "openai", + "openclaw", + "opencode", + "openrouter", + "ovhcloud", + "perplexity", + "phind", + "picoclaw", + "playht", + "poe", + "pollinations", + "poolside", + "puter", + "qianfan", + "qwen", + "recraft", + "replicate", + "roocode", + "runway", + "sambanova", + "sap", + "scaleway", + "searchapi", + "searxng-search", + "sensenova", + "serper-search", + "snowflake", + "sparkdesk", + "stepfun", + "suno", + "synthetic", + "tavily", + "tencent", + "topazlabs", + "trae", + "udio", + "upstage", + "v0", + "vercel", + "vllm", + "volcengine", + "voyage", + "wandb", + "windsurf", + "xai", + "xinference", + "yi", + "youcom-search", + "zhipu", +]); + const KNOWN_PNGS = new Set([ + "adapta-web", "agentrouter", - "inner-ai", "aimlapi", "anthropic-m", "blackbox", - "claude", - "continue", - "copilot", - "cursor", - "deepgram", - "ironclaw", - "kie", - "nanobot", - "oai-cc", - "oai-r", - "openclaw", - "zeroclaw", - "adapta-web", "blackbox-web", "cliproxyapi", "empower", "gigachat", - "heroku", + "inner-ai", + "ironclaw", + "kie", "lemonade", "linkup-search", "llamafile", "llamagate", "maritalk", + "nanobot", "nanogpt", "nscale", - "ovhcloud", + "oai-cc", + "oai-r", "piapi", "predibase", "reka", -]); -const KNOWN_SVGS = new Set([ - "apikey", - "bazaarlink", - "brave", - "brave-search", - "cartesia", - "360ai", - "huggingchat", - "iflytek", - "sparkdesk", - "arcee-ai", - "liquid", - "monsterapi", - "nomic", - "poolside", - "clarifai", - "command-code", - "claude-web", - "docker-model-runner", - "droid", - "gitlab", - "gitlab-duo", - "inworld", - "kiro", - "kilo-gateway", - "kilocode", - "modal", - "nlpcloud", - "oauth", - "oci", - "opencode", - "playht", - "puter", - "qianfan", - "sap", - "scaleway", - "serper-search", - "searxng-search", - "synthetic", - "wandb", - "youcom-search", + "zeroclaw", ]); const ProviderIcon = memo(function ProviderIcon({ @@ -143,20 +229,22 @@ const ProviderIcon = memo(function ProviderIcon({ }: ProviderIconProps) { const normalizedId = providerId.toLowerCase(); const lobeIcon = getLobeProviderIcon(normalizedId, type); - const hasPng = KNOWN_PNGS.has(normalizedId); const hasSvg = KNOWN_SVGS.has(normalizedId); + const hasPng = KNOWN_PNGS.has(normalizedId); const [failedAssets, setFailedAssets] = useState>({}); const [remoteSrcFailed, setRemoteSrcFailed] = useState(false); - const pngKey = `${normalizedId}:png`; const svgKey = `${normalizedId}:svg`; - const usePng = !lobeIcon && hasPng && !failedAssets[pngKey]; - const useSvg = !lobeIcon && hasSvg && !failedAssets[svgKey] && (!hasPng || failedAssets[pngKey]); + const pngKey = `${normalizedId}:png`; + const theSvgKey = `${normalizedId}:thesvg`; const trimmedSrc = typeof src === "string" ? src.trim() : ""; + const svgFailed = failedAssets[svgKey]; + const theSvgFailed = failedAssets[theSvgKey]; + const pngFailed = failedAssets[pngKey]; - // #2166: a custom remote icon URL always wins over the @lobehub/static resolution - // below. It is a plain (not next/image) so operators can point at any host + // #2166: a custom remote icon URL always wins over the resolution chain below. + // It is a plain (not next/image) so operators can point at any host // without requiring `images.remotePatterns` allow-listing for arbitrary domains. if (trimmedSrc && !remoteSrcFailed) { return ( @@ -199,43 +287,8 @@ const ProviderIcon = memo(function ProviderIcon({ ); } - if (lobeIcon) { - return ( - - {createElement(lobeIcon, { - "aria-label": providerId, - size, - style: { flex: "none" }, - })} - - ); - } - - if (usePng) { - return ( - - {providerId} { - setFailedAssets((current) => ({ ...current, [pngKey]: true })); - }} - unoptimized - /> - - ); - } - - if (useSvg) { + // Tier 1: Local SVG — fastest, cached separately from the JS bundle + if (hasSvg && !svgFailed) { return ( + {createElement(lobeIcon, { + "aria-label": providerId, + size, + style: { flex: "none" }, + })} + + ); + } + + // Tier 3: thesvg.org CDN — external SVG fallback for unknown providers + if (!theSvgFailed) { + return ( + + {/* eslint-disable-next-line @next/next/no-img-element -- external SVG from thesvg.org, not a static/known asset */} + {providerId} setFailedAssets((current) => ({ ...current, [theSvgKey]: true }))} + /> + + ); + } + + // Tier 4: Local PNG — last resort before generic icon + if (hasPng && !pngFailed) { + return ( + + {providerId} setFailedAssets((current) => ({ ...current, [pngKey]: true }))} + unoptimized + /> + + ); + } + + // Tier 5: Generic AI icon return ( diff --git a/tests/unit/ui/ProviderIcon-icon-url.test.tsx b/tests/unit/ui/ProviderIcon-icon-url.test.tsx index 19977c1455..f34ace21a5 100644 --- a/tests/unit/ui/ProviderIcon-icon-url.test.tsx +++ b/tests/unit/ui/ProviderIcon-icon-url.test.tsx @@ -23,8 +23,8 @@ const { default: ProviderIcon } = await import("@/shared/components/ProviderIcon // ── Helpers ─────────────────────────────────────────────────────────────────── // Deliberately not registered in @lobehub/icons aliases or the KNOWN_PNGS/KNOWN_SVGS -// static-asset sets, so tests exercise only the `src` override + generic-icon fallback -// paths, never the @lobehub/static resolution chain. +// static-asset sets, so tests exercise only the `src` override + fallback chain +// (thesvg.org → generic icon). Never reaches the local SVG or @lobehub tiers. const UNKNOWN_PROVIDER_ID = "openai-compatible-test-node-xyz"; const containers: HTMLElement[] = []; @@ -50,8 +50,9 @@ function fireImgError(container: HTMLElement) { } beforeEach(() => { - (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = - true; + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; }); afterEach(() => { @@ -71,18 +72,31 @@ describe("ProviderIcon — custom remote icon URL (#2166)", () => { expect(img?.getAttribute("src")).toBe("https://example.com/logo.png"); }); - it("falls back to the generic icon when `src` is unset", () => { + it("falls back to thesvg.org CDN when `src` is unset (Tier 3 for unknown providers)", () => { const container = renderIcon({}); - expect(container.querySelector("img")).toBeNull(); - expect(container.querySelector("svg")).not.toBeNull(); + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + expect(img?.getAttribute("src")).toBe( + "https://thesvg.org/icons/openai-compatible-test-node-xyz/default.svg" + ); }); - it("falls back to the generic (lobehub/static) icon chain when `src` load fails and no fallbackText is given", () => { + it("falls back through thesvg.org CDN then generic icon when `src` load fails and no fallbackText is given", () => { const container = renderIcon({ src: "https://example.com/broken.png" }); expect(container.querySelector("img")).not.toBeNull(); fireImgError(container); + // Falls back to thesvg.org + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + expect(img?.getAttribute("src")).toBe( + "https://thesvg.org/icons/openai-compatible-test-node-xyz/default.svg" + ); + + fireImgError(container); + + // thesvg.org fails → generic SVG icon expect(container.querySelector("img")).toBeNull(); expect(container.querySelector("svg")).not.toBeNull(); }); @@ -102,9 +116,12 @@ describe("ProviderIcon — custom remote icon URL (#2166)", () => { expect(container.textContent).toBe("OC"); }); - it("ignores a whitespace-only src and falls back to the generic icon", () => { + it("ignores a whitespace-only src and falls back to thesvg.org CDN", () => { const container = renderIcon({ src: " " }); - expect(container.querySelector("img")).toBeNull(); - expect(container.querySelector("svg")).not.toBeNull(); + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + expect(img?.getAttribute("src")).toBe( + "https://thesvg.org/icons/openai-compatible-test-node-xyz/default.svg" + ); }); }); From eeec4d9e87a8cdae832ff3cbc0e2baa4b811f665 Mon Sep 17 00:00:00 2001 From: Moseyuh333 <148680980+Moseyuh333@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:49:48 +0700 Subject: [PATCH 5/5] feat(chaos): big update - optimize, fix bugs, add features, enhance UX (#6728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(chaos): add Chaos Mode — multi-model parallel/collaborative execution - New DB column chaos_mode_enabled on api_keys table - API key create/PATCH routes support chaosModeEnabled toggle - Core library src/lib/chaos/chaosConfig.ts for persistent config - API routes: GET/PUT/DELETE /api/chaos/config - Chaos execution POST /api/skills/collect/chaos with key auth - Dashboard page at /dashboard/chaos with full config UI - Sidebar entry in Agentic Features section - Chaos mode toggle in API Key editor permissions panel - i18n keys for chaos config (en.json) * feat(chaos): big update — optimize, fix bugs, add features === Changes === 1. NEW: src/lib/chaos/chaosExecutor.ts — shared execution engine - Removed ~150 lines of duplicate dispatch logic between two API routes - Single executeChaosRun() function used by both endpoints - Added concurrency limit (max 10 parallel requests) - Added proper TypeScript interfaces (ChaosRunInput, ChaosRunResult) - Added error logging throughout 2. FIX: src/app/api/skills/collect/chaos/route.ts - Was MISSING logger import (log.error was undefined at runtime) - Reduced from 388 lines → 142 lines by delegating to shared executor - Added maxTokens support in schema validation 3. REFACTOR: src/app/api/chaos/run/route.ts - Simplified to thin wrapper: auth + validate + delegate to executor - Added maxTokens support 4. ENHANCE: src/lib/chaos/chaosConfig.ts - Added maxTokens config field (256-128k, default 4096) - Persisted per-instance via settings table 5. ENHANCE: UI — ChaosConfigPageClient.tsx - Loads available providers from /api/models for dropdown autocomplete - Added datalist-based provider selector in overrides section - Added Max Tokens configuration input - Added expandable provider list showing all detected providers - Fixed duplicate override detection * fix(chaos): fetch providers from /api/providers instead of /api/keys * fix(chaos): remove dead code isOverrideDuplicate, fix maxTokens fallback to include global config * fix(chaos): resetConfig now shows error on HTTP failure (was silent) * feat(dashboard): Chaos Mode — multi-model parallel/collaborative execution Splits the PR down to only the genuinely new Chaos Mode feature (drops the duplicate Skill Collector/GitHub-discovery portion already shipped via #6186). Replaces the loopback fetch() dispatch (hardcoded to the wrong port) with the established in-process synthetic-Request/route-handler pattern used by src/lib/batches/dispatch.ts, moves settings persistence off raw SQL, and adds unit test coverage for chaosConfig, chaosExecutor and the 3 chaos API routes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(chaos): fix external Bearer-auth bypass and stale config cache in tests validateApiKey() returns a plain boolean for both the deployment-time env key and a DB-backed key, so branching on `keyInfo === true` in verifyChaosKey() (src/app/api/skills/collect/chaos/route.ts) treated every valid API key as having full env-key access, silently skipping the chaosModeEnabled permission check entirely. Now always resolves through getApiKeyMetadata() and only bypasses the per-key check for the synthesized env-key record (id: "env-key"). Also exports invalidateChaosConfigCache() from chaosConfig.ts and wires it into the route tests' resetStorage() — the in-process config cache was surviving DB resets between tests, causing state to leak across cases. Fixes CHANGELOG-eat from the release merge (re-inserted the Chaos Mode bullet against the base CHANGELOG.md, verified additive via check-changelog-integrity.mjs) and re-syncs against release/v3.8.47 tip. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * docs(changelog): Chaos Mode overhaul bullet referencing #6728 after release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(merge): restore #6126 clinepass files reverted by release auto-resolve + baseline re-merge The release sync's auto-resolve reverted sibling PR #6126's clinepass work (registry, catalog, oauth constants, clineAuth.ts, token-refresh case, tests) and the file-size baseline — all outside this PR's scope. Restored to the release versions, re-applied only this PR's own baseline entries, restored the #6126 CHANGELOG bullet (re-inserting only this PR's own). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(dashboard): chaos client hook must not import the server Pino logger useChaosConfigData ("use client") pulled @/sse/utils/logger → shared Pino → logRotation/dataPaths → node:fs into the browser bundle, breaking next build (Turbopack: Can't resolve 'fs') — caught by the DAST smoke's isolated build. console.error matches every other dashboard client component. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test(api-manager): align switch-count invariant with the extracted toggle components The Self-service block now renders 4 inline switches; the #5731 quota-bypass and #6728 chaos-access toggles were extracted into dedicated components. The type="button" invariant is preserved AND extended: the test now also asserts each extracted component's switches declare type="button". Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(sync): merge release tip + restore own CHANGELOG bullet Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Moseyuh333 Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza --- CHANGELOG.md | 1 + .../api-manager/ApiManagerPageClient.tsx | 51 +- .../components/BypassProviderQuotaToggle.tsx | 45 ++ .../components/ChaosModeAccessToggle.tsx | 45 ++ .../dashboard/chaos/ChaosConfigPageClient.tsx | 107 +++++ .../dashboard/chaos/chaosPageTypes.ts | 28 ++ .../components/ChaosBasicSettingsFields.tsx | 122 +++++ .../components/ChaosConfigActionsBar.tsx | 74 +++ .../chaos/components/ChaosModeSelector.tsx | 60 +++ .../ChaosProviderOverridesPanel.tsx | 156 +++++++ .../chaos/components/ChaosStatusMessage.tsx | 23 + .../components/ChaosTestResultsPanel.tsx | 66 +++ src/app/(dashboard)/dashboard/chaos/page.tsx | 12 + .../dashboard/chaos/useChaosConfigData.ts | 96 ++++ .../dashboard/chaos/useChaosConfigPage.ts | 72 +++ .../chaos/useChaosConfigPersistence.ts | 65 +++ .../dashboard/chaos/useChaosTestRun.ts | 48 ++ src/app/api/chaos/config/route.ts | 84 ++++ src/app/api/chaos/run/route.ts | 73 +++ src/app/api/keys/[id]/route.ts | 3 + src/app/api/keys/route.ts | 6 +- src/app/api/skills/collect/chaos/route.ts | 147 ++++++ src/i18n/messages/en.json | 31 ++ src/lib/chaos/chaosConfig.ts | 119 +++++ src/lib/chaos/chaosExecutor.ts | 439 ++++++++++++++++++ src/lib/db/apiKeyColumnFallbacks.ts | 4 + src/lib/db/apiKeys.ts | 22 +- src/lib/db/apiKeys/rowParsers.ts | 4 + src/shared/constants/publicApiRoutes.ts | 7 + .../constants/sidebarVisibility/sections.ts | 8 + .../constants/sidebarVisibility/types.ts | 1 + src/shared/validation/schemas/keys.ts | 5 +- tests/unit/api-manager-page-static.test.ts | 26 +- tests/unit/chaos-api-routes.test.ts | 320 +++++++++++++ tests/unit/chaos-config.test.ts | 126 +++++ tests/unit/chaos-executor.test.ts | 243 ++++++++++ 36 files changed, 2703 insertions(+), 36 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/api-manager/components/BypassProviderQuotaToggle.tsx create mode 100644 src/app/(dashboard)/dashboard/api-manager/components/ChaosModeAccessToggle.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/ChaosConfigPageClient.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/chaosPageTypes.ts create mode 100644 src/app/(dashboard)/dashboard/chaos/components/ChaosBasicSettingsFields.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/components/ChaosConfigActionsBar.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/components/ChaosModeSelector.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/components/ChaosProviderOverridesPanel.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/components/ChaosStatusMessage.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/components/ChaosTestResultsPanel.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/page.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/useChaosConfigData.ts create mode 100644 src/app/(dashboard)/dashboard/chaos/useChaosConfigPage.ts create mode 100644 src/app/(dashboard)/dashboard/chaos/useChaosConfigPersistence.ts create mode 100644 src/app/(dashboard)/dashboard/chaos/useChaosTestRun.ts create mode 100644 src/app/api/chaos/config/route.ts create mode 100644 src/app/api/chaos/run/route.ts create mode 100644 src/app/api/skills/collect/chaos/route.ts create mode 100644 src/lib/chaos/chaosConfig.ts create mode 100644 src/lib/chaos/chaosExecutor.ts create mode 100644 tests/unit/chaos-api-routes.test.ts create mode 100644 tests/unit/chaos-config.test.ts create mode 100644 tests/unit/chaos-executor.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f92ef4b3..9b4ec31d1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **ClinePass dual-auth**: ClinePass now offers both sign-in methods on its dashboard page — OAuth (reusing the Cline WorkOS flow) as the primary "Connect" path, or a pasted BYOK API key via "Manual API key", instead of only the API-key-only provider shipped in #5942. The registry alias was aligned to `cp` (matching the `OAUTH_PROVIDERS` catalog alias) so `/` routing resolves correctly, the OAuth refresh dispatch now routes `clinepass` to the shared Cline refresh flow, and the duplicate API-key-only catalog entry was removed to keep ClinePass listed once. Regression guard: `tests/unit/clinepass-provider.test.ts`. (#6126 — thanks @hajilok) - **feat(oauth):** Kiro/Amazon Q auto-import now supports enterprise **External IdP** ("Your organization") logins via Microsoft Entra/Okta/Auth0/OneLogin/Ping/Google/Cognito — these org-issued tokens are not AWS SSO tokens (no `aorAAAAAG`-prefixed refresh token) and can't refresh through the AWS OIDC/Kiro-social path, so `tryAwsSsoCache()` now detects them (`authMethod`/`provider === "externalidp"`) and refreshes via the org IdP's own `tokenEndpoint` (public-client OAuth2 refresh grant, no client secret), persisting `TokenType: EXTERNAL_IDP` gating so the runtime executor sends the header the AWS CodeWhisperer API requires for these accounts; `tokenEndpoint` is SSRF-guarded against an HTTPS + known-IdP-host-suffix allowlist. (#6363 — thanks @artickc) - **Kiro long-lived API key auth**: new `/api/oauth/kiro/api-key` route + `KiroService.validateApiKey` let a Kiro account be linked with a long-lived AWS CodeWhisperer/Kiro API key instead of the interactive OAuth device flow, with live per-account model discovery (`ListAvailableModels`, 5-minute cache) layered over the existing static registry fallback (#6587 — thanks @strangersp) +- **Chaos Mode**: multi-model parallel/collaborative task execution — dispatches a task to every active provider connection at once (parallel) or chains outputs sequentially so each model builds on the previous one's answer (collaborative), configurable via Dashboard → Chaos Mode (`GET`/`PUT`/`DELETE /api/chaos/config`) and gated per-API-key via a new `chaosModeEnabled` permission (opt-in — disabled by default globally and per key). `POST /api/chaos/run` (dashboard session) and `POST /api/skills/collect/chaos` (external Bearer-token) delegate to a shared `executeChaosRun()` engine (`src/lib/chaos/chaosExecutor.ts`) that dispatches in-process via the established synthetic-Request/route-handler pattern (no network hop, no hardcoded port), with a concurrency cap (max 10 parallel), configurable `max_tokens` (256–128k), a clear error when `stream` is requested, and collaborative-chain info (provider order + input size). Fixes external Bearer-auth bypass and stale config-cache leakage. Regression guard: `tests/unit/chaos-config.test.ts`, `tests/unit/chaos-executor.test.ts`, `tests/unit/chaos-api-routes.test.ts`. ([#6728](https://github.com/diegosouzapw/OmniRoute/pull/6728) — thanks @Moseyuh333) ### 🐛 Bug Fixes 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")}

+