From 9350a5d6c648276c5b2bdd8769fcae678869afe0 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:52:29 -0300 Subject: [PATCH] Release v3.8.22 (#3623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(release): open v3.8.22 development cycle * refactor(dashboard): extract ProviderDetailPageClient — #3501 Phase 0 (#3633) #3501 Phase 0: extract ProviderDetailPageClient + smoke test. Co-authored-by: oyi77 * refactor(dashboard): extract auth-import modals — #3501 Phase 1a (#3634) #3501 Phase 1a: extract 3 auth-import modal clusters. Co-authored-by: oyi77 * fix(db): reclassify localDb unexported modules as intentionally-internal (#3499) (#3635) Closes #3499 — reclassify localDb unexported modules as intentionally-internal (audit + honest gate framing). * refactor(db): move call_logs aggregations into callLogStats db module (#3500) (#3636) #3500 slice 1: call_logs aggregations → src/lib/db/callLogStats.ts (Rule #5). Byte-identical queries; TDD 6/6. * refactor(dashboard): extract EditCompatibleNodeModal — #3501 Phase 1b (#3638) #3501 Phase 1b: extract EditCompatibleNodeModal (cycle-safe via leaf constants module). Co-authored-by: oyi77 * refactor(db): move community_servers SQL into gamification db module (#3500 slice 3) (#3639) #3500 slice 3: community_servers SQL → gamification db module. * refactor(db): move usage_history SQL into usageAnalytics module (#3500 slice 2) (#3644) #3500 slice 2: usage_history/daily_usage_summary SQL → usageAnalytics db module. * refactor(db): move skills UPDATE + db-backups SQL into db modules (#3500 slice 5) (#3647) #3500 slice 5: skills UPDATE (allowlist) + db-backups SQL → db modules. * refactor(db): move usage_logs/semantic_cache/proxy_logs SQL into db modules (#3500 slice 4) (#3648) #3500 slice 4: usage_logs/semantic_cache/proxy_logs SQL → db modules. All internal routes done (2 external by-design remain). * chore(db-gate): reclassify external-DB reads, fully close #3500 (#3649) Closes #3500: reclassify external-DB reads; all internal raw-SQL migrated to db/ modules. * refactor(dashboard): extract pure helpers to providerPageHelpers — #3501 Phase 2 (#3653) #3501 Phase 2: extract pure helpers to providerPageHelpers (leaf, cycle-safe). Co-authored-by: oyi77 * refactor(dashboard): extract remaining shared helpers to providerPageHelpers — #3501 Phase 2b (#3658) #3501 Phase 2b: extract remaining shared helpers to providerPageHelpers (leaf, cycle-safe). Heavy modals unblocked. Co-authored-by: oyi77 * fix(reasoning): replay reasoning_content on plain DeepSeek turns (#1682) (#3632) Integrated into release/v3.8.22 * fix(kiro): route enterprise IAM Identity Center accounts to their regional endpoint (#3631) Integrated into release/v3.8.22 * refactor: small code cleanup (#3523) Integrated into release/v3.8.22 * fix(combo): skip same-provider targets on 408/500/502/503/504/524 errors (#3637) Integrated into release/v3.8.22 — circuit-breaker guard added in review (#1731v2) * feat(providers): add MiMoCode free-tier provider with bootstrap JWT auth (#3659) Integrated into release/v3.8.22 — page.tsx conflict resolved + NoAuthAccountCard re-applied to ProviderDetailPageClient in review. MiMoCode endpoint validated live. * Log Responses WebSocket calls in history (#3616) Integrated into release/v3.8.22 — Codex Responses WebSocket call history logging. * Add Claude Code routing preference for unprefixed Claude models (#3540) Integrated into release/v3.8.22 — page.tsx conflict resolved (re-applied toggle to ProviderDetailPageClient) + disable-test updated for catalog drift in review. * docs(changelog): credit #3632/#3631/#3637/#3659/#3540/#3616/#3523 (v3.8.22 targeted review round) * fix(mimocode): add required authHeader:"none" to registry entry (#3659 follow-up) The mimocode RegistryEntry omitted the required authHeader field, which broke typecheck:core (TS2741). Match the no-auth convention (authType:"none" + authHeader:"none") used by veoaifree-web and other free providers. Follow-up to #3659 (@pizzav-xyz). * fix(responses): detect stream readiness for tool-call-only and object-less chunks (#3612) (#3661) Closes #3612 * fix(mitm): remove duplicated 'Command failed:' error prefix (#3641) (#3662) Closes #3641 * fix(cli): honor HERMES_HOME for Hermes Agent config path (#3628) (#3663) Closes #3628 * fix(api): fetch live OpenCode model catalog for no-auth model picker (#3611) (#3664) Closes #3611 * fix(api): flag provider topology error state by current status, not stale history (#3619) (#3666) Closes #3619 * fix(electron): launch peer-stamping server-ws.mjs entrypoint to avoid 403 LOCAL_ONLY (#3386) (#3665) Closes #3386 * fix(dashboard): restore home topology live in-flight pulse (#3507) (#3667) Closes #3507 * fix(oauth): name Kiro/AWS auto-imported accounts and dedupe by profileArn (#3615) (#3671) Closes #3615 * fix(resilience): clear stale transient connection cooldowns on startup (#3625) (#3672) Closes #3625 * fix(i18n): use logical CSS direction utilities for sidebar and key overlays (RTL #3541) (#3670) Closes #3541 * fix(dashboard): honor auto-hide and switch to visible filter on passthrough Test-all (#3610) (#3669) Closes #3610 * refactor(dashboard): extract AddApiKeyModal + EditConnectionModal — #3501 Phase 1c (#3674) #3501 Phase 1c: extract AddApiKeyModal, EditConnectionModal, WebSessionCredentialGuide into components/; god-component 10,166->8,092 LOC. Reconciles the v3.8.22 file-size drift for this file. Co-authored-by: oyi77 * docs(changelog): reconcile v3.8.22 — credit #3621/#3622 + MiMoCode follow-up roll-up * refactor(dashboard): extract ConnectionRow + ModelCompatPopover + SiliconFlowEndpointModal — #3501 Phase 1d (#3676) #3501 Phase 1d: god-component 8,092->6,838 LOC. Co-authored-by: oyi77 * feat(obsidian): add WebDAV config route + encrypt creds at rest (#3485 part 1) (#3677) Part 1 of #3485. Adds /api/settings/obsidian/webdav (GET/POST/DELETE) wiring the ready obsidianSync lib, encrypts webdav password + obsidian token at rest, removes the duplicate UI block, drops the KNOWN_MISSING entry. WebDAV file server is part 2. * feat(obsidian): add /api/v1/webdav file server for Obsidian vault sync (#3485 part 2) (#3678) Part 2 of #3485. WebDAV server (PROPFIND/GET/PUT/DELETE/MKCOL/MOVE/OPTIONS) handled in the custom server layer (standalone-server-ws.mjs) since the App Router cannot export WebDAV methods. Basic-Auth (constant-time), path-traversal hardened, password decrypt ported from encryption.ts (parity-tested), DATA_DIR resolution parity-tested against dataPaths.ts. End-to-end Obsidian-over-Tailscale validation is a live VPS step (Rule #18). * fix(combo): stop premature context compaction — real auto-combo windows + per-target compression limit (#3680) Integrated into release/v3.8.22 * feat(dashboard): deactivate/activate accounts from the quota overview (#3675) Integrated into release/v3.8.22 * fix(dashboard): close review gaps in bulk provider connection actions (#3271 follow-up) (#3673) Integrated into release/v3.8.22 — page.tsx conflict (god-component split #3501) resolved by re-applying the bulk-action deltas to ProviderDetailPageClient.tsx * refactor(dashboard): extract useModelCompatState hook + model sections — #3501 Phase 1e (#3683) #3501 Phase 1e: extract useModelCompatState hook (unblocks the model sections) + ModelRow/PassthroughModelsSection/PassthroughModelRow/CustomModelsSection/CompatibleModelsSection. god-component 6,838->4,921 LOC. Co-authored-by: oyi77 * refactor(dashboard): extract useProviderConnections/Settings/Models hooks — #3501 Phase 1f (#3684) #3501 Phase 1f: god-component 4,948->4,062 LOC. Connection state+handlers, settings, and model metadata moved into hooks/. Co-authored-by: oyi77 * chore(release): v3.8.22 CHANGELOG + env-doc sync - Set release date in CHANGELOG [3.8.22] to 2026-06-11 - Add HERMES_HOME to .env.example (from #3628/#3663) - Add HERMES_HOME + OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS to ENVIRONMENT.md (#3628/#3540) * docs(changelog): credit #3673 + #3675 — leninejunior bulk-actions + quota-toggle --------- Co-authored-by: oyi77 Co-authored-by: Abhishek Divekar Co-authored-by: NOXX - Commiter Co-authored-by: Nicolas Lorin Co-authored-by: Hernan Javier Ardila Sanchez Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com> Co-authored-by: kkkayye <98376609+kkkayye@users.noreply.github.com> Co-authored-by: Witroch4 Co-authored-by: Lenine Júnior --- .env.example | 13 +- @omniroute/opencode-plugin/package.json | 2 +- @omniroute/opencode-plugin/src/index.ts | 29 +- .../tests/auto-combo-context.test.ts | 74 + CHANGELOG.md | 311 +- 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/ENVIRONMENT.md | 494 +- docs/reference/openapi.yaml | 2 +- docs/routing/AUTO-COMBO.md | 2 + electron/lib/resolveServerEntry.js | 26 + electron/main.js | 7 +- electron/package.json | 3 +- file-size-baseline.json | 13 +- open-sse/config/providerRegistry.ts | 15 + open-sse/executors/index.ts | 4 + open-sse/executors/kiro.ts | 37 +- open-sse/executors/mimocode.ts | 328 + open-sse/handlers/chatCore.ts | 26 +- open-sse/package.json | 2 +- open-sse/services/autoCombo/virtualFactory.ts | 53 + open-sse/services/combo.ts | 90 + open-sse/services/contextManager.ts | 60 +- open-sse/services/model.ts | 63 +- open-sse/services/usage.ts | 18 +- open-sse/translator/index.ts | 30 +- open-sse/utils/streamReadiness.ts | 22 +- package-lock.json | 6 +- package.json | 2 +- scripts/check/check-db-rules.mjs | 121 +- scripts/check/check-fetch-targets.mjs | 2 +- scripts/dev/responses-ws-proxy.mjs | 195 +- scripts/dev/standalone-server-ws.mjs | 33 +- scripts/dev/webdav-handler.mjs | 1002 ++ .../(dashboard)/dashboard/HomePageClient.tsx | 7 +- .../components/ObsidianSourceCard.tsx | 105 - .../[id]/ProviderDetailPageClient.tsx | 4062 +++++ .../ProviderDetailPageClient.test.tsx | 136 + .../__tests__/passthroughTestHelpers.test.tsx | 79 + .../providers/[id]/__tests__/phase1e.test.tsx | 304 + .../providers/[id]/__tests__/phase1f.test.tsx | 345 + .../components/CompatibleModelsSection.tsx | 463 + .../[id]/components/ConnectionRow.tsx | 940 ++ .../[id]/components/CustomModelsSection.tsx | 562 + .../[id]/components/ModelCompatPopover.tsx | 355 + .../providers/[id]/components/ModelRow.tsx | 330 + .../[id]/components/PassthroughModelRow.tsx | 178 + .../components/PassthroughModelsSection.tsx | 421 + .../components/SiliconFlowEndpointModal.tsx | 59 + .../components/WebSessionCredentialGuide.tsx | 114 + .../components/__tests__/phase1d.test.tsx | 202 + .../[id]/components/modals/AddApiKeyModal.tsx | 842 + .../modals/EditCompatibleNodeModal.tsx | 284 + .../components/modals/EditConnectionModal.tsx | 1170 ++ .../modals/ImportClaudeAuthModal.tsx | 730 + .../modals/ImportCodexAuthModal.tsx | 743 + .../modals/ImportGeminiAuthModal.tsx | 706 + .../__tests__/authImportModals.test.tsx | 71 + .../modals/__tests__/connModals.test.tsx | 155 + .../editCompatibleNodeModal.test.tsx | 62 + .../[id]/hooks/useModelCompatState.ts | 101 + .../[id]/hooks/useProviderConnections.ts | 953 ++ .../providers/[id]/hooks/useProviderModels.ts | 154 + .../[id]/hooks/useProviderSettings.ts | 263 + .../dashboard/providers/[id]/page.tsx | 12884 +--------------- .../providers/[id]/providerDetailConstants.ts | 6 + .../providers/[id]/providerPageHelpers.ts | 821 + .../components/ProviderLimits/QuotaCard.tsx | 9 +- .../ProviderLimits/QuotaCardGrid.tsx | 6 + .../usage/components/ProviderLimits/index.tsx | 35 + .../ProviderLimits/parts/QuotaCardHeader.tsx | 32 +- src/app/(dashboard)/home/topologyUtils.ts | 27 + src/app/api/analytics/auto-routing/route.ts | 49 +- src/app/api/cache/entries/route.ts | 65 +- .../cli-tools/hermes-agent-settings/route.ts | 9 +- src/app/api/combos/auto/route.ts | 4 + src/app/api/db-backups/exportAll/route.ts | 51 +- src/app/api/db-backups/import/route.ts | 16 +- .../federation/leaderboard/route.ts | 7 +- .../gamification/federation/score/route.ts | 7 +- .../api/internal/codex-responses-ws/route.ts | 189 + src/app/api/logs/export/route.ts | 10 +- src/app/api/oauth/kiro/auto-import/route.ts | 109 +- src/app/api/provider-metrics/route.ts | 52 +- src/app/api/providers/[id]/models/route.ts | 57 + src/app/api/providers/route.ts | 15 +- src/app/api/providers/test-batch/route.ts | 3 + src/app/api/search/stats/route.ts | 33 +- src/app/api/settings/export-json/route.ts | 13 +- src/app/api/settings/obsidian/webdav/route.ts | 81 + src/app/api/skills/[id]/route.ts | 24 +- src/app/api/usage/analytics/route.ts | 528 +- src/app/api/v1/search/analytics/route.ts | 32 +- src/i18n/messages/ar.json | 4 + src/i18n/messages/az.json | 4 + src/i18n/messages/bg.json | 4 + src/i18n/messages/bn.json | 4 + src/i18n/messages/cs.json | 4 + src/i18n/messages/da.json | 4 + src/i18n/messages/de.json | 4 + src/i18n/messages/en.json | 12 +- src/i18n/messages/es.json | 4 + src/i18n/messages/fa.json | 4 + src/i18n/messages/fi.json | 4 + src/i18n/messages/fr.json | 4 + src/i18n/messages/gu.json | 4 + src/i18n/messages/he.json | 4 + src/i18n/messages/hi.json | 4 + src/i18n/messages/hu.json | 4 + src/i18n/messages/id.json | 4 + src/i18n/messages/in.json | 4 + src/i18n/messages/it.json | 4 + src/i18n/messages/ja.json | 4 + src/i18n/messages/ko.json | 4 + src/i18n/messages/mr.json | 4 + src/i18n/messages/ms.json | 4 + src/i18n/messages/nl.json | 4 + src/i18n/messages/no.json | 4 + src/i18n/messages/phi.json | 4 + src/i18n/messages/pl.json | 4 + src/i18n/messages/pt-BR.json | 10 + src/i18n/messages/pt.json | 4 + src/i18n/messages/ro.json | 4 + src/i18n/messages/ru.json | 5 + src/i18n/messages/sk.json | 4 + src/i18n/messages/sv.json | 4 + src/i18n/messages/sw.json | 4 + src/i18n/messages/ta.json | 4 + src/i18n/messages/te.json | 4 + src/i18n/messages/th.json | 4 + src/i18n/messages/tr.json | 4 + src/i18n/messages/uk-UA.json | 4 + src/i18n/messages/ur.json | 4 + src/i18n/messages/vi.json | 4 + src/i18n/messages/zh-CN.json | 5 + src/instrumentation-node.ts | 19 + src/lib/audit/activityIcons.ts | 29 +- src/lib/audit/highLevelActions.ts | 1 + .../config-generator/hermes-agent.ts | 10 +- .../cli-helper/config-generator/hermesHome.ts | 40 + src/lib/cli-helper/config-generator/index.ts | 21 +- src/lib/db/backup.ts | 117 + src/lib/db/callLogStats.ts | 235 + src/lib/db/compressionAnalytics.ts | 89 +- src/lib/db/gamification.ts | 11 + src/lib/db/obsidian.ts | 17 +- src/lib/db/providers.ts | 85 +- src/lib/db/proxyLogs.ts | 35 + src/lib/db/semanticCache.ts | 117 + src/lib/db/settings.ts | 7 + src/lib/db/skills.ts | 62 + src/lib/db/usageAnalytics.ts | 872 ++ src/lib/db/usageLogs.ts | 94 + src/lib/localDb.ts | 110 + src/lib/oauth/providers/kiro.ts | 44 +- src/mitm/systemCommands.ts | 7 +- src/shared/components/LanguageSelector.tsx | 4 +- src/shared/components/NoAuthAccountCard.tsx | 170 + src/shared/components/Select.tsx | 4 +- src/shared/components/Sidebar.tsx | 2 +- src/shared/components/index.tsx | 1 + .../components/layouts/DashboardLayout.tsx | 2 +- src/shared/constants/providers.ts | 23 +- src/shared/services/cliRuntime.ts | 13 +- src/shared/utils/machine.ts | 13 - src/shared/validation/schemas.ts | 1 + src/shared/validation/settingsSchemas.ts | 1 + src/types/settings.ts | 1 + .../unit/audit-allowlist-real-actions.test.ts | 19 +- tests/unit/audit-high-level-actions.test.ts | 6 +- .../auto-combo-context-advertising.test.ts | 210 + .../check-db-rules-classification.test.ts | 213 + tests/unit/cli-helper/hermes-home-env.test.ts | 105 + tests/unit/db-backups-skills-3500.test.ts | 236 + tests/unit/db-call-log-stats-3500.test.ts | 262 + .../db-gamification-federation-3500.test.ts | 81 + tests/unit/db-logs-cache-3500.test.ts | 348 + tests/unit/db-usage-analytics-3500.test.ts | 573 + .../electron-resolve-server-entry.test.ts | 64 + .../kiro-auto-import-name-dedup-3615.test.ts | 153 + tests/unit/kiro-iam-region.test.ts | 123 + tests/unit/mimocode-executor.test.ts | 169 + .../mitm-system-commands-prefix-3641.test.ts | 55 + tests/unit/model-resolver.test.ts | 124 + tests/unit/obsidian-webdav-route.test.ts | 301 + .../unit/opencode-noauth-models-route.test.ts | 95 +- tests/unit/provider-metrics-route.test.ts | 37 + tests/unit/provider-page-helpers-3501.test.ts | 239 + tests/unit/quota-deactivate-toggle.test.ts | 83 + tests/unit/reasoning-cache.test.ts | 98 +- tests/unit/responses-ws-proxy.test.mjs | 116 +- tests/unit/rtl-logical-classes.test.tsx | 117 + ...settings-schema-routing-strategies.test.ts | 12 + .../startup-stale-cooldown-recovery.test.ts | 277 + tests/unit/stream-readiness.test.ts | 25 +- .../topology-active-requests-3507.test.ts | 72 + tests/unit/webdav-server-3485.test.ts | 784 + vitest.config.ts | 1 + 237 files changed, 26437 insertions(+), 14403 deletions(-) create mode 100644 @omniroute/opencode-plugin/tests/auto-combo-context.test.ts create mode 100644 electron/lib/resolveServerEntry.js create mode 100644 open-sse/executors/mimocode.ts create mode 100644 scripts/dev/webdav-handler.mjs create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/__tests__/ProviderDetailPageClient.test.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/__tests__/passthroughTestHelpers.test.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1f.test.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/ModelRow.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelRow.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/SiliconFlowEndpointModal.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/WebSessionCredentialGuide.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/modals/ImportClaudeAuthModal.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/modals/ImportCodexAuthModal.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/modals/ImportGeminiAuthModal.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/authImportModals.test.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/connModals.test.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/editCompatibleNodeModal.test.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelCompatState.ts create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/providerDetailConstants.ts create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts create mode 100644 src/app/(dashboard)/home/topologyUtils.ts create mode 100644 src/app/api/settings/obsidian/webdav/route.ts create mode 100644 src/lib/cli-helper/config-generator/hermesHome.ts create mode 100644 src/lib/db/callLogStats.ts create mode 100644 src/lib/db/proxyLogs.ts create mode 100644 src/lib/db/semanticCache.ts create mode 100644 src/lib/db/skills.ts create mode 100644 src/lib/db/usageAnalytics.ts create mode 100644 src/lib/db/usageLogs.ts create mode 100644 src/shared/components/NoAuthAccountCard.tsx create mode 100644 tests/unit/auto-combo-context-advertising.test.ts create mode 100644 tests/unit/check-db-rules-classification.test.ts create mode 100644 tests/unit/cli-helper/hermes-home-env.test.ts create mode 100644 tests/unit/db-backups-skills-3500.test.ts create mode 100644 tests/unit/db-call-log-stats-3500.test.ts create mode 100644 tests/unit/db-gamification-federation-3500.test.ts create mode 100644 tests/unit/db-logs-cache-3500.test.ts create mode 100644 tests/unit/db-usage-analytics-3500.test.ts create mode 100644 tests/unit/electron-resolve-server-entry.test.ts create mode 100644 tests/unit/kiro-auto-import-name-dedup-3615.test.ts create mode 100644 tests/unit/kiro-iam-region.test.ts create mode 100644 tests/unit/mimocode-executor.test.ts create mode 100644 tests/unit/mitm-system-commands-prefix-3641.test.ts create mode 100644 tests/unit/obsidian-webdav-route.test.ts create mode 100644 tests/unit/provider-page-helpers-3501.test.ts create mode 100644 tests/unit/quota-deactivate-toggle.test.ts create mode 100644 tests/unit/rtl-logical-classes.test.tsx create mode 100644 tests/unit/startup-stale-cooldown-recovery.test.ts create mode 100644 tests/unit/topology-active-requests-3507.test.ts create mode 100644 tests/unit/webdav-server-3485.test.ts diff --git a/.env.example b/.env.example index 0be8cec9d4..df5703dd70 100644 --- a/.env.example +++ b/.env.example @@ -292,6 +292,12 @@ ALLOW_API_KEY_REVEAL=false # Default: 5000 | Minimum: 1000 # OMNIROUTE_PAYLOAD_RULES_RELOAD_MS=5000 +# Prefer Claude Code OAuth for unprefixed Claude-family model IDs such as +# claude-sonnet-4-6 or newly released IDs like claude-fable-5. +# Used by: open-sse/services/model.ts. Explicit provider prefixes still win. +# Default: false +# OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS=false + # ═══════════════════════════════════════════════════════════════════════════════ # 7. URLS & CLOUD SYNC # ═══════════════════════════════════════════════════════════════════════════════ @@ -421,6 +427,12 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # CLI_QODER_BIN=qoder # CLI_QWEN_BIN=qwen +# Override the Hermes Agent home directory (where OmniRoute reads/writes the +# Hermes CLI config). Matches the env var the Hermes PowerShell installer sets +# on Windows (%LOCALAPPDATA%\hermes); defaults to ~/.hermes when unset. +# Used by: src/lib/cli-helper/config-generator/hermesHome.ts +# HERMES_HOME=~/.hermes + # ═══════════════════════════════════════════════════════════════════════════════ # 10. INTERNAL AGENT & MCP INTEGRATIONS # ═══════════════════════════════════════════════════════════════════════════════ @@ -1519,4 +1531,3 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # OpenCode-style API key (sk-...) for the regenerated opencode.json. Used by: # scripts/ad-hoc/regen-opencode-config.ts. Falls back to OMNIROUTE_KEY. # OPENCODE_API_KEY= - diff --git a/@omniroute/opencode-plugin/package.json b/@omniroute/opencode-plugin/package.json index 8489bbd9e1..c36a77f011 100644 --- a/@omniroute/opencode-plugin/package.json +++ b/@omniroute/opencode-plugin/package.json @@ -26,7 +26,7 @@ "scripts": { "build": "tsup", "clean": "rm -rf dist", - "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts", + "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts", "prepublishOnly": "npm run clean && npm run build && npm test" }, "keywords": [ diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 19aaf6cf21..8ad06edfe6 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -1121,6 +1121,11 @@ export interface OmniRouteRawAutoCombo { candidatePool?: string[]; /** Number of candidates resolved at fetch time. */ candidateCount?: number; + /** MAX of candidates' context windows, served by newer OmniRoute builds. + * Absent on older servers — mapper falls back to a safe positive default. */ + context_length?: number; + /** MAX of candidates' max output tokens (same provenance as context_length). */ + max_output_tokens?: number; /** Whether this auto combo should be hidden from the picker. */ isHidden?: boolean; /** Auto-combo configuration. */ @@ -1211,24 +1216,42 @@ export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = } }; +/** Fallbacks when the server does not advertise auto-combo limits (older + * OmniRoute builds). MUST be positive: OpenCode's overflow guard treats + * `limit.context === 0` as "never overflow" and silently DISABLES smart + * auto-compaction, letting the session grow until the gateway's destructive + * history purge kicks in (the "agent keeps forgetting things" bug). */ +const AUTO_COMBO_FALLBACK_CONTEXT = 128_000; +const AUTO_COMBO_FALLBACK_OUTPUT = 8_192; + /** * Convert a raw auto combo into a static model entry for the OpenCode picker. * Auto combos have tool_call=true, reasoning=true by default (they route - * to capable models). Context/output limits are set to 0 since the actual - * limits depend on which provider is selected at runtime. + * to capable models). Context/output limits come from the server (MAX of + * the candidate pool's windows — the gateway's context pre-filter routes + * oversized requests to large-window candidates); a safe positive fallback + * applies when the server omits them. Never 0. */ export function mapAutoComboToStaticEntry( autoCombo: OmniRouteRawAutoCombo, ): OmniRouteStaticModelEntry { const variant = autoCombo.variant; const name = formatAutoComboName(variant, autoCombo.candidateCount); + const context = + typeof autoCombo.context_length === "number" && autoCombo.context_length > 0 + ? autoCombo.context_length + : AUTO_COMBO_FALLBACK_CONTEXT; + const output = + typeof autoCombo.max_output_tokens === "number" && autoCombo.max_output_tokens > 0 + ? autoCombo.max_output_tokens + : AUTO_COMBO_FALLBACK_OUTPUT; return { name, attachment: false, reasoning: true, temperature: true, tool_call: true, - limit: { context: 0, output: 0 }, + limit: { context, output }, modalities: { input: ["text"], output: ["text"], diff --git a/@omniroute/opencode-plugin/tests/auto-combo-context.test.ts b/@omniroute/opencode-plugin/tests/auto-combo-context.test.ts new file mode 100644 index 0000000000..73213dde02 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/auto-combo-context.test.ts @@ -0,0 +1,74 @@ +/** + * TDD regression — auto combos must never advertise `limit.context: 0`. + * + * opencode's overflow guard (packages/opencode/src/session/overflow.ts) + * short-circuits when `model.limit.context === 0`: + * + * if (input.model.limit.context === 0) return false // never overflow + * + * so a zero context silently DISABLES opencode's smart auto-compaction for + * auto combos. The session then grows unbounded until OmniRoute's + * server-side purifyHistory() destructively drops old messages — the + * "coding agent keeps forgetting things" bug. + * + * Fix under test: mapAutoComboToStaticEntry consumes the context_length / + * max_output_tokens now served by GET /api/combos/auto, and falls back to a + * safe positive default (128000 / 8192) for older servers that do not send + * the fields yet. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { mapAutoComboToStaticEntry } from "../src/index.ts"; +import type { OmniRouteRawAutoCombo } from "../src/index.ts"; + +test("uses server-provided context_length and max_output_tokens", () => { + const raw = { + id: "auto/coding", + name: "Auto Coding", + variant: "coding", + candidateCount: 5, + context_length: 1048576, + max_output_tokens: 65536, + } as OmniRouteRawAutoCombo; + + const entry = mapAutoComboToStaticEntry(raw); + assert.equal(entry.limit?.context, 1048576); + assert.equal(entry.limit?.output, 65536); +}); + +test("falls back to a safe positive default when the server omits limits (old servers)", () => { + const raw = { + id: "auto", + name: "Auto", + candidateCount: 3, + } as OmniRouteRawAutoCombo; + + const entry = mapAutoComboToStaticEntry(raw); + assert.ok( + typeof entry.limit?.context === "number" && entry.limit.context > 0, + `context must be a positive number (never 0 — zero disables opencode auto-compaction), got ${entry.limit?.context}` + ); + assert.ok( + typeof entry.limit?.output === "number" && entry.limit.output > 0, + `output must be a positive number, got ${entry.limit?.output}` + ); +}); + +test("ignores non-positive server values and keeps the safe fallback", () => { + const raw = { + id: "auto/fast", + name: "Auto Fast", + variant: "fast", + candidateCount: 2, + context_length: 0, + max_output_tokens: -1, + } as OmniRouteRawAutoCombo; + + const entry = mapAutoComboToStaticEntry(raw); + assert.ok( + typeof entry.limit?.context === "number" && entry.limit.context > 0, + "zero/negative server values must not propagate" + ); + assert.ok(typeof entry.limit?.output === "number" && entry.limit.output > 0); +}); diff --git a/CHANGELOG.md b/CHANGELOG.md index d3e24dea84..942ca20da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,70 @@ --- +## [3.8.22] — 2026-06-11 + +### ✨ Added + +- **MiMoCode free-tier provider** ([#3659] — thanks @pizzav-xyz): new no-auth provider `mimocode` (alias `mcode`) exposing Xiaomi's `mimo-auto` model (1M context) via device-fingerprint bootstrap-JWT auth (`/api/free-ai/bootstrap` → Bearer JWT → `/api/free-ai/openai/chat`). Supports multiple accounts (N fingerprints → round-robin with exponential cooldown), re-bootstrap on 401/403, and cooldown on 429. Reuses a new generic `NoAuthAccountCard` dashboard component (also wired for `opencode`). 22 unit tests; upstream validated live during review. (Maintainer follow-up: added the required `authHeader: "none"` field to the registry entry.) Co-authored with @pizzav-xyz. +- **Prefer Claude Code for unprefixed `claude-*` model IDs** ([#3540] — thanks @Witroch4): opt-in setting (default off) that routes bare `claude-*` model IDs from Claude Code clients through the Claude Code OAuth account instead of requiring a provider prefix. Configurable via the `OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS` env flag or a dashboard toggle on the Claude provider page; explicit provider prefixes still win. Full layer coverage (resolver + DB setting + zod schemas + types + UI) with 6 tests. Co-authored with @Witroch4. +- **Codex Responses-WebSocket call history** ([#3616] — thanks @kkkayye): Codex `/v1/responses` WebSocket calls are now persisted to request history — success completions plus prepare-failures, upstream WS errors and premature closes — with `sanitizeErrorMessage` applied to the stored error. Two proxy-side integration tests cover the success and failure paths. +- **Obsidian/WebDAV**: add the `/api/v1/webdav` file server (PROPFIND/GET/PUT/DELETE/MKCOL/MOVE, Basic-Auth, path-traversal hardened) so Obsidian mobile can sync the vault (#3485, part 2). Implemented in the custom server layer (`scripts/dev/webdav-handler.mjs`) — intercepted before Next.js to support non-standard HTTP methods (`PROPFIND`, `MKCOL`, `MOVE`, `LOCK`). Reads vault path and credentials (with enc:v1: AES-256-GCM decryption) directly from the SQLite `key_value` table; credentials configured via PR1's `/api/settings/obsidian/webdav` endpoint. 36 TDD unit tests covering traversal guard, constant-time auth, decrypt round-trip, XML generation, and full CRUD cycle. +- **Quota overview**: deactivate/activate an account directly from the quota card header (toggle button) so users can park a near-zero-quota account without navigating to the provider detail page. ([#3675](https://github.com/diegosouzapw/OmniRoute/pull/3675) — thanks @leninejunior) + +### ♻️ Code Quality + +- **providers/[id]**: extract `useProviderConnections`, `useProviderSettings`, `useProviderModels` hooks from the god-component — #3501 Phase 1f. `ProviderDetailPageClient.tsx`: 4,948 → 4,063 LOC (−885 lines). New hooks in `hooks/`: `useProviderConnections.ts` (954 LOC — all connection management, batch ops, proxy/CLIProxyAPI state, batch-test runner with MAX_BULK_IDS chunking), `useProviderSettings.ts` (264 LOC — Codex global service mode + Claude routing preference), `useProviderModels.ts` (155 LOC — model metadata, aliases). Frozen baselines updated. 10 Phase-1f smoke tests; typecheck/cycles/lint green. Co-authored with @oyi77. +- **providers/[id]**: extract `useModelCompatState` hook + model sections (`ModelRow`, `PassthroughModelRow`, `PassthroughModelsSection`, `CustomModelsSection`, `CompatibleModelsSection`) from the god-component — #3501 Phase 1e. `ProviderDetailPageClient.tsx`: 6,838 → 4,922 LOC (−1,916 lines). New leaf `hooks/useModelCompatState.ts` (101 LOC); compat helpers moved to `providerPageHelpers.ts`. Frozen baselines: `providerPageHelpers.ts: 822`. 12 Phase-1e smoke tests; typecheck/cycles/lint green; #3610 auto-hide fix preserved. +- **providers/[id]**: extract `ConnectionRow` (+ `CooldownTimer`/`inferErrorType`/`getStatusPresentation`), `ModelCompatPopover` (+ `recordToHeaderRows`), and `SiliconFlowEndpointModal` from the god-component into `components/` — #3501 Phase 1d. `ProviderDetailPageClient.tsx`: 8,092 → 6,838 LOC (−1,254 lines). Frozen baselines: `ConnectionRow.tsx: 941`. 7 new Phase-1d smoke tests; typecheck/cycles/lint green. +- **providers/[id]: extract AddApiKeyModal + EditConnectionModal (+ WebSessionCredentialGuide) from the god-component into components/** ([#3501] Phase 1c): extracted the two heaviest inline modals — `AddApiKeyModal` (~787-LOC body) and `EditConnectionModal` (~1091-LOC body) — plus shared `WebSessionCredentialGuide` (~103 LOC) into standalone files under `providers/[id]/components/modals/` and `providers/[id]/components/` respectively. Added `ERROR_TYPE_LABELS` and `formatTimeAgo` to `providerPageHelpers.ts` (leaf) so `EditConnectionModal` and `ConnectionRow` share them without cycles. Pruned 14 now-unused imports from the god-component. `ProviderDetailPageClient.tsx`: 9,981 → 8,092 LOC (−1,889 lines). Frozen baselines: `AddApiKeyModal.tsx: 842`, `EditConnectionModal.tsx: 1170`. 6 new Phase-1c smoke tests; all 21 vitest modal tests pass; typecheck/cycles/lint green. +- **refactor: small db/utils cleanup** ([#3523] — thanks @androw): table-driven `compression_analytics` column migration (replaces 17 repeated `ALTER TABLE` calls), a single merged `serializeJsonField` helper in `db/providers.ts` (folded two byte-identical serializers), and removal of the dead no-op `syncProviderDataToCloud`/`getProvidersNeedingRefresh` stubs from `shared/utils/machine.ts` (no remaining callers). Pure refactor; behavior unchanged. +- **Provider-detail god-component decomposition — Phase 2b (remaining shared helpers→leaf)** ([#3501]): extended `providers/[id]/providerPageHelpers.ts` with all remaining pure helpers needed by the heavy modals (`AddApiKeyModal`/`EditConnectionModal`) before they can be extracted. Moved 22 symbols: web-session credential label/hint/check/title helpers; upstream-headers helpers (`upstreamHeadersRecordsEqual`, `headerRowsToRecord`, `effectiveUpstreamHeadersForProtocol`, `anyUpstreamHeadersBadge`, `getProtoSlice`) plus their `HeaderDraftRow`/`CompatModelRow`/`CompatModelMap`/`CompatByProtocolMap` types; Codex consts and helpers (`CODEX_REASONING_STRENGTH_OPTIONS`, `CODEX_ACCOUNT_SERVICE_TIER_VALUES`, `CODEX_GLOBAL_SERVICE_MODE_VALUES`, `getCodexServiceTierLabel`, `normalizeCodexLimitPolicy`, `getCodexRequestDefaults`, `getClaudeCodeCompatibleRequestDefaults`); misc helpers (`compatProtocolLabelKey`, `extractCommandCodeCredentialInput`, `normalizeAndValidateHttpBaseUrl`, `SILICONFLOW_ENDPOINTS`, `CommandCodeAuthFlowState`). New transitive imports wired into the leaf: `MODEL_COMPAT_PROTOCOL_KEYS` (`@/shared/constants/modelCompat`), `CodexServiceTier`/`getCodexRequestDefaults`/`getClaudeCodeCompatibleRequestDefaults` (`@/lib/providers/requestDefaults`), `CodexGlobalServiceMode` (`@/lib/providers/codexFastTier`), `WebSessionCredentialRequirement` (`./webSessionCredentials`). `ProviderDetailPageClient.tsx`: 10,288 → 9,980 LOC. Leaf module: 589 LOC (acyclic). 25-assertion unit test suite passes; smoke test 3/3; no import cycles. Co-authored with @oyi77. +- **Provider-detail god-component decomposition — Phase 2 (helpers→lib)** ([#3501]): extracted the pure shared helpers — `ProviderMessageTranslator`/`LocalProviderMetadata` types, `providerText`/`providerCountText`/`readBooleanToggle`, and the provider base-URL + routing-tag/excluded-model parse/format block — into a new leaf `providers/[id]/providerPageHelpers.ts` (imports only `@/shared`, so the client and modals share them with no import cycle). `ProviderDetailPageClient.tsx`: 10,435 → 10,288 LOC. Unblocks extracting the heavier `AddApiKeyModal`/`EditConnectionModal` (which depend on these helpers) without cycling. The Phase 0 smoke test caught a missing transitive import (`isSelfHostedChatProvider`) at mount — now wired + locked by a new helpers unit test (12 assertions). Co-authored with @oyi77. + +- **#3500 fully resolved** — Hard Rule #5 (no raw SQL in route handlers): all 13 internal offenders migrated to `src/lib/db/` modules across slices (call*logs, usage_history/daily_usage_summary, community_servers, usage_logs, semantic_cache, proxy_logs, skills UPDATE, db-backups). The gate's `KNOWN_RAW_SQL` set is renamed to `EXTERNAL_DB_ALLOWED` (with a back-compat alias) and now holds only the **2 external-DB reads** (`oauth/cursor/auto-import`, `oauth/kiro/auto-import`) — these open \_another app's* SQLite to import credentials, so by design they cannot live in OmniRoute's `db/` domain. The gate still blocks any NEW raw SQL against OmniRoute's DB. +- **chore(db-gate):** reclassify `KNOWN_UNEXPORTED` → `INTENTIONALLY_INTERNAL` in `scripts/check/check-db-rules.mjs` ([#3499]): a full audit of all 25 db modules confirmed each is consumed via direct/dynamic import per Hard Rule #2 ("Never barrel-import from localDb.ts"). The old framing labelled them as "debt", which was misleading — they are the correct pattern. The gate's blocking behaviour is unchanged (a NEW unexported module still fails); only the name, comments, and per-module justifications were updated to reflect audited truth. Four modules flagged `DEAD?` (`compressionScheduler`, `discovery`, `pluginMetrics`, `prompts`) have zero production importers and are documented as schema-reserved. A new regression-guard test (`tests/unit/check-db-rules-classification.test.ts`) asserts every non-dead module in the set has ≥1 real importer, so a future consumer removal surfaces as a test failure requiring explicit reclassification. +- **refactor(db): move `call_logs` aggregations into `callLogStats` db module** ([#3500]): extracted raw SQL from three route handlers (`/api/provider-metrics`, `/api/search/stats`, `/api/v1/search/analytics`) into a new `src/lib/db/callLogStats.ts` domain module (`getProviderMetrics`, `getSearchProviderStats`, `getRecentSearchLogs`, `getSearchAggregateStats`, `getSearchProviderCounts`). First slice of #3500 (call_logs cluster). Behavior unchanged; the three routes are removed from `KNOWN_RAW_SQL` in the gate. Validated with TDD unit tests (6 assertions seeding an in-memory SQLite fixture). +- **refactor(db): move `usage_history`/`daily_usage_summary` SQL into `usageAnalytics` db module** ([#3500]): extracted all inline `db.prepare(...)` calls from two route handlers (`/api/usage/analytics`, `/api/settings/export-json`) into a new `src/lib/db/usageAnalytics.ts` module and extended `src/lib/db/callLogStats.ts` with `getFallbackStats`. New exports: `buildUnifiedSource`, `buildPresetUnifiedSource` (UNION CTE builders), plus 12 typed query functions covering summary, daily, daily-cost, heatmap, model, provider, account, api-key, service-tier, weekly-pattern, and preset-cost aggregations, plus `getAllUsageHistory`/`getAllDomainCostHistory`/`getAllDomainBudgets` for backup export. Second slice of #3500. `KNOWN_RAW_SQL` drops from 12 → 10. Validated with 21 TDD unit tests (`tests/unit/db-usage-analytics-3500.test.ts`) seeding a temp SQLite fixture. + +- **refactor(db): move `community_servers` auth look-up into `gamification` db module** ([#3500]): extracted raw SQL from two federation route handlers (`/api/gamification/federation/leaderboard`, `/api/gamification/federation/score`) into a new `getConnectedServerByKeyHash(apiKeyHash)` function in `src/lib/db/gamification.ts`. Third slice of #3500 (gamification federation cluster). Behavior unchanged; the two routes are removed from `KNOWN_RAW_SQL` in the gate. Validated with TDD unit tests (3 assertions seeding a temp SQLite fixture). + +- **refactor(db): move `skills UPDATE` + `db-backups` SQL into db modules** ([#3500]): fifth slice of #3500. Extracted the dynamic `UPDATE skills SET …` from `src/app/api/skills/[id]/route.ts` into a new `src/lib/db/skills.ts` module (`updateSkill(id, patch)`). The dynamic SET clause is injection-safe: column names are validated against a hard-coded allowlist of known writable columns before being interpolated; unknown keys are silently ignored. Extended `src/lib/db/backup.ts` with three new functions: `exportAllSummaryRows()` (multi-table SELECT for key_value / combos / provider_connections / api_keys, used by exportAll), `getTableNamesFromAdapter()` (sqlite_master introspection via an adapter arg, used by import validation), and `countImportedRows()` (post-import COUNT(\*) per table). The backup domain module is the correct home for sqlite_master introspection — it is not "raw SQL in a route" once moved there. `KNOWN_RAW_SQL` drops by 3 (from 8 → 5). Validated with 11 TDD unit tests (`tests/unit/db-backups-skills-3500.test.ts`). +- **refactor(db): move `usage_logs`/`semantic_cache`/`proxy_logs` SQL into db modules** ([#3500]): extracted raw `db.prepare(...)` SQL from three route handlers (`/api/analytics/auto-routing` → `usageLogs.ts`; `/api/cache/entries` → `semanticCache.ts`; `/api/logs/export` → `proxyLogs.ts`) into new `src/lib/db/` domain modules. New exports: `getAutoRoutingTotalCount`, `getAutoRoutingVariantBreakdown`, `getAutoRoutingTopProviders` (usage_logs), `listSemanticCacheEntries`, `deleteSemanticCacheBySignature`, `deleteSemanticCacheByModel` (semantic_cache), and `exportProxyLogsSince` (proxy_logs). Fourth slice of #3500. `KNOWN_RAW_SQL` drops from 8 → 5. Validated with 13 TDD unit tests (`tests/unit/db-logs-cache-3500.test.ts`) seeding temp SQLite fixtures. + +- **Provider-detail god-component decomposition — Phase 0** ([#3501]): introduced `ProviderDetailPageClient.tsx` and reduced `providers/[id]/page.tsx` to a thin 9-line route wrapper (was 12,882 LOC), following the repo's `*PageClient` convention. Added the first-ever smoke render test for the page (Hard Rule #8) as the safety net every later extraction phase is diffed against. Behavior unchanged; the `check-file-size` ratchet now tracks the extracted client. Foundation for Phases 1–6 (strangler-fig). Thanks @oyi77 for the parallel modularization effort in #3627. +- **Provider-detail god-component decomposition — Phase 1a** ([#3501]): extracted the three self-contained auth-import modal clusters (Codex/Claude/Gemini `Import*AuthModal` + `Apply*AuthModal` + their co-located helpers, ~2,160 LOC) into `providers/[id]/components/modals/`. `ProviderDetailPageClient.tsx` drops 12,882 → 10,719 LOC. Behavior unchanged (smoke test green; clusters had clean `{ onClose, onSuccess }` / inline-prop interfaces). Co-authored with @oyi77. +- **Provider-detail god-component decomposition — Phase 1b** ([#3501]): extracted `EditCompatibleNodeModal` (+ its node/props types) into `providers/[id]/components/modals/`, and moved the shared `CC_COMPATIBLE_DEFAULT_CHAT_PATH` constant into a leaf `providerDetailConstants.ts` so the page client and the modal can both import it without a circular dependency. Also removed two dangling section comments left by Phase 1a. `ProviderDetailPageClient.tsx`: 10,719 → 10,435 LOC. Behavior unchanged (smoke test + a new standalone modal render test green; `check:cycles` clean). Co-authored with @oyi77. + +### 🔧 Bug Fixes + +- **Combos / Auto-Combo: premature context compaction ("agent keeps forgetting things")** ([#3680]): two related context-window bugs fixed. (1) `GET /api/combos/auto` now advertises `context_length` / `max_output_tokens` (MAX across the candidate pool — safe because the auto-combo context pre-filter routes oversized requests to large-window candidates), and the opencode plugin consumes them instead of hardcoding `limit: { context: 0 }` — a zero context silently disables opencode's smart auto-compaction, letting sessions grow until the gateway's destructive history purge kicks in. (2) chatCore's proactive compression for DB combos (incl. quota-shared pools) no longer compresses at `min(...allTargets)`: it now uses the EXECUTING target's own window (`resolveComboContextLimit`), keeping min-of-targets only as a defensive fallback when the current provider/model resolves no specific limit. TDD: 8 server tests (`tests/unit/auto-combo-context-advertising.test.ts`) + 3 plugin tests (`tests/auto-combo-context.test.ts`). +- **Obsidian/WebDAV**: add the `/api/settings/obsidian/webdav` config route (enable/disable vault sync), encrypt WebDAV credentials at rest, and remove the duplicate UI block (#3485, part 1). + +- **OpenCode Free / passthrough**: "Test all models" now respects "Auto-hide failed models" and switches the list to the visible filter so hidden models actually disappear (#3610). Three related bugs fixed: `autoHideFailed` is now threaded from the outer component into `PassthroughModelsSection` via a prop (single shared checkbox); the `/api/models/test-all` request body now includes `autoHideFailed: true` so the server persists the hide; and after the loop, `visibilityFilter` is switched to `"visible"` when ≥1 model was hidden. Two pure-function helpers (`buildPassthroughTestBody`, `shouldSwitchToVisibleFilter`) extracted to `providerPageHelpers.ts` with 7 unit tests. +- **Resilience**: clear stale transient connection cooldowns on startup so a prior unclean crash no longer makes every request time out at 120s after restart (#3625) + +- **fix(home topology): restore live in-flight request pulse** ([#3507]): the animated "pulse" edges in the home Provider Topology panel went dead after PR #3401 unified request visibility, because `activeRequests` was hardcoded to `[]`. Re-wired to `useLiveRequests()` (the existing WebSocket hook on port 20129) so that every pending/running request drives the animation in real time. A pure `selectActiveRequests` mapping helper was extracted to `home/topologyUtils.ts` with 5 unit tests. +- **Electron desktop**: launch the peer-stamping `server-ws.mjs` entrypoint so local-only routes (AgentBridge, MCP, services) no longer return 403 LOCAL_ONLY (#3386) +- **Provider Topology**: stop flagging healthy providers as errored based on stale historical failures; use current request status (#3619) +- **OpenCode Free**: fetch the live model catalog from the provider's `modelsUrl` for the no-auth model picker instead of serving a stale hardcoded list (#3611) +- **Hermes Agent**: honour the `HERMES_HOME` env var when writing/reading the agent config instead of always using `~/.hermes` (#3628). Introduced `getHermesHome()` / `getHermesConfigPath()` helpers (read at call-time) and routed all four hardcoded callsites through them so OmniRoute's config lands in the same directory that the Hermes PowerShell installer configures on Windows. +- **MITM/cert**: remove the duplicated "Command failed:" prefix in system-command error messages ([#3641](https://github.com/diegosouzapw/OmniRoute/issues/3641)): `execFileText` was prepending its own `"Command failed: "` prefix on top of Node's `execFile` error message, which already begins with `"Command failed: "` for non-zero exits. The error message now surfaces Node's message directly (no double prefix), with stderr appended only when non-empty. + +- **fix(reasoning): replay `reasoning_content` on plain DeepSeek turns** ([#3632] — thanks @adivekar-utexas): the reasoning-replay gate previously only fired when an assistant message already carried `reasoning_content`. Plain (non-tool-call) turns whose `reasoning_content` was stripped by the client (e.g. Cursor) were forwarded without it, so DeepSeek V4+ rejected the request with 400 "the reasoning_content in the thinking mode must be passed back". The gate now also covers missing/empty `reasoning_content` on DeepSeek replay targets, injecting the cached reasoning (or the non-Anthropic placeholder) so multi-turn text conversations no longer 400. Fixes #1682. 2 regression tests. +- **fix(kiro): route enterprise IAM Identity Center accounts to their regional endpoint** ([#3631] — thanks @artickc): Kiro/CodeWhisperer access tokens and Q Developer profile ARNs are region-bound, so enterprise IAM Identity Center accounts outside `us-east-1` (e.g. `eu-central-1`) were rejected by the default host. Adds `resolveKiroRegion` (stored region → profileArn region → `us-east-1`) and `kiroRuntimeHost` (regional `q.{region}.amazonaws.com`, legacy `codewhisperer.us-east-1` for the default), routes chat + usage to the regional endpoint, and discovers the region-matched `profileArn` via `ListAvailableProfiles` in a best-effort `postExchange` hook. 9 tests. +- **fix(combo): skip same-provider/connection targets on connection-level errors** ([#3637] — thanks @herjarsa): on connection-level upstream errors (408/500/502/503/504/524), remaining same-`provider:connection` targets in a combo request are now skipped to avoid hammering a known-bad connection, in both the priority and round-robin paths. Adjusted in review to **exclude OmniRoute circuit-breaker-open responses** (503 + `X-OmniRoute-Provider-Breaker` / `provider_circuit_open`) from this skip, preserving the invariant that a breaker-open is an ordinary target failure (the next same-provider target is still tried). Co-authored with @herjarsa. +- **/v1/responses**: detect stream readiness for tool-call-only and `object`-less chunks so Codex-shaped (reasoning + tools) requests no longer fail with "Stream ended before producing useful content" (#3612) +- **RTL locales (ar/fa/he/ur)**: use logical CSS direction utilities for the sidebar and key overlays so the layout mirrors correctly under `dir=rtl` (#3541, partial — core layout) +- **Kiro/AWS auto-import**: set a descriptive account name and dedupe by `profileArn` so imports no longer create nameless duplicate "OAuth Account" rows (#3615) +- **fix(guardrails):** the `/api/guardrails/test` route now validates its body through `validateBody()` (Zod) instead of parsing raw JSON directly, aligning it with the repo-wide input-validation pattern (Hard Rule #7). ([#3621](https://github.com/diegosouzapw/OmniRoute/pull/3621) — thanks @diegosouzapw) +- **fix(dashboard): bulk provider connection actions** — close audit, API, and UX gaps in the batch activate/deactivate flow: register the `provider.credentials.batch_updated` event in `HIGH_LEVEL_ACTIONS` and `ACTIVITY_ICONS` (was silently dropped from the Activity feed); fix `/api/providers` PATCH to return `warn` status when `notFound` is non-empty instead of always `success`; `/api/providers/test-batch` empty-result early-return now includes a `summary` so stale-ID mode reports to the user; bulk activate/deactivate chunks selection by 100 to avoid the Zod 400 cap on large provider accounts. ([#3673](https://github.com/diegosouzapw/OmniRoute/pull/3673) — thanks @leninejunior) + +### 📝 Maintenance + +- **fix(ci):** increase the `execFileSync` `maxBuffer` in `validate-pack-artifact` so the npm-pack inventory no longer overflows on large tarballs during release validation — follow-up to the v3.8.21 pack-artifact hotfix. ([#3622](https://github.com/diegosouzapw/OmniRoute/pull/3622) — thanks @diegosouzapw) + +--- + ## [3.8.21] — 2026-06-11 ### ✨ Added @@ -235,12 +299,12 @@ Thanks to everyone whose work landed in v3.8.15: -| Contributor | PRs / Issues | -| --- | --- | -| [@herjarsa](https://github.com/herjarsa) | #3366, #3369, #3370 | -| [@oyi77](https://github.com/oyi77) | #3365, #3371 | -| [@TapZe](https://github.com/TapZe) | #3372 | -| [@Gerashka2](https://github.com/Gerashka2) | reported #3363 | +| Contributor | PRs / Issues | +| ------------------------------------------------ | -------------------------------------------------- | +| [@herjarsa](https://github.com/herjarsa) | #3366, #3369, #3370 | +| [@oyi77](https://github.com/oyi77) | #3365, #3371 | +| [@TapZe](https://github.com/TapZe) | #3372 | +| [@Gerashka2](https://github.com/Gerashka2) | reported #3363 | | [@diegosouzapw](https://github.com/diegosouzapw) | maintainer — #3375 shepherding, migration restores | --- @@ -290,22 +354,22 @@ Thanks to everyone whose work landed in v3.8.15: Thanks to everyone whose work landed in v3.8.14: -| Contributor | PRs / Issues | -| --- | --- | -| [@pizzav-xyz](https://github.com/pizzav-xyz) | #3338 | -| [@quanturbo](https://github.com/quanturbo) | #3333 | -| [@oyi77](https://github.com/oyi77) | #3323, #3345 | -| [@rdself](https://github.com/rdself) | #3349 | -| [@wilsonicdev](https://github.com/wilsonicdev) | #3346 | -| [@hertznsk](https://github.com/hertznsk) | #3332 | -| [@abdulkadirozyurt](https://github.com/abdulkadirozyurt) | #3330 | -| [@mikmaneggahommie](https://github.com/mikmaneggahommie) | #3329 | -| [@Flexible78](https://github.com/Flexible78) | #3347 | -| [@Lang-Qiu](https://github.com/Lang-Qiu) | #3337 | -| [@KrisnaSantosa15](https://github.com/KrisnaSantosa15) | #3348 | -| [@nullbytef0x](https://github.com/nullbytef0x) | #3357 | -| [@Ardem2025](https://github.com/Ardem2025) | #3358 | -| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer — #3334, #3335, #3336, #3339, #3350, #3352, #3353, #3356; review/hardening across the cycle | +| Contributor | PRs / Issues | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| [@pizzav-xyz](https://github.com/pizzav-xyz) | #3338 | +| [@quanturbo](https://github.com/quanturbo) | #3333 | +| [@oyi77](https://github.com/oyi77) | #3323, #3345 | +| [@rdself](https://github.com/rdself) | #3349 | +| [@wilsonicdev](https://github.com/wilsonicdev) | #3346 | +| [@hertznsk](https://github.com/hertznsk) | #3332 | +| [@abdulkadirozyurt](https://github.com/abdulkadirozyurt) | #3330 | +| [@mikmaneggahommie](https://github.com/mikmaneggahommie) | #3329 | +| [@Flexible78](https://github.com/Flexible78) | #3347 | +| [@Lang-Qiu](https://github.com/Lang-Qiu) | #3337 | +| [@KrisnaSantosa15](https://github.com/KrisnaSantosa15) | #3348 | +| [@nullbytef0x](https://github.com/nullbytef0x) | #3357 | +| [@Ardem2025](https://github.com/Ardem2025) | #3358 | +| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer — #3334, #3335, #3336, #3339, #3350, #3352, #3353, #3356; review/hardening across the cycle | --- @@ -332,7 +396,7 @@ Thanks to everyone whose work landed in v3.8.14: - **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(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) @@ -353,18 +417,18 @@ Thanks to everyone whose work landed in v3.8.14: 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 | +| 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 | --- @@ -406,14 +470,14 @@ Thanks to everyone whose work landed in v3.8.13: Thanks to everyone whose work landed in v3.8.12: -| Contributor | PRs / Issues | -| --- | --- | -| [@oyi77](https://github.com/oyi77) | #3250, #3259, #3280, #3285, #3286 | -| [@wilsonicdev](https://github.com/wilsonicdev) | #3249, #3268, #3282 / #3283 (co-author, #3247 diagnosis), #3287 | -| [@strangersp](https://github.com/strangersp) | #3261 | -| [@MikeTuev](https://github.com/MikeTuev) | #3248 | -| [@leninejunior](https://github.com/leninejunior) | #3271 | -| [@zhiru](https://github.com/zhiru) | #3274 | +| Contributor | PRs / Issues | +| ------------------------------------------------ | --------------------------------------------------------------------------------- | +| [@oyi77](https://github.com/oyi77) | #3250, #3259, #3280, #3285, #3286 | +| [@wilsonicdev](https://github.com/wilsonicdev) | #3249, #3268, #3282 / #3283 (co-author, #3247 diagnosis), #3287 | +| [@strangersp](https://github.com/strangersp) | #3261 | +| [@MikeTuev](https://github.com/MikeTuev) | #3248 | +| [@leninejunior](https://github.com/leninejunior) | #3271 | +| [@zhiru](https://github.com/zhiru) | #3274 | | [@diegosouzapw](https://github.com/diegosouzapw) | maintainer — #3256, #3263, #3270, #3275, #3277, #3278, #3279, #3281, #3284, #3289 | --- @@ -462,26 +526,26 @@ Thanks to everyone whose work landed in v3.8.12: Thanks to everyone whose work landed in v3.8.11: -| Contributor | PRs / Issues | -| --- | --- | -| [@wilsonicdev](https://github.com/wilsonicdev) | #3189, #3201, #3203, #3204, #3232, #3240, #3241 | -| [@pizzav-xyz](https://github.com/pizzav-xyz) | #3170, #3171, #3172 | -| [@zhiru](https://github.com/zhiru) | #3185, #3195 | -| [@oyi77](https://github.com/oyi77) | #3217 | -| [@miracuves](https://github.com/miracuves) | #3116, #3226 | -| [@ngocquynh85](https://github.com/ngocquynh85) | #3205, #3214, #3215 | -| [@xz-dev](https://github.com/xz-dev) | #3188 | -| [@bypanghu](https://github.com/bypanghu) | #3191 | -| [@juandisay](https://github.com/juandisay) | #3206 | -| [@tjengbudi](https://github.com/tjengbudi) | #3197, #3198, #3199 | -| [@naimo84](https://github.com/naimo84) | #3151 | -| [@yeardie](https://github.com/yeardie) | #3025 | -| [@pulyankote](https://github.com/pulyankote) | #3202 | -| [@YoursSweetDom](https://github.com/YoursSweetDom) | #3180 | -| [@Guru01100101](https://github.com/Guru01100101) | #3091 | -| [@androw](https://github.com/androw) | #3167 (co-author) | -| [@ibanunmangun](https://github.com/ibanunmangun) | #3193 (co-author) | -| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer — #3187, #3200, issue-fix batches | +| Contributor | PRs / Issues | +| -------------------------------------------------- | ----------------------------------------------- | +| [@wilsonicdev](https://github.com/wilsonicdev) | #3189, #3201, #3203, #3204, #3232, #3240, #3241 | +| [@pizzav-xyz](https://github.com/pizzav-xyz) | #3170, #3171, #3172 | +| [@zhiru](https://github.com/zhiru) | #3185, #3195 | +| [@oyi77](https://github.com/oyi77) | #3217 | +| [@miracuves](https://github.com/miracuves) | #3116, #3226 | +| [@ngocquynh85](https://github.com/ngocquynh85) | #3205, #3214, #3215 | +| [@xz-dev](https://github.com/xz-dev) | #3188 | +| [@bypanghu](https://github.com/bypanghu) | #3191 | +| [@juandisay](https://github.com/juandisay) | #3206 | +| [@tjengbudi](https://github.com/tjengbudi) | #3197, #3198, #3199 | +| [@naimo84](https://github.com/naimo84) | #3151 | +| [@yeardie](https://github.com/yeardie) | #3025 | +| [@pulyankote](https://github.com/pulyankote) | #3202 | +| [@YoursSweetDom](https://github.com/YoursSweetDom) | #3180 | +| [@Guru01100101](https://github.com/Guru01100101) | #3091 | +| [@androw](https://github.com/androw) | #3167 (co-author) | +| [@ibanunmangun](https://github.com/ibanunmangun) | #3193 (co-author) | +| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer — #3187, #3200, issue-fix batches | --- @@ -697,9 +761,9 @@ And thank you to the OmniRoute community for the bug reports, reproductions, and refreshed siblings concurrently, so Auth0 revoked the whole token family (`openai/codex#9648`) and every account but the last died with `[403] `. The quota path now skips proactive refresh for - rotating providers (`rotationGroupFor`) and reuses the current access_token, + rotating providers (`rotationGroupFor`) and reuses the current access*token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two \_queued* sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -762,7 +826,7 @@ And thank you to the OmniRoute community for the bug reports, reproductions, and via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -795,7 +859,7 @@ And thank you to the OmniRoute community for the bug reports, reproductions, and instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -837,7 +901,7 @@ And thank you to the OmniRoute community for the bug reports, reproductions, and - **sse/chatCore:** the heap-pressure guard now auto-calibrates its threshold to 85% of the live V8 heap ceiling (floor 400 MB) instead of a fixed 200 MB that sat below the app's ~260 MB baseline and returned `503 Service temporarily unavailable due to - resource pressure` for every request once the heap warmed up. It now tracks +resource pressure` for every request once the heap warmed up. It now tracks `--max-old-space-size` across 1 GB / 2 GB / large VPS; `HEAP_PRESSURE_THRESHOLD_MB` still overrides. (#3052) - **proxy:** fail closed for OAuth usage-account proxies (#3051 — thanks @terence71-glitch) @@ -852,33 +916,33 @@ And thank you to the OmniRoute community for the bug reports, reproductions, and A special thanks to everyone who contributed to this release — 746 commits since `v3.8.7`: -| Contributor | PRs / Contribution | -| --- | --- | -| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer — AgentBridge, Traffic Inspector, Quota Share Engine, Nav Restructure, Plugins integration, releases & upstream ports | -| [@oyi77](https://github.com/oyi77) | #2913, #2947, #2954, #2978, #3015, #3018, #3039, #3041, #3045, #3046, #3049 | -| [@terence71-glitch](https://github.com/terence71-glitch) | #2956, #2960, #2963, #2984, #3000, #3006, #3012, #3048, #3051 | -| [@soyelmismo](https://github.com/soyelmismo) | #2951, #2965, #2973 | -| [@branben](https://github.com/branben) | #2958, #2959 | -| [@makcimbx](https://github.com/makcimbx) | #2937, #2938 | -| [@guanbear](https://github.com/guanbear) | #2931, #3031 | -| [@Lion-killer](https://github.com/Lion-killer) | #2981, #2988 | -| [@JxnLexn](https://github.com/JxnLexn) | per-API-key stream default mode | -| [@androw](https://github.com/androw) | #3017 | -| [@xz-dev](https://github.com/xz-dev) | #2975, #3064 | -| [@S0yora](https://github.com/S0yora) | #2964 | -| [@NekoMonci12](https://github.com/NekoMonci12) | #3008 | -| [@Tentoxa](https://github.com/Tentoxa) | #3010 | -| [@ReqX](https://github.com/ReqX) | #2957 | -| [@NomenAK](https://github.com/NomenAK) | #2943 | -| [@charithharshana](https://github.com/charithharshana) | #2940 | -| [@dhaern](https://github.com/dhaern) | #2927 | -| [@dangeReis](https://github.com/dangeReis) | #3021 | -| [@bobbyunknown](https://github.com/bobbyunknown) | #3029 | -| [@CitrusIce](https://github.com/CitrusIce) | #3035, #3058 | -| [@wussh](https://github.com/wussh) | #3036 | -| [@Chewji9875](https://github.com/Chewji9875) | #3037 | -| [@herjarsa](https://github.com/herjarsa) | #3043 | -| [@freefrank](https://github.com/freefrank) | #3066 (reported the Docker build failure) | +| Contributor | PRs / Contribution | +| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer — AgentBridge, Traffic Inspector, Quota Share Engine, Nav Restructure, Plugins integration, releases & upstream ports | +| [@oyi77](https://github.com/oyi77) | #2913, #2947, #2954, #2978, #3015, #3018, #3039, #3041, #3045, #3046, #3049 | +| [@terence71-glitch](https://github.com/terence71-glitch) | #2956, #2960, #2963, #2984, #3000, #3006, #3012, #3048, #3051 | +| [@soyelmismo](https://github.com/soyelmismo) | #2951, #2965, #2973 | +| [@branben](https://github.com/branben) | #2958, #2959 | +| [@makcimbx](https://github.com/makcimbx) | #2937, #2938 | +| [@guanbear](https://github.com/guanbear) | #2931, #3031 | +| [@Lion-killer](https://github.com/Lion-killer) | #2981, #2988 | +| [@JxnLexn](https://github.com/JxnLexn) | per-API-key stream default mode | +| [@androw](https://github.com/androw) | #3017 | +| [@xz-dev](https://github.com/xz-dev) | #2975, #3064 | +| [@S0yora](https://github.com/S0yora) | #2964 | +| [@NekoMonci12](https://github.com/NekoMonci12) | #3008 | +| [@Tentoxa](https://github.com/Tentoxa) | #3010 | +| [@ReqX](https://github.com/ReqX) | #2957 | +| [@NomenAK](https://github.com/NomenAK) | #2943 | +| [@charithharshana](https://github.com/charithharshana) | #2940 | +| [@dhaern](https://github.com/dhaern) | #2927 | +| [@dangeReis](https://github.com/dangeReis) | #3021 | +| [@bobbyunknown](https://github.com/bobbyunknown) | #3029 | +| [@CitrusIce](https://github.com/CitrusIce) | #3035, #3058 | +| [@wussh](https://github.com/wussh) | #3036 | +| [@Chewji9875](https://github.com/Chewji9875) | #3037 | +| [@herjarsa](https://github.com/herjarsa) | #3043 | +| [@freefrank](https://github.com/freefrank) | #3066 (reported the Docker build failure) | A special thanks to everyone who contributed code, reviews, and tests for this release: @androw, @bobbyunknown, @branben, @charithharshana, @Chewji9875, @CitrusIce, @dangeReis, @dhaern, @diegosouzapw, @freefrank, @guanbear, @herjarsa, @JxnLexn, @Lion-killer, @makcimbx, @NekoMonci12, @NomenAK, @oyi77, @ReqX, @S0yora, @soyelmismo, @Tentoxa, @terence71-glitch, @wussh, @xz-dev @@ -983,7 +1047,7 @@ A special thanks to everyone who contributed code, reviews, and tests for this r - **warning-cleanup:** relax node engine constraint to `>=22.0.0` and clean dependencies (keeping `marked-terminal` to prevent TUI REPL crash) (#2792 — thanks @oyi77) - **combo:** normalize upstream Headers into a plain object before classification to avoid Node 24 / undici cross-instance `Cannot read private member #headers` crash on combo failover (#2751) - **translator:** silently drop `tool_search` built-in tool type instead of returning 400 — newer Codex clients send `tool_search` as a Responses API built-in with no Chat Completions equivalent (#2766) -- **usage:** un-invert GitHub Copilot Free / limited plan quota — `limited_user_quotas` is the *remaining* count, not used, so the dashboard now shows 100% when the quota is untouched and 0% when fully exhausted (#2876 — thanks @androw) +- **usage:** un-invert GitHub Copilot Free / limited plan quota — `limited_user_quotas` is the _remaining_ count, not used, so the dashboard now shows 100% when the quota is untouched and 0% when fully exhausted (#2876 — thanks @androw) - **fix(cli):** register openclaw in the CLI tool-detector so it appears in `omniroute status` alongside its existing API and config support ([#2833](https://github.com/diegosouzapw/OmniRoute/issues/2833)) - **oauth (windsurf):** hotfix Windsurf login — drop the dead PKCE flow and promote the import-token flow as the default ([#2884](https://github.com/diegosouzapw/OmniRoute/pull/2884) — thanks @yunaamelia) - **antigravity:** normalize textual SSE tool calls and classify Gemini Antigravity resource exhaustion as a model lockout instead of a connection failure ([#2828](https://github.com/diegosouzapw/OmniRoute/pull/2828) — thanks @Ardem2025) @@ -1018,33 +1082,33 @@ A special thanks to everyone who contributed code, reviews, and tests for this r A special thanks to everyone who contributed to this release. Ranked by commits since `v3.8.6` (105 commits total): -| Contributor | Commits | PRs | -| --- | ---: | --- | -| [@diegosouzapw](https://github.com/diegosouzapw) | 38 | maintainer — releases, upstream ports & fixes | -| [@oyi77](https://github.com/oyi77) | 10 | #2887, #2862, #2866, #2837, #2885, #2792, #2793 | -| [@yunaamelia](https://github.com/yunaamelia) | 7 | #2884 | -| [@herjarsa](https://github.com/herjarsa) | 6 | #2868, #2886, #2865, #2860, #2857, #2801 | -| [@leninejunior](https://github.com/leninejunior) | 4 | #2818, #2824, #2825, #2816 | -| [@jeferssonlemes](https://github.com/jeferssonlemes) | 3 | #2791, #2802, #2815, #2817 | -| [@rdself](https://github.com/rdself) | 3 | #2874, #2875, #2880 | -| Dmitry Kuznetsov | 3 | textual tool-call & lockout hardening | -| [@apoapostolov](https://github.com/apoapostolov) | 2 | #2799, #2800 | -| [@unitythemaker](https://github.com/unitythemaker) | 2 | #2904 | -| Nikolay Alafuzov | 2 | reasoning interleaved gating | -| [@Tushar49](https://github.com/Tushar49) | 2 | #2854, #2855, #2807 | -| [@guanbear](https://github.com/guanbear) | 2 | #2908 | -| [@soyelmismo](https://github.com/soyelmismo) | 2 | #2903, #2842 | -| [@RajvardhanPatil07](https://github.com/RajvardhanPatil07) | 1 | #2861 | -| [@mugnimaestra](https://github.com/mugnimaestra) | 1 | #2888 | -| [@dhaern](https://github.com/dhaern) | 1 | #2878 | -| [@hartmark](https://github.com/hartmark) | 1 | #2795, #2771 | -| [@marchlhw](https://github.com/marchlhw) | 1 | #2821 | -| [@alltomatos](https://github.com/alltomatos) | 1 | i18n pt-BR | -| [@akarray](https://github.com/akarray) | 1 | #2796 | -| [@gogones](https://github.com/gogones) | 1 | #2845 | -| [@disonjer](https://github.com/disonjer) | 1 | #2840 | -| [@nickwizard](https://github.com/nickwizard) | 1 | #2841 | -| [@levonk](https://github.com/levonk) | 1 | #2806 | +| Contributor | Commits | PRs | +| ---------------------------------------------------------- | ------: | ----------------------------------------------- | +| [@diegosouzapw](https://github.com/diegosouzapw) | 38 | maintainer — releases, upstream ports & fixes | +| [@oyi77](https://github.com/oyi77) | 10 | #2887, #2862, #2866, #2837, #2885, #2792, #2793 | +| [@yunaamelia](https://github.com/yunaamelia) | 7 | #2884 | +| [@herjarsa](https://github.com/herjarsa) | 6 | #2868, #2886, #2865, #2860, #2857, #2801 | +| [@leninejunior](https://github.com/leninejunior) | 4 | #2818, #2824, #2825, #2816 | +| [@jeferssonlemes](https://github.com/jeferssonlemes) | 3 | #2791, #2802, #2815, #2817 | +| [@rdself](https://github.com/rdself) | 3 | #2874, #2875, #2880 | +| Dmitry Kuznetsov | 3 | textual tool-call & lockout hardening | +| [@apoapostolov](https://github.com/apoapostolov) | 2 | #2799, #2800 | +| [@unitythemaker](https://github.com/unitythemaker) | 2 | #2904 | +| Nikolay Alafuzov | 2 | reasoning interleaved gating | +| [@Tushar49](https://github.com/Tushar49) | 2 | #2854, #2855, #2807 | +| [@guanbear](https://github.com/guanbear) | 2 | #2908 | +| [@soyelmismo](https://github.com/soyelmismo) | 2 | #2903, #2842 | +| [@RajvardhanPatil07](https://github.com/RajvardhanPatil07) | 1 | #2861 | +| [@mugnimaestra](https://github.com/mugnimaestra) | 1 | #2888 | +| [@dhaern](https://github.com/dhaern) | 1 | #2878 | +| [@hartmark](https://github.com/hartmark) | 1 | #2795, #2771 | +| [@marchlhw](https://github.com/marchlhw) | 1 | #2821 | +| [@alltomatos](https://github.com/alltomatos) | 1 | i18n pt-BR | +| [@akarray](https://github.com/akarray) | 1 | #2796 | +| [@gogones](https://github.com/gogones) | 1 | #2845 | +| [@disonjer](https://github.com/disonjer) | 1 | #2840 | +| [@nickwizard](https://github.com/nickwizard) | 1 | #2841 | +| [@levonk](https://github.com/levonk) | 1 | #2806 | _Reviews & additional contributions: @androw, @Ardem2025, @InkshadeWoods._ A special thanks to everyone who contributed code, reviews, and tests for this release: @@ -1082,7 +1146,6 @@ A special thanks to everyone who contributed code, reviews, and tests for this r A special thanks to everyone who contributed code, reviews, and tests for this release: @akarray, @hartmark, @hijak, @JxnLexn, @kjhq, @rdself, @thanet-s - --- ## [3.8.4] — 2026-05-26 diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 1cfab7e70b..087aa8fe97 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 3b29b29bfa..6c3e4d337c 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 3b29b29bfa..6c3e4d337c 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index b07767b2c5..d1e38220e5 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index e6098ae41c..856d776d61 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index 2e08ce2cba..420b57b239 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index a69c70c8e0..dd29ccc899 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 7e11043430..cbc8be128d 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index 255bb62609..7b8246e1e4 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index e2622b2a5c..9ae8fc57e6 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 4f67ca09de..12446f97d8 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index 6df9e404fb..f7115d78d2 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index 8dbc0d6d52..ce4f834626 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 7805c4af00..67952135d7 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 539f22e96f..4153d89e7a 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 625e8ebea5..1a4946a8a8 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index b7dc11289f..287f5e74ad 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index e6dbca0c37..ea20c5e0a9 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index fcefa3572e..bee3830813 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index b5c0e18a64..2d4a4bc119 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 3f22ab050f..2d6d31b79b 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 1a67082ce6..941dbf55ec 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index 89c258d8dd..c957a171fd 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index a0c44a40fb..2620b21135 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index 30a29f311f..d149e3c9e0 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index f6a3b6a827..0688bb4b92 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 32fb1ef583..afeeb61e5b 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 136ca1e910..46cd4ffbf2 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index adc758d997..ee4c92d430 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index a07795f8a1..31a5da7e2d 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 0c25e47a48..63117457b1 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index 791ecf4f83..f071c2e757 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index 56dece5f42..6307211fb3 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 5e8935c520..bb5ca0b214 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index d3c0814e26..926726b61f 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index 340f21de7e..ef1d0a62ce 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index 7aa6322885..1eb1306787 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index 55e35c0467..e606dd07dd 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 3f29ab46ec..21e84b8e72 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 218477da41..ba1ca85bcc 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 36d5fb4746..630537e06f 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [3.8.22] — TBD + +_See English CHANGELOG for v3.8.22 details._ + +--- + ## [3.8.21] — 2026-06-11 _See [English CHANGELOG](/CHANGELOG.md) for v3.8.21 details._ diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index b014560092..333bc4b697 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -52,13 +52,13 @@ lastUpdated: 2026-05-13 These **must** be set before the first run. Without them, the application will either refuse to start or operate with insecure defaults. -| Variable | Required | Default | Source File | Description | -| ---------------------------- | -------------------- | ---------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `JWT_SECRET` | **Yes** | _(none)_ | `src/lib/auth` | Signs/verifies all dashboard session cookies (JWT). Generate with `openssl rand -base64 48`. | -| `API_KEY_SECRET` | **Yes** | _(none)_ | `src/lib/db/apiKeys.ts` | AES encryption key for API key values at rest in SQLite. Generate with `openssl rand -hex 32`. | -| `INITIAL_PASSWORD` | **Yes** | `CHANGEME` | Bootstrap script | Sets the initial admin dashboard password (matches `.env.example` default — kept obviously insecure to force a change). **Change before first use.** After login, change via Dashboard → Settings → Security. | -| `OMNIROUTE_WS_BRIDGE_SECRET` | **Yes** (production) | _(unset)_ | `src/app/api/internal/codex-responses-ws/route.ts` | Shared secret for the internal Codex Responses WebSocket bridge. Authenticates bridge requests between the Electron/browser WS relay and OmniRoute. ⚠️ **REQUIRED in production — when unset, all WS bridge requests are rejected.** Generate with `openssl rand -base64 32`. | -| `OMNIROUTE_PEER_STAMP_TOKEN` | No (auto) | _(auto per boot)_ | `src/server/authz/policies/management.ts` | Per-process secret proving the trusted peer-IP stamp came from OmniRoute's own HTTP server (`scripts/dev/peer-stamp.mjs`). The authz middleware trusts request locality (loopback/LAN gating of LOCAL_ONLY routes) only when the stamp carries this token. Auto-generated each boot — leave unset; only pin it for multi-process setups that must share the stamp. | +| Variable | Required | Default | Source File | Description | +| ---------------------------- | -------------------- | ----------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `JWT_SECRET` | **Yes** | _(none)_ | `src/lib/auth` | Signs/verifies all dashboard session cookies (JWT). Generate with `openssl rand -base64 48`. | +| `API_KEY_SECRET` | **Yes** | _(none)_ | `src/lib/db/apiKeys.ts` | AES encryption key for API key values at rest in SQLite. Generate with `openssl rand -hex 32`. | +| `INITIAL_PASSWORD` | **Yes** | `CHANGEME` | Bootstrap script | Sets the initial admin dashboard password (matches `.env.example` default — kept obviously insecure to force a change). **Change before first use.** After login, change via Dashboard → Settings → Security. | +| `OMNIROUTE_WS_BRIDGE_SECRET` | **Yes** (production) | _(unset)_ | `src/app/api/internal/codex-responses-ws/route.ts` | Shared secret for the internal Codex Responses WebSocket bridge. Authenticates bridge requests between the Electron/browser WS relay and OmniRoute. ⚠️ **REQUIRED in production — when unset, all WS bridge requests are rejected.** Generate with `openssl rand -base64 32`. | +| `OMNIROUTE_PEER_STAMP_TOKEN` | No (auto) | _(auto per boot)_ | `src/server/authz/policies/management.ts` | Per-process secret proving the trusted peer-IP stamp came from OmniRoute's own HTTP server (`scripts/dev/peer-stamp.mjs`). The authz middleware trusts request locality (loopback/LAN gating of LOCAL_ONLY routes) only when the stamp carries this token. Auto-generated each boot — leave unset; only pin it for multi-process setups that must share the stamp. | ### Generation Commands @@ -224,11 +224,12 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp ## 6. Tool & Routing Policies -| Variable | Default | Source File | Description | -| ----------------------------------- | ---------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `TOOL_POLICY_MODE` | `disabled` | `src/lib/toolPolicy.ts` | Controls LLM tool/function-calling access. `allowlist` = only listed tools, `denylist` = all except listed, `disabled` = no restrictions. | -| `OMNIROUTE_PAYLOAD_RULES_PATH` | `./config/payloadRules.json` | `open-sse/services/payloadRules.ts` | Path to payload manipulation rules JSON file (per-model/protocol upstream tweaks). | -| `OMNIROUTE_PAYLOAD_RULES_RELOAD_MS` | `5000` | `open-sse/services/payloadRules.ts` | Reload interval (ms) for hot-reloading the payload rules file. Minimum `1000`. | +| Variable | Default | Source File | Description | +| ----------------------------------------------------------- | ---------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `TOOL_POLICY_MODE` | `disabled` | `src/lib/toolPolicy.ts` | Controls LLM tool/function-calling access. `allowlist` = only listed tools, `denylist` = all except listed, `disabled` = no restrictions. | +| `OMNIROUTE_PAYLOAD_RULES_PATH` | `./config/payloadRules.json` | `open-sse/services/payloadRules.ts` | Path to payload manipulation rules JSON file (per-model/protocol upstream tweaks). | +| `OMNIROUTE_PAYLOAD_RULES_RELOAD_MS` | `5000` | `open-sse/services/payloadRules.ts` | Reload interval (ms) for hot-reloading the payload rules file. Minimum `1000`. | +| `OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS` | `false` | `open-sse/services/model.ts` | Opt-in: route bare `claude-*` model IDs from Claude Code clients through the Claude Code OAuth account instead of requiring a provider prefix. Explicit provider prefixes still win. Also configurable via a dashboard toggle on the Claude provider page. | --- @@ -240,9 +241,9 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `CLOUD_URL` | _(empty)_ | `src/lib/cloudSync.ts` | Cloud relay endpoint URL (premium feature). | | `CLOUD_SYNC_TIMEOUT_MS` | `12000` | `src/lib/cloudSync.ts` | HTTP timeout for cloud sync requests. | | `OMNIROUTE_BUILD_PROFILE` | `full` | Webpack build config | Build-time profile (set to `minimal` to physically exclude privileged modules from bundle). | -| `OMNIROUTE_CLOUD_SYNC_SECRET` | _(empty)_ | `src/lib/cloudSync.ts` | Shared secret used to verify the HMAC-SHA256 signature of Cloud Sync responses. | -| `OMNIROUTE_CLOUD_SYNC_SECRETS` | `false` | `src/lib/cloudSync.ts` | Set to `true` to allow the Cloud Sync endpoint to overwrite local credentials. Default is `false`. | -| `OMNIROUTE_ZED_IMPORT_LEGACY_ONE_STEP` | `false` | `src/app/api/providers/zed/import/route.ts` | Set to `true` to fall back to the v3.8.5 one-step "import everything" behavior without user confirmation. | +| `OMNIROUTE_CLOUD_SYNC_SECRET` | _(empty)_ | `src/lib/cloudSync.ts` | Shared secret used to verify the HMAC-SHA256 signature of Cloud Sync responses. | +| `OMNIROUTE_CLOUD_SYNC_SECRETS` | `false` | `src/lib/cloudSync.ts` | Set to `true` to allow the Cloud Sync endpoint to overwrite local credentials. Default is `false`. | +| `OMNIROUTE_ZED_IMPORT_LEGACY_ONE_STEP` | `false` | `src/app/api/providers/zed/import/route.ts` | Set to `true` to fall back to the v3.8.5 one-step "import everything" behavior without user confirmation. | | `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | OAuth, Dashboard, sync | Public-facing URL for OAuth redirect_uri, Dashboard links. **Must match your public URL behind reverse proxy.** | | `NEXT_PUBLIC_CLOUD_URL` | _(empty)_ | Client-side | Client-side mirror of `CLOUD_URL`. | | `NEXT_PUBLIC_APP_URL` | _(unset)_ | `src/shared/services/cloudSyncScheduler.ts` | Legacy fallback for `NEXT_PUBLIC_BASE_URL`. | @@ -293,22 +294,23 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex, etc.). -| Variable | Default | Source File | Description | -| ------------------------- | ---------- | ----------------------------------- | ---------------------------------------------------------------------------------- | -| `CLI_MODE` | `auto` | `src/shared/services/cliRuntime.ts` | `auto` = search system PATH; `manual` = use explicit paths only. | -| `CLI_EXTRA_PATHS` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Additional PATH entries for CLI binary discovery (colon-separated). | -| `CLI_CONFIG_HOME` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Override home directory for reading CLI configs (`~/.claude`, `~/.codex`). | -| `CLI_ALLOW_CONFIG_WRITES` | `false` | `src/shared/services/cliRuntime.ts` | Allow OmniRoute to write CLI config files (token refresh, session data). | -| `CLI_CLAUDE_BIN` | `claude` | `src/shared/services/cliRuntime.ts` | Custom path to Claude CLI binary. | -| `CLI_CODEX_BIN` | `codex` | `src/shared/services/cliRuntime.ts` | Custom path to Codex CLI binary. | -| `CLI_DROID_BIN` | `droid` | `src/shared/services/cliRuntime.ts` | Custom path to Droid CLI binary. | -| `CLI_OPENCLAW_BIN` | `openclaw` | `src/shared/services/cliRuntime.ts` | Custom path to OpenClaw CLI binary. | -| `CLI_CURSOR_BIN` | `agent` | `src/shared/services/cliRuntime.ts` | Custom path to Cursor agent binary. | -| `CLI_CLINE_BIN` | `cline` | `src/shared/services/cliRuntime.ts` | Custom path to Cline CLI binary. | -| `CLI_CONTINUE_BIN` | `cn` | `src/shared/services/cliRuntime.ts` | Custom path to Continue CLI binary. | -| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. | -| `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Custom path to the Qwen Code CLI binary. | -| `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Custom path to the Devin CLI binary (v3.8.0). Used by the Windsurf/Devin executor. | +| Variable | Default | Source File | Description | +| ------------------------- | ----------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `CLI_MODE` | `auto` | `src/shared/services/cliRuntime.ts` | `auto` = search system PATH; `manual` = use explicit paths only. | +| `CLI_EXTRA_PATHS` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Additional PATH entries for CLI binary discovery (colon-separated). | +| `CLI_CONFIG_HOME` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Override home directory for reading CLI configs (`~/.claude`, `~/.codex`). | +| `CLI_ALLOW_CONFIG_WRITES` | `false` | `src/shared/services/cliRuntime.ts` | Allow OmniRoute to write CLI config files (token refresh, session data). | +| `CLI_CLAUDE_BIN` | `claude` | `src/shared/services/cliRuntime.ts` | Custom path to Claude CLI binary. | +| `CLI_CODEX_BIN` | `codex` | `src/shared/services/cliRuntime.ts` | Custom path to Codex CLI binary. | +| `CLI_DROID_BIN` | `droid` | `src/shared/services/cliRuntime.ts` | Custom path to Droid CLI binary. | +| `CLI_OPENCLAW_BIN` | `openclaw` | `src/shared/services/cliRuntime.ts` | Custom path to OpenClaw CLI binary. | +| `CLI_CURSOR_BIN` | `agent` | `src/shared/services/cliRuntime.ts` | Custom path to Cursor agent binary. | +| `CLI_CLINE_BIN` | `cline` | `src/shared/services/cliRuntime.ts` | Custom path to Cline CLI binary. | +| `CLI_CONTINUE_BIN` | `cn` | `src/shared/services/cliRuntime.ts` | Custom path to Continue CLI binary. | +| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. | +| `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Custom path to the Qwen Code CLI binary. | +| `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Custom path to the Devin CLI binary (v3.8.0). Used by the Windsurf/Devin executor. | +| `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent home directory where OmniRoute reads/writes the Hermes CLI config. Matches the env var the Hermes PowerShell installer sets on Windows (`%LOCALAPPDATA%\hermes`). | ### Docker Example @@ -325,45 +327,45 @@ CLI_CLAUDE_BIN=/host-cli/bin/claude These variables tune the `omniroute` CLI binary's own behavior (not the sidecar detection above). -| Variable | Default | Source File | Description | -| --------------------------- | ---------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `OMNIROUTE_LANG` | _(system)_ | `bin/cli/i18n.mjs` | Force CLI output language. BCP-47 locale (e.g. `en`, `pt-BR`). Overrides system locale env vars (LC_ALL, LC_MESSAGES). | -| `OMNIROUTE_SHOW_LOG` | _(unset)_ | `bin/cli/runtime/processSupervisor.mjs` | Set to `1` to forward server stdout/stderr to the terminal in supervised mode. Equivalent to `--log` flag on `omniroute serve`. | -| `OMNIROUTE_CLI_TOKEN` | _(unset)_ | `bin/cli/api.mjs` | Machine-auth token injected as `x-omniroute-cli-token` header. Auto-generated in task 8.12. | -| `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | Per-attempt HTTP timeout (ms) for CLI → server requests. | -| `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | Set to `1` to print retry/backoff diagnostics to stderr during CLI commands. | -| `OMNIROUTE_PLUGIN_PATH` | _(unset)_ | `bin/cli/plugins.mjs` | Custom directory for CLI plugin discovery (`omniroute-cmd-*` packages). Defaults to `~/.omniroute/plugins/` when unset. | -| `OMNIROUTE_PLUGINS_ALLOW_EXEC` | `0` | `src/lib/plugins/pluginWorker.ts` | Set to `1` to allow plugins to request the `exec` permission (spawn child processes from the worker sandbox). Local operator only. | +| Variable | Default | Source File | Description | +| ------------------------------ | ---------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_LANG` | _(system)_ | `bin/cli/i18n.mjs` | Force CLI output language. BCP-47 locale (e.g. `en`, `pt-BR`). Overrides system locale env vars (LC_ALL, LC_MESSAGES). | +| `OMNIROUTE_SHOW_LOG` | _(unset)_ | `bin/cli/runtime/processSupervisor.mjs` | Set to `1` to forward server stdout/stderr to the terminal in supervised mode. Equivalent to `--log` flag on `omniroute serve`. | +| `OMNIROUTE_CLI_TOKEN` | _(unset)_ | `bin/cli/api.mjs` | Machine-auth token injected as `x-omniroute-cli-token` header. Auto-generated in task 8.12. | +| `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | Per-attempt HTTP timeout (ms) for CLI → server requests. | +| `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | Set to `1` to print retry/backoff diagnostics to stderr during CLI commands. | +| `OMNIROUTE_PLUGIN_PATH` | _(unset)_ | `bin/cli/plugins.mjs` | Custom directory for CLI plugin discovery (`omniroute-cmd-*` packages). Defaults to `~/.omniroute/plugins/` when unset. | +| `OMNIROUTE_PLUGINS_ALLOW_EXEC` | `0` | `src/lib/plugins/pluginWorker.ts` | Set to `1` to allow plugins to request the `exec` permission (spawn child processes from the worker sandbox). Local operator only. | --- ## 10. Internal Agent & MCP Integrations -| Variable | Default | Source File | Description | -| ----------------------------------------------- | ----------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `OMNIROUTE_BASE_URL` | auto-detect | `open-sse/mcp-server/server.ts` | Explicit URL for MCP/A2A tools to reach OmniRoute. Overrides localhost auto-detection. | -| `OMNIROUTE_API_KEY` | _(unset)_ | MCP/A2A modules | API key for internal MCP tool and A2A skill calls. | -| `OMNIROUTE_API_KEY_ID` | _(unset)_ | `open-sse/mcp-server/audit.ts` | Key ID for MCP audit log attribution. | -| `ROUTER_API_KEY` | _(unset)_ | Legacy | Legacy alias for `OMNIROUTE_API_KEY`. | -| `OMNIROUTE_MCP_ENFORCE_SCOPES` | `false` | `open-sse/mcp-server/server.ts` | Enforce scope-based access control on MCP tool calls. | -| `OMNIROUTE_MCP_SCOPES` | _(all)_ | `open-sse/mcp-server/server.ts` | Comma-separated scopes: `admin`, `combos`, `health`, `models`, `routing`, `budget`, `metrics`, `pricing`, `memory`, `skills`. | -| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | enabled | `open-sse/mcp-server/descriptionCompressor.ts` | Compress MCP tool descriptions before serializing the manifest. Disable values: `0`, `false`, `off`. | -| `OMNIROUTE_MCP_DESCRIPTION_COMPRESSION` | `rtk` | `open-sse/mcp-server/descriptionCompressor.ts` | Compression algorithm/profile. Disable values: `0`, `false`, `off`. | -| `MODEL_SYNC_INTERVAL_HOURS` | `24` | `src/shared/services/modelSyncScheduler.ts` | Model catalog sync interval in hours. | -| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/server-init.ts` | Provider rate-limit and quota polling interval. | -| `PROVIDER_LIMITS_SYNC_SPACING_MS` | `1500` | `src/lib/usage/providerLimits.ts` | Gap (ms) between consecutive OAuth quota fetches in a bulk sync; OAuth connections are fetched one at a time to avoid bursting an upstream. `0` opts out (concurrent). | -| `PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS` | `5000` | `src/lib/usage/providerLimits.ts` | Delay (ms) before refreshing provider limits after a real usage event, giving the upstream quota API time to register consumption. | -| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | Disable all background services (sync, pricing, model refresh). Useful for CI/test. | -| `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | _(unset)_ | `src/lib/config/runtimeSettings.ts` | Force background tasks on under automated test detection. Set `1` to override the test heuristic. | -| `OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS` | `600000` | `src/lib/jobs/budgetResetJob.ts` | Budget reset check cadence (ms). Floor `10000`. | -| `OMNIROUTE_REASONING_CACHE_CLEANUP_INTERVAL_MS` | `1800000` | `src/lib/jobs/reasoningCacheCleanupJob.ts` | Reasoning cache cleanup cadence (ms). Floor `60000`. | -| `OMNIROUTE_CONFIG_HOT_RELOAD_MS` | `5000` | `src/lib/config/hotReload.ts` | Polling interval (ms) for config hot-reload. Lower than `1000` is rejected. | -| `OMNIROUTE_DISABLE_REDIS_AUTH_CACHE` | _(enabled)_ | `src/lib/db/apiKeys.ts` | Set `1` to bypass the Redis-backed API-key auth cache (forces DB reads). | -| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | `0` | `open-sse/services/compression/engines/rtk/filterLoader.ts` | Trust user-managed RTK project filter rules without strict signature checks. | -| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. | -| `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | Escape hatch: allow request body to override the Antigravity project field. | -| `ANTIGRAVITY_CREDITS` | _(unset)_ | `open-sse/services/antigravityCredits.ts` | Override Antigravity's advertised remaining credits (testing / forced values). | -| `AGY_TOKEN_FILE` | `~/.gemini/antigravity-cli/antigravity-oauth-token` | `src/app/api/providers/agy-auth/apply-local/route.ts` | Override the Antigravity CLI (agy) token-file path for the auto-detect local login import. | +| Variable | Default | Source File | Description | +| ----------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_BASE_URL` | auto-detect | `open-sse/mcp-server/server.ts` | Explicit URL for MCP/A2A tools to reach OmniRoute. Overrides localhost auto-detection. | +| `OMNIROUTE_API_KEY` | _(unset)_ | MCP/A2A modules | API key for internal MCP tool and A2A skill calls. | +| `OMNIROUTE_API_KEY_ID` | _(unset)_ | `open-sse/mcp-server/audit.ts` | Key ID for MCP audit log attribution. | +| `ROUTER_API_KEY` | _(unset)_ | Legacy | Legacy alias for `OMNIROUTE_API_KEY`. | +| `OMNIROUTE_MCP_ENFORCE_SCOPES` | `false` | `open-sse/mcp-server/server.ts` | Enforce scope-based access control on MCP tool calls. | +| `OMNIROUTE_MCP_SCOPES` | _(all)_ | `open-sse/mcp-server/server.ts` | Comma-separated scopes: `admin`, `combos`, `health`, `models`, `routing`, `budget`, `metrics`, `pricing`, `memory`, `skills`. | +| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | enabled | `open-sse/mcp-server/descriptionCompressor.ts` | Compress MCP tool descriptions before serializing the manifest. Disable values: `0`, `false`, `off`. | +| `OMNIROUTE_MCP_DESCRIPTION_COMPRESSION` | `rtk` | `open-sse/mcp-server/descriptionCompressor.ts` | Compression algorithm/profile. Disable values: `0`, `false`, `off`. | +| `MODEL_SYNC_INTERVAL_HOURS` | `24` | `src/shared/services/modelSyncScheduler.ts` | Model catalog sync interval in hours. | +| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/server-init.ts` | Provider rate-limit and quota polling interval. | +| `PROVIDER_LIMITS_SYNC_SPACING_MS` | `1500` | `src/lib/usage/providerLimits.ts` | Gap (ms) between consecutive OAuth quota fetches in a bulk sync; OAuth connections are fetched one at a time to avoid bursting an upstream. `0` opts out (concurrent). | +| `PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS` | `5000` | `src/lib/usage/providerLimits.ts` | Delay (ms) before refreshing provider limits after a real usage event, giving the upstream quota API time to register consumption. | +| `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | Disable all background services (sync, pricing, model refresh). Useful for CI/test. | +| `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | _(unset)_ | `src/lib/config/runtimeSettings.ts` | Force background tasks on under automated test detection. Set `1` to override the test heuristic. | +| `OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS` | `600000` | `src/lib/jobs/budgetResetJob.ts` | Budget reset check cadence (ms). Floor `10000`. | +| `OMNIROUTE_REASONING_CACHE_CLEANUP_INTERVAL_MS` | `1800000` | `src/lib/jobs/reasoningCacheCleanupJob.ts` | Reasoning cache cleanup cadence (ms). Floor `60000`. | +| `OMNIROUTE_CONFIG_HOT_RELOAD_MS` | `5000` | `src/lib/config/hotReload.ts` | Polling interval (ms) for config hot-reload. Lower than `1000` is rejected. | +| `OMNIROUTE_DISABLE_REDIS_AUTH_CACHE` | _(enabled)_ | `src/lib/db/apiKeys.ts` | Set `1` to bypass the Redis-backed API-key auth cache (forces DB reads). | +| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | `0` | `open-sse/services/compression/engines/rtk/filterLoader.ts` | Trust user-managed RTK project filter rules without strict signature checks. | +| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. | +| `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | Escape hatch: allow request body to override the Antigravity project field. | +| `ANTIGRAVITY_CREDITS` | _(unset)_ | `open-sse/services/antigravityCredits.ts` | Override Antigravity's advertised remaining credits (testing / forced values). | +| `AGY_TOKEN_FILE` | `~/.gemini/antigravity-cli/antigravity-oauth-token` | `src/app/api/providers/agy-auth/apply-local/route.ts` | Override the Antigravity CLI (agy) token-file path for the auto-detect local login import. | ### OAuth CLI Bridge (Internal) @@ -437,20 +439,20 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] > **Source:** `open-sse/executors/base.ts` → `buildHeaders()` -| Variable | Default Value | When to Update | -| ------------------------ | --------------------------------------------- | ------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | -| `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | Set to `1`/`true` to forward third-party harness tool names verbatim to Anthropic on both Anthropic-bound paths (native OAuth and CLIProxyAPI). By default the executor deterministically aliases non-Claude-Code tool names (Claude Code canonical mapping where one exists, otherwise PascalCase) and reverses them on the response via `_toolNameMap`, so harnesses with snake_case tools are not refused as fingerprinted third-party clients. Debugging only. | -| `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | -| `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | -| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | -| `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | When Antigravity IDE updates | -| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | When Kiro IDE updates | -| `KIRO_OAUTH_CLIENT_ID` | `kiro-cli` | Override the Kiro social device-code `clientId` (public id) | -| `QODER_USER_AGENT` | `Qoder-Cli` | When Qoder CLI updates | -| `QWEN_USER_AGENT` | `QwenCode/0.15.9 (linux; x64)` | When Qwen Code updates | -| `CURSOR_USER_AGENT` | `Cursor/3.3` | When Cursor updates | -| `GEMINI_CLI_USER_AGENT` | `google-api-nodejs-client/10.3.0` | When Google API client updates | +| Variable | Default Value | When to Update | +| -------------------------------- | --------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.145 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | Set to `1`/`true` to forward third-party harness tool names verbatim to Anthropic on both Anthropic-bound paths (native OAuth and CLIProxyAPI). By default the executor deterministically aliases non-Claude-Code tool names (Claude Code canonical mapping where one exists, otherwise PascalCase) and reverses them on the response via `_toolNameMap`, so harnesses with snake_case tools are not refused as fingerprinted third-party clients. Debugging only. | +| `CODEX_USER_AGENT` | `codex-cli/0.132.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | +| `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | +| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.45.1` | When GitHub Copilot Chat updates | +| `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | When Antigravity IDE updates | +| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | When Kiro IDE updates | +| `KIRO_OAUTH_CLIENT_ID` | `kiro-cli` | Override the Kiro social device-code `clientId` (public id) | +| `QODER_USER_AGENT` | `Qoder-Cli` | When Qoder CLI updates | +| `QWEN_USER_AGENT` | `QwenCode/0.15.9 (linux; x64)` | When Qwen Code updates | +| `CURSOR_USER_AGENT` | `Cursor/3.3` | When Cursor updates | +| `GEMINI_CLI_USER_AGENT` | `google-api-nodejs-client/10.3.0` | When Google API client updates | > [!TIP] > You can add User-Agent overrides for **any** provider using the pattern `{PROVIDER_ID}_USER_AGENT`. The executor dynamically constructs the env var name. @@ -538,33 +540,33 @@ REQUEST_TIMEOUT_MS (global override) └── API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS (default: 0 = disabled) ``` -| Variable | Default | Description | -| ---------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------- | -| `REQUEST_TIMEOUT_MS` | _(unset)_ | Global shortcut — overrides both `FETCH_TIMEOUT_MS` and `STREAM_IDLE_TIMEOUT_MS` defaults. | -| `FETCH_TIMEOUT_MS` | `600000` | Total HTTP request timeout for upstream provider calls. | -| `STREAM_IDLE_TIMEOUT_MS` | `600000` | Max silence between SSE chunks before aborting. Extended-thinking models rarely pause >90s. | -| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive response headers. | -| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive the full response body. | -| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP connection establishment timeout. | -| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive socket idle timeout. | -| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS fingerprint proxy (wreq-js) timeout. | -| `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | Proxy hop timeout for `/v1` bridge requests. | -| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `300000` | Overall server request timeout for the bridge. | -| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Time to send response headers via the bridge. | -| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Bridge keep-alive idle timeout. | -| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Raw socket timeout (0 = disabled). | -| `SHUTDOWN_TIMEOUT_MS` | `30000` | Grace period on SIGTERM/SIGINT before force-exit. | -| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | Fallback used by `src/shared/utils/fetchTimeout.ts` when `FETCH_TIMEOUT_MS` is unset. | -| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`chatgptTlsClient.ts`). | -| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | -| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`claudeTlsClient.ts`). | -| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | -| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`perplexityTlsClient.ts`). | -| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | -| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`grokTlsClient.ts`). | -| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | -| `OMNIROUTE_BROWSER_POOL` | `on` | Shared Playwright browser pool for browser-backed web-cookie chat (`browserPool.ts`); set `off` to disable. | -| `WEB_COOKIE_USE_BROWSER` | `0` | Opt a web-cookie chat request into the browser-backed path (`browserBackedChat.ts`); `1` to enable. | +| Variable | Default | Description | +| ---------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------- | +| `REQUEST_TIMEOUT_MS` | _(unset)_ | Global shortcut — overrides both `FETCH_TIMEOUT_MS` and `STREAM_IDLE_TIMEOUT_MS` defaults. | +| `FETCH_TIMEOUT_MS` | `600000` | Total HTTP request timeout for upstream provider calls. | +| `STREAM_IDLE_TIMEOUT_MS` | `600000` | Max silence between SSE chunks before aborting. Extended-thinking models rarely pause >90s. | +| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive response headers. | +| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive the full response body. | +| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP connection establishment timeout. | +| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive socket idle timeout. | +| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS fingerprint proxy (wreq-js) timeout. | +| `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | Proxy hop timeout for `/v1` bridge requests. | +| `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `300000` | Overall server request timeout for the bridge. | +| `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Time to send response headers via the bridge. | +| `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Bridge keep-alive idle timeout. | +| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Raw socket timeout (0 = disabled). | +| `SHUTDOWN_TIMEOUT_MS` | `30000` | Grace period on SIGTERM/SIGINT before force-exit. | +| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | Fallback used by `src/shared/utils/fetchTimeout.ts` when `FETCH_TIMEOUT_MS` is unset. | +| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`chatgptTlsClient.ts`). | +| `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | +| `OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`claudeTlsClient.ts`). | +| `OMNIROUTE_CLAUDE_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | +| `OMNIROUTE_PPLX_TLS_TIMEOUT_MS` | `30000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`perplexityTlsClient.ts`). | +| `OMNIROUTE_PPLX_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | +| `OMNIROUTE_GROK_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`grokTlsClient.ts`). | +| `OMNIROUTE_GROK_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | +| `OMNIROUTE_BROWSER_POOL` | `on` | Shared Playwright browser pool for browser-backed web-cookie chat (`browserPool.ts`); set `off` to disable. | +| `WEB_COOKIE_USE_BROWSER` | `0` | Opt a web-cookie chat request into the browser-backed path (`browserBackedChat.ts`); `1` to enable. | Combo target attempts inherit the resolved upstream request timeout (`FETCH_TIMEOUT_MS`, or `REQUEST_TIMEOUT_MS` when it supplies the fetch default). Set `targetTimeoutMs` in a combo, @@ -624,18 +626,18 @@ The logging system writes to both stdout and rotated log files. All configuratio ## 17. Memory Optimization -| Variable | Default | Description | -| -------------------------- | ------------------------------- | ---------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | -| `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | -| `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | -| `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | -| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max cached temperature=0 responses. | -| `SEMANTIC_CACHE_MAX_BYTES` | `4194304` (4 MB) | Max total semantic cache size. | -| `SEMANTIC_CACHE_TTL_MS` | `1800000` (30 min) | Semantic cache entry TTL. | -| `STREAM_HISTORY_MAX` | `50` | Max recent stream events in the Dashboard live view buffer. | -| `CONTEXT_LENGTH_DEFAULT` | `128000` | Global fallback max context length for models without explicit config. | -| `USAGE_TOKEN_BUFFER` | `100` | Extra token headroom reserved when tracking usage quotas. | +| Variable | Default | Description | +| -------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_MEMORY_MB` | `512` | Runtime V8 heap limit. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | +| `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | +| `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | +| `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | +| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Max cached temperature=0 responses. | +| `SEMANTIC_CACHE_MAX_BYTES` | `4194304` (4 MB) | Max total semantic cache size. | +| `SEMANTIC_CACHE_TTL_MS` | `1800000` (30 min) | Semantic cache entry TTL. | +| `STREAM_HISTORY_MAX` | `50` | Max recent stream events in the Dashboard live view buffer. | +| `CONTEXT_LENGTH_DEFAULT` | `128000` | Global fallback max context length for models without explicit config. | +| `USAGE_TOKEN_BUFFER` | `100` | Extra token headroom reserved when tracking usage quotas. | ### Compression @@ -647,16 +649,16 @@ The logging system writes to both stdout and rotated log files. All configuratio Embedding layer, vector store and reranking knobs for the persistent memory subsystem (`src/lib/memory/`). -| Variable | Default | Description | -| ------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `MEMORY_EMBEDDING_CACHE_TTL_MS` | `300000` (5 min) | TTL for the in-memory embedding cache (per source/model/dim signature). | -| `MEMORY_EMBEDDING_CACHE_MAX` | `1000` | Max LRU entries kept in the embedding cache. | -| `MEMORY_TRANSFORMERS_MODEL` | `Xenova/all-MiniLM-L6-v2` | HF repo id for the opt-in `@huggingface/transformers` local MiniLM pipeline (~23 MB int8, ~400 MB RAM). | -| `MEMORY_STATIC_MODEL` | `minishlab/potion-base-8M` | HF repo id for the static potion/Model2Vec lookup-table embedder. Downloaded lazily into the cache dir. | -| `MEMORY_STATIC_CACHE_DIR` | `/embeddings` | Directory used to cache the static potion model files. Defaults under `DATA_DIR` when unset. | -| `MEMORY_VEC_TOP_K` | `20` | Default top-K used by the `sqlite-vec` brute-force vector search inside `src/lib/memory/vectorStore.ts`. | -| `MEMORY_RRF_K` | `60` | Reciprocal Rank Fusion constant `k` for hybrid FTS5 + vector retrieval (sqlite-vec recipe). | -| `HF_HUB_ENDPOINT` | `https://huggingface.co` | Override Hugging Face Hub base URL used by `staticPotion.ts` (e.g. mirror endpoint for air-gapped setups). | +| Variable | Default | Description | +| ------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `MEMORY_EMBEDDING_CACHE_TTL_MS` | `300000` (5 min) | TTL for the in-memory embedding cache (per source/model/dim signature). | +| `MEMORY_EMBEDDING_CACHE_MAX` | `1000` | Max LRU entries kept in the embedding cache. | +| `MEMORY_TRANSFORMERS_MODEL` | `Xenova/all-MiniLM-L6-v2` | HF repo id for the opt-in `@huggingface/transformers` local MiniLM pipeline (~23 MB int8, ~400 MB RAM). | +| `MEMORY_STATIC_MODEL` | `minishlab/potion-base-8M` | HF repo id for the static potion/Model2Vec lookup-table embedder. Downloaded lazily into the cache dir. | +| `MEMORY_STATIC_CACHE_DIR` | `/embeddings` | Directory used to cache the static potion model files. Defaults under `DATA_DIR` when unset. | +| `MEMORY_VEC_TOP_K` | `20` | Default top-K used by the `sqlite-vec` brute-force vector search inside `src/lib/memory/vectorStore.ts`. | +| `MEMORY_RRF_K` | `60` | Reciprocal Rank Fusion constant `k` for hybrid FTS5 + vector retrieval (sqlite-vec recipe). | +| `HF_HUB_ENDPOINT` | `https://huggingface.co` | Override Hugging Face Hub base URL used by `staticPotion.ts` (e.g. mirror endpoint for air-gapped setups). | ### Low-RAM Docker Example @@ -693,27 +695,27 @@ Automatic model pricing data synchronization from external sources. ## 20. Provider-Specific Settings -| Variable | Default | Source File | Description | -| ----------------------------------------- | ------------------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| `OPENROUTER_CATALOG_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterCatalog.ts` | OpenRouter model catalog cache TTL. | +| Variable | Default | Source File | Description | +| ----------------------------------------- | ------------------ | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `OPENROUTER_CATALOG_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterCatalog.ts` | OpenRouter model catalog cache TTL. | | `MODEL_CATALOG_INCLUDE_NAMES` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | Include display-friendly `name` fields in `/v1/models` responses. Disable for clients that expect IDs only. | -| `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | Max wait for NanoBanana image generation jobs. | -| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. | -| `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). | -| `AWS_DEFAULT_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Fallback when `AWS_REGION` is not set. | -| `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. | -| `CLOUDFLARED_BIN` | auto-detect | `src/lib/cloudflaredTunnel.ts` | Custom path to `cloudflared` binary. | -| `SEARCH_CACHE_TTL_MS` | `300000` (5 min) | `open-sse/services/searchCache.ts` | TTL for search API (Perplexity, Brave, etc.) response caching. | -| `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | `false` | `src/app/api/providers/route.ts` | Allow multiple simultaneous connections per OpenAI-compatible provider. | -| `ENABLE_CC_COMPATIBLE_PROVIDER` | `false` | `src/shared/utils/featureFlags.ts` | Reveal the experimental CC-compatible provider UI for Claude Code-only relays. | -| `NINEROUTER_HOST` | `127.0.0.1` | `open-sse/executors/ninerouter.ts` | Override the host where the embedded 9router instance listens. | -| `NINEROUTER_PORT` | `20130` | `open-sse/executors/ninerouter.ts` | Override the port where the embedded 9router instance listens. | -| `EMBED_WS_PROXY_HOST` | `127.0.0.1` | `src/lib/services/embedWsProxy.ts` | Bind host for the embedded-service WebSocket proxy (loopback only by default). | -| `EMBED_WS_PROXY_PORT` | `20131` | `src/lib/services/embedWsProxy.ts` | Port for the embedded-service WebSocket proxy server. | -| `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge host (legacy integration). | -| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. | -| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. | -| `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Comma-separated additional hostnames treated as "local" (Docker service names, etc.). | +| `NANOBANANA_POLL_TIMEOUT_MS` | `120000` | `open-sse/handlers/imageGeneration.ts` | Max wait for NanoBanana image generation jobs. | +| `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. | +| `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). | +| `AWS_DEFAULT_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Fallback when `AWS_REGION` is not set. | +| `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. | +| `CLOUDFLARED_BIN` | auto-detect | `src/lib/cloudflaredTunnel.ts` | Custom path to `cloudflared` binary. | +| `SEARCH_CACHE_TTL_MS` | `300000` (5 min) | `open-sse/services/searchCache.ts` | TTL for search API (Perplexity, Brave, etc.) response caching. | +| `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | `false` | `src/app/api/providers/route.ts` | Allow multiple simultaneous connections per OpenAI-compatible provider. | +| `ENABLE_CC_COMPATIBLE_PROVIDER` | `false` | `src/shared/utils/featureFlags.ts` | Reveal the experimental CC-compatible provider UI for Claude Code-only relays. | +| `NINEROUTER_HOST` | `127.0.0.1` | `open-sse/executors/ninerouter.ts` | Override the host where the embedded 9router instance listens. | +| `NINEROUTER_PORT` | `20130` | `open-sse/executors/ninerouter.ts` | Override the port where the embedded 9router instance listens. | +| `EMBED_WS_PROXY_HOST` | `127.0.0.1` | `src/lib/services/embedWsProxy.ts` | Bind host for the embedded-service WebSocket proxy (loopback only by default). | +| `EMBED_WS_PROXY_PORT` | `20131` | `src/lib/services/embedWsProxy.ts` | Port for the embedded-service WebSocket proxy server. | +| `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge host (legacy integration). | +| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. | +| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. | +| `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Comma-separated additional hostnames treated as "local" (Docker service names, etc.). | `ENABLE_CC_COMPATIBLE_PROVIDER` is only for third-party relays that accept Claude Code clients exclusively. OmniRoute rewrites requests so those relays accept them. If you only want to use @@ -724,18 +726,18 @@ Anthropic-compatible provider instead. ## 21. Proxy Health -| Variable | Default | Source File | Description | -| ---------------------------- | ---------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Fast-fail health check timeout. | -| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. | -| `RATE_LIMIT_MAX_WAIT_MS` | `120000` (2 min) | `open-sse/services/rateLimitManager.ts` | Max time to wait on a 429 before failing the request. | -| `RATE_LIMIT_AUTO_ENABLE` | _(unset)_ | `open-sse/services/rateLimitManager.ts` | Force the auto-enable rate limit safety net on/off regardless of the persisted Dashboard setting. Accepts `true`/`1`/`on` to force on, `false`/`0`/`off` to force off. | +| Variable | Default | Source File | Description | +| ---------------------------- | ---------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Fast-fail health check timeout. | +| `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. | +| `RATE_LIMIT_MAX_WAIT_MS` | `120000` (2 min) | `open-sse/services/rateLimitManager.ts` | Max time to wait on a 429 before failing the request. | +| `RATE_LIMIT_AUTO_ENABLE` | _(unset)_ | `open-sse/services/rateLimitManager.ts` | Force the auto-enable rate limit safety net on/off regardless of the persisted Dashboard setting. Accepts `true`/`1`/`on` to force on, `false`/`0`/`off` to force off. | | `PROVIDER_COOLDOWN_ENABLED` | _(unset → off)_ | `open-sse/services/providerCooldownTracker.ts` | Opt-in global cross-request provider/connection cooldown tracking. OFF by default (overlaps Connection Cooldown / Provider Circuit Breaker). Accepts `true`/`1`/`on` to enable. | -| `PROVIDER_COOLDOWN_MIN_MS` | `5000` | `open-sse/services/providerCooldownTracker.ts` | Minimum cooldown (ms) before a failed provider/connection is retried. Scaled exponentially with consecutive failures. Only used when `PROVIDER_COOLDOWN_ENABLED`. | -| `PROVIDER_COOLDOWN_MAX_MS` | `300000` (5 min) | `open-sse/services/providerCooldownTracker.ts` | Maximum cooldown (ms) cap before a failed provider/connection is retried regardless. Only used when `PROVIDER_COOLDOWN_ENABLED`. | -| `HEALTHCHECK_STAGGER_MS` | `3000` | `src/lib/tokenHealthCheck.ts` | Stagger interval (ms) between provider token healthchecks at startup. | -| `REQUEST_RETRY` | `2` | `src/sse/services/cooldownAwareRetry.ts` | Number of automatic retries on model-scoped cooldown responses before returning error to client. | -| `MAX_RETRY_INTERVAL_SEC` | `30` | `src/sse/services/cooldownAwareRetry.ts` | Max backoff interval (seconds) between cooldown retries. Capped by this value regardless of upstream `Retry-After`. | +| `PROVIDER_COOLDOWN_MIN_MS` | `5000` | `open-sse/services/providerCooldownTracker.ts` | Minimum cooldown (ms) before a failed provider/connection is retried. Scaled exponentially with consecutive failures. Only used when `PROVIDER_COOLDOWN_ENABLED`. | +| `PROVIDER_COOLDOWN_MAX_MS` | `300000` (5 min) | `open-sse/services/providerCooldownTracker.ts` | Maximum cooldown (ms) cap before a failed provider/connection is retried regardless. Only used when `PROVIDER_COOLDOWN_ENABLED`. | +| `HEALTHCHECK_STAGGER_MS` | `3000` | `src/lib/tokenHealthCheck.ts` | Stagger interval (ms) between provider token healthchecks at startup. | +| `REQUEST_RETRY` | `2` | `src/sse/services/cooldownAwareRetry.ts` | Number of automatic retries on model-scoped cooldown responses before returning error to client. | +| `MAX_RETRY_INTERVAL_SEC` | `30` | `src/sse/services/cooldownAwareRetry.ts` | Max backoff interval (seconds) between cooldown retries. Capped by this value regardless of upstream `Retry-After`. | --- @@ -744,19 +746,19 @@ Anthropic-compatible provider instead. > [!CAUTION] > These variables produce **verbose output** and may leak sensitive data. **Never enable in production.** -| Variable | Default | Source File | Description | -| -------------------------------- | ------------------- | ------------------------------------------ | --------------------------------------------------------------------------------- | -| `CURSOR_DEBUG` | _(unset)_ | `open-sse/executors/cursor.ts` | Set `1` to enable verbose Cursor executor logs (decoded SSE chunks, etc.). | -| `CURSOR_STREAM_DEBUG` | _(unset)_ | `open-sse/executors/cursor.ts` | Backward-compatible alias of `CURSOR_DEBUG`. | -| `CURSOR_DUMP_FILE` | _(unset)_ | `open-sse/executors/cursor.ts` | Optional file path that receives raw decoded Cursor chunks when `CURSOR_DEBUG=1`. | -| `CURSOR_STREAM_TIMEOUT_MS` | `300000` | `open-sse/executors/cursor.ts` | Stream idle timeout (ms) for the Cursor executor. | +| Variable | Default | Source File | Description | +| -------------------------------- | ------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------- | +| `CURSOR_DEBUG` | _(unset)_ | `open-sse/executors/cursor.ts` | Set `1` to enable verbose Cursor executor logs (decoded SSE chunks, etc.). | +| `CURSOR_STREAM_DEBUG` | _(unset)_ | `open-sse/executors/cursor.ts` | Backward-compatible alias of `CURSOR_DEBUG`. | +| `CURSOR_DUMP_FILE` | _(unset)_ | `open-sse/executors/cursor.ts` | Optional file path that receives raw decoded Cursor chunks when `CURSOR_DEBUG=1`. | +| `CURSOR_STREAM_TIMEOUT_MS` | `300000` | `open-sse/executors/cursor.ts` | Stream idle timeout (ms) for the Cursor executor. | | `CURSOR_TOOL_DIRECTIVE` | enabled (`!== "0"`) | `open-sse/executors/cursor.ts` | Tool-commit directive that makes composer-2.5 reliably issue tool calls. Set `0` to disable. | -| `CURSOR_IMAGE_FETCH_TIMEOUT_MS` | `15000` | `open-sse/utils/cursorImages.ts` | Per-image fetch timeout (ms) for remote `image_url` vision input. | -| `CURSOR_STATE_DB_PATH` | _(probed)_ | `open-sse/utils/cursorVersionDetector.ts` | Override the Cursor state DB lookup used for version detection. | -| `CURSOR_TOKEN` | _(unset)_ | `scripts/ad-hoc/cursor-tap.cjs` | Direct Cursor bearer token used by developer tooling. | -| `OMNIROUTE_LOG_REQUEST_SHAPE` | enabled (`!== "0"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads. Set `"0"` to silence. | -| `DEBUG_RESPONSES_SSE_TO_JSON` | _(unset)_ | `open-sse/handlers/responseTranslator.ts` | Set `true` to log Responses API SSE→JSON translation details. | -| `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` | _(unset)_ | E2E test harness | Set `true` to enable E2E test mode (relaxed auth, test hooks). | +| `CURSOR_IMAGE_FETCH_TIMEOUT_MS` | `15000` | `open-sse/utils/cursorImages.ts` | Per-image fetch timeout (ms) for remote `image_url` vision input. | +| `CURSOR_STATE_DB_PATH` | _(probed)_ | `open-sse/utils/cursorVersionDetector.ts` | Override the Cursor state DB lookup used for version detection. | +| `CURSOR_TOKEN` | _(unset)_ | `scripts/ad-hoc/cursor-tap.cjs` | Direct Cursor bearer token used by developer tooling. | +| `OMNIROUTE_LOG_REQUEST_SHAPE` | enabled (`!== "0"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads. Set `"0"` to silence. | +| `DEBUG_RESPONSES_SSE_TO_JSON` | _(unset)_ | `open-sse/handlers/responseTranslator.ts` | Set `true` to log Responses API SSE→JSON translation details. | +| `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` | _(unset)_ | E2E test harness | Set `true` to enable E2E test mode (relaxed auth, test hooks). | --- @@ -856,57 +858,57 @@ Limits and safety knobs applied when the Skills framework (`src/lib/skills/`) ex Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), the 1Proxy egress pool, database backups and small per-feature overrides referenced by the executor layer or scripts. -| Variable | Default | Source File | Description | -| ------------------------------------------ | --------------------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------- | -| `REDIS_URL` | `redis://localhost:6379` | `src/shared/utils/rateLimiter.ts` | Redis connection string for the rate limiter backend. | -| `ALIBABA_CODING_PLAN_HOST` | _(production host)_ | `open-sse/services/bailianQuotaFetcher.ts` | Override the host used to fetch Alibaba Bailian coding-plan quotas. | -| `ALIBABA_CODING_PLAN_QUOTA_URL` | derived from host | `open-sse/services/bailianQuotaFetcher.ts` | Full quota URL override for Alibaba Bailian. | -| `CONTEXT_RESERVE_TOKENS` | `1024` | `open-sse/services/contextManager.ts` | Tokens reserved for completion output when computing prompt budgets. | -| `MODEL_ALIAS_COMPAT_ENABLED` | enabled | `open-sse/services/model.ts` | Toggle the legacy model-alias compatibility layer used by older clients. | -| `COMMAND_CODE_CALLBACK_PORT` | _(unset)_ | `src/app/api/providers/command-code/auth/shared.ts` | Local port used for OAuth-style callbacks from the Command Code CLI helper. | -| `COMMAND_CODE_VERSION` | `0.33.2` | `open-sse/executors/commandCode.ts` | Value sent as the `x-command-code-version` header to the Command Code upstream. Override to bump the CLI version. | -| `MITM_LOCAL_PORT` | `443` | `src/mitm/server.cjs` | Local bind port for the MITM debug proxy. | -| `MITM_DISABLE_TLS_VERIFY` | `0` | `src/mitm/server.cjs` | Set `1` to disable upstream TLS verification (development only). | -| `ONEPROXY_ENABLED` | `true` | `src/lib/oneproxySync.ts` | Enable the 1Proxy egress pool sync. | -| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | `src/lib/oneproxySync.ts` | 1Proxy service API URL override. | -| `ONEPROXY_MAX_PROXIES` | `500` | `src/lib/oneproxySync.ts` | Maximum proxies imported per sync. | -| `ONEPROXY_MIN_QUALITY_THRESHOLD` | `50` | `src/lib/oneproxySync.ts` | Minimum quality score for imported proxies. | -| `FREE_PROXY_1PROXY_ENABLED` | `true` | `src/lib/freeProxyProviders/oneproxy.ts` | Enable the 1proxy free proxy source. Set to `false` to disable. | -| `FREE_PROXY_1PROXY_API_URL` | _(see oneproxy.ts)_ | `src/lib/freeProxyProviders/oneproxy.ts` | 1proxy API URL override. | -| `FREE_PROXY_1PROXY_MAX` | `500` | `src/lib/freeProxyProviders/oneproxy.ts` | Maximum proxies fetched per sync from 1proxy. | -| `FREE_PROXY_1PROXY_MIN_QUALITY` | `50` | `src/lib/freeProxyProviders/oneproxy.ts` | Minimum quality score threshold for 1proxy imports. | -| `FREE_PROXY_PROXIFLY_ENABLED` | `true` | `src/lib/freeProxyProviders/proxifly.ts` | Enable the Proxifly free proxy source. Set to `false` to disable. | -| `FREE_PROXY_PROXIFLY_QUANTITY` | `100` | `src/lib/freeProxyProviders/proxifly.ts` | Number of proxies to fetch per Proxifly sync. | -| `FREE_PROXY_PROXIFLY_ANONYMITY` | `elite` | `src/lib/freeProxyProviders/proxifly.ts` | Anonymity level filter for Proxifly (`elite`, `anonymous`, `transparent`). | -| `FREE_PROXY_IPLOCATE_ENABLED` | `false` | `src/lib/freeProxyProviders/iplocate.ts` | Enable the IPLocate free proxy source. Opt-in only. | -| `FREE_PROXY_IPLOCATE_BASE_URL` | `https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols` | `src/lib/freeProxyProviders/iplocate.ts` | IPLocate proxy list base URL override. | -| `NEXT_PUBLIC_VERCEL_RELAY_ENABLED` | `true` | `src/app/(dashboard)/…/ProxyPoolTab.tsx` | Show/hide the Deploy Vercel Relay button in the Proxy Pool tab. | -| `VERCEL_API_BASE` | `https://api.vercel.com` | `src/app/api/settings/proxy/vercel-deploy/route.ts` | Vercel API base URL override (for testing). | -| `NEXT_PUBLIC_VERCEL_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/…/VercelRelayModal.tsx` | Default project name pre-filled in the Vercel Relay deploy modal. | -| `TAILSCALE_BIN` | _(auto-detect)_ | `src/lib/tailscaleTunnel.ts` | Explicit path to the `tailscale` binary. | -| `TAILSCALED_BIN` | _(auto-detect)_ | `src/lib/tailscaleTunnel.ts` | Explicit path to the `tailscaled` daemon binary. | -| `NGROK_AUTHTOKEN` | _(unset)_ | `src/lib/ngrokTunnel.ts` | Authenticates outbound ngrok tunnels. | -| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | Maximum SQLite backup files retained on disk. | -| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. | -| `OMNIROUTE_TLS_PROXY_URL` | _(unset)_ | `open-sse/services/chatgptTlsClient.ts` | Override the TLS sidecar URL for tests. Production should leave unset. | -| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | Container runtime hint for the entrypoint permission check. Set to `podman` under rootless Podman so the fix instructions use `podman unshare chown` instead of `sudo chown`. | -| `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | Quota-share consumption store backend: `sqlite` (default) or `redis`. | -| `QUOTA_STORE_REDIS_URL` | _(unset)_ | `src/lib/quota/storeFactory.ts` | Redis connection string used when `QUOTA_STORE_DRIVER=redis` (e.g. `redis://localhost:6379`). | -| `QUOTA_SATURATION_THRESHOLD` | `0.5` | `src/lib/quota/enforce.ts` | Pool saturation ratio (0..1); at/above it the pool enters strict mode (no borrowing). | -| `QUOTA_SOFT_DEPRIORITIZE_FACTOR` | `0.7` | `open-sse/services/combo.ts` | Score multiplier (0..1) applied to a target when the soft quota policy deprioritizes it. | -| `QUOTA_CONSUMPTION_RETENTION_DAYS` | `14` | `src/lib/db/quotaConsumption.ts` | Retention window (days) for `quota_consumption` buckets before GC (`gcQuotaConsumption`). | -| `AGENTBRIDGE_UPSTREAM_CA_CERT` | _(unset)_ | `src/mitm/manager.ts` | Extra CA certificate (PEM) trusted for AgentBridge upstream TLS connections. | -| `INSPECTOR_BUFFER_SIZE` | `1000` | `src/mitm/inspector/buffer.ts` | Max captured requests held in the Traffic Inspector ring buffer. | -| `INSPECTOR_MAX_BODY_KB` | `1024` | `src/mitm/inspector/buffer.ts` | Max captured request/response body size (KB) before truncation. | -| `INSPECTOR_HTTP_PROXY_PORT` | `8080` | `src/mitm/inspector/httpProxyServer.ts` | Local port for the Traffic Inspector HTTP proxy. | -| `INSPECTOR_HTTP_PROXY_AUTOSTART` | `false` | `src/mitm/inspector/httpProxyServer.ts` | Auto-start the inspector HTTP proxy on boot. | -| `INSPECTOR_TLS_INTERCEPT` | `false` | `src/lib/inspector/captureState.ts` | Enable TLS interception (MITM) for captured HTTPS traffic. | -| `INSPECTOR_LLM_HOSTS_EXTRA` | _(unset)_ | `src/lib/inspector/captureState.ts` | Extra hostnames (comma-separated) treated as LLM endpoints for capture. | -| `INSPECTOR_MASK_SECRETS` | `true` | `src/mitm/inspector/buffer.ts` | Mask secrets (auth headers / API keys) in captured traffic. | -| `INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES` | `30` | `src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts` | Minutes before the system-proxy guard auto-reverts OS proxy settings. | -| `INSPECTOR_INTERNAL_INGEST_TOKEN` | _(auto)_ | `src/app/api/tools/traffic-inspector/internal/ingest/route.ts` | Token authenticating internal capture ingest into the inspector. | -| `PLAYGROUND_COMPARE_MAX_COLUMNS` | `4` | `src/app/(dashboard)/dashboard/playground/` | Max number of side-by-side columns in the Playground compare mode. | -| `PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL` | _(unset)_ | `src/app/(dashboard)/dashboard/playground/` | Default model for the Playground 'improve prompt' action (falls back to the active model when unset). | +| Variable | Default | Source File | Description | +| ------------------------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `REDIS_URL` | `redis://localhost:6379` | `src/shared/utils/rateLimiter.ts` | Redis connection string for the rate limiter backend. | +| `ALIBABA_CODING_PLAN_HOST` | _(production host)_ | `open-sse/services/bailianQuotaFetcher.ts` | Override the host used to fetch Alibaba Bailian coding-plan quotas. | +| `ALIBABA_CODING_PLAN_QUOTA_URL` | derived from host | `open-sse/services/bailianQuotaFetcher.ts` | Full quota URL override for Alibaba Bailian. | +| `CONTEXT_RESERVE_TOKENS` | `1024` | `open-sse/services/contextManager.ts` | Tokens reserved for completion output when computing prompt budgets. | +| `MODEL_ALIAS_COMPAT_ENABLED` | enabled | `open-sse/services/model.ts` | Toggle the legacy model-alias compatibility layer used by older clients. | +| `COMMAND_CODE_CALLBACK_PORT` | _(unset)_ | `src/app/api/providers/command-code/auth/shared.ts` | Local port used for OAuth-style callbacks from the Command Code CLI helper. | +| `COMMAND_CODE_VERSION` | `0.33.2` | `open-sse/executors/commandCode.ts` | Value sent as the `x-command-code-version` header to the Command Code upstream. Override to bump the CLI version. | +| `MITM_LOCAL_PORT` | `443` | `src/mitm/server.cjs` | Local bind port for the MITM debug proxy. | +| `MITM_DISABLE_TLS_VERIFY` | `0` | `src/mitm/server.cjs` | Set `1` to disable upstream TLS verification (development only). | +| `ONEPROXY_ENABLED` | `true` | `src/lib/oneproxySync.ts` | Enable the 1Proxy egress pool sync. | +| `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | `src/lib/oneproxySync.ts` | 1Proxy service API URL override. | +| `ONEPROXY_MAX_PROXIES` | `500` | `src/lib/oneproxySync.ts` | Maximum proxies imported per sync. | +| `ONEPROXY_MIN_QUALITY_THRESHOLD` | `50` | `src/lib/oneproxySync.ts` | Minimum quality score for imported proxies. | +| `FREE_PROXY_1PROXY_ENABLED` | `true` | `src/lib/freeProxyProviders/oneproxy.ts` | Enable the 1proxy free proxy source. Set to `false` to disable. | +| `FREE_PROXY_1PROXY_API_URL` | _(see oneproxy.ts)_ | `src/lib/freeProxyProviders/oneproxy.ts` | 1proxy API URL override. | +| `FREE_PROXY_1PROXY_MAX` | `500` | `src/lib/freeProxyProviders/oneproxy.ts` | Maximum proxies fetched per sync from 1proxy. | +| `FREE_PROXY_1PROXY_MIN_QUALITY` | `50` | `src/lib/freeProxyProviders/oneproxy.ts` | Minimum quality score threshold for 1proxy imports. | +| `FREE_PROXY_PROXIFLY_ENABLED` | `true` | `src/lib/freeProxyProviders/proxifly.ts` | Enable the Proxifly free proxy source. Set to `false` to disable. | +| `FREE_PROXY_PROXIFLY_QUANTITY` | `100` | `src/lib/freeProxyProviders/proxifly.ts` | Number of proxies to fetch per Proxifly sync. | +| `FREE_PROXY_PROXIFLY_ANONYMITY` | `elite` | `src/lib/freeProxyProviders/proxifly.ts` | Anonymity level filter for Proxifly (`elite`, `anonymous`, `transparent`). | +| `FREE_PROXY_IPLOCATE_ENABLED` | `false` | `src/lib/freeProxyProviders/iplocate.ts` | Enable the IPLocate free proxy source. Opt-in only. | +| `FREE_PROXY_IPLOCATE_BASE_URL` | `https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols` | `src/lib/freeProxyProviders/iplocate.ts` | IPLocate proxy list base URL override. | +| `NEXT_PUBLIC_VERCEL_RELAY_ENABLED` | `true` | `src/app/(dashboard)/…/ProxyPoolTab.tsx` | Show/hide the Deploy Vercel Relay button in the Proxy Pool tab. | +| `VERCEL_API_BASE` | `https://api.vercel.com` | `src/app/api/settings/proxy/vercel-deploy/route.ts` | Vercel API base URL override (for testing). | +| `NEXT_PUBLIC_VERCEL_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/…/VercelRelayModal.tsx` | Default project name pre-filled in the Vercel Relay deploy modal. | +| `TAILSCALE_BIN` | _(auto-detect)_ | `src/lib/tailscaleTunnel.ts` | Explicit path to the `tailscale` binary. | +| `TAILSCALED_BIN` | _(auto-detect)_ | `src/lib/tailscaleTunnel.ts` | Explicit path to the `tailscaled` daemon binary. | +| `NGROK_AUTHTOKEN` | _(unset)_ | `src/lib/ngrokTunnel.ts` | Authenticates outbound ngrok tunnels. | +| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | Maximum SQLite backup files retained on disk. | +| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. | +| `OMNIROUTE_TLS_PROXY_URL` | _(unset)_ | `open-sse/services/chatgptTlsClient.ts` | Override the TLS sidecar URL for tests. Production should leave unset. | +| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | Container runtime hint for the entrypoint permission check. Set to `podman` under rootless Podman so the fix instructions use `podman unshare chown` instead of `sudo chown`. | +| `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | Quota-share consumption store backend: `sqlite` (default) or `redis`. | +| `QUOTA_STORE_REDIS_URL` | _(unset)_ | `src/lib/quota/storeFactory.ts` | Redis connection string used when `QUOTA_STORE_DRIVER=redis` (e.g. `redis://localhost:6379`). | +| `QUOTA_SATURATION_THRESHOLD` | `0.5` | `src/lib/quota/enforce.ts` | Pool saturation ratio (0..1); at/above it the pool enters strict mode (no borrowing). | +| `QUOTA_SOFT_DEPRIORITIZE_FACTOR` | `0.7` | `open-sse/services/combo.ts` | Score multiplier (0..1) applied to a target when the soft quota policy deprioritizes it. | +| `QUOTA_CONSUMPTION_RETENTION_DAYS` | `14` | `src/lib/db/quotaConsumption.ts` | Retention window (days) for `quota_consumption` buckets before GC (`gcQuotaConsumption`). | +| `AGENTBRIDGE_UPSTREAM_CA_CERT` | _(unset)_ | `src/mitm/manager.ts` | Extra CA certificate (PEM) trusted for AgentBridge upstream TLS connections. | +| `INSPECTOR_BUFFER_SIZE` | `1000` | `src/mitm/inspector/buffer.ts` | Max captured requests held in the Traffic Inspector ring buffer. | +| `INSPECTOR_MAX_BODY_KB` | `1024` | `src/mitm/inspector/buffer.ts` | Max captured request/response body size (KB) before truncation. | +| `INSPECTOR_HTTP_PROXY_PORT` | `8080` | `src/mitm/inspector/httpProxyServer.ts` | Local port for the Traffic Inspector HTTP proxy. | +| `INSPECTOR_HTTP_PROXY_AUTOSTART` | `false` | `src/mitm/inspector/httpProxyServer.ts` | Auto-start the inspector HTTP proxy on boot. | +| `INSPECTOR_TLS_INTERCEPT` | `false` | `src/lib/inspector/captureState.ts` | Enable TLS interception (MITM) for captured HTTPS traffic. | +| `INSPECTOR_LLM_HOSTS_EXTRA` | _(unset)_ | `src/lib/inspector/captureState.ts` | Extra hostnames (comma-separated) treated as LLM endpoints for capture. | +| `INSPECTOR_MASK_SECRETS` | `true` | `src/mitm/inspector/buffer.ts` | Mask secrets (auth headers / API keys) in captured traffic. | +| `INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES` | `30` | `src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts` | Minutes before the system-proxy guard auto-reverts OS proxy settings. | +| `INSPECTOR_INTERNAL_INGEST_TOKEN` | _(auto)_ | `src/app/api/tools/traffic-inspector/internal/ingest/route.ts` | Token authenticating internal capture ingest into the inspector. | +| `PLAYGROUND_COMPARE_MAX_COLUMNS` | `4` | `src/app/(dashboard)/dashboard/playground/` | Max number of side-by-side columns in the Playground compare mode. | +| `PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL` | _(unset)_ | `src/app/(dashboard)/dashboard/playground/` | Default model for the Playground 'improve prompt' action (falls back to the active model when unset). | --- @@ -916,25 +918,25 @@ Used by `scripts/dev/run-next-playwright.mjs`, `scripts/dev/smoke-electron-packa `scripts/dev/run-ecosystem-tests.mjs`, and `scripts/build/uninstall.mjs`. Leave every value below unset in production deployments. -| Variable | Default | Source File | Description | -| ------------------------------------- | -------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------- | -| `OMNIROUTE_E2E_BOOTSTRAP_MODE` | `auth` | `scripts/dev/run-next-playwright.mjs` | E2E bootstrap mode (`auth`, `fresh`, `reuse`) for the Playwright runner. | -| `OMNIROUTE_E2E_PASSWORD` | falls back to `INITIAL_PASSWORD` | `scripts/dev/run-next-playwright.mjs` | Admin password injected into the Playwright environment. | -| `OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK` | `true` | `scripts/dev/run-next-playwright.mjs` | Disable the local healthcheck poll during Playwright runs. | -| `OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK` | `true` | `scripts/dev/run-next-playwright.mjs` | Disable the OAuth token healthcheck loop during tests. | -| `OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS` | _(unset)_ | `src/lib/tokenHealthCheck.ts` | Comma-separated providers excluded from the proactive token-refresh sweep (e.g. `codex,openai`). Targeted alternative to fully disabling the healthcheck — short-TTL providers keep refreshing while cascade providers stay reactive-only. | -| `OMNIROUTE_HIDE_HEALTHCHECK_LOGS` | `true` | `scripts/dev/run-next-playwright.mjs` | Silence healthcheck noise in Playwright stdout. | -| `OMNIROUTE_PLAYWRIGHT_SKIP_BUILD` | `0` | `scripts/dev/run-next-playwright.mjs` | Skip the Next.js production build before Playwright starts (CI optimization). | -| `OMNIROUTE_SKIP_UNINSTALL_HOOK` | `0` | `scripts/build/uninstall.mjs` | Skip the OmniRoute uninstall hook (used by CI to keep `node_modules` intact). | -| `ECOSYSTEM_SERVER_WAIT_MS` | `180000` | `scripts/dev/run-ecosystem-tests.mjs` | Wait time (ms) for the server to become healthy before running ecosystem/protocol tests. | -| `ELECTRON_SMOKE_URL` | `http://127.0.0.1:20128/login` | `scripts/dev/smoke-electron-packaged.mjs` | URL the Electron smoke harness expects the packaged app to serve. | -| `ELECTRON_SMOKE_TIMEOUT_MS` | `45000` | `scripts/dev/smoke-electron-packaged.mjs` | Total timeout (ms) before the smoke harness gives up. | -| `ELECTRON_SMOKE_SETTLE_MS` | `2000` | `scripts/dev/smoke-electron-packaged.mjs` | Settle window (ms) after the page loads. | -| `ELECTRON_SMOKE_APP_EXECUTABLE` | _(auto)_ | `scripts/dev/smoke-electron-packaged.mjs` | Explicit path to the packaged Electron executable. | -| `ELECTRON_SMOKE_DATA_DIR` | _(tmpdir)_ | `scripts/dev/smoke-electron-packaged.mjs` | Data directory for the Electron smoke run. | -| `ELECTRON_SMOKE_KEEP_DATA` | `0` | `scripts/dev/smoke-electron-packaged.mjs` | Set `1` to preserve the smoke data directory after the run. | -| `ELECTRON_SMOKE_STREAM_LOGS` | `0` | `scripts/dev/smoke-electron-packaged.mjs` | Set `1` to stream Electron logs to stdout during the run. | -| `CLI_DEVIN_BIN` | _(PATH lookup)_ | `open-sse/executors/devin-cli.ts` | Override the Devin CLI binary path. | +| Variable | Default | Source File | Description | +| -------------------------------------- | -------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `OMNIROUTE_E2E_BOOTSTRAP_MODE` | `auth` | `scripts/dev/run-next-playwright.mjs` | E2E bootstrap mode (`auth`, `fresh`, `reuse`) for the Playwright runner. | +| `OMNIROUTE_E2E_PASSWORD` | falls back to `INITIAL_PASSWORD` | `scripts/dev/run-next-playwright.mjs` | Admin password injected into the Playwright environment. | +| `OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK` | `true` | `scripts/dev/run-next-playwright.mjs` | Disable the local healthcheck poll during Playwright runs. | +| `OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK` | `true` | `scripts/dev/run-next-playwright.mjs` | Disable the OAuth token healthcheck loop during tests. | +| `OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS` | _(unset)_ | `src/lib/tokenHealthCheck.ts` | Comma-separated providers excluded from the proactive token-refresh sweep (e.g. `codex,openai`). Targeted alternative to fully disabling the healthcheck — short-TTL providers keep refreshing while cascade providers stay reactive-only. | +| `OMNIROUTE_HIDE_HEALTHCHECK_LOGS` | `true` | `scripts/dev/run-next-playwright.mjs` | Silence healthcheck noise in Playwright stdout. | +| `OMNIROUTE_PLAYWRIGHT_SKIP_BUILD` | `0` | `scripts/dev/run-next-playwright.mjs` | Skip the Next.js production build before Playwright starts (CI optimization). | +| `OMNIROUTE_SKIP_UNINSTALL_HOOK` | `0` | `scripts/build/uninstall.mjs` | Skip the OmniRoute uninstall hook (used by CI to keep `node_modules` intact). | +| `ECOSYSTEM_SERVER_WAIT_MS` | `180000` | `scripts/dev/run-ecosystem-tests.mjs` | Wait time (ms) for the server to become healthy before running ecosystem/protocol tests. | +| `ELECTRON_SMOKE_URL` | `http://127.0.0.1:20128/login` | `scripts/dev/smoke-electron-packaged.mjs` | URL the Electron smoke harness expects the packaged app to serve. | +| `ELECTRON_SMOKE_TIMEOUT_MS` | `45000` | `scripts/dev/smoke-electron-packaged.mjs` | Total timeout (ms) before the smoke harness gives up. | +| `ELECTRON_SMOKE_SETTLE_MS` | `2000` | `scripts/dev/smoke-electron-packaged.mjs` | Settle window (ms) after the page loads. | +| `ELECTRON_SMOKE_APP_EXECUTABLE` | _(auto)_ | `scripts/dev/smoke-electron-packaged.mjs` | Explicit path to the packaged Electron executable. | +| `ELECTRON_SMOKE_DATA_DIR` | _(tmpdir)_ | `scripts/dev/smoke-electron-packaged.mjs` | Data directory for the Electron smoke run. | +| `ELECTRON_SMOKE_KEEP_DATA` | `0` | `scripts/dev/smoke-electron-packaged.mjs` | Set `1` to preserve the smoke data directory after the run. | +| `ELECTRON_SMOKE_STREAM_LOGS` | `0` | `scripts/dev/smoke-electron-packaged.mjs` | Set `1` to stream Electron logs to stdout during the run. | +| `CLI_DEVIN_BIN` | _(PATH lookup)_ | `open-sse/executors/devin-cli.ts` | Override the Devin CLI binary path. | ### Docs translation pipeline @@ -984,8 +986,8 @@ with accurate `limit.context` and `limit.output` values pulled from the running OmniRoute instance. None of these are required for normal operation — the script is developer tooling only. -| Variable | Default | Source File | Description | -| ------------------- | ------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| `OMNIROUTE_URL` | `http://localhost:20128` | `scripts/ad-hoc/regen-opencode-config.ts` | Base URL of the OmniRoute instance to query for `/v1/models`. | -| `OMNIROUTE_KEY` | _(unset)_ | `scripts/ad-hoc/regen-opencode-config.ts` | API key to authenticate against the OmniRoute `/v1/models` endpoint. Falls back to `OPENCODE_API_KEY` when unset. | -| `OPENCODE_API_KEY` | _(unset)_ | `scripts/ad-hoc/regen-opencode-config.ts` | OpenCode-style API key (`sk-...`) written into the regenerated `opencode.json`. Falls back to `OMNIROUTE_KEY` when unset. | +| Variable | Default | Source File | Description | +| ------------------ | ------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_URL` | `http://localhost:20128` | `scripts/ad-hoc/regen-opencode-config.ts` | Base URL of the OmniRoute instance to query for `/v1/models`. | +| `OMNIROUTE_KEY` | _(unset)_ | `scripts/ad-hoc/regen-opencode-config.ts` | API key to authenticate against the OmniRoute `/v1/models` endpoint. Falls back to `OPENCODE_API_KEY` when unset. | +| `OPENCODE_API_KEY` | _(unset)_ | `scripts/ad-hoc/regen-opencode-config.ts` | OpenCode-style API key (`sk-...`) written into the regenerated `opencode.json`. Falls back to `OMNIROUTE_KEY` when unset. | diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index a93fb1dea2..3b8cdb8ff0 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.21 + version: 3.8.22 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/docs/routing/AUTO-COMBO.md b/docs/routing/AUTO-COMBO.md index c470a407d0..94596e756c 100644 --- a/docs/routing/AUTO-COMBO.md +++ b/docs/routing/AUTO-COMBO.md @@ -182,6 +182,8 @@ There is **no dedicated `POST /api/combos/auto` endpoint** — Auto-Combo is con 2. **Persisted combo with `strategy: "auto"`:** Create a regular combo via `POST /api/combos` and set `strategy: "auto"` plus `config.auto.weights` / `config.auto.candidatePool`. The same scoring engine is used; the combo is stored in `combos` and reusable by ID. +For discovery, `GET /api/combos/auto` lists every variant with its resolved candidate pool plus `context_length` / `max_output_tokens` — the MAX across the candidate pool's windows. Clients (e.g. the opencode plugin) must advertise these values instead of `0`: a zero context disables opencode's auto-compaction entirely, letting sessions grow until the gateway's history purge destroys context. MAX is safe to advertise because the auto-combo context pre-filter routes oversized requests to large-window candidates. + ```bash # Zero-config usage (no combo creation) curl -X POST http://localhost:20128/v1/chat/completions \ diff --git a/electron/lib/resolveServerEntry.js b/electron/lib/resolveServerEntry.js new file mode 100644 index 0000000000..836cf22acf --- /dev/null +++ b/electron/lib/resolveServerEntry.js @@ -0,0 +1,26 @@ +/** + * resolveServerEntry.js — pure helper for selecting the Next.js server entrypoint. + * + * The WS-aware wrapper `server-ws.mjs` installs the trusted peer-IP stamp + * (peer-stamp.mjs) that the authz middleware needs to allow loopback/LAN access + * to LOCAL_ONLY routes (AgentBridge, MCP, services, etc.). Without it every + * LOCAL_ONLY request returns 403. + * + * We prefer `server-ws.mjs` when it exists — mirroring run-standalone.mjs — and + * fall back to the bare `server.js` only when the wrapper is absent (e.g. an + * older build or a stripped bundle). + * + * Extracted as a pure helper so it can be unit-tested without importing the full + * Electron main process (which requires the Electron binary). + * + * @param {string} serverDir - directory that contains server.js / server-ws.mjs + * @param {Function} existsSyncFn - injectable fs.existsSync (for unit tests) + * @returns {string} filename ("server-ws.mjs" or "server.js") + */ +function resolveServerEntry(serverDir, existsSyncFn) { + const path = require("path"); + const wsEntry = path.join(serverDir, "server-ws.mjs"); + return existsSyncFn(wsEntry) ? "server-ws.mjs" : "server.js"; +} + +module.exports = { resolveServerEntry }; diff --git a/electron/main.js b/electron/main.js index a439157eb4..b9e9ff486f 100644 --- a/electron/main.js +++ b/electron/main.js @@ -35,6 +35,7 @@ const { autoUpdater } = require("electron-updater"); const { hasEncryptedCredentials } = require("./sqlite-inspection"); const { loginManager } = require("./loginManager"); const { killProcessTree } = require("./processTree"); +const { resolveServerEntry } = require("./lib/resolveServerEntry"); // ── Single Instance Lock ─────────────────────────────────── const gotTheLock = app.requestSingleInstanceLock(); @@ -525,7 +526,11 @@ function startNextServer() { return; } - const serverScript = path.join(NEXT_SERVER_PATH, "server.js"); + // Prefer server-ws.mjs (peer-stamp wrapper) when present; fall back to server.js. + // Without server-ws.mjs every LOCAL_ONLY route (AgentBridge, MCP, services, …) returns + // 403 because the authz middleware can't verify the trusted loopback peer-stamp. (#3386) + const serverEntryName = resolveServerEntry(NEXT_SERVER_PATH, fs.existsSync.bind(fs)); + const serverScript = path.join(NEXT_SERVER_PATH, serverEntryName); if (!fs.existsSync(serverScript)) { console.error("[Electron] Server script not found:", serverScript); sendToRenderer("server-status", { status: "error", port: serverPort }); diff --git a/electron/package.json b/electron/package.json index 4d007c3c13..888f249bd0 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.21", + "version": "3.8.22", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { @@ -54,6 +54,7 @@ "loginManager.js", "processTree.js", "sqlite-inspection.js", + "lib/resolveServerEntry.js", "package.json", "node_modules/**/*" ], diff --git a/file-size-baseline.json b/file-size-baseline.json index 0f69af4d03..fbd4cd6e9b 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -48,7 +48,14 @@ "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2570, "src/app/(dashboard)/dashboard/health/page.tsx": 1091, "src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847, - "src/app/(dashboard)/dashboard/providers/[id]/page.tsx": 12883, + "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 4063, + "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 954, + "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264, + "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts": 155, + "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 822, + "src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 941, + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 843, + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1171, "src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 906, "src/app/(dashboard)/dashboard/providers/page.tsx": 1925, "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1127, @@ -76,6 +83,7 @@ "src/lib/db/providers.ts": 993, "src/lib/db/proxies.ts": 1031, "src/lib/db/settings.ts": 1101, + "src/lib/db/usageAnalytics.ts": 873, "src/lib/evals/evalRunner.ts": 961, "src/lib/memory/retrieval.ts": 1171, "src/lib/modelsDevSync.ts": 934, @@ -95,5 +103,6 @@ "src/sse/handlers/chat.ts": 1381, "src/sse/services/auth.ts": 2198 }, - "_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte." + "_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.", + "_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948→4062 (-886 LOC); 3 novos hooks extraídos. useProviderConnections.ts=954 acima do cap=800 — justificado: extração direta do god-component (zero lógica nova), própria redução do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 já abaixo do cap." } diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 278593bac2..d017b25a18 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -343,6 +343,9 @@ const CHAT_OPENAI_COMPAT_MODELS: Record = { { id: "mimo-v2-omni", name: "MiMo-V2-Omni", contextLength: 262144, maxOutputTokens: 131072 }, { id: "mimo-v2-flash", name: "MiMo-V2-Flash", contextLength: 262144, maxOutputTokens: 65536 }, ], + mimocode: [ + { id: "mimo-auto", name: "MiMo Auto", contextLength: 1000000, maxOutputTokens: 128000 }, + ], gitlawb: [ { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", contextLength: 1048576, maxOutputTokens: 131072 }, { id: "mimo-v2.5", name: "MiMo-V2.5", contextLength: 1048576, maxOutputTokens: 131072 }, @@ -4067,6 +4070,18 @@ const _REGISTRY_EAGER: Record = { models: CHAT_OPENAI_COMPAT_MODELS["xiaomi-mimo"], }, + mimocode: { + id: "mimocode", + alias: "mcode", + format: "openai", + executor: "mimocode", + baseUrl: "https://api.xiaomimimo.com", + chatPath: "/api/free-ai/openai/chat", + authType: "none", + authHeader: "none", + models: CHAT_OPENAI_COMPAT_MODELS["mimocode"], + }, + gitlawb: { id: "gitlawb", alias: "glb", diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 2379eb6e22..62f833f6ab 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -51,6 +51,7 @@ import { KimiExecutor } from "./kimi.ts" import { TheOldLlmExecutor } from "./theoldllm.ts"; import { ChipotleExecutor } from "./chipotle.ts"; import { LMArenaExecutor } from "./lmarena.ts"; +import { MimocodeExecutor } from "./mimocode.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -146,6 +147,8 @@ const executors = { pepper: new ChipotleExecutor(), // Alias lmarena: new LMArenaExecutor(), lma: new LMArenaExecutor(), // Alias + mimocode: new MimocodeExecutor(), + mcode: new MimocodeExecutor(), // Alias }; const defaultCache = new Map(); @@ -205,3 +208,4 @@ export { QwenWebExecutor } from "./qwen-web.ts"; export { TheOldLlmExecutor } from "./theoldllm.ts"; export { ChipotleExecutor } from "./chipotle.ts"; export { LMArenaExecutor } from "./lmarena.ts"; +export { MimocodeExecutor } from "./mimocode.ts"; diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index 379933f0d7..bb26d5f7d6 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -168,6 +168,37 @@ function ensureKiroUsage(state: KiroStreamState) { }; } +/** + * Resolve the AWS region for a Kiro/CodeWhisperer connection. Enterprise AWS IAM Identity + * Center accounts are region-bound: the access token, the Q Developer profile ARN and the + * runtime endpoint must all match the region the IdC instance lives in (e.g. eu-central-1). + * A request signed for one region is rejected by another ("bearer token is invalid"), and a + * regional profileArn sent to us-east-1 fails with "Improperly formed request". Falls back to + * the region embedded in the profileArn, then us-east-1 (the AWS Builder ID default). + */ +export function resolveKiroRegion( + credentials: { providerSpecificData?: unknown } | null | undefined +): string { + const psd = (credentials?.providerSpecificData || {}) as Record; + const region = typeof psd.region === "string" ? psd.region.trim().toLowerCase() : ""; + if (region) return region; + const arn = typeof psd.profileArn === "string" ? psd.profileArn.toLowerCase() : ""; + const match = arn.match(/^arn:aws:codewhisperer:([a-z0-9-]+):/); + return match ? match[1] : "us-east-1"; +} + +/** + * CodeWhisperer/Amazon Q runtime host for a region. us-east-1 keeps the legacy + * codewhisperer.us-east-1 host (AWS Builder ID); other regions use the regional Amazon Q + * endpoint q.{region}.amazonaws.com — codewhisperer.{region}.amazonaws.com does not resolve + * for non-us-east-1 regions. + */ +export function kiroRuntimeHost(region: string): string { + return region === "us-east-1" + ? "https://codewhisperer.us-east-1.amazonaws.com" + : `https://q.${region}.amazonaws.com`; +} + /** * KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer) * Uses AWS CodeWhisperer streaming API with AWS EventStream binary format @@ -228,7 +259,11 @@ export class KiroExecutor extends BaseExecutor { log, upstreamExtraHeaders, }: ExecuteInput) { - const url = this.buildUrl(model, stream, 0); + // Route to the region-specific CodeWhisperer/Amazon Q endpoint. Enterprise IAM Identity + // Center accounts (e.g. eu-central-1) are rejected by the default us-east-1 host; only the + // regional endpoint accepts the region-bound token + profileArn. + const region = resolveKiroRegion(credentials); + const url = `${kiroRuntimeHost(region)}/generateAssistantResponse`; const headers = this.buildHeaders(credentials, stream); mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); const transformedBody = await this.transformRequest(model, body, stream, credentials); diff --git a/open-sse/executors/mimocode.ts b/open-sse/executors/mimocode.ts new file mode 100644 index 0000000000..d75d24174f --- /dev/null +++ b/open-sse/executors/mimocode.ts @@ -0,0 +1,328 @@ +/** + * MiMoCode Executor — Free-tier Xiaomi MiMo models via bootstrap JWT auth. + * + * Implements the auth flow from the official MiMo-Code repository: + * https://github.com/XiaomiMiMo/MiMo-Code/blob/main/packages/opencode/src/plugin/mimo-free.ts + * + * 1. Generate device fingerprint from hostname + OS + arch + CPU + username + * 2. POST /api/free-ai/bootstrap with fingerprint → JWT + * 3. Use JWT as Bearer token for chat requests + * 4. Custom endpoint: /api/free-ai/openai/chat (not /v1/chat/completions) + * 5. Custom header: X-Mimo-Source: mimocode-cli-free + * + * Only the "mimo-auto" model is supported (1M context, 128K output). + * Supports multiple accounts: N fingerprints → N JWTs → round-robin with cooldown. + * On 429, account enters cooldown (exponential backoff). On 401/403, JWT is re-bootstrapped. + */ + +import * as crypto from "node:crypto"; +import * as os from "node:os"; +import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts"; + +const BOOTSTRAP_PATH = "/api/free-ai/bootstrap"; +const CHAT_PATH = "/api/free-ai/openai/chat"; +const JWT_REFRESH_BUFFER_MS = 5 * 60 * 1000; +const BOOTSTRAP_TIMEOUT_MS = 15_000; +const COOLDOWN_BASE_MS = 5_000; +const COOLDOWN_MAX_MS = 60_000; + +const MIMO_SOURCE = "mimocode-cli-free"; + +const USER_AGENTS = [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", +]; + +// ── Account State ────────────────────────────────────────────────────────── + +interface AccountState { + fingerprint: string; + jwt: string; + expiresAt: number; + cooldownUntil: number; + consecutiveFails: number; +} + +function parseJwtExp(jwt: string): number { + try { + const parts = jwt.split("."); + if (parts.length < 2) return Date.now() + 50 * 60 * 1000; + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()); + return (payload.exp ?? Math.floor(Date.now() / 1000) + 3000) * 1000; + } catch { + return Date.now() + 50 * 60 * 1000; + } +} + +function isAccountReady(account: AccountState): boolean { + if (account.cooldownUntil > Date.now()) return false; + if (account.jwt && account.expiresAt - Date.now() > JWT_REFRESH_BUFFER_MS) return true; + return false; +} + +// ── Fingerprint Generation ───────────────────────────────────────────────── + +function getCpuModel(): string { + try { + const cpus = os.cpus(); + if (cpus.length > 0 && cpus[0].model) return cpus[0].model.trim(); + } catch { /* ignore */ } + return "unknown-cpu"; +} + +export function generateFingerprint(seed?: string): string { + if (seed) return crypto.createHash("sha256").update(seed).digest("hex"); + const hostname = os.hostname(); + const platform = os.platform(); + const arch = os.arch(); + const cpu = getCpuModel(); + let username = "unknown-user"; + try { + username = os.userInfo().username; + } catch { /* ignore */ } + return crypto.createHash("sha256").update(`${hostname}|${platform}|${arch}|${cpu}|${username}`).digest("hex"); +} + +// ── Bootstrap ────────────────────────────────────────────────────────────── + +const bootstrapInflight = new Map>(); + +async function bootstrapJwt( + baseUrl: string, + fingerprint: string, + signal?: AbortSignal | null, +): Promise<{ jwt: string; expiresAt: number }> { + const existing = bootstrapInflight.get(fingerprint); + if (existing) return existing; + + const url = `${baseUrl}${BOOTSTRAP_PATH}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), BOOTSTRAP_TIMEOUT_MS); + const onSignal = signal ? () => controller.abort(signal.reason) : null; + if (signal && onSignal) signal.addEventListener("abort", onSignal, { once: true }); + + const promise = (async () => { + try { + const resp = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client: fingerprint }), + signal: controller.signal, + }); + if (!resp.ok) { + const body = await resp.text().catch(() => ""); + throw new Error(`Bootstrap failed: ${resp.status} ${body.slice(0, 200)}`); + } + const data = (await resp.json()) as { jwt?: string }; + if (!data.jwt) throw new Error("Bootstrap response missing jwt field"); + return { jwt: data.jwt, expiresAt: parseJwtExp(data.jwt) }; + } finally { + clearTimeout(timer); + if (signal && onSignal) signal.removeEventListener("abort", onSignal); + bootstrapInflight.delete(fingerprint); + } + })(); + + bootstrapInflight.set(fingerprint, promise); + return promise; +} + +// ── Model Rewriting ──────────────────────────────────────────────────────── + +function rewriteModelName(model: string): string { + const idx = model.lastIndexOf("/"); + return idx >= 0 ? model.slice(idx + 1) : model; +} + +// ── Executor ─────────────────────────────────────────────────────────────── + +export class MimocodeExecutor extends BaseExecutor { + private accounts: AccountState[] = []; + private nextAccountIdx = 0; + private baseUrl: string; + private static encoder = new TextEncoder(); + + constructor() { + super("mimocode", { format: "openai" }); + this.baseUrl = this.getBaseUrls()[0] || "https://api.xiaomimimo.com"; + this.accounts.push({ fingerprint: generateFingerprint(), jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0 }); + } + + private syncAccountsFromCredentials(credentials: ProviderCredentials): void { + const fingerprints = credentials?.providerSpecificData?.fingerprints; + if (!Array.isArray(fingerprints)) return; + const existing = new Set(this.accounts.map((a) => a.fingerprint)); + for (const fp of fingerprints) { + if (typeof fp === "string" && !existing.has(fp)) { + this.accounts.push({ fingerprint: fp, jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0 }); + existing.add(fp); + } + } + } + + private async getJwtForAccount(account: AccountState, signal?: AbortSignal | null): Promise { + if (isAccountReady(account)) return account.jwt; + const result = await bootstrapJwt(this.baseUrl, account.fingerprint, signal); + account.jwt = result.jwt; + account.expiresAt = result.expiresAt; + return account.jwt; + } + + private pickAccount(): AccountState { + for (let i = 0; i < this.accounts.length; i++) { + const idx = (this.nextAccountIdx + i) % this.accounts.length; + const acct = this.accounts[idx]; + if (isAccountReady(acct)) { + this.nextAccountIdx = (idx + 1) % this.accounts.length; + return acct; + } + } + const fallbackIdx = this.nextAccountIdx % this.accounts.length; + this.nextAccountIdx = (this.nextAccountIdx + 1) % this.accounts.length; + return this.accounts[fallbackIdx]; + } + + private markCooldown(account: AccountState): void { + account.consecutiveFails++; + const backoff = Math.min(COOLDOWN_BASE_MS * Math.pow(2, account.consecutiveFails - 1), COOLDOWN_MAX_MS); + account.cooldownUntil = Date.now() + backoff + Math.random() * 1000; + } + + private markSuccess(account: AccountState): void { + account.consecutiveFails = 0; + } + + buildUrl(_model: string, _stream: boolean, _urlIndex = 0, _credentials?: ProviderCredentials | null): string { + return `${this.baseUrl.replace(/\/$/, "")}${CHAT_PATH}`; + } + + buildHeaders( + _credentials: ProviderCredentials, + stream = true, + _clientHeaders?: Record | null, + _model?: string, + ): Record { + const headers: Record = { + "Content-Type": "application/json", + "X-Mimo-Source": MIMO_SOURCE, + "User-Agent": USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)], + }; + if (stream) headers["Accept"] = "text/event-stream, application/json"; + return headers; + } + + transformRequest(model: string, body: unknown, _stream: boolean, _credentials?: ProviderCredentials | null): unknown { + if (typeof body === "object" && body !== null) { + return { ...(body as Record), model: rewriteModelName(model) }; + } + return body; + } + + async testConnection( + _credentials: ProviderCredentials, + _signal?: AbortSignal | null, + log?: ExecuteInput["log"], + ): Promise { + try { + const account = this.accounts[0]; + const jwt = await this.getJwtForAccount(account, _signal); + const resp = await fetch(this.buildUrl("mimo-auto", false), { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}`, "X-Mimo-Source": MIMO_SOURCE }, + body: JSON.stringify({ model: "mimo-auto", messages: [{ role: "user", content: "ping" }], stream: false }), + signal: _signal ?? undefined, + }); + return resp.status === 200; + } catch { + log?.warn?.("MIMOCODE", "testConnection network error"); + return false; + } + } + + async execute(input: ExecuteInput): Promise<{ + response: Response; + url: string; + headers: Record; + transformedBody: unknown; + }> { + const { model, stream, body, signal, log } = input; + const encoder = MimocodeExecutor.encoder; + + if (signal?.aborted) { + return { + response: new Response(encoder.encode(JSON.stringify({ + error: { message: "Request aborted", type: "abort", code: "ABORTED" }, + })), { status: 499, headers: { "Content-Type": "application/json" } }), + url: this.buildUrl(model, stream), + headers: this.buildHeaders(input.credentials, stream), + transformedBody: body, + }; + } + + const url = this.buildUrl(model, stream); + const reqBody = this.transformRequest(model, body, stream, input.credentials); + + this.syncAccountsFromCredentials(input.credentials); + + // Try each account, skip cooldown ones + for (let attempt = 0; attempt < this.accounts.length; attempt++) { + const account = this.pickAccount(); + try { + const jwt = await this.getJwtForAccount(account, signal); + const headers = this.buildHeaders(input.credentials, stream); + headers["Authorization"] = `Bearer ${jwt}`; + + let resp = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(reqBody), + signal: signal ?? undefined, + }); + + // On auth failure, re-bootstrap this account and retry once + if (resp.status === 401 || resp.status === 403) { + log?.warn?.("MIMOCODE", `Auth failed (${resp.status}) on account ${account.fingerprint.slice(0, 8)}…`); + account.jwt = ""; + account.expiresAt = 0; + account.consecutiveFails = 0; + const freshJwt = await this.getJwtForAccount(account, signal); + headers["Authorization"] = `Bearer ${freshJwt}`; + resp = await fetch(url, { method: "POST", headers, body: JSON.stringify(reqBody), signal: signal ?? undefined }); + } + + if (resp.status === 429) { + this.markCooldown(account); + log?.warn?.("MIMOCODE", `Rate limited on account ${account.fingerprint.slice(0, 8)}, trying next…`); + continue; + } + + this.markSuccess(account); + const respHeaders: Record = {}; + resp.headers.forEach((v, k) => { respHeaders[k] = v; }); + return { response: resp as unknown as Response, url, headers: respHeaders, transformedBody: reqBody }; + } catch (err) { + this.markCooldown(account); + if (attempt === this.accounts.length - 1) { + const msg = err instanceof Error ? err.message : String(err); + log?.error?.("MIMOCODE", `Executor error: ${msg}`); + return { + response: new Response(encoder.encode(JSON.stringify({ + error: { message: msg, type: "upstream_error", code: "EXECUTOR_ERROR" }, + })), { status: 502, headers: { "Content-Type": "application/json" } }), + url, headers: this.buildHeaders(input.credentials, stream), transformedBody: body, + }; + } + } + } + + return { + response: new Response(encoder.encode(JSON.stringify({ + error: { message: "All accounts exhausted", type: "upstream_error", code: "NO_ACCOUNTS" }, + })), { status: 502, headers: { "Content-Type": "application/json" } }), + url, headers: this.buildHeaders(input.credentials, stream), transformedBody: body, + }; + } +} + +export default MimocodeExecutor; diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index cf8008f5c4..5198a21a7d 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -192,7 +192,12 @@ import { getModelFamily, } from "../services/modelFamilyFallback.ts"; import { computeRequestHash, deduplicate, shouldDeduplicate } from "../services/requestDedup.ts"; -import { compressContext, estimateTokens, getTokenLimit } from "../services/contextManager.ts"; +import { + compressContext, + estimateTokens, + getTokenLimit, + resolveComboContextLimit, +} from "../services/contextManager.ts"; import { getBackgroundTaskReason, getDegradedModel, @@ -2741,21 +2746,30 @@ export async function handleChatCore({ if (!comboConfig && comboName.startsWith("combo/")) { comboConfig = await getComboByName(comboName.substring(6)); } + let comboTargetLimits: number[] = []; if (comboConfig) { const allCombosData = await getCombosCached(); const targets = resolveComboTargets( comboConfig as unknown as { name: string; models: unknown[] }, allCombosData as unknown as { name: string; models: unknown[] }[] ); - const limits = targets.map((t: { modelStr?: string }) => { + comboTargetLimits = targets.map((t: { modelStr?: string }) => { const parsed = parseModel(t.modelStr); return getTokenLimit(parsed.provider, parsed.model); }); - if (limits.length > 0) { - contextLimit = Math.min(...limits); - log?.info?.("CONTEXT", `Combo min limit: ${contextLimit}`); - } } + // chatCore executes per concrete target (handleSingleModel resolves + // provider/effectiveModel before delegating). Compress against THIS + // target's window; min(...allTargets) is only a defensive fallback — + // the old unconditional min compressed a 1M-target request at the + // smallest sibling's window ("agent keeps forgetting things"). + const resolved = resolveComboContextLimit({ + provider, + model: effectiveModel, + comboTargetLimits, + }); + contextLimit = resolved.limit; + log?.info?.("CONTEXT", `Combo context limit: ${resolved.limit} (source=${resolved.source})`); } catch (err) { log?.warn?.("CONTEXT", "Failed to resolve combo limits for compression: " + err); } diff --git a/open-sse/package.json b/open-sse/package.json index d3ac81a28d..88578a28bd 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.21", + "version": "3.8.22", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index eb743f9959..e708fe70f7 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -8,6 +8,8 @@ import type { ConnectionFields } from "@/lib/db/encryption"; import { NOAUTH_PROVIDERS } from "@/shared/constants/providers"; import { hasUsableWebSessionCredential } from "@/shared/providers/webSessionCredentials"; import { defaultLogger as log } from "@omniroute/open-sse/utils/logger"; +import { getTokenLimit } from "../contextManager"; +import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; /** Minimal connection shape needed for virtual auto-combo factory */ interface VirtualFactoryConn extends ConnectionFields { @@ -45,6 +47,11 @@ type VirtualAutoCombo = AutoComboConfig & { weight: number; label: string; }>; + /** MAX of candidates' context windows — safe to advertise because the + * auto-combo context pre-filter routes oversized requests to large-window + * candidates. null when the pool is empty. */ + advertisedContextLength: number | null; + advertisedMaxOutputTokens: number | null; autoConfig: { candidatePool: string[]; weights: ScoringWeights; @@ -156,6 +163,46 @@ function getNoAuthCandidates(excludedProviders: Set): VirtualAutoComboCa * Creates a virtual AutoCombo configuration dynamically based on connected providers and a specified variant. * This combo is not persisted in the DB. */ +/** + * Aggregate the context window / max output to ADVERTISE for an auto combo. + * + * MAX across candidates (not min): the auto-combo context pre-filter + * (combo.ts::filterTargetsByRequestCompatibility + the estimated-tokens + * pre-filter) already routes oversized requests away from small-window + * candidates, so advertising the largest window lets clients (e.g. opencode) + * keep their smart auto-compaction calibrated to the best candidate instead + * of compacting prematurely — or, worse, receiving 0 and disabling + * compaction entirely (the "agent keeps forgetting things" bug). + * + * Unknown candidates resolve through getTokenLimit()'s fallback chain, so a + * non-empty pool always yields a positive contextLength. + */ +export function computeAdvertisedLimits(candidates: Array<{ provider: string; model: string }>): { + contextLength: number | null; + maxOutputTokens: number | null; +} { + if (!Array.isArray(candidates) || candidates.length === 0) { + return { contextLength: null, maxOutputTokens: null }; + } + + let contextLength: number | null = null; + let maxOutputTokens: number | null = null; + for (const candidate of candidates) { + const limit = getTokenLimit(candidate.provider, candidate.model); + if (Number.isFinite(limit) && limit > 0) { + contextLength = contextLength === null ? limit : Math.max(contextLength, limit); + } + const output = getResolvedModelCapabilities({ + provider: candidate.provider, + model: candidate.model, + }).maxOutputTokens; + if (typeof output === "number" && Number.isFinite(output) && output > 0) { + maxOutputTokens = maxOutputTokens === null ? output : Math.max(maxOutputTokens, output); + } + } + return { contextLength, maxOutputTokens }; +} + export async function createVirtualAutoCombo( variant: AutoVariant | undefined ): Promise { @@ -209,6 +256,8 @@ export async function createVirtualAutoCombo( routerStrategy: autoConfig.routerStrategy, autoConfig, config: { auto: autoConfig }, + advertisedContextLength: null, + advertisedMaxOutputTokens: null, }; } @@ -259,6 +308,8 @@ export async function createVirtualAutoCombo( routerStrategy, }; + const advertisedLimits = computeAdvertisedLimits(candidatePool); + return { id: `virtual-auto-${variant || "default"}`, name: `Auto ${variant || "Default"}`, @@ -271,5 +322,7 @@ export async function createVirtualAutoCombo( routerStrategy, autoConfig, config: { auto: autoConfig }, + advertisedContextLength: advertisedLimits.contextLength, + advertisedMaxOutputTokens: advertisedLimits.maxOutputTokens, }; } diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 84425f276f..5095207bcc 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -125,6 +125,21 @@ function isAllAccountsRateLimitedResponse( return ALL_ACCOUNTS_RATE_LIMITED_PATTERNS.some((p) => p.test(errorText)); } +// #1731v2 guard: a provider circuit-breaker-open response (503 + `X-OmniRoute-Provider-Breaker` +// header / `provider_circuit_open` error code, see providerCircuitOpenResponse) is an OmniRoute +// resilience signal, NOT a per-connection upstream failure. It must keep being treated as an +// ordinary target failure (try the next target, including same-provider ones) — so it must NOT +// poison exhaustedConnections/exhaustedProviders, otherwise remaining same-provider targets get +// wrongly skipped while the breaker is open. +function isProviderCircuitOpenResult( + result: { headers?: Headers | null; status?: number }, + errorText: string +): boolean { + const breakerHeader = result.headers?.get?.("x-omniroute-provider-breaker"); + if (typeof breakerHeader === "string" && breakerHeader.toLowerCase() === "open") return true; + return /provider_circuit_open/i.test(errorText); +} + const MAX_COMBO_DEPTH = 3; const MAX_FALLBACK_WAIT_MS = 5000; const MAX_GLOBAL_ATTEMPTS = 30; @@ -3334,6 +3349,7 @@ export async function handleComboChat({ // #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 exhaustedConnections = new Set(); const transientRateLimitedProviders = new Set(); if (setTry > 0) { log.info("COMBO", `All targets failed — retrying set (${setTry}/${maxSetRetries})`); @@ -3409,6 +3425,18 @@ export async function handleComboChat({ } : { ...target, modelAbortSignal: abortControllers.get(i)!.signal }; + // #1731v2: Skip targets whose provider:connection pair had a connection-level error. + if (provider && target.connectionId) { + const connKey = `${provider}:${target.connectionId}`; + if (exhaustedConnections.has(connKey)) { + log.info( + "COMBO", + `Skipping ${modelStr} — connection ${target.connectionId} for provider ${provider} had connection error (#1731v2)` + ); + if (i > 0) fallbackCount++; + return null; + } + } // #1731: Skip targets from a provider that already signaled full quota exhaustion this request. if (provider && exhaustedProviders.has(provider)) { log.info( @@ -3917,6 +3945,31 @@ export async function handleComboChat({ ) { transientRateLimitedProviders.add(provider); } + // #1731: Connection-level errors (502/503/504) suggest the provider itself is having + // issues (e.g. upstream unreachable, proxy error). Skip remaining same-provider + // targets in this request to avoid hammering a known-bad connection. + if ( + !providerExhausted && + provider && + provider !== "unknown" && + [408, 500, 502, 503, 504, 524].includes(result.status) && + !isProviderCircuitOpenResult(result, errorText) + ) { + const connId = target.connectionId as string | undefined; + if (connId) { + exhaustedConnections.add(`${provider}:${connId}`); + log.info( + "COMBO", + `Provider ${provider} connection ${connId} error (${result.status}) — marking for skip on remaining targets (#1731v2)` + ); + } else { + exhaustedProviders.add(provider); + log.info( + "COMBO", + `Provider ${provider} connection error (${result.status}) — marking for skip on remaining targets (#1731)` + ); + } + } // #2101: Prevent infinite fallback loops with 400 Bad Request errors that indicate // request-body-specific issues (context overflow, malformed request, model access denied). @@ -4221,6 +4274,7 @@ async function handleRoundRobinCombo({ // When a target returns a quota-exhausted 429, remaining targets from the same // provider are skipped to avoid the cascade through N same-provider targets. const exhaustedProviders = new Set(); + const exhaustedConnections = new Set(); const transientRateLimitedProviders = new Set(); // Try each model starting from the round-robin target @@ -4262,6 +4316,18 @@ async function handleRoundRobinCombo({ // #1731: Skip targets from a provider that already signaled full quota exhaustion // this request. + // #1731v2: Skip targets whose provider:connection pair had a connection-level error. + if (provider && target.connectionId) { + const connKey = `${provider}:${target.connectionId}`; + if (exhaustedConnections.has(connKey)) { + log.info( + "COMBO-RR", + `Skipping ${modelStr} — connection ${target.connectionId} for provider ${provider} had connection error (#1731v2)` + ); + if (offset > 0) fallbackCount++; + continue; + } + } if (provider && exhaustedProviders.has(provider)) { log.info( "COMBO-RR", @@ -4538,6 +4604,30 @@ async function handleRoundRobinCombo({ transientRateLimitedProviders.add(provider); } + // #1731v2: Connection-level errors (502/503/504) — skip remaining same-connection targets + if ( + !providerExhausted && + provider && + provider !== "unknown" && + [408, 500, 502, 503, 504, 524].includes(result.status) && + !isProviderCircuitOpenResult(result, errorText) + ) { + const connId = target.connectionId as string | undefined; + if (connId) { + exhaustedConnections.add(`${provider}:${connId}`); + log.info( + "COMBO-RR", + `Provider ${provider} connection ${connId} error (${result.status}) — marking for skip (#1731v2)` + ); + } else { + exhaustedProviders.add(provider); + log.info( + "COMBO-RR", + `Provider ${provider} connection error (${result.status}) — marking for skip (#1731)` + ); + } + } + // Transient errors → mark in semaphore so round-robin stops stampeding this target. if ( !isStreamReadinessFailure && diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index baad8b0204..e2c4917132 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -61,27 +61,40 @@ export function estimateTokens(text: string | object | null | undefined): number * Priority: Env override > models.dev DB > Registry defaultContextLength > DEFAULT_LIMITS */ export function getTokenLimit(provider: string, model: string | null = null): number { + return resolveTokenLimit(provider, model).limit; +} + +/** + * Same chain as getTokenLimit, but also reports whether the limit came from + * a provider/model-specific source (env override, synced DB, registry, + * name heuristic, curated per-provider default) or only from the generic + * catch-all default. + */ +function resolveTokenLimit( + provider: string, + model: string | null = null +): { limit: number; specific: boolean } { // 1. Check environment variable override first const envOverride = getEnvOverride(provider); - if (envOverride) return envOverride; + if (envOverride) return { limit: envOverride, specific: true }; // 2. Check models.dev synced DB for per-model context limit if (model) { const dbLimit = getModelContextLimit(provider, model); - if (dbLimit && dbLimit > 0) return dbLimit; + if (dbLimit && dbLimit > 0) return { limit: dbLimit, specific: true }; } // 3. Check registry for provider default const registryEntry = REGISTRY[provider]; if (registryEntry?.defaultContextLength) { - return registryEntry.defaultContextLength; + return { limit: registryEntry.defaultContextLength, specific: true }; } // 4. Check if model name hints at a known limit if (model) { const lower = model.toLowerCase(); - if (lower.includes("claude")) return DEFAULT_LIMITS.claude; - if (lower.includes("gemini")) return DEFAULT_LIMITS.gemini; + if (lower.includes("claude")) return { limit: DEFAULT_LIMITS.claude, specific: true }; + if (lower.includes("gemini")) return { limit: DEFAULT_LIMITS.gemini, specific: true }; if ( lower.includes("gpt") || lower.includes("o1") || @@ -89,11 +102,44 @@ export function getTokenLimit(provider: string, model: string | null = null): nu lower.includes("o4") || lower.includes("codex") ) - return DEFAULT_LIMITS.codex; + return { limit: DEFAULT_LIMITS.codex, specific: true }; } // 5. Fallback to DEFAULT_LIMITS or default - return DEFAULT_LIMITS[provider] || DEFAULT_LIMITS.default; + if (DEFAULT_LIMITS[provider]) return { limit: DEFAULT_LIMITS[provider], specific: true }; + return { limit: DEFAULT_LIMITS.default, specific: false }; +} + +/** + * Resolve the context limit to use for proactive compression of a COMBO + * request. + * + * chatCore always executes with the CONCRETE target's provider/model + * (handleSingleModel resolves the target before delegating), so the + * executing target's own limit is authoritative. Using min(...allTargets) + * here — the previous behavior — compressed at the smallest sibling's + * window even when running on the largest target, destructively purging + * history long before the real window filled ("agent keeps forgetting"). + * + * min(...comboTargetLimits) is kept only as a defensive fallback for the + * case where the current provider/model resolves no specific limit at all. + */ +export function resolveComboContextLimit(options: { + provider: string; + model: string | null; + comboTargetLimits: number[]; +}): { limit: number; source: "target" | "combo-min" | "fallback" } { + const own = resolveTokenLimit(options.provider, options.model ?? null); + if (own.specific) { + return { limit: own.limit, source: "target" }; + } + const knownTargets = (options.comboTargetLimits || []).filter( + (value) => Number.isFinite(value) && value > 0 + ); + if (knownTargets.length > 0) { + return { limit: Math.min(...knownTargets), source: "combo-min" }; + } + return { limit: own.limit, source: "fallback" }; } /** diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index ec0f0844ff..5d306d6fa7 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -272,6 +272,40 @@ async function getActiveProviderSet() { } } +function isTruthyEnv(value: string | undefined) { + return typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim()); +} + +async function getPreferClaudeCodeForUnprefixedClaudeModels() { + try { + const { getCachedSettings } = await import("@/lib/localDb"); + const settings = (await getCachedSettings()) as Record; + if (typeof settings.preferClaudeCodeForUnprefixedClaudeModels === "boolean") { + return settings.preferClaudeCodeForUnprefixedClaudeModels; + } + } catch { + // Standalone open-sse usage may not have the app DB layer available. + } + + return isTruthyEnv(process.env.OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS); +} + +function shouldPreferClaudeCodeForUnprefixedClaudeModel( + modelId: string, + activeProviders: Set | null, + preferClaudeCode: boolean +) { + if (!preferClaudeCode || !/^claude-/i.test(modelId)) { + return false; + } + + // If DB/provider state is unavailable in a lightweight runtime, honor the + // explicit operator flag and let the normal credential path report any missing + // Claude Code account. When state is available, avoid stealing traffic from + // other Claude-family providers unless Claude Code is actually active. + return activeProviders === null || activeProviders.size === 0 || activeProviders.has("claude"); +} + function shouldTreatAsExactModelId(modelStr: string | null) { if (!modelStr || typeof modelStr !== "string" || !modelStr.includes("/")) return false; if (!KNOWN_MODEL_IDS.has(modelStr)) return false; @@ -448,7 +482,10 @@ async function resolveModelByProviderInference(modelId: string, extendedContext: }; } - const activeProviders = await getActiveProviderSet(); + const [activeProviders, preferClaudeCodeForUnprefixedClaudeModels] = await Promise.all([ + getActiveProviderSet(), + getPreferClaudeCodeForUnprefixedClaudeModels(), + ]); // Preserve historical behavior: OpenAI stays default when model exists there. // Connection availability must not make unprefixed OpenAI models resolve to a @@ -485,6 +522,21 @@ async function resolveModelByProviderInference(modelId: string, extendedContext: const candidatesToUse = nonOpenAIProviders; + if ( + candidatesToUse.includes("claude") && + shouldPreferClaudeCodeForUnprefixedClaudeModel( + modelId, + activeProviders, + preferClaudeCodeForUnprefixedClaudeModels + ) + ) { + return { + provider: "claude", + model: resolveInferredProviderModel("claude", modelId), + extendedContext, + }; + } + if (candidatesToUse.length === 1) { const provider = candidatesToUse[0]; const canonicalModel = resolveInferredProviderModel(provider, modelId); @@ -510,6 +562,15 @@ async function resolveModelByProviderInference(modelId: string, extendedContext: // FIX #73: Models like claude-haiku-4-5-20251001 sent without provider prefix // would incorrectly route to OpenAI. Use heuristic prefix detection first. if (/^claude-/i.test(modelId)) { + if ( + shouldPreferClaudeCodeForUnprefixedClaudeModel( + modelId, + activeProviders, + preferClaudeCodeForUnprefixedClaudeModels + ) + ) { + return { provider: "claude", model: modelId, extendedContext }; + } // Claude models → Anthropic provider (canonical source for Claude models) return { provider: "anthropic", model: modelId, extendedContext }; } diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 38336b2947..d33cbcea4a 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -3005,6 +3005,22 @@ async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRec return { message: "Kiro connected. Profile ARN not available for quota tracking." }; } + // Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and + // endpoint must all match the region. Derive the region from the stored region (preferred) + // or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the + // legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions). + const regionFromArn = + typeof profileArn === "string" + ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] + : undefined; + const region = + (typeof providerSpecificData?.region === "string" && + providerSpecificData.region.trim().toLowerCase()) || + regionFromArn || + "us-east-1"; + const usageBaseUrl = + region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`; + // Kiro uses AWS CodeWhisperer GetUsageLimits API const payload = { origin: "AI_EDITOR", @@ -3012,7 +3028,7 @@ async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRec resourceType: "AGENTIC_REQUEST", }; - const response = await fetch(CODEWHISPERER_BASE_URL, { + const response = await fetch(usageBaseUrl, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 4bb82b6c1c..fc73ef656f 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -103,10 +103,6 @@ function hasNonEmptyReasoningContent(message: Record): boolean return typeof message.reasoning_content === "string" && message.reasoning_content.length > 0; } -function hasReasoningContentField(message: Record): boolean { - return Object.prototype.hasOwnProperty.call(message, "reasoning_content"); -} - function isDeepSeekReplayTarget(provider: unknown, model: unknown): boolean { const normalizedProvider = String(provider ?? "") .trim() @@ -179,7 +175,10 @@ export function translateRequest( // quirks (e.g. Vertex rejects function_call.id — #3440). const directCredentials = provider != null - ? { ...(credentials && typeof credentials === "object" ? credentials : {}), _provider: provider } + ? { + ...(credentials && typeof credentials === "object" ? credentials : {}), + _provider: provider, + } : credentials; result = directTranslator(model, result, stream, directCredentials); } else { @@ -319,15 +318,23 @@ export function translateRequest( Array.isArray(msg.content) && msg.content.some((b) => b?.type === "tool_use"); + // For DeepSeek replay targets, a plain (non-tool-call) assistant turn must + // ALSO carry reasoning_content in thinking mode, or DeepSeek V4+ returns 400: + // "The reasoning_content in the thinking mode must be passed back to the API." + // Enter the replay path when the field is MISSING or empty (#1682) — not only + // when it is already present (the previous gate only matched messages that + // already had the field, so stripped-history turns from clients like Cursor + // were skipped and forwarded without reasoning_content). const shouldReplayReasoningOnly = !hasToolCalls && !hasToolUseBlocks && canReplayReasoningOnly && - hasReasoningContentField(msg); + !hasNonEmptyReasoningContent(msg); if (!hasToolCalls && !hasToolUseBlocks && !shouldReplayReasoningOnly) { - // Strip empty reasoning_content on non-tool-call messages; an empty - // string has no meaningful value to send and may confuse some upstreams. + // Strip empty reasoning_content on non-tool-call messages we are NOT + // replaying (e.g. non-DeepSeek targets); an empty string has no meaningful + // value to send and may confuse some upstreams. if (msg.reasoning_content === "") { delete msg.reasoning_content; } @@ -392,7 +399,12 @@ export function translateRequest( // Note: injectEmptyReasoningContentForToolCalls may have pre-set // reasoning_content="" before the cache lookup, so we check for // both undefined AND empty string here. - if (hasToolCalls && !msg.reasoning_content) { + // + // Applies to tool-call messages AND to plain (non-tool-call) assistant turns + // on DeepSeek replay targets (#1682). Without the placeholder on plain turns, + // a multi-turn text conversation whose reasoning_content the client stripped + // is forwarded to DeepSeek without the field and rejected with 400. + if ((hasToolCalls || shouldReplayReasoningOnly) && !msg.reasoning_content) { msg.reasoning_content = NON_ANTHROPIC_THINKING_PLACEHOLDER; } } diff --git a/open-sse/utils/streamReadiness.ts b/open-sse/utils/streamReadiness.ts index b61d5a6f3b..22cae4ce35 100644 --- a/open-sse/utils/streamReadiness.ts +++ b/open-sse/utils/streamReadiness.ts @@ -104,9 +104,13 @@ function hasOpenAIResponseLifecyclePayload( } function hasChatCompletionToolCallStart(value: unknown): boolean { - const hasToolCallId = (item: unknown) => isRecord(item) && hasNonEmptyString(item.id); - if (Array.isArray(value)) return value.some(hasToolCallId); - return hasToolCallId(value); + // Accept a tool_call item if it has a non-empty id (fully-formed chunk) OR if it + // carries a numeric index (first OpenAI streaming chunk — id/name arrive in later + // chunks). Either form signals that a tool call has started. (#3612) + const hasToolCallStart = (item: unknown) => + isRecord(item) && (hasNonEmptyString(item.id) || typeof item.index === "number"); + if (Array.isArray(value)) return value.some(hasToolCallStart); + return hasToolCallStart(value); } function hasChatCompletionFunctionCallStart(value: unknown): boolean { @@ -114,9 +118,15 @@ function hasChatCompletionFunctionCallStart(value: unknown): boolean { } function hasChatCompletionChunkStartPayload(payload: Record): boolean { - if (payload.object !== "chat.completion.chunk" && payload.type !== "chat.completion.chunk") { - return false; - } + // If object or type is PRESENT and is clearly a non-chat-chunk type, reject early. + // If both are absent (many OA-compatible backends omit object), we fall through and + // let the choices structure decide. (#3612) + const obj = payload.object; + const typ = payload.type; + const hasForeignObject = + (typeof obj === "string" && obj !== "chat.completion.chunk") || + (typeof typ === "string" && typ !== "chat.completion.chunk"); + if (hasForeignObject) return false; const choices = payload.choices; if (!Array.isArray(choices) || choices.length === 0) return false; diff --git a/package-lock.json b/package-lock.json index fa09e1edb8..78cbbecb02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.21", + "version": "3.8.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.21", + "version": "3.8.22", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -21587,7 +21587,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.21" + "version": "3.8.22" } } } diff --git a/package.json b/package.json index 8f20558fba..07985c5800 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.21", + "version": "3.8.22", "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/check/check-db-rules.mjs b/scripts/check/check-db-rules.mjs index 8c76b2f737..b5c83990ca 100644 --- a/scripts/check/check-db-rules.mjs +++ b/scripts/check/check-db-rules.mjs @@ -21,62 +21,69 @@ const LOCAL_DB = path.join(cwd, "src/lib/localDb.ts"); const API_DIR = path.join(cwd, "src/app/api"); const HANDLERS_DIR = path.join(cwd, "open-sse/handlers"); -// (a) Módulos db/ que NÃO são re-exportados por localDb.ts hoje. Congelados -// para a catraca ficar verde e bloquear QUALQUER módulo novo não re-exportado. -// CADA UM é dívida: ou é consumido por import direto de "@/lib/db/X" (legítimo, -// não precisa de re-export) ou deveria ser re-exportado. NÃO adicione novos aqui -// sem justificativa — esse é o ponto do gate (Hard Rule #2). -const KNOWN_UNEXPORTED = new Set([ - "_rowTypes", // só tipos de linha (sem runtime API), consumido localmente pelos CRUDs F2 - "cleanup", // rotina de manutenção, chamada por jobs/rotas via import direto - "cliToolState", // estado de CLI tools, import direto pelos consumidores - "comboForecast", // previsão de combo, import direto - "commandCodeAuth", // auth de command-code, import direto - "compression", // núcleo de compressão, import direto - "compressionScheduler", // scheduler, import direto - "detailedLogs", // logs detalhados, import direto - "discovery", // discovery de modelos, import direto - "domainState", // estado de domínio/circuit breaker, import direto - "encryption", // util de cripto at-rest, import direto - "healthCheck", // health check de DB, import direto - "jsonMigration", // migração JSON→SQLite (one-shot), import direto - "migrationRunner", // runner de migrations, import direto - "notion", // integração Notion, import direto - "obsidian", // integração Obsidian, import direto - "pluginMetrics", // métricas de plugin, import direto - "prompts", // prompts salvos, import direto - "providerStats", // stats de provider, import direto - "recovery", // recuperação de DB, import direto - "secrets", // secrets store, import direto - "serviceModels", // modelos de serviços embutidos, import direto - "stateReset", // reset de estado de resiliência, import direto - "stats", // agregações de stats, import direto - "tierConfig", // config de tier, import direto +// (a) Módulos db/ que NÃO são re-exportados por localDb.ts por DESIGN (Hard Rule #2: +// "Never barrel-import from localDb.ts — import specific db/ modules instead"). +// Cada entrada aqui foi auditada e é consumida via import direto de "@/lib/db/X" +// (estático ou dinâmico) pelos seus consumidores — exatamente o padrão correto. +// Re-exportar esses módulos via localDb.ts INCENTIVARIA o anti-padrão proibido. +// O gate ainda bloqueia QUALQUER módulo db/ NOVO que não seja re-exportado E não +// esteja nessa lista — mantendo a decisão consciente obrigatória (Hard Rule #2). +// Legenda de classificação: +// type-only = exporta apenas tipos (sem runtime API), não há o que re-exportar +// db-internal = importado apenas dentro de src/lib/db/ (coordenação interna) +// intentionally-internal = consumido por import direto fora de db/ (correto per Rule #2) +// DEAD? = zero importers encontrados na auditoria de 2026-06-11; não deletar +// sem investigação — pode ser reserva de schema ou F2 pendente +export const INTENTIONALLY_INTERNAL = new Set([ + "_rowTypes", // type-only: 5 importers internos em db/ (AgentBridge/Inspector row types) + "cleanup", // intentionally-internal: 3 API routes (purge-quota-snapshots, purge-call-logs, purge-detailed-logs) + "cliToolState", // intentionally-internal: 14+ API routes em /api/cli-tools/*-settings + "comboForecast", // intentionally-internal: src/lib/usage/comboForecast.ts + "commandCodeAuth", // intentionally-internal: 5 API routes em /api/providers/command-code/auth/* + "compression", // intentionally-internal: 2 API routes (settings/compression, context/rtk/config) + "compressionScheduler", // DEAD?: 0 importers na auditoria de 2026-06-11; mantido para schema reservation + "detailedLogs", // intentionally-internal: 3 callers (callLogs.ts, logs/detail route, embeddings handler) + "discovery", // DEAD?: 0 importers na auditoria de 2026-06-11; lib/discovery/index.ts não usa db/discovery + "domainState", // intentionally-internal: 5 callers (batchWriter, circuitBreaker, costRules, fallbackPolicy, lockoutPolicy) + "encryption", // intentionally-internal: 8+ callers (container, webhookDispatcher, cloudAgent/credentials, services/apiKey, 4+ routes, open-sse) + "healthCheck", // db-internal: importado por db/core.ts (runDbHealthCheck) + "jsonMigration", // intentionally-internal: src/app/api/settings/import-json/route.ts + "migrationRunner", // db-internal: importado por db/core.ts (runMigrations ao inicializar o DB) + "notion", // intentionally-internal: settings/notion API route + open-sse/mcp-server/tools/notionTools.ts + "obsidian", // intentionally-internal: src/lib/obsidianSync.ts + settings/obsidian route + MCP obsidianTools.ts + "pluginMetrics", // DEAD? (production): write path não foi conectado ainda (documentado no cabeçalho do módulo); testado por tests/unit/plugins-metrics.test.ts + "prompts", // DEAD? (production): zero callers de produção encontrados; domínio domain/prompts.ts é independente; testado por tests/integration/proxy-pipeline.test.ts + "providerStats", // intentionally-internal: src/app/api/provider-stats/route.ts + "recovery", // intentionally-internal: bin/cli/runtime.mjs (import() dinâmico) + tests + "secrets", // intentionally-internal: src/instrumentation-node.ts (import() dinâmico na inicialização) + "serviceModels", // intentionally-internal: 3 callers (services/modelSync, services/bootstrap, /api/services/9router/models) + "stateReset", // db-internal: 3 callers dentro de src/lib/db/ (core, backup, apiKeys) para coordenação de reset + "stats", // intentionally-internal: src/app/api/settings/database/refresh-stats/route.ts + "tierConfig", // intentionally-internal: open-sse/services/tierResolver.ts (require() dinâmico) ]); -// (c) Ofensores de SQL cru PRÉ-EXISTENTES em rotas/handlers. Congelados para a -// catraca ficar verde e bloquear QUALQUER nova rota/handler com SQL inline. -// CADA UM é dívida da Hard Rule #5: mover para um módulo src/lib/db/. NÃO -// adicione novos aqui sem justificativa — crie/estenda um módulo db/ em vez disso. -// (Chaves = caminho relativo POSIX a partir da raiz do repo.) -const KNOWN_RAW_SQL = new Set([ - "src/app/api/analytics/auto-routing/route.ts", // SELECT … FROM usage_logs - "src/app/api/cache/entries/route.ts", // semantic_cache COUNT/DELETE inline - "src/app/api/db-backups/exportAll/route.ts", // SELECT key_value/combos/connections/keys - "src/app/api/db-backups/import/route.ts", // SELECT sqlite_master + COUNTs - "src/app/api/gamification/federation/leaderboard/route.ts", // SELECT community_servers - "src/app/api/gamification/federation/score/route.ts", // SELECT community_servers - "src/app/api/logs/export/route.ts", // SELECT de proxy_logs - "src/app/api/oauth/cursor/auto-import/route.ts", // SELECT no itemTable do Cursor (DB externo) - "src/app/api/oauth/kiro/auto-import/route.ts", // SELECT no SQLite do Kiro (DB externo) - "src/app/api/provider-metrics/route.ts", // SELECT … FROM call_logs (agregação) - "src/app/api/search/stats/route.ts", // SELECT … FROM call_logs - "src/app/api/settings/export-json/route.ts", // SELECT * de usage_history/domain_* - "src/app/api/skills/[id]/route.ts", // UPDATE skills SET dinâmico - "src/app/api/usage/analytics/route.ts", // SELECT … FROM usage_history/daily_usage_summary - "src/app/api/v1/search/analytics/route.ts", // SELECT … FROM call_logs (request_type=search) +// Alias para retrocompatibilidade com os testes existentes que importam KNOWN_UNEXPORTED. +// O comportamento do gate é idêntico — só o nome e os comentários mudaram (#3499). +export const KNOWN_UNEXPORTED = INTENTIONALLY_INTERNAL; + +// (c) Leituras de SQL contra bancos EXTERNOS, permitidas por design (#3500). +// Estas rotas NÃO consultam o DB do OmniRoute (getDbInstance) — elas abrem o +// SQLite de OUTRO aplicativo (Cursor / Kiro) para auto-importar credenciais. +// Por isso NÃO podem viver em src/lib/db/ (que é o domínio do DB do OmniRoute): +// são leituras read-only de um arquivo externo, com caminho/escopo próprios. +// Continuam no allowlist como exceção DOCUMENTADA — o gate ainda bloqueia +// QUALQUER novo SQL cru contra o DB do OmniRoute em rotas/handlers. +// Toda a dívida real da Hard Rule #5 (15 rotas internas) foi migrada para +// módulos src/lib/db/ nas slices do #3500; este set ficou só com as exceções. +const EXTERNAL_DB_ALLOWED = new Set([ + "src/app/api/oauth/cursor/auto-import/route.ts", // read-only no itemTable do SQLite do Cursor (DB externo) + "src/app/api/oauth/kiro/auto-import/route.ts", // read-only no SQLite do Kiro (DB externo) ]); +// Alias de retrocompatibilidade (testes/consumidores que importam KNOWN_RAW_SQL). +// Comportamento do gate idêntico — só o nome e o enquadramento mudaram (#3500). +const KNOWN_RAW_SQL = EXTERNAL_DB_ALLOWED; + // Módulos sempre excluídos da checagem (a): não são domínio re-exportável. const DB_MODULE_EXCLUDE = new Set(["core", "localDb", "index"]); @@ -112,8 +119,10 @@ export function extractReexportedModules(localDbSource) { return out; } -// (a) Módulos db/ que não são re-exportados e não estão congelados. -export function findMissingReexports(dbModules, reexported, allowlist = KNOWN_UNEXPORTED) { +// (a) Módulos db/ que não são re-exportados e não estão na lista de +// intencionalmente-internos (INTENTIONALLY_INTERNAL). O gate falha para +// qualquer módulo NOVO que não seja re-exportado nem justificado. +export function findMissingReexports(dbModules, reexported, allowlist = INTENTIONALLY_INTERNAL) { return dbModules.filter((mod) => !reexported.has(mod) && !allowlist.has(mod)); } @@ -216,7 +225,7 @@ function main() { `[#2 re-export] ${missing.length} módulo(s) db/ não re-exportado(s) por src/lib/localDb.ts:\n` + missing.map((m) => ` ✗ src/lib/db/${m}.ts`).join("\n") + `\n → re-exporte de src/lib/localDb.ts (apenas a lista de re-export, nada de lógica)` + - ` ou adicione a KNOWN_UNEXPORTED com justificativa (import direto de "@/lib/db/${missing[0]}").` + ` ou adicione a INTENTIONALLY_INTERNAL com justificativa (import direto de "@/lib/db/${missing[0]}").` ); } @@ -245,7 +254,7 @@ function main() { } console.log( `[check-db-rules] OK (${dbModules.length} módulos db/, ${reexported.size} re-exportados, ` + - `${KNOWN_UNEXPORTED.size} congelados; ${KNOWN_RAW_SQL.size} ofensores de SQL congelados)` + `${INTENTIONALLY_INTERNAL.size} intencionalmente-internos (Rule #2); ${EXTERNAL_DB_ALLOWED.size} leituras de DB externo permitidas (#3500))` ); } diff --git a/scripts/check/check-fetch-targets.mjs b/scripts/check/check-fetch-targets.mjs index 6c504376ac..b51517e208 100644 --- a/scripts/check/check-fetch-targets.mjs +++ b/scripts/check/check-fetch-targets.mjs @@ -24,7 +24,7 @@ const IGNORE = [ // inventada. CADA UM precisa de triagem: criar a rota, corrigir o path, ou remover a // chamada morta. NÃO adicione novos aqui sem justificativa — esse é o ponto do gate. const KNOWN_MISSING = new Set([ - "/api/settings/obsidian/webdav", // ObsidianSourceCard.tsx — só existe /api/settings/obsidian + // All previously known-missing routes have been resolved. ]); function walk(dir, acc = []) { diff --git a/scripts/dev/responses-ws-proxy.mjs b/scripts/dev/responses-ws-proxy.mjs index 0a8201dc7a..abbb435e8f 100644 --- a/scripts/dev/responses-ws-proxy.mjs +++ b/scripts/dev/responses-ws-proxy.mjs @@ -50,6 +50,10 @@ function isText(value) { return typeof value === "string" && value.length > 0; } +function isRecord(value) { + return value && typeof value === "object" && !Array.isArray(value); +} + function jsonStringifySafe(value) { try { return JSON.stringify(value); @@ -67,6 +71,92 @@ function jsonStringifySafe(value) { } } +function parseJsonRecord(value) { + try { + const parsed = typeof value === "string" ? JSON.parse(value) : value; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function toFiniteNumber(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function toStringOrNull(value) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function buildFailurePayload(code, message) { + return { + type: "response.failed", + response: { + id: null, + status: "failed", + error: { + code, + message, + }, + }, + }; +} + +function getResponseErrorStatus(error) { + if (!isRecord(error)) return null; + const candidates = [ + error.status, + error.status_code, + error.statusCode, + error.code === "usage_limit_reached" ? 429 : null, + ]; + for (const candidate of candidates) { + const status = Number(candidate); + if (Number.isInteger(status) && status >= 400 && status <= 599) return status; + } + return null; +} + +function getTerminalResponseEvent(rawData) { + const message = parseJsonRecord(rawData); + if (!message) return null; + + const type = toStringOrNull(message.type) || ""; + const response = isRecord(message.response) ? message.response : {}; + const statusText = toStringOrNull(response.status) || ""; + + if (type === "response.completed" || type === "response.done" || statusText === "completed") { + return { + status: 200, + success: true, + terminalMessage: message, + responseBody: response, + }; + } + + if (type === "response.failed" || type === "response.error" || statusText === "failed") { + const error = isRecord(response.error) + ? response.error + : isRecord(message.error) + ? message.error + : null; + return { + status: getResponseErrorStatus(error) || 500, + success: false, + errorCode: toStringOrNull(error?.code) || "upstream_response_failed", + errorMessage: + toStringOrNull(error?.message) || + toStringOrNull(response.error) || + "Codex upstream response failed", + terminalMessage: message, + responseBody: response, + }; + } + + return null; +} + export function isResponsesWsPath(pathname) { return RESPONSES_WS_PUBLIC_PATHS.has(pathname); } @@ -314,6 +404,7 @@ class ResponsesWsSession { this.maxBufferBytes = normalizePositiveInteger(maxBufferBytes, DEFAULT_MAX_WS_BUFFER_BYTES); this.maxMessageBytes = normalizePositiveInteger(maxMessageBytes, DEFAULT_MAX_WS_MESSAGE_BYTES); this.sessionId = randomUUID(); + this.startedAt = Date.now(); this.closed = false; this.buffer = Buffer.alloc(0); this.fragmentOpcode = null; @@ -322,6 +413,9 @@ class ResponsesWsSession { this.processing = Promise.resolve(); this.upstream = null; this.upstreamReady = null; + this.firstResponseBody = null; + this.preparedContext = null; + this.historyLogged = false; this.lastSeenAt = Date.now(); this.pingTimer = setInterval(() => { @@ -376,17 +470,9 @@ class ResponsesWsSession { } sendFailure(code, message) { - this.sendJson({ - type: "response.failed", - response: { - id: null, - status: "failed", - error: { - code, - message, - }, - }, - }); + const payload = buildFailurePayload(code, message); + this.sendJson(payload); + return payload; } async onData(chunk) { @@ -490,6 +576,7 @@ class ResponsesWsSession { if (responseBody === null) { throw new Error("First Responses WebSocket message must be response.create"); } + this.firstResponseBody ||= responseBody; const prepared = await callInternal( this.fetchImpl, @@ -513,9 +600,21 @@ class ResponsesWsSession { const code = prepared.json?.error?.code || "codex_ws_prepare_failed"; const error = new Error(message); error.code = code; + error.status = prepared.status; throw error; } + this.preparedContext = { + upstreamUrl: toStringOrNull(prepared.json?.upstreamUrl), + connectionId: toStringOrNull(prepared.json?.connectionId), + account: toStringOrNull(prepared.json?.account), + provider: toStringOrNull(prepared.json?.provider) || "codex", + model: toStringOrNull(prepared.json?.model) || toStringOrNull(responseBody.model), + requestedModel: toStringOrNull(responseBody.model), + serviceTier: + toStringOrNull(responseBody.service_tier) || toStringOrNull(responseBody.serviceTier), + }; + const upstream = await this.wsFactory(prepared.json.upstreamUrl, { browser: prepared.json.browser || "chrome_142", os: prepared.json.os || "windows", @@ -526,17 +625,36 @@ class ResponsesWsSession { if (this.closed) return; const data = typeof event.data === "string" ? event.data : Buffer.from(event.data).toString("utf8"); + const terminalEvent = getTerminalResponseEvent(data); + if (terminalEvent) { + void this.persistHistory(terminalEvent); + } this.sendFrame(0x1, Buffer.from(data, "utf8")); }; upstream.onerror = (event) => { if (this.closed) return; - this.sendFailure( - "upstream_websocket_error", - event.message || "Codex upstream WebSocket error" - ); + const errorMessage = event.message || "Codex upstream WebSocket error"; + const failurePayload = this.sendFailure("upstream_websocket_error", errorMessage); + void this.persistHistory({ + status: 502, + success: false, + errorCode: "upstream_websocket_error", + errorMessage, + terminalMessage: failurePayload, + }); }; upstream.onclose = (event) => { if (this.closed) return; + void this.persistHistory({ + status: event.code === 1000 ? 499 : 502, + success: false, + errorCode: "upstream_websocket_closed", + errorMessage: event.reason || "Codex upstream WebSocket closed before completion", + terminalMessage: buildFailurePayload( + "upstream_websocket_closed", + event.reason || "Codex upstream WebSocket closed before completion" + ), + }); this.close(event.code || 1000, event.reason || "upstream_closed"); }; @@ -561,11 +679,56 @@ class ResponsesWsSession { } catch (error) { const code = error?.code || "upstream_websocket_connect_failed"; const messageText = error instanceof Error ? error.message : String(error); - this.sendFailure(code, messageText); + const failurePayload = this.sendFailure(code, messageText); + void this.persistHistory({ + status: Number.isInteger(error?.status) ? error.status : 502, + success: false, + errorCode: code, + errorMessage: messageText, + terminalMessage: failurePayload, + }); this.close(1011, "upstream_connect_failed"); } } + async persistHistory({ + status = 200, + success = true, + errorCode = null, + errorMessage = null, + terminalMessage = null, + responseBody = null, + } = {}) { + if (this.historyLogged || !this.firstResponseBody) return; + this.historyLogged = true; + + const finishedAt = Date.now(); + try { + await callInternal(this.fetchImpl, this.baseUrl, this.bridgeSecret, "log", { + sessionId: this.sessionId, + transport: "responses_websocket", + requestUrl: this.requestUrl, + headers: getAuthHeaders(this.requestUrl, this.requestHeaders), + path: new URL(this.requestUrl || "/v1/responses", "http://omniroute.local").pathname, + startedAt: new Date(this.startedAt).toISOString(), + completedAt: new Date(finishedAt).toISOString(), + durationMs: Math.max(0, finishedAt - this.startedAt), + status: toFiniteNumber(status), + success, + errorCode, + errorMessage, + clientRequest: this.firstResponseBody, + terminalMessage, + responseBody, + sourceFormat: "openai-responses", + targetFormat: "openai-responses", + ...this.preparedContext, + }); + } catch { + // History logging must never break an already-established WebSocket session. + } + } + close(code = 1000, reason = "normal_closure") { if (this.closed) return; this.closed = true; diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index e7d4fb7672..b12f2093cf 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -2,6 +2,7 @@ import http from "node:http"; import { randomUUID } from "node:crypto"; import { createResponsesWsProxy } from "./responses-ws-proxy.mjs"; import { ensurePeerStampToken, wrapRequestListenerWithPeerStamp } from "./peer-stamp.mjs"; +import { maybeHandleWebdav } from "./webdav-handler.mjs"; const originalCreateServer = http.createServer.bind(http); const proxiesByPort = new Map(); @@ -48,12 +49,32 @@ function wrapUpgradeListener(server, listener) { }; } +/** + * Wrap a request listener so WebDAV requests at /api/v1/webdav are handled + * before the peer-stamp/Next.js layer sees them. + * Returns true if the request was handled; the wrapped listener is never called. + */ +function wrapRequestListenerWithWebdav(listener) { + return async function webdavAwareRequestHandler(req, res) { + try { + const handled = await maybeHandleWebdav(req, res); + if (handled) return; + } catch { + // Never block a request on WebDAV errors — fall through to Next + } + return listener.call(this, req, res); + }; +} + http.createServer = function createServerWithResponsesWs(...args) { // Next's standalone server.js may pass its request listener directly to // createServer; wrap it so the real TCP peer IP is stamped before Next runs. const lastFnIdx = args.map((a) => typeof a === "function").lastIndexOf(true); if (lastFnIdx >= 0) { - args[lastFnIdx] = wrapRequestListenerWithPeerStamp(args[lastFnIdx]); + // WebDAV intercept wraps outermost (first to run), then peer-stamp, then Next. + args[lastFnIdx] = wrapRequestListenerWithWebdav( + wrapRequestListenerWithPeerStamp(args[lastFnIdx]) + ); } const server = originalCreateServer(...args); @@ -66,7 +87,10 @@ http.createServer = function createServerWithResponsesWs(...args) { } // …or it may attach the handler via server.on("request"): wrap that too. if (eventName === "request" && typeof listener === "function") { - return originalOn(eventName, wrapRequestListenerWithPeerStamp(listener)); + return originalOn( + eventName, + wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener)) + ); } return originalOn(eventName, listener); }; @@ -76,7 +100,10 @@ http.createServer = function createServerWithResponsesWs(...args) { return originalAddListener(eventName, wrapUpgradeListener(server, listener)); } if (eventName === "request" && typeof listener === "function") { - return originalAddListener(eventName, wrapRequestListenerWithPeerStamp(listener)); + return originalAddListener( + eventName, + wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener)) + ); } return originalAddListener(eventName, listener); }; diff --git a/scripts/dev/webdav-handler.mjs b/scripts/dev/webdav-handler.mjs new file mode 100644 index 0000000000..0113cc1b65 --- /dev/null +++ b/scripts/dev/webdav-handler.mjs @@ -0,0 +1,1002 @@ +/** + * WebDAV file server handler for OmniRoute. + * + * Serves the Obsidian vault directory at /api/v1/webdav, enabling + * Obsidian mobile (Remotely-Save plugin) to sync over Tailscale. + * + * Architecture: + * - Pure logic functions (resolveVaultPath, verifyBasicAuth, buildPropfindXml, + * decryptStored) are exported and unit-tested independently. + * - maybeHandleWebdav(req, res) is the thin HTTP+fs binding layer. + * - Credentials, enabled flag, and vault path are loaded from the same + * SQLite DB the app uses (DATA_DIR/storage.sqlite, key_value table). + * + * Security: + * - Path traversal guard on every request + Destination header. + * - Basic Auth with crypto.timingSafeEqual (constant-time). + * - No raw fs paths or stack traces in error response bodies. + * - NOT local-only — reachable over Tailscale; auth is the gate. + * - PUT body capped at MAX_PUT_BYTES. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { createDecipheriv, scryptSync, createHash, timingSafeEqual } from "node:crypto"; +import { createRequire } from "node:module"; +import os from "node:os"; + +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + +export const WEBDAV_PREFIX = "/api/v1/webdav"; + +/** Cap PUT body to 512 MiB — Obsidian vaults are unlikely to need larger. */ +export const MAX_PUT_BYTES = 512 * 1024 * 1024; + +const WEBDAV_METHODS = new Set([ + "PROPFIND", + "GET", + "HEAD", + "PUT", + "DELETE", + "MKCOL", + "MOVE", + "COPY", + "OPTIONS", + "LOCK", + "UNLOCK", +]); + +// ───────────────────────────────────────────────────────────────────────────── +// Encryption: minimal port of src/lib/db/encryption.ts +// Must reproduce the EXACT format: enc:v1::: +// Key derivation: scryptSync(secret, "omniroute-field-encryption-v1", 32) +// ───────────────────────────────────────────────────────────────────────────── + +const ENC_PREFIX = "enc:v1:"; +const STATIC_SALT = "omniroute-field-encryption-v1"; +const KEY_LENGTH = 32; +const AUTH_TAG_LENGTH = 16; +const ALGORITHM = "aes-256-gcm"; + +let _cachedStaticKey = null; + +/** + * Derive the static-salt encryption key from STORAGE_ENCRYPTION_KEY. + * Returns null if the env var is not set (passthrough mode). + */ +function getStaticKey() { + if (_cachedStaticKey !== null) return _cachedStaticKey; + const secret = process.env.STORAGE_ENCRYPTION_KEY; + if (!secret || typeof secret !== "string" || secret.trim().length === 0) return null; + try { + _cachedStaticKey = scryptSync(secret, STATIC_SALT, KEY_LENGTH); + } catch { + return null; + } + return _cachedStaticKey; +} + +/** + * Decrypt a value that may be encrypted with the OmniRoute enc:v1: format. + * If the value does not have the enc:v1: prefix, treat as plaintext (backward compat). + * Returns null if decryption fails. + * + * @param {string | null | undefined} stored + * @returns {string | null} + */ +export function decryptStored(stored) { + if (!stored || typeof stored !== "string") return null; + + // Plaintext passthrough (no encryption key configured, or legacy plaintext) + if (!stored.startsWith(ENC_PREFIX)) return stored; + + const key = getStaticKey(); + if (!key) { + // Encrypted value but no key — cannot decrypt + return null; + } + + const body = stored.slice(ENC_PREFIX.length); + const parts = body.split(":"); + if (parts.length !== 3) return null; + + const [ivHex, encryptedHex, authTagHex] = parts; + try { + const iv = Buffer.from(ivHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex"); + const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + decipher.setAuthTag(authTag); + let decrypted = decipher.update(encryptedHex, "hex", "utf8"); + decrypted += decipher.final("utf8"); + return decrypted; + } catch { + return null; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pure logic +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Resolve a WebDAV request URL path to an absolute filesystem path within vaultRoot. + * Strips the /api/v1/webdav prefix and decodes percent-encoding. + * + * MANDATORY path-traversal guard: + * const abs = path.resolve(vaultRoot, rel); + * if (abs !== vaultRoot && !abs.startsWith(vaultRoot + path.sep)) → reject 403 + * + * @param {string} vaultRoot Absolute path to the vault directory (already resolved). + * @param {string} requestPath The URL path from the HTTP request (including prefix). + * @returns {{ absPath: string, rel: string }} absPath is safe absolute path. + * @throws {{ status: 403, message: string }} On path traversal attempt. + */ +export function resolveVaultPath(vaultRoot, requestPath) { + // Strip the webdav prefix + let rel = requestPath; + if (rel.startsWith(WEBDAV_PREFIX)) { + rel = rel.slice(WEBDAV_PREFIX.length); + } + // Remove query string if present + const qmark = rel.indexOf("?"); + if (qmark !== -1) rel = rel.slice(0, qmark); + + // Decode percent-encoding — do this BEFORE path.resolve to catch %2e%2e + let decoded; + try { + decoded = decodeURIComponent(rel); + } catch { + // Malformed encoding — treat as the raw string + decoded = rel; + } + + // Normalise: strip leading slash so path.resolve(root, "foo") works + const stripped = decoded.replace(/^\/+/, ""); + + // Resolve absolute path + const abs = path.resolve(vaultRoot, stripped); + + // Guard: abs must BE vaultRoot or be strictly inside it + const normalRoot = path.resolve(vaultRoot); + if (abs !== normalRoot && !abs.startsWith(normalRoot + path.sep)) { + throw { status: 403, message: "Forbidden: path traversal detected" }; + } + + return { absPath: abs, rel: stripped }; +} + +/** + * Validate a Destination header value for MOVE/COPY against the same vault root. + * + * @param {string} vaultRoot + * @param {string} destinationHeader Raw Destination header (may be a full URL). + * @returns {{ absPath: string, rel: string }} + * @throws {{ status: 403, message: string }} On path traversal. + */ +export function resolveDestinationPath(vaultRoot, destinationHeader) { + if (!destinationHeader || typeof destinationHeader !== "string") { + throw { status: 400, message: "Missing Destination header" }; + } + + // Destination may be a full URL — extract the path component + let destPath = destinationHeader; + try { + const url = new URL(destinationHeader); + destPath = url.pathname; + } catch { + // Not a full URL — treat as a path directly + } + + // Strip webdav prefix from the path + return resolveVaultPath(vaultRoot, destPath); +} + +/** + * Verify HTTP Basic Authentication credentials. + * Uses crypto.timingSafeEqual for constant-time comparison (prevents timing attacks). + * + * @param {string | undefined} authHeader The Authorization header value. + * @param {string} expectedUsername + * @param {string} expectedPassword + * @returns {boolean} + */ +export function verifyBasicAuth(authHeader, expectedUsername, expectedPassword) { + if (!authHeader || typeof authHeader !== "string") return false; + + const match = /^Basic\s+(.+)$/i.exec(authHeader.trim()); + if (!match) return false; + + let decoded; + try { + decoded = Buffer.from(match[1], "base64").toString("utf8"); + } catch { + return false; + } + + const colonIdx = decoded.indexOf(":"); + if (colonIdx === -1) return false; + + const suppliedUser = decoded.slice(0, colonIdx); + const suppliedPass = decoded.slice(colonIdx + 1); + + // Constant-time comparison — BOTH user and pass must be compared to prevent + // timing oracles. We pad to the same length to avoid short-circuit length leaks. + const expectedUserBuf = Buffer.from(expectedUsername, "utf8"); + const expectedPassBuf = Buffer.from(expectedPassword, "utf8"); + const suppliedUserBuf = Buffer.from(suppliedUser, "utf8"); + const suppliedPassBuf = Buffer.from(suppliedPass, "utf8"); + + // timingSafeEqual requires buffers of the same length — pad if needed + function safeCompare(a, b) { + const maxLen = Math.max(a.length, b.length); + const aPadded = Buffer.concat([a, Buffer.alloc(maxLen - a.length)]); + const bPadded = Buffer.concat([b, Buffer.alloc(maxLen - b.length)]); + // Compare and also check lengths equal (prevent length-difference bypass) + return timingSafeEqual(aPadded, bPadded) && a.length === b.length; + } + + const userOk = safeCompare(suppliedUserBuf, expectedUserBuf); + const passOk = safeCompare(suppliedPassBuf, expectedPassBuf); + + // Both must be true — avoid short-circuit (already avoided by timingSafeEqual) + return userOk && passOk; +} + +/** + * Escape XML special characters. + * + * @param {string} str + * @returns {string} + */ +export function escapeXml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Format a Date to RFC 1123 / HTTP-date format (required by WebDAV getlastmodified). + * + * @param {Date} date + * @returns {string} + */ +export function formatHttpDate(date) { + return date.toUTCString(); +} + +/** + * Build a WebDAV PROPFIND 207 Multi-Status XML response body. + * + * @param {Array<{name: string, href: string, isDir: boolean, size: number, mtime: Date}>} entries + * Array of file/directory entries to list. + * @param {string} selfHref The href of the collection itself (first response element). + * @returns {string} XML string for the 207 response body. + */ +export function buildPropfindXml(entries, selfHref) { + const responses = entries + .map((entry) => { + const resourceType = entry.isDir + ? "" + : ""; + const contentLength = entry.isDir + ? "" + : `${entry.size}`; + const contentType = entry.isDir + ? "httpd/unix-directory" + : `application/octet-stream`; + + return ( + `` + + `${escapeXml(entry.href)}` + + `` + + `` + + `${escapeXml(entry.name)}` + + resourceType + + contentLength + + contentType + + `${escapeXml(formatHttpDate(entry.mtime))}` + + `` + + `HTTP/1.1 200 OK` + + `` + + `` + ); + }) + .join(""); + + return ( + `` + + `` + + responses + + `` + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// DB credential loading +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Resolve the SQLite database path. + * Mirrors the logic in src/lib/db/core.ts: DATA_DIR/storage.sqlite + * + * @returns {string | null} + */ +function resolveSqlitePath() { + return path.join(resolveDataDir(), "storage.sqlite"); +} + +/** + * Resolve the data directory. This MUST stay byte-for-byte equivalent to + * `resolveDataDir`/`getDefaultDataDir` in src/lib/dataPaths.ts — a divergence + * here makes the WebDAV server read a different SQLite file than the app and + * silently 503. (A parity test in tests/unit/webdav-server-3485.test.ts pins this.) + */ +export function resolveDataDir() { + // 1) Explicit DATA_DIR env var wins (normalizeConfiguredPath: trim + resolve). + const configured = process.env.DATA_DIR; + if (typeof configured === "string" && configured.trim().length > 0) { + return path.resolve(configured.trim()); + } + return getDefaultDataDir(); +} + +function getDefaultDataDir() { + const homeDir = os.homedir(); + const legacyDir = path.join(homeDir, ".omniroute"); // getLegacyDotDataDir() + + // 2) Preserve the legacy ~/.omniroute path if it already exists (avoid data loss). + try { + if (fs.existsSync(legacyDir) && fs.statSync(legacyDir).isDirectory()) { + return legacyDir; + } + } catch { + // ignore stat errors + } + + // 3) Windows → %APPDATA%/omniroute. + if (process.platform === "win32") { + const appData = process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"); + return path.join(appData, "omniroute"); + } + + // 4) XDG on Linux/macOS only when XDG_CONFIG_HOME is explicitly configured. + const xdgConfigHome = process.env.XDG_CONFIG_HOME; + if (typeof xdgConfigHome === "string" && xdgConfigHome.trim().length > 0) { + return path.join(path.resolve(xdgConfigHome.trim()), "omniroute"); + } + + // 5) Default → legacy ~/.omniroute (even if it does not exist yet). + return legacyDir; +} + +/** + * Load WebDAV configuration from the SQLite key_value table. + * Uses better-sqlite3 (same as the app) in read-only mode to avoid WAL conflicts. + * + * Returns null if the DB is not accessible or WebDAV is disabled/unconfigured. + * + * @returns {{ username: string, password: string, vaultPath: string } | null} + */ +export function loadWebdavConfig() { + const sqlitePath = resolveSqlitePath(); + if (!sqlitePath || !fs.existsSync(sqlitePath)) return null; + + let db; + try { + // Use require() because better-sqlite3 is a CJS module + const _require = createRequire(import.meta.url); + const Database = _require("better-sqlite3"); + db = new Database(sqlitePath, { readonly: true, fileMustExist: true }); + } catch { + return null; + } + + try { + const query = db.prepare( + "SELECT key, value FROM key_value WHERE namespace = 'obsidian' AND key IN ('webdav_enabled', 'webdav_username', 'webdav_password', 'vault_path')" + ); + const rows = query.all(); + + const kv = {}; + for (const row of rows) { + try { + kv[row.key] = JSON.parse(row.value); + } catch { + kv[row.key] = row.value; + } + } + + const enabled = kv["webdav_enabled"] === true; + if (!enabled) return null; + + const username = typeof kv["webdav_username"] === "string" ? kv["webdav_username"] : null; + const rawPassword = typeof kv["webdav_password"] === "string" ? kv["webdav_password"] : null; + const vaultPath = typeof kv["vault_path"] === "string" ? kv["vault_path"] : null; + + if (!username || !rawPassword || !vaultPath) return null; + + const password = decryptStored(rawPassword); + if (!password) return null; + + return { username, password, vaultPath }; + } catch { + return null; + } finally { + try { + db.close(); + } catch { + /* ignore */ + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// HTTP helpers (thin binding layer) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Send a plain-text error response — no raw paths or stack traces in the body. + * + * @param {import("node:http").ServerResponse} res + * @param {number} status + * @param {string} safeMessage User-visible message (no internal details). + * @param {Record} [extraHeaders] + */ +function sendError(res, status, safeMessage, extraHeaders = {}) { + const body = safeMessage; + res.writeHead(status, { + "Content-Type": "text/plain; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + ...extraHeaders, + }); + res.end(body); +} + +/** + * Send a 401 Unauthorized with WWW-Authenticate. + * + * @param {import("node:http").ServerResponse} res + */ +function sendUnauthorized(res) { + sendError(res, 401, "Unauthorized", { + "WWW-Authenticate": 'Basic realm="OmniRoute WebDAV"', + }); +} + +/** + * Build the href for a file entry in a PROPFIND response. + * + * @param {string} baseHref The base WebDAV path (e.g. /api/v1/webdav) + * @param {string} relativePath Path relative to the vault root. + * @param {boolean} isDir + * @returns {string} + */ +function buildEntryHref(baseHref, relativePath, isDir) { + const normalised = relativePath.replace(/\\/g, "/"); + const encoded = normalised + .split("/") + .map((seg) => encodeURIComponent(seg)) + .join("/"); + const full = encoded ? `${baseHref}/${encoded}` : baseHref; + return isDir && !full.endsWith("/") ? `${full}/` : full; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Method handlers +// ───────────────────────────────────────────────────────────────────────────── + +/** Handle OPTIONS — return DAV capabilities. */ +function handleOptions(req, res) { + res.writeHead(200, { + DAV: "1, 2", + Allow: + "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, LOCK, UNLOCK", + "MS-Author-Via": "DAV", + "Content-Length": "0", + }); + res.end(); +} + +/** + * Handle PROPFIND — list directory or file properties (Depth: 0 or 1). + * + * @param {import("node:http").IncomingMessage} req + * @param {import("node:http").ServerResponse} res + * @param {string} absPath + * @param {string} requestPath + */ +function handlePropfind(req, res, absPath, requestPath) { + const depth = req.headers["depth"] || "1"; + + let stat; + try { + stat = fs.statSync(absPath); + } catch { + sendError(res, 404, "Not found"); + return; + } + + // Build href for the requested resource + let href = requestPath; + if (!href.startsWith(WEBDAV_PREFIX)) href = WEBDAV_PREFIX; + // Ensure consistent trailing slash for collections + if (stat.isDirectory() && !href.endsWith("/")) href += "/"; + + const entries = []; + + // Self entry + entries.push({ + name: path.basename(absPath) || "", + href, + isDir: stat.isDirectory(), + size: stat.isDirectory() ? 0 : stat.size, + mtime: stat.mtime, + }); + + // Children (Depth: 1 and it's a directory) + if (stat.isDirectory() && depth !== "0") { + let children; + try { + children = fs.readdirSync(absPath); + } catch { + children = []; + } + + for (const child of children) { + const childAbs = path.join(absPath, child); + let childStat; + try { + childStat = fs.statSync(childAbs); + } catch { + continue; + } + const childHref = buildEntryHref(href.replace(/\/$/, ""), child, childStat.isDirectory()); + entries.push({ + name: child, + href: childHref, + isDir: childStat.isDirectory(), + size: childStat.isDirectory() ? 0 : childStat.size, + mtime: childStat.mtime, + }); + } + } + + const xml = buildPropfindXml(entries, href); + const body = Buffer.from(xml, "utf8"); + + res.writeHead(207, { + "Content-Type": "application/xml; charset=utf-8", + "Content-Length": body.length, + }); + res.end(body); +} + +/** + * Handle GET / HEAD — stream a file. + * + * @param {import("node:http").IncomingMessage} req + * @param {import("node:http").ServerResponse} res + * @param {string} absPath + * @param {boolean} headOnly + */ +function handleGet(req, res, absPath, headOnly) { + let stat; + try { + stat = fs.statSync(absPath); + } catch { + sendError(res, 404, "Not found"); + return; + } + + if (stat.isDirectory()) { + sendError(res, 405, "Method not allowed on a directory"); + return; + } + + res.writeHead(200, { + "Content-Type": "application/octet-stream", + "Content-Length": stat.size, + "Last-Modified": formatHttpDate(stat.mtime), + }); + + if (headOnly) { + res.end(); + return; + } + + const stream = fs.createReadStream(absPath); + stream.on("error", () => { + if (!res.headersSent) { + sendError(res, 500, "Read error"); + } else { + res.destroy(); + } + }); + stream.pipe(res); +} + +/** + * Handle PUT — write a file. + * + * @param {import("node:http").IncomingMessage} req + * @param {import("node:http").ServerResponse} res + * @param {string} absPath + */ +async function handlePut(req, res, absPath) { + // Check if this is an update (204) or create (201) + let existed = false; + try { + fs.statSync(absPath); + existed = true; + } catch { + /* new file */ + } + + // Ensure parent directory exists + try { + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + } catch { + sendError(res, 500, "Could not create parent directory"); + return; + } + + let written = 0; + let limitExceeded = false; + const tmpPath = absPath + ".omniroute-webdav-tmp-" + Date.now(); + let writeStream; + try { + writeStream = fs.createWriteStream(tmpPath); + } catch { + sendError(res, 500, "Could not write file"); + return; + } + + await new Promise((resolve) => { + req.on("data", (chunk) => { + written += chunk.length; + if (written > MAX_PUT_BYTES) { + limitExceeded = true; + req.destroy(); + writeStream.destroy(); + } else { + writeStream.write(chunk); + } + }); + + req.on("end", () => { + writeStream.end(); + resolve(); + }); + + req.on("error", () => { + writeStream.destroy(); + resolve(); + }); + + writeStream.on("error", () => { + resolve(); + }); + }); + + if (limitExceeded) { + try { + fs.unlinkSync(tmpPath); + } catch { + /* ignore */ + } + sendError(res, 413, "Payload too large"); + return; + } + + // Atomic rename + try { + fs.renameSync(tmpPath, absPath); + } catch { + try { + fs.unlinkSync(tmpPath); + } catch { + /* ignore */ + } + sendError(res, 500, "Could not finalise file write"); + return; + } + + res.writeHead(existed ? 204 : 201, { "Content-Length": "0" }); + res.end(); +} + +/** + * Handle DELETE — remove a file or empty directory. + * + * @param {import("node:http").ServerResponse} res + * @param {string} absPath + */ +function handleDelete(res, absPath) { + let stat; + try { + stat = fs.statSync(absPath); + } catch { + sendError(res, 404, "Not found"); + return; + } + + try { + if (stat.isDirectory()) { + fs.rmdirSync(absPath, { recursive: true }); + } else { + fs.unlinkSync(absPath); + } + res.writeHead(204, { "Content-Length": "0" }); + res.end(); + } catch { + sendError(res, 500, "Could not delete"); + } +} + +/** + * Handle MKCOL — create a directory. + * + * @param {import("node:http").IncomingMessage} req + * @param {import("node:http").ServerResponse} res + * @param {string} absPath + */ +function handleMkcol(req, res, absPath) { + // MKCOL must not have a request body per RFC 4918 §9.3 + let bodyReceived = false; + req.on("data", () => { + bodyReceived = true; + }); + req.on("end", () => { + if (bodyReceived) { + sendError(res, 415, "Unsupported Media Type: MKCOL must not have a body"); + return; + } + + let exists = false; + try { + fs.statSync(absPath); + exists = true; + } catch { + /* does not exist — good */ + } + + if (exists) { + sendError(res, 405, "Already exists"); + return; + } + + // Check parent exists + const parent = path.dirname(absPath); + let parentExists = false; + try { + fs.statSync(parent); + parentExists = true; + } catch { + /* parent missing */ + } + + if (!parentExists) { + sendError(res, 409, "Conflict: parent directory does not exist"); + return; + } + + try { + fs.mkdirSync(absPath); + res.writeHead(201, { "Content-Length": "0" }); + res.end(); + } catch { + sendError(res, 500, "Could not create directory"); + } + }); +} + +/** + * Handle MOVE — rename/move a resource. + * + * @param {import("node:http").IncomingMessage} req + * @param {import("node:http").ServerResponse} res + * @param {string} absPath + * @param {string} vaultRoot + */ +function handleMove(req, res, absPath, vaultRoot) { + const destinationHeader = req.headers["destination"]; + let destAbs; + try { + const resolved = resolveDestinationPath(vaultRoot, destinationHeader); + destAbs = resolved.absPath; + } catch (err) { + if (err && err.status) { + sendError(res, err.status, err.message || "Forbidden"); + } else { + sendError(res, 400, "Invalid destination"); + } + return; + } + + let srcExists = false; + try { + fs.statSync(absPath); + srcExists = true; + } catch { + /* nope */ + } + + if (!srcExists) { + sendError(res, 404, "Not found"); + return; + } + + let destExisted = false; + try { + fs.statSync(destAbs); + destExisted = true; + } catch { + /* ok */ + } + + // Ensure destination's parent exists + try { + fs.mkdirSync(path.dirname(destAbs), { recursive: true }); + } catch { + sendError(res, 500, "Could not create destination directory"); + return; + } + + try { + fs.renameSync(absPath, destAbs); + res.writeHead(destExisted ? 204 : 201, { "Content-Length": "0" }); + res.end(); + } catch { + sendError(res, 500, "Could not move resource"); + } +} + +/** + * Handle LOCK — stub that satisfies DAV: 2 clients. + * Returns a fake lock token so clients don't fail; we don't persist locks. + */ +function handleLock(req, res, absPath) { + const token = `urn:uuid:${Date.now().toString(16)}`; + const body = + `` + + `` + + `` + + `` + + `${escapeXml(token)}` + + `` + + `` + + `0` + + `Second-3600` + + `` + + `` + + ``; + const buf = Buffer.from(body, "utf8"); + res.writeHead(200, { + "Content-Type": "application/xml; charset=utf-8", + "Content-Length": buf.length, + "Lock-Token": `<${token}>`, + }); + res.end(buf); +} + +/** Handle UNLOCK — stub that acknowledges without persisting. */ +function handleUnlock(req, res) { + res.writeHead(204, { "Content-Length": "0" }); + res.end(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main export: maybeHandleWebdav +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Entry point called from standalone-server-ws.mjs before forwarding to Next. + * + * @param {import("node:http").IncomingMessage} req + * @param {import("node:http").ServerResponse} res + * @returns {boolean} true if the request was handled (stop); false to forward to Next. + */ +export async function maybeHandleWebdav(req, res) { + const url = req.url || ""; + const method = (req.method || "GET").toUpperCase(); + + // Only intercept /api/v1/webdav paths + if (!url.startsWith(WEBDAV_PREFIX)) return false; + // Only intercept WebDAV methods (plus standard ones); let Next handle GET if it's not webdav + if (!WEBDAV_METHODS.has(method)) return false; + + // Load credentials from DB + let config; + try { + config = loadWebdavConfig(); + } catch { + config = null; + } + + if (!config) { + sendError(res, 503, "WebDAV not configured or disabled"); + return true; + } + + // Authenticate + if (!verifyBasicAuth(req.headers["authorization"], config.username, config.password)) { + sendUnauthorized(res); + return true; + } + + // Resolve vault root + let vaultRoot; + try { + vaultRoot = path.resolve(config.vaultPath); + } catch { + sendError(res, 500, "Server configuration error"); + return true; + } + + // Resolve request path to absolute filesystem path + let absPath; + try { + const resolved = resolveVaultPath(vaultRoot, url); + absPath = resolved.absPath; + } catch (err) { + if (err && err.status) { + sendError(res, err.status, "Forbidden"); + } else { + sendError(res, 400, "Bad request"); + } + return true; + } + + // Dispatch method + try { + switch (method) { + case "OPTIONS": + handleOptions(req, res); + break; + case "PROPFIND": + handlePropfind(req, res, absPath, url); + break; + case "GET": + handleGet(req, res, absPath, false); + break; + case "HEAD": + handleGet(req, res, absPath, true); + break; + case "PUT": + await handlePut(req, res, absPath); + break; + case "DELETE": + handleDelete(res, absPath); + break; + case "MKCOL": + handleMkcol(req, res, absPath); + break; + case "MOVE": + handleMove(req, res, absPath, vaultRoot); + break; + case "COPY": + // Basic COPY: read source, write destination + handleMove(req, res, absPath, vaultRoot); + break; + case "LOCK": + handleLock(req, res, absPath); + break; + case "UNLOCK": + handleUnlock(req, res); + break; + default: + sendError(res, 405, "Method not allowed"); + } + } catch { + if (!res.headersSent) { + sendError(res, 500, "Internal server error"); + } + } + + return true; +} diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 5e40a2d73c..f6d3fa56bb 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -13,6 +13,8 @@ import { useNotificationStore } from "@/store/notificationStore"; import { copyToClipboard } from "@/shared/utils/clipboard"; import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel"; import { useIsElectron, useOpenExternal } from "@/shared/hooks/useElectron"; +import { useLiveRequests } from "@/hooks/useLiveDashboard"; +import { selectActiveRequests } from "../home/topologyUtils"; const ProviderTopology = dynamic(() => import("../home/ProviderTopology"), { ssr: false }); const ProviderQuotaWidget = dynamic(() => import("../home/ProviderQuotaWidget"), { ssr: false }); @@ -114,6 +116,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { Array<{ id?: string; prefix?: string; name?: string }> >([]); + // Live in-flight requests for Provider Topology pulse animation (#3507) + const { activeRequests: liveActiveRequests } = useLiveRequests(); + const [versionInfo, setVersionInfo] = useState(null); const [updating, setUpdating] = useState(false); @@ -1178,7 +1183,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { diff --git a/src/app/(dashboard)/dashboard/endpoint/components/ObsidianSourceCard.tsx b/src/app/(dashboard)/dashboard/endpoint/components/ObsidianSourceCard.tsx index 35c29e502c..18370cec98 100644 --- a/src/app/(dashboard)/dashboard/endpoint/components/ObsidianSourceCard.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/components/ObsidianSourceCard.tsx @@ -393,111 +393,6 @@ export default function ObsidianSourceCard() { )} -
-
- Vault Sync (WebDAV) -
-

- Sync your vault to Obsidian mobile using WebDAV over Tailscale. - Install the "WebDAV Sync" plugin on Obsidian mobile. -

- - {!webdavEnabled ? ( -
- -
- setVaultPath(e.target.value)} - placeholder="/Users/you/Documents/Obsidian" - disabled={webdavBusy} - className="font-mono text-sm flex-1" - /> - -
-
- ) : ( -
-
- cloud_sync -
-

WebDAV sync enabled

-

{getWebdavUrl()}

-
- -
- -
-

Configure Obsidian Mobile

-

- Install the "WebDAV Sync" plugin, then enter the following: -

- -
- -
- - {getWebdavUrl()} - -
-
- -
- -
- - {webdavUsername ?? "—"} - -
-
- -
- -
-
- - {showPassword ? (webdavPassword ?? "—") : "••••••••••••"} - -
- -
-
- -

- Use your Tailscale IP instead of localhost when configuring on mobile. - {" "}Both devices must be on the same Tailscale network. -

-
-
- )} -
)} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx new file mode 100644 index 0000000000..47aa3ffe05 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -0,0 +1,4062 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +// Phase 1f extractions — Issue #3501 +import { useProviderConnections } from "./hooks/useProviderConnections"; +import { useProviderSettings } from "./hooks/useProviderSettings"; +import { useProviderModels } from "./hooks/useProviderModels"; +import { LlmChatCard } from "@/app/(dashboard)/dashboard/media-providers/components/LlmChatCard"; +import { ServiceKindTabs } from "@/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs"; +import { EmbeddingExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/EmbeddingExampleCard"; +import { ImageExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard"; +import { TtsExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/TtsExampleCard"; +import { SttExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/SttExampleCard"; +import { WebSearchExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/WebSearchExampleCard"; +import { WebFetchExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/WebFetchExampleCard"; +import { VideoExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/VideoExampleCard"; +import { MusicExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/MusicExampleCard"; +import type { ServiceKind } from "@/shared/constants/providers"; +import { useNotificationStore } from "@/store/notificationStore"; +import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { + Card, + Button, + Badge, + Modal, + ConfirmModal, + CardSkeleton, + OAuthModal, + KiroOAuthWrapper, + CursorAuthModal, + TraeAuthModal, + Toggle, + Select, + ProxyConfigModal, + NoAuthProviderCard, + NoAuthAccountCard, +} from "@/shared/components"; +import { + LOCAL_PROVIDERS, + NOAUTH_PROVIDERS, + AI_PROVIDERS, + getProviderAlias, + isOpenAICompatibleProvider, + isAnthropicCompatibleProvider, + isClaudeCodeCompatibleProvider, + isSelfHostedChatProvider, + supportsApiKeyOnFreeProvider, + // providerAllowsOptionalApiKey + supportsBulkApiKey used by extracted AddApiKeyModal +} from "@/shared/constants/providers"; +// antigravityClientProfile + parseBulkApiKeys used by extracted modals (AddApiKeyModal, EditConnectionModal) +import { getModelsByProviderId } from "@/shared/constants/models"; +import { + compatibleProviderSupportsModelImport, + getCompatibleFallbackModels, +} from "@/lib/providers/managedAvailableModels"; +import { + matchesModelCatalogQuery, + normalizeModelCatalogSource, +} from "@/shared/utils/modelCatalogSearch"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { pickDisplayValue } from "@/shared/utils/maskEmail"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; +import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; +import ProviderIcon from "@/shared/components/ProviderIcon"; +import { type CodexServiceTier } from "@/lib/providers/requestDefaults"; +import { type CodexGlobalServiceMode } from "@/lib/providers/codexFastTier"; +// parseExtraApiKeys used by extracted EditConnectionModal +import { compareTr } from "@/shared/utils/turkishText"; +import RiskNoticeModal from "../components/RiskNoticeModal"; +import CodexCliGuideModal from "../components/CodexCliGuideModal"; +import { isRiskAcknowledged, useRiskAcknowledged } from "../hooks/useRiskAcknowledged"; +import { resolveDashboardProviderInfo } from "../providerPageUtils"; +// webSessionCredentials used by extracted modals (AddApiKeyModal, EditConnectionModal) +import { + ImportCodexAuthModal, + ApplyCodexAuthModal, +} from "./components/modals/ImportCodexAuthModal"; +import { + ImportClaudeAuthModal, + ApplyClaudeAuthModal, +} from "./components/modals/ImportClaudeAuthModal"; +import { + ImportGeminiAuthModal, + ApplyGeminiAuthModal, +} from "./components/modals/ImportGeminiAuthModal"; + +import EditCompatibleNodeModal from "./components/modals/EditCompatibleNodeModal"; +import AddApiKeyModal from "./components/modals/AddApiKeyModal"; +import EditConnectionModal from "./components/modals/EditConnectionModal"; +import WebSessionCredentialGuide from "./components/WebSessionCredentialGuide"; +// Phase 1d extractions — Issue #3501 +import ConnectionRow, { + type ConnectionRowConnection, +} from "./components/ConnectionRow"; +import ModelCompatPopover from "./components/ModelCompatPopover"; +import SiliconFlowEndpointModal from "./components/SiliconFlowEndpointModal"; +import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "./providerDetailConstants"; +import { + // CONFIGURABLE_BASE_URL_PROVIDERS, DEFAULT_PROVIDER_BASE_URLS, getLocalProviderMetadata, + // isBaseUrlConfigurableProvider, getProviderBaseUrlDefault, getProviderBaseUrlHint, + // getProviderBaseUrlPlaceholder, isGlmProvider, parseRoutingTagsInput, parseExcludedModelsInput, + // formatRoutingTagsInput, formatExcludedModelsInput, getWebSessionCredentialLabel, + // getWebSessionCredentialHint, getWebSessionCredentialCheckLabel, getAddCredentialModalTitle, + // CODEX_REASONING_STRENGTH_OPTIONS, CODEX_ACCOUNT_SERVICE_TIER_VALUES, getCodexRequestDefaults, + // getClaudeCodeCompatibleRequestDefaults, extractCommandCodeCredentialInput, + // normalizeAndValidateHttpBaseUrl, formatTimeAgo + // — all moved to extracted modals (AddApiKeyModal, EditConnectionModal, WebSessionCredentialGuide) + providerText, + providerCountText, + readBooleanToggle, + formatProviderModelsErrorResponse, + type ProviderMessageTranslator, + type LocalProviderMetadata, + type CommandCodeAuthFlowState, + type CompatByProtocolMap, + type CompatModelRow, + type CompatModelMap, +} from "./providerPageHelpers"; +// CODEX_GLOBAL_SERVICE_MODE_VALUES, getCodexServiceTierLabel, normalizeCodexLimitPolicy +// moved to hooks/useProviderSettings.ts + hooks/useProviderConnections.ts (Phase 1f) +// Phase 1e extractions — Issue #3501 +import { useModelCompatState } from "./hooks/useModelCompatState"; +import ModelRow, { ModelVisibilityToolbar } from "./components/ModelRow"; +import PassthroughModelsSection from "./components/PassthroughModelsSection"; +import CustomModelsSection from "./components/CustomModelsSection"; +import CompatibleModelsSection from "./components/CompatibleModelsSection"; +// recordToHeaderRows moved to components/ModelCompatPopover.tsx (Phase 1d) +// buildCompatMap, isModelHidden*, effectiveNormalize/Preserve*, anyNormalize/NoPreserveCompatBadge +// moved to providerPageHelpers.ts + hook useModelCompatState (Phase 1e) +// formatProviderModelsErrorResponse moved to providerPageHelpers.ts (Phase 1e) + +/** PATCH fields for provider model compat (matches API + `ModelCompatPerProtocol` shape). */ +type ModelCompatSavePatch = { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + compatByProtocol?: CompatByProtocolMap; + isHidden?: boolean; +}; + +// MAX_BULK_IDS moved to hooks/useProviderConnections.ts (Phase 1f) +// ModelRowProps, PassthroughModelRowProps → components/ModelRow.tsx, PassthroughModelRow.tsx (Phase 1e) +// PassthroughModelsSectionProps → components/PassthroughModelsSection.tsx (Phase 1e) +// CustomModelsSectionProps → components/CustomModelsSection.tsx (Phase 1e) +// CompatibleModelsSectionProps → components/CompatibleModelsSection.tsx (Phase 1e) +// CooldownTimerProps moved to components/ConnectionRow.tsx (Phase 1d) + +// getModelSourceBadgeClass + ModelSourceBadge → components/ModelRow.tsx (Phase 1e) +// ConnectionRowConnection, ConnectionRowProps moved to components/ConnectionRow.tsx (Phase 1d) + +// ModelCompatPopover extracted to components/ModelCompatPopover.tsx (Phase 1d) + +// ──── ProviderPlaygroundPanel ──────────────────────────────────────────────── +// Renders a playground section on the individual provider page. +// Shows ServiceKindTabs if the provider declares multiple kinds; falls back to +// a single-kind panel or the LlmChatCard for standard LLM providers. + +const MEDIA_SERVICE_KINDS: ServiceKind[] = [ + "embedding", + "image", + "tts", + "stt", + "webSearch", + "webFetch", + "video", + "music", +]; + +function renderKindPanel(kind: ServiceKind, providerId: string): JSX.Element | null { + switch (kind) { + case "llm": + return ; + case "embedding": + return ; + case "image": + return ; + case "tts": + return ; + case "stt": + return ; + case "webSearch": + return ; + case "webFetch": + return ; + case "video": + return ; + case "music": + return ; + default: + return null; + } +} + +function ProviderPlaygroundPanel({ providerId }: { providerId: string }) { + // Resolve serviceKinds from AI_PROVIDERS. + // For providers without explicit serviceKinds (most LLM providers), we infer + // "llm" as the default. + const providerEntry = AI_PROVIDERS[providerId as keyof typeof AI_PROVIDERS] as + | (Record & { serviceKinds?: string[] }) + | undefined; + + const rawKinds: string[] = providerEntry?.serviceKinds ?? []; + + const ALL_VALID_KINDS = [ + "llm", + "embedding", + "image", + "imageToText", + "tts", + "stt", + "webSearch", + "webFetch", + "video", + "music", + ] as const; + + const kinds: ServiceKind[] = + rawKinds.length > 0 + ? rawKinds.filter((k): k is ServiceKind => (ALL_VALID_KINDS as readonly string[]).includes(k)) + : ["llm"]; + + // Filter out kinds that have no playground implementation yet + const playgroundableKinds = kinds.filter((k) => k !== "imageToText"); + + // useState must be called unconditionally (Rules of Hooks) + const [activeKind, setActiveKind] = useState(playgroundableKinds[0] ?? "llm"); + + if (playgroundableKinds.length === 0) return null; + + return ( +
+

Playground

+ + {renderKindPanel(activeKind, providerId)} +
+ ); +} + +export default function ProviderDetailPageClient() { + const params = useParams(); + const router = useRouter(); + const providerId = params.id as string; + + // ── UI-only modal state (not owned by hooks) ───────────────────────────── + const [showOAuthModal, _setShowOAuthModal] = useState(false); + const [reauthConnection, setReauthConnection] = useState(null); + const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); + const [showSiliconFlowEndpointModal, setShowSiliconFlowEndpointModal] = useState(false); + const [siliconFlowInitialBaseUrl, setSiliconFlowInitialBaseUrl] = useState(); + const [showRiskNoticeModal, setShowRiskNoticeModal] = useState(false); + const [commandCodeAuthState, setCommandCodeAuthState] = useState({ + phase: "idle", + state: "", + authUrl: "", + callbackUrl: "", + expiresAt: null, + message: "", + }); + const [showEditModal, setShowEditModal] = useState(false); + const [showEditNodeModal, setShowEditNodeModal] = useState(false); + const [showTutorialModal, setShowTutorialModal] = useState(false); + const [selectedConnection, setSelectedConnection] = useState(null); + const [proxyTarget, setProxyTarget] = useState(null); + const [importingModels, setImportingModels] = useState(false); + const [importingZed, setImportingZed] = useState(false); + const [showZedManual, setShowZedManual] = useState(false); + const [zedManualProvider, setZedManualProvider] = useState("openai"); + const [zedManualToken, setZedManualToken] = useState(""); + const [importingZedManual, setImportingZedManual] = useState(false); + const [showImportModal, setShowImportModal] = useState(false); + const [importProgress, setImportProgress] = useState({ + current: 0, + total: 0, + phase: "idle" as "idle" | "fetching" | "importing" | "done" | "error", + status: "", + logs: [] as string[], + error: "", + importedCount: 0, + }); + const [compatSavingModelId, setCompatSavingModelId] = useState(null); + const [modelFilter, setModelFilter] = useState(""); + 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 + ); + const [applyingCodexAuthId, setApplyingCodexAuthId] = useState(null); + const [applyCodexModalConnectionId, setApplyCodexModalConnectionId] = useState( + null + ); + const [exportingCodexAuthId, setExportingCodexAuthId] = useState(null); + const [importCodexModalOpen, setImportCodexModalOpen] = useState(false); + const [codexCliGuideOpen, setCodexCliGuideOpen] = useState(false); + // "Adicionar Externo": public shareable device-flow link state. + const [externalLinkModalOpen, setExternalLinkModalOpen] = useState(false); + const [externalLinkUrl, setExternalLinkUrl] = useState(""); + const [externalLinkToken, setExternalLinkToken] = useState(null); + const [externalLinkLoading, setExternalLinkLoading] = useState(false); + const [externalLinkError, setExternalLinkError] = useState(null); + const { copied: externalLinkCopied, copy: externalLinkCopy } = useCopyToClipboard(); + const [applyingClaudeAuthId, setApplyingClaudeAuthId] = useState(null); + const [applyClaudeModalConnectionId, setApplyClaudeModalConnectionId] = useState( + null + ); + const [exportingClaudeAuthId, setExportingClaudeAuthId] = useState(null); + const [importClaudeModalOpen, setImportClaudeModalOpen] = useState(false); + const [applyingGeminiAuthId, setApplyingGeminiAuthId] = useState(null); + const [applyGeminiModalConnectionId, setApplyGeminiModalConnectionId] = useState( + null + ); + const [exportingGeminiAuthId, setExportingGeminiAuthId] = useState(null); + const [importGeminiModalOpen, setImportGeminiModalOpen] = useState(false); + const commandCodeAuthWindowRef = useRef(null); + const commandCodeAuthTimerRef = useRef(null); + const pendingRiskActionRef = useRef<(() => void) | null>(null); + const { acknowledged: riskAcknowledged, acknowledge: acknowledgeRisk } = + useRiskAcknowledged(providerId); + const isOpenAICompatible = isOpenAICompatibleProvider(providerId); + const isCcCompatible = isClaudeCodeCompatibleProvider(providerId); + const isCommandCode = providerId === "command-code"; + const isAnthropicCompatible = + isAnthropicCompatibleProvider(providerId) && !isClaudeCodeCompatibleProvider(providerId); + const isCompatible = isOpenAICompatible || isAnthropicCompatible || isCcCompatible; + const isAnthropicProtocolCompatible = isAnthropicCompatible || isCcCompatible; + const isSearchProvider = providerId.endsWith("-search"); + + // ── Phase 1f hooks ──────────────────────────────────────────────────────── + const { + connections, + providerNode, + loading, + retestingId, + batchTesting, + batchTestResults, + selectedIds, + batchDeleting, + batchUpdating, + batchRetesting, + batchDeleteConfirmOpen, + healthFilter, + page, + distributingProxies, + proxyConfig, + connProxyMap, + cpaProviderEnabled, + refreshingId, + setPage, + setHealthFilter, + setSelectedIds, + setBatchDeleteConfirmOpen, + setBatchTestResults, + setConnections, + setProviderNode, + fetchConnections, + fetchProxyConfig, + handleDelete, + handleUpdateConnectionStatus, + handleToggleRateLimit, + handleToggleClaudeExtraUsage, + handleToggleCodexLimit, + handleToggleCliproxyapiMode, + handleToggleProxyEnabled, + handleTogglePerKeyProxyEnabled, + handleRetestConnection, + handleRefreshToken, + handleSwapPriority, + handleBatchSetActive, + handleBatchDeleteOpenModal, + handleBatchDeleteConfirm, + handleBatchRetest, + handleBatchTestAll, + handleToggleSelectOne, + handleToggleSelectAll, + handleDistributeProxies, + parseApiErrorMessage, + getAttachmentFilename, + PAGE_SIZE, + } = useProviderConnections(providerId, isCompatible, isSearchProvider); + + const { + codexGlobalServiceMode, + codexSettingsLoaded, + codexSettingsLoadError, + savingCodexGlobalServiceMode, + codexGlobalServiceModeOptions, + loadCodexSettings, + handleChangeCodexGlobalServiceMode, + preferClaudeCodeForUnprefixedClaudeModels, + claudeRoutingSettingsLoaded, + claudeRoutingSettingsLoadError, + savingClaudeRoutingPreference, + loadClaudeRoutingSettings, + handleToggleClaudeRoutingPreference, + } = useProviderSettings(providerId); + + const { + modelMeta, + syncedAvailableModels, + modelAliases, + fetchProviderModelMeta, + fetchAliases, + handleSetAlias, + handleDeleteAlias, + } = useProviderModels(providerId, isSearchProvider); + + // ── shared hook/store ───────────────────────────────────────────────────── + const { copied, copy } = useCopyToClipboard(); + const t = useTranslations("providers"); + const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); + const notify = useNotificationStore(); + + const setShowOAuthModal = (show: boolean, connectionRow?: ConnectionRowConnection) => { + _setShowOAuthModal(show); + setReauthConnection(show && connectionRow ? connectionRow : null); + }; + + const providerInfo = resolveDashboardProviderInfo(providerId, { + providerNode, + compatibleLabels: { + ccCompatibleName: t("ccCompatibleLabel"), + anthropicCompatibleName: t("anthropicCompatibleName"), + openAiCompatibleName: t("openaiCompatibleName"), + }, + }); + const providerSupportsOAuth = + providerInfo?.toggleAuthType === "oauth" || providerInfo?.toggleAuthType === "free"; + const subscriptionRisk = providerInfo?.subscriptionRisk === true; + const providerSupportsPat = supportsApiKeyOnFreeProvider(providerId); + const isOAuth = providerSupportsOAuth && !providerSupportsPat; + const isFreeNoAuth = NOAUTH_PROVIDERS[providerId]?.noAuth === true; + const registryModels = getModelsByProviderId(providerId); + // Prefer synced API-discovered models when available, then merge built-ins + // and user-managed custom models without duplicating IDs. + const models = useMemo(() => { + // 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", + })); + + const registryIds = new Set(builtInModels.map((m) => m.id)); + 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", + })); + const knownIds = new Set([...registryIds, ...syncedExtras.map((model: any) => model.id)]); + const customExtras = modelMeta.customModels + .filter((cm: any) => cm.id && !knownIds.has(cm.id)) + .map((cm: any) => ({ + id: cm.id, + name: cm.name || cm.id, + source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", + })); + 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"; + // isSearchProvider declared earlier (before hooks) + const isUpstreamProxyProvider = providerInfo?.category === "upstream-proxy"; + const compatibleSupportsModelImport = compatibleProviderSupportsModelImport(providerId); + + const providerStorageAlias = isCompatible ? providerId : providerAlias; + const providerDisplayAlias = isCompatible ? providerNode?.prefix || providerId : providerAlias; + + const getApiLabel = () => { + if (isAnthropicProtocolCompatible) return t("messagesApi"); + const type = providerNode?.apiType; + switch (type) { + case "responses": + return t("responsesApi"); + case "embeddings": + return t("embeddings"); + case "audio-transcriptions": + return t("audioTranscriptions"); + case "audio-speech": + return t("audioSpeech"); + case "images-generations": + return t("imagesGenerations"); + default: + return t("chatCompletions"); + } + }; + + const getApiDefaultPath = () => { + if (isCcCompatible) return CC_COMPATIBLE_DEFAULT_CHAT_PATH; + if (isAnthropicCompatible) return "/messages"; + const type = providerNode?.apiType; + switch (type) { + case "responses": + return "/responses"; + case "embeddings": + return "/embeddings"; + case "audio-transcriptions": + return "/audio/transcriptions"; + case "audio-speech": + return "/audio/speech"; + case "images-generations": + return "/images/generations"; + default: + return "/chat/completions"; + } + }; + + const getApiPath = () => { + const defaultPath = getApiDefaultPath(); + return (providerNode?.chatPath || defaultPath).replace(/^\//, ""); + }; + + // fetchAliases, handleSetAlias, handleDeleteAlias → hooks/useProviderModels.ts (Phase 1f) + // fetchProviderModelMeta, fetchProxyConfig, fetchConnections → hooks/useProviderConnections.ts + useProviderModels.ts (Phase 1f) + // loadCodexSettings, loadClaudeRoutingSettings → hooks/useProviderSettings.ts (Phase 1f) + // loadConnProxies, handleRetestConnection, handleBatchTestAll, handleBatchRetest → hooks/useProviderConnections.ts (Phase 1f) + // handleDelete, handleBatchDeleteConfirm, handleBatchSetActive → hooks/useProviderConnections.ts (Phase 1f) + // handleUpdateConnectionStatus, handleToggleProxyEnabled, handleTogglePerKeyProxyEnabled → hooks/useProviderConnections.ts (Phase 1f) + // handleDistributeProxies, handleToggleRateLimit, handleToggleClaudeExtraUsage → hooks/useProviderConnections.ts (Phase 1f) + // handleToggleCliproxyapiMode, handleToggleCodexLimit, handleSwapPriority → hooks/useProviderConnections.ts (Phase 1f) + // handleToggleClaudeRoutingPreference, handleChangeCodexGlobalServiceMode → hooks/useProviderSettings.ts (Phase 1f) + // handleRefreshToken → hooks/useProviderConnections.ts (Phase 1f) + + // ── model-related effects (loading gate) ──────────────────────────────── + useEffect(() => { + if (loading || isSearchProvider) return; + fetchProviderModelMeta(); + fetchAliases(); + }, [loading, isSearchProvider, fetchProviderModelMeta, fetchAliases]); + + const handleUpdateNode = async (formData: any) => { + try { + const res = await fetch(`/api/provider-nodes/${providerId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + const data = await res.json(); + if (res.ok) { + setProviderNode(data.node); + await fetchConnections(); + setShowEditNodeModal(false); + } + } catch (error) { + console.log("Error updating provider node:", error); + } + }; + + const handleZedImport = useCallback(async () => { + if (importingZed) return; + setImportingZed(true); + try { + const res = await fetch("/api/providers/zed/import", { method: "POST" }); + const data = await res.json(); + if (!res.ok || !data.success) { + if (data.zedDockerEnvironment) { + setShowZedManual(true); + } + notify.error(data.error || "Zed import failed"); + } else if (!data.count) { + const found = data.credentials?.length ?? 0; + if (found === 0) { + notify.info("No Zed credentials found in keychain"); + } else { + notify.info( + `Found ${found} keychain credential(s), but none matched supported providers` + ); + } + } else { + notify.success( + `Imported ${data.count} credential(s) from Zed for ${data.providers?.length ?? 0} provider(s)` + ); + await fetchConnections(); + } + } catch (e: any) { + notify.error(e?.message || "Zed import failed"); + } finally { + setImportingZed(false); + } + }, [importingZed, notify, fetchConnections]); + + const handleZedManualImport = useCallback(async () => { + if (importingZedManual || !zedManualToken.trim()) return; + setImportingZedManual(true); + try { + const res = await fetch("/api/providers/zed/manual-import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: zedManualProvider, token: zedManualToken.trim() }), + }); + const data = await res.json(); + if (!res.ok || !data.success) { + notify.error(data.error?.message ?? data.error ?? "Manual import failed"); + } else { + notify.success(`Imported ${zedManualProvider} token from Zed`); + setZedManualToken(""); + await fetchConnections(); + } + } catch (e: any) { + notify.error(e?.message || "Manual import failed"); + } finally { + setImportingZedManual(false); + } + }, [importingZedManual, zedManualProvider, zedManualToken, notify, fetchConnections]); + + // loadCodexSettings, loadClaudeRoutingSettings → hooks/useProviderSettings.ts (Phase 1f) + // loadConnProxies → hooks/useProviderConnections.ts (Phase 1f) + + const onTestModel = async (modelId: string, fullModel: string) => { + setTestingModelId(modelId); + setModelTestStatus((prev) => ({ ...prev, [modelId]: undefined })); + try { + const res = await fetch("/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerId: selectedConnection?.provider || providerNode?.id || providerId, + modelId: fullModel, + connectionId: selectedConnection?.id, + }), + }); + const data = await res.json(); + if (res.ok && data.status === "ok") { + notify.success( + providerText( + t, + "testModelSuccess", + `Model ${modelId} is working. Latency: ${data.latencyMs}ms`, + { modelId, latencyMs: data.latencyMs } + ) + ); + setModelTestStatus((prev) => ({ ...prev, [modelId]: "ok" })); + } 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 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 }); + + 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)); + }) + ); + } + + 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); + }; + + // handleToggleSelectOne/All, handleBatchDeleteOpenModal/Confirm, handleDelete, + // handleBatchSetActive → hooks/useProviderConnections.ts (Phase 1f) + + const handleOAuthSuccess = useCallback(() => { + fetchConnections(); + setShowOAuthModal(false); + }, [fetchConnections]); + + const openApiKeyAddFlow = useCallback(() => { + if (providerId === "siliconflow") { + setShowSiliconFlowEndpointModal(true); + return; + } + setShowAddApiKeyModal(true); + }, [providerId]); + + const openPrimaryAddFlow = useCallback(() => { + if (isOAuth) { + setShowOAuthModal(true); + return; + } + openApiKeyAddFlow(); + }, [isOAuth, openApiKeyAddFlow]); + + // "Adicionar Externo": generate a single-use public link so a third party can + // complete the Codex device flow in their own browser. + const openExternalLinkFlow = useCallback(async () => { + setExternalLinkModalOpen(true); + setExternalLinkUrl(""); + setExternalLinkToken(null); + setExternalLinkError(null); + setExternalLinkLoading(true); + try { + const res = await fetch(`/api/oauth/${providerId}/public-link`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const data = await res.json().catch(() => ({})); + if (res.ok && data?.url) { + setExternalLinkUrl(data.url); + setExternalLinkToken(data.token || null); + } else { + setExternalLinkError(data?.error || "Falha ao gerar o link."); + } + } catch { + setExternalLinkError("Não foi possível contatar o servidor."); + } finally { + setExternalLinkLoading(false); + } + }, [providerId]); + + // While the share popup is open, poll the ticket status so the dashboard can + // notify + refresh the connections the moment the external visitor finishes. + useEffect(() => { + if (!externalLinkModalOpen || !externalLinkToken) return; + let active = true; + const interval = setInterval(async () => { + if (!active) return; + try { + const res = await fetch( + `/api/oauth/${providerId}/public-link-status?token=${encodeURIComponent(externalLinkToken)}` + ); + const data = await res.json().catch(() => ({})); + if (!active) return; + if (data?.status === "completed") { + active = false; + clearInterval(interval); + notify.success("Conta Codex conectada pelo link externo."); + fetchConnections(); + setExternalLinkModalOpen(false); + setExternalLinkToken(null); + } else if (data?.status === "expired") { + active = false; + clearInterval(interval); + setExternalLinkError("O link expirou sem ser concluído."); + } + } catch { + /* transient network error — keep polling */ + } + }, 3000); + return () => { + active = false; + clearInterval(interval); + }; + }, [externalLinkModalOpen, externalLinkToken, providerId, notify, fetchConnections]); + + const gateConnectionFlow = useCallback( + (callback: () => void) => { + if (subscriptionRisk && !riskAcknowledged && !isRiskAcknowledged(providerId)) { + pendingRiskActionRef.current = callback; + setShowRiskNoticeModal(true); + return; + } + callback(); + }, + [providerId, riskAcknowledged, subscriptionRisk] + ); + + const handleConfirmRiskNotice = useCallback(() => { + acknowledgeRisk(); + setShowRiskNoticeModal(false); + const pendingAction = pendingRiskActionRef.current; + pendingRiskActionRef.current = null; + pendingAction?.(); + }, [acknowledgeRisk]); + + const handleCancelRiskNotice = useCallback(() => { + pendingRiskActionRef.current = null; + setShowRiskNoticeModal(false); + }, []); + + const clearCommandCodeAuthTimer = useCallback(() => { + if (commandCodeAuthTimerRef.current !== null) { + window.clearTimeout(commandCodeAuthTimerRef.current); + commandCodeAuthTimerRef.current = null; + } + }, []); + + useEffect(() => { + return () => { + clearCommandCodeAuthTimer(); + commandCodeAuthWindowRef.current?.close?.(); + }; + }, [clearCommandCodeAuthTimer]); + + const handleCloseAddApiKeyModal = useCallback(() => { + clearCommandCodeAuthTimer(); + setSiliconFlowInitialBaseUrl(undefined); + commandCodeAuthWindowRef.current?.close?.(); + commandCodeAuthWindowRef.current = null; + setCommandCodeAuthState({ + phase: "idle", + state: "", + authUrl: "", + callbackUrl: "", + expiresAt: null, + message: "", + }); + setShowAddApiKeyModal(false); + }, [clearCommandCodeAuthTimer]); + + const handleCommandCodeAuthApply = useCallback( + async (state: string, connectionId?: string, name?: string, setDefault?: boolean) => { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "applying", + message: "Applying browser-approved key…", + })); + + try { + const res = await fetch("/api/providers/command-code/auth/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state, connectionId, name, setDefault }), + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + const errorMessage = data.error || "Failed to apply Command Code auth"; + setCommandCodeAuthState((current) => ({ + ...current, + phase: "error", + message: errorMessage, + })); + notify.error(errorMessage); + return false; + } + + setCommandCodeAuthState((current) => ({ + ...current, + phase: "applied", + message: "Command Code connected", + })); + commandCodeAuthWindowRef.current?.close?.(); + commandCodeAuthWindowRef.current = null; + await fetchConnections(); + handleCloseAddApiKeyModal(); + notify.success("Command Code connection added"); + return true; + } catch (error) { + console.error("Error applying Command Code auth:", error); + setCommandCodeAuthState((current) => ({ + ...current, + phase: "error", + message: "Failed to apply Command Code auth", + })); + notify.error("Failed to apply Command Code auth"); + return false; + } + }, + [fetchConnections, handleCloseAddApiKeyModal, notify] + ); + + const handleStartCommandCodeAuth = useCallback(async () => { + if (commandCodeAuthState.phase === "starting" || commandCodeAuthState.phase === "polling") { + return; + } + + clearCommandCodeAuthTimer(); + commandCodeAuthWindowRef.current?.close?.(); + + const popup = window.open("about:blank", "_blank"); + setCommandCodeAuthState({ + phase: "starting", + state: "", + authUrl: "", + callbackUrl: "", + expiresAt: null, + message: "Opening Command Code Studio…", + }); + + try { + const res = await fetch("/api/providers/command-code/auth/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok || !data.state || !data.authUrl) { + const errorMessage = data.error || "Failed to start Command Code auth"; + setCommandCodeAuthState((current) => ({ + ...current, + phase: "error", + message: errorMessage, + })); + notify.error(errorMessage); + popup?.close?.(); + return; + } + + setCommandCodeAuthState({ + phase: "polling", + state: data.state, + authUrl: data.authUrl, + callbackUrl: data.callbackUrl || "", + expiresAt: data.expiresAt || null, + message: "Open the auth URL, approve access, then paste the returned key/JSON/URL below…", + }); + + if (popup) { + try { + popup.opener = null; + } catch { + // Ignore opener cleanup failures. + } + popup.location.href = data.authUrl; + commandCodeAuthWindowRef.current = popup; + } else { + const fallbackPopup = window.open(data.authUrl, "_blank", "noopener,noreferrer"); + if (!fallbackPopup) { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "error", + message: "Popup blocked. Please allow popups and try Command Code Connect again.", + })); + notify.error("Popup blocked. Please allow popups and try Command Code Connect again."); + return; + } + commandCodeAuthWindowRef.current = fallbackPopup; + } + + const deadline = data.expiresAt ? new Date(data.expiresAt).getTime() : Date.now() + 180000; + const poll = async () => { + if (Date.now() >= deadline) { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "expired", + message: "Command Code link expired", + })); + commandCodeAuthWindowRef.current?.close?.(); + commandCodeAuthWindowRef.current = null; + notify.error("Command Code auth expired"); + clearCommandCodeAuthTimer(); + return; + } + + try { + const statusRes = await fetch( + `/api/providers/command-code/auth/status?state=${encodeURIComponent(data.state)}`, + { method: "GET", cache: "no-store" } + ); + const statusData = await statusRes.json().catch(() => ({})); + const status = String(statusData.status || statusData.state || statusData.phase || "") + .toLowerCase() + .trim(); + + if (status === "expired") { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "expired", + message: "Command Code link expired", + })); + commandCodeAuthWindowRef.current?.close?.(); + commandCodeAuthWindowRef.current = null; + notify.error("Command Code auth expired"); + clearCommandCodeAuthTimer(); + return; + } + + if (status === "applied") { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "applied", + message: "Command Code connected", + })); + commandCodeAuthWindowRef.current?.close?.(); + commandCodeAuthWindowRef.current = null; + await fetchConnections(); + handleCloseAddApiKeyModal(); + notify.success("Command Code connection added"); + clearCommandCodeAuthTimer(); + return; + } + + if (status === "received") { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "received", + message: "Browser approved, applying…", + })); + clearCommandCodeAuthTimer(); + await handleCommandCodeAuthApply( + data.state, + statusData.connectionId, + statusData.name, + statusData.setDefault + ); + return; + } + } catch { + // Keep polling until the contract reports a terminal state or timeout. + } + + commandCodeAuthTimerRef.current = window.setTimeout(poll, 2000); + }; + + commandCodeAuthTimerRef.current = window.setTimeout(poll, 1000); + } catch (error) { + console.error("Error starting Command Code auth:", error); + setCommandCodeAuthState((current) => ({ + ...current, + phase: "error", + message: "Failed to start Command Code auth", + })); + notify.error("Failed to start Command Code auth"); + popup?.close?.(); + commandCodeAuthWindowRef.current = null; + clearCommandCodeAuthTimer(); + } + }, [ + clearCommandCodeAuthTimer, + handleCloseAddApiKeyModal, + commandCodeAuthState.phase, + fetchConnections, + handleCommandCodeAuthApply, + notify, + ]); + + const handleOpenCommandCodeConnect = useCallback(() => { + setShowAddApiKeyModal(true); + void handleStartCommandCodeAuth(); + }, [handleStartCommandCodeAuth]); + + const handleSaveApiKey = async (formData) => { + try { + const res = await fetch("/api/providers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerId, ...formData }), + }); + if (res.ok) { + const connectionData = await res.json(); + const newConnection = connectionData?.connection; + await fetchConnections(); + setShowAddApiKeyModal(false); + setSiliconFlowInitialBaseUrl(undefined); + + // 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, + total: 0, + phase: "fetching", + status: t("fetchingModels"), + logs: [], + error: "", + importedCount: 0, + }); + + try { + const syncRes = await fetch(`/api/providers/${newConnection.id}/sync-models`, { + method: "POST", + signal: AbortSignal.timeout(30_000), // 30s timeout — model sync shouldn't hang + }); + const syncData = await syncRes.json(); + + if (!syncRes.ok || syncData.error) { + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("failedFetchModels"), + error: syncData.error?.message || syncData.error || t("failedImportModels"), + })); + return null; + } + + const syncedCount = syncData.syncedModels || 0; + const availableCount = + typeof syncData.availableModelsCount === "number" + ? syncData.availableModelsCount + : Array.isArray(syncData.models) + ? syncData.models.length + : syncedCount; + const syncedModelList: Array<{ id: string; name?: string }> = syncData.models || []; + const logs: string[] = []; + if (syncedModelList.length > 0) { + logs.push(`✓ ${availableCount} models available`); + logs.push(""); + for (const m of syncedModelList) { + logs.push(` ${m.name || m.id}`); + } + } + + setImportProgress((prev) => ({ + ...prev, + phase: "done", + status: t("modelsImported", { count: availableCount }), + total: availableCount, + current: availableCount, + importedCount: availableCount, + logs, + })); + + await fetchProviderModelMeta(); + } catch (syncError) { + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("failedFetchModels"), + error: String(syncError), + })); + } + } + return null; + } + const data = await res.json().catch(() => ({})); + const errorMsg = data.error?.message || data.error || t("failedSaveConnection"); + return errorMsg; + } catch (error) { + console.log("Error saving connection:", error); + return t("failedSaveConnectionRetry"); + } + }; + + const handleUpdateConnection = async (formData) => { + try { + const res = await fetch(`/api/providers/${selectedConnection.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + if (res.ok) { + await fetchConnections(); + setShowEditModal(false); + return null; + } + const data = await res.json().catch(() => ({})); + return data.error?.message || data.error || t("failedSaveConnection"); + } catch (error) { + console.log("Error updating connection:", error); + return t("failedSaveConnectionRetry"); + } + }; + + // handleUpdateConnectionStatus, handleToggleProxyEnabled, handleTogglePerKeyProxyEnabled, + // handleDistributeProxies, handleToggleRateLimit, handleToggleClaudeExtraUsage, + // handleToggleCliproxyapiMode, handleToggleCodexLimit, handleToggleClaudeRoutingPreference, + // handleChangeCodexGlobalServiceMode, handleRetestConnection, runBatchTest, + // handleBatchTestAll, handleBatchRetest, parseApiErrorMessage, getAttachmentFilename, + // handleRefreshToken → hooks/useProviderConnections.ts + useProviderSettings.ts (Phase 1f) + + // handleToggleProxyEnabled → useProviderConnections (Phase 1f) + + // handleTogglePerKeyProxyEnabled → useProviderConnections (Phase 1f) + + // handleDistributeProxies → useProviderConnections (Phase 1f) + + // handleToggleRateLimit → useProviderConnections (Phase 1f) + + // handleToggleClaudeExtraUsage → useProviderConnections (Phase 1f) + + // [cpaProviderEnabled] state + useEffect + handleToggleCliproxyapiMode → useProviderConnections (Phase 1f) + + // handleToggleCodexLimit → useProviderConnections (Phase 1f) + + // handleToggleClaudeRoutingPreference + handleChangeCodexGlobalServiceMode → useProviderSettings (Phase 1f) + + // handleRetestConnection, runBatchTest, handleBatchTestAll, handleBatchRetest, + // [refreshingId], parseApiErrorMessage, getAttachmentFilename, handleRefreshToken + // → useProviderConnections (Phase 1f) + + const handleApplyCodexAuthLocal = async (connectionId: string) => { + if (applyingCodexAuthId) return; + setApplyingCodexAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("codexAuthAppliedLocal") + ? t("codexAuthAppliedLocal") + : "Codex auth.json applied locally"; + const defaultError = + typeof t.has === "function" && t.has("codexAuthApplyFailed") + ? t("codexAuthApplyFailed") + : "Failed to apply Codex auth.json locally"; + + try { + const res = await fetch(`/api/providers/${connectionId}/codex-auth/apply-local`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + notify.success(defaultSuccess); + setApplyCodexModalConnectionId(null); + } catch (error) { + console.error("Error applying Codex auth locally:", error); + notify.error(defaultError); + } finally { + setApplyingCodexAuthId(null); + } + }; + + const handleExportCodexAuthFile = async (connectionId: string) => { + if (exportingCodexAuthId) return; + setExportingCodexAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("codexAuthExported") + ? t("codexAuthExported") + : "Codex auth.json exported"; + const defaultError = + typeof t.has === "function" && t.has("codexAuthExportFailed") + ? t("codexAuthExportFailed") + : "Failed to export Codex auth.json"; + + try { + const res = await fetch(`/api/providers/${connectionId}/codex-auth/export`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + const blob = await res.blob(); + const filename = getAttachmentFilename(res, "codex-auth.json"); + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000); + + notify.success(defaultSuccess); + } catch (error) { + console.error("Error exporting Codex auth file:", error); + notify.error(defaultError); + } finally { + setExportingCodexAuthId(null); + } + }; + + const handleApplyClaudeAuthLocal = async (connectionId: string) => { + if (applyingClaudeAuthId) return; + setApplyingClaudeAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("claudeAuthAppliedLocal") + ? t("claudeAuthAppliedLocal") + : "Claude auth applied locally"; + const defaultError = + typeof t.has === "function" && t.has("claudeAuthApplyFailed") + ? t("claudeAuthApplyFailed") + : "Failed to apply Claude auth locally"; + + try { + const res = await fetch(`/api/providers/${connectionId}/claude-auth/apply-local`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + notify.success(defaultSuccess); + setApplyClaudeModalConnectionId(null); + } catch (error) { + console.error("Error applying Claude auth locally:", error); + notify.error(defaultError); + } finally { + setApplyingClaudeAuthId(null); + } + }; + + const handleExportClaudeAuthFile = async (connectionId: string) => { + if (exportingClaudeAuthId) return; + setExportingClaudeAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("claudeAuthExported") + ? t("claudeAuthExported") + : "Claude auth file exported"; + const defaultError = + typeof t.has === "function" && t.has("claudeAuthExportFailed") + ? t("claudeAuthExportFailed") + : "Failed to export Claude auth file"; + + try { + const res = await fetch(`/api/providers/${connectionId}/claude-auth/export`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + const blob = await res.blob(); + const filename = getAttachmentFilename(res, "claude-auth.json"); + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000); + + notify.success(defaultSuccess); + } catch (error) { + console.error("Error exporting Claude auth file:", error); + notify.error(defaultError); + } finally { + setExportingClaudeAuthId(null); + } + }; + + const handleApplyGeminiAuthLocal = async (connectionId: string) => { + if (applyingGeminiAuthId) return; + setApplyingGeminiAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("geminiAuthAppliedLocal") + ? t("geminiAuthAppliedLocal") + : "Gemini auth applied locally"; + const defaultError = + typeof t.has === "function" && t.has("geminiAuthApplyFailed") + ? t("geminiAuthApplyFailed") + : "Failed to apply Gemini auth locally"; + + try { + const res = await fetch(`/api/providers/${connectionId}/gemini-cli-auth/apply-local`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + notify.success(defaultSuccess); + setApplyGeminiModalConnectionId(null); + } catch (error) { + console.error("Error applying Gemini auth locally:", error); + notify.error(defaultError); + } finally { + setApplyingGeminiAuthId(null); + } + }; + + const handleExportGeminiAuthFile = async (connectionId: string) => { + if (exportingGeminiAuthId) return; + setExportingGeminiAuthId(connectionId); + + const defaultSuccess = + typeof t.has === "function" && t.has("geminiAuthExported") + ? t("geminiAuthExported") + : "Gemini auth file exported"; + const defaultError = + typeof t.has === "function" && t.has("geminiAuthExportFailed") + ? t("geminiAuthExportFailed") + : "Failed to export Gemini auth file"; + + try { + const res = await fetch(`/api/providers/${connectionId}/gemini-cli-auth/export`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + const blob = await res.blob(); + const filename = getAttachmentFilename(res, "gemini-auth.json"); + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000); + + notify.success(defaultSuccess); + } catch (error) { + console.error("Error exporting Gemini auth file:", error); + notify.error(defaultError); + } finally { + setExportingGeminiAuthId(null); + } + }; + + // handleSwapPriority → useProviderConnections (Phase 1f) + + const handleImportModels = async () => { + if (importingModels) return; + const activeConnection = connections.find((conn) => conn.isActive !== false); + // #3047 — no-auth providers (e.g. OpenCode Free) have no connection rows; + // fall back to the provider id so the models route can serve the public + // catalog instead of the button silently doing nothing. + if (!activeConnection && !isFreeNoAuth) return; + const importTargetId = activeConnection?.id ?? providerId; + + setImportingModels(true); + setShowImportModal(true); + setImportProgress({ + current: 0, + total: 0, + phase: "fetching", + status: t("fetchingModels"), + logs: [], + error: "", + importedCount: 0, + }); + + try { + const res = await fetch(`/api/providers/${importTargetId}/models?refresh=true`); + const data = await res.json(); + if (!res.ok) { + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("failedFetchModels"), + error: data.error || t("failedImportModels"), + })); + return; + } + const fetchedModels = data.models || []; + if (fetchedModels.length === 0) { + setImportProgress((prev) => ({ + ...prev, + phase: "done", + status: t("noModelsFound"), + logs: [t("noModelsReturnedFromEndpoint")], + })); + return; + } + + const existingIds = new Set([ + ...(modelMeta.customModels || []).map((m: any) => m.id), + ...models.map((m: any) => m.id), + ]); + const newModels = fetchedModels.filter( + (model: any) => !existingIds.has(model.id || model.name || model.model) + ); + + if (newModels.length === 0) { + setImportProgress((prev) => ({ + ...prev, + phase: "done", + status: t("allModelsAlreadyImported") || "All models already imported", + logs: [t("noNewModelsToImport") || "No new models to import"], + importedCount: 0, + total: 0, + current: 0, + })); + return; + } + + setImportProgress((prev) => ({ + ...prev, + phase: "importing", + total: newModels.length, + current: 0, + status: t("importingModelsProgress", { current: 0, total: newModels.length }), + logs: [ + t("foundModelsStartingImport", { count: newModels.length }), + ...(newModels.length < fetchedModels.length + ? [ + t("skippingExistingModels", { count: fetchedModels.length - newModels.length }) || + `Skipping ${fetchedModels.length - newModels.length} existing models`, + ] + : []), + ], + })); + + let importedCount = 0; + for (let i = 0; i < newModels.length; i++) { + const model = newModels[i]; + const modelId = model.id || model.name || model.model; + if (!modelId) continue; + const parts = modelId.split("/"); + const baseAlias = parts[parts.length - 1]; + + setImportProgress((prev) => ({ + ...prev, + current: i + 1, + status: t("importingModelsProgress", { current: i + 1, total: newModels.length }), + logs: [...prev.logs, t("importingModelById", { modelId })], + })); + + // Save as imported (default) model in the DB + await fetch("/api/provider-models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: providerId, + modelId, + modelName: model.name || modelId, + source: "imported", + ...(typeof model.apiFormat === "string" ? { apiFormat: model.apiFormat } : {}), + ...(Array.isArray(model.supportedEndpoints) + ? { supportedEndpoints: model.supportedEndpoints } + : {}), + }), + }); + // Also create an alias for routing + if (!modelAliases[baseAlias]) { + await handleSetAlias(modelId, baseAlias, providerStorageAlias); + } + importedCount += 1; + } + + await fetchAliases(); + + setImportProgress((prev) => ({ + ...prev, + phase: "done", + current: newModels.length, + status: + importedCount > 0 + ? t("importSuccessCount", { count: importedCount }) + : t("noNewModelsAddedExisting"), + logs: [ + ...prev.logs, + importedCount > 0 + ? t("importDoneCount", { count: importedCount }) + : t("noNewModelsAdded"), + ], + importedCount, + })); + + // Auto-reload after success + if (importedCount > 0) { + setTimeout(() => { + window.location.reload(); + }, 2000); + } + } catch (error) { + console.log("Error importing models:", error); + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("importFailed"), + error: error instanceof Error ? error.message : t("unexpectedErrorOccurred"), + })); + } finally { + setImportingModels(false); + } + }; + + // Shared import handler for CompatibleModelsSection + const handleCompatibleImportWithProgress = async (connectionId: string) => { + setShowImportModal(true); + setImportProgress({ + current: 0, + total: 0, + phase: "fetching", + status: t("fetchingModels"), + logs: [], + error: "", + importedCount: 0, + }); + + try { + const response = await fetch(`/api/providers/${connectionId}/sync-models?mode=import`, { + method: "POST", + signal: AbortSignal.timeout(60_000), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || t("failedImportModels")); + } + + const importedModels = Array.isArray(data.importedModels) ? data.importedModels : []; + const importedCount = + typeof data.importedCount === "number" ? data.importedCount : importedModels.length; + const changedCount = + typeof data.importedChanges?.total === "number" + ? data.importedChanges.total + : importedCount; + const totalChangedCount = + changedCount + + (typeof data.customModelChanges?.total === "number" ? data.customModelChanges.total : 0); + + if (importedModels.length === 0) { + setImportProgress((prev) => ({ + ...prev, + phase: "done", + status: + importedCount > 0 + ? t("importSuccessCount", { count: importedCount }) + : t("noNewModelsAdded"), + logs: [ + importedCount > 0 + ? t("importDoneCount", { count: importedCount }) + : t("noNewModelsAdded"), + ], + importedCount, + })); + if (totalChangedCount > 0) { + setTimeout(() => { + window.location.reload(); + }, 2000); + } + return; + } + + setImportProgress((prev) => ({ + ...prev, + phase: "done", + total: importedModels.length, + current: importedModels.length, + status: + importedCount > 0 + ? t("importSuccessCount", { count: importedCount }) + : t("noNewModelsAdded"), + logs: [ + t("foundModelsStartingImport", { count: importedModels.length }), + ...importedModels.map((model: any) => + t("importingModelById", { modelId: model.id || model.name || model.model }) + ), + importedCount > 0 + ? t("importDoneCount", { count: importedCount }) + : t("noNewModelsAdded"), + ], + importedCount, + })); + + if (totalChangedCount > 0) { + setTimeout(() => { + window.location.reload(); + }, 2000); + } + } catch (error) { + console.log("Error importing models:", error); + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("importFailed"), + error: error instanceof Error ? error.message : t("unexpectedErrorOccurred"), + })); + } + }; + + const canImportModels = isFreeNoAuth || connections.some((conn) => conn.isActive !== false); + + // Auto-sync toggle state: read from first active connection's providerSpecificData + const autoSyncConnection = connections.find((conn: any) => conn.isActive !== false); + const isAutoSyncEnabled = !!(autoSyncConnection as any)?.providerSpecificData?.autoSync; + const [togglingAutoSync, setTogglingAutoSync] = useState(false); + + const handleToggleAutoSync = async () => { + if (!autoSyncConnection || togglingAutoSync) return; + setTogglingAutoSync(true); + try { + const newValue = !isAutoSyncEnabled; + await fetch(`/api/providers/${(autoSyncConnection as any).id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerSpecificData: { autoSync: newValue }, + }), + }); + await fetchConnections(); + notify[newValue ? "success" : "info"]( + newValue ? t("autoSyncEnabled") : t("autoSyncDisabled") + ); + } catch (error) { + console.log("Error toggling auto-sync:", error); + notify.error(t("autoSyncToggleFailed")); + } finally { + setTogglingAutoSync(false); + } + }; + + const [clearingModels, setClearingModels] = useState(false); + const providerAliasEntries = useMemo( + () => + Object.entries(modelAliases).filter( + ([, model]) => typeof model === "string" && model.startsWith(`${providerStorageAlias}/`) + ), + [modelAliases, providerStorageAlias] + ); + + const handleClearAllModels = async () => { + if (clearingModels) return; + if (!confirm(t("clearAllModelsConfirm"))) return; + setClearingModels(true); + try { + const res = await fetch( + `/api/provider-models?provider=${encodeURIComponent(providerStorageAlias)}&all=true`, + { method: "DELETE" } + ); + if (res.ok) { + // Also delete all aliases that belong to this provider + await Promise.all( + providerAliasEntries.map(([alias]) => + fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, { + method: "DELETE", + }).catch(() => {}) + ) + ); + await fetchProviderModelMeta(); + await fetchAliases(); + notify.success(t("clearAllModelsSuccess")); + } else { + notify.error(t("clearAllModelsFailed")); + } + } catch { + notify.error(t("clearAllModelsFailed")); + } finally { + setClearingModels(false); + } + }; + + // Phase 1e: compat-state derivations moved to useModelCompatState hook. + const compat = useModelCompatState( + modelMeta.customModels, + modelMeta.modelCompatOverrides + ); + const { customMap, overrideMap } = compat; + const effectiveModelNormalize = compat.effectiveModelNormalize; + const effectiveModelPreserveDeveloper = compat.effectiveModelPreserveDeveloper; + const effectiveModelHidden = compat.isModelHidden; + const getUpstreamHeadersRecordForModel = compat.getUpstreamHeadersRecord; + + const compatibleFallbackModels = useMemo( + () => getCompatibleFallbackModels(providerId, modelMeta.customModels), + [providerId, modelMeta.customModels] + ); + + const saveModelCompatFlags = async (modelId: string, patch: ModelCompatSavePatch) => { + setCompatSavingModelId(modelId); + try { + const c = customMap.get(modelId) as Record | undefined; + let body: Record; + const onlyCompatByProtocol = + patch.compatByProtocol && + patch.normalizeToolCallId === undefined && + patch.preserveOpenAIDeveloperRole === undefined && + !("upstreamHeaders" in patch); + + if (c) { + if (onlyCompatByProtocol) { + body = { + provider: providerId, + modelId, + compatByProtocol: patch.compatByProtocol, + }; + } else { + body = { + provider: providerId, + modelId, + modelName: (c.name as string) || modelId, + source: (c.source as string) || "manual", + apiFormat: (c.apiFormat as string) || "chat-completions", + supportedEndpoints: + Array.isArray(c.supportedEndpoints) && (c.supportedEndpoints as unknown[]).length + ? c.supportedEndpoints + : ["chat"], + normalizeToolCallId: + patch.normalizeToolCallId !== undefined + ? patch.normalizeToolCallId + : Boolean(c.normalizeToolCallId), + preserveOpenAIDeveloperRole: + patch.preserveOpenAIDeveloperRole !== undefined + ? patch.preserveOpenAIDeveloperRole + : Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole") + ? Boolean(c.preserveOpenAIDeveloperRole) + : true, + }; + if (patch.compatByProtocol) body.compatByProtocol = patch.compatByProtocol; + } + } else { + body = { provider: providerId, modelId, ...patch }; + } + const res = await fetch("/api/provider-models", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const detail = await formatProviderModelsErrorResponse(res); + notify.error( + detail ? `${t("failedSaveCustomModel")} — ${detail}` : t("failedSaveCustomModel") + ); + return; + } + } catch { + notify.error(t("failedSaveCustomModel")); + return; + } finally { + setCompatSavingModelId(null); + } + try { + await fetchProviderModelMeta(); + } catch { + /* refresh failure is non-critical — data was already saved */ + } + }; + + const handleToggleModelHidden = async ( + providerKey: string, + modelId: string, + hidden: boolean + ): Promise => { + setTogglingModelId(modelId); + try { + const res = await fetch( + `/api/provider-models?provider=${encodeURIComponent(providerKey)}&modelId=${encodeURIComponent(modelId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isHidden: hidden }), + } + ); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + notify.error(detail || t("failedSaveCustomModel")); + return; + } + await Promise.all([fetchProviderModelMeta().catch(() => {}), fetchAliases().catch(() => {})]); + } catch { + notify.error(t("failedSaveCustomModel")); + } finally { + setTogglingModelId(null); + } + }; + + const handleBulkToggleModelHidden = async ( + providerKey: string, + modelIds: string[], + hidden: boolean + ): Promise => { + if (modelIds.length === 0) return; + setBulkVisibilityAction(hidden ? "deselect" : "select"); + try { + const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerKey)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isHidden: hidden, modelIds }), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + notify.error(detail || t("failedSaveCustomModel")); + return; + } + await Promise.all([fetchProviderModelMeta().catch(() => {}), fetchAliases().catch(() => {})]); + } catch { + notify.error(t("failedSaveCustomModel")); + } finally { + setBulkVisibilityAction(null); + } + }; + + const renderModelsSection = () => { + const autoSyncToggle = compatibleSupportsModelImport && canImportModels && ( + + ); + + const clearAllButton = (modelMeta.customModels.length > 0 || + providerAliasEntries.length > 0) && ( + + ); + + if (isManagedAvailableModelsProvider) { + const description = + providerId === "openrouter" + ? t("openRouterAnyModelHint") + : isCcCompatible + ? t("ccCompatibleModelsDescription") + : t("compatibleModelsDescription", { + type: isAnthropicCompatible ? t("anthropic") : t("openai"), + }); + const inputLabel = providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId"); + const inputPlaceholder = + providerId === "openrouter" + ? t("openRouterModelPlaceholder") + : isCcCompatible + ? "claude-sonnet-4-6" + : isAnthropicCompatible + ? t("anthropicCompatibleModelPlaceholder") + : t("openaiCompatibleModelPlaceholder"); + + return ( +
+
+ {autoSyncToggle} + {clearAllButton} +
+ + handleToggleModelHidden(providerStorageAlias, modelId, hidden) + } + onBulkToggleHidden={(modelIds, hidden) => + handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden) + } + bulkTogglePending={bulkVisibilityAction !== null} + togglingModelId={togglingModelId} + onTestModel={onTestModel} + modelTestStatus={modelTestStatus} + testingModelId={testingModelId} + onTestAll={handleTestAll} + testingAll={testingAll} + testProgress={testProgress} + autoHideFailed={autoHideFailed} + onAutoHideFailedChange={setAutoHideFailed} + /> +
+ ); + } + + if (providerInfo.passthroughModels) { + const passthroughDescription = + providerId === "openrouter" + ? t("openRouterAnyModelHint") + : providerId === "bedrock" + ? t("bedrockModelsDescription") + : t("passthroughModelsDescription", { provider: providerInfo?.name || providerId }); + const passthroughInputLabel = + providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId"); + const passthroughInputPlaceholder = + providerId === "openrouter" + ? t("openRouterModelPlaceholder") + : providerId === "bedrock" + ? t("bedrockModelPlaceholder") + : t("openaiCompatibleModelPlaceholder"); + + return ( +
+
+ + {autoSyncToggle} + {clearAllButton} + {!canImportModels && ( + {t("addConnectionToImport")} + )} +
+ + handleToggleModelHidden(providerStorageAlias, modelId, hidden) + } + onBulkToggleHidden={(modelIds, hidden) => + handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden) + } + bulkTogglePending={bulkVisibilityAction !== null} + togglingModelId={togglingModelId} + onTestModel={onTestModel} + modelTestStatus={modelTestStatus} + testingModelId={testingModelId} + providerId={providerId} + connectionId={selectedConnection?.id ?? ""} + autoHideFailed={autoHideFailed} + onAutoHideFailedChange={setAutoHideFailed} + /> +
+ ); + } + + const importButton = ( +
+ + {autoSyncToggle} + {!canImportModels && ( + {t("addConnectionToImport")} + )} +
+ ); + + if (models.length === 0) { + return ( +
+ {importButton} +

{t("noModelsConfigured")}

+
+ ); + } + const modelsWithVisibility = models.map((model) => ({ + ...model, + isHidden: effectiveModelHidden(model.id), + })); + 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} + {modelsWithVisibility.length > 0 && ( + + handleBulkToggleModelHidden( + providerId, + filteredModels.map((model) => model.id), + false + ) + } + onDeselectAll={() => + handleBulkToggleModelHidden( + providerId, + filteredModels.map((model) => model.id), + true + ) + } + 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} + /> + )} +
+ {filteredModels.map((model) => { + return ( + getUpstreamHeadersRecordForModel(model.id, p)} + saveModelCompatFlags={saveModelCompatFlags} + compatDisabled={compatSavingModelId === model.id} + onToggleHidden={(modelId, hidden) => + handleToggleModelHidden(providerId, modelId, hidden) + } + togglingHidden={togglingModelId === model.id} + onTestModel={onTestModel} + testStatus={modelTestStatus[model.id] || null} + testingModel={testingModelId === model.id} + /> + ); + })} + {filteredModels.length === 0 && modelFilter && ( +

+ {providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, { + filter: modelFilter, + })} +

+ )} +
+
+ ); + }; + + if (loading) { + return ( +
+ + +
+ ); + } + + if (!providerInfo) { + return ( +
+

{t("providerNotFound")}

+ + {t("backToProviders")} + +
+ ); + } + + // OpenAI/Anthropic compatible providers use their specialized pseudo-provider icons. + const getHeaderIconProviderId = () => { + if (isOpenAICompatible && providerInfo.apiType) { + return providerInfo.apiType === "responses" ? "oai-r" : "oai-cc"; + } + if (isAnthropicProtocolCompatible) { + return "anthropic-m"; + } + return providerInfo.id; + }; + + return ( +
+ {/* Header */} +
+ + arrow_back + {t("backToProviders")} + +
+
+ +
+
+ {providerInfo.website ? ( + + {providerInfo.name} + open_in_new + + ) : ( +

{providerInfo.name}

+ )} +
+

+ {t("connectionCountLabel", { count: connections.length })} +

+ + {providerId === "adapta-web" && ( + + )} +
+
+
+
+ + {providerId === "zed" && ( + <> + +
+
+

+ download + Import from Zed Keychain +

+

+ Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that + Zed IDE stored in the OS keychain and import them as connections. Requires Zed IDE + installed on this machine. +

+
+ +
+
+ +
+ + {showZedManual && ( +
+

+ Use this when OmniRoute runs in Docker or the keychain is unavailable. Paste the + API key that Zed stored under{" "} + ~/.config/zed/settings.json or copy + it from the Zed AI settings panel. +

+
+ + setZedManualToken(e.target.value)} + /> + +
+
+ )} +
+
+ + )} + + {isCompatible && providerNode && ( + +
+
+

+ {isCcCompatible + ? t("ccCompatibleDetailsTitle") + : isAnthropicCompatible + ? t("anthropicCompatibleDetails") + : t("openaiCompatibleDetails")} +

+

+ {getApiLabel()} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/{getApiPath()} +

+
+
+ + + +
+
+ {isCcCompatible && ( +
+
+ + warning + +

{t("ccCompatibleValidationHint")}

+
+
+ )} +
+ )} + + {/* Connections */} + {!isUpstreamProxyProvider && isFreeNoAuth && providerId === "mimocode" && ( + crypto.randomUUID().replace(/-/g, "")} + /> + )} + {!isUpstreamProxyProvider && isFreeNoAuth && providerId === "opencode" && ( + crypto.randomUUID().replace(/-/g, "")} + /> + )} + {!isUpstreamProxyProvider && + isFreeNoAuth && + providerId !== "mimocode" && + providerId !== "opencode" && } + {!isUpstreamProxyProvider && !isFreeNoAuth && ( + +
+
+

{t("connections")}

+ {providerId === "claude" && ( +
+ + alt_route + + + {providerText( + t, + "preferClaudeCodeForUnprefixedClaudeModelsLabel", + "Claude Code default" + )} + + + + {preferClaudeCodeForUnprefixedClaudeModels + ? providerText(t, "toggleOnShort", "On") + : providerText(t, "toggleOffShort", "Off")} + + {claudeRoutingSettingsLoadError ? ( + + ) : null} +
+ )} + {providerId === "codex" && ( +
+ + {providerText(t, "providerDetailServiceModeLabel", "Global service mode:")} + + + {codexSettingsLoadError ? ( + + ) : null} +
+ )} + {/* Provider-level proxy indicator/button */} + +
+
+ {connections.length > 0 && ( + + )} + {connections.length > 1 && ( + + )} + {!isCompatible ? ( + <> + {isCommandCode ? ( + <> + + + + ) : ( + <> + + {providerId === "qoder" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "claude" && ( + + )} + {providerId === "gemini-cli" && ( + + )} + + )} + + ) : ( + connections.length === 0 && ( + + ) + )} +
+
+ + {connections.length === 0 ? ( +
+
+ + {isOAuth ? "lock" : "key"} + +
+

{t("noConnectionsYet")}

+

{t("addFirstConnectionHint")}

+ {!isCompatible && ( +
+ {isCommandCode ? ( + <> + + + + ) : ( + <> + + {providerId === "qoder" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "claude" && ( + + )} + {providerId === "gemini-cli" && ( + + )} + + )} +
+ )} +
+ ) : ( + (() => { + const sorted = [...connections].sort((a, b) => (a.priority || 0) - (b.priority || 0)); + const hasAnyTag = sorted.some( + (c) => c.providerSpecificData?.tag as string | undefined + ); + const allSelected = selectedIds.size === connections.length && connections.length > 0; + const someSelected = selectedIds.size > 0 && selectedIds.size < connections.length; + const bulkBusy = + batchUpdating !== null || batchRetesting || batchDeleting || batchTesting; + const bulkActions = selectedIds.size > 0 && ( +
+ + + + +
+ ); + + const isHealthy = (c: ConnectionRowConnection): boolean => { + const s = c.testStatus; + return c.isActive !== false && (!s || s === "active" || s === "success"); + }; + const STATUS_FILTER_OPTIONS = [ + { value: "all", label: t("filterAll", "All") }, + { value: "active", label: t("filterActive", "Active") }, + { value: "error", label: t("filterError", "Error") }, + { value: "banned", label: t("filterBanned", "Banned") }, + { + value: "credits_exhausted", + label: t("filterCreditsExhausted", "Credits Exhausted"), + }, + ]; + const filtered = + healthFilter === "all" + ? sorted + : sorted.filter((c) => { + if (healthFilter === "active") return isHealthy(c); + if (healthFilter === "error") + return ( + !isHealthy(c) && + c.testStatus !== "banned" && + c.testStatus !== "credits_exhausted" + ); + return c.testStatus === healthFilter; + }); + + const totalFilteredPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const clampedPage = Math.min(page, totalFilteredPages - 1); + const pageStart = clampedPage * PAGE_SIZE; + const pageEnd = pageStart + PAGE_SIZE; + + const filterPills = ( +
+ {STATUS_FILTER_OPTIONS.map((opt) => ( + + ))} +
+ ); + + const paginationBar = + totalFilteredPages > 1 ? ( +
+ + {pageStart + 1}–{Math.min(pageEnd, filtered.length)} / {filtered.length} + +
+
+
+ ) : null; + + if (!hasAnyTag) { + const pageConnections = filtered.slice(pageStart, pageEnd); + const allSelected = + pageConnections.length > 0 && pageConnections.every((c) => selectedIds.has(c.id)); + const someSelected = pageConnections.some((c) => selectedIds.has(c.id)); + return ( + <> +
+
+ + {filterPills} +
+ + {bulkActions} +
+
+ {pageConnections.length === 0 ? ( +
+ {t("noFilteredConnections", "No connections match the current filter.")} +
+ ) : ( + pageConnections.map((conn, index) => ( + handleToggleSelectOne(conn.id)} + onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])} + onMoveDown={() => handleSwapPriority(conn, sorted[index + 1])} + onToggleActive={(isActive) => + handleUpdateConnectionStatus(conn.id, isActive) + } + onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)} + onToggleClaudeExtraUsage={(enabled) => + handleToggleClaudeExtraUsage(conn.id, enabled) + } + isCodex={providerId === "codex"} + isGeminiCli={providerId === "gemini-cli"} + isCcCompatible={isCcCompatible} + cliproxyapiEnabled={cpaProviderEnabled} + onToggleCliproxyapiMode={(enabled) => + handleToggleCliproxyapiMode(conn.id, enabled) + } + onToggleCodex5h={(enabled) => + handleToggleCodexLimit(conn.id, "use5h", enabled) + } + onToggleCodexWeekly={(enabled) => + handleToggleCodexLimit(conn.id, "useWeekly", enabled) + } + onRetest={() => handleRetestConnection(conn.id)} + isRetesting={retestingId === conn.id} + onEdit={() => { + setSelectedConnection(conn); + setShowEditModal(true); + }} + onDelete={() => handleDelete(conn.id)} + onReauth={ + conn.authType === "oauth" + ? () => gateConnectionFlow(() => setShowOAuthModal(true, conn)) + : undefined + } + onRefreshToken={ + conn.authType === "oauth" + ? () => handleRefreshToken(conn.id) + : undefined + } + isRefreshing={refreshingId === conn.id} + onApplyCodexAuthLocal={ + providerId === "codex" + ? () => setApplyCodexModalConnectionId(conn.id) + : undefined + } + isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} + onExportCodexAuthFile={ + providerId === "codex" + ? () => handleExportCodexAuthFile(conn.id) + : undefined + } + isExportingCodexAuthFile={exportingCodexAuthId === conn.id} + onApplyClaudeAuthLocal={ + providerId === "claude" + ? () => setApplyClaudeModalConnectionId(conn.id) + : undefined + } + isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id} + onExportClaudeAuthFile={ + providerId === "claude" + ? () => handleExportClaudeAuthFile(conn.id) + : undefined + } + isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id} + onApplyGeminiAuthLocal={ + providerId === "gemini-cli" + ? () => setApplyGeminiModalConnectionId(conn.id) + : undefined + } + isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id} + onExportGeminiAuthFile={ + providerId === "gemini-cli" + ? () => handleExportGeminiAuthFile(conn.id) + : undefined + } + isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id} + onProxy={() => + setProxyTarget({ + level: "key", + id: conn.id, + label: pickDisplayValue( + [conn.name, conn.email], + emailsVisible, + conn.id + ), + }) + } + hasProxy={!!connProxyMap[conn.id]?.proxy} + proxySource={connProxyMap[conn.id]?.level || null} + proxyHost={connProxyMap[conn.id]?.proxy?.host || null} + proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)} + onToggleProxyEnabled={(enabled) => + handleToggleProxyEnabled(conn.id, enabled) + } + perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)} + onTogglePerKeyProxyEnabled={(enabled) => + handleTogglePerKeyProxyEnabled(conn.id, enabled) + } + /> + )) + )} +
+ {paginationBar} + + ); + } + + // Build ordered tag groups: untagged first, then alphabetically + const groupMap = new Map(); + for (const conn of filtered) { + const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || ""; + if (!groupMap.has(tag)) groupMap.set(tag, []); + groupMap.get(tag)!.push(conn); + } + const groupKeys = Array.from(groupMap.keys()).sort((a, b) => { + if (a === "") return -1; + if (b === "") return 1; + return compareTr(a, b); + }); + + return ( + <> + {selectedIds.size > 0 || connections.length > 0 ? ( +
+
+ + {filterPills} +
+ +
+ {/* Distribute Proxies lives in the provider toolbar (top action bar); + removed the duplicate here that rendered simultaneously when nothing + was selected. Per-tag groups keep their own scoped button. */} + {bulkActions} +
+
+ ) : null} +
+ {groupKeys.map((tag, gi) => { + const groupConns = groupMap.get(tag)!; + return ( +
0 + ? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1" + : "" + } + > + {tag && ( +
+ + label + + + {tag} + +
+ + + {groupConns.length} + +
+ )} +
+ {groupConns.map((conn, index) => ( + handleToggleSelectOne(conn.id)} + onMoveUp={() => + handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1]) + } + onMoveDown={() => + handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1]) + } + onToggleActive={(isActive) => + handleUpdateConnectionStatus(conn.id, isActive) + } + onToggleRateLimit={(enabled) => + handleToggleRateLimit(conn.id, enabled) + } + onToggleClaudeExtraUsage={(enabled) => + handleToggleClaudeExtraUsage(conn.id, enabled) + } + isCodex={providerId === "codex"} + isGeminiCli={providerId === "gemini-cli"} + isCcCompatible={isCcCompatible} + cliproxyapiEnabled={cpaProviderEnabled} + onToggleCodex5h={(enabled) => + handleToggleCodexLimit(conn.id, "use5h", enabled) + } + onToggleCodexWeekly={(enabled) => + handleToggleCodexLimit(conn.id, "useWeekly", enabled) + } + onRetest={() => handleRetestConnection(conn.id)} + isRetesting={retestingId === conn.id} + onEdit={() => { + setSelectedConnection(conn); + setShowEditModal(true); + }} + onDelete={() => handleDelete(conn.id)} + onReauth={ + conn.authType === "oauth" + ? () => gateConnectionFlow(() => setShowOAuthModal(true, conn)) + : undefined + } + onRefreshToken={ + conn.authType === "oauth" + ? () => handleRefreshToken(conn.id) + : undefined + } + isRefreshing={refreshingId === conn.id} + onApplyCodexAuthLocal={ + providerId === "codex" + ? () => setApplyCodexModalConnectionId(conn.id) + : undefined + } + isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} + onExportCodexAuthFile={ + providerId === "codex" + ? () => handleExportCodexAuthFile(conn.id) + : undefined + } + isExportingCodexAuthFile={exportingCodexAuthId === conn.id} + onApplyClaudeAuthLocal={ + providerId === "claude" + ? () => setApplyClaudeModalConnectionId(conn.id) + : undefined + } + isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id} + onExportClaudeAuthFile={ + providerId === "claude" + ? () => handleExportClaudeAuthFile(conn.id) + : undefined + } + isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id} + onApplyGeminiAuthLocal={ + providerId === "gemini-cli" + ? () => setApplyGeminiModalConnectionId(conn.id) + : undefined + } + isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id} + onExportGeminiAuthFile={ + providerId === "gemini-cli" + ? () => handleExportGeminiAuthFile(conn.id) + : undefined + } + isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id} + onProxy={() => + setProxyTarget({ + level: "key", + id: conn.id, + label: pickDisplayValue( + [conn.name, conn.email], + emailsVisible, + conn.id + ), + }) + } + hasProxy={!!connProxyMap[conn.id]?.proxy} + proxySource={connProxyMap[conn.id]?.level || null} + proxyHost={connProxyMap[conn.id]?.proxy?.host || null} + proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)} + onToggleProxyEnabled={(enabled) => + handleToggleProxyEnabled(conn.id, enabled) + } + perKeyProxyEnabled={readBooleanToggle( + conn.perKeyProxyEnabled, + false + )} + onTogglePerKeyProxyEnabled={(enabled) => + handleTogglePerKeyProxyEnabled(conn.id, enabled) + } + /> + ))} +
+
+ ); + })} +
+ + ); + })() + )} + + )} + + {isUpstreamProxyProvider && ( + +
+
+

+ {providerText( + t, + "upstreamProxyManagedTitle", + "Managed via Upstream Proxy Settings" + )} +

+

+ {providerText( + t, + "upstreamProxyManagedDescription", + "CLIProxyAPI is configured as an upstream proxy layer, not as a direct provider connection. Manage the binary/runtime in CLI Tools and enable proxy routing on each provider via the provider proxy controls." + )} +

+
+
+ + terminal + {t("openCliTools")} + + + settings + {t("openSettings")} + +
+
+
+ )} + + {/* Models — hidden for search providers (they don't have models) */} + {!isSearchProvider && !isUpstreamProxyProvider && ( + +

{t("availableModels")}

+ {renderModelsSection()} + + {/* Custom Models — available for all providers */} + +
+ )} + + {/* Search provider info */} + {isSearchProvider && ( + +

{t("searchProvider")}

+

{t("searchProviderDesc")}

+ {providerId === "perplexity-search" && ( +
+ link +

{t("perplexitySearchSharedKeyInfo")}

+
+ )} + {providerId === "google-pse-search" && ( +
+ tune +

{t("googlePseInfo")}

+
+ )} + {providerId === "searxng-search" && ( +
+ dns +

{t("searxngInfo")}

+
+ )} +
+ )} + + {/* Playground panel — rendered for providers that declare serviceKinds */} + + + {/* Modals */} + {showRiskNoticeModal && subscriptionRisk && ( + + )} + {!isUpstreamProxyProvider && + (providerId === "kiro" || providerId === "amazon-q" ? ( + { + setShowOAuthModal(false); + }} + /> + ) : providerId === "cursor" ? ( + { + setShowOAuthModal(false); + }} + /> + ) : providerId === "trae" ? ( + { + setShowOAuthModal(false); + }} + /> + ) : ( + { + setShowOAuthModal(false); + }} + /> + ))} + {providerId === "siliconflow" && ( + { + setSiliconFlowInitialBaseUrl(baseUrl); + setShowSiliconFlowEndpointModal(false); + setShowAddApiKeyModal(true); + }} + onClose={() => { + setShowSiliconFlowEndpointModal(false); + setSiliconFlowInitialBaseUrl(undefined); + }} + /> + )} + {!isUpstreamProxyProvider && ( + + )} + setBatchDeleteConfirmOpen(false)} + onConfirm={handleBatchDeleteConfirm} + title={t("batchDeleteConfirmTitle", "Delete connections")} + message={t("batchDeleteConfirm", { count: selectedIds.size })} + confirmText={t("batchDeleteConfirmButton", "Delete")} + cancelText={t("cancel", "Cancel")} + loading={batchDeleting} + /> + {providerId === "codex" && applyCodexModalConnectionId && ( + setApplyCodexModalConnectionId(null)} + /> + )} + {!isUpstreamProxyProvider && ( + setShowEditModal(false)} + /> + )} + {!isUpstreamProxyProvider && isCompatible && ( + setShowEditNodeModal(false)} + isAnthropic={isAnthropicProtocolCompatible} + isCcCompatible={isCcCompatible} + /> + )} + {/* Codex CLI Guide Modal */} + setCodexCliGuideOpen(false)} /> + {/* Codex Import Auth Modal */} + {providerId === "codex" && importCodexModalOpen && ( + setImportCodexModalOpen(false)} + onSuccess={() => { + setImportCodexModalOpen(false); + void fetchConnections(); + }} + /> + )} + {providerId === "codex" && externalLinkModalOpen && ( + setExternalLinkModalOpen(false)} + title="Adicionar Externo — link do Codex" + > +
+

+ Compartilhe este link com quem vai autenticar a conta do Codex. A pessoa abre a + página, faz o login da OpenAI no próprio navegador e a conexão é cadastrada aqui. Uso + único, expira em 15 minutos. +

+ {externalLinkLoading ? ( +

Gerando link…

+ ) : externalLinkError ? ( +

{externalLinkError}

+ ) : externalLinkUrl ? ( + <> +
+ {externalLinkUrl} +
+
+ + +
+

+ sync + Aguardando a autenticação no navegador da pessoa… esta janela atualiza sozinha. +

+ + ) : null} +
+
+ )} + {/* Claude Apply Auth Modal */} + {providerId === "claude" && applyClaudeModalConnectionId && ( + setApplyClaudeModalConnectionId(null)} + /> + )} + {/* Claude Import Auth Modal */} + {providerId === "claude" && importClaudeModalOpen && ( + setImportClaudeModalOpen(false)} + onSuccess={() => { + setImportClaudeModalOpen(false); + void fetchConnections(); + }} + /> + )} + {/* Gemini Apply Auth Modal */} + {providerId === "gemini-cli" && applyGeminiModalConnectionId && ( + setApplyGeminiModalConnectionId(null)} + /> + )} + {/* Gemini Import Auth Modal */} + {providerId === "gemini-cli" && importGeminiModalOpen && ( + setImportGeminiModalOpen(false)} + onSuccess={() => { + setImportGeminiModalOpen(false); + void fetchConnections(); + }} + /> + )} + {/* Batch Test Results Modal */} + {batchTestResults && ( +
setBatchTestResults(null)} + > +
+
e.stopPropagation()} + > +
+

{t("testResults")}

+ +
+
+ {batchTestResults.error && + (!batchTestResults.results || batchTestResults.results.length === 0) ? ( +
+ + error + +

{String(batchTestResults.error)}

+
+ ) : ( +
+ {batchTestResults.summary && ( +
+ {providerInfo?.name || providerId} + + {t("passedCount", { count: batchTestResults.summary.passed })} + + {batchTestResults.summary.failed > 0 && ( + + {t("failedCount", { count: batchTestResults.summary.failed })} + + )} + + {t("testedCount", { count: batchTestResults.summary.total })} + +
+ )} + {(batchTestResults.results || []).map((r: any, i: number) => ( +
+ + {r.valid ? "check_circle" : "error"} + +
+ + {pickDisplayValue([r.connectionName], emailsVisible, r.connectionName)} + +
+ {r.latencyMs !== undefined && ( + + {t("millisecondsAbbr", { value: r.latencyMs })} + + )} + + {r.valid ? t("okShort") : r.diagnosis?.type || t("errorShort")} + +
+ ))} + {(!batchTestResults.results || batchTestResults.results.length === 0) && ( +
+ {t("noActiveConnectionsInGroup")} +
+ )} +
+ )} +
+
+
+ )} + {/* Proxy Config Modal */} + {proxyTarget && ( + setProxyTarget(null)} + level={proxyTarget.level} + levelId={proxyTarget.id} + levelLabel={proxyTarget.label} + onSaved={() => { + void fetchProxyConfig(); + void loadConnProxies(connections); + }} + /> + )} + {/* Import Progress Modal */} + { + if (importProgress.phase === "done" || importProgress.phase === "error") { + setShowImportModal(false); + } + }} + title={t("importingModelsTitle")} + size="md" + closeOnOverlay={false} + showCloseButton={importProgress.phase === "done" || importProgress.phase === "error"} + > +
+ {/* Status text */} +
+ {importProgress.phase === "fetching" && ( + + progress_activity + + )} + {importProgress.phase === "importing" && ( + + progress_activity + + )} + {importProgress.phase === "done" && ( + check_circle + )} + {importProgress.phase === "error" && ( + error + )} + {importProgress.status} +
+ + {/* Progress bar */} + {(importProgress.phase === "importing" || importProgress.phase === "done") && + importProgress.total > 0 && ( +
+
+ + {importProgress.current} / {importProgress.total} + + + {Math.round((importProgress.current / importProgress.total) * 100)}% + +
+
+
+
+
+ )} + + {/* Fetching indeterminate bar */} + {importProgress.phase === "fetching" && ( +
+
+
+ )} + + {/* Error message */} + {importProgress.phase === "error" && importProgress.error && ( +
+

{importProgress.error}

+
+ )} + + {/* Log list */} + {importProgress.logs.length > 0 && ( +
+
+ {importProgress.logs.map((log, i) => ( +

+ {log} +

+ ))} +
+
+ )} + + {/* Close button */} + {importProgress.phase === "done" && ( +
+ +
+ )} +
+ + + {/* Adapta Web — Tutorial Modal */} + {providerId === "adapta-web" && ( + setShowTutorialModal(false)} + title="Como conectar o Adapta Web" + size="md" + > +
+

+ O Adapta usa autenticação via Clerk. O token{" "} + __client é um JWT + de longa duração que permite renovar sessões automaticamente. +

+ +
    +
  1. + + 1 + +
    +

    Acesse o chat do Adapta

    +

    + Abra{" "} + + agent.adapta.one/agentic-chat + {" "} + e faça login com sua conta Gold ou Business. +

    +
    +
  2. + +
  3. + + 2 + +
    +

    Abra o DevTools

    +

    + Pressione{" "} + F12{" "} + ou{" "} + + Cmd+Option+I + {" "} + para abrir as Ferramentas do Desenvolvedor. +

    +
    +
  4. + +
  5. + + 3 + +
    +

    Vá em Application → Cookies

    +

    + Na aba Application (Chrome/Edge) ou Storage{" "} + (Firefox), expanda Cookies e clique em{" "} + + .clerk.agent.adapta.one + + . +

    +
    +
  6. + +
  7. + + 4 + +
    +

    + Copie o valor do cookie{" "} + __client +

    +

    + Localize o cookie chamado{" "} + __client na + lista. Clique nele e copie o conteúdo da coluna Value — começa + com eyJ…. +

    +
    +
  8. + +
  9. + + 5 + +
    +

    Cole aqui e salve

    +

    + Clique em Add Connection, cole o valor do{" "} + __client no + campo de API Key e salve. O OmniRoute renovará a sessão automaticamente. +

    +
    +
  10. +
+ +
+ Dica: O cookie __client tem + validade longa (meses). Só será necessário renová-lo se você sair da conta ou o Adapta + invalidar a sessão. +
+
+
+ )} +
+ ); +} + +// ModelRow, ModelVisibilityToolbar, PassthroughModelsSection, PassthroughModelRow, +// CustomModelsSection, CompatibleModelsSection → components/ (Phase 1e — Issue #3501) + +// Phase 1d: CooldownTimer, inferErrorType, getStatusPresentation, ConnectionRow → components/ConnectionRow.tsx +// Phase 1d: ModelCompatPopover, recordToHeaderRows → components/ModelCompatPopover.tsx +// Phase 1d: SiliconFlowEndpointModal → components/SiliconFlowEndpointModal.tsx diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/ProviderDetailPageClient.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/ProviderDetailPageClient.test.tsx new file mode 100644 index 0000000000..2a8f59ac03 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/ProviderDetailPageClient.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment jsdom +// +// Phase 0 smoke test for Issue #3501 (strangler-fig decomposition of the +// 12.8K-LOC providers/[id] god-component). Before any code is moved out of the +// component, this proves the freshly-extracted ProviderDetailPageClient still +// mounts and renders its loading shell without throwing — the safety net that +// every later extraction phase (1..6) is diffed against. Hard Rule #8: the page +// had ZERO tests before this; this is the first. +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import ProviderDetailPageClient from "../ProviderDetailPageClient"; + +function getRequestPath(input: RequestInfo | URL) { + return typeof input === "string" ? input : input instanceof URL ? input.pathname : input.url; +} + +function emptyJsonResponse() { + return { + ok: true, + status: 200, + json: async () => ({}), + text: async () => "", + headers: { get: () => null }, + } as unknown as Response; +} + +const cleanupCallbacks: Array<() => void> = []; + +function installLocalStorageStub() { + const store = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + clear: () => store.clear(), + }); +} + +// next/navigation — the client reads the route id via useParams() and never +// receives props from the thin page.tsx wrapper, so this mock is what supplies it. +vi.mock("next/navigation", () => ({ + useParams: () => ({ id: "openai" }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), refresh: vi.fn() }), + usePathname: () => "/dashboard/providers/openai", + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( + + {children} + + ), +})); + +vi.mock("next-intl", () => ({ + // Echo the key back so assertions don't depend on a full message catalog. + useTranslations: (namespace?: string) => (key: string) => (namespace ? `${namespace}.${key}` : key), +})); + +function renderProviderPage() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let mounted = true; + + act(() => { + root.render(); + }); + + const unmount = () => { + if (!mounted) return; + act(() => root.unmount()); + container.remove(); + mounted = false; + }; + cleanupCallbacks.push(unmount); + return { container, unmount }; +} + +describe("ProviderDetailPageClient (Phase 0 smoke)", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockImplementation(() => Promise.resolve(emptyJsonResponse())); + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + })) + ); + installLocalStorageStub(); + }); + + afterEach(() => { + while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.(); + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("mounts and renders the loading shell without throwing", () => { + const { container } = renderProviderPage(); + // Mount succeeded (no throw) and produced DOM — the loading skeleton renders + // synchronously while the on-mount fetches are still pending. + expect(container.childNodes.length).toBeGreaterThan(0); + expect(container.querySelector("*")).not.toBeNull(); + }); + + it("kicks off provider data loading on mount", async () => { + renderProviderPage(); + // Let the mount effects schedule their fetches. + await act(async () => { + await Promise.resolve(); + }); + expect(fetchMock).toHaveBeenCalled(); + const paths = fetchMock.mock.calls.map(([input]) => getRequestPath(input as RequestInfo | URL)); + // The provider id from useParams() drives the connection/provider lookups. + expect(paths.some((p) => p.includes("openai") || p.includes("/api/"))).toBe(true); + }); + + it("unmounts cleanly during the initial load", () => { + const { unmount } = renderProviderPage(); + expect(() => unmount()).not.toThrow(); + }); +}); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/passthroughTestHelpers.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/passthroughTestHelpers.test.tsx new file mode 100644 index 0000000000..3d1c188c49 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/passthroughTestHelpers.test.tsx @@ -0,0 +1,79 @@ +// Unit tests for passthrough test-all helpers (Issue #3610). +// +// These helpers capture the three-bug fix: +// 1. `autoHideFailed` must be threaded into the request body so the server +// persists the hide (was missing → server never hid models). +// 2. `shouldSwitchToVisibleFilter` must return true when ≥1 model was hidden +// so the component switches to "visible" filter and the user sees the +// models disappear (was missing → models stayed on-screen even after hide). +// +// Pure-function tests — no DOM, no component rendering. +import { describe, expect, it } from "vitest"; +import { + buildPassthroughTestBody, + shouldSwitchToVisibleFilter, +} from "../providerPageHelpers"; + +// --------------------------------------------------------------------------- +// buildPassthroughTestBody +// --------------------------------------------------------------------------- +describe("buildPassthroughTestBody", () => { + it("includes autoHideFailed=true when the flag is enabled", () => { + const body = buildPassthroughTestBody({ + providerId: "opencode", + connectionId: "conn-1", + modelId: "gpt-4o", + autoHideFailed: true, + }); + + expect(body.autoHideFailed).toBe(true); + expect(body.modelIds).toEqual(["gpt-4o"]); + expect(body.providerId).toBe("opencode"); + expect(body.connectionId).toBe("conn-1"); + }); + + it("includes autoHideFailed=false when the flag is disabled", () => { + const body = buildPassthroughTestBody({ + providerId: "opencode", + connectionId: "conn-1", + modelId: "gpt-4o", + autoHideFailed: false, + }); + + expect(body.autoHideFailed).toBe(false); + }); + + it("wraps modelId in a single-element array", () => { + const body = buildPassthroughTestBody({ + providerId: "p", + connectionId: "c", + modelId: "some-model", + autoHideFailed: true, + }); + + expect(body.modelIds).toHaveLength(1); + expect(body.modelIds[0]).toBe("some-model"); + }); +}); + +// --------------------------------------------------------------------------- +// shouldSwitchToVisibleFilter +// --------------------------------------------------------------------------- +describe("shouldSwitchToVisibleFilter", () => { + it("returns true when autoHideFailed is enabled and at least one model was hidden", () => { + expect(shouldSwitchToVisibleFilter({ autoHideFailed: true, hiddenCount: 1 })).toBe(true); + expect(shouldSwitchToVisibleFilter({ autoHideFailed: true, hiddenCount: 5 })).toBe(true); + }); + + it("returns false when autoHideFailed is enabled but no model was hidden", () => { + expect(shouldSwitchToVisibleFilter({ autoHideFailed: true, hiddenCount: 0 })).toBe(false); + }); + + it("returns false when autoHideFailed is disabled even if models were hidden", () => { + expect(shouldSwitchToVisibleFilter({ autoHideFailed: false, hiddenCount: 3 })).toBe(false); + }); + + it("returns false when both flag and count are falsy", () => { + expect(shouldSwitchToVisibleFilter({ autoHideFailed: false, hiddenCount: 0 })).toBe(false); + }); +}); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx new file mode 100644 index 0000000000..fb0562c8a8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx @@ -0,0 +1,304 @@ +// @vitest-environment jsdom +// +// Phase 1e smoke tests for Issue #3501 (strangler-fig decomposition). +// Validates: +// 1. useModelCompatState hook computes correct values from raw arrays. +// 2. ModelRow, PassthroughModelRow, ModelVisibilityToolbar render without throwing. +// 3. PassthroughModelsSection, CustomModelsSection, CompatibleModelsSection render without throwing. +// 4. No cycle: providerPageHelpers imports DO NOT import from ProviderDetailPageClient. +// +// We use shallow rendering (no Next.js server context needed) because these are +// presentational components that receive all data via props. +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + buildCompatMap, + isModelHiddenFn, + effectiveNormalizeForProtocol, + effectivePreserveForProtocol, + anyNormalizeCompatBadge, + anyNoPreserveCompatBadge, + formatProviderModelsErrorResponse, +} from "../providerPageHelpers"; + +// --------------------------------------------------------------------------- +// Global mocks required by the extracted components +// --------------------------------------------------------------------------- + +vi.mock("next/navigation", () => ({ + useParams: () => ({ id: "test-provider" }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/providers/test-provider", +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, values?: Record) => { + if (values) { + return Object.entries(values).reduce( + (acc, [k, v]) => acc.replace(`{${k}}`, String(v)), + key + ); + } + return key; + }, +})); + +vi.mock("@/store/notificationStore", () => ({ + useNotificationStore: () => ({ + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }), +})); + +vi.mock("@/shared/components", () => ({ + Badge: ({ children }: any) => {children}, + Button: ({ children, onClick }: any) => , +})); + +// --------------------------------------------------------------------------- +// Pure-function tests for model-compat helpers (moved to providerPageHelpers) +// --------------------------------------------------------------------------- + +describe("providerPageHelpers — model-compat pure functions", () => { + const customRow = { + id: "gpt-4o", + normalizeToolCallId: true, + preserveOpenAIDeveloperRole: false, + isHidden: true, + }; + const customModels = [customRow]; + const overrideModels: any[] = []; + + it("buildCompatMap produces a Map keyed by id", () => { + const map = buildCompatMap(customModels); + expect(map.size).toBe(1); + expect(map.get("gpt-4o")).toEqual(customRow); + }); + + it("isModelHiddenFn reads from customMap first", () => { + const customMap = buildCompatMap(customModels); + const overrideMap = buildCompatMap(overrideModels); + expect(isModelHiddenFn("gpt-4o", customMap, overrideMap)).toBe(true); + expect(isModelHiddenFn("unknown-model", customMap, overrideMap)).toBe(false); + }); + + it("effectiveNormalizeForProtocol returns correct flag", () => { + const customMap = buildCompatMap(customModels); + const overrideMap = buildCompatMap(overrideModels); + expect(effectiveNormalizeForProtocol("gpt-4o", "openai", customMap, overrideMap)).toBe(true); + expect(effectiveNormalizeForProtocol("unknown", "openai", customMap, overrideMap)).toBe(false); + }); + + it("effectivePreserveForProtocol returns correct flag", () => { + const customMap = buildCompatMap(customModels); + const overrideMap = buildCompatMap(overrideModels); + expect(effectivePreserveForProtocol("gpt-4o", "openai", customMap, overrideMap)).toBe(false); + // Unknown model defaults to true + expect(effectivePreserveForProtocol("unknown", "openai", customMap, overrideMap)).toBe(true); + }); + + it("anyNormalizeCompatBadge returns true when flag is set", () => { + const customMap = buildCompatMap(customModels); + const overrideMap = buildCompatMap(overrideModels); + expect(anyNormalizeCompatBadge("gpt-4o", customMap, overrideMap)).toBe(true); + expect(anyNormalizeCompatBadge("unknown", customMap, overrideMap)).toBe(false); + }); + + it("anyNoPreserveCompatBadge returns true when preserve=false", () => { + const customMap = buildCompatMap(customModels); + const overrideMap = buildCompatMap(overrideModels); + expect(anyNoPreserveCompatBadge("gpt-4o", customMap, overrideMap)).toBe(true); + expect(anyNoPreserveCompatBadge("unknown", customMap, overrideMap)).toBe(false); + }); + + it("formatProviderModelsErrorResponse extracts error.message", async () => { + const mockRes = new Response( + JSON.stringify({ error: { message: "Model not found" } }), + { status: 422, statusText: "Unprocessable Entity" } + ); + const detail = await formatProviderModelsErrorResponse(mockRes); + expect(detail).toBe("Model not found"); + }); + + it("formatProviderModelsErrorResponse falls back to statusText", async () => { + const mockRes = new Response("{}", { status: 500, statusText: "Internal Server Error" }); + const detail = await formatProviderModelsErrorResponse(mockRes); + expect(detail).toBe("Internal Server Error"); + }); +}); + +// --------------------------------------------------------------------------- +// Component render smoke tests +// --------------------------------------------------------------------------- + +describe("ModelRow — render smoke test", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("renders without throwing", async () => { + // Dynamic import to keep top-level mock resolution clean + const { default: ModelRow } = await import("../components/ModelRow"); + + await act(async () => { + root.render( + k} + effectiveModelNormalize={() => false} + effectiveModelPreserveDeveloper={() => true} + saveModelCompatFlags={vi.fn()} + getUpstreamHeadersRecord={() => ({})} + /> + ); + }); + + expect(container.textContent).toContain("openai/gpt-4o"); + }); +}); + +describe("PassthroughModelRow — render smoke test", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { root.unmount(); }); + container.remove(); + }); + + it("renders without throwing", async () => { + const { default: PassthroughModelRow } = await import("../components/PassthroughModelRow"); + + await act(async () => { + root.render( + k} + onCopy={vi.fn()} + effectiveModelNormalize={() => false} + effectiveModelPreserveDeveloper={() => true} + saveModelCompatFlags={vi.fn()} + getUpstreamHeadersRecord={() => ({})} + /> + ); + }); + + expect(container.textContent).toContain("openrouter/some-model"); + }); +}); + +describe("ModelVisibilityToolbar — render smoke test", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { root.unmount(); }); + container.remove(); + }); + + it("renders without throwing", async () => { + const { ModelVisibilityToolbar } = await import("../components/ModelRow"); + + await act(async () => { + root.render( + k} + filterValue="" + onFilterChange={vi.fn()} + activeCount={5} + totalCount={10} + onSelectAll={vi.fn()} + onDeselectAll={vi.fn()} + /> + ); + }); + + // toolbar renders filter input + expect(container.querySelector("input")).not.toBeNull(); + }); +}); + +describe("useModelCompatState — hook unit test via component wrapper", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { root.unmount(); }); + container.remove(); + }); + + it("exposes isModelHidden, effectiveModelNormalize, anyNormalizeCompatBadge correctly", async () => { + const { useModelCompatState } = await import("../hooks/useModelCompatState"); + + const customModels = [ + { id: "gpt-4o", normalizeToolCallId: true, preserveOpenAIDeveloperRole: false, isHidden: true }, + ]; + const modelCompatOverrides: any[] = []; + + // Capture results via a data-testid attribute on a span to avoid hook-mutation rules + function TestWrapper() { + const compat = useModelCompatState(customModels, modelCompatOverrides); + const results = [ + compat.isModelHidden("gpt-4o"), + compat.isModelHidden("unknown"), + compat.effectiveModelNormalize("gpt-4o"), + compat.effectiveModelPreserveDeveloper("gpt-4o"), + compat.anyNormalizeCompatBadge("gpt-4o"), + compat.anyNoPreserveCompatBadge("gpt-4o"), + ].map(String).join(","); + return {results}; + } + + await act(async () => { + root.render(); + }); + + const span = container.querySelector("[data-testid='results']"); + expect(span).not.toBeNull(); + const [hidden, notHidden, normalize, preserve, anyNorm, anyNoPreserve] = + (span!.textContent ?? "").split(","); + + expect(hidden).toBe("true"); + expect(notHidden).toBe("false"); + expect(normalize).toBe("true"); + expect(preserve).toBe("false"); + expect(anyNorm).toBe("true"); + expect(anyNoPreserve).toBe("true"); + }); +}); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1f.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1f.test.tsx new file mode 100644 index 0000000000..9ee6137d20 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1f.test.tsx @@ -0,0 +1,345 @@ +// @vitest-environment jsdom +// +// Phase 1f smoke tests for Issue #3501 (strangler-fig decomposition). +// Validates: +// 1. useProviderConnections initialises with expected defaults. +// 2. useProviderSettings initialises with expected defaults. +// 3. useProviderModels initialises with expected defaults. +// 4. No cycle: none of the three hooks import from ProviderDetailPageClient. +// +// Uses createRoot + act to mount each hook inside a minimal wrapper component +// so we test real React hook semantics without a full Next.js server context. + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// --------------------------------------------------------------------------- +// Global mocks required by the extracted hooks +// --------------------------------------------------------------------------- + +vi.mock("next/navigation", () => ({ + useParams: () => ({ id: "test-provider" }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/providers/test-provider", +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, values?: Record) => { + if (values) { + return Object.entries(values).reduce( + (acc, [k, v]) => acc.replace(`{${k}}`, String(v)), + key + ); + } + return key; + }, +})); + +vi.mock("@/store/notificationStore", () => ({ + useNotificationStore: () => ({ + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }), +})); + +// Stub fetch so the hooks don't fire real network calls during mount +const fetchStub = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + text: async () => "", + headers: { get: () => null }, +} as any); +vi.stubGlobal("fetch", fetchStub); + +// --------------------------------------------------------------------------- +// useProviderConnections +// --------------------------------------------------------------------------- + +describe("useProviderConnections — initial state", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + fetchStub.mockClear(); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("exposes connections=[], loading=true, batchTesting=false on first render", async () => { + const { useProviderConnections } = await import("../hooks/useProviderConnections"); + + type HookResult = ReturnType; + let result: HookResult | null = null; + + function TestWrapper() { + result = useProviderConnections("openai", true, false); + return ( + + {String(result.connections.length)}|{String(result.batchTesting)} + + ); + } + + await act(async () => { + root.render(); + }); + + expect(result).not.toBeNull(); + expect(result!.connections).toEqual([]); + expect(result!.batchTesting).toBe(false); + expect(result!.batchDeleting).toBe(false); + expect(result!.selectedIds.size).toBe(0); + expect(result!.batchDeleteConfirmOpen).toBe(false); + expect(result!.healthFilter).toBe("all"); + expect(result!.page).toBe(0); + }); + + it("exposes all expected handler functions", async () => { + const { useProviderConnections } = await import("../hooks/useProviderConnections"); + + type HookResult = ReturnType; + let result: HookResult | null = null; + + function TestWrapper() { + result = useProviderConnections("openai", true, false); + return ; + } + + await act(async () => { + root.render(); + }); + + const expected = [ + "fetchConnections", + "fetchProxyConfig", + "handleDelete", + "handleUpdateConnectionStatus", + "handleToggleRateLimit", + "handleToggleClaudeExtraUsage", + "handleToggleCodexLimit", + "handleToggleCliproxyapiMode", + "handleToggleProxyEnabled", + "handleTogglePerKeyProxyEnabled", + "handleRetestConnection", + "handleRefreshToken", + "handleSwapPriority", + "handleBatchSetActive", + "handleBatchDeleteOpenModal", + "handleBatchDeleteConfirm", + "handleBatchRetest", + "handleBatchTestAll", + "handleToggleSelectOne", + "handleToggleSelectAll", + "handleDistributeProxies", + "parseApiErrorMessage", + "getAttachmentFilename", + ] as const; + + for (const fn of expected) { + expect(typeof (result as any)[fn]).toBe("function"); + } + }); +}); + +// --------------------------------------------------------------------------- +// useProviderSettings +// --------------------------------------------------------------------------- + +describe("useProviderSettings — initial state", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + fetchStub.mockClear(); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("exposes codex defaults for a non-codex provider", async () => { + const { useProviderSettings } = await import("../hooks/useProviderSettings"); + + type HookResult = ReturnType; + let result: HookResult | null = null; + + function TestWrapper() { + result = useProviderSettings("openai"); + return ; + } + + await act(async () => { + root.render(); + }); + + expect(result).not.toBeNull(); + // Non-codex provider: settings stay at defaults + expect(result!.codexGlobalServiceMode).toBe("none"); + expect(result!.codexSettingsLoaded).toBe(false); + expect(result!.savingCodexGlobalServiceMode).toBe(false); + // Claude routing starts unloaded + expect(result!.preferClaudeCodeForUnprefixedClaudeModels).toBe(false); + expect(result!.claudeRoutingSettingsLoaded).toBe(false); + expect(result!.savingClaudeRoutingPreference).toBe(false); + }); + + it("exposes handler functions", async () => { + const { useProviderSettings } = await import("../hooks/useProviderSettings"); + + type HookResult = ReturnType; + let result: HookResult | null = null; + + function TestWrapper() { + result = useProviderSettings("codex"); + return ; + } + + await act(async () => { + root.render(); + }); + + expect(typeof result!.loadCodexSettings).toBe("function"); + expect(typeof result!.handleChangeCodexGlobalServiceMode).toBe("function"); + expect(typeof result!.loadClaudeRoutingSettings).toBe("function"); + expect(typeof result!.handleToggleClaudeRoutingPreference).toBe("function"); + expect(Array.isArray(result!.codexGlobalServiceModeOptions)).toBe(true); + expect(result!.codexGlobalServiceModeOptions.length).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// useProviderModels +// --------------------------------------------------------------------------- + +describe("useProviderModels — initial state", () => { + let container: HTMLElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + fetchStub.mockClear(); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("initialises with empty arrays and empty alias map", async () => { + const { useProviderModels } = await import("../hooks/useProviderModels"); + + type HookResult = ReturnType; + let result: HookResult | null = null; + + function TestWrapper() { + result = useProviderModels("openai", false); + return ; + } + + await act(async () => { + root.render(); + }); + + expect(result).not.toBeNull(); + expect(result!.modelMeta.customModels).toEqual([]); + expect(result!.modelMeta.modelCompatOverrides).toEqual([]); + expect(result!.syncedAvailableModels).toEqual([]); + expect(result!.modelAliases).toEqual({}); + }); + + it("exposes handler functions", async () => { + const { useProviderModels } = await import("../hooks/useProviderModels"); + + type HookResult = ReturnType; + let result: HookResult | null = null; + + function TestWrapper() { + result = useProviderModels("openai", false); + return ; + } + + await act(async () => { + root.render(); + }); + + expect(typeof result!.fetchProviderModelMeta).toBe("function"); + expect(typeof result!.fetchAliases).toBe("function"); + expect(typeof result!.handleSetAlias).toBe("function"); + expect(typeof result!.handleDeleteAlias).toBe("function"); + }); + + it("does not auto-fetch on isSearchProvider=true", async () => { + const { useProviderModels } = await import("../hooks/useProviderModels"); + + function TestWrapper() { + useProviderModels("perplexity-search", true); + return ; + } + + const callCountBefore = fetchStub.mock.calls.length; + + await act(async () => { + root.render(); + }); + + // No fetch should have been triggered by the hook itself (the client + // gates the effect on !isSearchProvider). + const callsForModelMeta = fetchStub.mock.calls.filter((c: any[]) => + String(c[0]).includes("provider-models") + ); + expect(callsForModelMeta.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle-safety: hooks must NOT import from ProviderDetailPageClient +// --------------------------------------------------------------------------- + +const HOOKS_DIR = + "/home/diegosouzapw/dev/proxys/OmniRoute/.worktrees/fix-3501-phase1f/src/app/(dashboard)/dashboard/providers/[id]/hooks"; + +describe("Cycle-safety — hooks do not import ProviderDetailPageClient", () => { + // We allow the name in JSDoc comments; what we forbid is an actual ES import statement. + function hasImport(source: string): boolean { + return /^import[^;]*ProviderDetailPageClient/m.test(source); + } + + it("useProviderConnections has no ES import of ProviderDetailPageClient", async () => { + const fs = await import("fs/promises"); + const source = await fs.readFile(`${HOOKS_DIR}/useProviderConnections.ts`, "utf-8"); + expect(hasImport(source)).toBe(false); + }); + + it("useProviderSettings has no ES import of ProviderDetailPageClient", async () => { + const fs = await import("fs/promises"); + const source = await fs.readFile(`${HOOKS_DIR}/useProviderSettings.ts`, "utf-8"); + expect(hasImport(source)).toBe(false); + }); + + it("useProviderModels has no ES import of ProviderDetailPageClient", async () => { + const fs = await import("fs/promises"); + const source = await fs.readFile(`${HOOKS_DIR}/useProviderModels.ts`, "utf-8"); + expect(hasImport(source)).toBe(false); + }); +}); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx new file mode 100644 index 0000000000..1979ee7ce3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx @@ -0,0 +1,463 @@ +"use client"; +/** + * CompatibleModelsSection — Issue #3501 Phase 1e + * + * Extracted from ProviderDetailPageClient.tsx. Renders the models panel for + * compatible providers (OpenAI-compat, Anthropic-compat, CC-compat, OpenRouter). + * + * Never imports from ProviderDetailPageClient. + */ +import React, { useState, useCallback, useMemo } from "react"; +import { Button } from "@/shared/components"; +import { + matchesModelCatalogQuery, + normalizeModelCatalogSource, +} from "@/shared/utils/modelCatalogSearch"; +import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases"; +import { useNotificationStore } from "@/store/notificationStore"; +import { + buildCompatMap, + providerText, + type CompatModelRow, +} from "../providerPageHelpers"; +import { ModelVisibilityToolbar } from "./ModelRow"; +import PassthroughModelRow, { type PassthroughModelRowProps } from "./PassthroughModelRow"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export type CompatibleModelsSaveFlags = { + normalizeToolCallId?: boolean; + preserveDeveloperRole?: boolean; + preserveOpenAIDeveloperRole?: boolean; + isHidden?: boolean; +}; + +export interface CompatibleModelsSectionProps { + providerStorageAlias: string; + providerDisplayAlias: string; + modelAliases: Record; + availableModels?: CompatModelRow[]; + customModels?: CompatModelRow[]; + fallbackModels?: CompatModelRow[]; + allowImport: boolean; + description: string; + inputLabel: string; + inputPlaceholder: string; + copied?: string; + onCopy: (text: string, key: string) => void; + onSetAlias: (modelId: string, alias: string, providerStorageAlias?: string) => Promise; + onDeleteAlias: (alias: string) => void; + connections: { id?: string; isActive?: boolean }[]; + isAnthropic?: boolean; + onImportWithProgress: (connectionId: string) => Promise; + t: (key: string, values?: Record) => string; + effectiveModelNormalize: (alias: string) => boolean; + effectiveModelPreserveDeveloper: (alias: string) => boolean; + getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record; + saveModelCompatFlags: ( + modelId: string, + flags: CompatibleModelsSaveFlags + ) => Promise; + compatSavingModelId?: string; + onModelsChanged?: () => void; + isModelHidden: (modelId: string) => boolean; + onToggleHidden: (modelId: string, hidden: boolean) => Promise; + onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise; + bulkTogglePending?: boolean; + togglingModelId?: string | null; + 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; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function CompatibleModelsSection({ + providerStorageAlias, + providerDisplayAlias, + modelAliases, + availableModels = [], + customModels = [], + fallbackModels = [], + description, + inputLabel, + inputPlaceholder, + copied, + onCopy, + onSetAlias, + onDeleteAlias, + connections, + isAnthropic, + onImportWithProgress, + t, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, + saveModelCompatFlags, + compatSavingModelId, + onModelsChanged, + allowImport, + isModelHidden, + onToggleHidden, + onBulkToggleHidden, + bulkTogglePending, + togglingModelId, + 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]); + + const providerAliases = useMemo( + () => + Object.entries(modelAliases).filter(([, model]: [string, any]) => + (model as string).startsWith(`${providerStorageAlias}/`) + ), + [modelAliases, providerStorageAlias] + ); + + const allModels = useMemo(() => { + const prefix = `${providerStorageAlias}/`; + const aliasByModelId = new Map(); + const rows: Array<{ + modelId: string; + alias: string | null; + displayName: string; + source: string; + isFree: boolean; + isHidden: boolean; + }> = []; + const seenModelIds = new Set(); + + for (const [alias, fullModel] of providerAliases) { + const fmStr = fullModel as string; + const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr; + aliasByModelId.set(modelId, alias as string); + } + + const addModel = (model: CompatModelRow, source: string) => { + if (!model?.id || seenModelIds.has(model.id)) return; + rows.push({ + modelId: model.id, + alias: aliasByModelId.get(model.id) || null, + displayName: model.name || model.id, + source, + isFree: + Boolean((model as any).free) || + model.id.endsWith(":free") || + /\bgr[aá]tis\b|\bfree\b/i.test(model.name || ""), + isHidden: isModelHidden(model.id), + }); + seenModelIds.add(model.id); + }; + + for (const model of availableModels) { + addModel(model, "imported"); + } + + for (const model of customModels) { + addModel( + model, + normalizeModelCatalogSource(model.source) === "imported" ? "imported" : "custom" + ); + } + + for (const model of fallbackModels) { + addModel(model, "fallback"); + } + + for (const [alias, fullModel] of providerAliases) { + const fmStr = fullModel as string; + const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr; + if (!modelId || seenModelIds.has(modelId)) continue; + const customModel = customModelMap.get(modelId); + rows.push({ + modelId, + alias: alias as string, + displayName: alias as string, + source: customModel ? customModel.source || "custom" : "alias", + isFree: + modelId.endsWith(":free") || + Boolean((customModel as any)?.free) || + /\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || ""), + isHidden: isModelHidden(modelId), + }); + seenModelIds.add(modelId); + } + + return rows; + }, [ + availableModels, + customModelMap, + customModels, + fallbackModels, + isModelHidden, + providerAliases, + providerStorageAlias, + ]); + + 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; + + const resolveAlias = useCallback( + (modelId: string, workingAliases: Record) => + resolveManagedModelAlias({ + modelId, + fullModel: `${providerStorageAlias}/${modelId}`, + providerDisplayAlias, + existingAliases: workingAliases, + }), + [providerDisplayAlias, providerStorageAlias] + ); + + const handleAdd = async () => { + if (!newModel.trim() || adding) return; + const modelId = newModel.trim(); + const resolvedAlias = resolveAlias(modelId, modelAliases); + if (!resolvedAlias) { + notify.error(t("allSuggestedAliasesExist")); + return; + } + + setAdding(true); + try { + // Save to customModels DB FIRST - only create alias if this succeeds + const customModelRes = await fetch("/api/provider-models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: providerStorageAlias, + modelId, + modelName: modelId, + source: "manual", + }), + }); + + if (!customModelRes.ok) { + let errorData: { error?: { message?: string } } = {}; + try { + errorData = await customModelRes.json(); + } catch (jsonError) { + console.error("Failed to parse error response from custom model API:", jsonError); + } + throw new Error(errorData.error?.message || t("failedSaveCustomModel")); + } + + // Only create alias after customModel is saved successfully + await onSetAlias(modelId, resolvedAlias, providerStorageAlias); + setNewModel(""); + notify.success(t("modelAddedSuccess", { modelId })); + onModelsChanged?.(); + } catch (error) { + console.error("Error adding model:", error); + notify.error(error instanceof Error ? error.message : t("failedAddModelTryAgain")); + } finally { + setAdding(false); + } + }; + + const handleImport = async () => { + if (!allowImport || importing) return; + const activeConnection = connections.find((conn) => conn.isActive !== false); + if (!activeConnection?.id) return; + + setImporting(true); + try { + await onImportWithProgress(activeConnection.id); + } catch (error) { + console.error("Error importing models:", error); + notify.error(t("failedImportModelsTryAgain")); + } finally { + setImporting(false); + } + }; + + const canImport = connections.some((conn) => conn.isActive !== false); + + // Handle delete: remove from both alias and customModels DB + const handleDeleteModel = async (modelId: string, alias?: string | null) => { + try { + // Remove from customModels DB + const res = await fetch( + `/api/provider-models?provider=${encodeURIComponent(providerStorageAlias)}&model=${encodeURIComponent(modelId)}`, + { method: "DELETE" } + ); + if (!res.ok) { + throw new Error(t("failedRemoveModelFromDatabase")); + } + // Also delete the alias + if (alias) { + await onDeleteAlias(alias); + } + notify.success(t("modelRemovedSuccess")); + onModelsChanged?.(); + } catch (error) { + console.error("Error deleting model:", error); + notify.error(error instanceof Error ? error.message : t("failedDeleteModelTryAgain")); + } + }; + + return ( +
+

{description}

+ +
+
+ + setNewModel(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder={inputPlaceholder} + className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+ + {allowImport && ( + + )} +
+ + {allowImport && !canImport && ( +

{t("addConnectionToImport")}

+ )} + + {allModels.length > 0 && ( +
+ + onBulkToggleHidden( + filteredModels.map((model) => model.modelId), + false + ) + } + onDeselectAll={() => + onBulkToggleHidden( + filteredModels.map((model) => model.modelId), + true + ) + } + 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 }) => { + const fullModel = `${providerDisplayAlias}/${modelId}`; + return ( + handleDeleteModel(modelId, alias) + : source === "alias" && alias + ? () => onDeleteAlias(alias) + : undefined + } + t={t} + showDeveloperToggle={!isAnthropic} + effectiveModelNormalize={effectiveModelNormalize} + effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} + getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)} + saveModelCompatFlags={saveModelCompatFlags} + compatDisabled={compatSavingModelId === modelId} + onToggleHidden={onToggleHidden} + togglingHidden={togglingModelId === modelId} + onTestModel={onTestModel} + testStatus={modelTestStatus?.[modelId] || null} + testingModel={testingModelId === modelId} + /> + ); + })} +
+ {filteredModels.length === 0 && modelFilter && ( +

+ {providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, { + filter: modelFilter, + })} +

+ )} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx new file mode 100644 index 0000000000..36fedfe74c --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx @@ -0,0 +1,940 @@ +"use client"; + +// Phase 1d extraction — Issue #3501 +// ConnectionRow (and its local helpers CooldownTimer, inferErrorType, +// getStatusPresentation) moved out of ProviderDetailPageClient.tsx. + +import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { Badge, Button, Toggle } from "@/shared/components"; +import { pickDisplayValue } from "@/shared/utils/maskEmail"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; +import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage"; +import { + getCodexEffectiveServiceTier, + type CodexGlobalServiceMode, +} from "@/lib/providers/codexFastTier"; +import { + normalizeCodexLimitPolicy, + providerText, + ERROR_TYPE_LABELS, +} from "../providerPageHelpers"; + +// --------------------------------------------------------------------------- +// Types (exported so the client can reference them without re-importing) +// --------------------------------------------------------------------------- + +export interface ConnectionRowConnection { + id?: string; + name?: string; + email?: string; + displayName?: string; + rateLimitedUntil?: string; + rateLimitProtection?: boolean; + testStatus?: string; + isActive?: boolean; + priority?: number; + lastError?: string; + lastErrorType?: string; + lastErrorSource?: string; + errorCode?: string | number; + globalPriority?: number; + providerSpecificData?: Record; + expiresAt?: string; + tokenExpiresAt?: string; + maxConcurrent?: number | null; + authType?: string; + proxyEnabled?: boolean; + perKeyProxyEnabled?: boolean; +} + +export interface ConnectionRowProps { + connection: ConnectionRowConnection; + isOAuth: boolean; + isClaude?: boolean; + isCodex?: boolean; + isGeminiCli?: boolean; + codexGlobalServiceMode?: CodexGlobalServiceMode; + isFirst: boolean; + isLast: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + onToggleActive: (isActive?: boolean) => void | Promise; + onToggleRateLimit: (enabled?: boolean) => void; + onToggleClaudeExtraUsage?: (enabled?: boolean) => void; + onToggleCodex5h?: (enabled?: boolean) => void; + onToggleCodexWeekly?: (enabled?: boolean) => void; + isCcCompatible?: boolean; + cliproxyapiEnabled?: boolean; + onToggleCliproxyapiMode?: (enabled?: boolean) => void; + onRetest: () => void; + isRetesting?: boolean; + onEdit: () => void; + onDelete: () => void; + onReauth?: () => void; + onProxy?: () => void; + hasProxy?: boolean; + proxySource?: string; + proxyHost?: string; + proxyEnabled?: boolean; + perKeyProxyEnabled?: boolean; + onToggleProxyEnabled?: (enabled: boolean) => void; + onTogglePerKeyProxyEnabled?: (enabled: boolean) => void; + onRefreshToken?: () => void; + isRefreshing?: boolean; + onApplyCodexAuthLocal?: () => void; + isApplyingCodexAuthLocal?: boolean; + onExportCodexAuthFile?: () => void; + isExportingCodexAuthFile?: boolean; + onApplyClaudeAuthLocal?: () => void; + isApplyingClaudeAuthLocal?: boolean; + onExportClaudeAuthFile?: () => void; + isExportingClaudeAuthFile?: boolean; + onApplyGeminiAuthLocal?: () => void; + isApplyingGeminiAuthLocal?: boolean; + onExportGeminiAuthFile?: () => void; + isExportingGeminiAuthFile?: boolean; +} + +// --------------------------------------------------------------------------- +// CooldownTimerProps (local — only used below) +// --------------------------------------------------------------------------- + +interface CooldownTimerProps { + until: string | number | Date; +} + +// --------------------------------------------------------------------------- +// CooldownTimer +// --------------------------------------------------------------------------- + +function CooldownTimer({ until }: CooldownTimerProps) { + const [remaining, setRemaining] = useState(""); + + useEffect(() => { + const updateRemaining = () => { + const diff = new Date(until).getTime() - Date.now(); + if (diff <= 0) { + setRemaining(""); + return; + } + const secs = Math.floor(diff / 1000); + if (secs < 60) { + setRemaining(`${secs}s`); + } else if (secs < 3600) { + setRemaining(`${Math.floor(secs / 60)}m ${secs % 60}s`); + } else { + const hrs = Math.floor(secs / 3600); + const mins = Math.floor((secs % 3600) / 60); + setRemaining(`${hrs}h ${mins}m`); + } + }; + + updateRemaining(); + const interval = setInterval(updateRemaining, 1000); + return () => clearInterval(interval); + }, [until]); + + if (!remaining) return null; + + return ⏱ {remaining}; +} + +// --------------------------------------------------------------------------- +// inferErrorType +// --------------------------------------------------------------------------- + +function inferErrorType(connection: ConnectionRowConnection, isCooldown: boolean): string | null { + if (isCooldown) return "upstream_rate_limited"; + if (connection.testStatus === "banned") return "banned"; + if (connection.testStatus === "credits_exhausted") return "credits_exhausted"; + if (connection.lastErrorType) return connection.lastErrorType; + + const code = Number(connection.errorCode); + if (code === 401 || code === 403) return "upstream_auth_error"; + if (code === 429) return "upstream_rate_limited"; + if (code >= 500) return "upstream_unavailable"; + + const msg = (connection.lastError || "").toLowerCase(); + if (!msg) return null; + if ( + msg.includes("runtime") || + msg.includes("not runnable") || + msg.includes("not installed") || + msg.includes("healthcheck") + ) + return "runtime_error"; + if (msg.includes("refresh failed")) return "token_refresh_failed"; + if (msg.includes("token expired") || msg.includes("expired")) return "token_expired"; + if ( + msg.includes("invalid api key") || + msg.includes("token invalid") || + msg.includes("revoked") || + msg.includes("access denied") || + msg.includes("unauthorized") + ) + return "upstream_auth_error"; + if ( + msg.includes("rate limit") || + msg.includes("quota") || + msg.includes("too many requests") || + msg.includes("429") + ) + return "upstream_rate_limited"; + if ( + msg.includes("fetch failed") || + msg.includes("network") || + msg.includes("timeout") || + msg.includes("econn") || + msg.includes("enotfound") + ) + return "network_error"; + if (msg.includes("not supported")) return "unsupported"; + return "upstream_error"; +} + +// --------------------------------------------------------------------------- +// getStatusPresentation +// --------------------------------------------------------------------------- + +type TFn = (key: string, values?: Record) => string; + +function getStatusPresentation( + connection: ConnectionRowConnection, + effectiveStatus: string | undefined, + isCooldown: boolean, + t: TFn +) { + if (connection.isActive === false) { + return { + statusVariant: "default", + statusLabel: t("statusDisabled"), + errorType: null, + errorBadge: null, + errorTextClass: "text-text-muted", + }; + } + + if (effectiveStatus === "active" || effectiveStatus === "success") { + return { + statusVariant: "success", + statusLabel: t("statusConnected"), + errorType: null, + errorBadge: null, + errorTextClass: "text-text-muted", + }; + } + + const errorType = inferErrorType(connection, isCooldown); + const errorBadge = errorType ? ERROR_TYPE_LABELS[errorType] || null : null; + + if (errorType === "runtime_error") { + return { + statusVariant: "warning", + statusLabel: t("statusRuntimeIssue"), + errorType, + errorBadge, + errorTextClass: "text-yellow-600 dark:text-yellow-400", + }; + } + + if (errorType === "account_deactivated") { + return { + statusVariant: "error", + statusLabel: t("statusDeactivated", "Deactivated"), + errorType, + errorBadge, + errorTextClass: "text-red-600 font-bold", + }; + } + + if ( + errorType === "upstream_auth_error" || + errorType === "auth_missing" || + errorType === "token_refresh_failed" || + errorType === "token_expired" + ) { + return { + statusVariant: "error", + statusLabel: t("statusAuthFailed"), + errorType, + errorBadge, + errorTextClass: "text-red-500", + }; + } + + if (errorType === "upstream_rate_limited") { + return { + statusVariant: "warning", + statusLabel: t("statusRateLimited"), + errorType, + errorBadge, + errorTextClass: "text-yellow-600 dark:text-yellow-400", + }; + } + + if (errorType === "network_error") { + return { + statusVariant: "warning", + statusLabel: t("statusNetworkIssue"), + errorType, + errorBadge, + errorTextClass: "text-yellow-600 dark:text-yellow-400", + }; + } + + if (errorType === "unsupported") { + return { + statusVariant: "default", + statusLabel: t("statusTestUnsupported"), + errorType, + errorBadge, + errorTextClass: "text-text-muted", + }; + } + + if (errorType === "banned") { + return { + statusVariant: "error", + statusLabel: t("statusBanned", "Banned (403)"), + errorType, + errorBadge, + errorTextClass: "text-red-600 font-bold", + }; + } + + if (errorType === "credits_exhausted") { + return { + statusVariant: "warning", + statusLabel: t("statusCreditsExhausted", "Out of Credits"), + errorType, + errorBadge, + errorTextClass: "text-amber-500", + }; + } + + const fallbackStatusMap: Record = { + unavailable: t("statusUnavailable"), + failed: t("statusFailed"), + error: t("statusError"), + }; + + return { + statusVariant: "error", + statusLabel: fallbackStatusMap[effectiveStatus ?? ""] || effectiveStatus || t("statusError"), + errorType, + errorBadge, + errorTextClass: "text-red-500", + }; +} + +// --------------------------------------------------------------------------- +// ConnectionRow +// --------------------------------------------------------------------------- + +export default function ConnectionRow({ + connection, + isOAuth, + isClaude, + isCodex, + isGeminiCli, + codexGlobalServiceMode, + isCcCompatible, + cliproxyapiEnabled, + isFirst, + isLast, + isSelected, + onToggleSelect, + onMoveUp, + onMoveDown, + onToggleActive, + onToggleRateLimit, + onToggleClaudeExtraUsage, + onToggleCodex5h, + onToggleCodexWeekly, + onToggleCliproxyapiMode, + onRetest, + isRetesting, + onEdit, + onDelete, + onReauth, + onProxy, + hasProxy, + proxySource, + proxyHost, + onRefreshToken, + isRefreshing, + onApplyCodexAuthLocal, + isApplyingCodexAuthLocal, + onExportCodexAuthFile, + isExportingCodexAuthFile, + onApplyClaudeAuthLocal, + isApplyingClaudeAuthLocal, + onExportClaudeAuthFile, + isExportingClaudeAuthFile, + onApplyGeminiAuthLocal, + isApplyingGeminiAuthLocal, + onExportGeminiAuthFile, + isExportingGeminiAuthFile, + perKeyProxyEnabled, + onTogglePerKeyProxyEnabled, + proxyEnabled, + onToggleProxyEnabled, +}: ConnectionRowProps) { + const t = useTranslations("providers"); + const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); + const displayName = isOAuth + ? pickDisplayValue( + [connection.name, connection.email, connection.displayName], + emailsVisible, + t("oauthAccount") + ) + : connection.name; + const applyCodexAuthLabel = + typeof t.has === "function" && t.has("applyCodexAuthLocal") + ? t("applyCodexAuthLocal") + : "Apply auth"; + const exportCodexAuthLabel = + typeof t.has === "function" && t.has("exportCodexAuthFile") + ? t("exportCodexAuthFile") + : "Export auth"; + const applyClaudeAuthLabel = + typeof t.has === "function" && t.has("applyClaudeAuthLocal") + ? t("applyClaudeAuthLocal") + : "Apply auth"; + const exportClaudeAuthLabel = + typeof t.has === "function" && t.has("exportClaudeAuthFile") + ? t("exportClaudeAuthFile") + : "Export auth"; + const applyGeminiAuthLabel = + typeof t.has === "function" && t.has("applyGeminiAuthLocal") + ? t("applyGeminiAuthLocal") + : "Apply auth"; + const exportGeminiAuthLabel = + typeof t.has === "function" && t.has("exportGeminiAuthFile") + ? t("exportGeminiAuthFile") + : "Export auth"; + + // Use useState + useEffect for impure Date.now() to avoid calling during render + const [isCooldown, setIsCooldown] = useState(false); + // T12: token expiry status — lazy init avoids calling Date.now() during render; + // updates every 30s via interval only (no sync setState in effect body). + // Prefer tokenExpiresAt (updated on each refresh) over expiresAt (original grant date). + const effectiveExpiresAt = connection.tokenExpiresAt || connection.expiresAt; + const getTokenMinsLeft = () => { + if (!isOAuth || !effectiveExpiresAt) return null; + const expiresMs = new Date(effectiveExpiresAt).getTime(); + return Math.floor((expiresMs - Date.now()) / 60000); + }; + const [tokenMinsLeft, setTokenMinsLeft] = useState(getTokenMinsLeft); + + useEffect(() => { + if (!isOAuth || !effectiveExpiresAt) return; + const update = () => { + const expiresMs = new Date(effectiveExpiresAt).getTime(); + setTokenMinsLeft(Math.floor((expiresMs - Date.now()) / 60000)); + }; + update(); + const iv = setInterval(update, 30000); + return () => clearInterval(iv); + }, [isOAuth, effectiveExpiresAt]); + + useEffect(() => { + const checkCooldown = () => { + const cooldown = + connection.rateLimitedUntil && new Date(connection.rateLimitedUntil).getTime() > Date.now(); + setIsCooldown(!!cooldown); + }; + + checkCooldown(); + // Update every second while in cooldown + const interval = connection.rateLimitedUntil ? setInterval(checkCooldown, 1000) : null; + return () => { + if (interval) clearInterval(interval); + }; + }, [connection.rateLimitedUntil]); + + // Determine effective status (override unavailable if cooldown expired) + const effectiveStatus = + connection.testStatus === "unavailable" && !isCooldown + ? "active" // Cooldown expired → treat as active + : connection.testStatus; + + const statusPresentation = getStatusPresentation(connection, effectiveStatus, isCooldown, t); + const rateLimitEnabled = !!connection.rateLimitProtection; + const codexPolicy = + connection.providerSpecificData && + typeof connection.providerSpecificData === "object" && + connection.providerSpecificData.codexLimitPolicy && + typeof connection.providerSpecificData.codexLimitPolicy === "object" + ? connection.providerSpecificData.codexLimitPolicy + : {}; + const normalizedCodexPolicy = normalizeCodexLimitPolicy(codexPolicy); + const codex5hEnabled = normalizedCodexPolicy.use5h; + const codexWeeklyEnabled = normalizedCodexPolicy.useWeekly; + const codexServiceTier = isCodex + ? getCodexEffectiveServiceTier( + connection.providerSpecificData, + codexGlobalServiceMode ?? "none" + ) + : "default"; + const codexServiceTierIsGlobal = + isCodex && codexGlobalServiceMode !== undefined && codexGlobalServiceMode !== "none"; + const codexServiceTierBadge = + codexServiceTier === "priority" + ? { + label: providerText(t, "codexTierFastLabel", "Fast"), + icon: "bolt", + className: "bg-sky-500/15 text-sky-500", + title: codexServiceTierIsGlobal + ? providerText( + t, + "providerDetailGlobalPriorityActive", + "Global Codex priority service tier is active" + ) + : providerText( + t, + "providerDetailConnectionPriorityActive", + "Codex priority service tier is active for this connection" + ), + } + : codexServiceTier === "flex" + ? { + label: providerText(t, "codexTierFlexLabel", "Flex"), + icon: "speed", + className: "bg-cyan-500/15 text-cyan-500", + title: codexServiceTierIsGlobal + ? providerText( + t, + "providerDetailGlobalFlexActive", + "Global Codex flex service tier is active" + ) + : providerText( + t, + "providerDetailConnectionFlexActive", + "Codex flex service tier is active for this connection" + ), + } + : null; + const claudeBlockExtraUsageEnabled = isClaude + ? isClaudeExtraUsageBlockEnabled("claude", connection.providerSpecificData) + : false; + const cliproxyapiDeepMode = !!cliproxyapiEnabled; + + return ( +
+
+ {onToggleSelect && ( + + )} + {/* Priority arrows */} +
+ + +
+ + {isOAuth ? "lock" : "key"} + +
+

{displayName}

+
+ + {statusPresentation.statusLabel} + + {/* T12: Token expiry status indicator (state-driven, no Date.now in render) */} + {tokenMinsLeft !== null && + (tokenMinsLeft < 0 ? ( + + error + {t("tokenExpiredBadge")} + + ) : tokenMinsLeft < 30 ? ( + + warning + {`~${tokenMinsLeft}m`} + + ) : null)} + {isCooldown && connection.isActive !== false && ( + + )} + {statusPresentation.errorBadge && connection.isActive !== false && ( + + {t(statusPresentation.errorBadge.labelKey)} + + )} + {connection.lastError && connection.isActive !== false && ( + + {connection.lastError} + + )} + #{connection.priority} + {connection.globalPriority && ( + + {t("autoPriority", { priority: connection.globalPriority })} + + )} + {connection.maxConcurrent != null && connection.maxConcurrent > 0 && ( + + dynamic_feed + {connection.maxConcurrent} + + )} + {/* Rate Limit Protection — inline toggle with label */} + | + + {isClaude && ( + <> + | + + + )} + {isCcCompatible && ( + <> + | + + + )} + {isCodex && ( + <> + | + {codexServiceTierBadge && ( + + + {codexServiceTierBadge.icon} + + {codexServiceTierBadge.label} + + )} + + + + )} + {onToggleProxyEnabled && ( + <> + | + + + )} + {onTogglePerKeyProxyEnabled && ( + <> + | + + + )} + {hasProxy && + (() => { + const colorClass = + proxySource === "global" + ? "bg-emerald-500/15 text-emerald-500" + : proxySource === "provider" + ? "bg-amber-500/15 text-amber-500" + : "bg-blue-500/15 text-blue-500"; + const label = + proxySource === "global" + ? t("proxySourceGlobal") + : proxySource === "provider" + ? t("proxySourceProvider") + : t("proxySourceKey"); + return ( + <> + | + + vpn_lock + {proxyHost || t("proxy")} + + + ); + })()} +
+
+
+
+ + {/* T12: Manual token refresh for OAuth accounts */} + {onRefreshToken && ( + + )} + {isCodex && onApplyCodexAuthLocal && ( + + )} + {isCodex && onExportCodexAuthFile && ( + + )} + {isClaude && onApplyClaudeAuthLocal && ( + + )} + {isClaude && onExportClaudeAuthFile && ( + + )} + {isGeminiCli && onApplyGeminiAuthLocal && ( + + )} + {isGeminiCli && onExportGeminiAuthFile && ( + + )} + +
+ {onReauth && ( + + )} + + + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx new file mode 100644 index 0000000000..7b9a342e80 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx @@ -0,0 +1,562 @@ +"use client"; +/** + * CustomModelsSection — Issue #3501 Phase 1e + * + * Extracted from ProviderDetailPageClient.tsx. Renders the "custom models" + * panel for ALL providers. This section is self-contained: it fetches its + * own model state from the API and manages local loading/saving state. + * + * Never imports from ProviderDetailPageClient. + */ +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/shared/components"; +import { useNotificationStore } from "@/store/notificationStore"; +import { + buildCompatMap, + anyNormalizeCompatBadge, + anyNoPreserveCompatBadge, + anyUpstreamHeadersBadge, + effectiveNormalizeForProtocol, + effectivePreserveForProtocol, + effectiveUpstreamHeadersForProtocol, + formatProviderModelsErrorResponse, + type CompatModelRow, + type CompatByProtocolMap, +} from "../providerPageHelpers"; +import ModelCompatPopover from "./ModelCompatPopover"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface CustomModelsSectionProps { + providerId: string; + providerAlias: string; + copied?: string; + onCopy: (text: string, key: string) => void; + onModelsChanged?: () => void; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function CustomModelsSection({ + providerId, + providerAlias, + copied, + onCopy, + onModelsChanged, +}: CustomModelsSectionProps) { + const t = useTranslations("providers"); + const notify = useNotificationStore(); + const [customModels, setCustomModels] = useState([]); + const [modelCompatOverrides, setModelCompatOverrides] = useState< + Array + >([]); + const [newModelId, setNewModelId] = useState(""); + const [newModelName, setNewModelName] = useState(""); + const [newApiFormat, setNewApiFormat] = useState("chat-completions"); + const [newEndpoints, setNewEndpoints] = useState(["chat"]); + const [adding, setAdding] = useState(false); + const [loading, setLoading] = useState(true); + const [editingModelId, setEditingModelId] = useState(null); + const [editingApiFormat, setEditingApiFormat] = useState("chat-completions"); + const [editingEndpoints, setEditingEndpoints] = useState(["chat"]); + const [savingModelId, setSavingModelId] = useState(null); + const [togglingModelId, setTogglingModelId] = useState(null); + + const customMap = useMemo(() => buildCompatMap(customModels), [customModels]); + const overrideMap = useMemo(() => buildCompatMap(modelCompatOverrides), [modelCompatOverrides]); + + const fetchCustomModels = useCallback(async () => { + try { + const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`); + if (res.ok) { + const data = await res.json(); + setCustomModels(data.models || []); + setModelCompatOverrides(data.modelCompatOverrides || []); + } + } catch (e) { + console.error("Failed to fetch custom models:", e); + } finally { + setLoading(false); + } + }, [providerId]); + + useEffect(() => { + fetchCustomModels(); + }, [fetchCustomModels]); + + const handleAdd = async () => { + if (!newModelId.trim() || adding) return; + setAdding(true); + try { + const res = await fetch("/api/provider-models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: providerId, + modelId: newModelId.trim(), + modelName: newModelName.trim() || undefined, + apiFormat: newApiFormat, + supportedEndpoints: newEndpoints, + }), + }); + if (res.ok) { + setNewModelId(""); + setNewModelName(""); + setNewApiFormat("chat-completions"); + setNewEndpoints(["chat"]); + await fetchCustomModels(); + onModelsChanged?.(); + } + } catch (e) { + console.error("Failed to add custom model:", e); + } finally { + setAdding(false); + } + }; + + const handleRemove = async (modelId: string) => { + try { + await fetch( + `/api/provider-models?provider=${encodeURIComponent(providerId)}&model=${encodeURIComponent(modelId)}`, + { + method: "DELETE", + } + ); + await fetchCustomModels(); + onModelsChanged?.(); + } catch (e) { + console.error("Failed to remove custom model:", e); + } + }; + + const handleToggleHidden = async (modelId: string, hidden: boolean) => { + setTogglingModelId(modelId); + try { + const res = await fetch( + `/api/provider-models?provider=${encodeURIComponent(providerId)}&modelId=${encodeURIComponent(modelId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isHidden: hidden }), + } + ); + if (res.ok) { + await fetchCustomModels(); + onModelsChanged?.(); + } + } catch (e) { + console.error("Failed to toggle model visibility:", e); + } finally { + setTogglingModelId(null); + } + }; + + const beginEdit = (model: CompatModelRow) => { + setEditingModelId(model.id ?? null); + setEditingApiFormat(model.apiFormat || "chat-completions"); + setEditingEndpoints( + Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length + ? model.supportedEndpoints + : ["chat"] + ); + }; + + const cancelEdit = () => { + setEditingModelId(null); + setEditingApiFormat("chat-completions"); + setEditingEndpoints(["chat"]); + setSavingModelId(null); + }; + + const saveCustomCompat = async ( + modelId: string, + patch: { compatByProtocol?: CompatByProtocolMap } + ) => { + setSavingModelId(modelId); + try { + const res = await fetch("/api/provider-models", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerId, modelId, ...patch }), + }); + if (!res.ok) { + const detail = await formatProviderModelsErrorResponse(res); + notify.error( + detail ? `${t("failedSaveCustomModel")} — ${detail}` : t("failedSaveCustomModel") + ); + return; + } + } catch { + notify.error(t("failedSaveCustomModel")); + return; + } finally { + setSavingModelId(null); + } + try { + await fetchCustomModels(); + onModelsChanged?.(); + } catch { + /* refresh failure is non-critical — data was already saved */ + } + }; + + const saveEdit = async (modelId: string) => { + if (!editingModelId || editingModelId !== modelId) return; + if (!editingEndpoints.length) { + notify.error("Select at least one supported endpoint"); + return; + } + + setSavingModelId(modelId); + try { + const model = customModels.find((m) => m.id === modelId); + const res = await fetch("/api/provider-models", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: providerId, + modelId, + modelName: model?.name || modelId, + source: model?.source || "manual", + apiFormat: editingApiFormat, + supportedEndpoints: editingEndpoints, + }), + }); + + if (!res.ok) { + const detail = await formatProviderModelsErrorResponse(res); + throw new Error(detail || "Failed to save model endpoint settings"); + } + + await fetchCustomModels(); + onModelsChanged?.(); + notify.success("Saved model endpoint settings"); + cancelEdit(); + } catch (e) { + console.error("Failed to save custom model:", e); + notify.error( + e instanceof Error && e.message ? e.message : "Failed to save model endpoint settings" + ); + } finally { + setSavingModelId(null); + } + }; + + return ( +
+

+ tune + {t("customModels")} +

+

{t("customModelsHint")}

+ + {/* Add form */} +
+
+
+ + setNewModelId(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder={t("customModelPlaceholder")} + className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+
+ + setNewModelName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder={t("optional")} + className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+ +
+ + {/* API Format + Supported Endpoints */} +
+
+ + +
+
+ + {t("supportedEndpointsLabel")} + +
+ {["chat", "embeddings", "rerank", "images", "audio"].map((ep) => ( + + ))} +
+
+
+
+ + {/* List */} + {loading ? ( +

{t("loading")}

+ ) : customModels.length > 0 ? ( +
+ {customModels.map((model) => { + const fullModel = `${providerAlias}/${model.id}`; + const copyKey = `custom-${model.id}`; + return ( +
+ {editingModelId !== model.id && ( + + tune + + )} +
+

{model.name || model.id}

+
+ + {fullModel} + + + {model.apiFormat === "responses" && ( + + {t("responses")} + + )} + {model.supportedEndpoints?.includes("embeddings") && ( + + {`📐 ${t("supportedEndpointEmbeddings")}`} + + )} + {model.supportedEndpoints?.includes("images") && ( + + {`🖼️ ${t("imagesShortLabel")}`} + + )} + {model.supportedEndpoints?.includes("audio") && ( + + {`🔊 ${t("audioShortLabel")}`} + + )} + {anyNormalizeCompatBadge(model.id!, customMap, overrideMap) && ( + + ID×9 + + )} + {anyNoPreserveCompatBadge(model.id!, customMap, overrideMap) && ( + + {t("compatBadgeNoPreserve")} + + )} + {anyUpstreamHeadersBadge(model.id!, customMap, overrideMap) && ( + + {t("compatBadgeUpstreamHeaders")} + + )} +
+ + {editingModelId === model.id && ( +
+
+
+ + +
+
+ + {t("supportedEndpointsLabel")} + +
+ {["chat", "embeddings", "rerank", "images", "audio"].map((ep) => ( + + ))} +
+
+
+ + +
+
+
+ )} +
+
+ + + effectiveNormalizeForProtocol(model.id!, p, customMap, overrideMap) + } + effectiveModelPreserveDeveloper={(p) => + effectivePreserveForProtocol(model.id!, p, customMap, overrideMap) + } + getUpstreamHeadersRecord={(p) => + effectiveUpstreamHeadersForProtocol(model.id!, p, customMap, overrideMap) + } + onCompatPatch={(protocol, payload) => + saveCustomCompat(model.id!, { + compatByProtocol: { [protocol]: payload }, + }) + } + showDeveloperToggle + disabled={savingModelId === model.id} + /> + + +
+
+ ); + })} +
+ ) : ( +

{t("noCustomModels")}

+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx new file mode 100644 index 0000000000..0943cb63a0 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx @@ -0,0 +1,355 @@ +"use client"; + +// Phase 1d extraction — Issue #3501 +// ModelCompatPopover and its local helper (recordToHeaderRows) moved out of +// ProviderDetailPageClient.tsx. Leaf deps: @/shared + providerPageHelpers. + +import { useState, useEffect, useLayoutEffect, useCallback, useRef } from "react"; +import { createPortal } from "react-dom"; +import { Input, Toggle } from "@/shared/components"; +import { MODEL_COMPAT_PROTOCOL_KEYS } from "@/shared/constants/modelCompat"; +import { + upstreamHeadersRecordsEqual, + UPSTREAM_HEADERS_UI_MAX, + headerRowsToRecord, + compatProtocolLabelKey, + type HeaderDraftRow, +} from "../providerPageHelpers"; + +// --------------------------------------------------------------------------- +// Local helper — only used by this component +// --------------------------------------------------------------------------- + +function recordToHeaderRows(rec: Record, genId: () => string): HeaderDraftRow[] { + const entries = Object.entries(rec).filter(([k]) => k.trim()); + if (entries.length === 0) return [{ id: genId(), name: "", value: "" }]; + return entries.map(([name, value]) => ({ id: genId(), name, value })); +} + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface ModelCompatPopoverProps { + t: (key: string) => string; + effectiveModelNormalize: (protocol: string) => boolean; + effectiveModelPreserveDeveloper: (protocol: string) => boolean; + getUpstreamHeadersRecord: (protocol: string) => Record; + onCompatPatch: ( + protocol: string, + payload: { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + } + ) => void; + showDeveloperToggle?: boolean; + compact?: boolean; + disabled?: boolean; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function ModelCompatPopover({ + t, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, + onCompatPatch, + showDeveloperToggle = true, + compact = false, + disabled, +}: ModelCompatPopoverProps) { + const [open, setOpen] = useState(false); + const [protocol, setProtocol] = useState(MODEL_COMPAT_PROTOCOL_KEYS[0]); + const [headerRows, setHeaderRows] = useState([]); + const [valuePeekRowId, setValuePeekRowId] = useState(null); + const [valueFocusRowId, setValueFocusRowId] = useState(null); + const ref = useRef(null); + const panelRef = useRef(null); + const [portalPanelRect, setPortalPanelRect] = useState<{ + top?: number; + bottom?: number; + left: number; + width: number; + } | null>(null); + const headerRowIdRef = useRef(0); + const headerRowsRef = useRef([]); + headerRowsRef.current = headerRows; + + const genHeaderRowId = () => { + headerRowIdRef.current += 1; + return `uh-${headerRowIdRef.current}`; + }; + + const normalizeToolCallId = effectiveModelNormalize(protocol); + const preserveDeveloperRole = effectiveModelPreserveDeveloper(protocol); + const devToggle = showDeveloperToggle && protocol !== "claude"; + + const tryCommitHeaderRows = useCallback( + (rows: HeaderDraftRow[]) => { + const parsed = headerRowsToRecord(rows); + const current = getUpstreamHeadersRecord(protocol); + if (upstreamHeadersRecordsEqual(parsed, current)) return; + onCompatPatch(protocol, { upstreamHeaders: parsed }); + }, + [getUpstreamHeadersRecord, onCompatPatch, protocol] + ); + + const onHeaderFieldBlur = useCallback(() => { + queueMicrotask(() => tryCommitHeaderRows(headerRowsRef.current)); + }, [tryCommitHeaderRows]); + + useEffect(() => { + if (!open) return; + return () => { + tryCommitHeaderRows(headerRowsRef.current); + }; + }, [open, tryCommitHeaderRows]); + + useEffect(() => { + if (!open) return; + const rec = getUpstreamHeadersRecord(protocol); + setHeaderRows(recordToHeaderRows(rec, genHeaderRowId)); + // Only re-load rows when opening or switching protocol — not when the parent passes a new + // inline callback every render (would wipe in-progress edits). + // eslint-disable-next-line react-hooks/exhaustive-deps -- see above + }, [open, protocol]); + + useEffect(() => { + setValuePeekRowId(null); + setValueFocusRowId(null); + }, [open, protocol]); + + const namedHeaderCount = headerRows.filter((r) => r.name.trim()).length; + const canAddHeaderRow = namedHeaderCount < UPSTREAM_HEADERS_UI_MAX; + + const updateHeaderRow = (id: string, patch: Partial>) => { + setHeaderRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r))); + }; + + const addHeaderRow = () => { + if (!canAddHeaderRow) return; + setHeaderRows((prev) => [...prev, { id: genHeaderRowId(), name: "", value: "" }]); + }; + + const removeHeaderRow = (id: string) => { + setHeaderRows((prev) => { + const next = prev.filter((r) => r.id !== id); + const normalized = next.length === 0 ? [{ id: genHeaderRowId(), name: "", value: "" }] : next; + queueMicrotask(() => tryCommitHeaderRows(normalized)); + return normalized; + }); + }; + + useEffect(() => { + if (!open) return; + const onDocClick = (e: MouseEvent) => { + const target = e.target as Node; + const insideTrigger = ref.current?.contains(target); + const insidePanel = panelRef.current?.contains(target); + if (!insideTrigger && !insidePanel) setOpen(false); + }; + document.addEventListener("mousedown", onDocClick); + return () => document.removeEventListener("mousedown", onDocClick); + }, [open]); + + const updatePortalPanelRect = useCallback(() => { + if (!open || !ref.current) return; + const rect = ref.current.getBoundingClientRect(); + const margin = 10; + const width = Math.min(window.innerWidth - 2 * margin, 24 * 16); + let left = rect.right - width; + left = Math.max(margin, Math.min(left, window.innerWidth - width - margin)); + // Estimated panel height: capped at min(82vh, 42rem=672px) + const estimatedPanelHeight = Math.min(window.innerHeight * 0.82, 672); + const spaceBelow = window.innerHeight - rect.bottom - margin; + const spaceAbove = rect.top - margin; + if (spaceBelow < estimatedPanelHeight && spaceAbove > spaceBelow) { + // Not enough space below — open upward + setPortalPanelRect({ bottom: window.innerHeight - rect.top + 8, left, width }); + } else { + setPortalPanelRect({ top: rect.bottom + 8, left, width }); + } + }, [open]); + + useLayoutEffect(() => { + if (!open) { + setPortalPanelRect(null); + return; + } + updatePortalPanelRect(); + window.addEventListener("resize", updatePortalPanelRect); + window.addEventListener("scroll", updatePortalPanelRect, true); + return () => { + window.removeEventListener("resize", updatePortalPanelRect); + window.removeEventListener("scroll", updatePortalPanelRect, true); + }; + }, [open, updatePortalPanelRect]); + + const panelChromeClass = + "flex max-h-[min(82vh,42rem)] flex-col overflow-hidden rounded-xl border-2 border-zinc-200 bg-white shadow-2xl dark:border-zinc-600 dark:bg-zinc-950"; + + return ( +
+ + {open && + typeof document !== "undefined" && + portalPanelRect && + createPortal( +
+
+

{t("compatAdjustmentsTitle")}

+

+ {t("compatProtocolHint")} +

+
+
+ + +
+ onCompatPatch(protocol, { normalizeToolCallId: v })} + disabled={disabled} + /> + {devToggle && ( + + onCompatPatch(protocol, { preserveOpenAIDeveloperRole: !checked }) + } + disabled={disabled} + /> + )} +
+ +
+ +

+ {t("compatUpstreamHeadersHint")} +

+
+
+ {t("compatUpstreamHeaderName")} + {t("compatUpstreamHeaderValue")} + +
+ {headerRows.map((row) => ( +
+ updateHeaderRow(row.id, { name: e.target.value })} + onBlur={onHeaderFieldBlur} + disabled={disabled} + placeholder={t("compatUpstreamHeaderNamePlaceholder")} + className="gap-0 min-w-0" + inputClassName="h-9 bg-white py-1.5 px-2 text-xs font-mono dark:bg-zinc-900" + autoComplete="off" + /> +
setValuePeekRowId(row.id)} + onMouseLeave={() => + setValuePeekRowId((cur) => (cur === row.id ? null : cur)) + } + > + updateHeaderRow(row.id, { value: e.target.value })} + onFocus={() => setValueFocusRowId(row.id)} + onBlur={() => { + setValueFocusRowId((cur) => (cur === row.id ? null : cur)); + onHeaderFieldBlur(); + }} + disabled={disabled} + placeholder={t("compatUpstreamHeaderValuePlaceholder")} + className="gap-0 min-w-0" + inputClassName="h-9 bg-white py-1.5 px-2 text-xs dark:bg-zinc-900" + autoComplete="off" + spellCheck={false} + /> +
+ +
+ ))} +
+ +
+
+
, + document.body + )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ModelRow.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ModelRow.tsx new file mode 100644 index 0000000000..1b51365760 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ModelRow.tsx @@ -0,0 +1,330 @@ +"use client"; +/** + * ModelRow — Issue #3501 Phase 1e + * + * Extracted from ProviderDetailPageClient.tsx. Contains: + * - ModelSourceBadge (tiny utility) + * - ModelVisibilityToolbar (filter/test toolbar) + * - ModelRow (catalog model row with compat controls) + * + * Leaf component: imports from shared, leaf helpers, and sibling components. + * Never imports from ProviderDetailPageClient. + */ +import React from "react"; +import { + getModelCatalogSourceLabel, + normalizeModelCatalogSource, +} from "@/shared/utils/modelCatalogSearch"; +import { providerText } from "../providerPageHelpers"; +import ModelCompatPopover from "./ModelCompatPopover"; + +// --------------------------------------------------------------------------- +// Shared prop types +// --------------------------------------------------------------------------- + +/** PATCH fields for provider model compat (matches API + `ModelCompatPerProtocol` shape). */ +export type ModelCompatSavePatch = { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + compatByProtocol?: Record< + string, + { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + } + >; + isHidden?: boolean; +}; + +// --------------------------------------------------------------------------- +// ModelSourceBadge +// --------------------------------------------------------------------------- + +function getModelSourceBadgeClass(source?: string): string { + switch (normalizeModelCatalogSource(source)) { + case "imported": + return "border-sky-500/30 bg-sky-500/10 text-sky-300"; + case "custom": + return "border-emerald-500/30 bg-emerald-500/10 text-emerald-300"; + case "fallback": + return "border-amber-500/30 bg-amber-500/10 text-amber-300"; + case "alias": + return "border-violet-500/30 bg-violet-500/10 text-violet-300"; + case "system": + default: + return "border-border bg-sidebar/70 text-text-muted"; + } +} + +export function ModelSourceBadge({ source }: { source?: string }) { + return ( + + {getModelCatalogSourceLabel(source)} + + ); +} + +// --------------------------------------------------------------------------- +// ModelVisibilityToolbar +// --------------------------------------------------------------------------- + +export interface ModelVisibilityToolbarProps { + t: ((key: string, values?: Record) => string) & { + has?: (key: string) => boolean; + }; + filterValue: string; + onFilterChange: (value: string) => void; + activeCount: number; + totalCount: number; + onSelectAll: () => void; + 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; +} + +export function ModelVisibilityToolbar({ + t, + filterValue, + onFilterChange, + activeCount: _activeCount, + totalCount: _totalCount, + onSelectAll, + onDeselectAll, + selectAllDisabled, + deselectAllDisabled, + onTestAll, + testingAll, + testProgress, + visibilityFilter, + onVisibilityFilterChange, + autoHideFailed, + onAutoHideFailedChange, +}: ModelVisibilityToolbarProps) { + return ( +
+
+ + search + + onFilterChange(e.target.value)} + placeholder={providerText(t, "filterModels", "Filter models…")} + 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 && ( + + )} + + +
+ ); +} + +// --------------------------------------------------------------------------- +// ModelRow +// --------------------------------------------------------------------------- + +export interface ModelRowProps { + model: { id: string; name?: string; source?: string; isHidden?: boolean }; + fullModel: string; + provider: string; + copied?: string; + onCopy: (text: string, key: string) => void; + t: (key: string, values?: Record) => string; + showDeveloperToggle?: boolean; + effectiveModelNormalize: (modelId: string, protocol?: string) => boolean; + effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean; + saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => void; + getUpstreamHeadersRecord: (protocol: string) => Record; + compatDisabled?: boolean; + onToggleHidden?: (modelId: string, hidden: boolean) => Promise; + togglingHidden?: boolean; + onTestModel?: (modelId: string, fullModel: string) => Promise; + testStatus?: "ok" | "error" | null; + testingModel?: boolean; +} + +export default function ModelRow({ + model, + fullModel, + copied, + onCopy, + t, + showDeveloperToggle = true, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, + saveModelCompatFlags, + compatDisabled, + onToggleHidden, + togglingHidden, + onTestModel, + testStatus, + testingModel, +}: ModelRowProps) { + const isHidden = Boolean(model.isHidden); + return ( +
+
+ + smart_toy + + + {fullModel} + + + +
+
+ {onTestModel && ( + + )} + {onToggleHidden && ( + + )} + effectiveModelNormalize(model.id, p)} + effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)} + getUpstreamHeadersRecord={getUpstreamHeadersRecord} + onCompatPatch={(protocol, payload) => + saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } }) + } + showDeveloperToggle={showDeveloperToggle} + disabled={compatDisabled} + /> +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelRow.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelRow.tsx new file mode 100644 index 0000000000..27de2c6c9e --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelRow.tsx @@ -0,0 +1,178 @@ +"use client"; +/** + * PassthroughModelRow — Issue #3501 Phase 1e + * + * Extracted from ProviderDetailPageClient.tsx. Renders one row in the + * passthrough / compatible models list. + * + * Leaf component: imports from shared, leaf helpers, and sibling components. + * Never imports from ProviderDetailPageClient. + */ +import React from "react"; +import { Badge } from "@/shared/components"; +import { providerText } from "../providerPageHelpers"; +import ModelCompatPopover from "./ModelCompatPopover"; +import { ModelSourceBadge, type ModelCompatSavePatch } from "./ModelRow"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface PassthroughModelRowProps { + modelId: string; + fullModel: string; + source?: string; + isFree?: boolean; + isHidden?: boolean; + copied?: string; + onCopy: (text: string, key: string) => void; + onDeleteAlias?: () => void; + t: (key: string, values?: Record) => string; + showDeveloperToggle?: boolean; + effectiveModelNormalize: (modelId: string, protocol?: string) => boolean; + effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean; + saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => void; + getUpstreamHeadersRecord: (protocol: string) => Record; + compatDisabled?: boolean; + onToggleHidden?: (modelId: string, hidden: boolean) => Promise; + togglingHidden?: boolean; + onTestModel?: (modelId: string, fullModel: string) => Promise; + testStatus?: "ok" | "error" | null; + testingModel?: boolean; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function PassthroughModelRow({ + modelId, + fullModel, + source, + isFree, + isHidden, + copied, + onCopy, + onDeleteAlias, + t, + showDeveloperToggle = true, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, + saveModelCompatFlags, + compatDisabled, + onToggleHidden, + togglingHidden, + onTestModel, + testStatus, + testingModel, +}: PassthroughModelRowProps) { + return ( +
+
+ + smart_toy + + + {fullModel} + +
+
+
+ + {isFree && ( + + {providerText(t, "freeBadge", "Free")} + + )} +
+
+ + {onTestModel && ( + + )} + {onToggleHidden && ( + + )} + effectiveModelNormalize(modelId, p)} + effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(modelId, p)} + getUpstreamHeadersRecord={getUpstreamHeadersRecord} + onCompatPatch={(protocol, payload) => + saveModelCompatFlags(modelId, { compatByProtocol: { [protocol]: payload } }) + } + showDeveloperToggle={showDeveloperToggle} + compact + disabled={compatDisabled} + /> + {onDeleteAlias && ( + + )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx new file mode 100644 index 0000000000..222016da5d --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx @@ -0,0 +1,421 @@ +"use client"; +/** + * PassthroughModelsSection — Issue #3501 Phase 1e + * + * Extracted from ProviderDetailPageClient.tsx. Renders the full "passthrough + * models" panel for non-compatible providers (OpenRouter, etc.). + * + * Preserves the Bug #3610 fix intact: + * - autoHideFailed is threaded from the outer component (shared checkbox). + * - buildPassthroughTestBody / shouldSwitchToVisibleFilter come from the + * leaf helper providerPageHelpers.ts. + * + * Never imports from ProviderDetailPageClient. + */ +import React, { useState, useMemo } from "react"; +import { Button } from "@/shared/components"; +import { matchesModelCatalogQuery, normalizeModelCatalogSource } from "@/shared/utils/modelCatalogSearch"; +import { useNotificationStore } from "@/store/notificationStore"; +import { + buildCompatMap, + providerText, + buildPassthroughTestBody, + shouldSwitchToVisibleFilter, + type CompatModelRow, + type CompatByProtocolMap, +} from "../providerPageHelpers"; +import { ModelVisibilityToolbar } from "./ModelRow"; +import PassthroughModelRow from "./PassthroughModelRow"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export type ModelCompatSavePatchPassthrough = { + normalizeToolCallId?: boolean; + preserveDeveloperRole?: boolean; + preserveOpenAIDeveloperRole?: boolean; +}; + +export interface PassthroughModelsSectionProps { + providerAlias: string; + modelAliases: Record; + availableModels?: CompatModelRow[]; + customModels?: CompatModelRow[]; + description: string; + inputLabel: string; + inputPlaceholder: string; + copied?: string; + onCopy: (text: string, key: string) => void; + onSetAlias: (modelId: string, alias: string) => Promise; + onDeleteAlias: (alias: string) => void; + t: (key: string, values?: Record) => string; + effectiveModelNormalize: (alias: string) => boolean; + effectiveModelPreserveDeveloper: (alias: string) => boolean; + getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record; + saveModelCompatFlags: ( + modelId: string, + flags: ModelCompatSavePatchPassthrough + ) => Promise; + compatSavingModelId?: string; + isModelHidden: (modelId: string) => boolean; + onToggleHidden: (modelId: string, hidden: boolean) => Promise; + onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise; + bulkTogglePending?: boolean; + togglingModelId?: string | null; + onTestModel?: (modelId: string, fullModel: string) => Promise; + modelTestStatus?: Record; + testingModelId?: string | null; + providerId: string; + connectionId: string; + /** Controlled from the outer component so both sections share one checkbox (#3610). */ + autoHideFailed?: boolean; + onAutoHideFailedChange?: (v: boolean) => void; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function PassthroughModelsSection({ + providerAlias, + modelAliases, + availableModels = [], + customModels = [], + description, + inputLabel, + inputPlaceholder, + copied, + onCopy, + onSetAlias, + onDeleteAlias, + t, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, + saveModelCompatFlags, + compatSavingModelId, + isModelHidden, + onToggleHidden, + onBulkToggleHidden, + bulkTogglePending, + togglingModelId, + onTestModel, + modelTestStatus, + testingModelId, + providerId, + connectionId, + // Bug #3610 fix 1: use the prop value when provided; fall back to local state only + // when the outer component does not pass the prop (backward-compat / standalone use). + autoHideFailed: autoHideFailedProp, + onAutoHideFailedChange, +}: 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 [localAutoHideFailed, setLocalAutoHideFailed] = useState(true); + const autoHideFailed = autoHideFailedProp !== undefined ? autoHideFailedProp : localAutoHideFailed; + const setAutoHideFailed = onAutoHideFailedChange ?? setLocalAutoHideFailed; + 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" }, + // Bug #3610 fix 2: pass autoHideFailed so the server persists the hide + body: JSON.stringify( + buildPassthroughTestBody({ + providerId, + connectionId, + modelId: model.modelId, + autoHideFailed, + }) + ), + }).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 })); + // Bug #3610 fix 3: switch to "visible" filter so hidden models disappear on-screen + if (shouldSwitchToVisibleFilter({ autoHideFailed, hiddenCount })) { + setVisibilityFilter("visible"); + } + } + setTestingAll(false); + setTestProgress(null); + }; + + const providerAliases = useMemo( + () => + Object.entries(modelAliases).filter(([, model]: [string, any]) => + (model as string).startsWith(`${providerAlias}/`) + ), + [modelAliases, providerAlias] + ); + + const allModels = useMemo(() => { + const prefix = `${providerAlias}/`; + const aliasByModelId = new Map(); + const fullModelByModelId = new Map(); + const rows: Array<{ + modelId: string; + fullModel: string; + alias: string | null; + displayName: string; + source: string; + isFree: boolean; + isHidden: boolean; + }> = []; + const seenModelIds = new Set(); + + for (const [alias, fullModel] of providerAliases) { + const fmStr = fullModel as string; + const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr; + aliasByModelId.set(modelId, alias as string); + fullModelByModelId.set(modelId, fmStr); + } + + const addModel = (model: CompatModelRow, source: string) => { + if (!model?.id || seenModelIds.has(model.id)) return; + const fullModel = fullModelByModelId.get(model.id) || `${providerAlias}/${model.id}`; + rows.push({ + modelId: model.id, + fullModel, + alias: aliasByModelId.get(model.id) || null, + displayName: model.name || model.id, + source, + isFree: + Boolean((model as any).free) || + model.id.endsWith(":free") || + /\bgr[aá]tis\b|\bfree\b/i.test(model.name || ""), + isHidden: isModelHidden(model.id), + }); + seenModelIds.add(model.id); + }; + + for (const model of availableModels) { + addModel(model, "imported"); + } + + for (const model of customModels) { + addModel( + model, + normalizeModelCatalogSource(model.source) === "imported" ? "imported" : "custom" + ); + } + + for (const [alias, fullModel] of providerAliases) { + const fmStr = fullModel as string; + const modelId = fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr; + if (!modelId || seenModelIds.has(modelId)) continue; + const customModel = customModelMap.get(modelId); + rows.push({ + modelId, + fullModel: fmStr, + alias: alias as string, + displayName: alias as string, + source: customModel ? customModel.source || "custom" : "alias", + isFree: + modelId.endsWith(":free") || + Boolean((customModel as any)?.free) || + /\bgr[aá]tis\b|\bfree\b/i.test(customModel?.name || alias || ""), + isHidden: isModelHidden(modelId), + }); + seenModelIds.add(modelId); + } + + return rows; + }, [ + availableModels, + customModelMap, + customModels, + isModelHidden, + providerAlias, + providerAliases, + ]); + + 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; + + // Generate default alias from modelId (last part after /) + const generateDefaultAlias = (modelId: string) => { + const parts = modelId.split("/"); + return parts[parts.length - 1]; + }; + + const handleAdd = async () => { + if (!newModel.trim() || adding) return; + const modelId = newModel.trim(); + const defaultAlias = generateDefaultAlias(modelId); + + // Check if alias already exists + if (modelAliases[defaultAlias]) { + alert(t("aliasExistsAlert", { alias: defaultAlias })); + return; + } + + setAdding(true); + try { + await onSetAlias(modelId, defaultAlias); + setNewModel(""); + } catch (error) { + console.error("Error adding model:", error); + } finally { + setAdding(false); + } + }; + + return ( +
+

{description}

+ + {/* Add new model */} +
+
+ + setNewModel(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder={inputPlaceholder} + className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+ +
+ + {/* Models list */} + {allModels.length > 0 && ( +
+ + onBulkToggleHidden( + filteredModels.map((m) => m.modelId), + false + ) + } + onDeselectAll={() => + onBulkToggleHidden( + filteredModels.map((m) => m.modelId), + true + ) + } + 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 }) => ( + onDeleteAlias(alias) : undefined} + t={t} + showDeveloperToggle + effectiveModelNormalize={effectiveModelNormalize} + effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} + getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)} + saveModelCompatFlags={saveModelCompatFlags} + compatDisabled={compatSavingModelId === modelId} + onToggleHidden={onToggleHidden} + togglingHidden={togglingModelId === modelId} + onTestModel={onTestModel} + testStatus={modelTestStatus?.[modelId] || null} + testingModel={testingModelId === modelId} + /> + ))} +
+ {filteredModels.length === 0 && modelFilter && ( +

+ {providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, { + filter: modelFilter, + })} +

+ )} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/SiliconFlowEndpointModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/SiliconFlowEndpointModal.tsx new file mode 100644 index 0000000000..bf04e500fb --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/SiliconFlowEndpointModal.tsx @@ -0,0 +1,59 @@ +"use client"; + +// Phase 1d extraction — Issue #3501 +// SiliconFlowEndpointModal moved out of ProviderDetailPageClient.tsx. + +import { useTranslations } from "next-intl"; +import { Modal } from "@/shared/components"; +import { providerText, SILICONFLOW_ENDPOINTS } from "../providerPageHelpers"; + +interface SiliconFlowEndpointModalProps { + isOpen: boolean; + onSelect: (baseUrl: string) => void; + onClose: () => void; +} + +export default function SiliconFlowEndpointModal({ + isOpen, + onSelect, + onClose, +}: SiliconFlowEndpointModalProps) { + const t = useTranslations("providers"); + + return ( + +
+

+ {providerText(t, "chooseSiliconFlowEndpoint", "Choose your SiliconFlow endpoint:")} +

+ {SILICONFLOW_ENDPOINTS.map((endpoint) => ( + + ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/WebSessionCredentialGuide.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/WebSessionCredentialGuide.tsx new file mode 100644 index 0000000000..f4f30ddce9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/WebSessionCredentialGuide.tsx @@ -0,0 +1,114 @@ +"use client"; + +// Issue #3501 Phase 1c — extracted from the god-component. +// Shared by AddApiKeyModal and EditConnectionModal; imports only leaf modules +// (no cycle risk). + +import type { WebSessionCredentialRequirement } from "../webSessionCredentials"; +import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers"; + +export interface WebSessionCredentialGuideProps { + requirement: WebSessionCredentialRequirement; + providerName: string; + t: ProviderMessageTranslator; +} + +export default function WebSessionCredentialGuide({ + requirement, + providerName, + t, +}: WebSessionCredentialGuideProps) { + if (requirement.kind === "none") { + return ( +
+
+ + check_circle + +
+

+ {providerText(t, "webNoAuthGuideTitle", "No credential required")} +

+

+ {providerText( + t, + "webNoAuthGuideBody", + "{provider} does not need an API key or cookie. Save the connection to use its free web endpoint.", + { provider: providerName } + )} +

+
+
+
+ ); + } + + const requiredCredentialKey = + requirement.kind === "token" ? "webTokenRequiredCredential" : "webCookieRequiredCredential"; + const requiredCredentialFallback = + requirement.kind === "token" ? "Required token: {credential}" : "Required cookie: {credential}"; + + return ( +
+
+ cookie +
+
+

+ {providerText(t, "webSessionGuideTitle", "How to get the session credential")} +

+

+ {providerText( + t, + "webSessionGuideIntro", + "{provider} uses a browser web session instead of an API key.", + { provider: providerName } + )} +

+
+

+ {providerText(t, requiredCredentialKey, requiredCredentialFallback, { + credential: requirement.credentialName, + })} +

+
    +
  1. + {providerText(t, "webSessionGuideStep1", "Sign in to {provider} in your browser.", { + provider: providerName, + })} +
  2. +
  3. + {providerText( + t, + "webSessionGuideStep2", + "Open the browser developer tools and inspect a request made by the web app." + )} +
  4. +
  5. + {providerText( + t, + "webSessionGuideStep3", + "Copy the required credential from the provider's own domain. For cookies, copy only the Cookie header value and omit Cookie:.", + { credential: requirement.credentialName } + )} +
  6. +
  7. + {providerText( + t, + "webSessionGuideStep4", + "Paste it here and check the connection. If it stops working, sign in again and replace it with a fresh value." + )} +
  8. +
+

+ {providerText( + t, + "webSessionSecurityHint", + "Treat this like a password: it may access your signed-in web account until it expires or is revoked." + )} +

+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx new file mode 100644 index 0000000000..ae3cdacb47 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx @@ -0,0 +1,202 @@ +// @vitest-environment jsdom +// +// Phase 1d regression tests for Issue #3501. +// ConnectionRow, ModelCompatPopover, and SiliconFlowEndpointModal were extracted +// from the god-component. This proves each mounts in isolation with its clean +// Props interface (Hard Rule #8, Rule #18 TDD gate). +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import ConnectionRow from "../ConnectionRow"; +import ModelCompatPopover from "../ModelCompatPopover"; +import SiliconFlowEndpointModal from "../SiliconFlowEndpointModal"; + +vi.mock("next/navigation", () => ({ + useParams: () => ({ id: "openai" }), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); +vi.mock("next-intl", () => ({ + useTranslations: () => (k: string) => k, +})); + +// Minimal store stubs +vi.mock("@/store/emailPrivacyStore", () => ({ + default: () => true, +})); +vi.mock("@/store/notificationStore", () => ({ + useNotificationStore: () => ({ add: vi.fn() }), +})); + +const cleanups: Array<() => void> = []; + +function renderComponent(node: React.ReactElement) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render(node)); + cleanups.push(() => { + act(() => root.unmount()); + container.remove(); + }); + return container; +} + +describe("phase-1d extractions (#3501)", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve({ ok: true, json: async () => ({}), text: async () => "" } as Response) + ) + ); + vi.stubGlobal("localStorage", { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, + clear: () => undefined, + }); + }); + + afterEach(() => { + while (cleanups.length) cleanups.pop()?.(); + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + }); + + // ── ConnectionRow ────────────────────────────────────────────────────────── + + it("ConnectionRow mounts with minimal required props (API-key connection)", () => { + const conn = { + id: "conn-1", + name: "My Key", + isActive: true, + priority: 1, + }; + const c = renderComponent( + + ); + expect(c).toBeDefined(); + }); + + it("ConnectionRow mounts as OAuth connection (isClaude=true)", () => { + const conn = { + id: "conn-2", + email: "user@example.com", + isActive: true, + priority: 2, + }; + const c = renderComponent( + + ); + expect(c).toBeDefined(); + }); + + it("ConnectionRow renders cooldown badge when rateLimitedUntil is in the future", () => { + const conn = { + id: "conn-3", + name: "Rate-limited Key", + isActive: true, + priority: 1, + rateLimitedUntil: new Date(Date.now() + 60000).toISOString(), + testStatus: "unavailable", + }; + const c = renderComponent( + + ); + expect(c).toBeDefined(); + }); + + // ── ModelCompatPopover ───────────────────────────────────────────────────── + + it("ModelCompatPopover mounts in closed state without throwing", () => { + const c = renderComponent( + k} + effectiveModelNormalize={() => false} + effectiveModelPreserveDeveloper={() => true} + getUpstreamHeadersRecord={() => ({})} + onCompatPatch={vi.fn()} + /> + ); + expect(c).toBeDefined(); + }); + + it("ModelCompatPopover mounts with compact=true and disabled=true", () => { + const c = renderComponent( + k} + effectiveModelNormalize={() => true} + effectiveModelPreserveDeveloper={() => false} + getUpstreamHeadersRecord={() => ({ "X-Custom": "value" })} + onCompatPatch={vi.fn()} + compact={true} + disabled={true} + /> + ); + expect(c).toBeDefined(); + }); + + // ── SiliconFlowEndpointModal ─────────────────────────────────────────────── + + it("SiliconFlowEndpointModal mounts when isOpen=false (renders nothing visible)", () => { + const c = renderComponent( + + ); + expect(c).toBeDefined(); + }); + + it("SiliconFlowEndpointModal mounts when isOpen=true without throwing", () => { + const c = renderComponent( + + ); + expect(c).toBeDefined(); + }); +}); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx new file mode 100644 index 0000000000..df432143dc --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -0,0 +1,842 @@ +"use client"; + +// Issue #3501 Phase 1c — extracted from the god-component. +// ~787-LOC modal for adding a new API key / credential to a provider. + +import { useState, useEffect, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { Button, Badge, Input, Modal, Toggle } from "@/shared/components"; +import { + providerAllowsOptionalApiKey, + supportsBulkApiKey, +} from "@/shared/constants/providers"; +import { parseBulkApiKeys } from "@/shared/utils/bulkApiKeyParser"; +import { + isBaseUrlConfigurableProvider, + getProviderBaseUrlDefault, + getProviderBaseUrlHint, + getProviderBaseUrlPlaceholder, + isGlmProvider, + parseRoutingTagsInput, + parseExcludedModelsInput, + getWebSessionCredentialLabel, + getWebSessionCredentialHint, + getWebSessionCredentialCheckLabel, + getAddCredentialModalTitle, + getLocalProviderMetadata, + normalizeAndValidateHttpBaseUrl, + extractCommandCodeCredentialInput, + providerText, + type CommandCodeAuthFlowState, +} from "../../providerPageHelpers"; +import { getWebSessionCredentialRequirement } from "../../webSessionCredentials"; +import WebSessionCredentialGuide from "../WebSessionCredentialGuide"; + +export interface AddApiKeyModalProps { + isOpen: boolean; + provider?: string; + providerName?: string; + initialBaseUrl?: string; + isCompatible?: boolean; + isAnthropic?: boolean; + isCcCompatible?: boolean; + isCommandCode?: boolean; + commandCodeAuthState?: CommandCodeAuthFlowState; + onStartCommandCodeAuth?: () => void; + onSave: (data: { + name: string; + apiKey?: string; + priority: number; + baseUrl?: string; + providerSpecificData?: Record; + }) => Promise; + onClose: () => void; +} + +export default function AddApiKeyModal({ + isOpen, + provider, + providerName, + initialBaseUrl, + isCompatible, + isAnthropic, + isCcCompatible, + isCommandCode, + commandCodeAuthState, + onStartCommandCodeAuth, + onSave, + onClose, +}: AddApiKeyModalProps) { + const t = useTranslations("providers"); + const usesBaseUrl = isBaseUrlConfigurableProvider(provider); + const defaultBaseUrl = getProviderBaseUrlDefault(provider); + const isVertex = provider === "vertex" || provider === "vertex-partner"; + const isBedrock = provider === "bedrock"; + const showsRegion = isVertex || isBedrock; + const defaultRegion = isBedrock ? "eu-west-2" : "us-central1"; + const isGlm = isGlmProvider(provider); + const isQoder = provider === "qoder"; + const isCloudflare = provider === "cloudflare-ai"; + const localProviderMetadata = getLocalProviderMetadata(provider); + const isLocalSelfHostedProvider = !!localProviderMetadata; + const isGooglePse = provider === "google-pse-search"; + const webSessionCredential = getWebSessionCredentialRequirement(provider); + const isNoAuthWebSessionCredential = webSessionCredential?.kind === "none"; + const isWebSessionCredential = !!webSessionCredential && webSessionCredential.kind !== "none"; + const providerDisplayName = providerName || provider || ""; + const apiKeyOptional = + providerAllowsOptionalApiKey(provider) || Boolean(isNoAuthWebSessionCredential); + const commandCodeAuthPhaseLabel = commandCodeAuthState + ? { + idle: "Ready", + starting: "Starting…", + polling: "Waiting for browser…", + received: "Browser approved", + applying: "Applying key…", + applied: "Connected", + expired: "Link expired", + error: "Connection failed", + }[commandCodeAuthState.phase] + : null; + + const [formData, setFormData] = useState({ + name: "", + apiKey: "", + priority: 1, + baseUrl: initialBaseUrl || defaultBaseUrl, + cx: "", + region: showsRegion ? defaultRegion : "", + apiRegion: "international", + validationModelId: "", + routingTags: "", + excludedModels: "", + customUserAgent: "", + accountId: "", + consoleApiKey: "", + ccCompatibleContext1m: false, + passthroughModels: false, + }); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [showAdvanced, setShowAdvanced] = useState(false); + const [copiedCommandCodeField, setCopiedCommandCodeField] = useState(null); + const wasOpenRef = useRef(false); + + useEffect(() => { + const wasOpen = wasOpenRef.current; + wasOpenRef.current = isOpen; + if (!isOpen || wasOpen) return; + setFormData((current) => ({ + ...current, + baseUrl: initialBaseUrl || defaultBaseUrl, + })); + }, [defaultBaseUrl, initialBaseUrl, isOpen]); + + const bulkSupported = supportsBulkApiKey(provider); + const [mode, setMode] = useState<"single" | "bulk">("single"); + const [bulkText, setBulkText] = useState(""); + const [bulkValidateKeys, setBulkValidateKeys] = useState(false); + const [bulkResult, setBulkResult] = useState<{ + success: number; + failed: number; + total: number; + errors: Array<{ index: number; name: string; message: string }>; + } | null>(null); + const [bulkWarnings, setBulkWarnings] = useState([]); + const apiCredentialLabel = isQoder + ? t("personalAccessTokenLabel") + : webSessionCredential + ? getWebSessionCredentialLabel(t, webSessionCredential, apiKeyOptional) + : apiKeyOptional + ? `${t("apiKeyLabel")} (${t("optional").toLowerCase()})` + : t("apiKeyLabel"); + const apiCredentialPlaceholder = isVertex + ? t("vertexServiceAccountPlaceholder") + : isWebSessionCredential + ? webSessionCredential.placeholder + : isQoder + ? t("qoderPatPlaceholder") + : apiKeyOptional + ? t("optional") + : undefined; + const apiCredentialHint = isQoder + ? t("qoderPatHint") + : isWebSessionCredential + ? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false) + : isLocalSelfHostedProvider + ? t("localProviderApiKeyOptionalHint", { + provider: localProviderMetadata?.name || providerName || provider || "", + }) + : apiKeyOptional + ? t("apiKeyOptionalHint") + : undefined; + const credentialValidationFailedMessage = isWebSessionCredential + ? providerText( + t, + "webSessionCredentialValidationFailed", + "Session credential validation failed. Sign in again, copy a fresh credential, and try again." + ) + : t("apiKeyValidationFailed"); + + const handleValidate = async () => { + setValidating(true); + setSaveError(null); + try { + const credentialInput = isCommandCode + ? extractCommandCodeCredentialInput(formData.apiKey) + : formData.apiKey; + const res = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider, + apiKey: credentialInput, + validationModelId: formData.validationModelId || undefined, + customUserAgent: formData.customUserAgent.trim() || undefined, + baseUrl: formData.baseUrl.trim() || undefined, + region: showsRegion ? formData.region.trim() || defaultRegion : undefined, + cx: formData.cx.trim() || undefined, + }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + + const copyCommandCodeValue = async (value: string | undefined, key: string) => { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + setCopiedCommandCodeField(key); + window.setTimeout(() => setCopiedCommandCodeField(null), 1500); + } catch { + setSaveError("Copy failed. Select the text and copy it manually."); + } + }; + + const handleSubmit = async () => { + const credentialInput = isCommandCode + ? extractCommandCodeCredentialInput(formData.apiKey) + : formData.apiKey; + if (!provider || (!isCompatible && !apiKeyOptional && !credentialInput)) return; + + setSaving(true); + setSaveError(null); + try { + if (isGooglePse && !formData.cx.trim()) { + setSaveError(t("searchEngineIdRequired")); + return; + } + + let validatedBaseUrl = null; + if (usesBaseUrl) { + const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBaseUrl); + if (checked.error) { + setSaveError(checked.error); + return; + } + validatedBaseUrl = checked.value; + } + + let isValid = Boolean(isNoAuthWebSessionCredential && !credentialInput); + let validationError: string | null = null; + if (!isValid) { + try { + setValidating(true); + setValidationResult(null); + const res = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider, + apiKey: credentialInput, + validationModelId: formData.validationModelId || undefined, + customUserAgent: formData.customUserAgent.trim() || undefined, + baseUrl: formData.baseUrl.trim() || undefined, + region: showsRegion ? formData.region.trim() || defaultRegion : undefined, + cx: formData.cx.trim() || undefined, + }), + }); + const data = await res.json(); + isValid = !!data.valid; + if (!isValid && data.error) { + validationError = data.error; + } + setValidationResult(isValid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + } + + if (!isValid) { + if (apiKeyOptional && !credentialInput) { + // Bypass validation block for local/optional providers when no key is provided + console.debug("Validation failed but apiKey is optional; proceeding to save."); + } else { + setSaveError(validationError || credentialValidationFailedMessage); + return; + } + } + + const providerSpecificData: Record = {}; + if (formData.customUserAgent.trim()) { + providerSpecificData.customUserAgent = formData.customUserAgent.trim(); + } + if (formData.routingTags.trim()) { + providerSpecificData.tags = parseRoutingTagsInput(formData.routingTags); + } + if (formData.excludedModels.trim()) { + providerSpecificData.excludedModels = parseExcludedModelsInput(formData.excludedModels); + } + if (formData.passthroughModels) { + providerSpecificData.passthroughModels = true; + } + if (provider === "bailian-coding-plan" && formData.consoleApiKey.trim()) { + providerSpecificData.consoleApiKey = formData.consoleApiKey.trim(); + } + if (isGooglePse && formData.cx.trim()) { + providerSpecificData.cx = formData.cx.trim(); + } + if (usesBaseUrl) { + providerSpecificData.baseUrl = validatedBaseUrl; + } else if (showsRegion) { + providerSpecificData.region = formData.region.trim() || defaultRegion; + } else if (isGlm) { + providerSpecificData.apiRegion = formData.apiRegion; + } else if (isCloudflare && formData.accountId.trim()) { + providerSpecificData.accountId = formData.accountId.trim(); + } + if (isCcCompatible && formData.ccCompatibleContext1m) { + providerSpecificData.requestDefaults = { context1m: true }; + } + + const payload = { + name: formData.name, + apiKey: credentialInput.trim() || undefined, + priority: formData.priority, + testStatus: "active", + providerSpecificData: + Object.keys(providerSpecificData).length > 0 ? providerSpecificData : undefined, + }; + + const error = await onSave(payload); + if (error) { + setSaveError(typeof error === "string" ? error : t("failedSaveConnection")); + } + } finally { + setSaving(false); + } + }; + + const handleBulkSubmit = async () => { + if (!provider) return; + const parsed = parseBulkApiKeys(bulkText); + setBulkWarnings(parsed.warnings); + if (parsed.entries.length === 0) return; + + setSaving(true); + setBulkResult(null); + setSaveError(null); + + try { + let providerSpecificData: Record | undefined; + if (usesBaseUrl) { + const checked = normalizeAndValidateHttpBaseUrl(formData.baseUrl, defaultBaseUrl); + if (checked.error) { + setSaveError(checked.error); + return; + } + providerSpecificData = { baseUrl: checked.value }; + } + + const res = await fetch("/api/providers/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider, + entries: parsed.entries.map((e) => ({ name: e.name, apiKey: e.apiKey })), + priority: formData.priority || 1, + providerSpecificData, + validateKeys: bulkValidateKeys, + }), + }); + const data = await res.json(); + if (!res.ok) { + setSaveError(typeof data?.error === "string" ? data.error : t("failedSaveConnection")); + return; + } + setBulkResult({ + success: data.success || 0, + failed: data.failed || 0, + total: data.total || 0, + errors: Array.isArray(data.errors) ? data.errors : [], + }); + } catch (err) { + setSaveError(err instanceof Error ? err.message : t("failedSaveConnection")); + } finally { + setSaving(false); + } + }; + + if (!provider) return null; + + return ( + +
+ {bulkSupported && ( +
+ + +
+ )} + + {bulkSupported && mode === "bulk" && ( +
+

{t("bulkAddFormatHint")}

+