mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
* chore(ci): add .mergify.yml to main — Mergify only reads config from the default branch (#7168) * fix(ci): add the auto-enqueue pull_request_rule to the Mergify config (queue_conditions alone are eligibility-only) (#7179) * fix(ci): migrate Mergify auto-enqueue to merge_protections_settings.auto_merge_conditions (rules-based path is EOL 2026-07-16) (#7216) * fix(ci): drop Mergify batch settings (batching is a paid-tier feature; free plan queue is serial) (#7220) * fix(ci): merge queue tolerates the advisory dast-smoke failure (its GH-hosted build hang dequeued every attempt) (#7225) * test(ci): make the #6634 selfref guard hermetic — main's copy hard-fails every PR (#7341) main's copy of this test still does git I/O inside a unit test: const baseSrc = git(['show', 'origin/main:' + FILE]); Runners check out a shallow single ref, so origin/main does not resolve and the test dies with 'fatal: invalid object name origin/main'. Every PR into main fails Unit Tests (7/8) on it — today that is #7313, #7315, #7316, #7334, #7336 and #7337, six PRs red on a defect none of them introduced. #7313 has no other red at all. release/v3.8.49 already carries a fix (2e42b8efc, #7174: try/catch, fetch origin/main on demand, t.skip() when unreachable), but it only reaches main at release time — so main stays broken for the whole cycle. Cherry-picking it would also import a new problem: PR Test Policy classifies t.skip() as a silenced assertion, which we watched it correctly catch on #7300 today. This is the hermetic version instead (ported from #7327, which does the same for the release branch): read the file straight off disk, compare against an empty base so baseTaut/baseExtTaut are 0 — the strictest possible comparison point — and call evaluateMasking() directly. No git ref, no fetch, no skip, nothing the runner's checkout depth can break. The #6634 regression stays covered: the guard's logic lives in SELF_TEST_FIXTURE_RE (check-test-masking.mjs:337), not in the test. Proven both ways on main before committing — neutralise SELF_TEST_FIXTURE_RE to /$^/ and the test FAILS; restore it and it passes 2/2, with check-test-masking.mjs left byte-identical. Co-authored-by: growab <nekron@icloud.com> * chore(quality): tighten main's coverage baseline to the CI's real numbers (#7347) main's ratchet had been failing --require-tighten on every PR: 11 metrics improved but the baseline was never tightened. Same class as the #6634 selfref guard — an infra fix that lands only on the release branch leaves main red for the whole cycle, and every PR into main pays for it. Values are the merged-coverage numbers from a run on main itself (a local run measures ~68% vs CI's ~80%; the baseline's own note warns about that gap). Only the 11 coverage values change — gitleaks and semgrepFindings keep main's own state. No changelog fragment: #7326 carries it on release/v3.8.49, and a second one here would double the entry at release time. * Add cliproxy provider exposure controls and manifest injection (#7329) * feat(fusion): let judge use its own knowledge and override the panel (#6804) The judge prompt said to write an answer 'grounded in that analysis', implicitly capping output at the panel's union. When all panel members miss or are collectively wrong on something, the judge should apply its own reasoning as a full participant and override consensus, while keeping an honesty guard against fabrication. Adds a regression test. Co-authored-by: Chirag Singhal <chirag127@users.noreply.github.com> * fix(api): raise provider apiKey cap for cookie-based web providers (#6715) (#6759) * fix(cli): fall back to settings.json when Claude Code binary is unresolvable (#6701) (#6734) getCliRuntimeStatus() only ever answered `installed` from binary resolution (known install paths + where/which PATH search), so a stale PATH, moved binary, or uncatalogued install method reported "not found" even when ~/.claude/settings.json proved the CLI was installed and used before — regressing behind upstream 9router's checkClaudeInstalled(), which already falls back to the settings file when where/which fails. withSettingsFallback() (new src/shared/services/cliInstallFallback.ts, kept out of the frozen cliRuntime.ts to respect its file-size ceiling) restores that parity: only when the binary lookup's own reason is "not_found" (never for deliberate security rejections like unsafe/relative env overrides or symlink escapes) and the tool's settings file exists on disk. * fix(providers): honor explicit thinking.budget_tokens 0 in openai->gemini transform (#6813) (#6821) The transform forwarded the Claude-style thinking.budget_tokens into generationConfig.thinkingConfig.thinkingBudget, but the presence check was truthy (&& thinking.budget_tokens). An explicit budget_tokens: 0 — the natural way to disable thinking — is falsy, so it was dropped and the request fell through to the default thinkingConfig injection, making the model think despite an explicit request for zero. Use an explicit numeric check so 0 is honored as thinkingBudget 0; includeThoughts is only set for a non-zero budget. * fix(compression): reconcile outer vs per-engine token counts (#6488) (#6741) * fix(compression): reconcile outer vs per-engine token counts on degenerate output (#6488) Outer originalTokens/compressedTokens (real tiktoken counter over extracted message text) diverged from engineBreakdown[0]'s counts (a crude JSON.stringify(requestBody).length/4 estimate), worst on small/degenerate inputs where JSON structural overhead dominates. A single-engine breakdown entry represents the exact same before/after transformation as the overall response, so reconcileSingleEngineTokens() now overwrites that one entry's counts with the outer, more accurate figures; multi-step pipeline breakdowns are left untouched. * chore(6741): resolve release sync — CHANGELOG.md restored to release tip, entry moved to changelog.d fragment (fragments-first) * fix(api): accept enableRenderers in RTK compression config schema (#6703) (#6757) * fix(db): break probe-failed/restore loop on large storage.sqlite (#6632) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's changes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * feat(cursor): add Opus 4.8, Fable 5, and Sonnet 5 model families (#6779) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's cursor registry + test changes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(translator): read PDF/video file attachments for Gemini/Antigravity and Claude (#6790) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's translator + test changes. Co-authored-by: Wital <witalorocha216@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(codex): strip include from compact responses requests (#6805) * fix(codex): strip include from compact responses requests Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's changes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(6805): move include-strip assertion to standalone test file to keep executor-codex.test.ts under frozen size cap Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(i18n): translate hardcoded Portuguese dashboard strings to English (#6769) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's changes. Co-authored-by: Chirag Singhal <chirag127@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(bootstrap): filter empty process.env values to prevent Docker env crash loop (#6828) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR) and the direct CHANGELOG.md edit (fragments-first); keeps only the author's bootstrap change. Co-authored-by: Andrian B. <andrewbalanesq@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(providers): update SenseNova Token Plan support (#6330) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); the author's constants/registry/snapshot deltas were re-applied cleanly onto the release tip. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(providers): classify 404 as MODEL_NOT_FOUND to stop retry storm (#6829) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR) and the direct CHANGELOG.md edit (fragments-first); the author's chatCore/errorClassifier deltas were re-applied cleanly onto the release tip. Co-authored-by: Andrian B. <andrewbalanesq@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(api): accept all catalog engines on compression PUT schema (#6792) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR). Resolved the release's OmniGlyph engine addition additively (types.ts/compression.ts kept both 'relevance' and 'omniglyph') and extended stackedPipelineStepSchema + STACKED_PIPELINE_ENGINE_INTENSITIES with the omniglyph branch so the ENGINE_CATALOG-parity test passes. Co-authored-by: Pitchfork-and-Torch <Pitchfork-and-Torch@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(api): point CLI health command at /api/monitoring/health (#6677) (#6717) * fix(api): point CLI health command at /api/monitoring/health (#6677) bin/cli/commands/health.mjs called GET /api/health, a route that was moved to /api/monitoring/health without updating the CLI; the top-level /api/health handler never existed on disk (only degradation/ and ping/ sub-routes). Point runHealthCommand()/runHealthComponentsCommand() at /api/monitoring/health and read its real payload shape (activeConnections, circuitBreakers: {open,halfOpen,closed}, memoryUsage) instead of the old nonexistent requests/breakers/cache/memory fields. * chore(6717): re-sync onto release tip; move CHANGELOG entry to changelog.d fragment (fragments-first) * chore(cursor): add Grok 4.5 effort/fast model IDs (#6774) Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's changes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(providers): ensure DeepSeek Web SSE emits [DONE] after FINISHED (#6791) * fix(providers): ensure DeepSeek Web SSE emits [DONE] after FINISHED Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); keeps only the author's changes. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(deepseek): extract done-terminator helper to keep frozen file under cap Extracts the FINISHED-drain scheduler and finish-once guard added for the [DONE] terminator fix (#6777) into a new deepseek-web-done-terminator.ts module, so deepseek-web.ts stays under its frozen line cap (1148). Behavior is unchanged. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Pitchfork-and-Torch <Pitchfork-and-Torch@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * feat(models): add capability override UI (#6727) * feat(models): add capability override UI Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); renumbered the migration 118 -> 119 to resolve the collision with 118_provider_param_filters.sql already on release/v3.8.47; the author's i18n/localDb deltas were re-applied cleanly onto the release tip. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(6727): import model-capability-overrides DB fns directly (not via localDb barrel) to keep localDb under file-size cap; aligns with anti-barrel convention * chore(db): satisfy known-symbols contract for modelCapabilityOverrides Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> * fix(cursor): use Agent CLI build id for x-cursor-client-version (#6795) * fix(cursor): use Agent CLI build id for x-cursor-client-version Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); the author's .env.example/docs deltas were re-applied cleanly onto the release tip. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(changelog): re-sync CHANGELOG.md to release tip (restore #6701 bullet) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(startup): rename reasoningControls.ts to avoid webpack casing collision (#6584) (#6718) * fix(startup): rename reasoningControls.ts to avoid webpack casing collision (#6584) * chore(6718): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(build): suppress Turbopack over-bundling warning from agentSkills generator (#6582) (#6720) * fix(build): suppress Turbopack over-bundling warning from agentSkills generator (#6582) generator.ts builds outputBase from a non-literal outputDir parameter, so Turbopack's file-tracing analyzer can't narrow it and emits an "Overly broad patterns" warning per entry point that imports the module (603 warnings on v3.8.46, up from 379). The fs access is legitimate and bounded, so next.config.mjs now suppresses this specific diagnostic via turbopack.ignoreIssue, mirroring the existing webpack.ignoreWarnings precedent in the same file. * chore(6720): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(providers): drop image_generation for Codex Spark models regardless of plan (#6651) (#6721) * fix(providers): drop image_generation for Codex Spark models regardless of plan (#6651) * chore(6721): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(providers): stop quota card re-sorting Codex/GLM bars by remaining % (#6687) (#6722) * fix(providers): stop quota card re-sorting Codex/GLM bars by remaining % (#6687) QuotaCardExpanded.tsx unconditionally re-sorted quotas by remaining percentage via sortQuotasByRemaining(), discarding the deterministic CODEX_QUOTA_ORDER/GLM_QUOTA_ORDER window order quotaParsing.ts's sortCodexOrder()/sortGlmOrder() had already established. A new hasFixedQuotaOrder() + resolveQuotaDisplayOrder() skip the re-sort for providers with a fixed window order (codex, glm family), threading providerId from QuotaCard.tsx through to the display layer. Regression guard: tests/unit/quota-card-expanded-fixed-order-6687.test.ts * chore(6722): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(startup): lazy-import ioredis in rateLimiter to fix MCP ERR_MODULE_NOT_FOUND (#6559) (#6725) * fix(startup): lazy-import ioredis in rateLimiter to fix MCP ERR_MODULE_NOT_FOUND (#6559) * chore(6725): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(resilience): resolve fp-pinned combo account back to real connection id (#6696) (#6732) * fix(resilience): resolve fp-pinned combo account back to real connection id (#6696) * chore(6732): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(api): Responses passthrough emits event-only SSE frames after filtering commentary output (#6561) (#6735) * fix(api): Responses passthrough emits event-only SSE frames after filtering commentary output (#6561) The #6199 commentary-drop `continue;` branches in stream.ts skipped the data: line for a dropped commentary event but never cleared the already-buffered event: line for the same frame, so the next blank line flushed the stale event: line alone -- an event-only SSE frame that crashes the OpenAI Python SDK's json.loads(). Both drop sites now call clearPendingPassthroughEvent() before continue. The commentary-drop decision was extracted into a new responsesCommentaryDrop.ts module so the fix does not grow the frozen stream.ts. * chore(6735): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(api): emit reasoning_content on claude-web + v0-vercel-web SSE (#6662) (#6743) * fix(api): emit reasoning_content on claude-web + v0-vercel-web /v1/chat/completions SSE (#6662) * chore(6743): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(sse): unwrap bare {function:{…}} tools in openai→claude translation (#6704) * fix(sse): unwrap bare {function:{…}} tools in openai→claude translation Some OpenAI-shape clients send a tool as a bare `{ function: {...} }` object, omitting the spec-required `type: "function"` parent wrapper. The tools-mapping in openai-to-claude.ts (~line 366) only unwrapped `tool.function` when `tool.type === "function"` was ALSO true, so a bare-function tool fell through to `toolData = tool` (the wrapper itself, with no `.name`), producing an empty `originalName` and silently dropping the tool from the translated request — worse than a 400, since the caller has no signal the tool never made it upstream. Unwrap `tool.function` whenever present, independent of the parent `type` field. Regression guard: tests/unit/openai-to-claude-bare-tool.test.ts. Co-authored-by: Samir Abis <me@samirabis.com> Inspired-by: https://github.com/decolua/9router/pull/2473 * chore(6704): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) --------- Co-authored-by: Samir Abis <me@samirabis.com> * fix(oauth): avoid bare-email dedup of Codex OAuth logins (#6706) * fix(oauth): avoid bare-email dedup of Codex OAuth logins When an incoming Codex OAuth connection has no verifiable workspace/account id, do not merge it into an existing row on email match alone — that silently overwrote the other account's token pair. Require a matching chatgptUserId (a stable per-account JWT id) before merging; otherwise insert a distinct connection row. Co-authored-by: lucasjustinudin <34107354+lucasjustinudin@users.noreply.github.com> Inspired-by: https://github.com/decolua/9router/pull/2477 * chore(6706): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) --------- Co-authored-by: lucasjustinudin <34107354+lucasjustinudin@users.noreply.github.com> * fix(sse): skip thinkingConfig for gemma models in openai→gemini translation (#6708) open-sse/translator/request/claude-to-gemini.ts already guards against sending thinkingConfig for gemma-4-* models (Gemma doesn't support it — Vertex returns 400: "Thinking budget is not supported for this model"), but the OpenAI-shape path (openai-to-gemini.ts) lacked the same guard, so OpenAI-shape clients hitting a vertex gemma-4-* model still got a 400. Mirrors the existing claude-to-gemini.ts guard: wrap the reasoning_effort and Claude-shape thinking.budget_tokens branches with a model.startsWith ("gemma-4") check. Branch 3 (default includeThoughts for modern Gemini models) already excludes non-"gemini" model ids and needed no change. Inspired-by: https://github.com/decolua/9router/pull/2480 Co-authored-by: chy1211 <31048289+chy1211@users.noreply.github.com> * fix(codex): surface capacity errors embedded in 200-OK SSE streams (#6710) * fix(codex): surface capacity errors embedded in 200-OK SSE streams Codex sometimes answers with HTTP 200 and a text/event-stream body whose payload carries a transient error mid-stream (e.g. "Selected model is at capacity...", server_is_overloaded, service_unavailable_error). Because the outer HTTP status was 200, this looked like a successful response to every caller — no retry, no circuit breaker, and no combo/account fallback ever engaged, so a healthy account sat idle while the request silently failed or truncated. Add peekCodexSseTransientError() to open-sse/executors/codex.ts: it peeks the first bytes of a text/event-stream Codex response, pattern-matches the known transient-error signatures, and converts a match into a real 503 Response via errorResponse() (Hard Rule #12 — sanitized, never raw upstream text). A 503 is already a recognized provider-failure status in accountFallback.ts, so combo routing and connection cooldown pick it up automatically. When no error signature is found, the peeked prefix is prepended back onto the remaining upstream body so the passthrough stays byte-identical to the unmodified response. Regression guard: tests/unit/codex-sse-capacity-fallback.test.ts — a model-at-capacity payload and a server_is_overloaded/service_unavailable_error payload both convert to 503; a normal single-chunk SSE stream and one split across multiple network chunks both reassemble byte-for-byte unchanged. Inspired-by: https://github.com/decolua/9router/pull/2452 (sub-bug #3 only — OmniRoute already covers PR #2452's other two sub-bugs: service_tier "fast" normalization and reasoning_effort "max" normalization). Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com> * chore(6710): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) --------- Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com> * fix(volcengine): clamp Kimi max_tokens to Ark endpoint cap (#6712) * fix(volcengine): clamp Kimi max_tokens to Ark endpoint cap VolcEngine Ark's Kimi coding-plan endpoint (ark.cn-beijing.volces.com) enforces max_tokens <= 32768 server-side and returns 400 "integer above maximum value, expected a value <= 32768" for anything over that ceiling. OmniRoute's StripRule only supported dropping params outright, with no numeric clamp mechanism, so a client sending a larger max_tokens (common default, e.g. 65536) 400s outright against volcengine's kimi-k2-5-260127. The 32768 cap is independently confirmed against two live-endpoint bug reports hitting this exact Ark endpoint for both kimi-k2.5 and kimi-k2.7-code (NousResearch/hermes-agent#51773, MoonshotAI/kimi-cli#1124), not just upstream's own value — same cap upstream 9router#2460 uses. StripRule gains two optional fields: `clampToModelMaxOutput` (clamp to the model's own catalog maxOutputTokens ceiling, when set) and `maxOutputCap` (a fixed endpoint-imposed ceiling); when both apply, the lower wins. The new rule is scoped to the literal id `kimi-k2-5-260127` (OmniRoute's real volcengine Kimi model, not upstream's `Kimi-K2.7-Code`), not a broad /kimi/i regex, so it can never clamp an unrelated future Kimi listing whose Ark cap may differ. glm-4-7-251222 (the other volcengine model) is unaffected. Inspired-by: https://github.com/decolua/9router/pull/2460 Co-authored-by: whale9820 <whale9820@users.noreply.github.com> * chore(6712): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) --------- Co-authored-by: whale9820 <whale9820@users.noreply.github.com> * fix(antigravity): surface aborted Gemini tool calls off end_turn (#6713) * fix(antigravity): surface aborted Gemini tool calls off end_turn Gemini/Antigravity aborts a turn with finishReason MALFORMED_FUNCTION_CALL (or a sibling like UNEXPECTED_TOOL_CALL) instead of completing cleanly. Both Claude-facing translators collapsed these to a clean end_turn, hiding the aborted tool call as a successful completion: - the OpenAI hub path (openai-to-claude.ts convertFinishReason default), and - the DIRECT Gemini->Claude path (gemini-to-claude.ts), which is the one Claude Code actually hits through an antigravity/Gemini-routed model. Add isAbortFinishReason() to finishReason.ts and map these reasons to tool_use on both paths; genuinely unknown reasons still fall back to end_turn. Co-authored-by: anhdiepmmk <n08ni.dieppn@gmail.com> Inspired-by: https://github.com/decolua/9router/pull/2462 * chore(6713): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) --------- Co-authored-by: anhdiepmmk <n08ni.dieppn@gmail.com> * fix(translator): strip empty cloud_base_branch from Cursor Subagent tool call (#6729) * fix(translator): strip empty cloud_base_branch from Cursor Subagent tool call (port from 9router#2446) The Responses->Chat tool-arg cleanup (stripEmptyOptionalToolArgs) only stripped empty-string/empty-array optional args for Claude Code's Read tool. Cursor's local Subagent tool call therefore passed through with the cloud-only field cloud_base_branch: "", which Cursor rejects ("cloud_base_branch may only be specified when environment equals cloud") before starting the subagent. Extend the cleanup to an allowlist of Read + Subagent; arbitrary tools stay untouched. Reported-by: like3213934360-lab (https://github.com/decolua/9router/issues/2446) * chore(6729): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) * fix(translator): defer content_block_start until GLM streams the tool name (#6730) * fix(translator): defer content_block_start until GLM streams the tool name (port from 9router#2077) GLM 5.2 (and similar OpenAI-compatible upstreams) stream a tool call's id and function.name across separate SSE delta chunks. The openai-to-claude streaming translator emitted content_block_start immediately on the id-only chunk with an empty name; the Claude SSE protocol cannot patch a block after emission, so the later name-only chunk was dropped and Claude Code rejected the tool_use with an empty tool name / "No such tool available:". Defer content_block_start until the name arrives (start on args if they arrive first), and emit a start for any orphaned id-only tool call at finish so content_block_stop is never orphaned. Reported-by: itiwant (https://github.com/decolua/9router/issues/2077) * chore(6730): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) * feat(dashboard): add search to Playground model picker dropdown (#4086) (#6811) * feat(dashboard): add search to Playground model picker dropdown (#4086) The shared ModelSelectModal (combo builder + CLI-code cards) already had search, but the Playground's raw model <select> in StudioConfigPane stayed a flat unsearchable list - unusable once a provider like OpenRouter contributed 50+ models. Adds a search input above the dropdown that filters options via filterModelsByQuery() (Turkish-safe accent/case-insensitive match, reusing matchesSearch()). The currently selected model always stays pinned in the list even when it doesn't match the query, so typing never silently swaps the active selection. Reuses the existing common.search i18n key already translated in all 42 locales - no new key needed. * chore(6811): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) * feat: request count log per provider, per date (#4009) (#6812) * feat(dashboard): request count log per provider, per date (#4009) Some providers bill by request rather than by token, so operators need a plain per-provider, per-date request count breakdown, not just token aggregates. Adds a new getProviderDailyUsageRows() aggregation query (src/lib/db/usageAnalytics.ts), a dedicated GET /api/usage/requests-by-provider-date route (kept separate from the frozen /api/usage/analytics route to respect the file-size baseline), and a sortable, single-date-filterable table on Dashboard -> Analytics. Closes #4009 * chore(6812): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) * feat(xai): route xAI clients to Grok native /v1/responses endpoint (#6709) * feat(xai): route xAI clients to Grok native /v1/responses endpoint xAI ships a native /v1/responses endpoint (https://api.x.ai/v1/responses) alongside /v1/chat/completions, but XaiExecutor extended BaseExecutor without overriding buildUrl(), so every request always resolved to the static chat-completions baseUrl regardless of target format — the last genuinely-missing slice of decolua/9router#2439 (grok-build-0.1, the reasoning-effort suffix routing, and bare grok-* routing were already ported in prior cycles). Add responsesBaseUrl to the xai registry entry and tag grok-4.20-multi-agent-0309 (upstream's own Responses-only id) with targetFormat: "openai-responses", mirroring the existing model-tag-driven routing pattern already used by the gh executor (9router#102) and the "openai" -pro heuristic in open-sse/executors/default.ts — the per-model registry tag is the single source of truth that also drives chatCore's body translation, so URL and body stay in lockstep. XaiExecutor.buildUrl now checks getModelTargetFormat("xai", model) and resolves to the native Responses endpoint only for tagged models, leaving every other grok-* model on the existing chat-completions bridge. TDD: tests/unit/executor-xai.test.ts adds a RED-then-GREEN case asserting grok-4.20-multi-agent-0309 resolves to https://api.x.ai/v1/responses and a control case asserting grok-4.3 still resolves to https://api.x.ai/v1/chat/completions. Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com> Inspired-by: https://github.com/decolua/9router/pull/2439 * chore(6709): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) --------- Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com> * fix(resilience): route remaining credential-selection call sites through quota preflight (#6686) (#6742) * fix(resilience): route remaining credential-selection call sites through quota preflight (#6686) * chore(6742): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(resilience): apikey-provider 429s honor explicit quota-exhausted text (#6638) (#6731) * fix(resilience): apikey-provider 429s honor explicit quota-exhausted text (#6638) Ollama Cloud (and any other apikey-category provider) 429s skipped body-text quota classification entirely; a genuine multi-day quota exhaustion was misclassified as a plain rate_limit_exceeded with a few seconds of cooldown, so combo routing retried the account immediately. shouldPreserveQuotaSignals() now lets an explicit quota-exhausted signal (looksLikeQuotaExhausted) override the apikey-category default, and parseDayGranularityResetMs() adds day- granularity reset-hint parsing ("...reset in 3 days.") alongside the existing Xh/Ym/Zs parsing. Regression guard: tests/unit/issue-6638-ollama-quota.test.ts (RED before the fix, GREEN after). Aligned two tests/unit/account-fallback-service.test.ts cases that had codified the old buggy behavior for apikey-provider quota text. * chore(6731): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * feat(resilience): weekly-429 cooldown for fetcher-less providers (#3709) (#6817) * feat(resilience): weekly-429 cooldown for fetcher-less providers (#3709) Ollama Cloud free-tier accounts have a hard WEEKLY request cap. On cap the upstream returns 429 "you (<account>) have reached your weekly usage limit", but ollama-cloud is an apikey-category provider, so the existing oauth-only shouldUseQuotaSignal gate in checkFallbackError skips the subscription-quota-text classifier (Issue #2321) for its 429s -- the account fell through to the generic exponential backoff (~1s, capped at 2min) and got retried every few minutes for the rest of the week (one account took 285x429 in 48h). Adds a new, ungated weekly-usage-limit text classifier that applies a 24h QUOTA_EXHAUSTED cooldown regardless of provider category. Extracted the new classifier -- together with the existing #2321 subscription-quota logic -- into a new open-sse/services/quotaTextCooldowns.ts module so the frozen accountFallback.ts (file-size-baseline cap) didn't have to grow; net effect shrinks accountFallback.ts by 20 lines. This is Phase A of the plan (open-sse/services/accountFallback.ts:1038-1045 "weekly-429 cooldown"); Phase B (generic local request-counter preflight for manual provider_plans dimensions) is a separate, larger follow-up per the plan's own phasing. * chore(6817): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * fix(providers): Kiro adaptive-thinking allowlist excludes sonnet-4.5/haiku-4.5 (#6576) (#6726) * fix(providers): Kiro adaptive-thinking allowlist excludes sonnet-4.5/haiku-4.5 (#6576) * chore(6726): re-sync onto release tip; CHANGELOG entry → changelog.d fragment (fragments-first) * test(kiro): migrate selector-strip test to claude-sonnet-5 (only Kiro adaptive-thinking model, #6576) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(quality): rebaseline complexity 2053->2054 (merge-burst drift, v3.8.47) Inherited drift from today's /implement-prs merge burst (~36 PRs). check:complexity does not run on the PR->release fast-path, so the branch accrued +1 unmeasured. No orphan/feature PR introduces a NEW violation (complexity-net-zero); the only flagged function is the pre-existing getResolvedModelCapabilities. Owner-approved rebaseline to unblock the FQG of ~7 green-except-complexity orphans. * chore(stryker): register ollama-quota covering tests (merge-burst drift, v3.8.47) The 3 covering unit tests from #6731/#6817/#6742 (issue-6638-ollama-quota, ollama-cloud-weekly-quota-cooldown-3709, issue-6686-quota-preflight-coverage) exist on release but were never added to tap.testFiles when those PRs merged. Completes the registration so mutant kills count; unblocks every PR touching a mutated module. Part of the owner-approved merge-burst drift cleanup. * fix: auto-start WS server in-process and change default port to 20132 (#6072) * feat: change default LIVE_WS_PORT from 20129 to 20132 Update the default WebSocket port for the live dashboard server from 20129 to 20132 across all configuration files, documentation, code comments, and tests. Also consolidate OMNIROUTE_DISABLE_LIVE_WS and OMNIROUTE_ENABLE_LIVE_WS into a single OMNIROUTE_ENABLE_LIVE_WS flag. Wire the live WebSocket server to start in-process via instrumentation-node.ts. * feat: clarify NEXT_PUBLIC_LIVE_WS_PUBLIC_URL path usage and derive upgrade path from URL Update .env.example and ENVIRONMENT.md to document that the pathname portion of NEXT_PUBLIC_LIVE_WS_PUBLIC_URL (e.g. /live-ws) is used as the WebSocket upgrade path by the dev proxy, handshake response, and client connection logic. Extract deriveLiveWsPath() into shared/utils/wsPath.ts and wire it through: - src/app/api/v1/ws/route.ts — handshake response path field - src/hooks/useLiveDashboard.ts — build * fix: use the standard URL API to safely parse and update the effectiveWsUrl * build(docker): expose live WebSocket server port and configure CORS origins Add LIVE_WS_PORT (20132), LIVE_WS_HOST (0.0.0.0), and LIVE_WS_ALLOWED_ORIGINS environment variables to all Docker Compose profiles and expose the WebSocket port mapping. Prevent infinite self-loop in standalone-server-ws.mjs by skipping proxy when the server itself is running on the LiveWS port. * docs(env): fix comment formatting for HOST and HOSTNAME variables --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(logs): prevent stale detail refresh reopening modal (#6323) * fix(logs): prevent stale detail refresh reopening modal * chore(stryker): register ollama-quota covering tests (release drift from merge burst) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * \ feat: operator-configurable account rotation\ (#6763) * feat(resilience): operator-configurable account rotation Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR); the author's accountFallback/.env deltas were re-applied cleanly onto the release tip. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * docs(env): document configurable account-rotation env vars in ENVIRONMENT.md Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(rotation): extract rotation gate/context helpers to keep accountFallback.ts under frozen cap Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(changelog): re-sync CHANGELOG.md to release tip (restore lost base bullet) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test(stryker): register rotation-config test in tap.testFiles for mutation coverage Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test(stryker): register ollama-quota covering tests (drift from #6731/#6817/#6742) + re-sync Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> * fix(lmarena): modernize Arena web provider + static Direct-chat catalog (#6280) * fix(lmarena): modernize Arena web provider + static Direct-chat catalog Update the lmarena provider for arena.ai (product rebranded from LMArena): - Route chat via arena.ai create-evaluation with Chrome TLS impersonation (tls-client-node) and optional browser-minted recaptchaV3Token. - Seed Text+Search (48) into the chat registry; seed Image (27) only into IMAGE_PROVIDERS. Disable live HTML model discovery; resolve public names to Arena UUIDs from the static TypeScript allowlist (no scrape JSON in-repo). - Soft-exclude 404/502 model ids; slow/stop bulk test-all probes for this provider. - Do not fold IMAGE_PROVIDERS/video specialty into the chat provider catalog when a chat registry already exists (lmarena/openai/xai). - Display name Arena (Free); keep wire id `lmarena` / alias `lma` for back-compat. - Theme-aware provider icons: arena-light.svg / arena-dark.svg. - Preserve split Supabase SSR cookie reconstruction for arena-auth-prod-v1.*. * fix(providers): align provider-models-route test fixture + regen provider reference Fold the topaz image-only catalog entry's apiFormat/supportedEndpoints into the local-catalog test fixture (route now tags media-only providers per the lmarena PR's staticModels.ts change), regenerate PROVIDER_REFERENCE.md against the merged release providers.ts, and add the changelog fragment for #6280. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test: align web-cookie fallback suite — lmarena now has a registry entry (probe path) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * docs(changelog): reconcile 3-day merge burst — 16 fragments, 4 promised credits, contributors hall 32→63 - changelog.d fragments for the 20 merged PRs that landed without a bullet (#6072 #6308 #6323 #6538 #6556 #6586 #6611 #6647 #6675 #6698 #6757 #6759 #6804 #6821 + ci rollup #6781/#6691/#6693 + docs rollup #6643/#6644/#6646/#6663; omniglyph bump #6661 folded into the #6556 bullet) - deliver the 4 credits promised in close comments but never written: @alltomatos (#6819 dup of #6721), @samimozcan (#6762/#6753 subsumed by #6790), @chirag127 (#6756 dup of #6757), @Squawk7777 (#6565 dup of #6564 — appended to the existing #6564 bullet; changelog-integrity flags that edit as a removal, intentional: ALLOW_CHANGELOG_REMOVALS justification) - rebuild the v3.8.47 Contributors hall from merged-PR authors + thanks credits + prior hall: 32 → 63 contributors * Clamp reasoning token buffer to model output cap (#6714) * fix(combo): clamp reasoning buffer to model output cap * fix(routing): preserve near-cap reasoning max tokens * fix(routing): getExplicitModelOutputCap falls through to registry cap on non-numeric synced limit_output getExplicitModelOutputCap short-circuited to null whenever a synced capability row existed, even if that row's limit_output was not a number (models.dev commonly omits it). That silently disabled the reasoning-token buffer clamp for any model with a synced row lacking an output limit. Now only return the synced value when it IS a number; otherwise fall through to registryModel.maxOutputTokens / spec.maxOutputTokens, matching the ??-chain precedence already used by getResolvedModelCapabilities(). Adds a standalone regression test (proves the fallthrough returns the real registry cap, not null) and hardens the #6274 fixture id so its no-output-cap case does not prefix-match the real glm-5.2 static spec. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(stryker): register ollama-quota covering tests (release drift from merge burst) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * feat(i18n): add Traditional Chinese (zh-TW) localization for frontend and CLI (#6320) * feat(i18n): add Traditional Chinese (zh-TW) localization for frontend and CLI - Add src/i18n/messages/zh-TW.json translating frontend web UI - Add bin/cli/locales/zh-TW.json translating CLI commands and descriptors - Register zh-TW in config/i18n.json and docs/guides/I18N.md - Update scripts/i18n/generate-multilang.mjs matching the new locale setup * fix: update i18n locale count from 42 to 43 after adding zh-TW The docs strict checker (check-docs-counts-sync.mjs) validates that README.md and I18N.md reflect the real locale count. Adding zh-TW bumped the count from 42 → 43. * fix(i18n): translate providers free-filter labels in zh-TW (#6694 guard) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: lunkerchen <lunkerchen@users.noreply.github.com> * feat(proxy): implement latency-optimized proxy rotation strategy (#6798) * feat(proxy): implement latency-optimized proxy rotation strategy Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR) and the direct CHANGELOG.md edit (fragments-first); the author's env/docs/i18n deltas were re-applied cleanly onto the release tip. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(proxy): add latency-rotation env var to .env.example PROXY_LATENCY_WINDOW_HOURS was referenced in src/lib/db/proxies.ts and documented in docs/reference/ENVIRONMENT.md, but missing from .env.example, tripping the env/docs sync gate. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(changelog): re-sync CHANGELOG.md to release tip Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(proxy): extract latency-strategy helpers to keep frozen files under cap Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test(db-rules): expect 35 audited modules (proxyLatency joins INTENTIONALLY_INTERNAL) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * docs(readme): fix stale strategy/tool/scoring counts (#6853) README still claimed 17 routing strategies (the table was missing pipeline), 95 MCP tools, and 9-factor Auto-Combo scoring. Align with the source (ROUTING_STRATEGY_VALUES has 18 entries) and the canonical docs (MCP-SERVER.md: 94 tools; AUTO-COMBO.md: 12-factor). * fix(antigravity): sanitize Cloud Code safety settings (#6839) Co-authored-by: kfiramar <83420275+kfiramar@users.noreply.github.com> * fix(kiro): probe IdC region during profileArn discovery, cross-region (recovers #6099) (#6840) * fix(kiro): route Amazon Q runtime by profileArn region for cross-region IdC Enterprise AWS IAM Identity Center accounts whose IdC instance lives outside the two Amazon Q Developer profile regions (us-east-1 / eu-central-1) - e.g. eu-north-1 (Stockholm), start URL https://d-XXXX.awsapps.com/start - showed no limits and returned 502 on every request. Root cause: the backend used the IdC/OIDC token region (providerSpecificData.region, e.g. eu-north-1) for every CodeWhisperer runtime call, hitting q.eu-north-1.amazonaws.com - a host that does not exist as a Q Developer runtime endpoint. Per AWS docs ("Supported Regions for the Q Developer console and Q Developer profile"), the Q Developer *profile* (which produces the profileArn and hosts generateAssistantResponse / GetUsageLimits / ListAvailableModels / ListAvailableProfiles) is only hosted in us-east-1 and eu-central-1, regardless of the IdC region; "data is stored in the Region where you create the Amazon Q Developer profile." Fix (new open-sse/services/kiroRegion.ts) decouples the two regions: - providerSpecificData.region stays the IdC/OIDC region, used ONLY for oidc.{region}.amazonaws.com token mint/refresh. - The runtime region is derived from the profileArn (resolveKiroRuntimeRegion): profileArn region -> a valid stored profile region -> us-east-1. A stored IdC region that is not a Q profile region (eu-north-1) is ignored for runtime. - Profile discovery (discoverKiroProfileArnAcrossRegions) probes the Q profile regions (EU IdC -> eu-central-1 first) with the cross-region SSO token instead of q.{idcRegion}. Wired into: executors/kiro.ts (generateAssistantResponse targets the profile region), services/usage/kiro.ts (getKiroUsage multi-region discovery + profileArn runtime region so Limits resolves), services/kiroModels.ts (ListAvailableModels), and src/lib/oauth/providers/kiro.ts (login-time postExchange profile discovery). Adds tests/unit/kiro-idc-cross-region.test.ts (15 cases). All Kiro suites pass (60 tests). * fix(kiro): probe the IdC region too during profileArn discovery (any IdC region) Make profile discovery general for an IdC in ANY of the ~30 IdC-supported AWS regions (us-west-2, ap-southeast-2, me-central-1, af-south-1, ...), not just eu-north-1. buildKiroProfileDiscoveryRegions now probes the two documented Q Developer profile regions FIRST (us-east-1 / eu-central-1, EU-first for EMEA IdC regions to cut latency), then appends the IdC/stored region itself as a forward-compatible fallback: if AWS ever co-locates the profile with the IdC or expands the profile-region list, a same-region probe still finds it. Probing a region with no profile simply returns nothing and we fall through. The profileArn's own region remains authoritative for every runtime call (resolveKiroRuntimeRegion), so a newly-issued ARN in any region is honored automatically. Adds ap-southeast-2 (APAC) cross-region coverage and updates the discovery-order tests. --------- Co-authored-by: artickc <artur1992123@mail.ru> * feat(providers): manual context-window override for custom models (#4125) (#6822) Add a manual per-model "Context Window Override" so an operator can correct a provider's misreported context length (e.g. reports 1M when the real limit is 128K) instead of the model getting silently dropped from combo routing once the wrong value lands in the catalog. Reuses the existing Feature-5004 model_context_overrides table (source="manual") — already the priority-0 source getModelContextLimit() (the function combo's context-window filter calls) reads ahead of the models.dev/registry/static catalog — so no new resolver logic was needed, only the missing write path: - PUT /api/provider-models now accepts an optional contextWindowOverride (number to set, null to clear), persisted via setModelContextOverride/ removeModelContextOverride. - GET /api/provider-models surfaces the current override value + source back on each custom-model row. - CustomModelsSection.tsx: edit form gained a Context Window Override input + a badge on the model row when an override is set. Regression guard: tests/unit/provider-models-context-window-override-4125.test.ts (manual override wins over a misreported catalog value, GET round-trip, clearing via null, default-unchanged behavior). * feat(dashboard): improve Provider Quota page horizontal density (#3520) (#6815) QuotaCardGrid stacked every provider group vertically in a single flex flex-col container, and each group's own card grid didn't go multi-column until the md breakpoint. Provider groups now flow into a 2-column CSS multi-column layout on very wide (2xl) screens instead of an unconditional vertical stack, and each group's card grid starts at 2 columns immediately, filling horizontal whitespace sooner on narrower-but-not-mobile viewports. Regression guard: tests/unit/quota-card-grid-horizontal-layout.test.ts * refactor(usage): type saveRequestUsage with UsageEntry interface + any-budget ratchet (#3512) (#6809) Replace saveRequestUsage(entry: any) with a typed UsageEntry interface mirroring the usage_history columns 1:1. Fields stay optional/nullable since different writers (chatCore success/failure, rejected-request accounting, Codex Responses WS) populate the row incrementally; tokens stays unknown since callers pass either raw provider-shaped usage or the normalized {input,output,cacheRead,...} shape. Also cleaned the file's other any usages (getUsageHistory filter, getUsageDb next-cursor cast, appendRequestLog tokens param, getRecentLogs catch) so it now sits at zero any and can be added to the check:any-budget:t11 zero-any allowlist. Documents the DB-entity <-> TS-interface convention in docs/architecture/CODEBASE_DOCUMENTATION.md Sec 11. * feat(combo): strict budget-cap fallback policy for auto/* combos (#3470) (#6816) Auto-combo transparency + budget controls: the engine's budgetCap enforcement always degraded to the globally cheapest candidate when every candidate exceeded the cap - silently overspending instead of respecting the cap. - engine.ts: budgetFallback "cheapest" (default, legacy) | "strict" (BudgetExceededError when no candidate fits budgetCap) - requestControls.ts: X-OmniRoute-Budget-Fallback header + resolveRequestAutoControls() consolidating mode/budget/fallback parsing - resolveAutoStrategy.ts / autoConfig.ts: thread combo-level config.budgetFallback and catch BudgetExceededError into an HTTP 402 - chat.ts: switch to the consolidated resolveRequestAutoControls() helper (net line reduction, stays under the frozen file-size baseline) Regression guard: tests/unit/auto-combo-budget-fallback-3470.test.ts * fix(usage): honor xAI provider-reported exact cost (#6711) OmniRoute's calculateCost() always estimated request cost from token counts x static pricing, discarding xAI's exact provider-reported cost when present. xAI's chat-completions usage object reports the precise billed cost via cost_in_usd_ticks (docs.x.ai/developers/cost-tracking and the API reference's usage schema: "TICKS_IN_USD_CENT: i64 = 100_000_000" => 1e10 ticks/USD, e.g. 37756000 ticks ~= $0.0038). calculateCost()/computeCostFromPricing() now short-circuit to this exact figure when present -- before any pricing DB lookup, so it also works for models without a local pricing row -- and still fall back to the token-based estimate when it is absent. The field is threaded through both the streaming (extractUsage/normalizeUsage) and non-streaming (extractUsageFromResponse) usage-extraction paths. Corrected divisor vs upstream: the upstream PR used /1e12 (a 100x under-report, e.g. reporting $0.00123 as the doc's $0.123 example); this port uses the doc-verified /1e10 instead, confirmed against both the cost-tracking guide and the API reference's usage-object schema. Inspired-by: https://github.com/decolua/9router/pull/2453 Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com> * docs: rename /implement-prs → /merge-prs in Hard Rule #21 (skill renamed 2026-07-11) (#6847) * docs: refresh stale llm.txt facts + relocate design.md to docs/architecture/DESIGN_SYSTEM.md (#6849) * docs: refresh stale llm.txt facts + move design.md to docs/architecture/DESIGN_SYSTEM.md llm.txt was frozen at the v3.8.8 era (177 providers, 37 MCP tools, 14 strategies, 9-factor scoring, 75% coverage gate). Update every factual claim to the current state (248 providers, 94 tools / 30 scopes, 18 strategies, 12-factor scoring, ratchet + 60% floor, TS 6, current docs/ layout) and re-sync the 42 exact-copy i18n mirrors. design.md at the root was a standardization plan whose phases 1-6 all shipped; rewrite its header as a permanent reference and relocate it to docs/architecture/DESIGN_SYSTEM.md per the root-hygiene policy (root = configs + canonical docs only). * docs: add MDX frontmatter to DESIGN_SYSTEM.md (in-app docs pipeline requires it) * feat: per-model web-search interception rule (#3384) (#6814) * feat(routing): per-model web-search interception rule (#3384) Adds a per-provider/per-model interceptSearch rule (src/lib/db/interceptionRules.ts, key_value namespace interception_rules) that overrides the existing native web-search bypass defaults (Codex/Gemini/Claude->Claude passthrough) in webSearchFallback.ts. Wired at the existing prepareWebSearchFallbackBody() call site in chatCore.ts. Resolution precedence: per-model rule > provider-level rule > existing native-bypass defaults. This lands Phase 1-2 of the plan (rule store + search interception). Web-fetch interception and the dashboard UI toggle are tracked as follow-up phases. * fix(db): register interceptionRules in localDb re-export layer (db-rules gate) * fix(db): renumber interception_rules migration 119→120 (collision with model_capability_overrides) * feat: sidebar search/filter input (#4013) (#6810) * feat(dashboard): add search/filter input to the dashboard sidebar (#4013) Adds a search box at the top of the expanded sidebar that filters nav sections/groups/items client-side by label, so users don't have to hunt through the growing nav tree. Reuses the existing common.search / common.noResults i18n keys (no new locale edits needed) and the shared Input icon="search" pattern. Matching sections auto-expand while searching and the accordion/pin state is restored once the query is cleared. Filtering logic is extracted into a pure filterSidebarSectionsByQuery() helper (src/shared/utils/sidebarSearch.ts) so it is trivially unit testable independent of React/next-intl/next-navigation. * fix(test): move Sidebar.search test to a runner-collected path (test-discovery gate) * fix(i18n): backfill 194 missing pt-BR keys (#6695) (#6723) * fix(i18n): backfill 194 missing pt-BR keys and add key-parity regression test (#6695) * Merge branch 'release/v3.8.47' into fix/6695-i18n-drift Resolve i18n key-parity and CHANGELOG-fragment conflicts: - Convert the #6695 CHANGELOG.md bullet to a changelog.d/ fragment (the fragment convention landed on release/v3.8.47 after this PR branched, per changelog.d/README.md). - Backfill 61 additional pt-BR keys that entered en.json on release/v3.8.47 after this PR's original 194-key backfill, so the PR's own key-parity regression test (tests/unit/i18n-pt-br.test.ts) stays green against the moving release baseline. * Discover live Codex models (#6776) * Add live model discovery for provider catalog * Fix model discovery request headers * fix(codex): sync live model limits with local catalog * test(codex): split live model discovery coverage into dedicated route tests * fix(codex): use chatgpt account id for live model sync * Add GitHub-backed Codex model discovery fallback * fix(providers): tighten oauth config tests and provider model display comments * test: align client version expectations with release default * fix(codex): keep discovery complexity within baseline * fix: rebase live Codex model discovery onto release/v3.8.47, preserving kimi-web buildHeaders (#6308) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * feat(codex): echo requested effort-suffixed model id in Responses payloads (#3697) (#6820) * feat(codex): echo requested effort-suffixed model id in Responses payloads (#3697) Codex CLI compatibility shim: the Responses API response.created/ response.in_progress/response.completed payloads now carry a `model` field (previously absent), and for Codex-CLI-originated requests it echoes the client-requested effort-suffixed model id (e.g. gpt-5.5-xhigh) instead of the bare upstream id (gpt-5.5), so the Codex CLI status line/model button shows the active reasoning effort. - openai-responses.ts translator threads the upstream model into the Responses event objects (additive, omitted when unknown). - New isCodexOriginatedHeaders() (codexIdentity.ts) reuses PR #3481's originator/User-Agent detection, header-based so it still fires when a combo routes codex/gpt-5.5-xhigh to a non-codex upstream. - chatCore's existing opt-in #1311 echoModel pipeline now also fires automatically for Codex clients on the Responses API, regardless of the echoRequestedModelName setting. - responseModelEcho.ts now also rewrites the nested response.model field the Responses API uses (previously only top-level model). - /v1/models keeps returning models: [] for Codex (unchanged, #3481). Regression guard: tests/unit/codex-effort-model-echo-3697.test.ts. Closes #3697 * chore(merge): re-sync with release/v3.8.47 (restore CHANGELOG, keep own bullet) * chore(merge): re-sync with release/v3.8.47; move changelog bullet to changelog.d fragment (merge-storm proof) * feat(usage): surface Antigravity weekly quota alongside the 5-hour window (#4017) (#6818) * feat(usage): surface Antigravity weekly quota alongside the 5-hour window (#4017) Antigravity enforces both a 5-hour and a weekly usage limit, but the agy/antigravity quota widget only exposed the 5-hour window. The weekly limit isn't in the per-model retrieveUserQuota response already fetched — it lives in a separate, undocumented retrieveUserQuotaSummary RPC that groups models into families (Gemini Models, Claude and GPT models) with one weekly bucket per family. Adds a self-contained usage/antigravityWeeklyQuota.ts leaf: a cached, best-effort fetch of that RPC + a pure parser that extracts the weekly-labeled bucket per group (window inferred from bucketId/displayName text, matching the reverse-engineered shape documented by third-party Antigravity clients) into gemini_weekly/ claude_gpt_weekly quota entries, merged into the existing quotas map the widget already renders generically. A failed/unavailable RPC never affects the existing per-model quotas. Live VPS validation attempt (192.168.0.15, real antigravity account): both retrieveUserQuota and retrieveUserQuotaSummary currently return 429 RESOURCE_EXHAUSTED for that account, so the live response shape could not be captured directly. The parser was instead validated via TDD against the bucket shape documented by CodexBar (steipete/CodexBar), a third-party Antigravity client that reverse-engineered the same RPC, and is defensive against both response envelopes it has observed (top-level groups[] and nested quotaSummary.groups[]). * chore(merge): re-sync with release/v3.8.47 (restore CHANGELOG, keep own bullet) * chore(merge): re-sync with release/v3.8.47; move changelog bullet to changelog.d fragment (merge-storm proof) * feat: add Z.ai Web free web-cookie provider (#4056) (#6823) * feat(providers): add Z.ai Web free web-cookie provider (#4056) New zai-web web-session provider drives the free chat.z.ai consumer chat UI via a pasted browser cookie, distinct from the existing API-key zai/glm/glm-cn/glmt providers (api.z.ai). ZaiWebExecutor posts to chat.z.ai/api/chat/completions with the cookie forwarded both as Cookie and Authorization: Bearer <token>, and normalizes both z.ai's internal delta_content/phase SSE envelope and a pass-through OpenAI-shaped choices[].delta frame into standard chat-completion chunks. Registered in WEB_COOKIE_PROVIDERS, WEB_SESSION_CREDENTIAL_REQUIREMENTS, the provider registry (GLM-4.6/4.5/4.5V models), the executor factory, and tokenExtractionConfig.ts for in-app cookie capture. * fix(providers): regenerate translate-path golden for zai-web + reduce cognitive complexity * fix(providers): rename ZaiWebExecutor.buildHeaders to avoid incompatible BaseExecutor override * chore(merge): re-sync with release/v3.8.47; move changelog bullet to changelog.d fragment (merge-storm proof) * fix(codex): bump default client version to 0.144.0 (#6780) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(usage): extract per-group parsing in antigravityWeeklyQuota (cognitive-complexity gate 886→885, release-level drift from #6818 merge) * ci(quality): cut PR gate wall time without dropping protection (#6716) Collapse duplicate CI spend while keeping each gate's existence reason: - quality.yml: TIA __RUN_ALL__ defers full unit to fast-unit 4-shard (#6781); path filters via classify-pr-changes; docs-gates split; draft skip - ci.yml: wire docs/i18n/code path filters; ESLint JSON artifact for quality-gate; drop advisory typecheck:noimplicit; float actions/cache@v6 - TIA parity: memory/usage/combo/serial; **/*.test.mjs any depth; electron/bin no longer force unit __RUN_ALL__ - check:complexity-ratchets: one ESLint walk, ruleId-isolated baselines + cache - check:api-docs-refs + lib/apiRoutes: shared API route inventory - husky pre-push: intentionally light (gates live in pre-commit); CLAUDE.md + QUALITY_GATES.md docs synced - collect-metrics / lint:json: path.resolve cache path; Windows-safe eslint bin - env-doc allowlist for ESLINT_RESULTS_JSON / COMPLEXITY_ESLINT_REPORT - release-green --full-ci expects check:api-docs-refs (not docs-symbols alone) Tests: select-impacted, classify-pr-changes, api-routes lib, complexity-rule-count, validate-release-green. Reconciled after #6781 (fast-unit 2→4 shards) per maintainer request on #6716. Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> * fix(docs): document Turbopack build memory tradeoff for RAM-constrained machines (#6409) (#6885) * fix(routing): recognize Kimi token-limit 400 as context overflow for combo fallback (#6637) (#6893) combo.ts's isContextOverflow400() guard required the literal word 'context' in the 400 error body before letting a combo fall through to the next target. Kimi's exact wording ('Your request exceeded model token limit: 262144 (requested: 308458)') never says 'context', so the guard misclassified it as a body-specific error and halted the whole combo instead of trying the next (larger-context) target. accountFallback.ts's CONTEXT_OVERFLOW_PATTERNS already recognized this wording one layer below (via checkFallbackError -> shouldFallback), so the two independently-maintained classifiers disagreed and the stricter one won. Export CONTEXT_OVERFLOW_PATTERNS from accountFallback.ts and reuse it inside combo.ts's isContextOverflow400() so both layers share a single source of truth. Regression test: tests/unit/repro-6637-kimi-token-limit.test.ts (RED on unfixed code -> GREEN after the fix). Existing #4519 guard tests (tests/unit/combo-param-validation-fallback-4519.test.ts) still pass, including the negative case that a genuinely body-specific 400 is NOT misclassified as overflow. * fix(providers): honor a provider-level proxy assigned to no-auth providers (#6272) (#6895) No-auth providers (mimocode, opencode, ...) are always dispatched with a single hardcoded connectionId ("noauth" — SYNTHETIC_NOAUTH_CONNECTION_ID in src/sse/services/auth.ts). No provider_connections row ever has id="noauth", so resolveProxyForConnection() in src/lib/db/settings.ts could never populate connectionRecord for them, and its provider-level proxy lookup (Steps 6/8) only runs when connectionRecord is present. A proxy assigned via Settings -> Providers -> mimocode was therefore silently ignored, reproducing the reporter's "same thing happen when i set the proxy directly in the provider menu" symptom. Adds a best-effort fallback (src/lib/db/settings/noAuthProxyFallback.ts): when connectionRecord could not be resolved, scan the known no-auth provider ids for a configured provider-level proxy (registry first, then legacy) before falling through to the global/direct steps. Regression test: tests/unit/proxy-noauth-provider-6272.test.ts (RED on unfixed code — resolved to level=direct/proxy=null; GREEN after the fix). * fix(dashboard): surface Claude extraUsage credits in quota card (#6806) (#6896) Enterprise-tier Claude accounts (default_raven_enterprise) don't get five_hour/seven_day utilization windows from Anthropic's OAuth usage endpoint — only an extra_usage credit-billing block. parseClaude() only read data.quotas, so quotas stayed {} and the dashboard showed "No quota data" even when extraUsage showed the account 100% exhausted. parseClaude() now folds an enabled extraUsage block into a credits-style quota row (mirroring parseCodex's bankedResetCredits pattern), both when quotas is empty and when it's already populated. * fix(db): share sql.js preinit across callers, fix named-param bind (#6628, #6802) (#6899) - preInitSqlJs() now memoizes an in-flight Promise (not just the resolved adapter) per filePath, so concurrent BATCH/STARTUP/HealthCheck/ ProviderLimitsSync callers at boot share one full-file read+WASM decode instead of each independently reloading the whole database — the thundering-herd amplifier of the OOM condition #6632 already partly fixed, left un-implemented by the reporter's own proposed fix (#6628). - sqljsAdapter's run/get/all now unwrap a lone named-parameter object (e.g. .all({ isActive: 1 }) for "WHERE is_active = @isActive", the same call shape getProviderConnections() already uses against better-sqlite3) before calling sql.js's stmt.bind(), expanding it to the @/:/$ sigil variants sql.js's own named-bind path requires. Previously the object was wrapped into an array and sql.js took the positional-bind path, throwing "Wrong API use : tried to bind a value of an unknown type ([object Object])." whenever the sql.js WASM fallback driver was active — exactly the error #6802 reported (misattributed to better-sqlite3). Regression tests added to tests/unit/db-adapters/driverFactory.test.ts and tests/unit/db-adapters/sqljsAdapter.test.ts, both proven RED against the prior code and GREEN after the fix. * fix(plugin): split OC-gate provider id from OmniRoute-facing routing id (#6859) (#6900) resolveOmniRoutePluginOptions() auto-prefixes providerId with "opencode-" (commit75b52e286) so OpenCode 1.17.8+'s native-adapter gate accepts it as a registered provider id. That prefixed value was being reused for the OmniRoute-server-facing identifiers too: mapRawModelToModelV2's id/providerID, mapComboToModelV2's providerID, and the dynamic provider hook's combo catalog keys. OmniRoute's server has no "opencode-<x>" provider alias, so every dispatched model failed credential lookup with "No credentials for opencode-omniroute" / "No active credentials for provider: opencode-omniroute". Add a … * test(ci): static body in codex e2e mock route bridge (CodeQL #737) (#7559) CodeQL js/stack-trace-exposure flags ANY error-derived value returned in the mock route bridge's 500 path, not just error.stack — swapping .stack for error.message (in #7354, alert #736) left sibling alert #737 open on the same line. Replace the body with a static string; the test only asserts status===200, so the 500 body is never inspected. Clears the last open CodeQL alert repo-wide, unblocking the Quality Ratchet on every PR. Companion to the release/v3.8.49 PR (merge-gates §8 — gate/CI-touching fix lands on main in the same session). * fix(security): bump adm-zip >=0.6.0 + exact host matching in mitm DNS test (#7733) * feat: add getCachedProviderConnectionById and getCachedProviderNodes with 38-file callers conversion PR #2 of memory-pressure series — cache the two remaining hot database access patterns that were uncached: Core changes: - readCache.ts: add connectionByIdCache, nodesCache, getCachedProviderConnectionById, getCachedProviderNodes, extend invalidateDbCache for "nodes" scope - nodes.ts: invalidate nodes cache on create/update/delete - localDb.ts: export new cache functions, remove dead code exports (isConnectionRateLimited, getRateLimitedConnections) Callers converted (38 files): - open-sse hot paths: chatCore.ts, codexFailover.ts, concurrencyCaps.ts, quotaExhaustionCutoff.ts, tokenRefresh.ts - src hot paths: quotaCache.ts, connectionProvider.ts, quotaCombos.ts, quotaKey.ts, saturationSignals.ts, tokenHealthCheck.ts, providerHealthAutopilot.ts, claudeAuthFile.ts, codexAuthFile.ts - Admin routes: providers/[id]/{route,login,models,refresh,sync-models,test}.ts - Nodes/export/sync: provider-nodes/route.ts, export-json/route.ts, sync/bundle.ts, localHealthCheck.ts - v1 audio/speech/route.ts, transcriptions/route.ts, translations/route.ts - v1 models/catalog.ts, rerank/route.ts - embeddings/service.ts, imageRouteModel.ts, memory/embedding/index.ts - sse/services/auth.ts, model.ts All cached wrappers use 5s TTL (matching existing pattern). Cache invalidated on all related writes. Both core and open-sse typechecks pass (zero errors). * fix(pr-review): bypass cache for CAS-sensitive token paths (chatCore + tokenRefresh) Gemini code-assist review flagged race conditions in CAS checks, token rotation, and OAuth refresh paths where cached data could cause OAuth token family revocation. Reverted those callers to direct DB reads: - chatCore.ts: CAS reread (line 3077) + rotation detection (line 3168) → getProviderConnectionById - tokenRefresh.ts: staleness check before network refresh → getProviderConnectionById Health check paths (providerHealthAutopilot, tokenHealthCheck) retain cache — they are background sweeps where 5s staleness is acceptable. * fix(perf): add touchConnectionLastUsed to break cache-thrashing on credential selection Every getProviderCredentials call was calling updateProviderConnection to bump lastUsedAt/consecutiveUseCount, which triggered: - SELECT + full re-encrypt - invalidateDbCache('connections') - backupDbFile() - bumpProxyConfigGeneration() This busted the 5s cache on every chat request, forcing a full 3000-row decrypt on the next read. Fix with a bare SQL UPDATE that touches only the stat columns — no SELECT, no encrypt, no cache invalidation, no backup. Also cache filtered getCachedProviderConnections calls per filter key so filtered queries (by provider, isActive, etc.) benefit from the cache. * perf(cache): lazy-decrypt provider connection credentials via raw cache + proxy Replace getCachedProviderConnections() with getCachedRawProviderConnections() in the auth selection hot path. Rows are cached undecrypted; decrypt() runs only on first access to apiKey/accessToken/refreshToken via a Proxy wrapper (createLazyConnectionView). Before: 3000 AES-256-GCM decrypts per cache fill (every row, every field) After: 0 decrypts per cache fill — 3 decrypts max, for the 1 chosen connection Changes: - providers.ts: add getRawProviderConnections() skips decryptConnectionFields - readCache.ts: add rawConnectionsCache (5s TTL) + getCachedRawProviderConnections() - localDb.ts: re-export getCachedRawProviderConnections - auth.ts: createLazyConnectionView() — toProviderConnection() typed, then Proxy intercepts 3 credential fields with lazy decrypt() - auth.ts hot path: use raw cache + lazy mapping - test: update split-test expected surface (22 symbols) * perf(cache): replace connectionsCache with rawConnectionsCache + shared lazyConnectionView Phase 2a: extract lazy connection view into shared module - Move createLazyConnectionView, toProviderConnection, ProviderConnectionView from sse/services/auth.ts to src/lib/db/providers/lazyConnectionView.ts - Update auth.ts to import from the shared location - Update catalog.ts to use getCachedRawProviderConnections + createLazyConnectionView Phase 3: delegate getCachedProviderConnections through raw cache - getCachedProviderConnections now delegates to getProviderConnections which calls getCachedRawProviderConnections (single source of truth) - Add LRU eviction (maxSize param) to TTLCache to prevent memory leaks - rawConnectionsCache limited to 500 entries, connectionByIdCache to 10K - Provider metadata (combo/catalog) caches get dedicated invalidation scopes - Fix deleteProviderConnectionsByProvider: add missing invalidateDbCache call Phase 4: fix test isolation + dead code audit - db-read-cache.test.ts: use real module import (not importFresh) so invalidateDbCache reaches the same rawConnectionsCache instance - Verified: no stale connectionsCache references, clean barrel exports * fix(db): reconcile provider-connections raw cache + lazy-decrypt with release tip Merge origin/release/v3.8.49 into #7787's branch and resolve the divergence in src/lib/db/providers.ts: keep the tip's column-projection support on getProviderConnections/getRawProviderConnections and its invalidateReasoningRoutingRuleCache() call in deleteProviderConnectionsByProvider, alongside the PR's raw-cache + lazy-decrypt-proxy read path (getCachedRawProviderConnections + createLazyRowProxy). Column-projected reads bypass the raw-row cache since its key doesn't account for projection. All other conflicts were pure base drift (PR touched none of those files) and were resolved by taking the release tip verbatim. Restored CHANGELOG.md to the tip (merge auto-resolve had dropped 294 sibling bullets) and added this PR's entry as a changelog.d fragment per the fragment convention. Co-authored-by: oyi77 <oyi77@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(cache): address 5 PR review comments on connectionsCache refactor 1. Add falsy-id guard to touchConnectionLastUsed (gemini review) 2. Add falsy-id guard to getCachedProviderConnectionById (gemini review) 3-5. After each touchConnectionLastUsed call, sync raw cache rows with fresh lastUsedAt/consecutiveUseCount so round-robin stays correct within the TTL window Closes PR #7787 review comments * test(cache): add staleness regression + cache surface tests Addresses diegosouzapw's PR #7787 review comments: - Restore isConnectionRateLimited/getRateLimitedConnections re-exports in localDb.ts - Add staleness regression test: getProviderConnections returns fresh data after delete - Add getCachedProviderConnectionById caching/invalidation test - Add getCachedProviderNodes caching/invalidation test All 7 read-cache tests pass, all 51 provider tests pass, typecheck clean. * test(cache): add deleteProviderConnectionsByProvider staleness regression test Matches the owner's original bug description exactly — the fix was adding invalidateDbCache('connections') to deleteProviderConnectionsByProvider in commit438bfc83c. * chore(quality): annotated file-size rebaseline for localDb re-export growth (805->807) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: growab <nekron@icloud.com> Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Co-authored-by: Chirag Singhal <76880977+chirag127@users.noreply.github.com> Co-authored-by: Chirag Singhal <chirag127@users.noreply.github.com> Co-authored-by: Ronaldo Davi <ronaldodavi@gmail.com> Co-authored-by: Andrew Munsell <andrew@wizardapps.net> Co-authored-by: WITALO ROCHA <witalo_rocha@hotmail.com> Co-authored-by: Wital <witalorocha216@gmail.com> Co-authored-by: Aoxiong Yin <i@yinaoxiong.cn> Co-authored-by: Andrew B. <37745667+AndrianBalanescu@users.noreply.github.com> Co-authored-by: Andrian B. <andrewbalanesq@gmail.com> Co-authored-by: Xiangzhe <32761048+xz-dev@users.noreply.github.com> Co-authored-by: Jon Bailey <297513015+Pitchfork-and-Torch@users.noreply.github.com> Co-authored-by: Pitchfork-and-Torch <Pitchfork-and-Torch@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: Samir Abis <me@samirabis.com> Co-authored-by: lucasjustinudin <34107354+lucasjustinudin@users.noreply.github.com> Co-authored-by: chy1211 <31048289+chy1211@users.noreply.github.com> Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com> Co-authored-by: whale9820 <whale9820@users.noreply.github.com> Co-authored-by: anhdiepmmk <n08ni.dieppn@gmail.com> Co-authored-by: Septianata Rizky Pratama <19322988+ianriizky@users.noreply.github.com> Co-authored-by: NOXX - Commiter <artur1992123@mail.ru> Co-authored-by: backryun <bakryun0718@proton.me> Co-authored-by: lunkerchen <labanchen@gmail.com> Co-authored-by: lunkerchen <lunkerchen@users.noreply.github.com> Co-authored-by: Ray Doan <raydoan.contact@gmail.com> Co-authored-by: kfiramar <83420275+kfiramar@users.noreply.github.com> Co-authored-by: Jan Leon <Jan.gaschler@gmail.com> Co-authored-by: Someres <168349709+quanturbo@users.noreply.github.com> Co-authored-by: MikeTuev <ra9ftm@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Imam Wahyu Widodo <120608486+hajilok@users.noreply.github.com> Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> Co-authored-by: AgentKiller45 <jamalzzj45@gmail.com> Co-authored-by: judy459 <JUDYZHU459@outlook.com> Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com> Co-authored-by: Markus Hartung <mail@hartmark.se> Co-authored-by: KooshaPari <koosha@phenotype.io> Co-authored-by: Jade Guo <jade.gly@gmail.com> Co-authored-by: oyi77 <oyi77@users.noreply.github.com> Co-authored-by: Dayna Blackwell <dayna@blackwell-systems.com> Co-authored-by: backryun <backryun@daonlab.local> Co-authored-by: brick30llc-ctrl <brick30llc@gmail.com> Co-authored-by: brick30llc-ctrl <admin@brick30.com> Co-authored-by: Saren <saren@dumstruck.com> Co-authored-by: Rafael Dias Zendron <mmmarckos@gmail.com> Co-authored-by: Xiangzhe <xiangzhedev@gmail.com> Co-authored-by: Rafael Dias Zendron <rafael.zendron22@gmail.com> Co-authored-by: KooshaPari <62650152+KooshaPari@users.noreply.github.com> Co-authored-by: Wibias <37517432+Wibias@users.noreply.github.com> Co-authored-by: huohua-dev <celentanohertor@gmail.com> Co-authored-by: huohua-dev <258873123+huohua-dev@users.noreply.github.com> Co-authored-by: CitrusIce <31264099+CitrusIce@users.noreply.github.com> Co-authored-by: minisforum <no@mail.com>
2249 lines
77 KiB
TypeScript
Executable File
2249 lines
77 KiB
TypeScript
Executable File
// @ts-nocheck
|
||
import { AsyncLocalStorage } from "node:async_hooks";
|
||
import { pbkdf2Sync } from "node:crypto";
|
||
import { hostname, release } from "node:os";
|
||
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
|
||
import {
|
||
buildKimiCodeIdentityHeaders,
|
||
normalizeKimiDeviceId,
|
||
} from "../config/providers/registry/kimi/coding/runtime.ts";
|
||
import { getGitHubCopilotRefreshHeaders } from "../config/providerHeaderProfiles.ts";
|
||
import { getKimiDeviceModel } from "../utils/kimiDevice.ts";
|
||
import { runWithProxyContext } from "../utils/proxyFetch.ts";
|
||
import { serializeRefresh, wasRefreshTokenRotated } from "./refreshSerializer.ts";
|
||
import { buildExternalIdpRefreshParams, isExternalIdpAuthMethod } from "./kiroExternalIdp.ts";
|
||
import { WINDSURF_CONFIG } from "@/lib/oauth/constants/oauth";
|
||
import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oauth/gitlab";
|
||
|
||
// Default token expiry buffer (refresh if expires within 5 minutes).
|
||
// Used as fallback for providers without an explicit lead time in
|
||
// REFRESH_LEAD_MS below.
|
||
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||
|
||
// Per-provider proactive-refresh lead time.
|
||
//
|
||
// For multi-account OAuth on providers that enforce "single active session per
|
||
// client_id" (notably OpenAI Codex / Auth0), refreshing one account's token
|
||
// can invalidate the refresh_token family of OTHER accounts under the same
|
||
// client. We MINIMIZE refresh frequency for these providers: stay on the
|
||
// original access_token until it is genuinely about to expire, so each account
|
||
// gets the full access_token lifetime without triggering Auth0's family-
|
||
// invalidation logic on its siblings.
|
||
//
|
||
// Trade-off: when refresh finally happens (last 5 min before expiry), Auth0
|
||
// MAY invalidate other accounts' refresh_tokens. The user must re-auth those.
|
||
// This is the upstream limitation documented in openai/codex#9648.
|
||
//
|
||
// Providers with non-rotating tokens (Google, Anthropic) or where multi-
|
||
// account is naturally isolated keep longer lead times.
|
||
export const REFRESH_LEAD_MS: Record<string, number> = {
|
||
// Rotating refresh tokens — minimize refresh frequency to avoid the
|
||
// "refresh-invalidates-siblings" cascade documented for OpenAI Auth0.
|
||
codex: 5 * 60 * 1000, // 5 minutes
|
||
openai: 5 * 60 * 1000, // same Auth0 backend as codex
|
||
claude: 5 * 60 * 1000, // Anthropic OAuth rotates refresh_tokens (user-reported)
|
||
"gitlab-duo": 5 * 60 * 1000, // GitLab token family revocation on misuse
|
||
kiro: 5 * 60 * 1000, // AWS SSO OIDC issues one-time-use refresh tokens
|
||
"kimi-coding": 5 * 60 * 1000, // Moonshot rotates per-refresh
|
||
qwen: 5 * 60 * 1000, // Alibaba device-code path also rotates
|
||
// Non-rotating providers — longer lead is safe.
|
||
iflow: 24 * 60 * 60 * 1000, // 24 hours
|
||
// Google OAuth refresh_tokens are permanent (non-rotating) — longer lead
|
||
// is safe and reduces unnecessary upstream chatter.
|
||
antigravity: 15 * 60 * 1000,
|
||
agy: 15 * 60 * 1000, // same Google backend as antigravity (non-rotating refresh tokens)
|
||
};
|
||
|
||
/**
|
||
* Get the proactive refresh lead time (ms) for a given provider.
|
||
*
|
||
* Precedence:
|
||
* 1. A per-connection override in `providerSpecificData.refreshLeadMs`
|
||
* (must be a positive finite number), so an operator can tune the lead
|
||
* time for a single connection without touching the provider defaults.
|
||
* 2. The provider default from REFRESH_LEAD_MS.
|
||
* 3. TOKEN_EXPIRY_BUFFER_MS (5 min) when nothing else applies.
|
||
*/
|
||
export function getRefreshLeadMs(
|
||
provider: string,
|
||
providerSpecificData?: { refreshLeadMs?: unknown } | null
|
||
): number {
|
||
const override = providerSpecificData?.refreshLeadMs;
|
||
if (typeof override === "number" && Number.isFinite(override) && override > 0) {
|
||
return override;
|
||
}
|
||
return REFRESH_LEAD_MS[provider] ?? TOKEN_EXPIRY_BUFFER_MS;
|
||
}
|
||
|
||
const CACHE_SECRET = "omniroute-token-cache";
|
||
|
||
// In-flight refresh promise cache to prevent race conditions
|
||
// Key: "provider:sha256(refreshToken)" → Value: Promise<result>
|
||
const refreshPromiseCache = new Map();
|
||
|
||
// Per-connection mutex: prevents parallel OAuth refresh for rotating tokens.
|
||
// Key: connectionId → Value: { promise, waiters }
|
||
// Primary dedup when credentials.connectionId is present; refreshPromiseCache is fallback.
|
||
const connectionRefreshMutex = new Map();
|
||
|
||
// ─── Token Rotation Map (codex-multi-auth pattern) ─────────────────────────
|
||
//
|
||
// When a rotating-token provider (Codex, Kimi, GitLab Duo, etc.) refreshes,
|
||
// the old refresh_token is consumed and a new one is issued. Any subsequent
|
||
// caller arriving with the OLD token would, without protection, hit upstream
|
||
// and trigger "refresh_token_reused" — which Auth0 treats as a security event
|
||
// and invalidates the entire token family.
|
||
//
|
||
// This in-memory map caches RECENT rotations so a stale caller can be redirected
|
||
// to the new tokens WITHOUT touching upstream. The DB staleness check inside
|
||
// the per-connection mutex covers the same scenario when connectionId is known,
|
||
// but not all callers pass connectionId (e.g., legacy code paths, retries that
|
||
// snapshot credentials before the rotation lands in DB).
|
||
//
|
||
// Ported from ndycode/codex-multi-auth (lib/refresh-queue.ts:218-248), the only
|
||
// publicly known tool that reliably sustains multiple Codex OAuth accounts.
|
||
//
|
||
// Key format: `provider:sha256(oldRefreshToken)`
|
||
// Value: { result: tokens, expiresAt: ms_since_epoch }
|
||
type RotationEntry = {
|
||
result: { accessToken: string; refreshToken: string; expiresIn?: number; expiresAt?: string };
|
||
expiresAt: number;
|
||
};
|
||
const tokenRotationMap = new Map<string, RotationEntry>();
|
||
const ROTATION_MAP_TTL_MS = 60 * 1000; // 60 seconds — long enough to catch in-flight stale callers
|
||
|
||
function cleanupRotationMap(now: number = Date.now()): void {
|
||
if (tokenRotationMap.size === 0) return;
|
||
for (const [key, entry] of tokenRotationMap.entries()) {
|
||
if (entry.expiresAt <= now) tokenRotationMap.delete(key);
|
||
}
|
||
}
|
||
|
||
function lookupRotation(provider: string, refreshToken: string): RotationEntry | undefined {
|
||
cleanupRotationMap();
|
||
const key = getRefreshCacheKey(provider, refreshToken);
|
||
const entry = tokenRotationMap.get(key);
|
||
if (!entry) return undefined;
|
||
if (entry.expiresAt <= Date.now()) {
|
||
tokenRotationMap.delete(key);
|
||
return undefined;
|
||
}
|
||
return entry;
|
||
}
|
||
|
||
function recordRotation(
|
||
provider: string,
|
||
oldRefreshToken: string,
|
||
result: { accessToken: string; refreshToken: string; expiresIn?: number; expiresAt?: string }
|
||
): void {
|
||
if (!oldRefreshToken || !result.refreshToken || oldRefreshToken === result.refreshToken) {
|
||
return;
|
||
}
|
||
const key = getRefreshCacheKey(provider, oldRefreshToken);
|
||
tokenRotationMap.set(key, {
|
||
result,
|
||
expiresAt: Date.now() + ROTATION_MAP_TTL_MS,
|
||
});
|
||
}
|
||
|
||
// Exported for tests + diagnostics; not part of the public API surface.
|
||
export function _getTokenRotationMapStats(): { size: number; entries: number } {
|
||
cleanupRotationMap();
|
||
return { size: tokenRotationMap.size, entries: tokenRotationMap.size };
|
||
}
|
||
|
||
export function _clearTokenRotationMap(): void {
|
||
tokenRotationMap.clear();
|
||
}
|
||
|
||
// AsyncLocalStorage for plumbing `onPersist` through executor.refreshCredentials
|
||
// without modifying every executor's signature. The chatCore.ts / base.ts call
|
||
// sites wrap executor.refreshCredentials in `runWithOnPersist(persistFn, () => ...)`
|
||
// and `getAccessToken` reads the active store as a fallback when no explicit
|
||
// onPersist parameter is provided. This keeps Fix A's atomic [refresh + persist]
|
||
// guarantee while avoiding per-executor signature changes.
|
||
type RefreshPersistResult = Record<string, unknown>;
|
||
type RefreshPersistFn = (result: RefreshPersistResult) => Promise<void>;
|
||
const onPersistStore = new AsyncLocalStorage<RefreshPersistFn>();
|
||
|
||
export function runWithOnPersist<T>(
|
||
onPersist: RefreshPersistFn | undefined | null,
|
||
fn: () => Promise<T>
|
||
): Promise<T> {
|
||
if (!onPersist) return fn();
|
||
return onPersistStore.run(onPersist, fn);
|
||
}
|
||
|
||
export function getActiveOnPersist(): RefreshPersistFn | undefined {
|
||
return onPersistStore.getStore();
|
||
}
|
||
|
||
// ── #4038: compare-and-swap (CAS) guard on the refresh persist ───────────────
|
||
// Fix A makes [network refresh + DB write] atomic *for a single connection's
|
||
// mutex*. It does NOT protect against a THIRD writer (a sibling process, a
|
||
// concurrent HealthCheck, or a replica) landing a fresher rotation on the same
|
||
// `connection_id` between the moment the caller read the row and the moment this
|
||
// persist runs. Overwriting that fresher row reverts the sibling's rotation, the
|
||
// next caller loads the reverted (now-consumed) refresh_token, and Auth0/Anthropic
|
||
// revoke the whole token family (the 1352× claude/aa5dd5cf invalidation storm).
|
||
//
|
||
// The CAS guard carries the refresh_token the caller PRESENTED (the version token,
|
||
// since refresh_tokens rotate on every refresh) plus a `reread` of the row's
|
||
// current refresh_token. Right before persisting, `getAccessToken` re-reads and, if
|
||
// a concurrent writer already rotated the row past the presented token, SKIPS the
|
||
// persist so the DB stays at the fresher state. The caller still receives the new
|
||
// accessToken — upstream already authenticated the request; only the DB write is
|
||
// skipped. No active guard ⇒ behavior is byte-identical to before (opt-in).
|
||
type CasGuard = {
|
||
/** The refresh_token the caller presented for this refresh (CAS version token). */
|
||
expectedRefreshToken: string | null;
|
||
/** Re-reads the CURRENT persisted refresh_token for this connection (decrypted). */
|
||
reread: () => Promise<string | null | undefined>;
|
||
};
|
||
const casGuardStore = new AsyncLocalStorage<CasGuard>();
|
||
const casGuardStats = { skipped: 0, persisted: 0 };
|
||
|
||
export function runWithCasGuard<T>(
|
||
guard: CasGuard | undefined | null,
|
||
fn: () => Promise<T>
|
||
): Promise<T> {
|
||
if (!guard) return fn();
|
||
return casGuardStore.run(guard, fn);
|
||
}
|
||
|
||
export function getActiveCasGuard(): CasGuard | undefined {
|
||
return casGuardStore.getStore();
|
||
}
|
||
|
||
/** Skip/persist counters for observability + tests. */
|
||
export function getCasGuardStats(): { skipped: number; persisted: number } {
|
||
return { ...casGuardStats };
|
||
}
|
||
|
||
/** Test-only: reset the CAS counters between cases. */
|
||
export function _resetCasGuardStats(): void {
|
||
casGuardStats.skipped = 0;
|
||
casGuardStats.persisted = 0;
|
||
}
|
||
|
||
/**
|
||
* Returns true when the persist should be SKIPPED because a concurrent writer
|
||
* already rotated the row's refresh_token past the one we presented (CAS mismatch).
|
||
* Best-effort: any reread failure falls through to persist (never blocks recovery).
|
||
*/
|
||
async function casGuardShouldSkipPersist(log?: RefreshLogger): Promise<boolean> {
|
||
const guard = getActiveCasGuard();
|
||
if (!guard || !guard.expectedRefreshToken) return false;
|
||
let current: string | null | undefined;
|
||
try {
|
||
current = await guard.reread();
|
||
} catch {
|
||
return false; // reread failed — fall through to persist (best-effort)
|
||
}
|
||
// wasRefreshTokenRotated is true iff both are non-empty AND current !== expected.
|
||
if (wasRefreshTokenRotated(guard.expectedRefreshToken, current)) {
|
||
casGuardStats.skipped++;
|
||
log?.warn?.(
|
||
"TOKEN_REFRESH",
|
||
"CAS guard: skipping persist — a concurrent writer already rotated the refresh_token (#4038)"
|
||
);
|
||
return true;
|
||
}
|
||
casGuardStats.persisted++;
|
||
return false;
|
||
}
|
||
|
||
type RefreshLogger = {
|
||
info?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
||
warn?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
||
error?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
||
debug?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
||
} | null;
|
||
|
||
function buildFormParams(entries: Record<string, unknown>): URLSearchParams {
|
||
const params = new URLSearchParams();
|
||
for (const [key, value] of Object.entries(entries)) {
|
||
if (typeof value === "string" && value.length > 0) {
|
||
params.set(key, value);
|
||
}
|
||
}
|
||
return params;
|
||
}
|
||
|
||
function getRefreshCacheKey(provider, refreshToken) {
|
||
const tokenHash = pbkdf2Sync(refreshToken, CACHE_SECRET, 1000, 32, "sha256").toString("hex");
|
||
return `${provider}:${tokenHash}`;
|
||
}
|
||
|
||
/**
|
||
* OAuth2 error codes that mean the refresh token is permanently dead and
|
||
* retrying will never succeed → callers must emit the unrecoverable sentinel
|
||
* so the HealthCheck deactivates the account instead of looping every 60s.
|
||
* Deliberately EXCLUDES transient codes (server_error, temporarily_unavailable,
|
||
* slow_down) so we never deactivate an account over a recoverable blip.
|
||
*/
|
||
const UNRECOVERABLE_OAUTH_ERROR_CODES = new Set([
|
||
"invalid_grant",
|
||
"invalid_request",
|
||
"refresh_token_reused",
|
||
"invalid_token",
|
||
"expired_token",
|
||
"unauthorized_client",
|
||
"access_denied",
|
||
]);
|
||
|
||
/**
|
||
* Extract a canonical OAuth error code from a refresh-endpoint error body of
|
||
* ANY shape. Production proxies/MITMs deliver the same `invalid_grant` 400 in
|
||
* several shapes — a plain object `{error:"invalid_grant"}`, a nested
|
||
* `{error:{code:"invalid_grant"}}`, a JSON **string** (double-encoded body),
|
||
* or the raw JSON text wrapped as `{error:"<json text>"}` by a catch branch.
|
||
* The old `errorBody.error === "invalid_grant"` only matched the first shape,
|
||
* so the others returned `null` → the HealthCheck refresh loop (root cause of
|
||
* the 1352× claude/aa5dd5cf invalidation storm).
|
||
*
|
||
* Returns the matched code (only if it is in UNRECOVERABLE_OAUTH_ERROR_CODES)
|
||
* or null. Never matches loosely — a known code is accepted only when it is a
|
||
* bare code string or the value of an `"error"`/`"error_code"` field, so a 502
|
||
* HTML page or a `server_error` body never becomes a false positive.
|
||
*/
|
||
export function extractOAuthErrorCode(raw: unknown, depth = 0): string | null {
|
||
if (raw == null || depth > 6) return null;
|
||
|
||
if (typeof raw === "string") {
|
||
const s = raw.trim();
|
||
if (!s) return null;
|
||
if (UNRECOVERABLE_OAUTH_ERROR_CODES.has(s)) return s;
|
||
// The string may itself be JSON (a double-encoded body, or the raw text).
|
||
if (s[0] === "{" || s[0] === "[" || s[0] === '"') {
|
||
try {
|
||
const nested = extractOAuthErrorCode(JSON.parse(s), depth + 1);
|
||
if (nested) return nested;
|
||
} catch {
|
||
// not valid JSON — fall through to the field scan
|
||
}
|
||
}
|
||
// Safety net: a known code appearing as the value of an "error"/"error_code"
|
||
// field inside otherwise-unparsed text. Scoped to avoid false positives.
|
||
const m = s.match(/"error(?:_code)?"\s*:\s*"([a-z_]+)"/i);
|
||
if (m && UNRECOVERABLE_OAUTH_ERROR_CODES.has(m[1])) return m[1];
|
||
return null;
|
||
}
|
||
|
||
if (typeof raw === "object") {
|
||
const o = raw as Record<string, unknown>;
|
||
return (
|
||
extractOAuthErrorCode(o.error, depth + 1) ??
|
||
extractOAuthErrorCode(o.code, depth + 1) ??
|
||
extractOAuthErrorCode(o.error_code, depth + 1)
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Read an error response body ONCE and classify it. Returns the raw text (for
|
||
* logging) and the extracted unrecoverable OAuth code (or null). Reading once
|
||
* avoids the double-read bug where `response.json()` consumes the stream and a
|
||
* later `response.text()` returns empty.
|
||
*/
|
||
async function readRefreshErrorBody(
|
||
response: Response
|
||
): Promise<{ rawText: string; code: string | null }> {
|
||
const rawText = await response.text().catch(() => "");
|
||
let parsed: unknown = rawText;
|
||
try {
|
||
parsed = JSON.parse(rawText);
|
||
} catch {
|
||
// keep rawText as-is
|
||
}
|
||
const code = extractOAuthErrorCode(parsed) ?? extractOAuthErrorCode(rawText);
|
||
return { rawText, code };
|
||
}
|
||
|
||
/**
|
||
* Refresh OAuth access token using refresh token
|
||
*/
|
||
export async function refreshAccessToken(
|
||
provider,
|
||
refreshToken,
|
||
credentials,
|
||
log,
|
||
proxyConfig: unknown = null
|
||
) {
|
||
const config = PROVIDERS[provider];
|
||
|
||
const refreshEndpoint = config?.refreshUrl || config?.tokenUrl;
|
||
if (!config || !refreshEndpoint) {
|
||
log?.warn?.("TOKEN_REFRESH", `No refresh endpoint configured for provider: ${provider}`);
|
||
return null;
|
||
}
|
||
|
||
if (!refreshToken) {
|
||
log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`);
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const params = new URLSearchParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
});
|
||
if (config.clientId) params.set("client_id", config.clientId);
|
||
if (config.clientSecret) params.set("client_secret", config.clientSecret);
|
||
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(refreshEndpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
},
|
||
body: params,
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", `Failed to refresh token for ${provider}`, {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
const code = extractOAuthErrorCode(errorText);
|
||
if (code === "invalid_grant" || code === "invalid_request") {
|
||
return { error: "unrecoverable_refresh_error", code };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", `Successfully refreshed token for ${provider}`, {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, {
|
||
error: error.message,
|
||
});
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for Cline OAuth tokens.
|
||
* Cline refresh endpoint expects JSON body and returns camelCase fields.
|
||
*/
|
||
/**
|
||
* Refresh Windsurf (Devin CLI / Codeium) tokens.
|
||
*
|
||
* Windsurf uses Firebase Secure Token Service (STS) for token refresh.
|
||
* If the token is a long-lived Codeium API key (import flow), it never
|
||
* expires and refresh is a no-op returning the same token.
|
||
* If the token is a Firebase ID token (device-code flow), it expires after
|
||
* ~1 hour and can be refreshed with the stored Firebase refresh token.
|
||
*/
|
||
export async function refreshWindsurfToken(
|
||
refreshToken: string,
|
||
providerSpecificData: Record<string, unknown> | null | undefined,
|
||
log: RefreshLogger,
|
||
proxyConfig: unknown = null
|
||
) {
|
||
if (!refreshToken) {
|
||
log?.warn?.(
|
||
"TOKEN_REFRESH",
|
||
"No refresh token stored for Windsurf — token may be a long-lived API key"
|
||
);
|
||
return null;
|
||
}
|
||
|
||
const authMethod = (providerSpecificData?.authMethod as string) || "import";
|
||
|
||
// Long-lived Codeium API keys (import flow) have no expiry — nothing to refresh.
|
||
if (authMethod === "import") {
|
||
log?.debug?.("TOKEN_REFRESH", "Windsurf import token is long-lived — no refresh needed");
|
||
return null;
|
||
}
|
||
|
||
// Firebase STS refresh for browser-flow tokens.
|
||
// Resolves via WINDSURF_CONFIG.firebaseApiKey, which honors the
|
||
// WINDSURF_FIREBASE_API_KEY env override and falls back to the embedded
|
||
// public default in publicCreds.ts. See docs/security/PUBLIC_CREDS.md.
|
||
const firebaseApiKey = WINDSURF_CONFIG.firebaseApiKey || "";
|
||
if (!firebaseApiKey) {
|
||
log?.warn?.(
|
||
"TOKEN_REFRESH",
|
||
"Windsurf Firebase API key unavailable — skipping Firebase token refresh"
|
||
);
|
||
return null;
|
||
}
|
||
const tokenUrl = `https://securetoken.googleapis.com/v1/token?key=${firebaseApiKey}`;
|
||
|
||
try {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(tokenUrl, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: buildFormParams({ grant_type: "refresh_token", refresh_token: refreshToken }),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Windsurf Firebase token", {
|
||
status: response.status,
|
||
error: errorText.slice(0, 200),
|
||
});
|
||
|
||
// Firebase STS returns structured errors. Detect unrecoverable token states.
|
||
try {
|
||
const fbError = JSON.parse(errorText);
|
||
const fbCode =
|
||
typeof fbError?.error?.message === "string"
|
||
? fbError.error.message
|
||
: typeof fbError?.error === "string"
|
||
? fbError.error
|
||
: null;
|
||
if (
|
||
typeof fbCode === "string" &&
|
||
(fbCode.includes("USER_DISABLED") ||
|
||
fbCode.includes("TOKEN_EXPIRED") ||
|
||
fbCode.includes("INVALID_REFRESH_TOKEN") ||
|
||
fbCode.includes("USER_NOT_FOUND"))
|
||
) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"Windsurf Firebase token is permanently invalid. Re-authentication required.",
|
||
{
|
||
fbCode,
|
||
}
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code: fbCode };
|
||
}
|
||
} catch {
|
||
// not JSON — fall through
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
const data = await response.json();
|
||
const expiresIn = parseInt(data.expires_in ?? "3600", 10);
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Windsurf Firebase token", {
|
||
expiresIn,
|
||
hasNewIdToken: !!data.id_token,
|
||
});
|
||
|
||
return {
|
||
accessToken: data.id_token,
|
||
refreshToken: data.refresh_token || refreshToken,
|
||
expiresIn,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
`Network error refreshing Windsurf token: ${error instanceof Error ? error.message : String(error)}`
|
||
);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* CodeBuddy CN (Tencent) token refresh — POST /v2/plugin/auth/token/refresh with
|
||
* the refresh token carried in the X-Refresh-Token header (not a form body),
|
||
* matching the official CodeBuddy CLI. Response: { code: 0, data: <token> }.
|
||
*/
|
||
export async function refreshCodebuddyCnToken(
|
||
refreshToken: string,
|
||
log: RefreshLogger,
|
||
proxyConfig: unknown = null
|
||
) {
|
||
if (!refreshToken) return null;
|
||
const { CODEBUDDY_CN_CONFIG } = await import("@/lib/oauth/constants/oauth");
|
||
const oauth = CODEBUDDY_CN_CONFIG;
|
||
try {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(oauth.refreshUrl, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
"User-Agent": oauth.userAgent,
|
||
"X-Requested-With": "XMLHttpRequest",
|
||
"X-Domain": "copilot.tencent.com",
|
||
"X-Refresh-Token": refreshToken,
|
||
"X-Auth-Refresh-Source": "plugin",
|
||
"X-Product": "SaaS",
|
||
},
|
||
body: "{}",
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh CodeBuddy CN token", {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (data?.code !== 0 || !data?.data?.accessToken) {
|
||
log?.error?.("TOKEN_REFRESH", "CodeBuddy CN token refresh returned no token", {
|
||
code: data?.code,
|
||
msg: data?.msg,
|
||
});
|
||
return null;
|
||
}
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed CodeBuddy CN token", {
|
||
hasNewAccessToken: !!data.data.accessToken,
|
||
hasNewRefreshToken: !!data.data.refreshToken,
|
||
expiresIn: data.data.expiresIn,
|
||
});
|
||
|
||
return {
|
||
accessToken: data.data.accessToken,
|
||
refreshToken: data.data.refreshToken || refreshToken,
|
||
expiresIn: data.data.expiresIn,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.("TOKEN_REFRESH", `Network error refreshing CodeBuddy CN token: ${error?.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function refreshClineToken(refreshToken, log, proxyConfig: unknown = null) {
|
||
const endpoint = PROVIDERS.cline?.refreshUrl;
|
||
if (!endpoint) {
|
||
log?.warn?.("TOKEN_REFRESH", "No refresh URL configured for Cline");
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(endpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
refreshToken,
|
||
grantType: "refresh_token",
|
||
clientType: "extension",
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Cline token", {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
const code = extractOAuthErrorCode(errorText);
|
||
if (code === "invalid_grant" || code === "invalid_request") {
|
||
return { error: "unrecoverable_refresh_error", code };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const payload = await response.json();
|
||
const data = payload?.data || payload;
|
||
const expiresAtIso = data?.expiresAt;
|
||
const expiresIn = expiresAtIso
|
||
? Math.max(1, Math.floor((new Date(expiresAtIso).getTime() - Date.now()) / 1000))
|
||
: undefined;
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Cline token", {
|
||
hasNewAccessToken: !!data?.accessToken,
|
||
hasNewRefreshToken: !!data?.refreshToken,
|
||
expiresIn,
|
||
});
|
||
|
||
return {
|
||
accessToken: data?.accessToken,
|
||
refreshToken: data?.refreshToken || refreshToken,
|
||
expiresIn,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Cline token: ${error.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for Kimi Coding OAuth tokens.
|
||
* Uses custom X-Msh-* headers required by Kimi OAuth API.
|
||
*
|
||
* Uses a stable device_id from providerSpecificData (stored at login) to avoid
|
||
* anti-bot detection from ephemeral IDs. If absent, derives a deterministic ID
|
||
* from the refresh token hash so it is at least stable across refreshes for the
|
||
* same token.
|
||
*/
|
||
export async function refreshKimiCodingToken(
|
||
refreshToken: string,
|
||
providerSpecificData: Record<string, unknown> | null | undefined,
|
||
log: RefreshLogger,
|
||
proxyConfig: unknown = null
|
||
) {
|
||
const endpoint = PROVIDERS["kimi-coding"]?.refreshUrl || PROVIDERS["kimi-coding"]?.tokenUrl;
|
||
if (!endpoint) {
|
||
log?.warn?.("TOKEN_REFRESH", "No refresh URL configured for Kimi Coding");
|
||
return null;
|
||
}
|
||
|
||
// Prefer stable device_id persisted at login time; fall back to a
|
||
// deterministic hash of the refresh token so it is at least consistent
|
||
// across refreshes for the same session.
|
||
const stableDeviceId =
|
||
normalizeKimiDeviceId(providerSpecificData?.deviceId) ||
|
||
normalizeKimiDeviceId(
|
||
pbkdf2Sync(refreshToken, "kimi-device-id", 1000, 16, "sha256").toString("hex")
|
||
);
|
||
|
||
const osRelease = release();
|
||
const persistedDeviceModel =
|
||
typeof providerSpecificData?.deviceModel === "string"
|
||
? providerSpecificData.deviceModel.trim()
|
||
: "";
|
||
const deviceModel = persistedDeviceModel || getKimiDeviceModel();
|
||
|
||
try {
|
||
const params = new URLSearchParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: PROVIDERS["kimi-coding"]?.clientId || "",
|
||
});
|
||
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(endpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
...buildKimiCodeIdentityHeaders({
|
||
deviceId: stableDeviceId,
|
||
deviceName: providerSpecificData?.deviceName || hostname(),
|
||
deviceModel,
|
||
osVersion: providerSpecificData?.osVersion || osRelease,
|
||
}),
|
||
},
|
||
body: params,
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
|
||
// Detect unrecoverable errors
|
||
try {
|
||
const parsed = JSON.parse(errorText);
|
||
const errorCode = parsed?.error;
|
||
if (errorCode === "invalid_grant" || errorCode === "invalid_request") {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"Kimi Coding refresh token invalid. Re-authentication required.",
|
||
{
|
||
errorCode,
|
||
}
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code: errorCode };
|
||
}
|
||
} catch {
|
||
// not JSON — fall through
|
||
}
|
||
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kimi Coding token", {
|
||
status: response.status,
|
||
error: errorText.slice(0, 200),
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kimi Coding token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
tokenType: tokens.token_type,
|
||
scope: tokens.scope,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
`Network error refreshing Kimi Coding token: ${error instanceof Error ? error.message : String(error)}`
|
||
);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for GitLab Duo OAuth tokens.
|
||
* Token URL is instance-specific; resolves from providerSpecificData.baseUrl.
|
||
* Uses PKCE authorization_code flow initially but refresh_token grant does NOT
|
||
* require code_verifier — only client_id + refresh_token.
|
||
* On invalid_grant (revoked/expired refresh token) returns the unrecoverable sentinel.
|
||
*/
|
||
export async function refreshGitLabDuoToken(
|
||
refreshToken: string,
|
||
providerSpecificData: Record<string, unknown> | null | undefined,
|
||
log: RefreshLogger,
|
||
proxyConfig: unknown = null
|
||
) {
|
||
if (!refreshToken) {
|
||
log?.warn?.("TOKEN_REFRESH", "No refresh token for GitLab Duo");
|
||
return null;
|
||
}
|
||
|
||
const baseUrl = resolveGitLabOAuthBaseUrl(providerSpecificData);
|
||
const endpoints = buildGitLabOAuthEndpoints(baseUrl);
|
||
const tokenUrl = endpoints.tokenUrl;
|
||
|
||
// client_id from providerSpecificData (stored at login) or fall back to PROVIDERS config
|
||
const clientId =
|
||
(providerSpecificData?.clientId as string) ||
|
||
PROVIDERS["gitlab-duo"]?.clientId ||
|
||
process.env.GITLAB_DUO_OAUTH_CLIENT_ID ||
|
||
process.env.GITLAB_OAUTH_CLIENT_ID ||
|
||
"";
|
||
|
||
try {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(tokenUrl, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
},
|
||
body: buildFormParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: clientId,
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
|
||
// Detect unrecoverable token — GitLab returns standard OAuth2 error codes.
|
||
try {
|
||
const errorBody = JSON.parse(errorText);
|
||
const errorCode = errorBody.error;
|
||
if (errorCode === "invalid_grant" || errorCode === "invalid_request") {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"GitLab Duo refresh token invalid. Re-authentication required.",
|
||
{
|
||
errorCode,
|
||
}
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code: errorCode };
|
||
}
|
||
} catch {
|
||
// not JSON — fall through
|
||
}
|
||
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh GitLab Duo token", {
|
||
status: response.status,
|
||
error: errorText.slice(0, 200),
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitLab Duo token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
`Network error refreshing GitLab Duo token: ${error instanceof Error ? error.message : String(error)}`
|
||
);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for Claude OAuth tokens
|
||
*/
|
||
export async function refreshClaudeOAuthToken(refreshToken, log, proxyConfig: unknown = null) {
|
||
try {
|
||
// Standard OAuth2 token refresh uses form-urlencoded (not JSON)
|
||
const params = buildFormParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: PROVIDERS.claude.clientId,
|
||
});
|
||
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(OAUTH_ENDPOINTS.anthropic.token, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
"anthropic-beta": "oauth-2025-04-20",
|
||
},
|
||
body: params.toString(),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
// Read + classify the body ONCE, shape-agnostic. A proxy/MITM can deliver
|
||
// the invalid_grant 400 as a JSON string, a double-encoded string, a
|
||
// nested {error:{code}}, or raw text — all must yield the sentinel so the
|
||
// HealthCheck deactivates instead of looping every 60s.
|
||
const { rawText, code } = await readRefreshErrorBody(response);
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", {
|
||
status: response.status,
|
||
error: rawText.slice(0, 300),
|
||
});
|
||
if (code === "invalid_grant" || code === "invalid_request") {
|
||
return { error: "unrecoverable_refresh_error", code };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for Google providers (Gemini, Antigravity)
|
||
*/
|
||
export async function refreshGoogleToken(
|
||
refreshToken,
|
||
clientId,
|
||
clientSecret,
|
||
log,
|
||
proxyConfig: unknown = null
|
||
) {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(OAUTH_ENDPOINTS.google.token, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
},
|
||
body: buildFormParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: clientId,
|
||
client_secret: clientSecret,
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", {
|
||
status: response.status,
|
||
error: errorText.slice(0, 200),
|
||
});
|
||
|
||
// Detect unrecoverable token (invalid_grant = revoked / expired refresh token)
|
||
try {
|
||
const errorBody = JSON.parse(errorText);
|
||
if (errorBody.error === "invalid_grant") {
|
||
log?.error?.("TOKEN_REFRESH", "Google refresh token invalid. Re-authentication required.", {
|
||
provider: "google",
|
||
});
|
||
return { error: "unrecoverable_refresh_error", code: "invalid_grant" };
|
||
}
|
||
} catch {
|
||
// not JSON — fall through
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
};
|
||
}
|
||
|
||
export async function refreshQwenToken(refreshToken, log, proxyConfig: unknown = null) {
|
||
const endpoint = OAUTH_ENDPOINTS.qwen.token;
|
||
|
||
try {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(endpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
},
|
||
body: buildFormParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: PROVIDERS.qwen.clientId,
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (response.status === 200) {
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
providerSpecificData: tokens.resource_url
|
||
? { resourceUrl: tokens.resource_url }
|
||
: undefined,
|
||
};
|
||
} else {
|
||
const errorText = await response.text().catch(() => "");
|
||
|
||
// Detect unrecoverable invalid_request (expired/revoked refresh token or bad client_id)
|
||
let errorCode = null;
|
||
try {
|
||
const parsed = JSON.parse(errorText);
|
||
errorCode = parsed?.error;
|
||
} catch {
|
||
// not JSON, ignore
|
||
}
|
||
|
||
if (errorCode === "invalid_request" || errorCode === "invalid_grant") {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"Qwen refresh token is invalid or expired. Re-authentication required.",
|
||
{
|
||
status: response.status,
|
||
errorCode,
|
||
}
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code: errorCode };
|
||
}
|
||
|
||
log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, {
|
||
error: error.message,
|
||
});
|
||
}
|
||
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token");
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for Codex (OpenAI) OAuth tokens.
|
||
* OpenAI uses rotating (one-time-use) refresh tokens.
|
||
* Returns { error: 'unrecoverable_refresh_error', code } when the token has already been
|
||
* consumed or is invalid, so callers can stop retrying and request re-authentication.
|
||
*/
|
||
export async function refreshCodexToken(refreshToken, log, proxyConfig: unknown = null) {
|
||
try {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(OAUTH_ENDPOINTS.openai.token, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
},
|
||
// Body intentionally omits `scope`. RFC 6749 §6 makes scope optional on a
|
||
// refresh_token grant (the server reuses the originally-granted scope when
|
||
// absent). Including `scope` causes Auth0 (which OpenAI Codex OAuth is
|
||
// built on) to treat the request as a re-scope, which can invalidate
|
||
// sibling refresh_token families on the same client_id. Matches the
|
||
// pattern used by ndycode/codex-multi-auth, the only known tool that
|
||
// sustains multiple Codex accounts without cross-invalidation.
|
||
body: buildFormParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: PROVIDERS.codex.clientId,
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
|
||
// Detect unrecoverable "refresh_token_reused" or "invalid_grant" error from OpenAI
|
||
// This means the token was already consumed or has expired.
|
||
// Retrying with the same token will never succeed.
|
||
let errorCode = null;
|
||
try {
|
||
const parsed = JSON.parse(errorText);
|
||
errorCode =
|
||
parsed?.error?.code || (typeof parsed?.error === "string" ? parsed.error : null);
|
||
} catch {
|
||
// not JSON, ignore
|
||
}
|
||
|
||
if (
|
||
errorCode === "refresh_token_reused" ||
|
||
errorCode === "invalid_grant" ||
|
||
errorCode === "token_expired" ||
|
||
errorCode === "invalid_token"
|
||
) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"Codex refresh token already used or invalid. Re-authentication required.",
|
||
{
|
||
status: response.status,
|
||
errorCode,
|
||
}
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code: errorCode };
|
||
}
|
||
|
||
// Defense-in-depth (port from decolua/9router#1821): any 401 from OpenAI's
|
||
// OAuth token endpoint means the refresh credential itself was rejected
|
||
// (e.g. rotated away, or a payload variant whose code we do not yet
|
||
// recognize — OpenAI has shipped both `token_expired` and the bare
|
||
// "Could not validate your token" message). Retrying with the same dead
|
||
// refresh token will never succeed; surface re-auth instead of looping.
|
||
// 429 / 5xx remain transient and fall through to the retryable branch.
|
||
if (response.status === 401) {
|
||
const code = errorCode || "unauthorized";
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"Codex OAuth token endpoint returned 401. Re-authentication required.",
|
||
{
|
||
status: response.status,
|
||
errorCode: code,
|
||
}
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code };
|
||
}
|
||
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for Kiro (AWS CodeWhisperer) tokens
|
||
* Supports both AWS SSO OIDC (Builder ID/IDC) and Social Auth (Google/GitHub)
|
||
*/
|
||
export async function refreshKiroToken(
|
||
refreshToken,
|
||
providerSpecificData,
|
||
log,
|
||
proxyConfig: unknown = null
|
||
) {
|
||
try {
|
||
const authMethod = providerSpecificData?.authMethod;
|
||
const clientId = providerSpecificData?.clientId;
|
||
const clientSecret = providerSpecificData?.clientSecret;
|
||
const region = providerSpecificData?.region;
|
||
|
||
// Enterprise / Microsoft Entra "Your organization" (external_idp) logins refresh with a
|
||
// standard PUBLIC-client OAuth2 refresh_token grant against the org IdP's own tokenEndpoint
|
||
// (form-encoded client_id + refresh_token + scope, no client_secret) — NOT the AWS SSO OIDC
|
||
// or Kiro social endpoints. The rotated refresh_token is persisted by the caller.
|
||
if (isExternalIdpAuthMethod(authMethod)) {
|
||
let refreshRequest;
|
||
try {
|
||
refreshRequest = buildExternalIdpRefreshParams(refreshToken, providerSpecificData);
|
||
} catch (cfgErr) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
`Invalid Kiro external_idp refresh config: ${cfgErr instanceof Error ? cfgErr.message : String(cfgErr)}`
|
||
);
|
||
return null;
|
||
}
|
||
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(refreshRequest.tokenEndpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
},
|
||
body: refreshRequest.body,
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
let oauthErr: string | undefined;
|
||
try {
|
||
oauthErr = JSON.parse(errorText)?.error;
|
||
} catch {
|
||
/* not JSON */
|
||
}
|
||
if (oauthErr === "invalid_grant" || oauthErr === "invalid_client") {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"Kiro external_idp refresh token expired/invalid. Re-authentication required.",
|
||
{ oauthErr }
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code: oauthErr };
|
||
}
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro external_idp token", {
|
||
status: response.status,
|
||
error: errorText.slice(0, 200),
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro external_idp token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in || 3600,
|
||
};
|
||
}
|
||
|
||
// AWS SSO OIDC (Builder ID or IDC)
|
||
// If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified).
|
||
// Exception: imported social tokens (authMethod === "imported") carry a freshly-registered
|
||
// clientId/clientSecret but their refresh token is Kiro-social-issued — the isolated OIDC client
|
||
// cannot refresh it, so they must fall through to the social auth path (#2467).
|
||
if (clientId && clientSecret && authMethod !== "imported") {
|
||
const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`;
|
||
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(endpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
clientId: clientId,
|
||
clientSecret: clientSecret,
|
||
refreshToken: refreshToken,
|
||
grantType: "refresh_token",
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
|
||
// AWS SSO OIDC uses {"__type": "InvalidGrantException"} error format (not standard OAuth2).
|
||
let awsErrorType: string | undefined;
|
||
try {
|
||
const awsError = JSON.parse(errorText);
|
||
awsErrorType = awsError.__type || awsError.error;
|
||
} catch {
|
||
// not JSON
|
||
}
|
||
|
||
// If the refresh token itself is expired/revoked, no amount of re-registration helps.
|
||
if (
|
||
awsErrorType === "InvalidGrantException" ||
|
||
awsErrorType === "ExpiredTokenException" ||
|
||
awsErrorType === "invalid_grant"
|
||
) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"Kiro AWS refresh token expired/invalid. Re-authentication required.",
|
||
{ awsErrorType }
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code: awsErrorType };
|
||
}
|
||
|
||
// Client credentials may be expired/invalid (DB import, TTL expiry, browser conflict).
|
||
// Re-register a fresh OIDC client and retry once before giving up (#2524).
|
||
log?.warn?.(
|
||
"TOKEN_REFRESH",
|
||
"Kiro OIDC refresh failed, attempting client re-registration...",
|
||
{ status: response.status, error: errorText.slice(0, 200) }
|
||
);
|
||
|
||
try {
|
||
const resolvedRegion = region || "us-east-1";
|
||
const regEndpoint = `https://oidc.${resolvedRegion}.amazonaws.com/client/register`;
|
||
const regRes = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(regEndpoint, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
body: JSON.stringify({
|
||
clientName: "kiro-oauth-client",
|
||
clientType: "public",
|
||
scopes: [
|
||
"codewhisperer:completions",
|
||
"codewhisperer:analysis",
|
||
"codewhisperer:conversations",
|
||
],
|
||
grantTypes: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
|
||
issuerUrl: "https://identitycenter.amazonaws.com/ssoins-722374e8c3c8e6c6",
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (regRes.ok) {
|
||
const newClient = await regRes.json();
|
||
const retryRes = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(endpoint, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
body: JSON.stringify({
|
||
clientId: newClient.clientId,
|
||
clientSecret: newClient.clientSecret,
|
||
refreshToken: refreshToken,
|
||
grantType: "refresh_token",
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (retryRes.ok) {
|
||
const retryTokens = await retryRes.json();
|
||
log?.info?.("TOKEN_REFRESH", "Kiro refresh recovered via client re-registration", {
|
||
hasNewAccessToken: !!retryTokens.accessToken,
|
||
expiresIn: retryTokens.expiresIn,
|
||
});
|
||
return {
|
||
accessToken: retryTokens.accessToken,
|
||
refreshToken: retryTokens.refreshToken || refreshToken,
|
||
expiresIn: retryTokens.expiresIn,
|
||
_newClientId: newClient.clientId,
|
||
_newClientSecret: newClient.clientSecret,
|
||
_newClientSecretExpiresAt: newClient.clientSecretExpiresAt,
|
||
};
|
||
}
|
||
}
|
||
} catch (reRegErr) {
|
||
log?.warn?.("TOKEN_REFRESH", "Kiro client re-registration fallback failed", {
|
||
error: String(reRegErr),
|
||
});
|
||
}
|
||
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro AWS token", {
|
||
status: response.status,
|
||
error: errorText.slice(0, 200),
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro AWS token", {
|
||
hasNewAccessToken: !!tokens.accessToken,
|
||
expiresIn: tokens.expiresIn,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.accessToken,
|
||
refreshToken: tokens.refreshToken || refreshToken,
|
||
expiresIn: tokens.expiresIn,
|
||
};
|
||
}
|
||
|
||
// Social Auth (Google/GitHub) - use Kiro's refresh endpoint
|
||
const tokenUrl = PROVIDERS.kiro.tokenUrl;
|
||
if (!tokenUrl) {
|
||
log?.error?.("TOKEN_REFRESH", "Missing Kiro token endpoint");
|
||
return null;
|
||
}
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(tokenUrl, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
refreshToken: refreshToken,
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
|
||
// Also check for AWS-style errors on the social auth path (Kiro may relay them)
|
||
try {
|
||
const awsError = JSON.parse(errorText);
|
||
const awsErrorType = awsError.__type || awsError.error;
|
||
if (
|
||
awsErrorType === "InvalidGrantException" ||
|
||
awsErrorType === "ExpiredTokenException" ||
|
||
awsErrorType === "invalid_grant"
|
||
) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"Kiro social refresh token expired/invalid. Re-authentication required.",
|
||
{
|
||
awsErrorType,
|
||
}
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code: awsErrorType };
|
||
}
|
||
} catch {
|
||
// not JSON — fall through
|
||
}
|
||
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro social token", {
|
||
status: response.status,
|
||
error: errorText.slice(0, 200),
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro social token", {
|
||
hasNewAccessToken: !!tokens.accessToken,
|
||
expiresIn: tokens.expiresIn,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.accessToken,
|
||
refreshToken: tokens.refreshToken || refreshToken,
|
||
expiresIn: tokens.expiresIn,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Kiro token: ${error.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for Qoder OAuth tokens
|
||
*/
|
||
export async function refreshQoderToken(refreshToken, log, proxyConfig: unknown = null) {
|
||
if (!OAUTH_ENDPOINTS.qoder.token || !PROVIDERS.qoder.clientId || !PROVIDERS.qoder.clientSecret) {
|
||
log?.warn?.(
|
||
"TOKEN_REFRESH",
|
||
"Qoder OAuth refresh skipped: browser OAuth is not configured in this environment"
|
||
);
|
||
return null;
|
||
}
|
||
|
||
const basicAuth = btoa(`${PROVIDERS.qoder.clientId}:${PROVIDERS.qoder.clientSecret}`);
|
||
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(OAUTH_ENDPOINTS.qoder.token, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
Authorization: `Basic ${basicAuth}`,
|
||
},
|
||
body: buildFormParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: PROVIDERS.qoder.clientId,
|
||
client_secret: PROVIDERS.qoder.clientSecret,
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qoder token", {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
const code = extractOAuthErrorCode(errorText);
|
||
if (code === "invalid_grant" || code === "invalid_request") {
|
||
return { error: "unrecoverable_refresh_error", code };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qoder token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for GitHub Copilot OAuth tokens
|
||
*/
|
||
export async function refreshGitHubToken(refreshToken, log, proxyConfig: unknown = null) {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(OAUTH_ENDPOINTS.github.token, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
},
|
||
body: buildFormParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: PROVIDERS.github.clientId,
|
||
client_secret: PROVIDERS.github.clientSecret,
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
const code = extractOAuthErrorCode(errorText);
|
||
if (code === "invalid_grant" || code === "invalid_request") {
|
||
return { error: "unrecoverable_refresh_error", code };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Refresh GitHub Copilot token using GitHub access token
|
||
*/
|
||
export async function refreshCopilotToken(githubAccessToken, log, proxyConfig: unknown = null) {
|
||
try {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch("https://api.github.com/copilot_internal/v2/token", {
|
||
headers: getGitHubCopilotRefreshHeaders(`token ${githubAccessToken}`),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Copilot token", {
|
||
hasToken: !!data.token,
|
||
expiresAt: data.expires_at,
|
||
});
|
||
|
||
return {
|
||
token: data.token,
|
||
expiresAt: data.expires_at,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", {
|
||
error: error.message,
|
||
});
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get access token for a specific provider (internal, does the actual work)
|
||
*/
|
||
async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: unknown = null) {
|
||
switch (provider) {
|
||
case "gemini":
|
||
case "antigravity":
|
||
case "agy":
|
||
return await refreshGoogleToken(
|
||
credentials.refreshToken,
|
||
PROVIDERS[provider].clientId,
|
||
PROVIDERS[provider].clientSecret,
|
||
log,
|
||
proxyConfig
|
||
);
|
||
|
||
case "claude":
|
||
return await refreshClaudeOAuthToken(credentials.refreshToken, log, proxyConfig);
|
||
|
||
case "codex":
|
||
return await refreshCodexToken(credentials.refreshToken, log, proxyConfig);
|
||
|
||
case "qwen":
|
||
return await refreshQwenToken(credentials.refreshToken, log, proxyConfig);
|
||
|
||
case "qoder":
|
||
return await refreshQoderToken(credentials.refreshToken, log, proxyConfig);
|
||
|
||
case "github":
|
||
return await refreshGitHubToken(credentials.refreshToken, log, proxyConfig);
|
||
|
||
case "kiro":
|
||
case "amazon-q":
|
||
return await refreshKiroToken(
|
||
credentials.refreshToken,
|
||
credentials.providerSpecificData,
|
||
log,
|
||
proxyConfig
|
||
);
|
||
|
||
case "cline":
|
||
case "clinepass": // reuses the Cline WorkOS refresh flow (clinepass: cline)
|
||
return await refreshClineToken(credentials.refreshToken, log, proxyConfig);
|
||
|
||
case "kimi-coding":
|
||
return await refreshKimiCodingToken(
|
||
credentials.refreshToken,
|
||
credentials.providerSpecificData,
|
||
log,
|
||
proxyConfig
|
||
);
|
||
|
||
case "gitlab-duo":
|
||
return await refreshGitLabDuoToken(
|
||
credentials.refreshToken,
|
||
credentials.providerSpecificData,
|
||
log,
|
||
proxyConfig
|
||
);
|
||
|
||
case "windsurf":
|
||
case "devin-cli":
|
||
return await refreshWindsurfToken(
|
||
credentials.refreshToken,
|
||
credentials.providerSpecificData,
|
||
log,
|
||
proxyConfig
|
||
);
|
||
|
||
case "codebuddy-cn":
|
||
return await refreshCodebuddyCnToken(credentials.refreshToken, log, proxyConfig);
|
||
|
||
default:
|
||
// Fallback to generic OAuth refresh for unknown providers
|
||
return refreshAccessToken(provider, credentials.refreshToken, credentials, log, proxyConfig);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Whether a provider has a supported refresh path in this service.
|
||
*/
|
||
export function supportsTokenRefresh(provider) {
|
||
const explicitlySupported = new Set([
|
||
"gemini",
|
||
"antigravity",
|
||
"agy",
|
||
"claude",
|
||
"codex",
|
||
"qwen",
|
||
"qoder",
|
||
"github",
|
||
"kiro",
|
||
"amazon-q",
|
||
"cline",
|
||
"kimi-coding",
|
||
"windsurf",
|
||
"devin-cli",
|
||
"gitlab-duo",
|
||
"codebuddy-cn",
|
||
]);
|
||
if (explicitlySupported.has(provider)) return true;
|
||
const config = PROVIDERS[provider];
|
||
return !!(config?.refreshUrl || config?.tokenUrl);
|
||
}
|
||
|
||
/**
|
||
* Check if a refresh result indicates an unrecoverable error
|
||
* (e.g. the refresh token was already consumed and cannot be reused).
|
||
* Callers should stop retrying and request re-authentication.
|
||
*/
|
||
export function isUnrecoverableRefreshError(result) {
|
||
return (
|
||
result &&
|
||
typeof result === "object" &&
|
||
(result.error === "unrecoverable_refresh_error" ||
|
||
result.error === "refresh_token_reused" ||
|
||
result.error === "invalid_request" ||
|
||
result.error === "invalid_grant")
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Get access token for a specific provider (with deduplication).
|
||
*
|
||
* Deduplication strategy (two layers):
|
||
* 1. Per-connection mutex (primary): if credentials.connectionId is present, all concurrent
|
||
* callers for that connection share one in-flight promise regardless of which token they
|
||
* loaded. This prevents refresh_token_reused errors with rotating (one-time-use) tokens,
|
||
* e.g. Codex/OpenAI, where callers that loaded credentials at different times may hold
|
||
* different token strings but refer to the same connection.
|
||
* 2. Token-hash fallback: if no connectionId, dedup by provider+sha256(refreshToken) as before.
|
||
*
|
||
* Additionally, when connectionId is present, the stale-token check reads the DB to detect
|
||
* whether another process already refreshed the token. If the DB token is still valid it is
|
||
* returned immediately without a new upstream call.
|
||
*
|
||
* @param onPersist - Optional callback invoked INSIDE the per-connection mutex closure after a
|
||
* successful refresh, before the mutex releases. Use this to atomically persist the new tokens
|
||
* to the DB within the same lock window. If `onPersist` throws, the error is logged and
|
||
* re-thrown so the caller is aware of the persistence failure.
|
||
*/
|
||
export async function getAccessToken(
|
||
provider,
|
||
credentials,
|
||
log,
|
||
proxyConfig: unknown = null,
|
||
onPersist?: RefreshPersistFn
|
||
) {
|
||
if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") {
|
||
log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`);
|
||
return null;
|
||
}
|
||
|
||
// If the caller did not pass onPersist explicitly, fall back to the active
|
||
// AsyncLocalStorage store. This lets `runWithOnPersist(persistFn, () =>
|
||
// executor.refreshCredentials(creds, log))` plumb the persist callback through
|
||
// executors (e.g. CodexExecutor) without modifying their signature.
|
||
const effectiveOnPersist = onPersist ?? getActiveOnPersist();
|
||
|
||
const connectionId = credentials.connectionId;
|
||
|
||
// ── Layer 1: per-connection mutex ──────────────────────────────────────────
|
||
if (connectionId && typeof connectionId === "string") {
|
||
const existing = connectionRefreshMutex.get(connectionId);
|
||
if (existing) {
|
||
existing.waiters++;
|
||
log?.info?.("TOKEN_REFRESH", "Concurrent refresh detected — sharing in-flight refresh", {
|
||
provider,
|
||
connectionId,
|
||
waiters: existing.waiters,
|
||
});
|
||
return existing.promise;
|
||
}
|
||
|
||
const entry = { promise: null, waiters: 0 };
|
||
entry.promise = (async () => {
|
||
const result = await _getAccessTokenWithStalenessCheck(
|
||
provider,
|
||
credentials,
|
||
log,
|
||
proxyConfig
|
||
);
|
||
// Invoke onPersist INSIDE the mutex so [network call + DB write] are one atomic step.
|
||
// This prevents a concurrent waiter from reading stale credentials before the DB is updated.
|
||
if (result?.accessToken && effectiveOnPersist) {
|
||
// #4038: skip the persist if a concurrent writer already rotated this row past the
|
||
// refresh_token we presented (compare-and-swap) — overwriting would revert it.
|
||
if (await casGuardShouldSkipPersist(log)) {
|
||
return result;
|
||
}
|
||
try {
|
||
await effectiveOnPersist(result);
|
||
} catch (persistErr) {
|
||
const { sanitizeErrorMessage } = await import("../utils/error.ts");
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
`onPersist callback failed for ${provider}/${connectionId}: ${sanitizeErrorMessage(persistErr instanceof Error ? persistErr : new Error(String(persistErr)))}`
|
||
);
|
||
throw persistErr;
|
||
}
|
||
}
|
||
return result;
|
||
})().finally(() => {
|
||
connectionRefreshMutex.delete(connectionId);
|
||
});
|
||
connectionRefreshMutex.set(connectionId, entry);
|
||
return entry.promise;
|
||
}
|
||
|
||
// ── Layer 2: token-hash fallback (no connectionId) ─────────────────────────
|
||
const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken);
|
||
|
||
if (refreshPromiseCache.has(cacheKey)) {
|
||
log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`);
|
||
return refreshPromiseCache.get(cacheKey);
|
||
}
|
||
|
||
// Layer 2 has no per-connection mutex, so callers that pass an onPersist
|
||
// callback expect it to fire after a successful refresh. Without this hook
|
||
// the legacy `connectionId`-less path would silently swallow the callback,
|
||
// leaving DB rows out of sync with rotated tokens (Codex/OpenAI). We still
|
||
// resolve the promise to all waiters with the refreshed credentials.
|
||
const refreshPromise = serializeRefresh(provider, () =>
|
||
_getAccessTokenInternal(provider, credentials, log, proxyConfig)
|
||
)
|
||
.then(async (result) => {
|
||
if (result?.accessToken && effectiveOnPersist) {
|
||
// #4038: same compare-and-swap guard as Layer 1 — skip the persist if a concurrent
|
||
// writer already rotated this row past the refresh_token we presented.
|
||
if (await casGuardShouldSkipPersist(log)) {
|
||
return result;
|
||
}
|
||
try {
|
||
await effectiveOnPersist(result);
|
||
} catch (persistErr) {
|
||
const { sanitizeErrorMessage } = await import("../utils/error.ts");
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
`Layer 2 onPersist callback failed for ${provider}: ${sanitizeErrorMessage(persistErr instanceof Error ? persistErr : new Error(String(persistErr)))}`
|
||
);
|
||
throw persistErr;
|
||
}
|
||
} else if (result?.accessToken && !effectiveOnPersist) {
|
||
log?.warn?.(
|
||
"TOKEN_REFRESH",
|
||
`Layer 2 refresh succeeded for ${provider} without onPersist — DB row will not be updated with rotated token. Callers should pass connectionId for Layer 1 atomicity.`
|
||
);
|
||
}
|
||
return result;
|
||
})
|
||
.finally(() => {
|
||
refreshPromiseCache.delete(cacheKey);
|
||
});
|
||
|
||
refreshPromiseCache.set(cacheKey, refreshPromise);
|
||
return refreshPromise;
|
||
}
|
||
|
||
/**
|
||
* Internal helper: performs the DB staleness check then calls the actual refresh.
|
||
* Only called from the per-connection mutex path (Layer 1 above).
|
||
*/
|
||
async function _getAccessTokenWithStalenessCheck(provider, credentials, log, proxyConfig) {
|
||
// ROTATION MAP CHECK (codex-multi-auth pattern): if this refresh_token was
|
||
// rotated very recently (within ROTATION_MAP_TTL_MS), reuse the cached new
|
||
// tokens INSTEAD of hitting upstream. Auth0 treats re-use of a rotated token
|
||
// as a security event and revokes the entire token family — fatal for
|
||
// multi-account Codex setups. The in-memory rotation map catches this even
|
||
// when the caller bypasses the DB staleness path (no connectionId, stale
|
||
// in-memory credentials in retries, etc.).
|
||
const rotated = lookupRotation(provider, credentials.refreshToken);
|
||
if (rotated) {
|
||
log?.info?.(
|
||
"TOKEN_REFRESH",
|
||
`Rotation map hit for ${provider}. Returning cached rotated tokens (avoids family-revoke).`
|
||
);
|
||
return rotated.result;
|
||
}
|
||
|
||
// RACE CONDITION PREVENTION:
|
||
// If the credentials object in memory is stale (e.g. it waited in a semaphore while another
|
||
// request refreshed the token), using its OLD refreshToken will cause the provider (e.g. OpenAI)
|
||
// to reject it with 'refresh_token_reused' and revoke the new token family.
|
||
// We MUST check if the DB has a newer token before proceeding with a network refresh.
|
||
if (credentials.connectionId) {
|
||
try {
|
||
const { getProviderConnectionById } = await import("@/lib/db/providers");
|
||
const dbConnection = await getProviderConnectionById(credentials.connectionId);
|
||
if (dbConnection && dbConnection.refreshToken) {
|
||
const now = Date.now();
|
||
const dbExpiresAt = dbConnection.expiresAt ? new Date(dbConnection.expiresAt).getTime() : 0;
|
||
|
||
if (dbConnection.refreshToken !== credentials.refreshToken) {
|
||
log?.info?.(
|
||
"TOKEN_REFRESH",
|
||
`Stale token detected in memory for ${provider}. Using refreshed token from DB.`
|
||
);
|
||
|
||
// If the DB token is not expired, we can just return it!
|
||
if (dbExpiresAt > now + 60000) {
|
||
// 60 seconds buffer
|
||
log?.info?.("TOKEN_REFRESH", `DB token is still valid. Skipping OAuth refresh.`);
|
||
return {
|
||
accessToken: dbConnection.accessToken,
|
||
refreshToken: dbConnection.refreshToken,
|
||
// Return absolute expiresAt so downstream callers do NOT recompute lifetime
|
||
// from a relative expiresIn value (which would incorrectly extend the TTL).
|
||
// expiresIn intentionally omitted here.
|
||
expiresAt: dbConnection.expiresAt,
|
||
};
|
||
} else {
|
||
// DB token is also expired, but it's the NEWEST one. We must use it to refresh.
|
||
credentials.refreshToken = dbConnection.refreshToken;
|
||
credentials.accessToken = dbConnection.accessToken;
|
||
}
|
||
}
|
||
// NOTE: Fix F (skip when DB == memory and DB > now+60s) was intentionally
|
||
// removed. The caller (checkAndRefreshToken) already decided to refresh
|
||
// because the token is within TOKEN_EXPIRY_BUFFER_MS of expiry. Re-checking
|
||
// with a tighter 60-second window here would skip legitimate refreshes and
|
||
// let near-expired tokens hit the upstream. Layer-1 mutex (per-connection)
|
||
// and Layer-2 dedup (token-hash) already prevent concurrent refreshes for
|
||
// the import-burst scenario.
|
||
}
|
||
} catch (e) {
|
||
log?.warn?.(
|
||
"TOKEN_REFRESH",
|
||
`Failed to check DB for stale token: ${e instanceof Error ? e.message : String(e)}`
|
||
);
|
||
}
|
||
}
|
||
|
||
const oldRefreshToken = credentials.refreshToken;
|
||
// Front 1: serialize the network refresh across all connections of the same
|
||
// rotation group (e.g. Codex+openai share one Auth0 client) so two sibling
|
||
// accounts never refresh concurrently and trip Auth0 family revocation.
|
||
const result = await serializeRefresh(provider, () =>
|
||
_getAccessTokenInternal(provider, credentials, log, proxyConfig)
|
||
);
|
||
|
||
// Record the rotation so subsequent stale callers can be redirected to the
|
||
// new tokens without re-hitting upstream (which would trigger Auth0 family
|
||
// revocation). Only records when the refresh actually rotated the token.
|
||
if (
|
||
result &&
|
||
typeof result === "object" &&
|
||
!("error" in result) &&
|
||
(result as { accessToken?: string }).accessToken &&
|
||
(result as { refreshToken?: string }).refreshToken
|
||
) {
|
||
recordRotation(
|
||
provider,
|
||
oldRefreshToken,
|
||
result as {
|
||
accessToken: string;
|
||
refreshToken: string;
|
||
expiresIn?: number;
|
||
expiresAt?: string;
|
||
}
|
||
);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Refresh token by provider type (alias for getAccessToken)
|
||
* @deprecated Since v0.2.70 — use getAccessToken() directly.
|
||
* Still exported because open-sse/index.js and src/sse wrapper use it.
|
||
* Will be removed in a future major version.
|
||
*/
|
||
export const refreshTokenByProvider = getAccessToken;
|
||
|
||
/**
|
||
* Format credentials for provider
|
||
*/
|
||
export function formatProviderCredentials(provider, credentials, log) {
|
||
const config = PROVIDERS[provider];
|
||
if (!config) {
|
||
log?.warn?.("TOKEN_REFRESH", `No configuration found for provider: ${provider}`);
|
||
return null;
|
||
}
|
||
|
||
switch (provider) {
|
||
case "gemini":
|
||
return {
|
||
apiKey: credentials.apiKey,
|
||
accessToken: credentials.accessToken,
|
||
projectId: credentials.projectId,
|
||
};
|
||
|
||
case "claude":
|
||
return {
|
||
apiKey: credentials.apiKey,
|
||
accessToken: credentials.accessToken,
|
||
};
|
||
|
||
case "codex":
|
||
case "qwen":
|
||
case "qoder":
|
||
case "openai":
|
||
case "openrouter":
|
||
return {
|
||
apiKey: credentials.apiKey,
|
||
accessToken: credentials.accessToken,
|
||
};
|
||
|
||
case "antigravity":
|
||
case "agy":
|
||
return {
|
||
accessToken: credentials.accessToken,
|
||
refreshToken: credentials.refreshToken,
|
||
};
|
||
|
||
default:
|
||
return {
|
||
apiKey: credentials.apiKey,
|
||
accessToken: credentials.accessToken,
|
||
refreshToken: credentials.refreshToken,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get all access tokens for a user
|
||
*/
|
||
export async function getAllAccessTokens(userInfo, log) {
|
||
const results = {};
|
||
|
||
if (userInfo.connections && Array.isArray(userInfo.connections)) {
|
||
for (const connection of userInfo.connections) {
|
||
if (connection.isActive && connection.provider) {
|
||
const token = await getAccessToken(
|
||
connection.provider,
|
||
{
|
||
refreshToken: connection.refreshToken,
|
||
},
|
||
log
|
||
);
|
||
|
||
if (token) {
|
||
results[connection.provider] = token;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* Refresh token with retry and exponential backoff
|
||
* Retries on failure with increasing delay: 1s, 2s, 3s...
|
||
*
|
||
* Includes:
|
||
* - Per-provider circuit breaker (5 consecutive failures → 30min pause)
|
||
* - 30s timeout per refresh attempt to prevent hanging connections
|
||
*
|
||
* @param {function} refreshFn - Async function that returns token or null
|
||
* @param {number} maxRetries - Max retry attempts (default 3)
|
||
* @param {object} log - Logger instance (optional)
|
||
* @param {string} provider - Provider ID for circuit breaker tracking (optional)
|
||
* @returns {Promise<object|null>} Token result or null if all retries fail
|
||
*/
|
||
|
||
// ─── Circuit Breaker State ──────────────────────────────────────────────────
|
||
const _circuitBreaker: Record<string, { failures: number; blockedUntil: number }> = {};
|
||
const CIRCUIT_BREAKER_THRESHOLD = 5; // consecutive failures before tripping
|
||
const CIRCUIT_BREAKER_COOLDOWN = 30 * 60 * 1000; // 30 minutes
|
||
const REFRESH_TIMEOUT_MS = 30_000; // 30s max per refresh attempt
|
||
|
||
interface CircuitBreakerStatusEntry {
|
||
failures: number;
|
||
blocked: boolean;
|
||
blockedUntil: string | null;
|
||
remainingMs: number;
|
||
}
|
||
|
||
interface RefreshLoggerLike {
|
||
error?: (scope: string, message: string) => void;
|
||
warn?: (scope: string, message: string) => void;
|
||
}
|
||
|
||
/**
|
||
* Check if a provider is circuit-breaker blocked.
|
||
*/
|
||
export function isProviderBlocked(provider: string): boolean {
|
||
const state = _circuitBreaker[provider];
|
||
if (!state) return false;
|
||
if (!state.blockedUntil) return false;
|
||
if (state.blockedUntil > Date.now()) return true;
|
||
// Cooldown expired — reset
|
||
delete _circuitBreaker[provider];
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Get active per-connection mutex entries (for diagnostics/metrics).
|
||
* Returns a snapshot of connections that have an in-flight refresh and their waiter count.
|
||
*/
|
||
export function getConnectionRefreshMutexStatus(): Record<string, { waiters: number }> {
|
||
const result: Record<string, { waiters: number }> = {};
|
||
for (const [connectionId, entry] of connectionRefreshMutex.entries()) {
|
||
result[connectionId] = { waiters: entry.waiters };
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Get circuit breaker status for all providers (for diagnostics).
|
||
*/
|
||
export function getCircuitBreakerStatus(): Record<string, CircuitBreakerStatusEntry> {
|
||
const result: Record<string, CircuitBreakerStatusEntry> = {};
|
||
for (const [provider, state] of Object.entries(_circuitBreaker)) {
|
||
result[provider] = {
|
||
failures: state.failures,
|
||
blocked: state.blockedUntil > Date.now(),
|
||
blockedUntil:
|
||
state.blockedUntil > Date.now() ? new Date(state.blockedUntil).toISOString() : null,
|
||
remainingMs: Math.max(0, state.blockedUntil - Date.now()),
|
||
};
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Record a successful refresh — resets circuit breaker for provider.
|
||
*/
|
||
function recordSuccess(provider: string) {
|
||
if (_circuitBreaker[provider]) {
|
||
delete _circuitBreaker[provider];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Record a failed refresh — increments circuit breaker counter.
|
||
*/
|
||
function recordFailure(provider: string, log: RefreshLoggerLike | null = null) {
|
||
if (!_circuitBreaker[provider]) {
|
||
_circuitBreaker[provider] = { failures: 0, blockedUntil: 0 };
|
||
}
|
||
_circuitBreaker[provider].failures++;
|
||
|
||
if (_circuitBreaker[provider].failures >= CIRCUIT_BREAKER_THRESHOLD) {
|
||
_circuitBreaker[provider].blockedUntil = Date.now() + CIRCUIT_BREAKER_COOLDOWN;
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
`🔴 Circuit breaker tripped for ${provider}: ${CIRCUIT_BREAKER_THRESHOLD} consecutive failures. ` +
|
||
`Blocked for ${CIRCUIT_BREAKER_COOLDOWN / 60000}min. Provider needs re-authentication.`
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Execute a function with a timeout.
|
||
*/
|
||
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number): Promise<T | null> {
|
||
return await new Promise<T | null>((resolve, reject) => {
|
||
const timer = setTimeout(() => resolve(null), timeoutMs);
|
||
if (typeof timer === "object" && "unref" in timer) {
|
||
(timer as { unref?: () => void }).unref?.();
|
||
}
|
||
|
||
fn().then(
|
||
(result) => {
|
||
clearTimeout(timer);
|
||
resolve(result);
|
||
},
|
||
(error) => {
|
||
clearTimeout(timer);
|
||
reject(error);
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
export async function refreshWithRetry(
|
||
refreshFn,
|
||
maxRetries = 3,
|
||
log: RefreshLogger = null,
|
||
provider = "unknown"
|
||
) {
|
||
// Circuit breaker check
|
||
if (isProviderBlocked(provider)) {
|
||
log?.warn?.("TOKEN_REFRESH", `⚡ Circuit breaker active for ${provider}, skipping refresh`);
|
||
return null;
|
||
}
|
||
|
||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||
if (attempt > 0) {
|
||
const delay = attempt * 1000;
|
||
log?.debug?.("TOKEN_REFRESH", `Retry ${attempt}/${maxRetries} after ${delay}ms`);
|
||
await new Promise((r) => setTimeout(r, delay));
|
||
}
|
||
|
||
try {
|
||
const result = await withTimeout(refreshFn, REFRESH_TIMEOUT_MS);
|
||
if (isUnrecoverableRefreshError(result)) {
|
||
log?.warn?.(
|
||
"TOKEN_REFRESH",
|
||
`Unrecoverable refresh error for ${provider}: ${result.error} — skipping retries`
|
||
);
|
||
return result;
|
||
}
|
||
if (result) {
|
||
recordSuccess(provider);
|
||
return result;
|
||
}
|
||
} catch (error) {
|
||
log?.warn?.("TOKEN_REFRESH", `Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// All retries exhausted — record failure for circuit breaker
|
||
recordFailure(provider, log);
|
||
log?.error?.("TOKEN_REFRESH", `All ${maxRetries} retry attempts failed for ${provider}`);
|
||
return null;
|
||
}
|