diff --git a/CHANGELOG.md b/CHANGELOG.md index 5defc67e03..475f5d2746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ --- +## [3.8.15] — 2026-06-07 + +### ✨ New Features + +- **feat(error-rules):** provider-specific error classification with scope — a declarative rules layer lets providers map upstream error shapes to the right resilience action (provider circuit-breaker vs connection cooldown vs model lockout) at the correct scope, instead of relying on generic status-code heuristics. ([#3370](https://github.com/diegosouzapw/OmniRoute/pull/3370) — thanks @herjarsa) + +### 🔧 Bug Fixes + +- **fix(combo):** add `429` to `PROVIDER_FAILURE_ERROR_CODES` so a rate-limited target no longer drives an infinite retry loop — the combo now cools the target down and moves on. ([#3366](https://github.com/diegosouzapw/OmniRoute/pull/3366) — thanks @herjarsa) +- **fix(catalog):** add a `getTokenLimit` fallback for combo targets with an unknown context window, so a target whose context can't be resolved no longer breaks token-limit computation for the combo. ([#3369](https://github.com/diegosouzapw/OmniRoute/pull/3369) — thanks @herjarsa) +- **fix(auto-combo):** include no-auth providers in Auto-Combo declaratively (driven by provider metadata rather than a hard-coded list), so keyless providers are eligible candidates. ([#3365](https://github.com/diegosouzapw/OmniRoute/pull/3365) — thanks @oyi77) +- **fix(auto-combo):** validate web-session credentials before selecting a web-cookie provider as an Auto-Combo target, so an expired/empty session doesn't get picked. ([#3371](https://github.com/diegosouzapw/OmniRoute/pull/3371) — thanks @oyi77) +- **fix(command-code):** update the Command Code base URL from `/alpha/` to `/provider/v1/` (upstream moved the endpoint). ([#3372](https://github.com/diegosouzapw/OmniRoute/pull/3372) — thanks @TapZe) +- **fix(kiro):** probe `%APPDATA%\kiro\storage.db` on Windows during Kiro auto-import, so the import finds the credential store where Kiro actually writes it on Windows. ([#3375](https://github.com/diegosouzapw/OmniRoute/pull/3375), fixes #3363 — thanks @diegosouzapw; reported by @Gerashka2) + +### 📝 Maintenance + +- **fix(migrations):** restore `095_provider_node_custom_headers.sql` — it was twice deleted from the release branch by a contributor branch's `git rm` of a duplicate getting folded into the squash merge; restored and guarded. (thanks @diegosouzapw) + +### 🙌 Contributors + +Thanks to everyone whose work landed in v3.8.15: + +| Contributor | PRs / Issues | +| --- | --- | +| [@herjarsa](https://github.com/herjarsa) | #3366, #3369, #3370 | +| [@oyi77](https://github.com/oyi77) | #3365, #3371 | +| [@TapZe](https://github.com/TapZe) | #3372 | +| [@Gerashka2](https://github.com/Gerashka2) | reported #3363 | +| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer — #3375 shepherding, migration restores | + +--- + ## [3.8.14] — 2026-06-07 ### ✨ New Features diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 5dd8dd41fe..6084a02f47 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 3a2f9f50a3..a38440a17c 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 3a2f9f50a3..a38440a17c 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 3cbd8dfec4..04ac61c4b1 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index 586abb1517..89362e9e53 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index 6332a18c43..f227ec9faa 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 5193d0ed52..e7a3d482f6 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 43eeefcbf2..0da960f60f 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index 736f0c27ea..d5b15b48b3 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index 3b29d496b4..10d9d52f7b 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index aeeb558992..961bdfe052 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index 88370b8445..036b06a73d 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index aa836eed70..ff485edb80 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 6f6f039e90..92e3f35399 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 760b72e550..219a7c03c5 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 9369d6099c..51e84e6aa4 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 83cf25ea31..e478ec2c6e 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index dd4135aada..9943bbb874 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index b38dc049e5..001ebf9b28 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 0b61bf14bb..44dfc939c5 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 0681156d8a..132ebc40ed 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 11ac167ad1..61fbeecafb 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index 6985e75741..da04a22970 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index 19c9e7b52b..e8f6f95e24 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index 85ae63914c..b878045569 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 051f54b5ca..54d0d4fec2 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 5e9b90fbbc..f2f9e72604 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 2583c59cf9..f81c05dee0 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index 76f57f888a..36ac82c0af 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index bf75ad18bc..f30cf2d505 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 414b73d58e..1ad27ea09c 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index 63d1802112..b5f9130585 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index d6fa461ffc..83ca7e219c 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 1d788413ee..a57c96579a 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index ce9b424345..e41184ab8e 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index 3953f1f554..e9bfcd790e 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index 356d79df2f..d2fae72d72 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index dff1d717c8..87a6072ec3 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 4c14b5d7b5..f81780005e 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 0ac017bc39..f1aeba99cc 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 471ff4484b..8fcb788aab 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.15] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.14] — Unreleased _Development cycle in progress._ diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 246e05ce37..2cab59dd4a 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -113,7 +113,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `cloudflare-ai` | `cf` | Cloudflare Workers AI | API key | [link](https://developers.cloudflare.com/workers-ai) | Requires API Token AND Account ID (found at dash.cloudflare.com) | | `codestral` | `codestral` | Codestral | API key | [link](https://mistral.ai) | — | | `cohere` | `cohere` | Cohere | API key | [link](https://cohere.com) | Free Trial: 1,000 API calls/month for testing, no credit card required | -| `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint. | +| `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /provider/v1/chat/completions endpoint. | | `coze` | `coze` | Coze | API key | [link](https://coze.com) | Get API key at coze.com/open/api | | `crof` | `crof` | CrofAI | API key | [link](https://crof.ai) | — | | `databricks` | `databricks` | Databricks | API key, enterprise | [link](https://www.databricks.com) | — | diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 176f8ea5bb..8a8b5ffecf 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.14 + version: 3.8.15 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/electron/package-lock.json b/electron/package-lock.json index f93404345a..bc8f19b8ad 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.14", + "version": "3.8.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.14", + "version": "3.8.15", "license": "MIT", "dependencies": { "electron-updater": "^6.8.8" diff --git a/electron/package.json b/electron/package.json index d86270d603..0c13c46e4b 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.14", + "version": "3.8.15", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/open-sse/config/providerErrorRules.ts b/open-sse/config/providerErrorRules.ts new file mode 100644 index 0000000000..d9f2797fe3 --- /dev/null +++ b/open-sse/config/providerErrorRules.ts @@ -0,0 +1,146 @@ +/** + * Provider-specific error rules. + * + * Different providers expose different quota signals: + * - Opencode: account-wide quota. A 429 with `x-ratelimit-remaining-requests: 0` + * means the whole organization is out — we must lock the connection, not + * a specific model, so the combo router falls back to a different provider. + * - Minimax: per-model quota. A 429 with `x-model-quota-remaining: =0` + * means only that model is locked — the rest of the connection stays healthy. + * + * New providers register a `ProviderErrorRule[]` in `providerRuleRegistry`. Rules + * are evaluated BEFORE the global ERROR_RULES in classifyError. If no rule + * matches, behavior falls through to the existing global text/status rules. + * + * Adding a new provider = create one ProviderErrorRule[] and register it below. + * No changes to classifyError, lockModel, or updateProviderConnection needed. + */ + +import type { ConfiguredErrorReason } from "./errorConfig.ts"; + +export type ProviderErrorRule = { + id: string; + match: (ctx: { + status: number; + headers: Record; + body: unknown; + }) => ProviderErrorRuleMatch | null; +}; + +export type ProviderErrorRuleMatch = { + reason: ConfiguredErrorReason; + /** Default "provider" — lock the whole connection so other providers take over. */ + scope: "model" | "provider" | "connection"; + /** Optional explicit cooldown; falls back to the existing per-reason defaults. */ + cooldownMs?: number; +}; + +// ─── Opencode ────────────────────────────────────────────────────────────── +// Opencode Go uses an account-wide quota. The body usually says "rate limit +// reached" but the presence of `x-ratelimit-remaining-requests: 0` is the +// tell. Without this rule, an exhausted org quota would be classified as +// RATE_LIMIT_EXCEEDED (~5s cooldown), causing the combo to keep retrying +// every model on the same provider until the 5h window resets. +function buildOpencodeRules(): ProviderErrorRule[] { + return [ + { + id: "opencode-quota-exhausted-headers", + match: ({ status, headers }) => { + if (status !== 429) return null; + const remainingRequests = headers["x-ratelimit-remaining-requests"]; + if (remainingRequests === "0") { + return { reason: "quota_exhausted", scope: "provider" }; + } + const remainingTokens = headers["x-ratelimit-remaining-tokens"]; + if (remainingTokens === "0") { + return { reason: "quota_exhausted", scope: "provider" }; + } + return null; + }, + }, + { + id: "opencode-quota-exhausted-body", + match: ({ status, body }) => { + if (status !== 429) return null; + const text = JSON.stringify(body ?? "").toLowerCase(); + if ( + text.includes("organization_quota_exceeded") || + text.includes("account_quota_exceeded") || + text.includes("plan_limit_reached") + ) { + return { reason: "quota_exhausted", scope: "provider" }; + } + return null; + }, + }, + ]; +} + +// ─── Minimax ──────────────────────────────────────────────────────────────── +// Minimax returns per-model quota info via custom headers. The body is generic +// "rate limit exceeded" so we MUST read the headers. Other models on the same +// connection stay healthy; only the named model gets locked. +function buildMinimaxRules(): ProviderErrorRule[] { + return [ + { + id: "minimax-per-model-quota", + match: ({ status, headers }) => { + if (status !== 429) return null; + // Header pattern: "x-model-quota-remaining: haiku=0,sonnet=42,opus=100" + const headerVal = headers["x-model-quota-remaining"]; + if (!headerVal) return null; + // If any model reports 0 remaining, the request was rejected for that + // model. We classify as quota_exhausted so lockModel is called with + // scope=model instead of poisoning the whole connection. + const exhausted = headerVal + .split(",") + .some((pair) => pair.split("=")[1]?.trim() === "0"); + if (exhausted) { + return { reason: "quota_exhausted", scope: "model" }; + } + return null; + }, + }, + ]; +} + +/** + * Global registry. Provider name → ordered list of rules (first match wins). + * Add new providers here; the matcher in classifyError will pick them up + * automatically. + */ +export const providerRuleRegistry = new Map([ + ["opencode", buildOpencodeRules()], + ["opencode-go", buildOpencodeRules()], + ["opencode-cli", buildOpencodeRules()], + ["minimax", buildMinimaxRules()], + ["minimax-passthrough", buildMinimaxRules()], +]); + +/** + * Returns the first matching rule for a provider, or null if none match. + * Callers use this to (a) classify the reason and (b) decide whether to + * lock just the model or the whole connection. + */ +export function getProviderErrorRuleMatch( + provider: string | null | undefined, + status: number, + headers: Headers | Record | null | undefined, + body?: unknown +): ProviderErrorRuleMatch | null { + if (!provider) return null; + const rules = providerRuleRegistry.get(provider); + if (!rules) return null; + // Normalize headers: accept either a `Headers` object (from `fetch()`) or + // a plain record. Provider rules access headers via plain object indexing. + const safeHeaders: Record = !headers + ? {} + : typeof (headers as Headers).get === "function" + ? Object.fromEntries((headers as Headers).entries()) + : (headers as Record); + for (const rule of rules) { + const match = rule.match({ status, headers: safeHeaders, body }); + if (match) return match; + } + return null; +} diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 91b3e959c4..6e76c4da8c 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1626,8 +1626,9 @@ const _REGISTRY_EAGER: Record = { alias: "cmd", format: "openai", executor: "command-code", - baseUrl: "https://api.commandcode.ai", - chatPath: "/alpha/generate", + baseUrl: "https://api.commandcode.ai/provider/v1", + chatPath: "/chat/completions", + modelsUrl: "https://api.commandcode.ai/provider/v1/models", authType: "apikey", authHeader: "Authorization", authPrefix: "Bearer ", diff --git a/open-sse/executors/commandCode.ts b/open-sse/executors/commandCode.ts index 81c1e04611..f606ec3cbb 100644 --- a/open-sse/executors/commandCode.ts +++ b/open-sse/executors/commandCode.ts @@ -509,8 +509,8 @@ export class CommandCodeExecutor extends BaseExecutor { } buildUrl() { - const baseUrl = (this.config.baseUrl || "https://api.commandcode.ai").replace(/\/$/, ""); - return `${baseUrl}${this.config.chatPath || "/alpha/generate"}`; + const baseUrl = (this.config.baseUrl || "https://api.commandcode.ai/provider/v1").replace(/\/$/, ""); + return `${baseUrl}${this.config.chatPath || "/chat/completions"}`; } async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }: ExecuteInput) { diff --git a/open-sse/package.json b/open-sse/package.json index ace6702703..ba1845cc2c 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.14", + "version": "3.8.15", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 140317964a..690abf8609 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -12,6 +12,7 @@ import { matchErrorRuleByText, matchErrorRuleByStatus, } from "../config/errorConfig.ts"; +import { getProviderErrorRuleMatch } from "../config/providerErrorRules.ts"; import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts"; import { DEFAULT_RESILIENCE_SETTINGS, @@ -78,12 +79,12 @@ function toJsonRecord(value: unknown): JsonRecord { } // Provider-level failure tracking for circuit breaker behavior -// Error codes that count toward provider-level failure threshold -// 429 (rate limit) is intentionally excluded: rate limits are connection-scoped -// and handled via Connection Cooldown, not provider-wide circuit breaker. -// Counting 429 toward provider failure causes cascading provider trips at scale -// when many connections hit rate limits simultaneously (Issue #1846). -const PROVIDER_FAILURE_ERROR_CODES = new Set([408, 500, 502, 503, 504]); +// Error codes that count toward provider-level failure threshold. +// 429 is included: per-error-type cooldowns (rate_limit: 60s, quota_exhausted: 1h) +// prevent cascading provider trips at scale (Issue #1846 concern addressed), +// while still allowing the circuit breaker to open on sustained 429s and +// prevent infinite combo retries (Issue #3200). +const PROVIDER_FAILURE_ERROR_CODES = new Set([408, 429, 500, 502, 503, 504]); // Per-connection failure deduplication: prevents rapid-fire failures from the // same connection from counting multiple times toward the provider breaker. @@ -1009,8 +1010,31 @@ export function classifyErrorText(errorText: unknown): RateLimitReasonValue { /** * Classify HTTP status + error text into RateLimitReason + * + * If context (provider, headers, body) is supplied, provider-specific rules + * are evaluated FIRST. A provider like Opencode can signal account-wide quota + * exhaustion via `x-ratelimit-remaining-requests: 0` even when the body says + * "rate limit" — without context, classifyError falls through to the global + * text rules and misclassifies as RATE_LIMIT_EXCEEDED. With context, the + * provider rule takes precedence. */ -export function classifyError(status: number, errorText: unknown): RateLimitReasonValue { +export function classifyError( + status: number, + errorText: unknown, + context?: { provider?: string | null; headers?: Record | null; body?: unknown } +): RateLimitReasonValue { + // Provider-specific rules take priority — they have the most accurate signal + // (e.g. `x-ratelimit-remaining-requests: 0` is irrefutable account exhaustion). + if (context?.provider) { + const match = getProviderErrorRuleMatch( + context.provider, + status, + context.headers ?? null, + context.body + ); + if (match) return match.reason; + } + // Text classification takes priority (more specific) const textReason = classifyErrorText(errorText); if (textReason !== RateLimitReason.UNKNOWN) return textReason; @@ -1367,7 +1391,21 @@ export function checkFallbackError( : findMatchingErrorRule(status, errorStr); if (configuredRule) { if (configuredRule.backoff) { - return buildRetryableFallback(configuredRule.reason ?? classifyError(status, errorStr)); + // Provider-specific rules in `providerRuleRegistry` are MORE SPECIFIC + // than the configured (global) rule, so we check them first. If a + // provider rule matches, it overrides the configured rule's reason + // (e.g. Opencode's `x-ratelimit-remaining-requests: 0` overrides + // 429 → RATE_LIMIT_EXCEEDED). We do NOT call the full `classifyError` + // here because its global status fallback would otherwise override + // specific configured reasons (e.g. 503 → SERVER_ERROR would be + // shadowed by 503 → MODEL_CAPACITY). + const providerMatch = provider + ? getProviderErrorRuleMatch(provider, status, headers, structuredError ?? null) + : null; + const reason = providerMatch + ? providerMatch.reason + : (configuredRule.reason ?? RateLimitReason.UNKNOWN); + return buildRetryableFallback(reason); } const cooldownMs = configuredRule.cooldownMs ?? 0; return { diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index 90203dc81e..eb743f9959 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -6,6 +6,7 @@ import { getProviderConnections } from "@/lib/db/providers"; import { getProviderRegistry } from "./providerRegistryAccessor"; import type { ConnectionFields } from "@/lib/db/encryption"; import { NOAUTH_PROVIDERS } from "@/shared/constants/providers"; +import { hasUsableWebSessionCredential } from "@/shared/providers/webSessionCredentials"; import { defaultLogger as log } from "@omniroute/open-sse/utils/logger"; /** Minimal connection shape needed for virtual auto-combo factory */ @@ -15,12 +16,14 @@ interface VirtualFactoryConn extends ConnectionFields { defaultModel?: string; expiresAt?: number | string | null; tokenExpiresAt?: number | string | null; + providerSpecificData?: Record | null; } type NoAuthProviderDefinition = { id?: string; alias?: string; noAuth?: boolean; + serviceKinds?: string[]; }; export interface VirtualAutoComboCandidate { @@ -88,8 +91,23 @@ function hasUsableOAuthToken(conn: VirtualFactoryConn): boolean { return expiryMs === null || expiryMs > Date.now(); } +function hasProviderSpecificSessionData(conn: VirtualFactoryConn): boolean { + return hasUsableWebSessionCredential(conn.provider, conn.providerSpecificData); +} + +function hasUsableConnectionCredential(conn: VirtualFactoryConn): boolean { + const hasApiKey = typeof conn.apiKey === "string" && conn.apiKey.trim().length > 0; + return hasApiKey || hasUsableOAuthToken(conn) || hasProviderSpecificSessionData(conn); +} + const SYNTHETIC_NOAUTH_CONNECTION_ID = "noauth"; -const ZERO_CONFIG_NOAUTH_CHAT_PROVIDERS = new Set(["opencode"]); + +function isChatAutoComboNoAuthProvider(providerDef: NoAuthProviderDefinition): boolean { + if (providerDef.noAuth !== true) return false; + if (!Array.isArray(providerDef.serviceKinds) || providerDef.serviceKinds.length === 0) + return true; + return providerDef.serviceKinds.includes("llm"); +} function getFirstRegistryModelId(providerInfo: { models?: Array<{ id?: string }> } | undefined) { const firstModel = Array.isArray(providerInfo?.models) ? providerInfo.models[0] : undefined; @@ -103,11 +121,10 @@ function getNoAuthCandidates(excludedProviders: Set): VirtualAutoComboCa const candidates: VirtualAutoComboCandidate[] = []; for (const providerDef of Object.values(NOAUTH_PROVIDERS) as NoAuthProviderDefinition[]) { - if (providerDef?.noAuth !== true) continue; + if (!isChatAutoComboNoAuthProvider(providerDef)) continue; const providerId = providerDef.id; if (!providerId || excludedProviders.has(providerId)) continue; - if (!ZERO_CONFIG_NOAUTH_CHAT_PROVIDERS.has(providerId)) continue; const providerInfo = registry[providerId]; const modelId = getFirstRegistryModelId(providerInfo); @@ -116,8 +133,8 @@ function getNoAuthCandidates(excludedProviders: Set): VirtualAutoComboCa // No-auth providers do not have provider_connections rows. Use the same // synthetic connection id returned by getProviderCredentials() so the // downstream combo path can still carry a stable target/account identity. - // For OpenCode Free specifically, route through its alias (oc/...) because - // opencode/... is a compatibility alias for the opencode-zen API-key tier. + // Prefer provider aliases because some canonical provider IDs are reserved + // for credentialed tiers with different routing semantics. const registryAlias = typeof providerInfo?.alias === "string" && providerInfo.alias.trim().length > 0 ? providerInfo.alias @@ -144,10 +161,7 @@ export async function createVirtualAutoCombo( ): Promise { const connections = (await getProviderConnections({ isActive: true })) as VirtualFactoryConn[]; - const validConnections = connections.filter((conn) => { - const hasApiKey = typeof conn.apiKey === "string" && conn.apiKey.trim().length > 0; - return hasApiKey || hasUsableOAuthToken(conn); - }); + const validConnections = connections.filter(hasUsableConnectionCredential); const candidatePool: VirtualAutoComboCandidate[] = []; for (const conn of validConnections) { diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index ddaa7b5077..7c4154129a 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -3890,6 +3890,8 @@ export async function handleComboChat({ fallbackResult.shouldFallback && (fallbackResult.reason === RateLimitReason.MODEL_CAPACITY || errorText.toLowerCase().includes("context") || + errorText.toLowerCase().includes("prompt") || + errorText.toLowerCase().includes("token") || errorText.toLowerCase().includes("malformed") || errorText.toLowerCase().includes("invalid") || errorText.toLowerCase().includes("bad request")) diff --git a/package-lock.json b/package-lock.json index e44c0f47aa..4901690a86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.14", + "version": "3.8.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.14", + "version": "3.8.15", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -21467,7 +21467,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.14" + "version": "3.8.15" } } } diff --git a/package.json b/package.json index 45d9f2b0a5..ec68e551c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.14", + "version": "3.8.15", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts b/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts index bdd8a9cf14..82dc484641 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts @@ -1,186 +1,8 @@ -import { WEB_COOKIE_PROVIDERS } from "@/shared/constants/providers"; +export { + WEB_SESSION_CREDENTIAL_REQUIREMENTS, + getWebSessionCredentialRequirement, + hasUsableWebSessionCredential, + requiresWebSessionCredential, +} from "@/shared/providers/webSessionCredentials"; -export type WebSessionCredentialRequirement = - | { - kind: "cookie" | "token"; - credentialName: string; - placeholder: string; - acceptsFullCookieHeader: boolean; - } - | { - kind: "none"; - credentialName: ""; - placeholder: ""; - acceptsFullCookieHeader: false; - }; - -export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { - "chatgpt-web": { - kind: "cookie", - credentialName: "__Secure-next-auth.session-token", - placeholder: "__Secure-next-auth.session-token=...", - acceptsFullCookieHeader: true, - }, - "grok-web": { - // Grok needs BOTH the sso and sso-rw cookies (#3180) — the hint previously named - // only `sso`, which read as "paste just sso" and produced anti-bot 403s. - kind: "cookie", - credentialName: "sso + sso-rw", - placeholder: "sso=...; sso-rw=...", - acceptsFullCookieHeader: true, - }, - "gemini-web": { - kind: "cookie", - credentialName: "__Secure-1PSID (optional: __Secure-1PSIDTS)", - placeholder: "__Secure-1PSID=...; __Secure-1PSIDTS=...", - acceptsFullCookieHeader: true, - }, - "perplexity-web": { - kind: "cookie", - credentialName: "__Secure-next-auth.session-token", - placeholder: "__Secure-next-auth.session-token=...", - acceptsFullCookieHeader: true, - }, - "blackbox-web": { - kind: "cookie", - credentialName: "__Secure-authjs.session-token", - placeholder: "__Secure-authjs.session-token=...; other=value", - acceptsFullCookieHeader: true, - }, - "muse-spark-web": { - kind: "cookie", - credentialName: "abra_sess", - placeholder: "abra_sess=...; other=value", - acceptsFullCookieHeader: true, - }, - "claude-web": { - kind: "cookie", - credentialName: "sessionKey", - placeholder: "sessionKey=... or full Cookie header from claude.ai", - acceptsFullCookieHeader: true, - }, - "deepseek-web": { - kind: "token", - credentialName: "userToken", - placeholder: "userToken=... or paste raw userToken", - acceptsFullCookieHeader: false, - }, - "copilot-web": { - kind: "token", - credentialName: "access_token", - placeholder: "access_token=... or a DevTools HAR export", - acceptsFullCookieHeader: false, - }, - "t3-web": { - kind: "cookie", - credentialName: "convex-session-id + Cookie header", - placeholder: "convex-session-id=abc123...; Cookie: ...", - acceptsFullCookieHeader: true, - }, - "adapta-web": { - kind: "cookie", - credentialName: "__client", - placeholder: "__client=... or full Cookie header from agent.adapta.one", - acceptsFullCookieHeader: true, - }, - "inner-ai": { - kind: "cookie", - credentialName: "token + email", - placeholder: "token_value user@example.com", - acceptsFullCookieHeader: false, - }, - huggingchat: { - kind: "cookie", - credentialName: "hf-chat", - placeholder: "hf-chat=... or full Cookie header from huggingface.co", - acceptsFullCookieHeader: true, - }, - phind: { - kind: "cookie", - credentialName: "phind_session", - placeholder: "phind_session=... or full Cookie header from phind.com", - acceptsFullCookieHeader: true, - }, - "poe-web": { - kind: "cookie", - credentialName: "p-b", - placeholder: "p-b=... or full Cookie header from poe.com", - acceptsFullCookieHeader: true, - }, - "venice-web": { - kind: "cookie", - credentialName: "session", - placeholder: "session=... or full Cookie header from venice.ai", - acceptsFullCookieHeader: true, - }, - "v0-vercel-web": { - kind: "cookie", - credentialName: "__vercel_session", - placeholder: "__vercel_session=... or full Cookie header from v0.dev", - acceptsFullCookieHeader: true, - }, - "kimi-web": { - kind: "cookie", - credentialName: "session", - placeholder: "session=... or full Cookie header from kimi.moonshot.cn", - acceptsFullCookieHeader: true, - }, - "doubao-web": { - kind: "cookie", - credentialName: "session", - placeholder: "session=... or full Cookie header from doubao.com", - acceptsFullCookieHeader: true, - }, - "qwen-web": { - kind: "token", - credentialName: "token", - placeholder: "Paste your Qwen token from chat.qwen.ai (Local Storage → token)", - acceptsFullCookieHeader: false, - }, - "duckduckgo-web": { - kind: "cookie", - credentialName: "duckai", - placeholder: "duckai=... or full Cookie header from duckduckgo.com", - acceptsFullCookieHeader: true, - }, - "t3-chat-web": { - kind: "token", - credentialName: "token", - placeholder: "Paste your T3 Chat token from t3.chat (Local Storage → token)", - acceptsFullCookieHeader: false, - }, - "chatglm-web": { - kind: "cookie", - credentialName: "chatglm_session", - placeholder: "chatglm_session=... or full Cookie header from chatglm.cn", - acceptsFullCookieHeader: true, - }, - "xiaomimimo-web": { - kind: "cookie", - credentialName: "session", - placeholder: "session=... or full Cookie header from aistudio.xiaomimimo.com", - acceptsFullCookieHeader: true, - }, - "manus-web": { - kind: "cookie", - credentialName: "manus_session", - placeholder: "manus_session=... or full Cookie header from manus.im", - acceptsFullCookieHeader: true, - }, -} satisfies Record; - -export function getWebSessionCredentialRequirement( - providerId: unknown -): WebSessionCredentialRequirement | null { - if (typeof providerId !== "string") return null; - return ( - WEB_SESSION_CREDENTIAL_REQUIREMENTS[ - providerId as keyof typeof WEB_SESSION_CREDENTIAL_REQUIREMENTS - ] ?? null - ); -} - -export function requiresWebSessionCredential(providerId: unknown): boolean { - const requirement = getWebSessionCredentialRequirement(providerId); - return !!requirement && requirement.kind !== "none"; -} +export type { WebSessionCredentialRequirement } from "@/shared/providers/webSessionCredentials"; diff --git a/src/app/api/oauth/kiro/auto-import/route.ts b/src/app/api/oauth/kiro/auto-import/route.ts index 177f264fb1..6bacc16d62 100755 --- a/src/app/api/oauth/kiro/auto-import/route.ts +++ b/src/app/api/oauth/kiro/auto-import/route.ts @@ -46,7 +46,7 @@ export async function GET(request: Request) { "Kiro credentials not found. " + "Run `kiro-cli login --use-device-flow` then retry, " + "or use the Import Token option in the dashboard.", - triedPaths: [sqliteResult.triedPath, cacheResult.triedPath].filter(Boolean), + triedPaths: [...(sqliteResult.triedPaths ?? []), cacheResult.triedPath].filter(Boolean), }); } @@ -54,7 +54,7 @@ export async function GET(request: Request) { async function tryKiroCliSqlite(): Promise<{ found: boolean; - triedPath?: string; + triedPaths?: string[]; refreshToken?: string; accessToken?: string; expiresAt?: string; @@ -64,94 +64,134 @@ async function tryKiroCliSqlite(): Promise<{ profileArn?: string; source?: string; }> { - const dbPath = join(homedir(), ".local/share/kiro-cli/data.sqlite3"); + // Build list of candidate DB paths to probe in order. + const candidatePaths: string[] = [join(homedir(), ".local/share/kiro-cli/data.sqlite3")]; + if (process.env.APPDATA) { + candidatePaths.push(join(process.env.APPDATA, "kiro", "storage.db")); + } let Database: any; try { Database = (await import("better-sqlite3")).default; } catch { - return { found: false, triedPath: dbPath }; + return { found: false, triedPaths: candidatePaths }; } - let db: any; - try { - db = new Database(dbPath, { readonly: true, fileMustExist: true }); - } catch { - return { found: false, triedPath: dbPath }; - } - - try { - // Read OIDC token (access + refresh token) - const tokenKeys = ["kirocli:odic:token", "kirocli:oidc:token"]; - let tokenData: any = null; - for (const key of tokenKeys) { - const row = db.prepare("SELECT value FROM auth_kv WHERE key = ?").get(key) as - | { value: string } - | undefined; - if (row?.value) { - try { - tokenData = JSON.parse(row.value); - break; - } catch { - // continue - } - } - } - - if (!tokenData?.refresh_token) { - return { found: false, triedPath: dbPath }; - } - - // Read device registration (client_id + client_secret) - const regKeys = ["kirocli:odic:device-registration", "kirocli:oidc:device-registration"]; - let regData: any = null; - for (const key of regKeys) { - const row = db.prepare("SELECT value FROM auth_kv WHERE key = ?").get(key) as - | { value: string } - | undefined; - if (row?.value) { - try { - regData = JSON.parse(row.value); - break; - } catch { - // continue - } - } - } - - // Read profileArn from state table (enterprise SSO / IDC) - let profileArn: string | undefined; + for (const dbPath of candidatePaths) { + let db: any; try { - const profileRow = db - .prepare("SELECT value FROM state WHERE key = 'api.codewhisperer.profile'") - .get() as { value: string } | undefined; - if (profileRow?.value) { - const profileData = JSON.parse(profileRow.value); - profileArn = profileData.arn || profileData.profileArn; - } + db = new Database(dbPath, { readonly: true, fileMustExist: true }); } catch { - // state table may not exist for personal Builder ID accounts + // File does not exist or cannot be opened — try next candidate. + continue; } - const region = tokenData.region || regData?.region || "us-east-1"; - const expiresAt = tokenData.expires_at - ? new Date(tokenData.expires_at).toISOString() - : new Date(Date.now() + 3600 * 1000).toISOString(); + try { + // Read OIDC token (access + refresh token). + // Try auth_kv table first (kiro-cli Linux/macOS schema), then fallback + // key-value tables used by the Kiro IDE on Windows (VS Code-style storage). + // "kiro:auth:token" is the key Kiro IDE writes in its VS Code Extension Storage + // API-backed SQLite (ItemTable / storage tables) — confirmed from #3363 reporter's + // %APPDATA%\kiro\storage.db dump where the token starts with "aorAAAAAG". + const tokenKeys = ["kirocli:odic:token", "kirocli:oidc:token", "kiro:auth:token"]; + let tokenData: any = null; - return { - found: true, - source: "kiro-cli-sqlite", - refreshToken: tokenData.refresh_token, - accessToken: tokenData.access_token, - expiresAt, - clientId: regData?.client_id, - clientSecret: regData?.client_secret, - region, - profileArn, - }; - } finally { - db.close(); + for (const key of tokenKeys) { + for (const table of ["auth_kv", "ItemTable", "storage"]) { + try { + const row = db.prepare(`SELECT value FROM ${table} WHERE key = ?`).get(key) as + | { value: string } + | undefined; + if (row?.value) { + try { + tokenData = JSON.parse(row.value); + if (tokenData?.refresh_token) break; + } catch { + // continue + } + } + } catch { + // no such table — skip gracefully + } + } + if (tokenData?.refresh_token) break; + } + + if (!tokenData?.refresh_token) { + continue; + } + + // Read device registration (client_id + client_secret). + const regKeys = ["kirocli:odic:device-registration", "kirocli:oidc:device-registration"]; + let regData: any = null; + for (const key of regKeys) { + for (const table of ["auth_kv", "ItemTable", "storage"]) { + try { + const row = db.prepare(`SELECT value FROM ${table} WHERE key = ?`).get(key) as + | { value: string } + | undefined; + if (row?.value) { + try { + regData = JSON.parse(row.value); + if (regData?.client_id) break; + } catch { + // continue + } + } + } catch { + // no such table — skip gracefully + } + } + if (regData?.client_id) break; + } + + // Read profileArn (enterprise SSO / IDC). The kiro-cli Linux schema stores this + // in the `state` table; the Windows Kiro IDE schema may store it in `ItemTable` + // or `storage` with the same key. Probe all three so IDC users on Windows also + // get a valid profileArn and are not silently downgraded to the Builder ID path. + let profileArn: string | undefined; + const profileKey = "api.codewhisperer.profile"; + for (const table of ["state", "ItemTable", "storage"]) { + try { + const profileRow = db + .prepare(`SELECT value FROM ${table} WHERE key = ?`) + .get(profileKey) as { value: string } | undefined; + if (profileRow?.value) { + const profileData = JSON.parse(profileRow.value); + profileArn = profileData.arn || profileData.profileArn; + if (profileArn) break; + } + } catch { + // table may not exist — skip gracefully + } + } + + const region = tokenData.region || regData?.region || "us-east-1"; + const expiresAt = tokenData.expires_at + ? new Date(tokenData.expires_at).toISOString() + : new Date(Date.now() + 3600 * 1000).toISOString(); + + return { + found: true, + source: "kiro-cli-sqlite", + refreshToken: tokenData.refresh_token, + accessToken: tokenData.access_token, + expiresAt, + clientId: regData?.client_id, + clientSecret: regData?.client_secret, + region, + profileArn, + }; + } finally { + try { + db.close(); + } catch { + // ignore close errors + } + } } + + return { found: false, triedPaths: candidatePaths }; } // ── ~/.aws/sso/cache fallback ───────────────────────────────────────────────── diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 928d52cc5c..17596a0e7c 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -496,7 +496,8 @@ export async function getUnifiedModelsResponse( const specContext = isPositiveFiniteNumber(spec?.contextWindow) ? spec.contextWindow : undefined; - const contextLength = syncedContext ?? registryContext ?? specContext; + const contextLength = syncedContext ?? registryContext ?? specContext ?? + (getTokenLimit(providerId, modelId) || undefined); const maxInputTokens = isPositiveFiniteNumber(synced?.limit_input) ? synced.limit_input : contextLength; diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index e2ac5d9b66..fda1067bda 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -567,8 +567,8 @@ async function validateDirectChatProvider({ export async function validateCommandCodeProvider({ apiKey, providerSpecificData = {} }: any) { const entry = getRegistryEntry("command-code"); - const baseUrl = normalizeBaseUrl(entry?.baseUrl || "https://api.commandcode.ai"); - const chatPath = entry?.chatPath || "/alpha/generate"; + const baseUrl = normalizeBaseUrl(entry?.baseUrl || "https://api.commandcode.ai/provider/v1"); + const chatPath = entry?.chatPath || "/chat/completions"; const url = `${baseUrl}${chatPath.startsWith("/") ? chatPath : `/${chatPath}`}`; const validationModelId = providerSpecificData?.validationModelId || diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index faa00d061f..f47a2dc866 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -38,6 +38,7 @@ export const NOAUTH_PROVIDERS = { website: "https://opencode.ai", noAuth: true, hasFree: true, + serviceKinds: ["llm"], authHint: "No API key required — uses OpenCode's public free endpoint.", freeNote: "No API key required — public OpenCode endpoint with Kimi, GLM, Qwen, MiMo, MiniMax models.", @@ -55,6 +56,7 @@ export const NOAUTH_PROVIDERS = { website: "https://duckduckgo.com/duckchat", noAuth: true, hasFree: true, + serviceKinds: ["llm"], freeNote: "Free — anonymous access to multiple AI models via DuckDuckGo.", authHint: "No credentials required — DuckDuckGo AI Chat is anonymous and free.", }, @@ -68,6 +70,7 @@ export const NOAUTH_PROVIDERS = { website: "https://theoldllm.vercel.app", noAuth: true, hasFree: true, + serviceKinds: ["llm"], freeNote: "Free — GPT-5.4, Claude 4.6 Opus/Sonnet/Haiku, + more. No API key — tokens auto-generated via browser.", authHint: @@ -83,6 +86,7 @@ export const NOAUTH_PROVIDERS = { website: "https://amelia.chipotle.com", noAuth: true, hasFree: true, + serviceKinds: ["llm"], freeNote: "Free — Chipotle's Pepper AI (IPsoft Amelia). Anonymous sessions, no API key. Rate-limited.", authHint: @@ -98,6 +102,7 @@ export const NOAUTH_PROVIDERS = { website: "https://veoaifree.com", noAuth: true, hasFree: true, + serviceKinds: ["video"], freeNote: "Free video generation — VEO 3.1, Seedance. 6 requests/hour.", authHint: "No auth required. Rate limited to 6 requests/hour per IP.", }, @@ -168,7 +173,8 @@ export const OAUTH_PROVIDERS = { subscriptionRisk: true, riskNoticeVariant: "deprecated", hasFree: true, - freeNote: "Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use.", + freeNote: + "Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use.", }, "amazon-q": { id: "amazon-q", @@ -567,7 +573,7 @@ export const WEB_COOKIE_PROVIDERS = { "Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → " + 'copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token).', }, - }; +}; // API Key Providers export const APIKEY_PROVIDERS = { @@ -593,7 +599,7 @@ export const APIKEY_PROVIDERS = { textIcon: "CC", website: "https://commandcode.ai/", authHint: - "Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint.", + "Use a Command Code API key. Requests are sent to Command Code's /provider/v1/chat/completions endpoint.", apiHint: "Create or copy an API key from Command Code, then paste it here as a Bearer token.", }, openrouter: { @@ -1139,6 +1145,7 @@ export const APIKEY_PROVIDERS = { icon: "opencode", color: "#6366f1", website: "https://opencode.ai/zen", + anonymousFallback: true, }, "opencode-go": { id: "opencode-go", @@ -1147,6 +1154,7 @@ export const APIKEY_PROVIDERS = { icon: "opencode", color: "#6366f1", website: "https://opencode.ai/go", + anonymousFallback: true, }, alibaba: { id: "alibaba", @@ -1177,7 +1185,8 @@ export const APIKEY_PROVIDERS = { textIcon: "LC", website: "https://longcat.chat/platform/docs", hasFree: true, - freeNote: "Free: 5M tokens/day on LongCat-2.0-Preview (Flash models retired 2026-05-29); up to 120M/day via feedback.", + freeNote: + "Free: 5M tokens/day on LongCat-2.0-Preview (Flash models retired 2026-05-29); up to 120M/day via feedback.", }, pollinations: { id: "pollinations", @@ -1188,6 +1197,7 @@ export const APIKEY_PROVIDERS = { textIcon: "PO", website: "https://pollinations.ai", hasFree: true, + anonymousFallback: true, freeNote: "No API key required for free public endpoint. Optional Spore tier: ~0.01 pollen/hour.", }, diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts new file mode 100644 index 0000000000..bf4c4a190a --- /dev/null +++ b/src/shared/providers/webSessionCredentials.ts @@ -0,0 +1,227 @@ +import { WEB_COOKIE_PROVIDERS } from "@/shared/constants/providers"; + +export type WebSessionCredentialRequirement = + | { + kind: "cookie" | "token"; + credentialName: string; + placeholder: string; + acceptsFullCookieHeader: boolean; + storageKeys: readonly string[]; + } + | { + kind: "none"; + credentialName: ""; + placeholder: ""; + acceptsFullCookieHeader: false; + storageKeys: readonly []; + }; + +export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { + "chatgpt-web": { + kind: "cookie", + credentialName: "__Secure-next-auth.session-token", + placeholder: "__Secure-next-auth.session-token=...", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "sessionToken", "session-token", "__Secure-next-auth.session-token"], + }, + "grok-web": { + kind: "cookie", + credentialName: "sso + sso-rw", + placeholder: "sso=...; sso-rw=...", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "sso", "sso-rw"], + }, + "gemini-web": { + kind: "cookie", + credentialName: "__Secure-1PSID (optional: __Secure-1PSIDTS)", + placeholder: "__Secure-1PSID=...; __Secure-1PSIDTS=...", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "__Secure-1PSID", "__Secure-1PSIDTS"], + }, + "perplexity-web": { + kind: "cookie", + credentialName: "__Secure-next-auth.session-token", + placeholder: "__Secure-next-auth.session-token=...", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "sessionToken", "session-token", "__Secure-next-auth.session-token"], + }, + "blackbox-web": { + kind: "cookie", + credentialName: "__Secure-authjs.session-token", + placeholder: "__Secure-authjs.session-token=...; other=value", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "sessionToken", "__Secure-authjs.session-token"], + }, + "muse-spark-web": { + kind: "cookie", + credentialName: "abra_sess", + placeholder: "abra_sess=...; other=value", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "abra_sess"], + }, + "claude-web": { + kind: "cookie", + credentialName: "sessionKey", + placeholder: "sessionKey=... or full Cookie header from claude.ai", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "sessionKey"], + }, + "deepseek-web": { + kind: "token", + credentialName: "userToken", + placeholder: "userToken=... or paste raw userToken", + acceptsFullCookieHeader: false, + storageKeys: ["token", "userToken"], + }, + "copilot-web": { + kind: "token", + credentialName: "access_token", + placeholder: "access_token=... or a DevTools HAR export", + acceptsFullCookieHeader: false, + storageKeys: ["token", "access_token", "accessToken"], + }, + "t3-web": { + kind: "cookie", + credentialName: "convex-session-id + Cookie header", + placeholder: "convex-session-id=abc123...; Cookie: ...", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "convex-session-id", "convexSessionId"], + }, + "adapta-web": { + kind: "cookie", + credentialName: "__client", + placeholder: "__client=... or full Cookie header from agent.adapta.one", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "__client"], + }, + "inner-ai": { + kind: "cookie", + credentialName: "token + email", + placeholder: "token_value user@example.com", + acceptsFullCookieHeader: false, + storageKeys: ["token", "cookie", "email"], + }, + huggingchat: { + kind: "cookie", + credentialName: "hf-chat", + placeholder: "hf-chat=... or full Cookie header from huggingface.co", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "hf-chat"], + }, + phind: { + kind: "cookie", + credentialName: "phind_session", + placeholder: "phind_session=... or full Cookie header from phind.com", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "phind_session"], + }, + "poe-web": { + kind: "cookie", + credentialName: "p-b", + placeholder: "p-b=... or full Cookie header from poe.com", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "p-b"], + }, + "venice-web": { + kind: "cookie", + credentialName: "session", + placeholder: "session=... or full Cookie header from venice.ai", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "session"], + }, + "v0-vercel-web": { + kind: "cookie", + credentialName: "__vercel_session", + placeholder: "__vercel_session=... or full Cookie header from v0.dev", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "__vercel_session"], + }, + "kimi-web": { + kind: "cookie", + credentialName: "session", + placeholder: "session=... or full Cookie header from kimi.moonshot.cn", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "session"], + }, + "doubao-web": { + kind: "cookie", + credentialName: "session", + placeholder: "session=... or full Cookie header from doubao.com", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "session"], + }, + "qwen-web": { + kind: "token", + credentialName: "token", + placeholder: "Paste your Qwen token from chat.qwen.ai (Local Storage → token)", + acceptsFullCookieHeader: false, + storageKeys: ["token", "tongyi_sso_ticket"], + }, + "duckduckgo-web": { + kind: "cookie", + credentialName: "duckai", + placeholder: "duckai=... or full Cookie header from duckduckgo.com", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "duckai"], + }, + "t3-chat-web": { + kind: "token", + credentialName: "token", + placeholder: "Paste your T3 Chat token from t3.chat (Local Storage → token)", + acceptsFullCookieHeader: false, + storageKeys: ["token"], + }, + "chatglm-web": { + kind: "cookie", + credentialName: "chatglm_session", + placeholder: "chatglm_session=... or full Cookie header from chatglm.cn", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "chatglm_session"], + }, + "xiaomimimo-web": { + kind: "cookie", + credentialName: "session", + placeholder: "session=... or full Cookie header from aistudio.xiaomimimo.com", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "session"], + }, + "manus-web": { + kind: "cookie", + credentialName: "manus_session", + placeholder: "manus_session=... or full Cookie header from manus.im", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "manus_session"], + }, +} satisfies Record; + +export function getWebSessionCredentialRequirement( + providerId: unknown +): WebSessionCredentialRequirement | null { + if (typeof providerId !== "string") return null; + return ( + WEB_SESSION_CREDENTIAL_REQUIREMENTS[ + providerId as keyof typeof WEB_SESSION_CREDENTIAL_REQUIREMENTS + ] ?? null + ); +} + +export function requiresWebSessionCredential(providerId: unknown): boolean { + const requirement = getWebSessionCredentialRequirement(providerId); + return !!requirement && requirement.kind !== "none"; +} + +function hasNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +export function hasUsableWebSessionCredential( + providerId: unknown, + providerSpecificData: unknown +): boolean { + const requirement = getWebSessionCredentialRequirement(providerId); + if (!requirement || requirement.kind === "none") return false; + if (!providerSpecificData || typeof providerSpecificData !== "object") return false; + + const data = providerSpecificData as Record; + return requirement.storageKeys.some((key) => hasNonEmptyString(data[key])); +} diff --git a/src/shared/validation/providerSchema.ts b/src/shared/validation/providerSchema.ts index f8615188ab..9351c621b1 100644 --- a/src/shared/validation/providerSchema.ts +++ b/src/shared/validation/providerSchema.ts @@ -42,6 +42,8 @@ export const ProviderSchema = z.object({ authHint: z.string().optional(), apiHint: z.string().optional(), serviceKinds: z.array(z.enum(SERVICE_KIND_VALUES)).optional(), + noAuth: z.boolean().optional(), + anonymousFallback: z.boolean().optional(), }); export const ProvidersMapSchema = z.record(z.string(), ProviderSchema); diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 2e5efb681a..6e8f0415ae 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -173,7 +173,7 @@ function intersectAllowedConnectionIds(primary: unknown, secondary: unknown): st return first || second || null; } -const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]); +const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 429, 500, 502, 503, 504]); /** * Handle chat completion request diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 7dc74cb87b..a5fce790cb 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -718,12 +718,17 @@ async function selectSessionAffinityConnection( /** * Sentinel connection id used for the synthetic credentials of no-auth / - * keyless providers (opencode / opencode-zen). It is NOT a real DB row, so it + * keyless providers. It is NOT a real DB row, so it * cannot carry cooldown state — the account-fallback loop must be able to * exclude it (#3061), otherwise it gets re-selected forever. */ const SYNTHETIC_NOAUTH_CONNECTION_ID = "noauth"; +type AnonymousFallbackProviderDefinition = { + anonymousFallback?: boolean; + noAuth?: boolean; +}; + function buildSyntheticNoAuthCredentials(): { apiKey: null; accessToken: null; @@ -764,6 +769,31 @@ function buildSyntheticNoAuthCredentials(): { }; } +function providerCanUseSyntheticNoAuthFallback(providerId: string): boolean { + const providerDef = getProviderById(providerId) as + | AnonymousFallbackProviderDefinition + | undefined; + return ( + providerDef?.anonymousFallback === true || + Boolean( + (NOAUTH_PROVIDERS as Record)[ + providerId + ]?.noAuth + ) || + Boolean( + (WEB_COOKIE_PROVIDERS as Record)[ + providerId + ]?.noAuth + ) + ); +} + +function maybeSyntheticNoAuthFallback(providerId: string, excludedConnectionIds: Set) { + if (!providerCanUseSyntheticNoAuthFallback(providerId)) return null; + if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) return null; + return buildSyntheticNoAuthCredentials(); +} + function normalizeExcludedConnectionIds( excludeConnectionId: string | null, extraExcludedConnectionIds: string[] | null | undefined @@ -940,10 +970,7 @@ export async function getProviderCredentials( excludeConnectionId, options.excludeConnectionIds ); - if (excludedForNoAuth.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) { - return null; - } - return buildSyntheticNoAuthCredentials(); + return maybeSyntheticNoAuthFallback(resolvedId, excludedForNoAuth); } const allowSuppressedConnections = options.allowSuppressedConnections === true; @@ -1028,6 +1055,9 @@ export async function getProviderCredentials( // the dashboard sees a misleading "bad_request" code. const terminalConnections = allConnections.filter(isTerminalConnectionStatus); if (terminalConnections.length === allConnections.length) { + const syntheticFallback = maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds); + if (syntheticFallback) return syntheticFallback; + const statusCounts = new Map(); for (const c of terminalConnections) { const key = normalizeStatus(c.testStatus) || "expired"; @@ -1042,21 +1072,8 @@ export async function getProviderCredentials( }; } } - // #2962: opencode-zen exposes the public, signup-free OpenCode Zen endpoint - // (https://opencode.ai/zen/v1). With no usable API-key connection, fall back - // to anonymous (no-auth) access — the free tier — instead of erroring with - // "No credentials". This is what the Playground/combos hit when selecting an - // OpenCode free model. A configured, active key is still selected above; a - // rate-limited/terminal key returns its own signal before reaching here. - if (resolvedId === "opencode-zen") { - // #3061: same loop guard as the NOAUTH_PROVIDERS path above — once the - // single synthetic "noauth" connection has been excluded by the chat - // fallback loop, return null instead of re-handing it back forever. - if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) { - return null; - } - return buildSyntheticNoAuthCredentials(); - } + const syntheticFallback = maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds); + if (syntheticFallback) return syntheticFallback; log.warn("AUTH", `No credentials for ${provider}`); return null; } @@ -1226,13 +1243,8 @@ export async function getProviderCredentials( cooldownModel: allBlockedByModelCooldown ? requestedModel : null, }; } - if (resolvedId === "opencode-zen") { - if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) { - return null; - } - return buildSyntheticNoAuthCredentials(); - } - + const syntheticFallback = maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds); + if (syntheticFallback) return syntheticFallback; log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`); return null; } @@ -1586,7 +1598,10 @@ export async function getProviderCredentialsWithQuotaPreflight( return null; } - if (credentials.allRateLimited || credentials.allExpired) { + if ( + ("allRateLimited" in credentials && credentials.allRateLimited) || + ("allExpired" in credentials && credentials.allExpired) + ) { return credentials; } diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index 713a93440e..753e40e3ed 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -536,7 +536,7 @@ test("recordModelLockoutFailure uses provider profile cooldowns, backoff, and re // Provider-level failure circuit breaker tests test("isProviderFailureCode correctly identifies provider-wide transient error codes", () => { - assert.equal(isProviderFailureCode(429), false); + assert.equal(isProviderFailureCode(429), true); assert.equal(isProviderFailureCode(408), true); assert.equal(isProviderFailureCode(500), true); assert.equal(isProviderFailureCode(502), true); diff --git a/tests/unit/auth-opencode-zen-noauth-fallback.test.ts b/tests/unit/auth-opencode-zen-noauth-fallback.test.ts index 453a31b67a..218e186bcb 100644 --- a/tests/unit/auth-opencode-zen-noauth-fallback.test.ts +++ b/tests/unit/auth-opencode-zen-noauth-fallback.test.ts @@ -37,6 +37,22 @@ test("#2962 opencode-zen with no connection falls back to anonymous no-auth cred assert.equal((creds as { apiKey?: unknown }).apiKey, null, "anonymous access carries no api key"); }); +test("apikey providers with anonymous fallback use no-auth when saved rows are terminal", async () => { + await createProviderConnection({ + provider: "pollinations", + authType: "apikey", + name: "expired-pollinations-key", + apiKey: "pollinations-expired", + isActive: true, + testStatus: "expired", + }); + + const creds = await getProviderCredentials("pollinations"); + assert.ok(creds, "pollinations should fall back to anonymous credentials"); + assert.equal((creds as { connectionId?: string }).connectionId, "noauth"); + assert.equal((creds as { apiKey?: unknown }).apiKey, null); +}); + test("#2962 a normal api-key provider with no connection still returns null (no over-broadening)", async () => { const creds = await getProviderCredentials("openai"); // Must NOT synthesize no-auth creds for a real api-key provider. diff --git a/tests/unit/command-code-executor.test.ts b/tests/unit/command-code-executor.test.ts index 250a61b081..758e5c7946 100644 --- a/tests/unit/command-code-executor.test.ts +++ b/tests/unit/command-code-executor.test.ts @@ -74,8 +74,8 @@ test("Command Code provider catalog has pinned models and alias lookup", () => { assert.ok(entry); assert.equal(entry.alias, "cmd"); assert.equal(entry.executor, "command-code"); - assert.equal(entry.baseUrl, "https://api.commandcode.ai"); - assert.equal(entry.chatPath, "/alpha/generate"); + assert.equal(entry.baseUrl, "https://api.commandcode.ai/provider/v1"); + assert.equal(entry.chatPath, "/chat/completions"); assert.deepEqual( entry.models.map((model) => model.id), PINNED_COMMAND_CODE_MODELS @@ -89,7 +89,7 @@ test("getExecutor returns the specialized Command Code executor", () => { assert.ok(getExecutor("cmd") instanceof CommandCodeExecutor); }); -test("Command Code executor posts wrapped body and required headers to alpha/generate", async () => { +test("Command Code executor posts wrapped body and required headers to /provider/v1/chat/completions", async () => { const calls: any[] = []; globalThis.fetch = async (url, init = {}) => { calls.push({ url: String(url), init }); @@ -112,9 +112,9 @@ test("Command Code executor posts wrapped body and required headers to alpha/gen }, }); - assert.equal(url, "https://api.commandcode.ai/alpha/generate"); + assert.equal(url, "https://api.commandcode.ai/provider/v1/chat/completions"); assert.equal(calls.length, 1); - assert.equal(calls[0].url, "https://api.commandcode.ai/alpha/generate"); + assert.equal(calls[0].url, "https://api.commandcode.ai/provider/v1/chat/completions"); assert.equal(calls[0].init.method, "POST"); assert.equal(headers.Authorization, "Bearer cc_test_key"); assert.equal(headers["x-command-code-version"], "0.24.1"); diff --git a/tests/unit/executor-command-code.test.ts b/tests/unit/executor-command-code.test.ts index 13ddc7e900..47776f57b6 100644 --- a/tests/unit/executor-command-code.test.ts +++ b/tests/unit/executor-command-code.test.ts @@ -18,7 +18,7 @@ describe("CommandCodeExecutor", () => { const executor = new mod.CommandCodeExecutor(); const url = executor.buildUrl(); assert.ok(typeof url === "string"); - assert.ok(url.includes("generate") || url.includes("commandcode")); + assert.ok(url.includes("/chat/completions") && url.includes("commandcode")); }); it("execute throws when no API key", async () => { diff --git a/tests/unit/kiro-windows-auto-import-3363.test.ts b/tests/unit/kiro-windows-auto-import-3363.test.ts new file mode 100644 index 0000000000..b3bbf12cfd --- /dev/null +++ b/tests/unit/kiro-windows-auto-import-3363.test.ts @@ -0,0 +1,191 @@ +/** + * Regression guard for #3363 — Kiro auto-import failed on Windows because + * tryKiroCliSqlite() only probed the Linux/macOS path + * (~/.local/share/kiro-cli/data.sqlite3) and never checked the Kiro IDE + * path that Windows users have: %APPDATA%\kiro\storage.db + */ +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"; +// @ts-ignore — better-sqlite3 has no bundled types in this project +import Database from "better-sqlite3"; + +// Set DATA_DIR before importing any app modules so isAuthRequired() reads from +// a fresh, empty settings DB (no password → requireLogin defaults to false). +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kiro-3363-data-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); + +// Import the GET handler at the module level so the DB is initialised once +// before any test runs. +const { GET } = await import("../../src/app/api/oauth/kiro/auto-import/route.ts"); + +const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_APPDATA = process.env.APPDATA; +const ORIGINAL_FETCH = globalThis.fetch; + +let tmpHome: string; + +test.beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-kiro-3363-")); + // Reset DB instance so each test gets a clean settings DB (no requireLogin). + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + 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; + // 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_APPDATA !== undefined) { + process.env.APPDATA = ORIGINAL_APPDATA; + } else { + delete process.env.APPDATA; + } + globalThis.fetch = ORIGINAL_FETCH; + if (tmpHome) fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// Helper to call the GET handler and parse the JSON body. +async function callGet(): Promise<{ status: number; body: Record }> { + const request = new Request("http://localhost/api/oauth/kiro/auto-import"); + const response = await GET(request); + const body = (await response.json()) as Record; + return { status: response.status, body }; +} + +test("triedPaths includes the Windows APPDATA path when process.env.APPDATA is set", async () => { + // Simulate a Windows environment with %APPDATA% pointing to a temp dir. + // The storage.db file does not exist there, so the probe fails gracefully. + process.env.APPDATA = tmpHome; + + const { body } = await callGet(); + + assert.equal(body.found, false, "credentials must not be found when both DB files are absent"); + assert.ok(Array.isArray(body.triedPaths), "triedPaths must be an array"); + + const expectedWindowsPath = path.join(tmpHome, "kiro", "storage.db"); + assert.ok( + (body.triedPaths as string[]).includes(expectedWindowsPath), + `triedPaths must include the Windows APPDATA path ${expectedWindowsPath}, got: ${JSON.stringify(body.triedPaths)}` + ); +}); + +test("triedPaths does NOT include any Windows path when process.env.APPDATA is not set", async () => { + // APPDATA is already unset by beforeEach. + const { body } = await callGet(); + + assert.equal(body.found, false, "credentials must not be found when DB file is absent"); + assert.ok(Array.isArray(body.triedPaths), "triedPaths must be an array"); + + const paths = body.triedPaths as string[]; + + // No path should reference "kiro/storage.db" (the Windows IDE storage path). + const hasWindowsPath = paths.some( + (p) => p.includes("storage.db") && p.includes("kiro") + ); + assert.equal( + hasWindowsPath, + false, + `triedPaths must not include any Windows kiro/storage.db path when APPDATA is unset, got: ${JSON.stringify(paths)}` + ); +}); + +test("triedPaths always includes the Linux/macOS kiro-cli path", async () => { + const { body } = await callGet(); + + assert.ok(Array.isArray(body.triedPaths), "triedPaths must be an array"); + + const expectedLinuxPath = path.join(tmpHome, ".local/share/kiro-cli/data.sqlite3"); + assert.ok( + (body.triedPaths as string[]).includes(expectedLinuxPath), + `triedPaths must always include the Linux/macOS kiro-cli path ${expectedLinuxPath}, got: ${JSON.stringify(body.triedPaths)}` + ); +}); + +// ── Synthetic SQLite test: exercises the actual Windows schema-reading path ── + +/** + * Creates a minimal storage.db in the Windows Kiro IDE schema: + * - Table: ItemTable (key TEXT, value TEXT) + * - Row: key="kiro:auth:token", value=JSON with refresh_token starting with "aorAAAAAG" + * + * Then sets APPDATA to the directory containing kiro/storage.db, calls GET, + * and asserts found:true with the correct refreshToken. + */ +test("GET extracts refresh_token from a Windows storage.db with ItemTable schema", async () => { + // Build the Windows storage.db directory structure: /kiro/storage.db + const kiroDir = path.join(tmpHome, "kiro"); + fs.mkdirSync(kiroDir, { recursive: true }); + const dbPath = path.join(kiroDir, "storage.db"); + + // Populate the SQLite file using the Kiro IDE Windows schema. + const db = new Database(dbPath); + db.exec(` + CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT NOT NULL); + `); + const tokenValue = JSON.stringify({ + refresh_token: "aorAAAAAGsynthetic-refresh-token", + access_token: "access-synthetic", + expires_at: new Date(Date.now() + 3600 * 1000).toISOString(), + region: "us-east-1", + }); + db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)").run( + "kiro:auth:token", + tokenValue + ); + db.close(); + + // Point APPDATA at tmpHome so tryKiroCliSqlite() resolves + // join(process.env.APPDATA, "kiro", "storage.db") to dbPath. + process.env.APPDATA = tmpHome; + + // Mock fetch so the saveAndRespond path (kiroService.refreshToken()) succeeds + // without hitting the real Kiro OIDC endpoint. + globalThis.fetch = (async (url: string | URL | Request) => { + const u = String(url); + // registerClient() call — returns a new OIDC client registration. + if (u.includes("oidc.") && u.endsWith("/client/register")) { + return new Response( + JSON.stringify({ clientId: "reg-cid", clientSecret: "reg-secret", expiresIn: 86400 }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + // refreshToken() call — simulates Kiro OIDC token refresh. + if (u.includes("oidc.") && u.endsWith("/token")) { + return new Response( + JSON.stringify({ + accessToken: "access-refreshed", + refreshToken: "aorAAAAAGrefreshed-token", + expiresIn: 3600, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + throw new Error(`[kiro-3363 test] unexpected fetch to ${u}`); + }) as typeof fetch; + + const { status, body } = await callGet(); + + assert.equal( + status, + 200, + `expected HTTP 200, got ${status}: ${JSON.stringify(body)}` + ); + assert.equal( + body.found, + true, + `expected found:true from Windows storage.db, got: ${JSON.stringify(body)}` + ); +}); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 3d392c7d4a..a1dce71407 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -1416,7 +1416,7 @@ test("v1 models catalog computes combo context_length from known targets when so assert.equal( comboModel.context_length, 128000, - "combo context_length should be the MIN of known target model limits, ignoring unknown targets" + "combo context_length should be the MIN of known target model limits, ignoring targets with no registry/spec/synced source" ); }); diff --git a/tests/unit/provider-error-rules.test.ts b/tests/unit/provider-error-rules.test.ts new file mode 100644 index 0000000000..aac4f820a1 --- /dev/null +++ b/tests/unit/provider-error-rules.test.ts @@ -0,0 +1,120 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +/** + * Provider-specific error rules extend the global ERROR_RULES with per-provider + * signatures: headers, body markers, and a lock scope that tells the fallback + * engine whether to lock the model, the connection, or the entire provider. + * + * Each provider has its own quota model: + * - Opencode: account-wide quota. A 429 with `x-ratelimit-remaining-requests: 0` + * means the ORG is out, not just this model. + * - Minimax: per-model quota. A 429 with `x-model-quota-remaining: =0` + * means only that specific model is locked. + * - Anything else: falls back to global ERROR_RULES. + */ + +const { classifyError, checkFallbackError } = await import( + "../../open-sse/services/accountFallback.ts" +); +const { RateLimitReason } = await import( + "../../open-sse/config/constants.ts" +); + +test("S1: Opencode 429 with x-ratelimit-remaining-requests=0 → QUOTA_EXHAUSTED, not RATE_LIMIT_EXCEEDED", () => { + // Opencode uses account-wide quota. The header `x-ratelimit-remaining-requests: 0` + // signals the whole org is out, so this MUST classify as QUOTA_EXHAUSTED so the + // engine locks the provider connection (not just the model), forcing fallback to + // a different provider. + const reason = classifyError(429, "Rate limit reached", { + provider: "opencode", + headers: { "x-ratelimit-remaining-requests": "0" }, + body: { error: { message: "Rate limit reached" } }, + }); + assert.equal( + reason, + RateLimitReason.QUOTA_EXHAUSTED, + "Opencode account-wide quota exhaustion must classify as QUOTA_EXHAUSTED so the connection is locked, not the model" + ); +}); + +test("S2: Minimax 429 with x-model-quota-remaining header → QUOTA_EXHAUSTED with model scope", async () => { + // Minimax uses per-model quota. The header `x-model-quota-remaining: haiku=0` + // signals ONLY that model is locked; other models on the same connection must + // remain available. classifyError returns the reason; the caller (combo.ts) + // reads the scope from providerRuleMatch to decide lockModel vs updateProviderConnection. + const { providerRuleRegistry, getProviderErrorRuleMatch } = await import( + "../../open-sse/config/providerErrorRules.ts" + ); + + // The registry must be loaded for minimax + const minimaxRules = providerRuleRegistry.get("minimax"); + assert.ok( + minimaxRules && minimaxRules.length > 0, + "minimax must be registered in the provider rule registry" + ); + + // The match function returns { reason, scope } for the given provider + status + headers + const match = getProviderErrorRuleMatch("minimax", 429, { + "x-model-quota-remaining": "haiku=0", + }); + assert.ok(match, "minimax must have a rule that matches 429 + per-model quota header"); + assert.equal(match.reason, "quota_exhausted"); + assert.equal( + match.scope, + "model", + "Minimax per-model quota must scope the lock to the model only" + ); +}); + +test("S3: Regression — provider with no rules falls back to global ERROR_RULES unchanged", () => { + // A provider not in the registry (e.g. "unknown-vendor") must NOT cause + // classifyError to crash or return a different result. It must behave + // identically to the pre-feature implementation: global text/status rules. + const reason = classifyError(429, "rate limit reached", { + provider: "unknown-vendor", + headers: {}, + body: null, + }); + assert.equal( + reason, + RateLimitReason.RATE_LIMIT_EXCEEDED, + "Unknown providers must fall through to global rules without modification" + ); + + // And without any context at all (old call sites), still works. + const reasonNoCtx = classifyError(429, "rate limit reached"); + assert.equal(reasonNoCtx, RateLimitReason.RATE_LIMIT_EXCEEDED); +}); + +test("S4: End-to-end — checkFallbackError forwards provider+headers to classifyError", () => { + // The wiring test: when combo.ts calls checkFallbackError with provider=opencode + // and headers containing x-ratelimit-remaining-requests: 0, the reason must be + // QUOTA_EXHAUSTED (not RATE_LIMIT_EXCEEDED). This proves the registry is ACTIVE + // in the production fallback path, not just callable in isolation. + // + // Simulate what combo.ts:3849 does — it passes provider, headers, and structuredError. + // For Opencode with account-wide quota exhausted, the fallback should signal + // quota_exhausted so the combo router skips remaining targets from the same provider. + const result = checkFallbackError( + 429, + "rate limit reached", // generic body that would normally be RATE_LIMIT_EXCEEDED + 0, // backoffLevel + null, // model + "opencode", // provider + { "x-ratelimit-remaining-requests": "0" }, // headers + null, // profileOverride + null // structuredError + ); + + assert.equal( + result.reason, + RateLimitReason.QUOTA_EXHAUSTED, + "checkFallbackError must forward provider+headers to classifyError so the Opencode quota rule fires" + ); + assert.equal( + result.shouldFallback, + true, + "quota_exhausted must trigger fallback to the next provider" + ); +}); diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index 235326650d..b101f747a6 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -88,7 +88,7 @@ test("specialty provider validators cover Deepgram, AssemblyAI, ElevenLabs and I test("validateCommandCodeProvider ignores caller baseUrl and chatPath overrides", async () => { globalThis.fetch = async (url, init = {}) => { - assert.equal(String(url), "https://api.commandcode.ai/alpha/generate"); + assert.equal(String(url), "https://api.commandcode.ai/provider/v1/chat/completions"); const headers = init.headers as Record; assert.equal(headers.Authorization, "Bearer cc-key"); const body = JSON.parse(String(init.body)); @@ -1956,7 +1956,7 @@ test("validateCommandCodeProvider sends Command Code probe URL, headers, and wra assert.deepEqual(result, { valid: true, error: null }); assert.equal(calls.length, 1); - assert.equal(calls[0].url, "https://api.commandcode.ai/alpha/generate"); + assert.equal(calls[0].url, "https://api.commandcode.ai/provider/v1/chat/completions"); assert.equal(calls[0].method, "POST"); assert.equal(calls[0].headers.Authorization, "Bearer cc_test_key"); assert.equal(calls[0].headers["Content-Type"], "application/json"); diff --git a/tests/unit/responses-handler.test.ts b/tests/unit/responses-handler.test.ts index cdcd6676da..816bddc26c 100644 --- a/tests/unit/responses-handler.test.ts +++ b/tests/unit/responses-handler.test.ts @@ -251,7 +251,7 @@ test("handleResponsesCore transforms Command Code executor SSE through Responses }); assert.equal(result.success, true); - assert.equal(call.url, "https://api.commandcode.ai/alpha/generate"); + assert.equal(call.url, "https://api.commandcode.ai/provider/v1/chat/completions"); assert.equal(call.headers.Authorization, "Bearer cc_test_key"); assert.equal(call.headers["x-command-code-version"], "0.24.1"); assert.equal(call.body.params.model, "gpt-5.4-mini"); diff --git a/tests/unit/virtual-auto-combo.test.ts b/tests/unit/virtual-auto-combo.test.ts index fbf58e99ae..313f2e1db9 100644 --- a/tests/unit/virtual-auto-combo.test.ts +++ b/tests/unit/virtual-auto-combo.test.ts @@ -74,6 +74,78 @@ test("createVirtualAutoCombo includes OAuth accessToken connections with real ex assert.ok(combo.autoConfig.candidatePool.includes("anthropic")); }); +test("createVirtualAutoCombo includes configured web-session providers without apiKey fields", async () => { + await providersDb.createProviderConnection({ + provider: "qwen-web", + authType: "apikey", + name: "Qwen Web Session", + providerSpecificData: { token: "qwen-web-session-token" }, + defaultModel: "qwen3-coder-plus", + }); + + const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("coding"); + + const qwenWeb = combo.models.find((model) => model.providerId === "qwen-web"); + assert.ok(qwenWeb, "configured web-session providers should be auto-combo candidates"); + assert.equal(qwenWeb.model, "qwen-web/qwen3-coder-plus"); + assert.ok(combo.autoConfig.candidatePool.includes("qwen-web")); +}); + +test("createVirtualAutoCombo excludes web-session providers with empty required token data", async () => { + await providersDb.createProviderConnection({ + provider: "qwen-web", + authType: "apikey", + name: "Qwen Web Empty Session", + providerSpecificData: { token: " " }, + defaultModel: "qwen3-coder-plus", + }); + + const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("coding"); + + assert.equal( + combo.models.some((model) => model.providerId === "qwen-web"), + false, + "web-session providers with empty required token data must not be auto-combo candidates" + ); + assert.equal(combo.autoConfig.candidatePool.includes("qwen-web"), false); +}); + +test("createVirtualAutoCombo excludes web-session providers with irrelevant providerSpecificData", async () => { + await providersDb.createProviderConnection({ + provider: "chatgpt-web", + authType: "apikey", + name: "ChatGPT Web Invalid Session", + providerSpecificData: { unrelated: "value" }, + defaultModel: "gpt-4o", + }); + + const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("coding"); + + assert.equal( + combo.models.some((model) => model.providerId === "chatgpt-web"), + false, + "web-session providers with irrelevant providerSpecificData must not be auto-combo candidates" + ); + assert.equal(combo.autoConfig.candidatePool.includes("chatgpt-web"), false); +}); + +test("createVirtualAutoCombo includes cookie web-session providers with required cookie data", async () => { + await providersDb.createProviderConnection({ + provider: "chatgpt-web", + authType: "apikey", + name: "ChatGPT Web Session", + providerSpecificData: { cookie: "__Secure-next-auth.session-token=chatgpt-session" }, + defaultModel: "gpt-4o", + }); + + const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("coding"); + + const chatgptWeb = combo.models.find((model) => model.providerId === "chatgpt-web"); + assert.ok(chatgptWeb, "cookie web-session providers with required cookie data should be candidates"); + assert.equal(chatgptWeb.model, "chatgpt-web/gpt-4o"); + assert.ok(combo.autoConfig.candidatePool.includes("chatgpt-web")); +}); + test("createVirtualAutoCombo includes no-auth OpenCode Free without provider_connections rows", async () => { const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("fast"); @@ -87,6 +159,24 @@ test("createVirtualAutoCombo includes no-auth OpenCode Free without provider_con assert.ok(combo.autoConfig.candidatePool.includes("opencode")); }); +test("createVirtualAutoCombo includes all chat-capable no-auth providers without connections", async () => { + const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("fast"); + + const byProvider = new Map(combo.models.map((model) => [model.providerId, model])); + + assert.equal(byProvider.get("duckduckgo-web")?.connectionId, "noauth"); + assert.equal(byProvider.get("duckduckgo-web")?.model, "ddgw/gpt-4o-mini"); + assert.equal(byProvider.get("theoldllm")?.connectionId, "noauth"); + assert.equal(byProvider.get("theoldllm")?.model, "tllm/GPT_5_4"); + assert.equal(byProvider.get("chipotle")?.connectionId, "noauth"); + assert.equal(byProvider.get("chipotle")?.model, "pepper/pepper-1"); + assert.equal( + byProvider.has("veoaifree-web"), + false, + "video-only no-auth providers must not be inserted into chat auto-combos" + ); +}); + test("createVirtualAutoCombo keeps credential-required providers out when disconnected", async () => { const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("fast"); diff --git a/tests/unit/web-session-credentials.test.ts b/tests/unit/web-session-credentials.test.ts index 9f83f8910f..7bbf2422a7 100644 --- a/tests/unit/web-session-credentials.test.ts +++ b/tests/unit/web-session-credentials.test.ts @@ -21,18 +21,21 @@ test("web session credential metadata identifies cookie, token, and no-auth prov credentialName: "sso + sso-rw", placeholder: "sso=...; sso-rw=...", acceptsFullCookieHeader: true, + storageKeys: ["cookie", "sso", "sso-rw"], }); assert.deepEqual(webSessionCredentials.getWebSessionCredentialRequirement("copilot-web"), { kind: "token", credentialName: "access_token", placeholder: "access_token=... or a DevTools HAR export", acceptsFullCookieHeader: false, + storageKeys: ["token", "access_token", "accessToken"], }); assert.deepEqual(webSessionCredentials.getWebSessionCredentialRequirement("deepseek-web"), { kind: "token", credentialName: "userToken", placeholder: "userToken=... or paste raw userToken", acceptsFullCookieHeader: false, + storageKeys: ["token", "userToken"], }); // veoaifree-web is now a NOAUTH provider — not in WEB_SESSION_CREDENTIAL_REQUIREMENTS assert.equal(webSessionCredentials.getWebSessionCredentialRequirement("veoaifree-web"), null); @@ -41,9 +44,35 @@ test("web session credential metadata identifies cookie, token, and no-auth prov credentialName: "convex-session-id + Cookie header", placeholder: "convex-session-id=abc123...; Cookie: ...", acceptsFullCookieHeader: true, + storageKeys: ["cookie", "convex-session-id", "convexSessionId"], }); }); +test("web session credential validator requires provider-specific non-empty values", () => { + assert.equal( + webSessionCredentials.hasUsableWebSessionCredential("qwen-web", { token: "qwen-token" }), + true + ); + assert.equal( + webSessionCredentials.hasUsableWebSessionCredential("qwen-web", { token: " " }), + false + ); + assert.equal( + webSessionCredentials.hasUsableWebSessionCredential("qwen-web", { unrelated: "value" }), + false + ); + assert.equal( + webSessionCredentials.hasUsableWebSessionCredential("chatgpt-web", { + cookie: "__Secure-next-auth.session-token=session", + }), + true + ); + assert.equal( + webSessionCredentials.hasUsableWebSessionCredential("chatgpt-web", { unrelated: "value" }), + false + ); +}); + test("no-auth web providers can be saved without an API key", () => { assert.equal(providers.providerAllowsOptionalApiKey("veoaifree-web"), true); assert.equal(webSessionCredentials.requiresWebSessionCredential("veoaifree-web"), false);