From a25d5f1ef6e2d0bdd3dc4323c65405357e50eed6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 6 Jun 2026 19:13:11 -0300 Subject: [PATCH] Release v3.8.13 (#3327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(release): open v3.8.13 development cycle Bump 3.8.12 → 3.8.13 across package.json, lockfile, electron/, open-sse/, and docs/reference/openapi.yaml; add the [3.8.13] cycle placeholder to the root CHANGELOG and the 41 i18n mirrors. Integration branch for the v3.8.13 cycle — fixes/features land here via per-issue PRs and it merges to main at release time. * fix(ci): skip auto-deploy when VPS host is unreachable from the runner (#3299) Integrated into release/v3.8.13 * fix(dev): auto-rebuild better-sqlite3 on Node ABI mismatch at dev startup (#3301) Integrated into release/v3.8.13 * feat(api): accept path-scoped API keys on client API routes (#3300) Integrated into release/v3.8.13 * fix(sse): harden against empty responses causing Copilot Chat failures (#3297) Integrated into release/v3.8.13 * fix(api): remove Completions.me rickroll provider (discussion #3293) (#3302) Integrated into release/v3.8.13 * fix(opencode-provider): extract contextLength from live model catalog (#3298) Integrated into release/v3.8.13 * feat(web-cookie): self-service login infrastructure + auto-refresh daemon (#3292) Integrated into release/v3.8.13 * docs(changelog): record the v3.8.13 PRs merged this round (#3292/#3300/#3297/#3298/#3301/#3302/#3299) * fix(auth): harden URL token extraction — drop query-string fallback, gate to client routes (security follow-up to #3300) (#3309) Security follow-up to #3300 — integrated into release/v3.8.13 * docs: rename resolve-issues → review-issues skill references * fix(dashboard): keep no-auth providers visible under 'Show configured only' (#3290) (#3312) no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) never create a DB connection row so stats.total stays 0, which the configured-only filter treated as 'unconfigured' and hid them — even though they are always usable and appear unconditionally in /v1/models. filterConfiguredProviderEntries now treats displayAuthType === 'no-auth' as configured. Co-authored-by: uniQta * fix(cli): resolve update paths relative to script + recursive backup (#3295) (#3313) omniroute update always failed on a global install: - getCurrentVersion() read package.json from process.cwd(), which on a global npm/brew install is the user's working dir, not the package root → null → 'Could not determine current version'. - createBackup() resolved bin/ from cwd too, and passed the 'cli' directory to copyFileSync → EISDIR, swallowed by the catch → 'Failed to create backup'. Both now resolve package.json/bin relative to the script via import.meta.url, and the backup uses cpSync({recursive:true}) so the cli/ directory is copied. Co-authored-by: uniQta * fix(theoldllm): read upstream body once to avoid [502] body-already-read (#3296) (#3314) On the cached-token path the executor never enters the refresh branch, so the same upstream Response was read with .text() twice (token-rejection check + final body). A Response body is single-use, so the second read threw 'Body is unusable: Body has already been read', caught and surfaced as [502]. Read the body once into finalBody and only re-read after a token-rejection refetch. Co-authored-by: onizukashonan14-png * fix(sse): strip leaked internal tool envelopes from streaming output (#3311) Integrated into release/v3.8.13 * fix(sse): expose Claude + Gemini budget tiers in the antigravity catalog (#3184) (#3303) Integrated into release/v3.8.13 (#3184) * fix(catalog): compute combo context_length from known targets only (#3304) Integrated into release/v3.8.13 — live contextLength + known-targets combo context (#3298 follow-up) * chore(i18n): add message keys for proxy UI + vscode/ollama endpoint (#3307) Integrated into release/v3.8.13 — i18n message keys for proxy UI + vscode/ollama * feat(dashboard): i18n the proxy settings UI (#3310) Integrated into release/v3.8.13 — i18n the proxy settings UI * feat(api): model catalog enrichment + MCP model-catalog tools (#3306) Integrated into release/v3.8.13 — model catalog enrichment + MCP model-catalog tools, reconciled with #3309 URL-token hardening * test(catalog): align Antigravity preview-alias test with #3303 budget tiers #3303 added the Gemini `-high`/`-low` budget tiers to ANTIGRAVITY_PUBLIC_MODELS (user-callable on the Antigravity OAuth backend, verified via #3184), but did not update the catalog-route test that asserted `antigravity/gemini-3.1-pro-high` must NOT be exposed. The assertion now reflects the intended behavior — the client-visible budget alias IS surfaced — while keeping the legacy `gemini-claude-*` alias keys unexposed. Caught running the full catalog suite on the merged release HEAD (the #3303 round only ran the antigravity-aliases and usage-hardening files). * docs(changelog): record the 6 PRs merged this review round into v3.8.13 #3306/#3307/#3310 (New Features — VS Code split: catalog+MCP, i18n keys, proxy UI i18n), #3311/#3303/#3304 (Bug Fixes — SSE envelope sanitizer, antigravity budget tiers, combo known-targets context_length). * chore(release): finalize v3.8.13 changelog and cleanup Finalize the v3.8.13 changelog with release date, maintenance notes, and contributor credits. Update MCP docs to reference the correct tool inventory diagram, exclude nested .claude worktrees from ESLint scans, and tighten a response sanitizer type guard. * fix(dashboard): refresh connections after provider auth import (#3320) Integrated into release/v3.8.13 — refresh connections after provider auth import * fix(codex): strip client-only params on native /responses passthrough (#3317) (#3325) A /v1/responses request against the built-in codex/ provider does an openai-responses -> openai-responses passthrough (CodexExecutor.transformRequest returns the body early for _nativeCodexPassthrough). It forwarded client-only fields verbatim and the Codex upstream rejected them with 400 Unsupported parameter: prompt_cache_retention / safety_identifier / user — breaking Factory Droid (which injects all three). The chat-completions path already strips these (base.ts #1884, openai-responses translator #2770) but the passthrough skips translation. Strip the three fields in the shared block before the passthrough return; user is removed unconditionally since Codex /responses always rejects it. Co-authored-by: tycronk20 * fix(dashboard): normalize agent-bridge /state response to stop page crash (#3318) (#3326) The Agent Bridge page seeded a well-shaped initialData default then replaced it wholesale with the raw /api/tools/agent-bridge/state response. The route returns { server, agents } but the UI reads { serverState, agentStates, bypassPatterns, mappings }, so serverState became undefined and AgentBridgeServerCard crashed on serverState.running — surfaced as the full-page 'Internal Server Error' boundary (client render error, not a real 5xx). Add a shared normalizeAgentBridgeState() that maps the route shape into the page contract (server.running/certExists -> serverState) and always returns safe defaults (never undefined serverState). Wired into both the SSR loader (page.tsx) and the polling hook. The legacy 'agents' entry shape differs from AgentStateEntry so it is not coerced; full route<->page contract reconciliation (port, upstreamCa, bypassPatterns, mappings, agentStates) is a follow-up. Co-authored-by: tycronk20 * docs: VS Code/Ollama endpoints + env & i18n tooling (#3319) Integrated into release/v3.8.13 — VS Code/Ollama docs + env & i18n tooling * feat(provider): test-all endpoint, rate-limit overrides, visibility f… (#3267) Integrated into release/v3.8.13 — provider test-all endpoint, rate-limit overrides, model visibility * feat: auto-combo optimization, playground model dropdown, only-configured toggle (#3322) Integrated into release/v3.8.13 — auto-combo candidate expansion + playground dropdown + only-configured toggle * feat(api): VS Code Copilot Ollama-compatible BYOK endpoint (#3316) Integrated into release/v3.8.13 — VS Code Copilot Ollama-compatible BYOK endpoint (reconciled with #3306/#3309 auth hardening) * chore(release): document #3320 in the v3.8.13 changelog + contributor credits --------- Co-authored-by: Felipe Almeman <4226997+zhiru@users.noreply.github.com> Co-authored-by: Wilson Co-authored-by: Hernan Javier Ardila Sanchez Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: uniQta Co-authored-by: onizukashonan14-png Co-authored-by: tycronk20 Co-authored-by: Vinayrnani --- .dockerignore | 13 +- .github/workflows/deploy-vps.yml | 26 + .gitignore | 3 + @omniroute/opencode-provider/src/index.ts | 35 +- .../opencode-provider/tests/index.test.ts | 28 + CHANGELOG.md | 61 +- README.md | 13 + bin/cli/commands/update.mjs | 22 +- bin/reset-password.mjs | 0 docs/architecture/REPOSITORY_MAP.md | 2 +- docs/guides/SETUP_GUIDE.md | 9 + docs/i18n/ar/CHANGELOG.md | 6 + docs/i18n/az/CHANGELOG.md | 6 + docs/i18n/bg/CHANGELOG.md | 6 + docs/i18n/bn/CHANGELOG.md | 6 + docs/i18n/cs/CHANGELOG.md | 6 + docs/i18n/da/CHANGELOG.md | 6 + docs/i18n/de/CHANGELOG.md | 6 + docs/i18n/es/CHANGELOG.md | 6 + docs/i18n/fa/CHANGELOG.md | 6 + docs/i18n/fi/CHANGELOG.md | 6 + docs/i18n/fr/CHANGELOG.md | 6 + docs/i18n/gu/CHANGELOG.md | 6 + docs/i18n/he/CHANGELOG.md | 6 + docs/i18n/hi/CHANGELOG.md | 6 + docs/i18n/hu/CHANGELOG.md | 6 + docs/i18n/id/CHANGELOG.md | 6 + docs/i18n/in/CHANGELOG.md | 6 + docs/i18n/it/CHANGELOG.md | 6 + docs/i18n/ja/CHANGELOG.md | 6 + docs/i18n/ko/CHANGELOG.md | 6 + docs/i18n/mr/CHANGELOG.md | 6 + docs/i18n/ms/CHANGELOG.md | 6 + docs/i18n/nl/CHANGELOG.md | 6 + docs/i18n/no/CHANGELOG.md | 6 + docs/i18n/phi/CHANGELOG.md | 6 + docs/i18n/pl/CHANGELOG.md | 6 + docs/i18n/pt-BR/CHANGELOG.md | 6 + docs/i18n/pt/CHANGELOG.md | 6 + docs/i18n/ro/CHANGELOG.md | 6 + docs/i18n/ru/CHANGELOG.md | 6 + docs/i18n/sk/CHANGELOG.md | 6 + docs/i18n/sv/CHANGELOG.md | 6 + docs/i18n/sw/CHANGELOG.md | 6 + docs/i18n/ta/CHANGELOG.md | 6 + docs/i18n/te/CHANGELOG.md | 6 + docs/i18n/th/CHANGELOG.md | 6 + docs/i18n/tr/CHANGELOG.md | 6 + docs/i18n/uk-UA/CHANGELOG.md | 6 + docs/i18n/ur/CHANGELOG.md | 6 + docs/i18n/vi/CHANGELOG.md | 6 + docs/i18n/zh-CN/CHANGELOG.md | 6 + docs/reference/API_REFERENCE.md | 41 + docs/reference/CLI-TOOLS.md | 375 +++++ docs/reference/FREE_TIERS.md | 3 - docs/reference/PROVIDER_REFERENCE.md | 11 +- docs/reference/openapi.yaml | 2 +- electron/loginManager.js | 386 +++++ electron/main.js | 43 + electron/package-lock.json | 4 +- electron/package.json | 2 +- electron/preload.js | 11 +- eslint.config.mjs | 4 + open-sse/config/antigravityModelAliases.ts | 69 +- open-sse/config/freeModelCatalog.data.ts | 8 - open-sse/config/providerRegistry.ts | 55 +- open-sse/executors/codex.ts | 11 + open-sse/executors/theoldllm.ts | 10 +- open-sse/handlers/responseSanitizer.ts | 232 ++- open-sse/mcp-server/schemas/tools.ts | 1 + open-sse/mcp-server/server.ts | 252 ++- open-sse/package.json | 2 +- open-sse/services/autoRefreshDaemon.ts | 204 +++ open-sse/services/combo.ts | 1403 +++++++++-------- open-sse/services/inAppLoginService.ts | 256 +++ open-sse/services/rateLimitManager.ts | 115 +- open-sse/services/tokenExtractionConfig.ts | 411 +++++ open-sse/utils/stream.ts | 47 + package-lock.json | 6 +- package.json | 2 +- scripts/build/bootstrap-env.mjs | 29 +- scripts/dev/ensure-native-sqlite.mjs | 114 ++ scripts/dev/run-next.mjs | 7 + scripts/i18n/check-ui-keys-coverage.mjs | 11 +- .../dashboard/endpoint/ApiEndpointsTab.tsx | 53 +- .../dashboard/endpoint/EndpointPageClient.tsx | 3 + .../endpoint/VscodeTokenAliasCard.tsx | 213 +++ .../__tests__/ApiEndpointsTab.test.tsx | 111 +- .../__tests__/EndpointPageClient.test.tsx | 38 +- .../components/StudioConfigPane.tsx | 3 +- .../dashboard/providers/[id]/page.tsx | 635 ++++++-- .../providers/[id]/webSessionCredentials.ts | 30 + .../dashboard/providers/providerPageUtils.ts | 7 +- .../settings/components/ProxyTab.tsx | 2 +- .../components/proxy/DocumentationTab.tsx | 38 +- .../settings/components/proxy/FreePoolTab.tsx | 46 +- .../components/proxy/VercelRelayModal.tsx | 12 +- .../agent-bridge/hooks/useAgentBridgeState.ts | 5 +- .../tools/agent-bridge/normalizeState.ts | 76 + .../dashboard/tools/agent-bridge/page.tsx | 7 +- .../translator/hooks/useAvailableModels.tsx | 16 +- src/app/api/models/test-all/route.ts | 196 +++ src/app/api/models/test/route.ts | 237 +-- src/app/api/providers/[id]/login/route.ts | 75 + src/app/api/providers/[id]/route.ts | 11 + src/app/api/providers/route.ts | 43 + src/app/api/v1/models/catalog.ts | 59 +- .../api/v1/vscode/[token]/api/chat/route.ts | 1 + .../api/v1/vscode/[token]/api/show/route.ts | 329 ++++ .../api/v1/vscode/[token]/api/tags/route.ts | 257 +++ .../v1/vscode/[token]/api/version/route.ts | 20 + .../vscode/[token]/chat/completions/route.ts | 10 + src/app/api/v1/vscode/[token]/combos/route.ts | 41 + .../v1/vscode/[token]/familyFirstModelIds.ts | 85 + .../v1/vscode/[token]/modelPresentation.ts | 178 +++ src/app/api/v1/vscode/[token]/models/route.ts | 424 +++++ .../v1/vscode/[token]/reasoningMetadata.ts | 175 ++ .../api/v1/vscode/[token]/responses/route.ts | 10 + src/app/api/v1/vscode/[token]/route.ts | 20 + .../v1/vscode/[token]/serviceTierVariants.ts | 190 +++ .../api/v1/vscode/[token]/tokenizedRequest.ts | 50 + .../[token]/v1/chat/completions/route.ts | 10 + .../api/v1/vscode/[token]/v1/models/route.ts | 1 + .../combos/[token]/[[...slug]]/route.ts | 421 +++++ .../v1/vscode/raw/[token]/api/chat/route.ts | 5 + .../v1/vscode/raw/[token]/api/show/route.ts | 327 ++++ .../v1/vscode/raw/[token]/api/tags/route.ts | 213 +++ .../vscode/raw/[token]/api/version/route.ts | 20 + .../raw/[token]/chat/completions/route.ts | 10 + .../api/v1/vscode/raw/[token]/combos/route.ts | 41 + .../vscode/raw/[token]/familyFirstModelIds.ts | 85 + .../vscode/raw/[token]/modelPresentation.ts | 178 +++ .../api/v1/vscode/raw/[token]/models/route.ts | 43 + .../vscode/raw/[token]/reasoningMetadata.ts | 175 ++ .../v1/vscode/raw/[token]/responses/route.ts | 10 + src/app/api/v1/vscode/raw/[token]/route.ts | 20 + .../vscode/raw/[token]/serviceTierVariants.ts | 190 +++ .../v1/vscode/raw/[token]/tokenizedRequest.ts | 50 + .../raw/[token]/v1/chat/completions/route.ts | 10 + .../v1/vscode/raw/[token]/v1/models/route.ts | 1 + src/domain/types.ts | 6 + src/i18n/messages/en.json | 109 +- src/i18n/messages/pt-BR.json | 60 +- src/instrumentation-node.ts | 8 + src/lib/api/modelTestRunner.ts | 335 ++++ src/lib/api/requireManagementAuth.ts | 4 +- src/lib/db/core.ts | 5 + src/lib/db/models.ts | 38 + src/lib/db/providers.ts | 91 +- src/lib/localDb.ts | 1 + src/lib/modelMetadataRegistry.ts | 4 + src/server/authz/policies/clientApi.ts | 16 +- src/server/authz/policies/management.ts | 8 +- src/server/authz/routeGuard.ts | 21 +- src/shared/constants/modelSpecs.ts | 9 + src/shared/constants/providers.ts | 12 - src/shared/utils/apiAuth.ts | 45 +- src/shared/validation/schemas.ts | 14 + src/sse/services/auth.ts | 116 +- .../agent-bridge-state-normalize-3318.test.ts | 74 + tests/unit/antigravity-model-aliases.test.ts | 27 +- tests/unit/api-auth.test.ts | 50 + tests/unit/auth-extract-api-key.test.ts | 38 + tests/unit/autoRefreshDaemon.test.ts | 87 + .../unit/cli-update-global-paths-3295.test.ts | 65 + ...x-responses-passthrough-strip-3317.test.ts | 53 + .../combo-auto-candidate-expansion.test.ts | 127 ++ .../combo-custom-provider-resolution.test.ts | 2 +- .../unit/completions-provider-removed.test.ts | 26 + tests/unit/dev-ensure-native-sqlite.test.ts | 112 ++ tests/unit/empty-response-hardening.test.ts | 83 + tests/unit/executor-default-base.test.ts | 6 +- tests/unit/mcp-model-catalog.test.ts | 147 ++ tests/unit/model-test-runner.test.ts | 88 ++ tests/unit/models-catalog-route.test.ts | 82 +- tests/unit/provider-models-route.test.ts | 21 + ...ovider-rate-limit-overrides-schema.test.ts | 73 + tests/unit/providers-page-utils.test.ts | 34 + tests/unit/response-sanitizer.test.ts | 98 ++ ...te-guard-provider-login-local-only.test.ts | 38 + tests/unit/sse-auth.test.ts | 26 +- tests/unit/stream-utils.test.ts | 99 ++ tests/unit/t28-model-catalog-updates.test.ts | 8 +- .../unit/t31-t33-t34-t38-model-specs.test.ts | 7 +- .../theoldllm-body-double-read-3296.test.ts | 53 + tests/unit/tokenExtractionConfig.test.ts | 158 ++ tests/unit/usage-service-hardening.test.ts | 7 +- tests/unit/vscode-token-routes.test.ts | 1207 ++++++++++++++ 188 files changed, 12555 insertions(+), 1344 deletions(-) mode change 100644 => 100755 bin/reset-password.mjs create mode 100644 electron/loginManager.js create mode 100644 open-sse/services/autoRefreshDaemon.ts create mode 100644 open-sse/services/inAppLoginService.ts create mode 100644 open-sse/services/tokenExtractionConfig.ts create mode 100644 scripts/dev/ensure-native-sqlite.mjs create mode 100644 src/app/(dashboard)/dashboard/endpoint/VscodeTokenAliasCard.tsx create mode 100644 src/app/(dashboard)/dashboard/tools/agent-bridge/normalizeState.ts create mode 100644 src/app/api/models/test-all/route.ts create mode 100644 src/app/api/providers/[id]/login/route.ts create mode 100644 src/app/api/v1/vscode/[token]/api/chat/route.ts create mode 100644 src/app/api/v1/vscode/[token]/api/show/route.ts create mode 100644 src/app/api/v1/vscode/[token]/api/tags/route.ts create mode 100644 src/app/api/v1/vscode/[token]/api/version/route.ts create mode 100644 src/app/api/v1/vscode/[token]/chat/completions/route.ts create mode 100644 src/app/api/v1/vscode/[token]/combos/route.ts create mode 100644 src/app/api/v1/vscode/[token]/familyFirstModelIds.ts create mode 100644 src/app/api/v1/vscode/[token]/modelPresentation.ts create mode 100644 src/app/api/v1/vscode/[token]/models/route.ts create mode 100644 src/app/api/v1/vscode/[token]/reasoningMetadata.ts create mode 100644 src/app/api/v1/vscode/[token]/responses/route.ts create mode 100644 src/app/api/v1/vscode/[token]/route.ts create mode 100644 src/app/api/v1/vscode/[token]/serviceTierVariants.ts create mode 100644 src/app/api/v1/vscode/[token]/tokenizedRequest.ts create mode 100644 src/app/api/v1/vscode/[token]/v1/chat/completions/route.ts create mode 100644 src/app/api/v1/vscode/[token]/v1/models/route.ts create mode 100644 src/app/api/v1/vscode/combos/[token]/[[...slug]]/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/api/chat/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/api/show/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/api/tags/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/api/version/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/chat/completions/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/combos/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/familyFirstModelIds.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/modelPresentation.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/models/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/reasoningMetadata.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/responses/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/serviceTierVariants.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/tokenizedRequest.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/v1/chat/completions/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/v1/models/route.ts create mode 100644 src/lib/api/modelTestRunner.ts create mode 100644 tests/unit/agent-bridge-state-normalize-3318.test.ts create mode 100644 tests/unit/autoRefreshDaemon.test.ts create mode 100644 tests/unit/cli-update-global-paths-3295.test.ts create mode 100644 tests/unit/codex-responses-passthrough-strip-3317.test.ts create mode 100644 tests/unit/combo-auto-candidate-expansion.test.ts create mode 100644 tests/unit/completions-provider-removed.test.ts create mode 100644 tests/unit/dev-ensure-native-sqlite.test.ts create mode 100644 tests/unit/empty-response-hardening.test.ts create mode 100644 tests/unit/mcp-model-catalog.test.ts create mode 100644 tests/unit/model-test-runner.test.ts create mode 100644 tests/unit/provider-rate-limit-overrides-schema.test.ts create mode 100644 tests/unit/route-guard-provider-login-local-only.test.ts create mode 100644 tests/unit/theoldllm-body-double-read-3296.test.ts create mode 100644 tests/unit/tokenExtractionConfig.test.ts create mode 100644 tests/unit/vscode-token-routes.test.ts diff --git a/.dockerignore b/.dockerignore index bc06bf65fd..f0d97df70c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -39,10 +39,15 @@ playwright-report blob-report # Documentation -# Translations (~51 MB) are excluded — the Docs viewer reads English sources. -# Screenshots (~1.7 MB) and SVGs (~250 KB) are needed at build time for MDX -# image resolution (fumadocs-mdx bundles them). -# Raster sources under docs/diagrams/ only (exported SVGs are required). +# Issue #2348: The Dashboard Docs viewer reads markdown from `/app/docs` at +# runtime. The previous `docs/*` block hid every file except openapi.yaml, +# so the in-product help screen failed with ENOENT for every page. +# We now keep the English markdown tree plus the docs assets imported by MDX +# during `next build`, while still dropping the bulky translated docs and +# extra raster diagram sources that account for most of the docs footprint +# of the ~50 MB docs directory. The Docs viewer reads the default-locale +# (English) sources at runtime, so translations are not required in the +# container image. docs/i18n/** docs/diagrams/**/*.png docs/diagrams/**/*.jpg diff --git a/.github/workflows/deploy-vps.yml b/.github/workflows/deploy-vps.yml index 760a53ba1e..bcd5b61c78 100644 --- a/.github/workflows/deploy-vps.yml +++ b/.github/workflows/deploy-vps.yml @@ -17,7 +17,33 @@ jobs: name: Deploy OmniRoute to VPS runs-on: ubuntu-latest steps: + - name: Check VPS SSH reachability from runner + id: reach + env: + # Pass the host via env (never interpolate a secret straight into the + # script body) so /dev/tcp gets a shell variable, not inlined text. + VPS_HOST: ${{ secrets.VPS_HOST }} + run: | + set -uo pipefail + # A GitHub-hosted runner can only deploy when it can actually open a TCP + # connection to the VPS SSH port. The Local VPS lives on a private LAN and + # the Akamai host firewalls :22 to known IPs, so the runner is routinely + # unable to reach it (`dial tcp ***:22: i/o timeout`). Treat "unreachable + # from the runner" as a SKIP — the real deploys are run manually from an + # allowed network via the deploy-vps-local / deploy-vps-akamai skills — so + # an unreachable host no longer red-fails every release/push pipeline. + # When the host IS reachable, the deploy step below still runs in full and + # its health gate surfaces any genuine deploy failure. + if timeout 15 bash -c 'exec 3<>"/dev/tcp/${VPS_HOST}/22"' 2>/dev/null; then + echo "reachable=true" >> "$GITHUB_OUTPUT" + echo "✅ VPS_HOST:22 reachable from the runner — proceeding with deploy." + else + echo "reachable=false" >> "$GITHUB_OUTPUT" + echo "::warning title=Auto-deploy skipped::VPS_HOST:22 is not reachable from this GitHub runner (private LAN / firewalled). Deploy manually with the deploy-vps-local or deploy-vps-akamai skill." + fi + - name: Deploy via SSH + if: steps.reach.outputs.reachable == 'true' uses: appleboy/ssh-action@v1 with: host: ${{ secrets.VPS_HOST }} diff --git a/.gitignore b/.gitignore index c5e212e3b9..746f05b94f 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,9 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* !.env.example +# Provider API keys (never commit) +*.api-key +.nvidia-api-key # vercel .vercel diff --git a/@omniroute/opencode-provider/src/index.ts b/@omniroute/opencode-provider/src/index.ts index 6f3a2384ee..292bb22b4a 100644 --- a/@omniroute/opencode-provider/src/index.ts +++ b/@omniroute/opencode-provider/src/index.ts @@ -129,8 +129,8 @@ export interface OmniRouteProviderOptions { apiKey: string; /** Override the display name shown in OpenCode. Default: `"OmniRoute"`. */ displayName?: string; - /** Override the model catalog. Defaults to `OMNIROUTE_DEFAULT_OPENCODE_MODELS`. */ - models?: readonly string[]; + /** Override the model catalog. Accepts model ids (strings) or live model entries from `fetchLiveModels`. When entries carry a `contextLength`, it is used directly — no hardcoded map needed. */ + models?: readonly (string | { id: string; contextLength?: number })[]; /** Optional human-readable labels keyed by model id. Overridden by `modelCapabilities[id].label`. */ modelLabels?: Record; /** @@ -139,6 +139,12 @@ export interface OmniRouteProviderOptions { * for custom ids the override is used verbatim. */ modelCapabilities?: Record; + /** + * Optional per-model context-length overrides (tokens). Takes precedence + * over the static `OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS` map but is + * superseded by `contextLength` on live model entries passed via `models`. + */ + modelContextLengths?: Record; /** * Primary model for OpenCode (top-level `model` key). * Emitted as `"omniroute/"`. When omitted the key is not written. @@ -248,7 +254,12 @@ export function createOmniRouteProvider(options: OmniRouteProviderOptions): Open const models: Record = {}; const seen = new Set(); for (const raw of modelList) { - const id = typeof raw === "string" ? raw.trim() : ""; + const id = + typeof raw === "object" && raw !== null && "id" in raw && typeof (raw as any).id === "string" + ? (raw as { id: string }).id.trim() + : typeof raw === "string" + ? raw.trim() + : ""; if (!id || seen.has(id)) continue; seen.add(id); const defaults = OMNIROUTE_DEFAULT_MODEL_CAPABILITIES[id] ?? {}; @@ -266,10 +277,18 @@ export function createOmniRouteProvider(options: OmniRouteProviderOptions): Open if (typeof merged.temperature === "boolean") entry.temperature = merged.temperature; if (typeof merged.tool_call === "boolean") entry.tool_call = merged.tool_call; - // Include context window limit when known — OpenCode reads this to - // determine usable context length for compaction & overflow detection. - const contextLength = OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS[id]; - if (typeof contextLength === "number" && contextLength > 0) { + // Context window: live model entry (from API catalog) > modelContextLengths > static defaults + const liveContext = + typeof raw === "object" && raw !== null + ? (raw as { contextLength?: number }).contextLength + : undefined; + const rawContextLength = + liveContext ?? + options.modelContextLengths?.[id] ?? + OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS[id]; + const contextLength = + typeof rawContextLength === "string" ? parseInt(rawContextLength, 10) : rawContextLength; + if (typeof contextLength === "number" && !isNaN(contextLength) && contextLength > 0) { entry.limit = { context: contextLength }; } @@ -508,7 +527,7 @@ export interface OmniRouteLiveModel { * const config = buildOmniRouteOpenCodeConfig({ * baseURL: "http://localhost:20128", * apiKey: "sk_omniroute", - * models: models.map((m) => m.id), + * models, // OmniRouteLiveModel[] — contextLength auto-extracted * modelLabels: Object.fromEntries(models.map((m) => [m.id, m.name])), * }); * ``` diff --git a/@omniroute/opencode-provider/tests/index.test.ts b/@omniroute/opencode-provider/tests/index.test.ts index 50d3439a33..aac6e7a27b 100644 --- a/@omniroute/opencode-provider/tests/index.test.ts +++ b/@omniroute/opencode-provider/tests/index.test.ts @@ -430,6 +430,34 @@ test("createOmniRouteProvider omits limit.context for unknown model ids", () => assert.equal(entry.limit, undefined); }); +test("createOmniRouteProvider reads contextLength from a live model entry for ids absent from the static map", () => { + // #3298 regression guard: the static OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS + // map only covers the legacy 8 Claude/Gemini ids. Before this change, any + // other model got `undefined` context (see the test above, string form) and + // OpenCode silently fell back to its 128K internal default. A live model + // entry carrying `contextLength` must now surface as `limit.context`. + const provider = createOmniRouteProvider({ + baseURL: "http://localhost:20128", + apiKey: "sk_omniroute", + models: [{ id: "completely-unknown-model", contextLength: 262_144 }], + }); + const entry = provider.models["completely-unknown-model"]; + assert.ok(entry.limit, "a live contextLength should produce a limit field even for ids absent from the static map"); + assert.equal(entry.limit!.context, 262_144); +}); + +test("createOmniRouteProvider: a live model contextLength wins over the static default map", () => { + // `cc/claude-opus-4-8` has a static default (1_000_000). A live entry carrying + // a different contextLength must take precedence (live > modelContextLengths > + // static defaults). + const provider = createOmniRouteProvider({ + baseURL: "http://localhost:20128", + apiKey: "sk_omniroute", + models: [{ id: "cc/claude-opus-4-8", contextLength: 524_288 }], + }); + assert.equal(provider.models["cc/claude-opus-4-8"].limit!.context, 524_288); +}); + test("createOmniRouteProvider serialises limit.context to JSON", () => { const provider = createOmniRouteProvider({ baseURL: "http://localhost:20128", diff --git a/CHANGELOG.md b/CHANGELOG.md index 544ee7d5b2..21afca071d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,65 @@ --- +## [3.8.13] — 2026-06-06 + +### ✨ New Features + +- **feat(web-cookie):** self-service login infrastructure for 21 web-cookie providers — three login pathways (Electron BrowserWindow, Playwright dashboard fallback, `POST /api/providers/{id}/login`), token-extraction configs, and a 15-min cookie-validity auto-refresh daemon. Hardened on merge: error bodies sanitized (Hard Rule #12), the spawn-capable login route classified LOCAL_ONLY (Hard Rules #15/#17), and the Electron status listener de-duplicated. ([#3292](https://github.com/diegosouzapw/OmniRoute/pull/3292), closes #3070 — thanks @oyi77 / @diegosouzapw) +- **feat(api):** accept path-scoped API keys on client API routes — keys may arrive via `/api/v1/vscode//…` path aliases (incl. `raw`/`combos`); explicit `Authorization`/`x-api-key` headers still take precedence. Split out of #3073. ([#3300](https://github.com/diegosouzapw/OmniRoute/pull/3300) — thanks @zhiru) +- **feat(api):** model-catalog enrichment + MCP `model-catalog` tools — richer per-model metadata (context window, capabilities) surfaced through `/v1/models` and new MCP tools, plus `readHeaderValue` header-record support. Split out of #3073; reconciled on merge with the #3309 URL-token hardening (kept the security gate — no query-string credential fallback, management auth stays header-only). ([#3306](https://github.com/diegosouzapw/OmniRoute/pull/3306) — thanks @zhiru / @diegosouzapw) +- **feat(dashboard):** internationalize the proxy settings UI — `ProxyTab` + the proxy `DocumentationTab`/`FreePoolTab`/`VercelRelayModal` now render via `t(...)`, with matching `en`/`pt-BR` message keys. Split out of #3073. ([#3307](https://github.com/diegosouzapw/OmniRoute/pull/3307), [#3310](https://github.com/diegosouzapw/OmniRoute/pull/3310) — thanks @zhiru) +- **feat(provider):** provider test-all endpoint + per-connection rate-limit overrides + model visibility — `POST /api/models/test-all` runs parallel model tests (chunked, timeout-skip) atop a shared `runSingleModelTest` runner; per-connection rate-limit overrides land via `PATCH /api/providers/:id` (new `rate_limit_overrides_json` column + Zod schema); a dashboard model-visibility toolbar (All / Visible / Hidden) drives a `/v1/models` catalog that excludes user-hidden models; models auto-fetch on every connection add; and passthrough (OpenRouter) models gain test buttons. Folds in dashboard fixes on merge (missing alias/delete handlers, duplicate-model-ID React keys, "Hide all" restored) and a build fix so empty `.env` values no longer override real config. ([#3267](https://github.com/diegosouzapw/OmniRoute/pull/3267) — thanks @Vinayrnani) +- **feat(api):** VS Code Copilot Ollama-compatible BYOK endpoint — exposes an Ollama-shaped surface so VS Code Copilot's "bring your own key" Ollama provider can target OmniRoute directly, with a `VscodeTokenAliasCard` in the dashboard endpoint tab to generate the path-scoped token alias. ([#3316](https://github.com/diegosouzapw/OmniRoute/pull/3316) — thanks @zhiru) +- **feat(combo):** Auto-Combo candidate-expansion optimization + playground model dropdown + "only configured" model toggle — reworks the `auto` strategy's candidate selection in `combo.ts` and surfaces a model picker in the playground `StudioConfigPane` / `useAvailableModels`. ([#3322](https://github.com/diegosouzapw/OmniRoute/pull/3322) — thanks @oyi77) + +### 🔒 Security + +- **fix(auth):** follow-up hardening of the client-API key extractor (#3300) — removed the generic query-string token fallbacks (`?token=`/`?key=`/`?apiKey=`/`?api_key=`), which leak credentials into access logs / Referer headers, and gated URL-borne tokens to client routes only (management auth is now header-only) so a credential in the URL can never authenticate a management route. The path-scoped `/vscode//…` form the VS Code integration needs is unchanged. (security review follow-up to [#3300](https://github.com/diegosouzapw/OmniRoute/pull/3300) — thanks @zhiru / @diegosouzapw) + +### 🔧 Bug Fixes + +- **fix(dashboard):** Agent Bridge page (`/dashboard/tools/agent-bridge`) no longer crashes with "Internal Server Error" — the page replaced its well-shaped state with the raw `/api/tools/agent-bridge/state` response (`{ server, agents }`), leaving `serverState` undefined and throwing `Cannot read properties of undefined (reading 'running')`. A shared `normalizeAgentBridgeState()` now maps the route shape into the page contract (incl. `server.certExists → certTrusted`) and always returns safe defaults, used by both the SSR loader and the polling hook. (#3318 — thanks @tycronk20) +- **fix(codex):** strip client-only params (`prompt_cache_retention`, `safety_identifier`, `user`) on the native `codex/` `/v1/responses` passthrough — Codex upstream rejects them with `400 Unsupported parameter`, which broke Factory Droid and any client injecting those fields. The chat-completions path already stripped them; the responses→responses passthrough now does too. (#3317 — thanks @tycronk20) +- **fix(theoldllm):** stop the `[502]: Body is unusable: Body has already been read` error on the cached-token path — the executor read the same upstream `Response` body with `.text()` twice; it now reads it once and only re-reads after a token-rejection refetch. (#3296 — thanks @onizukashonan14-png) +- **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) visible under the "Show configured only" filter — they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === "no-auth"` as configured. (#3290 — thanks @uniQta) +- **fix(dashboard):** refresh the connection list after a Codex/Claude/Gemini auth import — the import modals called `fetchData()` (which only reloads provider metadata), so a freshly-imported connection stayed invisible until a manual reload; they now call `fetchConnections()`. ([#3320](https://github.com/diegosouzapw/OmniRoute/pull/3320) — thanks @zhiru) +- **fix(cli):** `omniroute update` no longer always fails on a global install — `getCurrentVersion()` and `createBackup()` now resolve `package.json`/`bin` relative to the script (`import.meta.url`) instead of `process.cwd()` (the user's working dir on a global npm/brew install → *"Could not determine current version"*), and the backup copies the `cli` directory with `cpSync({recursive:true})` instead of `copyFileSync`, which threw a swallowed `EISDIR` → *"Failed to create backup. Aborting"*. (#3295 — thanks @uniQta) +- **fix(sse):** harden the passthrough stream against empty upstream responses — emit a synthetic retry chunk on an empty `choices: []` (fixes a Copilot Chat crash) and log empty post-`tool_calls` completions; also registers **MiniMax M3** (1M context) across 8 provider tiers. ([#3297](https://github.com/diegosouzapw/OmniRoute/pull/3297), #3110 — thanks @wilsonicdev) +- **fix(opencode-provider):** extract `contextLength` from the live `/v1/models` catalog (live > `modelContextLengths` > static map) so passthrough models outside the legacy 8-model map no longer silently truncate to OpenCode's 128K default. ([#3298](https://github.com/diegosouzapw/OmniRoute/pull/3298) — thanks @herjarsa / @diegosouzapw) +- **fix(dev):** auto-rebuild `better-sqlite3` on a Node ABI mismatch at `npm run dev` startup (nvm 22↔24) — dev-only, no-op on the healthy path, unrelated errors not swallowed. ([#3301](https://github.com/diegosouzapw/OmniRoute/pull/3301) — thanks @zhiru) +- **fix(api):** remove the bundled **Completions.me** provider preset — empirically verified to return Rick Astley lyrics instead of real completions for every model/prompt. ([#3302](https://github.com/diegosouzapw/OmniRoute/pull/3302), discussion #3293 — thanks @diegosouzapw; reported by @mikmaneggahommie) +- **fix(ci):** skip the auto-deploy step when the VPS SSH port is unreachable from the GitHub runner (private LAN / firewall) instead of red-failing every release pipeline; genuine deploy/boot failures still fail honestly. ([#3299](https://github.com/diegosouzapw/OmniRoute/pull/3299) — thanks @diegosouzapw) +- **fix(sse):** strip leaked internal tool-call envelopes (`to=functions.*` / `multi_tool_use.parallel { … }`) from visible assistant text and sanitize Responses-API streaming (drop `commentary`-phase output items) so harness syntax never reaches the client. ([#3311](https://github.com/diegosouzapw/OmniRoute/pull/3311) — thanks @zhiru) +- **fix(sse):** expose the Claude (`claude-opus-4-6-thinking`, `claude-sonnet-4-6`) and Gemini budget tiers (`gemini-3.1-pro-{high,low}`, `gemini-3.5-flash-{low,extra-low}`) in the Antigravity catalog — they are user-callable on the Antigravity OAuth backend (agy parity), correcting an earlier assumption that Claude had been removed. ([#3303](https://github.com/diegosouzapw/OmniRoute/pull/3303), discussion #3184 — thanks @diegosouzapw) +- **fix(catalog):** compute a combo's `context_length` from the known targets only — a single target with unknown context no longer collapses the whole combo to `undefined`; also accepts live `{id, contextLength}` model entries in the opencode-provider helper (follow-up to #3298). ([#3304](https://github.com/diegosouzapw/OmniRoute/pull/3304) — thanks @herjarsa / @diegosouzapw) + +### 📝 Maintenance + +- **test(catalog):** align the Antigravity preview-alias catalog test with the #3303 budget tiers — asserts the restored Claude/Gemini tiers are surfaced, locking in the behavior so a future tier change can't silently drop them again (thanks @diegosouzapw) +- **docs:** rename the `resolve-issues` skill references to `review-issues` across the docs/skill surfaces, matching the renamed governance skill (thanks @diegosouzapw) +- **docs:** document the VS Code / Ollama endpoints (API reference + new `docs/reference/CLI-TOOLS.md`) and improve the env-bootstrap + i18n key-coverage tooling. ([#3319](https://github.com/diegosouzapw/OmniRoute/pull/3319) — thanks @zhiru) +- **chore(release):** open the v3.8.13 development cycle (version bump + cycle bookkeeping) and finalize this changelog (thanks @diegosouzapw) + +### 🙌 Contributors + +Thanks to everyone whose work landed in v3.8.13: + +| Contributor | PRs / Issues | +| --- | --- | +| [@zhiru](https://github.com/zhiru) | #3300, #3306, #3307 / #3310, #3309, #3301, #3311, #3320, #3319, #3316 | +| [@tycronk20](https://github.com/tycronk20) | #3317, #3318 | +| [@Vinayrnani](https://github.com/Vinayrnani) | #3267 | +| [@oyi77](https://github.com/oyi77) | #3292 (closes #3070), #3322 | +| [@onizukashonan14-png](https://github.com/onizukashonan14-png) | #3296 | +| [@uniQta](https://github.com/uniQta) | #3290, #3295 | +| [@wilsonicdev](https://github.com/wilsonicdev) | #3297 | +| [@herjarsa](https://github.com/herjarsa) | #3298, #3304 | +| [@mikmaneggahommie](https://github.com/mikmaneggahommie) | reported the Completions.me rickroll (discussion #3293) | +| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer — #3299, #3302, #3303; co-author on #3292 / #3306 / #3298 / #3304 / #3309 | + +--- + ## [3.8.12] — 2026-06-06 ### ✨ New Features @@ -1850,7 +1909,7 @@ Thank you to all **55+ community contributors** who made v3.8.0 possible! 🎉 ### 🧹 Chores -- **chore(workflow):** mandate implementation plan generation in `/resolve-issues` workflow before coding +- **chore(workflow):** mandate implementation plan generation in `/review-issues` workflow before coding - **chore(release):** expand contributor credits to 155 PRs across full project history ### 🏆 Community Contributors Acknowledgment diff --git a/README.md b/README.md index e56197c887..bee23a128f 100644 --- a/README.md +++ b/README.md @@ -523,6 +523,19 @@ curl http://localhost:20128/v1/models -H "Authorization: Bearer YOUR_KEY" You should see your connected models listed. 🎉 That's it — start coding, and OmniRoute auto-routes & falls back for you. +If your client cannot send custom headers, OmniRoute also exposes tokenized compatibility aliases: + +```txt +OpenAI catalog: http://localhost:20128/vscode/YOUR_KEY/ +OpenAI models: http://localhost:20128/vscode/YOUR_KEY/models +OpenAI chat: http://localhost:20128/vscode/YOUR_KEY/chat/completions +OpenAI responses: http://localhost:20128/vscode/YOUR_KEY/responses +Ollama chat: http://localhost:20128/vscode/YOUR_KEY/api/chat +Ollama tags: http://localhost:20128/vscode/YOUR_KEY/api/tags +``` + +Use these only for clients that cannot attach `Authorization: Bearer ...`. Header auth remains the preferred mode. +
## 📦 More install methods — Docker, source, pnpm, Arch diff --git a/bin/cli/commands/update.mjs b/bin/cli/commands/update.mjs index 5fe56e8c49..e3c95cfd81 100644 --- a/bin/cli/commands/update.mjs +++ b/bin/cli/commands/update.mjs @@ -1,16 +1,24 @@ import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { homedir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { t } from "../i18n.mjs"; const execFileAsync = promisify(execFile); -async function getCurrentVersion() { +// This file lives at /bin/cli/commands/update.mjs — resolve package +// paths relative to the script, NOT process.cwd(). On a global npm/brew install +// the user's cwd is not the package root, so cwd-relative lookups break (#3295). +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = path.resolve(SCRIPT_DIR, "..", "..", ".."); +const BIN_DIR = path.join(PKG_ROOT, "bin"); + +export async function getCurrentVersion() { try { const { readFileSync } = await import("node:fs"); - const pkg = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf-8")); + const pkg = JSON.parse(readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8")); return pkg.version; } catch { return null; @@ -38,12 +46,12 @@ function compareVersions(a, b) { return 0; } -async function createBackup() { - const binPath = path.join(process.cwd(), "bin"); +export async function createBackup() { + const binPath = BIN_DIR; const backupDir = path.join(homedir(), ".omniroute", "backups", `omniroute-${Date.now()}`); try { - const { mkdirSync, copyFileSync, existsSync } = await import("node:fs"); + const { mkdirSync, cpSync, existsSync } = await import("node:fs"); if (!existsSync(binPath)) return null; mkdirSync(backupDir, { recursive: true }); @@ -51,7 +59,9 @@ async function createBackup() { for (const f of files) { const src = path.join(binPath, f); if (existsSync(src)) { - copyFileSync(src, path.join(backupDir, f)); + // cpSync handles both files and directories; the old copyFileSync threw + // EISDIR on the "cli" directory, which was swallowed by the catch (#3295). + cpSync(src, path.join(backupDir, f), { recursive: true }); } } return backupDir; diff --git a/bin/reset-password.mjs b/bin/reset-password.mjs old mode 100644 new mode 100755 diff --git a/docs/architecture/REPOSITORY_MAP.md b/docs/architecture/REPOSITORY_MAP.md index c9c2bbb9b2..bddfefd9d5 100644 --- a/docs/architecture/REPOSITORY_MAP.md +++ b/docs/architecture/REPOSITORY_MAP.md @@ -491,7 +491,7 @@ Shipped configuration templates and sample files (referenced by setup wizard). | `commands/deploy-vps-{local,akamai,both}-cc.md` | Deploy to VPS | | `commands/capture-release-evidences-cc.md` | Browser-record new features as WebP | | `commands/review-{prs,discussions}-cc.md` | Triage GitHub PRs/discussions | -| `commands/{issue-triage,resolve-issues,implement-features}-cc.md` | Issue workflows | +| `commands/{review-issues,implement-features}-cc.md` | Issue workflows | | `settings.local.json` | Per-project Claude Code settings | --- diff --git a/docs/guides/SETUP_GUIDE.md b/docs/guides/SETUP_GUIDE.md index 04c1e29d72..1946fd8e23 100644 --- a/docs/guides/SETUP_GUIDE.md +++ b/docs/guides/SETUP_GUIDE.md @@ -153,6 +153,15 @@ API Key: [copy from Endpoint page] Model: if/kimi-k2-thinking (or any provider/model prefix) ``` +If your editor cannot send `Authorization: Bearer ...`, use the tokenized compatibility base instead: + +```txt +Base URL: http://localhost:20128/api/v1/vscode/YOUR_KEY/ +Models URL: http://localhost:20128/api/v1/vscode/YOUR_KEY/models +Chat URL: http://localhost:20128/api/v1/vscode/YOUR_KEY/chat/completions +Ollama Tags URL: http://localhost:20128/api/v1/vscode/YOUR_KEY/api/tags +``` + Works with Claude Code, Codex CLI, Gemini CLI, Cursor, Cline, OpenClaw, OpenCode, and OpenAI-compatible SDKs. For detailed per-tool configuration (Claude Code, Codex CLI, Cursor, Cline, OpenClaw, Kilo Code, Copilot, and more), see the dedicated **[CLI Tools Guide](../reference/CLI-TOOLS.md)**. diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 7099f35915..4136e52273 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 6ed35d921e..98b9c41c1d 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 6ed35d921e..98b9c41c1d 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 6357c2c966..9705ecb4f8 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index 97c5293ff5..f9179b1f2a 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index b6da9f37d5..0ff9ae4d54 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 4b98b56749..3f5c0ba412 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 3a3a0e4a7b..add692fc28 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index 2d8a775e12..a3de3e8340 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index 1cc8f68ace..d886bcc0aa 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index faa0d1307c..4cb69e0825 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index cecf848488..f272199b0c 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index 81fe4585da..c5dcae3884 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index d637662048..c8162bab02 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index eca10f8689..a8dea7557b 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index ee81bfebc9..6ee3a8f3be 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index ddea5fb1c0..2a4911b70a 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index 8d977033e3..b9c02cd599 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 87d01e497b..4650931ed4 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 0d2b79e13f..0446104f61 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 32e3601df0..54cacc8796 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index cf684789d5..85167f999f 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index ef2cd39cb6..a9e80a1414 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index 5956066847..d64394d6b8 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index 67d8f06a8b..cdf41a6a49 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 82c526e787..2ac00641cc 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index fd0aa78204..9b9c89f1de 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index f8ab9cc2f7..710739ebfa 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index 85a56f549f..a6e5789f59 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 3ccb45317b..ef78374cdd 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index ee187265d7..6b948f30ea 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index f7076f36a2..b3501f977c 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index 290d8120e7..16d5e5eb69 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 9b209cd9c9..e251de7ce6 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 0239fcdb2a..446a26df3f 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index 03094c6d21..ee410061e0 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index 70020f4e1f..0d164c8f08 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index 3ec67e6e53..a92d030ab7 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 28c8fbe81f..2ff61461be 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 74931a0741..13c1a60f3a 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 936d958389..93a2b91674 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.13] — Unreleased + +_Development cycle in progress._ + +--- + ## [3.8.12] — Unreleased _Development cycle in progress._ diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 43c6a71e7f..dc5712fc42 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -154,9 +154,17 @@ Authorization: Bearer your-api-key | GET | `/v1beta/models` | Gemini | | POST | `/v1beta/models/{...path}` | Gemini generateContent | | POST | `/v1/api/chat` | Ollama | +| GET | `/api/v1/vscode/{token}/` | OpenAI catalog alias | +| GET | `/api/v1/vscode/{token}/models` | OpenAI models alias | +| POST | `/api/v1/vscode/{token}/chat/completions` | OpenAI tokenized alias | +| POST | `/api/v1/vscode/{token}/responses` | OpenAI Responses tokenized alias | +| POST | `/api/v1/vscode/{token}/api/chat` | Ollama tokenized alias | +| GET | `/api/v1/vscode/{token}/api/tags` | Ollama tags tokenized alias | All POST routes follow the same shape: `Bearer your-api-key` + Zod-validated JSON body (`v1RerankSchema`, `v1ModerationSchema`, `v1AudioSpeechSchema`, etc., see `src/shared/validation/schemas.ts`). 4xx is returned on schema failure. +For clients that cannot attach `Authorization: Bearer ...`, OmniRoute also accepts API keys in the URL via either query-string compatibility (`?token=...`, `?apiKey=...`, `?api_key=...`, `?key=...`) or the dedicated `/api/v1/vscode/{token}/...` endpoints documented below. + ```bash # Rerank POST /v1/rerank { "model": "cohere/rerank-3", "query": "...", "documents": ["..."] } @@ -608,6 +616,39 @@ GET /api/tags Requests are automatically translated between Ollama and internal formats. +## Tokenized VS Code / Headerless Aliases + +Use these aliases when an integration cannot inject an `Authorization` header and needs the API key embedded in the base URL. + +```bash +# OpenAI-style catalog alias +GET /api/v1/vscode/{token}/ +GET /api/v1/vscode/{token}/models + +# OpenAI-style chat aliases +POST /api/v1/vscode/{token}/chat/completions +POST /api/v1/vscode/{token}/responses + +# Ollama-style aliases +POST /api/v1/vscode/{token}/api/chat +GET /api/v1/vscode/{token}/api/tags +``` + +Example: + +```bash +curl https://your-host.example/api/v1/vscode/YOUR_API_KEY/models +curl -X POST https://your-host.example/api/v1/vscode/YOUR_API_KEY/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model":"auto","messages":[{"role":"user","content":"hello"}]}' +``` + +Notes: + +- The tokenized aliases reuse the same handlers as `/v1/*` and `/api/tags`; response shapes stay identical. +- Prefer `Authorization: Bearer ...` whenever the client supports custom headers. +- URL-based tokens may appear in reverse-proxy logs, browser history, and telemetry outside OmniRoute. Treat them as a compatibility option, not the default authentication mode. + --- ## Telemetry diff --git a/docs/reference/CLI-TOOLS.md b/docs/reference/CLI-TOOLS.md index 24e2a199b6..3526930133 100644 --- a/docs/reference/CLI-TOOLS.md +++ b/docs/reference/CLI-TOOLS.md @@ -297,6 +297,243 @@ export OPENAI_BASE_URL="http://localhost:20128/v1" export OPENAI_API_KEY="sk-your-omniroute-key" export ANTHROPIC_BASE_URL="http://localhost:20128" export ANTHROPIC_AUTH_TOKEN="sk-your-omniroute-key" +export GEMINI_BASE_URL="http://localhost:20128/v1" +export GEMINI_API_KEY="sk-your-omniroute-key" +``` + +> For a **remote server** replace `localhost:20128` with the server IP or domain, +> e.g. `http://:20128`. + +--- + +### Step 4 — Configure Each Tool + +#### Claude Code + +```bash +# Create ~/.claude/settings.json: +mkdir -p ~/.claude && cat > ~/.claude/settings.json << EOF +{ + "env": { + "ANTHROPIC_BASE_URL": "http://localhost:20128", + "ANTHROPIC_AUTH_TOKEN": "sk-your-omniroute-key" + } +} +EOF +``` + +Use the unified Anthropic gateway root for Claude Code. Do not append `/v1` here. + +**Test:** `claude "say hello"` + +--- + +#### OpenAI Codex + +```bash +mkdir -p ~/.codex && cat > ~/.codex/config.yaml << EOF +model: auto +apiKey: sk-your-omniroute-key +apiBaseUrl: http://localhost:20128/v1 +EOF +``` + +**Test:** `codex "what is 2+2?"` + +--- + +#### OpenCode + +```bash +mkdir -p ~/.config/opencode && cat > ~/.config/opencode/opencode.json << EOF +{ + "\$schema": "https://opencode.ai/config.json", + "provider": { + "omniroute": { + "npm": "@ai-sdk/openai-compatible", + "name": "OmniRoute", + "options": { + "baseURL": "http://localhost:20128/v1", + "apiKey": "sk-your-omniroute-key" + }, + "models": { + "claude-sonnet-4-5": { "name": "claude-sonnet-4-5" }, + "claude-sonnet-4-5-thinking": { "name": "claude-sonnet-4-5-thinking" }, + "gemini-3-flash": { "name": "gemini-3-flash" } + } + } + } +} +EOF +``` + +**Test:** `opencode` + +> Use `opencode run "your prompt" --model omniroute/claude-sonnet-4-5-thinking --variant high` +> to send thinking variants. + +--- + +#### Cline (CLI or VS Code) + +**CLI mode:** + +```bash +mkdir -p ~/.cline/data && cat > ~/.cline/data/globalState.json << EOF +{ + "apiProvider": "openai", + "openAiBaseUrl": "http://localhost:20128/v1", + "openAiApiKey": "sk-your-omniroute-key" +} +EOF +``` + +**VS Code mode:** +Cline extension settings → API Provider: `OpenAI Compatible` → Base URL: `http://localhost:20128/v1` + +Or use the OmniRoute dashboard → **CLI Tools → Cline → Apply Config**. + +--- + +#### KiloCode (CLI or VS Code) + +**CLI mode:** + +```bash +kilocode --api-base http://localhost:20128/v1 --api-key sk-your-omniroute-key +``` + +**VS Code settings:** + +```json +{ + "kilo-code.openAiBaseUrl": "http://localhost:20128/v1", + "kilo-code.apiKey": "sk-your-omniroute-key" +} +``` + +Or use the OmniRoute dashboard → **CLI Tools → KiloCode → Apply Config**. + +--- + +#### Continue (VS Code Extension) + +Edit `~/.continue/config.yaml`: + +```yaml +models: + - name: OmniRoute + provider: openai + model: auto + apiBase: http://localhost:20128/v1 + apiKey: sk-your-omniroute-key + default: true +``` + +Restart VS Code after editing. + +--- + +#### VS Code Insiders (`chatLanguageModels.json`) + +Use this when VS Code Insiders is configured for custom endpoint models and you want OmniRoute to work without a custom header field. + +**Recommended location:** + +- Linux: `~/.config/Code - Insiders/User/chatLanguageModels.json` +- Windows: `%APPDATA%/Code - Insiders/User/chatLanguageModels.json` + +**Example using the tokenized OmniRoute alias:** + +```json +[ + { + "vendor": "customendpoint", + "id": "auto", + "name": "OmniRoute Auto", + "family": "gpt-4", + "version": "1.0.0", + "url": "http://localhost:20128/api/v1/vscode/sk-your-omniroute-key/chat/completions", + "modelsUrl": "http://localhost:20128/api/v1/vscode/sk-your-omniroute-key/models", + "requestFormat": "openai-chat-completions", + "contextWindow": 256000, + "maxOutputTokens": 32768, + "auth": { + "type": "none" + } + } +] +``` + +**Notes:** + +- Replace `sk-your-omniroute-key` with an API key created in OmniRoute. +- The `url` field should point to `/api/v1/vscode/{token}/chat/completions`. +- The `modelsUrl` field should point to `/api/v1/vscode/{token}/models`. +- Prefer the normal `/v1` + Bearer header flow when the client supports custom headers. +- URL-embedded tokens are a compatibility fallback and may appear in editor logs or proxy history. + +--- + +#### Kiro CLI (Amazon) + +```bash +# Login to your AWS/Kiro account: +kiro-cli login + +# The CLI uses its own auth — OmniRoute is not needed as backend for Kiro CLI itself. +# Use kiro-cli alongside OmniRoute for other tools. +kiro-cli status +``` + +For the **Kiro IDE** desktop app, use the MITM endpoint exposed by OmniRoute +under `/dashboard/cli-tools → Kiro`. + +--- + +#### Qwen Code (Alibaba) + +Qwen Code supports OpenAI-compatible API endpoints via environment variables or `settings.json`. + +> Qwen OAuth free tier was discontinued on 2026-04-15. Use OmniRoute with +> `bailian-coding-plan` / `alibaba` / `alibaba-cn` / `openrouter` / `anthropic` / +> `gemini` providers instead. + +**Option 1: Environment variables (`~/.qwen/.env`)** + +```bash +mkdir -p ~/.qwen && cat > ~/.qwen/.env << EOF +OPENAI_API_KEY="sk-your-omniroute-key" +OPENAI_BASE_URL="http://localhost:20128/v1" +OPENAI_MODEL="auto" +EOF +``` + +**Option 2: `settings.json` with `security.auth`** + +```json +// ~/.qwen/settings.json +{ + "security": { + "auth": { + "selectedType": "openai", + "apiKey": "sk-your-omniroute-key", + "baseUrl": "http://localhost:20128/v1" + } + }, + "model": { + "name": "claude-sonnet-4-6" + } +} +``` + +**Option 3: Inline CLI flags** + +```bash +OPENAI_BASE_URL="http://localhost:20128/v1" \ +OPENAI_API_KEY="sk-your-omniroute-key" \ +OPENAI_MODEL="auto" \ +qwen ``` > For a **remote server** replace `localhost:20128` with the server IP or domain. @@ -320,6 +557,144 @@ omniroute --version # Print version omniroute --help # Show all commands ``` +### Setup & Initialization + +```bash +omniroute setup # Interactive setup wizard +omniroute setup --non-interactive # CI/automation mode (reads env vars + flags) +omniroute setup --password '' # Set admin password directly +omniroute setup --add-provider \ + --provider openai \ + --api-key '' \ + --test-provider # Add and test a provider in one shot +``` + +Recognized environment variables for non-interactive setup: + +| Var | Purpose | +| ----------------------------- | -------------------------------------------- | +| `OMNIROUTE_SETUP_PASSWORD` | Admin password (>=8 chars) | +| `OMNIROUTE_PROVIDER` | Provider id (e.g. `openai`, `anthropic`) | +| `OMNIROUTE_PROVIDER_NAME` | Display name for the connection | +| `OMNIROUTE_PROVIDER_BASE_URL` | Optional OpenAI-compatible base URL override | +| `OMNIROUTE_API_KEY` | Provider API key | +| `OMNIROUTE_DEFAULT_MODEL` | Optional default model | +| `DATA_DIR` | Override the OmniRoute data directory | + +### Diagnostics + +```bash +omniroute doctor # Check config, DB, ports, runtime, memory, liveness +omniroute doctor --json # Machine-readable JSON +omniroute doctor --no-liveness # Skip the HTTP health probe +omniroute doctor --host 0.0.0.0 # Override liveness host +omniroute doctor --liveness-url # Full health endpoint URL override +``` + +The doctor runs these checks: `Config`, `Database`, `Storage/encryption`, +`Port availability`, `Node runtime`, `Native binary` (better-sqlite3), +`Memory`, and `Server liveness`. It exits non-zero if any check is `fail`. + +### Provider Management + +```bash +omniroute providers available # OmniRoute provider catalog +omniroute providers available --search openai # Filter catalog by id/name/alias/category +omniroute providers available --category api-key # Filter by category (api-key, oauth, free, ...) +omniroute providers available --json # Machine-readable JSON + +omniroute providers list # Configured provider connections +omniroute providers list --json + +omniroute providers test # Test one configured connection +omniroute providers test-all # Test every active connection +omniroute providers validate # Local-only structural validation +``` + +> `providers available` reads the OmniRoute catalog; `providers list/test/test-all/validate` +> read the local SQLite database directly and do not require the server to be running. + +### Recovery & Reset + +```bash +omniroute reset-password # Reset the admin password (legacy alias still works) +omniroute reset-encrypted-columns # Show warning + dry-run for encrypted credential reset +omniroute reset-encrypted-columns --force # Actually null out encrypted credentials in SQLite +``` + +### Other subcommands + +These assume a running OmniRoute server, unless noted otherwise: + +```bash +omniroute status # Comprehensive runtime status +omniroute logs # Stream request logs (--json, --search, --follow) +omniroute config show # Display current configuration + +omniroute provider list # List available providers (alias of providers list) +omniroute provider add # Register OmniRoute as a provider on a tool +omniroute keys add | list | remove # Manage API keys +omniroute models [provider] # List models (--json, --search) +omniroute combo list | switch | create | delete + +omniroute backup # Snapshot config + DB +omniroute restore # Restore from a previous snapshot + +omniroute health # Detailed health (breakers, cache, memory) +omniroute quota # Provider quota usage +omniroute cache # Cache status +omniroute cache clear # Clear semantic + signature caches + +omniroute mcp status | restart # MCP server status / restart +omniroute a2a status | card # A2A server status / agent card + +omniroute tunnel list | create | stop # Manage tunnels (cloudflare/tailscale/ngrok) +omniroute env show | get | set # Inspect / set env vars (temporary) + +omniroute test # Provider connectivity smoke test +omniroute update # Check for updates +omniroute completion # Generate shell completion +``` + +### Common flags + +| Flag | Description | +| ------------------- | ------------------------------------------------------ | +| `--no-open` | Don't auto-open the browser on start | +| `--port ` | Override the API port (default 20128) | +| `--mcp` | Run as MCP server over stdio (for IDEs) | +| `--non-interactive` | CI mode (no prompts; reads from env/flags) | +| `--json` | Machine-readable JSON output (doctor, providers, etc.) | +| `--help`, `-h` | Show command-specific help | +| `--version`, `-v` | Print the installed version | + +--- + +## Available API Endpoints + +| Endpoint | Description | Use For | +| -------------------------- | ----------------------------- | --------------------------- | +| `/v1/chat/completions` | Standard chat (all providers) | All modern tools | +| `/v1/responses` | Responses API (OpenAI format) | Codex, agentic workflows | +| `/v1/completions` | Legacy text completions | Older tools using `prompt:` | +| `/v1/embeddings` | Text embeddings | RAG, search | +| `/v1/images/generations` | Image generation | GPT-Image, Flux, etc. | +| `/v1/audio/speech` | Text-to-speech | ElevenLabs, OpenAI TTS | +| `/v1/audio/transcriptions` | Speech-to-text | Deepgram, AssemblyAI | + +Ready-to-paste examples with a tokenized OmniRoute URL: + +```txt +Token example: sk-a3ab3c080beaee3a-69f4a4-070d71af + +Standard OpenAI base: http://localhost:20128/v1 +VS Code models: http://localhost:20128/api/v1/vscode/sk-a3ab3c080beaee3a-69f4a4-070d71af/models +VS Code chat: http://localhost:20128/api/v1/vscode/sk-a3ab3c080beaee3a-69f4a4-070d71af/chat/completions +VS Code responses: http://localhost:20128/api/v1/vscode/sk-a3ab3c080beaee3a-69f4a4-070d71af/responses +Ollama tags: http://localhost:20128/api/v1/vscode/sk-a3ab3c080beaee3a-69f4a4-070d71af/api/tags +Ollama chat: http://localhost:20128/api/v1/vscode/sk-a3ab3c080beaee3a-69f4a4-070d71af/api/chat +``` + --- ## Troubleshooting diff --git a/docs/reference/FREE_TIERS.md b/docs/reference/FREE_TIERS.md index a5efc566b0..69dde3c47c 100644 --- a/docs/reference/FREE_TIERS.md +++ b/docs/reference/FREE_TIERS.md @@ -51,7 +51,6 @@ Biggest **documented** contributors: `mistral` 1.00B, `longcat` 150M, `cloudflar | `ai21` | ToS §4.2/§8.2 prohibits sublicensing or distributing API access to third parties; §3.3 restricts trial/evaluation products to "internal evaluation on… | | `amazon-q` | Product is discontinued for new signups; existing users are subject to AWS Customer Agreement which governs use of managed services — self-hosted pro… | | `blackbox` | ToS explicitly prohibits sublicensing, reselling, making the service available to third parties, and building derivative services — a self-hosted per… | -| `completions` | No published ToS found (404 on /terms, /faq, /docs). The service proxies Anthropic/OpenAI/Google APIs without authorization, violating those upstream… | | `coze` | Coze ToS explicitly restricts use to "personal and non-commercial use" and prohibits renting, distributing, sublicensing, or reselling the service; a… | | `duckduckgo-web` | Duck.ai ToS (duckduckgo.com/duckai/privacy-terms) explicitly prohibits "automated querying and developing or offering AI services" and circumventing … | | `featherless-ai` | Individual plans explicitly restricted to "interactive use or proto-typing and experimentation by the purchaser" — inference resale and proxy use req… | @@ -201,7 +200,6 @@ Biggest **documented** contributors: `mistral` 1.00B, `longcat` 150M, `cloudflar | `bytez` | aggregator | recurring-credit | — | med | ambiguous | Bytez offers $1 in free credits that refresh every 4 weeks (credits expire if unused within the cycle). Free tier is li… | | `chutes` | aggregator | discontinued | — | high | unknown | The free Early Access program (200 requests/day) was officially discontinued on March 15, 2026. Chutes.ai now operates … | | `comfyui` | image | keyless-unlimited | — | high | ok | ComfyUI is a fully open-source (GPL-3.0), self-hosted diffusion model interface that runs entirely on local hardware wi… | -| `completions` | aggregator | keyless-unlimited | — | med | caution | Completions.me claims to offer completely free, unlimited access to Claude Opus 4.6, GPT-5.2, Gemini 3.1 Pro, and 15+ m… | | `coze` | aggregator | recurring-daily | — | med | caution | Coze's free plan provides 10 message credits per day — a platform-level unit (not raw LLM tokens) where each model call… | | `deepinfra` | aggregator | one-time-trial-credit | — | med | caution | DeepInfra is a pay-as-you-go inference provider that explicitly requires a credit card or prepayment to use services; a… | | `deepseek` | llm-chat | one-time-trial-credit | — | high | caution | DeepSeek offers a one-time signup credit of 5 million tokens (no credit card required) valid for 30 days from account c… | @@ -281,7 +279,6 @@ Biggest **documented** contributors: `mistral` 1.00B, `longcat` 150M, `cloudflar - **`byteplus`** — Our catalog shipped "(none)" but BytePlus ModelArk does have a free tier: a one-time trial credit of 500k tokens per LLM model for new accounts. The catalog underreports this. - **`cerebras`** — TPM appears tightened from 60K to 30K on current documented models (gpt-oss-120b, zai-glm-4.7). RPM of 5 is now explicitly documented (was not in our shipped note). Daily token cap of 1M/day is uncha… - **`chutes`** — The shipped freeNote says "Free tier available" but as of March 15, 2026, the free tier has been officially discontinued. The catalog note is stale and should be updated to reflect that there is no r… -- **`completions`** — Our shipped freeNote ("Free unlimited access to Claude, GPT, Gemini — no rate limits") still matches the site's self-described claims. However, the service is a legally dubious, short-lived aggregato… - **`coze`** — The shipped note "Free ByteDance agent platform" is directionally accurate but omits that the free tier is now tightly credit-capped (10 credits/day ≈ 5–100 messages depending on model), a constraint… - **`deepinfra`** — Our shipped freeNote says "Free signup credits for API testing" — this appears stale. The official pricing page now requires card/prepayment with no documented general free signup credit. The free ti… - **`deepseek`** — Our shipped note says "5M free tokens on signup - no credit card required" — this is still accurate for the one-time grant, but importantly the credits expire after 30 days (not mentioned in the ship… diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index a1f5c81146..246e05ce37 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,16 +1,16 @@ --- title: "Provider Reference" -version: 3.8.11 -lastUpdated: 2026-06-05 +version: 3.8.12 +lastUpdated: 2026-06-06 --- # Provider Reference > **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand. > Regenerate with: `npm run gen:provider-reference` -> **Last generated:** 2026-06-05 +> **Last generated:** 2026-06-06 -Total providers: **224**. See category breakdown below. +Total providers: **223**. See category breakdown below. ## Categories @@ -80,7 +80,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `v0-vercel-web` | `v0` | v0 Vercel Web (Code Gen) | Web cookie | [link](https://v0.dev) | Paste your session cookie from v0.dev (DevTools → Application → Cookies) | | `venice-web` | `ven` | Venice Web (Privacy) | Web cookie | [link](https://venice.ai) | Paste your session cookie from venice.ai (DevTools → Application → Cookies) | -## API Key Providers (paid / paid-with-free-credits) (152) +## API Key Providers (paid / paid-with-free-credits) (151) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -114,7 +114,6 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `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. | -| `completions` | `cpl` | Completions.me | API key | [link](https://completions.me) | Free unlimited access to Claude, GPT, Gemini — no credit card, no rate limits | | `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 21aeb4689b..2fdccc1bd8 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.12 + version: 3.8.13 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/loginManager.js b/electron/loginManager.js new file mode 100644 index 0000000000..0e0bb2d2f1 --- /dev/null +++ b/electron/loginManager.js @@ -0,0 +1,386 @@ +/** + * LoginManager — Electron BrowserWindow-based web login for cookie providers + * + * Opens a native Electron window navigated to the provider's login page. + * Polls the session cookie store for target cookies after login completes. + * + * Events: + * "status" — { status: string, message: string, providerId: string } + * status values: starting, navigating, waiting, polling, complete, error, cancelled + */ + +const { BrowserWindow, session } = require("electron"); +const { EventEmitter } = require("events"); +const path = require("path"); + +// In production, the tokenExtractionConfig is bundled under open-sse/services/. +// We resolve relative to the Electron resources path. +let TOKEN_EXTRACTION_CONFIGS = null; +function getConfigs() { + if (TOKEN_EXTRACTION_CONFIGS) return TOKEN_EXTRACTION_CONFIGS; + try { + const mod = require("../open-sse/services/tokenExtractionConfig"); + TOKEN_EXTRACTION_CONFIGS = mod.TOKEN_EXTRACTION_CONFIGS; + } catch { + // Fallback: try from app resources + try { + const mod = require("./open-sse/services/tokenExtractionConfig"); + TOKEN_EXTRACTION_CONFIGS = mod.TOKEN_EXTRACTION_CONFIGS; + } catch {} + } + return TOKEN_EXTRACTION_CONFIGS; +} + +class LoginManager extends EventEmitter { + constructor() { + super(); + this.window = null; + this.activeProviderId = null; + this.resolvePromise = null; + this.rejectPromise = null; + this.timeoutId = null; + this.isCompleted = false; + this.pollIntervalId = null; + this.loginSession = null; + } + + /** + * Start a login flow for a web-cookie provider. + * @param {string} providerId - e.g. "claude-web", "chatgpt-web" + * @param {object} [options] + * @param {number} [options.timeout] - Total timeout in ms (default: config or 300s) + * @returns {Promise<{success: boolean, credentials?: Record, error?: string}>} + */ + startLogin(providerId, options = {}) { + const configs = getConfigs(); + if (!configs) { + return Promise.resolve({ + success: false, + error: "tokenExtractionConfig module not found", + }); + } + + const extractionConfig = configs.get(providerId); + if (!extractionConfig) { + return Promise.resolve({ + success: false, + error: `No extraction config for provider: ${providerId}`, + }); + } + + if (this.activeProviderId) { + return Promise.resolve({ + success: false, + error: "A login process is already in progress", + }); + } + + this.activeProviderId = providerId; + this.isCompleted = false; + + const timeout = options.timeout || extractionConfig.pollingConfig.timeout || 300_000; + const minLoginTime = extractionConfig.pollingConfig.minLoginTime || 5000; + const pollInterval = extractionConfig.pollingConfig.pollInterval || 1000; + + return new Promise((resolve, reject) => { + this.resolvePromise = resolve; + this.rejectPromise = reject; + + this.emit("status", { + providerId, + status: "starting", + message: `Opening ${extractionConfig.displayName} login...`, + }); + + try { + this._openLoginWindow(providerId, extractionConfig, timeout, minLoginTime, pollInterval); + } catch (err) { + this._cleanup(); + this.emit("status", { + providerId, + status: "error", + message: `Failed to open window: ${err.message}`, + }); + resolve({ success: false, error: err.message }); + } + }); + } + + /** + * Open the Electron BrowserWindow for login + */ + _openLoginWindow(providerId, config, timeout, minLoginTime, pollInterval) { + this.window = new BrowserWindow({ + width: 1000, + height: 750, + title: `Login - ${config.displayName}`, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + session: session.fromPartition(`login-${providerId}-${Date.now()}`), + }, + show: true, + autoHideMenuBar: true, + }); + + const winSession = this.window.webContents.session; + + // Track navigation for success URL detection + let navigatedToLogin = false; + const startTime = Date.now(); + + this.window.webContents.on("did-navigate", (_event, url) => { + if (this.isCompleted) return; + + try { + const parsedUrl = new URL(url); + // Check if we've navigated away from the login page (successful login) + if (navigatedToLogin && config.successUrlPattern) { + if (config.successUrlPattern.test(url)) { + this.emit("status", { + providerId, + status: "detected", + message: "Login page redirect detected — extracting cookies...", + }); + } + } + if (!navigatedToLogin) { + navigatedToLogin = true; + } + + this.emit("status", { + providerId, + status: "navigating", + message: `Navigated to ${parsedUrl.hostname}`, + }); + } catch { + // ignore bad URLs + } + }); + + // Load the login page + this.emit("status", { + providerId, + status: "navigating", + message: `Loading ${config.loginUrl}...`, + }); + this.window.loadURL(config.loginUrl); + + // Show window when ready + this.window.once("ready-to-show", () => { + this.window.show(); + }); + + // Handle window close by user + this.window.on("closed", () => { + if (!this.isCompleted) { + this._cleanup(); + this.emit("status", { + providerId, + status: "cancelled", + message: "Login window closed by user", + }); + if (this.resolvePromise) { + this.resolvePromise({ success: false, error: "Login window closed" }); + } + } + }); + + // Start polling for cookies after minLoginTime has elapsed + this.timeoutId = setTimeout(() => { + if (this.isCompleted) return; + this._startPolling(providerId, config, winSession, pollInterval, startTime, minLoginTime); + }, minLoginTime); + + // Overall timeout + this._timeoutTimer = setTimeout(() => { + if (!this.isCompleted) { + this._cleanup(); + this.emit("status", { + providerId, + status: "error", + message: "Login timed out", + }); + if (this.resolvePromise) { + this.resolvePromise({ success: false, error: "Login timed out" }); + } + } + }, timeout); + } + + /** + * Poll the Electron session cookie store for the target cookies + */ + _startPolling(providerId, config, winSession, pollInterval, startTime, minLoginTime) { + const maxPolls = Math.floor(config.pollingConfig.timeout / pollInterval); + let pollCount = 0; + + const poll = () => { + if (this.isCompleted) return; + pollCount++; + + // Emit progress every 30 polls + if (pollCount % 30 === 0) { + const elapsed = Math.round((Date.now() - startTime) / 60000); + this.emit("status", { + providerId, + status: "waiting", + message: `Waiting for login... (${elapsed}m)`, + }); + } + + winSession.cookies + .get({}) + .then((cookies) => { + if (this.isCompleted) return; + + const tokenSources = config.tokenSources; + const credentials = {}; + + // Collect all cookie-based sources + const cookieSources = tokenSources.filter((s) => s.type === "cookie"); + for (const source of cookieSources) { + const domain = source.domain || undefined; + const matched = cookies.find( + (c) => c.name === source.name && (!domain || c.domain.includes(domain.replace(/^\./, ""))) + ); + if (matched) { + credentials[source.name] = matched.value; + } + } + + // Check localStorage-based tokens via executeJavaScript + const storageSources = tokenSources.filter( + (s) => s.type === "localStorage" || s.type === "sessionStorage" + ); + + if (storageSources.length > 0 && this.window && !this.window.isDestroyed()) { + // Execute JS to extract all localStorage/sessionStorage tokens + const storageType = storageSources[0].type === "localStorage" ? "localStorage" : "sessionStorage"; + const keys = storageSources.map((s) => s.key); + const js = `(() => { + const res = {}; + ${JSON.stringify(keys)}.forEach(k => { + try { res[k] = ${storageType}.getItem(k); } catch {} + }); + return res; + })()`; + + this.window.webContents + .executeJavaScript(js) + .then((values) => { + if (values && typeof values === "object") { + Object.assign(credentials, values); + } + this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval); + }) + .catch(() => { + this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval); + }); + } else { + this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval); + } + }) + .catch(() => { + if (!this.isCompleted) { + this.pollIntervalId = setTimeout(poll, pollInterval); + } + }); + }; + + // Start first poll + this.pollIntervalId = setTimeout(poll, 0); + } + + /** + * Check if we have all required credentials, otherwise continue polling + */ + _checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval) { + if (this.isCompleted) return; + + // Collect the required source names/keys + const requiredKeys = [ + ...cookieSources.map((s) => s.name), + ...storageSources.map((s) => s.key), + ]; + const foundKeys = Object.keys(credentials); + const allFound = requiredKeys.every((k) => foundKeys.includes(k)); + + if (allFound && foundKeys.length > 0) { + // Success — all credentials extracted + this._completeLogin(providerId, foundKeys.reduce((acc, k) => { + acc[k] = credentials[k]; + return acc; + }, {})); + } else if (!this.isCompleted) { + // Continue polling using the configured interval + this.pollIntervalId = setTimeout(poll, pollInterval); + } + } + + /** + * Complete the login flow successfully + */ + _completeLogin(providerId, credentials) { + this._cleanup(); + this.emit("status", { + providerId, + status: "complete", + message: "Credentials extracted successfully", + }); + if (this.resolvePromise) { + this.resolvePromise({ success: true, credentials }); + } + } + + /** + * Cancel the current login flow + */ + cancel() { + if (!this.activeProviderId) return; + this._cleanup(); + this.emit("status", { + providerId: this.activeProviderId, + status: "cancelled", + message: "Login cancelled", + }); + if (this.resolvePromise) { + this.resolvePromise({ success: false, error: "Login cancelled" }); + } + } + + /** + * Clean up all resources + */ + _cleanup() { + this.isCompleted = true; + this.activeProviderId = null; + + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + if (this._timeoutTimer) { + clearTimeout(this._timeoutTimer); + this._timeoutTimer = null; + } + if (this.pollIntervalId) { + clearTimeout(this.pollIntervalId); + this.pollIntervalId = null; + } + if (this.window && !this.window.isDestroyed()) { + this.window.close(); + } + this.window = null; + this.loginSession = null; + } + + /** + * Get the active provider ID, if any + */ + getActiveProvider() { + return this.activeProviderId; + } +} + +module.exports = { LoginManager, loginManager: new LoginManager() }; diff --git a/electron/main.js b/electron/main.js index ddcaa25bb6..ee0e22a783 100644 --- a/electron/main.js +++ b/electron/main.js @@ -33,6 +33,7 @@ const { spawn } = require("child_process"); const fs = require("fs"); const { autoUpdater } = require("electron-updater"); const { hasEncryptedCredentials } = require("./sqlite-inspection"); +const { loginManager } = require("./loginManager"); // ── Single Instance Lock ─────────────────────────────────── const gotTheLock = app.requestSingleInstanceLock(); @@ -799,6 +800,48 @@ function setupIpcHandlers() { ipcMain.handle("get-app-version", () => app.getVersion()); + // ── Web-Cookie Login IPC Handlers ────────────────────────── + // Forward login status events to the renderer. Registered ONCE here — never + // inside the login:start handler, which would attach a fresh listener (and + // duplicate every subsequent status event) on each invocation. + loginManager.on("status", (status) => { + sendToRenderer("login:status", status); + }); + + ipcMain.handle("login:start", async (_event, providerId, options) => { + const result = await loginManager.startLogin(providerId, options); + + // Persist extracted credentials + if (result.success && result.credentials) { + try { + // Store as JSON blob under the provider ID + const { persistSecret: ps } = require("../src/lib/db/secrets"); + if (typeof ps === "function") { + ps(providerId, JSON.stringify(result.credentials)); + } + sendToRenderer("login:status", { + providerId, + status: "persisted", + message: "Credentials saved", + }); + } catch (err) { + console.error("[Electron] Failed to persist credentials:", err); + return { success: false, error: "Extracted but failed to save credentials" }; + } + } + + return result; + }); + + ipcMain.handle("login:cancel", async () => { + loginManager.cancel(); + return { success: true }; + }); + + ipcMain.handle("login:status", async () => { + return { active: loginManager.getActiveProvider() !== null }; + }); + // Autostart management handlers ipcMain.handle("get-autostart-status", () => { if (process.platform === "linux") { diff --git a/electron/package-lock.json b/electron/package-lock.json index a1909685d1..6926f65b66 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.12", + "version": "3.8.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.12", + "version": "3.8.13", "license": "MIT", "dependencies": { "electron-updater": "^6.8.8" diff --git a/electron/package.json b/electron/package.json index bc0fb0f3b1..afac7c5ccc 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.12", + "version": "3.8.13", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/electron/preload.js b/electron/preload.js index 1979c2ef79..0eabaa2748 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -103,9 +103,12 @@ const VALID_CHANNELS = { "get-autostart-status", "enable-autostart", "disable-autostart", + "login:start", + "login:cancel", + "login:status", ], send: ["window-minimize", "window-maximize", "window-close"], - receive: ["server-status", "port-changed", "update-status"], + receive: ["server-status", "port-changed", "update-status", "login:status"], }; // ── Fix #16: Generic IPC wrappers ────────────────────────── @@ -161,6 +164,12 @@ contextBridge.exposeInMainWorld("electronAPI", { onPortChanged: (callback) => safeOn("port-changed", callback), onUpdateStatus: (callback) => safeOn("update-status", callback), + // ── Web-Cookie Login ────────────────────────────────────── + startLogin: (providerId, options) => safeInvoke("login:start", providerId, options), + cancelLogin: () => safeInvoke("login:cancel"), + getLoginStatus: () => safeInvoke("login:status"), + onLoginStatus: (callback) => safeOn("login:status", callback), + // ── Static Properties ──────────────────────────────────── isElectron: true, platform: process.platform, diff --git a/eslint.config.mjs b/eslint.config.mjs index 3a29e83b5c..3a99309a10 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -72,6 +72,10 @@ const eslintConfig = [ // Dependencies "node_modules/**", ".worktrees/**", + // Nested git worktrees created by review/resolve skills live under + // .claude/ (gitignored). They hold other sessions' in-progress work and + // their files move mid-scan, so never lint them from the main checkout. + ".claude/**", ".omnivscodeagent/**", // VS Code extension and its large test fixtures "vscode-extension/**", diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts index f15b44d331..970fd44ac2 100644 --- a/open-sse/config/antigravityModelAliases.ts +++ b/open-sse/config/antigravityModelAliases.ts @@ -1,4 +1,27 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ + // Claude (Antigravity backend). The `agy` provider already ships these from the live + // :fetchAvailableModels probe (see agyModels.ts) and discussion #3184 confirmed they + // are user-callable through the `antigravity` OAuth provider too — same backend. + // `antigravity/claude-opus-4-6-thinking` and `antigravity/claude-sonnet-4-6` both work. + // They are upstream IDs, so no alias remapping is required. + { + id: "claude-opus-4-6-thinking", + name: "Claude Opus 4.6 (Thinking)", + contextLength: 200000, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6 (Thinking)", + contextLength: 200000, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, // Gemini 3.5 Flash — flagship model in Antigravity 2.0 (May 2026) { id: "gemini-3.5-flash-preview", @@ -27,6 +50,27 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ supportsVision: true, toolCalling: true, }, + // Gemini 3.1 Pro budget tiers — agy already ships these; #3184 confirmed they work via + // the antigravity OAuth provider. The -high/-low suffix is aliased to the plain + // gemini-3.1-pro upstream id (see ANTIGRAVITY_MODEL_ALIASES / #3229). + { + id: "gemini-3.1-pro-high", + name: "Gemini 3.1 Pro (High)", + contextLength: 1048576, + maxOutputTokens: 65535, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.1-pro-low", + name: "Gemini 3.1 Pro (Low)", + contextLength: 1048576, + maxOutputTokens: 65535, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, { id: "gemini-3-flash-preview", name: "Gemini 3 Flash", @@ -36,6 +80,24 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ supportsVision: true, toolCalling: true, }, + // Gemini 3.5 Flash budget tiers — agy ships these as exact upstream ids; #3184 verified + // they work via the antigravity OAuth provider (no alias remapping required). + { + id: "gemini-3.5-flash-low", + name: "Gemini 3.5 Flash (Low)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.5-flash-extra-low", + name: "Gemini 3.5 Flash (Extra Low)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsVision: true, + toolCalling: true, + }, { id: "gemini-3.1-flash-lite", name: "Gemini 3.1 Flash Lite", @@ -111,8 +173,11 @@ export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({ "gemini-3-flash-preview": "gemini-3-flash", "gemini-3-pro-image-preview": "gemini-3-pro-image", "gemini-2.5-computer-use-preview-10-2025": "rev19-uic3-1p", - // Deprecated: Claude models were removed from Antigravity 2.0 (May 2026). - // These aliases are kept for backward compatibility but will 404 on new requests. + // Legacy Claude display ids → current upstream ids. NOTE: an earlier comment here + // assumed Claude was removed from Antigravity 2.0 and would 404; discussion #3184 + // disproved that — the Antigravity OAuth backend still serves claude-opus-4-6-thinking + // and claude-sonnet-4-6 (now listed in ANTIGRAVITY_PUBLIC_MODELS above). These aliases + // remap the old gemini-claude-* ids to the live upstream ids. "gemini-claude-sonnet-4-5": "claude-sonnet-4-6", "gemini-claude-sonnet-4-5-thinking": "claude-sonnet-4-6", "gemini-claude-opus-4-5-thinking": "claude-opus-4-6-thinking", diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index c0a825cafb..adba8f6bba 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -170,14 +170,6 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "bazaarlink", modelId: "mistral-medium-3.1", displayName: "Mistral Medium 3.1", monthlyTokens: 3600000, creditTokens: 0, freeType: "recurring-daily", poolKey: "bazaarlink", tos: "caution" }, { provider: "bazaarlink", modelId: "mistral-small-2603", displayName: "Mistral Small 4", monthlyTokens: 3600000, creditTokens: 0, freeType: "recurring-daily", poolKey: "bazaarlink", tos: "caution" }, { provider: "bazaarlink", modelId: "nemotron-3-super-120b-a12b", displayName: "Nemotron 3 Super", monthlyTokens: 3600000, creditTokens: 0, freeType: "recurring-daily", poolKey: "bazaarlink", tos: "caution" }, - { provider: "completions", modelId: "claude-opus-4.6", displayName: "Claude Opus 4.6", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, - { provider: "completions", modelId: "claude-sonnet-4.6", displayName: "Claude Sonnet 4.6", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, - { provider: "completions", modelId: "claude-haiku-4.5", displayName: "Claude Haiku 4.5", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, - { provider: "completions", modelId: "gpt-5.2", displayName: "GPT-5.2", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, - { provider: "completions", modelId: "gpt-5-mini", displayName: "GPT-5 Mini", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, - { provider: "completions", modelId: "gpt-4.1", displayName: "GPT-4.1", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, - { provider: "completions", modelId: "gemini-3.1-pro-preview", displayName: "Gemini 3.1 Pro", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, - { provider: "completions", modelId: "gemini-3-flash-preview", displayName: "Gemini 3 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "completions", tos: "avoid" }, { provider: "mistral", modelId: "mistral-large-latest", displayName: "Mistral Large 3", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" }, { provider: "mistral", modelId: "mistral-medium-3-5", displayName: "Mistral Medium 3.5", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" }, { provider: "mistral", modelId: "mistral-small-latest", displayName: "Mistral Small 4", monthlyTokens: 1000000000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "mistral", tos: "caution" }, diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index a3150d1ba8..bef0b02233 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -207,6 +207,16 @@ const GPT_5_5_CODEX_CAPABILITIES = { contextLength: GPT_5_5_CONTEXT_LENGTH, } as const; +const GPT_5_4_CODEX_CAPABILITIES = { + targetFormat: "openai-responses", + toolCalling: true, + supportsReasoning: true, + supportsVision: true, + supportsXHighEffort: true, + contextLength: 200000, + maxOutputTokens: 128000, +} as const; + const CHAT_OPENAI_COMPAT_MODELS: Record = { deepinfra: buildModels([ "anthropic/claude-4-opus", @@ -866,9 +876,27 @@ const _REGISTRY_EAGER: Record = { { id: "gpt-5.4", name: "GPT 5.4", - targetFormat: "openai-responses", - supportsReasoning: true, - supportsXHighEffort: true, + ...GPT_5_4_CODEX_CAPABILITIES, + }, + { + id: "gpt-5.4-xhigh", + name: "GPT 5.4 (xHigh)", + ...GPT_5_4_CODEX_CAPABILITIES, + }, + { + id: "gpt-5.4-high", + name: "GPT 5.4 (High)", + ...GPT_5_4_CODEX_CAPABILITIES, + }, + { + id: "gpt-5.4-medium", + name: "GPT 5.4 (Medium)", + ...GPT_5_4_CODEX_CAPABILITIES, + }, + { + id: "gpt-5.4-low", + name: "GPT 5.4 (Low)", + ...GPT_5_4_CODEX_CAPABILITIES, }, { id: "gpt-5.4-mini", name: "GPT 5.4 Mini", targetFormat: "openai-responses" }, { id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark" }, @@ -1007,7 +1035,7 @@ const _REGISTRY_EAGER: Record = { { id: "gpt-5-mini", name: "GPT-5 Mini", targetFormat: "openai-responses" }, { id: "gpt-5.3-codex", name: "GPT-5.3 Codex", targetFormat: "openai-responses" }, { id: "gpt-5.4-mini", name: "GPT-5.4 Mini", targetFormat: "openai-responses" }, - { id: "gpt-5.4", name: "GPT-5.4", targetFormat: "openai-responses" }, + { id: "gpt-5.4", name: "GPT-5.4", targetFormat: "openai-responses", supportsXHighEffort: true }, { id: "gpt-5.5", name: "GPT-5.5", ...GPT_5_5_CODEX_CAPABILITIES }, { id: "claude-haiku-4.5", @@ -2689,25 +2717,6 @@ const _REGISTRY_EAGER: Record = { { id: "nemotron-3-super-120b-a12b", name: "Nemotron 3 Super" }, ], }, - completions: { - id: "completions", - alias: "cpl", - format: "openai", - executor: "default", - baseUrl: "https://completions.me/api/v1/chat/completions", - authType: "apikey", - authHeader: "bearer", - models: [ - { id: "claude-opus-4.6", name: "Claude Opus 4.6" }, - { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, - { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, - { id: "gpt-5.2", name: "GPT-5.2" }, - { id: "gpt-5-mini", name: "GPT-5 Mini" }, - { id: "gpt-4.1", name: "GPT-4.1" }, - { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" }, - { id: "gemini-3-flash-preview", name: "Gemini 3 Flash" }, - ], - }, xai: { id: "xai", alias: "xai", diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index b31f96be51..93fc326da8 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -1361,6 +1361,17 @@ export class CodexExecutor extends BaseExecutor { delete body.truncation; delete body.background; // Droid CLI sends this but Codex Responses API rejects it + // Issue #3317: strip client-only fields the Codex Responses API rejects with + // 400 "Unsupported parameter" — for BOTH the native passthrough (early return + // below) and the translated path. The chat-completions path already removes + // these (base.ts prompt_cache_retention #1884; openai-responses translator + // safety_identifier #2770), but the responses->responses passthrough skips + // translation. `user` is always rejected by Codex /responses, so it is removed + // unconditionally here (unlike base.ts, which only drops it when empty). + delete body.prompt_cache_retention; + delete body.safety_identifier; + delete body.user; + // Inject prompt_cache_key for Codex prompt caching. // The official Codex client sets this to conversation_id (a stable UUID per session). // Ref: openai/codex core/src/client.rs line 853: diff --git a/open-sse/executors/theoldllm.ts b/open-sse/executors/theoldllm.ts index 94ba8d0f7f..a9dfbba9ef 100644 --- a/open-sse/executors/theoldllm.ts +++ b/open-sse/executors/theoldllm.ts @@ -407,9 +407,12 @@ export class TheOldLlmExecutor extends BaseExecutor { upstream = await directFetch(token, reqBody, signal); } - const upstreamBody = await upstream.text(); + // Read the body once — a Response body is single-use, so re-reading the + // same Response throws "Body has already been read" (#3296). Only re-read + // when a token rejection forces a fresh fetch below. + let finalBody = await upstream.text(); - if (isTokenRejected(upstream.status, upstreamBody)) { + if (isTokenRejected(upstream.status, finalBody)) { log?.warn?.("THEOLDLLM", `Token rejected (${upstream.status}), refreshing…`); invalidateToken(); try { @@ -419,10 +422,9 @@ export class TheOldLlmExecutor extends BaseExecutor { log?.warn?.("THEOLDLLM", "Token refresh failed, retrying with existing token"); } upstream = await directFetch(token, reqBody, signal); + finalBody = await upstream.text(); } - const finalBody = await upstream.text(); - if (upstream.status === 200 && finalBody) { const payload = stream ? finalBody diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 43d8005c34..80dec34e6e 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -27,6 +27,8 @@ const ALLOWED_RESPONSES_USAGE_FIELDS = new Set([ type JsonRecord = Record; +export const OMIT_STREAMING_CHUNK_MARKER = "__omniroute_omit_streaming_chunk"; + const DEEPSEEK_V4_SANITIZER_MODEL_PATTERN = /deepseek[-/]v4/i; function isDeepSeekV4Model(model: unknown): boolean { @@ -62,9 +64,84 @@ function stripZeroWidthValue(value: unknown): unknown { return value; } +function findBalancedJsonEnd(text: string, startIndex: number): number { + if (startIndex < 0 || startIndex >= text.length || text[startIndex] !== "{") return -1; + + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = startIndex; index < text.length; index += 1) { + const char = text[index]; + + if (inString) { + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === "{") { + depth += 1; + continue; + } + + if (char === "}") { + depth -= 1; + if (depth === 0) return index; + } + } + + return -1; +} + +function stripInternalToolEnvelopeText(content: string): string { + let sanitized = stripZeroWidthText(content); + const markerRegex = /to=(?:functions\.[A-Za-z0-9_.-]+|multi_tool_use\.[A-Za-z0-9_.-]+|[A-Za-z_][A-Za-z0-9_]*)/g; + + while (true) { + const match = markerRegex.exec(sanitized); + if (!match || match.index < 0) break; + + const searchWindowEnd = Math.min(sanitized.length, match.index + 1200); + const jsonStart = sanitized.indexOf("{", match.index); + if (jsonStart < 0 || jsonStart >= searchWindowEnd) { + sanitized = `${sanitized.slice(0, match.index)}${sanitized.slice(match.index + match[0].length)}`; + markerRegex.lastIndex = 0; + continue; + } + + const jsonEnd = findBalancedJsonEnd(sanitized, jsonStart); + if (jsonEnd < 0) { + sanitized = sanitized.slice(0, match.index); + break; + } + + const prefix = sanitized.slice(0, match.index).replace(/[ \t]+$/g, ""); + const suffix = sanitized.slice(jsonEnd + 1).replace(/^[ \t]+/g, ""); + sanitized = `${prefix}${suffix}`; + markerRegex.lastIndex = 0; + } + + return sanitized.replace(/\n{3,}/g, "\n\n").trim(); +} + function parseTextualToolCallContent(content: unknown): { name: string; args: unknown } | null { if (typeof content !== "string") return null; - const normalized = stripZeroWidthText(content); + const normalized = stripInternalToolEnvelopeText(content); const toolCallIndex = normalized.lastIndexOf("[Tool call:"); if (toolCallIndex < 0) return null; const candidate = normalized.slice(toolCallIndex); @@ -93,7 +170,7 @@ function parseTextualToolCallContent(content: unknown): { name: string; args: un } function containsTextualToolCallContent(content: unknown): boolean { - return typeof content === "string" && stripZeroWidthText(content).includes("[Tool call:"); + return typeof content === "string" && stripInternalToolEnvelopeText(content).includes("[Tool call:"); } function hasVisibleMessageContent(content: unknown): boolean { @@ -297,7 +374,9 @@ function sanitizeMessage(msg: unknown, isDeepSeekV4 = false): unknown { // Handle content — extract tags if (typeof msgRecord.content === "string") { - const { content, thinking } = extractThinkingFromContent(msgRecord.content); + const { content, thinking } = extractThinkingFromContent( + stripInternalToolEnvelopeText(msgRecord.content) + ); sanitized.content = collapseExcessiveNewlines(content); // Set reasoning_content from tags (if not already set) @@ -520,6 +599,140 @@ function normalizeResponsesId(id: unknown): string { return `resp_${id}`; } +function sanitizeResponsesStreamingOutputItem(item: unknown): JsonRecord | null { + const itemRecord = toRecord(item); + if (!itemRecord) return null; + + const type = toString(itemRecord.type) || "message"; + + if (type === "message") { + const role = toString(itemRecord.role) || "assistant"; + const phase = toString(itemRecord.phase); + if (role === "assistant" && phase === "commentary") { + return null; + } + + const content = sanitizeResponsesMessageContent(itemRecord.content).filter((part) => { + const partRecord = toRecord(part); + const partPhase = partRecord ? toString(partRecord.phase) : undefined; + return partPhase !== "commentary"; + }); + + if (role === "assistant" && content.length === 0) { + return null; + } + + return { + ...itemRecord, + type: "message", + role, + content, + }; + } + + if (type === "reasoning") { + const summary = Array.isArray(itemRecord.summary) + ? itemRecord.summary + .map((part) => { + const partRecord = toRecord(part); + if (!partRecord) return null; + return { + ...partRecord, + type: toString(partRecord.type) || "summary_text", + text: collapseExcessiveNewlines(toString(partRecord.text) || ""), + }; + }) + .filter((part): part is NonNullable => part !== null) + : []; + + return { + ...itemRecord, + type: "reasoning", + summary, + }; + } + + if (type === "function_call") { + return { + ...itemRecord, + type: "function_call", + arguments: + typeof itemRecord.arguments === "string" + ? itemRecord.arguments + : JSON.stringify(itemRecord.arguments || {}), + }; + } + + if (type === "function_call_output") { + return { + ...itemRecord, + type: "function_call_output", + output: + typeof itemRecord.output === "string" + ? collapseExcessiveNewlines(itemRecord.output) + : JSON.stringify(itemRecord.output ?? ""), + }; + } + + return { ...itemRecord }; +} + +function sanitizeResponsesStreamingOutput(output: unknown): JsonRecord[] { + if (!Array.isArray(output)) return []; + + return output + .map((item) => sanitizeResponsesStreamingOutputItem(item)) + .filter((item): item is JsonRecord => item !== null); +} + +function sanitizeResponsesStreamingEvent(parsedRecord: JsonRecord): JsonRecord { + const sanitized: JsonRecord = { ...parsedRecord }; + const eventType = toString(parsedRecord.type) || ""; + + if (parsedRecord.item !== undefined) { + const sanitizedItem = sanitizeResponsesStreamingOutputItem(parsedRecord.item); + if (sanitizedItem) { + sanitized.item = sanitizedItem; + } else { + delete sanitized.item; + if (eventType === "response.output_item.added" || eventType === "response.output_item.done") { + sanitized[OMIT_STREAMING_CHUNK_MARKER] = true; + } + } + } + + if (Array.isArray(parsedRecord.output)) { + const output = sanitizeResponsesStreamingOutput(parsedRecord.output); + sanitized.output = output; + const outputText = extractResponsesOutputText(output); + if (outputText.length > 0) { + sanitized.output_text = outputText; + } else { + delete sanitized.output_text; + } + } + + const responseRecord = toRecord(parsedRecord.response); + if (responseRecord) { + const responseOutput = Array.isArray(responseRecord.output) + ? sanitizeResponsesStreamingOutput(responseRecord.output) + : undefined; + const sanitizedResponse: JsonRecord = { + ...responseRecord, + ...(responseOutput ? { output: responseOutput } : {}), + }; + const responseOutputText = responseOutput ? extractResponsesOutputText(responseOutput) : ""; + if (responseOutputText.length > 0) { + sanitizedResponse.output_text = responseOutputText; + } else { + delete sanitizedResponse.output_text; + } + sanitized.response = sanitizedResponse; + } + + return sanitized; +} + function sanitizeResponsesOutput(output: unknown): JsonRecord[] { if (!Array.isArray(output)) return []; @@ -598,7 +811,7 @@ function sanitizeResponsesMessageContent(content: unknown): JsonRecord[] { return [ { type: "output_text", - text: collapseExcessiveNewlines(content), + text: collapseExcessiveNewlines(stripInternalToolEnvelopeText(content)), annotations: [], }, ]; @@ -613,7 +826,7 @@ function sanitizeResponsesMessageContent(content: unknown): JsonRecord[] { if (typeof part === "string") { return { type: "output_text", - text: collapseExcessiveNewlines(part), + text: collapseExcessiveNewlines(stripInternalToolEnvelopeText(part)), annotations: [], }; } @@ -629,7 +842,9 @@ function sanitizeResponsesMessageContent(content: unknown): JsonRecord[] { return { ...partRecord, type: "output_text", - text: collapseExcessiveNewlines(toString(partRecord.text) || ""), + text: collapseExcessiveNewlines( + stripInternalToolEnvelopeText(toString(partRecord.text) || "") + ), annotations: Array.isArray(partRecord.annotations) ? partRecord.annotations : [], }; } @@ -752,6 +967,11 @@ export function sanitizeStreamingChunk(parsed: unknown): unknown { const parsedRecord = toRecord(parsed); if (!parsedRecord) return parsed; + const eventType = toString(parsedRecord.type) || ""; + if (eventType.startsWith("response.") || parsedRecord.object === "response") { + return sanitizeResponsesStreamingEvent(parsedRecord); + } + // Build sanitized chunk const sanitized: JsonRecord = {}; diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index f4f0e71252..1d7065e70e 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -370,6 +370,7 @@ export const listModelsCatalogOutput = z.object({ provider: z.string(), capabilities: z.array(z.string()), status: z.enum(["available", "degraded", "unavailable"]), + thinkingEffort: z.string().optional(), pricing: z .object({ inputPerMillion: z.number().nullable(), diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index ba2ee2b976..0418d0c544 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -91,7 +91,10 @@ import { type McpAccessibilityConfig, } from "../services/compression/engines/mcpAccessibility/constants.ts"; import { getDbInstance } from "../../src/lib/db/core.ts"; +import { getProviderConnections } from "../../src/lib/db/providers.ts"; +import { getCodexRequestDefaults } from "../../src/lib/providers/requestDefaults.ts"; import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts"; +import { AI_PROVIDERS, NOAUTH_PROVIDERS } from "../../src/shared/constants/providers.ts"; import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts"; // ============ Configuration ============ @@ -147,6 +150,28 @@ type TextToolResult = { isError?: boolean; }; +type McpCatalogStatus = "available" | "degraded" | "unavailable"; + +type McpCatalogResponse = { + models: Array<{ + id: string; + provider: string; + capabilities: string[]; + status: McpCatalogStatus; + thinkingEffort?: string; + pricing?: unknown; + }>; + source: string; + warning?: string; +}; + +type ProviderConnectionLike = { + id?: string; + provider?: string; + isActive?: boolean; + providerSpecificData?: unknown; +}; + function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } @@ -214,6 +239,188 @@ async function omniRouteFetch(path: string, options: RequestInit = {}): Promise< return response.json(); } +function buildProviderAliasMap(): Record { + const aliasMap: Record = {}; + + for (const provider of Object.values(AI_PROVIDERS)) { + if (!provider?.id) continue; + aliasMap[provider.id] = provider.id; + if (typeof provider.alias === "string" && provider.alias.length > 0) { + aliasMap[provider.alias] = provider.id; + } + } + + for (const provider of Object.values(NOAUTH_PROVIDERS)) { + if (!provider?.id) continue; + aliasMap[provider.id] = provider.id; + if ("alias" in provider && typeof provider.alias === "string" && provider.alias.length > 0) { + aliasMap[provider.alias] = provider.id; + } + } + + return aliasMap; +} + +function normalizeCapability(value: string): string { + switch (value) { + case "embeddings": + return "embedding"; + case "images": + return "image"; + case "videos": + return "video"; + case "moderations": + return "moderation"; + case "chat-completions": + return "chat"; + default: + return value; + } +} + +function getCatalogModelCapabilities(model: JsonRecord): string[] { + if (Array.isArray(model.capabilities) && model.capabilities.length > 0) { + return toStringArray(model.capabilities, ["chat"]).map(normalizeCapability); + } + + if (Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0) { + return toStringArray(model.supportedEndpoints, ["chat"]).map(normalizeCapability); + } + + const type = toString(model.type); + if (type) return [normalizeCapability(type)]; + + return ["chat"]; +} + +function normalizeCatalogStatus(model: JsonRecord, source: string, warning?: string): McpCatalogStatus { + const explicitStatus = toString(model.status); + if (explicitStatus === "available" || explicitStatus === "degraded" || explicitStatus === "unavailable") { + return explicitStatus; + } + + if (warning || source === "local_catalog") return "degraded"; + return "available"; +} + +function getConnectionThinkingEffort(connection: ProviderConnectionLike): string | undefined { + const provider = typeof connection.provider === "string" ? connection.provider : null; + const providerSpecificData = toRecord(connection.providerSpecificData); + + if (provider === "codex") { + return getCodexRequestDefaults(providerSpecificData).reasoningEffort || "medium"; + } + + const rawThinkingEffort = toString(providerSpecificData.thinkingEffort); + return rawThinkingEffort || undefined; +} + +function normalizeProviderModelRecord( + rawModel: unknown, + fallbackProvider: string, + source: string, + warning?: string, + thinkingEffort?: string +) { + const model = toRecord(rawModel); + const id = toString(model.id, ""); + + return { + id, + provider: toString(model.owned_by, toString(model.provider, fallbackProvider)), + capabilities: getCatalogModelCapabilities(model), + status: normalizeCatalogStatus(model, source, warning), + ...(thinkingEffort ? { thinkingEffort } : {}), + pricing: model.pricing, + }; +} + +export async function getMcpModelsCatalog( + args: { provider?: string; capability?: string }, + deps: { + fetchJson?: (path: string) => Promise; + listProviderConnections?: () => Promise; + } = {} +): Promise { + const fetchJson = deps.fetchJson ?? ((path: string) => omniRouteFetch(path)); + const listProviderConnections = deps.listProviderConnections ?? getProviderConnections; + const aliasMap = buildProviderAliasMap(); + const normalizeProviderId = (value: string) => aliasMap[value] || value; + const requestedProvider = args.provider ? normalizeProviderId(args.provider) : null; + const requestedCapability = args.capability ? normalizeCapability(args.capability) : null; + + let connections = await listProviderConnections(); + connections = Array.isArray(connections) ? connections : []; + + const activeConnections = connections.filter((connection) => { + const provider = typeof connection?.provider === "string" ? normalizeProviderId(connection.provider) : null; + if (!provider || !connection?.id || connection.isActive === false) return false; + if (requestedProvider && provider !== requestedProvider) return false; + return true; + }); + + const requestSpecs = activeConnections.map((connection) => ({ + provider: normalizeProviderId(String(connection.provider)), + path: `/api/providers/${encodeURIComponent(String(connection.id))}/models?excludeHidden=true`, + thinkingEffort: getConnectionThinkingEffort(connection), + })); + + if (requestedProvider && requestSpecs.length === 0) { + const isNoAuthProvider = Object.values(NOAUTH_PROVIDERS).some((provider) => provider.id === requestedProvider); + if (isNoAuthProvider) { + requestSpecs.push({ + provider: requestedProvider, + path: `/api/v1/providers/${encodeURIComponent(requestedProvider)}/models`, + thinkingEffort: undefined, + }); + } else { + return { + models: [], + source: "provider_connections", + warning: `No active connections found for provider '${requestedProvider}'.`, + }; + } + } + + const collectedModels = new Map(); + const warnings = new Set(); + const sources = new Set(); + + for (const spec of requestSpecs) { + const raw = toRecord(await fetchJson(spec.path)); + const source = toString(raw.source, spec.path.startsWith("/api/providers/") ? "api" : "v1_catalog"); + const warning = raw.warning ? String(raw.warning) : undefined; + if (warning) warnings.add(warning); + sources.add(source); + + const rawModels = Array.isArray(raw.models) + ? raw.models + : Array.isArray(raw.data) + ? raw.data + : []; + + for (const rawModel of rawModels) { + const normalized = normalizeProviderModelRecord(rawModel, spec.provider, source, warning); + if (spec.thinkingEffort && !normalized.thinkingEffort) { + normalized.thinkingEffort = spec.thinkingEffort; + } + if (!normalized.id) continue; + if (requestedCapability && !normalized.capabilities.includes(requestedCapability)) continue; + + const key = `${normalized.provider}:${normalized.id}`; + if (!collectedModels.has(key)) { + collectedModels.set(key, normalized); + } + } + } + + return { + models: [...collectedModels.values()], + source: sources.size === 1 ? [...sources][0] : "aggregated_provider_models", + ...(warnings.size > 0 ? { warning: [...warnings].join(" | ") } : {}), + }; +} + function withScopeEnforcement( toolName: string, handler: (args: unknown, extra?: McpToolExtraLike) => Promise, @@ -522,50 +729,7 @@ async function handleCostReport(args: { period?: string }) { async function handleListModelsCatalog(args: { provider?: string; capability?: string }) { const start = Date.now(); try { - let path = "/v1/models"; - let isProviderSpecific = false; - let source = "local_catalog"; - let warning: string | undefined; - - if (args.provider && !args.capability) { - // Use direct provider fetch to get real-time API status - path = `/api/providers/${encodeURIComponent(args.provider)}/models?excludeHidden=true`; - isProviderSpecific = true; - } else { - const params = new URLSearchParams(); - if (args.provider) params.set("provider", args.provider); - if (args.capability) params.set("capability", args.capability); - if (params.toString()) path += `?${params.toString()}`; - } - - const raw = toRecord(await omniRouteFetch(path)); - - // If we used the direct provider endpoint - let rawModels: unknown[] = []; - if (isProviderSpecific) { - rawModels = Array.isArray(raw.models) ? raw.models : []; - source = typeof raw.source === "string" ? raw.source : "api"; - if (raw.warning) warning = String(raw.warning); - } else { - rawModels = Array.isArray(raw.data) ? raw.data : []; - source = "local_catalog"; - // OmniRoute's global /v1/models is always a cached/local catalog - } - - const result = { - models: rawModels.map((rawModel) => { - const model = toRecord(rawModel); - return { - id: toString(model.id, ""), - provider: toString(model.owned_by, toString(model.provider, args.provider || "unknown")), - capabilities: toStringArray(model.capabilities, ["chat"]), - status: toString(model.status, "available"), - pricing: model.pricing, - }; - }), - source, - ...(warning ? { warning } : {}), - }; + const result = await getMcpModelsCatalog(args); await logToolCall( "omniroute_list_models_catalog", diff --git a/open-sse/package.json b/open-sse/package.json index 9f41059bcd..793134af7a 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.12", + "version": "3.8.13", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/autoRefreshDaemon.ts b/open-sse/services/autoRefreshDaemon.ts new file mode 100644 index 0000000000..29c22a4a14 --- /dev/null +++ b/open-sse/services/autoRefreshDaemon.ts @@ -0,0 +1,204 @@ +/** + * AutoRefreshDaemon — Background cookie validity checker for web-cookie providers + * + * Periodically checks stored credentials for web-cookie providers by making + * lightweight requests to their home pages. If a credential is expired, it + * logs a warning and marks the credential for re-authentication. + * + * The daemon does NOT automatically re-login (that requires user interaction + * for security). It alerts the system so higher-level components can decide + * what to do (e.g., fallback to another provider, prompt user to re-login). + */ + +import { TOKEN_EXTRACTION_CONFIGS } from "./tokenExtractionConfig"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface DaemonStatus { + running: boolean; + checkedProviderCount: number; + expiredCredentials: string[]; + lastRun: number | null; +} + +interface StoredCredentialEntry { + providerId: string; + value: string; + storedAt: number; +} + +// ─── Constants ────────────────────────────────────────────────────────────── + +const DEFAULT_CHECK_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes +const MIN_CHECK_INTERVAL_MS = 60 * 1000; // 1 minute minimum + +// ─── Daemon ───────────────────────────────────────────────────────────────── + +class AutoRefreshDaemon { + private timerId: ReturnType | null = null; + private running = false; + private checkIntervalMs: number; + private expiredCredentials: string[] = []; + private lastRun: number | null = null; + /** In-memory store of web-cookie credentials (real persistence uses SQLite) */ + private credentialStore = new Map(); + + constructor(checkIntervalMs = DEFAULT_CHECK_INTERVAL_MS) { + this.checkIntervalMs = Math.max(checkIntervalMs, MIN_CHECK_INTERVAL_MS); + } + + /** + * Register a credential for auto-refresh monitoring. + * Called when credentials are extracted/updated. + */ + registerCredential(providerId: string, value: string): void { + this.credentialStore.set(providerId, { + providerId, + value, + storedAt: Date.now(), + }); + } + + /** + * Remove a credential from monitoring (e.g., provider deleted) + */ + unregisterCredential(providerId: string): void { + this.credentialStore.delete(providerId); + } + + /** + * Start the daemon — begins periodic credential checks + */ + start(): void { + if (this.running) return; + this.running = true; + + // Run an initial check immediately + this.check().catch(() => {}); + + this.timerId = setInterval(() => { + this.check().catch(() => {}); + }, this.checkIntervalMs); + + console.log( + `[AutoRefreshDaemon] Started — checking ${this.credentialStore.size} credentials every ${this.checkIntervalMs / 1000}s` + ); + } + + /** + * Stop the daemon + */ + stop(): void { + if (!this.running) return; + this.running = false; + if (this.timerId) { + clearInterval(this.timerId); + this.timerId = null; + } + console.log("[AutoRefreshDaemon] Stopped"); + } + + /** + * Check all stored credentials for validity. + * Makes a lightweight HEAD/GET request to the provider's home page. + */ + async check(): Promise { + this.lastRun = Date.now(); + const newlyExpired: string[] = []; + + const entries = [...this.credentialStore.entries()]; + + for (const [providerId] of entries) { + const config = TOKEN_EXTRACTION_CONFIGS.get(providerId); + if (!config) { + this.credentialStore.delete(providerId); + continue; + } + + try { + const isValid = await this.validateCredential(providerId, config.homeUrl); + if (!isValid) { + newlyExpired.push(providerId); + console.warn( + `[AutoRefreshDaemon] Credential expired for "${providerId}" (${config.displayName})` + ); + } + } catch { + // Network errors are non-fatal — retry next cycle + } + } + + // Update expired list + for (const id of newlyExpired) { + if (!this.expiredCredentials.includes(id)) { + this.expiredCredentials.push(id); + } + } + } + + /** + * Validate a credential by making a request to the provider's home page. + * Returns true if the response suggests the credential is still valid. + */ + private async validateCredential(providerId: string, homeUrl: string): Promise { + const entry = this.credentialStore.get(providerId); + if (!entry) return false; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + try { + const response = await fetch(homeUrl, { + method: "HEAD", + signal: controller.signal, + headers: { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + }, + }); + + // A valid credential typically returns 200 (occasionally 301/302) + // 401/403 strongly suggest expired credential + if (response.status === 401 || response.status === 403) { + return false; + } + + return true; + } catch { + // Network errors (timeout, DNS failure) don't mean the credential is bad + return true; + } finally { + clearTimeout(timeout); + } + } + + /** + * Get the current daemon status + */ + getStatus(): DaemonStatus { + return { + running: this.running, + checkedProviderCount: this.credentialStore.size, + expiredCredentials: [...this.expiredCredentials], + lastRun: this.lastRun, + }; + } + + /** + * Clear expired credentials list (e.g., after re-authentication) + */ + clearExpired(): void { + this.expiredCredentials = []; + } + + /** + * Restart the daemon (useful when config changes) + */ + restart(): void { + this.stop(); + this.start(); + } +} + +// ─── Singleton ────────────────────────────────────────────────────────────── + +export const autoRefreshDaemon = new AutoRefreshDaemon(); diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 88c8c09a35..ddaa7b5077 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -78,6 +78,7 @@ import type { RoutingHint } from "./manifestAdapter"; import type { CompressionMode } from "./compression/types.ts"; import { getModelContextLimit } from "../../src/lib/modelCapabilities"; import { getProviderConnections } from "../../src/lib/db/providers"; +import { getProviderModels } from "../config/providerModels.ts"; import { getComboModelString, getComboStepTarget, @@ -1631,7 +1632,10 @@ async function getQuotaAwareConnectionsForTarget( const activeConnections = Array.isArray(connections) ? (connections as Array>) : []; - if (!resetAwareConnectionCache.has(provider) && resetAwareConnectionCache.size >= MAX_RESET_AWARE_CACHE) { + if ( + !resetAwareConnectionCache.has(provider) && + resetAwareConnectionCache.size >= MAX_RESET_AWARE_CACHE + ) { const oldest = resetAwareConnectionCache.keys().next().value; if (oldest !== undefined) resetAwareConnectionCache.delete(oldest); } @@ -1766,7 +1770,10 @@ async function fetchResetAwareQuotaWithCache({ const refreshPromise = fetcher(connectionId, connection) .then((quota) => { if (quota) { - if (!resetAwareQuotaCache.has(cacheKey) && resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE) { + if ( + !resetAwareQuotaCache.has(cacheKey) && + resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE + ) { const oldest = resetAwareQuotaCache.keys().next().value; if (oldest !== undefined) resetAwareQuotaCache.delete(oldest); } @@ -1783,7 +1790,10 @@ async function fetchResetAwareQuotaWithCache({ .catch((error) => { const previous = resetAwareQuotaCache.get(cacheKey); if (previous) { - if (!resetAwareQuotaCache.has(cacheKey) && resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE) { + if ( + !resetAwareQuotaCache.has(cacheKey) && + resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE + ) { const oldest = resetAwareQuotaCache.keys().next().value; if (oldest !== undefined) resetAwareQuotaCache.delete(oldest); } @@ -2577,6 +2587,67 @@ function scoreAutoTargets( .sort((a, b) => b.score - a.score); } +/** + * For an auto-combo WITHOUT an explicit `candidatePool`, broaden the eligible + * targets to every model of every active provider connection so the router has + * the full pool to score over. Already-present `modelStr`s are not duplicated. + * + * Best-effort: if loading active connections or provider models throws, the + * explicitly-resolved targets are returned unchanged (the combo still runs). + * Exported for unit testing. Mutates and returns `eligibleTargets`. + */ +export async function expandAutoComboCandidatePool( + eligibleTargets: ResolvedComboTarget[], + combo: { autoConfig?: unknown; config?: unknown } | null | undefined +): Promise { + const localAutoConfig = + (combo?.autoConfig as Record | undefined) || + (isRecord((combo?.config as Record)?.auto) + ? ((combo?.config as Record).auto as Record) + : null) || + (combo?.config as Record | undefined) || + {}; + + if (Array.isArray(localAutoConfig?.candidatePool)) return eligibleTargets; + + try { + const allConnections = await getProviderConnections({ isActive: true }); + const providerIds = [ + ...new Set( + (allConnections as Array<{ provider?: unknown }>) + .map((c) => c.provider) + .filter((p): p is string => typeof p === "string" && p.length > 0) + ), + ]; + for (const providerId of providerIds) { + const providerModels = getProviderModels(providerId); + for (const model of providerModels) { + const modelStr = `${providerId}/${model.id}`; + if (!eligibleTargets.some((t) => t.modelStr === modelStr)) { + eligibleTargets.push({ + kind: "model", + stepId: modelStr, + executionKey: modelStr, + provider: providerId, + providerId: providerId, + modelStr, + weight: 1, + connectionId: null, + label: null, + }); + } + } + } + } catch { + // Best-effort candidate expansion only: if loading active connections or + // provider models fails, fall back to the explicitly-resolved targets + // rather than aborting the combo. The push above is the only mutation, + // so a throw leaves eligibleTargets exactly as explicit resolution built it. + } + + return eligibleTargets; +} + /** * Handle combo chat with fallback. * @param {Object} options @@ -2983,6 +3054,8 @@ export async function handleComboChat({ ); // eligibleTargets intentionally unchanged — same fallback contract as tool-calling filter } + + eligibleTargets = await expandAutoComboCandidatePool(eligibleTargets, combo); } const prompt = extractPromptForIntent(body); @@ -3293,227 +3366,449 @@ export async function handleComboChat({ let globalAttempts = 0; try { - for (let setTry = 0; setTry <= maxSetRetries; setTry++) { - // #1731: Per-set-iteration set of providers whose quota is fully exhausted. - // Reset each retry so providers excluded in a previous attempt get another chance. - const exhaustedProviders = new Set(); - const transientRateLimitedProviders = new Set(); - if (setTry > 0) { - log.info("COMBO", `All targets failed — retrying set (${setTry}/${maxSetRetries})`); - await new Promise((resolve) => { - const timer = setTimeout(resolve, setRetryDelayMs); - signal?.addEventListener( - "abort", - () => { - clearTimeout(timer); - resolve(undefined); - }, - { once: true } - ); - }); - if (signal?.aborted) { - log.info("COMBO", "Client disconnected during set retry delay — aborting"); - return errorResponse(499, "Client disconnected"); - } - } - - let lastError: string | null = null; - let earliestRetryAfter: ComboRetryAfter | null = null; - let lastStatus: number | null = null; - const startTime = Date.now(); - let fallbackCount = 0; - let recordedAttempts = 0; - - let globalResolve: ((res: Response) => void) | null = null; - const globalPromise = new Promise((res) => { - globalResolve = res; - }); - const runningTasks = new Set>(); - let anySuccess = false; - const abortControllers = new Map(); - const zeroLatencyOptimizationsEnabled = config.zeroLatencyOptimizationsEnabled === true; - - const executeTarget = async ( - i: number - ): Promise<{ ok: boolean; response?: Response } | null> => { - const target = orderedTargets[i]; - const modelStr = target.modelStr; - const provider = target.provider; - const profile = await getRuntimeProviderProfile(provider); - const allowRateLimitedConnection = - Boolean(provider && provider !== "unknown") && transientRateLimitedProviders.has(provider); - const targetForAttempt = allowRateLimitedConnection - ? { - ...target, - allowRateLimitedConnection: true, - modelAbortSignal: abortControllers.get(i)!.signal, - } - : { ...target, modelAbortSignal: abortControllers.get(i)!.signal }; - - // #1731: Skip targets from a provider that already signaled full quota exhaustion this request. - if (provider && exhaustedProviders.has(provider)) { - log.info( - "COMBO", - `Skipping ${modelStr} — provider ${provider} marked exhausted this request (#1731)` - ); - if (i > 0) fallbackCount++; - return null; - } - - // Pre-check: skip models where no credentials are available (excluded, rate-limited, or unavailable) - if (isModelAvailable) { - const available = await isModelAvailable(modelStr, targetForAttempt); - if (!available) { - log.debug?.("COMBO", `Skipping ${modelStr} — no credentials available or model excluded`); - if (i > 0) fallbackCount++; - return null; - } - } - - // Credential gate: skip targets with known-bad credentials (fail-fast) - const connectionId = target.connectionId as string | undefined; - if (connectionId) { - const gateResult = checkCredentialGate(connectionId, provider, modelStr); - if (gateResult.allowed === false) { - logCredentialSkip(log, modelStr, gateResult.reason || "Credential gate blocked"); - if (i > 0) fallbackCount++; - return null; - } - } - - // Retry loop for transient errors - for (let retry = 0; retry <= maxRetries; retry++) { - // Fix #1681: Bail out immediately if the client has disconnected - if (signal?.aborted) { - log.info("COMBO", `Client disconnected — aborting combo loop before model ${modelStr}`); - return { ok: false, response: errorResponse(499, "Client disconnected") }; - } - globalAttempts++; - if (globalAttempts > MAX_GLOBAL_ATTEMPTS) { - log.warn( - "COMBO", - `Maximum combo attempts (${MAX_GLOBAL_ATTEMPTS}) exceeded across all targets and fallbacks. Terminating loop to prevent runaway background requests.` + for (let setTry = 0; setTry <= maxSetRetries; setTry++) { + // #1731: Per-set-iteration set of providers whose quota is fully exhausted. + // Reset each retry so providers excluded in a previous attempt get another chance. + const exhaustedProviders = new Set(); + const transientRateLimitedProviders = new Set(); + if (setTry > 0) { + log.info("COMBO", `All targets failed — retrying set (${setTry}/${maxSetRetries})`); + await new Promise((resolve) => { + const timer = setTimeout(resolve, setRetryDelayMs); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(undefined); + }, + { once: true } ); - return { ok: false, response: errorResponse(503, "Maximum combo retry limit reached") }; + }); + if (signal?.aborted) { + log.info("COMBO", "Client disconnected during set retry delay — aborting"); + return errorResponse(499, "Client disconnected"); } + } - // Predictive TTFT Circuit Breaker (skip slow models) - if ( - zeroLatencyOptimizationsEnabled && - config.predictiveTtftMs && - config.predictiveTtftMs > 0 && - retry === 0 - ) { - const cMetrics = getComboMetrics(combo.name); - if (cMetrics) { - const targetKey = orderedTargets[i].executionKey || modelStr; - const m = cMetrics.byTarget[targetKey] || cMetrics.byModel[modelStr]; - if (m && m.requests >= 5 && m.avgLatencyMs > config.predictiveTtftMs) { - log.warn( - "COMBO", - `Predictive TTFT Circuit Breaker: skipping ${modelStr} (avg ${m.avgLatencyMs}ms > max ${config.predictiveTtftMs}ms)` - ); - return null; + let lastError: string | null = null; + let earliestRetryAfter: ComboRetryAfter | null = null; + let lastStatus: number | null = null; + const startTime = Date.now(); + let fallbackCount = 0; + let recordedAttempts = 0; + + let globalResolve: ((res: Response) => void) | null = null; + const globalPromise = new Promise((res) => { + globalResolve = res; + }); + const runningTasks = new Set>(); + let anySuccess = false; + const abortControllers = new Map(); + const zeroLatencyOptimizationsEnabled = config.zeroLatencyOptimizationsEnabled === true; + + const executeTarget = async ( + i: number + ): Promise<{ ok: boolean; response?: Response } | null> => { + const target = orderedTargets[i]; + const modelStr = target.modelStr; + const provider = target.provider; + const profile = await getRuntimeProviderProfile(provider); + const allowRateLimitedConnection = + Boolean(provider && provider !== "unknown") && + transientRateLimitedProviders.has(provider); + const targetForAttempt = allowRateLimitedConnection + ? { + ...target, + allowRateLimitedConnection: true, + modelAbortSignal: abortControllers.get(i)!.signal, } - } - } + : { ...target, modelAbortSignal: abortControllers.get(i)!.signal }; - if (retry > 0) { + // #1731: Skip targets from a provider that already signaled full quota exhaustion this request. + if (provider && exhaustedProviders.has(provider)) { log.info( "COMBO", - `Retrying ${modelStr} in ${retryDelayMs}ms (attempt ${retry + 1}/${maxRetries + 1})` + `Skipping ${modelStr} — provider ${provider} marked exhausted this request (#1731)` ); - await new Promise((resolve) => { - const timer = setTimeout(resolve, retryDelayMs); - signal?.addEventListener( - "abort", - () => { - clearTimeout(timer); - resolve(undefined); - }, - { once: true } + if (i > 0) fallbackCount++; + return null; + } + + // Pre-check: skip models where no credentials are available (excluded, rate-limited, or unavailable) + if (isModelAvailable) { + const available = await isModelAvailable(modelStr, targetForAttempt); + if (!available) { + log.debug?.( + "COMBO", + `Skipping ${modelStr} — no credentials available or model excluded` ); - }); + if (i > 0) fallbackCount++; + return null; + } + } + + // Credential gate: skip targets with known-bad credentials (fail-fast) + const connectionId = target.connectionId as string | undefined; + if (connectionId) { + const gateResult = checkCredentialGate(connectionId, provider, modelStr); + if (gateResult.allowed === false) { + logCredentialSkip(log, modelStr, gateResult.reason || "Credential gate blocked"); + if (i > 0) fallbackCount++; + return null; + } + } + + // Retry loop for transient errors + for (let retry = 0; retry <= maxRetries; retry++) { + // Fix #1681: Bail out immediately if the client has disconnected if (signal?.aborted) { - log.info("COMBO", `Client disconnected during retry delay — aborting`); + log.info("COMBO", `Client disconnected — aborting combo loop before model ${modelStr}`); return { ok: false, response: errorResponse(499, "Client disconnected") }; } - } - - log.info( - "COMBO", - `Trying model ${i + 1}/${orderedTargets.length}: ${modelStr}${retry > 0 ? ` (retry ${retry})` : ""}` - ); - emit("combo.target.attempt", { - comboName: combo.name, - targetIndex: i, - provider, - model: modelStr, - timestamp: Date.now(), - strategy, - }); - - // Deep clone the body to ensure context preservation and prevent mutations - // from affecting other targets in the combo - let attemptBody = JSON.parse(JSON.stringify(body)); - - // Proactive Context Compression for fallbacks (Zero-Latency optimization) - if ( - zeroLatencyOptimizationsEnabled && - i > 0 && - config.fallbackCompressionMode && - config.fallbackCompressionMode !== "off" - ) { - const { estimateTokens } = await import("./contextManager.ts"); - const estimatedTokens = estimateTokens(JSON.stringify(attemptBody)); - if (estimatedTokens > (config.fallbackCompressionThreshold ?? 1000)) { - const { applyCompression } = await import("./compression/strategySelector.ts"); - const compressionResult = applyCompression( - attemptBody, - config.fallbackCompressionMode as CompressionMode, - { model: modelStr } - ); - if (compressionResult.compressed) { - log.info( - "COMBO", - `Proactive fallback compression applied (${config.fallbackCompressionMode}): ${estimatedTokens} -> ${compressionResult.stats?.compressedTokens} tokens` - ); - attemptBody = compressionResult.body; - } - } - } - - // Universal handoff: inject existing handoff if model changed - if ( - universalHandoffConfig.enabled && - relayOptions?.sessionId && - !(body as Record)?.[SKIP_UNIVERSAL_HANDOFF_FLAG] - ) { - const lastModel = getLastSessionModel(relayOptions.sessionId, combo.name); - if (lastModel && lastModel !== modelStr) { - const existingHandoff = getHandoff(relayOptions.sessionId, combo.name); - attemptBody = injectUniversalHandoffBody( - attemptBody, // Use the cloned body to maintain isolation - lastModel, - modelStr, - `Model routing: ${lastModel} → ${modelStr}`, - existingHandoff - ); - } - } - const result = await handleSingleModelWithTimeout(attemptBody, modelStr, { - ...targetForAttempt, - failoverBeforeRetry: config.failoverBeforeRetry, - }); - - // Success — validate response quality before returning - if (result.ok) { - const quality = await validateResponseQuality(result, clientRequestedStream, log); - if (!quality.valid) { + globalAttempts++; + if (globalAttempts > MAX_GLOBAL_ATTEMPTS) { log.warn( "COMBO", - `Model ${modelStr} returned 200 but failed quality check: ${quality.reason}` + `Maximum combo attempts (${MAX_GLOBAL_ATTEMPTS}) exceeded across all targets and fallbacks. Terminating loop to prevent runaway background requests.` ); + return { ok: false, response: errorResponse(503, "Maximum combo retry limit reached") }; + } + + // Predictive TTFT Circuit Breaker (skip slow models) + if ( + zeroLatencyOptimizationsEnabled && + config.predictiveTtftMs && + config.predictiveTtftMs > 0 && + retry === 0 + ) { + const cMetrics = getComboMetrics(combo.name); + if (cMetrics) { + const targetKey = orderedTargets[i].executionKey || modelStr; + const m = cMetrics.byTarget[targetKey] || cMetrics.byModel[modelStr]; + if (m && m.requests >= 5 && m.avgLatencyMs > config.predictiveTtftMs) { + log.warn( + "COMBO", + `Predictive TTFT Circuit Breaker: skipping ${modelStr} (avg ${m.avgLatencyMs}ms > max ${config.predictiveTtftMs}ms)` + ); + return null; + } + } + } + + if (retry > 0) { + log.info( + "COMBO", + `Retrying ${modelStr} in ${retryDelayMs}ms (attempt ${retry + 1}/${maxRetries + 1})` + ); + await new Promise((resolve) => { + const timer = setTimeout(resolve, retryDelayMs); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(undefined); + }, + { once: true } + ); + }); + if (signal?.aborted) { + log.info("COMBO", `Client disconnected during retry delay — aborting`); + return { ok: false, response: errorResponse(499, "Client disconnected") }; + } + } + + log.info( + "COMBO", + `Trying model ${i + 1}/${orderedTargets.length}: ${modelStr}${retry > 0 ? ` (retry ${retry})` : ""}` + ); + emit("combo.target.attempt", { + comboName: combo.name, + targetIndex: i, + provider, + model: modelStr, + timestamp: Date.now(), + strategy, + }); + + // Deep clone the body to ensure context preservation and prevent mutations + // from affecting other targets in the combo + let attemptBody = JSON.parse(JSON.stringify(body)); + + // Proactive Context Compression for fallbacks (Zero-Latency optimization) + if ( + zeroLatencyOptimizationsEnabled && + i > 0 && + config.fallbackCompressionMode && + config.fallbackCompressionMode !== "off" + ) { + const { estimateTokens } = await import("./contextManager.ts"); + const estimatedTokens = estimateTokens(JSON.stringify(attemptBody)); + if (estimatedTokens > (config.fallbackCompressionThreshold ?? 1000)) { + const { applyCompression } = await import("./compression/strategySelector.ts"); + const compressionResult = applyCompression( + attemptBody, + config.fallbackCompressionMode as CompressionMode, + { model: modelStr } + ); + if (compressionResult.compressed) { + log.info( + "COMBO", + `Proactive fallback compression applied (${config.fallbackCompressionMode}): ${estimatedTokens} -> ${compressionResult.stats?.compressedTokens} tokens` + ); + attemptBody = compressionResult.body; + } + } + } + + // Universal handoff: inject existing handoff if model changed + if ( + universalHandoffConfig.enabled && + relayOptions?.sessionId && + !(body as Record)?.[SKIP_UNIVERSAL_HANDOFF_FLAG] + ) { + const lastModel = getLastSessionModel(relayOptions.sessionId, combo.name); + if (lastModel && lastModel !== modelStr) { + const existingHandoff = getHandoff(relayOptions.sessionId, combo.name); + attemptBody = injectUniversalHandoffBody( + attemptBody, // Use the cloned body to maintain isolation + lastModel, + modelStr, + `Model routing: ${lastModel} → ${modelStr}`, + existingHandoff + ); + } + } + const result = await handleSingleModelWithTimeout(attemptBody, modelStr, { + ...targetForAttempt, + failoverBeforeRetry: config.failoverBeforeRetry, + }); + + // Success — validate response quality before returning + if (result.ok) { + const quality = await validateResponseQuality(result, clientRequestedStream, log); + if (!quality.valid) { + log.warn( + "COMBO", + `Model ${modelStr} returned 200 but failed quality check: ${quality.reason}` + ); + recordComboRequest(combo.name, modelStr, { + success: false, + latencyMs: Date.now() - startTime, + fallbackCount, + strategy, + target: toRecordedTarget(target), + }); + recordedAttempts++; + // Fix #1707: Set terminal state so the fallback doesn't emit + // misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality. + lastError = `Upstream response failed quality validation: ${quality.reason}`; + if (!lastStatus) lastStatus = 502; + if (i > 0) fallbackCount++; + emit("combo.target.failed", { + comboName: combo.name, + targetIndex: i, + provider, + model: modelStr, + error: `Quality: ${quality.reason}`, + latencyMs: Date.now() - startTime, + }); + return null; + } + const latencyMs = Date.now() - startTime; + emit("combo.target.succeeded", { + comboName: combo.name, + targetIndex: i, + provider, + model: modelStr, + latencyMs, + }); + log.info( + "COMBO", + `Model ${modelStr} succeeded (${latencyMs}ms, ${fallbackCount} fallbacks)` + ); + recordComboRequest(combo.name, modelStr, { + success: true, + latencyMs, + fallbackCount, + strategy, + target: toRecordedTarget(target), + }); + recordedAttempts++; + // Webhook fan-out: best-effort, never blocks the response stream. + notifyWebhookEvent("request.completed", { + combo: combo.name, + provider, + model: modelStr, + latencyMs, + fallbackCount, + }); + + // Universal handoff: record model usage for session + if ( + universalHandoffConfig.enabled && + relayOptions?.sessionId && + !(body as Record)?.[SKIP_UNIVERSAL_HANDOFF_FLAG] + ) { + const prevModel = getLastSessionModel(relayOptions.sessionId, combo.name); + recordSessionModelUsage( + relayOptions.sessionId, + combo.name, + modelStr, + provider, + target.connectionId ?? undefined + ); + if (prevModel && prevModel !== modelStr) { + const handoffSourceMessages = + Array.isArray(body?.messages) && body.messages.length > 0 + ? body.messages + : Array.isArray(body?.input) + ? body.input + : []; + + maybeGenerateUniversalHandoff({ + sessionId: relayOptions.sessionId, + comboName: combo.name, + messages: handoffSourceMessages as MessageLike[], + prevModel, + currModel: modelStr, + universalConfig: universalHandoffConfig, + handleSingleModel: handleSingleModelWithTimeout, + }); + } + + recordSessionModelUsage( + relayOptions.sessionId, + combo.name, + modelStr, + provider, + target.connectionId ?? undefined + ); + } + // Context-relay intentionally splits responsibilities: + // combo.ts decides whether a successful turn should generate a handoff, + // while chat.ts injects the handoff after the real connectionId is resolved. + if ( + strategy === "context-relay" && + relayOptions?.sessionId && + relayConfig && + relayConfig.handoffProviders.includes(provider) && + provider === "codex" + ) { + const connectionId = getSessionConnection(relayOptions.sessionId); + if (connectionId) { + const quotaInfo = await fetchCodexQuota(connectionId).catch(() => null); + if (quotaInfo) { + const resetCandidates = [ + quotaInfo.windows?.session?.resetAt, + quotaInfo.windows?.weekly?.resetAt, + quotaInfo.resetAt, + ] + .filter( + (value): value is string => typeof value === "string" && value.length > 0 + ) + .sort((a, b) => a.localeCompare(b)); + const handoffSourceMessages = + Array.isArray(body?.messages) && body.messages.length > 0 + ? body.messages + : Array.isArray(body?.input) + ? body.input + : []; + + maybeGenerateHandoff({ + sessionId: relayOptions.sessionId, + comboName: combo.name, + connectionId, + percentUsed: quotaInfo.percentUsed, + messages: handoffSourceMessages, + model: modelStr, + expiresAt: resetCandidates[0] || null, + config: relayConfig, + handleSingleModel: handleSingleModelWithTimeout, + }); + } + } + } + + // Record last known good provider (LKGP) for this combo/model (#919) + if (provider) { + const connId = target.connectionId || undefined; + void (async () => { + try { + const { setLKGP } = await import("../../src/lib/localDb"); + await Promise.all([ + setLKGP(combo.name, target.executionKey, provider, connId), + setLKGP(combo.name, combo.id || combo.name, provider, connId), + ]); + } catch (err) { + log.warn( + "COMBO", + "Failed to record Last Known Good Provider. This is non-fatal.", + { + err, + } + ); + } + })(); + } + + return { ok: true, response: quality.clonedResponse ?? result }; + } + + // Extract error info from response + let errorText = result.statusText || ""; + let errorBody: ComboErrorBody = null; + let retryAfter: ComboRetryAfter | null = null; + try { + const cloned = result.clone(); + try { + const text = await cloned.text(); + if (text) { + errorText = text.substring(0, 500); + errorBody = JSON.parse(text); + const parsedError = errorBody?.error; + errorText = + (typeof parsedError === "object" && parsedError?.message) || + (typeof parsedError === "string" ? parsedError : null) || + errorBody?.message || + errorText; + retryAfter = errorBody?.retryAfter || null; + } + } catch { + /* Clone parse failed */ + } + } catch { + /* Clone failed */ + } + + // Track earliest retryAfter + if ( + retryAfter && + (!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter)) + ) { + earliestRetryAfter = retryAfter; + } + + // Normalize error text + if (typeof errorText !== "string") { + try { + errorText = JSON.stringify(errorText); + } catch { + errorText = String(errorText); + } + } + + const isStreamReadinessFailure = + (result.status === 502 || result.status === 504) && + isStreamReadinessFailureErrorBody(errorBody); + + // FIX 5: a local per-API-key token-limit 429 must not cool shared accounts. + const isTokenLimitBreach = + result.status === 429 && isTokenLimitBreachErrorBody(errorBody); + + // Fix #1681: Status 499 means client disconnected — stop combo loop immediately. + // There is no point trying fallback models when nobody is listening. + if (result.status === 499) { + log.info("COMBO", `Client disconnected (499) during ${modelStr} — stopping combo loop`); recordComboRequest(combo.name, modelStr, { success: false, latencyMs: Date.now() - startTime, @@ -3522,299 +3817,132 @@ export async function handleComboChat({ target: toRecordedTarget(target), }); recordedAttempts++; - // Fix #1707: Set terminal state so the fallback doesn't emit - // misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality. - lastError = `Upstream response failed quality validation: ${quality.reason}`; - if (!lastStatus) lastStatus = 502; - if (i > 0) fallbackCount++; - emit("combo.target.failed", { - comboName: combo.name, - targetIndex: i, - provider, - model: modelStr, - error: `Quality: ${quality.reason}`, + // executeTarget must return the {ok,response} contract — a raw Response + // here makes the speculative loop's res.ok/res.response checks both miss, + // so the combo would wrongly fall through to the next model after a 499. + return { ok: false, response: result }; + } + + // Combo fallback is target-level orchestration: a non-ok target response is + // treated as local to that target and the combo continues to the next target. + // Error classification is retained only for retry/cooldown pacing; it must + // not decide whether fallback happens, including for generic 400 responses. + const rawError = errorBody?.error; + const structuredError = + rawError && typeof rawError === "object" + ? { + // Upstream JSON may carry a numeric `code`/`type` (e.g. {"code":40001}). + // Coerce to string if present instead of discarding, so downstream string + // ops (.toLowerCase, .startsWith) can run safely without type crashes. + code: + (rawError as Record).code !== undefined && + (rawError as Record).code !== null + ? String((rawError as Record).code) + : undefined, + type: + (rawError as Record).type !== undefined && + (rawError as Record).type !== null + ? String((rawError as Record).type) + : undefined, + } + : undefined; + const fallbackResult = checkFallbackError( + result.status, + errorText, + 0, + null, + provider, + result.headers, + profile, + structuredError + ); + const { cooldownMs } = fallbackResult; + + // #1731: If the entire provider quota is exhausted, mark it so subsequent + // same-provider targets are skipped immediately. API-key 429s still use + // the short resilience cooldown, but explicit quota text should stop the + // combo from trying another target for the same provider in this request. + const providerExhausted = + Boolean(provider && provider !== "unknown") && + (isProviderExhaustedReason(fallbackResult) || + classifyErrorText(errorText) === RateLimitReason.QUOTA_EXHAUSTED); + if (providerExhausted) { + exhaustedProviders.add(provider); + log.info( + "COMBO", + `Provider ${provider} quota exhausted — marking for skip on remaining targets (#1731)` + ); + } else if ( + result.status === 429 && + !isTokenLimitBreach && + provider && + provider !== "unknown" + ) { + transientRateLimitedProviders.add(provider); + } + + // #2101: Prevent infinite fallback loops with 400 Bad Request errors that indicate + // request-body-specific issues (context overflow, malformed request, model access denied). + // These errors are unlikely to be resolved by trying different target models since + // the same problematic request body would be sent to all targets. + if ( + result.status === 400 && + fallbackResult.shouldFallback && + (fallbackResult.reason === RateLimitReason.MODEL_CAPACITY || + errorText.toLowerCase().includes("context") || + errorText.toLowerCase().includes("malformed") || + errorText.toLowerCase().includes("invalid") || + errorText.toLowerCase().includes("bad request")) + ) { + log.warn( + "COMBO", + `400 Bad Request with body-specific error detected on ${modelStr} — skipping fallback to other targets to prevent infinite loop` + ); + // Record the failure and break to avoid trying other targets with the same bad request + recordComboRequest(combo.name, modelStr, { + success: false, latencyMs: Date.now() - startTime, + fallbackCount, + strategy, + target: toRecordedTarget(target), }); - return null; + recordedAttempts++; + lastError = errorText || String(result.status); + if (!lastStatus) lastStatus = result.status; + if (i > 0) fallbackCount++; + log.warn("COMBO", `Model ${modelStr} failed with body-specific error, stopping combo`); + break; // Break out of the target loop to avoid trying other models } - const latencyMs = Date.now() - startTime; - emit("combo.target.succeeded", { - comboName: combo.name, - targetIndex: i, - provider, - model: modelStr, - latencyMs, - }); - log.info( - "COMBO", - `Model ${modelStr} succeeded (${latencyMs}ms, ${fallbackCount} fallbacks)` - ); - recordComboRequest(combo.name, modelStr, { - success: true, - latencyMs, - fallbackCount, - strategy, - target: toRecordedTarget(target), - }); - recordedAttempts++; - // Webhook fan-out: best-effort, never blocks the response stream. - notifyWebhookEvent("request.completed", { - combo: combo.name, - provider, - model: modelStr, - latencyMs, - fallbackCount, - }); - // Universal handoff: record model usage for session + // Trigger shared provider circuit breaker for 5xx errors and connection failures. + // If the next target in the combo is on the same provider, don't mark the provider + // as failed — different models on the same provider may still succeed. + // G-02: when fallbackResult.skipProviderBreaker is set (embedded service supervisor + // outage signalled via X-Omni-Fallback-Hint: connection_cooldown) apply connection + // cooldown only — do NOT trip the whole-provider breaker. + const nextTarget = orderedTargets[i + 1]; + const sameProviderNext = + typeof nextTarget?.provider === "string" && nextTarget.provider === provider; if ( - universalHandoffConfig.enabled && - relayOptions?.sessionId && - !(body as Record)?.[SKIP_UNIVERSAL_HANDOFF_FLAG] + !isStreamReadinessFailure && + isProviderFailureCode(result.status) && + !sameProviderNext && + !fallbackResult.skipProviderBreaker ) { - const prevModel = getLastSessionModel(relayOptions.sessionId, combo.name); - recordSessionModelUsage( - relayOptions.sessionId, - combo.name, - modelStr, - provider, - target.connectionId ?? undefined - ); - if (prevModel && prevModel !== modelStr) { - const handoffSourceMessages = - Array.isArray(body?.messages) && body.messages.length > 0 - ? body.messages - : Array.isArray(body?.input) - ? body.input - : []; - - maybeGenerateUniversalHandoff({ - sessionId: relayOptions.sessionId, - comboName: combo.name, - messages: handoffSourceMessages as MessageLike[], - prevModel, - currModel: modelStr, - universalConfig: universalHandoffConfig, - handleSingleModel: handleSingleModelWithTimeout, - }); - } - - recordSessionModelUsage( - relayOptions.sessionId, - combo.name, - modelStr, - provider, - target.connectionId ?? undefined - ); - } - // Context-relay intentionally splits responsibilities: - // combo.ts decides whether a successful turn should generate a handoff, - // while chat.ts injects the handoff after the real connectionId is resolved. - if ( - strategy === "context-relay" && - relayOptions?.sessionId && - relayConfig && - relayConfig.handoffProviders.includes(provider) && - provider === "codex" - ) { - const connectionId = getSessionConnection(relayOptions.sessionId); - if (connectionId) { - const quotaInfo = await fetchCodexQuota(connectionId).catch(() => null); - if (quotaInfo) { - const resetCandidates = [ - quotaInfo.windows?.session?.resetAt, - quotaInfo.windows?.weekly?.resetAt, - quotaInfo.resetAt, - ] - .filter((value): value is string => typeof value === "string" && value.length > 0) - .sort((a, b) => a.localeCompare(b)); - const handoffSourceMessages = - Array.isArray(body?.messages) && body.messages.length > 0 - ? body.messages - : Array.isArray(body?.input) - ? body.input - : []; - - maybeGenerateHandoff({ - sessionId: relayOptions.sessionId, - comboName: combo.name, - connectionId, - percentUsed: quotaInfo.percentUsed, - messages: handoffSourceMessages, - model: modelStr, - expiresAt: resetCandidates[0] || null, - config: relayConfig, - handleSingleModel: handleSingleModelWithTimeout, - }); - } - } + recordProviderFailure(provider, log, target.connectionId, profile); } - // Record last known good provider (LKGP) for this combo/model (#919) - if (provider) { - const connId = target.connectionId || undefined; - void (async () => { - try { - const { setLKGP } = await import("../../src/lib/localDb"); - await Promise.all([ - setLKGP(combo.name, target.executionKey, provider, connId), - setLKGP(combo.name, combo.id || combo.name, provider, connId), - ]); - } catch (err) { - log.warn("COMBO", "Failed to record Last Known Good Provider. This is non-fatal.", { - err, - }); - } - })(); + // Check if this is a transient error worth retrying on same model. + // A token-limit 429 is terminal for the client — never retry it. + const isTransient = + !isStreamReadinessFailure && + !isTokenLimitBreach && + [408, 429, 500, 502, 503, 504].includes(result.status); + if (retry < maxRetries && isTransient && !providerExhausted) { + continue; // Retry same model } - return { ok: true, response: quality.clonedResponse ?? result }; - } - - // Extract error info from response - let errorText = result.statusText || ""; - let errorBody: ComboErrorBody = null; - let retryAfter: ComboRetryAfter | null = null; - try { - const cloned = result.clone(); - try { - const text = await cloned.text(); - if (text) { - errorText = text.substring(0, 500); - errorBody = JSON.parse(text); - const parsedError = errorBody?.error; - errorText = - (typeof parsedError === "object" && parsedError?.message) || - (typeof parsedError === "string" ? parsedError : null) || - errorBody?.message || - errorText; - retryAfter = errorBody?.retryAfter || null; - } - } catch { - /* Clone parse failed */ - } - } catch { - /* Clone failed */ - } - - // Track earliest retryAfter - if ( - retryAfter && - (!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter)) - ) { - earliestRetryAfter = retryAfter; - } - - // Normalize error text - if (typeof errorText !== "string") { - try { - errorText = JSON.stringify(errorText); - } catch { - errorText = String(errorText); - } - } - - const isStreamReadinessFailure = - (result.status === 502 || result.status === 504) && - isStreamReadinessFailureErrorBody(errorBody); - - // FIX 5: a local per-API-key token-limit 429 must not cool shared accounts. - const isTokenLimitBreach = result.status === 429 && isTokenLimitBreachErrorBody(errorBody); - - // Fix #1681: Status 499 means client disconnected — stop combo loop immediately. - // There is no point trying fallback models when nobody is listening. - if (result.status === 499) { - log.info("COMBO", `Client disconnected (499) during ${modelStr} — stopping combo loop`); - recordComboRequest(combo.name, modelStr, { - success: false, - latencyMs: Date.now() - startTime, - fallbackCount, - strategy, - target: toRecordedTarget(target), - }); - recordedAttempts++; - // executeTarget must return the {ok,response} contract — a raw Response - // here makes the speculative loop's res.ok/res.response checks both miss, - // so the combo would wrongly fall through to the next model after a 499. - return { ok: false, response: result }; - } - - // Combo fallback is target-level orchestration: a non-ok target response is - // treated as local to that target and the combo continues to the next target. - // Error classification is retained only for retry/cooldown pacing; it must - // not decide whether fallback happens, including for generic 400 responses. - const rawError = errorBody?.error; - const structuredError = - rawError && typeof rawError === "object" - ? { - // Upstream JSON may carry a numeric `code`/`type` (e.g. {"code":40001}). - // Coerce to string if present instead of discarding, so downstream string - // ops (.toLowerCase, .startsWith) can run safely without type crashes. - code: - (rawError as Record).code !== undefined && - (rawError as Record).code !== null - ? String((rawError as Record).code) - : undefined, - type: - (rawError as Record).type !== undefined && - (rawError as Record).type !== null - ? String((rawError as Record).type) - : undefined, - } - : undefined; - const fallbackResult = checkFallbackError( - result.status, - errorText, - 0, - null, - provider, - result.headers, - profile, - structuredError - ); - const { cooldownMs } = fallbackResult; - - // #1731: If the entire provider quota is exhausted, mark it so subsequent - // same-provider targets are skipped immediately. API-key 429s still use - // the short resilience cooldown, but explicit quota text should stop the - // combo from trying another target for the same provider in this request. - const providerExhausted = - Boolean(provider && provider !== "unknown") && - (isProviderExhaustedReason(fallbackResult) || - classifyErrorText(errorText) === RateLimitReason.QUOTA_EXHAUSTED); - if (providerExhausted) { - exhaustedProviders.add(provider); - log.info( - "COMBO", - `Provider ${provider} quota exhausted — marking for skip on remaining targets (#1731)` - ); - } else if ( - result.status === 429 && - !isTokenLimitBreach && - provider && - provider !== "unknown" - ) { - transientRateLimitedProviders.add(provider); - } - - // #2101: Prevent infinite fallback loops with 400 Bad Request errors that indicate - // request-body-specific issues (context overflow, malformed request, model access denied). - // These errors are unlikely to be resolved by trying different target models since - // the same problematic request body would be sent to all targets. - if ( - result.status === 400 && - fallbackResult.shouldFallback && - (fallbackResult.reason === RateLimitReason.MODEL_CAPACITY || - errorText.toLowerCase().includes("context") || - errorText.toLowerCase().includes("malformed") || - errorText.toLowerCase().includes("invalid") || - errorText.toLowerCase().includes("bad request")) - ) { - log.warn( - "COMBO", - `400 Bad Request with body-specific error detected on ${modelStr} — skipping fallback to other targets to prevent infinite loop` - ); - // Record the failure and break to avoid trying other targets with the same bad request + // Done retrying this model recordComboRequest(combo.name, modelStr, { success: false, latencyMs: Date.now() - startTime, @@ -3826,182 +3954,143 @@ export async function handleComboChat({ lastError = errorText || String(result.status); if (!lastStatus) lastStatus = result.status; if (i > 0) fallbackCount++; - log.warn("COMBO", `Model ${modelStr} failed with body-specific error, stopping combo`); - break; // Break out of the target loop to avoid trying other models - } + log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status }); - // Trigger shared provider circuit breaker for 5xx errors and connection failures. - // If the next target in the combo is on the same provider, don't mark the provider - // as failed — different models on the same provider may still succeed. - // G-02: when fallbackResult.skipProviderBreaker is set (embedded service supervisor - // outage signalled via X-Omni-Fallback-Hint: connection_cooldown) apply connection - // cooldown only — do NOT trip the whole-provider breaker. - const nextTarget = orderedTargets[i + 1]; - const sameProviderNext = - typeof nextTarget?.provider === "string" && nextTarget.provider === provider; - if ( - !isStreamReadinessFailure && - isProviderFailureCode(result.status) && - !sameProviderNext && - !fallbackResult.skipProviderBreaker - ) { - recordProviderFailure(provider, log, target.connectionId, profile); - } - - // Check if this is a transient error worth retrying on same model. - // A token-limit 429 is terminal for the client — never retry it. - const isTransient = - !isStreamReadinessFailure && - !isTokenLimitBreach && - [408, 429, 500, 502, 503, 504].includes(result.status); - if (retry < maxRetries && isTransient && !providerExhausted) { - continue; // Retry same model - } - - // Done retrying this model - recordComboRequest(combo.name, modelStr, { - success: false, - latencyMs: Date.now() - startTime, - fallbackCount, - strategy, - target: toRecordedTarget(target), - }); - recordedAttempts++; - lastError = errorText || String(result.status); - if (!lastStatus) lastStatus = result.status; - if (i > 0) fallbackCount++; - log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status }); - - const fallbackWaitMs = - fallbackDelayMs > 0 && cooldownMs > 0 && cooldownMs <= MAX_FALLBACK_WAIT_MS - ? Math.min(cooldownMs, fallbackDelayMs) - : 0; - if ([502, 503, 504].includes(result.status) && fallbackWaitMs > 0) { - log.debug?.("COMBO", `Waiting ${fallbackWaitMs}ms before fallback to next model`); - await new Promise((resolve) => { - const timer = setTimeout(resolve, fallbackWaitMs); - signal?.addEventListener( - "abort", - () => { - clearTimeout(timer); - resolve(undefined); - }, - { once: true } - ); - }); - if (signal?.aborted) { - log.info("COMBO", `Client disconnected during fallback wait — aborting`); - return { ok: false, response: errorResponse(499, "Client disconnected") }; - } - } - - return null; - } - return null; - }; - - for (let i = 0; i < orderedTargets.length; i++) { - if (anySuccess) break; - - const abortController = new AbortController(); - abortControllers.set(i, abortController); - const onClientAbort = () => abortController.abort(); - signal?.addEventListener("abort", onClientAbort); - - const task = (async () => { - try { - const res = await executeTarget(i); - if (res && !anySuccess) { - if (res.ok) { - anySuccess = true; - globalResolve!(res.response!); - for (const [idx, ac] of abortControllers.entries()) { - if (idx !== i) ac.abort(); - } - } else if (res.response) { - // Fatal error, abort combo - anySuccess = true; - globalResolve!(res.response); + const fallbackWaitMs = + fallbackDelayMs > 0 && cooldownMs > 0 && cooldownMs <= MAX_FALLBACK_WAIT_MS + ? Math.min(cooldownMs, fallbackDelayMs) + : 0; + if ([502, 503, 504].includes(result.status) && fallbackWaitMs > 0) { + log.debug?.("COMBO", `Waiting ${fallbackWaitMs}ms before fallback to next model`); + await new Promise((resolve) => { + const timer = setTimeout(resolve, fallbackWaitMs); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(undefined); + }, + { once: true } + ); + }); + if (signal?.aborted) { + log.info("COMBO", `Client disconnected during fallback wait — aborting`); + return { ok: false, response: errorResponse(499, "Client disconnected") }; } } - } finally { - signal?.removeEventListener("abort", onClientAbort); + + return null; } - })().catch((err) => { - const logError = log.error ?? log.warn; - logError("COMBO", `Speculative task error for target ${i}`, err); - }); + return null; + }; - runningTasks.add(task); - task.finally(() => runningTasks.delete(task)); + for (let i = 0; i < orderedTargets.length; i++) { + if (anySuccess) break; - if (zeroLatencyOptimizationsEnabled && config.hedging && i + 1 < orderedTargets.length) { - const hedgeDelay = resolveDelayMs(config.hedgeDelayMs, 500); - let timeoutResolve: () => void; - const timeoutPromise = new Promise((r) => { - timeoutResolve = r; - setTimeout(r, hedgeDelay); + const abortController = new AbortController(); + abortControllers.set(i, abortController); + const onClientAbort = () => abortController.abort(); + signal?.addEventListener("abort", onClientAbort); + + const task = (async () => { + try { + const res = await executeTarget(i); + if (res && !anySuccess) { + if (res.ok) { + anySuccess = true; + globalResolve!(res.response!); + for (const [idx, ac] of abortControllers.entries()) { + if (idx !== i) ac.abort(); + } + } else if (res.response) { + // Fatal error, abort combo + anySuccess = true; + globalResolve!(res.response); + } + } + } finally { + signal?.removeEventListener("abort", onClientAbort); + } + })().catch((err) => { + const logError = log.error ?? log.warn; + logError("COMBO", `Speculative task error for target ${i}`, err); }); - await Promise.race([task, globalPromise, timeoutPromise]); - } else { - await Promise.race([task, globalPromise]); + + runningTasks.add(task); + task.finally(() => runningTasks.delete(task)); + + if (zeroLatencyOptimizationsEnabled && config.hedging && i + 1 < orderedTargets.length) { + const hedgeDelay = resolveDelayMs(config.hedgeDelayMs, 500); + let timeoutResolve: () => void; + const timeoutPromise = new Promise((r) => { + timeoutResolve = r; + setTimeout(r, hedgeDelay); + }); + await Promise.race([task, globalPromise, timeoutPromise]); + } else { + await Promise.race([task, globalPromise]); + } } - } - if (!anySuccess && runningTasks.size > 0) { - await Promise.race([globalPromise, Promise.all([...runningTasks])]); - } + if (!anySuccess && runningTasks.size > 0) { + await Promise.race([globalPromise, Promise.all([...runningTasks])]); + } - if (anySuccess) { - return await globalPromise; - } + if (anySuccess) { + return await globalPromise; + } - // All models failed in this set try - const latencyMs = Date.now() - startTime; - if (recordedAttempts === 0) { - recordComboRequest(combo.name, null, { success: false, latencyMs, fallbackCount, strategy }); - } + // All models failed in this set try + const latencyMs = Date.now() - startTime; + if (recordedAttempts === 0) { + recordComboRequest(combo.name, null, { + success: false, + latencyMs, + fallbackCount, + strategy, + }); + } - // Retry the entire set if more attempts remain - if (setTry < maxSetRetries) continue; + // Retry the entire set if more attempts remain + if (setTry < maxSetRetries) continue; - // All set retries exhausted — return the final error - if (!lastStatus) { - notifyWebhookEvent("request.failed", { - combo: combo.name, - reason: "ALL_ACCOUNTS_INACTIVE", - latencyMs, - fallbackCount, + // All set retries exhausted — return the final error + if (!lastStatus) { + notifyWebhookEvent("request.failed", { + combo: combo.name, + reason: "ALL_ACCOUNTS_INACTIVE", + latencyMs, + fallbackCount, + }); + return new Response( + JSON.stringify({ + error: { + message: "Service temporarily unavailable: all upstream accounts are inactive", + type: "service_unavailable", + code: "ALL_ACCOUNTS_INACTIVE", + }, + }), + { status: 503, headers: { "Content-Type": "application/json" } } + ); + } + + const status = lastStatus; + const msg = lastError || "All combo models unavailable"; + + if (earliestRetryAfter) { + const retryHuman = formatRetryAfter(toRetryAfterDisplayValue(earliestRetryAfter)); + log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`); + return unavailableResponse(status, msg, earliestRetryAfter, retryHuman); + } + + log.warn("COMBO", `All models failed | ${msg}`); + return new Response(JSON.stringify({ error: { message: msg } }), { + status, + headers: { "Content-Type": "application/json" }, }); - return new Response( - JSON.stringify({ - error: { - message: "Service temporarily unavailable: all upstream accounts are inactive", - type: "service_unavailable", - code: "ALL_ACCOUNTS_INACTIVE", - }, - }), - { status: 503, headers: { "Content-Type": "application/json" } } - ); } - const status = lastStatus; - const msg = lastError || "All combo models unavailable"; - - if (earliestRetryAfter) { - const retryHuman = formatRetryAfter(toRetryAfterDisplayValue(earliestRetryAfter)); - log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`); - return unavailableResponse(status, msg, earliestRetryAfter, retryHuman); - } - - log.warn("COMBO", `All models failed | ${msg}`); - return new Response(JSON.stringify({ error: { message: msg } }), { - status, - headers: { "Content-Type": "application/json" }, - }); - } - - return errorResponse(503, "Combo routing completed without an upstream response"); + return errorResponse(503, "Combo routing completed without an upstream response"); } finally { // G2: Clean up candidate registry to prevent unbounded memory growth. _unregisterExecutionCandidates(_registeredExecutionKeys); @@ -4105,7 +4194,10 @@ async function handleRoundRobinCombo({ if (isModelAvailable) { const available = await isModelAvailable(modelStr, targetForAttempt); if (!available) { - log.debug?.("COMBO-RR", `Skipping ${modelStr} — no credentials available or model excluded`); + log.debug?.( + "COMBO-RR", + `Skipping ${modelStr} — no credentials available or model excluded` + ); if (offset > 0) fallbackCount++; continue; } @@ -4346,7 +4438,10 @@ async function handleRoundRobinCombo({ isAllAccountsRateLimited); if (providerExhausted) { exhaustedProviders.add(provider); - log.debug?.("COMBO-RR", `Provider ${provider} quota exhausted — marking for skip (#1731)`); + log.debug?.( + "COMBO-RR", + `Provider ${provider} quota exhausted — marking for skip (#1731)` + ); } else if ( result.status === 429 && !isTokenLimitBreach && diff --git a/open-sse/services/inAppLoginService.ts b/open-sse/services/inAppLoginService.ts new file mode 100644 index 0000000000..0a71b99929 --- /dev/null +++ b/open-sse/services/inAppLoginService.ts @@ -0,0 +1,256 @@ +/** + * InAppLoginService — Playwright-based web login for cookie providers + * + * Opens a Playwright browser context, navigates to the provider's login page, + * and polls for target cookies/tokens after the user completes login. + * + * Used as the dashboard/web fallback path when Electron is not available. + * For Electron-native login, see electron/loginManager.js. + * + * Events: + * "status" — { providerId: string, status: string, message: string } + * status values: starting, navigating, waiting, polling, complete, error, cancelled + */ + +import { EventEmitter } from "events"; +import { TOKEN_EXTRACTION_CONFIGS, TokenExtractionConfig, type TokenSource } from "./tokenExtractionConfig"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface LoginResult { + success: boolean; + credentials?: Record; + error?: string; +} + +interface ActiveLogin { + providerId: string; + aborted: boolean; +} + +// ─── Service ──────────────────────────────────────────────────────────────── + +export class InAppLoginService extends EventEmitter { + private activeLogin: ActiveLogin | null = null; + + /** + * Start a login flow for a web-cookie provider using Playwright. + * @param providerId - e.g. "claude-web", "chatgpt-web" + * @param options.timeout - Total timeout in ms (default: config value or 300s) + */ + async startLogin(providerId: string, options?: { timeout?: number }): Promise { + const config = TOKEN_EXTRACTION_CONFIGS.get(providerId); + if (!config) { + this.emit("status", { providerId, status: "error", message: "No extraction config found" }); + return { success: false, error: `No extraction config for provider: ${providerId}` }; + } + + if (this.activeLogin) { + this.emit("status", { providerId, status: "error", message: "A login is already in progress" }); + return { success: false, error: "A login process is already in progress" }; + } + + this.activeLogin = { providerId, aborted: false }; + this.emit("status", { providerId, status: "starting", message: `Opening ${config.displayName} login...` }); + + try { + const result = await this.runBrowserLogin(config, options?.timeout); + this.emit("status", { + providerId, + status: result.success ? "complete" : "error", + message: result.success ? "Credentials extracted successfully" : (result.error || "Login failed"), + }); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.emit("status", { providerId, status: "error", message }); + return { success: false, error: `Login failed: ${message}` }; + } finally { + this.activeLogin = null; + } + } + + /** + * Run the actual Playwright browser login flow + */ + private async runBrowserLogin( + config: TokenExtractionConfig, + timeout?: number + ): Promise { + const pollInterval = config.pollingConfig.pollInterval || 1000; + const maxTimeout = timeout || config.pollingConfig.timeout || 300_000; + const minLoginTime = config.pollingConfig.minLoginTime || 5000; + const providerId = config.providerId; + + // Dynamically import Playwright (it's a heavy dep, only load when needed) + let playwright: any; + try { + playwright = await import("playwright"); + } catch { + return { success: false, error: "Playwright is not installed. Use Electron for native login." }; + } + + if (this.activeLogin?.aborted) { + return { success: false, error: "Login cancelled" }; + } + + // Launch browser + this.emit("status", { providerId, status: "starting", message: "Launching browser..." }); + const browser = await playwright.chromium.launch({ + headless: false, // User must interact with the login page + }); + + try { + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + locale: "en-US", + }); + const page = await context.newPage(); + + // Navigate to login URL + this.emit("status", { providerId, status: "navigating", message: `Loading ${config.loginUrl}` }); + await page.goto(config.loginUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); + + // Poll for success URL + token extraction + const maxPolls = Math.floor(maxTimeout / pollInterval); + const credentials: Record = {}; + const startTime = Date.now(); + + for (let i = 0; i < maxPolls; i++) { + if (this.activeLogin?.aborted) { + this.emit("status", { providerId, status: "cancelled", message: "Login cancelled by user" }); + return { success: false, error: "Login cancelled" }; + } + + // Emit progress every 30 seconds + if (i > 0 && i % 30 === 0) { + this.emit("status", { + providerId, + status: "waiting", + message: `Waiting for login... (${Math.round(i / 60)}m)`, + }); + } + + // Wait before polling (respect minLoginTime on first iteration) + if (Date.now() - startTime < minLoginTime) { + await sleep(pollInterval); + continue; + } + + // Gather cookies from browser context + const cookies = await context.cookies(); + const tokenSources = config.tokenSources; + + // Check cookie-based sources + for (const source of tokenSources) { + if (source.type === "cookie") { + const domain = source.domain || undefined; + const matched = cookies.find( + (c: any) => + c.name === source.name && + (!domain || c.domain.includes(domain.replace(/^\./, ""))) + ); + if (matched && !credentials[source.name]) { + credentials[source.name] = matched.value; + } + } + } + + // Check localStorage-based tokens + for (const source of tokenSources) { + if (source.type === "localStorage" && !credentials[source.key]) { + try { + const value = await page.evaluate((key: string) => localStorage.getItem(key), source.key); + if (value && typeof value === "string") { + credentials[source.key] = value; + } + } catch { + // localStorage access may fail on some domains + } + } + if (source.type === "sessionStorage" && !credentials[source.key]) { + try { + const value = await page.evaluate((key: string) => sessionStorage.getItem(key), source.key); + if (value && typeof value === "string") { + credentials[source.key] = value; + } + } catch { + // sessionStorage access may fail on some domains + } + } + } + + // Check if all required tokens are found + const requiredKeys = tokenSources.map((s) => + s.type === "cookie" ? s.name : s.type === "localStorage" || s.type === "sessionStorage" ? s.key : s.name + ); + const allFound = requiredKeys.every((k) => credentials[k] !== undefined); + + if (allFound && Object.keys(credentials).length > 0) { + return { success: true, credentials }; + } + + // Check for success URL pattern + if (config.successUrlPattern) { + try { + const currentUrl = page.url(); + if (config.successUrlPattern.test(currentUrl) && Object.keys(credentials).length > 0) { + return { success: true, credentials }; + } + } catch { + // URL access may fail on some pages + } + } + + await sleep(pollInterval); + } + + return { success: false, error: "Login timed out" }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.emit("status", { providerId, status: "error", message }); + return { success: false, error: `Login failed: ${message}` }; + } finally { + await browser.close().catch(() => {}); + } + } + + /** + * Cancel the current login flow + */ + cancel(): void { + if (this.activeLogin) { + this.emit("status", { + providerId: this.activeLogin.providerId, + status: "cancelled", + message: "Login cancelled by user", + }); + this.activeLogin.aborted = true; + this.activeLogin = null; + } + } + + /** + * Get the active provider ID, if any + */ + getActiveProvider(): string | null { + return this.activeLogin?.providerId || null; + } + + /** + * Check if a login flow is in progress + */ + isActive(): boolean { + return this.activeLogin !== null && !this.activeLogin.aborted; + } +} + +// ─── Sleep helper ─────────────────────────────────────────────────────────── + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ─── Singleton ────────────────────────────────────────────────────────────── + +export const inAppLoginService = new InAppLoginService(); diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index c5a4a181ae..f967b3fd97 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -73,6 +73,10 @@ const limiters = new Map(); // Store connections that have rate limit protection enabled const enabledConnections = new Set(); +// Store per-connection rate limit overrides (RPM, TPM, TPD, minTime, maxConcurrent) +// Populated from provider_connections.rateLimitOverrides on startup and refresh. +const connectionRateLimitOverrides = new Map>(); + // Store learned limits for persistence (debounced) const learnedLimits: Record = {}; const MAX_LEARNED_LIMITS = 200; @@ -115,12 +119,40 @@ function isAutoEnableActive(settings: RequestQueueSettings): boolean { return settings.autoEnableApiKeyProviders; } +// Sentinels for "no rate limit" / effectively infinite capacity. The reservoir +// value uses Number.MAX_SAFE_INTEGER so the bucket can never realistically be +// exhausted; maxConcurrent uses a smaller-but-still-vast ceiling since +// Bottleneck tracks concurrent jobs in memory and an unbounded number would +// risk internal counter overflow under sustained pressure. +const EFFECTIVELY_INFINITE = Number.MAX_SAFE_INTEGER; +const EFFECTIVELY_INFINITE_CONCURRENCY = 1000; + +// Resolve an RPM override. 0 or missing means "infinite" (no rate cap). +function resolveRpm(override: number | undefined | null): number { + return typeof override === "number" && override > 0 ? override : EFFECTIVELY_INFINITE; +} + +// Resolve a minTime override. 0 or missing means "no minimum gap". +function resolveMinTime(override: number | undefined | null): number { + return typeof override === "number" && override > 0 ? override : 0; +} + +// Resolve a maxConcurrent override. 0 or missing means "effectively infinite". +function resolveMaxConcurrent(override: number | undefined | null): number { + return typeof override === "number" && override > 0 + ? override + : EFFECTIVELY_INFINITE_CONCURRENCY; +} + function buildLimiterDefaults() { + // 0 or missing values mean "infinite" / no rate limit applies. This treats + // the global request-queue settings the same way per-connection overrides + // are interpreted (see resolveRpm / resolveMinTime / resolveMaxConcurrent). return { - maxConcurrent: currentRequestQueueSettings.concurrentRequests, - minTime: currentRequestQueueSettings.minTimeBetweenRequestsMs, - reservoir: currentRequestQueueSettings.requestsPerMinute, - reservoirRefreshAmount: currentRequestQueueSettings.requestsPerMinute, + maxConcurrent: resolveMaxConcurrent(currentRequestQueueSettings.concurrentRequests), + minTime: resolveMinTime(currentRequestQueueSettings.minTimeBetweenRequestsMs), + reservoir: resolveRpm(currentRequestQueueSettings.requestsPerMinute), + reservoirRefreshAmount: resolveRpm(currentRequestQueueSettings.requestsPerMinute), reservoirRefreshInterval: 60 * 1000, }; } @@ -316,6 +348,15 @@ export async function initializeRateLimits() { ); updateAllLimiterSettings(); + // Load per-connection rate limit overrides + connectionRateLimitOverrides.clear(); + for (const conn of connections as Array>) { + const overrides = conn.rateLimitOverrides; + if (overrides && typeof overrides === "object" && !Array.isArray(overrides)) { + connectionRateLimitOverrides.set(String(conn.id), overrides as Record); + } + } + if (explicitCount > 0 || autoCount > 0) { logRateLimit( `🛡️ [RATE-LIMIT] Loaded ${explicitCount} explicit + ${autoCount} auto-enabled protection(s)` @@ -342,7 +383,7 @@ export async function applyRequestQueueSettings(nextSettings: RequestQueueSettin } /** - * Enable rate limit protection for a connection + * Get or create a limiter for a given provider+connection combination */ export function enableRateLimitProtection(connectionId) { enabledConnections.add(connectionId); @@ -378,6 +419,33 @@ export function isRateLimitEnabled(connectionId) { return enabledConnections.has(connectionId); } +/** + * Refresh per-connection rate limit overrides. + * + * Called after a PATCH update to `rateLimitOverrides` on a provider connection. + * Updates the in-memory map and evicts existing Bottleneck limiters for the + * connection so the next request gets a fresh limiter with the new settings. + * + * @param {string} connectionId + * @param {Record | null} overrides - New overrides (null/undefined clears) + */ +export function refreshConnectionRateLimits(connectionId, overrides) { + if (overrides === null || overrides === undefined) { + connectionRateLimitOverrides.delete(connectionId); + } else { + connectionRateLimitOverrides.set(connectionId, overrides); + } + // Evict limiters referencing this connection so they get recreated on next use + for (const [key, limiter] of Array.from(limiters)) { + if (key.includes(connectionId)) { + limiters.delete(key); + lastDispatchAt.delete(key); + limiterLastUsed.delete(key); + trackAsyncOperation(limiter.disconnect()); + } + } +} + /** * Get or create a limiter for a given provider+connection combination */ @@ -397,19 +465,32 @@ function getLimiter(provider, connectionId, model = null) { const key = getLimiterKey(provider, connectionId, model); if (!limiters.has(key)) { - const limiter = new Bottleneck({ - ...buildLimiterDefaults(), - id: key, - }); - - // Log when jobs are queued - limiter.on("queued", () => { - const counts = limiter.counts(); - if (counts.QUEUED > 0) { - logRateLimit( - `⏳ [RATE-LIMIT] ${key} — ${counts.QUEUED} request(s) queued, ${counts.RUNNING} running` - ); + const defaults = buildLimiterDefaults(); + const overrides = connectionRateLimitOverrides.get(connectionId); + if (overrides) { + // 0 (or missing) means "no override — fall through to buildLimiterDefaults()". + // Without this guard, an rpm of 0 sets reservoir=0, which Bottleneck treats + // as "depleted" and blocks ALL requests indefinitely. Treating 0 as "use + // default" lets users effectively disable per-connection limits without + // globally raising the system default. + if (typeof overrides.maxConcurrent === "number" && overrides.maxConcurrent > 0) { + defaults.maxConcurrent = overrides.maxConcurrent; } + if (typeof overrides.minTime === "number" && overrides.minTime > 0) { + defaults.minTime = overrides.minTime; + } + if (typeof overrides.rpm === "number" && overrides.rpm > 0) { + defaults.reservoir = overrides.rpm; + defaults.reservoirRefreshAmount = overrides.rpm; + defaults.reservoirRefreshInterval = 60 * 1000; + } + // TODO: TPM/TPD integration — requires a token-bucket vs request-bucket + // separation (Bottleneck's reservoir is request-count, not token-count). + // When added, treat 0/missing the same way: fall through to system default. + } + const limiter = new Bottleneck({ + ...defaults, + id: key, }); // Heartbeat: timestamp every dispatch so the watchdog can tell a healthy // queue (just dispatched a job) from a wedged one (queue has work but diff --git a/open-sse/services/tokenExtractionConfig.ts b/open-sse/services/tokenExtractionConfig.ts new file mode 100644 index 0000000000..d9f6f0a86c --- /dev/null +++ b/open-sse/services/tokenExtractionConfig.ts @@ -0,0 +1,411 @@ +/** + * TokenExtractionConfig — Login & cookie extraction configs for web-cookie providers + * + * Each config describes how to: + * 1. Open a browser window/navigate to the provider's login page + * 2. Detect successful login (URL change + token presence) + * 3. Extract session cookies / tokens from the browser context + * + * Used by InAppLoginService (Electron BrowserWindow path) and + * the Playwright-based login flow (dashboard API). + */ + +// ─── Types ────────────────────────────────────────────────────────────────── + +/** Describes where to extract credential data from after login */ +export type TokenSource = + | { type: "cookie"; name: string; domain?: string } + | { type: "localStorage"; key: string } + | { type: "sessionStorage"; key: string } + | { type: "header"; name: string }; + +export interface PollingConfig { + /** Milliseconds between extraction polls (default 1000) */ + pollInterval: number; + /** Total timeout in ms (default 300000 = 5 min) */ + timeout: number; + /** Minimum time in ms before first extraction attempt (default 5000) */ + minLoginTime: number; +} + +export interface TokenExtractionConfig { + /** Matches the executor's provider ID (e.g. "claude-web", "gemini-web") */ + providerId: string; + /** Human-readable name shown in dashboard UI */ + displayName: string; + /** The URL to navigate to for login */ + loginUrl: string; + /** The provider's home page URL (for cookie domain binding) */ + homeUrl: string; + /** Optional regex. If current URL matches → login is likely complete */ + successUrlPattern?: RegExp; + /** Sources to extract credentials from after login */ + tokenSources: TokenSource[]; + /** Polling behaviour */ + pollingConfig: PollingConfig; + /** Short instructions shown to the user in the login modal */ + instructions: string; + /** Optional: cookie domain override for cookie injection */ + cookieDomain?: string; +} + +// ─── Defaults ─────────────────────────────────────────────────────────────── + +const DEFAULT_POLLING: PollingConfig = { + pollInterval: 1000, + timeout: 300_000, + minLoginTime: 5000, +}; + +const QUICK_POLLING: PollingConfig = { + pollInterval: 800, + timeout: 120_000, + minLoginTime: 3000, +}; + +// ─── Helper ────────────────────────────────────────────────────────────────── + +function config( + providerId: string, + displayName: string, + loginUrl: string, + homeUrl: string, + tokenSources: TokenSource[], + instructions: string, + opts?: { + successUrlPattern?: RegExp; + pollingConfig?: Partial; + cookieDomain?: string; + } +): TokenExtractionConfig { + return { + providerId, + displayName, + loginUrl, + homeUrl, + tokenSources, + instructions, + pollingConfig: { ...DEFAULT_POLLING, ...opts?.pollingConfig }, + successUrlPattern: opts?.successUrlPattern, + cookieDomain: opts?.cookieDomain, + }; +} + +// ─── Configuration Map ────────────────────────────────────────────────────── + +const RAW_CONFIGS: TokenExtractionConfig[] = [ + // ── Claude Web ──────────────────────────────────────────── + config( + "claude-web", + "Claude Web", + "https://claude.ai/login", + "https://claude.ai", + [{ type: "cookie", name: "sessionKey", domain: ".claude.ai" }], + "Log in to your Claude account at claude.ai. After login, the session cookie will be extracted automatically." + ), + + // ── ChatGPT Web ─────────────────────────────────────────── + config( + "chatgpt-web", + "ChatGPT Web", + "https://chatgpt.com/auth/login", + "https://chatgpt.com", + [ + { type: "cookie", name: "__Secure-next-auth.session-token", domain: ".chatgpt.com" }, + ], + "Log in to ChatGPT. The __Secure-next-auth.session-token cookie will be extracted after login." + ), + + // ── Gemini Web ──────────────────────────────────────────── + config( + "gemini-web", + "Gemini Web", + "https://gemini.google.com/app", + "https://gemini.google.com", + [ + { type: "cookie", name: "__Secure-1PSID", domain: ".google.com" }, + { type: "cookie", name: "__Secure-1PSIDTS", domain: ".google.com" }, + ], + "Log in to your Google account at gemini.google.com. Both __Secure-1PSID and __Secure-1PSIDTS cookies will be extracted.", + { cookieDomain: ".google.com" } + ), + + // ── Grok Web ────────────────────────────────────────────── + config( + "grok-web", + "Grok Web", + "https://grok.com/login", + "https://grok.com", + [{ type: "cookie", name: "sso", domain: ".grok.com" }], + "Log in to your xAI account at grok.com. The sso session cookie will be extracted." + ), + + // ── Perplexity Web ──────────────────────────────────────── + config( + "perplexity-web", + "Perplexity Web", + "https://www.perplexity.ai/login", + "https://www.perplexity.ai", + [ + { type: "cookie", name: "__Secure-next-auth.session-token", domain: ".perplexity.ai" }, + ], + "Log in to Perplexity. The __Secure-next-auth.session-token cookie will be extracted.", + { cookieDomain: ".perplexity.ai" } + ), + + // ── DeepSeek Web ────────────────────────────────────────── + config( + "deepseek-web", + "DeepSeek Web", + "https://chat.deepseek.com/sign_in", + "https://chat.deepseek.com", + [ + { type: "cookie", name: "user-token", domain: ".deepseek.com" }, + { type: "localStorage", key: "userToken" }, + ], + "Log in to DeepSeek at chat.deepseek.com. The user-token cookie will be extracted.", + { cookieDomain: ".deepseek.com" } + ), + + // ── Qwen Web ────────────────────────────────────────────── + config( + "qwen-web", + "Qwen Web (Tongyi)", + "https://chat.qwen.ai/", + "https://chat.qwen.ai", + [ + { type: "cookie", name: "XSRF_TOKEN", domain: ".chat.qwen.ai" }, + { type: "localStorage", key: "token" }, + ], + "Log in to Qwen at chat.qwen.ai using your Alibaba account. The session token will be extracted.", + { cookieDomain: ".chat.qwen.ai" } + ), + + // ── Kimi Web ────────────────────────────────────────────── + config( + "kimi-web", + "Kimi (Moonshot)", + "https://kimi.moonshot.cn/", + "https://kimi.moonshot.cn", + [ + { type: "cookie", name: "kimi_token", domain: ".kimi.moonshot.cn" }, + { type: "localStorage", key: "kimi_token" }, + ], + "Log in to Kimi at kimi.moonshot.cn via phone/WeChat. The session token will be extracted.", + { cookieDomain: ".kimi.moonshot.cn" } + ), + + // ── Blackbox Web ────────────────────────────────────────── + config( + "blackbox-web", + "Blackbox AI", + "https://app.blackbox.ai/login", + "https://app.blackbox.ai", + [ + { type: "cookie", name: "connect.sid", domain: ".blackbox.ai" }, + { type: "localStorage", key: "token" }, + ], + "Log in to Blackbox AI at app.blackbox.ai using Google/GitHub. The session cookie will be extracted.", + { cookieDomain: ".blackbox.ai" } + ), + + // ── Poe Web ─────────────────────────────────────────────── + config( + "poe-web", + "Poe (Quora)", + "https://poe.com/login", + "https://poe.com", + [ + { type: "cookie", name: "p-b", domain: ".poe.com" }, + ], + "Log in to Poe at poe.com. The session cookie will be extracted.", + { cookieDomain: ".poe.com" } + ), + + // ── Copilot Web ─────────────────────────────────────────── + config( + "copilot-web", + "Microsoft Copilot", + "https://copilot.microsoft.com/", + "https://copilot.microsoft.com", + [ + { type: "cookie", name: "RPSCAuth", domain: ".microsoft.com" }, + ], + "Log in with your Microsoft account at copilot.microsoft.com. The session auth cookie will be extracted.", + { cookieDomain: ".microsoft.com" } + ), + + // ── DuckDuckGo Web ──────────────────────────────────────── + config( + "duckduckgo-web", + "DuckDuckGo AI Chat", + "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=1", + "https://duckduckgo.com", + [ + { type: "cookie", name: "duckai", domain: ".duckduckgo.com" }, + ], + "Open DuckDuckGo AI Chat. Some models may require a free account. The duckai cookie will be extracted.", + { + cookieDomain: ".duckduckgo.com", + pollingConfig: QUICK_POLLING, + } + ), + + // ── DouBao Web ──────────────────────────────────────────── + config( + "doubao-web", + "DouBao (ByteDance)", + "https://www.doubao.com/", + "https://www.doubao.com", + [ + { type: "cookie", name: "sessionid", domain: ".doubao.com" }, + ], + "Log in to DouBao at doubao.com with your ByteDance account. The sessionid will be extracted.", + { cookieDomain: ".doubao.com" } + ), + + // ── T3 Chat Web ─────────────────────────────────────────── + config( + "t3-chat-web", + "T3 Chat", + "https://t3.chat/login", + "https://t3.chat", + [ + { type: "localStorage", key: "token" }, + ], + "Log in to T3 Chat at t3.chat using Google/GitHub. The token from localStorage will be extracted.", + { pollingConfig: QUICK_POLLING } + ), + + // ── Venice Web ──────────────────────────────────────────── + config( + "venice-web", + "Venice AI", + "https://venice.ai/login", + "https://venice.ai", + [ + { type: "cookie", name: "venice_session", domain: ".venice.ai" }, + { type: "localStorage", key: "token" }, + ], + "Log in to Venice AI at venice.ai. The session cookie will be extracted.", + { cookieDomain: ".venice.ai" } + ), + + // ── v0 Dev Web ──────────────────────────────────────────── + config( + "v0-vercel-web", + "v0 by Vercel", + "https://v0.dev/login", + "https://v0.dev", + [ + { type: "cookie", name: "__Secure-next-auth.session-token", domain: ".v0.dev" }, + ], + "Log in to v0.dev with your Vercel/Google/GitHub account. The session cookie will be extracted.", + { cookieDomain: ".v0.dev" } + ), + + // ── Muse / Spark Web ────────────────────────────────────── + config( + "muse-spark-web", + "Meta AI (Muse)", + "https://www.meta.ai/", + "https://www.meta.ai", + [ + { type: "cookie", name: "session", domain: ".meta.ai" }, + ], + "Log in to Meta AI at meta.ai with your Facebook/Instagram account. The session cookie will be extracted.", + { cookieDomain: ".meta.ai" } + ), + + // ── Adapta Web ──────────────────────────────────────────── + config( + "adapta-web", + "Adapta AI", + "https://agent.adapta.one/login", + "https://agent.adapta.one", + [ + { type: "cookie", name: "__session", domain: ".adapta.one" }, + ], + "Log in to Adapta at agent.adapta.one. The session token will be extracted.", + { cookieDomain: ".adapta.one" } + ), + + // ── VeoAI Free Web ──────────────────────────────────────── + config( + "veoaifree-web", + "VeoAI Free", + "https://veoaifree.com/", + "https://veoaifree.com", + [ + { type: "cookie", name: "wordpress_logged_in", domain: ".veoaifree.com" }, + ], + "Log in to VeoAI Free at veoaifree.com. The WordPress session cookie will be extracted.", + { + cookieDomain: ".veoaifree.com", + pollingConfig: QUICK_POLLING, + } + ), + + // ── Missing Provider: ChatGLM (Zhipu) ────────────────────── + config( + "chatglm-web", + "ChatGLM (Zhipu AI)", + "https://chatglm.cn/", + "https://chatglm.cn", + [ + { type: "cookie", name: "chatglm_session", domain: ".chatglm.cn" }, + { type: "localStorage", key: "token" }, + ], + "Log in to ChatGLM at chatglm.cn with your phone number. The session token will be extracted.", + { cookieDomain: ".chatglm.cn" } + ), + + // ── Missing Provider: Xiaomi MiMo ────────────────────────── + config( + "xiaomimimo-web", + "Xiaomi MiMo AI Studio", + "https://aistudio.xiaomimimo.com/login", + "https://aistudio.xiaomimimo.com", + [ + { type: "cookie", name: "session", domain: ".xiaomimimo.com" }, + { type: "localStorage", key: "access_token" }, + ], + "Log in to Xiaomi MiMo AI Studio at aistudio.xiaomimimo.com. The session token will be extracted.", + { cookieDomain: ".xiaomimimo.com" } + ), + + // ── Missing Provider: Manus ──────────────────────────────── + config( + "manus-web", + "Manus AI", + "https://manus.im/login", + "https://manus.im", + [ + { type: "cookie", name: "manus_session", domain: ".manus.im" }, + { type: "localStorage", key: "auth_token" }, + ], + "Log in to Manus at manus.im. The session cookie will be extracted.", + { cookieDomain: ".manus.im" } + ), +]; + +// ─── Registry ─────────────────────────────────────────────────────────────── + +const CONFIG_MAP = new Map(); + +for (const cfg of RAW_CONFIGS) { + CONFIG_MAP.set(cfg.providerId, cfg); +} + +/** Get extraction config for a specific provider */ +export function getExtractionConfig(providerId: string): TokenExtractionConfig | undefined { + return CONFIG_MAP.get(providerId); +} + +/** List all registered extraction configs */ +export function listExtractionConfigs(): TokenExtractionConfig[] { + return [...RAW_CONFIGS]; +} + +/** The shared config map — used by LoginManager and InAppLoginService */ +export const TOKEN_EXTRACTION_CONFIGS = CONFIG_MAP; diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 407b0600f1..0cb878a3c9 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -25,6 +25,7 @@ import { } from "./streamPayloadCollector.ts"; import { STREAM_IDLE_TIMEOUT_MS, FETCH_BODY_TIMEOUT_MS, HTTP_STATUS } from "../config/constants.ts"; import { + OMIT_STREAMING_CHUNK_MARKER, sanitizeStreamingChunk, extractThinkingFromContent, } from "../handlers/responseSanitizer.ts"; @@ -1600,6 +1601,36 @@ export function createSSEStream(options: StreamOptions = {}) { } else { // Chat Completions: full sanitization pipeline + // Hardening: detect upstream returning empty choices array + // which breaks OpenAI-compatible clients (e.g. Copilot Chat) + if (Array.isArray(parsed.choices) && parsed.choices.length === 0) { + console.warn( + `[STREAM] Upstream returned empty choices array (${provider || "provider"}:${model || "unknown"}) — emitting error chunk` + ); + const errorChunk = { + id: parsed.id || `omniroute-empty-choices-${Date.now()}`, + object: "chat.completion.chunk", + created: parsed.created || Math.floor(Date.now() / 1000), + model: parsed.model || model || "unknown", + choices: [ + { + index: 0, + delta: { + role: "assistant", + content: "[OmniRoute] Upstream returned an empty response. Please retry.", + }, + finish_reason: "stop", + }, + ], + }; + output = `data: ${JSON.stringify(errorChunk)}\n`; + injectedUsage = true; + clientPayload = errorChunk; + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(encoder.encode(output)); + continue; + } + // Detect reasoning alias before sanitization strips it const hadReasoningAlias = !!( parsed.choices?.[0]?.delta?.reasoning && @@ -1608,6 +1639,14 @@ export function createSSEStream(options: StreamOptions = {}) { ); parsed = sanitizeStreamingChunk(parsed); + if ( + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + (parsed as Record)[OMIT_STREAMING_CHUNK_MARKER] === true + ) { + continue; + } const idFixed = fixInvalidId(parsed); @@ -2118,6 +2157,14 @@ export function createSSEStream(options: StreamOptions = {}) { (a, b) => a.index - b.index ); } + // Hardening: log empty assistant response after tool completion + // for observability — helps diagnose Copilot "Sorry, no response was returned" + if (passthroughHasToolCalls && !content.trim() && !reasoning.trim()) { + console.warn( + `[STREAM] Empty assistant response after tool_calls completion (${provider || "provider"}:${model || "unknown"}) — sessionId=${sessionId}` + ); + } + const responseBody = { choices: [ { diff --git a/package-lock.json b/package-lock.json index 50bf945f27..53977e033e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.12", + "version": "3.8.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.12", + "version": "3.8.13", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -21467,7 +21467,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.12" + "version": "3.8.13" } } } diff --git a/package.json b/package.json index 269c95b51d..89dad24a8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.12", + "version": "3.8.13", "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/scripts/build/bootstrap-env.mjs b/scripts/build/bootstrap-env.mjs index c39e9b9107..54d4f8d3c6 100644 --- a/scripts/build/bootstrap-env.mjs +++ b/scripts/build/bootstrap-env.mjs @@ -67,6 +67,21 @@ function getPreferredEnvFilePath(env = process.env) { return candidates.find((filePath) => existsSync(filePath)) ?? null; } +function isNativeSqliteLoadError(error) { + const message = error instanceof Error ? error.message : String(error); + const code = error && typeof error === "object" && "code" in error ? error.code : undefined; + + return ( + message.includes("Module did not self-register") || + message.includes("NODE_MODULE_VERSION") || + message.includes("ERR_DLOPEN_FAILED") || + message.includes("Could not locate the bindings file") || + message.includes("Cannot find module 'better-sqlite3'") || + code === "ERR_DLOPEN_FAILED" || + code === "MODULE_NOT_FOUND" + ); +} + function hasEncryptedCredentials(dataDir) { const dbPath = join(dataDir, "storage.sqlite"); if (!existsSync(dbPath)) return false; @@ -91,6 +106,10 @@ function hasEncryptedCredentials(dataDir) { db.close(); } } catch (error) { + if (isNativeSqliteLoadError(error)) { + return false; + } + const message = error instanceof Error ? error.message : String(error); throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`); } @@ -150,7 +169,15 @@ export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) { // ── Layer 2: Load the same preferred .env that the CLI wrapper uses ─────── // This keeps run-next / run-standalone consistent with `bin/omniroute.mjs`. - const merged = { ...persisted, ...preferredEnv, ...process.env }; + // + // We strip empty values from preferredEnv so an empty placeholder + // (e.g. `STORAGE_ENCRYPTION_KEY=` in the project .env template) does not + // override the real value persisted in server.env. Only the .env entries + // that the operator actually set should win. + const preferredEnvFiltered = Object.fromEntries( + Object.entries(preferredEnv).filter(([, v]) => typeof v === "string" && v.length > 0) + ); + const merged = { ...persisted, ...preferredEnvFiltered, ...process.env }; // ── Auto-generate required secrets ──────────────────────────────────────── let needsPersist = false; diff --git a/scripts/dev/ensure-native-sqlite.mjs b/scripts/dev/ensure-native-sqlite.mjs new file mode 100644 index 0000000000..1edbeb8268 --- /dev/null +++ b/scripts/dev/ensure-native-sqlite.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +/** + * OmniRoute — Dev-startup native SQLite ABI guard. + * + * `better-sqlite3` is a native addon compiled for a specific Node.js ABI + * (NODE_MODULE_VERSION). This project supports both Node 22 (ABI 127) and + * Node 24 (ABI 137); switching between them via nvm leaves the previously + * built `better_sqlite3.node` incompatible, so `npm run dev` crashes during + * bootstrap with: + * + * "The module '…/better_sqlite3.node' was compiled against a different + * Node.js version using NODE_MODULE_VERSION 127. This version of Node.js + * requires NODE_MODULE_VERSION 137." + * + * `postinstall.mjs` only fixes the published standalone bundle and only runs + * on `npm install` — it does NOT cover "cloned repo, switched Node, ran dev". + * + * This guard probes the root binary against the *current* Node ABI and, ONLY + * when it detects a genuine ABI mismatch, runs `npm rebuild better-sqlite3` + * once. The healthy path (matching ABI) does no work, so dev startup stays + * fast. Unrelated errors are NOT swallowed — they fall through so the normal + * bootstrap surfaces them. + */ + +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, "..", ".."); + +export const SQLITE_BINARY = join( + ROOT, + "node_modules", + "better-sqlite3", + "build", + "Release", + "better_sqlite3.node" +); + +/** + * Whether an error message indicates a native-addon ABI / load mismatch + * (as opposed to an unrelated runtime error such as a missing table). + * Mirrors the detection in src/lib/db/core.ts::isNativeSqliteLoadError. + * @param {unknown} message + * @returns {boolean} + */ +export function isNativeAbiMismatch(message) { + const m = String(message ?? ""); + return ( + m.includes("NODE_MODULE_VERSION") || + m.includes("was compiled against a different Node.js version") || + m.includes("Module did not self-register") || + m.includes("ERR_DLOPEN_FAILED") || + m.includes("Could not locate the bindings file") + ); +} + +/** Probe a native binary against the current Node ABI without polluting the require cache. */ +function probeLoad(binaryPath) { + process.dlopen({ exports: {} }, binaryPath); +} + +/** Default rebuild: `npm rebuild better-sqlite3` at the repo root (no shell interpolation). */ +function defaultRebuild() { + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + const result = spawnSync(npm, ["rebuild", "better-sqlite3"], { cwd: ROOT, stdio: "inherit" }); + return result.status === 0; +} + +/** + * Ensure better-sqlite3 loads under the current Node. Rebuilds once on ABI + * mismatch. Returns a result object; never throws for the mismatch path. + * + * @param {{ logger?: Pick, rebuild?: () => boolean, probe?: (p: string) => void, binaryPath?: string }} [opts] + * @returns {{ ok: boolean, rebuilt: boolean, error?: unknown }} + */ +export function ensureNativeSqlite(opts = {}) { + const { + logger = console, + rebuild = defaultRebuild, + probe = probeLoad, + binaryPath = SQLITE_BINARY, + } = opts; + + // Nothing built yet (fresh clone before install) — let install/bootstrap handle it. + if (!existsSync(binaryPath)) return { ok: true, rebuilt: false }; + + try { + probe(binaryPath); + return { ok: true, rebuilt: false }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!isNativeAbiMismatch(message)) { + // Not an ABI problem — do not mask it; bootstrap will surface the real error. + return { ok: false, rebuilt: false, error }; + } + logger.warn( + `[dev] better-sqlite3 was built for a different Node ABI than ${process.version} — ` + + "rebuilding (one-time)…" + ); + if (!rebuild()) { + logger.error( + "[dev] Automatic 'npm rebuild better-sqlite3' failed. Run it manually:\n" + + " npm rebuild better-sqlite3" + ); + return { ok: false, rebuilt: false }; + } + logger.log("[dev] better-sqlite3 rebuilt for the current Node. Continuing startup."); + return { ok: true, rebuilt: true }; + } +} diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index 7a956d93a9..23a6d326ed 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -9,6 +9,7 @@ import { resolveRuntimePorts, withRuntimePortEnv } from "../build/runtime-env.mj import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs"; import { createResponsesWsProxy } from "./responses-ws-proxy.mjs"; import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs"; +import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs"; import { randomUUID } from "node:crypto"; // Pre-read DATA_DIR from local .env before bootstrap resolves paths @@ -36,6 +37,12 @@ if (fs.existsSync(rootAppDir) && fs.statSync(rootAppDir).isDirectory()) { const mode = process.argv[2] === "start" ? "start" : "dev"; const dev = mode === "dev"; +// Self-heal a stale better-sqlite3 native binary after a Node version switch +// (nvm 22 <-> 24) before bootstrap touches the DB. No-op when the ABI matches. +if (dev) { + ensureNativeSqlite(); +} + const bootstrappedEnv = bootstrapEnv(); const runtimePorts = resolveRuntimePorts(bootstrappedEnv); const mergedEnv = withRuntimePortEnv(bootstrappedEnv, runtimePorts); diff --git a/scripts/i18n/check-ui-keys-coverage.mjs b/scripts/i18n/check-ui-keys-coverage.mjs index 24be2aa9e5..db4dde8a10 100644 --- a/scripts/i18n/check-ui-keys-coverage.mjs +++ b/scripts/i18n/check-ui-keys-coverage.mjs @@ -76,10 +76,10 @@ function isPlainObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } -function collectLeafPaths(obj, prefix = "") { +function collectLeafPaths(obj, prefix = []) { const paths = []; for (const [key, value] of Object.entries(obj)) { - const next = prefix ? `${prefix}.${key}` : key; + const next = [...prefix, key]; if (isPlainObject(value)) { paths.push(...collectLeafPaths(value, next)); } else { @@ -94,8 +94,7 @@ function collectLeafPaths(obj, prefix = "") { // scanner correctly flags any dynamic indexing with untrusted-looking keys. const FORBIDDEN_KEY_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]); -function lookupPath(obj, dotPath) { - const parts = dotPath.split("."); +function lookupPath(obj, parts) { let cur = obj; for (const part of parts) { if (!isPlainObject(cur)) return undefined; @@ -170,8 +169,8 @@ async function main() { let present = 0; let missing = 0; let placeholder = 0; - for (const dotPath of enPaths) { - const value = lookupPath(target, dotPath); + for (const pathParts of enPaths) { + const value = lookupPath(target, pathParts); if (value === undefined) { missing++; continue; diff --git a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx index 9ffa88f36c..2489e7ccfd 100644 --- a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; import { Card } from "@/shared/components"; import { useDisplayBaseUrl } from "@/shared/hooks"; +import VscodeTokenAliasCard from "./VscodeTokenAliasCard"; import { matchesSearch } from "@/shared/utils/turkishText"; /* ─── Types ──────────────────────────────────────────── */ @@ -271,31 +272,35 @@ export default function ApiEndpointsTab() { {/* ═══ API CATALOG ═══ */} {!catalog && ( - -
-
- error -
-
-

- {t("apiEndpointsCatalogUnavailable")} -

-

- {catalogError || "The OpenAPI specification could not be loaded."} -

- + -
- + + + + )} {catalog && ( @@ -385,6 +390,8 @@ export default function ApiEndpointsTab() {
+ + {/* Endpoint groups */} {Object.entries(groupedEndpoints).map(([tag, endpoints]) => ( diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index e3461e4a1e..4feb02b230 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -12,6 +12,7 @@ import A2ADashboardPage from "./components/A2ADashboard"; import McpDashboardPage from "./components/MCPDashboard"; import TokenSaverCard from "./components/TokenSaverCard"; import NotionSourceCard from "./components/NotionSourceCard"; +import VscodeTokenAliasCard from "./VscodeTokenAliasCard"; const BUILD_TIME_CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL || null; const CLOUD_ACTION_TIMEOUT_MS = 15000; @@ -2023,6 +2024,8 @@ export default function APIPageClient({ machineId }: Readonly + + diff --git a/src/app/(dashboard)/dashboard/endpoint/VscodeTokenAliasCard.tsx b/src/app/(dashboard)/dashboard/endpoint/VscodeTokenAliasCard.tsx new file mode 100644 index 0000000000..b693c0b4a2 --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/VscodeTokenAliasCard.tsx @@ -0,0 +1,213 @@ +"use client"; + +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { useEffect, useMemo, useState } from "react"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { useDisplayBaseUrl } from "@/shared/hooks"; +import { Card } from "@/shared/components"; + +type EndpointApiKeySummary = { + id: string; + key: string; + rawKey?: string; + isActive?: boolean; +}; + +export default function VscodeTokenAliasCard({ + className = "", + variant = "highlight", +}: Readonly<{ className?: string; variant?: "highlight" | "catalog" }>) { + const t = useTranslations("endpoint"); + const [cliApiKeys, setCliApiKeys] = useState([]); + const [keysLoading, setKeysLoading] = useState(true); + const [keysError, setKeysError] = useState(false); + const { copied, copy } = useCopyToClipboard(); + const displayBaseUrl = useDisplayBaseUrl(); + + useEffect(() => { + let cancelled = false; + + const loadCliApiKeys = async () => { + setKeysLoading(true); + setKeysError(false); + + try { + const res = await fetch("/api/cli-tools/keys"); + if (!res.ok) { + throw new Error("Failed to load CLI keys"); + } + + const data = await res.json(); + if (!cancelled) { + setCliApiKeys(data.keys || []); + } + } catch { + if (!cancelled) { + setCliApiKeys([]); + setKeysError(true); + } + } finally { + if (!cancelled) { + setKeysLoading(false); + } + } + }; + + void loadCliApiKeys(); + + return () => { + cancelled = true; + }; + }, []); + + const preferredCliApiKey = useMemo(() => { + if (cliApiKeys.length === 0) { + return null; + } + + const storedCopilotKeyId = + typeof window !== "undefined" ? window.localStorage.getItem("omniroute-cli-key-copilot") : null; + + return ( + (storedCopilotKeyId ? cliApiKeys.find((key) => key.id === storedCopilotKeyId) : null) ?? + cliApiKeys.find((key) => key.isActive !== false) ?? + cliApiKeys[0] ?? + null + ); + }, [cliApiKeys]); + + const tokenSegment = preferredCliApiKey?.rawKey || "{token}"; + const hasRealToken = Boolean(preferredCliApiKey?.rawKey); + const translationToken = { token: "{token}" }; + const baseUrl = `${displayBaseUrl}/api/v1/vscode/${tokenSegment}`; + const vscodeTokenizedUrls = [ + { + label: t("vscodeAliasBaseLabel"), + url: `${baseUrl}/`, + key: "vscode_token_base", + }, + { + label: t("vscodeAliasModelsLabel"), + url: `${baseUrl}/models`, + key: "vscode_token_models", + }, + { + label: t("vscodeAliasChatLabel"), + url: `${baseUrl}/chat/completions`, + key: "vscode_token_chat", + }, + ]; + + const description = hasRealToken + ? t("vscodeAliasDescriptionReady", translationToken) + : keysError + ? t("vscodeAliasDescriptionError") + : keysLoading + ? t("vscodeAliasDescriptionLoading") + : t("vscodeAliasDescriptionPlaceholder", translationToken); + + if (variant === "catalog") { + return ( + +
+ key +

+ {t("vscodeAliasTitle")} +

+
+ + {t("vscodeAliasManage")} + +
+ +
+

{description}

+ +
+ {vscodeTokenizedUrls.map(({ label, url, key }) => ( + void copy(text, copyKey)} + copied={copied} + variant="catalog" + /> + ))} +
+
+ + ); + } + + return ( +
+
+
+

+ {t("vscodeAliasTitle")} +

+

{description}

+
+ + {t("vscodeAliasManage")} + +
+ +
+ {vscodeTokenizedUrls.map(({ label, url, key }) => ( + void copy(text, copyKey)} + copied={copied} + variant="highlight" + /> + ))} +
+
+ ); +} + +function CopyableEndpointRow({ + label, + url, + copyKey, + copy, + copied, + variant = "highlight", +}: Readonly<{ + label: string; + url: string; + copyKey: string; + copy: (text: string, key?: string) => void; + copied?: string | null; + variant?: "highlight" | "catalog"; +}>) { + const rowClassName = + variant === "catalog" + ? "flex items-center gap-2 min-w-0 rounded-lg border border-black/10 dark:border-white/10 bg-black/[0.02] dark:bg-white/[0.02] px-3 py-2.5" + : "flex items-center gap-2 min-w-0 rounded-md border border-sky-500/15 bg-background/60 px-2.5 py-2"; + + return ( +
+ {label} + {url} + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx b/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx index 8b7aa4e33f..58b46fdd08 100644 --- a/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx @@ -3,6 +3,58 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ApiEndpointsTab from "../ApiEndpointsTab"; +import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks/useDisplayBaseUrl"; + +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( + + {children} + + ), +})); + +vi.mock("next-intl", () => ({ + useTranslations: (namespace?: string) => { + const messages: Record = { + "endpoint.apiEndpointsCatalogUnavailable": "API catalog unavailable", + "endpoint.apiEndpointsSearchPlaceholder": "Search endpoints", + "endpoint.badgeLoopbackTooltip": "Loopback only", + "endpoint.badgeAlwaysProtectedTooltip": "Always protected", + "endpoint.badgeInternalTooltip": "Internal endpoint", + "endpoint.tierAll": "All", + "endpoint.tierAuth": "Auth", + "endpoint.tierLoopback": "Loopback", + "endpoint.tierAlwaysProtected": "Protected", + "endpoint.tierPublic": "Public", + "endpoint.showInternal": "Show internal", + "endpoint.hideInternal": "Hide internal", + "endpoint.vscodeAliasTitle": "VS Code Token Alias", + "endpoint.vscodeAliasDescriptionReady": "Ready-to-paste compatibility URLs using the /api/v1/vscode/{token}/... endpoint.", + "endpoint.vscodeAliasDescriptionError": "Showing placeholder URLs because CLI keys could not be loaded in this session.", + "endpoint.vscodeAliasDescriptionLoading": "Loading CLI keys. Placeholder URLs are shown until a key is available.", + "endpoint.vscodeAliasDescriptionPlaceholder": "Showing placeholder URLs. Create or activate an API key in CLI Tools to replace {token}.", + "endpoint.vscodeAliasManage": "CLI Tools", + "endpoint.vscodeAliasBaseLabel": "VS Code base", + "endpoint.vscodeAliasModelsLabel": "VS Code models", + "endpoint.vscodeAliasChatLabel": "VS Code chat", + "endpoint.tryIt": "Try it", + "endpoint.parameters": "Parameters", + "endpoint.responses": "Responses", + "endpoint.requestBody": "Request body", + "endpoint.description": "Description", + "endpoint.noDescription": "No description", + "endpoint.security": "Security", + "endpoint.authRequired": "Auth required", + "endpoint.noAuth": "No auth", + "endpoint.execute": "Execute", + "endpoint.executing": "Executing", + "endpoint.close": "Close", + "endpoint.openJsonResponse": "Open JSON response", + }; + + return (key: string) => messages[`${namespace}.${key}`] || key; + }, +})); function jsonResponse(data: unknown, status = 200) { return { @@ -66,18 +118,32 @@ describe("ApiEndpointsTab", () => { }); it("shows an API catalog error state instead of a blank page", async () => { - fetchMock.mockResolvedValue(jsonResponse({ error: "openapi.yaml not found" }, 404)); + fetchMock.mockImplementation(async (input) => { + if (input === "/api/cli-tools/keys") { + return jsonResponse({ keys: [] }); + } + + return jsonResponse({ error: "openapi.yaml not found" }, 404); + }); renderApiEndpointsTab(); + await waitForText("VS Code Token Alias"); await waitForText("API catalog unavailable"); expect(document.body.textContent).toContain("openapi.yaml not found"); + expect(document.body.textContent).toContain("/api/v1/vscode/{token}/models"); expect(document.body.textContent).toContain("Open JSON response"); }); it("renders catalog content when the OpenAPI catalog loads", async () => { - fetchMock.mockResolvedValue( - jsonResponse({ + fetchMock.mockImplementation(async (input) => { + if (input === "/api/cli-tools/keys") { + return jsonResponse({ + keys: [{ id: "copilot", key: "sk-***", rawKey: "sk-live-123", isActive: true }], + }); + } + + return jsonResponse({ info: { title: "OmniRoute API", version: "3.7.6" }, servers: [], tags: [{ name: "Chat" }], @@ -95,19 +161,25 @@ describe("ApiEndpointsTab", () => { }, ], schemas: [], - }) - ); + }); + }); renderApiEndpointsTab(); + await waitForText("VS Code Token Alias"); await waitForText("OmniRoute API"); expect(document.body.textContent).toContain("1 endpoints across 1 categories"); + expect(document.body.textContent).toContain("/api/v1/vscode/sk-live-123/models"); expect(document.body.textContent).toContain("/api/v1/chat/completions"); }); it("renders curl example using window.location.origin when NEXT_PUBLIC_BASE_URL is unset", async () => { - fetchMock.mockResolvedValue( - jsonResponse({ + fetchMock.mockImplementation(async (input) => { + if (input === "/api/cli-tools/keys") { + return jsonResponse({ keys: [] }); + } + + return jsonResponse({ info: { title: "OmniRoute API", version: "3.7.6" }, servers: [], tags: [{ name: "Chat" }], @@ -125,27 +197,32 @@ describe("ApiEndpointsTab", () => { }, ], schemas: [], - }) - ); + }); + }); renderApiEndpointsTab(); await waitForText("OmniRoute API"); // Expand the endpoint to reveal the curl example - const endpointRow = document.body.querySelector("code.font-mono.flex-1"); + const endpointRow = Array.from(document.body.querySelectorAll("code")).find((node) => + node.textContent?.includes("/api/v1/chat/completions") + ); if (endpointRow?.parentElement) { await act(async () => { endpointRow.parentElement!.click(); }); } - // After mount the hook swaps DEFAULT_DISPLAY_BASE_URL for window.location.origin. - // In jsdom the default origin is "http://localhost". - const expectedOrigin = window.location.origin; // "http://localhost" in jsdom - await waitForText(`curl -X POST ${expectedOrigin}/v1/chat/completions`); - expect(document.body.textContent).toContain( - `curl -X POST ${expectedOrigin}/v1/chat/completions` - ); + await waitForText("curl -X POST"); + + const renderedText = document.body.textContent || ""; + const expectedOrigins = [window.location.origin, DEFAULT_DISPLAY_BASE_URL]; + + expect( + expectedOrigins.some((origin) => + renderedText.includes(`curl -X POST ${origin}/v1/chat/completions`) + ) + ).toBe(true); }); }); diff --git a/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx b/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx index 3c92edfe9a..acb0ccd5f5 100644 --- a/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx @@ -118,6 +118,15 @@ vi.mock("next-intl", () => ({ "endpoint.categoryCore": "Core APIs", "endpoint.categoryMedia": "Media APIs", "endpoint.categoryUtility": "Utility APIs", + "endpoint.vscodeAliasTitle": "VS Code Token Alias", + "endpoint.vscodeAliasDescriptionReady": "Ready-to-paste compatibility URLs using the /api/v1/vscode/{token}/... endpoint.", + "endpoint.vscodeAliasDescriptionError": "Showing placeholder URLs because CLI keys could not be loaded in this session.", + "endpoint.vscodeAliasDescriptionLoading": "Loading CLI keys. Placeholder URLs are shown until a key is available.", + "endpoint.vscodeAliasDescriptionPlaceholder": "Showing placeholder URLs. Create or activate an API key in CLI Tools to replace {token}.", + "endpoint.vscodeAliasManage": "CLI Tools", + "endpoint.vscodeAliasBaseLabel": "VS Code base", + "endpoint.vscodeAliasModelsLabel": "VS Code models", + "endpoint.vscodeAliasChatLabel": "VS Code chat", "endpoint.machineId": "Machine {id}", "endpoint.usingLocalServer": "Using local server", "common.copy": "Copy", @@ -187,6 +196,30 @@ describe("EndpointPageClient", () => { if (path === "/api/search/providers") { return Promise.resolve(jsonResponse({ providers: [] })); } + if (path === "/api/cli-tools/keys") { + return Promise.resolve( + jsonResponse({ + keys: [ + { + id: "key-1", + key: "sk-test-1234", + rawKey: "sk-test-1234", + isActive: true, + }, + ], + }) + ); + } + if (path === "/api/settings/compression") { + return Promise.resolve( + jsonResponse({ + enabled: true, + cavemanConfig: { enabled: true, intensity: "full" }, + cavemanOutputMode: { enabled: false, intensity: "full" }, + rtkConfig: { enabled: true, intensity: "standard" }, + }) + ); + } throw new Error(`Unexpected request: ${path}`); }); @@ -217,7 +250,10 @@ describe("EndpointPageClient", () => { }) ); - await waitForText("2 models across 4 endpoints"); + await waitForText("2 models across"); + await waitForText("/api/v1/vscode/sk-test-1234/models"); + expect(document.body.textContent).toContain("VS Code Token Alias"); + expect(document.body.textContent).toContain("/api/v1/vscode/sk-test-1234/models"); }); it("does not start background endpoint requests after unmounting during settings load", async () => { diff --git a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx index 20440a705a..3a66230b18 100644 --- a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx @@ -52,7 +52,7 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio const { provider, setProvider, providerOptions, loading: loadingProviders } = useProviderOptions( configState.provider ?? "" ); - const { availableModels, loading: loadingModels } = useAvailableModels(); + const { availableModels, loading: loadingModels } = useAvailableModels(provider || undefined); function update(key: K, value: ConfigState[K]) { setConfigState({ ...configState, [key]: value }); @@ -125,6 +125,7 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio onChange={(e) => { setProvider(e.target.value); update("provider", e.target.value); + update("model", ""); }} disabled={loadingProviders} className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main" diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 934efc73d1..9dfff75e0b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -634,6 +634,8 @@ interface PassthroughModelsSectionProps { onTestModel?: (modelId: string, fullModel: string) => Promise; modelTestStatus?: Record; testingModelId?: string | null; + providerId: string; + connectionId: string; } interface CustomModelsSectionProps { @@ -685,6 +687,11 @@ interface CompatibleModelsSectionProps { onTestModel?: (modelId: string, fullModel: string) => Promise; modelTestStatus?: Record; testingModelId?: string | null; + onTestAll?: (targets: Array<{ modelId: string; fullModel: string }>) => Promise; + testingAll?: boolean; + testProgress?: { done: number; total: number } | null; + autoHideFailed?: boolean; + onAutoHideFailedChange?: (v: boolean) => void; } interface CooldownTimerProps { @@ -842,6 +849,7 @@ interface EditConnectionModalConnection { email?: string; priority?: number; maxConcurrent?: number | null; + rateLimitOverrides?: Record | null; authType?: string; provider?: string; apiKey?: string; @@ -1433,6 +1441,10 @@ export default function ProviderDetailPage() { const [togglingModelId, setTogglingModelId] = useState(null); const [testingModelId, setTestingModelId] = useState(null); const [modelTestStatus, setModelTestStatus] = useState>({}); + const [testingAll, setTestingAll] = useState(false); + const [testProgress, setTestProgress] = useState<{ done: number; total: number } | null>(null); + const [autoHideFailed, setAutoHideFailed] = useState(true); + const [visibilityFilter, setVisibilityFilter] = useState<"all" | "visible" | "hidden">("all"); const [bulkVisibilityAction, setBulkVisibilityAction] = useState<"select" | "deselect" | null>( null ); @@ -1519,13 +1531,10 @@ export default function ProviderDetailPage() { // Prefer synced API-discovered models when available, then merge built-ins // and user-managed custom models without duplicating IDs. const models = useMemo(() => { - if (providerId === "gemini") { - return syncedAvailableModels.map((model: any) => ({ - ...model, - source: "imported", - })); - } - + // Universal: merge built-in registry models with API-synced models and + // user-managed custom models for ALL providers (was previously Gemini-only). + // Synced models keep their full property spread so provider-specific fields + // (e.g. Gemini's `supportedGenerationMethods`) survive into the table. const builtInModels = registryModels.map((model) => ({ ...model, source: "system", @@ -1535,6 +1544,7 @@ export default function ProviderDetailPage() { const syncedExtras = syncedAvailableModels .filter((model: any) => model?.id && !registryIds.has(model.id)) .map((model: any) => ({ + ...model, id: model.id, name: model.name || model.id, source: "imported", @@ -1547,7 +1557,12 @@ export default function ProviderDetailPage() { name: cm.name || cm.id, source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", })); - return [...builtInModels, ...syncedExtras, ...customExtras]; + const allModels = [...builtInModels, ...syncedExtras, ...customExtras]; + const deduped = new Map(); + for (const m of allModels) { + if (m.id && !deduped.has(m.id)) deduped.set(m.id, m); + } + return Array.from(deduped.values()); }, [providerId, registryModels, syncedAvailableModels, modelMeta.customModels]); const providerAlias = getProviderAlias(providerId); const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter"; @@ -1615,6 +1630,56 @@ export default function ProviderDetailPage() { } }, []); + const handleSetAlias = useCallback( + async (modelId: string, alias: string, providerAlias?: string) => { + const qualifiedModel = providerAlias + ? modelId.includes("/") + ? `${providerAlias}/${modelId.split("/").slice(1).join("/")}` + : `${providerAlias}/${modelId}` + : modelId; + try { + const res = await fetch("/api/models/alias", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: qualifiedModel, alias }), + }); + if (res.ok) { + await fetchAliases(); + notify.success(t("setAliasSuccess", { alias })); + } else { + const data = await res.json().catch(() => ({})); + notify.error(data?.error?.message || "Failed to set alias"); + } + } catch (error) { + console.log("Error setting alias:", error); + notify.error("Network error setting alias"); + } + }, + [fetchAliases, t] + ); + + const handleDeleteAlias = useCallback( + async (alias: string) => { + try { + const res = await fetch( + `/api/models/alias?alias=${encodeURIComponent(alias)}`, + { method: "DELETE" } + ); + if (res.ok) { + await fetchAliases(); + notify.success(t("deleteAliasSuccess", { alias })); + } else { + const data = await res.json().catch(() => ({})); + notify.error(data?.error?.message || "Failed to delete alias"); + } + } catch (error) { + console.log("Error deleting alias:", error); + notify.error("Network error deleting alias"); + } + }, + [fetchAliases, t] + ); + const fetchProviderModelMeta = useCallback(async () => { if (isSearchProvider) return; try { @@ -1866,6 +1931,7 @@ export default function ProviderDetailPage() { body: JSON.stringify({ providerId: selectedConnection?.provider || providerNode?.id || providerId, modelId: fullModel, + connectionId: selectedConnection?.id, }), }); const data = await res.json(); @@ -1882,67 +1948,93 @@ export default function ProviderDetailPage() { } else { notify.error(data.error || "Model test failed"); setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" })); + if (handleToggleModelHidden) { + await handleToggleModelHidden(providerStorageAlias, modelId, true); + } } } catch (err) { notify.error("Network error testing model"); setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" })); + if (handleToggleModelHidden) { + await handleToggleModelHidden(providerStorageAlias, modelId, true); + } } finally { setTestingModelId(null); } }; - const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => { - const fullModel = `${providerAliasOverride}/${modelId}`; - try { - const res = await fetch("/api/models/alias", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: fullModel, alias }), - }); - if (res.ok) { - await fetchAliases(); - } else { - const data = await res.json(); - alert(data.error || t("failedSetAlias")); - } - } catch (error) { - console.log("Error setting alias:", error); + const handleTestAll = async ( + targets: Array<{ modelId: string; fullModel: string }> + ): Promise => { + if (testingAll) return; + if (targets.length === 0) { + notify.error(providerText(t, "noModelsToTest", "No models to test")); + return; } - }; + setTestingAll(true); + setTestProgress({ done: 0, total: targets.length }); - const handleDeleteAlias = async (alias) => { - try { - const res = await fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, { - method: "DELETE", - }); - if (res.ok) { - await fetchAliases(); - } - } catch (error) { - console.log("Error deleting alias:", error); + let ok = 0; + let error = 0; + let hiddenCount = 0; + + const CHUNK_SIZE = 3; + for (let i = 0; i < targets.length; i += CHUNK_SIZE) { + const chunk = targets.slice(i, i + CHUNK_SIZE); + await Promise.all( + chunk.map(async ({ modelId, fullModel }) => { + try { + const result: { + results?: Record< + string, + { + status?: "ok" | "error"; + rateLimited?: boolean; + isTimeout?: boolean; + error?: string; + } + >; + } = await fetch("/api/models/test-all", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerId: providerId, + connectionId: selectedConnection?.id, + modelIds: [fullModel], + }), + }).then((r) => r.json()); + + const entry = result.results?.[fullModel]; + if (entry?.status === "ok") { + ok++; + } else { + error++; + if (autoHideFailed && !entry?.rateLimited && !entry?.isTimeout) { + await handleToggleModelHidden(providerStorageAlias, modelId, true); + hiddenCount++; + } + } + } catch (e) { + error++; + } + setTestProgress((prev) => + prev ? { done: prev.done + 1, total: prev.total } : null + ); + }) + ); } - }; - const handleDelete = async (id) => { - if (!confirm(t("deleteConnectionConfirm"))) return; - try { - const res = await fetch(`/api/providers/${id}`, { method: "DELETE" }); - if (res.ok) { - setConnections(connections.filter((c) => c.id !== id)); - if (providerId === "gemini") { - await fetchProviderModelMeta(); - } - } - } catch (error) { - console.log("Error deleting connection:", error); - } - }; - - const handleToggleSelectAll = useCallback(() => { - setSelectedIds((prev) => - prev.size === connections.length ? new Set() : new Set(connections.map((c) => c.id)) + notify.info( + providerText(t, "testAllResults", "{ok} ok, {error} error", { ok, error }) ); - }, [connections]); + if (hiddenCount > 0) { + notify.info( + providerText(t, "testAllFailedHidden", "{count} hidden", { count: hiddenCount }) + ); + } + setTestingAll(false); + setTestProgress(null); + }; const handleToggleSelectOne = useCallback((id: string) => { setSelectedIds((prev) => { @@ -1953,6 +2045,15 @@ export default function ProviderDetailPage() { }); }, []); + const handleToggleSelectAll = useCallback(() => { + setSelectedIds((prev) => { + if (prev.size === connections.length && connections.length > 0) { + return new Set(); + } + return new Set(connections.map((c) => (c as { id: string }).id)); + }); + }, [connections]); + const handleBatchDelete = async () => { if (selectedIds.size === 0) return; if (!confirm(t("batchDeleteConfirm", { count: selectedIds.size }))) return; @@ -1969,9 +2070,7 @@ export default function ProviderDetailPage() { setSelectedIds(new Set()); await fetchConnections(); notify.success(t("batchDeleteSuccess", { count: selectedIds.size })); - if (providerId === "gemini") { - await fetchProviderModelMeta(); - } + await fetchProviderModelMeta(); } else { const data = await res.json(); notify.error(data.error || "Batch delete failed"); @@ -1983,6 +2082,31 @@ export default function ProviderDetailPage() { } }; + const handleDelete = useCallback( + async (connectionId: string) => { + if (!connectionId) return; + try { + const res = await fetch(`/api/providers/${connectionId}`, { method: "DELETE" }); + if (res.ok) { + notify.success("Connection deleted"); + await fetchConnections(); + await fetchProviderModelMeta(); + } else { + const data = await res.json().catch(() => ({})); + const message = + (typeof data?.error === "string" && data.error) || + data?.error?.message || + "Failed to delete connection"; + notify.error(message); + } + } catch (error) { + console.error("Error deleting connection:", error); + notify.error("Failed to delete connection"); + } + }, + [fetchConnections, fetchProviderModelMeta, notify] + ); + const handleBatchSetActive = async (isActive: boolean) => { if (selectedIds.size === 0 || batchUpdating) return; setBatchUpdating(isActive ? "activate" : "deactivate"); @@ -2387,8 +2511,9 @@ export default function ProviderDetailPage() { setShowAddApiKeyModal(false); setSiliconFlowInitialBaseUrl(undefined); - // For Gemini: show progress dialog and sync models from endpoint - if (providerId === "gemini" && newConnection?.id) { + // Universal: sync models from the provider endpoint on every new connection + // (was previously Gemini-only). Do NOT re-introduce a providerId guard here. + if (newConnection?.id) { setShowImportModal(true); setImportProgress({ current: 0, @@ -3824,6 +3949,11 @@ export default function ProviderDetailPage() { onTestModel={onTestModel} modelTestStatus={modelTestStatus} testingModelId={testingModelId} + onTestAll={handleTestAll} + testingAll={testingAll} + testProgress={testProgress} + autoHideFailed={autoHideFailed} + onAutoHideFailedChange={setAutoHideFailed} />
); @@ -3893,29 +4023,30 @@ export default function ProviderDetailPage() { onTestModel={onTestModel} modelTestStatus={modelTestStatus} testingModelId={testingModelId} + providerId={providerId} + connectionId={selectedConnection?.id ?? ""} /> ); } - const importButton = - providerId === "gemini" ? null : ( -
- - {autoSyncToggle} - {!canImportModels && ( - {t("addConnectionToImport")} - )} -
- ); + const importButton = ( +
+ + {autoSyncToggle} + {!canImportModels && ( + {t("addConnectionToImport")} + )} +
+ ); if (models.length === 0) { return ( @@ -3929,16 +4060,26 @@ export default function ProviderDetailPage() { ...model, isHidden: effectiveModelHidden(model.id), })); - const filteredModels = modelsWithVisibility.filter((model) => - matchesModelCatalogQuery(modelFilter, { + const filteredModels = modelsWithVisibility.filter((model) => { + const matchesQuery = matchesModelCatalogQuery(modelFilter, { modelId: model.id, modelName: model.name, source: model.source, - }) - ); + }); + const matchesVisibility = + visibilityFilter === "all" + ? true + : visibilityFilter === "visible" + ? !model.isHidden + : model.isHidden; + return matchesQuery && matchesVisibility; + }); const activeCount = modelsWithVisibility.filter((m) => !m.isHidden).length; const hiddenFilteredCount = filteredModels.filter((m) => m.isHidden).length; const visibleFilteredCount = filteredModels.length - hiddenFilteredCount; + const testAllTargets = filteredModels + .filter((m) => !m.isHidden) + .map((m) => ({ modelId: m.id, fullModel: `${providerDisplayAlias}/${m.id}` })); return (
{importButton} @@ -3965,6 +4106,13 @@ export default function ProviderDetailPage() { } selectAllDisabled={hiddenFilteredCount === 0 || bulkVisibilityAction !== null} deselectAllDisabled={visibleFilteredCount === 0 || bulkVisibilityAction !== null} + onTestAll={() => handleTestAll(testAllTargets)} + testingAll={testingAll} + testProgress={testProgress} + visibilityFilter={visibilityFilter} + onVisibilityFilterChange={setVisibilityFilter} + autoHideFailed={autoHideFailed} + onAutoHideFailedChange={setAutoHideFailed} /> )}
@@ -4227,7 +4375,7 @@ export default function ProviderDetailPage() { router.push("/dashboard/providers"); } } catch (error) { - console.log("Error deleting provider node:", error); + console.error("Error deleting provider node:", error); } }} > @@ -4455,7 +4603,11 @@ export default function ProviderDetailPage() { ) : ( connections.length === 0 && ( - ) @@ -5146,7 +5298,7 @@ export default function ProviderDetailPage() { onClose={() => setImportCodexModalOpen(false)} onSuccess={() => { setImportCodexModalOpen(false); - fetchData(); + void fetchConnections(); }} /> )} @@ -5213,7 +5365,7 @@ export default function ProviderDetailPage() { onClose={() => setImportClaudeModalOpen(false)} onSuccess={() => { setImportClaudeModalOpen(false); - fetchData(); + void fetchConnections(); }} /> )} @@ -5234,7 +5386,7 @@ export default function ProviderDetailPage() { onClose={() => setImportGeminiModalOpen(false)} onSuccess={() => { setImportGeminiModalOpen(false); - fetchData(); + void fetchConnections(); }} /> )} @@ -5699,6 +5851,13 @@ function ModelVisibilityToolbar({ onDeselectAll, selectAllDisabled, deselectAllDisabled, + onTestAll, + testingAll, + testProgress, + visibilityFilter, + onVisibilityFilterChange, + autoHideFailed, + onAutoHideFailedChange, }: { t: ((key: string, values?: Record) => string) & { has?: (key: string) => boolean; @@ -5711,6 +5870,13 @@ function ModelVisibilityToolbar({ onDeselectAll: () => void; selectAllDisabled?: boolean; deselectAllDisabled?: boolean; + onTestAll?: () => void; + testingAll?: boolean; + testProgress?: { done: number; total: number } | null; + visibilityFilter?: "all" | "visible" | "hidden"; + onVisibilityFilterChange?: (filter: "all" | "visible" | "hidden") => void; + autoHideFailed?: boolean; + onAutoHideFailedChange?: (v: boolean) => void; }) { return (
@@ -5726,30 +5892,73 @@ function ModelVisibilityToolbar({ className="w-full rounded-lg border border-border bg-sidebar/50 py-1.5 pl-7 pr-3 text-xs text-text-main placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-primary" />
+ {visibilityFilter !== undefined && onVisibilityFilterChange && ( +
+ {(["all", "visible", "hidden"] as const).map((f) => ( + + ))} +
+ )} + {onAutoHideFailedChange && ( + + )} + {onTestAll && ( + + )} - - {providerText(t, "modelsActiveCount", "{active}/{total} active", { - active: activeCount, - total: totalCount, - })} -
); } @@ -5780,12 +5989,78 @@ function PassthroughModelsSection({ onTestModel, modelTestStatus, testingModelId, + providerId, + connectionId, }: PassthroughModelsSectionProps) { const [newModel, setNewModel] = useState(""); const [adding, setAdding] = useState(false); const [modelFilter, setModelFilter] = useState(""); + const [testingAll, setTestingAll] = useState(false); + const [testProgress, setTestProgress] = useState<{ done: number; total: number } | null>(null); + const [autoHideFailed, setAutoHideFailed] = useState(true); + const [visibilityFilter, setVisibilityFilter] = useState<"all" | "visible" | "hidden">("all"); + const notify = useNotificationStore(); const customModelMap = useMemo(() => buildCompatMap(customModels), [customModels]); + const handleTestAll = async () => { + const modelsToTest = filteredModels.filter((m) => !m.isHidden); + if (modelsToTest.length === 0) { + notify.error(providerText(t, "noModelsToTest", "No models to test")); + return; + } + setTestingAll(true); + setTestProgress({ done: 0, total: modelsToTest.length }); + + let ok = 0; + let error = 0; + let hiddenCount = 0; + + for (const model of modelsToTest) { + try { + const result: { + results?: Record< + string, + { + status?: "ok" | "error"; + rateLimited?: boolean; + isTimeout?: boolean; + error?: string; + } + >; + } = await fetch("/api/models/test-all", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerId, + connectionId, + modelIds: [model.modelId], + }), + }).then((r) => r.json()); + + const entry = result.results?.[model.modelId]; + if (entry?.status === "ok") { + ok++; + } else { + error++; + if (autoHideFailed && !entry?.rateLimited && !entry?.isTimeout) { + await onToggleHidden(model.modelId, true); + hiddenCount++; + } + } + } catch (e) { + error++; + } + setTestProgress((prev) => (prev ? { done: prev.done + 1, total: prev.total } : null)); + } + + notify.info(providerText(t, "testAllResults", "{ok} ok, {error} error", { ok, error })); + if (hiddenCount > 0) { + notify.info(providerText(t, "testAllFailedHidden", "{count} hidden", { count: hiddenCount })); + } + setTestingAll(false); + setTestProgress(null); + }; + const providerAliases = useMemo( () => Object.entries(modelAliases).filter(([, model]: [string, any]) => @@ -5874,14 +6149,23 @@ function PassthroughModelsSection({ providerAlias, providerAliases, ]); - const filteredModels = allModels.filter((model) => - matchesModelCatalogQuery(modelFilter, { + const filteredModels = allModels.filter((model) => { + const matchesQuery = matchesModelCatalogQuery(modelFilter, { modelId: model.modelId, modelName: model.displayName, alias: model.alias, source: model.source, - }) - ); + }); + + const matchesVisibility = + visibilityFilter === "all" + ? true + : visibilityFilter === "visible" + ? !model.isHidden + : model.isHidden; + + return matchesQuery && matchesVisibility; + }); const activeCount = allModels.filter((model) => !model.isHidden).length; const hiddenFilteredCount = filteredModels.filter((model) => model.isHidden).length; const visibleFilteredCount = filteredModels.length - hiddenFilteredCount; @@ -5908,7 +6192,7 @@ function PassthroughModelsSection({ await onSetAlias(modelId, defaultAlias); setNewModel(""); } catch (error) { - console.log("Error adding model:", error); + console.error("Error adding model:", error); } finally { setAdding(false); } @@ -5950,18 +6234,24 @@ function PassthroughModelsSection({ totalCount={allModels.length} onSelectAll={() => onBulkToggleHidden( - filteredModels.map((model) => model.modelId), + filteredModels.map((m) => m.modelId), false ) } onDeselectAll={() => onBulkToggleHidden( - filteredModels.map((model) => model.modelId), + filteredModels.map((m) => m.modelId), true ) } - selectAllDisabled={hiddenFilteredCount === 0 || bulkTogglePending} - deselectAllDisabled={visibleFilteredCount === 0 || bulkTogglePending} + selectAllDisabled={bulkTogglePending || filteredModels.length === 0} + deselectAllDisabled={bulkTogglePending || filteredModels.length === 0} + onTestAll={handleTestAll} + testingAll={testingAll} + visibilityFilter={visibilityFilter} + onVisibilityFilterChange={setVisibilityFilter} + autoHideFailed={autoHideFailed} + onAutoHideFailedChange={setAutoHideFailed} />
{filteredModels.map(({ modelId, fullModel, alias, isHidden, source, isFree }) => ( @@ -6687,11 +6977,17 @@ function CompatibleModelsSection({ onTestModel, modelTestStatus, testingModelId, + onTestAll, + testingAll, + testProgress, + autoHideFailed, + onAutoHideFailedChange, }: CompatibleModelsSectionProps) { const [newModel, setNewModel] = useState(""); const [adding, setAdding] = useState(false); const [importing, setImporting] = useState(false); const [modelFilter, setModelFilter] = useState(""); + const [visibilityFilter, setVisibilityFilter] = useState<"all" | "visible" | "hidden">("all"); const notify = useNotificationStore(); const customModelMap = useMemo(() => buildCompatMap(customModels), [customModels]); @@ -6782,14 +7078,21 @@ function CompatibleModelsSection({ providerAliases, providerStorageAlias, ]); - const filteredModels = allModels.filter((model) => - matchesModelCatalogQuery(modelFilter, { + const filteredModels = allModels.filter((model) => { + const matchesQuery = matchesModelCatalogQuery(modelFilter, { modelId: model.modelId, modelName: model.displayName, alias: model.alias, source: model.source, - }) - ); + }); + const matchesVisibility = + visibilityFilter === "all" + ? true + : visibilityFilter === "visible" + ? !model.isHidden + : model.isHidden; + return matchesQuery && matchesVisibility; + }); const activeCount = allModels.filter((model) => !model.isHidden).length; const hiddenFilteredCount = filteredModels.filter((model) => model.isHidden).length; const visibleFilteredCount = filteredModels.length - hiddenFilteredCount; @@ -6956,6 +7259,21 @@ function CompatibleModelsSection({ } selectAllDisabled={hiddenFilteredCount === 0 || bulkTogglePending} deselectAllDisabled={visibleFilteredCount === 0 || bulkTogglePending} + visibilityFilter={visibilityFilter} + onVisibilityFilterChange={setVisibilityFilter} + onTestAll={() => { + const targets = filteredModels + .filter((m) => !m.isHidden) + .map((m) => ({ + modelId: m.modelId, + fullModel: `${providerDisplayAlias}/${m.modelId}`, + })); + return onTestAll?.(targets); + }} + testingAll={testingAll} + testProgress={testProgress} + autoHideFailed={autoHideFailed} + onAutoHideFailedChange={onAutoHideFailedChange} />
{filteredModels.map(({ modelId, alias, isHidden, source, isFree }) => { @@ -7050,8 +7368,8 @@ const ERROR_TYPE_LABELS = { network_error: { labelKey: "errorTypeNetworkError", variant: "warning" }, unsupported: { labelKey: "errorTypeTestUnsupported", variant: "default" }, upstream_error: { labelKey: "errorTypeUpstreamError", variant: "error" }, - banned: { labelKey: "403 Banned", variant: "error" }, - credits_exhausted: { labelKey: "No Credits", variant: "warning" }, + banned: { labelKey: "errorTypeBanned", variant: "error" }, + credits_exhausted: { labelKey: "errorTypeCreditsExhausted", variant: "warning" }, }; function inferErrorType(connection, isCooldown) { @@ -10331,6 +10649,11 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec name: "", priority: 1, maxConcurrent: "", + rpm: "", + tpm: "", + tpd: "", + minTime: "", + rateLimitMaxConcurrent: "", apiKey: "", healthCheckInterval: 60, baseUrl: "", @@ -10460,6 +10783,26 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec connection.maxConcurrent !== null && connection.maxConcurrent !== undefined ? String(connection.maxConcurrent) : "", + rpm: + connection.rateLimitOverrides?.rpm != null + ? String(connection.rateLimitOverrides.rpm) + : "", + tpm: + connection.rateLimitOverrides?.tpm != null + ? String(connection.rateLimitOverrides.tpm) + : "", + tpd: + connection.rateLimitOverrides?.tpd != null + ? String(connection.rateLimitOverrides.tpd) + : "", + minTime: + connection.rateLimitOverrides?.minTime != null + ? String(connection.rateLimitOverrides.minTime) + : "", + rateLimitMaxConcurrent: + connection.rateLimitOverrides?.maxConcurrent != null + ? String(connection.rateLimitOverrides.maxConcurrent) + : "", apiKey: "", healthCheckInterval: connection.healthCheckInterval ?? 60, baseUrl: existingBaseUrl || defaultBaseUrl, @@ -10613,6 +10956,16 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec healthCheckInterval: formData.healthCheckInterval, }; + // Build rateLimitOverrides from non-empty fields + const overrides: Record = {}; + if (formData.rpm.trim()) overrides.rpm = Number(formData.rpm); + if (formData.tpm.trim()) overrides.tpm = Number(formData.tpm); + if (formData.tpd.trim()) overrides.tpd = Number(formData.tpd); + if (formData.minTime.trim()) overrides.minTime = Number(formData.minTime); + if (formData.rateLimitMaxConcurrent.trim()) + overrides.maxConcurrent = Number(formData.rateLimitMaxConcurrent); + updates.rateLimitOverrides = Object.keys(overrides).length > 0 ? overrides : null; + if (supportsGoogleProjectId) { updates.projectId = trimmedCloudCodeProjectId || null; } @@ -11057,6 +11410,60 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec type="password" /> )} +
+

+ {t("rateLimitOverridesSection")} +

+
+ setFormData({ ...formData, rpm: e.target.value })} + placeholder="Inherit" + hint={t("rateLimitOverridesRpmHint")} + /> + setFormData({ ...formData, tpm: e.target.value })} + placeholder="Inherit" + hint={t("rateLimitOverridesTpmHint")} + /> + setFormData({ ...formData, tpd: e.target.value })} + placeholder="Inherit" + hint={t("rateLimitOverridesTpdHint")} + /> + setFormData({ ...formData, minTime: e.target.value })} + placeholder="Inherit" + hint={t("rateLimitOverridesMinTimeHint")} + /> + + setFormData({ ...formData, rateLimitMaxConcurrent: e.target.value }) + } + placeholder="Inherit" + hint={t("rateLimitOverridesMaxConcurrentHint")} + /> +
+
)} ; export function getWebSessionCredentialRequirement( diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 845a82a64e..8da41a3e1b 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -107,7 +107,12 @@ export function filterConfiguredProviderEntries( let filtered = entries; if (showConfiguredOnly) { - filtered = filtered.filter((entry) => Number(entry.stats?.total || 0) > 0); + // no-auth providers never create a DB connection row (stats.total === 0) but + // are always usable and appear unconditionally in the /v1/models catalog, so + // they must not be hidden by the configured-only filter (#3290). + filtered = filtered.filter( + (entry) => entry.displayAuthType === "no-auth" || Number(entry.stats?.total || 0) > 0 + ); } if (showFreeOnly) { diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx index bd77236dee..f08fa359fd 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx @@ -38,7 +38,7 @@ export default function ProxyTab() {
{TABS.map((tab) => (
{stats && (
- Total: {stats.total} - In pool: {stats.inPool} - {stats.avgQuality != null && Avg quality: {stats.avgQuality}} + {t("proxyFreePoolTotal")}: {stats.total} + {t("proxyFreePoolInPool")}: {stats.inPool} + {stats.avgQuality != null && {t("proxyFreePoolAvgQuality")}: {stats.avgQuality}} {stats.lastSyncAt && ( - Last sync: {new Date(stats.lastSyncAt).toLocaleTimeString()} + {t("lastSync")}: {new Date(stats.lastSyncAt).toLocaleTimeString()} )}
)} {selected.size > 0 && (
- {selected.size} selected + {t("proxyFreePoolSelected", { count: selected.size })} {bulkProgress && {bulkProgress}}
@@ -209,7 +211,7 @@ export default function FreePoolTab() { variant="secondary" onClick={() => handleBulkAdd(notInPoolProxies.slice(0, 100).map((p) => p.id))} > - ⊕ Add all visible to pool + {t("proxyFreePoolAddVisible")}
)} @@ -220,22 +222,22 @@ export default function FreePoolTab() { - Source + {t("proxyFreePoolSource")} - Host:Port + {t("proxyFreePoolHostPort")} - Type + {t("proxyFreePoolType")} - Country + {t("proxyFreePoolCountry")} - Quality + {t("proxyFreePoolQuality")} - Latency + {t("proxyFreePoolLatency")} @@ -244,13 +246,13 @@ export default function FreePoolTab() { {loading ? ( - Loading... + {t("loading")} ) : proxies.length === 0 ? ( - No proxies found. Click Sync All to fetch from sources. + {t("proxyFreePoolEmpty")} ) : ( diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/VercelRelayModal.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/VercelRelayModal.tsx index 80b88edbc1..794674f119 100644 --- a/src/app/(dashboard)/dashboard/settings/components/proxy/VercelRelayModal.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/VercelRelayModal.tsx @@ -20,7 +20,7 @@ export default function VercelRelayModal({ isOpen, onClose, onDeployed }: Vercel const handleDeploy = async () => { if (!token.trim()) { - setError("Token is required"); + setError(t("vercelRelayTokenRequired")); return; } setDeploying(true); @@ -33,14 +33,14 @@ export default function VercelRelayModal({ isOpen, onClose, onDeployed }: Vercel }); const data = await res.json(); if (!res.ok || !data.success) { - setError(data.error?.message || "Deploy failed"); + setError(data.error?.message || t("vercelRelayDeployFailed")); } else { setToken(""); onDeployed(data.poolProxyId as string, data.relayUrl as string); onClose(); } } catch (err) { - setError(err instanceof Error ? err.message : "Unknown error"); + setError(err instanceof Error ? err.message : t("unknownError")); } finally { setDeploying(false); } @@ -63,7 +63,7 @@ export default function VercelRelayModal({ isOpen, onClose, onDeployed }: Vercel {t("vercelRelayModalTitle")} -