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 cebc56620a..229016bdd7 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -182,10 +182,12 @@ "open-sse/services/compression/strategySelector.ts": 1043, "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.", - "open-sse/services/tokenRefresh.ts": 2181, + "_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.", + "_rebaseline_2026_07_09_6363_kiro_external_idp": "PR #6363 (@artickc, Kiro external IdP) own growth: tokenRefresh.ts 2182->2249 (+67 = the external_idp refresh branch inside refreshKiroToken — standard public-client OAuth2 refresh_token grant against the org IdP tokenEndpoint via buildExternalIdpRefreshParams/isExternalIdpAuthMethod from the new leaf open-sse/services/kiroExternalIdp.ts, with invalid_grant/invalid_client -> unrecoverable_refresh_error mapping). Cohesive addition at the existing refreshKiroToken chokepoint. Covered by tests/unit/kiro-external-idp.test.ts.", + "open-sse/services/tokenRefresh.ts": 2249, "open-sse/services/usage.ts": 3454, "open-sse/translator/request/openai-to-gemini.ts": 906, - "open-sse/translator/request/openai-to-kiro.ts": 890, + "open-sse/translator/request/openai-to-kiro.ts": 912, "open-sse/translator/response/openai-responses.ts": 1092, "open-sse/utils/cursorAgentProtobuf.ts": 1521, "open-sse/utils/stream.ts": 2792, @@ -246,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, @@ -268,14 +270,16 @@ "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": { + "_rebaseline_2026_07_09_6126_clinepass_dualauth": "#6126 (ClinePass dual-auth) own test growth: oauth-providers-config.test.ts 842->845 (+3: clinepass key/config/required-fields entries reusing the Cline WorkOS flow config, needed after registering clinepass in the oauth.ts PROVIDERS enum).", "_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).", "_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.", "_rebaseline_basered_codebuddy_cn": "Base-red fix (#4664 CodeBuddy CN): oauth-providers-config.test.ts 867->870 (+3) to align the EXPECTED provider list/config with the codebuddy-cn provider that #4664 added to the registry without updating this test (it asserts 'exactly once').", @@ -305,11 +309,11 @@ "tests/unit/image-generation-handler.test.ts": 2019, "tests/unit/model-sync-route.test.ts": 1016, "tests/unit/models-catalog-route.test.ts": 1605, - "tests/unit/oauth-providers-config.test.ts": 842, + "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, @@ -321,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/config/providers/registry/clinepass/index.ts b/open-sse/config/providers/registry/clinepass/index.ts index e6d3697a03..12a57c76ff 100644 --- a/open-sse/config/providers/registry/clinepass/index.ts +++ b/open-sse/config/providers/registry/clinepass/index.ts @@ -1,22 +1,33 @@ import type { RegistryEntry } from "../../shared.ts"; -// ClinePass — Cline's $9.99/mo BYOK API-key gateway (https://cline.bot). Distinct -// from the OAuth `cline` provider: same host (api.cline.bot) but a plain Bearer -// API key and the `cline-pass/*` model namespace. Responses are wrapped in a -// {success, data} envelope — unwrapped by open-sse/utils/clinepassEnvelope.ts. +// ClinePass — Cline's $9.99/mo gateway (https://cline.bot). Dual-auth: sign in +// with a Cline account (OAuth, reusing the `cline` WorkOS flow) OR paste a direct +// BYOK API key. Same host (api.cline.bot) as the OAuth `cline` provider; the +// `cline-pass/*` model namespace. Responses are wrapped in a {success, data} +// envelope — unwrapped by open-sse/utils/clinepassEnvelope.ts. export const clinepassProvider: RegistryEntry = { id: "clinepass", - alias: "clinepass", + // MUST match the OAUTH_PROVIDERS catalog alias (src/shared/constants/providers/oauth.ts). + // The dashboard sends models as `/` (e.g. "cp/cline-pass/glm-5.2"), + // and routing resolves the prefix via ALIAS_TO_PROVIDER_ID (built from this field). If the + // registry alias drifts from the catalog alias, the prefix won't resolve, the executor falls + // back to PROVIDERS.openai, and requests hit api.openai.com with the ClinePass key → 401. + alias: "cp", format: "openai", executor: "default", // ClinePass shares Cline's streaming-only API — a non-streaming request returns // "generateText is not implemented" / an empty body. Force upstream streaming; // chatCore accumulates the SSE and converts it back to JSON for stream:false - // clients. (Same as the sibling `cline` provider.) + // clients. (Same as the sibling `cline` provider. #6165.) forceStream: true, baseUrl: "https://api.cline.bot/api/v1/chat/completions", - authType: "apikey", + authType: "oauth", authHeader: "bearer", + oauth: { + tokenUrl: "https://api.cline.bot/api/v1/auth/token", + refreshUrl: "https://api.cline.bot/api/v1/auth/refresh", + authUrl: "https://api.cline.bot/api/v1/auth/authorize", + }, extraHeaders: { "HTTP-Referer": "https://cline.bot", "X-Title": "Cline", diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index b2697a349e..f3bd3930c8 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -44,7 +44,7 @@ import { buildMaritalkChatUrl } from "../config/maritalk.ts"; import { LOCAL_PROVIDERS } from "@/shared/constants/providers"; import { isForbiddenCustomHeaderName } from "@/shared/constants/upstreamHeaders"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; -import { buildClineHeaders } from "@/shared/utils/clineAuth"; +import { buildClineHeaders, buildClinepassHeaders } from "@/shared/utils/clineAuth"; import { normalizeHerokuChatUrl, normalizeDatabricksChatUrl, @@ -369,6 +369,9 @@ export class DefaultExecutor extends BaseExecutor { case "glm-coding-apikey": headers["x-api-key"] = effectiveKey || credentials.accessToken; break; + case "clinepass": // dual-auth (OAuth or BYOK) — see buildClinepassHeaders() + Object.assign(headers, buildClinepassHeaders(credentials, effectiveKey)); + break; case "cline": // Cline's API requires the bearer token prefixed with `workos:` plus a // set of Cline client-identification headers; plain `Bearer ` diff --git a/open-sse/executors/kimi-web.ts b/open-sse/executors/kimi-web.ts index cf4ff4bdbe..a2fe1e6df1 100644 --- a/open-sse/executors/kimi-web.ts +++ b/open-sse/executors/kimi-web.ts @@ -26,7 +26,10 @@ * session; the upstream returns the same response either way. */ import { BaseExecutor, type ExecuteInput } from "./base.ts"; -import { makeExecutorErrorResult as makeErrorResult, sanitizeErrorMessage } from "../utils/error.ts"; +import { + makeExecutorErrorResult as makeErrorResult, + sanitizeErrorMessage, +} from "../utils/error.ts"; import { extractKimiJwt } from "@/lib/providers/webCookieAuth"; export { extractKimiJwt }; @@ -93,7 +96,10 @@ const MAX_FRAME_LEN = 8 * 1024 * 1024; * (caller must treat this as a stream-fatal protocol error) * - `consumed: N` + the parsed frame otherwise */ -export function decodeConnectFrame(buf: Uint8Array, byteOffset: number): { consumed: number; frame: ConnectFrame | null } { +export function decodeConnectFrame( + buf: Uint8Array, + byteOffset: number +): { consumed: number; frame: ConnectFrame | null } { if (byteOffset + 5 > buf.length) return { consumed: 0, frame: null }; const flags = buf[byteOffset]; const len = @@ -130,7 +136,9 @@ type DeltaKind = "text" | "think" | null; * Anything else (heartbeats, chat/message metadata, stage transitions) is * suppressed; we only surface text to the client. */ -export function extractDelta(msg: Record | null): { kind: DeltaKind; text: string } | null { +export function extractDelta( + msg: Record | null +): { kind: DeltaKind; text: string } | null { if (!msg) return null; const op = String(msg.op ?? ""); const mask = String(msg.mask ?? ""); @@ -167,7 +175,11 @@ export function isEndOfStream(msg: Record | null): boolean { if (!msg) return false; // Assistant message flipped to COMPLETED. const message = (msg.message ?? null) as Record | null; - if (message && String(message.status ?? "") === "MESSAGE_STATUS_COMPLETED" && String(message.role ?? "") === "assistant") { + if ( + message && + String(message.status ?? "") === "MESSAGE_STATUS_COMPLETED" && + String(message.role ?? "") === "assistant" + ) { return true; } return false; @@ -252,7 +264,7 @@ export class KimiWebExecutor extends BaseExecutor { } const messages = (bodyObj.messages as Array<{ role: string; content: unknown }>) || []; - const modelId = (bodyObj.model as string) || "kimi-default"; + const modelId = (bodyObj.model as string) || "k2d6"; // Resolve scenario + default thinking flag from the model id (catalog truth), // then honour an explicit `reasoning_effort: "none"` override from the caller. const modelConfig = resolveModelConfig(modelId); @@ -285,7 +297,12 @@ export class KimiWebExecutor extends BaseExecutor { if (!upstream.ok) { const errText = await upstream.text().catch(() => ""); - return makeErrorResult(upstream.status, `Kimi error: ${sanitizeErrorMessage(errText)}`, body, CHAT_URL); + return makeErrorResult( + upstream.status, + `Kimi error: ${sanitizeErrorMessage(errText)}`, + body, + CHAT_URL + ); } const encoder = new TextEncoder(); diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index 7def296e39..d5623aacdd 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -8,6 +8,11 @@ import { import { PROVIDERS } from "../config/constants.ts"; import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.ts"; +import { + isExternalIdpAuthMethod, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE, +} from "../services/kiroExternalIdp.ts"; import { splitInlineThinking, flushPendingThinking, @@ -196,8 +201,29 @@ export class KiroExecutor extends BaseExecutor { "anthropic-beta": "prompt-caching-2024-07-31", }; - if (credentials.accessToken) { - headers["Authorization"] = `Bearer ${credentials.accessToken}`; + const authMethod = + typeof credentials.providerSpecificData?.authMethod === "string" + ? credentials.providerSpecificData.authMethod + : undefined; + const isApiKey = authMethod === "api_key"; + const token = isApiKey + ? credentials.apiKey || credentials.accessToken + : credentials.accessToken; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + // Long-lived Kiro/CodeWhisperer API keys authenticate with `tokentype: API_KEY`. + if (isApiKey) headers["tokentype"] = "API_KEY"; + + // Enterprise / Microsoft Entra "Your organization" (external_idp) logins send an + // org-IdP-issued access token. CodeWhisperer only binds it to the Amazon Q Developer + // profile when the request carries `TokenType: EXTERNAL_IDP`; without it every call + // returns `ValidationException: Invalid ARN ` (the service falls back to the + // token's client id as the resource ARN). AWS SSO (Builder ID / IDC) and social tokens + // must NOT send this header, so it is gated on the persisted authMethod. + if (isExternalIdpAuthMethod(authMethod)) { + headers[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE; + } } return headers; @@ -780,6 +806,7 @@ export class KiroExecutor extends BaseExecutor { } async refreshCredentials(credentials: ProviderCredentials, log?: ExecutorLog | null) { + if (credentials.providerSpecificData?.authMethod === "api_key") return null; if (!credentials.refreshToken) return null; try { diff --git a/open-sse/mcp-server/tools/githubSkillTools.ts b/open-sse/mcp-server/tools/githubSkillTools.ts index 2161ec0945..18ef2a853d 100644 --- a/open-sse/mcp-server/tools/githubSkillTools.ts +++ b/open-sse/mcp-server/tools/githubSkillTools.ts @@ -77,11 +77,13 @@ async function handleInstall(args: z.infer) { try { const dest = resolveInstallPath(target, skillName, args.description); // In a real implementation, this would clone the repo and copy files. - // For now, we return the planned install path as a dry-run result. + // For now, we return the planned install path as a dry-run result — matches + // the honest `action: "planned"` the REST route (/api/github-skills POST) + // reports for the same operation. results.push({ target, ok: true, - action: "installed", + action: "planned", destDir: dest, }); } catch (err) { diff --git a/open-sse/services/kiroExternalIdp.ts b/open-sse/services/kiroExternalIdp.ts new file mode 100644 index 0000000000..3e27c8120d --- /dev/null +++ b/open-sse/services/kiroExternalIdp.ts @@ -0,0 +1,175 @@ +/** + * kiroExternalIdp.ts — shared helpers for Kiro / Amazon Q **External IdP** + * (enterprise "Your organization" SSO) accounts. + * + * Unlike AWS Builder ID / IAM Identity Center (which mint AWS SSO-OIDC tokens + * refreshed at `oidc.{region}.amazonaws.com` and whose refresh token starts with + * `aorAAAAAG`) or the Google/GitHub social flow (refreshed at the Kiro auth + * service), an **External IdP** login federates through the organization's own + * identity provider (most commonly Microsoft Entra ID). Its Kiro token file + * (`~/.aws/sso/cache/kiro-auth-token.json`) looks like: + * + * { + * "accessToken": "", + * "refreshToken": "", + * "authMethod": "external_idp", + * "provider": "ExternalIdp", + * "clientId": "", + * "tokenEndpoint":"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", + * "issuerUrl": "https://login.microsoftonline.com/{tenant}/v2.0", + * "scopes": "api://{clientId}/codewhisperer:conversations … offline_access" + * } + * + * Two consequences this module encodes (both verified against a live org token): + * 1. The token is refreshed with a **standard public-client OAuth2 + * `refresh_token` grant against `tokenEndpoint`** (form-encoded + * client_id + refresh_token + scope, NO client_secret) — see + * {@link buildExternalIdpRefreshParams}. + * 2. At runtime the access token is sent to CodeWhisperer as a normal bearer + * but MUST carry the header `TokenType: EXTERNAL_IDP` so the service binds + * it to the Amazon Q Developer profile (without it every call returns + * `ValidationException: Invalid ARN `). The profileArn itself is + * NOT discoverable via `ListAvailableProfiles` (it returns an empty list + * for these tokens); it is read from the Kiro IDE `profile.json` at import. + */ + +/** authMethod marker persisted on External IdP connections. */ +export const KIRO_EXTERNAL_IDP_AUTH_METHOD = "external_idp"; + +/** Header CodeWhisperer requires to bind an External IdP bearer to its profile. */ +export const KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER = "TokenType"; +export const KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE = "EXTERNAL_IDP"; + +/** + * Allowlist of enterprise IdP token-endpoint host suffixes. The refresh token is + * POSTed to this endpoint, so we constrain it to well-known identity providers + * (SSRF guard — the value ultimately originates from an on-disk token file). + * Microsoft Entra is by far the most common Kiro org IdP; the others cover the + * major enterprise SSO vendors an org might federate Kiro through. + */ +const ALLOWED_IDP_HOST_SUFFIXES: readonly string[] = [ + "login.microsoftonline.com", + "login.microsoftonline.us", + "login.partner.microsoftonline.cn", + "login.microsoft.com", + "login.windows.net", + "sts.windows.net", + ".okta.com", + ".oktapreview.com", + ".okta-emea.com", + ".auth0.com", + ".onelogin.com", + ".pingidentity.com", + ".pingone.com", + "accounts.google.com", + "oauth2.googleapis.com", + ".amazoncognito.com", +]; + +function normalizeString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +/** True when a connection's providerSpecificData marks it as an External IdP login. */ +export function isExternalIdpAuthMethod(authMethod: unknown): boolean { + return normalizeString(authMethod).toLowerCase() === KIRO_EXTERNAL_IDP_AUTH_METHOD; +} + +/** + * Validate the IdP token endpoint before it is used as a fetch target. Requires + * https and a host on {@link ALLOWED_IDP_HOST_SUFFIXES}. Returns the normalized + * URL string; throws on anything unexpected. + */ +export function validateExternalIdpTokenEndpoint(rawEndpoint: unknown): string { + const tokenEndpoint = normalizeString(rawEndpoint); + if (!tokenEndpoint) throw new Error("tokenEndpoint is required for external_idp"); + let parsed: URL; + try { + parsed = new URL(tokenEndpoint); + } catch { + throw new Error("tokenEndpoint must be a valid URL"); + } + if (parsed.protocol !== "https:") { + throw new Error("tokenEndpoint must use https"); + } + const host = parsed.hostname.toLowerCase(); + const allowed = ALLOWED_IDP_HOST_SUFFIXES.some((suffix) => + suffix.startsWith(".") ? host.endsWith(suffix) : host === suffix + ); + if (!allowed) { + throw new Error(`tokenEndpoint host is not an allowed identity provider: ${host}`); + } + return parsed.toString(); +} + +/** Collapse an array-or-space-delimited scope value into a single space-delimited string. */ +export function normalizeScope(scopes: unknown): string { + if (Array.isArray(scopes)) { + return scopes.map(normalizeString).filter(Boolean).join(" "); + } + return normalizeString(scopes); +} + +/** Best-effort base64url JWT payload decode (no signature verification). */ +export function decodeJwtPayload(jwt: unknown): Record | null { + try { + if (typeof jwt !== "string") return null; + const parts = jwt.split("."); + if (parts.length !== 3) return null; + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padding = (4 - (base64.length % 4)) % 4; + const json = Buffer.from(`${base64}${"=".repeat(padding)}`, "base64").toString("utf8"); + return JSON.parse(json) as Record; + } catch { + return null; + } +} + +/** + * Extract the login identity (email) from an External IdP access token. Org IdP + * tokens carry it as `preferred_username`/`upn`/`email` rather than the AWS + * `email` claim — otherwise the connection surfaces as the opaque "ExternalIdp". + */ +export function emailFromExternalIdpToken(accessToken: unknown): string | null { + const claims = decodeJwtPayload(accessToken); + if (!claims) return null; + const pick = (k: string): string | undefined => + typeof claims[k] === "string" ? (claims[k] as string) : undefined; + return pick("email") || pick("preferred_username") || pick("upn") || null; +} + +export interface ExternalIdpRefreshRequest { + tokenEndpoint: string; + body: URLSearchParams; +} + +/** + * Build the public-client `refresh_token` grant for an External IdP token. The + * IdP application is a PUBLIC client (no secret), so the body is exactly + * `grant_type=refresh_token&client_id&refresh_token&scope`. Throws when any + * required field is missing/invalid so callers can fail closed. + */ +export function buildExternalIdpRefreshParams( + refreshToken: string, + providerSpecificData: Record | null | undefined +): ExternalIdpRefreshRequest { + const psd = providerSpecificData || {}; + const clientId = normalizeString(psd.clientId ?? (psd as Record).client_id); + const tokenEndpoint = validateExternalIdpTokenEndpoint( + psd.tokenEndpoint ?? (psd as Record).token_endpoint + ); + const scope = normalizeScope(psd.scope ?? psd.scopes); + + if (!refreshToken) throw new Error("refresh token is required for external_idp refresh"); + if (!clientId) throw new Error("clientId is required for external_idp refresh"); + if (!scope) throw new Error("scope is required for external_idp refresh"); + + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: refreshToken, + scope, + }); + + return { tokenEndpoint, body }; +} diff --git a/open-sse/services/kiroModels.ts b/open-sse/services/kiroModels.ts index 24bc13d810..89680655d4 100644 --- a/open-sse/services/kiroModels.ts +++ b/open-sse/services/kiroModels.ts @@ -23,8 +23,21 @@ * never breaks when the account is offline / unauthenticated / token-expired. */ +import { createHash } from "node:crypto"; + +import { v4 as uuidv4 } from "uuid"; + type RawRecord = Record; +const KIRO_RUNTIME_SDK_VERSION = "1.0.0"; +const KIRO_AGENT_OS = "windows"; +const KIRO_AGENT_OS_VERSION = "10.0.26200"; +const KIRO_NODE_VERSION = "22.21.1"; +const KIRO_IDE_VERSION = "0.10.32"; +const CACHE_TTL_MS = 5 * 60 * 1000; + +const catalogCache = new Map(); + function asRecord(value: unknown): RawRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as RawRecord) : {}; } @@ -39,6 +52,14 @@ export type KiroModel = { id: string; name: string; owned_by: string; + capabilities?: { + thinking: boolean; + agentic: boolean; + }; + contextLength?: number; + rateMultiplier?: number; + upstreamModelId?: string; + description?: string; }; export type KiroModelsResult = { @@ -75,6 +96,94 @@ export function parseKiroModels(data: unknown): KiroModel[] { return models; } +function stripSyntheticSuffixes(id: string): string { + let out = id; + if (out.endsWith("-agentic")) out = out.slice(0, -"-agentic".length); + if (out.endsWith("-thinking")) out = out.slice(0, -"-thinking".length); + return out; +} + +function formatDisplayName(modelName: unknown, modelId: string, rateMultiplier: unknown): string { + const base = toNonEmptyString(modelName) || modelId; + const rate = Number(rateMultiplier); + if (!Number.isFinite(rate) || Math.abs(rate - 1.0) < 1e-9 || rate <= 0) { + return `Kiro ${base}`; + } + return `Kiro ${base} (${rate.toFixed(1)}x credit)`; +} + +function buildVariants(upstream: string, displayName: string): KiroModel[] { + const safeUpstream = stripSyntheticSuffixes(upstream); + const display = displayName || `Kiro ${safeUpstream}`; + const isAuto = safeUpstream === "auto" || safeUpstream === "auto-kiro"; + const variants: KiroModel[] = [ + { + id: safeUpstream, + name: display, + owned_by: "kiro", + capabilities: { thinking: false, agentic: false }, + }, + { + id: `${safeUpstream}-thinking`, + name: `${display} (Thinking)`, + owned_by: "kiro", + capabilities: { thinking: true, agentic: false }, + }, + ]; + + if (!isAuto) { + variants.push({ + id: `${safeUpstream}-agentic`, + name: `${display} (Agentic)`, + owned_by: "kiro", + capabilities: { thinking: false, agentic: true }, + }); + variants.push({ + id: `${safeUpstream}-thinking-agentic`, + name: `${display} (Thinking + Agentic)`, + owned_by: "kiro", + capabilities: { thinking: true, agentic: true }, + }); + } + + return variants; +} + +function expandKiroModels(data: unknown): KiroModel[] { + const payload = asRecord(data); + const items = Array.isArray(payload.models) + ? (payload.models as unknown[]) + : Array.isArray(payload.availableModels) + ? (payload.availableModels as unknown[]) + : []; + const expanded: KiroModel[] = []; + const seen = new Set(); + + for (const value of items) { + const item = asRecord(value); + const upstreamId = toNonEmptyString(item.modelId) || toNonEmptyString(item.id); + if (!upstreamId) continue; + const display = formatDisplayName(item.modelName || item.name, upstreamId, item.rateMultiplier); + const tokenLimits = asRecord(item.tokenLimits); + const contextLength = Number(tokenLimits.maxInputTokens) || 200000; + const rateMultiplier = Number(item.rateMultiplier); + + for (const variant of buildVariants(upstreamId, display)) { + if (seen.has(variant.id)) continue; + seen.add(variant.id); + expanded.push({ + ...variant, + contextLength, + rateMultiplier: Number.isFinite(rateMultiplier) ? rateMultiplier : 1.0, + upstreamModelId: upstreamId, + description: toNonEmptyString(item.description) || "", + }); + } + } + + return expanded; +} + /** * Derive the AWS region for a Kiro connection. Mirrors getKiroUsage: prefer the * stored region, then the region embedded in the profileArn, else us-east-1. @@ -124,28 +233,69 @@ function toFallbackResult( .map((model) => { const id = toNonEmptyString(model.id); if (!id) return null; - return { id, name: toNonEmptyString(model.name) || id, owned_by: "kiro" }; + return { + id, + name: toNonEmptyString(model.name) || id, + owned_by: "kiro", + }; }) .filter((model): model is KiroModel => Boolean(model)); return { models, source: "fallback" }; } +function buildKiroFingerprintHeaders(providerSpecificData: unknown, accessToken: string) { + const psd = asRecord(providerSpecificData); + const seed = + toNonEmptyString(psd.clientId) || + toNonEmptyString(psd.profileArn) || + accessToken || + "kiro-anonymous"; + const machineId = createHash("sha256").update(String(seed)).digest("hex"); + const userAgent = + `aws-sdk-js/${KIRO_RUNTIME_SDK_VERSION} ua/2.1 ` + + `os/${KIRO_AGENT_OS}#${KIRO_AGENT_OS_VERSION} ` + + `lang/js md/nodejs#${KIRO_NODE_VERSION} ` + + `api/codewhispererruntime#${KIRO_RUNTIME_SDK_VERSION} m/N,E ` + + `KiroIDE-${KIRO_IDE_VERSION}-${machineId}`; + + return { + "User-Agent": userAgent, + "x-amz-user-agent": `aws-sdk-js/${KIRO_RUNTIME_SDK_VERSION} KiroIDE-${KIRO_IDE_VERSION}-${machineId}`, + "x-amzn-kiro-agent-mode": "vibe", + "x-amzn-codewhisperer-optout": "true", + "amz-sdk-request": "attempt=1; max=1", + "amz-sdk-invocation-id": uuidv4(), + Accept: "application/json", + }; +} + +function cacheKey(accessToken: string, providerSpecificData: unknown): string { + const psd = asRecord(providerSpecificData); + const seed = + toNonEmptyString(psd.profileArn) || + toNonEmptyString(psd.clientId) || + accessToken || + "anonymous"; + return createHash("sha256").update(`kiro:${seed}`).digest("hex"); +} + async function tryFetchModels( fetchImpl: typeof fetch, url: string, - accessToken: string + accessToken: string, + providerSpecificData: unknown ): Promise { try { const response = await fetchImpl(url, { method: "GET", headers: { + ...buildKiroFingerprintHeaders(providerSpecificData, accessToken), Authorization: `Bearer ${accessToken}`, - Accept: "application/json", }, }); if (!response.ok) return null; const data = await response.json(); - const models = parseKiroModels(data); + const models = expandKiroModels(data); return models.length > 0 ? models : null; } catch { return null; @@ -172,14 +322,28 @@ export async function fetchKiroAvailableModels( return toFallbackResult(fallbackModels); } + const key = cacheKey(token, providerSpecificData); + const cached = catalogCache.get(key); + if (cached && cached.expiresAt > Date.now()) { + return { models: cached.models, source: "api" }; + } + const region = resolveKiroRegion(providerSpecificData); const endpoints = buildKiroModelsEndpoints(region); const profileArn = toNonEmptyString(asRecord(providerSpecificData).profileArn); // Pass 1: origin-only (works for Builder ID / social / IdC). for (const base of endpoints) { - const models = await tryFetchModels(fetchImpl, `${base}?origin=AI_EDITOR`, token); - if (models) return { models, source: "api" }; + const models = await tryFetchModels( + fetchImpl, + `${base}?origin=AI_EDITOR`, + token, + providerSpecificData + ); + if (models) { + catalogCache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, models }); + return { models, source: "api" }; + } } // Pass 2: retry with profileArn (desktop accounts that require it) on the @@ -187,9 +351,16 @@ export async function fetchKiroAvailableModels( // profileArn can 403. if (profileArn) { const url = `${endpoints[0]}?origin=AI_EDITOR&profileArn=${encodeURIComponent(profileArn)}`; - const models = await tryFetchModels(fetchImpl, url, token); - if (models) return { models, source: "api" }; + const models = await tryFetchModels(fetchImpl, url, token, providerSpecificData); + if (models) { + catalogCache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, models }); + return { models, source: "api" }; + } } return toFallbackResult(fallbackModels); } + +export function clearKiroModelCache(): void { + catalogCache.clear(); +} diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 338ddca604..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 @@ -1602,6 +1669,7 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: ); case "cline": + case "clinepass": // reuses the Cline WorkOS refresh flow (clinepass: cline) return await refreshClineToken(credentials.refreshToken, log, proxyConfig); case "kimi-coding": diff --git a/open-sse/services/usage/kiro.ts b/open-sse/services/usage/kiro.ts index 9b85579103..b96b5041ca 100644 --- a/open-sse/services/usage/kiro.ts +++ b/open-sse/services/usage/kiro.ts @@ -13,6 +13,11 @@ import { toRecord, toNumber } from "./scalars.ts"; import { type UsageQuota, parseResetTime } from "./quota.ts"; +import { + isExternalIdpAuthMethod, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE, +} from "../kiroExternalIdp.ts"; type JsonRecord = Record; @@ -113,17 +118,24 @@ export function buildKiroUsageResult( export async function discoverKiroProfileArn( accessToken: string, usageBaseUrl: string, - region: string + region: string, + authMethod?: string ): Promise { try { + const isApiKey = authMethod === "api_key"; + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles", + Accept: "application/json", + }; + if (isApiKey) { + headers.tokentype = "API_KEY"; + } + const response = await fetch(usageBaseUrl, { method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/x-amz-json-1.0", - "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles", - Accept: "application/json", - }, + headers, body: JSON.stringify({ maxResults: 10 }), // Don't let a hung profile lookup block the usage/quota refresh indefinitely. signal: AbortSignal.timeout(10000), @@ -145,84 +157,237 @@ export async function discoverKiroProfileArn( } } +/** + * The three GetUsageLimits attempts (regional GET, CodeWhisperer POST, Q GET) tried in + * order by getKiroUsage — extracted so the auth-method header variants (api_key + * `tokentype`, external_idp `TokenType`) stay in one authHeaders object and the parent + * function stays under the function-length gate. + */ +function buildKiroUsageAttempts(opts: { + authHeaders: Record; + usageParams: URLSearchParams; + qParams: URLSearchParams; + payload: Record; + usageBaseUrl: string; + qBaseUrl: string; +}): Array<{ name: string; run: () => Promise }> { + const { authHeaders, usageParams, qParams, payload, usageBaseUrl, qBaseUrl } = opts; + return [ + { + name: "codewhisperer-get", + run: () => + fetch(`${CODEWHISPERER_BASE_URL}/getUsageLimits?${usageParams.toString()}`, { + method: "GET", + headers: { + ...authHeaders, + "x-amz-user-agent": "aws-sdk-js/1.0.0 KiroIDE", + "user-agent": "aws-sdk-js/1.0.0 KiroIDE", + }, + }), + }, + { + name: "codewhisperer-post", + run: () => + fetch(usageBaseUrl, { + method: "POST", + headers: { + ...authHeaders, + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits", + }, + body: JSON.stringify(payload), + }), + }, + { + name: "q-get", + run: () => + fetch(`${qBaseUrl}/getUsageLimits?${qParams.toString()}`, { + method: "GET", + headers: authHeaders, + }), + }, + ]; +} + +/** + * Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and + * endpoint must all match the region. Derive the region from the stored region (preferred) + * or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the + * legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions). + */ +function resolveKiroUsageEndpoints(providerSpecificData?: JsonRecord, profileArn?: string) { + const regionFromArn = profileArn + ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] + : undefined; + const region = + (typeof providerSpecificData?.region === "string" && + providerSpecificData.region.trim().toLowerCase()) || + regionFromArn || + "us-east-1"; + const usageBaseUrl = + region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`; + const qBaseUrl = `https://q.${region}.amazonaws.com`; + return { region, usageBaseUrl, qBaseUrl }; +} + +/** + * Base auth headers for the usage endpoints, per auth method: long-lived API keys add + * `tokentype: API_KEY`; enterprise / Microsoft Entra (external_idp) org accounts require + * `TokenType: EXTERNAL_IDP` for CodeWhisperer to bind the bearer to the profile (without + * it GetUsageLimits returns `ValidationException: Invalid ARN`). + */ +function buildKiroAuthHeaders( + accessToken: string | undefined, + isApiKey: boolean, + providerSpecificData?: JsonRecord +): Record { + const authHeaders: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }; + if (isApiKey) { + authHeaders.tokentype = "API_KEY"; + } + if (isExternalIdpAuthMethod(providerSpecificData?.authMethod)) { + authHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE; + } + return authHeaders; +} + +/** + * Runs the GetUsageLimits attempts in order until one succeeds. Collects per-attempt + * errors and whether any endpoint rejected the token (401/403) so getKiroUsage can + * pick the right user-facing message — extracted for the function-length gate. + */ +async function runKiroUsageAttempts( + attempts: Array<{ name: string; run: () => Promise }> +): Promise<{ + data?: JsonRecord; + sawAuthError: boolean; + errors: string[]; + lastHttpFailure?: string; +}> { + let sawAuthError = false; + let lastHttpFailure: string | undefined; + const errors: string[] = []; + for (const attempt of attempts) { + let response: Response; + try { + response = await attempt.run(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`${attempt.name}:${message}`); + continue; + } + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + if (response.status === 401 || response.status === 403) { + sawAuthError = true; + } + lastHttpFailure = `Kiro API error (${response.status}): ${errorText}`; + errors.push(`${attempt.name}:${response.status}${errorText ? `:${errorText}` : ""}`); + continue; + } + return { data: toRecord(await response.json()), sawAuthError, errors, lastHttpFailure }; + } + return { sawAuthError, errors, lastHttpFailure }; +} + /** * Kiro (AWS CodeWhisperer) Usage */ export async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRecord) { try { + const authMethod = + typeof providerSpecificData?.authMethod === "string" + ? providerSpecificData.authMethod + : undefined; + const isApiKey = authMethod === "api_key"; let profileArn = typeof providerSpecificData?.profileArn === "string" ? providerSpecificData.profileArn : undefined; - // Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and - // endpoint must all match the region. Derive the region from the stored region (preferred) - // or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the - // legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions). - const regionFromArn = profileArn - ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] - : undefined; - const region = - (typeof providerSpecificData?.region === "string" && - providerSpecificData.region.trim().toLowerCase()) || - regionFromArn || - "us-east-1"; - const usageBaseUrl = - region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`; + const { region, usageBaseUrl, qBaseUrl } = resolveKiroUsageEndpoints( + providerSpecificData, + profileArn + ); // IAM Identity Center logins and kiro-cli imports frequently don't persist a profileArn, which // previously caused the quota card to show nothing ("0 used"). Discover it on demand from // ListAvailableProfiles (region-matched) so usage still resolves for those accounts. if (!profileArn && accessToken) { - profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region); + profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region, authMethod); } - if (!profileArn) { + if (!profileArn && !isApiKey) { return { message: "Kiro connected. Profile ARN not available for quota tracking." }; } - // Kiro uses AWS CodeWhisperer GetUsageLimits API + const authHeaders = buildKiroAuthHeaders(accessToken, isApiKey, providerSpecificData); + + const usageParams = new URLSearchParams({ + isEmailRequired: "true", + origin: "AI_EDITOR", + resourceType: "AGENTIC_REQUEST", + }); + const qParams = new URLSearchParams({ + origin: "AI_EDITOR", + ...(profileArn ? { profileArn } : {}), + resourceType: "AGENTIC_REQUEST", + }); const payload = { origin: "AI_EDITOR", - profileArn: profileArn, + ...(profileArn ? { profileArn } : {}), resourceType: "AGENTIC_REQUEST", }; - const response = await fetch(usageBaseUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/x-amz-json-1.0", - "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits", - Accept: "application/json", - }, - body: JSON.stringify(payload), +const attempts = buildKiroUsageAttempts({ + authHeaders, + usageParams, + qParams, + payload, + usageBaseUrl, + qBaseUrl, }); - if (!response.ok) { + const outcome = await runKiroUsageAttempts(attempts); + if (outcome.data) { + return buildKiroUsageResult(outcome.data); + } + const { sawAuthError, errors } = outcome; + + if (sawAuthError) { // Social-auth Kiro accounts (added via /api/oauth/kiro/social-exchange with provider // Google or GitHub) use a different token format that AWS CodeWhisperer's GetUsageLimits // routinely rejects with 401/403, even when /messages still works. Surface a clear // "auth expired, chat may still work" message instead of a generic upstream-error blob // so the quota card matches what users with legacy social-auth accounts already see. // Inspired by https://github.com/decolua/9router/pull/620. - if ( - (response.status === 401 || response.status === 403) && - isSocialAuthKiroAccount(providerSpecificData) - ) { + if (isSocialAuthKiroAccount(providerSpecificData)) { return { message: "Kiro quota API authentication expired. Chat may still work.", quotas: {}, }; } - const errorText = await response.text(); - throw new Error(`Kiro API error (${response.status}): ${errorText}`); + return { + message: "Kiro quota API rejected the current token. Chat may still work.", + quotas: {}, + }; } - const data = toRecord(await response.json()); - return buildKiroUsageResult(data); + // Hard (non-auth) failure keeps the pre-#6587 reject semantics — callers and + // tests/unit/usage-service-hardening.test.ts rely on the rejection; prefer the last + // HTTP-status failure (most informative) over a network-level error. + throw new Error( + outcome.lastHttpFailure || + (errors.length > 0 + ? errors[errors.length - 1] + : "no usage endpoint responded") + ); } catch (error) { - throw new Error(`Failed to fetch Kiro usage: ${error.message}`); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to fetch Kiro usage: ${message}`); } } diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 8508fe54d0..d5d6d7f825 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -579,6 +579,26 @@ function convertMessages(messages, tools, model) { /** Kiro's accepted reasoning-effort levels (`output_config.effort`). */ const KIRO_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]; +function resolveKiroModelAlias(model: string): { upstream: string; thinking: boolean } { + let upstream = String(model || ""); + let thinking = false; + + if (upstream.endsWith("-agentic")) { + upstream = upstream.slice(0, -"-agentic".length); + } + if (upstream.endsWith("-thinking")) { + upstream = upstream.slice(0, -"-thinking".length); + thinking = true; + } + if (upstream === "auto-kiro") { + upstream = "auto"; + } + + upstream = upstream.replace(/^(claude-(?:opus|sonnet|haiku|3-\d+)-\d+)-(\d{1,2})$/, "$1.$2"); + + return { upstream, thinking }; +} + /** * Resolve the Kiro effort level for a request, or "" when no reasoning was asked * for. Effort sources, in priority order: @@ -665,10 +685,11 @@ export function buildKiroPayload(model, body, stream, credentials) { // The minor group is bounded to 1-2 digits so date-suffixed ids (e.g. // claude-opus-4-20250514) are never mistaken for a dash-separated minor // version and corrupted into claude-opus-4.20250514 (upstream 9router #2270). - const normalizedModel = model.replace( - /^(claude-(?:opus|sonnet|haiku|3-\d+)-\d+)-(\d{1,2})$/, - "$1.$2" - ); + // Synthetic Kiro selector variants (`-thinking`, `-agentic`) are local aliases: + // strip them before the request leaves OmniRoute so Kiro only receives real + // upstream model IDs. We intentionally do not inject an agentic system prompt here. + const { upstream: normalizedModel, thinking: modelRequestsThinking } = + resolveKiroModelAlias(model); const messages = body.messages || []; let tools = body.tools || []; const maxTokens = body.max_tokens ?? body.max_completion_tokens ?? 32000; @@ -844,7 +865,8 @@ export function buildKiroPayload(model, body, stream, credentials) { // thinking:{type:"adaptive"} + a clamped max_tokens), forwarded to AWS by // the Kiro executor's transformRequest allowlist — this is the graded // effort lever. Gated on models that advertise thinking support. - const kiroEffort = supportsReasoning(normalizedModel) ? resolveKiroEffort(body) : ""; + const requestedEffort = resolveKiroEffort(body) || (modelRequestsThinking ? "high" : ""); + const kiroEffort = supportsReasoning(normalizedModel) ? requestedEffort : ""; if (kiroEffort) { // `` / `` are Kiro/CodeWhisperer prompt // conventions (NOT Anthropic API params); the length is a soft hint (the hard diff --git a/public/providers/360ai.svg b/public/providers/360ai.svg index caeb6eb2c9..dc393ef67b 100644 --- a/public/providers/360ai.svg +++ b/public/providers/360ai.svg @@ -1,5 +1 @@ - - - - 360 - +AI360 \ No newline at end of file diff --git a/public/providers/alibaba.svg b/public/providers/alibaba.svg new file mode 100644 index 0000000000..f6b764f99d --- /dev/null +++ b/public/providers/alibaba.svg @@ -0,0 +1,6 @@ + + Alibaba + + \ No newline at end of file diff --git a/public/providers/anthropic.svg b/public/providers/anthropic.svg new file mode 100644 index 0000000000..f31bfac298 --- /dev/null +++ b/public/providers/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/public/providers/arcee-ai.svg b/public/providers/arcee-ai.svg index b63a13ab3f..dc809fc95e 100644 --- a/public/providers/arcee-ai.svg +++ b/public/providers/arcee-ai.svg @@ -1,5 +1 @@ - - - - AR - +Arcee \ No newline at end of file diff --git a/public/providers/arcee.svg b/public/providers/arcee.svg new file mode 100644 index 0000000000..df3fd30356 --- /dev/null +++ b/public/providers/arcee.svg @@ -0,0 +1,5 @@ + + Arcee + + \ No newline at end of file diff --git a/public/providers/assemblyai.svg b/public/providers/assemblyai.svg new file mode 100644 index 0000000000..c6d4cffee7 --- /dev/null +++ b/public/providers/assemblyai.svg @@ -0,0 +1,7 @@ + + AssemblyAI + + + \ No newline at end of file diff --git a/public/providers/aws.svg b/public/providers/aws.svg new file mode 100644 index 0000000000..0e1a90a7b0 --- /dev/null +++ b/public/providers/aws.svg @@ -0,0 +1,6 @@ + + AWS + + + \ No newline at end of file diff --git a/public/providers/azure.svg b/public/providers/azure.svg new file mode 100644 index 0000000000..038645c7e0 --- /dev/null +++ b/public/providers/azure.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/azureai.svg b/public/providers/azureai.svg new file mode 100644 index 0000000000..b9ae255923 --- /dev/null +++ b/public/providers/azureai.svg @@ -0,0 +1,82 @@ + + AzureAI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/baichuan.svg b/public/providers/baichuan.svg index fa3789f62e..11b27e7913 100644 --- a/public/providers/baichuan.svg +++ b/public/providers/baichuan.svg @@ -1,5 +1 @@ - - - - BC - +Baichuan \ No newline at end of file diff --git a/public/providers/baidu.svg b/public/providers/baidu.svg index 0c74817895..ead7f89822 100644 --- a/public/providers/baidu.svg +++ b/public/providers/baidu.svg @@ -1,5 +1 @@ - - - - BD - +Baidu \ No newline at end of file diff --git a/public/providers/bailian.svg b/public/providers/bailian.svg new file mode 100644 index 0000000000..7618aaad1c --- /dev/null +++ b/public/providers/bailian.svg @@ -0,0 +1,16 @@ + + Bailian (阿里云百炼) + + + + + + + + \ No newline at end of file diff --git a/public/providers/baseten.svg b/public/providers/baseten.svg new file mode 100644 index 0000000000..c6a5fb32c1 --- /dev/null +++ b/public/providers/baseten.svg @@ -0,0 +1,4 @@ + + Baseten + + \ No newline at end of file diff --git a/public/providers/cerebras.svg b/public/providers/cerebras.svg new file mode 100644 index 0000000000..88b7cb5632 --- /dev/null +++ b/public/providers/cerebras.svg @@ -0,0 +1,8 @@ + + Cerebras + + + \ No newline at end of file diff --git a/public/providers/claude-web.svg b/public/providers/claude-web.svg index 882c6d2245..62dc0db12d 100644 --- a/public/providers/claude-web.svg +++ b/public/providers/claude-web.svg @@ -1,5 +1 @@ - - - - CW - +Claude \ No newline at end of file diff --git a/public/providers/claude.svg b/public/providers/claude.svg index 8bc1fd43ba..62dc0db12d 100644 --- a/public/providers/claude.svg +++ b/public/providers/claude.svg @@ -1 +1 @@ - \ No newline at end of file +Claude \ No newline at end of file diff --git a/public/providers/cline.svg b/public/providers/cline.svg new file mode 100644 index 0000000000..8dcc05786c --- /dev/null +++ b/public/providers/cline.svg @@ -0,0 +1 @@ +Cline \ No newline at end of file diff --git a/public/providers/cloudflare.svg b/public/providers/cloudflare.svg new file mode 100644 index 0000000000..ac59cd621a --- /dev/null +++ b/public/providers/cloudflare.svg @@ -0,0 +1,7 @@ + + Cloudflare + + + \ No newline at end of file diff --git a/public/providers/cohere.svg b/public/providers/cohere.svg new file mode 100644 index 0000000000..62aec42312 --- /dev/null +++ b/public/providers/cohere.svg @@ -0,0 +1,13 @@ + + Cohere + + + + \ No newline at end of file diff --git a/public/providers/comfyui.svg b/public/providers/comfyui.svg new file mode 100644 index 0000000000..85e93cd06b --- /dev/null +++ b/public/providers/comfyui.svg @@ -0,0 +1,5 @@ + + ComfyUI + + \ No newline at end of file diff --git a/public/providers/continue.png b/public/providers/continue.png deleted file mode 100644 index f54685be8d..0000000000 Binary files a/public/providers/continue.png and /dev/null differ diff --git a/public/providers/continue.svg b/public/providers/continue.svg new file mode 100644 index 0000000000..e9f56c259e --- /dev/null +++ b/public/providers/continue.svg @@ -0,0 +1 @@ + diff --git a/public/providers/copilot.png b/public/providers/copilot.png deleted file mode 100644 index 9907963e40..0000000000 Binary files a/public/providers/copilot.png and /dev/null differ diff --git a/public/providers/copilot.svg b/public/providers/copilot.svg new file mode 100644 index 0000000000..5426933b61 --- /dev/null +++ b/public/providers/copilot.svg @@ -0,0 +1 @@ +Copilot \ No newline at end of file diff --git a/public/providers/cursor.png b/public/providers/cursor.png deleted file mode 100644 index ec02b070ba..0000000000 Binary files a/public/providers/cursor.png and /dev/null differ diff --git a/public/providers/cursor.svg b/public/providers/cursor.svg new file mode 100644 index 0000000000..10d50ca847 --- /dev/null +++ b/public/providers/cursor.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/public/providers/deepgram.png b/public/providers/deepgram.png deleted file mode 100644 index 6bee16b860..0000000000 --- a/public/providers/deepgram.png +++ /dev/null @@ -1,52 +0,0 @@ - - - - 404 - - - -
-

404

-

Not Found

-
- - - \ No newline at end of file diff --git a/public/providers/deepgram.svg b/public/providers/deepgram.svg new file mode 100644 index 0000000000..988a37743f --- /dev/null +++ b/public/providers/deepgram.svg @@ -0,0 +1 @@ +Deepgram \ No newline at end of file diff --git a/public/providers/deepinfra.svg b/public/providers/deepinfra.svg new file mode 100644 index 0000000000..458107109c --- /dev/null +++ b/public/providers/deepinfra.svg @@ -0,0 +1,7 @@ + + DeepInfra + + + \ No newline at end of file diff --git a/public/providers/deepseek.svg b/public/providers/deepseek.svg new file mode 100644 index 0000000000..abf56cfecb --- /dev/null +++ b/public/providers/deepseek.svg @@ -0,0 +1,5 @@ + + DeepSeek + + \ No newline at end of file diff --git a/public/providers/dify.svg b/public/providers/dify.svg index 80be2e3447..4cea903478 100644 --- a/public/providers/dify.svg +++ b/public/providers/dify.svg @@ -1,5 +1 @@ - - - - DF - +Dify \ No newline at end of file diff --git a/public/providers/doubao.svg b/public/providers/doubao.svg index 4317871d38..e2511454cf 100644 --- a/public/providers/doubao.svg +++ b/public/providers/doubao.svg @@ -1,5 +1 @@ - - - - DB - +Doubao \ No newline at end of file diff --git a/public/providers/elevenlabs.svg b/public/providers/elevenlabs.svg new file mode 100644 index 0000000000..d44617dbde --- /dev/null +++ b/public/providers/elevenlabs.svg @@ -0,0 +1 @@ +ElevenLabs \ No newline at end of file diff --git a/public/providers/exa.svg b/public/providers/exa.svg new file mode 100644 index 0000000000..bc9f73e106 --- /dev/null +++ b/public/providers/exa.svg @@ -0,0 +1,7 @@ + + Exa + + \ No newline at end of file diff --git a/public/providers/fal.svg b/public/providers/fal.svg new file mode 100644 index 0000000000..0e54aaf865 --- /dev/null +++ b/public/providers/fal.svg @@ -0,0 +1,7 @@ + + Fal + + \ No newline at end of file diff --git a/public/providers/fireworks.svg b/public/providers/fireworks.svg new file mode 100644 index 0000000000..946ee079af --- /dev/null +++ b/public/providers/fireworks.svg @@ -0,0 +1,7 @@ + + Fireworks + + \ No newline at end of file diff --git a/public/providers/friendli.svg b/public/providers/friendli.svg new file mode 100644 index 0000000000..7da5293002 --- /dev/null +++ b/public/providers/friendli.svg @@ -0,0 +1,6 @@ + + Friendli + + + + \ No newline at end of file diff --git a/public/providers/gemini.svg b/public/providers/gemini.svg new file mode 100644 index 0000000000..f8d7189dc6 --- /dev/null +++ b/public/providers/gemini.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/google.svg b/public/providers/google.svg new file mode 100644 index 0000000000..2f69df7189 --- /dev/null +++ b/public/providers/google.svg @@ -0,0 +1,11 @@ + + Google + + + + + \ No newline at end of file diff --git a/public/providers/grok.svg b/public/providers/grok.svg new file mode 100644 index 0000000000..7057a3bfa6 --- /dev/null +++ b/public/providers/grok.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/providers/groq.svg b/public/providers/groq.svg new file mode 100644 index 0000000000..cf90d287b1 --- /dev/null +++ b/public/providers/groq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/heroku.png b/public/providers/heroku.png deleted file mode 100644 index b96cb3f6a7..0000000000 Binary files a/public/providers/heroku.png and /dev/null differ diff --git a/public/providers/heroku.svg b/public/providers/heroku.svg new file mode 100644 index 0000000000..6089d6f2c2 --- /dev/null +++ b/public/providers/heroku.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/providers/huggingchat.svg b/public/providers/huggingchat.svg index 30a8cef613..dc1cf3ffb7 100644 --- a/public/providers/huggingchat.svg +++ b/public/providers/huggingchat.svg @@ -1,5 +1 @@ - - - - HC - +HuggingFace \ No newline at end of file diff --git a/public/providers/huggingface.svg b/public/providers/huggingface.svg new file mode 100644 index 0000000000..c141ab811a --- /dev/null +++ b/public/providers/huggingface.svg @@ -0,0 +1,15 @@ + + HuggingFace + + + + + + + \ No newline at end of file diff --git a/public/providers/hyperbolic.svg b/public/providers/hyperbolic.svg new file mode 100644 index 0000000000..591eb157ba --- /dev/null +++ b/public/providers/hyperbolic.svg @@ -0,0 +1,5 @@ + + Hyperbolic + + \ No newline at end of file diff --git a/public/providers/ibm.svg b/public/providers/ibm.svg new file mode 100644 index 0000000000..36fe5889dd --- /dev/null +++ b/public/providers/ibm.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/providers/iflytek.svg b/public/providers/iflytek.svg index bad262efe4..4962d9e161 100644 --- a/public/providers/iflytek.svg +++ b/public/providers/iflytek.svg @@ -1,5 +1 @@ - - - - IF - +iFlyTekCloud \ No newline at end of file diff --git a/public/providers/inference.svg b/public/providers/inference.svg new file mode 100644 index 0000000000..b53e8e4616 --- /dev/null +++ b/public/providers/inference.svg @@ -0,0 +1,4 @@ + + Inference + + \ No newline at end of file diff --git a/public/providers/kimi.svg b/public/providers/kimi.svg new file mode 100644 index 0000000000..ec12852549 --- /dev/null +++ b/public/providers/kimi.svg @@ -0,0 +1,7 @@ + + Kimi + + + \ No newline at end of file diff --git a/public/providers/kiro.svg b/public/providers/kiro.svg index 83e2845fbf..0c651b9747 100644 --- a/public/providers/kiro.svg +++ b/public/providers/kiro.svg @@ -1 +1 @@ - \ No newline at end of file +Kiro \ No newline at end of file diff --git a/public/providers/lambda.svg b/public/providers/lambda.svg new file mode 100644 index 0000000000..be9061029b --- /dev/null +++ b/public/providers/lambda.svg @@ -0,0 +1,4 @@ + + Lambda + + \ No newline at end of file diff --git a/public/providers/longcat.svg b/public/providers/longcat.svg new file mode 100644 index 0000000000..6c05c8ecc2 --- /dev/null +++ b/public/providers/longcat.svg @@ -0,0 +1,8 @@ + + LongCat + + + \ No newline at end of file diff --git a/public/providers/meta.svg b/public/providers/meta.svg new file mode 100644 index 0000000000..45c34b10e5 --- /dev/null +++ b/public/providers/meta.svg @@ -0,0 +1,121 @@ + + Meta + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/metaai.svg b/public/providers/metaai.svg new file mode 100644 index 0000000000..4d623976fd --- /dev/null +++ b/public/providers/metaai.svg @@ -0,0 +1,49 @@ + + MetaAI + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/minimax.svg b/public/providers/minimax.svg new file mode 100644 index 0000000000..b1b0a6a2df --- /dev/null +++ b/public/providers/minimax.svg @@ -0,0 +1 @@ +MiniMax \ No newline at end of file diff --git a/public/providers/mistral.svg b/public/providers/mistral.svg new file mode 100644 index 0000000000..605b629a38 --- /dev/null +++ b/public/providers/mistral.svg @@ -0,0 +1,10 @@ + + Mistral + + + + + + \ No newline at end of file diff --git a/public/providers/moonshot.svg b/public/providers/moonshot.svg new file mode 100644 index 0000000000..b36d4e7e0f --- /dev/null +++ b/public/providers/moonshot.svg @@ -0,0 +1,4 @@ + + Moonshot (月之暗面) + + \ No newline at end of file diff --git a/public/providers/morph.svg b/public/providers/morph.svg new file mode 100644 index 0000000000..474c521785 --- /dev/null +++ b/public/providers/morph.svg @@ -0,0 +1,5 @@ + + Morph + + \ No newline at end of file diff --git a/public/providers/nebius.svg b/public/providers/nebius.svg new file mode 100644 index 0000000000..719baf1544 --- /dev/null +++ b/public/providers/nebius.svg @@ -0,0 +1,5 @@ + + Nebius + + + \ No newline at end of file diff --git a/public/providers/novita.svg b/public/providers/novita.svg new file mode 100644 index 0000000000..7c53907792 --- /dev/null +++ b/public/providers/novita.svg @@ -0,0 +1,7 @@ + + Novita + + \ No newline at end of file diff --git a/public/providers/nvidia.svg b/public/providers/nvidia.svg new file mode 100644 index 0000000000..48c7d75d26 --- /dev/null +++ b/public/providers/nvidia.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/ollama.svg b/public/providers/ollama.svg new file mode 100644 index 0000000000..96e9e86624 --- /dev/null +++ b/public/providers/ollama.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/providers/openai.svg b/public/providers/openai.svg new file mode 100644 index 0000000000..b6d542d099 --- /dev/null +++ b/public/providers/openai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/openclaw.png b/public/providers/openclaw.png deleted file mode 100644 index 7ef77ac754..0000000000 Binary files a/public/providers/openclaw.png and /dev/null differ diff --git a/public/providers/openclaw.svg b/public/providers/openclaw.svg new file mode 100644 index 0000000000..bf6acb9f51 --- /dev/null +++ b/public/providers/openclaw.svg @@ -0,0 +1 @@ +OpenClaw \ No newline at end of file diff --git a/public/providers/openrouter.svg b/public/providers/openrouter.svg new file mode 100644 index 0000000000..61033bfc67 --- /dev/null +++ b/public/providers/openrouter.svg @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/public/providers/ovhcloud.png b/public/providers/ovhcloud.png deleted file mode 100644 index a88d1b448d..0000000000 Binary files a/public/providers/ovhcloud.png and /dev/null differ diff --git a/public/providers/ovhcloud.svg b/public/providers/ovhcloud.svg new file mode 100644 index 0000000000..71ec8e5b13 --- /dev/null +++ b/public/providers/ovhcloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/perplexity.svg b/public/providers/perplexity.svg new file mode 100644 index 0000000000..b6e121bbc3 --- /dev/null +++ b/public/providers/perplexity.svg @@ -0,0 +1,6 @@ + + Perplexity + + \ No newline at end of file diff --git a/public/providers/picoclaw.svg b/public/providers/picoclaw.svg new file mode 100644 index 0000000000..c05d253215 --- /dev/null +++ b/public/providers/picoclaw.svg @@ -0,0 +1,30 @@ + + PicoClaw + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/providers/poe.svg b/public/providers/poe.svg new file mode 100644 index 0000000000..cbd9ec4c7d --- /dev/null +++ b/public/providers/poe.svg @@ -0,0 +1 @@ +Poe \ No newline at end of file diff --git a/public/providers/pollinations.svg b/public/providers/pollinations.svg new file mode 100644 index 0000000000..83b569419b --- /dev/null +++ b/public/providers/pollinations.svg @@ -0,0 +1,4 @@ + + Pollinations + + \ No newline at end of file diff --git a/public/providers/qwen.svg b/public/providers/qwen.svg new file mode 100644 index 0000000000..3a2f756e66 --- /dev/null +++ b/public/providers/qwen.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/public/providers/recraft.svg b/public/providers/recraft.svg new file mode 100644 index 0000000000..e5bb701575 --- /dev/null +++ b/public/providers/recraft.svg @@ -0,0 +1,5 @@ + + Recraft + + + \ No newline at end of file diff --git a/public/providers/replicate.svg b/public/providers/replicate.svg new file mode 100644 index 0000000000..4637f35378 --- /dev/null +++ b/public/providers/replicate.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/providers/roocode.svg b/public/providers/roocode.svg new file mode 100644 index 0000000000..3ce8ece8d3 --- /dev/null +++ b/public/providers/roocode.svg @@ -0,0 +1,4 @@ + + RooCode + + \ No newline at end of file diff --git a/public/providers/runway.svg b/public/providers/runway.svg new file mode 100644 index 0000000000..5dc14b1fdf --- /dev/null +++ b/public/providers/runway.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/providers/sambanova.svg b/public/providers/sambanova.svg new file mode 100644 index 0000000000..38afe1b9ee --- /dev/null +++ b/public/providers/sambanova.svg @@ -0,0 +1,9 @@ + + SambaNova + + + + \ No newline at end of file diff --git a/public/providers/searchapi.svg b/public/providers/searchapi.svg new file mode 100644 index 0000000000..fe1da13449 --- /dev/null +++ b/public/providers/searchapi.svg @@ -0,0 +1,4 @@ + + SearchApi + + \ No newline at end of file diff --git a/public/providers/sensenova.svg b/public/providers/sensenova.svg index 9bd7ebda11..17f1d82c53 100644 --- a/public/providers/sensenova.svg +++ b/public/providers/sensenova.svg @@ -1,5 +1 @@ - - - - SN - +SenseNova \ No newline at end of file diff --git a/public/providers/snowflake.svg b/public/providers/snowflake.svg new file mode 100644 index 0000000000..f62ab5f446 --- /dev/null +++ b/public/providers/snowflake.svg @@ -0,0 +1,7 @@ + + Snowflake + + \ No newline at end of file diff --git a/public/providers/stepfun.svg b/public/providers/stepfun.svg index 2a7196ea47..920e8a607a 100644 --- a/public/providers/stepfun.svg +++ b/public/providers/stepfun.svg @@ -1,5 +1 @@ - - - - SF - +Stepfun \ No newline at end of file diff --git a/public/providers/suno.svg b/public/providers/suno.svg new file mode 100644 index 0000000000..e2ddb78425 --- /dev/null +++ b/public/providers/suno.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/tavily.svg b/public/providers/tavily.svg new file mode 100644 index 0000000000..c2475ed2be --- /dev/null +++ b/public/providers/tavily.svg @@ -0,0 +1,15 @@ + + Tavily + + + + + + + \ No newline at end of file diff --git a/public/providers/tencent.svg b/public/providers/tencent.svg index d1dff98da9..98da272a26 100644 --- a/public/providers/tencent.svg +++ b/public/providers/tencent.svg @@ -1,5 +1 @@ - - - - TC - +Tencent \ No newline at end of file diff --git a/public/providers/topazlabs.svg b/public/providers/topazlabs.svg new file mode 100644 index 0000000000..c141a9a36b --- /dev/null +++ b/public/providers/topazlabs.svg @@ -0,0 +1,4 @@ + + TopazLabs + + \ No newline at end of file diff --git a/public/providers/trae.svg b/public/providers/trae.svg new file mode 100644 index 0000000000..3d048c0c25 --- /dev/null +++ b/public/providers/trae.svg @@ -0,0 +1,5 @@ + + TRAE + + \ No newline at end of file diff --git a/public/providers/udio.svg b/public/providers/udio.svg new file mode 100644 index 0000000000..7bb9b62c3b --- /dev/null +++ b/public/providers/udio.svg @@ -0,0 +1,5 @@ + + Udio + + \ No newline at end of file diff --git a/public/providers/upstage.svg b/public/providers/upstage.svg new file mode 100644 index 0000000000..d9db3f1838 --- /dev/null +++ b/public/providers/upstage.svg @@ -0,0 +1,14 @@ + + Upstage + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/v0.svg b/public/providers/v0.svg new file mode 100644 index 0000000000..32fcc405fd --- /dev/null +++ b/public/providers/v0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/vercel.svg b/public/providers/vercel.svg new file mode 100644 index 0000000000..75968916c4 --- /dev/null +++ b/public/providers/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/vllm.svg b/public/providers/vllm.svg new file mode 100644 index 0000000000..3adec97b6b --- /dev/null +++ b/public/providers/vllm.svg @@ -0,0 +1,5 @@ + + vLLM + + + \ No newline at end of file diff --git a/public/providers/volcengine.svg b/public/providers/volcengine.svg new file mode 100644 index 0000000000..1423adbc65 --- /dev/null +++ b/public/providers/volcengine.svg @@ -0,0 +1,11 @@ + + Volcengine (火山引擎) + + + + + \ No newline at end of file diff --git a/public/providers/voyage.svg b/public/providers/voyage.svg new file mode 100644 index 0000000000..76861e9f57 --- /dev/null +++ b/public/providers/voyage.svg @@ -0,0 +1,5 @@ + + Voyage + + \ No newline at end of file diff --git a/public/providers/windsurf.svg b/public/providers/windsurf.svg new file mode 100644 index 0000000000..8f4b214454 --- /dev/null +++ b/public/providers/windsurf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/xai.svg b/public/providers/xai.svg new file mode 100644 index 0000000000..6f6057d454 --- /dev/null +++ b/public/providers/xai.svg @@ -0,0 +1,4 @@ + + xAI + + \ No newline at end of file diff --git a/public/providers/xinference.svg b/public/providers/xinference.svg new file mode 100644 index 0000000000..d97033d417 --- /dev/null +++ b/public/providers/xinference.svg @@ -0,0 +1,53 @@ + + Xinference + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/yi.svg b/public/providers/yi.svg index 8ddc60de6e..8d0c6470b4 100644 --- a/public/providers/yi.svg +++ b/public/providers/yi.svg @@ -1,5 +1 @@ - - - - YI - +Yi \ No newline at end of file diff --git a/public/providers/zhipu.svg b/public/providers/zhipu.svg new file mode 100644 index 0000000000..a92eaff077 --- /dev/null +++ b/public/providers/zhipu.svg @@ -0,0 +1,6 @@ + + Zhipu (智谱) + + \ No newline at end of file diff --git a/scripts/check/check-route-guard-membership.ts b/scripts/check/check-route-guard-membership.ts index 9f21c13d13..c73d9e2667 100644 --- a/scripts/check/check-route-guard-membership.ts +++ b/scripts/check/check-route-guard-membership.ts @@ -49,6 +49,7 @@ export const SPAWN_CAPABLE_ROUTE_ROOTS: ReadonlyArray = [ "src/app/api/mcp", "src/app/api/cli-tools/runtime", "src/app/api/local", // T-12: 1-click local service launchers (Redis today) — every child here spawns podman/docker (Hard Rules #15 + #17) + "src/app/api/skills/collect", // Skill Collector CLI detection: GET .../detect spawns a child process per CLI_TOOL_IDS entry via getCliRuntimeStatus() (Hard Rules #15 + #17, PR #6294 review) ]; // Frozen pre-existing exceptions: spawn-capable routes NOT yet classified diff --git a/skills/README.md b/skills/README.md index 6fa1823d0e..1cab48496b 100644 --- a/skills/README.md +++ b/skills/README.md @@ -5,10 +5,10 @@ consume OmniRoute via OpenAI-compatible REST in one fetch. ## Entry points -| Type | Skill | Manifest | -| ---- | ----- | -------- | +| Type | Skill | Manifest | +| ---- | ------------------------------------------- | ---------------------------------------- | | API | Authentication (start here for REST access) | [omni-auth/SKILL.md](omni-auth/SKILL.md) | -| CLI | Serve (start here for CLI access) | [cli-serve/SKILL.md](cli-serve/SKILL.md) | +| CLI | Serve (start here for CLI access) | [cli-serve/SKILL.md](cli-serve/SKILL.md) | ## How agents discover capabilities @@ -24,57 +24,58 @@ See [`docs/frameworks/AGENT-SKILLS.md`](../docs/frameworks/AGENT-SKILLS.md) for Each manifest URL follows the pattern: `https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/skills//SKILL.md` -| ID | Name | Description | -| -- | ---- | ----------- | -| `omni-auth` | Authentication | Manage API key authentication and session tokens. Start here to authenticate requests via Bearer token, obtain session cookies, and configure login requirements. | -| `omni-providers` | Providers | Manage provider connections, API keys, OAuth flows, and connection tests. List, add, update, remove, and test AI provider integrations (OpenAI, Anthropic, Gemini, and 160+). | -| `omni-models` | Models | Query available AI models across all configured providers. List models, resolve model aliases, and browse the full model catalog including provider-specific variants. | -| `omni-combos-routing` | Combos & Routing | Create and manage routing combos with 14 strategies (priority, weighted, round-robin, Auto-combo, etc.). Configure fallback chains, test routing outcomes, and retrieve combo metrics. | -| `omni-api-keys` | API Keys | Create, list, rotate, and revoke OmniRoute API keys. Control per-key scopes, spending limits, and expiration. | -| `omni-usage-logs` | Usage & Logs | Access detailed call logs and usage analytics. Filter by provider, model, time range, status, and cost. Export logs and aggregate token usage. | -| `omni-budget` | Budget & Rate Limits | Configure spending limits, token quotas, and rate-limit policies per API key or globally. Inspect current consumption and enforce cost controls. | -| `omni-settings` | Settings | Read and update global application settings: system prompts, thinking budget, IP filters, payload rules, combo defaults, and require-login configuration. | -| `omni-proxies` | Proxy Configuration | Configure HTTP/HTTPS/SOCKS proxies for upstream provider requests. Set per-provider or global proxy rules, test connectivity, and manage proxy rotation. | -| `omni-cache` | Cache | Manage the LLM response cache. View cache statistics, clear entries, configure TTL policies, and control semantic-similarity caching thresholds. | -| `omni-compression` | Compression | Configure RTK, Caveman, and stacked compression modes. Manage language packs, custom rules, and test prompt compression reducing tokens by 60–90%. | -| `omni-context-rtk` | Context & RTK | Configure RTK filters, context engineering rules, and context relay settings. Test compression with real prompt samples and manage context transformation pipelines. | -| `omni-resilience` | Resilience & Monitoring | Monitor provider health, circuit-breaker states, p50/p95/p99 latency metrics, and budget guard alerts. Inspect connection cooldowns and model lockouts in real time. | -| `omni-cli-tools` | CLI Tools | Manage CLI tool integrations exposed via the API. List, configure, and invoke CLI tool plugins that extend OmniRoute's automation surface. | -| `omni-tunnels` | Tunnels | Create and manage secure tunnels (ngrok, Cloudflare Tunnel, custom) to expose OmniRoute to the internet or share access with remote agents and CI pipelines. | -| `omni-sync-cloud` | Cloud Sync | Synchronise OmniRoute configuration, provider connections, and settings to/from cloud storage. Manage cloud worker authentication and remote backup targets. | -| `omni-db-backups` | Database & Backups | Trigger system backups, restore from backup files, and manage the SQLite database lifecycle. Supports export, import, and incremental snapshot strategies. | -| `omni-webhooks` | Webhooks | Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries. | -| `omni-mcp` | MCP Server | Connect to the OmniRoute MCP server (37 tools, 3 transports: SSE/stdio/HTTP). Covers routing, cache, compression, memory, skills, providers, and audit tools across 16 permission scopes. | -| `omni-agents-a2a` | Agents & A2A Protocol | Interact with OmniRoute via JSON-RPC 2.0 agent-to-agent protocol. 6 built-in A2A skills: smart-routing, quota-management, provider-discovery, cost-analysis, health-report, list-capabilities. | -| `omni-version-manager` | Version Manager | Install, start, stop, restart, and update embedded services (9Router, CLIProxyAPI). Monitor service status, retrieve logs, and configure auto-start. | -| `omni-inference` | Inference (OpenAI-compatible) | The core OpenAI-compatible inference endpoints: chat completions, embeddings, images, audio (TTS/STT), moderations, rerank, and the Responses API. | +| ID | Name | Description | +| ---------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `omni-auth` | Authentication | Manage API key authentication and session tokens. Start here to authenticate requests via Bearer token, obtain session cookies, and configure login requirements. | +| `omni-providers` | Providers | Manage provider connections, API keys, OAuth flows, and connection tests. List, add, update, remove, and test AI provider integrations (OpenAI, Anthropic, Gemini, and 160+). | +| `omni-models` | Models | Query available AI models across all configured providers. List models, resolve model aliases, and browse the full model catalog including provider-specific variants. | +| `omni-combos-routing` | Combos & Routing | Create and manage routing combos with 14 strategies (priority, weighted, round-robin, Auto-combo, etc.). Configure fallback chains, test routing outcomes, and retrieve combo metrics. | +| `omni-api-keys` | API Keys | Create, list, rotate, and revoke OmniRoute API keys. Control per-key scopes, spending limits, and expiration. | +| `omni-usage-logs` | Usage & Logs | Access detailed call logs and usage analytics. Filter by provider, model, time range, status, and cost. Export logs and aggregate token usage. | +| `omni-budget` | Budget & Rate Limits | Configure spending limits, token quotas, and rate-limit policies per API key or globally. Inspect current consumption and enforce cost controls. | +| `omni-settings` | Settings | Read and update global application settings: system prompts, thinking budget, IP filters, payload rules, combo defaults, and require-login configuration. | +| `omni-proxies` | Proxy Configuration | Configure HTTP/HTTPS/SOCKS proxies for upstream provider requests. Set per-provider or global proxy rules, test connectivity, and manage proxy rotation. | +| `omni-cache` | Cache | Manage the LLM response cache. View cache statistics, clear entries, configure TTL policies, and control semantic-similarity caching thresholds. | +| `omni-compression` | Compression | Configure RTK, Caveman, and stacked compression modes. Manage language packs, custom rules, and test prompt compression reducing tokens by 60–90%. | +| `omni-context-rtk` | Context & RTK | Configure RTK filters, context engineering rules, and context relay settings. Test compression with real prompt samples and manage context transformation pipelines. | +| `omni-resilience` | Resilience & Monitoring | Monitor provider health, circuit-breaker states, p50/p95/p99 latency metrics, and budget guard alerts. Inspect connection cooldowns and model lockouts in real time. | +| `omni-cli-tools` | CLI Tools | Manage CLI tool integrations exposed via the API. List, configure, and invoke CLI tool plugins that extend OmniRoute's automation surface. | +| `omni-tunnels` | Tunnels | Create and manage secure tunnels (ngrok, Cloudflare Tunnel, custom) to expose OmniRoute to the internet or share access with remote agents and CI pipelines. | +| `omni-sync-cloud` | Cloud Sync | Synchronise OmniRoute configuration, provider connections, and settings to/from cloud storage. Manage cloud worker authentication and remote backup targets. | +| `omni-db-backups` | Database & Backups | Trigger system backups, restore from backup files, and manage the SQLite database lifecycle. Supports export, import, and incremental snapshot strategies. | +| `omni-webhooks` | Webhooks | Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries. | +| `omni-mcp` | MCP Server | Connect to the OmniRoute MCP server (37 tools, 3 transports: SSE/stdio/HTTP). Covers routing, cache, compression, memory, skills, providers, and audit tools across 16 permission scopes. | +| `omni-agents-a2a` | Agents & A2A Protocol | Interact with OmniRoute via JSON-RPC 2.0 agent-to-agent protocol. 6 built-in A2A skills: smart-routing, quota-management, provider-discovery, cost-analysis, health-report, list-capabilities. | +| `omni-version-manager` | Version Manager | Install, start, stop, restart, and update embedded services (9Router, CLIProxyAPI). Monitor service status, retrieve logs, and configure auto-start. | +| `omni-inference` | Inference (OpenAI-compatible) | The core OpenAI-compatible inference endpoints: chat completions, embeddings, images, audio (TTS/STT), moderations, rerank, and the Responses API. | --- -## CLI Skills (20) +## CLI Skills (21) -| ID | Name | Description | -| -- | ---- | ----------- | -| `cli-serve` | CLI: Serve | Start, stop, and restart the OmniRoute server from the CLI. Manage daemon mode, port configuration, auto-recovery, system tray integration, and the dashboard open shortcut. | -| `cli-health` | CLI: Health | Check server health, component status, and live metrics from the CLI. Run `health`, `health components`, and `health watch` for a real-time dashboard of circuit breakers and provider status. | -| `cli-providers` | CLI: Providers | Manage provider connections from the CLI: list available/configured providers, add, test, test-all, validate, rotate API keys, and view per-provider metrics. | -| `cli-keys` | CLI: API Keys | Create, list, rotate, and revoke OmniRoute API keys from the CLI. Manage OAuth flows for provider authentication and inspect key scopes and expiration. | -| `cli-models` | CLI: Models | Query available AI models, list model aliases, and browse the full model catalog from the CLI. Filter by provider, search by capability, and resolve model name variants. | -| `cli-chat` | CLI: Chat | Send chat completions, stream responses, and start an interactive REPL session from the CLI. Supports all OmniRoute providers, combo routing, and system prompt configuration. | -| `cli-routing` | CLI: Routing & Combos | Create, list, update, and delete routing combos from the CLI. Test routing strategies, inspect combo metrics, and configure fallback chains interactively. | -| `cli-resilience` | CLI: Resilience & Quotas | Inspect and manage circuit-breaker states, connection cooldowns, quota limits, and backoff levels from the CLI. Reset stuck providers and configure resilience thresholds. | -| `cli-compression` | CLI: Compression | Configure and test prompt compression from the CLI. Manage RTK filters, Caveman rules, stacked compression modes, and preview compression output with real prompts. | -| `cli-contexts` | CLI: Contexts & Sessions | Manage context engineering configurations, RTK filter sets, and conversation sessions from the CLI. Apply context-relay settings and inspect active context pipelines. | -| `cli-cost-usage` | CLI: Cost & Usage | View cost breakdowns, token usage, and call logs from the CLI. Filter by provider, model, or date range. Export usage reports and inspect per-connection spending. | -| `cli-mcp` | CLI: MCP | Inspect the MCP server status, list registered tools and scopes, run tool invocations, and manage MCP audit logs from the CLI. | -| `cli-a2a` | CLI: A2A Protocol | Interact with the OmniRoute A2A server from the CLI. Send tasks, inspect skill execution history, and test the JSON-RPC 2.0 agent-to-agent protocol interactively. | -| `cli-tunnel` | CLI: Tunnels | Start and stop tunnel connections (ngrok, Cloudflare, custom) from the CLI. Inspect active tunnel URLs, configure authentication, and test external reachability. | -| `cli-backup-sync` | CLI: Backup & Sync | Backup and restore OmniRoute data from the CLI. Trigger incremental snapshots, sync to cloud storage, manage backup schedules, and restore from archive files. | -| `cli-policy-audit` | CLI: Policy & Audit | Inspect audit logs, manage access policies, view telemetry data, and review request history from the CLI. Filter by event type, user, or time range for compliance workflows. | -| `cli-batches` | CLI: Batches & Files | Submit and monitor batch inference jobs from the CLI. Upload and manage files for batch processing, retrieve results, and integrate batch pipelines with CI/CD workflows. | -| `cli-eval` | CLI: Evals | Create and run evaluation suites, watch live benchmark progress, view scorecards, compare model performance, and integrate eval runs with CI workflows from the CLI. | -| `cli-plugins-skills` | CLI: Plugins, Skills & Memory | Manage Omni Skills (list, install, test, remove), plugins (create, configure), and persistent memory (search, add, clear) from the CLI. | -| `cli-setup` | CLI: Setup & Config | Run initial setup, configure global CLI settings, manage environment variables, check for updates, and configure autostart via the CLI setup and config commands. | +| ID | Name | Description | +| --------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli-serve` | CLI: Serve | Start, stop, and restart the OmniRoute server from the CLI. Manage daemon mode, port configuration, auto-recovery, system tray integration, and the dashboard open shortcut. | +| `cli-health` | CLI: Health | Check server health, component status, and live metrics from the CLI. Run `health`, `health components`, and `health watch` for a real-time dashboard of circuit breakers and provider status. | +| `cli-providers` | CLI: Providers | Manage provider connections from the CLI: list available/configured providers, add, test, test-all, validate, rotate API keys, and view per-provider metrics. | +| `cli-keys` | CLI: API Keys | Create, list, rotate, and revoke OmniRoute API keys from the CLI. Manage OAuth flows for provider authentication and inspect key scopes and expiration. | +| `cli-models` | CLI: Models | Query available AI models, list model aliases, and browse the full model catalog from the CLI. Filter by provider, search by capability, and resolve model name variants. | +| `cli-chat` | CLI: Chat | Send chat completions, stream responses, and start an interactive REPL session from the CLI. Supports all OmniRoute providers, combo routing, and system prompt configuration. | +| `cli-routing` | CLI: Routing & Combos | Create, list, update, and delete routing combos from the CLI. Test routing strategies, inspect combo metrics, and configure fallback chains interactively. | +| `cli-resilience` | CLI: Resilience & Quotas | Inspect and manage circuit-breaker states, connection cooldowns, quota limits, and backoff levels from the CLI. Reset stuck providers and configure resilience thresholds. | +| `cli-compression` | CLI: Compression | Configure and test prompt compression from the CLI. Manage RTK filters, Caveman rules, stacked compression modes, and preview compression output with real prompts. | +| `cli-contexts` | CLI: Contexts & Sessions | Manage context engineering configurations, RTK filter sets, and conversation sessions from the CLI. Apply context-relay settings and inspect active context pipelines. | +| `cli-cost-usage` | CLI: Cost & Usage | View cost breakdowns, token usage, and call logs from the CLI. Filter by provider, model, or date range. Export usage reports and inspect per-connection spending. | +| `cli-mcp` | CLI: MCP | Inspect the MCP server status, list registered tools and scopes, run tool invocations, and manage MCP audit logs from the CLI. | +| `cli-a2a` | CLI: A2A Protocol | Interact with the OmniRoute A2A server from the CLI. Send tasks, inspect skill execution history, and test the JSON-RPC 2.0 agent-to-agent protocol interactively. | +| `cli-tunnel` | CLI: Tunnels | Start and stop tunnel connections (ngrok, Cloudflare, custom) from the CLI. Inspect active tunnel URLs, configure authentication, and test external reachability. | +| `cli-backup-sync` | CLI: Backup & Sync | Backup and restore OmniRoute data from the CLI. Trigger incremental snapshots, sync to cloud storage, manage backup schedules, and restore from archive files. | +| `cli-policy-audit` | CLI: Policy & Audit | Inspect audit logs, manage access policies, view telemetry data, and review request history from the CLI. Filter by event type, user, or time range for compliance workflows. | +| `cli-batches` | CLI: Batches & Files | Submit and monitor batch inference jobs from the CLI. Upload and manage files for batch processing, retrieve results, and integrate batch pipelines with CI/CD workflows. | +| `cli-eval` | CLI: Evals | Create and run evaluation suites, watch live benchmark progress, view scorecards, compare model performance, and integrate eval runs with CI workflows from the CLI. | +| `cli-plugins-skills` | CLI: Plugins, Skills & Memory | Manage Omni Skills (list, install, test, remove), plugins (create, configure), and persistent memory (search, add, clear) from the CLI. | +| `cli-setup` | CLI: Setup & Config | Run initial setup, configure global CLI settings, manage environment variables, check for updates, and configure autostart via the CLI setup and config commands. | +| `cli-skill-collector` | CLI: Skill Collector | Detect installed coding CLI tools, search GitHub for matching agent skills, and plan their installation into the detected tools' skill directories. | --- @@ -87,6 +88,7 @@ https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/skills//SKILL. ``` Examples: + - API entry: `https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/skills/omni-auth/SKILL.md` - CLI entry: `https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/skills/cli-serve/SKILL.md` diff --git a/skills/cli-skill-collector/SKILL.md b/skills/cli-skill-collector/SKILL.md new file mode 100644 index 0000000000..2e3cec2776 --- /dev/null +++ b/skills/cli-skill-collector/SKILL.md @@ -0,0 +1,152 @@ +--- +name: cli-skill-collector +description: "Agent workflow: detect installed CLI coding tools (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.), search GitHub for matching agent skills, and install them to the detected tools. Replaces the standalone Skill Collector Python app." +--- + +# /cli-skill-collector — Agent Skill Collector + +Discover and install agent skills for your coding CLI tools — all through OmniRoute's built-in APIs. + +This skill teaches you how to: + +1. **Detect** which coding CLIs are installed on this machine +2. **Search** GitHub for relevant agent skills (SKILL.md repos) +3. **Install** discovered skills to the detected coding tools + +No separate Skill Collector app needed — OmniRoute's own CLI detection + GitHub search handles everything. + +--- + +## Step 1 — Detect installed coding tools + +Query OmniRoute's CLI tool detection to find which coding agents are installed: + +```bash +curl -H "Authorization: Bearer $OMNIROUTE_API_KEY" http://localhost:20128/api/skills/collect/detect +``` + +This returns: + +- Every CLI tool in OmniRoute's catalog (`CLI_TOOL_IDS`: claude, codex, cursor, copilot, opencode, cline, kilocode, hermes, hermes-agent, openclaw, droid, continue, qwen, windsurf, devin, antigravity, etc.) +- Whether each is **installed** and **runnable** +- GitHub skills **matched** to your installed tools (scored by relevance) + +Example response: + +```json +{ + "tools": { + "codex": { "installed": true, "runnable": true, "command": "codex" }, + "claude": { "installed": true, "runnable": true, "command": "claude" }, + "cursor": { "installed": false, "runnable": false } + }, + "installedToolIds": ["codex", "claude"], + "matchedSkills": [ + { "toolId": "codex", "repo": "user/skill-codex-xxx", "score": 0.85, "stars": 120 }, + { "toolId": "claude", "repo": "user/claude-agent-rules", "score": 0.92, "stars": 340 } + ], + "totalSkills": 85 +} +``` + +--- + +## Step 2 — Review matched skills + +For each installed tool, the API returns relevant GitHub repos that contain SKILL.md or agent configuration files. Use the `score` field to prioritize: + +| Score | Recommendation | +| ----- | ----------------------------------------------- | +| 0.80+ | Excellent — well-maintained, high stars, active | +| 0.60+ | Good — relevant with decent quality | +| 0.40+ | Fair — may need review | +| <0.40 | Low quality — skip | + +You can also browse manually: + +```bash +curl -H "Authorization: Bearer $OMNIROUTE_API_KEY" \ + "http://localhost:20128/api/github-skills?minStars=3&maxResults=50" +``` + +--- + +## Step 3 — Install skills to detected tools + +Install a chosen skill to one or more detected tools: + +```bash +curl -X POST http://localhost:20128/api/skills/collect/install \ + -H "Authorization: Bearer $OMNIROUTE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "repoName": "user/skill-codex-xxx", + "targets": ["codex", "claude"], + "description": "Agent skill for coding workflows" + }' +``` + +This plans the installation path for each target tool: + +- **claude** → `~/.claude/skills/{category}/` +- **codex** → `~/.codex/skills/{category}/` +- **hermes** → `~/AppData/Local/hermes/skills/{category}/` +- **opencode** → `~/.opencode/skills/{category}/` +- **gemini** → `~/.gemini/skills/{category}/` + +The actual file sync (cloning from GitHub and copying SKILL.md) is done by the agent using standard `curl` + `cp` commands. + +--- + +## Step 4 — Verify installation + +After installing, verify the skill is in place: + +```bash +# For Codex +ls -la ~/.codex/skills/imported-github/*/SKILL.md + +# For Claude Code +ls -la ~/.claude/skills/imported-github/*/SKILL.md + +# For Hermes (Windows) +ls -la ~/AppData/Local/hermes/skills/imported-github/*/SKILL.md +``` + +Also re-check detection: + +```bash +curl -H "Authorization: Bearer $OMNIROUTE_API_KEY" http://localhost:20128/api/skills/collect/detect +``` + +--- + +## Quick start (full workflow) + +```bash +AUTH_HEADER="Authorization: Bearer $OMNIROUTE_API_KEY" + +# 1. Detect +DETECT=$(curl -s -H "$AUTH_HEADER" http://localhost:20128/api/skills/collect/detect) + +# 2. Pick top matched skill for first installed tool +TOOL=$(echo "$DETECT" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d['installedToolIds'][0] if d['installedToolIds'] else '')") +SKILL=$(echo "$DETECT" | python3 -c "import sys,json;d=json.load(sys.stdin);ms=d.get('matchedSkills',[]);print(ms[0]['repo'] if ms else '')") + +if [ -n "$TOOL" ] && [ -n "$SKILL" ]; then + # 3. Install + curl -s -X POST http://localhost:20128/api/skills/collect/install \ + -H "$AUTH_HEADER" \ + -H "Content-Type: application/json" \ + -d "{\"repoName\": \"$SKILL\", \"targets\": [\"$TOOL\"]}" + echo "Installed $SKILL to $TOOL" +fi +``` + +--- + +## Notes + +- OmniRoute must be running locally on port 20128 (default) — see `docs/frameworks/SKILLS.md` for custom-port setups. +- The `/api/skills/collect/*` and `/api/github-skills` endpoints require **management-scoped authentication** the same way every other `/api/skills/*` route does: a dashboard session, the loopback CLI token, or an API key with the `manage` scope (`requireManagementAuth()`). Auth is only bypassed when the server has no login/API-key requirement configured at all. +- This replaces the standalone Skill Collector Python app — all logic is now inside OmniRoute. 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")}

+