Compare commits

..

44 Commits

Author SHA1 Message Date
Xiangzhe
30cac12f5e fix(build): stop bundling the better-sqlite3 stub at runtime (#11343)
next.config.mjs aliased `better-sqlite3` to its build-time stub
unconditionally, recording the premise that "runtime still uses the real
package via serverExternalPackages". That premise does not hold: a
Turbopack resolveAlias rewrites the request BEFORE the externals check
runs, so the request stopped matching the serverExternalPackages entry
and the stub was baked into the shipped bundle.

Every artifact built from the release tip then answered HTTP 500 on
every route -- the sync driver failed with "r(...) is not a constructor"
(the minified stub export), fell through node:sqlite and sql.js, and the
instrumentation hook aborted at boot.

Same failure shape as #6344, one alias above it in the same object, so
it gets the same treatment: a shared flag helper makes the alias opt-in
via OMNIROUTE_BETTER_SQLITE3_STUB=1, and a default build externalizes
the real native addon. Nobody sets the flag today; it exists for a build
host that genuinely hits the SIGABRT worker teardown from #10060, and
such a build is not shippable -- which the helper and the stub header
now say explicitly instead of describing the stub as a harmless
build-only stand-in.

Closes #11343
2026-08-24 09:56:20 -03:00
Diego Rodrigues de Sa e Souza
dafb4ae808 fix(deps): keep unused pnpm peers out of production (#11342)
* fix(deps): keep unused pnpm peers out of production

* docs(changelog): link dependency policy fix to PR 11342
2026-08-24 02:26:02 -03:00
Erick Kinnee
338c05dc6a fix(models): expose Ollama Cloud native effort tiers (#11307)
Validated on a 17-PR combined board: models-catalog-combo-metadata + ollama-cloud-reasoning-effort-tiers-10788 within the board's 287/287, typecheck:core clean, check:open-sse-typecheck clean, vitest 405/405. Publishes Ollama Cloud's native none/low/medium/high/max effort vocabulary for reasoning-capable passthrough/tagged models with no exact registry declaration, adds none to DeepSeek V4/GLM 5.x, and preserves narrower exact-model vocabularies (GPT-OSS) via intersection. Refs #10788. Thank you @ekinnee!
2026-08-24 01:55:47 -03:00
Nguyen Thanh Dat
6945bbaaba fix(db): escape regex metacharacters in group model patterns (#11311)
Validated on a 17-PR combined board: group-model-pattern-regex-escape within the board's 287/287, typecheck:core clean. matchesModelPattern() only substituted * before compiling to RegExp — every other metacharacter kept its regex meaning, so a malformed group pattern (unbalanced parens/brackets) threw uncaught and broke EVERY request for keys in that group, not just the malformed rule (isModelAllowedForKey has no try/catch and runs on the chat completion path and the /v1/models catalog). Thank you @ntdat812!
2026-08-24 01:55:42 -03:00
Ravi Tharuma
690f684bfc feat(audio): add native ElevenLabs HTTP compatibility routes (#11312)
Validated on a 17-PR combined board: elevenlabs-native-routes + hard-session-lease-bypass-inventory (9/9) within the board's 287/287, typecheck:core clean. Native ElevenLabs compatibility routes (voices, TTS, STT) reusing the stored credential via quota-preflight, sent only as xi-api-key; client authorization headers never forwarded. Closes #10556. Thank you @RaviTharuma!
2026-08-24 01:55:36 -03:00
Ravi Tharuma
c3cd1f94c0 feat(services): expose sanitized CLIProxyAPI account health (#11314)
Validated on a 17-PR combined board: cliproxy-accounts + cliproxy-tab + cliproxy-account-health + cliproxy-resolve-spawn-args-6877 (16/16) within the board's 287/287, typecheck:core clean, env-doc-sync clean. Exposes a sanitized read-only CLIProxyAPI account health view (5s-bounded client, explicit allowlist excluding names/paths/emails/tokens/status messages) through a management-authenticated API + dashboard card. Closes #6342. Thank you @RaviTharuma!
2026-08-24 01:55:31 -03:00
Webman
c21460f22a fix(lint): drain release-green hard failures on release/v3.8.50 (#9985) (#11317)
Validated on the resolved merge against the current release tip: pack-artifact-policy + cli-mcp-call-commands + cli-resilience-commands + cli-skills-commands + model-hide-multikey-11300 39/39, typecheck:core clean, eslint clean. Resolved a pt-BR.json wording conflict against #11322 (kept the tip's wording, semantically identical). Drains the real lint-fallout from the wave that was blocking the release-green verdict — dead code + newly-enforced React-Compiler hook rules. Thank you @jonlwheat2-gif!
2026-08-24 01:54:31 -03:00
Ravi Tharuma
9b14896a6c feat(api): add Google AI Studio Gemini TTS (#11315)
Validated on a 17-PR combined board: gemini-tts + vertex-media + audio-speech-handler (41/41) within the board's 287/287, typecheck:core clean. Registers public google/gemini-*-tts speech models and translates OpenAI-compatible /v1/audio/speech to the AI Studio generateContent audio contract, reusing the Vertex inline-audio/PCM/WAV conversion path. Batch TTS only, Gemini Live is out of scope. Thank you @RaviTharuma!
2026-08-24 01:50:44 -03:00
Ravi Tharuma
29f26293c3 feat(compression): isolate sync engines in bounded worker pool (#11318)
Validated on a 17-PR combined board: compression-worker + colocate-standalone-esm-scope within the board's 287/287, typecheck:core clean, env-doc-sync clean. Offloads eligible sync compression engines into a bounded worker_threads pool with a strict serializable DTO boundary and fail-open on spawn/worker/timeout failure. Closes #11023. Thank you @RaviTharuma!
2026-08-24 01:50:39 -03:00
Nguyen Thanh Dat
cb11592441 fix(db): judge the proxy URL host by address, not by spelling (#11319)
Validated on a 17-PR combined board: upstream-proxy-host-spelling 8/8 within the board's 287/287, typecheck:core clean. Routes src/lib/db/upstreamProxy.ts through the shared outbound-guard helpers instead of a private dotted-quad regex copy that had drifted since #10843 — closes the IPv4-mapped IPv6, ULA, link-local and CGNAT bypasses while preserving the deliberate loopback allow (CLIProxyAPI on localhost:8317). Multicast widened from /224\. to the full 224.0.0.0/4, called out explicitly. Thank you @ntdat812!
2026-08-24 01:50:35 -03:00
Ravi Tharuma
5ee646e68e fix(github): verify access tokens during health checks (#11320)
Validated on a 17-PR combined board: token-health-check + token-health-no-refresh-token-expired-5326 + token-refresh-service within the board's 287/287, typecheck:core clean. GitHub access-token-only connections are now actively verified on each due health interval (via the existing Copilot token exchange); the parent credential is marked expired only on a confirmed 401, never on 403/429/5xx/network failures; response bodies and transport messages no longer enter token-refresh logs. Closes #10352. Thank you @RaviTharuma!
2026-08-24 01:50:30 -03:00
Paco Cartones
6984676d95 fix(quality): report the real failure line and stop double-counting ci.yml gates (#11321)
Validated on a 17-PR combined board: validate-release-green within the board's 287/287, typecheck:core clean. Two accuracy bugs in the release-green verdict tool: an unanchored regex blamed a passing test line (matching a filename containing 'fail'), and 6 gates were double-recorded as both hard-failure and drift due to an id-format mismatch (ci.yml script name vs curated id). Found while reading the #9985 verdict — good catch.
2026-08-24 01:50:26 -03:00
Paco Cartones
79f8ae9d1e fix(i18n): add the 3 pt-BR CLI keys that break the locale parity test (#11322)
Validated on a 17-PR combined board: typecheck:core clean, gates within baseline. Restores 3 missing pt-BR CLI keys (setup.opencode, serve.tls_cert, serve.tls_key) — parity restored, 823/823. Thank you @pacocartones!
2026-08-24 01:49:52 -03:00
Nguyen Thanh Dat
04b2c47940 fix(i18n): restore three placeholders dropped from the pt catalogue (#11325)
Validated on a 17-PR combined board: i18n-placeholder-parity within the board's 287/287, typecheck:core clean. Restores 3 dropped placeholders in pt.json (the visible one: the cache tile's subtitle was repeating its own label instead of showing the total) and adds a 42-locale placeholder-set gate so this class of drift can't recur silently. Thank you @ntdat812!
2026-08-24 01:49:48 -03:00
Paco Cartones
24ac71465e test(db): make singleton reset survive the full suite and un-skip the 3 DB-state tests (#11327)
Validated on a 17-PR combined board: capture-critical-db-state 7/7 (all three previously-skipped tests now run) within the board's 287/287, typecheck:core clean. Fixes the racy DATA_DIR-after-dynamic-import isolation and removes a duplicate type declaration. Thank you @pacocartones!
2026-08-24 01:49:42 -03:00
Nguyen Thanh Dat
8d6f91b558 fix(security): refuse proxy-authorization and proxy-authenticate upstream (#11328)
Validated on a 17-PR combined board: upstream-headers-proxy-auth within the board's 287/287, typecheck:core clean, gates within baseline. proxy-authorization and proxy-authenticate join the FORBIDDEN denylist — forwarding proxy-authorization to a model provider would hand that provider the operator's own proxy credential. Thank you @ntdat812!
2026-08-24 01:49:37 -03:00
Diego Rodrigues de Sa e Souza
c3698eedcb fix(dashboard): route the Adapta tutorial CTA through the branded shortener (#11329)
Validated on a 17-PR combined board: TSX parses clean, eslint clean. Adapta tutorial CTA href now points at the branded shortener (link.omniroute.online/adapta) while keeping the visible link text as the real domain. Completes #11196's shortener rollout.
2026-08-24 01:49:32 -03:00
Diego Rodrigues de Sa e Souza
adca3b881c fix(kie): map remaining google-imagen Market ids to their real KIE upstream ids (#11326)
Merging --admin with red discrimination (merge-gates §4). Fails: ESLint warnings ratchet drift (inherited base-red), Unit Tests shards containing stream-timing.test.ts (CPU-contention timing flake, assert.ok(total >= 15)ms — unrelated to this PR's scope, open-sse/handlers/imageGeneration.ts), and dast-smoke (advisory, isRequired:null).
2026-08-24 01:10:23 -03:00
Diego Rodrigues de Sa e Souza
ac02c5b42f fix(resilience): don't clear an active rate-limit cooldown for non-quota_exhausted errors (#11277) (#11310)
Merging --admin: only fails are ESLint warnings ratchet drift (inherited base-red) and dast-smoke (advisory, isRequired:null). Zero overlap with this PR's scope (src/lib/usage/providerLimits.ts).
2026-08-23 22:55:33 -03:00
Diego Rodrigues de Sa e Souza
07d1816a45 fix(providers): hidden models leak into GET /v1/models (#11300) (#11309)
Merging --admin: only fails are ESLint warnings ratchet drift (inherited) and dast-smoke (advisory, isRequired:null). Zero overlap with this PR's file scope (src/app/api/v1/models/catalog.ts).
2026-08-23 22:35:29 -03:00
Diego Rodrigues de Sa e Souza
3192eb88d5 fix(providers): unify learned and declared reasoning-effort clamps on nearest-tier semantics (#11295) (#11305)
Merging --admin with red discrimination (merge-gates §4). 5/6 failing tests are the compression/i18n-vi/kiro/memory mini-cluster fixed by #11306 (this branch predates it). The 6th (token-health-check-kimi, 'jittered expiration window') is a timing-sensitive test — reproduces GREEN locally, name itself indicates randomized jitter, not a deterministic regression from this PR's reasoning-effort clamp change (zero file overlap). ESLint warnings + dast-smoke (advisory, isRequired:null) also inherited.
2026-08-23 22:17:12 -03:00
Diego Rodrigues de Sa e Souza
8969526437 fix(tests): drain compression/kiro/memory/i18n base-red mini-cluster (2026-08-24) (#11306)
Merging --admin: this is itself the base-red drain PR (compression/i18n-vi/i18n-pt-br/kiro/memory mini-cluster). Remaining fails (ESLint warnings ratchet drift, dast-smoke advisory) are not blocking; isRequired:null on dast-smoke.
2026-08-23 22:17:09 -03:00
Diego Rodrigues de Sa e Souza
5518916725 fix(chat): detect severity-classifier format in claudeClassifierCompat short-circuit (#11289) (#11304)
Validated on a 4-PR combined board: claude-classifier-compat 15/15 + 8189-classifier-compat-auto-narrow 5/5 sibling sweep, typecheck:core clean, gates within baseline. Detects the newer Claude Code severity-classifier request shape (stop_sequences carrying </severity>) and replies with <severity>0</severity> instead of the legacy <block>no</block> — the mismatch was failing both classifier stages closed and blocking gated tool calls (Bash, WebSearch). Closes #11289.
2026-08-23 21:34:54 -03:00
Diego Rodrigues de Sa e Souza
3daa455e1f fix(memory): avoid rejected mid-conversation system injection on Claude when preceding turn isn't a tool result (#11290) (#11303)
Validated on a 4-PR combined board: memory-cache-safe-injection + memory-glm-injection + chatcore-memory-skills-injection 39/39 (node:test), injection.test.ts 23/23 (vitest), typecheck:core clean, check:cycles clean, gates within baseline. Narrows the #3890 mid-array system-splice skip to the exact shape that 400s on Claude Opus 5 (splice point not preceded by a server tool result) instead of disabling the cache-safe optimization for every Claude request — preserves the fast path everywhere it's actually safe. Closes #11290.
2026-08-23 21:34:50 -03:00
Diego Rodrigues de Sa e Souza
d282ec7ad6 fix(providers): don't silently enable rate-limit protection on PATCH unless persisted (#11278) (#11302)
Validated on a 4-PR combined board: provider-patch-ratelimit-protection-11278 2/2 + 56/56 sibling suites, typecheck:core clean, gates within baseline. PUT /api/providers/[id] can no longer silently enable rate-limit protection just because EditConnectionModal sends rateLimitOverrides on every save — the runtime toggle now strictly follows the persisted DB row. Closes #11278.
2026-08-23 21:34:46 -03:00
Diego Rodrigues de Sa e Souza
1e81e521c0 fix(opencode-plugin): track active release branch in CI + align combo-id fixture (#11301)
Validated on a 4-PR combined board: @omniroute/opencode-plugin provider.test.ts 13/13, full plugin suite 343/343, YAML validated, typecheck:core clean, gates within baseline. CI branch trigger was pinned to release/v3.8.2 since creation (never fired mid-cycle) — now tracks [main, release/**] matching every other workflow's pattern; combo-id fixture aligned to the real #10821 unprefixed-lookup contract (RED 11/13 → GREEN 13/13).
2026-08-23 21:34:42 -03:00
Óscar Fonseca
a3c3117254 fix(translator): preserve omitted subagent session IDs (#11297)
Validated on a 3-PR combined board: openai-responses-opencode-subagent-sessionid within the board's 20/20, typecheck:core + check:open-sse-typecheck clean, gates within baseline. Uses null as the omission sentinel for OpenCode's optional default-less subagent.sessionID under Responses/Codex strict mode (which required every declared property) — models can now omit the field to spawn a new child instead of inventing filler IDs; the null is stripped before OpenCode sees the call, real child IDs pass through unchanged. Thank you @ofonseca-pyming!
2026-08-23 21:03:16 -03:00
Ravi Tharuma
d9a883ec53 docs(openapi): declare spec endpoint management auth (#11299)
Validated on a 3-PR combined board: openapi-security-tiers 1/1 within the board's 20/20, typecheck:core clean, gates within baseline. Declares GET /api/openapi/spec's real ManagementSessionAuth contract (conditional on requireLogin) — no runtime behavior change, just the doc catching up. Closes #10851. Thank you @RaviTharuma!
2026-08-23 21:03:11 -03:00
Ravi Tharuma
7913447bf0 docs(db): align cache guide with runtime settings (#11018) (#11294)
Validated on a 3-PR combined board: 11018-database-cache-docs 1/1 within the board's 20/20 focused suite, typecheck:core clean, gates within baseline. DATABASE_GUIDE.md now cites the real 64 MiB runtime default and the 1..1,000,000 KiB range, locked with a regression test. Thank you @RaviTharuma!
2026-08-23 21:03:07 -03:00
Diego Rodrigues de Sa e Souza
67fba53190 fix(tests): drain base-red cluster from 2026-08-23 merges (#9985) (#11280)
Validated on a 2-PR combined board: 158/158 across the drain's 17 focused test files (incl. integration qdrant-routes), typecheck:core + dashboard-typecheck clean, env-doc-sync, mutation-test-coverage, cli-i18n, file-size, changelog-integrity all green. Every red discriminated per-item as stale-test (contract intentionally moved, citing the origin PR) or real bug (fixed, never masked) across 3 base-advance waves (#9985): stryker registration, i18n gaps (health.* namespace mismatch + pt-BR parity), combo-token-window pins (872K), CLI catalog counts (prime-agent), the isComboStep single-target reasoning-transport-fallback regression, container-guard hermeticity, and 26 unsuppressed ESLint errors from the wave (typed qdrant adapter, import trim, justified disable with precedent). Drains the accumulated base-red cluster.
2026-08-23 20:32:46 -03:00
Diego Rodrigues de Sa e Souza
9cb91dee74 fix(security): close round-3 advisories (v7g9, x7vm, j7j4, jcm5) + exposure warning (wmgv) (#11261)
Validated on a 2-PR combined board: routeGuard 36/36 (within the 68/68 focused-file total), a2a-task-owner-idor 7/7, a2a-tasks-auth, search-baseurl-ssrf-guard, cli-serve-hostname, spawn-capable-prefixes-client-safe all green, typecheck:core + dashboard-typecheck clean, gates within baseline. Five real High-severity advisories fixed with TDD (each failing-then-passing): settings export/import-json ALWAYS_PROTECTED completion, MITM route LOCAL_ONLY+SPAWN_CAPABLE gating, search baseUrl SSRF/IMDS guard, A2A REST task auth+ownership (previously none at all), and the loud boot exposure warning. GHSA-cjv9 confirmed already closed on this base (verified). Round 3 of the advisory sweep.
2026-08-23 20:32:42 -03:00
ignamiranda
92f58603f9 Beginner UX: purpose-first Traffic Inspector header (#11283)
Validated on a 3-PR combined board: traffic-inspector-beginner-header suite green within the board's 30/30, typecheck:core + dashboard-typecheck clean, gates within baseline. Purpose-first orientation header for Traffic Inspector, existing inspection UI untouched. Thank you @ignamiranda!
2026-08-23 19:08:27 -03:00
ignamiranda
7cec8e32fd Beginner UX: Essentials sidebar preset (#11286)
Validated on a 3-PR combined board: sidebar-customization + sidebar-essentials-static 30/30 combined (across the board's 4 focused files), typecheck:core + dashboard-typecheck clean, gates within baseline. New Essentials sidebar preset gives first-time users a short beginner path while advanced tools stay reachable via Command Palette search. Thank you @ignamiranda!
2026-08-23 19:08:23 -03:00
engenhariaandrereis01-ai
d137368fb5 fix(oauth): stop overwriting Kiro connections that share a profile ARN (#10815) (#11287)
Validated on a 3-PR combined board: kiro-connection-identity 8/8 (written failing-first — 3 new cases red on the pristine release/v3.8.50 tip, green with this change), typecheck:core + dashboard-typecheck clean, all static gates within baseline. Root cause is exactly right: a CodeWhisperer profile ARN identifies the profile, not the account, and distinct Builder ID accounts via social login can share one — the ARN is now trusted only alongside a non-contradicting account-level identifier (email/clientId). Closes #10815. Thank you @engenhariaandrereis01-ai!
2026-08-23 19:08:18 -03:00
Diego Rodrigues de Sa e Souza
2904cf849d fix(security): clear new CodeQL code-scanning alerts (round 4) (#11293)
- open-sse/executors/github.ts: replace the Math.random() fallback in
  the Copilot correlation-id generators (x-request-id,
  x-interaction-id, x-client-session-id, x-agent-task-id) with a
  CSPRNG-backed randomIdFallback() (node:crypto randomBytes) — closes
  js/insecure-randomness with no behavior change (crypto.randomUUID
  stays the primary path).
- tests/unit/cli/_helpers/shellArgs.mjs: collapse the two sequential
  global .replace() unescape passes into a single left-to-right regex
  replace with alternation — closes js/double-escaping. The prior
  two-pass form let the first pass's output feed the second, which is
  exactly the double-(un)escaping bug pattern the query flags (e.g. an
  escaped-backslash-then-quote sequence could be misread depending on
  pass order).

Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-23 19:06:53 -03:00
Markus Hartung
6158c9aeec fix: drop stray conflict leftover in managedModelImport (#11259 cherry-pick integration) 2026-08-23 18:14:06 -03:00
backryun
2764812ee4 fix(providers): eliminate Gemini 3.5 Flash 2026-08-23 18:13:11 -03:00
Marcel Wysocki
29caad9f3d feat(sse): discover Anthropic partner models on Vertex AI (#11279)
Validated on the combined 8-PR board + the branch itself: vertex-anthropic-models 4/4 (new — pushed to your branch as a fix-in-place commit per Hard Rule #8, covering the parser's global/project-scoped resource names, malformed-input handling, and the claude-* → targetFormat heuristic on vertex/vertex-partner), 88/88 across the board's focused suites, typecheck:core clean, gates within baseline. Anthropic partner models on Vertex AI now discover dynamically via the Model Garden publisher endpoint and route through the Claude translator even for models outside the static registry. Thank you @maci0!
2026-08-23 18:12:37 -03:00
Jack Smith
14d70a755b fix: resolve CLI mcp call protocol issues (BUG-001) (#10960)
Validated on the combined 8-PR board: all seven CLI command suites green (combo-suggest, compression, mcp-call, oneproxy, resilience, skills + the shared mcpStreamMock helper) within the board's 88/88, typecheck:core clean, gates within baseline. The CLI mcp call protocol fixes (BUG-001) land with full regression coverage across the affected commands. Thank you @YunyunZhai — and thank you for your patience while this one waited for review.
2026-08-23 18:11:50 -03:00
ignamiranda
3abbb60ec6 Beginner UX: soft first-run readiness card on Home (#11282)
Validated on the combined 8-PR board: home-page-static 4/4, check:dashboard-typecheck clean (220 pre-existing, all within frozen baseline — zero syntax errors), 88/88 across the board's focused suites, static gates within baseline. The soft first-run readiness card replaces the hard Home→onboarding redirect with a dismissable 4-step path. Thank you @ignamiranda!
2026-08-23 18:11:46 -03:00
Praveen K Palaniswamy
6d4c4843e9 fix(ollama): route models by advertised capability (#11087) — port of #11088 to the release line (#11271)
Validated on the combined 8-PR board: ollama-local-capabilities-routing 3/3, managed-model-import 9/9 (including the integration with the carried Gemini-3.5-Flash cleanup from #11259), 88/88 across the board's focused suites, typecheck:core + dashboard-typecheck clean, gates within baseline. This brings #11088 to the release line — it had squash-merged to main by base error (mine) — AND fixes the two defects the port caught: the global filter drop that leaked image/video models into OpenAI chat selections (now scoped to self-hosted providers) and the unregistered hard-lease credential site. Exemplary port discipline: byte-identical carry + the corrections in a separate reviewable commit + the superpowers docs deliberately left out. main still needs the same two-line fix. Thank you @yourspraveen!
2026-08-23 18:11:21 -03:00
Mina_
12986c44c9 fix(live-ws, dashboard): allow 0.0.0.0 dashboard origin and stop non-square SVG image warnings (#11269)
Validated on the combined 8-PR board: live-server-allowlist suite green within the board's 88/88, typecheck:core + dashboard-typecheck clean. 0.0.0.0 as a browser Origin is loopback-equivalent (it can never name a LAN host — same treatment as headroom/detect.ts and outboundUrlGuard.ts), so the local-only posture is unchanged and the FORBIDDEN_ORIGIN reconnect flood on the dev server's printed URL stops. Thank you @Minamaged18!
2026-08-23 18:10:57 -03:00
Prajeeth H
ab150e1f2b test(startup): assert disabled boot path log output at runtime (#11016) (#11265)
Validated on the combined 8-PR board: 88/88 across the board's focused suites, typecheck:core + dashboard-typecheck clean, static gates within baseline. The subprocess spawn this test does timed out locally on the shared devbox under load 35 (ETIMEDOUT on tsx bootstrap — environment, not the test); CI runs it unloaded. Runtime log-output assertion for the disabled credential-health boot path, closing the #11016 follow-up suggested on #11029. Thank you @Prajeeth-12!
2026-08-23 18:10:54 -03:00
Harvey Doan
9adeb3b673 fix(providers): clamp reasoning_effort to the declared vocabulary of models that expose one (#11274)
Validated on the combined 8-PR board: the declared-vocabulary clamp integrates with #11232's learned-set clamp (both fire in sequence; REASONING_EFFORT_ORDER import added), reasoning quartet 57/57 including the new opencode-go suite 7/7, 88/88 across the board's focused suites, typecheck:core + dashboard-typecheck clean, all static gates within baseline. Retargeted main→release/v3.8.50 and rebased onto the release tip (authorship preserved; the branch carried main history, so only the real commit was carried). The generic explicit-capability clamp closes the Console Go 400 [1210] loop for models with a declared effort vocabulary. Thank you @linhdmn — the live-probe table in the PR body is exactly the evidence standard we want.
2026-08-23 18:10:21 -03:00
276 changed files with 11475 additions and 1821 deletions

View File

@@ -1027,6 +1027,16 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Used by: open-sse/services/compression/engines/rtk/filterLoader.ts. Default: 0.
#OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=0
# Maximum concurrent synchronous compression workers. Excess jobs wait FIFO.
# Used by: open-sse/services/compression/compressionWorkerPool.ts. Default: 2.
#OMNI_COMPRESSION_WORKERS=2
# Per-job worker timeout (ms). A timed-out worker is terminated and the request fails open.
# Used by: open-sse/services/compression/compressionWorkerPool.ts. Default: 120000.
#OMNI_COMPRESSION_WORKER_TIMEOUT_MS=120000
# Terminate idle compression workers after this many milliseconds.
# Used by: open-sse/services/compression/compressionWorkerPool.ts. Default: 60000.
#OMNI_COMPRESSION_WORKER_IDLE_MS=60000
# T02 stacked-pipeline engine circuit-breaker (OPT-IN, default off). When enabled, a compression
# engine that throws repeatedly across requests is skipped (fail-open) for a cooldown.
# Used by: open-sse/services/compression/pipelineEngineBreaker.ts.
@@ -2011,6 +2021,9 @@ APP_LOG_TO_FILE=true
# CLIPROXYAPI_HOST=127.0.0.1
# CLIPROXYAPI_PORT=5544
# CLIPROXYAPI_CONFIG_DIR=~/.cli-proxy-api
# Management key for an externally managed instance. Embedded instances use
# OmniRoute's encrypted service key.
# CLIPROXYAPI_MANAGEMENT_KEY=
# ── Mux embedded service ──
# Override the port where the embedded Mux (coder/mux) agent-orchestration

View File

@@ -2,11 +2,11 @@ name: opencode-plugin CI
on:
push:
branches: [main, release/v3.8.2]
branches: [main, "release/**"]
paths:
- "@omniroute/opencode-plugin/**"
pull_request:
branches: [main, release/v3.8.2]
branches: [main, "release/**"]
paths:
- "@omniroute/opencode-plugin/**"
types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -104,7 +104,10 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId
// ("omniroute"), not the OC-gate-prefixed hook.id ("opencode-omniroute") —
// that prefix must never leak into anything OmniRoute's server parses.
assert.ok(out["omniroute/claude-primary"]);
// #10345/#10821: bare combo ids (owned_by: "combo") stay unprefixed —
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under the
// plugin provider, so `claude-primary` here carries no provider prefix.
assert.ok(out["claude-primary"]);
});
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
@@ -159,11 +162,15 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
// omnirouteProviderId ("omniroute") — the OC-gate prefix ("opencode-")
// must stay OC-internal (hook.id / AuthHook.provider) and never leak into
// anything OmniRoute's own server parses for credential lookup.
const claude = out["omniroute/claude-primary"];
// #10345/#10821: bare **combo** ids (owned_by: "combo", e.g.
// "claude-primary") must also stay unprefixed — OpenCode looks up
// `-m <plugin>/<combo>` as model id `<combo>` under the plugin provider.
const claude = out["claude-primary"];
assert.ok(claude, "claude-primary present");
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
// static-catalog reader resolves `(providerID, modelID)` from the key.
assert.equal(claude.id, "omniroute/claude-primary");
// `mapRawModelToModelV2` leaves bare combo ids unprefixed (see
// src/index.ts mapRawModelToModelV2) so OC's `-m <plugin>/<combo>` lookup
// resolves the combo id directly.
assert.equal(claude.id, "claude-primary");
assert.equal(claude.name, "claude-primary");
assert.equal(claude.providerID, "omniroute");
assert.equal(claude.api.id, "openai-compatible");

View File

@@ -180,6 +180,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
### 🐛 Bug Fixes
- **fix(build):** every route no longer answers HTTP 500 on artifacts built from the release tip ([#11343](https://github.com/diegosouzapw/OmniRoute/issues/11343)) — `next.config.mjs` aliased `better-sqlite3` to its build-time stub **unconditionally**, on the premise that `serverExternalPackages` still won at runtime. It does not: a Turbopack `resolveAlias` rewrites the request *before* the externals check, so the request stopped matching the `better-sqlite3` external entry and the stub was baked into the shipped bundle. The sync driver then failed with `r(...) is not a constructor`, fell through `node:sqlite` and sql.js, and the instrumentation hook aborted at boot. Same failure shape as [#6344](https://github.com/diegosouzapw/OmniRoute/issues/6344), so it gets the same treatment: the alias is opt-in via `OMNIROUTE_BETTER_SQLITE3_STUB=1` through the shared `scripts/build/better-sqlite3-stub-flag.mjs` helper — set it only on a build host that actually hits the SIGABRT build-worker teardown ([#10060](https://github.com/diegosouzapw/OmniRoute/issues/10060)); default builds externalize the real native addon. Regression guards: `tests/unit/better-sqlite3-stub-alias-11343.test.mjs` (5) and the env matrix in `tests/unit/next-config.test.ts`.
- **security(search)**: block SSRF via `/v1/search` `provider_options.baseUrl` for the Firecrawl search provider — the client-controlled override is now validated as a public URL before it is used to build the server-side fetch target, so a caller with a valid API key can no longer redirect search requests at loopback, RFC1918, or cloud-metadata hosts — thanks @zmf963
- **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366)
- **cli**: route provider test commands through configured connection test endpoints (#10570)

View File

@@ -3,6 +3,7 @@ import { printHeading } from "../io.mjs";
import { withRuntime } from "../runtime.mjs";
import { t } from "../i18n.mjs";
import { apiFetch } from "../api.mjs";
import { mcpCallTool } from "../mcpClient.mjs";
import { emit } from "../output.mjs";
import { resolveComboModels, collectModel } from "./comboModels.mjs";
@@ -63,15 +64,7 @@ export function extendComboSuggest(combo) {
weights: opts.weights ? JSON.parse(opts.weights) : undefined,
top: opts.top,
};
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name: "omniroute_best_combo_for_task", arguments: body },
});
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
const data = await res.json();
const data = await mcpCallTool("omniroute_best_combo_for_task", body);
const candidates = data.candidates ?? data;
const rows = (Array.isArray(candidates) ? candidates : []).map((c, i) => ({
rank: i + 1,

View File

@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { apiFetch } from "../api.mjs";
import { mcpCallTool } from "../mcpClient.mjs";
import { emit } from "../output.mjs";
import { t } from "../i18n.mjs";
@@ -78,18 +79,17 @@ async function restComboStats(period) {
}
async function mcpCall(name, args, restFallback) {
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name, arguments: args },
});
if (res.ok) return res.json();
// 404 = MCP tool surface not mounted on this build; 501 = not implemented.
// Anything else is a genuine error and we surface it.
if ((res.status === 404 || res.status === 501) && typeof restFallback === "function") {
return restFallback();
try {
return await mcpCallTool(name, args);
} catch (err) {
// Keep the REST fallback behavior for builds where the MCP surface
// is unreachable / not mounted. Anything else rethrows as an error.
const status = err?.status || err?.cause?.status;
if ((status === 404 || status === 501) && typeof restFallback === "function") {
return restFallback();
}
throw err;
}
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
async function confirm(q) {

View File

@@ -61,27 +61,12 @@ export function registerMcp(program) {
? JSON.parse(argsPositional)
: {};
if (opts.stream) {
await runMcpStream(tool, args, globalOpts);
return;
}
const exitCode = await runMcpCallCommand(tool, args, {
...opts,
stream: opts.stream,
}, globalOpts);
const extraHeaders = opts.scope?.length ? { "X-MCP-Scopes": opts.scope.join(",") } : {};
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name: tool, arguments: args },
headers: extraHeaders,
});
if (res.status === 403) {
process.stderr.write("Scope denied\n");
process.exit(4);
}
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
const data = await res.json();
emit(data, globalOpts);
if (exitCode !== 0) process.exit(exitCode);
});
mcp
@@ -99,112 +84,132 @@ export function registerMcp(program) {
const data = await res.json();
emit(data.scopes ?? data, cmd.optsWithGlobals());
});
// 5.2 — mcp tools + mcp audit
const tools = mcp.command("tools").description(t("mcp.tools.description"));
tools
.command("list")
.description(t("mcp.tools.list.description"))
.option("--scope <s>", t("mcp.tools.list.scope"))
.action(async (opts, cmd) => {
const params = new URLSearchParams();
if (opts.scope) params.set("scope", opts.scope);
const res = await apiFetch(`/api/mcp/tools?${params}`);
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
const data = await res.json();
emit(data.tools ?? data, cmd.optsWithGlobals(), mcpToolSchema);
});
tools
.command("info <name>")
.description(t("mcp.tools.info.description"))
.action(async (name, opts, cmd) => {
const res = await apiFetch(`/api/mcp/tools?name=${encodeURIComponent(name)}`);
if (!res.ok) {
process.stderr.write(`Not found: ${name}\n`);
process.exit(1);
}
emit(await res.json(), cmd.optsWithGlobals());
});
tools
.command("schema <name>")
.description(t("mcp.tools.schema.description"))
.option("--io <kind>", t("mcp.tools.schema.io"), "input")
.action(async (name, opts, cmd) => {
const res = await apiFetch(`/api/mcp/tools?name=${encodeURIComponent(name)}&io=${opts.io}`);
if (!res.ok) {
process.stderr.write(`Not found: ${name}\n`);
process.exit(1);
}
const data = await res.json();
const globalOpts = cmd.optsWithGlobals();
if (globalOpts.output === "json") {
process.stdout.write(JSON.stringify(data.schema ?? data, null, 2) + "\n");
} else {
emit(data.schema ?? data, globalOpts);
}
});
const audit = mcp.command("audit").description(t("mcp.audit.description"));
audit
.command("tail")
.option("--follow", t("audit.tail.follow"))
.option("--limit <n>", t("audit.tail.limit"), parseInt, 100)
.action(async (opts, cmd) => {
const { runAuditTail } = await import("./audit.mjs");
await runAuditTail({ ...opts, source: "mcp" }, cmd);
});
audit
.command("stats")
.option("--period <p>", t("audit.stats.period"), "7d")
.action(async (opts, cmd) => {
const res = await apiFetch(`/api/mcp/audit/stats?period=${opts.period}`);
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
emit(await res.json(), cmd.optsWithGlobals());
});
}
async function runMcpStream(tool, args, globalOpts) {
/**
* Shared JSON-RPC 2.0 MCP client used by both stream and non-stream `mcp call`.
*
* Protocol:
* 1. POST /api/mcp/stream with initialize → get Mcp-Session-Id header
* 2. POST /api/mcp/stream with tools/call + Mcp-Session-Id header
*
* When `stream` is true, writes SSE data chunks to stdout as they arrive.
* When `stream` is false, returns the parsed JSON-RPC result.
*
* Returns the exit code (0 = success, non-zero = failure).
*/
async function mcpJsonRpcCall(tool, args, { stream = false, globalOpts = {} } = {}) {
const baseUrl = globalOpts.baseUrl ?? "http://localhost:20128";
const apiKey = globalOpts.apiKey ?? "";
const res = await fetch(`${baseUrl}/api/mcp/stream`, {
const streamUrl = `${baseUrl}/api/mcp/stream`;
const hdrs = {
"Content-Type": "application/json",
Accept: stream ? "text/event-stream" : "application/json",
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
};
// Step 1 — initialize
const initRes = await fetch(streamUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify({ name: tool, arguments: args }),
headers: hdrs,
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "omniroute-cli", version: "1.0" },
},
}),
});
if (!res.ok) {
process.stderr.write(`HTTP ${res.status}\n`);
process.exit(1);
if (!initRes.ok) {
const text = await initRes.text().catch(() => "");
process.stderr.write(`MCP initialize failed: HTTP ${initRes.status}${text ? `${text}` : ""}\n`);
return 1;
}
const reader = res.body.getReader();
const sessionId = initRes.headers.get("mcp-session-id");
if (!sessionId) {
process.stderr.write("MCP initialize failed: no Mcp-Session-Id in response\n");
return 1;
}
// Step 2 — tools/call
const callHeaders = {
...hdrs,
"mcp-session-id": sessionId,
};
const callRes = await fetch(streamUrl, {
method: "POST",
headers: callHeaders,
body: JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: { name: tool, arguments: args },
}),
});
if (!callRes.ok) {
const text = await callRes.text().catch(() => "");
process.stderr.write(`MCP call failed: HTTP ${callRes.status}${text ? `${text}` : ""}\n`);
return 1;
}
if (stream) {
return readMcpSseStream(callRes.body);
}
// Non-stream: parse JSON-RPC response
const data = await callRes.json();
if (data.error) {
process.stderr.write(`MCP error: ${data.error.message || JSON.stringify(data.error)}\n`);
return 1;
}
// Print the result content
const content = data.result?.content;
if (content) {
for (const item of content) {
if (item.type === "text") {
process.stdout.write(item.text + "\n");
} else if (item.type === "resource") {
process.stdout.write(JSON.stringify(item.resource) + "\n");
} else {
process.stdout.write(JSON.stringify(item) + "\n");
}
}
} else {
process.stdout.write(JSON.stringify(data.result, null, 2) + "\n");
}
return 0;
}
async function readMcpSseStream(body) {
if (!body) return 1;
const reader = body.getReader();
const dec = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const raw = line.slice(6).trim();
if (raw && raw !== "[DONE]") process.stdout.write(raw + "\n");
}
}
const lines = buf.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const raw = line.slice(6).trim();
if (raw && raw !== "[DONE]") process.stdout.write(raw + "\n");
}
}
return 0;
}
export async function runMcpCallCommand(tool, args, opts = {}, globalOpts = {}) {
return mcpJsonRpcCall(tool, args, { stream: opts.stream, globalOpts });
}
export async function runMcpStatusCommand(opts = {}) {
@@ -233,7 +238,8 @@ export async function runMcpStatusCommand(opts = {}) {
}
const transport = status.transport || "stdio";
console.log(status.running ? t("mcp.running", { transport }) : t("mcp.stopped"));
const online = status.online ?? status.running;
console.log(online ? t("mcp.running", { transport }) : t("mcp.stopped"));
if (status.toolsCount !== undefined) console.log(` Tools: ${status.toolsCount}`);
if (status.scopes?.length) {
console.log(" Scopes:");

View File

@@ -1,4 +1,5 @@
import { apiFetch } from "../api.mjs";
import { mcpCallTool } from "../mcpClient.mjs";
import { emit } from "../output.mjs";
import { t } from "../i18n.mjs";
@@ -8,15 +9,7 @@ function fmtTs(v) {
}
async function mcpCall(name, args) {
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name, arguments: args },
});
if (!res.ok) {
process.stderr.write(`MCP error: ${res.status}\n`);
process.exit(1);
}
return res.json();
return mcpCallTool(name, args);
}
const proxySchema = [

View File

@@ -1,6 +1,7 @@
import { createInterface } from "node:readline";
import { Argument } from "commander";
import { apiFetch } from "../api.mjs";
import { mcpCallTool } from "../mcpClient.mjs";
import { emit } from "../output.mjs";
import { t } from "../i18n.mjs";
@@ -166,14 +167,7 @@ export function registerResilience(program) {
])
)
.action(async (name, opts, cmd) => {
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name: "omniroute_set_resilience_profile", arguments: { profile: name } },
});
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
await mcpCallTool("omniroute_set_resilience_profile", { profile: name });
process.stdout.write(`Profile: ${name}\n`);
});

View File

@@ -12,7 +12,7 @@ import {
isFatalInstrumentationHookFailure,
formatAndroidInstrumentationFailureHint,
} from "../utils/ensureAndroidCacheDir.mjs";
import { resolveServerHost } from "../utils/serverHost.mjs";
import { resolveServerHost, resolveExposureWarning } from "../utils/serverHost.mjs";
import {
resolveMaxOldSpaceMb,
calibrateHeapFallbackMb,
@@ -162,6 +162,15 @@ export async function runServe(opts = {}) {
`);
}
// GHSA-wmgv-ph3p-rv57: the default posture (all interfaces + no API key) is a
// deliberate local-first choice, but it must be loud at startup — an operator
// on an untrusted network learns the two escape hatches here, not after a
// surprise quota bill.
const exposureWarning = resolveExposureWarning();
if (exposureWarning) {
console.warn(`\x1b[33m ⚠ ${exposureWarning}\x1b[0m\n`);
}
const serverWsJs = join(APP_DIR, "server-ws.mjs");
const serverJs = existsSync(serverWsJs) ? serverWsJs : join(APP_DIR, "server.js");

View File

@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { apiFetch } from "../api.mjs";
import { mcpCallTool } from "../mcpClient.mjs";
import { emit } from "../output.mjs";
import { t } from "../i18n.mjs";
@@ -106,14 +107,7 @@ export async function runSkillsInstall(opts, cmd) {
}
export async function runSkillsEnable(id, opts, cmd) {
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name: "omniroute_skills_enable", arguments: { skillId: id, enabled: true } },
});
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
await mcpCallTool("omniroute_skills_enable", { skillId: id, enabled: true });
process.stdout.write(`Enabled: ${id}\n`);
}
@@ -122,14 +116,7 @@ export async function runSkillsDisable(id, opts, cmd) {
const ok = await confirm(`Disable ${id}?`);
if (!ok) return;
}
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name: "omniroute_skills_enable", arguments: { skillId: id, enabled: false } },
});
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
await mcpCallTool("omniroute_skills_enable", { skillId: id, enabled: false });
process.stdout.write(`Disabled: ${id}\n`);
}
@@ -153,16 +140,11 @@ export async function runSkillsExecute(id, opts, cmd) {
: opts.inputFile
? JSON.parse(readFileSync(opts.inputFile, "utf8"))
: {};
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name: "omniroute_skills_execute", arguments: { skillId: id, input } },
timeout: opts.timeout ?? 30000,
});
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
const data = await res.json();
const data = await mcpCallTool(
"omniroute_skills_execute",
{ skillId: id, input },
{ timeout: opts.timeout ?? 30000 },
);
emit(data, globalOpts);
}

View File

@@ -26,7 +26,8 @@
"testFailed": "Teste do provedor falhou: {error}",
"loginEnabled": "Login: habilitado (senha atualizada)",
"loginDisabled": "Login: desabilitado",
"providerInfo": "Provedor: {info}"
"providerInfo": "Provedor: {info}",
"opencode": "Instala e configura o plugin @omniroute/opencode-plugin incluído para o OpenCode"
},
"doctor": {
"title": "OmniRoute Doctor",
@@ -254,7 +255,9 @@
"no_recovery": "Desabilitar reinício automático em crash (modo debug)",
"max_restarts": "Máximo de reinícios em 30s antes de desistir (padrão: 2)",
"tray": "Mostrar ícone na bandeja do sistema (apenas desktop, opt-in)",
"no_tray": "Desabilitar ícone na bandeja do sistema"
"no_tray": "Desabilitar ícone na bandeja do sistema",
"tls_cert": "Caminho para um certificado TLS (PEM) para servir HTTPS (também OMNIROUTE_TLS_CERT)",
"tls_key": "Caminho para a chave privada TLS (PEM) para servir HTTPS (também OMNIROUTE_TLS_KEY)"
},
"backup": {
"title": "Backup",

127
bin/cli/mcpClient.mjs Normal file
View File

@@ -0,0 +1,127 @@
/**
* Shared MCP JSON-RPC client for CLI commands.
*
* The server exposes MCP through /api/mcp/stream (Streamable HTTP transport).
* Calling a tool requires:
* 1. POST initialize → get Mcp-Session-Id response header
* 2. POST tools/call with that session header
*
* Older CLI paths POSTed { name, arguments } to /api/mcp/tools/call, which is
* not a registered route, so every MCP-backed command was broken.
*
* These functions route through apiFetch so CLI auth, remote contexts and
* timeouts are handled the same way as every other management API call.
*/
import { apiFetch } from "./api.mjs";
function mcpError(message, status) {
const err = new Error(message);
if (status) err.status = status;
return err;
}
async function callMcpEndpoint(payload, { timeout, stream }) {
const res = await apiFetch("/api/mcp/stream", {
method: "POST",
body: payload,
timeout,
acceptNotOk: true,
headers: stream ? { Accept: "text/event-stream" } : {},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw mcpError(
`${payload.method} ${payload.id}: HTTP ${res.status}${text ? `${text}` : ""}`,
res.status,
);
}
return res;
}
/**
* Call an MCP tool over /api/mcp/stream.
*
* Non-stream: returns the JSON-RPC result payload.
* Stream: writes SSE `data:` chunks to stdout and returns null on success.
*/
export async function mcpCallTool(name, args = {}, options = {}) {
const { timeout, scope } = options;
const scopeHeader = scope?.length ? { "X-MCP-Scopes": scope.join(",") } : {};
const initRes = await callMcpEndpoint(
{
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "omniroute-cli", version: "1.0" },
},
},
{ timeout, stream: options.stream },
);
const sessionId = initRes.headers.get("mcp-session-id");
if (!sessionId) {
throw mcpError("MCP initialize failed: no Mcp-Session-Id in response", 500);
}
const callRes = await callMcpEndpoint(
{
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: { name, arguments: args },
},
{ timeout, stream: options.stream },
);
if (options.stream) {
return consumeSse(callRes.body, options.onChunk);
}
const data = await callRes.json();
if (data.error) {
const err = mcpError(`MCP error: ${data.error.message || JSON.stringify(data.error)}`);
err.code = data.error.code;
throw err;
}
if (data.result?.isError) {
const msg = data.result?.content?.[0]?.text || "unknown tool error";
throw mcpError(`MCP error: ${msg}`, 500);
}
return data.result;
}
async function consumeSse(body, onChunk) {
if (!body) throw mcpError("MCP stream returned no body", 500);
const reader = body.getReader();
const decoder = new TextDecoder();
let buf = "";
const flushLines = () => {
let idx;
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (line.startsWith("data: ")) {
const raw = line.slice(6).trim();
if (raw && raw !== "[DONE]") (onChunk ?? writeStdout)(raw);
}
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
flushLines();
}
buf += decoder.decode();
flushLines();
return null;
}
function writeStdout(raw) {
process.stdout.write(raw + "\n");
}

View File

@@ -24,3 +24,34 @@ export function resolveServerHost(
}
return "0.0.0.0";
}
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
/**
* Boot-time exposure warning (GHSA-wmgv-ph3p-rv57): the shipped default binds
* all interfaces while the inference plane requires no credentials, so any
* LAN peer can spend the operator's quota. That local-first posture is a
* deliberate, documented default — but it must be LOUD at startup so an
* operator who never read the docs still learns the two escape hatches.
*
* Returns the warning text when the server will listen on a non-loopback
* interface with no API-key requirement, or null when the exposure is closed.
*
* @param {NodeJS.ProcessEnv} [env]
* @param {string} [host]
* @returns {string | null}
*/
export function resolveExposureWarning(env = process.env, host = resolveServerHost(env)) {
if (LOOPBACK_HOSTS.has(host)) return null;
const requireKey = String(env.REQUIRE_API_KEY || "")
.trim()
.toLowerCase();
if (requireKey === "true" || requireKey === "1" || requireKey === "yes") return null;
return (
`SECURITY: listening on ${host} with NO API-key requirement — the inference ` +
`plane (/v1/*) is reachable by ANY device that can route to this host, and ` +
`requests are billed to your configured providers. This local-first default ` +
`is intentional, but on an untrusted network either set REQUIRE_API_KEY=true ` +
`or bind loopback with OMNIROUTE_SERVER_HOST=127.0.0.1.`
);
}

View File

@@ -0,0 +1 @@
- **feat(audio):** proxy native ElevenLabs voices, text-to-speech, and speech-to-text HTTP routes through stored OmniRoute credentials, preserving query strings, multipart uploads, binary responses, and upstream errors (#10556).

View File

@@ -0,0 +1 @@
- Added Google AI Studio Gemini batch text-to-speech support through `POST /v1/audio/speech`.

View File

@@ -0,0 +1,3 @@
- Run synchronous RTK and Caveman request compression in a bounded worker-thread pool, keeping
large `/v1/responses` compression heaps outside the HTTP isolate while preserving strict
fail-open behavior and per-engine telemetry.

View File

@@ -0,0 +1 @@
- **feat(dashboard):** replace the hard Home → onboarding redirect with a dismissable first-run readiness card so returning users can stay on Home while new users still get a clear 4-step path ([#11282](https://github.com/diegosouzapw/OmniRoute/pull/11282))

View File

@@ -0,0 +1 @@
- **feat(dashboard):** lead Traffic Inspector with a purpose-first header that separates "what happened" from "how it happened", so beginners can read request outcomes without drowning in protocol detail ([#11283](https://github.com/diegosouzapw/OmniRoute/pull/11283))

View File

@@ -0,0 +1 @@
- **feat(dashboard):** add an Essentials sidebar preset that shows only the beginner core path (Home → Endpoints → API Keys → Providers → Health → Settings) while keeping Advanced tools reachable via Command Palette search ([#11286](https://github.com/diegosouzapw/OmniRoute/pull/11286))

View File

@@ -0,0 +1 @@
- feat(services): show sanitized CLIProxyAPI account health from its authenticated management API without exposing credentials, file paths, or raw account metadata (#6342)

View File

@@ -1 +0,0 @@
- fix(sse): mark gemini-3.5-flash as thinking-capable so reasoning_effort is no longer rejected with a spurious 400 (#10286)

View File

@@ -0,0 +1 @@
- **fix(github):** proactive credential health now verifies GitHub access tokens through the existing Copilot token exchange, marks only a confirmed `401 Unauthorized` as expired, and leaves rate limits, permission failures, upstream failures, and network errors routable ([#10352](https://github.com/diegosouzapw/OmniRoute/issues/10352)) — thanks @RaviTharuma

View File

@@ -0,0 +1 @@
- fix(oauth): stop treating the Kiro profile ARN as an account identity in `findKiroConnectionByIdentity()`, so a second Google/GitHub social login creates a new connection instead of overwriting the first — distinct Builder ID accounts share the same CodeWhisperer profile ARN, and the social token is not a JWT, so no e-mail was available to disambiguate them (#10815)

View File

@@ -0,0 +1 @@
- Document the conditional management authentication and 401/403 responses for `GET /api/openapi/spec`.

View File

@@ -0,0 +1 @@
- **fix(ollama):** Ollama Local models are no longer flattened to `chat` at sync time — the synced store persists every advertised capability and chat filtering moves to read time, so `/v1/embeddings` and `/v1/images/generations` stop rejecting models the daemon reports as capable ([#11271](https://github.com/diegosouzapw/OmniRoute/pull/11271)) — thanks @yourspraveen

View File

@@ -0,0 +1 @@
- **fix(translator):** preserve omitted OpenCode `subagent.sessionID` values — optional default-less plain strings now use the Responses `null = omit` sentinel and are stripped before the client sees the tool call, so Codex/Responses no longer invent filler session IDs ([#11297](https://github.com/diegosouzapw/OmniRoute/pull/11297)) — thanks @ofonseca-pyming

View File

@@ -0,0 +1 @@
- **fix(db):** group model patterns escape regex metacharacters, so `gpt-4.1*` no longer matches `gpt-4o1-preview` and a pattern like `gpt-4(*` no longer throws `SyntaxError` out of the completion and `/v1/models` paths ([#11311](https://github.com/diegosouzapw/OmniRoute/pull/11311))

View File

@@ -0,0 +1 @@
- **fix(db):** the upstream proxy URL check judges the host by address instead of by spelling, so `http://[::ffff:169.254.169.254]`, `[::ffff:10.0.0.5]`, ULA/link-local and CGNAT targets are refused like their dotted equivalents ([#11319](https://github.com/diegosouzapw/OmniRoute/pull/11319))

View File

@@ -0,0 +1 @@
- **fix(i18n):** three `pt` strings had dropped their placeholders — the cache tile's subtitle repeated its own label instead of showing `{total}` — and a unit test now enforces placeholder parity with `en` across all locales ([#11325](https://github.com/diegosouzapw/OmniRoute/pull/11325))

View File

@@ -0,0 +1 @@
- **fix(kie):** map the remaining `google-imagen/*` KIE Market catalog ids (`nano-banana`, `nano-banana-pro`, `nano-banana-edit`) to their real, KIE-documented upstream `model` values — `#11225`'s fix only covered `nano-banana-2` ([#11326](https://github.com/diegosouzapw/OmniRoute/pull/11326)).

View File

@@ -0,0 +1 @@
- **fix(security):** `proxy-authorization` and `proxy-authenticate` are refused as upstream/custom headers, so a proxy credential is no longer forwarded to the model provider — the canonical denylist now matches the RFC 7230 §6.1 set the rest of the codebase already strips ([#11328](https://github.com/diegosouzapw/OmniRoute/pull/11328))

View File

@@ -0,0 +1 @@
- **docs(database):** align the SQLite cache guide with the 65,536 KiB runtime default, supported 11,000,000 KiB range, and live Settings application behavior ([#11018](https://github.com/diegosouzapw/OmniRoute/issues/11018))

View File

@@ -0,0 +1,3 @@
- **fix(deps):** prevent pnpm from auto-installing the unused `@lobehub/ui` peer subtree of
`@lobehub/icons`, keeping six unneeded packages with incompatible or unverifiable license
metadata out of production installs ([#11342](https://github.com/diegosouzapw/OmniRoute/pull/11342)).

View File

@@ -3445,7 +3445,7 @@
},
"tests/integration/qdrant-routes.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 19
"count": 3
}
},
"tests/integration/quota-pools-usage.test.ts": {
@@ -4029,10 +4029,10 @@
},
"tests/unit/cli-combo-suggest-commands.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 16
"count": 14
},
"@typescript-eslint/no-unused-vars": {
"count": 2
"count": 1
}
},
"tests/unit/cli-completion-dynamic.test.ts": {
@@ -4042,7 +4042,7 @@
},
"tests/unit/cli-compression-commands.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 32
"count": 20
}
},
"tests/unit/cli-context-eng-commands.test.ts": {
@@ -4099,7 +4099,7 @@
},
"tests/unit/cli-mcp-call-commands.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 16
"count": 10
}
},
"tests/unit/cli-memory-commands.test.ts": {
@@ -4130,7 +4130,7 @@
},
"tests/unit/cli-oneproxy-commands.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 22
"count": 14
},
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -4203,9 +4203,6 @@
"tests/unit/cli-resilience-commands.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 16
},
"@typescript-eslint/no-unused-vars": {
"count": 2
}
},
"tests/unit/cli-runtime-extended.test.ts": {
@@ -4238,7 +4235,7 @@
},
"tests/unit/cli-skills-commands.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 22
"count": 16
}
},
"tests/unit/cli-stop-supervisor-respawn-9455.test.ts": {
@@ -6620,4 +6617,4 @@
"count": 2
}
}
}
}

View File

@@ -6,7 +6,7 @@ lastUpdated: 2026-07-31
# OmniRoute Antigravity (Google One AI) Onboarding Guide
> **What you get**: Access to Gemini 3.1 Pro, Gemini 3.5 Flash, Claude Sonnet 4.6, and other models through your Google One AI Pro subscription — routed through OmniRoute as a unified gateway.
> **What you get**: Access to Gemini 3.1 Pro, Gemini 3.7 Flash, Claude Sonnet 4.6, and other models through your Google One AI Pro subscription — routed through OmniRoute as a unified gateway.
**Official references**:
@@ -45,7 +45,7 @@ Both providers share the **same Google backend** — identical OAuth client, tok
**Why the model catalog differs**: Google's CLI is "optimized for speed and low overhead" and "co-optimized with Gemini models" (per Google's official blog). The Web/IDE product is "optimized for comprehensiveness." The CLI uses `:fetchAvailableModels` to dynamically discover models, while the IDE uses a static curated list.
**In practice**: Use `agy/` prefix for Gemini models (e.g. `agy/gemini-3.5-flash-high`). Use `antigravity/` for the static curated list. Both hit the same Google backend, but expose different model naming. The quota is shared — using either provider counts against the same Google account's limits.
**In practice**: Use `agy/` prefix for Gemini models (e.g. `agy/gemini-3.7-flash-high`). Use `antigravity/` for the static curated list. Both hit the same Google backend, but expose different model naming. The quota is shared — using either provider counts against the same Google account's limits.
---

View File

@@ -6866,7 +6866,11 @@ paths:
Returns a structured JSON catalog parsed from this `openapi.yaml`,
including info, servers, tags, schemas, and a flat list of endpoints
(method, path, tags, summary, security, parameters, responses).
Used by the in-app API explorer.
Used by the in-app API explorer. When `requireLogin` is enabled, this
management endpoint requires an authenticated dashboard session;
otherwise it is available without authentication.
security:
- ManagementSessionAuth: []
responses:
"200":
description: Parsed OpenAPI catalog
@@ -6920,6 +6924,10 @@ paths:
type: string
"404":
description: openapi.yaml file not found on disk
"401":
$ref: "#/components/responses/ManagementAuthenticationRequired"
"403":
$ref: "#/components/responses/ManagementInvalidToken"
"500":
description: Failed to parse OpenAPI spec

View File

@@ -1,7 +1,7 @@
---
title: "Database Schema & Operations Guide"
version: 3.8.40
lastUpdated: 2026-06-28
version: 3.8.50
lastUpdated: 2026-08-23
---
# Database Schema & Operations Guide
@@ -43,12 +43,17 @@ For **single-user, single-instance** deployments (the primary OmniRoute use case
db.pragma("journal_mode = WAL");
db.pragma("busy_timeout = 2000");
db.pragma("synchronous = NORMAL");
// Settings > System & Storage > Cache Size is applied as KiB.
db.pragma("cache_size = -16384");
db.pragma(`cache_size = -${DEFAULT_DATABASE_SETTINGS.optimization.cacheSize}`);
```
WAL allows **concurrent reads** during writes — important for the dashboard, which queries while requests are being recorded.
The default cache size is **65,536 KiB (64 MiB)**. SQLite interprets a negative
`cache_size` as an approximate upper bound in KiB and allocates pages on demand.
**Settings > System & Storage > Cache Size** accepts integer values from **1 to
1,000,000 KiB**; saving the setting applies it to the live database connection,
and OmniRoute restores the persisted value at startup.
---
## Database Location

View File

@@ -531,6 +531,9 @@ detection above).
| `OMNIROUTE_CONFIG_HOT_RELOAD_MS` | `5000` | `src/lib/config/hotReload.ts` | Polling interval (ms) for config hot-reload. Lower than `1000` is rejected. |
| `OMNIROUTE_DISABLE_REDIS_AUTH_CACHE` | _(enabled)_ | `src/lib/db/apiKeys.ts` | Set `1` to bypass the Redis-backed API-key auth cache (forces DB reads). |
| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | `0` | `open-sse/services/compression/engines/rtk/filterLoader.ts` | Trust user-managed RTK project filter rules without strict signature checks. |
| `OMNI_COMPRESSION_WORKERS` | `2` | `open-sse/services/compression/compressionWorkerPool.ts` | Maximum concurrent synchronous RTK/Caveman workers; excess jobs wait FIFO. |
| `OMNI_COMPRESSION_WORKER_TIMEOUT_MS` | `120000` | `open-sse/services/compression/compressionWorkerPool.ts` | Per-job timeout in milliseconds. Timed-out workers are terminated and the request fails open unchanged. |
| `OMNI_COMPRESSION_WORKER_IDLE_MS` | `60000` | `open-sse/services/compression/compressionWorkerPool.ts` | Idle lifetime in milliseconds before an unused compression worker is terminated. |
| `COMPRESSION_PIPELINE_BREAKER_ENABLED` | `false` | `open-sse/services/compression/pipelineEngineBreaker.ts` | T02 stacked-pipeline per-engine circuit-breaker master switch. **Opt-in (default off)** — when on, an engine that throws repeatedly across requests is skipped (fail-open) for a cooldown; off = byte-identical legacy behavior. |
| `COMPRESSION_PIPELINE_BREAKER_THRESHOLD` | `3` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Consecutive cross-request failures before an engine's breaker opens. |
| `COMPRESSION_PIPELINE_BREAKER_COOLDOWN_MS` | `30000` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Milliseconds an opened engine stays skipped before a half-open probe. |
@@ -1041,6 +1044,7 @@ desktop install.
| `EMBED_WS_PROXY_PORT` | `20131` | `src/lib/services/embedWsProxy.ts` | Port for the embedded-service WebSocket proxy server. |
| `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge host (legacy integration). |
| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. |
| `CLIPROXYAPI_MANAGEMENT_KEY` | _(empty)_ | `src/lib/services/cliproxyAccountHealth.ts` | Management key for account-health reads from an externally managed CLIProxyAPI instance. |
| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. |
| `MUX_SERVICE_PORT` | `8322` | `src/lib/services/bootstrap.ts` | Override the port where the embedded Mux (coder/mux) agent-orchestration daemon listens (always 127.0.0.1). |
| `DARIO_HOST` | `127.0.0.1` | `open-sse/executors/dario.ts` | Dario embedded-service bind/connect host (loopback only by default). |

View File

@@ -2,6 +2,7 @@ import createNextIntlPlugin from "next-intl/plugin";
import { createMDX } from "fumadocs-mdx/next";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { betterSqlite3AliasFor } from "./scripts/build/better-sqlite3-stub-flag.mjs";
import { mitmManagerAliasFor } from "./scripts/build/mitm-stub-flag.mjs";
import { normalizeBasePath } from "./scripts/build/normalizeBasePath.mjs";
import {
@@ -138,10 +139,14 @@ const nextConfig = {
// the stub to every npm/Electron/VPS artifact and broke Agent Bridge
// start for all non-Docker users (#6344). See scripts/build/mitm-stub-flag.mjs.
...mitmManagerAliasFor(process.env),
// Build-time stub so the bundler never traces the native better-sqlite3
// addon into a build worker (SIGABRT at worker teardown). Runtime still
// uses the real package via serverExternalPackages. (#10060)
"better-sqlite3": "./src/lib/db/better-sqlite3.stub.js",
// better-sqlite3 → build-time stub ONLY where the build worker actually
// aborts while tracing the native addon (SIGABRT at worker teardown,
// #10060); opt in with OMNIROUTE_BETTER_SQLITE3_STUB=1. The alias used to
// be unconditional on the premise that serverExternalPackages still won
// at runtime — it does not: resolveAlias rewrites the request before the
// externals check, so the stub was bundled and EVERY route answered 500
// (#11343). See scripts/build/better-sqlite3-stub-flag.mjs.
...betterSqlite3AliasFor(process.env),
...minimalBuildAliases,
},
// src/lib/agentSkills/generator.ts builds its fs base path from a runtime

View File

@@ -113,6 +113,7 @@ const AGY_RETIRED_MODEL_IDS = new Set([
"gemini-3.6-flash-medium",
"gemini-3.6-flash-low",
"gemini-3-flash-agent",
"gemini-3.5-flash",
"gemini-3.5-flash-extra-low",
"gemini-3.5-flash-low",
"gemini-3.5-flash-high",

View File

@@ -179,6 +179,7 @@ const ANTIGRAVITY_RETIRED_MODEL_IDS = new Set([
"gemini-3.6-flash-medium",
"gemini-3.6-flash-low",
"gemini-3-flash-agent",
"gemini-3.5-flash",
"gemini-3.5-flash-extra-low",
"gemini-3.5-flash-low",
"gemini-3.5-flash-high",

View File

@@ -287,6 +287,19 @@ export const AUDIO_TRANSLATION_PROVIDERS: Record<string, AudioProvider> = {
};
export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
google: {
id: "google",
credentialProviderId: "gemini",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
authType: "apikey",
authHeader: "x-goog-api-key",
format: "gemini-tts",
models: [
{ id: "gemini-3.1-flash-tts-preview", name: "Gemini 3.1 Flash TTS" },
{ id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS" },
{ id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS" },
],
},
vertex: {
id: "vertex",
baseUrl: "https://us-central1-aiplatform.googleapis.com/v1",

View File

@@ -8,7 +8,6 @@
"gemma-4-26b-it": { "rpm": 16000, "rpd": 14400, "tpm": 16000 },
"gemma-4-31b-it": { "rpm": 16000, "rpd": 14400, "tpm": 16000 },
"gemini-embedding-exp-03-07": { "rpm": 100, "rpd": 1000, "tpm": 30000 },
"gemini-3.5-flash": { "rpm": 5, "rpd": 20, "tpm": 250000 },
"gemini-3.1-flash-lite": { "rpm": 15, "rpd": 500, "tpm": 250000 },
"gemini-3.1-pro": { "rpm": 0, "rpd": 0, "tpm": 0 },
"gemini-2.5-flash-lite": { "rpm": 10, "rpd": 20, "tpm": 250000 },

View File

@@ -186,6 +186,9 @@ export function getModelTargetFormat(aliasOrId: string, modelId: string): string
// executor's /codex/i routing, 9router#102). Scoped to the openai alias so other
// providers shipping *-pro ids keep their own endpoint semantics.
if (alias === "openai" && /-pro$/i.test(bareModelId)) return "openai-responses";
// ponytail: Claude models on Vertex use rawPredict with Anthropic Messages format,
// not the Gemini generateContent format. Mirrors executor isClaudeModel() check.
if ((alias === "vertex" || alias === "vp") && /^claude-/i.test(bareModelId)) return "claude";
// Model-level targetFormat is provider-scoped: a catalog entry declares how THIS
// provider's endpoint serves the model — do NOT import another provider's tag.
// #9994 scoped this for providers WITH a catalog; #10072 extends it to catalogless

View File

@@ -181,6 +181,29 @@ export function getRegistryEntry(provider: string): RegistryEntry | null {
return REGISTRY[provider] || _byAlias.get(provider) || null;
}
/** Resolve only a model's explicit reasoning vocabulary. */
export function getRegistryModelThinkingEfforts(
provider: string,
modelId: string
): readonly string[] | undefined {
const entry = getRegistryEntry(provider);
if (!entry) return undefined;
const model = entry.models.find((candidate) => candidate.id === modelId);
return model?.supportedThinkingEfforts;
}
/** Resolve a model's explicit reasoning vocabulary before its provider fallback. */
export function getRegistryThinkingEfforts(
provider: string,
modelId: string
): readonly string[] | undefined {
const entry = getRegistryEntry(provider);
if (!entry) return undefined;
const modelEfforts = getRegistryModelThinkingEfforts(provider, modelId);
if (modelEfforts !== undefined) return modelEfforts;
return entry.defaultSupportedThinkingEfforts;
}
/**
* Decide whether a non-empty live catalog may exclude omitted static models
* during request routing and wildcard expansion.

View File

@@ -228,14 +228,14 @@ export const cursorProvider: RegistryEntry = {
{ id: "gpt-5.1-low", name: "GPT-5.1 Low" },
{ id: "gpt-5.1", name: "GPT-5.1" },
{ id: "gpt-5.1-high", name: "GPT-5.1 High" },
{ id: "gemini-3.5-flash", name: "Gemini 3.5 Flash" },
{ id: "claude-4-sonnet", name: "Sonnet 4" },
{ id: "claude-4-sonnet-thinking", name: "Sonnet 4 Thinking" },
{ id: "gpt-5-mini", name: "GPT-5 Mini" },
{ id: "kimi-k3-low", name: "Kimi K3 Low" },
{ id: "kimi-k3-max", name: "Kimi K3" },
{ id: "glm-5.2-high", name: "GLM 5.2" },
{ id: "glm-5.2-max", name: "GLM 5.2 Max" }, ],
{ id: "glm-5.2-max", name: "GLM 5.2 Max" },
],
};
/**

View File

@@ -9,6 +9,7 @@ export const ollama_cloudProvider: RegistryEntry = {
modelsUrl: "https://ollama.com/api/tags",
authType: "apikey",
authHeader: "bearer",
defaultSupportedThinkingEfforts: ["none", "low", "medium", "high", "max"],
// Note: rate limits vary by plan (free = "Light usage", Pro = more, Max = 5x Pro).
// Users can generate API keys at https://ollama.com/settings/keys
models: [
@@ -24,23 +25,20 @@ export const ollama_cloudProvider: RegistryEntry = {
supportsReasoning: true,
supportedThinkingEfforts: ["low", "medium", "high"],
},
// #10788: Ollama Cloud accepts low|medium|high|max|none uniformly across
// its reasoning-capable models (see supportsMaxEffortForProvider's
// isOllamaCloud comment in open-sse/executors/base/reasoningEffort.ts) —
// declare supportedThinkingEfforts so appendSyncedEffortVariants() (which
// runs before static-model capability enrichment) can synthesize the
// catalog's selectable -low/-high/-max variant ids for these models.
// #10788: these models accept none|low|medium|high|max. Keep their explicit
// declarations aligned with the provider fallback so the static and synced
// catalog paths expose the same native vocabulary.
{
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
supportsReasoning: true,
supportedThinkingEfforts: ["low", "medium", "high", "max"],
supportedThinkingEfforts: ["none", "low", "medium", "high", "max"],
},
{
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
supportsReasoning: true,
supportedThinkingEfforts: ["low", "medium", "high", "max"],
supportedThinkingEfforts: ["none", "low", "medium", "high", "max"],
},
{ id: "kimi-k2.6", name: "Kimi K2.6" },
// Ollama Cloud accepts low|medium|high|max|none and rejects xhigh, so the
@@ -50,14 +48,14 @@ export const ollama_cloudProvider: RegistryEntry = {
name: "GLM 5.1",
supportsReasoning: true,
supportsXHighEffort: false,
supportedThinkingEfforts: ["low", "medium", "high", "max"],
supportedThinkingEfforts: ["none", "low", "medium", "high", "max"],
},
{
id: "glm-5.2",
name: "GLM 5.2",
supportsReasoning: true,
supportsXHighEffort: false,
supportedThinkingEfforts: ["low", "medium", "high", "max"],
supportedThinkingEfforts: ["none", "low", "medium", "high", "max"],
},
// #3110: MiniMax M3 via Ollama
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },

View File

@@ -219,5 +219,18 @@ export const opencode_goProvider: RegistryEntry = {
supportedThinkingEfforts: ["none", "low", "high", "max"],
targetFormat: "openai-responses",
},
// Console Go free GLM-tier model (live-verified 2026-08-23): the upstream
// rejects every reasoning_effort outside {low, high, max} whenever tools
// are present — "[1210] This model always engages in thinking and cannot
// be disabled; please use low, high, or max" — which broke clients that
// default to reasoning_effort:"medium" (Hermes). Declaring the exact
// vocabulary lets sanitizeReasoningEffortForProvider clamp off-vocabulary
// requests to the nearest accepted tier instead of burning a 400.
{
id: "ox-alpha-free",
name: "ox-alpha (free)",
supportsReasoning: true,
supportedThinkingEfforts: ["low", "high", "max"],
},
],
};

View File

@@ -27,8 +27,17 @@ export const vertexProvider: RegistryEntry = {
{ id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro (Vertex Partner)" },
{ id: "Qwen3.6-35B-A3B", name: "Qwen3.6 35B A3B (Vertex Partner)" },
{ id: "GLM-5.1-FP8", name: "GLM-5.1 (Vertex Partner)" },
{ id: "claude-opus-4-7", name: "Claude Opus 4.7 (Vertex)" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Vertex)" },
{ id: "claude-fable-5", name: "Claude Fable 5 (Vertex)", targetFormat: "claude" },
{ id: "claude-opus-5", name: "Claude Opus 5 (Vertex)", targetFormat: "claude" },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5 (Vertex)", targetFormat: "claude" },
{ id: "claude-opus-4-8", name: "Claude Opus 4.8 (Vertex)", targetFormat: "claude" },
{ id: "claude-opus-4-7", name: "Claude Opus 4.7 (Vertex)", targetFormat: "claude" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6 (Vertex)", targetFormat: "claude" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Vertex)", targetFormat: "claude" },
{ id: "claude-sonnet-4-5-v2", name: "Claude Sonnet 4.5 v2 (Vertex)", targetFormat: "claude" },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (Vertex)", targetFormat: "claude" },
{ id: "claude-opus-4-5", name: "Claude Opus 4.5 (Vertex)", targetFormat: "claude" },
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (Vertex)", targetFormat: "claude" },
],
passthroughModels: true,
};

View File

@@ -13,10 +13,17 @@ export const vertex_partnerProvider: RegistryEntry = {
{ id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro" },
{ id: "Qwen3.6-35B-A3B", name: "Qwen 3.6 35B A3B" },
{ id: "GLM-5.1-FP8", name: "GLM 5.1" },
// Sweep 2026-06-19: + Claude Opus on Vertex (Anthropic partner models).
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
{ id: "claude-opus-4-7", name: "Claude Opus 4.7" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "claude-fable-5", name: "Claude Fable 5", targetFormat: "claude" },
{ id: "claude-opus-5", name: "Claude Opus 5", targetFormat: "claude" },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5", targetFormat: "claude" },
{ id: "claude-opus-4-8", name: "Claude Opus 4.8", targetFormat: "claude" },
{ id: "claude-opus-4-7", name: "Claude Opus 4.7", targetFormat: "claude" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", targetFormat: "claude" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", targetFormat: "claude" },
{ id: "claude-sonnet-4-5-v2", name: "Claude Sonnet 4.5 v2", targetFormat: "claude" },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", targetFormat: "claude" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4", targetFormat: "claude" },
{ id: "claude-opus-4-5", name: "Claude Opus 4.5", targetFormat: "claude" },
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5", targetFormat: "claude" },
],
};

View File

@@ -139,6 +139,9 @@ export interface RegistryEntry {
requestDefaults?: ProviderRequestDefaults;
oauth?: RegistryOAuth;
models: RegistryModel[];
/** Provider-native reasoning vocabulary for reasoning-capable passthrough models
* that do not have an explicit per-model declaration. */
defaultSupportedThinkingEfforts?: readonly string[];
modelsUrl?: string;
/** Prefix to prepend to model IDs before upstream API calls (e.g. "accounts/fireworks/models/") */
modelIdPrefix?: string;

View File

@@ -11,6 +11,7 @@ import {
import {
getLearnedReasoningEffort,
clampToLearned,
REASONING_EFFORT_ORDER,
} from "../../services/learnedReasoningEffortCaps.ts";
/**
@@ -357,6 +358,43 @@ export function sanitizeReasoningEffortForProvider(
}
}
// ── explicit per-model capability clamp ──────────────────────────────────
// When the registry declares supportedThinkingEfforts for this exact model
// and the requested effort falls outside that vocabulary, remap to the
// nearest declared tier: the smallest ranked value ≥ the request, else the
// highest declared (a request above the ceiling lands on the ceiling).
// Live case: opencode-go/ox-alpha-free (Console Go) only accepts
// {low, high, max} — a client's reasoning_effort:"medium" reached the
// upstream verbatim and 400'd every turn ("[1210] This model always engages
// in thinking and cannot be disabled; please use low, high, or max"). The
// learned-caps path can't help here (it only clamps down from xhigh/max,
// and this error text isn't a parseable enum), so the declaration is the
// only source of truth. Models without an explicit declaration keep
// #8057's trust-the-upstream pass-through.
const providerModelIdForClamp = modelStr.startsWith(`${provider}/`)
? modelStr.slice(provider.length + 1)
: modelStr;
const declaredEfforts = getProviderModels(provider).find(
(entry) => entry.id === providerModelIdForClamp || entry.aliases?.includes(providerModelIdForClamp)
)?.supportedThinkingEfforts;
const declaredRanked = (
Array.isArray(declaredEfforts) ? declaredEfforts : []
)
.map((tier) => ({ tier, rank: REASONING_EFFORT_ORDER.indexOf(tier) }))
.filter((x) => x.rank >= 0)
.sort((a, b) => a.rank - b.rank);
if (declaredRanked.length > 0 && !declaredEfforts!.includes(effortStr)) {
const requestedRank = REASONING_EFFORT_ORDER.indexOf(effortStr);
const nearest =
declaredRanked.find((x) => x.rank >= requestedRank) ??
declaredRanked[declaredRanked.length - 1];
log?.info?.(
"REASONING_SANITIZE",
`${provider}/${modelStr}: mapped reasoning_effort ${effortStr}${nearest.tier} (model accepts ${declaredEfforts!.join("/")})`
);
return writeEffortValue(b, nearest.tier, c);
}
const supportsXHigh = supportsXHighEffort(provider, modelStr);
const supportsMax = supportsMaxEffortForProvider(provider, modelStr);

View File

@@ -0,0 +1,81 @@
import { Buffer } from "node:buffer";
import { extractInlineAudio, parsePcmSampleRate, pcmToWav } from "./vertexMedia.ts";
import { CORS_HEADERS } from "../utils/cors.ts";
import { upstreamErrorResponse } from "../utils/audioResponse.ts";
import { errorResponse } from "../utils/error.ts";
type GeminiTtsCredentials = {
apiKey?: string | null;
accessToken?: string | null;
};
export class GeminiTtsUpstreamError extends Error {
constructor(
public readonly response: Response,
public readonly body: string
) {
super(`Gemini TTS upstream error (${response.status})`);
}
}
export async function geminiGenerateSpeech(
credentials: GeminiTtsCredentials,
options: { model: string; text: string; voice: string }
): Promise<Buffer> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (credentials.apiKey) {
headers["x-goog-api-key"] = credentials.apiKey;
} else if (credentials.accessToken) {
headers.Authorization = `Bearer ${credentials.accessToken}`;
}
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(options.model)}:generateContent`,
{
method: "POST",
headers,
body: JSON.stringify({
contents: [{ parts: [{ text: options.text }] }],
generationConfig: {
responseModalities: ["AUDIO"],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: { voiceName: options.voice },
},
},
},
}),
}
);
if (!response.ok) {
throw new GeminiTtsUpstreamError(response, await response.text());
}
const inline = extractInlineAudio(await response.json());
if (!inline) throw new Error("Gemini TTS response did not contain audio data");
return pcmToWav(Buffer.from(inline.base64, "base64"), parsePcmSampleRate(inline.mimeType));
}
export async function handleGeminiTtsSpeech(
credentials: GeminiTtsCredentials,
options: { model: string; text: string; voice?: unknown }
): Promise<Response> {
try {
const wav = await geminiGenerateSpeech(credentials, {
model: options.model,
text: options.text,
voice:
typeof options.voice === "string" && options.voice.trim() ? options.voice.trim() : "Kore",
});
return new Response(new Uint8Array(wav), {
status: 200,
headers: { ...CORS_HEADERS, "Content-Type": "audio/wav" },
});
} catch (error) {
if (error instanceof GeminiTtsUpstreamError) {
return upstreamErrorResponse(error.response, error.body);
}
const message = error instanceof Error ? error.message : String(error);
return errorResponse(500, `Speech request failed: ${message}`);
}
}

View File

@@ -1,3 +1,5 @@
import { randomBytes } from "node:crypto";
import {
BaseExecutor,
ExecuteInput,
@@ -13,6 +15,11 @@ import {
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
import { stripUnsupportedParams } from "../translator/paramSupport.ts";
/** Correlation-id fallback for runtimes without crypto.randomUUID — still CSPRNG-backed. */
function randomIdFallback(): string {
return `${Date.now()}-${randomBytes(9).toString("hex")}`;
}
/**
* What a Copilot credential refresh resolves to.
*
@@ -329,7 +336,7 @@ export class GithubExecutor extends BaseExecutor {
...getGitHubCopilotChatHeaders(stream ? "text/event-stream" : "application/json", initiator),
Authorization: `Bearer ${token}`,
"x-request-id":
crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`,
crypto.randomUUID?.() || randomIdFallback(),
};
// Per-call / per-conversation / per-turn correlation ids the @github/copilot
@@ -338,7 +345,7 @@ export class GithubExecutor extends BaseExecutor {
// fresh uuids. A Copilot-aware client may pin the session/task ids across a
// conversation via its own headers — honor those when present, else mint.
const genId = () =>
crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
crypto.randomUUID?.() || randomIdFallback();
headers["x-interaction-id"] = this.readClientHeader(clientHeaders, "x-interaction-id") || genId();
headers["x-client-session-id"] =
this.readClientHeader(clientHeaders, "x-client-session-id") || genId();

View File

@@ -156,13 +156,13 @@ export function pcmToWav(
return Buffer.concat([header, pcm]);
}
function parseSampleRate(mimeType: string | undefined): number {
export function parsePcmSampleRate(mimeType: string | undefined): number {
if (!mimeType) return 24000;
const match = /rate=(\d+)/i.exec(mimeType);
return match ? parseInt(match[1], 10) : 24000;
}
function extractInlineAudio(
export function extractInlineAudio(
data: unknown
): { base64: string; mimeType: string } | null {
const parts = (data as { candidates?: Array<{ content?: { parts?: unknown[] } }> })?.candidates?.[0]
@@ -215,7 +215,7 @@ export async function vertexGenerateSpeech(
const inline = extractInlineAudio(data);
if (!inline) throw new Error("Vertex TTS returned no audio content");
const pcm = Buffer.from(inline.base64, "base64");
return { audio: pcmToWav(pcm, parseSampleRate(inline.mimeType)), contentType: "audio/wav" };
return { audio: pcmToWav(pcm, parsePcmSampleRate(inline.mimeType)), contentType: "audio/wav" };
}
/** Gemini transcription (audio → text). `audioBase64` is the raw file bytes, base64-encoded. */

View File

@@ -21,6 +21,7 @@ import { getSpeechProvider, parseSpeechModel } from "../config/audioRegistry.ts"
import { buildAuthHeaders } from "../config/registryUtils.ts";
import { kieExecutor } from "../executors/kie.ts";
import { vertexGenerateSpeech } from "../executors/vertexMedia.ts";
import { handleGeminiTtsSpeech } from "../executors/geminiTts.ts";
import { handleAwsPollySpeech } from "../executors/awsPollyTts.ts";
import { handleEdgeTtsSpeech } from "../executors/edgeTts.ts";
import { GttsUpstreamError, normalizeGttsLang, synthesizeGtts } from "../executors/gtts.ts";
@@ -889,6 +890,13 @@ export async function handleAudioSpeech({
headers: { ...CORS_HEADERS, "Content-Type": contentType },
});
}
if (providerConfig.format === "gemini-tts") {
return handleGeminiTtsSpeech(credentials, {
model: modelId,
text: body.input,
voice: body.voice,
});
}
if (providerConfig.format === "hyperbolic") {
return handleHyperbolicSpeech(providerConfig, body, token);

View File

@@ -20,6 +20,7 @@ import { checkSemanticCache } from "./chatCore/semanticCache.ts";
import { checkLifecycle, resolveLifecycle } from "./chatCore/modelLifecyclePolicy.ts";
import {
shouldDefaultAllowClassifier,
detectClassifierFormat,
buildDefaultAllowClaudeMessage,
} from "./chatCore/claudeClassifierCompat.ts";
import { applyClientUsageBuffer } from "./chatCore/clientUsageBuffer.ts";
@@ -379,6 +380,7 @@ import { isCompactResponsesEndpoint } from "../executors/codex.ts";
import { persistCodexChildQuotaResponse } from "../services/codexAccount/index.ts";
import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts";
import { translateNonStreamingResponse } from "./responseTranslator.ts";
import { extractToolSchemaMap } from "../translator/response/openai-responses/toolSchemas.ts";
import { unwrapClineNonStreamingEnvelope } from "./chatCore/clineResponseEnvelope.ts";
import { extractUsageFromResponse } from "./usageExtractor.ts";
import {
@@ -778,11 +780,12 @@ export async function handleChatCore({
classifierSettings.claudeClassifierCompat as string | undefined
)
) {
const classifierFormat = detectClassifierFormat(body as Record<string, unknown>);
log?.warn?.(
"CHAT",
`classifier compat=${classifierSettings.claudeClassifierCompat} | short-circuit default-allow`
`classifier compat=${classifierSettings.claudeClassifierCompat} format=${classifierFormat} | short-circuit default-allow`
);
return buildDefaultAllowClaudeMessage(requestedModel);
return buildDefaultAllowClaudeMessage(requestedModel, classifierFormat);
}
}
@@ -4910,12 +4913,14 @@ export async function handleChatCore({
// Translate response to client's expected format (usually OpenAI)
// Pass toolNameMap so Claude OAuth proxy_ prefix is stripped in tool_use blocks (#605)
const responseToolSchemas = extractToolSchemaMap(finalBody || translatedBody || body);
let translatedResponse = needsTranslation(responsePayloadFormat, clientResponseFormat)
? translateNonStreamingResponse(
responseBody,
responsePayloadFormat,
clientResponseFormat,
responseToolNameMap
responseToolNameMap,
responseToolSchemas
)
: responseBody;
const memoryExtractionResponse = translatedResponse;
@@ -4942,7 +4947,8 @@ export async function handleChatCore({
responseBody,
responsePayloadFormat,
FORMATS.OPENAI,
responseToolNameMap
responseToolNameMap,
responseToolSchemas
)
: responseBody;
const firstChoice = cacheResponse?.choices?.[0];
@@ -5465,7 +5471,8 @@ export async function handleChatCore({
streamBody,
clientResponseFormat,
FORMATS.OPENAI,
responseToolNameMap
responseToolNameMap,
extractToolSchemaMap(finalBody || translatedBody || body)
) as Record<string, unknown>)
: streamBody;
const choices = cacheStreamBody.choices as

View File

@@ -24,14 +24,19 @@ const SECURITY_MONITOR_MARKER = "You are a security monitor for autonomous AI co
export type ClaudeClassifierCompatMode = "off" | "auto" | "always";
/** The two synthetic-response shapes Claude Code's classifier can expect. */
export type ClaudeClassifierFormat = "block" | "severity";
function extractSystemTexts(body: Record<string, unknown> | null | undefined): string[] {
const system = body?.system;
if (typeof system === "string") return [system];
if (Array.isArray(system)) {
return system
.map((part) => (part && typeof (part as { text?: unknown }).text === "string"
? ((part as { text: string }).text)
: ""))
.map((part) =>
part && typeof (part as { text?: unknown }).text === "string"
? (part as { text: string }).text
: ""
)
.filter(Boolean);
}
return [];
@@ -60,6 +65,29 @@ export function shouldDefaultAllowClassifier(
return extractSystemTexts(body).some((text) => text.includes(SECURITY_MONITOR_MARKER));
}
/**
* Detect which synthetic-response shape the classifier request expects.
*
* Newer Claude Code builds send a "severity classifier" variant of the same internal
* request: it carries `stop_sequences: [..., "</severity>", ...]` and parses a
* `<severity>N</severity>` reply instead of `<block>no</block>`/`<block>yes</block>`.
* Feeding it the legacy `<block>no</block>` shape is unparseable, so it retries both
* stages and then fails closed — the same "blocking it for safety" failure this compat
* shim exists to avoid. Only `stop_sequences` distinguishes the two shapes; callers
* should only consult this after `shouldDefaultAllowClassifier` has already confirmed
* the request is the classifier (via the system-prompt marker), so an unrelated app
* that merely happens to use `</severity>` as a stop token is never affected (#8189).
*/
export function detectClassifierFormat(
body: Record<string, unknown> | null | undefined
): ClaudeClassifierFormat {
const stopSequences = body?.stop_sequences;
if (Array.isArray(stopSequences) && stopSequences.includes("</severity>")) {
return "severity";
}
return "block";
}
/**
* Build the synthetic Claude `message` ALLOW response. Always returns a plain JSON
* body (matching the upstream reference implementation) — Claude Code's classifier
@@ -67,7 +95,10 @@ export function shouldDefaultAllowClassifier(
* satisfies both streaming and non-streaming callers without needing to plumb a
* synthetic SSE encoding through the streaming/sseToJson/non-streaming handlers.
*/
export function buildDefaultAllowClaudeMessage(model?: string | null): {
export function buildDefaultAllowClaudeMessage(
model?: string | null,
format: ClaudeClassifierFormat = "block"
): {
success: true;
response: Response;
} {
@@ -76,7 +107,12 @@ export function buildDefaultAllowClaudeMessage(model?: string | null): {
type: "message",
role: "assistant",
model: model || "claude-3-5-sonnet-20241022",
content: [{ type: "text", text: "<block>no</block>" }],
content: [
{
type: "text",
text: format === "severity" ? "<severity>0</severity>" : "<block>no</block>",
},
],
stop_reason: "end_turn",
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },

View File

@@ -91,8 +91,20 @@ interface KieImageOptions {
} | null;
}
// KIE Market catalog ids are namespaced for OmniRoute's catalog
// (`google-imagen/<model>`), but the KIE Market createTask API expects
// vendor-specific upstream ids that do not follow a single consistent
// pattern (confirmed against docs.kie.ai/market/google/* — see #11225,
// #11296): nano-banana-2 and nano-banana-pro drop the vendor namespace
// entirely, while nano-banana and nano-banana-edit use a `google/` prefix
// instead of `google-imagen/`. Every other KIE Market namespace (seedream,
// flux, ideogram, qwen, wan, grok-imagine, gpt) already matches its real
// upstream id byte-for-byte, so this map stays scoped to google-imagen.
export const KIE_MARKET_UPSTREAM_MODEL_IDS: ReadonlyMap<string, string> = new Map([
["google-imagen/nano-banana", "google/nano-banana"],
["google-imagen/nano-banana-2", "nano-banana-2"],
["google-imagen/nano-banana-pro", "nano-banana-pro"],
["google-imagen/nano-banana-edit", "google/nano-banana-edit"],
]);
export function resolveKieMarketUpstreamModelId(publicModelId: string): string {

View File

@@ -13,6 +13,7 @@ import {
import { restoreClaudeToolName } from "../services/claudeCodeToolRemapper.ts";
import { extractReplayableResponsesReasoningText } from "../services/reasoningInputPolicy.ts";
import { sanitizeToolId } from "../translator/helpers/schemaCoercion.ts";
import { stripEmptyOptionalToolArgs } from "../translator/response/openai-responses/pureHelpers.ts";
type JsonRecord = Record<string, unknown>;
@@ -135,24 +136,28 @@ function findBestMessageText(output: unknown[]): {
* Handles different provider response formats (Gemini, Claude, etc.)
*
* @param toolNameMap - Optional Map<prefixedName, originalName> for Claude OAuth tool name stripping
* @param toolSchemas - Optional Map<toolName, parametersSchema> for schema-aware optional-arg cleanup
*/
export function translateNonStreamingResponse(
responseBody: JsonRecord,
targetFormat: string,
sourceFormat: string,
toolNameMap?: Map<string, string> | null
toolNameMap?: Map<string, string> | null,
toolSchemas?: Map<string, JsonRecord> | null
): JsonRecord;
export function translateNonStreamingResponse(
responseBody: unknown,
targetFormat: string,
sourceFormat: string,
toolNameMap?: Map<string, string> | null
toolNameMap?: Map<string, string> | null,
toolSchemas?: Map<string, JsonRecord> | null
): unknown;
export function translateNonStreamingResponse(
responseBody: unknown,
targetFormat: string,
sourceFormat: string,
toolNameMap?: Map<string, string> | null
toolNameMap?: Map<string, string> | null,
toolSchemas?: Map<string, JsonRecord> | null
): unknown {
// If already in source format, return as-is
if (targetFormat === sourceFormat) {
@@ -219,6 +224,11 @@ export function translateNonStreamingResponse(
toString(itemObj.id) ||
`call_${Date.now()}_${toolCalls.length}`;
let argsToEmit = itemObj.arguments;
const rawName = toString(itemObj.name);
const toolSchema = toolSchemas?.get(rawName);
if (toolSchema) {
argsToEmit = stripEmptyOptionalToolArgs(argsToEmit, rawName, toolSchema);
}
if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) {
const cleaned: JsonRecord = { ...(argsToEmit as JsonRecord) };
for (const [k, v] of Object.entries(cleaned)) {
@@ -229,7 +239,6 @@ export function translateNonStreamingResponse(
const fnArgs =
typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit || {});
const rawName = toString(itemObj.name);
// Strip Claude OAuth proxy_ prefix using toolNameMap
const resolvedName = caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName;
toolCalls.push({

View File

@@ -31,6 +31,7 @@ import * as xSearch from "./search/xSearch.ts";
import { freeWebSearch } from "../services/freeWebSearch.ts";
import { saveCallLog } from "@/lib/usageDb";
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
import { parseAndValidateNonMetadataUrl } from "@/shared/network/outboundUrlGuard";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { z } from "zod";
@@ -313,9 +314,23 @@ function getProviderSettingString(
return undefined;
}
function resolveSearchBaseUrl(config: SearchProviderConfig, params: SearchRequestParams): string {
export function resolveSearchBaseUrl(
config: SearchProviderConfig,
params: SearchRequestParams
): string {
const override = getProviderSettingString(params, "baseUrl");
return (override || config.baseUrl).replace(/\/+$/, "");
if (override) {
// GHSA-j7j4-g9qc-q69c: the override is client-controlled (provider_options /
// providerSpecificData) and flows into a plain fetch() sink — validate it
// before any builder uses it as the server-side fetch target. Mode is
// block-metadata (NOT public-only): the primary searxng use case is a
// self-hosted instance on loopback/LAN, so private hosts keep working,
// while cloud-metadata endpoints (IMDS credential theft) are rejected.
// The catalog's own config.baseUrl is operator config and stays untouched.
parseAndValidateNonMetadataUrl(override);
return override.replace(/\/+$/, "");
}
return config.baseUrl.replace(/\/+$/, "");
}
function toSearchPageNumber(offset: number | undefined, maxResults: number): number | undefined {

View File

@@ -28,6 +28,7 @@ import type {
ResolvedComboTarget,
} from "./types.ts";
import { extractSessionAffinityKey } from "@/sse/services/auth";
import { filterChatSelectableModels } from "../modelEndpointPolicy.ts";
import { DEFAULT_INTENT_CONFIG, type IntentClassifierConfig } from "../intentClassifier.ts";
import { getTaskFitness } from "../autoCombo/taskFitness.ts";
import {
@@ -470,10 +471,13 @@ export async function expandAutoComboCandidatePool(
// catalog only when the user has none. This keeps catalog-only models
// (e.g. openrouter/auto) out of pure-auto pools when the operator only
// synced a subset (e.g. OpenRouter with importFreeModelsOnly).
const [syncedModels, customModels] = await Promise.all([
// #11088 (option 1): the synced store now persists non-chat models too —
// chat combo pools must keep filtering them out at read time.
const [syncedModelsRaw, customModels] = await Promise.all([
getSyncedAvailableModels(providerId),
getCustomModels(providerId),
]);
const syncedModels = filterChatSelectableModels(providerId, syncedModelsRaw);
const hiddenModels = hiddenModelsMap.get(providerId);
const userVisibleIds = new Set<string>();
for (const m of syncedModels) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id);

View File

@@ -0,0 +1,40 @@
import { parentPort } from "node:worker_threads";
import {
applyCompression,
applyStackedCompression,
type StackedCompressionStep,
} from "./strategySelector.ts";
import type {
CompressionWorkerJob,
CompressionWorkerMessage,
} from "./compressionWorkerProtocol.ts";
if (!parentPort) throw new Error("compressionWorker must run in a worker thread");
parentPort.on("message", (job: CompressionWorkerJob) => {
try {
const onEngineStep = (step: StackedCompressionStep) =>
parentPort.postMessage({
id: job.id,
type: "step",
step,
} satisfies CompressionWorkerMessage);
const result =
job.mode === "stacked"
? applyStackedCompression(job.body, job.options?.config?.stackedPipeline, {
...job.options,
onEngineStep,
})
: applyCompression(job.body, job.mode, job.options);
parentPort.postMessage({
id: job.id,
type: "result",
result,
} satisfies CompressionWorkerMessage);
} catch (error) {
parentPort.postMessage({
id: job.id,
type: "error",
error: error instanceof Error ? error.message : String(error),
} satisfies CompressionWorkerMessage);
}
});

View File

@@ -0,0 +1,164 @@
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { Worker } from "node:worker_threads";
import type { CompressionResult } from "./types.ts";
import type { StackedCompressionStep } from "./strategySelector.ts";
import type {
CompressionWorkerJob,
CompressionWorkerMessage,
CompressionWorkerOptions,
} from "./compressionWorkerProtocol.ts";
function positiveInteger(value: string | undefined, fallback: number): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function workerUrl(): URL {
const dir = dirname(fileURLToPath(import.meta.url));
for (const name of ["compressionWorker.js", "compressionWorker.ts"]) {
if (existsSync(join(dir, name))) return new URL(name, import.meta.url);
}
return new URL("compressionWorker.js", import.meta.url);
}
function unchanged(body: Record<string, unknown>): CompressionResult {
return { body, compressed: false, stats: null };
}
interface PendingJob extends CompressionWorkerJob {
originalBody: Record<string, unknown>;
resolve: (result: CompressionResult) => void;
onEngineStep?: (step: StackedCompressionStep) => void;
}
interface PoolWorker {
worker: Worker;
job: PendingJob | null;
timeout: NodeJS.Timeout | null;
idle: NodeJS.Timeout | null;
}
export class CompressionWorkerPool {
private readonly queue: PendingJob[] = [];
private readonly workers = new Set<PoolWorker>();
private nextId = 1;
private readonly size: number;
private readonly timeoutMs: number;
private readonly idleMs: number;
constructor({
size = positiveInteger(process.env.OMNI_COMPRESSION_WORKERS, 2),
timeoutMs = positiveInteger(process.env.OMNI_COMPRESSION_WORKER_TIMEOUT_MS, 120_000),
idleMs = positiveInteger(process.env.OMNI_COMPRESSION_WORKER_IDLE_MS, 60_000),
}: { size?: number; timeoutMs?: number; idleMs?: number } = {}) {
this.size = Math.max(1, Math.floor(size));
this.timeoutMs = Math.max(1, Math.floor(timeoutMs));
this.idleMs = Math.max(1, Math.floor(idleMs));
}
run(
body: Record<string, unknown>,
mode: CompressionWorkerJob["mode"],
options?: CompressionWorkerOptions,
onEngineStep?: (step: StackedCompressionStep) => void
): Promise<CompressionResult> {
return new Promise((resolve) => {
this.queue.push({
id: this.nextId++,
body,
mode,
options,
originalBody: body,
resolve,
onEngineStep,
});
this.dispatch();
});
}
async close(): Promise<void> {
for (const job of this.queue.splice(0)) job.resolve(unchanged(job.originalBody));
await Promise.all([...this.workers].map((slot) => this.remove(slot, true)));
}
private spawn(): PoolWorker {
const slot: PoolWorker = {
worker: new Worker(workerUrl()),
job: null,
timeout: null,
idle: null,
};
this.workers.add(slot);
slot.worker.on("message", (message: CompressionWorkerMessage) =>
this.handleMessage(slot, message)
);
slot.worker.on("error", () => this.fail(slot));
slot.worker.on("exit", () => {
if (this.workers.has(slot)) this.fail(slot);
});
return slot;
}
private dispatch(): void {
while (this.queue.length) {
let slot = [...this.workers].find((candidate) => !candidate.job);
if (!slot && this.workers.size < this.size) slot = this.spawn();
if (!slot) return;
if (slot.idle) clearTimeout(slot.idle);
const job = this.queue.shift();
if (!job) return;
slot.job = job;
slot.timeout = setTimeout(() => this.fail(slot!), this.timeoutMs);
slot.timeout.unref();
const { originalBody: _body, resolve: _resolve, onEngineStep: _step, ...wireJob } = job;
slot.worker.postMessage(wireJob);
}
}
private handleMessage(slot: PoolWorker, message: CompressionWorkerMessage): void {
const job = slot.job;
if (!job || job.id !== message.id) return;
if (message.type === "step") {
try {
job.onEngineStep?.(message.step);
} catch {
// Telemetry is best-effort.
}
return;
}
this.finish(slot, message.type === "result" ? message.result : unchanged(job.originalBody));
}
private finish(slot: PoolWorker, result: CompressionResult): void {
const job = slot.job;
if (!job) return;
if (slot.timeout) clearTimeout(slot.timeout);
slot.timeout = null;
slot.job = null;
job.resolve(result);
slot.idle = setTimeout(() => void this.remove(slot, false), this.idleMs);
slot.idle.unref();
this.dispatch();
}
private fail(slot: PoolWorker): void {
const job = slot.job;
if (job) job.resolve(unchanged(job.originalBody));
slot.job = null;
void this.remove(slot, true).finally(() => this.dispatch());
}
private async remove(slot: PoolWorker, terminate: boolean): Promise<void> {
if (!this.workers.delete(slot)) return;
if (slot.timeout) clearTimeout(slot.timeout);
if (slot.idle) clearTimeout(slot.idle);
if (terminate) await slot.worker.terminate().catch(() => undefined);
}
}
let pool: CompressionWorkerPool | null = null;
export function runCompressionInWorker(
body: Record<string, unknown>,
mode: CompressionWorkerJob["mode"],
options?: CompressionWorkerOptions,
onEngineStep?: (step: StackedCompressionStep) => void
): Promise<CompressionResult> {
pool ??= new CompressionWorkerPool();
return pool.run(body, mode, options, onEngineStep);
}
export async function closeCompressionWorkerPoolForTests(): Promise<void> {
const active = pool;
pool = null;
await active?.close();
}

View File

@@ -0,0 +1,71 @@
import type { CompressionConfig, CompressionMode, CompressionResult } from "./types.ts";
import type { StackedCompressionStep } from "./strategySelector.ts";
import type {
CompressionStage,
CompressionWireFormat,
ImageTransportFidelity,
} from "./engines/types.ts";
export interface CompressionWorkerOptions {
model?: string;
supportsVision?: boolean | null;
providerTransport?: "direct" | "aggregator";
provider?: string;
imageTransportFidelity?: ImageTransportFidelity;
sourceFormat?: CompressionWireFormat;
targetFormat?: CompressionWireFormat;
compressionStage?: CompressionStage;
config?: CompressionConfig;
}
export interface CompressionWorkerJob {
id: number;
body: Record<string, unknown>;
mode: CompressionMode;
options?: CompressionWorkerOptions;
}
export type CompressionWorkerMessage =
| { id: number; type: "step"; step: StackedCompressionStep }
| { id: number; type: "result"; result: CompressionResult }
| { id: number; type: "error"; error: string };
function isPlainObject(value: object): value is Record<string, unknown> {
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
export function isStrictlySerializable(value: unknown, seen = new Set<object>()): boolean {
if (
value === null ||
typeof value === "string" ||
typeof value === "boolean" ||
typeof value === "number"
) {
return typeof value !== "number" || Number.isFinite(value);
}
if (typeof value !== "object" || seen.has(value)) return false;
seen.add(value);
if (Array.isArray(value)) return value.every((entry) => isStrictlySerializable(entry, seen));
if (!isPlainObject(value)) return false;
return Object.values(value).every((entry) => isStrictlySerializable(entry, seen));
}
const WORKER_STACK_ENGINES = new Set(["caveman", "rtk", "standard"]);
export function isCompressionWorkerEligible(
body: Record<string, unknown>,
mode: CompressionMode,
options?: CompressionWorkerOptions
): boolean {
if (mode !== "standard" && mode !== "rtk" && mode !== "stacked") return false;
if (mode === "stacked") {
const pipeline = options?.config?.stackedPipeline;
if (!Array.isArray(pipeline) || pipeline.length === 0) return false;
if (
pipeline.some((step) => {
const engine = typeof step === "string" ? step : step.engine;
return !WORKER_STACK_ENGINES.has(engine);
})
) {
return false;
}
}
return isStrictlySerializable({ body, mode, ...(options ? { options } : {}) });
}

View File

@@ -519,6 +519,28 @@ async function runCompressionAsync(
cachingContext?: CachingDetectionContext;
}
): Promise<CompressionResult> {
const workerOptions = options
? {
model: options.model,
supportsVision: options.supportsVision,
providerTransport: options.providerTransport,
provider: options.provider,
imageTransportFidelity: options.imageTransportFidelity,
sourceFormat: options.sourceFormat,
targetFormat: options.targetFormat,
compressionStage: options.compressionStage,
config: options.config,
}
: undefined;
const { isCompressionWorkerEligible } = await import("./compressionWorkerProtocol.ts");
if (isCompressionWorkerEligible(body, mode, workerOptions)) {
try {
const { runCompressionInWorker } = await import("./compressionWorkerPool.ts");
return await runCompressionInWorker(body, mode, workerOptions, options?.onEngineStep);
} catch {
return { body, compressed: false, stats: null };
}
}
if (
options?.config?.memoizeCompressionResults === true &&
// Only memoize for an explicit principal — a missing principalId would collapse

View File

@@ -78,7 +78,7 @@ const FALLBACK_MODEL_SEEDS: FallbackModelSeed[] = [
/** Presets exposed by the web client's model picker (id → text/multimodal model). */
export const CONOL_FALLBACK_MODEL_PRESETS: ConolModelPreset[] = [
{ id: "flash", text: "deepseek/deepseek-v4-flash", multimodal: "google/gemini-3.5-flash" },
{ id: "flash", text: "deepseek/deepseek-v4-flash", multimodal: "google/gemini-3.7-flash" },
{ id: "moderate", text: "deepseek/deepseek-v4-pro", multimodal: "claude-sonnet-5" },
{ id: "pro", text: "z-ai/glm-5.2", multimodal: "moonshotai/kimi-k3" },
{ id: "ultra", text: "claude-fable-5", multimodal: "claude-fable-5" },

View File

@@ -12,7 +12,21 @@
* `sanitizeReasoningEffortForProvider` in `executors/base/reasoningEffort.ts`)
* so the 4xx→retry round-trip is paid at most once per process per provider+model.
*
* `clampToLearned` implements downgrade-only clamping: greatest accepted <= demand.
* `clampToLearned` implements nearest-tier clamping: smallest accepted >= demand,
* falling back to the greatest accepted when demand exceeds every accepted value.
* (#11295 — unified with the static "declared" clamp in
* `executors/base/reasoningEffort.ts`, which already used nearest-tier semantics.
* Before #11295, this learned clamp was downgrade-only — greatest accepted <=
* demand — so the SAME accepted set {low,high,max} produced medium→low here but
* medium→high via the declared path: identical inputs, opposite outputs,
* depending only on whether the model had a static registry entry. #11274's
* DeepSeek native mapping is the precedent for nearest-tier. This also fixes a
* standalone bug: a request BELOW the learned floor (e.g. none/minimal on a
* model that only ever advertised {low,high,max}) used to return null — no
* clamp — so the too-low value passed straight through to the upstream, which
* 400'd again on every subsequent request without ever learning a lower floor.
* Nearest-tier naturally fixes this too: the smallest accepted value is always
* >= any demand below the floor, so it is returned instead of null.
*
* In-memory only (same operator-accepted tradeoff as the thinking-budget cache):
* restart resets, the first request after a restart may re-learn at the cost of
@@ -132,25 +146,39 @@ export function recordLearnedReasoningEffort(
}
/**
* Return the greatest accepted value <= effortStr (downgrade only), or null
* if effortStr is already accepted, below the minimum, or not in ORDER.
* Return the nearest-tier accepted value for effortStr: the smallest accepted
* value with rank >= effortStr's rank, or — when effortStr's rank exceeds every
* accepted value (demand above the learned ceiling) — the greatest accepted
* value. Returns null only when effortStr is already accepted (no clamp
* needed), empty, or not a recognized member of REASONING_EFFORT_ORDER.
*
* Mirrors the declared-capability clamp in `executors/base/reasoningEffort.ts`
* (#11295): both now use nearest-tier semantics so the same accepted set
* produces the same mapping regardless of whether the model has a static
* registry entry or was only learned reactively from an upstream 4xx.
*/
export function clampToLearned(effortStr: string, accepted: Set<string>): string | null {
if (!effortStr || accepted.has(effortStr)) return null;
const rank = rankOf(effortStr);
if (rank === -1) return null;
const minRank = Math.min(...[...accepted].map((v) => rankOf(v)));
if (rank < minRank) return null;
let best: string | null = null;
let bestRank = -1;
let nearestAbove: string | null = null;
let nearestAboveRank = Infinity;
let highest: string | null = null;
let highestRank = -1;
for (const v of accepted) {
const r = rankOf(v);
if (r <= rank && r > bestRank) {
bestRank = r;
best = v;
if (r < 0) continue;
if (r >= rank && r < nearestAboveRank) {
nearestAboveRank = r;
nearestAbove = v;
}
if (r > highestRank) {
highestRank = r;
highest = v;
}
}
return best;
return nearestAbove ?? highest;
}
// Matches prose shapes: OVH's "@ai-sdk/openai-compatible" deserializer

View File

@@ -6,7 +6,7 @@
*/
export interface PromptQlModel {
/** Client-facing id (model_reference slug, e.g. gemini-3.5-flash). */
/** Client-facing id (model_reference slug, e.g. gemini-3.7-flash). */
id: string;
/** Friendly picker label. */
name: string;

View File

@@ -28,12 +28,10 @@ export async function refreshCopilotToken(
);
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", {
status: response.status,
error: errorText,
});
return null;
return { status: response.status };
}
const data = await response.json();
@@ -49,8 +47,8 @@ export async function refreshCopilotToken(
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", {
error: error.message,
errorType: error?.name || "Error",
});
return null;
return { status: null };
}
}

View File

@@ -290,6 +290,31 @@ export function coerceToolSchemas(tools: unknown): unknown {
});
}
const NULL_OMISSION_NOTE = "null = omit this parameter";
function schemaTypeIncludes(type: unknown, wanted: string): boolean {
return type === wanted || (Array.isArray(type) && type.includes(wanted));
}
function isPlainStringType(type: unknown): boolean {
return type === "string" || (Array.isArray(type) && type.length === 1 && type[0] === "string");
}
function appendNullOmissionMarker(description: unknown): string {
if (typeof description === "string" && description.length > 0) {
return description.includes(NULL_OMISSION_NOTE)
? description
: `${description} (${NULL_OMISSION_NOTE})`;
}
return NULL_OMISSION_NOTE;
}
function widenTypeWithNull(type: unknown): unknown {
if (typeof type === "string") return [type, "null"];
if (Array.isArray(type) && !type.includes("null")) return [...type, "null"];
return type;
}
// #7023 — Responses API strict mode forces every "optional" tool property into
// `required`, so a model that intends to OMIT an optional enum property (no declared
// `default`) must still emit a concrete value (e.g. Agent.isolation:"remote"). Neither
@@ -299,7 +324,11 @@ export function coerceToolSchemas(tools: unknown): unknown {
// `null` (see pureHelpers.ts::isDroppableNullEntry). Scope: top-level
// `properties[key].enum` only — does not recurse into `items`/`anyOf`/`oneOf` branches
// (no real-world case beyond Agent.isolation is documented; extend with a concrete repro).
function shouldInjectNullOmission(key: string, propSchema: unknown, required: Set<string>): boolean {
function shouldInjectNullOmission(
key: string,
propSchema: unknown,
required: Set<string>
): boolean {
return (
isPlainObject(propSchema) &&
Array.isArray(propSchema.enum) &&
@@ -312,19 +341,38 @@ function widenPropertyForNullOmission(propSchema: JsonRecord): JsonRecord {
const widened: JsonRecord = { ...propSchema };
const enumValues = propSchema.enum as unknown[];
widened.enum = enumValues.includes(null) ? enumValues : [...enumValues, null];
if (typeof propSchema.type === "string") {
widened.type = [propSchema.type, "null"];
} else if (Array.isArray(propSchema.type) && !propSchema.type.includes("null")) {
widened.type = [...propSchema.type, "null"];
}
const note = "null = omit this parameter";
widened.description =
typeof propSchema.description === "string" && propSchema.description.length > 0
? `${propSchema.description} (${note})`
: note;
widened.type = widenTypeWithNull(propSchema.type);
widened.description = appendNullOmissionMarker(propSchema.description);
return widened;
}
// OpenCode `subagent.sessionID` (and any other optional default-less plain string) has
// the same strict-mode omission problem as #7023 enums, but no enum to widen. Inject
// the same nullable-union sentinel on top-level `properties[key]` only — do not recurse
// into `items`/`anyOf`/`$defs`, and do not touch enums (owned by the helper above).
function shouldInjectStringNullOmission(
key: string,
propSchema: unknown,
required: Set<string>
): boolean {
return (
isPlainObject(propSchema) &&
!Array.isArray(propSchema.enum) &&
isPlainStringType(propSchema.type) &&
!schemaTypeIncludes(propSchema.type, "null") &&
!required.has(key) &&
!hasOwn(propSchema, "default")
);
}
function widenStringPropertyForNullOmission(propSchema: JsonRecord): JsonRecord {
return {
...propSchema,
type: widenTypeWithNull(propSchema.type),
description: appendNullOmissionMarker(propSchema.description),
};
}
export function injectOptionalEnumOmissionSentinel(schema: unknown): unknown {
if (!isPlainObject(schema) || !isPlainObject(schema.properties)) return schema;
@@ -356,6 +404,43 @@ export function injectOptionalEnumOmissionForTools(tools: unknown): unknown {
});
}
export function injectOptionalStringOmissionSentinel(schema: unknown): unknown {
if (!isPlainObject(schema) || !isPlainObject(schema.properties)) return schema;
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
let changed = false;
const nextProperties: JsonRecord = { ...schema.properties };
for (const [key, propSchema] of Object.entries(schema.properties)) {
if (!shouldInjectStringNullOmission(key, propSchema, required)) continue;
nextProperties[key] = widenStringPropertyForNullOmission(propSchema as JsonRecord);
changed = true;
}
if (!changed) return schema;
return { ...schema, properties: nextProperties };
}
export function injectOptionalStringOmissionForTools(tools: unknown): unknown {
if (!Array.isArray(tools)) return tools;
return tools.map((tool) => {
if (!isPlainObject(tool)) return tool;
const result: JsonRecord = { ...tool };
if (isPlainObject(result.function) && "parameters" in result.function) {
result.function = {
...result.function,
parameters: injectOptionalStringOmissionSentinel(result.function.parameters),
};
}
if ("parameters" in result && !isPlainObject(result.function)) {
result.parameters = injectOptionalStringOmissionSentinel(result.parameters);
}
return result;
});
}
export function sanitizeToolDescriptions(tools: unknown): unknown {
if (!Array.isArray(tools)) return tools;
return tools.map((tool) => sanitizeToolDescription(tool));

View File

@@ -18,6 +18,7 @@ import {
coerceToolSchemas,
injectEmptyReasoningContentForToolCalls,
injectOptionalEnumOmissionForTools,
injectOptionalStringOmissionForTools,
sanitizeToolDescriptions,
} from "./helpers/schemaCoercion.ts";
import { getRequestTranslator, getResponseTranslator } from "./registry.ts";
@@ -595,6 +596,12 @@ export function translateRequest(
}
if (result.tools !== undefined) {
// Plain-string omission must run before coerceToolSchemas() strips `default`,
// so defaulted optional strings stay unsentinelled. Enum injection stays after
// coercion to preserve the #7023 pipeline.
if (targetFormat === FORMATS.OPENAI_RESPONSES) {
result.tools = injectOptionalStringOmissionForTools(result.tools);
}
result.tools = coerceToolSchemas(result.tools);
result.tools = sanitizeToolDescriptions(result.tools);
if (targetFormat === FORMATS.OPENAI_RESPONSES) {

View File

@@ -866,13 +866,13 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
function openaiResponsesToOpenAIResponseStream(chunk, state) {
if (!chunk) {
// Iterate every still-open call needing schema-aware normalization, not just a
// single one — multiple parallel calls can each be pending here if the stream
// ends before their output_item.done arrives.
// Iterate every still-open call with a buffered argument payload — argument
// deltas are buffered for every tool, so an incomplete stream must flush every
// buffered call, not only the historical uppercase Agent path.
const pendingNormalized: Array<{ index: number; argsStr: string }> = [];
if (state.toolCallByCallId instanceof Map) {
for (const entry of state.toolCallByCallId.values()) {
if (entry.needsNormalization && entry.argsBuffer) {
if (entry.argsBuffer) {
const toolSchema = state.toolSchemas?.get(entry.name);
const argsToEmit = stripEmptyOptionalToolArgs(entry.argsBuffer, entry.name, toolSchema);
pendingNormalized.push({

View File

@@ -56,21 +56,35 @@ function isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) {
return allowlisted || (propSchema != null && !required.has(key));
}
// #7023 — the request-side counterpart (injectOptionalEnumOmissionSentinel) widens
// no-default optional enum properties to accept `null`, meaning "omitted" (OpenAI's own
// nullable-union idiom for Responses-API strict mode). Drop the key when the model
// follows that idiom for a non-required, schema-declared property.
function schemaTypeIncludes(type, wanted) {
return type === wanted || (Array.isArray(type) && type.includes(wanted));
}
function hasOmissionSentinel(propSchema) {
if (!propSchema || typeof propSchema !== "object") return false;
if (
typeof propSchema.description !== "string" ||
!propSchema.description.includes("null = omit this parameter")
) {
return false;
}
return (
schemaTypeIncludes(propSchema.type, "null") ||
(Array.isArray(propSchema.enum) && propSchema.enum.includes(null))
);
}
// #7023 — the request-side counterpart widens no-default optional properties to accept
// `null`, meaning "omitted" (OpenAI's own nullable-union idiom for Responses-API strict
// mode). Enums use injectOptionalEnumOmissionSentinel; plain strings use
// injectOptionalStringOmissionSentinel. Drop the key when the model follows that idiom
// for a non-required, schema-declared property, or when OmniRoute's marker is present
// even after an upstream strictifies the field into `required`.
function isDroppableNullEntry(entry, propSchema, required, key, toolName) {
if (entry !== null) return false;
if (toolName === "Agent") return true;
if (propSchema == null) return false;
const omissionSentinel =
typeof propSchema === "object" &&
Array.isArray(propSchema.enum) &&
propSchema.enum.includes(null) &&
typeof propSchema.description === "string" &&
propSchema.description.includes("null = omit this parameter");
return !required.has(key) || omissionSentinel;
return !required.has(key) || hasOmissionSentinel(propSchema);
}
function stripEmptyOptionalToolArgsObject(value, toolName, schema) {
@@ -110,7 +124,11 @@ export function stripEmptyOptionalToolArgs(value, toolName, schema) {
// supplied (schema-aware normalization is not restricted to the allowlist).
// "Agent" also passes without a schema: isDroppableNullEntry drops its null
// omission sentinels even when the strict schema snapshot is unavailable (#9423).
if (!hasUsableSchema(schema) && !STRIPPABLE_EMPTY_ARG_TOOLS.has(toolName) && toolName !== "Agent") {
if (
!hasUsableSchema(schema) &&
!STRIPPABLE_EMPTY_ARG_TOOLS.has(toolName) &&
toolName !== "Agent"
) {
return value;
}
try {

View File

@@ -1,6 +1,10 @@
packages:
- "packages/*"
- "open-sse"
# Match `.npmrc`'s legacy-peer-deps posture. OmniRoute imports only the deep
# icon modules from @lobehub/icons; auto-installing its unused @lobehub/ui peer
# pulls a large UI subtree (including packages without distributable licenses).
autoInstallPeers: false
allowBuilds:
"@parcel/watcher": true
"@swc/core": true

View File

@@ -0,0 +1,36 @@
/**
* Decide whether the Next.js build should alias `better-sqlite3` to the
* build-time stub (src/lib/db/better-sqlite3.stub.js).
*
* History (#11343): the alias was UNCONDITIONAL, added to keep the bundler from
* tracing the native addon into a Next.js build worker, whose thread teardown
* can abort with SIGABRT (assertion in node::RemoveEnvironmentCleanupHook) and
* leave the build without standalone output (#10060).
*
* The premise recorded next to that alias — "runtime still uses the real
* package via serverExternalPackages" — does not hold. A Turbopack
* `resolveAlias` rewrites the request BEFORE the externals check runs, so
* `better-sqlite3` becomes a relative path, no longer matches the
* `serverExternalPackages` entry, and the stub is baked into the bundle. Every
* artifact built from that config answered HTTP 500 on every route: the stub's
* default export is not a constructor, the sync driver chain fell through to
* `node:sqlite` and then sql.js, and the instrumentation hook aborted at boot.
*
* This is the same failure shape as #6344 (the @/mitm/manager stub shipping to
* every npm/Electron/VPS artifact), so it gets the same treatment: the alias is
* opt-in, and a default build gets the real, externalized native package.
*
* Set OMNIROUTE_BETTER_SQLITE3_STUB=1 ONLY on a build host that actually hits
* the SIGABRT worker teardown, and never for an artifact that will be run —
* the resulting bundle cannot open a database.
*/
export function shouldStubBetterSqlite3(env = process.env) {
return env.OMNIROUTE_BETTER_SQLITE3_STUB === "1";
}
/** Turbopack resolveAlias fragment for `better-sqlite3`, derived from the env. */
export function betterSqlite3AliasFor(env = process.env) {
return shouldStubBetterSqlite3(env)
? { "better-sqlite3": "./src/lib/db/better-sqlite3.stub.js" }
: {};
}

View File

@@ -33,6 +33,14 @@ const STANDALONE = process.env.OMNIROUTE_STANDALONE_DIR
const CALL_LOG_WORKER_REL = join("src", "lib", "usage", "callLogArtifactWorker.js");
const CALL_LOG_WORKER_SRC = join(ROOT, "src", "lib", "usage", "callLogArtifactWorker.ts");
const COMPRESSION_WORKER_REL = join("open-sse", "services", "compression", "compressionWorker.js");
const COMPRESSION_WORKER_SRC = join(
ROOT,
"open-sse",
"services",
"compression",
"compressionWorker.ts"
);
const WORKER_REL = join(
"open-sse",
"services",
@@ -107,9 +115,26 @@ function main() {
);
console.log("[colocate-standalone] ✅ call-log artifact worker bundled");
const compressionWorkerDest = join(STANDALONE, COMPRESSION_WORKER_REL);
mkdirSync(dirname(compressionWorkerDest), { recursive: true });
runBuildTool(
"esbuild",
"esbuild",
[
COMPRESSION_WORKER_SRC,
"--bundle",
"--platform=node",
"--packages=external",
"--format=esm",
`--outfile=${compressionWorkerDest}`,
],
{ stdio: "inherit" }
);
console.log("[colocate-standalone] ✅ compression worker bundled");
// The call-log worker is always present; scope it to ESM immediately. The
// optional LLMLingua worker dir is added below only when its deps are installed.
const workerDirs = [dirname(callLogWorkerDest)];
const workerDirs = [dirname(callLogWorkerDest), dirname(compressionWorkerDest)];
if (!hasOptionals) {
console.log(

View File

@@ -45,6 +45,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [
// LLMLingua ONNX worker — esbuild'd standalone .js spawned via worker_threads
// (the Next.js bundler can't trace the computed Worker path). Kept like the MCP server.
"open-sse/services/compression/engines/llmlingua/onnxWorker.js",
"open-sse/services/compression/compressionWorker.js",
"src/lib/usage/callLogArtifactWorker.js",
"package.json",
"peer-stamp.mjs",
@@ -312,13 +313,27 @@ export const PACK_ARTIFACT_NEVER_ALLOWED_SEGMENTS: string[] = ["node_modules"];
export function findUnexpectedArtifactPaths(
filePaths: string[],
{ exactPaths = [], prefixPaths = [] }: { exactPaths?: string[]; prefixPaths?: string[] } = {}
{
exactPaths = [],
prefixPaths = [],
// #9985: the app-STAGING prune (prepublish Step 10.7) must be able to opt out
// of the node_modules segment ban — the standalone server's runtime deps live
// under dist/node_modules and Turbopack-hashed dirs (.build/next/node_modules/
// sql.js-*/dist/sql-wasm.wasm, transformers ort-wasm). Pruning them 500'd every
// DB-backed route in packaged boots while /api/monitoring/health stayed green.
// The PUBLISH gate (validate-pack-artifact) keeps the strict default.
neverAllowedSegments = PACK_ARTIFACT_NEVER_ALLOWED_SEGMENTS,
}: {
exactPaths?: string[];
prefixPaths?: string[];
neverAllowedSegments?: string[];
} = {}
): string[] {
const normalizedExact = new Set(exactPaths.map(normalizeArtifactPath));
const normalizedPrefixes = prefixPaths.map(normalizeArtifactPath);
const hasForbiddenSegment = (filePath: string): boolean =>
filePath.split("/").some((segment) => PACK_ARTIFACT_NEVER_ALLOWED_SEGMENTS.includes(segment));
filePath.split("/").some((segment) => neverAllowedSegments.includes(segment));
return filePaths
.map(normalizeArtifactPath)

View File

@@ -1,12 +1,13 @@
#!/usr/bin/env node
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { basename, dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { assembleStandalone } from "./assembleStandalone.mjs";
import { assertSqlitePrebuildExists } from "./electronRebuildPlan.mjs";
import { pruneElectronRuntimeDocs } from "./electronRuntimeDocs.mjs";
import { stageOptionalPacks } from "./optionalPackStaging.mjs";
import { runBuildTool } from "./buildToolRunner.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -169,6 +170,27 @@ assembleStandalone({
// app they would point at the build machine's absolute paths and break on install.
materializeSymlinks: true,
});
const compressionWorkerDest = join(
ELECTRON_STANDALONE_DIR,
"open-sse",
"services",
"compression",
"compressionWorker.js"
);
mkdirSync(dirname(compressionWorkerDest), { recursive: true });
runBuildTool(
"esbuild",
"esbuild",
[
join(ROOT, "open-sse", "services", "compression", "compressionWorker.ts"),
"--bundle",
"--platform=node",
"--packages=external",
"--format=esm",
`--outfile=${compressionWorkerDest}`,
],
{ stdio: "inherit" }
);
const docsPrune = pruneElectronRuntimeDocs(ELECTRON_STANDALONE_DIR);
if (docsPrune.removedFiles > 0) {

View File

@@ -407,6 +407,40 @@ if (existsSync(llmWorkerSrc)) {
}
}
// ── Step 8.6b: Bundle synchronous compression worker ──────────────────
const compressionWorkerSrc = join(
ROOT,
"open-sse",
"services",
"compression",
"compressionWorker.ts"
);
const compressionWorkerDest = join(
DIST_DIR,
"open-sse",
"services",
"compression",
"compressionWorker.js"
);
if (!existsSync(compressionWorkerSrc)) {
throw new Error("Required compression worker source is missing");
}
console.log(" 🔨 Bundling compression worker...");
mkdirSync(dirname(compressionWorkerDest), { recursive: true });
runBuildTool(
"esbuild",
"esbuild",
[
"open-sse/services/compression/compressionWorker.ts",
"--bundle",
"--platform=node",
"--packages=external",
"--format=esm",
"--outfile=dist/open-sse/services/compression/compressionWorker.js",
],
{ cwd: ROOT, stdio: "inherit" }
);
// ── Step 8.7: Bundle CLI Entrypoint ──────────────────────────
const cliSrcFile = join(ROOT, "bin", "omniroute.ts");
const cliDestFile = join(ROOT, "bin", "omniroute.mjs");
@@ -639,10 +673,15 @@ for (const relativePath of APP_STAGING_REMOVAL_PATHS) {
}
// ── Step 10.7: Prune any staged dist/ file outside the allowed runtime set ──
// #9985: neverAllowedSegments is EMPTY here on purpose — unlike the publish
// tarball gate, the staged dist/ legitimately contains node_modules (the
// standalone server's runtime deps, including Turbopack-hashed packages whose
// wasm files DB init requires). The allowlist prefixes above are the contract.
const stagedFiles = walkFiles(DIST_DIR);
const unexpectedStagedFiles = findUnexpectedArtifactPaths(stagedFiles, {
exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS,
prefixPaths: APP_STAGING_ALLOWED_PATH_PREFIXES,
neverAllowedSegments: [],
});
if (unexpectedStagedFiles.length > 0) {
@@ -657,6 +696,7 @@ if (unexpectedStagedFiles.length > 0) {
const remainingUnexpectedFiles = findUnexpectedArtifactPaths(walkFiles(DIST_DIR), {
exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS,
prefixPaths: APP_STAGING_ALLOWED_PATH_PREFIXES,
neverAllowedSegments: [],
});
if (remainingUnexpectedFiles.length > 0) {

View File

@@ -90,13 +90,30 @@ export function baselineValue(metric, root = ROOT) {
}
}
// A line that is unambiguously a PASS. Test reporters print the file name on BOTH the
// pass and the fail line, so a green line for a file whose NAME contains "fail"
// (fail-fast-*.test.ts, failover-*.test.ts) must never be offered as a failure cause.
const GREEN_LINE_RE = /^[✓✔√]/;
// Markers that are only meaningful at the START of a line: "FAIL" also occurs inside test
// FILE NAMES and inside summary prose ("Test Files 1 failed"), so matching it anywhere —
// and case-insensitively — reports a PASSING file as the cause of the red.
const LINE_START_FAILURE_RE = /^(?:[✖✗×]|FAIL\b|not ok\b|REGRESS)/;
// Markers that are unambiguous ANYWHERE in the line: tsc and Node emit them mid-line
// ("src/x.ts(10,5): error TS2322: ..."), so these stay unanchored. They are matched
// case-SENSITIVELY because that is how the emitting tools actually spell them.
const INLINE_FAILURE_RE = /\berror TS\d+\b|\bAssertionError\b|\bError:|\bREGRESS/;
/** Best-effort "first meaningful failure line" from captured command output. */
export function firstFailureLine(out) {
const lines = String(out || "")
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const hit = lines.find((l) => /||not ok|AssertionError|error TS|FAIL|Error:|REGRESS/i.test(l));
const hit = lines.find(
(l) => !GREEN_LINE_RE.test(l) && (LINE_START_FAILURE_RE.test(l) || INLINE_FAILURE_RE.test(l))
);
return (hit || lines[lines.length - 1] || "failed").slice(0, 200);
}
@@ -232,6 +249,36 @@ export function fullCiTimeoutFor(gateId) {
return FULL_CI_TIMEOUT_OVERRIDES_MS[gateId] ?? FULL_CI_DEFAULT_TIMEOUT_MS;
}
// ci.yml gate scripts whose result the CURATED pass already records under a DIFFERENT id.
// Without this map the --full-ci pass re-records them unconditionally as kind:"hard" while
// the curated pass recorded them as kind:"drift", and the SAME gate is printed in BOTH
// verdict buckets of one report (file-size / compression-budget appeared as a hard failure
// and as drift simultaneously in the #9985 verdict).
export const FULL_CI_CURATED_ALIASES = {
lint: "lint-errors",
"check:workflows": "workflow-lint",
"check:complexity-ratchets": "complexity",
};
/** Curated-pass id equivalent to a ci.yml gate script id ("check:file-size" -> "file-size"). */
export function curatedEquivalentId(scriptId) {
const id = String(scriptId || "");
if (Object.hasOwn(FULL_CI_CURATED_ALIASES, id)) return FULL_CI_CURATED_ALIASES[id];
return id.startsWith("check:") ? id.slice("check:".length) : id;
}
/**
* Bucket a --full-ci gate must be reported under: the classification the curated pass already
* gave the equivalent gate, else "hard" (the --full-ci default for gates the curated list does
* not cover). This only changes WHICH BUCKET a result is printed in — it never changes whether
* a gate runs, nor whether it passed.
*/
export function fullCiKindFor(scriptId, results) {
const equivalent = curatedEquivalentId(scriptId);
const curated = (results || []).find((r) => r.id === scriptId || r.id === equivalent);
return curated?.kind ?? "hard";
}
/**
* Parse a ci.yml text and return the ordered, de-duplicated list of gate commands to run.
* Each entry: { id, job, args:["run", <script>, ...("--" + args)], env }.
@@ -716,7 +763,10 @@ async function main() {
record({
id: g.id,
label: `ci.yml:${g.job} → npm ${g.args.join(" ")}`,
kind: "hard",
// Respect the curated classification when the curated pass already ran an equivalent
// gate under a different id — otherwise the same ratchet is reported as a HARD failure
// here AND as drift above, in one self-contradicting verdict.
kind: fullCiKindFor(g.id, results),
ok: code === 0,
detail: code === 0 ? "pass" : firstFailureLine(out),
});

View File

@@ -75,76 +75,3 @@ omniroute mcp call <tool> [argsJson]
```bash
omniroute mcp scopes
```
### `mcp tools`
**Example:**
```bash
omniroute mcp tools
```
### `mcp list`
**Flags:**
- `--scope <s>`
**Example:**
```bash
omniroute mcp list
```
### `mcp info <name>`
**Example:**
```bash
omniroute mcp info <name>
```
### `mcp schema <name>`
**Flags:**
- `--io <kind>`
**Example:**
```bash
omniroute mcp schema <name>
```
### `mcp audit`
**Example:**
```bash
omniroute mcp audit
```
### `mcp tail`
**Flags:**
- `--follow`
- `--limit <n>`
**Example:**
```bash
omniroute mcp tail
```
### `mcp stats`
**Flags:**
- `--period <p>`
**Example:**
```bash
omniroute mcp stats
```

View File

@@ -0,0 +1,112 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
const DISMISS_STORAGE_KEY = "omniroute-first-run-readiness-dismissed";
type FirstRunReadinessCardProps = {
setupComplete: boolean;
};
// #9985: dismissal lives in localStorage, read via useSyncExternalStore — keeps
// the component free of setState-in-effect cascades and hydration-safe (server
// snapshot treats the card as dismissed; the client corrects after hydration).
const readinessListeners = new Set<() => void>();
function subscribeReadiness(onStoreChange: () => void): () => void {
readinessListeners.add(onStoreChange);
window.addEventListener("storage", onStoreChange);
return () => {
readinessListeners.delete(onStoreChange);
window.removeEventListener("storage", onStoreChange);
};
}
function isReadinessDismissed(): boolean {
try {
return localStorage.getItem(DISMISS_STORAGE_KEY) === "true";
} catch {
// Storage unavailable (private mode etc.) — never show the nagging card.
return true;
}
}
function getServerSnapshot(): boolean {
return true;
}
/**
* Soft entry path for first-run users. Replaces the hard redirect to
* /dashboard/onboarding so returning users can dismiss and stay on Home.
*/
export default function FirstRunReadinessCard({ setupComplete }: FirstRunReadinessCardProps) {
const t = useTranslations("home");
const dismissed = useSyncExternalStore(subscribeReadiness, isReadinessDismissed, getServerSnapshot);
const dismiss = useCallback(() => {
try {
localStorage.setItem(DISMISS_STORAGE_KEY, "true");
} catch {
// ignore storage failures; still hide for this session
}
for (const listener of readinessListeners) listener();
}, []);
if (setupComplete || dismissed) return null;
const steps = [
t("readinessStep1"),
t("readinessStep2"),
t("readinessStep3"),
t("readinessStep4"),
];
return (
<div
role="region"
aria-label={t("readinessTitle")}
className="mb-4 rounded-xl border border-blue-200 dark:border-blue-500/30 bg-blue-50 dark:bg-blue-500/10 px-5 py-4"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-blue-700/80 dark:text-blue-300/80">
{t("readinessEyebrow")}
</p>
<h2 className="mt-1 text-lg font-semibold text-blue-950 dark:text-blue-100">
{t("readinessTitle")}
</h2>
<p className="mt-1 text-sm text-blue-900/80 dark:text-blue-200/80">
{t("readinessSubtitle")}
</p>
<ol className="mt-3 space-y-1.5 text-sm text-blue-900 dark:text-blue-100">
{steps.map((label, index) => (
<li key={label} className="flex items-center gap-2">
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-200/80 dark:bg-blue-400/20 text-xs font-semibold text-blue-800 dark:text-blue-200">
{index + 1}
</span>
<span>{label}</span>
</li>
))}
</ol>
<div className="mt-4 flex flex-wrap items-center gap-3">
<Link
href="/dashboard/onboarding"
className="inline-flex items-center rounded-lg bg-blue-600 px-3.5 py-2 text-sm font-medium text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400"
>
{t("readinessContinue")}
</Link>
<button
type="button"
onClick={dismiss}
className="text-sm font-medium text-blue-800/80 hover:text-blue-950 dark:text-blue-200/80 dark:hover:text-blue-100"
>
{t("readinessDismiss")}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -2,7 +2,6 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { Card, Button, ModelSelectModal } from "@/shared/components";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { copyToClipboard } from "@/shared/utils/clipboard";
import { buildOpenCodeConfigDocument } from "@/shared/services/opencodeConfig";
@@ -643,38 +642,32 @@ export default function DefaultToolCard({
};
const renderIcon = () => {
// Tool SVGs are non-square (e.g. opencode is 234×42, cursor is 467×532).
// next/image's dev check warns whenever the rendered aspect-ratio size
// differs from the square width/height attributes, so these render as a
// plain <img> capped at 32px on both axes — true ratio, no dev noise.
const renderImg = (src: string) => (
// eslint-disable-next-line @next/next/no-img-element -- local static SVG asset
<img
src={src}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
style={{ width: "auto", height: "auto", maxWidth: 32, maxHeight: 32 }}
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = "none";
}}
/>
);
if (tool.image) {
return (
<Image
src={tool.image}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = "none";
}}
/>
);
return renderImg(tool.image);
}
if (tool.imageLight || tool.imageDark) {
const themedSrc = isDark
? tool.imageDark || tool.imageLight
: tool.imageLight || tool.imageDark;
return (
<Image
src={themedSrc}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = "none";
}}
/>
);
return renderImg(themedSrc);
}
if (tool.icon) {
return (

View File

@@ -1,13 +1,10 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import Link from "next/link";
import { Card, Button, Input, Modal, CardSkeleton, SegmentedControl } from "@/shared/components";
import Toggle from "@/shared/components/Toggle";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { isPublicDisplayBaseUrl, useDisplayBaseUrl } from "@/shared/hooks";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
import { getProviderDisplayName } from "@/lib/display/names";
import { useTranslations } from "next-intl";
import A2ADashboardPage from "./components/A2ADashboard";
import McpDashboardPage from "./components/MCPDashboard";
@@ -138,7 +135,6 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
// Endpoints / models state
const [allModels, setAllModels] = useState([]);
const [modelsLoading, setModelsLoading] = useState(true);
const [expandedEndpoint, setExpandedEndpoint] = useState(null);
// Cloud sync state
const [cloudEnabled, setCloudEnabled] = useState(false);
@@ -151,8 +147,8 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
const [selectedProvider, setSelectedProvider] = useState(null); // for provider models popup
const [cloudBaseUrl, setCloudBaseUrl] = useState(BUILD_TIME_CLOUD_URL); // dynamic cloud URL from API response
const [cloudConfigured, setCloudConfigured] = useState(Boolean(BUILD_TIME_CLOUD_URL));
const [mcpStatus, setMcpStatus] = useState<any>(null);
const [a2aStatus, setA2aStatus] = useState<any>(null);
const [_mcpStatus, setMcpStatus] = useState<any>(null);
const [_a2aStatus, setA2aStatus] = useState<any>(null);
const [searchProviders, setSearchProviders] = useState<any[]>([]);
const [cloudflaredStatus, setCloudflaredStatus] = useState<CloudflaredTunnelStatus | null>(null);
const [cloudflaredBusy, setCloudflaredBusy] = useState(false);
@@ -1132,10 +1128,6 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
showNgrokTunnel && ngrokStatus?.running,
].filter(Boolean).length;
const mcpOnline = Boolean(mcpStatus?.online);
const a2aOnline = a2aStatus?.status === "ok";
const mcpToolCount = Number(mcpStatus?.heartbeat?.toolCount || 0);
const a2aActiveStreams = Number(a2aStatus?.tasks?.activeStreams || 0);
const cloudflaredPhase = cloudflaredStatus?.phase || "not_installed";
const cloudflaredPhaseMeta: Record<CloudflaredTunnelPhase, { label: string; className: string }> =
{
@@ -1169,10 +1161,6 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
: cloudflaredStatus?.installed
? translateOrFallback("cloudflaredEnable", "Enable Tunnel")
: translateOrFallback("cloudflaredInstallAndEnable", "Install & Enable");
const cloudflaredUrlNotice = translateOrFallback(
"cloudflaredUrlNotice",
"Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
);
const tailscalePhase = tailscaleStatus?.phase || "not_installed";
const tailscalePhaseMeta: Record<TailscaleTunnelPhase, { label: string; className: string }> = {
running: {
@@ -2498,137 +2486,3 @@ function EndpointCard({
);
}
function EndpointSection({
icon,
iconColor,
iconBg,
title,
path,
description,
models,
expanded,
onToggle,
copy,
copied,
baseUrl,
modelsLoading = false,
}: Readonly<{
icon: string;
iconColor: string;
iconBg: string;
title: string;
path: string;
description: string;
models: EndpointModelSummary[];
expanded: boolean;
onToggle: () => void;
copy: CopyHandler;
copied?: string | null;
baseUrl: string;
modelsLoading?: boolean;
}>) {
const t = useTranslations("endpoint");
const grouped = useMemo(() => {
const map = {};
for (const m of models) {
const owner = m.owned_by || "unknown";
if (!map[owner]) map[owner] = [];
map[owner].push(m);
}
return Object.entries(map).sort((a: any, b: any) => b[1].length - a[1].length);
}, [models]);
const resolveProvider = (id) => AI_PROVIDERS[id] || getProviderByAlias(id);
const providerColor = (id) => resolveProvider(id)?.color || "#888";
const providerName = (id) => getProviderDisplayName(id, resolveProvider(id));
const copyId = `endpoint_${path}`;
return (
<div className="border border-border rounded-lg overflow-hidden">
{/* Header (always visible) */}
<button
onClick={onToggle}
className="w-full flex items-center gap-3 p-4 hover:bg-surface/50 transition-colors text-left"
>
<div className={`flex items-center justify-center size-10 rounded-lg ${iconBg} shrink-0`}>
<span className={`material-symbols-outlined text-xl ${iconColor}`}>{icon}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">{title}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-surface text-text-muted font-medium">
{modelsLoading ? "..." : t("modelsCount", { count: models.length })}
</span>
</div>
<p className="text-xs text-text-muted mt-0.5">{description}</p>
</div>
<span
className={`material-symbols-outlined text-text-muted text-lg transition-transform ${expanded ? "rotate-180" : ""}`}
>
expand_more
</span>
</button>
{/* Expanded content */}
{expanded && (
<div className="border-t border-border px-4 pb-4">
{/* Endpoint path + copy */}
<div className="flex items-center gap-2 mt-3 mb-3">
<code className="flex-1 text-xs font-mono text-text-muted bg-surface/80 px-3 py-1.5 rounded-lg truncate">
{baseUrl.replace(/\/v1$/, "")}
{path}
</code>
<button
onClick={() => copy(`${baseUrl.replace(/\/v1$/, "")}${path}`, copyId)}
className="p-1.5 hover:bg-surface rounded-lg text-text-muted hover:text-primary transition-colors shrink-0"
>
<span className="material-symbols-outlined text-[16px]">
{copied === copyId ? "check" : "content_copy"}
</span>
</button>
</div>
{/* Models grouped by provider */}
{modelsLoading ? (
<div className="flex items-center gap-2 rounded-lg border border-border/70 bg-surface/40 px-3 py-2 text-xs text-text-muted">
<span className="material-symbols-outlined animate-spin text-sm">
progress_activity
</span>
<span>{t("loadingModels")}</span>
</div>
) : (
<div className="flex flex-col gap-2">
{grouped.map(([providerId, providerModels]) => (
<div key={providerId}>
<div className="flex items-center gap-2 mb-1">
<div
className="size-2.5 rounded-full shrink-0"
style={{ backgroundColor: providerColor(providerId) }}
/>
<span className="text-xs font-semibold text-text-main">
{providerName(providerId)}
</span>
<span className="text-xs text-text-muted">
({(providerModels as any).length})
</span>
</div>
<div className="ml-5 flex flex-wrap gap-1.5">
{(providerModels as any).map((m) => (
<span
key={m.id}
className="text-xs px-2 py-0.5 rounded-md bg-surface/80 text-text-muted font-mono"
title={m.id}
>
{m.root || m.id.split("/").pop()}
</span>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -7,6 +7,10 @@ type AdaptaTutorialModalProps = {
onClose: () => void;
};
// The Adapta CTA href points at https://link.omniroute.online/adapta (our own
// shortener, the `adapta` slug) so the click lands in our Kutt metrics. The visible
// link text intentionally stays the real domain (agent.adapta.one/agentic-chat) so
// users still see where they are going.
export function AdaptaTutorialModal({ isOpen, onClose }: AdaptaTutorialModalProps) {
const t = useTranslations("providers.adaptaTutorial");
@@ -29,7 +33,7 @@ export function AdaptaTutorialModal({ isOpen, onClose }: AdaptaTutorialModalProp
<p className="text-text-muted mt-0.5">
{t("step1DescPrefix")}{" "}
<a
href="https://agent.adapta.one/agentic-chat"
href="https://link.omniroute.online/adapta"
target="_blank"
rel="noopener noreferrer"
className="underline text-primary"

View File

@@ -294,7 +294,9 @@ export default function EditConnectionModal({
// external system on `isOpen`); remounting the 30+ field form per
// connection id is a behavior-risking restructure out of scope here
// (#11251 follow-up, #9985).
// eslint-disable-next-line react-hooks/set-state-in-effect
// NOTE: no react-hooks/set-state-in-effect suppression needed — the rule
// only fires on unconditional synchronous setState, and this one is
// guarded by the isOpen/connection condition above.
setFormData({
name: connection.name || "",
priority: connection.priority || 1,

View File

@@ -0,0 +1,95 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Badge, Button, Card } from "@/shared/components";
import type {
CliproxyAccountHealth,
CliproxyAccountHealthResult,
} from "@/lib/services/cliproxyAccountHealth";
const STATE_LABELS: Record<CliproxyAccountHealthResult["state"], string> = {
ready: "Account health",
disabled: "CLIProxyAPI is not installed",
missing_key: "Management key is not configured",
unreachable: "Management API is unreachable",
unauthorized: "Management key was rejected",
unsupported: "This CLIProxyAPI version does not expose account health",
invalid_response: "Management API returned an unsupported response",
};
function AccountRow({ account }: { account: CliproxyAccountHealth }) {
const state = account.disabled ? "Disabled" : account.unavailable ? "Unavailable" : account.status;
return (
<li className="flex flex-wrap items-center justify-between gap-3 border-t border-border py-3 first:border-t-0">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-text-main">
{account.label || account.authIndex}
</span>
<Badge variant={account.disabled || account.unavailable ? "warning" : "success"}>
{state || "Unknown"}
</Badge>
</div>
<p className="mt-1 text-xs text-text-muted">
{[account.provider || account.type, account.label ? account.authIndex : ""]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<div className="text-right text-xs text-text-muted">
<div>{account.success.toLocaleString()} succeeded</div>
<div>{account.failed.toLocaleString()} failed</div>
</div>
</li>
);
}
export function CliproxyAccountHealthCard() {
const [result, setResult] = useState<CliproxyAccountHealthResult | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const response = await fetch("/api/services/cliproxy/accounts", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setResult(await response.json());
} catch {
setResult({ state: "unreachable", accounts: [], version: null });
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<Card
title="CLIProxyAPI accounts"
subtitle="Read-only status from the authenticated management API"
action={
<Button variant="secondary" size="sm" onClick={() => void load()} loading={loading}>
Refresh
</Button>
}
>
{result?.state === "ready" ? (
result.accounts.length > 0 ? (
<ul aria-label="CLIProxyAPI account health">
{result.accounts.map((account) => (
<AccountRow key={account.authIndex} account={account} />
))}
</ul>
) : (
<p className="text-sm text-text-muted">No CLIProxyAPI accounts found.</p>
)
) : (
<p className="text-sm text-text-muted">
{loading && !result ? "Loading account health…" : STATE_LABELS[result?.state ?? "unreachable"]}
</p>
)}
</Card>
);
}

View File

@@ -8,6 +8,7 @@ import { AutoStartToggle } from "../components/AutoStartToggle";
import { AutoRestartAdoptedToggle } from "../components/AutoRestartAdoptedToggle";
import { CliproxyConnectionPanel } from "../components/CliproxyConnectionPanel";
import { CliproxyProviderExposureCard } from "../components/CliproxyProviderExposureCard";
import { CliproxyAccountHealthCard } from "../components/CliproxyAccountHealthCard";
const NAME = "cliproxy";
@@ -19,6 +20,7 @@ export function CliproxyServiceTab() {
<AutoStartToggle name={NAME} />
<AutoRestartAdoptedToggle name={NAME} />
<CliproxyConnectionPanel />
<CliproxyAccountHealthCard />
<CliproxyProviderExposureCard />
<CliproxyModelMappingEditor />
<ServiceLogsPanel name={NAME} />

View File

@@ -521,6 +521,7 @@ export default function SidebarTab() {
const presetLabels: Record<SidebarPresetId, string> = {
all: getSettingsLabel("presetAll", "All"),
essentials: getSettingsLabel("presetEssentials", "Essentials"),
minimal: getSettingsLabel("presetMinimal", "Minimal"),
developer: getSettingsLabel("presetDeveloper", "Developer"),
admin: getSettingsLabel("presetAdmin", "Admin"),
@@ -528,6 +529,10 @@ export default function SidebarTab() {
const presetDescriptions: Record<SidebarPresetId, string> = {
all: getSettingsLabel("presetAllDesc", "Show everything"),
essentials: getSettingsLabel(
"presetEssentialsDesc",
"Beginner path — Advanced tools stay searchable"
),
minimal: getSettingsLabel("presetMinimalDesc", "Core pages only"),
developer: getSettingsLabel("presetDeveloperDesc", "Dev & proxy tools"),
admin: getSettingsLabel("presetAdminDesc", "Monitoring & audit"),

View File

@@ -15,7 +15,15 @@ import { HistoricSessionBanner } from "./components/session/HistoricSessionBanne
const BUFFER_MAX = 1000;
export function TrafficInspectorPageClient() {
export function TrafficInspectorPageClient({
title,
subtitle,
purpose,
}: {
title?: string;
subtitle?: string;
purpose?: string;
} = {}) {
const [containerHeight, setContainerHeight] = useState(600);
const listContainerRef = useRef<HTMLDivElement | null>(null);
const [selectedRequest, setSelectedRequest] = useState<InterceptedRequest | null>(null);
@@ -91,6 +99,18 @@ export function TrafficInspectorPageClient() {
return (
<div className="flex flex-col h-full overflow-hidden">
{title && (
<div className="shrink-0 px-4 pt-4 pb-2">
<h1 className="text-2xl font-bold text-text-main">{title}</h1>
{subtitle && (
<p className="text-sm text-text-muted mt-1 max-w-2xl">{subtitle}</p>
)}
{purpose && (
<p className="text-xs text-text-muted mt-2 max-w-2xl italic">{purpose}</p>
)}
</div>
)}
{/* Capture modes toolbar */}
<div className="shrink-0 px-4 pt-4 pb-2">
<CaptureModesToolbar customHostCount={0} />

View File

@@ -9,6 +9,7 @@ export async function generateMetadata() {
};
}
export default function TrafficInspectorPage() {
return <TrafficInspectorPageClient />;
export default async function TrafficInspectorPage() {
const t = await getTranslations("sidebar");
return <TrafficInspectorPageClient title={t("trafficInspector")} subtitle={t("trafficInspectorSubtitle")} purpose={t("trafficInspectorPurpose")} />;
}

View File

@@ -1,4 +1,3 @@
import { redirect } from "next/navigation";
import { getMachineId } from "@/shared/utils/machine";
import { getSettings } from "@/lib/localDb";
import HomePageClient from "../dashboard/HomePageClient";
@@ -7,19 +6,18 @@ import KimiSponsorBanner from "../dashboard/KimiSponsorBanner";
import CheaperInferenceSponsorBanner from "../dashboard/CheaperInferenceSponsorBanner";
import VscodeCopilotBanner from "../dashboard/VscodeCopilotBanner";
import NewsBanner from "../dashboard/NewsBanner";
import FirstRunReadinessCard from "../dashboard/FirstRunReadinessCard";
export const dynamic = "force-dynamic";
export default async function HomePage() {
const settings = await getSettings();
if (!settings.setupComplete) {
redirect("/dashboard/onboarding");
}
const machineId = await getMachineId();
const isBootstrapped = process.env.OMNIROUTE_BOOTSTRAPPED === "true";
return (
<>
{isBootstrapped && <BootstrapBanner />}
<FirstRunReadinessCard setupComplete={Boolean(settings.setupComplete)} />
<KimiSponsorBanner />
<CheaperInferenceSponsorBanner />
<VscodeCopilotBanner />

View File

@@ -10,15 +10,13 @@
* Auth: Bearer token via Authorization header
*/
import { timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
import { logRoutingDecision } from "@/lib/a2a/routingLogger";
import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming";
import { A2A_SKILL_HANDLERS, executeA2ATaskWithState } from "@/lib/a2a/taskExecution";
import { getSettings } from "@/lib/db/settings";
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { authenticateA2ARequest, resolveA2AOwner } from "@/lib/a2a/authenticate";
// ============ A2A v1.0 ↔ v0.3 compatibility layer ============
// A2A 1.0 renamed the JSON-RPC methods (message/send → SendMessage,
@@ -55,7 +53,7 @@ function buildV1Task(
? result.artifacts
.map((a) =>
a && typeof a === "object" && typeof (a as { content?: unknown }).content === "string"
? ((a as { content: string }).content)
? (a as { content: string }).content
: ""
)
.filter((s) => s.length > 0)
@@ -124,39 +122,13 @@ function toMessageArray(raw: unknown): A2AMessage[] | null {
// ============ Auth ============
/**
* Constant-time comparison of the presented bearer token against the configured
* key. A plain `===` short-circuits on the first differing byte, leaking the
* length of the shared prefix through response timing; `timingSafeEqual` does
* not. It requires equal-length buffers, so mismatched lengths are rejected up
* front (the length itself is not secret).
*/
function tokensMatch(provided: string, expected: string): boolean {
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
async function authenticate(req: NextRequest): Promise<boolean> {
// /a2a is outside the authz proxy matcher, so the REQUIRE_API_KEY posture the
// pipeline enforces for /v1 never ran here — the route accepted every caller
// whenever OMNIROUTE_API_KEY was unset, which is the shipped default
// (GHSA-v54m-6rm3-p565). Apply the same posture directly: when a client key is
// required, demand a valid OmniRoute key; otherwise honor the legacy explicit
// A2A key; otherwise stay keyless (the same local-first default as /v1).
const apiKey = extractApiKey(req);
if (isRequireApiKeyEnabled()) {
return apiKey ? await isValidApiKey(apiKey) : false;
}
const configuredKey = process.env.OMNIROUTE_API_KEY;
if (configuredKey) {
return apiKey ? tokensMatch(apiKey, configuredKey) : false;
}
// No API key required and none configured — allow (keyless local-first).
return true;
// (GHSA-v54m-6rm3-p565). The shared helper applies the same posture on both
// the JSON-RPC and the REST task surfaces (GHSA-jcm5-6wpp-wjj8).
return authenticateA2ARequest(req);
}
// ============ JSON-RPC Helpers ============
@@ -213,6 +185,9 @@ export async function POST(req: NextRequest) {
if (disabledResponse) return disabledResponse;
const tm = getTaskManager();
// GHSA-jcm5-6wpp-wjj8: scope every task read/mutation below to the caller's
// owner id (hashed API key; undefined under the keyless local-first posture).
const callerOwner = resolveA2AOwner(req);
// A2A 1.0 method-name compatibility (SendMessage → message/send, etc.)
const isV1Method = method in V1_METHOD_ALIASES;
@@ -236,7 +211,7 @@ export async function POST(req: NextRequest) {
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
}
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
const task = tm.createTask({ skill, messages, metadata: params?.metadata }, callerOwner);
try {
tm.updateTask(task.id, "working");
const result = await handler(task);
@@ -302,7 +277,7 @@ export async function POST(req: NextRequest) {
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
}
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
const task = tm.createTask({ skill, messages, metadata: params?.metadata }, callerOwner);
tm.updateTask(task.id, "working");
const stream = createA2AStream(
@@ -323,7 +298,7 @@ export async function POST(req: NextRequest) {
const taskId = params?.taskId || params?.id;
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
const task = tm.getTask(taskId);
const task = tm.getTask(taskId, callerOwner);
if (!task) return jsonRpcError(id, -32601, `Task not found: ${taskId}`);
return jsonRpcResult(id, { task });
@@ -335,7 +310,7 @@ export async function POST(req: NextRequest) {
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
try {
const task = tm.cancelTask(taskId);
const task = tm.cancelTask(taskId, callerOwner);
return jsonRpcResult(id, { task: { id: task.id, state: task.state } });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);

51
src/app/api/a2a/_auth.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* Shared authorization for the REST A2A task routes (GHSA-jcm5-6wpp-wjj8).
*
* Dual audience: the dashboard calls these routes with a management session,
* A2A clients with an inference API key. Posture matrix:
*
* - REQUIRE_API_KEY=true: a valid OmniRoute key is mandatory (the same
* posture the /v1 inference plane enforces); a management session also
* passes (dashboard), via alwaysRequireAuth so requireLogin=false cannot
* bypass it.
* - otherwise + requireLogin=true: management session, or a valid key.
* - otherwise + requireLogin=false (local-first default): open, by design.
*
* Callers authenticated by key are owner-scoped — another principal's tasks
* answer as if they did not exist. Management/operator view sees all tasks.
*/
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
import { resolveA2AOwner } from "@/lib/a2a/authenticate";
export interface A2ARestAuth {
/** Owner scope for task reads/mutations; undefined = operator view (all tasks). */
owner: string | undefined;
}
/**
* NOTE: the failure branch is whatever requireManagementAuth returns — today a
* plain `Response` from createErrorResponse(), NOT a NextResponse. Callers must
* test with `instanceof Response` (NextResponse extends Response), never
* `instanceof NextResponse`, or the 401 silently falls through to the handler.
*/
export async function authorizeA2ATaskRoute(request: Request): Promise<A2ARestAuth | Response> {
const apiKey = extractApiKey(request);
if (isRequireApiKeyEnabled()) {
if (apiKey && (await isValidApiKey(apiKey))) return { owner: resolveA2AOwner(request) };
const managementError = await requireManagementAuth(request, {
invalidApiKeyStatus: 401,
alwaysRequireAuth: true,
});
if (managementError === null) return { owner: undefined };
return managementError;
}
const managementError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 });
if (managementError === null) return { owner: undefined };
if (apiKey && (await isValidApiKey(apiKey))) return { owner: resolveA2AOwner(request) };
return managementError;
}

View File

@@ -1,14 +1,23 @@
import { NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
import { authorizeA2ATaskRoute } from "@/app/api/a2a/_auth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
// GHSA-jcm5-6wpp-wjj8: this route had no auth call at all. The owner check
// happens inside cancelTask: another principal's task throws the same
// "not found" a missing one would (no existence oracle).
const auth = await authorizeA2ATaskRoute(request);
if (auth instanceof Response) return auth;
try {
const { id } = await params;
const tm = getTaskManager();
const task = tm.cancelTask(id);
const task = tm.cancelTask(id, auth.owner);
return NextResponse.json({ task: { id: task.id, state: task.state } });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to cancel A2A task";
const message = sanitizeErrorMessage(
error instanceof Error ? error.message : "Failed to cancel A2A task"
);
const status = message.includes("not found") ? 404 : 400;
return NextResponse.json({ error: message }, { status });
}

View File

@@ -1,17 +1,30 @@
import { NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
import { authorizeA2ATaskRoute } from "@/app/api/a2a/_auth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
// GHSA-jcm5-6wpp-wjj8: this route had no auth call at all — open regardless
// of configuration. Another principal's task answers 404, same as a missing
// one, so an IDOR probe cannot tell the two apart.
const auth = await authorizeA2ATaskRoute(request);
if (auth instanceof Response) return auth;
try {
const { id } = await params;
const tm = getTaskManager();
const task = tm.getTask(id);
const task = tm.getTask(id, auth.owner);
if (!task) {
return NextResponse.json({ error: `Task not found: ${id}` }, { status: 404 });
}
return NextResponse.json({ task });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load A2A task";
return NextResponse.json({ error: message }, { status: 500 });
return NextResponse.json(
{
error: sanitizeErrorMessage(
error instanceof Error ? error.message : "Failed to load A2A task"
),
},
{ status: 500 }
);
}
}

View File

@@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { getTaskManager, type TaskState } from "@/lib/a2a/taskManager";
import { authorizeA2ATaskRoute } from "@/app/api/a2a/_auth";
import { createConductorTask } from "@/lib/conductor/hubProxy";
import { getSettings } from "@/lib/db/settings";
@@ -22,6 +23,11 @@ function parseIntParam(value: string | null, fallback: number): number {
}
export async function GET(request: Request) {
// GHSA-jcm5-6wpp-wjj8: the list route had no auth call at all. Management
// (or the keyless posture) sees every task; a bare API key must be valid
// and is owner-scoped.
const auth = await authorizeA2ATaskRoute(request);
if (auth instanceof Response) return auth;
try {
const { searchParams } = new URL(request.url);
const stateParam = searchParams.get("state");
@@ -36,7 +42,7 @@ export async function GET(request: Request) {
const tm = getTaskManager();
const total = tm.countTasks({ state, skill });
const tasks = tm.listTasks({ state, skill, limit, offset });
const tasks = tm.listTasks({ state, skill, limit, offset }, auth.owner);
return NextResponse.json({
tasks,
@@ -104,7 +110,10 @@ export function authenticateA2A(request: Request): boolean {
*/
export async function POST(request: Request) {
if (!authenticateA2A(request)) {
return NextResponse.json({ error: "Unauthorized: missing or invalid API key" }, { status: 401 });
return NextResponse.json(
{ error: "Unauthorized: missing or invalid API key" },
{ status: 401 }
);
}
const settings = await getSettings();
if (settings.a2aEnabled !== true) {
@@ -122,12 +131,18 @@ export async function POST(request: Request) {
}
const parsed = delegationSchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid A2A task: provide messages[] (and metadata.conductor)" }, { status: 400 });
return NextResponse.json(
{ error: "Invalid A2A task: provide messages[] (and metadata.conductor)" },
{ status: 400 }
);
}
const { skill, messages, metadata } = parsed.data;
if (skill !== "conductor" && !skill.startsWith("conductor-cli-")) {
return NextResponse.json(
{ error: "Only Conductor fleet skills are delegable here (conductor / conductor-cli-<profile>)" },
{
error:
"Only Conductor fleet skills are delegable here (conductor / conductor-cli-<profile>)",
},
{ status: 400 }
);
}
@@ -138,7 +153,9 @@ export async function POST(request: Request) {
{ status: 400 }
);
}
const prompt = [...messages].reverse().find((m) => m.role === "user")?.content ?? messages[messages.length - 1].content;
const prompt =
[...messages].reverse().find((m) => m.role === "user")?.content ??
messages[messages.length - 1].content;
const created = await createConductorTask({
repoUrl: conductor.repo.url,

View File

@@ -439,13 +439,22 @@ type ProviderConnectionLike = {
* whose stored `providerSpecificData.profileArn` matches the given ARN.
* Returns null when profileArn is undefined/null or no match is found.
*
* #10815 hardened `findKiroConnectionByIdentity` to require an account-level
* identifier (email or clientId) alongside a matching profileArn before
* trusting the match — distinct Builder ID accounts (Google/GitHub social
* login) can share the same CodeWhisperer profile ARN, and matching on ARN
* alone let a second social login silently overwrite the first connection.
* `email`/`clientId` here let a caller supply that account identifier; the
* real `saveAndRespond()` call sites already do (see below).
*
* Exported for unit tests (#3615).
*/
export function findKiroConnectionByProfileArn(
connections: ProviderConnectionLike[],
profileArn: string | undefined
profileArn: string | undefined,
accountIdentity?: { email?: string | null; clientId?: string | null }
): ProviderConnectionLike | null {
return findKiroConnectionByIdentity(connections, { profileArn });
return findKiroConnectionByIdentity(connections, { profileArn, ...accountIdentity });
}
// ── Save to OmniRoute DB ──────────────────────────────────────────────────────

Some files were not shown because too many files have changed in this diff Show More