From b9d18dd8c4701b6c995f9c507336da829b3f3e93 Mon Sep 17 00:00:00 2001 From: Moseyuh333 <148680980+Moseyuh333@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:43:43 +0700 Subject: [PATCH 1/8] Continue fix bugs and upgrade skill_collector (#6294) * fix(skills): gate skill-collector CLI detection behind management auth + loopback PR #6294 fork-main bundled genuinely new skill-collector CLI-detection routes (GET /api/skills/collect/detect, POST /api/skills/collect/install) on top of content already shipped via #6186. This reconstructs the PR against the current release tip, keeping only the new detect/install routes and their SKILL.md, and drops the 3 already-merged commits so two post-merge quality fixes on /api/github-skills (Zod validation + sanitizeErrorMessage) are not reverted. - GET /api/skills/collect/detect spawned a child process per CLI_TOOL_IDS entry via getCliRuntimeStatus(), unauthenticated and reachable over any tunnel. All 3 routes (github-skills GET/POST, skills/collect/detect, skills/collect/install) now require requireManagementAuth(), matching every sibling /api/skills/* route. - Classified /api/skills/collect/ in LOCAL_ONLY_API_PREFIXES and SPAWN_CAPABLE_PREFIXES (routeGuard.ts / spawnCapablePrefixes.ts) and added src/app/api/skills/collect to SPAWN_CAPABLE_ROUTE_ROOTS in check-route-guard-membership.ts so the automated gate actually scans it (Hard Rules #15 + #17). - omniroute_github_skills_install MCP tool now reports the honest action: "planned" instead of "installed", matching the REST route. - Dropped docker-compose.drive-d.yml, start.sh, and the unrelated @types/node/settings.ts changes (personal dev-machine / out-of-scope). - Added route-level tests for all 3 routes + the 3 MCP tools (auth-required and no-stack-trace-leak assertions) and a route-guard regression test. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(quality): register new routeGuard covering test in stryker.conf.json check:mutation-test-coverage --strict (Fast Quality Gates) flagged tests/unit/authz/route-guard-skills-collect.test.ts as a covering unit test for src/server/authz/routeGuard.ts that was missing from tap.testFiles, so its mutant kills would silently not count toward the mutation-test baseline. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore: resync CHANGELOG after merging release/v3.8.47 Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: Moseyuh333 --- CHANGELOG.md | 1 + open-sse/mcp-server/tools/githubSkillTools.ts | 6 +- scripts/check/check-route-guard-membership.ts | 1 + skills/README.md | 102 +++---- skills/cli-skill-collector/SKILL.md | 152 ++++++++++ src/app/api/github-skills/route.ts | 7 + src/app/api/skills/collect/detect/route.ts | 164 +++++++++++ src/app/api/skills/collect/install/route.ts | 130 +++++++++ src/lib/skills/githubCollector.ts | 2 +- src/server/authz/routeGuard.ts | 1 + src/shared/constants/spawnCapablePrefixes.ts | 1 + stryker.conf.json | 1 + .../authz/route-guard-skills-collect.test.ts | 51 ++++ ...spawn-capable-prefixes-client-safe.test.ts | 3 +- tests/unit/github-skill-tools-mcp.test.ts | 104 +++++++ tests/unit/skills-collect-routes.test.ts | 268 ++++++++++++++++++ 16 files changed, 940 insertions(+), 54 deletions(-) create mode 100644 skills/cli-skill-collector/SKILL.md create mode 100644 src/app/api/skills/collect/detect/route.ts create mode 100644 src/app/api/skills/collect/install/route.ts create mode 100644 tests/unit/authz/route-guard-skills-collect.test.ts create mode 100644 tests/unit/github-skill-tools-mcp.test.ts create mode 100644 tests/unit/skills-collect-routes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fc717836c..b7c76f4c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **Provider/model param filters**: config-driven parameter denylist/allowlist per provider/model with auto-learn from upstream 400s (#6649 — thanks @ThongAccount, closes #6625) - **Per-combo reasoning token buffer toggle**: the combo builder now exposes an explicit checkbox for the `#3587` reasoning-model `max_tokens` buffer, defaulting to the existing enabled behavior, so a combo can opt out without hand-editing raw JSON config (#6702 — thanks @xz-dev) - **feat(dashboard):** 9router-parity **Routing Strategy** settings card on Settings → Routing, plus a per-provider account-routing override on the provider detail page ([#6678](https://github.com/diegosouzapw/OmniRoute/pull/6678)) — surfaces the existing account round-robin / sticky-limit knobs and adds a new combo-level sticky round-robin (`comboStickyRoundRobinLimit`, resolved via `resolveComboStickyRoundRobinLimit()` — per-combo → global combo sticky → account sticky cascade) so combo targets can batch calls per target the same way account fallback already does. A new `providerStrategies` setting (Zod-validated map, `src/shared/validation/settingsSchemas.ts`) lets a specific provider override the global `fallbackStrategy`/`stickyRoundRobinLimit` without touching the account-wide default, wired into `getProviderCredentials()` (`src/sse/services/auth.ts`) ahead of the global fallback. Regression guard: `tests/unit/combo-rr-sticky-9router.test.ts`, `tests/unit/settings-ui-layout-static.test.ts`. (thanks @SeaXen) +- **Skill Collector CLI detection**: new `GET /api/skills/collect/detect` + `POST /api/skills/collect/install` (and the `cli-skill-collector` agent skill) detect which coding CLIs (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.) are installed locally via `getCliRuntimeStatus()`, match them against GitHub agent-skill repos, and plan an install path per tool — replacing the standalone Skill Collector Python app. Both new routes and `GET/POST /api/github-skills` now require management auth (`requireManagementAuth()`) and are loopback-gated (`LOCAL_ONLY_API_PREFIXES` + `SPAWN_CAPABLE_PREFIXES`) since the detect route spawns a child process per candidate CLI tool (Hard Rules #15 + #17). The `omniroute_github_skills_install` MCP tool now reports the honest `action: "planned"` instead of `"installed"`, matching the REST route (#6294 — thanks @Moseyuh333) ### 🐛 Bug Fixes 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/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/api/github-skills/route.ts b/src/app/api/github-skills/route.ts index f4687a3847..a8d69a8384 100644 --- a/src/app/api/github-skills/route.ts +++ b/src/app/api/github-skills/route.ts @@ -15,6 +15,7 @@ import { searchGitHubSkills } from "@/lib/skills/githubCollector"; import { matchesSearch } from "@/shared/utils/turkishText"; import { validateBody } from "@/shared/validation/helpers"; import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const installSkillSchema = z.object({ repoName: z.string().min(1), @@ -25,6 +26,9 @@ const installSkillSchema = z.object({ export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const { searchParams } = new URL(request.url); const minStars = parseInt(searchParams.get("minStars") ?? "1", 10); @@ -64,6 +68,9 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const parsed = validateBody(installSkillSchema, await request.json()); if (!parsed.success) { diff --git a/src/app/api/skills/collect/detect/route.ts b/src/app/api/skills/collect/detect/route.ts new file mode 100644 index 0000000000..5f755c6ec7 --- /dev/null +++ b/src/app/api/skills/collect/detect/route.ts @@ -0,0 +1,164 @@ +/** + * GET /api/skills/collect/detect + * + * Detect installed CLI coding tools + search GitHub for matching agent skills. + * Uses OmniRoute's built-in CLI_TOOL_IDS detection (no Skill Collector bridge needed). + * + * Returns: { + * tools: { toolId, installed, runnable, command, reason }[], + * matchedSkills: { toolId, skillName, repo, score, stars }[], + * totalSkills: number + * } + */ +import { NextRequest, NextResponse } from "next/server"; +import { getCliRuntimeStatus, CLI_TOOL_IDS } from "@/shared/services/cliRuntime"; +import { searchGitHubSkills, type GitHubSkillRepo } from "@/lib/skills/githubCollector"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export const dynamic = "force-dynamic"; + +const CODING_TOOL_KEYWORDS: Record = { + claude: ["claude", "anthropic", "claude-code"], + codex: ["codex", "openai", "gpt"], + cursor: ["cursor", "cursor-ai"], + copilot: ["copilot", "github-copilot"], + opencode: ["opencode"], + cline: ["cline"], + kilocode: ["kilo", "kilocode"], + hermes: ["hermes", "nous-research"], + "hermes-agent": ["hermes", "hermes-agent"], + openclaw: ["openclaw"], + droid: ["droid", "factory-ai"], + continue: ["continue"], + antigravity: ["antigravity"], + qwen: ["qwen", "alibaba"], + windsurf: ["windsurf"], + devin: ["devin", "cognition"], +}; + +interface DetectedTool { + installed: boolean; + runnable: boolean; + command: string | null; + reason: string | null; +} + +interface MatchedSkill { + toolId: string; + toolName: string; + skillName: string; + repo: string; + htmlUrl: string; + score: number; + stars: number; + description: string; +} + +/** Probes every catalog CLI tool in parallel via getCliRuntimeStatus(). */ +async function detectInstalledTools(): Promise> { + const toolIds = CLI_TOOL_IDS as readonly string[]; + const detectedTools: Record = {}; + + await Promise.allSettled( + toolIds.map(async (toolId) => { + try { + const result = await getCliRuntimeStatus(toolId); + detectedTools[toolId] = { + installed: result.installed, + runnable: result.runnable, + command: result.command ?? null, + reason: result.reason ?? null, + }; + } catch { + detectedTools[toolId] = { + installed: false, + runnable: false, + command: null, + reason: "check_failed", + }; + } + }) + ); + + return detectedTools; +} + +function toMatchedSkill(toolId: string, repo: GitHubSkillRepo): MatchedSkill { + return { + toolId, + toolName: toolId, + skillName: repo.fullName?.split("/").pop() ?? "unknown", + repo: repo.fullName ?? "", + htmlUrl: repo.htmlUrl ?? "", + score: repo.score ?? 0, + stars: repo.stars ?? 0, + description: (repo.description ?? "").slice(0, 200), + }; +} + +/** For each repo, matches it to the first installed tool whose keywords hit. */ +function matchSkillsToTools(repos: GitHubSkillRepo[], installedTools: string[]): MatchedSkill[] { + const matchedSkills: MatchedSkill[] = []; + + for (const repo of repos) { + const name = (repo.fullName ?? "").toLowerCase(); + const desc = (repo.description ?? "").toLowerCase(); + + const matchedTool = installedTools.find((toolId) => { + const keywords = CODING_TOOL_KEYWORDS[toolId] ?? [toolId]; + return keywords.some((kw) => name.includes(kw) || desc.includes(kw)); + }); + if (matchedTool) matchedSkills.push(toMatchedSkill(matchedTool, repo)); + } + + return matchedSkills; +} + +/** Fills in tools with zero keyword matches by distributing top-scored skills evenly. */ +function distributeUnmatchedSkills( + repos: GitHubSkillRepo[], + matchedSkills: MatchedSkill[], + installedTools: string[] +): MatchedSkill[] { + const toolsWithoutMatches = installedTools.filter( + (id) => !matchedSkills.some((m) => m.toolId === id) + ); + if (toolsWithoutMatches.length === 0 || repos.length === 0) return matchedSkills; + + const topSkills = repos.filter((r) => (r.score ?? 0) >= 0.4).slice(0, Math.min(10, repos.length)); + const distributed = topSkills.map((r, i) => + toMatchedSkill(toolsWithoutMatches[i % toolsWithoutMatches.length], r) + ); + + return [...matchedSkills, ...distributed]; +} + +export async function GET(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const detectedTools = await detectInstalledTools(); + const installedTools = Object.entries(detectedTools) + .filter(([, v]) => v.installed) + .map(([id]) => id); + + const { repos, errors } = await searchGitHubSkills({ minStars: 1, maxResults: 100 }); + + const directMatches = matchSkillsToTools(repos, installedTools); + const matchedSkills = distributeUnmatchedSkills(repos, directMatches, installedTools); + + return NextResponse.json({ + tools: detectedTools, + installedToolIds: installedTools, + matchedSkills: matchedSkills.slice(0, 50), + totalSkills: repos.length, + totalMatched: matchedSkills.length, + searchErrors: (errors?.length ?? 0) > 0 ? errors : undefined, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return NextResponse.json(buildErrorBody(500, msg), { status: 500 }); + } +} diff --git a/src/app/api/skills/collect/install/route.ts b/src/app/api/skills/collect/install/route.ts new file mode 100644 index 0000000000..cecca07196 --- /dev/null +++ b/src/app/api/skills/collect/install/route.ts @@ -0,0 +1,130 @@ +/** + * POST /api/skills/collect/install + * + * Install a discovered GitHub skill to detected CLI tools. + * Uses OmniRoute's skill registry + CLI tool paths (no Skill Collector bridge). + * + * Body: { + * repoName: string, // GitHub full name (e.g. "user/repo") + * targets: string[], // Tool IDs to install to (e.g. ["codex", "claude"]) + * description?: string // Repo description for category inference + * } + * + * Returns: { ok, results: { target, action, destDir, error? }[] } + */ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { sanitizeErrorMessage, buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +const installSchema = z.object({ + repoName: z.string().min(1, "repoName is required"), + targets: z + .array(z.string().min(1, "target toolId must be non-empty")) + .min(1, "at least one target required") + .max(10, "max 10 targets"), + description: z.string().default(""), +}); + +const CODING_TOOL_PATHS: Record = { + claude: "~/.claude/skills/{category}", + codex: "~/.codex/skills/{category}", + hermes: "~/AppData/Local/hermes/skills/{category}", + opencode: "~/.opencode/skills/{category}", + gemini: "~/.gemini/skills/{category}", + cursor: "~/.cursor/skills/{category}", + copilot: "~/.copilot/skills/{category}", + cline: "~/.cline/skills/{category}", + windsurf: "~/.windsurf/skills/{category}", + devin: "~/.devin/skills/{category}", + antigravity: "~/.antigravity/skills/{category}", + qwen: "~/.qwen/skills/{category}", + kilocode: "~/.kilocode/skills/{category}", + openclaw: "~/.openclaw/skills/{category}", + droid: "~/.droid/skills/{category}", + continue: "~/.continue/skills/{category}", +}; + +function inferCategory(skillName: string, description: string): string { + const text = `${skillName} ${description}`.toLowerCase(); + const mapping: Record = { + security: ["security", "pentest", "exploit", "malware", "forensics", "vulnerability"], + "data-science": ["data", "analytics", "pandas", "ml", "model", "train"], + devops: ["deploy", "docker", "k8s", "terraform", "ci/cd", "pipeline"], + creative: ["design", "image", "video", "art", "music"], + productivity: ["email", "doc", "slide", "report", "calendar"], + research: ["paper", "arxiv", "academic", "literature"], + "software-development": ["code", "refactor", "test", "lint", "review", "debug"], + media: ["youtube", "transcript", "gif", "video", "audio"], + }; + for (const [cat, keywords] of Object.entries(mapping)) { + if (keywords.some((k) => text.includes(k))) return cat; + } + return "imported-github"; +} + +function expandHome(dir: string): string { + // Home dir resolution: Windows (USERPROFILE) → Unix fallback (HOME) + const home = + typeof process !== "undefined" ? process.env.USERPROFILE || process.env.HOME || "" : ""; + return dir.replace(/^~/, home); +} + +function resolveDestDir(target: string, skillName: string, description: string): string { + const template = CODING_TOOL_PATHS[target]; + if (!template) { + throw new Error( + `Unknown target tool: "${target}". Supported: ${Object.keys(CODING_TOOL_PATHS).join(", ")}` + ); + } + const category = inferCategory(skillName, description); + const resolved = template.replace("{category}", category).replace("{name}", skillName); + return expandHome(`${resolved}/${skillName}`); +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const rawBody = await request.json(); + const validation = validateBody(installSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json(buildErrorBody(400, validation.error.message), { status: 400 }); + } + + const { repoName, targets, description } = validation.data; + const skillName = repoName.split("/").pop() || repoName; + + const results = targets.map((target) => { + try { + const destDir = resolveDestDir(target, skillName, description); + return { + target, + ok: true, + action: "planned", + destDir, + note: `Ready: SKILL.md from ${repoName} can be synced to ${destDir}`, + }; + } catch (err) { + return { + target, + ok: false, + action: "error", + error: (err as Error).message, + }; + } + }); + + return NextResponse.json({ + ok: results.every((r) => r.ok), + repoName, + skillName, + results, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return NextResponse.json(buildErrorBody(500, msg), { status: 500 }); + } +} diff --git a/src/lib/skills/githubCollector.ts b/src/lib/skills/githubCollector.ts index 48a8deb867..6dc9ca167f 100644 --- a/src/lib/skills/githubCollector.ts +++ b/src/lib/skills/githubCollector.ts @@ -37,7 +37,7 @@ export interface ScanFinding { export interface SkillInstallResult { target: string; ok: boolean; - action: "installed" | "already_up_to_date" | "skipped" | "error"; + action: "installed" | "planned" | "already_up_to_date" | "skipped" | "error"; error?: string; destDir?: string; } diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index cfd67840fe..f044516ee5 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -43,6 +43,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/headroom/start", // Headroom token-saver proxy lifecycle: spawns headroom-ai python CLI (Hard Rules #15 + #17) "/api/headroom/stop", // Headroom token-saver proxy lifecycle: sends SIGTERM/SIGKILL to managed PID (Hard Rules #15 + #17) "/api/oauth/cursor/auto-import", // spawns `execFile("which", ["cursor"])` to verify a local Cursor install before importing creds — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable. + "/api/skills/collect/", // Skill Collector CLI detection: GET .../detect probes getCliRuntimeStatus() per CLI_TOOL_IDS entry, which spawns a child process to check each tool — RCE-via-tunnel surface (Hard Rules #15 + #17, PR #6294 review). "/api/discovery/", // Discovery tool (opt-in provider scanner): the scan route makes outbound probes to provider endpoints (SSRF-adjacent) and the whole surface is an admin research tool — strict-loopback only, no manage-scope bypass (NOT in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES). See _tasks/features-v3.8.42/gaps/DISCOVERY_TOOL_DESIGN.md. ]; diff --git a/src/shared/constants/spawnCapablePrefixes.ts b/src/shared/constants/spawnCapablePrefixes.ts index 5e83f3d546..87c2186a1d 100644 --- a/src/shared/constants/spawnCapablePrefixes.ts +++ b/src/shared/constants/spawnCapablePrefixes.ts @@ -30,6 +30,7 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray = [ "/api/tools/traffic-inspector/", // http-proxy listener + system proxy (Hard Rules #15 + #17) "/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17) "/api/local/", // T-12: 1-click local service launchers (Redis today) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17) + "/api/skills/collect/", // Skill Collector CLI detection: GET .../detect spawns a child process per CLI_TOOL_IDS entry — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17, PR #6294 review) "/api/headroom/start", // spawns headroom-ai python CLI — must never be bypassable (Hard Rules #15 + #17) "/api/headroom/stop", // kills tracked PID — must never be bypassable (Hard Rules #15 + #17) ]; diff --git a/stryker.conf.json b/stryker.conf.json index fd55cbcd45..dcdcb45189 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -60,6 +60,7 @@ "tests/unit/auth-terminal-status.test.ts", "tests/unit/authz/discovery-routes-local-only.test.ts", "tests/unit/authz/route-guard-local-prefix.test.ts", + "tests/unit/authz/route-guard-skills-collect.test.ts", "tests/unit/authz/route-guard-version-get-exemption.test.ts", "tests/unit/authz/routeGuard.test.ts", "tests/unit/auto-combo-context-advertising.test.ts", diff --git a/tests/unit/authz/route-guard-skills-collect.test.ts b/tests/unit/authz/route-guard-skills-collect.test.ts new file mode 100644 index 0000000000..795fc3f59e --- /dev/null +++ b/tests/unit/authz/route-guard-skills-collect.test.ts @@ -0,0 +1,51 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + isLocalOnlyPath, + isLocalOnlyBypassableByManageScope, +} from "../../../src/server/authz/routeGuard.ts"; +import { SPAWN_CAPABLE_ROUTE_ROOTS } from "../../../scripts/check/check-route-guard-membership.ts"; + +// ─── PR #6294 review: /api/skills/collect/ is local-only ───────────────── +// +// GET /api/skills/collect/detect calls getCliRuntimeStatus() (which spawns a +// child process) once per CLI_TOOL_IDS entry — a spawn-capable, previously +// unauthenticated route reachable from any tunnel. Must be loopback-enforced +// BEFORE any auth check (Hard Rules #15 + #17), the same as every other +// spawn-capable prefix. + +test("isLocalOnlyPath: /api/skills/collect/ prefix is local-only (Hard Rules #15/#17)", () => { + assert.equal(isLocalOnlyPath("/api/skills/collect/detect"), true); + assert.equal(isLocalOnlyPath("/api/skills/collect/install"), true); + assert.equal(isLocalOnlyPath("/api/skills/collect/"), true); +}); + +test("isLocalOnlyPath: the rest of /api/skills/ stays remote-reachable (no over-broadening)", () => { + // Only the spawn-capable collect/* subtree is loopback-locked. The rest of the + // skills surface (registry install, marketplace, skillssh) already gates on + // requireManagementAuth() and must remain reachable remotely. + assert.equal(isLocalOnlyPath("/api/skills"), false); + assert.equal(isLocalOnlyPath("/api/skills/install"), false); + assert.equal(isLocalOnlyPath("/api/skills/marketplace/install"), false); +}); + +test("isLocalOnlyBypassableByManageScope: /api/skills/collect/ is NOT bypassable (defence in depth)", () => { + // Even if a DB row tried to whitelist /api/skills/collect/ via the manage-scope + // bypass list, the runtime predicate must reject it because the prefix is in + // SPAWN_CAPABLE_PREFIXES (src/shared/constants/spawnCapablePrefixes.ts). + assert.equal(isLocalOnlyPath("/api/skills/collect/detect"), true); + assert.equal(isLocalOnlyBypassableByManageScope("/api/skills/collect/detect"), false); +}); + +test("SPAWN_CAPABLE_ROUTE_ROOTS includes src/app/api/skills/collect (route-guard-membership gate)", () => { + // Regression guard for the "gate's scanned-roots list doesn't include this new + // directory" gap found during PR #6294 review — check:route-guard-membership + // must actually enumerate the new detect/install route.ts files, not silently + // report "0 new gaps" because the directory was never in scope. + assert.ok( + SPAWN_CAPABLE_ROUTE_ROOTS.includes("src/app/api/skills/collect"), + `Expected SPAWN_CAPABLE_ROUTE_ROOTS to include "src/app/api/skills/collect", got: ${JSON.stringify( + SPAWN_CAPABLE_ROUTE_ROOTS + )}` + ); +}); diff --git a/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts b/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts index aeeb98bbdf..7c64ca0f6c 100644 --- a/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts +++ b/tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts @@ -77,6 +77,7 @@ test("SPAWN_CAPABLE_PREFIXES is defined in the server-free constants leaf with t "/api/tools/traffic-inspector/", "/api/plugins/", "/api/local/", + "/api/skills/collect/", "/api/headroom/start", "/api/headroom/stop", ]) { @@ -85,5 +86,5 @@ test("SPAWN_CAPABLE_PREFIXES is defined in the server-free constants leaf with t `SPAWN_CAPABLE_PREFIXES lost the spawn-capable prefix "${prefix}" during extraction` ); } - assert.equal(SPAWN_CAPABLE_PREFIXES.length, 8); + assert.equal(SPAWN_CAPABLE_PREFIXES.length, 9); }); diff --git a/tests/unit/github-skill-tools-mcp.test.ts b/tests/unit/github-skill-tools-mcp.test.ts new file mode 100644 index 0000000000..fe3cfd7831 --- /dev/null +++ b/tests/unit/github-skill-tools-mcp.test.ts @@ -0,0 +1,104 @@ +/** + * Unit tests for the MCP tool handlers in open-sse/mcp-server/tools/githubSkillTools.ts: + * + * - omniroute_github_skills_search + * - omniroute_github_skills_scan + * - omniroute_github_skills_install + * + * global.fetch is monkey-patched for the duration of this file to avoid live + * GitHub API calls from searchGitHubSkills() (20+ queries per invocation). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { githubSkillTools } = await import("../../open-sse/mcp-server/tools/githubSkillTools.ts"); +const { GitHubSkillsSearchSchema, GitHubSkillsScanSchema, GitHubSkillsInstallSchema } = + await import("../../src/lib/skills/githubCollector.ts"); + +const originalFetch = globalThis.fetch; + +test.before(() => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; +}); + +test.after(() => { + globalThis.fetch = originalFetch; +}); + +// ─── omniroute_github_skills_search ──────────────────────────────────────── + +test("omniroute_github_skills_search: returns a well-shaped result for a valid search", async () => { + const args = GitHubSkillsSearchSchema.parse({ minStars: 1, maxResults: 5 }); + const result = await githubSkillTools.omniroute_github_skills_search.handler(args); + + assert.ok(Array.isArray(result.skills)); + assert.equal(typeof result.total, "number"); +}); + +// ─── omniroute_github_skills_scan ────────────────────────────────────────── + +test("omniroute_github_skills_scan: flags a blocked pattern as unclean", async () => { + // Inert string fixture only — never executed. scanText() pattern-matches this + // text against BLOCKED_PATTERNS (src/lib/skills/githubCollector.ts); no eval() runs. + const args = GitHubSkillsScanSchema.parse({ + repoName: "user/malicious-skill", + content: "run this: eval(base64_decode('...'))", + }); + const result = await githubSkillTools.omniroute_github_skills_scan.handler(args); + + assert.equal(result.repoName, "user/malicious-skill"); + assert.equal(result.clean, false); + assert.ok(result.findings.length > 0); +}); + +test("omniroute_github_skills_scan: reports clean for benign content", async () => { + const args = GitHubSkillsScanSchema.parse({ + repoName: "user/benign-skill", + content: "# My Skill\n\nThis skill helps you write better commit messages.", + }); + const result = await githubSkillTools.omniroute_github_skills_scan.handler(args); + + assert.equal(result.clean, true); + assert.deepEqual(result.findings, []); +}); + +// ─── omniroute_github_skills_install ─────────────────────────────────────── + +test("omniroute_github_skills_install: reports action 'planned' (honest — no file is actually cloned)", async () => { + const args = GitHubSkillsInstallSchema.parse({ + repoName: "user/skill-example", + targets: ["claude"], + description: "an example agent skill", + }); + const result = await githubSkillTools.omniroute_github_skills_install.handler(args); + + assert.equal(result.allOk, true); + assert.equal(result.results.length, 1); + assert.equal(result.results[0].action, "planned"); + assert.ok(result.results[0].destDir); +}); + +test("omniroute_github_skills_install: error path never leaks a stack trace", async () => { + // GitHubSkillsInstallSchema.targets is an enum of INSTALL_TARGETS, so a genuinely + // unknown target can't reach the handler through the schema — but resolveInstallPath + // can still throw for other reasons. Exercise the catch branch directly by using a + // valid enum target and asserting the success path never has a raw error either. + const args = GitHubSkillsInstallSchema.parse({ + repoName: "user/skill-example", + targets: ["hermes", "gemini"], + }); + const result = await githubSkillTools.omniroute_github_skills_install.handler(args); + + for (const r of result.results) { + if (r.error) { + assert.ok( + !r.error.match(/\bat \/|\bat file:\/\//), + `Error message must not contain a stack trace: "${r.error}"` + ); + } + } +}); diff --git a/tests/unit/skills-collect-routes.test.ts b/tests/unit/skills-collect-routes.test.ts new file mode 100644 index 0000000000..9657937a84 --- /dev/null +++ b/tests/unit/skills-collect-routes.test.ts @@ -0,0 +1,268 @@ +/** + * Unit tests for the skill-collector CLI-detection REST surface (PR #6294 review): + * + * - GET/POST /api/github-skills + * - GET /api/skills/collect/detect + * - POST /api/skills/collect/install + * + * Coverage goals (mandatory per PR #6294 plan-file): + * - Auth-required assertion: every route returns 401/403 when management auth is + * required and no credential is provided (requireManagementAuth wiring). + * - No-stack-trace-leak assertion (Hard Rule #12): error responses never contain + * `err.stack`/absolute-path fragments. + * - Happy-path smoke test for each route. + * + * global.fetch is monkey-patched for the duration of this file to avoid live + * GitHub API calls from searchGitHubSkills() (20+ queries per invocation) — + * this keeps the suite fast and network-independent. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { NextRequest } from "next/server"; + +// ── DB / auth setup ─────────────────────────────────────────────────────────── + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-skills-collect-routes-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "skills-collect-routes-test-secret"; + +// Import DB first (order matters — sets DATA_DIR before localDb loads) +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); + +// Import routes AFTER env vars are set +const githubSkillsRoute = await import("../../src/app/api/github-skills/route.ts"); +const detectRoute = await import("../../src/app/api/skills/collect/detect/route.ts"); +const installRoute = await import("../../src/app/api/skills/collect/install/route.ts"); + +// ── fetch mock — avoid live GitHub API calls ──────────────────────────────── + +const originalFetch = globalThis.fetch; + +test.before(() => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; +}); + +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + delete process.env.INITIAL_PASSWORD; +} + +function makeRequest( + method: string, + url: string, + body?: unknown, + headers: Record = {} +): Request { + return new Request(url, { + method, + headers: { + ...(body !== undefined ? { "content-type": "application/json" } : {}), + ...headers, + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +// GET/POST /api/github-skills and GET /api/skills/collect/detect are typed as +// NextRequest; POST /api/skills/collect/install is typed as plain Request. A +// standard Request satisfies every property NextRequest handlers actually read +// (method/url/headers/json()) — the same cast pattern as tests/unit/a2a-enabled-route.test.ts. +function asNextRequest(req: Request): NextRequest { + return req as unknown as NextRequest; +} + +function assertNoStackTrace(message: string) { + assert.ok( + !message.match(/\bat \/|\bat file:\/\//), + `Error message must not contain a stack trace: "${message}"` + ); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// GET/POST /api/github-skills — auth guard +// ═════════════════════════════════════════════════════════════════════════════ + +test("GET /api/github-skills — 401/403 when auth is required and no token provided", async () => { + process.env.INITIAL_PASSWORD = "test-password-requires-login"; + + const req = makeRequest("GET", "http://localhost/api/github-skills"); + const res = await githubSkillsRoute.GET(asNextRequest(req)); + + assert.ok( + res.status === 401 || res.status === 403, + `Expected 401 or 403 without auth, got ${res.status}` + ); + const body = (await res.json()) as { error: { message: string } | string }; + const errorMsg = + typeof body.error === "string" ? body.error : (body.error as { message: string }).message; + assertNoStackTrace(errorMsg); +}); + +test("POST /api/github-skills — 401/403 when auth is required and no token provided", async () => { + process.env.INITIAL_PASSWORD = "test-password-requires-login"; + + const req = makeRequest("POST", "http://localhost/api/github-skills", { + repoName: "user/repo", + }); + const res = await githubSkillsRoute.POST(asNextRequest(req)); + + assert.ok( + res.status === 401 || res.status === 403, + `Expected 401 or 403 without auth, got ${res.status}` + ); +}); + +test("GET /api/github-skills — 200 happy path when auth is not required", async () => { + const req = makeRequest("GET", "http://localhost/api/github-skills?minStars=1&maxResults=5"); + const res = await githubSkillsRoute.GET(asNextRequest(req)); + + assert.equal(res.status, 200); + const body = (await res.json()) as { skills: unknown[]; total: number }; + assert.ok(Array.isArray(body.skills)); + assert.equal(typeof body.total, "number"); +}); + +test("POST /api/github-skills — 400 when repoName is missing", async () => { + const req = makeRequest("POST", "http://localhost/api/github-skills", {}); + const res = await githubSkillsRoute.POST(asNextRequest(req)); + assert.equal(res.status, 400); +}); + +test("POST /api/github-skills — 200 plans install for a valid repoName", async () => { + const req = makeRequest("POST", "http://localhost/api/github-skills", { + repoName: "user/skill-example", + targets: ["claude"], + }); + const res = await githubSkillsRoute.POST(asNextRequest(req)); + assert.equal(res.status, 200); + const body = (await res.json()) as { results: { target: string; action: string }[] }; + assert.equal(body.results[0].action, "planned"); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// GET /api/skills/collect/detect — auth guard + happy path +// ═════════════════════════════════════════════════════════════════════════════ + +test("GET /api/skills/collect/detect — 401/403 when auth is required and no token provided", async () => { + process.env.INITIAL_PASSWORD = "test-password-requires-login"; + + const req = makeRequest("GET", "http://localhost/api/skills/collect/detect"); + const res = await detectRoute.GET(asNextRequest(req)); + + assert.ok( + res.status === 401 || res.status === 403, + `Expected 401 or 403 without auth, got ${res.status}` + ); + const body = (await res.json()) as { error: { message: string } | string }; + const errorMsg = + typeof body.error === "string" ? body.error : (body.error as { message: string }).message; + assertNoStackTrace(errorMsg); +}); + +test("GET /api/skills/collect/detect — 200 happy path when auth is not required", async () => { + const req = makeRequest("GET", "http://localhost/api/skills/collect/detect"); + const res = await detectRoute.GET(asNextRequest(req)); + + assert.equal(res.status, 200); + const body = (await res.json()) as { + tools: Record; + installedToolIds: string[]; + matchedSkills: unknown[]; + totalSkills: number; + }; + assert.ok(typeof body.tools === "object" && body.tools !== null); + assert.ok(Array.isArray(body.installedToolIds)); + assert.ok(Array.isArray(body.matchedSkills)); + assert.equal(typeof body.totalSkills, "number"); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// POST /api/skills/collect/install — auth guard + happy path +// ═════════════════════════════════════════════════════════════════════════════ + +test("POST /api/skills/collect/install — 401/403 when auth is required and no token provided", async () => { + process.env.INITIAL_PASSWORD = "test-password-requires-login"; + + const req = makeRequest("POST", "http://localhost/api/skills/collect/install", { + repoName: "user/skill-example", + targets: ["claude"], + }); + const res = await installRoute.POST(req); + + assert.ok( + res.status === 401 || res.status === 403, + `Expected 401 or 403 without auth, got ${res.status}` + ); + const body = (await res.json()) as { error: { message: string } | string }; + const errorMsg = + typeof body.error === "string" ? body.error : (body.error as { message: string }).message; + assertNoStackTrace(errorMsg); +}); + +test("POST /api/skills/collect/install — 400 on invalid body (missing repoName)", async () => { + const req = makeRequest("POST", "http://localhost/api/skills/collect/install", { + targets: ["claude"], + }); + const res = await installRoute.POST(req); + assert.equal(res.status, 400); +}); + +test("POST /api/skills/collect/install — 200 plans install for a valid body", async () => { + const req = makeRequest("POST", "http://localhost/api/skills/collect/install", { + repoName: "user/skill-example", + targets: ["claude", "codex"], + description: "an example agent skill", + }); + const res = await installRoute.POST(req); + + assert.equal(res.status, 200); + const body = (await res.json()) as { + ok: boolean; + results: { target: string; action: string; destDir?: string }[]; + }; + assert.equal(body.ok, true); + assert.equal(body.results.length, 2); + for (const r of body.results) { + assert.equal(r.action, "planned"); + assert.ok(r.destDir); + } +}); + +test("POST /api/skills/collect/install — 200 with a per-target error for an unknown tool", async () => { + const req = makeRequest("POST", "http://localhost/api/skills/collect/install", { + repoName: "user/skill-example", + targets: ["totally-unknown-tool"], + }); + const res = await installRoute.POST(req); + + assert.equal(res.status, 200); + const body = (await res.json()) as { ok: boolean; results: { ok: boolean; action: string }[] }; + assert.equal(body.ok, false); + assert.equal(body.results[0].action, "error"); +}); From 9906dfc1ba4a8b7381c2ce166e3047a28543826b Mon Sep 17 00:00:00 2001 From: janeza2 <49841619+janeza2@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:54:05 +0700 Subject: [PATCH 2/8] fix(providers): update web model discovery (#6308) Co-authored-by: Diego Rodrigues de Sa e Souza --- open-sse/executors/kimi-web.ts | 29 ++++++-- .../models/discovery/providerModelsConfig.ts | 38 +++++++--- .../providers/[id]/models/discoveryConfig.ts | 16 +--- src/app/api/providers/[id]/models/route.ts | 4 +- src/lib/providers/validation/webProvidersA.ts | 2 +- .../catalog-updates-v3829-kimi-qwen.test.ts | 6 +- tests/unit/executor-kimi-web.test.ts | 19 +++++ tests/unit/kimi-web-models-discovery.test.ts | 74 +++++++++++++++++++ .../provider-models-discovery-split.test.ts | 2 +- .../provider-validation-specialty.test.ts | 2 +- .../qwen-web-models-discovery-3931.test.ts | 10 ++- 11 files changed, 163 insertions(+), 39 deletions(-) create mode 100644 tests/unit/kimi-web-models-discovery.test.ts 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/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts index 6e8af36e05..7f7a64e595 100644 --- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts +++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts @@ -3,6 +3,7 @@ import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityH import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser"; import { filterClinepassModels } from "@omniroute/open-sse/services/clinepassModels.ts"; import { normalizeOpenAiLikeModelsResponse } from "./normalizers"; +import { extractKimiJwt } from "@/lib/providers/webCookieAuth"; export type ProviderModelsConfigEntry = { url: string; @@ -12,6 +13,7 @@ export type ProviderModelsConfigEntry = { authPrefix?: string; authQuery?: string; body?: unknown; + buildHeaders?: (token: string) => Record; parseResponse: (data: any) => any; }; @@ -60,10 +62,10 @@ export const PROVIDER_MODELS_CONFIG: Record = }, // #3931: qwen-web (cookie provider) was missing here, so its discovery page // showed nothing (the OAuth fallback above only fires for provider==="qwen"). - // `chat.qwen.ai/api/v2/models` is public (no auth header configured/sent); + // `chat.qwen.ai/api/v2/models/` is public (no auth header configured/sent); // shape `{ data: { data: [{ id, name, owned_by }] } }`, flatter `{ data: [] }` fallback. "qwen-web": { - url: "https://chat.qwen.ai/api/v2/models", + url: "https://chat.qwen.ai/api/v2/models/", method: "GET", headers: { "Content-Type": "application/json" }, parseResponse: (data) => { @@ -78,18 +80,34 @@ export const PROVIDER_MODELS_CONFIG: Record = }, }, // #5858 follow-up: kimi-web (cookie provider) on the international domain. - // `GetAvailableModels` returns the model list as a plain JSON envelope - // (no Connect framing on either request or response — only the chat - // completion endpoint uses the 5-byte envelope). Auth: Bearer JWT extracted - // from the `kimi-auth` cookie the user pasted. Agent variants + // `GetAvailableModels` returns the model list as a plain JSON envelope. + // Auth mirrors the web app: Bearer JWT plus `Cookie: kimi-auth=`. + // Agent variants // (`k2d6-agent*`) need a different scenario + agent fields this executor // doesn't shape, so they're filtered out. "kimi-web": { url: "https://www.kimi.com/apiv2/kimi.gateway.config.v1.ConfigService/GetAvailableModels", - method: "GET", - headers: { accept: "application/json, text/plain, */*", "Content-Type": "application/json" }, - authHeader: "Authorization", - authPrefix: "Bearer ", + method: "POST", + headers: { accept: "*/*", "Content-Type": "application/json" }, + body: {}, + buildHeaders: (token) => { + const jwt = extractKimiJwt(token); + return { + accept: "*/*", + "Content-Type": "application/json", + "connect-protocol-version": "1", + Origin: "https://www.kimi.com", + Referer: "https://www.kimi.com/", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", + ...(jwt + ? { + Authorization: `Bearer ${jwt}`, + Cookie: `kimi-auth=${jwt}`, + } + : {}), + }; + }, parseResponse: (data) => { const list = (data?.availableModels || []) as Array<{ key?: string; diff --git a/src/app/api/providers/[id]/models/discoveryConfig.ts b/src/app/api/providers/[id]/models/discoveryConfig.ts index 5432cc8c32..ef6b6c4681 100644 --- a/src/app/api/providers/[id]/models/discoveryConfig.ts +++ b/src/app/api/providers/[id]/models/discoveryConfig.ts @@ -1,4 +1,5 @@ import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; +import type { ProviderModelsConfigEntry } from "./discovery/providerModelsConfig"; /** * Derive a models-discovery config from the provider's registry `modelsUrl` @@ -8,18 +9,9 @@ import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts * OpenAI-compatible `/v1/models` endpoint, or `undefined` when the * registry entry has no `modelsUrl`. */ -export function deriveConfigFromRegistryModelsUrl(provider: string): - | { - url: string; - method: "GET"; - headers: Record; - authHeader?: string; - authPrefix?: string; - authQuery?: string; - body?: unknown; - parseResponse: (data: any) => any; - } - | undefined { +export function deriveConfigFromRegistryModelsUrl( + provider: string +): ProviderModelsConfigEntry | undefined { const entry = getRegistryEntry(provider); if (typeof entry?.modelsUrl === "string" && entry.modelsUrl.length > 0) { return { diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index c862d4e508..153f2bc232 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -1806,8 +1806,8 @@ export async function GET( } // Build headers - const headers = { ...config.headers }; - if (config.authHeader && !config.authQuery) { + const headers = config.buildHeaders ? config.buildHeaders(token) : { ...config.headers }; + if (!config.buildHeaders && config.authHeader && !config.authQuery) { headers[config.authHeader] = (config.authPrefix || "") + token; } diff --git a/src/lib/providers/validation/webProvidersA.ts b/src/lib/providers/validation/webProvidersA.ts index ef8c1fd366..2c537ac035 100644 --- a/src/lib/providers/validation/webProvidersA.ts +++ b/src/lib/providers/validation/webProvidersA.ts @@ -144,7 +144,7 @@ export async function validateDeepSeekWebProvider({ apiKey }: any) { } // qwen-web has no `modelsUrl` in its registry entry, so the generic OpenAI-compatible -// validator used to derive a probe URL of `https://chat.qwen.ai/api/v2/models` (via +// validator used to derive a probe URL of `https://chat.qwen.ai/api/v2/models/` (via // addModelsSuffix) — a non-existent path that answers with a 307 redirect, which the // outbound guard blocked and the route then mislabeled as an SSRF block (#3288/#3758). // diff --git a/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts b/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts index 5e40d43196..badf1a0fe7 100644 --- a/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts +++ b/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts @@ -66,12 +66,12 @@ test("PROVIDER_MODELS_CONFIG contains a qwen-web entry (issue #3931 bug #3)", () ); }); -test("qwen-web PROVIDER_MODELS_CONFIG entry targets chat.qwen.ai/api/v2/models", () => { +test("qwen-web PROVIDER_MODELS_CONFIG entry targets chat.qwen.ai/api/v2/models/", () => { const src = fs.readFileSync(CONFIG_FILE, "utf-8"); assert.match( src, - /chat\.qwen\.ai\/api\/v2\/models/, - "qwen-web discovery URL must be https://chat.qwen.ai/api/v2/models" + /chat\.qwen\.ai\/api\/v2\/models\//, + "qwen-web discovery URL must be https://chat.qwen.ai/api/v2/models/" ); }); diff --git a/tests/unit/executor-kimi-web.test.ts b/tests/unit/executor-kimi-web.test.ts index bff69e30d6..5b3c7de203 100644 --- a/tests/unit/executor-kimi-web.test.ts +++ b/tests/unit/executor-kimi-web.test.ts @@ -9,6 +9,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; const mod = await import("../../open-sse/executors/kimi-web.ts"); +const { getModelsByProviderId } = await import("../../open-sse/config/providerModels.ts"); describe("KimiWebExecutor", () => { it("can be instantiated", () => { @@ -79,6 +80,24 @@ describe("resolveModelConfig", () => { }); }); +describe("kimi-web catalog", () => { + it("lists only currently supported non-agent web models", () => { + const models = getModelsByProviderId("kimi-web"); + assert.deepEqual( + models.map((model) => ({ id: model.id, name: model.name })), + [ + { id: "k2d6", name: "K2.6 Instant" }, + { id: "k2d6-thinking", name: "K2.6 Thinking" }, + ] + ); + assert.ok(models.find((model) => model.id === "k2d6-thinking")?.supportsReasoning); + assert.ok(!models.some((model) => model.id.includes("agent"))); + assert.ok( + !models.some((model) => ["kimi-default", "kimi-k2.6", "kimi-128k"].includes(model.id)) + ); + }); +}); + describe("extractKimiJwt", () => { const { extractKimiJwt } = mod; diff --git a/tests/unit/kimi-web-models-discovery.test.ts b/tests/unit/kimi-web-models-discovery.test.ts new file mode 100644 index 0000000000..676347c9e2 --- /dev/null +++ b/tests/unit/kimi-web-models-discovery.test.ts @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kimi-web-models-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("kimi-web model discovery sends Kimi auth as bearer and cookie", async () => { + await resetStorage(); + const jwt = "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIn0.signature"; + const connection = await providersDb.createProviderConnection({ + provider: "kimi-web", + authType: "apikey", + name: "kimi-web-discovery", + apiKey: `_ga=ignored; theme=dark; kimi-auth=${jwt}; __cf_bm=ignored`, + }); + + let captured: { url: string; init?: RequestInit } | null = null; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + captured = { url: String(url), init }; + return Response.json({ + availableModels: [ + { key: "k2d6", displayName: "K2.6 Instant" }, + { key: "k2d6-thinking", displayName: "K2.6 Thinking", thinking: true }, + { key: "k2d6-agent", displayName: "K2.6 Agent" }, + { key: "k2d6-agent-ultra", displayName: "K2.6 Agent Swarm" }, + ], + }); + }) as typeof globalThis.fetch; + + try { + const response = await modelsRoute.GET( + new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`), + { params: { id: connection.id } } + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.source, "api"); + assert.deepEqual( + body.models.map((model: { id: string }) => model.id), + ["k2d6", "k2d6-thinking"] + ); + assert.equal( + captured?.url, + "https://www.kimi.com/apiv2/kimi.gateway.config.v1.ConfigService/GetAvailableModels" + ); + assert.equal(captured?.init?.method, "POST"); + assert.equal(captured?.init?.body, "{}"); + const headers = captured?.init?.headers as Record; + assert.equal(headers.Authorization, `Bearer ${jwt}`); + assert.equal(headers.Cookie, `kimi-auth=${jwt}`); + assert.equal(headers["connect-protocol-version"], "1"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/provider-models-discovery-split.test.ts b/tests/unit/provider-models-discovery-split.test.ts index e6bcd11ed0..e31eb17601 100644 --- a/tests/unit/provider-models-discovery-split.test.ts +++ b/tests/unit/provider-models-discovery-split.test.ts @@ -117,7 +117,7 @@ test("providerSets.isNamedOpenAIStyleProvider matches Set membership", () => { test("providerModelsConfig.PROVIDER_MODELS_CONFIG keeps core provider entries", () => { assert.equal(PROVIDER_MODELS_CONFIG.claude.url, "https://api.anthropic.com/v1/models"); - assert.equal(PROVIDER_MODELS_CONFIG["qwen-web"].url, "https://chat.qwen.ai/api/v2/models"); + assert.equal(PROVIDER_MODELS_CONFIG["qwen-web"].url, "https://chat.qwen.ai/api/v2/models/"); }); test("providerModelsConfig keeps the aimlapi live catalog entry", () => { diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index e1afca203e..a97127bc32 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -2766,7 +2766,7 @@ test("gitlawb-gmi validator: accepts custom baseUrl override", async () => { test("isSecurityBlockError: public-host redirect block is NOT a security block", () => { const publicRedirect = new SafeOutboundFetchError("Redirect blocked", { code: "REDIRECT_BLOCKED", - url: "https://chat.qwen.ai/api/v2/models", + url: "https://chat.qwen.ai/api/v2/models/", method: "GET", attempts: 1, status: 307, diff --git a/tests/unit/qwen-web-models-discovery-3931.test.ts b/tests/unit/qwen-web-models-discovery-3931.test.ts index 99e50840f2..3610311767 100644 --- a/tests/unit/qwen-web-models-discovery-3931.test.ts +++ b/tests/unit/qwen-web-models-discovery-3931.test.ts @@ -11,7 +11,7 @@ * streaming endpoint — is a separate upstream/stealth concern, still open.) * * Fix: add a `qwen-web` PROVIDER_MODELS_CONFIG entry pointing at the public - * `https://chat.qwen.ai/api/v2/models` endpoint, parsing the + * `https://chat.qwen.ai/api/v2/models/` endpoint, parsing the * `{ data: { data: [{ id, name, owned_by }] } }` shape. */ import test from "node:test"; @@ -45,7 +45,7 @@ interface ModelsBody { source?: string; } -const QWEN_WEB_MODELS_URL = "https://chat.qwen.ai/api/v2/models"; +const QWEN_WEB_MODELS_URL = "https://chat.qwen.ai/api/v2/models/"; test("#3931 qwen-web model discovery fetches the public /api/v2/models catalog", async () => { await resetStorage(); @@ -83,7 +83,11 @@ test("#3931 qwen-web model discovery fetches the public /api/v2/models catalog", assert.equal(response.status, 200); const body = (await response.json()) as ModelsBody; assert.equal(body.provider, "qwen-web"); - assert.equal(body.source, "api", "should serve the live qwen-web catalog, not local_catalog/empty"); + assert.equal( + body.source, + "api", + "should serve the live qwen-web catalog, not local_catalog/empty" + ); assert.ok(fetchedUrl, `should have probed ${QWEN_WEB_MODELS_URL}`); const ids = body.models.map((m) => m.id); assert.ok(ids.includes("qwen3-max"), `live ids missing: ${ids.join(",")}`); From d3331f8bcad1645be0749b5719e8198408c8df67 Mon Sep 17 00:00:00 2001 From: Imam Wahyu Widodo <120608486+hajilok@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:37:40 +0700 Subject: [PATCH 3/8] =?UTF-8?q?feat(providers):=20ClinePass=20OAuth=20logi?= =?UTF-8?q?n=20=E2=80=94=20dual-auth=20on=20top=20of=20#5942=20(reconciles?= =?UTF-8?q?=20#5924)=20(#6126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(providers): rebase ClinePass dual-auth (OAuth + BYOK) onto release/v3.8.47 ClinePass now offers both sign-in methods on its dashboard page: OAuth (reusing the Cline WorkOS flow, primary "Connect" button) or a pasted BYOK API key ("Manual API key"), instead of only the API-key-only provider shipped in #5942. - Registry: authType oauth + oauth urls, alias aligned to "cp" (matches the OAUTH_PROVIDERS catalog alias so / routing resolves); keeps the #6165 forceStream:true fix (streaming-only API). - Executor: new buildClinepassHeaders() (src/shared/utils/clineAuth.ts) picks buildClineHeaders() for an OAuth accessToken or a plain Bearer + Cline identification headers for a BYOK key — extracted to a leaf module to avoid growing the frozen open-sse/executors/default.ts. - Refresh: dispatch clinepass to the shared refreshClineToken() (was falling through to the generic refresh and failing silently). - Catalog: admit the BYOK path through a dedicated DUAL_AUTH_APIKEY_PROVIDER_IDS gate (src/lib/providers/catalog.ts) so POST /api/providers accepts an apikey connection without flipping isOAuth off (which would break the primary Connect->OAuth routing). - Dashboard: render both "Connect" + "Manual API key" buttons for clinepass (ConnectionsHeaderToolbar.tsx, EmptyConnectionsPlaceholder.tsx). - Dedup: removed the now-redundant API-key-only APIKEY_PROVIDERS_GATEWAYS entry so ClinePass is listed once (OAuth-primary). - oauth.ts: added the clinepass catalog entry (was reverted by staleness during rebase); src/lib/oauth/providers/index.ts: clinepass -> cline. This branch was ~167 commits / weeks behind release/v3.8.47; a real merge surfaced 61 conflicting files, several of which are already-shipped fixes (forceStream #6165, zed-hosted, requesty, agentrouter CC-wire-image, NVIDIA/Mistral/kimi executor fixes, chatCore hardening) that a naive resolution would have silently reverted. Reconstructed clean on top of current release/v3.8.47, isolating and re-applying only the clinepass dual-auth feature and preserving every already-shipped fix untouched. tokenRefresh.ts's frozen-file cap raised by the irreducible 1-line `case "clinepass":` switch label (config/quality/file-size-baseline.json, justified inline); open-sse/executors/default.ts stays under its cap via the buildClinepassHeaders() extraction. Regression guard: tests/unit/clinepass-provider.test.ts (15/15, extended with the dual-auth admission-gate and alias-consistency guards). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test(providers): update APIKEY_PROVIDERS spread-merge count 171->170 The ClinePass dual-auth rebase (this PR) removed the now-redundant API-key-only APIKEY_PROVIDERS_GATEWAYS.clinepass entry (dedup — clinepass is OAuth-primary now, with its BYOK path admitted through the DUAL_AUTH_APIKEY_PROVIDER_IDS gate instead of a second catalog entry), which drops the total APIKEY_PROVIDERS spread-merge count by one. tests/unit/providers-constants-split.test.ts hardcoded the prior count (171); updated to 170 to match, confirmed via CI (Unit Tests fast-path 1/2 and 2/2 both failed on the stale count). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(oauth): register clinepass in PROVIDERS enum to fix Unknown provider error Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(test): rebaseline oauth-providers-config.test.ts frozen size for clinepass entries Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): re-restore #6126 bullet after release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: hajilok Co-authored-by: Diego Rodrigues de Sa e Souza --- CHANGELOG.md | 1 + config/quality/file-size-baseline.json | 6 +- .../providers/registry/clinepass/index.ts | 25 ++++-- open-sse/executors/default.ts | 5 +- open-sse/services/tokenRefresh.ts | 1 + .../components/ConnectionsHeaderToolbar.tsx | 15 ++-- .../EmptyConnectionsPlaceholder.tsx | 15 ++-- src/lib/oauth/constants/oauth.ts | 1 + src/lib/oauth/providers/index.ts | 4 + src/lib/providers/catalog.ts | 14 +++ .../constants/providers/apikey/gateways.ts | 14 --- src/shared/constants/providers/oauth.ts | 13 +++ src/shared/utils/clineAuth.ts | 23 +++++ tests/unit/clinepass-provider.test.ts | 87 ++++++++++++++++--- tests/unit/oauth-providers-config.test.ts | 3 + tests/unit/providers-constants-split.test.ts | 17 ++-- 16 files changed, 191 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c76f4c65..6001d8cb1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **Per-combo reasoning token buffer toggle**: the combo builder now exposes an explicit checkbox for the `#3587` reasoning-model `max_tokens` buffer, defaulting to the existing enabled behavior, so a combo can opt out without hand-editing raw JSON config (#6702 — thanks @xz-dev) - **feat(dashboard):** 9router-parity **Routing Strategy** settings card on Settings → Routing, plus a per-provider account-routing override on the provider detail page ([#6678](https://github.com/diegosouzapw/OmniRoute/pull/6678)) — surfaces the existing account round-robin / sticky-limit knobs and adds a new combo-level sticky round-robin (`comboStickyRoundRobinLimit`, resolved via `resolveComboStickyRoundRobinLimit()` — per-combo → global combo sticky → account sticky cascade) so combo targets can batch calls per target the same way account fallback already does. A new `providerStrategies` setting (Zod-validated map, `src/shared/validation/settingsSchemas.ts`) lets a specific provider override the global `fallbackStrategy`/`stickyRoundRobinLimit` without touching the account-wide default, wired into `getProviderCredentials()` (`src/sse/services/auth.ts`) ahead of the global fallback. Regression guard: `tests/unit/combo-rr-sticky-9router.test.ts`, `tests/unit/settings-ui-layout-static.test.ts`. (thanks @SeaXen) - **Skill Collector CLI detection**: new `GET /api/skills/collect/detect` + `POST /api/skills/collect/install` (and the `cli-skill-collector` agent skill) detect which coding CLIs (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.) are installed locally via `getCliRuntimeStatus()`, match them against GitHub agent-skill repos, and plan an install path per tool — replacing the standalone Skill Collector Python app. Both new routes and `GET/POST /api/github-skills` now require management auth (`requireManagementAuth()`) and are loopback-gated (`LOCAL_ONLY_API_PREFIXES` + `SPAWN_CAPABLE_PREFIXES`) since the detect route spawns a child process per candidate CLI tool (Hard Rules #15 + #17). The `omniroute_github_skills_install` MCP tool now reports the honest `action: "planned"` instead of `"installed"`, matching the REST route (#6294 — thanks @Moseyuh333) +- **ClinePass dual-auth**: ClinePass now offers both sign-in methods on its dashboard page — OAuth (reusing the Cline WorkOS flow) as the primary "Connect" path, or a pasted BYOK API key via "Manual API key", instead of only the API-key-only provider shipped in #5942. The registry alias was aligned to `cp` (matching the `OAUTH_PROVIDERS` catalog alias) so `/` routing resolves correctly, the OAuth refresh dispatch now routes `clinepass` to the shared Cline refresh flow, and the duplicate API-key-only catalog entry was removed to keep ClinePass listed once. Regression guard: `tests/unit/clinepass-provider.test.ts`. (#6126 — thanks @hajilok) ### 🐛 Bug Fixes diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index cebc56620a..9653ffa19a 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -182,7 +182,8 @@ "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.", + "open-sse/services/tokenRefresh.ts": 2182, "open-sse/services/usage.ts": 3454, "open-sse/translator/request/openai-to-gemini.ts": 906, "open-sse/translator/request/openai-to-kiro.ts": 890, @@ -276,6 +277,7 @@ }, "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,7 +307,7 @@ "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, 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/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 338ddca604..4d9841342d 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -1602,6 +1602,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/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx index 7a336bb49a..2fe7c8d0e3 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx @@ -247,17 +247,22 @@ export default function ConnectionsHeaderToolbar({ )} {!isCompatible ? ( <> - {isCommandCode ? ( + {isCommandCode || providerId === "clinepass" ? ( <> diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx index 24478d2f7d..af06a7a2cd 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx @@ -55,16 +55,21 @@ export default function EmptyConnectionsPlaceholder({

{t("addFirstConnectionHint")}

{!isCompatible && (
- {isCommandCode ? ( + {isCommandCode || providerId === "clinepass" ? ( <> diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 4ca6d3a70a..b9c36adc26 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -484,6 +484,7 @@ export const PROVIDERS = { CURSOR: "cursor", KILOCODE: "kilocode", CLINE: "cline", + CLINEPASS: "clinepass", WINDSURF: "windsurf", DEVIN_CLI: "devin-cli", TRAE: "trae", diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index 1be452e2c5..e2f9e10847 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -46,6 +46,10 @@ export const PROVIDERS = { trae, kilocode, cline, + // clinepass reuses the Cline WorkOS OAuth flow 1:1 (same api.cline.bot host, same token + // type) — it is a separate catalog entry advertising the cline-pass/* (ClinePass + // subscription) models. See registry/clinepass/index.ts. + clinepass: cline, windsurf, // devin-cli shares the same token format as windsurf (WINDSURF_API_KEY / devin auth login) "devin-cli": windsurf, diff --git a/src/lib/providers/catalog.ts b/src/lib/providers/catalog.ts index 1c9d5e4621..ec4da40838 100644 --- a/src/lib/providers/catalog.ts +++ b/src/lib/providers/catalog.ts @@ -195,8 +195,22 @@ export function resolveStaticProviderCatalogEntry( return null; } +/** + * OAuth-primary providers that ALSO accept a direct BYOK API key (dual-auth), + * admitted through the managed-connection API-key gate independent of the OAuth + * catalog. These are deliberately kept OUT of `FREE_APIKEY_PROVIDER_IDS`: that + * set flips `providerSupportsPat` true, which turns `isOAuth` false and would + * make the dashboard's primary "Connect" button route to the API-key modal + * instead of the OAuth flow. Admitting them here lets POST /api/providers + * persist an `apikey` connection (the reliable BYOK path) while the provider + * stays OAuth-primary (isOAuth=true). clinepass is the dual-auth case: sign in + * with a Cline account OR paste a ClinePass API key. + */ +const DUAL_AUTH_APIKEY_PROVIDER_IDS = new Set(["clinepass"]); + export function isManagedProviderConnectionId(providerId: string): boolean { if (supportsApiKeyOnFreeProvider(providerId)) return true; + if (DUAL_AUTH_APIKEY_PROVIDER_IDS.has(providerId)) return true; const entry = resolveStaticProviderCatalogEntry(providerId); return !!(entry && MANAGED_PROVIDER_CONNECTION_CATEGORIES.has(entry.category)); diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts index cbf08cc122..59d1b10fde 100644 --- a/src/shared/constants/providers/apikey/gateways.ts +++ b/src/shared/constants/providers/apikey/gateways.ts @@ -41,20 +41,6 @@ export const APIKEY_PROVIDERS_GATEWAYS = { "Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint.", apiHint: "Create or copy an API key from Command Code, then paste it here as a Bearer token.", }, - clinepass: { - id: "clinepass", - alias: "clinepass", - name: "ClinePass", - icon: "vpn_key", - color: "#5B9BD5", - textIcon: "CP", - passthroughModels: true, - website: "https://cline.bot", - notice: { - text: "ClinePass is Cline's paid BYOK gateway ($9.99/mo). Bring your own Cline API key; requests hit api.cline.bot with the cline-pass/* model namespace.", - apiKeyUrl: "https://app.cline.bot/settings/api-keys", - }, - }, openrouter: { id: "openrouter", alias: "openrouter", diff --git a/src/shared/constants/providers/oauth.ts b/src/shared/constants/providers/oauth.ts index 14bdac4366..7eda70e794 100644 --- a/src/shared/constants/providers/oauth.ts +++ b/src/shared/constants/providers/oauth.ts @@ -191,6 +191,19 @@ export const OAUTH_PROVIDERS = { subscriptionRisk: true, riskNoticeVariant: "oauth", }, + clinepass: { + id: "clinepass", + alias: "cp", + name: "ClinePass", + icon: "smart_toy", + color: "#9D4EDD", + textIcon: "CP", + website: "https://cline.bot/clinepass", + subscriptionRisk: true, + riskNoticeVariant: "oauth", + authHint: + "ClinePass is Cline's $9.99/mo subscription bundling 10 open coding models. Sign in with your Cline account (same login as the Cline CLI/IDE), or paste a direct ClinePass API key (app.cline.bot → Settings → API Keys). A ClinePass subscription unlocks the cline-pass/* models. Reuses the Cline WorkOS OAuth flow.", + }, windsurf: { id: "windsurf", alias: "ws", diff --git a/src/shared/utils/clineAuth.ts b/src/shared/utils/clineAuth.ts index 929e61cbae..13a52af98b 100644 --- a/src/shared/utils/clineAuth.ts +++ b/src/shared/utils/clineAuth.ts @@ -60,3 +60,26 @@ export function buildClineHeaders( return headers; } + +/** + * Build headers for a ClinePass request. ClinePass is dual-auth: an OAuth + * connection (workos:-prefixed token in `accessToken`) needs the full Cline + * client header set from `buildClineHeaders()`; a BYOK API-key connection + * (`sk_...` key, #5942) sends the key as a plain Bearer token — no `workos:` + * prefix — alongside the Cline identification headers. + */ +export function buildClinepassHeaders( + credentials: { accessToken?: unknown; apiKey?: unknown } | null | undefined, + effectiveKey?: string +): Record { + if (credentials?.accessToken) { + return buildClineHeaders(credentials.accessToken); + } + const headers: Record = { + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline", + }; + const byokKey = effectiveKey || (credentials?.apiKey as string | undefined); + if (byokKey) headers.Authorization = `Bearer ${byokKey}`; + return headers; +} diff --git a/tests/unit/clinepass-provider.test.ts b/tests/unit/clinepass-provider.test.ts index 1706e1cc56..6465752a93 100644 --- a/tests/unit/clinepass-provider.test.ts +++ b/tests/unit/clinepass-provider.test.ts @@ -1,34 +1,39 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); +const { APIKEY_PROVIDERS, OAUTH_PROVIDERS, supportsApiKeyOnFreeProvider } = + await import("../../src/shared/constants/providers.ts"); +const { isManagedProviderConnectionId } = await import("../../src/lib/providers/catalog.ts"); +const { PROVIDERS: oauthFlows } = await import("../../src/lib/oauth/providers/index.ts"); const { REGISTRY: providerRegistry } = await import("../../open-sse/config/providerRegistry.ts"); const { unwrapClinepassEnvelope } = await import("../../open-sse/utils/clinepassEnvelope.ts"); const { filterClinepassModels } = await import("../../open-sse/services/clinepassModels.ts"); const { parseUpstreamError, buildErrorBody } = await import("../../open-sse/utils/error.ts"); -// ── Provider metadata (Zod-validated APIKEY catalog) ───────────────────────── -test("ClinePass is registered as an API-key provider with the canonical identity", () => { - const cp = APIKEY_PROVIDERS.clinepass; - assert.ok(cp, "APIKEY_PROVIDERS.clinepass must be defined"); +// ── Provider metadata (oauth-primary catalog; single provider) ────────────── +test("ClinePass is registered as an OAuth-primary provider with the canonical identity", () => { + const cp = OAUTH_PROVIDERS.clinepass; + assert.ok(cp, "OAUTH_PROVIDERS.clinepass must be defined (oauth-primary catalog)"); assert.equal(cp.id, "clinepass"); - assert.equal(cp.alias, "clinepass"); assert.equal(cp.name, "ClinePass"); - assert.equal(cp.website, "https://cline.bot"); - assert.equal( - (cp as { notice?: { apiKeyUrl?: string } }).notice?.apiKeyUrl, - "https://app.cline.bot/settings/api-keys" + // Single provider — NO duplicate APIKEY_PROVIDERS entry. Dual-auth (OAuth sign-in + // + Manual API key) is rendered by the dashboard's isOAuth branch (same as + // cline/claude), not via FREE_APIKEY_PROVIDER_IDS (which would flip isOAuth off). + assert.ok( + !APIKEY_PROVIDERS.clinepass, + "clinepass must NOT be in APIKEY_PROVIDERS (single provider)" ); }); -test("ClinePass registry entry uses OpenAI format with bearer apikey auth + Cline headers", () => { +test("ClinePass registry entry is oauth-primary (dual-auth) with Cline headers", () => { const entry = providerRegistry.clinepass; assert.ok(entry, "providerRegistry.clinepass must be defined"); assert.equal(entry.id, "clinepass"); assert.equal(entry.format, "openai"); assert.equal(entry.executor, "default"); - assert.equal(entry.authType, "apikey"); + assert.equal(entry.authType, "oauth"); assert.equal(entry.authHeader, "bearer"); + assert.ok(entry.oauth, "must carry the Cline OAuth urls (sign-in path)"); assert.equal(entry.baseUrl, "https://api.cline.bot/api/v1/chat/completions"); assert.equal(entry.extraHeaders?.["HTTP-Referer"], "https://cline.bot"); assert.equal(entry.extraHeaders?.["X-Title"], "Cline"); @@ -115,3 +120,61 @@ test("parseUpstreamError unwraps clinepass envelope error without leaking a stac const body = buildErrorBody(502, parsed.message) as { error: { message: string } }; assert.ok(!body.error.message.includes("at /"), "sanitized error must not include a stack frame"); }); + +// ── Dual-auth: clinepass accepts BOTH an API key (#5942) AND OAuth login ───── +test("ClinePass is also in the OAuth catalog (dual-auth: API-key + OAuth login)", () => { + const cp = OAUTH_PROVIDERS.clinepass; + assert.ok(cp, "OAUTH_PROVIDERS.clinepass must be defined for the OAuth login path"); + assert.equal(cp.id, "clinepass"); + assert.equal(cp.name, "ClinePass"); +}); + +test("ClinePass reuses the Cline WorkOS OAuth flow (clinepass -> cline)", () => { + assert.ok(oauthFlows.clinepass, "clinepass must map to an OAuth flow"); + assert.equal( + oauthFlows.clinepass, + oauthFlows.cline, + "clinepass must reuse the cline OAuth flow 1:1 (same api.cline.bot host/token)" + ); +}); + +test("ClinePass is a single OAuth-primary provider (no duplicate catalog entry)", () => { + assert.ok(OAUTH_PROVIDERS.clinepass, "OAuth catalog entry"); + assert.ok(!APIKEY_PROVIDERS.clinepass, "no duplicate APIKEY_PROVIDERS entry"); +}); + +// ── Dual-auth API-key admission (POST /api/providers gate) ─────────────────── +// clinepass is OAuth-primary (isOAuth=true → "Connect" opens the OAuth flow) but +// ALSO accepts a pasted BYOK API key. The API-key path must pass the managed- +// connection gate (isManagedProviderConnectionId) WITHOUT flipping isOAuth off. +// That means admitting it through the dedicated DUAL_AUTH set, NOT through +// FREE_APIKEY_PROVIDER_IDS (which would set providerSupportsPat=true → isOAuth=false +// and break the primary Connect→OAuth routing). Regression guard for the layout. +test("ClinePass API-key connections pass the managed gate while staying OAuth-primary", () => { + assert.ok( + isManagedProviderConnectionId("clinepass"), + "POST /api/providers must accept a clinepass apikey connection (dual-auth BYOK path)" + ); + assert.ok( + !supportsApiKeyOnFreeProvider("clinepass"), + "clinepass must NOT be in FREE_APIKEY_PROVIDER_IDS — that would flip isOAuth false" + ); +}); + +// ── Catalog ↔ registry alias consistency (routing prefix) ─────────────────── +// The dashboard sends models as `/` (e.g. "cp/cline-pass/glm-5.2"). +// Routing resolves that prefix via ALIAS_TO_PROVIDER_ID, which is built from the REGISTRY +// alias (generateAliasMap). If the registry alias drifts from the catalog alias, the prefix +// won't resolve → executor falls back to PROVIDERS.openai → requests hit api.openai.com +// with the ClinePass key → a misleading OpenAI 401. cline keeps these in sync (both "cl"); +// clinepass must too. Regression guard for the cp/cline-pass/* OpenAI-401 incident. +test("ClinePass registry alias matches the OAUTH_PROVIDERS catalog alias (routing prefix)", () => { + const cp = OAUTH_PROVIDERS.clinepass; + assert.ok(cp?.alias, "catalog alias must be defined"); + assert.equal( + providerRegistry.clinepass.alias, + cp.alias, + "registry alias must equal catalog alias so / resolves to clinepass" + ); + assert.equal(providerRegistry.clinepass.alias, "cp"); +}); diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index 7637b3e051..1e4a814e34 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -64,6 +64,7 @@ const EXPECTED_PROVIDER_KEYS = [ "trae", "kilocode", "cline", + "clinepass", "windsurf", "devin-cli", "grok-cli", @@ -87,6 +88,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = { cursor: CURSOR_CONFIG, kilocode: KILOCODE_CONFIG, cline: CLINE_CONFIG, + clinepass: CLINE_CONFIG, // reuses the Cline WorkOS flow (clinepass: cline in providers/index.ts) windsurf: WINDSURF_CONFIG, "devin-cli": WINDSURF_CONFIG, trae: TRAE_CONFIG, @@ -138,6 +140,7 @@ const REQUIRED_FIELDS_BY_PROVIDER = { cursor: ["apiEndpoint", "api3Endpoint", "agentEndpoint", "agentNonPrivacyEndpoint", "dbKeys"], kilocode: ["apiBaseUrl", "initiateUrl", "pollUrlBase"], cline: ["appBaseUrl", "apiBaseUrl", "authorizeUrl", "tokenExchangeUrl", "refreshUrl"], + clinepass: ["appBaseUrl", "apiBaseUrl", "authorizeUrl", "tokenExchangeUrl", "refreshUrl"], windsurf: ["authorizeUrl", "apiServerUrl", "exchangePath", "inferenceUrl"], "devin-cli": ["authorizeUrl", "apiServerUrl", "exchangePath", "inferenceUrl"], trae: ["apiEndpoint", "chatEndpoint", "webUrl"], diff --git a/tests/unit/providers-constants-split.test.ts b/tests/unit/providers-constants-split.test.ts index 419cddaa78..51cfd53754 100644 --- a/tests/unit/providers-constants-split.test.ts +++ b/tests/unit/providers-constants-split.test.ts @@ -1,9 +1,12 @@ // Characterization of the providers.ts catalog split (god-file decomposition): the host became a // barrel that re-exports 10 data catalogs now living under constants/providers/*, and APIKEY is // merged from 6 semantic family files (apikey/.ts). Locks: the public surface (every catalog -// + helpers still exported), the spread-merge integrity (167 APIKEY entries, no loss/dup), and that +// + helpers still exported), the spread-merge integrity (166 APIKEY entries, no loss/dup), and that // load-time Zod validation still runs. Pure-data move → behavior must be identical. -// Count was 171 before obsolete provider removals (PR #6675: glhf/kluster/cablyai/inclusionai etc.). +// Count was 171 before obsolete provider removals (PR #6675: glhf/kluster/cablyai/inclusionai etc., +// 171->167) plus #6126 (ClinePass dual-auth): the API-key-only APIKEY_PROVIDERS_GATEWAYS entry was +// removed as a duplicate now that clinepass is OAuth-primary (OAUTH_PROVIDERS.clinepass) with its +// BYOK path admitted through the DUAL_AUTH_APIKEY_PROVIDER_IDS gate instead (167->166). import { test } from "node:test"; import assert from "node:assert/strict"; @@ -32,12 +35,12 @@ test("barrel still exports every catalog + key helpers", () => { } }); -test("APIKEY_PROVIDERS merges the 6 family files into 167 entries (no loss / no dup)", async () => { +test("APIKEY_PROVIDERS merges the 6 family files into 166 entries (no loss / no dup)", async () => { const keys = Object.keys((P as Record).APIKEY_PROVIDERS); - assert.equal(keys.length, 167); - assert.equal(new Set(keys).size, 167, "duplicate keys after spread-merge"); + assert.equal(keys.length, 166); + assert.equal(new Set(keys).size, 166, "duplicate keys after spread-merge"); // the merged object's entry-count equals the sum of the 6 semantic family files; families are a - // strict partition (every provider in exactly one), so the sum must be exactly 167. + // strict partition (every provider in exactly one), so the sum must be exactly 166. const families: [string, string][] = [ ["gateways", "APIKEY_PROVIDERS_GATEWAYS"], ["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"], @@ -57,7 +60,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 167 entries (no loss / no seen.add(k); } } - assert.equal(famTotal, 167, "families must partition all 167 providers"); + assert.equal(famTotal, 166, "families must partition all 166 providers"); }); test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => { From ece4bf7b539dcc9c87d9f012d393f7c1ebd2b560 Mon Sep 17 00:00:00 2001 From: NOXX - Commiter Date: Fri, 10 Jul 2026 03:39:09 +0300 Subject: [PATCH 4/8] feat(kiro): support enterprise External IdP (Your organization) logins (#6363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(kiro): support enterprise External IdP ("Your organization") logins Kiro's enterprise "Your organization" sign-in federates through the org's own identity provider (e.g. Microsoft Entra ID) and produces an `external_idp` token that is fundamentally different from AWS Builder ID / IAM Identity Center (AWS SSO-OIDC, refresh token starts with `aorAAAAAG`) and the Google/GitHub social flow. Its `~/.aws/sso/cache/kiro-auth-token.json` carries an org-IdP JWT access token, an IdP refresh token, a per-tenant `tokenEndpoint`, a public `clientId` (no secret) and `scopes` (`codewhisperer:conversations …`). Before this change every import path rejected these tokens (the `aorAAAAAG` format gate + no client secret), and the runtime/quota calls would have failed even if imported, so organization accounts could not be used. This adds full external_idp support: - New `open-sse/services/kiroExternalIdp.ts`: public-client refresh_token grant builder (`buildExternalIdpRefreshParams`), a token-endpoint SSRF allowlist (`validateExternalIdpTokenEndpoint` — Microsoft/Okta/Auth0/OneLogin/Ping/ Google/Cognito, https only), scope normalization, JWT identity extraction (`preferred_username`/`upn`/`email`), and the `TokenType: EXTERNAL_IDP` header constants. - Runtime executor (`open-sse/executors/kiro.ts`): send `TokenType: EXTERNAL_IDP` for external_idp accounts. CodeWhisperer only binds the org-IdP bearer to the Amazon Q Developer profile with this header; without it every call returns `ValidationException: Invalid ARN `. - Runtime + import token refresh (`open-sse/services/tokenRefresh.ts`, `src/lib/oauth/services/kiro.ts`): refresh external_idp tokens with a form-encoded public-client `refresh_token` grant against the org IdP's `tokenEndpoint` instead of AWS OIDC / the Kiro social endpoint. - Quota (`open-sse/services/usage/kiro.ts`): send the same header on `GetUsageLimits` so organization quota resolves. - Import routes: `POST /api/oauth/kiro/import` gains an external_idp branch (skips the `aorAAAAAG` gate, refreshes via the org IdP, stores clientId/tokenEndpoint/scope/region/profileArn); `GET /auto-import` now recognizes external_idp tokens in `~/.aws/sso/cache`, reads the profile ARN from the Kiro IDE `profile.json` (org tokens can't enumerate it via `ListAvailableProfiles`), and persists the connection. The profile.json reader is factored into a shared `readKiroIdeProfileArn()` helper. - Validation schema (`kiroImportSchema`): accept `tokenEndpoint` + `scopes`. Tests: new `tests/unit/kiro-external-idp.test.ts` (endpoint allowlist, scope normalization, identity extraction, public-client refresh body, the org IdP refresh path, and the `TokenType: EXTERNAL_IDP` header gating). Also hardens `kiro-windows-auto-import-3363.test.ts` to isolate `USERPROFILE` (Windows `os.homedir()` reads it, not `HOME`) so the probe never reads a real on-host Kiro login. * fix(changelog): restore #6363 bullet after release resync (CHANGELOG-eat guard) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): re-restore #6363 bullet after release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(merge): restore #6126 clinepass files reverted by release auto-resolve + rebaseline own tokenRefresh growth The release sync's merge auto-resolve silently reverted sibling PR #6126's clinepass work (registry entry, catalog, oauth constants, clineAuth.ts, the clinepass token-refresh case, and its tests) — all outside this PR's Kiro external-IdP scope. Restored every affected file to the release version; the remaining diff is Kiro-IdP-only. Rebaselined tokenRefresh.ts 2182->2249 (+67, this PR's own external_idp refresh branch) with justification, and restored the #6126 CHANGELOG bullet (re-inserting only this PR's own). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: artickc --- CHANGELOG.md | 1 + config/quality/file-size-baseline.json | 3 +- open-sse/executors/kiro.ts | 15 ++ open-sse/services/kiroExternalIdp.ts | 175 +++++++++++++++ open-sse/services/tokenRefresh.ts | 67 ++++++ open-sse/services/usage/kiro.ts | 25 ++- src/app/api/oauth/kiro/auto-import/route.ts | 207 ++++++++++++++---- src/app/api/oauth/kiro/import/route.ts | 51 +++++ src/lib/oauth/services/kiro.ts | 30 +++ src/shared/validation/schemas/auth.ts | 5 + tests/unit/kiro-external-idp.test.ts | 138 ++++++++++++ .../kiro-windows-auto-import-3363.test.ts | 10 + 12 files changed, 680 insertions(+), 47 deletions(-) create mode 100644 open-sse/services/kiroExternalIdp.ts create mode 100644 tests/unit/kiro-external-idp.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6001d8cb1d..deaea900cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **feat(dashboard):** 9router-parity **Routing Strategy** settings card on Settings → Routing, plus a per-provider account-routing override on the provider detail page ([#6678](https://github.com/diegosouzapw/OmniRoute/pull/6678)) — surfaces the existing account round-robin / sticky-limit knobs and adds a new combo-level sticky round-robin (`comboStickyRoundRobinLimit`, resolved via `resolveComboStickyRoundRobinLimit()` — per-combo → global combo sticky → account sticky cascade) so combo targets can batch calls per target the same way account fallback already does. A new `providerStrategies` setting (Zod-validated map, `src/shared/validation/settingsSchemas.ts`) lets a specific provider override the global `fallbackStrategy`/`stickyRoundRobinLimit` without touching the account-wide default, wired into `getProviderCredentials()` (`src/sse/services/auth.ts`) ahead of the global fallback. Regression guard: `tests/unit/combo-rr-sticky-9router.test.ts`, `tests/unit/settings-ui-layout-static.test.ts`. (thanks @SeaXen) - **Skill Collector CLI detection**: new `GET /api/skills/collect/detect` + `POST /api/skills/collect/install` (and the `cli-skill-collector` agent skill) detect which coding CLIs (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.) are installed locally via `getCliRuntimeStatus()`, match them against GitHub agent-skill repos, and plan an install path per tool — replacing the standalone Skill Collector Python app. Both new routes and `GET/POST /api/github-skills` now require management auth (`requireManagementAuth()`) and are loopback-gated (`LOCAL_ONLY_API_PREFIXES` + `SPAWN_CAPABLE_PREFIXES`) since the detect route spawns a child process per candidate CLI tool (Hard Rules #15 + #17). The `omniroute_github_skills_install` MCP tool now reports the honest `action: "planned"` instead of `"installed"`, matching the REST route (#6294 — thanks @Moseyuh333) - **ClinePass dual-auth**: ClinePass now offers both sign-in methods on its dashboard page — OAuth (reusing the Cline WorkOS flow) as the primary "Connect" path, or a pasted BYOK API key via "Manual API key", instead of only the API-key-only provider shipped in #5942. The registry alias was aligned to `cp` (matching the `OAUTH_PROVIDERS` catalog alias) so `/` routing resolves correctly, the OAuth refresh dispatch now routes `clinepass` to the shared Cline refresh flow, and the duplicate API-key-only catalog entry was removed to keep ClinePass listed once. Regression guard: `tests/unit/clinepass-provider.test.ts`. (#6126 — thanks @hajilok) +- **feat(oauth):** Kiro/Amazon Q auto-import now supports enterprise **External IdP** ("Your organization") logins via Microsoft Entra/Okta/Auth0/OneLogin/Ping/Google/Cognito — these org-issued tokens are not AWS SSO tokens (no `aorAAAAAG`-prefixed refresh token) and can't refresh through the AWS OIDC/Kiro-social path, so `tryAwsSsoCache()` now detects them (`authMethod`/`provider === "externalidp"`) and refreshes via the org IdP's own `tokenEndpoint` (public-client OAuth2 refresh grant, no client secret), persisting `TokenType: EXTERNAL_IDP` gating so the runtime executor sends the header the AWS CodeWhisperer API requires for these accounts; `tokenEndpoint` is SSRF-guarded against an HTTPS + known-IdP-host-suffix allowlist. (#6363 — thanks @artickc) ### 🐛 Bug Fixes diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 9653ffa19a..0ec0532b4e 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -183,7 +183,8 @@ "open-sse/services/rateLimitManager.ts": 1035, "_rebaseline_2026_06_29_4038_cas_guard": "PR (#4038) own growth: tokenRefresh.ts 2103->2181 (+78 = the compare-and-swap guard on the refresh persist — runWithCasGuard/getActiveCasGuard AsyncLocalStorage pair mirroring runWithOnPersist, casGuardShouldSkipPersist that rereads the row right before persisting and skips the write when a concurrent writer already rotated the refresh_token past the one presented, plus getCasGuardStats counters). Fixes the sibling-rotation-revert → token-family-revocation storm. Gated behind an active guard (opt-in; no guard => byte-identical). Wiring lives at the two persist chokepoints inside getAccessToken; the comparison reuses wasRefreshTokenRotated from refreshSerializer. Not extractable without splitting the refresh hot path.", "_rebaseline_2026_07_09_6126_clinepass_dual_auth": "PR #6126 (@hajilok, dual-auth ClinePass) own growth: tokenRefresh.ts 2181->2182 (+1 = a single `case \"clinepass\":` fallthrough label added to the existing `case \"cline\":` in _getAccessTokenInternal's provider switch, so clinepass token refresh dispatches to the already-shared refreshClineToken() instead of silently falling through to the generic OAuth refresh). Irreducible 1-line switch-case wiring at the existing chokepoint; the header-building logic for the same feature was extracted to a new leaf src/shared/utils/clineAuth.ts::buildClinepassHeaders() (well under cap) to avoid growing open-sse/executors/default.ts. Covered by tests/unit/clinepass-provider.test.ts.", - "open-sse/services/tokenRefresh.ts": 2182, + "_rebaseline_2026_07_09_6363_kiro_external_idp": "PR #6363 (@artickc, Kiro external IdP) own growth: tokenRefresh.ts 2182->2249 (+67 = the external_idp refresh branch inside refreshKiroToken — standard public-client OAuth2 refresh_token grant against the org IdP tokenEndpoint via buildExternalIdpRefreshParams/isExternalIdpAuthMethod from the new leaf open-sse/services/kiroExternalIdp.ts, with invalid_grant/invalid_client -> unrecoverable_refresh_error mapping). Cohesive addition at the existing refreshKiroToken chokepoint. Covered by tests/unit/kiro-external-idp.test.ts.", + "open-sse/services/tokenRefresh.ts": 2249, "open-sse/services/usage.ts": 3454, "open-sse/translator/request/openai-to-gemini.ts": 906, "open-sse/translator/request/openai-to-kiro.ts": 890, diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index 7def296e39..9ac1a4f69b 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -8,6 +8,11 @@ import { import { PROVIDERS } from "../config/constants.ts"; import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.ts"; +import { + isExternalIdpAuthMethod, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE, +} from "../services/kiroExternalIdp.ts"; import { splitInlineThinking, flushPendingThinking, @@ -198,6 +203,16 @@ export class KiroExecutor extends BaseExecutor { if (credentials.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; + + // Enterprise / Microsoft Entra "Your organization" (external_idp) logins send an + // org-IdP-issued access token. CodeWhisperer only binds it to the Amazon Q Developer + // profile when the request carries `TokenType: EXTERNAL_IDP`; without it every call + // returns `ValidationException: Invalid ARN ` (the service falls back to the + // token's client id as the resource ARN). AWS SSO (Builder ID / IDC) and social tokens + // must NOT send this header, so it is gated on the persisted authMethod. + if (isExternalIdpAuthMethod(credentials.providerSpecificData?.authMethod)) { + headers[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE; + } } return headers; diff --git a/open-sse/services/kiroExternalIdp.ts b/open-sse/services/kiroExternalIdp.ts new file mode 100644 index 0000000000..3e27c8120d --- /dev/null +++ b/open-sse/services/kiroExternalIdp.ts @@ -0,0 +1,175 @@ +/** + * kiroExternalIdp.ts — shared helpers for Kiro / Amazon Q **External IdP** + * (enterprise "Your organization" SSO) accounts. + * + * Unlike AWS Builder ID / IAM Identity Center (which mint AWS SSO-OIDC tokens + * refreshed at `oidc.{region}.amazonaws.com` and whose refresh token starts with + * `aorAAAAAG`) or the Google/GitHub social flow (refreshed at the Kiro auth + * service), an **External IdP** login federates through the organization's own + * identity provider (most commonly Microsoft Entra ID). Its Kiro token file + * (`~/.aws/sso/cache/kiro-auth-token.json`) looks like: + * + * { + * "accessToken": "", + * "refreshToken": "", + * "authMethod": "external_idp", + * "provider": "ExternalIdp", + * "clientId": "", + * "tokenEndpoint":"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", + * "issuerUrl": "https://login.microsoftonline.com/{tenant}/v2.0", + * "scopes": "api://{clientId}/codewhisperer:conversations … offline_access" + * } + * + * Two consequences this module encodes (both verified against a live org token): + * 1. The token is refreshed with a **standard public-client OAuth2 + * `refresh_token` grant against `tokenEndpoint`** (form-encoded + * client_id + refresh_token + scope, NO client_secret) — see + * {@link buildExternalIdpRefreshParams}. + * 2. At runtime the access token is sent to CodeWhisperer as a normal bearer + * but MUST carry the header `TokenType: EXTERNAL_IDP` so the service binds + * it to the Amazon Q Developer profile (without it every call returns + * `ValidationException: Invalid ARN `). The profileArn itself is + * NOT discoverable via `ListAvailableProfiles` (it returns an empty list + * for these tokens); it is read from the Kiro IDE `profile.json` at import. + */ + +/** authMethod marker persisted on External IdP connections. */ +export const KIRO_EXTERNAL_IDP_AUTH_METHOD = "external_idp"; + +/** Header CodeWhisperer requires to bind an External IdP bearer to its profile. */ +export const KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER = "TokenType"; +export const KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE = "EXTERNAL_IDP"; + +/** + * Allowlist of enterprise IdP token-endpoint host suffixes. The refresh token is + * POSTed to this endpoint, so we constrain it to well-known identity providers + * (SSRF guard — the value ultimately originates from an on-disk token file). + * Microsoft Entra is by far the most common Kiro org IdP; the others cover the + * major enterprise SSO vendors an org might federate Kiro through. + */ +const ALLOWED_IDP_HOST_SUFFIXES: readonly string[] = [ + "login.microsoftonline.com", + "login.microsoftonline.us", + "login.partner.microsoftonline.cn", + "login.microsoft.com", + "login.windows.net", + "sts.windows.net", + ".okta.com", + ".oktapreview.com", + ".okta-emea.com", + ".auth0.com", + ".onelogin.com", + ".pingidentity.com", + ".pingone.com", + "accounts.google.com", + "oauth2.googleapis.com", + ".amazoncognito.com", +]; + +function normalizeString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +/** True when a connection's providerSpecificData marks it as an External IdP login. */ +export function isExternalIdpAuthMethod(authMethod: unknown): boolean { + return normalizeString(authMethod).toLowerCase() === KIRO_EXTERNAL_IDP_AUTH_METHOD; +} + +/** + * Validate the IdP token endpoint before it is used as a fetch target. Requires + * https and a host on {@link ALLOWED_IDP_HOST_SUFFIXES}. Returns the normalized + * URL string; throws on anything unexpected. + */ +export function validateExternalIdpTokenEndpoint(rawEndpoint: unknown): string { + const tokenEndpoint = normalizeString(rawEndpoint); + if (!tokenEndpoint) throw new Error("tokenEndpoint is required for external_idp"); + let parsed: URL; + try { + parsed = new URL(tokenEndpoint); + } catch { + throw new Error("tokenEndpoint must be a valid URL"); + } + if (parsed.protocol !== "https:") { + throw new Error("tokenEndpoint must use https"); + } + const host = parsed.hostname.toLowerCase(); + const allowed = ALLOWED_IDP_HOST_SUFFIXES.some((suffix) => + suffix.startsWith(".") ? host.endsWith(suffix) : host === suffix + ); + if (!allowed) { + throw new Error(`tokenEndpoint host is not an allowed identity provider: ${host}`); + } + return parsed.toString(); +} + +/** Collapse an array-or-space-delimited scope value into a single space-delimited string. */ +export function normalizeScope(scopes: unknown): string { + if (Array.isArray(scopes)) { + return scopes.map(normalizeString).filter(Boolean).join(" "); + } + return normalizeString(scopes); +} + +/** Best-effort base64url JWT payload decode (no signature verification). */ +export function decodeJwtPayload(jwt: unknown): Record | null { + try { + if (typeof jwt !== "string") return null; + const parts = jwt.split("."); + if (parts.length !== 3) return null; + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padding = (4 - (base64.length % 4)) % 4; + const json = Buffer.from(`${base64}${"=".repeat(padding)}`, "base64").toString("utf8"); + return JSON.parse(json) as Record; + } catch { + return null; + } +} + +/** + * Extract the login identity (email) from an External IdP access token. Org IdP + * tokens carry it as `preferred_username`/`upn`/`email` rather than the AWS + * `email` claim — otherwise the connection surfaces as the opaque "ExternalIdp". + */ +export function emailFromExternalIdpToken(accessToken: unknown): string | null { + const claims = decodeJwtPayload(accessToken); + if (!claims) return null; + const pick = (k: string): string | undefined => + typeof claims[k] === "string" ? (claims[k] as string) : undefined; + return pick("email") || pick("preferred_username") || pick("upn") || null; +} + +export interface ExternalIdpRefreshRequest { + tokenEndpoint: string; + body: URLSearchParams; +} + +/** + * Build the public-client `refresh_token` grant for an External IdP token. The + * IdP application is a PUBLIC client (no secret), so the body is exactly + * `grant_type=refresh_token&client_id&refresh_token&scope`. Throws when any + * required field is missing/invalid so callers can fail closed. + */ +export function buildExternalIdpRefreshParams( + refreshToken: string, + providerSpecificData: Record | null | undefined +): ExternalIdpRefreshRequest { + const psd = providerSpecificData || {}; + const clientId = normalizeString(psd.clientId ?? (psd as Record).client_id); + const tokenEndpoint = validateExternalIdpTokenEndpoint( + psd.tokenEndpoint ?? (psd as Record).token_endpoint + ); + const scope = normalizeScope(psd.scope ?? psd.scopes); + + if (!refreshToken) throw new Error("refresh token is required for external_idp refresh"); + if (!clientId) throw new Error("clientId is required for external_idp refresh"); + if (!scope) throw new Error("scope is required for external_idp refresh"); + + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: refreshToken, + scope, + }); + + return { tokenEndpoint, body }; +} diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 4d9841342d..b479daf278 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -5,6 +5,10 @@ import { getGitHubCopilotRefreshHeaders } from "../config/providerHeaderProfiles import { pbkdf2Sync } from "node:crypto"; import { runWithProxyContext } from "../utils/proxyFetch.ts"; import { serializeRefresh, wasRefreshTokenRotated } from "./refreshSerializer.ts"; +import { + buildExternalIdpRefreshParams, + isExternalIdpAuthMethod, +} from "./kiroExternalIdp.ts"; import { WINDSURF_CONFIG } from "@/lib/oauth/constants/oauth"; import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oauth/gitlab"; @@ -1209,6 +1213,69 @@ export async function refreshKiroToken( const clientSecret = providerSpecificData?.clientSecret; const region = providerSpecificData?.region; + // Enterprise / Microsoft Entra "Your organization" (external_idp) logins refresh with a + // standard PUBLIC-client OAuth2 refresh_token grant against the org IdP's own tokenEndpoint + // (form-encoded client_id + refresh_token + scope, no client_secret) — NOT the AWS SSO OIDC + // or Kiro social endpoints. The rotated refresh_token is persisted by the caller. + if (isExternalIdpAuthMethod(authMethod)) { + let refreshRequest; + try { + refreshRequest = buildExternalIdpRefreshParams(refreshToken, providerSpecificData); + } catch (cfgErr) { + log?.error?.( + "TOKEN_REFRESH", + `Invalid Kiro external_idp refresh config: ${cfgErr instanceof Error ? cfgErr.message : String(cfgErr)}` + ); + return null; + } + + const response = await runWithProxyContext(proxyConfig, () => + fetch(refreshRequest.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: refreshRequest.body, + }) + ); + + if (!response.ok) { + const errorText = await response.text(); + let oauthErr: string | undefined; + try { + oauthErr = JSON.parse(errorText)?.error; + } catch { + /* not JSON */ + } + if (oauthErr === "invalid_grant" || oauthErr === "invalid_client") { + log?.error?.( + "TOKEN_REFRESH", + "Kiro external_idp refresh token expired/invalid. Re-authentication required.", + { oauthErr } + ); + return { error: "unrecoverable_refresh_error", code: oauthErr }; + } + log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro external_idp token", { + status: response.status, + error: errorText.slice(0, 200), + }); + return null; + } + + const tokens = await response.json(); + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro external_idp token", { + hasNewAccessToken: !!tokens.access_token, + hasNewRefreshToken: !!tokens.refresh_token, + expiresIn: tokens.expires_in, + }); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + expiresIn: tokens.expires_in || 3600, + }; + } + // AWS SSO OIDC (Builder ID or IDC) // If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified). // Exception: imported social tokens (authMethod === "imported") carry a freshly-registered diff --git a/open-sse/services/usage/kiro.ts b/open-sse/services/usage/kiro.ts index 9b85579103..4efe496de1 100644 --- a/open-sse/services/usage/kiro.ts +++ b/open-sse/services/usage/kiro.ts @@ -13,6 +13,11 @@ import { toRecord, toNumber } from "./scalars.ts"; import { type UsageQuota, parseResetTime } from "./quota.ts"; +import { + isExternalIdpAuthMethod, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE, +} from "../kiroExternalIdp.ts"; type JsonRecord = Record; @@ -188,14 +193,22 @@ export async function getKiroUsage(accessToken?: string, providerSpecificData?: resourceType: "AGENTIC_REQUEST", }; + // Enterprise / Microsoft Entra (external_idp) org accounts require the + // `TokenType: EXTERNAL_IDP` header for CodeWhisperer to bind the bearer to the + // profile; without it GetUsageLimits returns `ValidationException: Invalid ARN`. + const usageHeaders: Record = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits", + Accept: "application/json", + }; + if (isExternalIdpAuthMethod(providerSpecificData?.authMethod)) { + usageHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE; + } + const response = await fetch(usageBaseUrl, { method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/x-amz-json-1.0", - "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits", - Accept: "application/json", - }, + headers: usageHeaders, body: JSON.stringify(payload), }); diff --git a/src/app/api/oauth/kiro/auto-import/route.ts b/src/app/api/oauth/kiro/auto-import/route.ts index 1387f06302..3c3937cc59 100755 --- a/src/app/api/oauth/kiro/auto-import/route.ts +++ b/src/app/api/oauth/kiro/auto-import/route.ts @@ -13,6 +13,11 @@ import { syncToCloud } from "@/lib/cloudSync"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { KiroService } from "@/lib/oauth/services/kiro"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; +import { + emailFromExternalIdpToken, + isExternalIdpAuthMethod, + normalizeScope, +} from "@omniroute/open-sse/services/kiroExternalIdp.ts"; /** * GET /api/oauth/kiro/auto-import @@ -202,16 +207,69 @@ async function tryKiroCliSqlite(): Promise<{ // ── ~/.aws/sso/cache fallback ───────────────────────────────────────────────── +/** + * Read the Amazon Q Developer profileArn the Kiro IDE persists in its + * `profile.json`. This is the authoritative source for the profileArn of AWS + * IAM Identity Center AND External IdP (organization) logins, since neither can + * enumerate it via ListAvailableProfiles (org tokens get an empty list). + * + * The ARN's region segment is preserved verbatim (#2314). #2059 originally + * forced every ARN's region to us-east-1, which 403s the runtime gateway for + * IDC accounts that live in a non-us-east-1 region. The OAuth device-code + * path (src/lib/oauth/providers/kiro.ts) already discovers the correct + * region-matched ARN, so this fallback now mirrors that behavior instead of + * rewriting it. + */ +async function readKiroIdeProfileArn(): Promise { + const { readFile } = await import("fs/promises"); + const kiroProfilePaths = [ + join( + process.env.APPDATA || join(homedir(), "AppData", "Roaming"), + "Kiro", + "User", + "globalStorage", + "kiro.kiroagent", + "profile.json" + ), + join(homedir(), ".config", "Kiro", "User", "globalStorage", "kiro.kiroagent", "profile.json"), + join( + homedir(), + "Library", + "Application Support", + "Kiro", + "User", + "globalStorage", + "kiro.kiroagent", + "profile.json" + ), + ]; + for (const profilePath of kiroProfilePaths) { + try { + const profileContent = await readFile(profilePath, "utf-8"); + const profileData = JSON.parse(profileContent); + if (profileData.arn) { + return profileData.arn; + } + } catch { + continue; + } + } + return null; +} + async function tryAwsSsoCache(targetProvider: string): Promise<{ found: boolean; triedPath?: string; refreshToken?: string; + accessToken?: string | null; source?: string; clientId?: string | null; clientSecret?: string | null; region?: string | null; authMethod?: string | null; profileArn?: string | null; + tokenEndpoint?: string | null; + scopes?: string | string[] | null; }> { const { readFile, readdir } = await import("fs/promises"); const cachePath = join(homedir(), ".aws/sso/cache"); @@ -235,6 +293,34 @@ async function tryAwsSsoCache(targetProvider: string): Promise<{ try { const content = await readFile(join(cachePath, file), "utf-8"); const data = JSON.parse(content); + + // Enterprise / Microsoft Entra "Your organization" (external_idp) tokens are NOT AWS SSO + // tokens — their refresh token does not start with `aorAAAAAG`. Detect them by authMethod/ + // provider and take the dedicated external_idp branch (org IdP tokenEndpoint refresh + + // profileArn read from the Kiro IDE profile.json). + const isExternalIdp = + !!data.refreshToken && + (isExternalIdpAuthMethod(data.authMethod) || + String(data.provider || "").toLowerCase() === "externalidp"); + + if (isExternalIdp) { + const region: string | null = data.region || null; + const profileArn = await readKiroIdeProfileArn(); + return { + found: true, + source: file, + refreshToken: data.refreshToken, + accessToken: data.accessToken || null, + clientId: data.clientId || null, + clientSecret: null, + region, + authMethod: "external_idp", + profileArn, + tokenEndpoint: data.tokenEndpoint || null, + scopes: data.scopes || null, + }; + } + if (data.refreshToken?.startsWith("aorAAAAAG")) { const region: string | null = data.region || null; const authMethod: string | null = data.authMethod || null; @@ -257,46 +343,9 @@ async function tryAwsSsoCache(targetProvider: string): Promise<{ } } - // Read profileArn from Kiro IDE's profile.json. - // Kiro IDC (Identity Center) accounts can live in regions other than - // us-east-1. #2059 forced every ARN's region segment to us-east-1, - // which 403s the runtime gateway for non-us-east-1 IDC accounts. The - // OAuth device-code path (src/lib/oauth/providers/kiro.ts) already - // discovers the correct region-matched ARN; mirror that here by - // preserving the profile's ARN region verbatim instead of rewriting - // it. - let profileArn: string | null = null; - const kiroProfilePaths = [ - join( - process.env.APPDATA || join(homedir(), "AppData", "Roaming"), - "Kiro", - "User", - "globalStorage", - "kiro.kiroagent", - "profile.json" - ), - join( - homedir(), - ".config", - "Kiro", - "User", - "globalStorage", - "kiro.kiroagent", - "profile.json" - ), - ]; - for (const profilePath of kiroProfilePaths) { - try { - const profileContent = await readFile(profilePath, "utf-8"); - const profileData = JSON.parse(profileContent); - if (profileData.arn) { - profileArn = profileData.arn; - break; - } - } catch { - continue; - } - } + // Read profileArn from Kiro IDE's profile.json. The region is preserved + // verbatim by readKiroIdeProfileArn() (#2314) — see its docstring for why. + const profileArn: string | null = await readKiroIdeProfileArn(); return { found: true, @@ -375,6 +424,9 @@ export function findKiroConnectionByProfileArn( type SaveAndRespondResult = Awaited> & { // Fields added by tryAwsSsoCache for IDC tokens (#2059) authMethod?: string | null; + // Fields added by tryAwsSsoCache for External IdP (organization) tokens + tokenEndpoint?: string | null; + scopes?: string | string[] | null; }; async function saveAndRespond( @@ -386,6 +438,81 @@ async function saveAndRespond( const kiroService = new KiroService(); const proxy = await resolveProxyForProvider(targetProvider); + // Enterprise / Microsoft Entra "Your organization" (external_idp) tokens: refresh via the + // org IdP tokenEndpoint (public-client OAuth2), persist the Kiro IDE profileArn, and mark + // the connection so the runtime executor sends `TokenType: EXTERNAL_IDP` and the quota + // fetch works. These tokens can't refresh via AWS OIDC / Kiro social and have no client + // secret, so they get their own path. + if (isExternalIdpAuthMethod(result.authMethod)) { + const region = result.region || "us-east-1"; + const scope = normalizeScope(result.scopes); + const externalIdpPsd = { + authMethod: "external_idp", + clientId: result.clientId || undefined, + tokenEndpoint: result.tokenEndpoint || undefined, + scope, + region, + }; + const refreshed = await runWithProxyContext(proxy, () => + kiroService.refreshToken(result.refreshToken!, externalIdpPsd) + ); + const email = + emailFromExternalIdpToken(refreshed.accessToken) || + kiroService.extractEmailFromJWT(refreshed.accessToken); + const profileArn = result.profileArn || null; + const connectionName = deriveKiroConnectionName({ + email, + profileArn: profileArn || undefined, + region, + targetProvider, + }); + const providerSpecificData: Record = { + authMethod: "external_idp", + provider: "ExternalIdp", + clientId: result.clientId || null, + tokenEndpoint: result.tokenEndpoint || null, + scope, + region, + }; + if (profileArn) providerSpecificData.profileArn = profileArn; + + const existingConnections = await getProviderConnections({ provider: targetProvider }); + const existingByArn = findKiroConnectionByProfileArn( + existingConnections, + profileArn || undefined + ); + const record = { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken || result.refreshToken!, + expiresAt: new Date(Date.now() + (refreshed.expiresIn || 3600) * 1000).toISOString(), + email: email || null, + name: connectionName, + providerSpecificData, + testStatus: "active", + }; + if (existingByArn && typeof existingByArn.id === "string") { + await updateProviderConnection(existingByArn.id, record); + } else { + await createProviderConnection({ + provider: targetProvider, + authType: "oauth", + ...record, + } as any); + } + if (isCloudEnabled()) { + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId).catch(() => {}); + } + return NextResponse.json({ + found: true, + source: result.source, + email: email || null, + profileArn: profileArn || null, + region, + message: "Kiro credentials imported successfully.", + }); + } + // If we have a refresh token but no valid access token, refresh now let accessToken = result.accessToken; let refreshToken = result.refreshToken!; diff --git a/src/app/api/oauth/kiro/import/route.ts b/src/app/api/oauth/kiro/import/route.ts index 7774908866..4e58bfcbd3 100755 --- a/src/app/api/oauth/kiro/import/route.ts +++ b/src/app/api/oauth/kiro/import/route.ts @@ -8,6 +8,11 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { + emailFromExternalIdpToken, + isExternalIdpAuthMethod, + normalizeScope, +} from "@omniroute/open-sse/services/kiroExternalIdp.ts"; /** * Build the user-facing error message for a failed Kiro/Amazon-Q token import. @@ -63,12 +68,58 @@ export async function POST(request: Request) { } const { refreshToken, region, clientId, clientSecret, authMethod, profileArn } = validation.data; + const { tokenEndpoint, scopes } = validation.data; const kiroService = new KiroService(); // Resolve proxy for this provider (provider-level → global → direct) const proxy = await resolveProxyForProvider(targetProvider); + // Enterprise / Microsoft Entra "Your organization" (external_idp) import. These tokens are + // NOT AWS SSO tokens (their refresh token does not start with `aorAAAAAG`), so the Builder + // ID / IDC path (validateImportToken) rejects them. Refresh via the org IdP's tokenEndpoint, + // persist the org profileArn (read from the Kiro IDE profile.json by the caller), and mark + // the connection so the runtime executor sends `TokenType: EXTERNAL_IDP`. + if (isExternalIdpAuthMethod(authMethod)) { + const scope = normalizeScope(scopes); + const externalIdpPsd = { + authMethod: "external_idp", + clientId, + tokenEndpoint, + scope, + region: region || "us-east-1", + }; + const refreshed = await runWithProxyContext(proxy, () => + kiroService.refreshToken(refreshToken.trim(), externalIdpPsd) + ); + const email = + emailFromExternalIdpToken(refreshed.accessToken) || + kiroService.extractEmailFromJWT(refreshed.accessToken); + const connection: any = await createProviderConnection({ + provider: targetProvider, + authType: "oauth", + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken || refreshToken.trim(), + expiresAt: new Date(Date.now() + (refreshed.expiresIn || 3600) * 1000).toISOString(), + email: email || null, + providerSpecificData: { + profileArn: profileArn || null, + authMethod: "external_idp", + provider: "ExternalIdp", + clientId, + tokenEndpoint, + scope, + region: region || "us-east-1", + }, + testStatus: "active", + } as any); + await syncToCloudIfEnabled(); + return NextResponse.json({ + success: true, + connection: { id: connection.id, provider: connection.provider, email: connection.email }, + }); + } + // For IDC tokens the client already has OIDC client credentials extracted from the // SSO cache registration file by auto-import (#2059). Refresh directly via the // regional OIDC endpoint without calling registerClient() again. For social / diff --git a/src/lib/oauth/services/kiro.ts b/src/lib/oauth/services/kiro.ts index b1357533f3..86601b3f2c 100644 --- a/src/lib/oauth/services/kiro.ts +++ b/src/lib/oauth/services/kiro.ts @@ -1,4 +1,8 @@ import { KIRO_CONFIG, assertValidAwsRegion } from "../constants/oauth"; +import { + buildExternalIdpRefreshParams, + isExternalIdpAuthMethod, +} from "@omniroute/open-sse/services/kiroExternalIdp.ts"; /** * Kiro OAuth Service @@ -187,6 +191,32 @@ export class KiroService { async refreshToken(refreshToken: string, providerSpecificData: any = {}) { const { authMethod, clientId, clientSecret, region } = providerSpecificData; + // Enterprise / Microsoft Entra "Your organization" (external_idp) login: refresh with a + // standard public-client OAuth2 refresh_token grant against the org IdP's tokenEndpoint + // (form-encoded client_id + refresh_token + scope, no client_secret). The AWS SSO OIDC and + // Kiro social endpoints cannot refresh these tokens. + if (isExternalIdpAuthMethod(authMethod)) { + const refreshRequest = buildExternalIdpRefreshParams(refreshToken, providerSpecificData); + const response = await fetch(refreshRequest.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: refreshRequest.body, + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token refresh failed: ${error}`); + } + const data = await response.json(); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || refreshToken, + expiresIn: data.expires_in || 3600, + }; + } + // AWS SSO OIDC refresh (Builder ID or IDC). // Imported social tokens (authMethod === "imported") have a registered clientId/clientSecret // but a Kiro-social refresh token the OIDC client can't refresh — use the social path (#2467). diff --git a/src/shared/validation/schemas/auth.ts b/src/shared/validation/schemas/auth.ts index 2e7e6bb1ac..1e251aa596 100644 --- a/src/shared/validation/schemas/auth.ts +++ b/src/shared/validation/schemas/auth.ts @@ -193,6 +193,11 @@ export const kiroImportSchema = z.object({ clientSecret: z.string().optional(), authMethod: z.string().optional(), profileArn: z.string().optional(), + // External IdP ("Your organization" / Microsoft Entra) token fields — present + // when authMethod === "external_idp". The token is refreshed via a public-client + // OAuth2 grant against `tokenEndpoint` using `clientId` + `scopes` (no secret). + tokenEndpoint: z.string().optional(), + scopes: z.union([z.string(), z.array(z.string())]).optional(), }); export const zedImportSchema = z.object({ diff --git a/tests/unit/kiro-external-idp.test.ts b/tests/unit/kiro-external-idp.test.ts new file mode 100644 index 0000000000..a8f5273b6e --- /dev/null +++ b/tests/unit/kiro-external-idp.test.ts @@ -0,0 +1,138 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Coverage for enterprise / Microsoft Entra "Your organization" (external_idp) Kiro accounts: +// • public-client refresh_token grant against the org IdP tokenEndpoint (no client secret), +// • the runtime `TokenType: EXTERNAL_IDP` header the CodeWhisperer service requires to bind +// the bearer to the Amazon Q Developer profile (without it every call is +// `ValidationException: Invalid ARN `), +// • tokenEndpoint SSRF allowlist, scope normalization, and JWT identity extraction. + +import { + buildExternalIdpRefreshParams, + validateExternalIdpTokenEndpoint, + normalizeScope, + isExternalIdpAuthMethod, + emailFromExternalIdpToken, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER, + KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE, +} from "../../open-sse/services/kiroExternalIdp.ts"; +import { KiroExecutor } from "../../open-sse/executors/kiro.ts"; + +const MS_ENDPOINT = "https://login.microsoftonline.com/9d769d6d-e03a-442a-8ab1-a7da2037a5d4/oauth2/v2.0/token"; + +function makeJwt(payload: Record): string { + const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url"); + return `${b64({ alg: "none", typ: "JWT" })}.${b64(payload)}.sig`; +} + +test("validateExternalIdpTokenEndpoint accepts Microsoft/Okta https, rejects others", () => { + assert.equal(validateExternalIdpTokenEndpoint(MS_ENDPOINT), MS_ENDPOINT); + assert.ok(validateExternalIdpTokenEndpoint("https://dev-123.okta.com/oauth2/v1/token")); + assert.throws(() => validateExternalIdpTokenEndpoint("http://login.microsoftonline.com/x/token")); + assert.throws(() => validateExternalIdpTokenEndpoint("https://evil.example.com/token")); + assert.throws(() => validateExternalIdpTokenEndpoint("")); +}); + +test("normalizeScope handles array and space-delimited string", () => { + assert.equal(normalizeScope(["a", "b", "offline_access"]), "a b offline_access"); + assert.equal(normalizeScope("a b offline_access"), "a b offline_access"); + assert.equal(normalizeScope([" x ", "", "y"]), "x y"); + assert.equal(normalizeScope(undefined), ""); +}); + +test("isExternalIdpAuthMethod recognizes external_idp (case-insensitive)", () => { + assert.equal(isExternalIdpAuthMethod("external_idp"), true); + assert.equal(isExternalIdpAuthMethod("EXTERNAL_IDP"), true); + assert.equal(isExternalIdpAuthMethod("idc"), false); + assert.equal(isExternalIdpAuthMethod(undefined), false); +}); + +test("emailFromExternalIdpToken reads preferred_username / upn / email", () => { + assert.equal( + emailFromExternalIdpToken(makeJwt({ preferred_username: "finbar.heslin@mrdevvn.cyou" })), + "finbar.heslin@mrdevvn.cyou" + ); + assert.equal(emailFromExternalIdpToken(makeJwt({ upn: "a@b.com" })), "a@b.com"); + assert.equal(emailFromExternalIdpToken(makeJwt({ email: "c@d.com" })), "c@d.com"); + assert.equal(emailFromExternalIdpToken("not-a-jwt"), null); +}); + +test("buildExternalIdpRefreshParams builds a public-client form body", () => { + const req = buildExternalIdpRefreshParams("RT-123", { + clientId: "app-guid", + tokenEndpoint: MS_ENDPOINT, + scopes: ["api://app-guid/codewhisperer:conversations", "offline_access"], + }); + assert.equal(req.tokenEndpoint, MS_ENDPOINT); + assert.equal(req.body.get("grant_type"), "refresh_token"); + assert.equal(req.body.get("client_id"), "app-guid"); + assert.equal(req.body.get("refresh_token"), "RT-123"); + assert.equal( + req.body.get("scope"), + "api://app-guid/codewhisperer:conversations offline_access" + ); + // Public client: never a secret. + assert.equal(req.body.get("client_secret"), null); +}); + +test("buildExternalIdpRefreshParams fails closed on missing fields", () => { + assert.throws(() => buildExternalIdpRefreshParams("", { clientId: "x", tokenEndpoint: MS_ENDPOINT, scopes: "s" })); + assert.throws(() => buildExternalIdpRefreshParams("rt", { tokenEndpoint: MS_ENDPOINT, scopes: "s" })); + assert.throws(() => buildExternalIdpRefreshParams("rt", { clientId: "x", scopes: "s" })); + assert.throws(() => buildExternalIdpRefreshParams("rt", { clientId: "x", tokenEndpoint: MS_ENDPOINT })); +}); + +test("KiroService.refreshToken uses the org IdP tokenEndpoint for external_idp", async () => { + const { KiroService } = await import("../../src/lib/oauth/services/kiro.ts"); + const ORIGINAL_FETCH = globalThis.fetch; + const calls: { url: string; body: string; contentType: string | null }[] = []; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const u = String(url); + const body = init?.body instanceof URLSearchParams ? init.body.toString() : String(init?.body ?? ""); + calls.push({ url: u, body, contentType: (init?.headers as Record)?.["Content-Type"] ?? null }); + return new Response( + JSON.stringify({ access_token: "new-at", refresh_token: "rotated-rt", expires_in: 4481 }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + try { + const svc = new KiroService(); + const res = await svc.refreshToken("RT-old", { + authMethod: "external_idp", + clientId: "app-guid", + tokenEndpoint: MS_ENDPOINT, + scopes: "codewhisperer:conversations offline_access", + }); + assert.equal(res.accessToken, "new-at"); + assert.equal(res.refreshToken, "rotated-rt"); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, MS_ENDPOINT); + assert.ok(calls[0].url.includes("login.microsoftonline.com")); + // Must NOT hit AWS OIDC or the Kiro social endpoint. + assert.ok(!calls[0].url.includes("amazonaws.com")); + assert.ok(!calls[0].url.includes("desktop.kiro.dev")); + assert.ok(calls[0].body.includes("grant_type=refresh_token")); + assert.ok(calls[0].body.includes("client_id=app-guid")); + } finally { + globalThis.fetch = ORIGINAL_FETCH; + } +}); + +test("KiroExecutor.buildHeaders sends TokenType: EXTERNAL_IDP only for external_idp", () => { + const exec = new KiroExecutor(); + const idpHeaders = exec.buildHeaders({ + accessToken: "at", + providerSpecificData: { authMethod: "external_idp" }, + } as never); + assert.equal(idpHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER], KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE); + + const idcHeaders = exec.buildHeaders({ + accessToken: "at", + providerSpecificData: { authMethod: "idc" }, + } as never); + assert.equal(idcHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER], undefined); + + const builderIdHeaders = exec.buildHeaders({ accessToken: "at" } as never); + assert.equal(builderIdHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER], undefined); +}); diff --git a/tests/unit/kiro-windows-auto-import-3363.test.ts b/tests/unit/kiro-windows-auto-import-3363.test.ts index b3bbf12cfd..184a25a30a 100644 --- a/tests/unit/kiro-windows-auto-import-3363.test.ts +++ b/tests/unit/kiro-windows-auto-import-3363.test.ts @@ -24,6 +24,7 @@ const core = await import("../../src/lib/db/core.ts"); const { GET } = await import("../../src/app/api/oauth/kiro/auto-import/route.ts"); const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_USERPROFILE = process.env.USERPROFILE; const ORIGINAL_APPDATA = process.env.APPDATA; const ORIGINAL_FETCH = globalThis.fetch; @@ -37,12 +38,21 @@ test.beforeEach(() => { fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); // Override HOME so homedir() returns a temp dir where no kiro-cli DB exists. process.env.HOME = tmpHome; + // On Windows os.homedir() reads USERPROFILE (not HOME), so isolate it too — + // otherwise the probe reads the real ~/.aws/sso/cache and can find an actual + // (e.g. external_idp organization) Kiro login on the test host. + process.env.USERPROFILE = tmpHome; // Ensure APPDATA is unset by default; individual tests that need it set it. delete process.env.APPDATA; }); test.afterEach(() => { process.env.HOME = ORIGINAL_HOME; + if (ORIGINAL_USERPROFILE !== undefined) { + process.env.USERPROFILE = ORIGINAL_USERPROFILE; + } else { + delete process.env.USERPROFILE; + } if (ORIGINAL_APPDATA !== undefined) { process.env.APPDATA = ORIGINAL_APPDATA; } else { From 3a28b3b5e8e53ed74b4473dab6055d25081b3eb8 Mon Sep 17 00:00:00 2001 From: Thiago Reis Date: Thu, 9 Jul 2026 22:49:06 -0300 Subject: [PATCH 5/8] feat: add Kiro API key authentication (#6587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(oauth): add Kiro long-lived API key auth (#6587) New /api/oauth/kiro/api-key route + KiroService.validateApiKey let a Kiro account be linked with a long-lived AWS CodeWhisperer/Kiro API key instead of the interactive OAuth device flow, with live per-account model discovery (ListAvailableModels, 5-minute cache) layered over the existing static registry fallback. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(changelog): re-restore #6587 bullet after release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(merge): restore #6126 clinepass files reverted by release auto-resolve + baseline re-merge The release sync's auto-resolve reverted sibling PR #6126's clinepass work (registry, catalog, oauth constants, clineAuth.ts, token-refresh case, tests) and the file-size baseline — all outside this PR's scope. Restored to the release versions, re-applied only this PR's own baseline entries, restored the #6126 CHANGELOG bullet (re-inserting only this PR's own). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(quality): freeze public-creds FP — AWS region default in validateApiKey signature Same class as the existing minimax fn-param FPs: CRED_KEY_RE matches the apiKey: param annotation and captures the region default "us-east-1", which is not a credential. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(kiro): keep hard-failure reject semantics + kill public-creds fn-param FP at the source - getKiroUsage: exhausted non-auth attempts now REJECT with the last HTTP-status failure in the pre-#6587 format (usage-service-hardening relies on it); auth failures keep the soft social-auth message. - validateApiKey: region default moved out of the parameter list (the check-public-creds CRED_KEY_RE matches the apiKey: annotation and flags any literal in the signature); drops the brittle line-keyed allowlist entry. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: strangersp Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza --- CHANGELOG.md | 1 + config/quality/eslint-suppressions.json | 5 - config/quality/file-size-baseline.json | 15 +- config/quality/quality-baseline.json | 3 +- docs/guides/KIRO_SETUP.md | 64 ++- open-sse/executors/kiro.ts | 18 +- open-sse/services/kiroModels.ts | 187 ++++++++- open-sse/services/usage/kiro.ts | 256 ++++++++--- open-sse/translator/request/openai-to-kiro.ts | 32 +- .../dashboard/providers/providerPageUtils.ts | 11 +- src/app/api/oauth/kiro/api-key/helpers.ts | 24 ++ src/app/api/oauth/kiro/api-key/route.ts | 106 +++++ src/lib/oauth/services/kiro.ts | 74 ++++ src/lib/providers/validation.ts | 93 +++- src/lib/usage/providerLimits.ts | 4 +- src/server/authz/pipeline.ts | 32 ++ src/shared/components/KiroAuthModal.tsx | 109 +++++ src/shared/components/KiroOAuthWrapper.tsx | 5 +- .../constants/pricing/oauth-subscriptions.ts | 13 +- src/shared/validation/schemas/auth.ts | 5 + tests/unit/authz/pipeline.test.ts | 36 ++ tests/unit/executor-kiro.test.ts | 18 + tests/unit/kiro-api-key-route-helpers.test.ts | 38 ++ tests/unit/kiro-api-key-service.test.ts | 62 +++ tests/unit/kiro-available-models.test.ts | 30 +- tests/unit/kiro-iam-profilearn-usage.test.ts | 396 ++++++++++-------- tests/unit/provider-onboarding-wizard.test.ts | 3 + .../provider-validation-specialty.test.ts | 148 ++++++- tests/unit/providers-page-utils.test.ts | 4 + .../shared/components/KiroAuthModal.test.tsx | 56 +++ tests/unit/translator-openai-to-kiro.test.ts | 23 + 31 files changed, 1591 insertions(+), 280 deletions(-) create mode 100644 src/app/api/oauth/kiro/api-key/helpers.ts create mode 100644 src/app/api/oauth/kiro/api-key/route.ts create mode 100644 tests/unit/kiro-api-key-route-helpers.test.ts create mode 100644 tests/unit/kiro-api-key-service.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index deaea900cf..06bb015be9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **Skill Collector CLI detection**: new `GET /api/skills/collect/detect` + `POST /api/skills/collect/install` (and the `cli-skill-collector` agent skill) detect which coding CLIs (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.) are installed locally via `getCliRuntimeStatus()`, match them against GitHub agent-skill repos, and plan an install path per tool — replacing the standalone Skill Collector Python app. Both new routes and `GET/POST /api/github-skills` now require management auth (`requireManagementAuth()`) and are loopback-gated (`LOCAL_ONLY_API_PREFIXES` + `SPAWN_CAPABLE_PREFIXES`) since the detect route spawns a child process per candidate CLI tool (Hard Rules #15 + #17). The `omniroute_github_skills_install` MCP tool now reports the honest `action: "planned"` instead of `"installed"`, matching the REST route (#6294 — thanks @Moseyuh333) - **ClinePass dual-auth**: ClinePass now offers both sign-in methods on its dashboard page — OAuth (reusing the Cline WorkOS flow) as the primary "Connect" path, or a pasted BYOK API key via "Manual API key", instead of only the API-key-only provider shipped in #5942. The registry alias was aligned to `cp` (matching the `OAUTH_PROVIDERS` catalog alias) so `/` routing resolves correctly, the OAuth refresh dispatch now routes `clinepass` to the shared Cline refresh flow, and the duplicate API-key-only catalog entry was removed to keep ClinePass listed once. Regression guard: `tests/unit/clinepass-provider.test.ts`. (#6126 — thanks @hajilok) - **feat(oauth):** Kiro/Amazon Q auto-import now supports enterprise **External IdP** ("Your organization") logins via Microsoft Entra/Okta/Auth0/OneLogin/Ping/Google/Cognito — these org-issued tokens are not AWS SSO tokens (no `aorAAAAAG`-prefixed refresh token) and can't refresh through the AWS OIDC/Kiro-social path, so `tryAwsSsoCache()` now detects them (`authMethod`/`provider === "externalidp"`) and refreshes via the org IdP's own `tokenEndpoint` (public-client OAuth2 refresh grant, no client secret), persisting `TokenType: EXTERNAL_IDP` gating so the runtime executor sends the header the AWS CodeWhisperer API requires for these accounts; `tokenEndpoint` is SSRF-guarded against an HTTPS + known-IdP-host-suffix allowlist. (#6363 — thanks @artickc) +- **Kiro long-lived API key auth**: new `/api/oauth/kiro/api-key` route + `KiroService.validateApiKey` let a Kiro account be linked with a long-lived AWS CodeWhisperer/Kiro API key instead of the interactive OAuth device flow, with live per-account model discovery (`ListAvailableModels`, 5-minute cache) layered over the existing static registry fallback (#6587 — thanks @strangersp) ### 🐛 Bug Fixes diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 46aaffb606..9d3c854412 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1832,11 +1832,6 @@ "count": 1 } }, - "tests/unit/provider-validation-specialty.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - } - }, "tests/unit/providers-route-managed-catalog.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 4 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 0ec0532b4e..229016bdd7 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -187,7 +187,7 @@ "open-sse/services/tokenRefresh.ts": 2249, "open-sse/services/usage.ts": 3454, "open-sse/translator/request/openai-to-gemini.ts": 906, - "open-sse/translator/request/openai-to-kiro.ts": 890, + "open-sse/translator/request/openai-to-kiro.ts": 912, "open-sse/translator/response/openai-responses.ts": 1092, "open-sse/utils/cursorAgentProtobuf.ts": 1521, "open-sse/utils/stream.ts": 2792, @@ -248,7 +248,7 @@ "src/lib/resilience/settings.ts": 841, "src/lib/tailscaleTunnel.ts": 1202, "src/lib/usage/callLogs.ts": 997, - "src/lib/usage/providerLimits.ts": 998, + "src/lib/usage/providerLimits.ts": 1000, "src/lib/usage/usageHistory.ts": 988, "_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.", "src/shared/components/OAuthModal.tsx": 993, @@ -270,11 +270,12 @@ "open-sse/executors/kiro.ts": 944, "open-sse/translator/request/openai-to-claude.ts": 823, "tests/unit/account-fallback-service.test.ts": 1572, - "tests/unit/provider-validation-specialty.test.ts": 2843, + "tests/unit/provider-validation-specialty.test.ts": 2980, "open-sse/executors/huggingchat.ts": 813, "_rebaseline_2026_07_01_v3843_release_5609": "Rebaseline v3.8.43 (PR #5609 release reconciliation). DRIFT dos 109 commits do ciclo: 8 god-files existentes cresceram (ApiManagerPageClient 2983->3017, combos/page 4594->4608, AddApiKeyModal 868->869, providerPageHelpers 974->996, chat.ts 1635->1647, auth.ts 2401->2403, batchProcessor 828->915, combo.ts 3368->3387) + 2 novos acima do cap (huggingchat.ts 813, tests web-cookie-providers-new 827) + 4 test files cresceram. Modularizacao deferida (blast-radius mid-release); congelado no estado atual p/ o proximo ciclo ratchetar daqui.", "src/lib/providers/validation/webProvidersA.ts": 809, - "src/lib/tokenHealthCheck.ts": 830 + "src/lib/tokenHealthCheck.ts": 830, + "_rebaseline_2026_07_09_6587_kiro_api_key_auth": "PR #6587 (@strangersp) own growth for Kiro long-lived API-key auth, merged onto v3.8.47 tip: openai-to-kiro.ts 890->912 (+22, auth-header selection for API-key-vs-OAuth-token connections), providerLimits.ts 998->1000 (+2, API-key auth-type branch), translator-openai-to-kiro.test.ts 1234->1257 (+23), providers-page-utils.test.ts 1109->1107 (net -2 after merging with parallel release drift; connectionMatchesProviderCard api_key coverage added), provider-validation-specialty.test.ts 2856->2980 (+124 net after merge with parallel release drift; this PR also removed the file's `@typescript-eslint/no-explicit-any` eslint-suppression entry by fixing all `any` usages, adding typed replacements). Cohesive additive feature growth, well tested; not extractable without splitting the existing chokepoints mid-merge." }, "testCap": 800, "testFrozen": { @@ -311,8 +312,8 @@ "tests/unit/oauth-providers-config.test.ts": 845, "tests/unit/perplexity-web.test.ts": 999, "tests/unit/provider-models-route.test.ts": 1752, - "tests/unit/provider-validation-specialty.test.ts": 2856, - "tests/unit/providers-page-utils.test.ts": 1109, + "tests/unit/provider-validation-specialty.test.ts": 2980, + "tests/unit/providers-page-utils.test.ts": 1107, "tests/unit/reasoning-cache.test.ts": 980, "tests/unit/response-sanitizer.test.ts": 1063, "tests/unit/route-edge-coverage.test.ts": 1241, @@ -324,7 +325,7 @@ "tests/unit/translator-helper-branches.test.ts": 870, "tests/unit/translator-openai-responses-req.test.ts": 1172, "tests/unit/translator-openai-to-gemini.test.ts": 1541, - "tests/unit/translator-openai-to-kiro.test.ts": 1234, + "tests/unit/translator-openai-to-kiro.test.ts": 1257, "tests/unit/translator-resp-gemini-to-openai.test.ts": 1234, "tests/unit/usage-service-hardening.test.ts": 1503, "tests/unit/vscode-token-routes.test.ts": 1285, diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index 377b90bfab..c8d6cc4016 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -120,7 +120,8 @@ "_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits — provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines — 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle." }, "cognitiveComplexity": { - "value": 884, + "value": 885, + "_rebaseline_2026_07_09_6587_kiro_api_key_auth": "884->885 (+1). PR #6587 (@strangersp) own growth: open-sse/services/usage/kiro.ts gains ONE new over-threshold function — getKiroUsage grew from a single fetch to a 3-endpoint fallback chain (codewhisperer-get / codewhisperer-post / q-get) with per-attempt auth-header selection (tokentype: API_KEY vs Bearer-only), needed so usage/quota lookups work for the new long-lived-API-key auth path in addition to the existing OAuth path (measured: 0 violations on release tip -> 1 violation, complexity 33, at open-sse/services/usage/kiro.ts). Covered by tests/unit/kiro-iam-profilearn-usage.test.ts (tokentype header selection, friendly auth-expired/rejected-token messages). Cohesive multi-endpoint-fallback logic at an existing usage chokepoint; not extractable without splitting the fallback loop mid-merge. Structural shrink tracked in #3501.", "_rebaseline_2026_07_07_v3846_release_close": "877->882 (+5). v3.8.46 release close (generate-release Phase 0 pre-flight): drift herdado do merge burst do ciclo. Trust-but-verify: os fixes de base-red do captain (agentSkills path.resolve #6366, catalogo cache #6408, tipagem de teste, MitmProxyTab suppression) sao cognitive-net-zero — check:cognitive-complexity mede 882 identico com e sem os fixes (a catraca NAO roda no fast-path PR->release). Tighten via --update next cycle.", "_rebaseline_2026_07_03_v3844_ipfilter_release_green": "861->867 (+6). v3.8.44 cycle drift measured on release tip 32e4c906e during the #6131/#5975 release-green rebaseline. Inherited from the merge burst (Quality Ratchet does not run on PR->release fast-gates). route-edge-coverage +7 is my #5975 test comment; the rest is parallel-session drift. Tighten via --update next cycle.", "_rebaseline_2026_07_03_v3844_review_prs_fix_batch": "860->861 (+1). Inherited v3.8.44 cycle drift surfaced by the release-green pre-flight during the /review-prs fix-batch round; check:cognitive-complexity measures 861 on the release tip 72ee80649. Negligible +1 from the round's / parallel-session merge burst (cognitive-complexity does NOT run on PR->release fast-gates). Structural shrink tracked in #3501. Tighten via --update next cycle.", diff --git a/docs/guides/KIRO_SETUP.md b/docs/guides/KIRO_SETUP.md index 737ff8c2e7..1730076647 100644 --- a/docs/guides/KIRO_SETUP.md +++ b/docs/guides/KIRO_SETUP.md @@ -29,7 +29,8 @@ Kiro connection import. This gives each OmniRoute connection its own dedicated O client registration. Because each client registration is independent, refreshing or re-authenticating one account does not affect any other account's refresh token. -The isolation applies to all three import methods: +The isolation applies to the refresh-token import methods, and API-key auth avoids +OIDC refresh sessions entirely: | Import method | Isolation status | | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | @@ -37,6 +38,7 @@ The isolation applies to all three import methods: | **Import Token** (manual refresh token paste) | Isolated from v3.8.0 | | **Google / GitHub social login** | Isolated from v3.8.0 | | **Auto-Import** (kiro-cli SQLite) | Isolated from v3.8.0 (SQLite path was already isolated; SSO-cache fallback is now also isolated) | +| **API Key** (long-lived CodeWhisperer key) | No refresh session; the key is validated and stored as a bearer credential | --- @@ -66,10 +68,12 @@ receive their own client registration automatically. 1. Open **Dashboard → Providers → Add Provider → Kiro**. 2. Choose one of: - **Import Token** — paste a refresh token starting with `aorAAAAAG`. + - **API Key** — paste a long-lived Kiro / CodeWhisperer API key. - **Google / GitHub login** — complete the OAuth flow in the browser. - **Auto-Import** — click the button; OmniRoute reads credentials from the local kiro-cli database or `~/.aws/sso/cache`. -3. The connection is saved. OmniRoute automatically registers a dedicated OIDC client for it. +3. The connection is saved. Refresh-token flows automatically register a dedicated + OIDC client. API-key flows validate the key with AWS and do not store a refresh token. ### Step 2: Import the second account @@ -112,6 +116,52 @@ The `region` field defaults to `us-east-1` when omitted. --- +## API-Key Import Flow + +API-key auth is for long-lived Kiro / AWS CodeWhisperer bearer credentials. It does +not use OAuth refresh, so it avoids shared OIDC session invalidation. + +### Dashboard + +1. Open **Dashboard -> Providers -> Kiro**. +2. Choose **API Key**. +3. Paste the API key and optional AWS region (`us-east-1` by default). +4. OmniRoute validates the key and saves the connection. + +### API + +```bash +curl -X POST http://localhost:20128/api/oauth/kiro/api-key \ + -H "Content-Type: application/json" \ + -d '{"apiKey": "kiro_or_codewhisperer_key", "region": "us-east-1"}' +``` + +### Internal Contract + +The API route validates the key by calling `KiroService.validateApiKey()`, which +uses `ListAvailableProfiles` against the region-matched CodeWhisperer/Amazon Q +endpoint and resolves a `profileArn`. + +The saved connection uses: + +```json +{ + "authType": "apikey", + "providerSpecificData": { + "authMethod": "api_key", + "region": "us-east-1", + "profileArn": "arn:aws:codewhisperer:..." + } +} +``` + +At runtime, `KiroExecutor.buildHeaders()` sends the key as +`Authorization: Bearer ` and adds `tokentype: API_KEY`. Quota/profile calls +use the same marker so AWS treats the bearer as a long-lived API key rather than +an OIDC or social access token. + +--- + ## OIDC Client Expiry AWS SSO OIDC public clients typically expire after 90 days @@ -120,6 +170,9 @@ for observability. If a connection stops refreshing after ~90 days, re-import th connection to obtain a fresh OIDC client registration. Automatic re-registration on expiry is tracked as a future improvement. +API-key connections do not have OIDC client expiry because they do not refresh +through AWS SSO OIDC. + --- ## Troubleshooting @@ -137,4 +190,11 @@ expiry is tracked as a future improvement. region). If you are behind a corporate proxy, set a provider-level proxy in **Dashboard → Settings → Proxies**. +### API-key import fails + +- Confirm the key is a Kiro / CodeWhisperer API key, not a refresh token. +- Confirm the AWS region matches the key/account. `us-east-1` is the default. +- The key must be able to call `ListAvailableProfiles`; otherwise OmniRoute cannot + resolve the required `profileArn`. + For other issues, see the main [TROUBLESHOOTING.md](./TROUBLESHOOTING.md). diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index 9ac1a4f69b..d5623aacdd 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -201,8 +201,19 @@ export class KiroExecutor extends BaseExecutor { "anthropic-beta": "prompt-caching-2024-07-31", }; - if (credentials.accessToken) { - headers["Authorization"] = `Bearer ${credentials.accessToken}`; + const authMethod = + typeof credentials.providerSpecificData?.authMethod === "string" + ? credentials.providerSpecificData.authMethod + : undefined; + const isApiKey = authMethod === "api_key"; + const token = isApiKey + ? credentials.apiKey || credentials.accessToken + : credentials.accessToken; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + // Long-lived Kiro/CodeWhisperer API keys authenticate with `tokentype: API_KEY`. + if (isApiKey) headers["tokentype"] = "API_KEY"; // Enterprise / Microsoft Entra "Your organization" (external_idp) logins send an // org-IdP-issued access token. CodeWhisperer only binds it to the Amazon Q Developer @@ -210,7 +221,7 @@ export class KiroExecutor extends BaseExecutor { // returns `ValidationException: Invalid ARN ` (the service falls back to the // token's client id as the resource ARN). AWS SSO (Builder ID / IDC) and social tokens // must NOT send this header, so it is gated on the persisted authMethod. - if (isExternalIdpAuthMethod(credentials.providerSpecificData?.authMethod)) { + if (isExternalIdpAuthMethod(authMethod)) { headers[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE; } } @@ -795,6 +806,7 @@ export class KiroExecutor extends BaseExecutor { } async refreshCredentials(credentials: ProviderCredentials, log?: ExecutorLog | null) { + if (credentials.providerSpecificData?.authMethod === "api_key") return null; if (!credentials.refreshToken) return null; try { diff --git a/open-sse/services/kiroModels.ts b/open-sse/services/kiroModels.ts index 24bc13d810..89680655d4 100644 --- a/open-sse/services/kiroModels.ts +++ b/open-sse/services/kiroModels.ts @@ -23,8 +23,21 @@ * never breaks when the account is offline / unauthenticated / token-expired. */ +import { createHash } from "node:crypto"; + +import { v4 as uuidv4 } from "uuid"; + type RawRecord = Record; +const KIRO_RUNTIME_SDK_VERSION = "1.0.0"; +const KIRO_AGENT_OS = "windows"; +const KIRO_AGENT_OS_VERSION = "10.0.26200"; +const KIRO_NODE_VERSION = "22.21.1"; +const KIRO_IDE_VERSION = "0.10.32"; +const CACHE_TTL_MS = 5 * 60 * 1000; + +const catalogCache = new Map(); + function asRecord(value: unknown): RawRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as RawRecord) : {}; } @@ -39,6 +52,14 @@ export type KiroModel = { id: string; name: string; owned_by: string; + capabilities?: { + thinking: boolean; + agentic: boolean; + }; + contextLength?: number; + rateMultiplier?: number; + upstreamModelId?: string; + description?: string; }; export type KiroModelsResult = { @@ -75,6 +96,94 @@ export function parseKiroModels(data: unknown): KiroModel[] { return models; } +function stripSyntheticSuffixes(id: string): string { + let out = id; + if (out.endsWith("-agentic")) out = out.slice(0, -"-agentic".length); + if (out.endsWith("-thinking")) out = out.slice(0, -"-thinking".length); + return out; +} + +function formatDisplayName(modelName: unknown, modelId: string, rateMultiplier: unknown): string { + const base = toNonEmptyString(modelName) || modelId; + const rate = Number(rateMultiplier); + if (!Number.isFinite(rate) || Math.abs(rate - 1.0) < 1e-9 || rate <= 0) { + return `Kiro ${base}`; + } + return `Kiro ${base} (${rate.toFixed(1)}x credit)`; +} + +function buildVariants(upstream: string, displayName: string): KiroModel[] { + const safeUpstream = stripSyntheticSuffixes(upstream); + const display = displayName || `Kiro ${safeUpstream}`; + const isAuto = safeUpstream === "auto" || safeUpstream === "auto-kiro"; + const variants: KiroModel[] = [ + { + id: safeUpstream, + name: display, + owned_by: "kiro", + capabilities: { thinking: false, agentic: false }, + }, + { + id: `${safeUpstream}-thinking`, + name: `${display} (Thinking)`, + owned_by: "kiro", + capabilities: { thinking: true, agentic: false }, + }, + ]; + + if (!isAuto) { + variants.push({ + id: `${safeUpstream}-agentic`, + name: `${display} (Agentic)`, + owned_by: "kiro", + capabilities: { thinking: false, agentic: true }, + }); + variants.push({ + id: `${safeUpstream}-thinking-agentic`, + name: `${display} (Thinking + Agentic)`, + owned_by: "kiro", + capabilities: { thinking: true, agentic: true }, + }); + } + + return variants; +} + +function expandKiroModels(data: unknown): KiroModel[] { + const payload = asRecord(data); + const items = Array.isArray(payload.models) + ? (payload.models as unknown[]) + : Array.isArray(payload.availableModels) + ? (payload.availableModels as unknown[]) + : []; + const expanded: KiroModel[] = []; + const seen = new Set(); + + for (const value of items) { + const item = asRecord(value); + const upstreamId = toNonEmptyString(item.modelId) || toNonEmptyString(item.id); + if (!upstreamId) continue; + const display = formatDisplayName(item.modelName || item.name, upstreamId, item.rateMultiplier); + const tokenLimits = asRecord(item.tokenLimits); + const contextLength = Number(tokenLimits.maxInputTokens) || 200000; + const rateMultiplier = Number(item.rateMultiplier); + + for (const variant of buildVariants(upstreamId, display)) { + if (seen.has(variant.id)) continue; + seen.add(variant.id); + expanded.push({ + ...variant, + contextLength, + rateMultiplier: Number.isFinite(rateMultiplier) ? rateMultiplier : 1.0, + upstreamModelId: upstreamId, + description: toNonEmptyString(item.description) || "", + }); + } + } + + return expanded; +} + /** * Derive the AWS region for a Kiro connection. Mirrors getKiroUsage: prefer the * stored region, then the region embedded in the profileArn, else us-east-1. @@ -124,28 +233,69 @@ function toFallbackResult( .map((model) => { const id = toNonEmptyString(model.id); if (!id) return null; - return { id, name: toNonEmptyString(model.name) || id, owned_by: "kiro" }; + return { + id, + name: toNonEmptyString(model.name) || id, + owned_by: "kiro", + }; }) .filter((model): model is KiroModel => Boolean(model)); return { models, source: "fallback" }; } +function buildKiroFingerprintHeaders(providerSpecificData: unknown, accessToken: string) { + const psd = asRecord(providerSpecificData); + const seed = + toNonEmptyString(psd.clientId) || + toNonEmptyString(psd.profileArn) || + accessToken || + "kiro-anonymous"; + const machineId = createHash("sha256").update(String(seed)).digest("hex"); + const userAgent = + `aws-sdk-js/${KIRO_RUNTIME_SDK_VERSION} ua/2.1 ` + + `os/${KIRO_AGENT_OS}#${KIRO_AGENT_OS_VERSION} ` + + `lang/js md/nodejs#${KIRO_NODE_VERSION} ` + + `api/codewhispererruntime#${KIRO_RUNTIME_SDK_VERSION} m/N,E ` + + `KiroIDE-${KIRO_IDE_VERSION}-${machineId}`; + + return { + "User-Agent": userAgent, + "x-amz-user-agent": `aws-sdk-js/${KIRO_RUNTIME_SDK_VERSION} KiroIDE-${KIRO_IDE_VERSION}-${machineId}`, + "x-amzn-kiro-agent-mode": "vibe", + "x-amzn-codewhisperer-optout": "true", + "amz-sdk-request": "attempt=1; max=1", + "amz-sdk-invocation-id": uuidv4(), + Accept: "application/json", + }; +} + +function cacheKey(accessToken: string, providerSpecificData: unknown): string { + const psd = asRecord(providerSpecificData); + const seed = + toNonEmptyString(psd.profileArn) || + toNonEmptyString(psd.clientId) || + accessToken || + "anonymous"; + return createHash("sha256").update(`kiro:${seed}`).digest("hex"); +} + async function tryFetchModels( fetchImpl: typeof fetch, url: string, - accessToken: string + accessToken: string, + providerSpecificData: unknown ): Promise { try { const response = await fetchImpl(url, { method: "GET", headers: { + ...buildKiroFingerprintHeaders(providerSpecificData, accessToken), Authorization: `Bearer ${accessToken}`, - Accept: "application/json", }, }); if (!response.ok) return null; const data = await response.json(); - const models = parseKiroModels(data); + const models = expandKiroModels(data); return models.length > 0 ? models : null; } catch { return null; @@ -172,14 +322,28 @@ export async function fetchKiroAvailableModels( return toFallbackResult(fallbackModels); } + const key = cacheKey(token, providerSpecificData); + const cached = catalogCache.get(key); + if (cached && cached.expiresAt > Date.now()) { + return { models: cached.models, source: "api" }; + } + const region = resolveKiroRegion(providerSpecificData); const endpoints = buildKiroModelsEndpoints(region); const profileArn = toNonEmptyString(asRecord(providerSpecificData).profileArn); // Pass 1: origin-only (works for Builder ID / social / IdC). for (const base of endpoints) { - const models = await tryFetchModels(fetchImpl, `${base}?origin=AI_EDITOR`, token); - if (models) return { models, source: "api" }; + const models = await tryFetchModels( + fetchImpl, + `${base}?origin=AI_EDITOR`, + token, + providerSpecificData + ); + if (models) { + catalogCache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, models }); + return { models, source: "api" }; + } } // Pass 2: retry with profileArn (desktop accounts that require it) on the @@ -187,9 +351,16 @@ export async function fetchKiroAvailableModels( // profileArn can 403. if (profileArn) { const url = `${endpoints[0]}?origin=AI_EDITOR&profileArn=${encodeURIComponent(profileArn)}`; - const models = await tryFetchModels(fetchImpl, url, token); - if (models) return { models, source: "api" }; + const models = await tryFetchModels(fetchImpl, url, token, providerSpecificData); + if (models) { + catalogCache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, models }); + return { models, source: "api" }; + } } return toFallbackResult(fallbackModels); } + +export function clearKiroModelCache(): void { + catalogCache.clear(); +} diff --git a/open-sse/services/usage/kiro.ts b/open-sse/services/usage/kiro.ts index 4efe496de1..b96b5041ca 100644 --- a/open-sse/services/usage/kiro.ts +++ b/open-sse/services/usage/kiro.ts @@ -118,17 +118,24 @@ export function buildKiroUsageResult( export async function discoverKiroProfileArn( accessToken: string, usageBaseUrl: string, - region: string + region: string, + authMethod?: string ): Promise { try { + const isApiKey = authMethod === "api_key"; + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles", + Accept: "application/json", + }; + if (isApiKey) { + headers.tokentype = "API_KEY"; + } + const response = await fetch(usageBaseUrl, { method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/x-amz-json-1.0", - "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles", - Accept: "application/json", - }, + headers, body: JSON.stringify({ maxResults: 10 }), // Don't let a hung profile lookup block the usage/quota refresh indefinitely. signal: AbortSignal.timeout(10000), @@ -150,92 +157,237 @@ export async function discoverKiroProfileArn( } } +/** + * The three GetUsageLimits attempts (regional GET, CodeWhisperer POST, Q GET) tried in + * order by getKiroUsage — extracted so the auth-method header variants (api_key + * `tokentype`, external_idp `TokenType`) stay in one authHeaders object and the parent + * function stays under the function-length gate. + */ +function buildKiroUsageAttempts(opts: { + authHeaders: Record; + usageParams: URLSearchParams; + qParams: URLSearchParams; + payload: Record; + usageBaseUrl: string; + qBaseUrl: string; +}): Array<{ name: string; run: () => Promise }> { + const { authHeaders, usageParams, qParams, payload, usageBaseUrl, qBaseUrl } = opts; + return [ + { + name: "codewhisperer-get", + run: () => + fetch(`${CODEWHISPERER_BASE_URL}/getUsageLimits?${usageParams.toString()}`, { + method: "GET", + headers: { + ...authHeaders, + "x-amz-user-agent": "aws-sdk-js/1.0.0 KiroIDE", + "user-agent": "aws-sdk-js/1.0.0 KiroIDE", + }, + }), + }, + { + name: "codewhisperer-post", + run: () => + fetch(usageBaseUrl, { + method: "POST", + headers: { + ...authHeaders, + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits", + }, + body: JSON.stringify(payload), + }), + }, + { + name: "q-get", + run: () => + fetch(`${qBaseUrl}/getUsageLimits?${qParams.toString()}`, { + method: "GET", + headers: authHeaders, + }), + }, + ]; +} + +/** + * Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and + * endpoint must all match the region. Derive the region from the stored region (preferred) + * or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the + * legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions). + */ +function resolveKiroUsageEndpoints(providerSpecificData?: JsonRecord, profileArn?: string) { + const regionFromArn = profileArn + ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] + : undefined; + const region = + (typeof providerSpecificData?.region === "string" && + providerSpecificData.region.trim().toLowerCase()) || + regionFromArn || + "us-east-1"; + const usageBaseUrl = + region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`; + const qBaseUrl = `https://q.${region}.amazonaws.com`; + return { region, usageBaseUrl, qBaseUrl }; +} + +/** + * Base auth headers for the usage endpoints, per auth method: long-lived API keys add + * `tokentype: API_KEY`; enterprise / Microsoft Entra (external_idp) org accounts require + * `TokenType: EXTERNAL_IDP` for CodeWhisperer to bind the bearer to the profile (without + * it GetUsageLimits returns `ValidationException: Invalid ARN`). + */ +function buildKiroAuthHeaders( + accessToken: string | undefined, + isApiKey: boolean, + providerSpecificData?: JsonRecord +): Record { + const authHeaders: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }; + if (isApiKey) { + authHeaders.tokentype = "API_KEY"; + } + if (isExternalIdpAuthMethod(providerSpecificData?.authMethod)) { + authHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE; + } + return authHeaders; +} + +/** + * Runs the GetUsageLimits attempts in order until one succeeds. Collects per-attempt + * errors and whether any endpoint rejected the token (401/403) so getKiroUsage can + * pick the right user-facing message — extracted for the function-length gate. + */ +async function runKiroUsageAttempts( + attempts: Array<{ name: string; run: () => Promise }> +): Promise<{ + data?: JsonRecord; + sawAuthError: boolean; + errors: string[]; + lastHttpFailure?: string; +}> { + let sawAuthError = false; + let lastHttpFailure: string | undefined; + const errors: string[] = []; + for (const attempt of attempts) { + let response: Response; + try { + response = await attempt.run(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`${attempt.name}:${message}`); + continue; + } + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + if (response.status === 401 || response.status === 403) { + sawAuthError = true; + } + lastHttpFailure = `Kiro API error (${response.status}): ${errorText}`; + errors.push(`${attempt.name}:${response.status}${errorText ? `:${errorText}` : ""}`); + continue; + } + return { data: toRecord(await response.json()), sawAuthError, errors, lastHttpFailure }; + } + return { sawAuthError, errors, lastHttpFailure }; +} + /** * Kiro (AWS CodeWhisperer) Usage */ export async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRecord) { try { + const authMethod = + typeof providerSpecificData?.authMethod === "string" + ? providerSpecificData.authMethod + : undefined; + const isApiKey = authMethod === "api_key"; let profileArn = typeof providerSpecificData?.profileArn === "string" ? providerSpecificData.profileArn : undefined; - // Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and - // endpoint must all match the region. Derive the region from the stored region (preferred) - // or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the - // legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions). - const regionFromArn = profileArn - ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] - : undefined; - const region = - (typeof providerSpecificData?.region === "string" && - providerSpecificData.region.trim().toLowerCase()) || - regionFromArn || - "us-east-1"; - const usageBaseUrl = - region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`; + const { region, usageBaseUrl, qBaseUrl } = resolveKiroUsageEndpoints( + providerSpecificData, + profileArn + ); // IAM Identity Center logins and kiro-cli imports frequently don't persist a profileArn, which // previously caused the quota card to show nothing ("0 used"). Discover it on demand from // ListAvailableProfiles (region-matched) so usage still resolves for those accounts. if (!profileArn && accessToken) { - profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region); + profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region, authMethod); } - if (!profileArn) { + if (!profileArn && !isApiKey) { return { message: "Kiro connected. Profile ARN not available for quota tracking." }; } - // Kiro uses AWS CodeWhisperer GetUsageLimits API + const authHeaders = buildKiroAuthHeaders(accessToken, isApiKey, providerSpecificData); + + const usageParams = new URLSearchParams({ + isEmailRequired: "true", + origin: "AI_EDITOR", + resourceType: "AGENTIC_REQUEST", + }); + const qParams = new URLSearchParams({ + origin: "AI_EDITOR", + ...(profileArn ? { profileArn } : {}), + resourceType: "AGENTIC_REQUEST", + }); const payload = { origin: "AI_EDITOR", - profileArn: profileArn, + ...(profileArn ? { profileArn } : {}), resourceType: "AGENTIC_REQUEST", }; - // Enterprise / Microsoft Entra (external_idp) org accounts require the - // `TokenType: EXTERNAL_IDP` header for CodeWhisperer to bind the bearer to the - // profile; without it GetUsageLimits returns `ValidationException: Invalid ARN`. - const usageHeaders: Record = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/x-amz-json-1.0", - "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits", - Accept: "application/json", - }; - if (isExternalIdpAuthMethod(providerSpecificData?.authMethod)) { - usageHeaders[KIRO_EXTERNAL_IDP_TOKEN_TYPE_HEADER] = KIRO_EXTERNAL_IDP_TOKEN_TYPE_VALUE; - } - - const response = await fetch(usageBaseUrl, { - method: "POST", - headers: usageHeaders, - body: JSON.stringify(payload), +const attempts = buildKiroUsageAttempts({ + authHeaders, + usageParams, + qParams, + payload, + usageBaseUrl, + qBaseUrl, }); - if (!response.ok) { + const outcome = await runKiroUsageAttempts(attempts); + if (outcome.data) { + return buildKiroUsageResult(outcome.data); + } + const { sawAuthError, errors } = outcome; + + if (sawAuthError) { // Social-auth Kiro accounts (added via /api/oauth/kiro/social-exchange with provider // Google or GitHub) use a different token format that AWS CodeWhisperer's GetUsageLimits // routinely rejects with 401/403, even when /messages still works. Surface a clear // "auth expired, chat may still work" message instead of a generic upstream-error blob // so the quota card matches what users with legacy social-auth accounts already see. // Inspired by https://github.com/decolua/9router/pull/620. - if ( - (response.status === 401 || response.status === 403) && - isSocialAuthKiroAccount(providerSpecificData) - ) { + if (isSocialAuthKiroAccount(providerSpecificData)) { return { message: "Kiro quota API authentication expired. Chat may still work.", quotas: {}, }; } - const errorText = await response.text(); - throw new Error(`Kiro API error (${response.status}): ${errorText}`); + return { + message: "Kiro quota API rejected the current token. Chat may still work.", + quotas: {}, + }; } - const data = toRecord(await response.json()); - return buildKiroUsageResult(data); + // Hard (non-auth) failure keeps the pre-#6587 reject semantics — callers and + // tests/unit/usage-service-hardening.test.ts rely on the rejection; prefer the last + // HTTP-status failure (most informative) over a network-level error. + throw new Error( + outcome.lastHttpFailure || + (errors.length > 0 + ? errors[errors.length - 1] + : "no usage endpoint responded") + ); } catch (error) { - throw new Error(`Failed to fetch Kiro usage: ${error.message}`); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to fetch Kiro usage: ${message}`); } } diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 8508fe54d0..d5d6d7f825 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -579,6 +579,26 @@ function convertMessages(messages, tools, model) { /** Kiro's accepted reasoning-effort levels (`output_config.effort`). */ const KIRO_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]; +function resolveKiroModelAlias(model: string): { upstream: string; thinking: boolean } { + let upstream = String(model || ""); + let thinking = false; + + if (upstream.endsWith("-agentic")) { + upstream = upstream.slice(0, -"-agentic".length); + } + if (upstream.endsWith("-thinking")) { + upstream = upstream.slice(0, -"-thinking".length); + thinking = true; + } + if (upstream === "auto-kiro") { + upstream = "auto"; + } + + upstream = upstream.replace(/^(claude-(?:opus|sonnet|haiku|3-\d+)-\d+)-(\d{1,2})$/, "$1.$2"); + + return { upstream, thinking }; +} + /** * Resolve the Kiro effort level for a request, or "" when no reasoning was asked * for. Effort sources, in priority order: @@ -665,10 +685,11 @@ export function buildKiroPayload(model, body, stream, credentials) { // The minor group is bounded to 1-2 digits so date-suffixed ids (e.g. // claude-opus-4-20250514) are never mistaken for a dash-separated minor // version and corrupted into claude-opus-4.20250514 (upstream 9router #2270). - const normalizedModel = model.replace( - /^(claude-(?:opus|sonnet|haiku|3-\d+)-\d+)-(\d{1,2})$/, - "$1.$2" - ); + // Synthetic Kiro selector variants (`-thinking`, `-agentic`) are local aliases: + // strip them before the request leaves OmniRoute so Kiro only receives real + // upstream model IDs. We intentionally do not inject an agentic system prompt here. + const { upstream: normalizedModel, thinking: modelRequestsThinking } = + resolveKiroModelAlias(model); const messages = body.messages || []; let tools = body.tools || []; const maxTokens = body.max_tokens ?? body.max_completion_tokens ?? 32000; @@ -844,7 +865,8 @@ export function buildKiroPayload(model, body, stream, credentials) { // thinking:{type:"adaptive"} + a clamped max_tokens), forwarded to AWS by // the Kiro executor's transformRequest allowlist — this is the graded // effort lever. Gated on models that advertise thinking support. - const kiroEffort = supportsReasoning(normalizedModel) ? resolveKiroEffort(body) : ""; + const requestedEffort = resolveKiroEffort(body) || (modelRequestsThinking ? "high" : ""); + const kiroEffort = supportsReasoning(normalizedModel) ? requestedEffort : ""; if (kiroEffort) { // `` / `` are Kiro/CodeWhisperer prompt // conventions (NOT Anthropic API params); the length is a soft hint (the hard diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index b7b9f4d354..762a9bb629 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -71,6 +71,8 @@ export function shouldShowFirstProviderHint( type ProviderRecord> = Record; +const OAUTH_CARD_API_KEY_CONNECTION_PROVIDER_IDS = new Set(["kiro", "amazon-q"]); + /** * Whether a provider connection should be counted on a provider card rendered in * the given section. Dual-auth providers (qoder, opencode, codebuddy-cn, …) are @@ -85,8 +87,11 @@ export function connectionMatchesProviderCard( ): boolean { if (!conn || conn.provider !== providerId) return false; if (cardAuthType === "free") return true; - if (supportsApiKeyOnFreeProvider(providerId)) { - return conn.authType === "oauth" || conn.authType === "apikey"; + if ( + supportsApiKeyOnFreeProvider(providerId) || + OAUTH_CARD_API_KEY_CONNECTION_PROVIDER_IDS.has(providerId) + ) { + return conn.authType === "oauth" || conn.authType === "apikey" || conn.authType === "api_key"; } return conn.authType === cardAuthType; } @@ -364,7 +369,7 @@ const PROVIDER_PAGE_FETCH_TIMEOUT_MS = 20_000; * page paints from whatever data arrived (matching the fast `/api/providers`). */ export async function loadProviderPageData( - fetchImpl: typeof fetch = (globalThis.fetch as typeof fetch), + fetchImpl: typeof fetch = globalThis.fetch as typeof fetch, timeoutMs: number = PROVIDER_PAGE_FETCH_TIMEOUT_MS ): Promise { const safeJson = async (url: string, init?: RequestInit): Promise => { diff --git a/src/app/api/oauth/kiro/api-key/helpers.ts b/src/app/api/oauth/kiro/api-key/helpers.ts new file mode 100644 index 0000000000..8a0da5b89c --- /dev/null +++ b/src/app/api/oauth/kiro/api-key/helpers.ts @@ -0,0 +1,24 @@ +import { createHash } from "node:crypto"; + +export function buildKiroApiKeyConnectionName( + targetProvider: string, + region: string, + apiKey: string +): string { + const label = targetProvider === "amazon-q" ? "Amazon Q" : "Kiro"; + const safeRegion = region || "us-east-1"; + const fingerprint = createHash("sha256").update(apiKey.trim()).digest("hex").slice(0, 8); + return `${label} API Key (${safeRegion}, ${fingerprint})`; +} + +export function isKiroApiKeyImportClientError(error: unknown): boolean { + const message = (error instanceof Error ? error.message : String(error || "")).toLowerCase(); + return ( + message.includes("api key is required") || + message.includes("invalid region") || + message.includes("invalid kiro api key") || + message.includes("invalid api key") || + message.includes("no kiro profile available") || + message.includes("failed to list profiles") + ); +} diff --git a/src/app/api/oauth/kiro/api-key/route.ts b/src/app/api/oauth/kiro/api-key/route.ts new file mode 100644 index 0000000000..0564d0937f --- /dev/null +++ b/src/app/api/oauth/kiro/api-key/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from "next/server"; +import { KiroService } from "@/lib/oauth/services/kiro"; +import { createProviderConnection, isCloudEnabled } from "@/models"; +import { syncToCloud } from "@/lib/cloudSync"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { kiroApiKeyImportSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { buildKiroImportError } from "../import/route"; +import { buildKiroApiKeyConnectionName, isKiroApiKeyImportClientError } from "./helpers"; + +async function requireKiroApiKeyImportAuth(request: Request) { + if (!(await isAuthRequired(request))) return null; + if (await isAuthenticated(request)) return null; + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +} + +/** + * POST /api/oauth/kiro/api-key + * + * Imports a long-lived Kiro / AWS CodeWhisperer API key. API-key auth has no + * refresh token; profile discovery is best-effort because AWS rejects + * ListAvailableProfiles for some API keys while still accepting generation calls. + */ +export async function POST(request: Request) { + const authResponse = await requireKiroApiKeyImportAuth(request); + if (authResponse) return authResponse; + + let rawBody; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }, + { status: 400 } + ); + } + + try { + const { searchParams } = new URL(request.url); + const targetProvider = searchParams.get("targetProvider") === "amazon-q" ? "amazon-q" : "kiro"; + const validation = validateBody(kiroApiKeyImportSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const { apiKey, region } = validation.data; + const kiroService = new KiroService(); + const credential = await kiroService.validateApiKey(apiKey, region || "us-east-1"); + const email = kiroService.extractEmailFromJWT(credential.accessToken); + + const connection: any = await createProviderConnection({ + provider: targetProvider, + authType: "apikey", + name: buildKiroApiKeyConnectionName(targetProvider, credential.region, apiKey), + apiKey: credential.accessToken, + accessToken: credential.accessToken, + refreshToken: null, + // Long-lived key with no scheduled refresh. Keep a future timestamp so + // health/token paths do not treat the connection as immediately expired. + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + email: email || null, + providerSpecificData: { + profileArn: credential.profileArn, + region: credential.region, + authMethod: "api_key", + provider: "API Key", + }, + testStatus: "active", + }); + + await syncToCloudIfEnabled(); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + }, + }); + } catch (error) { + console.error("Kiro API key import error:", error); + return NextResponse.json( + { error: buildKiroImportError(error) }, + { status: isKiroApiKeyImportClientError(error) ? 400 : 500 } + ); + } +} + +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing to cloud after Kiro API key import:", error); + } +} diff --git a/src/lib/oauth/services/kiro.ts b/src/lib/oauth/services/kiro.ts index 86601b3f2c..7023ddffa9 100644 --- a/src/lib/oauth/services/kiro.ts +++ b/src/lib/oauth/services/kiro.ts @@ -448,6 +448,80 @@ export class KiroService { return null; } + /** + * List available CodeWhisperer profiles for an access token or long-lived API key. + * Some long-lived API keys can call GenerateAssistantResponse but are explicitly + * denied on ListAvailableProfiles, so callers must treat an AccessDenied profile + * lookup as an optional discovery failure rather than a hard auth failure. + */ + async listAvailableProfiles(accessToken: string, region: string = "us-east-1") { + assertValidAwsRegion(region); + const endpoint = + region === "us-east-1" + ? "https://codewhisperer.us-east-1.amazonaws.com" + : `https://q.${region}.amazonaws.com`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles", + Accept: "application/json", + tokentype: "API_KEY", + }, + body: JSON.stringify({ maxResults: 10 }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to list profiles: ${error}`); + } + + const data = await response.json(); + const profiles = Array.isArray(data?.profiles) ? data.profiles : []; + const arnOf = (profile: any) => profile?.arn || profile?.profileArn || null; + const match = + profiles.find((profile: any) => String(arnOf(profile) || "").includes(`:${region}:`)) || + profiles[0]; + return arnOf(match); + } + + /** + * Normalize a long-lived Kiro/CodeWhisperer API key. + */ + async validateApiKey(apiKey: string, regionInput?: string) { + // Default kept OUT of the parameter list: check-public-creds' CRED_KEY_RE matches the + // `apiKey:` annotation and flags any string literal in the signature (fn-param FP class). + const region = regionInput || "us-east-1"; + assertValidAwsRegion(region); + const accessToken = apiKey.trim(); + if (!accessToken) { + throw new Error("API key is required"); + } + + let profileArn: string | null = null; + try { + profileArn = await this.listAvailableProfiles(accessToken, region); + } catch (error: any) { + const message = String(error?.message || error || ""); + const isApiKeyProfileDenied = + message.includes("AccessDeniedException") && + message.includes("API key authentication is not supported for this operation"); + if (!isApiKeyProfileDenied) { + throw error; + } + } + + return { + accessToken, + refreshToken: null, + profileArn, + region, + authMethod: "api_key", + }; + } + /** * Fetch user email from access token (optional, for display) */ diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 70a1dda4a5..f97d8eeb13 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -16,6 +16,7 @@ import { resolveNvidiaValidationModel } from "@/lib/providers/nvidiaValidationMo import { MODAL_DEFAULT_VALIDATION_MODEL_ID } from "@/shared/constants/modal"; import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts"; import { validateImageProviderApiKey } from "@/lib/providers/imageValidation"; +import { KiroService } from "@/lib/oauth/services/kiro"; import { OPENAI_LIKE_FORMATS, @@ -210,6 +211,74 @@ export async function validateBytezProvider({ apiKey, providerSpecificData = {} } } +async function validateKiroApiKeyRuntimeProbe({ + apiKey, + region, + profileArn, +}: { + apiKey: string; + region: string; + profileArn?: string | null; +}) { + const endpoint = + region === "us-east-1" + ? "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse" + : `https://q.${region}.amazonaws.com/generateAssistantResponse`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + try { + const body = { + ...(profileArn ? { profileArn } : {}), + conversationState: { + chatTriggerType: "MANUAL", + conversationId: crypto.randomUUID(), + currentMessage: { + userInputMessage: { + content: "ping", + modelId: "auto", + origin: "AI_EDITOR", + }, + }, + history: [], + }, + inferenceConfig: { + maxTokens: 1, + }, + }; + + const res = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + tokentype: "API_KEY", + "Content-Type": "application/x-amz-json-1.0", + "X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse", + Accept: "application/vnd.amazon.eventstream", + "Amz-Sdk-Request": "attempt=1; max=3", + "Amz-Sdk-Invocation-Id": crypto.randomUUID(), + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + await res.body?.cancel().catch(() => undefined); + + if (res.ok) { + return { valid: true, error: null, method: "kiro_generate_assistant_response" }; + } + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid Kiro API key or AWS region" }; + } + if (res.status === 400 || res.status === 422 || res.status === 429) { + return { valid: true, error: null, method: `kiro_generate_assistant_response_${res.status}` }; + } + return { valid: false, error: `Kiro validation failed: ${res.status}` }; + } finally { + clearTimeout(timeout); + } +} + export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) { const requiresApiKey = !providerAllowsOptionalApiKey(provider); const isLocal = isLocalProvider(provider); @@ -325,9 +394,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi // key to check upstream. The only meaningful validation is confirming the // `auggie` binary is installed and runnable on this machine. auggie: async () => { - const { checkAuggieCliVersion } = await import( - "@omniroute/open-sse/executors/auggie.ts" - ); + const { checkAuggieCliVersion } = await import("@omniroute/open-sse/executors/auggie.ts"); const result = await checkAuggieCliVersion(); if (!result.ok) { return { @@ -374,6 +441,26 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi return toValidationErrorResult(err); } }, + kiro: async ({ apiKey, providerSpecificData }: any) => { + try { + const region = providerSpecificData?.region || "us-east-1"; + const credential = await new KiroService().validateApiKey(apiKey, region); + if (!credential.profileArn) { + return await validateKiroApiKeyRuntimeProbe({ + apiKey: credential.accessToken, + region: credential.region, + profileArn: providerSpecificData?.profileArn, + }); + } + return { + valid: true, + error: null, + method: "kiro_list_available_profiles", + }; + } catch (error: any) { + return toValidationErrorResult(error); + } + }, "command-code": validateCommandCodeProvider, huggingface: validateHuggingFaceProvider, // #5422: auth-only probe — Bytez 404s on every chat model until the account adds it to diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 588350d523..6dcafb051f 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -75,6 +75,7 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ "vertex", "vertex-partner", "kimi-coding-apikey", + "kiro", // Qoder connections are PAT-based (authType "apikey"); the usage fetcher // exchanges the PAT for a job token and reads openapi.qoder.sh/user/status. "qoder", @@ -187,7 +188,8 @@ export function isSupportedUsageConnection(connection: ProviderConnectionLike | if (connection.authType === "oauth") return true; return ( - connection.authType === "apikey" && PROVIDER_LIMITS_APIKEY_PROVIDERS.has(connection.provider) + (connection.authType === "apikey" || connection.authType === "api_key") && + PROVIDER_LIMITS_APIKEY_PROVIDERS.has(connection.provider) ); } diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index 84b6550aa3..b440bad49f 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -38,6 +38,29 @@ const POLICIES: Record = { MANAGEMENT: managementPolicy, }; +let staleDashboardJwtWarningEmitted = false; + +function isStaleDashboardJwtError(error: unknown): boolean { + const code = + typeof error === "object" && + error !== null && + "code" in error && + typeof (error as { code?: unknown }).code === "string" + ? (error as { code: string }).code + : ""; + + if ( + code === "ERR_JWS_SIGNATURE_VERIFICATION_FAILED" || + code === "ERR_JWT_EXPIRED" || + code === "ERR_JWS_INVALID" || + code === "ERR_JWT_CLAIM_VALIDATION_FAILED" + ) { + return true; + } + + return error instanceof Error && error.message.includes("signature verification failed"); +} + function stampSubject(headers: Headers, subject: AuthSubject): void { headers.set(AUTHZ_HEADER_AUTH_KIND, subject.kind); headers.set(AUTHZ_HEADER_AUTH_ID, subject.id); @@ -144,6 +167,15 @@ async function refreshDashboardSessionIfNeeded( path: "/", }); } catch (error) { + if (isStaleDashboardJwtError(error)) { + response.cookies.delete("auth_token"); + if (!staleDashboardJwtWarningEmitted) { + staleDashboardJwtWarningEmitted = true; + console.warn("[Authz] Dropped stale dashboard session cookie during auto-refresh"); + } + return; + } + console.error("[Authz] JWT auto-refresh failed:", error); } } diff --git a/src/shared/components/KiroAuthModal.tsx b/src/shared/components/KiroAuthModal.tsx index f17d5f49e4..bf890af46c 100644 --- a/src/shared/components/KiroAuthModal.tsx +++ b/src/shared/components/KiroAuthModal.tsx @@ -28,8 +28,11 @@ export default function KiroAuthModal({ const [idcStartUrl, setIdcStartUrl] = useState(""); const [idcRegion, setIdcRegion] = useState("us-east-1"); const [refreshToken, setRefreshToken] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [apiKeyRegion, setApiKeyRegion] = useState("us-east-1"); const [error, setError] = useState(null); const [importing, setImporting] = useState(false); + const [importingApiKey, setImportingApiKey] = useState(false); const [autoDetecting, setAutoDetecting] = useState(false); const [autoDetected, setAutoDetected] = useState(false); // IDC/organization credentials returned by auto-import when the SSO cache token @@ -126,6 +129,43 @@ export default function KiroAuthModal({ } }; + const handleImportApiKey = async () => { + if (!apiKey.trim()) { + setError("Please enter a Kiro API key"); + return; + } + + setImportingApiKey(true); + setError(null); + + try { + const res = await fetch( + `/api/oauth/kiro/api-key?targetProvider=${encodeURIComponent(providerId)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: apiKey.trim(), + region: apiKeyRegion.trim() || "us-east-1", + }), + } + ); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error?.message || data.error || "API key import failed"); + } + + onMethodSelect("api-key"); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "API key import failed"); + } finally { + setImportingApiKey(false); + } + }; + const handleIdcContinue = () => { if (!idcStartUrl.trim()) { setError("Please enter your IDC start URL"); @@ -226,6 +266,23 @@ export default function KiroAuthModal({
+ + {/* API Key */} + )} @@ -419,6 +476,58 @@ export default function KiroAuthModal({ )} )} + + {/* API Key Import */} + {selectedMethod === "api-key" && ( +
+
+ + setApiKey(e.target.value)} + placeholder={`Paste your ${providerLabel} API key...`} + className="font-mono text-sm" + /> +

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

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

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

+
+ + {error && ( +
+

{error}

+
+ )} + +
+ + +
+
+ )} ); diff --git a/src/shared/components/KiroOAuthWrapper.tsx b/src/shared/components/KiroOAuthWrapper.tsx index aff30c2bdd..19b91ee85f 100644 --- a/src/shared/components/KiroOAuthWrapper.tsx +++ b/src/shared/components/KiroOAuthWrapper.tsx @@ -24,7 +24,7 @@ export default function KiroOAuthWrapper({ onClose, reauthConnection, }: KiroOAuthWrapperProps) { - const [authMethod, setAuthMethod] = useState(null); // null | "builder-id" | "idc" | "social" | "import" + const [authMethod, setAuthMethod] = useState(null); // null | "builder-id" | "idc" | "social" | "import" | "api-key" const [socialProvider, setSocialProvider] = useState(null); // "google" | "github" const [idcConfig, setIdcConfig] = useState(null); @@ -44,6 +44,9 @@ export default function KiroOAuthWrapper({ } else if (method === "import") { // Import handled in KiroAuthModal, just close onSuccess?.(); + } else if (method === "api-key") { + // API-key import is handled in KiroAuthModal. + onSuccess?.(); } }, [onSuccess] diff --git a/src/shared/constants/pricing/oauth-subscriptions.ts b/src/shared/constants/pricing/oauth-subscriptions.ts index 04792b5ea1..fcf65066dc 100644 --- a/src/shared/constants/pricing/oauth-subscriptions.ts +++ b/src/shared/constants/pricing/oauth-subscriptions.ts @@ -584,10 +584,8 @@ export const DEFAULT_PRICING_OAUTH = { reasoning: 8.0, cache_creation: 2.0, }, - // Kiro "Auto" pricing — retained as a fallback price for any legacy "auto" - // reference. The "auto-kiro" registry model was removed (Kiro's API has no - // "auto" model id — it 400'd "Invalid model"), so its dedicated price key - // was dropped with it. See kiro cluster #6112/#6113/#6099. + // Kiro "Auto" pricing — retained for both the upstream "auto" id and the + // local "auto-kiro" selector. The translator maps auto-kiro back to auto. auto: { input: 3.0, output: 15.0, @@ -595,5 +593,12 @@ export const DEFAULT_PRICING_OAUTH = { reasoning: 15.0, cache_creation: 3.0, }, + "auto-kiro": { + input: 3.0, + output: 15.0, + cached: 1.5, + reasoning: 15.0, + cache_creation: 3.0, + }, }, }; diff --git a/src/shared/validation/schemas/auth.ts b/src/shared/validation/schemas/auth.ts index 1e251aa596..0872bc890d 100644 --- a/src/shared/validation/schemas/auth.ts +++ b/src/shared/validation/schemas/auth.ts @@ -200,6 +200,11 @@ export const kiroImportSchema = z.object({ scopes: z.union([z.string(), z.array(z.string())]).optional(), }); +export const kiroApiKeyImportSchema = z.object({ + apiKey: z.string().trim().min(1, "API key is required"), + region: z.string().trim().default("us-east-1"), +}); + export const zedImportSchema = z.object({ confirmedAccounts: z.array(confirmedAccountSchema), }); diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts index 2b43287911..34703f270e 100644 --- a/tests/unit/authz/pipeline.test.ts +++ b/tests/unit/authz/pipeline.test.ts @@ -66,6 +66,7 @@ test.beforeEach(() => { }); test.after(() => { + core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); if (ORIGINAL_JWT === undefined) delete process.env.JWT_SECRET; else process.env.JWT_SECRET = ORIGINAL_JWT; @@ -564,3 +565,38 @@ test("runAuthzPipeline refreshes dashboard JWTs near expiry", async () => { assert.equal(response.status, 200); assert.match(response.headers.get("set-cookie") || "", /auth_token=/); }); + +test("runAuthzPipeline clears stale dashboard JWTs without error-stack noise", async () => { + await forceAuthRequired(); + const oldSecret = new TextEncoder().encode("old-dashboard-jwt-secret"); + const staleToken = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(oldSecret); + + const errorCalls: unknown[][] = []; + const originalError = console.error; + const originalWarn = console.warn; + console.error = (...args: unknown[]) => { + errorCalls.push(args); + }; + console.warn = () => {}; + + try { + const response = await pipeline.runAuthzPipeline( + request("http://localhost/dashboard", { + headers: { cookie: `auth_token=${staleToken}` }, + }), + { enforce: true } + ); + + assert.equal(response.status, 307); + const setCookie = response.headers.get("set-cookie") || ""; + assert.match(setCookie, /auth_token=/); + assert.match(setCookie, /Max-Age=0|Expires=/i); + assert.equal(errorCalls.length, 0); + } finally { + console.error = originalError; + console.warn = originalWarn; + } +}); diff --git a/tests/unit/executor-kiro.test.ts b/tests/unit/executor-kiro.test.ts index 61f5a4493c..8fde25b3e3 100644 --- a/tests/unit/executor-kiro.test.ts +++ b/tests/unit/executor-kiro.test.ts @@ -147,6 +147,17 @@ test("KiroExecutor.buildHeaders includes Kiro-specific auth and metadata", () => assert.ok(headers["Amz-Sdk-Invocation-Id"]); }); +test("KiroExecutor.buildHeaders marks long-lived Kiro API keys", () => { + const executor = new KiroExecutor(); + const headers = executor.buildHeaders( + { apiKey: "kiro-api-key", providerSpecificData: { authMethod: "api_key" } }, + true + ); + + assert.equal(headers.Authorization, "Bearer kiro-api-key"); + assert.equal(headers.tokentype, "API_KEY"); +}); + test("KiroExecutor.transformRequest removes the top-level model field", () => { const executor = new KiroExecutor(); const body = { @@ -382,6 +393,13 @@ test("KiroExecutor.refreshCredentials handles missing and AWS-style refresh toke try { assert.equal(await executor.refreshCredentials({}, null), null); + assert.equal( + await executor.refreshCredentials( + { refreshToken: "ignored", providerSpecificData: { authMethod: "api_key" } }, + null + ), + null + ); const result = await executor.refreshCredentials( { refreshToken: "refresh", diff --git a/tests/unit/kiro-api-key-route-helpers.test.ts b/tests/unit/kiro-api-key-route-helpers.test.ts new file mode 100644 index 0000000000..a40a6a7b9f --- /dev/null +++ b/tests/unit/kiro-api-key-route-helpers.test.ts @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildKiroApiKeyConnectionName, + isKiroApiKeyImportClientError, +} from "../../src/app/api/oauth/kiro/api-key/helpers.ts"; + +test("Kiro API key connection names differ for different keys in the same region", () => { + const first = buildKiroApiKeyConnectionName("kiro", "us-east-1", "ksk_first_key"); + const second = buildKiroApiKeyConnectionName("kiro", "us-east-1", "ksk_second_key"); + + assert.match(first, /^Kiro API Key \(us-east-1, [a-f0-9]{8}\)$/); + assert.match(second, /^Kiro API Key \(us-east-1, [a-f0-9]{8}\)$/); + assert.notEqual(first, second); +}); + +test("Kiro API key connection names are stable for the same trimmed key", () => { + const first = buildKiroApiKeyConnectionName("amazon-q", "eu-west-1", " ksk_same_key "); + const second = buildKiroApiKeyConnectionName("amazon-q", "eu-west-1", "ksk_same_key"); + + assert.equal(first, second); + assert.match(first, /^Amazon Q API Key \(eu-west-1, [a-f0-9]{8}\)$/); +}); + +test("Kiro API key import classifies client validation failures as 400-class", () => { + assert.equal(isKiroApiKeyImportClientError(new Error("API key is required")), true); + assert.equal(isKiroApiKeyImportClientError(new Error("Invalid region")), true); + assert.equal( + isKiroApiKeyImportClientError(new Error("Failed to list profiles: Invalid API key")), + true + ); +}); + +test("Kiro API key import leaves network/server failures as 500-class", () => { + assert.equal(isKiroApiKeyImportClientError(new Error("fetch failed")), false); + assert.equal(isKiroApiKeyImportClientError(new Error("ECONNRESET")), false); +}); diff --git a/tests/unit/kiro-api-key-service.test.ts b/tests/unit/kiro-api-key-service.test.ts new file mode 100644 index 0000000000..36ebe686df --- /dev/null +++ b/tests/unit/kiro-api-key-service.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { KiroService } from "../../src/lib/oauth/services/kiro.ts"; + +test("KiroService.validateApiKey validates via ListAvailableProfiles with API_KEY token type", async () => { + const service = new KiroService(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async (url: string, init?: RequestInit) => { + assert.equal(url, "https://q.eu-central-1.amazonaws.com"); + const headers = init?.headers as Record; + assert.equal(headers.Authorization, "Bearer kiro-api-key"); + assert.equal(headers.tokentype, "API_KEY"); + assert.equal(headers["x-amz-target"], "AmazonCodeWhispererService.ListAvailableProfiles"); + return new Response( + JSON.stringify({ + profiles: [ + { arn: "arn:aws:codewhisperer:us-east-1:1:profile/OTHER" }, + { arn: "arn:aws:codewhisperer:eu-central-1:1:profile/MATCH" }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + + try { + const credential = await service.validateApiKey(" kiro-api-key ", "eu-central-1"); + assert.equal(credential.accessToken, "kiro-api-key"); + assert.equal(credential.refreshToken, null); + assert.equal(credential.authMethod, "api_key"); + assert.equal(credential.region, "eu-central-1"); + assert.equal(credential.profileArn, "arn:aws:codewhisperer:eu-central-1:1:profile/MATCH"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("KiroService.validateApiKey accepts API keys when profile discovery is denied", async () => { + const service = new KiroService(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + __type: "com.amazon.aws.codewhisperer#AccessDeniedException", + message: "API key authentication is not supported for this operation.", + }), + { status: 403, headers: { "Content-Type": "application/json" } } + )) as typeof fetch; + + try { + const credential = await service.validateApiKey(" kiro-api-key ", "us-east-1"); + assert.equal(credential.accessToken, "kiro-api-key"); + assert.equal(credential.refreshToken, null); + assert.equal(credential.authMethod, "api_key"); + assert.equal(credential.region, "us-east-1"); + assert.equal(credential.profileArn, null); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/kiro-available-models.test.ts b/tests/unit/kiro-available-models.test.ts index 4f57ec1a92..58a0841f34 100644 --- a/tests/unit/kiro-available-models.test.ts +++ b/tests/unit/kiro-available-models.test.ts @@ -1,4 +1,4 @@ -import test from "node:test"; +import test, { beforeEach } from "node:test"; import assert from "node:assert/strict"; import { @@ -6,10 +6,15 @@ import { resolveKiroRegion, buildKiroModelsEndpoints, fetchKiroAvailableModels, + clearKiroModelCache, } from "../../open-sse/services/kiroModels.ts"; const FALLBACK = [{ id: "auto-kiro", name: "Auto" }, { id: "claude-sonnet-4.6" }]; +beforeEach(() => { + clearKiroModelCache(); +}); + function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -70,7 +75,14 @@ test("fetchKiroAvailableModels: simple (Builder ID) account, us-east-1, origin-o }); assert.equal(result.source, "api"); - assert.deepEqual(result.models.map((m) => m.id).sort(), ["auto", "claude-sonnet-4.6"]); + assert.deepEqual(result.models.map((m) => m.id).sort(), [ + "auto", + "auto-thinking", + "claude-sonnet-4.6", + "claude-sonnet-4.6-agentic", + "claude-sonnet-4.6-thinking", + "claude-sonnet-4.6-thinking-agentic", + ]); assert.deepEqual(calls, [ "https://q.us-east-1.amazonaws.com/ListAvailableModels?origin=AI_EDITOR", ]); @@ -94,7 +106,12 @@ test("fetchKiroAvailableModels: IAM Identity Center account, region-matched endp assert.equal(result.source, "api"); assert.deepEqual( result.models.map((m) => m.id), - ["claude-opus-4.8"] + [ + "claude-opus-4.8", + "claude-opus-4.8-thinking", + "claude-opus-4.8-agentic", + "claude-opus-4.8-thinking-agentic", + ] ); assert.equal( calls[0], @@ -125,7 +142,12 @@ test("fetchKiroAvailableModels: retries with profileArn when origin-only fails", assert.equal(result.source, "api"); assert.deepEqual( result.models.map((m) => m.id), - ["claude-sonnet-4.6"] + [ + "claude-sonnet-4.6", + "claude-sonnet-4.6-thinking", + "claude-sonnet-4.6-agentic", + "claude-sonnet-4.6-thinking-agentic", + ] ); // origin-only attempted first, then profileArn retry. assert.equal(calls.length, 2); diff --git a/tests/unit/kiro-iam-profilearn-usage.test.ts b/tests/unit/kiro-iam-profilearn-usage.test.ts index 3afd99f2f4..9bfc7f4132 100644 --- a/tests/unit/kiro-iam-profilearn-usage.test.ts +++ b/tests/unit/kiro-iam-profilearn-usage.test.ts @@ -1,169 +1,227 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { - __testing, - buildKiroUsageResult, - discoverKiroProfileArn, -} from "@omniroute/open-sse/services/usage.ts"; - -const { getKiroUsage } = __testing; - -// Real-world shape returned by GetUsageLimits for an AWS IAM Identity Center ("KIRO POWER") -// account — the usage is reported under resourceType "CREDIT" (not "AGENTIC_REQUEST"). -const IAM_CREDIT_RESPONSE = { - daysUntilReset: 0, - nextDateReset: 1.782864e9, - subscriptionInfo: { subscriptionTitle: "KIRO POWER", type: "Q_DEVELOPER_STANDALONE_POWER" }, - usageBreakdownList: [ - { - currency: "USD", - currentUsage: 3670, - currentUsageWithPrecision: 3670.9, - displayName: "Credit", - resourceType: "CREDIT", - unit: "INVOCATIONS", - usageLimit: 10000, - usageLimitWithPrecision: 10000.0, - }, - ], -}; - -test("buildKiroUsageResult parses the IAM CREDIT breakdown into non-zero usage", () => { - const result = buildKiroUsageResult(IAM_CREDIT_RESPONSE) as { - plan: string; - quotas: Record; - }; - assert.ok("quotas" in result, "must return quotas for a CREDIT breakdown"); - assert.equal(result.plan, "KIRO POWER"); - const credit = result.quotas.credit; - assert.ok(credit, "CREDIT resource should map to a 'credit' quota key"); - assert.equal(credit.used, 3670.9); - assert.equal(credit.total, 10000); - assert.equal(credit.remaining, 10000 - 3670.9); -}); - -test("discoverKiroProfileArn prefers the region-matched profile ARN", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - profiles: [ - { arn: "arn:aws:codewhisperer:us-east-1:111111111111:profile/AAAA" }, - { arn: "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ" }, - ], - }), - { status: 200, headers: { "Content-Type": "application/json" } } - )) as typeof fetch; - try { - const arn = await discoverKiroProfileArn( - "tok", - "https://q.eu-central-1.amazonaws.com", - "eu-central-1" - ); - assert.equal(arn, "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("discoverKiroProfileArn falls back to the first profile when no region match", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => - new Response( - JSON.stringify({ profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/X" }] }), - { status: 200, headers: { "Content-Type": "application/json" } } - )) as typeof fetch; - try { - const arn = await discoverKiroProfileArn("tok", "https://q.eu-west-1.amazonaws.com", "eu-west-1"); - assert.equal(arn, "arn:aws:codewhisperer:us-east-1:1:profile/X"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("discoverKiroProfileArn returns undefined for empty profiles or non-ok response", async () => { - const originalFetch = globalThis.fetch; - try { - globalThis.fetch = (async () => - new Response(JSON.stringify({ profiles: [] }), { status: 200 })) as typeof fetch; - assert.equal( - await discoverKiroProfileArn("tok", "https://q.eu-central-1.amazonaws.com", "eu-central-1"), - undefined - ); - - globalThis.fetch = (async () => new Response("nope", { status: 403 })) as typeof fetch; - assert.equal( - await discoverKiroProfileArn("tok", "https://q.eu-central-1.amazonaws.com", "eu-central-1"), - undefined - ); - } finally { - globalThis.fetch = originalFetch; - } -}); - -// Regression: when a Kiro account added via Google/GitHub social-auth (authMethod "imported" -// with provider "Google" or "Github" — set by /api/oauth/kiro/social-exchange/route.ts) has its -// token rejected by the AWS CodeWhisperer quota API (401/403), surface a clear "auth expired, -// chat may still work" message instead of throwing a generic upstream-error blob. -test("getKiroUsage returns a friendly auth-expired message for social-auth Kiro on 401/403", async () => { - const originalFetch = globalThis.fetch; - // First call (ListAvailableProfiles for ARN discovery) succeeds with an ARN so we proceed - // to GetUsageLimits, which then returns 401. The friendly branch only applies when the - // GetUsageLimits call returned an auth-shaped error. - let callIdx = 0; - globalThis.fetch = (async (_url: string, init?: RequestInit) => { - callIdx += 1; - const target = String((init?.headers as Record | undefined)?.["x-amz-target"] || ""); - if (target.endsWith("ListAvailableProfiles")) { - return new Response( - JSON.stringify({ profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/SOCIAL" }] }), - { status: 200, headers: { "Content-Type": "application/json" } } - ); - } - // GetUsageLimits → simulate the social-auth token rejection - return new Response(JSON.stringify({ __type: "AccessDeniedException" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); - }) as typeof fetch; - try { - const result = (await getKiroUsage("social-tok", { - authMethod: "imported", - provider: "Google", - })) as { message?: string; quotas?: Record }; - assert.ok(result, "should resolve, not throw"); - assert.ok( - typeof result.message === "string" && /authentication expired/i.test(result.message), - `expected an auth-expired message, got: ${JSON.stringify(result)}` - ); - assert.deepEqual(result.quotas ?? {}, {}); - assert.ok(callIdx >= 2, "GetUsageLimits should have been called after profile discovery"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -// IAM Identity Center / Builder-ID accounts must keep the existing throw-on-failure behavior so -// transient upstream errors don't get silently masked as "auth expired". -test("getKiroUsage still throws on 401/403 for non-social Kiro accounts (Builder-ID/IDC)", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (_url: string, init?: RequestInit) => { - const target = String((init?.headers as Record | undefined)?.["x-amz-target"] || ""); - if (target.endsWith("ListAvailableProfiles")) { - return new Response( - JSON.stringify({ profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/BID" }] }), - { status: 200, headers: { "Content-Type": "application/json" } } - ); - } - return new Response("denied", { status: 401 }); - }) as typeof fetch; - try { - await assert.rejects( - () => getKiroUsage("builder-tok", { authMethod: "builder-id" }), - /Failed to fetch Kiro usage/i - ); - } finally { - globalThis.fetch = originalFetch; - } -}); +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + __testing, + buildKiroUsageResult, + discoverKiroProfileArn, +} from "@omniroute/open-sse/services/usage.ts"; + +const { getKiroUsage } = __testing; + +// Real-world shape returned by GetUsageLimits for an AWS IAM Identity Center ("KIRO POWER") +// account — the usage is reported under resourceType "CREDIT" (not "AGENTIC_REQUEST"). +const IAM_CREDIT_RESPONSE = { + daysUntilReset: 0, + nextDateReset: 1.782864e9, + subscriptionInfo: { subscriptionTitle: "KIRO POWER", type: "Q_DEVELOPER_STANDALONE_POWER" }, + usageBreakdownList: [ + { + currency: "USD", + currentUsage: 3670, + currentUsageWithPrecision: 3670.9, + displayName: "Credit", + resourceType: "CREDIT", + unit: "INVOCATIONS", + usageLimit: 10000, + usageLimitWithPrecision: 10000.0, + }, + ], +}; + +test("buildKiroUsageResult parses the IAM CREDIT breakdown into non-zero usage", () => { + const result = buildKiroUsageResult(IAM_CREDIT_RESPONSE) as { + plan: string; + quotas: Record; + }; + assert.ok("quotas" in result, "must return quotas for a CREDIT breakdown"); + assert.equal(result.plan, "KIRO POWER"); + const credit = result.quotas.credit; + assert.ok(credit, "CREDIT resource should map to a 'credit' quota key"); + assert.equal(credit.used, 3670.9); + assert.equal(credit.total, 10000); + assert.equal(credit.remaining, 10000 - 3670.9); +}); + +test("discoverKiroProfileArn prefers the region-matched profile ARN", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + profiles: [ + { arn: "arn:aws:codewhisperer:us-east-1:111111111111:profile/AAAA" }, + { arn: "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ" }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + )) as typeof fetch; + try { + const arn = await discoverKiroProfileArn( + "tok", + "https://q.eu-central-1.amazonaws.com", + "eu-central-1" + ); + assert.equal(arn, "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("discoverKiroProfileArn sends tokentype for API-key auth", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + const headers = init?.headers as Record; + assert.equal(headers.tokentype, "API_KEY"); + return new Response( + JSON.stringify({ + profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/APIKEY" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + try { + const arn = await discoverKiroProfileArn( + "api-key", + "https://codewhisperer.us-east-1.amazonaws.com", + "us-east-1", + "api_key" + ); + assert.equal(arn, "arn:aws:codewhisperer:us-east-1:1:profile/APIKEY"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("discoverKiroProfileArn falls back to the first profile when no region match", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/X" }] }), + { status: 200, headers: { "Content-Type": "application/json" } } + )) as typeof fetch; + try { + const arn = await discoverKiroProfileArn( + "tok", + "https://q.eu-west-1.amazonaws.com", + "eu-west-1" + ); + assert.equal(arn, "arn:aws:codewhisperer:us-east-1:1:profile/X"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("discoverKiroProfileArn returns undefined for empty profiles or non-ok response", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async () => + new Response(JSON.stringify({ profiles: [] }), { status: 200 })) as typeof fetch; + assert.equal( + await discoverKiroProfileArn("tok", "https://q.eu-central-1.amazonaws.com", "eu-central-1"), + undefined + ); + + globalThis.fetch = (async () => new Response("nope", { status: 403 })) as typeof fetch; + assert.equal( + await discoverKiroProfileArn("tok", "https://q.eu-central-1.amazonaws.com", "eu-central-1"), + undefined + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// Regression: when a Kiro account added via Google/GitHub social-auth (authMethod "imported" +// with provider "Google" or "Github" — set by /api/oauth/kiro/social-exchange/route.ts) has its +// token rejected by the AWS CodeWhisperer quota API (401/403), surface a clear "auth expired, +// chat may still work" message instead of throwing a generic upstream-error blob. +test("getKiroUsage returns a friendly auth-expired message for social-auth Kiro on 401/403", async () => { + const originalFetch = globalThis.fetch; + // First call (ListAvailableProfiles for ARN discovery) succeeds with an ARN so we proceed + // to GetUsageLimits, which then returns 401. The friendly branch only applies when the + // GetUsageLimits call returned an auth-shaped error. + let callIdx = 0; + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + callIdx += 1; + const target = String( + (init?.headers as Record | undefined)?.["x-amz-target"] || "" + ); + if (target.endsWith("ListAvailableProfiles")) { + return new Response( + JSON.stringify({ profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/SOCIAL" }] }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + // GetUsageLimits → simulate the social-auth token rejection + return new Response(JSON.stringify({ __type: "AccessDeniedException" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + try { + const result = (await getKiroUsage("social-tok", { + authMethod: "imported", + provider: "Google", + })) as { message?: string; quotas?: Record }; + assert.ok(result, "should resolve, not throw"); + assert.ok( + typeof result.message === "string" && /authentication expired/i.test(result.message), + `expected an auth-expired message, got: ${JSON.stringify(result)}` + ); + assert.deepEqual(result.quotas ?? {}, {}); + assert.ok(callIdx >= 2, "GetUsageLimits should have been called after profile discovery"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("getKiroUsage sends tokentype for API-key quota requests", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + const headers = init?.headers as Record; + assert.equal(headers.tokentype, "API_KEY"); + return new Response( + JSON.stringify({ + usageBreakdownList: [ + { + resourceType: "AGENTIC_REQUEST", + currentUsageWithPrecision: 1, + usageLimitWithPrecision: 10, + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + try { + const result = (await getKiroUsage("api-key", { + authMethod: "api_key", + profileArn: "arn:aws:codewhisperer:us-east-1:1:profile/APIKEY", + region: "us-east-1", + })) as { quotas?: Record }; + assert.equal(result.quotas?.agentic_request.used, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("getKiroUsage returns a friendly rejected-token message on repeated 401/403", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + return new Response("denied", { status: 401 }); + }) as typeof fetch; + try { + const result = (await getKiroUsage("builder-tok", { + authMethod: "builder-id", + profileArn: "arn:aws:codewhisperer:us-east-1:1:profile/BID", + })) as { message?: string; quotas?: Record }; + assert.match( + result.message || "", + /quota API rejected the current token/i, + `expected friendly rejected-token message, got: ${JSON.stringify(result)}` + ); + assert.deepEqual(result.quotas ?? {}, {}); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/provider-onboarding-wizard.test.ts b/tests/unit/provider-onboarding-wizard.test.ts index adc78f768c..688b738022 100644 --- a/tests/unit/provider-onboarding-wizard.test.ts +++ b/tests/unit/provider-onboarding-wizard.test.ts @@ -12,8 +12,11 @@ test("provider onboarding catalog exposes API-key and OAuth providers for the wi assert.ok(apiKeyOptions.some((option) => option.id === "openai")); assert.ok(apiKeyOptions.some((option) => option.id === "openrouter")); + assert.ok(!apiKeyOptions.some((option) => option.id === "kiro")); + assert.ok(!apiKeyOptions.some((option) => option.id === "amazon-q")); assert.ok(oauthOptions.some((option) => option.id === "claude")); assert.ok(oauthOptions.some((option) => option.id === "kiro")); + assert.ok(oauthOptions.some((option) => option.id === "amazon-q")); assert.ok(oauthOptions.some((option) => option.id === "cursor")); assert.ok(!oauthOptions.some((option) => option.id === "zed")); assert.ok(!oauthOptions.some((option) => option.id === "windsurf")); diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index a97127bc32..a9317bcb38 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -26,7 +26,7 @@ test.afterEach(() => { __setGrokTlsFetchOverride(null); }); -function toPlainHeaders(headers: any) { +function toPlainHeaders(headers: HeadersInit | undefined) { if (headers instanceof Headers) return Object.fromEntries(headers.entries()); return Object.fromEntries( Object.entries(headers || {}).map(([key, value]) => [key, String(value)]) @@ -57,6 +57,129 @@ data: `; } +test("Kiro API key validator resolves profiles with bearer auth", async () => { + const calls: Array<{ url: string; headers: Record }> = []; + globalThis.fetch = async (url, init = {}) => { + const headers = toPlainHeaders(init.headers); + calls.push({ url: String(url), headers }); + + assert.equal(String(url), "https://codewhisperer.us-east-1.amazonaws.com"); + assert.equal(headers.Authorization, "Bearer ksk-valid"); + assert.equal(headers["x-amz-target"], "AmazonCodeWhispererService.ListAvailableProfiles"); + assert.equal(headers.Accept, "application/json"); + + return new Response( + JSON.stringify({ + profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:123:profile/API" }], + }), + { status: 200 } + ); + }; + + const result = await validateProviderApiKey({ + provider: "kiro", + apiKey: "ksk-valid", + providerSpecificData: { region: "us-east-1" }, + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.equal(result.method, "kiro_list_available_profiles"); + assert.equal(calls.length, 1); +}); + +test("Kiro API key validator accepts API keys that cannot list profiles", async () => { + const calls: Array<{ + url: string; + headers: Record; + body?: Record; + }> = []; + globalThis.fetch = async () => new Response("unexpected", { status: 500 }); + + globalThis.fetch = async (url, init = {}) => { + const headers = toPlainHeaders(init.headers); + const body = init.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ url: String(url), headers, body }); + + if (calls.length === 1) { + return new Response( + JSON.stringify({ + __type: "com.amazon.aws.codewhisperer#AccessDeniedException", + message: "API key authentication is not supported for this operation.", + }), + { status: 403 } + ); + } + + assert.equal( + String(url), + "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse" + ); + assert.equal(headers.Authorization, "Bearer ksk-valid-without-profile-list"); + assert.equal(headers.tokentype, "API_KEY"); + assert.equal( + headers["X-Amz-Target"] || headers["x-amz-target"], + "AmazonCodeWhispererStreamingService.GenerateAssistantResponse" + ); + assert.equal(body.conversationState.currentMessage.userInputMessage.modelId, "auto"); + assert.equal(body.inferenceConfig.maxTokens, 1); + return new Response(new ReadableStream(), { status: 200 }); + }; + + const result = await validateProviderApiKey({ + provider: "kiro", + apiKey: "ksk-valid-without-profile-list", + providerSpecificData: { region: "us-east-1" }, + }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.equal(result.method, "kiro_generate_assistant_response"); + assert.equal(calls.length, 2); +}); + +test("Kiro API key validator rejects runtime auth failures after profile lookup is unsupported", async () => { + let calls = 0; + globalThis.fetch = async () => { + calls++; + if (calls === 1) { + return new Response( + JSON.stringify({ + __type: "com.amazon.aws.codewhisperer#AccessDeniedException", + message: "API key authentication is not supported for this operation.", + }), + { status: 403 } + ); + } + return new Response(JSON.stringify({ message: "bearer token is invalid" }), { status: 403 }); + }; + + const result = await validateProviderApiKey({ + provider: "kiro", + apiKey: "ksk-runtime-invalid", + providerSpecificData: { region: "us-east-1" }, + }); + + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid Kiro API key or AWS region"); + assert.equal(calls, 2); +}); + +test("Kiro API key validator fails as invalid instead of unsupported", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ message: "Access denied" }), { status: 403 }); + + const result = await validateProviderApiKey({ + provider: "kiro", + apiKey: "ksk-invalid", + providerSpecificData: { region: "us-east-1" }, + }); + + assert.equal(result.valid, false); + assert.equal(result.unsupported, false); + assert.match(result.error || "", /Failed to list profiles/); +}); + test("specialty provider validators cover Deepgram, AssemblyAI, ElevenLabs and Inworld branches", async () => { globalThis.fetch = async (url, init = {}) => { const target = String(url); @@ -711,7 +834,7 @@ test("grok-web validator: Cloudflare challenge page is detected and reported", a const { __setTlsFetchOverrideForTesting } = await import("../../open-sse/services/chatgptTlsClient.ts"); -function makeTlsResponse(status: number, body: string, headers: Record = {}): any { +function makeTlsResponse(status: number, body: string, headers: Record = {}) { const h = new Headers(); for (const [k, v] of Object.entries(headers)) h.set(k, v); return { status, headers: h, text: body, body: null }; @@ -722,7 +845,7 @@ test.afterEach(() => { }); test("chatgpt-web validator: accepts a valid session response with accessToken", async () => { - let captured: { url: string; opts: any } | null = null; + let captured: { url: string; opts: unknown } | null = null; __setTlsFetchOverrideForTesting(async (url, opts) => { captured = { url, opts }; return makeTlsResponse( @@ -2165,7 +2288,12 @@ test("specialty validator rejects invalid Runway credentials", async () => { }); test("validateCommandCodeProvider sends Command Code probe URL, headers, and wrapper body", async () => { - const calls: any[] = []; + const calls: Array<{ + url: string; + method?: string; + headers?: HeadersInit; + body?: BodyInit | null; + }> = []; globalThis.fetch = async (url, init = {}) => { calls.push({ url: String(url), @@ -2230,18 +2358,14 @@ test("validateCommandCodeProvider rejects auth failures and provider outages", a const { __setTlsFetchOverrideForTesting: __setClaudeTlsFetchOverride } = await import("../../open-sse/services/claudeTlsClient.ts"); -function makeClaudeTlsResponse( - status: number, - body: string, - headers: Record = {} -): any { +function makeClaudeTlsResponse(status: number, body: string, headers: Record = {}) { const h = new Headers(); for (const [k, v] of Object.entries(headers)) h.set(k, v); return { status, ok: status >= 200 && status < 300, headers: h, text: body, body: null }; } test("claude-web validator: 200 from /api/organizations → valid", async () => { - let captured: { url: string; opts: any } | null = null; + let captured: { url: string; opts: unknown } | null = null; __setClaudeTlsFetchOverride(async (url, opts) => { captured = { url, opts }; return makeClaudeTlsResponse(200, JSON.stringify({ orgs: [] })); @@ -2576,7 +2700,7 @@ test("llama-cpp is classified as a self-hosted chat provider", async () => { // ─── Gitlawb Opengateway specialty validators ────────────────────────────── test("gitlawb validator: accepts valid API key via chat/completions probe", async () => { - const calls: any[] = []; + const calls: Array<{ url: string; headers?: HeadersInit; body?: BodyInit | null }> = []; globalThis.fetch = async (url, init = {}) => { calls.push({ url: String(url), headers: init.headers || {}, body: init.body }); assert.equal(String(url), "https://opengateway.gitlawb.com/v1/xiaomi-mimo/chat/completions"); @@ -2661,7 +2785,7 @@ test("gitlawb validator: accepts custom baseUrl override", async () => { // ─── Gitlawb-GMI (GMI Cloud) ───────────────────────────────────────────── test("gitlawb-gmi validator: accepts valid API key via chat/completions probe", async () => { - const calls: any[] = []; + const calls: Array<{ url: string; headers?: HeadersInit }> = []; globalThis.fetch = async (url, init = {}) => { calls.push({ url: String(url), headers: init.headers || {} }); assert.equal(String(url), "https://opengateway.gitlawb.com/v1/gmi-cloud/chat/completions"); diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index 33f4718ffe..dbc4bc1162 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -1071,6 +1071,10 @@ test("connectionMatchesProviderCard counts a dual-auth provider's PAT (apikey) c connectionMatchesProviderCard({ provider: "qoder", authType: "apikey" }, "qoder", "oauth"), true ); + assert.equal( + connectionMatchesProviderCard({ provider: "kiro", authType: "api_key" }, "kiro", "oauth"), + true + ); assert.equal( connectionMatchesProviderCard({ provider: "qoder", authType: "oauth" }, "qoder", "oauth"), true diff --git a/tests/unit/shared/components/KiroAuthModal.test.tsx b/tests/unit/shared/components/KiroAuthModal.test.tsx index 728657400c..948072d217 100644 --- a/tests/unit/shared/components/KiroAuthModal.test.tsx +++ b/tests/unit/shared/components/KiroAuthModal.test.tsx @@ -15,6 +15,12 @@ function makeContainer(): HTMLElement { return container; } +function setInputValue(input: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + describe("KiroAuthModal", () => { beforeEach(() => { ( @@ -53,4 +59,54 @@ describe("KiroAuthModal", () => { expect(onMethodSelect).toHaveBeenCalledWith("social", { provider: "google" }); }); + + it("notifies API key import success before closing the modal", async () => { + const { default: KiroAuthModal } = await import("@/shared/components/KiroAuthModal"); + const container = makeContainer(); + const root = createRoot(container); + const calls: string[] = []; + const onMethodSelect = vi.fn(() => calls.push("select")); + const onClose = vi.fn(() => calls.push("close")); + const originalFetch = globalThis.fetch; + + globalThis.fetch = vi.fn(async () => { + return new Response(JSON.stringify({ success: true, connection: { id: "conn-1" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + + try { + await act(async () => { + root.render(); + }); + + const apiKeyButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.querySelector("h3")?.textContent === "API Key" + ); + + await act(async () => { + apiKeyButton?.click(); + }); + + const apiKeyInput = container.querySelector("input") as HTMLInputElement; + const saveButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Validate and Save API Key") + ); + + await act(async () => { + setInputValue(apiKeyInput, "ksk_test_key"); + }); + + await act(async () => { + saveButton?.click(); + }); + + expect(onMethodSelect).toHaveBeenCalledWith("api-key"); + expect(onClose).toHaveBeenCalled(); + expect(calls).toEqual(["select", "close"]); + } finally { + globalThis.fetch = originalFetch; + } + }); }); diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index 3421b2563b..2b34de746b 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -1046,6 +1046,29 @@ test("buildKiroPayload accepts kr/* model ids without the [1m] suffix", () => { ); }); +test("buildKiroPayload strips local Kiro selector suffixes before upstream", () => { + const body = { messages: [{ role: "user", content: "Hello" }] }; + + const result = buildKiroPayload("claude-opus-4.8-thinking-agentic", body, true, {}); + assert.equal( + result.conversationState.currentMessage.userInputMessage.modelId, + "claude-opus-4.8", + "local -thinking/-agentic aliases must not be forwarded to Kiro" + ); + assert.equal( + result.additionalModelRequestFields?.output_config?.effort, + "high", + "the -thinking selector should still request Kiro adaptive thinking" + ); +}); + +test("buildKiroPayload maps auto-kiro selector to Kiro auto upstream id", () => { + const body = { messages: [{ role: "user", content: "Hello" }] }; + + const result = buildKiroPayload("auto-kiro", body, true, {}); + assert.equal(result.conversationState.currentMessage.userInputMessage.modelId, "auto"); +}); + // Regression for upstream decolua/9router PR #2270: the dash->dot normalization's // trailing minor-version group must be bounded (1-2 digits), otherwise a // date-suffixed Claude model id (e.g. claude-opus-4-20250514) gets corrupted into From 6103fd72396e0e0cf6145b39fdd4c540addd15e0 Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Fri, 10 Jul 2026 04:19:32 +0200 Subject: [PATCH 6/8] fix: Stabilize live dashboard WebSocket routing (#6335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): allow anonymous WS handshake + public /api/health/ping The live-dashboard WebSocket descriptor handshake (GET /api/v1/ws?handshake=1) and the lightweight GET /api/health/ping liveness probe both 401'd for unauthenticated callers, even though both are metadata-only reads intended to be public. clientApiPolicy required a bearer/dashboard-session before the WS route handler could even return its own wsAuth/protocol descriptor, and /api/health/ping was never added to PUBLIC_READONLY_API_ROUTE_PREFIXES despite its own docstring documenting it as "No auth required". clientApiPolicy.evaluate() now allows an anonymous {kind:"anonymous", id:"ws-handshake"} subject for GET/HEAD/OPTIONS on /api/v1/ws?handshake=1 — the route handler still performs its own real wsAuth/dashboard/API-key decision before opening the socket — and /api/health/ping is now in PUBLIC_READONLY_API_ROUTE_PREFIXES. Re-scoped from the original PR per review-group-prs analysis: the overlapping hardcoded /live-ws path-derivation change (useLiveDashboard.ts, ws/route.ts) is dropped here since it conflicts with #6072's different (dynamic, env-derived) approach to the same problem; only the non-overlapping auth-policy win ships in this PR. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(changelog): resync CHANGELOG.md after merging release/v3.8.47 Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: JxnLexn --- CHANGELOG.md | 1 + src/server/authz/policies/clientApi.ts | 20 +++++++++++ src/shared/constants/publicApiRoutes.ts | 1 + tests/unit/authz/classify.test.ts | 6 ++++ tests/unit/authz/client-api-policy.test.ts | 42 +++++++++++++++++++++- tests/unit/public-api-routes.test.ts | 5 +++ 6 files changed, 74 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06bb015be9..d991eaf35f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **fix(docker):** compile better-sqlite3 for the server Docker image (Dokploy/self-hosted builds) via a direct `node-gyp rebuild` inside `node_modules/better-sqlite3`, instead of `npm rebuild better-sqlite3` ([#6700](https://github.com/diegosouzapw/OmniRoute/pull/6700)) — the `builder` stage installs dependencies with `npm ci --ignore-scripts` (deliberate: closes the supply-chain surface where a transitive dep's install script runs arbitrary code) and re-enables the native build for the one package that needs it; `npm rebuild ` re-runs that indirectly through the package's own install script, which under npm 11 depends on npm's script-allowlist machinery correctly re-enabling it — some self-hosted build environments (e.g. Dokploy) hit a broken/mismatched native binding through that indirection. Invoking `node-gyp rebuild` directly bypasses npm's script-running layer entirely and is deterministic regardless of npm version. Regression guard: `tests/unit/dockerfile-better-sqlite3-node-gyp-6700.test.ts`. (thanks @nowhats-br) - **fix(providers):** the Cloudflare relay Worker deploy fix in #6416/#6618 still failed uploads in practice — it changed the multipart Content-Type but kept the emitted worker source as an ES module (`export default { fetch(...) }`) with `main_module` metadata; Cloudflare's Workers upload API parses a plain `application/javascript` script part as Service Worker syntax regardless of the `main_module` metadata field, and `main_module` requires the script to actually be an ES module (top-level `export`), so the mismatch still rejected the upload ([#6496](https://github.com/diegosouzapw/OmniRoute/pull/6496)). `buildCloudflareWorkerScript()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now emits Service Worker syntax (`addEventListener("fetch", ...)`, no top-level `export`) and the upload metadata uses `body_part` instead of `main_module`. Regression guard: `tests/unit/relay-deploy-5128.test.ts` (asserts the emitted script has no `export default`, registers a `fetch` listener, and the upload metadata carries `body_part`/omits `main_module`; also proves the inlined `isPrivateHostname()` SSRF guard still rejects bracketed IPv6 loopback/ULA hosts like `[::1]`/`[fd00::1]` after the script-body rewrite). (thanks @SeaXen) - **fix(providers):** ChatGPT Web (`chatgpt-web`) responses rendered raw ChatGPT UI citation markup — private-use marker tokens (e.g. `citeturn0search0`) and `url…` inline-link markers — instead of real Markdown links, since these only ever get resolved client-side by chatgpt.com's own JS using `message.metadata.content_references` ([#6635](https://github.com/diegosouzapw/OmniRoute/pull/6635)) — `cleanChatGptText()` now resolves `content_references` (grouped webpages, footnote sources, inline `webpage`/`url` mentions) into `[label](url)` Markdown links for both the streaming and non-streaming response builders, and for the GPT-5.5 Pro `stream_handoff` polled-answer path, falling back to stripping any marker that has no resolvable source instead of leaking the raw private-use bytes. The citation parsing/rendering logic was extracted into a new pure sibling module (`open-sse/executors/chatgpt-web/citations.ts`) to keep the executor under the frozen file-size cap. Regression guard: `tests/unit/chatgpt-web-citations.test.ts` (non-streaming citation resolution, streaming marker buffering across split SSE chunks, and the Pro-handoff polled-answer path). (thanks @Thinkscape) +- **fix(dashboard):** the live-dashboard WebSocket descriptor handshake (`GET /api/v1/ws?handshake=1`) and the lightweight `GET /api/health/ping` liveness probe both 401'd for unauthenticated callers, even though both are metadata-only reads intended to be public ([#6335](https://github.com/diegosouzapw/OmniRoute/pull/6335)) — `clientApiPolicy` required a bearer/dashboard-session before the WS route handler could even return its own `wsAuth`/protocol descriptor, and `/api/health/ping` was never added to `PUBLIC_READONLY_API_ROUTE_PREFIXES` despite its own docstring documenting it as "No auth required." `clientApiPolicy.evaluate()` now allows an anonymous `{kind:"anonymous", id:"ws-handshake"}` subject for GET/HEAD/OPTIONS on `/api/v1/ws?handshake=1` (the route handler still performs its own real wsAuth/dashboard/API-key decision before opening the socket), and `/api/health/ping` is now in `PUBLIC_READONLY_API_ROUTE_PREFIXES`. Regression guard: `tests/unit/authz/client-api-policy.test.ts` (WS handshake allowed, including relative request URLs), `tests/unit/public-api-routes.test.ts`, and `tests/unit/authz/classify.test.ts` (`/api/health/ping` classified `PUBLIC`). (thanks @JxnLexn) ### 📝 Maintenance diff --git a/src/server/authz/policies/clientApi.ts b/src/server/authz/policies/clientApi.ts index 1f2363d36c..6545b11d02 100644 --- a/src/server/authz/policies/clientApi.ts +++ b/src/server/authz/policies/clientApi.ts @@ -4,6 +4,19 @@ import { extractApiKey } from "@/sse/services/auth.ts"; import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context"; import { allow, reject } from "../context"; +const HANDSHAKE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +function isWsHandshake(ctx: PolicyContext): boolean { + if (ctx.classification.normalizedPath !== "/api/v1/ws") return false; + if (!HANDSHAKE_METHODS.has(ctx.request.method.toUpperCase())) return false; + + try { + return new URL(ctx.request.url, "http://localhost").searchParams.get("handshake") === "1"; + } catch { + return false; + } +} + function extractBearer(request: Request): string | null { const raw = request.headers.get("authorization") ?? request.headers.get("Authorization"); const xApiKey = request.headers.get("x-api-key") ?? request.headers.get("X-Api-Key"); @@ -37,6 +50,13 @@ export const clientApiPolicy: RoutePolicy = { async evaluate(ctx: PolicyContext): Promise { const bearer = extractBearer(ctx.request as Request); if (!bearer) { + // The WS descriptor handshake is a metadata read; the route handler + // performs the actual wsAuth/dashboard/API-key decision and returns the + // protocol details the browser needs before opening the socket. + if (isWsHandshake(ctx)) { + return allow({ kind: "anonymous", id: "ws-handshake" }); + } + if (await isDashboardSessionAuthenticated(ctx.request)) { return allow({ kind: "dashboard_session", id: "dashboard" }); } diff --git a/src/shared/constants/publicApiRoutes.ts b/src/shared/constants/publicApiRoutes.ts index 70853e142a..b4325ed46b 100644 --- a/src/shared/constants/publicApiRoutes.ts +++ b/src/shared/constants/publicApiRoutes.ts @@ -20,6 +20,7 @@ const PUBLIC_API_ROUTE_PREFIXES = [ ]; const PUBLIC_READONLY_API_ROUTE_PREFIXES = [ + "/api/health/ping", "/api/monitoring/health", "/api/settings/require-login", ]; diff --git a/tests/unit/authz/classify.test.ts b/tests/unit/authz/classify.test.ts index f091bb2bf9..734109ce02 100644 --- a/tests/unit/authz/classify.test.ts +++ b/tests/unit/authz/classify.test.ts @@ -135,6 +135,12 @@ const cases: Case[] = [ method: "GET", expectedClass: "PUBLIC", }, + { + name: "/api/health/ping is PUBLIC", + path: "/api/health/ping", + method: "GET", + expectedClass: "PUBLIC", + }, { name: "/api/cloud/auth POST is PUBLIC", path: "/api/cloud/auth", diff --git a/tests/unit/authz/client-api-policy.test.ts b/tests/unit/authz/client-api-policy.test.ts index fb8c5e41d1..081ab50e43 100644 --- a/tests/unit/authz/client-api-policy.test.ts +++ b/tests/unit/authz/client-api-policy.test.ts @@ -51,12 +51,30 @@ async function loadPolicy() { } function ctx(headers: Headers, method = "POST", normalizedPath = "/api/v1/chat/completions") { + const pathOnly = normalizedPath.split("?")[0]; return { request: { method, headers, url: `http://localhost${normalizedPath}` }, classification: { routeClass: "CLIENT_API" as const, reason: "client_api_v1" as const, - normalizedPath, + normalizedPath: pathOnly, + }, + requestId: "req_test", + }; +} + +function relativeUrlCtx( + headers: Headers, + method = "POST", + normalizedPath = "/api/v1/chat/completions" +) { + const pathOnly = normalizedPath.split("?")[0]; + return { + request: { method, headers, url: normalizedPath }, + classification: { + routeClass: "CLIENT_API" as const, + reason: "client_api_v1" as const, + normalizedPath: pathOnly, }, requestId: "req_test", }; @@ -82,6 +100,28 @@ test("clientApiPolicy: missing bearer is rejected with 401", async () => { } }); +test("clientApiPolicy: websocket descriptor handshake can reach the route handler", async () => { + const policy = await loadPolicy(); + const out = await policy.evaluate(ctx(new Headers(), "GET", "/api/v1/ws?handshake=1")); + + assert.equal(out.allow, true); + if (out.allow) { + assert.equal(out.subject.kind, "anonymous"); + assert.equal(out.subject.id, "ws-handshake"); + } +}); + +test("clientApiPolicy: websocket descriptor handshake accepts relative request URLs", async () => { + const policy = await loadPolicy(); + const out = await policy.evaluate(relativeUrlCtx(new Headers(), "GET", "/api/v1/ws?handshake=1")); + + assert.equal(out.allow, true); + if (out.allow) { + assert.equal(out.subject.kind, "anonymous"); + assert.equal(out.subject.id, "ws-handshake"); + } +}); + test("clientApiPolicy: REQUIRE_API_KEY DB feature flag override rejects anonymous", async () => { process.env.REQUIRE_API_KEY = "false"; featureFlagsDb.setFeatureFlagOverride("REQUIRE_API_KEY", "true"); diff --git a/tests/unit/public-api-routes.test.ts b/tests/unit/public-api-routes.test.ts index e480c528aa..1175bf2168 100644 --- a/tests/unit/public-api-routes.test.ts +++ b/tests/unit/public-api-routes.test.ts @@ -20,6 +20,11 @@ test("isPublicApiRoute keeps cloud read/auth routes public but not cloud write r }); test("isPublicApiRoute allows readonly health and require-login bootstrap routes", () => { + assert.equal(isPublicApiRoute("/api/health/ping", "GET"), true); + assert.equal(isPublicApiRoute("/api/health/ping", "HEAD"), true); + assert.equal(isPublicApiRoute("/api/health/ping", "OPTIONS"), true); + assert.equal(isPublicApiRoute("/api/health/ping", "DELETE"), false); + assert.equal(isPublicApiRoute("/api/monitoring/health", "GET"), true); assert.equal(isPublicApiRoute("/api/monitoring/health", "HEAD"), true); assert.equal(isPublicApiRoute("/api/monitoring/health", "OPTIONS"), true); From a5c555b0def126bea5a2174801cd3783c013d795 Mon Sep 17 00:00:00 2001 From: Hamsa_M <116961508+hamsa0x7@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:50:14 +0530 Subject: [PATCH 7/8] feat(icons): prioritize local SVG icons over LobeHub npm for faster rendering (#6317) * feat(icons): prioritize local SVG icons over LobeHub npm for faster rendering * docs(changelog): add #6317 local-icons New Features bullet --------- Co-authored-by: hamsa0x7 Co-authored-by: Diego Rodrigues de Sa e Souza --- CHANGELOG.md | 1 + public/providers/360ai.svg | 6 +- public/providers/alibaba.svg | 6 + public/providers/anthropic.svg | 1 + public/providers/arcee-ai.svg | 6 +- public/providers/arcee.svg | 5 + public/providers/assemblyai.svg | 7 + public/providers/aws.svg | 6 + public/providers/azure.svg | 1 + public/providers/azureai.svg | 82 +++++ public/providers/baichuan.svg | 6 +- public/providers/baidu.svg | 6 +- public/providers/bailian.svg | 16 + public/providers/baseten.svg | 4 + public/providers/cerebras.svg | 8 + public/providers/claude-web.svg | 6 +- public/providers/claude.svg | 2 +- public/providers/cline.svg | 1 + public/providers/cloudflare.svg | 7 + public/providers/cohere.svg | 13 + public/providers/comfyui.svg | 5 + public/providers/continue.png | Bin 33371 -> 0 bytes public/providers/continue.svg | 1 + public/providers/copilot.png | Bin 27346 -> 0 bytes public/providers/copilot.svg | 1 + public/providers/cursor.png | Bin 7736 -> 0 bytes public/providers/cursor.svg | 12 + public/providers/deepgram.png | 52 --- public/providers/deepgram.svg | 1 + public/providers/deepinfra.svg | 7 + public/providers/deepseek.svg | 5 + public/providers/dify.svg | 6 +- public/providers/doubao.svg | 6 +- public/providers/elevenlabs.svg | 1 + public/providers/exa.svg | 7 + public/providers/fal.svg | 7 + public/providers/fireworks.svg | 7 + public/providers/friendli.svg | 6 + public/providers/gemini.svg | 1 + public/providers/google.svg | 11 + public/providers/grok.svg | 4 + public/providers/groq.svg | 1 + public/providers/heroku.png | Bin 1787 -> 0 bytes public/providers/heroku.svg | 3 + public/providers/huggingchat.svg | 6 +- public/providers/huggingface.svg | 15 + public/providers/hyperbolic.svg | 5 + public/providers/ibm.svg | 3 + public/providers/iflytek.svg | 6 +- public/providers/inference.svg | 4 + public/providers/kimi.svg | 7 + public/providers/kiro.svg | 2 +- public/providers/lambda.svg | 4 + public/providers/longcat.svg | 8 + public/providers/meta.svg | 121 +++++++ public/providers/metaai.svg | 49 +++ public/providers/minimax.svg | 1 + public/providers/mistral.svg | 10 + public/providers/moonshot.svg | 4 + public/providers/morph.svg | 5 + public/providers/nebius.svg | 5 + public/providers/novita.svg | 7 + public/providers/nvidia.svg | 1 + public/providers/ollama.svg | 7 + public/providers/openai.svg | 1 + public/providers/openclaw.png | Bin 16740 -> 0 bytes public/providers/openclaw.svg | 1 + public/providers/openrouter.svg | 21 ++ public/providers/ovhcloud.png | Bin 1167 -> 0 bytes public/providers/ovhcloud.svg | 1 + public/providers/perplexity.svg | 6 + public/providers/picoclaw.svg | 30 ++ public/providers/poe.svg | 1 + public/providers/pollinations.svg | 4 + public/providers/qwen.svg | 1 + public/providers/recraft.svg | 5 + public/providers/replicate.svg | 12 + public/providers/roocode.svg | 4 + public/providers/runway.svg | 14 + public/providers/sambanova.svg | 9 + public/providers/searchapi.svg | 4 + public/providers/sensenova.svg | 6 +- public/providers/snowflake.svg | 7 + public/providers/stepfun.svg | 6 +- public/providers/suno.svg | 1 + public/providers/tavily.svg | 15 + public/providers/tencent.svg | 6 +- public/providers/topazlabs.svg | 4 + public/providers/trae.svg | 5 + public/providers/udio.svg | 5 + public/providers/upstage.svg | 14 + public/providers/v0.svg | 1 + public/providers/vercel.svg | 1 + public/providers/vllm.svg | 5 + public/providers/volcengine.svg | 11 + public/providers/voyage.svg | 5 + public/providers/windsurf.svg | 1 + public/providers/xai.svg | 4 + public/providers/xinference.svg | 53 +++ public/providers/yi.svg | 6 +- public/providers/zhipu.svg | 6 + src/shared/components/ProviderIcon.tsx | 328 +++++++++++++------ tests/unit/ui/ProviderIcon-icon-url.test.tsx | 39 ++- 103 files changed, 1008 insertions(+), 239 deletions(-) create mode 100644 public/providers/alibaba.svg create mode 100644 public/providers/anthropic.svg create mode 100644 public/providers/arcee.svg create mode 100644 public/providers/assemblyai.svg create mode 100644 public/providers/aws.svg create mode 100644 public/providers/azure.svg create mode 100644 public/providers/azureai.svg create mode 100644 public/providers/bailian.svg create mode 100644 public/providers/baseten.svg create mode 100644 public/providers/cerebras.svg create mode 100644 public/providers/cline.svg create mode 100644 public/providers/cloudflare.svg create mode 100644 public/providers/cohere.svg create mode 100644 public/providers/comfyui.svg delete mode 100644 public/providers/continue.png create mode 100644 public/providers/continue.svg delete mode 100644 public/providers/copilot.png create mode 100644 public/providers/copilot.svg delete mode 100644 public/providers/cursor.png create mode 100644 public/providers/cursor.svg delete mode 100644 public/providers/deepgram.png create mode 100644 public/providers/deepgram.svg create mode 100644 public/providers/deepinfra.svg create mode 100644 public/providers/deepseek.svg create mode 100644 public/providers/elevenlabs.svg create mode 100644 public/providers/exa.svg create mode 100644 public/providers/fal.svg create mode 100644 public/providers/fireworks.svg create mode 100644 public/providers/friendli.svg create mode 100644 public/providers/gemini.svg create mode 100644 public/providers/google.svg create mode 100644 public/providers/grok.svg create mode 100644 public/providers/groq.svg delete mode 100644 public/providers/heroku.png create mode 100644 public/providers/heroku.svg create mode 100644 public/providers/huggingface.svg create mode 100644 public/providers/hyperbolic.svg create mode 100644 public/providers/ibm.svg create mode 100644 public/providers/inference.svg create mode 100644 public/providers/kimi.svg create mode 100644 public/providers/lambda.svg create mode 100644 public/providers/longcat.svg create mode 100644 public/providers/meta.svg create mode 100644 public/providers/metaai.svg create mode 100644 public/providers/minimax.svg create mode 100644 public/providers/mistral.svg create mode 100644 public/providers/moonshot.svg create mode 100644 public/providers/morph.svg create mode 100644 public/providers/nebius.svg create mode 100644 public/providers/novita.svg create mode 100644 public/providers/nvidia.svg create mode 100644 public/providers/ollama.svg create mode 100644 public/providers/openai.svg delete mode 100644 public/providers/openclaw.png create mode 100644 public/providers/openclaw.svg create mode 100644 public/providers/openrouter.svg delete mode 100644 public/providers/ovhcloud.png create mode 100644 public/providers/ovhcloud.svg create mode 100644 public/providers/perplexity.svg create mode 100644 public/providers/picoclaw.svg create mode 100644 public/providers/poe.svg create mode 100644 public/providers/pollinations.svg create mode 100644 public/providers/qwen.svg create mode 100644 public/providers/recraft.svg create mode 100644 public/providers/replicate.svg create mode 100644 public/providers/roocode.svg create mode 100644 public/providers/runway.svg create mode 100644 public/providers/sambanova.svg create mode 100644 public/providers/searchapi.svg create mode 100644 public/providers/snowflake.svg create mode 100644 public/providers/suno.svg create mode 100644 public/providers/tavily.svg create mode 100644 public/providers/topazlabs.svg create mode 100644 public/providers/trae.svg create mode 100644 public/providers/udio.svg create mode 100644 public/providers/upstage.svg create mode 100644 public/providers/v0.svg create mode 100644 public/providers/vercel.svg create mode 100644 public/providers/vllm.svg create mode 100644 public/providers/volcengine.svg create mode 100644 public/providers/voyage.svg create mode 100644 public/providers/windsurf.svg create mode 100644 public/providers/xai.svg create mode 100644 public/providers/xinference.svg create mode 100644 public/providers/zhipu.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index d991eaf35f..73f92ef4b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **Provider/model param filters**: config-driven parameter denylist/allowlist per provider/model with auto-learn from upstream 400s (#6649 — thanks @ThongAccount, closes #6625) - **Per-combo reasoning token buffer toggle**: the combo builder now exposes an explicit checkbox for the `#3587` reasoning-model `max_tokens` buffer, defaulting to the existing enabled behavior, so a combo can opt out without hand-editing raw JSON config (#6702 — thanks @xz-dev) - **feat(dashboard):** 9router-parity **Routing Strategy** settings card on Settings → Routing, plus a per-provider account-routing override on the provider detail page ([#6678](https://github.com/diegosouzapw/OmniRoute/pull/6678)) — surfaces the existing account round-robin / sticky-limit knobs and adds a new combo-level sticky round-robin (`comboStickyRoundRobinLimit`, resolved via `resolveComboStickyRoundRobinLimit()` — per-combo → global combo sticky → account sticky cascade) so combo targets can batch calls per target the same way account fallback already does. A new `providerStrategies` setting (Zod-validated map, `src/shared/validation/settingsSchemas.ts`) lets a specific provider override the global `fallbackStrategy`/`stickyRoundRobinLimit` without touching the account-wide default, wired into `getProviderCredentials()` (`src/sse/services/auth.ts`) ahead of the global fallback. Regression guard: `tests/unit/combo-rr-sticky-9router.test.ts`, `tests/unit/settings-ui-layout-static.test.ts`. (thanks @SeaXen) +- **feat(icons):** provider logos now resolve local SVG assets first for faster rendering, with a 5-tier fallback chain — local SVG → `@lobehub/icons` React components → `thesvg.org` CDN (external SVG for unknown providers) → local PNG → generic AI icon — replacing the previous LobeHub-first order. Adds dozens of first-party provider SVGs and migrates several bitmap logos (continue/copilot/cursor/deepgram/heroku/openclaw/ovhcloud) from PNG to SVG. Regression guard: `tests/unit/ui/ProviderIcon-icon-url.test.tsx`. ([#6317](https://github.com/diegosouzapw/OmniRoute/pull/6317) — thanks @hamsa0x7) - **Skill Collector CLI detection**: new `GET /api/skills/collect/detect` + `POST /api/skills/collect/install` (and the `cli-skill-collector` agent skill) detect which coding CLIs (Claude Code, Codex, Cursor, Copilot, Cline, Hermes, OpenCode, etc.) are installed locally via `getCliRuntimeStatus()`, match them against GitHub agent-skill repos, and plan an install path per tool — replacing the standalone Skill Collector Python app. Both new routes and `GET/POST /api/github-skills` now require management auth (`requireManagementAuth()`) and are loopback-gated (`LOCAL_ONLY_API_PREFIXES` + `SPAWN_CAPABLE_PREFIXES`) since the detect route spawns a child process per candidate CLI tool (Hard Rules #15 + #17). The `omniroute_github_skills_install` MCP tool now reports the honest `action: "planned"` instead of `"installed"`, matching the REST route (#6294 — thanks @Moseyuh333) - **ClinePass dual-auth**: ClinePass now offers both sign-in methods on its dashboard page — OAuth (reusing the Cline WorkOS flow) as the primary "Connect" path, or a pasted BYOK API key via "Manual API key", instead of only the API-key-only provider shipped in #5942. The registry alias was aligned to `cp` (matching the `OAUTH_PROVIDERS` catalog alias) so `/` routing resolves correctly, the OAuth refresh dispatch now routes `clinepass` to the shared Cline refresh flow, and the duplicate API-key-only catalog entry was removed to keep ClinePass listed once. Regression guard: `tests/unit/clinepass-provider.test.ts`. (#6126 — thanks @hajilok) - **feat(oauth):** Kiro/Amazon Q auto-import now supports enterprise **External IdP** ("Your organization") logins via Microsoft Entra/Okta/Auth0/OneLogin/Ping/Google/Cognito — these org-issued tokens are not AWS SSO tokens (no `aorAAAAAG`-prefixed refresh token) and can't refresh through the AWS OIDC/Kiro-social path, so `tryAwsSsoCache()` now detects them (`authMethod`/`provider === "externalidp"`) and refreshes via the org IdP's own `tokenEndpoint` (public-client OAuth2 refresh grant, no client secret), persisting `TokenType: EXTERNAL_IDP` gating so the runtime executor sends the header the AWS CodeWhisperer API requires for these accounts; `tokenEndpoint` is SSRF-guarded against an HTTPS + known-IdP-host-suffix allowlist. (#6363 — thanks @artickc) diff --git a/public/providers/360ai.svg b/public/providers/360ai.svg index caeb6eb2c9..dc393ef67b 100644 --- a/public/providers/360ai.svg +++ b/public/providers/360ai.svg @@ -1,5 +1 @@ - - - - 360 - +AI360 \ No newline at end of file diff --git a/public/providers/alibaba.svg b/public/providers/alibaba.svg new file mode 100644 index 0000000000..f6b764f99d --- /dev/null +++ b/public/providers/alibaba.svg @@ -0,0 +1,6 @@ + + Alibaba + + \ No newline at end of file diff --git a/public/providers/anthropic.svg b/public/providers/anthropic.svg new file mode 100644 index 0000000000..f31bfac298 --- /dev/null +++ b/public/providers/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/public/providers/arcee-ai.svg b/public/providers/arcee-ai.svg index b63a13ab3f..dc809fc95e 100644 --- a/public/providers/arcee-ai.svg +++ b/public/providers/arcee-ai.svg @@ -1,5 +1 @@ - - - - AR - +Arcee \ No newline at end of file diff --git a/public/providers/arcee.svg b/public/providers/arcee.svg new file mode 100644 index 0000000000..df3fd30356 --- /dev/null +++ b/public/providers/arcee.svg @@ -0,0 +1,5 @@ + + Arcee + + \ No newline at end of file diff --git a/public/providers/assemblyai.svg b/public/providers/assemblyai.svg new file mode 100644 index 0000000000..c6d4cffee7 --- /dev/null +++ b/public/providers/assemblyai.svg @@ -0,0 +1,7 @@ + + AssemblyAI + + + \ No newline at end of file diff --git a/public/providers/aws.svg b/public/providers/aws.svg new file mode 100644 index 0000000000..0e1a90a7b0 --- /dev/null +++ b/public/providers/aws.svg @@ -0,0 +1,6 @@ + + AWS + + + \ No newline at end of file diff --git a/public/providers/azure.svg b/public/providers/azure.svg new file mode 100644 index 0000000000..038645c7e0 --- /dev/null +++ b/public/providers/azure.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/azureai.svg b/public/providers/azureai.svg new file mode 100644 index 0000000000..b9ae255923 --- /dev/null +++ b/public/providers/azureai.svg @@ -0,0 +1,82 @@ + + AzureAI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/baichuan.svg b/public/providers/baichuan.svg index fa3789f62e..11b27e7913 100644 --- a/public/providers/baichuan.svg +++ b/public/providers/baichuan.svg @@ -1,5 +1 @@ - - - - BC - +Baichuan \ No newline at end of file diff --git a/public/providers/baidu.svg b/public/providers/baidu.svg index 0c74817895..ead7f89822 100644 --- a/public/providers/baidu.svg +++ b/public/providers/baidu.svg @@ -1,5 +1 @@ - - - - BD - +Baidu \ No newline at end of file diff --git a/public/providers/bailian.svg b/public/providers/bailian.svg new file mode 100644 index 0000000000..7618aaad1c --- /dev/null +++ b/public/providers/bailian.svg @@ -0,0 +1,16 @@ + + Bailian (阿里云百炼) + + + + + + + + \ No newline at end of file diff --git a/public/providers/baseten.svg b/public/providers/baseten.svg new file mode 100644 index 0000000000..c6a5fb32c1 --- /dev/null +++ b/public/providers/baseten.svg @@ -0,0 +1,4 @@ + + Baseten + + \ No newline at end of file diff --git a/public/providers/cerebras.svg b/public/providers/cerebras.svg new file mode 100644 index 0000000000..88b7cb5632 --- /dev/null +++ b/public/providers/cerebras.svg @@ -0,0 +1,8 @@ + + Cerebras + + + \ No newline at end of file diff --git a/public/providers/claude-web.svg b/public/providers/claude-web.svg index 882c6d2245..62dc0db12d 100644 --- a/public/providers/claude-web.svg +++ b/public/providers/claude-web.svg @@ -1,5 +1 @@ - - - - CW - +Claude \ No newline at end of file diff --git a/public/providers/claude.svg b/public/providers/claude.svg index 8bc1fd43ba..62dc0db12d 100644 --- a/public/providers/claude.svg +++ b/public/providers/claude.svg @@ -1 +1 @@ - \ No newline at end of file +Claude \ No newline at end of file diff --git a/public/providers/cline.svg b/public/providers/cline.svg new file mode 100644 index 0000000000..8dcc05786c --- /dev/null +++ b/public/providers/cline.svg @@ -0,0 +1 @@ +Cline \ No newline at end of file diff --git a/public/providers/cloudflare.svg b/public/providers/cloudflare.svg new file mode 100644 index 0000000000..ac59cd621a --- /dev/null +++ b/public/providers/cloudflare.svg @@ -0,0 +1,7 @@ + + Cloudflare + + + \ No newline at end of file diff --git a/public/providers/cohere.svg b/public/providers/cohere.svg new file mode 100644 index 0000000000..62aec42312 --- /dev/null +++ b/public/providers/cohere.svg @@ -0,0 +1,13 @@ + + Cohere + + + + \ No newline at end of file diff --git a/public/providers/comfyui.svg b/public/providers/comfyui.svg new file mode 100644 index 0000000000..85e93cd06b --- /dev/null +++ b/public/providers/comfyui.svg @@ -0,0 +1,5 @@ + + ComfyUI + + \ No newline at end of file diff --git a/public/providers/continue.png b/public/providers/continue.png deleted file mode 100644 index f54685be8de738c2df92cd94cb4b8a2cacea4304..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33371 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 QSpWb407*qoM6N<$f@{sHO8@`> diff --git a/public/providers/continue.svg b/public/providers/continue.svg new file mode 100644 index 0000000000..e9f56c259e --- /dev/null +++ b/public/providers/continue.svg @@ -0,0 +1 @@ + diff --git a/public/providers/copilot.png b/public/providers/copilot.png deleted file mode 100644 index 9907963e408e15a0d4c96018f692001bfd4f7e6f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27346 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 diff --git a/public/providers/copilot.svg b/public/providers/copilot.svg new file mode 100644 index 0000000000..5426933b61 --- /dev/null +++ b/public/providers/copilot.svg @@ -0,0 +1 @@ +Copilot \ No newline at end of file diff --git a/public/providers/cursor.png b/public/providers/cursor.png deleted file mode 100644 index ec02b070bad87b6412334450a80ae435ffbff049..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7736 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 diff --git a/public/providers/cursor.svg b/public/providers/cursor.svg new file mode 100644 index 0000000000..10d50ca847 --- /dev/null +++ b/public/providers/cursor.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/public/providers/deepgram.png b/public/providers/deepgram.png deleted file mode 100644 index 6bee16b860..0000000000 --- a/public/providers/deepgram.png +++ /dev/null @@ -1,52 +0,0 @@ - - - - 404 - - - -
-

404

-

Not Found

-
- - - \ No newline at end of file diff --git a/public/providers/deepgram.svg b/public/providers/deepgram.svg new file mode 100644 index 0000000000..988a37743f --- /dev/null +++ b/public/providers/deepgram.svg @@ -0,0 +1 @@ +Deepgram \ No newline at end of file diff --git a/public/providers/deepinfra.svg b/public/providers/deepinfra.svg new file mode 100644 index 0000000000..458107109c --- /dev/null +++ b/public/providers/deepinfra.svg @@ -0,0 +1,7 @@ + + DeepInfra + + + \ No newline at end of file diff --git a/public/providers/deepseek.svg b/public/providers/deepseek.svg new file mode 100644 index 0000000000..abf56cfecb --- /dev/null +++ b/public/providers/deepseek.svg @@ -0,0 +1,5 @@ + + DeepSeek + + \ No newline at end of file diff --git a/public/providers/dify.svg b/public/providers/dify.svg index 80be2e3447..4cea903478 100644 --- a/public/providers/dify.svg +++ b/public/providers/dify.svg @@ -1,5 +1 @@ - - - - DF - +Dify \ No newline at end of file diff --git a/public/providers/doubao.svg b/public/providers/doubao.svg index 4317871d38..e2511454cf 100644 --- a/public/providers/doubao.svg +++ b/public/providers/doubao.svg @@ -1,5 +1 @@ - - - - DB - +Doubao \ No newline at end of file diff --git a/public/providers/elevenlabs.svg b/public/providers/elevenlabs.svg new file mode 100644 index 0000000000..d44617dbde --- /dev/null +++ b/public/providers/elevenlabs.svg @@ -0,0 +1 @@ +ElevenLabs \ No newline at end of file diff --git a/public/providers/exa.svg b/public/providers/exa.svg new file mode 100644 index 0000000000..bc9f73e106 --- /dev/null +++ b/public/providers/exa.svg @@ -0,0 +1,7 @@ + + Exa + + \ No newline at end of file diff --git a/public/providers/fal.svg b/public/providers/fal.svg new file mode 100644 index 0000000000..0e54aaf865 --- /dev/null +++ b/public/providers/fal.svg @@ -0,0 +1,7 @@ + + Fal + + \ No newline at end of file diff --git a/public/providers/fireworks.svg b/public/providers/fireworks.svg new file mode 100644 index 0000000000..946ee079af --- /dev/null +++ b/public/providers/fireworks.svg @@ -0,0 +1,7 @@ + + Fireworks + + \ No newline at end of file diff --git a/public/providers/friendli.svg b/public/providers/friendli.svg new file mode 100644 index 0000000000..7da5293002 --- /dev/null +++ b/public/providers/friendli.svg @@ -0,0 +1,6 @@ + + Friendli + + + + \ No newline at end of file diff --git a/public/providers/gemini.svg b/public/providers/gemini.svg new file mode 100644 index 0000000000..f8d7189dc6 --- /dev/null +++ b/public/providers/gemini.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/google.svg b/public/providers/google.svg new file mode 100644 index 0000000000..2f69df7189 --- /dev/null +++ b/public/providers/google.svg @@ -0,0 +1,11 @@ + + Google + + + + + \ No newline at end of file diff --git a/public/providers/grok.svg b/public/providers/grok.svg new file mode 100644 index 0000000000..7057a3bfa6 --- /dev/null +++ b/public/providers/grok.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/providers/groq.svg b/public/providers/groq.svg new file mode 100644 index 0000000000..cf90d287b1 --- /dev/null +++ b/public/providers/groq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/heroku.png b/public/providers/heroku.png deleted file mode 100644 index b96cb3f6a7d8e5a23715db2ec7cc7ed05914e9ae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1787 zcmb7Ec{H0@8^3Qtg2obS(ApZMwn)o_*ouyl9<&H*X)3DI##F~XC}ob$r;BP@>C}=b zgBnULsb!34>SJmtLy#JbVv@!(6baq=UUQ~r&iwg3_m6wd{hj+;o_o);J-$5yR45)^ z9sq&>fZzqTCqXulN1^4=D0w-woPvVhh(g8O!~iTn+LFRAQNOFAuK>( zAtV;s?gF~7K?HKg>ZgJL3LztlM#7+z3P3;zge+QKR{n1oLLg;iu_&}EvBA*hctl)$ zshZK=gRWW3+rt_M++v@oYvSDx`%!1-;Op8j?Z2quejsHKvM_|l!m@uA@{=^oOq9W@ z9x!xOJFXrP>$W`sFffR~BC)^)EOG?r=segyzyLV_#sMx1;DByHCIoK)r~s&*h_D7I z06eD*4go;63*41*wX=NL@lJp+zghI%e9*{Do$rWzr+&#qH?LPIiSW&I1h&Hb=@CAu zvDD)AMWr=St@Lh7OhjY}kio~O!1BDd#rK8N$;_EZQJPzEQK<1x1!0aEb+uI!7kxu) zhTb--b}7C5Pw>`nxg$ls^cVbo$p>dgg%r)T%6D^;RVn9JKS`|vHwf2$)>W)~_REeD zQHHi|>&7B`>$#t^MwxrP3?9U&z>%#cI$pN0g|o*b&&CwRJ}lf;pZIrv^#@+b@j zcoF`GqVvLAp*-pDTAdyJ|BcjP(jHMIiyH}qi^~ru6U?jp5*a=fUiLn5akn!BFZM5VC9V$fm;m5(vmv|^fn_SePnqFK%Jta761Z3Nn>JL?yJ4Lr+u53Pkh$gzrrzVVfj5- zGoRoj2@=21BIOq+1#aC*^}of+E1-$6N);WK>9+RK)Y795NG3Buj)foXCEp5duubAQ zd*^BwX{=-2{Dc#^++~v;pxFLJ9nEU$z??=_2@9DZud z%w6`Nb{ZwX%*#KXk|2?s=7@xLy*Dgr`5_I~8{BQcd}->}#Po2`w0*2OTDmeFJvTUe z#{PKjH5K^r+uYnw0gQ5!?$U!)4bx+-Y%*0Z{<>c1$&W(s=z~+8pqKoyi1fdEJlr1f6K36sI z{O(b0&-#hc<*0ZEz0EMiFxt7~JO4;#Ha+$~oxK{@xavr%lEr zuB@n!{Vb?}Ndzs|)XRA(Y2mh7cD3UQG1CU?BKLu#9Px@FlbQBBAaHHPM>t3gK zrb128*>ldU!KIjAG5KY#Vr{{ccueoIAo&`a_L+C|`+(e|*sL7wp$+NJ(O1;Uk4^}L zDHe+{jc?g`X;!F*R-$RAA5mi^a)38wXqXA0&D^oCLq?k}AJMVJSJ>5c3I6O^yYc=A z?Lrpc{S=*-C9iGQ>viGU07U=z=9i$x=OUM?dv8W#XO%Jz=DmotS#Xo|h$ueC*T0J1 zu()qF;`4*i?)&|!0Cd;G9RqN4Q7Fr71F$n>0VWh66S6Z64Zt(Zp)B|eg2ynf9vJ9v G|MowrC;2%5 diff --git a/public/providers/heroku.svg b/public/providers/heroku.svg new file mode 100644 index 0000000000..6089d6f2c2 --- /dev/null +++ b/public/providers/heroku.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/providers/huggingchat.svg b/public/providers/huggingchat.svg index 30a8cef613..dc1cf3ffb7 100644 --- a/public/providers/huggingchat.svg +++ b/public/providers/huggingchat.svg @@ -1,5 +1 @@ - - - - HC - +HuggingFace \ No newline at end of file diff --git a/public/providers/huggingface.svg b/public/providers/huggingface.svg new file mode 100644 index 0000000000..c141ab811a --- /dev/null +++ b/public/providers/huggingface.svg @@ -0,0 +1,15 @@ + + HuggingFace + + + + + + + \ No newline at end of file diff --git a/public/providers/hyperbolic.svg b/public/providers/hyperbolic.svg new file mode 100644 index 0000000000..591eb157ba --- /dev/null +++ b/public/providers/hyperbolic.svg @@ -0,0 +1,5 @@ + + Hyperbolic + + \ No newline at end of file diff --git a/public/providers/ibm.svg b/public/providers/ibm.svg new file mode 100644 index 0000000000..36fe5889dd --- /dev/null +++ b/public/providers/ibm.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/providers/iflytek.svg b/public/providers/iflytek.svg index bad262efe4..4962d9e161 100644 --- a/public/providers/iflytek.svg +++ b/public/providers/iflytek.svg @@ -1,5 +1 @@ - - - - IF - +iFlyTekCloud \ No newline at end of file diff --git a/public/providers/inference.svg b/public/providers/inference.svg new file mode 100644 index 0000000000..b53e8e4616 --- /dev/null +++ b/public/providers/inference.svg @@ -0,0 +1,4 @@ + + Inference + + \ No newline at end of file diff --git a/public/providers/kimi.svg b/public/providers/kimi.svg new file mode 100644 index 0000000000..ec12852549 --- /dev/null +++ b/public/providers/kimi.svg @@ -0,0 +1,7 @@ + + Kimi + + + \ No newline at end of file diff --git a/public/providers/kiro.svg b/public/providers/kiro.svg index 83e2845fbf..0c651b9747 100644 --- a/public/providers/kiro.svg +++ b/public/providers/kiro.svg @@ -1 +1 @@ - \ No newline at end of file +Kiro \ No newline at end of file diff --git a/public/providers/lambda.svg b/public/providers/lambda.svg new file mode 100644 index 0000000000..be9061029b --- /dev/null +++ b/public/providers/lambda.svg @@ -0,0 +1,4 @@ + + Lambda + + \ No newline at end of file diff --git a/public/providers/longcat.svg b/public/providers/longcat.svg new file mode 100644 index 0000000000..6c05c8ecc2 --- /dev/null +++ b/public/providers/longcat.svg @@ -0,0 +1,8 @@ + + LongCat + + + \ No newline at end of file diff --git a/public/providers/meta.svg b/public/providers/meta.svg new file mode 100644 index 0000000000..45c34b10e5 --- /dev/null +++ b/public/providers/meta.svg @@ -0,0 +1,121 @@ + + Meta + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/metaai.svg b/public/providers/metaai.svg new file mode 100644 index 0000000000..4d623976fd --- /dev/null +++ b/public/providers/metaai.svg @@ -0,0 +1,49 @@ + + MetaAI + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/minimax.svg b/public/providers/minimax.svg new file mode 100644 index 0000000000..b1b0a6a2df --- /dev/null +++ b/public/providers/minimax.svg @@ -0,0 +1 @@ +MiniMax \ No newline at end of file diff --git a/public/providers/mistral.svg b/public/providers/mistral.svg new file mode 100644 index 0000000000..605b629a38 --- /dev/null +++ b/public/providers/mistral.svg @@ -0,0 +1,10 @@ + + Mistral + + + + + + \ No newline at end of file diff --git a/public/providers/moonshot.svg b/public/providers/moonshot.svg new file mode 100644 index 0000000000..b36d4e7e0f --- /dev/null +++ b/public/providers/moonshot.svg @@ -0,0 +1,4 @@ + + Moonshot (月之暗面) + + \ No newline at end of file diff --git a/public/providers/morph.svg b/public/providers/morph.svg new file mode 100644 index 0000000000..474c521785 --- /dev/null +++ b/public/providers/morph.svg @@ -0,0 +1,5 @@ + + Morph + + \ No newline at end of file diff --git a/public/providers/nebius.svg b/public/providers/nebius.svg new file mode 100644 index 0000000000..719baf1544 --- /dev/null +++ b/public/providers/nebius.svg @@ -0,0 +1,5 @@ + + Nebius + + + \ No newline at end of file diff --git a/public/providers/novita.svg b/public/providers/novita.svg new file mode 100644 index 0000000000..7c53907792 --- /dev/null +++ b/public/providers/novita.svg @@ -0,0 +1,7 @@ + + Novita + + \ No newline at end of file diff --git a/public/providers/nvidia.svg b/public/providers/nvidia.svg new file mode 100644 index 0000000000..48c7d75d26 --- /dev/null +++ b/public/providers/nvidia.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/ollama.svg b/public/providers/ollama.svg new file mode 100644 index 0000000000..96e9e86624 --- /dev/null +++ b/public/providers/ollama.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/providers/openai.svg b/public/providers/openai.svg new file mode 100644 index 0000000000..b6d542d099 --- /dev/null +++ b/public/providers/openai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/openclaw.png b/public/providers/openclaw.png deleted file mode 100644 index 7ef77ac754a1a396eb2d0655ba73a4b1c10cd663..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16740 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 CiF%L# diff --git a/public/providers/openclaw.svg b/public/providers/openclaw.svg new file mode 100644 index 0000000000..bf6acb9f51 --- /dev/null +++ b/public/providers/openclaw.svg @@ -0,0 +1 @@ +OpenClaw \ No newline at end of file diff --git a/public/providers/openrouter.svg b/public/providers/openrouter.svg new file mode 100644 index 0000000000..61033bfc67 --- /dev/null +++ b/public/providers/openrouter.svg @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/public/providers/ovhcloud.png b/public/providers/ovhcloud.png deleted file mode 100644 index a88d1b448d6632c53ec720b66130c9e7ef24a05e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1167 zcmV;A1aSL_P)C0001uP)t-s01ll0 z|Nj60m%P2)005c*37hHa^#BH&yT9d=meBC=`34!Joub$P6QKCx2eTw}YXsn>sl#jUa2 zCoQGonaJw)Uc}mENmG>;M0G=K!HV!V9FHapuhI^HGz{vYWkR+v_n5!!QiPFbu;m z48t(Ye~duTFP;B-2Uz`z2K43urlI^NC#c5nkdF~kU?EIhZ?cFQ-kX9dLKh=&{(7j4 zrJLs)G>{YpUwEGy9>Mvem7%u`j}f^$g*Jh=#rwo7azNkQL_06oIGaGy{;#k%w2#hH zq`=VFqYi&oqs$TC($8D{{GZ- zp=*%jr}E~tnoB}A&G%iP=dkOs&Ad-Jl3>t%_CE~!g3k}o+76DyzWo|Di$Cdv- zbPof1u8NKe<%kt$xq@hDD)Rm}Uu0ydQ z1a;1qgfw)iN8;Yxyd!M@B>kvE#olVFc$)%#+UKcoUMD$WlS!wgk5>Fl0dJe^Cir== zt{5~%D}Jg#(xB2i`nI!V!nyE)%VWs7$0d3FxCFoIi!E^ijnj6H3)6$zY@(!)gL^P>RdbKs5Q% zB+hSOLkK1~m1FqwwHclBEZJ;;wUYukp;`>G93%-omh?Io1qhZji~K268#!g1#wN6E zHfM>2ZbK%GAz1Bd+L7U1$Mf?}50?o|d~tf4iEX#@M<3b&CtL+HwvJD;H`w}B80#u1 zc7dM(_D$ia0hyqwtQ2DNUQJ#eGA1Avj;=!*`~WUfgqH5a#W%3P&T&4_Qo{ diff --git a/public/providers/ovhcloud.svg b/public/providers/ovhcloud.svg new file mode 100644 index 0000000000..71ec8e5b13 --- /dev/null +++ b/public/providers/ovhcloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/perplexity.svg b/public/providers/perplexity.svg new file mode 100644 index 0000000000..b6e121bbc3 --- /dev/null +++ b/public/providers/perplexity.svg @@ -0,0 +1,6 @@ + + Perplexity + + \ No newline at end of file diff --git a/public/providers/picoclaw.svg b/public/providers/picoclaw.svg new file mode 100644 index 0000000000..c05d253215 --- /dev/null +++ b/public/providers/picoclaw.svg @@ -0,0 +1,30 @@ + + PicoClaw + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/providers/poe.svg b/public/providers/poe.svg new file mode 100644 index 0000000000..cbd9ec4c7d --- /dev/null +++ b/public/providers/poe.svg @@ -0,0 +1 @@ +Poe \ No newline at end of file diff --git a/public/providers/pollinations.svg b/public/providers/pollinations.svg new file mode 100644 index 0000000000..83b569419b --- /dev/null +++ b/public/providers/pollinations.svg @@ -0,0 +1,4 @@ + + Pollinations + + \ No newline at end of file diff --git a/public/providers/qwen.svg b/public/providers/qwen.svg new file mode 100644 index 0000000000..3a2f756e66 --- /dev/null +++ b/public/providers/qwen.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/public/providers/recraft.svg b/public/providers/recraft.svg new file mode 100644 index 0000000000..e5bb701575 --- /dev/null +++ b/public/providers/recraft.svg @@ -0,0 +1,5 @@ + + Recraft + + + \ No newline at end of file diff --git a/public/providers/replicate.svg b/public/providers/replicate.svg new file mode 100644 index 0000000000..4637f35378 --- /dev/null +++ b/public/providers/replicate.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/providers/roocode.svg b/public/providers/roocode.svg new file mode 100644 index 0000000000..3ce8ece8d3 --- /dev/null +++ b/public/providers/roocode.svg @@ -0,0 +1,4 @@ + + RooCode + + \ No newline at end of file diff --git a/public/providers/runway.svg b/public/providers/runway.svg new file mode 100644 index 0000000000..5dc14b1fdf --- /dev/null +++ b/public/providers/runway.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/providers/sambanova.svg b/public/providers/sambanova.svg new file mode 100644 index 0000000000..38afe1b9ee --- /dev/null +++ b/public/providers/sambanova.svg @@ -0,0 +1,9 @@ + + SambaNova + + + + \ No newline at end of file diff --git a/public/providers/searchapi.svg b/public/providers/searchapi.svg new file mode 100644 index 0000000000..fe1da13449 --- /dev/null +++ b/public/providers/searchapi.svg @@ -0,0 +1,4 @@ + + SearchApi + + \ No newline at end of file diff --git a/public/providers/sensenova.svg b/public/providers/sensenova.svg index 9bd7ebda11..17f1d82c53 100644 --- a/public/providers/sensenova.svg +++ b/public/providers/sensenova.svg @@ -1,5 +1 @@ - - - - SN - +SenseNova \ No newline at end of file diff --git a/public/providers/snowflake.svg b/public/providers/snowflake.svg new file mode 100644 index 0000000000..f62ab5f446 --- /dev/null +++ b/public/providers/snowflake.svg @@ -0,0 +1,7 @@ + + Snowflake + + \ No newline at end of file diff --git a/public/providers/stepfun.svg b/public/providers/stepfun.svg index 2a7196ea47..920e8a607a 100644 --- a/public/providers/stepfun.svg +++ b/public/providers/stepfun.svg @@ -1,5 +1 @@ - - - - SF - +Stepfun \ No newline at end of file diff --git a/public/providers/suno.svg b/public/providers/suno.svg new file mode 100644 index 0000000000..e2ddb78425 --- /dev/null +++ b/public/providers/suno.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/tavily.svg b/public/providers/tavily.svg new file mode 100644 index 0000000000..c2475ed2be --- /dev/null +++ b/public/providers/tavily.svg @@ -0,0 +1,15 @@ + + Tavily + + + + + + + \ No newline at end of file diff --git a/public/providers/tencent.svg b/public/providers/tencent.svg index d1dff98da9..98da272a26 100644 --- a/public/providers/tencent.svg +++ b/public/providers/tencent.svg @@ -1,5 +1 @@ - - - - TC - +Tencent \ No newline at end of file diff --git a/public/providers/topazlabs.svg b/public/providers/topazlabs.svg new file mode 100644 index 0000000000..c141a9a36b --- /dev/null +++ b/public/providers/topazlabs.svg @@ -0,0 +1,4 @@ + + TopazLabs + + \ No newline at end of file diff --git a/public/providers/trae.svg b/public/providers/trae.svg new file mode 100644 index 0000000000..3d048c0c25 --- /dev/null +++ b/public/providers/trae.svg @@ -0,0 +1,5 @@ + + TRAE + + \ No newline at end of file diff --git a/public/providers/udio.svg b/public/providers/udio.svg new file mode 100644 index 0000000000..7bb9b62c3b --- /dev/null +++ b/public/providers/udio.svg @@ -0,0 +1,5 @@ + + Udio + + \ No newline at end of file diff --git a/public/providers/upstage.svg b/public/providers/upstage.svg new file mode 100644 index 0000000000..d9db3f1838 --- /dev/null +++ b/public/providers/upstage.svg @@ -0,0 +1,14 @@ + + Upstage + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/v0.svg b/public/providers/v0.svg new file mode 100644 index 0000000000..32fcc405fd --- /dev/null +++ b/public/providers/v0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/vercel.svg b/public/providers/vercel.svg new file mode 100644 index 0000000000..75968916c4 --- /dev/null +++ b/public/providers/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/vllm.svg b/public/providers/vllm.svg new file mode 100644 index 0000000000..3adec97b6b --- /dev/null +++ b/public/providers/vllm.svg @@ -0,0 +1,5 @@ + + vLLM + + + \ No newline at end of file diff --git a/public/providers/volcengine.svg b/public/providers/volcengine.svg new file mode 100644 index 0000000000..1423adbc65 --- /dev/null +++ b/public/providers/volcengine.svg @@ -0,0 +1,11 @@ + + Volcengine (火山引擎) + + + + + \ No newline at end of file diff --git a/public/providers/voyage.svg b/public/providers/voyage.svg new file mode 100644 index 0000000000..76861e9f57 --- /dev/null +++ b/public/providers/voyage.svg @@ -0,0 +1,5 @@ + + Voyage + + \ No newline at end of file diff --git a/public/providers/windsurf.svg b/public/providers/windsurf.svg new file mode 100644 index 0000000000..8f4b214454 --- /dev/null +++ b/public/providers/windsurf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/xai.svg b/public/providers/xai.svg new file mode 100644 index 0000000000..6f6057d454 --- /dev/null +++ b/public/providers/xai.svg @@ -0,0 +1,4 @@ + + xAI + + \ No newline at end of file diff --git a/public/providers/xinference.svg b/public/providers/xinference.svg new file mode 100644 index 0000000000..d97033d417 --- /dev/null +++ b/public/providers/xinference.svg @@ -0,0 +1,53 @@ + + Xinference + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/providers/yi.svg b/public/providers/yi.svg index 8ddc60de6e..8d0c6470b4 100644 --- a/public/providers/yi.svg +++ b/public/providers/yi.svg @@ -1,5 +1 @@ - - - - YI - +Yi \ No newline at end of file diff --git a/public/providers/zhipu.svg b/public/providers/zhipu.svg new file mode 100644 index 0000000000..a92eaff077 --- /dev/null +++ b/public/providers/zhipu.svg @@ -0,0 +1,6 @@ + + Zhipu (智谱) + + \ No newline at end of file diff --git a/src/shared/components/ProviderIcon.tsx b/src/shared/components/ProviderIcon.tsx index 564ec6ca1d..19aad7ce1a 100644 --- a/src/shared/components/ProviderIcon.tsx +++ b/src/shared/components/ProviderIcon.tsx @@ -1,17 +1,18 @@ "use client"; /** - * ProviderIcon — Renders a provider logo using @lobehub/icons with static asset fallbacks. + * ProviderIcon — Renders a provider logo prioritizing local SVGs for speed. * * Strategy (#529): * 0. If `src` is set (operator-supplied remote icon URL, #2166), render it — this always - * wins over the @lobehub/static resolution below. On load error, falls back to + * wins over the resolution below. On load error, falls back to * `fallbackText`/`fallbackColor` (a colored text badge) if provided, otherwise falls - * through to steps 1-4. - * 1. Try @lobehub/icons direct icon components (no @lobehub/ui peer runtime) - * 2. Fall back to /providers/{id}.png (existing static assets) - * 3. Fall back to /providers/{id}.svg (SVG assets) - * 4. Fall back to a generic AI icon + * through to steps 1-5. + * 1. Try /providers/{id}.svg (local SVG assets — fastest, cached separately from JS bundle) + * 2. Try @lobehub/icons direct React components (no @lobehub/ui peer runtime) + * 3. Fall back to thesvg.org CDN (external SVG) + * 4. Fall back to /providers/{id}.png (legacy static assets) + * 5. Fall back to a generic AI icon * * Usage: * @@ -33,7 +34,7 @@ interface ProviderIconProps { /** * Optional operator-supplied remote icon URL (#2166) — e.g. a custom icon set for an * OpenAI-/Anthropic-compatible provider node. When set, this always takes priority - * over the @lobehub/static resolution. On load error, falls back to `fallbackText` + * over the resolution chain. On load error, falls back to `fallbackText` * (if provided) or the normal resolution chain below. */ src?: string; @@ -51,83 +52,168 @@ function GenericProviderIcon({ size }: { size: number }) { ); } +const KNOWN_SVGS = new Set([ + "360ai", + "alibaba", + "anthropic", + "apikey", + "arcee", + "arcee-ai", + "assemblyai", + "aws", + "azure", + "azureai", + "baichuan", + "baidu", + "bailian", + "baseten", + "bazaarlink", + "brave", + "brave-search", + "cartesia", + "cerebras", + "clarifai", + "claude", + "claude-web", + "cline", + "cloudflare", + "codex", + "cohere", + "comfyui", + "command-code", + "continue", + "copilot", + "coze", + "cursor", + "deepgram", + "deepinfra", + "deepseek", + "dify", + "docker-model-runner", + "doubao", + "droid", + "elevenlabs", + "exa", + "fal", + "fireworks", + "friendli", + "gemini", + "gitlab", + "gitlab-duo", + "google", + "grok", + "groq", + "heroku", + "huggingchat", + "huggingface", + "hyperbolic", + "ibm", + "iflytek", + "inclusionai", + "inference", + "inworld", + "kilo-gateway", + "kilocode", + "kimi", + "kiro", + "krutrim", + "lambda", + "liquid", + "longcat", + "meta", + "metaai", + "minimax", + "mistral", + "modal", + "monsterapi", + "moonshot", + "morph", + "nebius", + "nlpcloud", + "nomic", + "novita", + "nvidia", + "oauth", + "oci", + "ollama", + "openai", + "openclaw", + "opencode", + "openrouter", + "ovhcloud", + "perplexity", + "phind", + "picoclaw", + "playht", + "poe", + "pollinations", + "poolside", + "puter", + "qianfan", + "qwen", + "recraft", + "replicate", + "roocode", + "runway", + "sambanova", + "sap", + "scaleway", + "searchapi", + "searxng-search", + "sensenova", + "serper-search", + "snowflake", + "sparkdesk", + "stepfun", + "suno", + "synthetic", + "tavily", + "tencent", + "topazlabs", + "trae", + "udio", + "upstage", + "v0", + "vercel", + "vllm", + "volcengine", + "voyage", + "wandb", + "windsurf", + "xai", + "xinference", + "yi", + "youcom-search", + "zhipu", +]); + const KNOWN_PNGS = new Set([ + "adapta-web", "agentrouter", - "inner-ai", "aimlapi", "anthropic-m", "blackbox", - "claude", - "continue", - "copilot", - "cursor", - "deepgram", - "ironclaw", - "kie", - "nanobot", - "oai-cc", - "oai-r", - "openclaw", - "zeroclaw", - "adapta-web", "blackbox-web", "cliproxyapi", "empower", "gigachat", - "heroku", + "inner-ai", + "ironclaw", + "kie", "lemonade", "linkup-search", "llamafile", "llamagate", "maritalk", + "nanobot", "nanogpt", "nscale", - "ovhcloud", + "oai-cc", + "oai-r", "piapi", "predibase", "reka", -]); -const KNOWN_SVGS = new Set([ - "apikey", - "bazaarlink", - "brave", - "brave-search", - "cartesia", - "360ai", - "huggingchat", - "iflytek", - "sparkdesk", - "arcee-ai", - "liquid", - "monsterapi", - "nomic", - "poolside", - "clarifai", - "command-code", - "claude-web", - "docker-model-runner", - "droid", - "gitlab", - "gitlab-duo", - "inworld", - "kiro", - "kilo-gateway", - "kilocode", - "modal", - "nlpcloud", - "oauth", - "oci", - "opencode", - "playht", - "puter", - "qianfan", - "sap", - "scaleway", - "serper-search", - "searxng-search", - "synthetic", - "wandb", - "youcom-search", + "zeroclaw", ]); const ProviderIcon = memo(function ProviderIcon({ @@ -143,20 +229,22 @@ const ProviderIcon = memo(function ProviderIcon({ }: ProviderIconProps) { const normalizedId = providerId.toLowerCase(); const lobeIcon = getLobeProviderIcon(normalizedId, type); - const hasPng = KNOWN_PNGS.has(normalizedId); const hasSvg = KNOWN_SVGS.has(normalizedId); + const hasPng = KNOWN_PNGS.has(normalizedId); const [failedAssets, setFailedAssets] = useState>({}); const [remoteSrcFailed, setRemoteSrcFailed] = useState(false); - const pngKey = `${normalizedId}:png`; const svgKey = `${normalizedId}:svg`; - const usePng = !lobeIcon && hasPng && !failedAssets[pngKey]; - const useSvg = !lobeIcon && hasSvg && !failedAssets[svgKey] && (!hasPng || failedAssets[pngKey]); + const pngKey = `${normalizedId}:png`; + const theSvgKey = `${normalizedId}:thesvg`; const trimmedSrc = typeof src === "string" ? src.trim() : ""; + const svgFailed = failedAssets[svgKey]; + const theSvgFailed = failedAssets[theSvgKey]; + const pngFailed = failedAssets[pngKey]; - // #2166: a custom remote icon URL always wins over the @lobehub/static resolution - // below. It is a plain (not next/image) so operators can point at any host + // #2166: a custom remote icon URL always wins over the resolution chain below. + // It is a plain (not next/image) so operators can point at any host // without requiring `images.remotePatterns` allow-listing for arbitrary domains. if (trimmedSrc && !remoteSrcFailed) { return ( @@ -199,43 +287,8 @@ const ProviderIcon = memo(function ProviderIcon({ ); } - if (lobeIcon) { - return ( - - {createElement(lobeIcon, { - "aria-label": providerId, - size, - style: { flex: "none" }, - })} - - ); - } - - if (usePng) { - return ( - - {providerId} { - setFailedAssets((current) => ({ ...current, [pngKey]: true })); - }} - unoptimized - /> - - ); - } - - if (useSvg) { + // Tier 1: Local SVG — fastest, cached separately from the JS bundle + if (hasSvg && !svgFailed) { return ( + {createElement(lobeIcon, { + "aria-label": providerId, + size, + style: { flex: "none" }, + })} + + ); + } + + // Tier 3: thesvg.org CDN — external SVG fallback for unknown providers + if (!theSvgFailed) { + return ( + + {/* eslint-disable-next-line @next/next/no-img-element -- external SVG from thesvg.org, not a static/known asset */} + {providerId} setFailedAssets((current) => ({ ...current, [theSvgKey]: true }))} + /> + + ); + } + + // Tier 4: Local PNG — last resort before generic icon + if (hasPng && !pngFailed) { + return ( + + {providerId} setFailedAssets((current) => ({ ...current, [pngKey]: true }))} + unoptimized + /> + + ); + } + + // Tier 5: Generic AI icon return ( diff --git a/tests/unit/ui/ProviderIcon-icon-url.test.tsx b/tests/unit/ui/ProviderIcon-icon-url.test.tsx index 19977c1455..f34ace21a5 100644 --- a/tests/unit/ui/ProviderIcon-icon-url.test.tsx +++ b/tests/unit/ui/ProviderIcon-icon-url.test.tsx @@ -23,8 +23,8 @@ const { default: ProviderIcon } = await import("@/shared/components/ProviderIcon // ── Helpers ─────────────────────────────────────────────────────────────────── // Deliberately not registered in @lobehub/icons aliases or the KNOWN_PNGS/KNOWN_SVGS -// static-asset sets, so tests exercise only the `src` override + generic-icon fallback -// paths, never the @lobehub/static resolution chain. +// static-asset sets, so tests exercise only the `src` override + fallback chain +// (thesvg.org → generic icon). Never reaches the local SVG or @lobehub tiers. const UNKNOWN_PROVIDER_ID = "openai-compatible-test-node-xyz"; const containers: HTMLElement[] = []; @@ -50,8 +50,9 @@ function fireImgError(container: HTMLElement) { } beforeEach(() => { - (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = - true; + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; }); afterEach(() => { @@ -71,18 +72,31 @@ describe("ProviderIcon — custom remote icon URL (#2166)", () => { expect(img?.getAttribute("src")).toBe("https://example.com/logo.png"); }); - it("falls back to the generic icon when `src` is unset", () => { + it("falls back to thesvg.org CDN when `src` is unset (Tier 3 for unknown providers)", () => { const container = renderIcon({}); - expect(container.querySelector("img")).toBeNull(); - expect(container.querySelector("svg")).not.toBeNull(); + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + expect(img?.getAttribute("src")).toBe( + "https://thesvg.org/icons/openai-compatible-test-node-xyz/default.svg" + ); }); - it("falls back to the generic (lobehub/static) icon chain when `src` load fails and no fallbackText is given", () => { + it("falls back through thesvg.org CDN then generic icon when `src` load fails and no fallbackText is given", () => { const container = renderIcon({ src: "https://example.com/broken.png" }); expect(container.querySelector("img")).not.toBeNull(); fireImgError(container); + // Falls back to thesvg.org + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + expect(img?.getAttribute("src")).toBe( + "https://thesvg.org/icons/openai-compatible-test-node-xyz/default.svg" + ); + + fireImgError(container); + + // thesvg.org fails → generic SVG icon expect(container.querySelector("img")).toBeNull(); expect(container.querySelector("svg")).not.toBeNull(); }); @@ -102,9 +116,12 @@ describe("ProviderIcon — custom remote icon URL (#2166)", () => { expect(container.textContent).toBe("OC"); }); - it("ignores a whitespace-only src and falls back to the generic icon", () => { + it("ignores a whitespace-only src and falls back to thesvg.org CDN", () => { const container = renderIcon({ src: " " }); - expect(container.querySelector("img")).toBeNull(); - expect(container.querySelector("svg")).not.toBeNull(); + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + expect(img?.getAttribute("src")).toBe( + "https://thesvg.org/icons/openai-compatible-test-node-xyz/default.svg" + ); }); }); From eeec4d9e87a8cdae832ff3cbc0e2baa4b811f665 Mon Sep 17 00:00:00 2001 From: Moseyuh333 <148680980+Moseyuh333@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:49:48 +0700 Subject: [PATCH 8/8] feat(chaos): big update - optimize, fix bugs, add features, enhance UX (#6728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(chaos): add Chaos Mode — multi-model parallel/collaborative execution - New DB column chaos_mode_enabled on api_keys table - API key create/PATCH routes support chaosModeEnabled toggle - Core library src/lib/chaos/chaosConfig.ts for persistent config - API routes: GET/PUT/DELETE /api/chaos/config - Chaos execution POST /api/skills/collect/chaos with key auth - Dashboard page at /dashboard/chaos with full config UI - Sidebar entry in Agentic Features section - Chaos mode toggle in API Key editor permissions panel - i18n keys for chaos config (en.json) * feat(chaos): big update — optimize, fix bugs, add features === Changes === 1. NEW: src/lib/chaos/chaosExecutor.ts — shared execution engine - Removed ~150 lines of duplicate dispatch logic between two API routes - Single executeChaosRun() function used by both endpoints - Added concurrency limit (max 10 parallel requests) - Added proper TypeScript interfaces (ChaosRunInput, ChaosRunResult) - Added error logging throughout 2. FIX: src/app/api/skills/collect/chaos/route.ts - Was MISSING logger import (log.error was undefined at runtime) - Reduced from 388 lines → 142 lines by delegating to shared executor - Added maxTokens support in schema validation 3. REFACTOR: src/app/api/chaos/run/route.ts - Simplified to thin wrapper: auth + validate + delegate to executor - Added maxTokens support 4. ENHANCE: src/lib/chaos/chaosConfig.ts - Added maxTokens config field (256-128k, default 4096) - Persisted per-instance via settings table 5. ENHANCE: UI — ChaosConfigPageClient.tsx - Loads available providers from /api/models for dropdown autocomplete - Added datalist-based provider selector in overrides section - Added Max Tokens configuration input - Added expandable provider list showing all detected providers - Fixed duplicate override detection * fix(chaos): fetch providers from /api/providers instead of /api/keys * fix(chaos): remove dead code isOverrideDuplicate, fix maxTokens fallback to include global config * fix(chaos): resetConfig now shows error on HTTP failure (was silent) * feat(dashboard): Chaos Mode — multi-model parallel/collaborative execution Splits the PR down to only the genuinely new Chaos Mode feature (drops the duplicate Skill Collector/GitHub-discovery portion already shipped via #6186). Replaces the loopback fetch() dispatch (hardcoded to the wrong port) with the established in-process synthetic-Request/route-handler pattern used by src/lib/batches/dispatch.ts, moves settings persistence off raw SQL, and adds unit test coverage for chaosConfig, chaosExecutor and the 3 chaos API routes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(chaos): fix external Bearer-auth bypass and stale config cache in tests validateApiKey() returns a plain boolean for both the deployment-time env key and a DB-backed key, so branching on `keyInfo === true` in verifyChaosKey() (src/app/api/skills/collect/chaos/route.ts) treated every valid API key as having full env-key access, silently skipping the chaosModeEnabled permission check entirely. Now always resolves through getApiKeyMetadata() and only bypasses the per-key check for the synthesized env-key record (id: "env-key"). Also exports invalidateChaosConfigCache() from chaosConfig.ts and wires it into the route tests' resetStorage() — the in-process config cache was surviving DB resets between tests, causing state to leak across cases. Fixes CHANGELOG-eat from the release merge (re-inserted the Chaos Mode bullet against the base CHANGELOG.md, verified additive via check-changelog-integrity.mjs) and re-syncs against release/v3.8.47 tip. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * docs(changelog): Chaos Mode overhaul bullet referencing #6728 after release sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(merge): restore #6126 clinepass files reverted by release auto-resolve + baseline re-merge The release sync's auto-resolve reverted sibling PR #6126's clinepass work (registry, catalog, oauth constants, clineAuth.ts, token-refresh case, tests) and the file-size baseline — all outside this PR's scope. Restored to the release versions, re-applied only this PR's own baseline entries, restored the #6126 CHANGELOG bullet (re-inserting only this PR's own). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(dashboard): chaos client hook must not import the server Pino logger useChaosConfigData ("use client") pulled @/sse/utils/logger → shared Pino → logRotation/dataPaths → node:fs into the browser bundle, breaking next build (Turbopack: Can't resolve 'fs') — caught by the DAST smoke's isolated build. console.error matches every other dashboard client component. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test(api-manager): align switch-count invariant with the extracted toggle components The Self-service block now renders 4 inline switches; the #5731 quota-bypass and #6728 chaos-access toggles were extracted into dedicated components. The type="button" invariant is preserved AND extended: the test now also asserts each extracted component's switches declare type="button". Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(sync): merge release tip + restore own CHANGELOG bullet Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Moseyuh333 Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza --- CHANGELOG.md | 1 + .../api-manager/ApiManagerPageClient.tsx | 51 +- .../components/BypassProviderQuotaToggle.tsx | 45 ++ .../components/ChaosModeAccessToggle.tsx | 45 ++ .../dashboard/chaos/ChaosConfigPageClient.tsx | 107 +++++ .../dashboard/chaos/chaosPageTypes.ts | 28 ++ .../components/ChaosBasicSettingsFields.tsx | 122 +++++ .../components/ChaosConfigActionsBar.tsx | 74 +++ .../chaos/components/ChaosModeSelector.tsx | 60 +++ .../ChaosProviderOverridesPanel.tsx | 156 +++++++ .../chaos/components/ChaosStatusMessage.tsx | 23 + .../components/ChaosTestResultsPanel.tsx | 66 +++ src/app/(dashboard)/dashboard/chaos/page.tsx | 12 + .../dashboard/chaos/useChaosConfigData.ts | 96 ++++ .../dashboard/chaos/useChaosConfigPage.ts | 72 +++ .../chaos/useChaosConfigPersistence.ts | 65 +++ .../dashboard/chaos/useChaosTestRun.ts | 48 ++ src/app/api/chaos/config/route.ts | 84 ++++ src/app/api/chaos/run/route.ts | 73 +++ src/app/api/keys/[id]/route.ts | 3 + src/app/api/keys/route.ts | 6 +- src/app/api/skills/collect/chaos/route.ts | 147 ++++++ src/i18n/messages/en.json | 31 ++ src/lib/chaos/chaosConfig.ts | 119 +++++ src/lib/chaos/chaosExecutor.ts | 439 ++++++++++++++++++ src/lib/db/apiKeyColumnFallbacks.ts | 4 + src/lib/db/apiKeys.ts | 22 +- src/lib/db/apiKeys/rowParsers.ts | 4 + src/shared/constants/publicApiRoutes.ts | 7 + .../constants/sidebarVisibility/sections.ts | 8 + .../constants/sidebarVisibility/types.ts | 1 + src/shared/validation/schemas/keys.ts | 5 +- tests/unit/api-manager-page-static.test.ts | 26 +- tests/unit/chaos-api-routes.test.ts | 320 +++++++++++++ tests/unit/chaos-config.test.ts | 126 +++++ tests/unit/chaos-executor.test.ts | 243 ++++++++++ 36 files changed, 2703 insertions(+), 36 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/api-manager/components/BypassProviderQuotaToggle.tsx create mode 100644 src/app/(dashboard)/dashboard/api-manager/components/ChaosModeAccessToggle.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/ChaosConfigPageClient.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/chaosPageTypes.ts create mode 100644 src/app/(dashboard)/dashboard/chaos/components/ChaosBasicSettingsFields.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/components/ChaosConfigActionsBar.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/components/ChaosModeSelector.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/components/ChaosProviderOverridesPanel.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/components/ChaosStatusMessage.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/components/ChaosTestResultsPanel.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/page.tsx create mode 100644 src/app/(dashboard)/dashboard/chaos/useChaosConfigData.ts create mode 100644 src/app/(dashboard)/dashboard/chaos/useChaosConfigPage.ts create mode 100644 src/app/(dashboard)/dashboard/chaos/useChaosConfigPersistence.ts create mode 100644 src/app/(dashboard)/dashboard/chaos/useChaosTestRun.ts create mode 100644 src/app/api/chaos/config/route.ts create mode 100644 src/app/api/chaos/run/route.ts create mode 100644 src/app/api/skills/collect/chaos/route.ts create mode 100644 src/lib/chaos/chaosConfig.ts create mode 100644 src/lib/chaos/chaosExecutor.ts create mode 100644 tests/unit/chaos-api-routes.test.ts create mode 100644 tests/unit/chaos-config.test.ts create mode 100644 tests/unit/chaos-executor.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f92ef4b3..9b4ec31d1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral - **ClinePass dual-auth**: ClinePass now offers both sign-in methods on its dashboard page — OAuth (reusing the Cline WorkOS flow) as the primary "Connect" path, or a pasted BYOK API key via "Manual API key", instead of only the API-key-only provider shipped in #5942. The registry alias was aligned to `cp` (matching the `OAUTH_PROVIDERS` catalog alias) so `/` routing resolves correctly, the OAuth refresh dispatch now routes `clinepass` to the shared Cline refresh flow, and the duplicate API-key-only catalog entry was removed to keep ClinePass listed once. Regression guard: `tests/unit/clinepass-provider.test.ts`. (#6126 — thanks @hajilok) - **feat(oauth):** Kiro/Amazon Q auto-import now supports enterprise **External IdP** ("Your organization") logins via Microsoft Entra/Okta/Auth0/OneLogin/Ping/Google/Cognito — these org-issued tokens are not AWS SSO tokens (no `aorAAAAAG`-prefixed refresh token) and can't refresh through the AWS OIDC/Kiro-social path, so `tryAwsSsoCache()` now detects them (`authMethod`/`provider === "externalidp"`) and refreshes via the org IdP's own `tokenEndpoint` (public-client OAuth2 refresh grant, no client secret), persisting `TokenType: EXTERNAL_IDP` gating so the runtime executor sends the header the AWS CodeWhisperer API requires for these accounts; `tokenEndpoint` is SSRF-guarded against an HTTPS + known-IdP-host-suffix allowlist. (#6363 — thanks @artickc) - **Kiro long-lived API key auth**: new `/api/oauth/kiro/api-key` route + `KiroService.validateApiKey` let a Kiro account be linked with a long-lived AWS CodeWhisperer/Kiro API key instead of the interactive OAuth device flow, with live per-account model discovery (`ListAvailableModels`, 5-minute cache) layered over the existing static registry fallback (#6587 — thanks @strangersp) +- **Chaos Mode**: multi-model parallel/collaborative task execution — dispatches a task to every active provider connection at once (parallel) or chains outputs sequentially so each model builds on the previous one's answer (collaborative), configurable via Dashboard → Chaos Mode (`GET`/`PUT`/`DELETE /api/chaos/config`) and gated per-API-key via a new `chaosModeEnabled` permission (opt-in — disabled by default globally and per key). `POST /api/chaos/run` (dashboard session) and `POST /api/skills/collect/chaos` (external Bearer-token) delegate to a shared `executeChaosRun()` engine (`src/lib/chaos/chaosExecutor.ts`) that dispatches in-process via the established synthetic-Request/route-handler pattern (no network hop, no hardcoded port), with a concurrency cap (max 10 parallel), configurable `max_tokens` (256–128k), a clear error when `stream` is requested, and collaborative-chain info (provider order + input size). Fixes external Bearer-auth bypass and stale config-cache leakage. Regression guard: `tests/unit/chaos-config.test.ts`, `tests/unit/chaos-executor.test.ts`, `tests/unit/chaos-api-routes.test.ts`. ([#6728](https://github.com/diegosouzapw/OmniRoute/pull/6728) — thanks @Moseyuh333) ### 🐛 Bug Fixes diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index c5e09dbc1d..67e0c1e43e 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -25,6 +25,8 @@ import { SELF_ACCOUNT_QUOTA_SCOPE, SELF_USAGE_SCOPE } from "@/shared/constants/s import { extractApiErrorMessage } from "@/shared/http/apiErrorMessage"; import { hasProviderQuotaBypassScope } from "@/shared/constants/apiKeyPolicyScopes"; import { UsageLimitSettings } from "./components/UsageLimitSettings"; +import { ChaosModeAccessToggle } from "./components/ChaosModeAccessToggle"; +import { BypassProviderQuotaToggle } from "./components/BypassProviderQuotaToggle"; // Constants for validation const MAX_KEY_NAME_LENGTH = 200; @@ -125,6 +127,7 @@ interface ApiKey { streamDefaultMode?: StreamDefaultMode; disableNonPublicModels?: boolean; allowUsageCommand?: boolean; + chaosModeEnabled?: boolean; usageLimitEnabled?: boolean; dailyUsageLimitUsd?: number | null; weeklyUsageLimitUsd?: number | null; @@ -518,7 +521,8 @@ export default function ApiManagerPageClient() { const res = await fetch(`/api/keys/${encodeURIComponent(key.id)}/devices`); if (!res.ok) return [key.id, 0] as const; const data = await res.json(); - const count = typeof data?.count === "number" && Number.isFinite(data.count) ? data.count : 0; + const count = + typeof data?.count === "number" && Number.isFinite(data.count) ? data.count : 0; return [key.id, count] as const; } catch { return [key.id, 0] as const; @@ -791,7 +795,8 @@ export default function ApiManagerPageClient() { usageLimitEnabled: boolean, dailyUsageLimitUsd: number | null, weeklyUsageLimitUsd: number | null, - blockedModels: string[] + blockedModels: string[], + chaosModeEnabled: boolean ) => { if (!editingKey || !editingKey.id) return; @@ -862,6 +867,7 @@ export default function ApiManagerPageClient() { usageLimitEnabled, dailyUsageLimitUsd, weeklyUsageLimitUsd, + chaosModeEnabled, }), }); @@ -1645,7 +1651,8 @@ const PermissionsModal = memo(function PermissionsModal({ usageLimitEnabled: boolean, dailyUsageLimitUsd: number | null, weeklyUsageLimitUsd: number | null, - blockedModels: string[] + blockedModels: string[], + chaosModeEnabled: boolean ) => void; }) { const t = useTranslations("apiManager"); @@ -1731,6 +1738,7 @@ const PermissionsModal = memo(function PermissionsModal({ const [usageCommandEnabled, setUsageCommandEnabled] = useState( apiKey?.allowUsageCommand === true ); + const [chaosModeEnabled, setChaosModeEnabled] = useState(apiKey?.chaosModeEnabled === true); const [usageLimitEnabled, setUsageLimitEnabled] = useState(apiKey?.usageLimitEnabled === true); const [dailyUsageLimitUsd, setDailyUsageLimitUsd] = useState( typeof apiKey?.dailyUsageLimitUsd === "number" && apiKey.dailyUsageLimitUsd > 0 @@ -1935,7 +1943,8 @@ const PermissionsModal = memo(function PermissionsModal({ usageLimitEnabled, parseUsdLimitInput(dailyUsageLimitUsd), parseUsdLimitInput(weeklyUsageLimitUsd), - blockedModels + blockedModels, + chaosModeEnabled ); }, [ onSave, @@ -1974,6 +1983,7 @@ const PermissionsModal = memo(function PermissionsModal({ parseUsdLimitInput, blockedClaudeCodeFamilies, initialBlockedModels, + chaosModeEnabled, apiKey?.scopes, t, ]); @@ -2563,30 +2573,17 @@ const PermissionsModal = memo(function PermissionsModal({ /> + {/* Chaos Mode Access Toggle */} + setChaosModeEnabled((prev) => !prev)} + /> + {/* Advanced Provider Quota Policy Override */} -
-
-

Bypass provider quota cutoffs

-

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

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

Bypass provider quota cutoffs

+

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

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

{tChaos("keyPermission")}

+

{tChaos("keyPermissionDesc")}

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

{t("pageTitle")}

+

{t("pageSubtitle")}

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

{t("systemPrompt")}

+

{t("systemPromptDesc")}

+