Compare commits

..

51 Commits

Author SHA1 Message Date
Xiangzhe
6cf1381938 fix(bridge): allow explicit 0 to disable the describe cap
updateSettingsSchema previously rejected modalityBridgeVisionMaxChars: 0
because the field's range was min(100).max(50000), so a dashboard PATCH
sending the explicit "unlimited" sentinel would 400. Widen the schema to
z.union([z.literal(0), z.number().int().min(100).max(50000)]) so 0
validates as its own valid value, not just an implicit default.
2026-08-13 17:42:43 -03:00
Xiangzhe
4fd2cd4de5 feat(dashboard): maxChars field on Modality Bridge vision tab
Add the "Max description characters" field to the Vision tab's Advanced
panel (modalityBridgeVisionMaxChars, clamped to the 100-50000 schema
range with 0 treated as the explicit "unlimited" sentinel), wire the
en.json copy and sync it across all 42 locales, and document the new
setting in GUARDRAILS.md.
2026-08-13 17:42:35 -03:00
Xiangzhe
3b2ad2de0c feat(bridge): configurable describe output cap (modalityBridgeVisionMaxChars) 2026-08-13 17:03:32 -03:00
Xiangzhe
2973cc558e test(bridge): explicit native-vision skip guard + skip log 2026-08-13 16:58:34 -03:00
DarkAngel
266e39d36d feat(i18n): complete Portuguese (PT-PT) translation (#10250)
- 12,141 strings translated to European Portuguese
- Built on top of the existing pt.json with full coverage of the v3.8.50 catalog
- Remaining ~440 strings are technical terms/brand names kept in English

Co-authored-by: DarkEsteves <DarkEsteves@users.noreply.github.com>
2026-08-13 12:40:36 -03:00
Jeyhun F. Aslanov
05b1311884 fix(sse): extract perplexity-web answers from workflow_block (#10259)
Perplexity moved the answer text out of `markdown_block` into
`workflow_block` (`intended_usage: "workflow_root"`), streaming it as
RFC-6902 patches whose `field` is `"workflow_block"` and whose paths
address `/steps/<n>/items/<m>/payload/text_payload/chunks/<k>`.

`extractContent` recognised neither shape. Two independent guards dropped
every answer frame:

  - `isAnswerTextUsage("workflow_root")` is false, so the block loop
    `continue`d before any accumulation.
  - the diff guard skipped every patch whose `field !== "markdown_block"`.

The stream therefore ran to `COMPLETED` with an empty accumulator and the
executor surfaced `Provider returned empty content` (502) even though the
upstream SSE carried the full answer. Every model was affected — the
carrying block is model-independent — so the provider was unusable.

Adds `workflow_block` to `PplxBlock`, an `applyWorkflowDiff` patch
applier for the streaming path, and `applyWorkflowBlock` for a
materialized block on the terminal frame. Answer tracks are keyed per
step+item so concurrent items cannot overwrite each other's chunk
indices, and only `variant: "answer"` payloads are accumulated — search
queries, sources and "thinking" items stay out of the message.

Fixtures in the regression test are trimmed from a live capture
(pplx-auto, mode=copilot); replaying the full 96 KB capture through the
patched extractor yields the complete 247-char answer over 7 incremental
deltas, against an empty string before the fix.

Co-authored-by: Jeyhun F. Aslanov <jeyhun.f.aslanov@Jeyhuns-MacBook-Pro.local>
2026-08-13 12:38:11 -03:00
SAMUEL AUGUSTO GUIMARAES LOPES
6143da70d1 fix(mcp): persist and re-attach Gemini thoughtSignature on the direct Claude<->Gemini path (#9448)
The direct Claude<->Gemini translator (claude-to-gemini.ts / gemini-to-claude.ts)
never persisted the thoughtSignature Gemini returns on functionCall parts, and
never re-attached one on the next turn. Gemini 3+/2.5 strictly reject a native
functionCall part with no signature (400), which surfaces whenever a combo falls
back onto a Gemini model mid-conversation (the fallback tool_use never went
through Gemini, so no signature exists for it).

- gemini-to-claude.ts: store the signature (keyed by tool_use id + connection
  namespace) when Gemini's response carries one, mirroring the existing
  gemini-to-openai.ts hub-path behavior.
- claude-to-gemini.ts: resolve a stored signature for historical tool_use
  blocks; when none exists and the target model requires one, downgrade the
  tool_use/tool_result pair to inert text instead of sending a signature-less
  native part, matching the "context" fallback already used by the OpenAI hub
  path (#3358) rather than the removed fake-signature injection.

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-13 07:55:00 -03:00
小妍儿 ✨
9a4cca4bc2 fix(sse): honor comment opt-out for final metadata (#9305) (#9378)
* test(sse): add RED coverage for comment opt-out

* fix(sse): honor comment opt-out for final metadata

---------

Co-authored-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-13 07:54:53 -03:00
小妍儿 ✨
fa923d974e fix(opencode-plugin): respect log level for lifecycle output (#8982) (#9316)
* test(opencode-plugin): cover configured log levels

* fix(opencode-plugin): respect lifecycle log level

* fix(opencode-plugin): isolate lifecycle loggers

---------

Co-authored-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-13 07:54:47 -03:00
SAMUEL AUGUSTO GUIMARAES LOPES
081f482680 fix(providers): raise default provider probe timeout from 5s to 8s (#9283)
* fix(providers): raise default provider probe timeout from 5s to 8s

The validationRead and modelsProbe presets in safeOutboundFetch.ts used a
fixed 5000ms timeout for the periodic credential health check and on-demand
connection test. Several real free-tier providers (Cerebras, Cloudflare AI
observed in practice) routinely take close to 5s to answer a lightweight
/models probe, which is indistinguishable from a real outage under that
budget — the connection flaps between "active" and "error" in the
dashboard/topology view purely from being near the edge of the timeout, not
from any actual failure.

Raised the default to 8000ms and made it configurable via
OMNIROUTE_PROVIDER_PROBE_TIMEOUT_MS (validated: falls back to 8000ms for
non-numeric or sub-1000ms values) so it can be tuned per-deployment without a
code change. validationWrite and modelsPagination presets are untouched.

Added tests/unit/safe-outbound-fetch-probe-timeout.test.ts covering the
default, env override, invalid-value fallback, and that the other two
presets are unaffected.

* docs(.env.example): document OMNIROUTE_PROVIDER_PROBE_TIMEOUT_MS

* Merge branch 'release/v3.8.50' into fix/provider-probe-timeout

Resolved merge conflict in .env.example: kept both Provider probe section (PR)
and Proxy/relay fetch section (release branch).

Added docs/reference/ENVIRONMENT.md entry for OMNIROUTE_PROVIDER_PROBE_TIMEOUT_MS.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-13 07:54:40 -03:00
小妍儿 ✨
62fd7f7c20 fix(opencode-plugin): stop warning when an auto combo replaces its expected /v1/models twin (#8983) (#9042)
* test(opencode): cover expected auto-combo twin

* fix(opencode): suppress expected auto-combo twin warning

---------

Co-authored-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-13 07:54:33 -03:00
小妍儿 ✨
4ff9f0df0a fix(dashboard): persist model param-filter edits on popover close (#8910) (#9013)
* test: reproduce model param filter close persistence

* fix(dashboard): persist model param filters on popover close (#8910)

ModelCompatPopover declared providerId/modelId in its props type but never
destructured them, so both param-filter fetches referenced undefined
identifiers (TS2304, frozen in the dashboard-typecheck baseline) and threw
into a silent catch. CustomModelsSection also never passed the two props.

- Destructure providerId/modelId; pass them from CustomModelsSection.
- Save pending block/allow drafts when the popover closes or unmounts, so an
  outside mousedown no longer discards them.
- Read drafts from refs at save time and guard concurrent saves, avoiding
  stale-closure payloads and duplicate PUTs.
- Keep dirty state and drafts on non-OK/failed GET or PUT instead of silently
  clearing them; skip state updates after unmount.
- Provider-level block/allow, autoLearn, and other model entries are preserved;
  an empty block+allow still removes only the selected model entry.
- Ratchet the three now-clean dashboard-typecheck baseline entries.

Compat-toggle and upstream-header paths are unchanged.

* fix(dashboard): avoid lost update and surface failed param-filter saves (#8910)

The close-time save could clear the dirty flag for a payload snapshotted
before the PUT resolved, silently discarding any keystroke that landed in
that window. Track a monotonic draft revision and only acknowledge the
revision that was actually written, re-running the save (bounded) otherwise.

A failed save previously stayed dirty to 'retry on a later close', but
reopening the popover reloaded server state and silently reverted the
draft. Keep a dirty draft for the same provider/model on reopen and show a
failure marker next to the saving indicator instead.

* fix(dashboard): protect dirty param-filter drafts from load-effect clobber (#8910)

The retained-draft guard in the param-filters load effect required
paramLoadedKeyRef to match the current target, but that ref was only
assigned after a successful GET. Any draft typed before a successful load
for that target was therefore unguarded, and the clean-slate write
overwrote both the text and the dirty flag:

- a draft typed while the INITIAL load GET was still in flight was
  overwritten and its dirty flag cleared, so the close-path save became a
  no-op and the keystrokes vanished with no feedback;
- after a FAILED initial load, the retained draft was destroyed by the next
  successful reopen load — the exact moment the user reopens to retry —
  and the failure indicator was cleared as if the save had succeeded.

Track the target on the dirty flag itself (paramDirtyKeyRef, set when the
draft is marked dirty) instead of deriving it from a completed load, and
re-check the guard after the GET await so a load result never overwrites
text, clears dirty, or clears the failure indicator for a draft that is
not on the server.

* fix(dashboard): bind the param-filter save to the draft's own target (#8910)

saveModelParamFilters guarded on paramDirtyRef alone and read the
providerId/modelId it closed over, never the target the draft was typed
for. ModelCompatPopover is not always keyed by a stable identity
(CompatibleModelsSection keys by `${alias}:${modelId}`,
PassthroughModelsSection by the full model string, and providerId is
threaded from route/page state), so a re-render can re-point a live,
mounted popover at a different provider/model. If the old target's save
had failed or never ran, the still-dirty draft was then PUT into the NEW
target — writing a filter list under a model/provider the user never
edited and destroying that target's real config.

Replace the dirty flag / revision counter / dirty-key trio with a single
ParamFilterDraft ref that carries the provider, model and both field
values captured at edit time. The save drives its GET, PUT and payload
from that draft instead of the current props, re-reads the ref after
each await (restarting the attempt if the draft was replaced by one for
another target), and only clears it when the exact draft object it wrote
is still pending. Object identity replaces the revision counter, keeping
the existing lost-update protection.

A load no longer clears the draft or the failure indicator: a draft
pending here belongs to another target and is still owed a write to it.
An orphaned draft is therefore neither dropped nor redirected — it keeps
its own provider/model, keeps the failure marker visible, and is retried
by the next blur/close/unmount save. The cleanup effect also depends on
the target key so re-pointing the popover flushes the old draft.

* fix(dashboard): keep param-filter fields and drafts bound to their own target (#8910)

Two remaining defects of the #8910 silent-data-loss family, both reached through
the re-point path of a live ModelCompatPopover.

1. The inputs render blockText/allowText, whose only writer was the load effect —
   and that effect early-returned whenever a draft was dirty for the target. So
   re-pointing A -> B -> A left B's server values on screen under A, and the next
   keystroke snapshotted them into A's draft, persisting B's content into A's
   entry. The fields are now a function of the target: on return to a target with
   a pending draft the draft is restored into the inputs, and on a target with no
   draft the previous target's values are cleared instead of being left behind.
   An edit also no longer trusts the counterpart field unless the values on
   screen belong to the target being edited.

2. The pending draft lived in a single slot that every edit overwrote, so typing
   into a newly pointed target destroyed the previous target's unsaved work while
   the new target's successful save cleared the failure indicator — a green UI
   over data that was never written. Drafts are now keyed by provider/model; the
   save drains every pending draft against its own target, and the indicator
   reflects unsaved work across all targets rather than the last write.

Regression tests: modelCompatPopover-param-filter-target-repoint.test.tsx
(3 cases, RED at ecd111489, GREEN here). Scope limited to this component.

* fix(dashboard): drain midflight param-filter drafts (#8910)

* fix(dashboard): serialize cross-row param-filter saves (#8910)

* docs(changelog): add fragment for #9013

* chore: remove debug console.log and O5 test prefix

---------

Co-authored-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-13 07:54:27 -03:00
小妍儿 ✨
767c01d195 fix(db): invalidate LKGP pins when their provider connection is deleted (#8887) (#8935)
* test(db): RED for LKGP pin invalidation on provider connection delete (#8887)

* fix(db): invalidate LKGP pins when their provider connection is deleted (#8887)

setLKGP() persists { provider, connectionId } under the `lkgp` namespace of
key_value, but none of the three delete paths in db/providers.ts touched that
namespace, so a pin outlived the connection it referenced and became unbounded
stale state.

- Add deleteLKGPByConnectionIds() to its owning module src/lib/db/settings/lkgp.ts
  (no raw lkgp SQL inside providers.ts). Pins without a connectionId and legacy
  plain-string pins are left untouched.
- Wire it into deleteProviderConnection, deleteProviderConnections and
  deleteProviderConnectionsByProvider.
- Add invalidateCachedLKGP() to readCache.ts so the 5s in-memory lkgpCache cannot
  serve a pin that was just deleted; called via the lazy-import pattern already
  used there, so no import cycle (npm run check:cycles OK, 391 files).

No change to updateProviderConnection semantics, no session_model_history change,
no new API route, no migration.

---------

Co-authored-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-13 07:54:20 -03:00
James
2355f7beb3 fix(translator): resolve the Claude thinking output cap with the routed provider (#10139) (#10238)
fitThinkingToMaxTokens() clamps the synthesized max_tokens to the model
output cap, but resolved that cap from a bare model id via
safeCapMaxOutputTokens(model) -> capMaxOutputTokens(model). A cap that is
only known per provider -- an operator max_output_tokens override, a
synced catalog limit_output, or a registry entry -- is invisible to a
bare-model lookup, so modelCap came back null and the unbounded
responseRoom + requestedBudget branch ran.

When the client sends no max-token field at all, adjustMaxTokens()
supplies DEFAULT_MAX_TOKENS (64000) and reasoning_effort: "high" supplies
a 131072 thinking budget, so the provider request carried
max_tokens: 195072 and every such request was rejected upstream with a
bare 400.

Thread the already-in-scope routedProvider (openai-to-claude.ts:122, used
two lines later for the Kimi-coding check) through fitThinkingToMaxTokens()
into capMaxOutputTokens({ provider, model }), which already supports
provider-scoped resolution via resolveCapabilityInput() -- no new lookup
path needed. Omitting the provider (existing callers, tests) keeps the
bare-model behavior unchanged; verified in the added regression test.

Follow-up to #6637, whose token-budgeting half was never addressed: #6893
fixed only the combo fallback classification. Rebased onto the
open-sse/translator/request/openai-to-claude/thinkingBudget.ts extraction
that landed after the original patch was written against the inline code
in openai-to-claude.ts.
2026-08-13 07:53:52 -03:00
adevwithpurpose
ce4abd7ef4 fix(opencode): force CLI User-Agent when CLI identity synthesis is enabled (#10222) 2026-08-13 07:53:47 -03:00
Aman
bf71656637 fix(deepseek-web): classify business auth rejection as 401 (#10218) 2026-08-13 07:53:43 -03:00
Markus Hartung
d2fd88dfbc fix(combo): make failoverBeforeRetry actually skip the same-model retry (#10217)
* fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190)

Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13
(with monaco-editor scoped override). Closes Dependabot #189, #190.

Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge —
awaiting Dependabot re-scan.

npm audit → 0 vulnerabilities.

* fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks)

_tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing
slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential
_tasks symlink can slip in via git add -A and, once pulled, checkout materializes
it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks
ignores the symlink too, preventing re-capture.

* fix(combo): make failoverBeforeRetry actually skip the same-model retry

Both same-target retry loops (priority/auto and round-robin) checked
isTransient/maxRetries/providerExhausted but never consulted
config.failoverBeforeRetry, so a rate-limited model still got
maxRetries+1 back-to-back attempts on itself before falling back to a
sibling — the config option (#2417) was only ever wired into
skipUpstreamRetry, a separate lower-level mechanism. Now the same-model
retry is skipped when failoverBeforeRetry is set AND a sibling target
is actually available; with no sibling left, it still retries same-model
since skipping would just burn the last attempt for nothing.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@outlook.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-13 07:53:38 -03:00
Paco Cartones
568888d7f1 feat(providers): publish Poolside's probed Laguna Preview catalog (#10216)
The Poolside entry landed with an empty `models` list because the public
matrix could only reach the unauthenticated endpoint, which answers 401
`No Authorization header provided` — the same response that a generic probe
once read back as "invalid key" and that got the provider dropped (#2723,
#3054). An authenticated probe against `/v1/models` (2026-08-07, #9085)
returned 200 and the full Preview catalog, so the two models are now static:

  poolside/laguna-xs-2.1  Laguna XS 2.1
  poolside/laguna-s-2.1   Laguna S 2.1

Both report 262144 context, 32768 max completion tokens, `tools` and
`reasoning`, and are text-only and free during Preview. The XS id is the
catalog form; the `laguna-xs.2` variant circulating in third-party listings
does not address this host. `passthroughModels` stays on, so live discovery
still admits models the Preview adds later.

Closes #9085
2026-08-13 07:53:32 -03:00
Aman
dc185e5aab fix(guardrails): support Responses input images in Vision Bridge (#10202)
* fix(guardrails): bridge Responses input images

* docs(changelog): add #10202 Vision Bridge fix fragment
2026-08-13 07:53:27 -03:00
backryun
9a3f550d88 chore(repo): remove stale one-shot and duplicate helper (#10187) 2026-08-13 07:53:22 -03:00
Dohyun Jung
de32d5ae58 fix(responses): preserve case-insensitive combo names before Codex rewrite (#10177)
* fix(responses): preserve case-insensitive combo names

* test(responses): guard case-insensitive combo rewrite

* test(responses): add case-insensitive combo SQLite guard coverage
2026-08-13 07:53:18 -03:00
Ravi Tharuma
4e1d21f756 docs(settings): Thinking Budget modes + fix Auto i18n collision (#10169)
Co-authored-by: RaviTharuma <RaviTharuma@users.noreply.github.com>
2026-08-13 07:53:12 -03:00
Jonathan Bailey
f5629d2166 fix(discovery): parse reasoning tiers nested under metadata.reasoning.supported_efforts (#10138)
neuralwatt's /v1/models wraps capabilities and reasoning under a metadata
object (metadata.reasoning.supported_efforts + metadata.capabilities
.reasoning_effort), one level deeper than the shapes detectSupported
ThinkingEfforts recognized. Synced openai-compatible rows therefore carried
no supportedThinkingEfforts and no effort aliases were advertised.

Recognize the metadata-nested shape with the same schema and validation as
the top-level #7694 reasoning.supported_efforts, placed right after it in
precedence so a top-level declaration still wins when both are present.
Covered by three regression tests (parse, precedence, malformed-degradation).
2026-08-13 07:53:07 -03:00
Nathan
06f41cda63 fix(combo): isolate session stickiness by combo (#10137)
Co-authored-by: Bryan Nathan <bryan@users.noreply.github.com>
2026-08-13 07:53:02 -03:00
Hernan Javier Ardila Sanchez
a2e5bd1dfc fix(combo): default chaos SSE to comment-only for OpenAI-compatible clients (#10128)
* fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190)

Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13
(with monaco-editor scoped override). Closes Dependabot #189, #190.

Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge —
awaiting Dependabot re-scan.

npm audit → 0 vulnerabilities.

* fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks)

_tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing
slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential
_tasks symlink can slip in via git add -A and, once pulled, checkout materializes
it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks
ignores the symlink too, preventing re-capture.

* fix(combo): default chaos SSE to comment-only for OpenAI-compatible clients

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@outlook.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-13 07:52:56 -03:00
Xiangzhe
fbcd57db56 fix(streaming): honor body stream intent for early heartbeats (#10127) 2026-08-13 07:52:51 -03:00
Xiangzhe
9e86a738ed perf(logging): bound call-log rotation work (#10125)
* perf(logging): bound call-log rotation work

* refactor(usage): extract call-log rotation/pruning from callLogs.ts to satisfy the file-size gate

Move the bounded rotation scheduler, orphan-artifact scanner, and row/overflow
pruning helpers (deleteCallLogsBefore, trimCallLogsToMaxRows,
cleanupOverflowCallLogFiles, cleanupOrphanCallLogFiles, rotateCallLogs,
scheduleCallLogRotation) into a new src/lib/usage/callLogRotation.ts module.
Pure extraction, no behavior change — callLogs.ts re-exports the same public
symbols so existing importers (usageDb.ts, compliance/index.ts, the
purge-logs route, and the rotation/cap test suite) are unaffected. Brings
callLogs.ts from 1108 to 787 lines, under the 1000-line file-size cap.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-13 07:52:46 -03:00
Xiangzhe
99d19f8f35 fix(kimi): normalize MFJS tool schemas (#10079)
Remove unsupported root-level anyOf constraints only on Kimi and Moonshot OpenAI tool requests while preserving nested schemas and caller-owned inputs. Mark Kimi Web models as unable to execute function tools so combo routing filters them correctly.
2026-08-13 07:52:41 -03:00
SB Yoon
ef5703c5e9 fix(mcp): move pack validation out of unit suite (#10065) 2026-08-13 07:52:37 -03:00
Jonathan Bailey
5d873e42d7 feat(crof): advertise reasoning effort tiers incl. max from live discovery and registry (#10062)
* feat(crof): advertise reasoning effort tiers incl. max from live discovery and registry

CrofAI's /v1/models exposes only a boolean reasoning_effort flag, so
discovery previously produced synced rows with no supportedThinkingEfforts
and the catalog/Combo Builder had nothing from which to derive -<tier>
aliases. Map the boolean to the full supported tier list (none/low/
medium/high/max) provider-scoped in discovery, thread providerId through
persistence, and declare the same tiers on every reasoning-capable seed
model (incl. glm-5.2, deepseek-v4-flash-0731, kimi-k3, and the rest of the
live roster) so stale synced caches still resolve effort aliases. max is
verified live: cache-bypassed fixed-seed requests produce distinctly more
reasoning than high, corroborating the Crof owner's statement.

* chore(changelog): add Crof reasoning effort feature fragment

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-13 07:52:32 -03:00
Arnav Rastogi
48124fca5a fix(zed-hosted): send the provider wire values cloud.zed.dev accepts (#10051)
Every zed-hosted completion failed with

  500 {"error":{"message":"[500]: An internal server error occurred."}}

for every model id, including deliberately invalid ones.

Root cause: ZED_PROVIDER held display-cased names ("Anthropic", "OpenAi",
"Google", "XAi"), and normalizeZedProvider's return value is serialized
straight into the `provider` field of the POST /completions envelope. Zed
matches that field exactly and fails the request before looking at the model,
which is why the model id never mattered.

Verified live against cloud.zed.dev with an otherwise identical request:

  {"provider":"anthropic",...} -> 200
  {"provider":"Anthropic",...} -> 500 {"message":"An internal server error occurred."}
  {"provider":"open_ai",...}   -> reaches the OpenAI request parser
  {"provider":"openai",...}    -> 500 (same internal error)

The spellings now follow Zed's own GET /models catalog, which reports
`anthropic`, `open_ai` and `google`. That also makes normalizeZedProvider
identity on catalog values instead of corrupting a value Zed just supplied —
previously it accepted the correct lowercase input and re-cased it into the
form that 500s.

`x_ai` follows the same underscore convention; this account's catalog exposes
no xAI models, so that one spelling is by convention rather than observation.

The constant is module-local and every branch compares against it, so internal
dispatch (initProviderState / convertProviderEvent / buildProviderRequest) is
unaffected. Two existing tests asserted the display-cased values and one passed
"Anthropic" to wrapZedCompletionStream directly; all are updated to the wire
values the executor now produces.

Co-authored-by: root <root@srv1710948.hstgr.cloud>
2026-08-13 07:52:27 -03:00
Arnav Rastogi
397ec88751 fix(ci): repair and wire the two live-server E2E suites (#10050)
tests/e2e/ecosystem.test.ts and tests/e2e/protocol-clients.test.ts appear in the
AGENTS.md test matrix but ran in no workflow, and could not run at all: both are
listed in vitest.config.ts include AND exclude, and their runners invoked Vitest
without --config, so the default config's exclusion discarded the very file each
passed as a positional filter.

  No test files found, exiting with code 1
  filter: tests/e2e/ecosystem.test.ts

Add vitest.e2e-live.config.ts covering only these two suites, point both runners
at it, and drop the contradictory include entries. The exclusions stay: these
drive a real server and must never run in the jsdom UI job.

Wire both into CI. test-ecosystem is blocking (20/20 green). test-protocols-e2e
is advisory pending #10049 — restoring it surfaced a pre-existing discrepancy
where GET /api/mcp/audit answers 403 over loopback against an expected 200|401.
2026-08-13 07:52:22 -03:00
Tiangao
7f2d75d6d5 feat(open-sse): expose provider-level circuit breaker thresholds via env vars (#10040) (#10046)
The provider-level breaker fields in PROVIDER_PROFILES
(providerFailureThreshold, providerFailureWindowMs, providerCooldownMs,
degradationThreshold, maxBackoffMultiplier, backoffEscalationCount) are
now env-overridable via OMNIROUTE_PROVIDER_BREAKER_<CATEGORY>_<FIELD>
variables, with the historical hardcoded defaults preserved when unset.

This makes the provider-level fuse (the entire-provider cooldown applied
after repeated upstream failures) tunable from the deployment surface,
matching the existing per-key circuit breaker knobs. Operators can now
raise thresholds to tolerate transient upstream sheds without
blacklisting the provider, or lower them to fail over faster on
premium routes — without rebuilding from source.

Closes #10040

Category-by-category field map (defaults preserved):

- oauth: FAILURE_THRESHOLD=10, FAILURE_WINDOW_MS=900000, COOLDOWN_MS=300000,
  DEGRADATION_THRESHOLD=5, MAX_BACKOFF_MULTIPLIER=8, BACKOFF_ESCALATION_COUNT=2
- apikey: [REDACTED:auth_header], FAILURE_WINDOW_MS=1800000, COOLDOWN_MS=600000,
  DEGRADATION_THRESHOLD=7, MAX_BACKOFF_MULTIPLIER=4, BACKOFF_ESCALATION_COUNT=3
- local: FAILURE_THRESHOLD=2, FAILURE_WINDOW_MS=300000, COOLDOWN_MS=60000
  (local category omits the adaptive v2 fields)

Docs:
- .env.example — 15 new commented entries grouped under a
  "Provider-level circuit breaker thresholds and cooldowns" section.
- docs/reference/ENVIRONMENT.md — 15 new rows documenting the
  provider-level breaker surface.

Tests:
- tests/unit/provider-breaker-env-overrides.test.ts — 4 cases:
  1. Every new env var is wired in constants.ts via envInt().
  2. Every new env var is documented in ENVIRONMENT.md.
  3. Every new env var is listed in .env.example.
  4. The historical defaults are preserved as the envInt fallback.

Behavior tests (loading the actual module with controlled env vars) are
left to upstream CI; the static source-shape test is sufficient here
because the envInt() helper is a plain function whose only dependency
is process.env at module load time.

Co-authored-by: Tiangao (hermes) <montigaud@aikumi.pro>
2026-08-13 07:52:17 -03:00
Ke Jin
1a8d38655d fix(reasoning): preserve and replay assistant turns (#10045) 2026-08-13 07:52:12 -03:00
backryun
7366bb6c3a fix(types): tighten chatCore helper contracts (#10175) 2026-08-13 07:52:07 -03:00
dependabot[bot]
c481ee3312 deps: bump the development group across 1 directory with 22 updates (#10043)
Bumps the development group with 22 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.61.1` | `1.62.1` |
| [@size-limit/file](https://github.com/ai/size-limit) | `12.1.0` | `13.0.3` |
| [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) | `6.9.1` | `7.0.0` |
| [@types/better-sqlite3](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/better-sqlite3) | `7.6.13` | `9.6.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.1.1` | `26.2.0` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.17` | `19.2.18` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.3` | `19.2.4` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.3` | `6.0.5` |
| [concurrently](https://github.com/open-cli-tools/concurrently) | `10.0.3` | `10.0.4` |
| [dpdm](https://github.com/acrazing/dpdm) | `4.2.0` | `4.3.0` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.2.10` | `16.3.0` |
| [fumadocs-mdx](https://github.com/fuma-nama/fumadocs) | `15.2.0` | `15.2.2` |
| [jsdom](https://github.com/jsdom/jsdom) | `29.1.1` | `30.0.1` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `6.27.0` | `6.32.0` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `17.1.0` | `17.3.0` |
| [opencode-ai](https://github.com/anomalyco/opencode) | `1.18.8` | `1.18.15` |
| [prettier](https://github.com/prettier/prettier) | `3.9.5` | `3.9.6` |
| [promptfoo](https://github.com/promptfoo/promptfoo) | `0.121.19` | `0.122.0` |
| [size-limit](https://github.com/ai/size-limit) | `12.1.0` | `13.0.3` |
| [type-coverage](https://github.com/plantain-00/type-coverage) | `2.29.7` | `2.30.1` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.65.0` | `8.66.0` |
| [wait-on](https://github.com/jeffbski/wait-on) | `9.0.10` | `9.1.0` |



Updates `@playwright/test` from 1.61.1 to 1.62.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.61.1...v1.62.1)

Updates `@size-limit/file` from 12.1.0 to 13.0.3
- [Release notes](https://github.com/ai/size-limit/releases)
- [Changelog](https://github.com/ai/size-limit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/size-limit/compare/12.1.0...13.0.3)

Updates `@testing-library/jest-dom` from 6.9.1 to 7.0.0
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.9.1...v7.0.0)

Updates `@types/better-sqlite3` from 7.6.13 to 9.6.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/better-sqlite3)

Updates `@types/node` from 26.1.1 to 26.2.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/react` from 19.2.17 to 19.2.18
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@types/react-dom` from 19.2.3 to 19.2.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `@vitejs/plugin-react` from 6.0.3 to 6.0.5
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.5/packages/plugin-react)

Updates `concurrently` from 10.0.3 to 10.0.4
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v10.0.3...v10.0.4)

Updates `dpdm` from 4.2.0 to 4.3.0
- [Release notes](https://github.com/acrazing/dpdm/releases)
- [Commits](https://github.com/acrazing/dpdm/compare/v4.2.0...v4.3.0)

Updates `eslint-config-next` from 16.2.10 to 16.3.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.0/packages/eslint-config-next)

Updates `fumadocs-mdx` from 15.2.0 to 15.2.2
- [Release notes](https://github.com/fuma-nama/fumadocs/releases)
- [Commits](https://github.com/fuma-nama/fumadocs/compare/fumadocs-mdx@15.2.0...fumadocs-mdx@15.2.2)

Updates `jsdom` from 29.1.1 to 30.0.1
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.1.1...v30.0.1)

Updates `knip` from 6.27.0 to 6.32.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.32.0/packages/knip)

Updates `lint-staged` from 17.1.0 to 17.3.0
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v17.1.0...v17.3.0)

Updates `opencode-ai` from 1.18.8 to 1.18.15
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/v1.18.8...v1.18.15)

Updates `prettier` from 3.9.5 to 3.9.6
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.5...3.9.6)

Updates `promptfoo` from 0.121.19 to 0.122.0
- [Release notes](https://github.com/promptfoo/promptfoo/releases)
- [Changelog](https://github.com/promptfoo/promptfoo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/promptfoo/promptfoo/compare/0.121.19...0.122.0)

Updates `size-limit` from 12.1.0 to 13.0.3
- [Release notes](https://github.com/ai/size-limit/releases)
- [Changelog](https://github.com/ai/size-limit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/size-limit/compare/12.1.0...13.0.3)

Updates `type-coverage` from 2.29.7 to 2.30.1
- [Changelog](https://github.com/plantain-00/type-coverage/blob/master/CHANGELOG.md)
- [Commits](https://github.com/plantain-00/type-coverage/compare/v2.29.7...v2.30.1)

Updates `typescript-eslint` from 8.65.0 to 8.66.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/typescript-eslint)

Updates `wait-on` from 9.0.10 to 9.1.0
- [Release notes](https://github.com/jeffbski/wait-on/releases)
- [Commits](https://github.com/jeffbski/wait-on/compare/v9.0.10...v9.1.0)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.62.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: "@size-limit/file"
  dependency-version: 13.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: "@testing-library/jest-dom"
  dependency-version: 7.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: "@types/better-sqlite3"
  dependency-version: 9.6.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: "@types/node"
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: "@types/react"
  dependency-version: 19.2.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: concurrently
  dependency-version: 10.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: dpdm
  dependency-version: 4.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: eslint-config-next
  dependency-version: 16.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: fumadocs-mdx
  dependency-version: 15.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: jsdom
  dependency-version: 30.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: knip
  dependency-version: 6.32.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: lint-staged
  dependency-version: 17.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: opencode-ai
  dependency-version: 1.18.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: prettier
  dependency-version: 3.9.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: promptfoo
  dependency-version: 0.122.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: size-limit
  dependency-version: 13.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: type-coverage
  dependency-version: 2.30.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: typescript-eslint
  dependency-version: 8.66.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: wait-on
  dependency-version: 9.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 07:52:02 -03:00
dependabot[bot]
242cc5e797 deps: bump electron from 43.2.0 to 43.3.0 in /electron (#10042)
Bumps [electron](https://github.com/electron/electron) from 43.2.0 to 43.3.0.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v43.2.0...v43.3.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 43.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 07:51:58 -03:00
Diego Rodrigues de Sa e Souza
d2dd39ea78 feat(dashboard): Kimi 15% first-top-up campaign — dedicated tracked link + discount-first banner copy (#10240)
Moonshot approved a 15% extra-credits offer for new users' first top-up,
attached to a dedicated tracked link issued for OmniRoute (valid through
2026-09-30). The dashboard banner and the three README API platform
placements now use that link, and the banner description leads with the
discount in all 43 locales, keeping the commitment made when the offer
was requested. The README CTA gains the 15% mention. A code comment
marks the strings to revisit after 2026-09-30 if the offer is not
renewed.

Co-authored-by: backryun <bakryun0718@proton.me>
2026-08-13 04:49:28 -03:00
Amar Tinawi
558412c9bb fix(cli): read the full provider catalog instead of the 6-entry fallback (#10097)
loadAvailableProviders() always returned COMMON_PROVIDERS, so 'omniroute keys
add <provider>' rejected ~290 of the ~296 catalog providers with 'Unknown
provider' and 'providers available' under-reported the catalog by ~98%. Two
independent causes, either sufficient on its own:

1. extractProviderBlocks() required 'typescript' at runtime, but it is only a
   devDependency — absent from every published/global install. The require
   failure was swallowed and the parse returned [].

2. The parser read src/shared/constants/providers.ts, which after the god-file
   decomposition contains only re-exports plus an empty 'FREE_PROVIDERS = {}'.
   Even with typescript present it yielded zero entries.

Replace the AST parse with a dependency-free, string/comment-aware brace walk
(these files are pure data literals) and walk src/shared/constants/providers/**
instead of the barrel. An explicit catalogPath / OMNIROUTE_PROVIDER_CATALOG_PATH
still wins, and the COMMON_PROVIDERS fallback still applies when no catalog is
present.

The walk is also hardened against an unbalanced literal (#10093): it recovers
the entries before the damage and terminates, instead of looping forever on a
reset regex lastIndex.

Closes #10080
2026-08-13 04:43:29 -03:00
Amar Tinawi
3ee5c1b4ae fix(cli): stop swallowing non-2xx responses into benign-looking results (#10092)
Three commands turned a transport failure into something that reads as real
state:

- `keys add` aborted on any 4xx. `/api/v1/providers/keys` is not mounted on
  the shipped server, so a 404 stranded the user with "HTTP 404" while the
  SQLite fallback directly below it — which works — was unreachable whenever the
  server was up. New isRouteUnavailableStatus() (404/405/501) lets the caller
  fall through; genuine client errors (400/401/403/409/422/429) stay fatal.

- `providers test-all` reported every OAuth connection as FAILED because
  getProviderApiKey() throws for non-apikey connections by design — and
  persisted that verdict to provider_connections.test_status, marking healthy
  OAuth providers broken. Those connections are now skipped. An "unsupported"
  probe result (no recipe in PROVIDER_TEST_CONFIGS) is likewise a CLI gap, not
  a provider failure, so it no longer overwrites a good test_status.

- `combo list` printed "No combos configured" when /api/combos returned
  non-2xx, which is indistinguishable from genuine emptiness. It now reports the
  status and exits non-zero.

Refs #10081
2026-08-13 04:43:18 -03:00
Amar Tinawi
4a7e8ddc16 fix(cli): openapi endpoints/paths/validate accept the served catalog shape (#10091)
GET /api/openapi/spec answers with a compact catalog
({ info, servers, tags, endpoints[], schemas }) rather than an OpenAPI document,
while dist/docs/openapi.yaml is a real spec. The CLI only read spec.paths, so
against a live server 'openapi endpoints' and 'openapi paths' printed nothing
and 'openapi validate' reported 'missing openapi/swagger version field' — with
318 endpoints sitting in spec.endpoints.

Normalize both shapes through extractEndpoints()/extractPaths() and let
validateBasic() accept a catalog that carries endpoints[] instead of a version
field. Path Item members that are not operations (parameters, summary,
description, servers, $ref) are no longer emitted as fake operations.

Closes #10082
2026-08-13 04:43:02 -03:00
Amar Tinawi
2c720f1fa7 fix(cli): doctor detects prebuilt better-sqlite3 binaries (#10090)
checkNativeBinary only probed the node-gyp layout
(build/Release/better_sqlite3.node), which exists only when better-sqlite3 is
compiled locally. Installs that resolve a prebuilt binary — the normal case for
`npm i -g omniroute` — ship prebuilds/<platform>-<arch>.node instead, so the
check never found a binary and warned "better-sqlite3 native binary was not
found" on every such install, next to real warnings.

Probe both layouts and report both in the failure details. prebuiltBinaryName()
mirrors the prebuild-install lookup, including the linuxmusl- prefix for
musl-based Linux.

Closes #10083
2026-08-13 04:42:44 -03:00
TengSivtean
8718d2b62f fix(providers): kilo-gateway authType should be optional, not apikey (#10086)
Probed live: /chat/completions answers HTTP 200 with no Authorization
header (kilo-auto/free routed to stepfun/step-3.7-flash). A real key
still raises limits, so this matches the ovhcloud/pollinations pattern
of authType: "optional" rather than "apikey".

Fixes #10068
2026-08-13 04:42:34 -03:00
Amar Tinawi
92a27f268a fix(cli): strip inline comments when parsing .env values (#10101)
loadEnvFile() took everything after the first '=', so 'KEY=value  # note' stored
the comment text as part of the value. The shipped .env/.env.example do exactly
that for QUOTA_STORE_DRIVER, so every install ran with
QUOTA_STORE_DRIVER='sqlite              # sqlite | redis'.

Consumers compare with '===' (storeFactory.ts), so a user following the
annotation in .env.example and writing 'QUOTA_STORE_DRIVER=redis  # ...' got
driver !== 'redis', fell through to SQLite, and saw no warning — the existing
'no Redis URL configured' warning is inside the redis branch and never fires.

parseEnvValue() adopts dotenv semantics: quoted values verbatim (a '#' inside
quotes is data), unquoted values cut at the first whitespace-preceded '#', so
'pass#word' survives. .env.example moves the annotation to its own line.

Closes #10100
2026-08-13 04:42:14 -03:00
Markus Hartung
d925f6bf73 fix(logging): document CHAT_LOG_MAX_BODY_KB, capture messageCount for Responses API bodies (#10038)
* fix(logging): document CHAT_LOG_MAX_BODY_KB, capture messageCount for Responses API bodies

Extracted from PR #9439 (agentic conversation tracking). Most of the
original scope this commit was cherry-picked from (CHAT_LOG_MAX_BODY_KB
env var support, the estimateSizeFast() earlyExitAt parameterization)
turned out to already be present on the current upstream/release/v3.8.50
tip -- confirmed via diff and by running check-env-doc-sync.test.ts /
tests/unit/chatcore-log-truncation.test.ts against pristine upstream
before making any changes here. Only two genuine gaps remained:

1. CHAT_LOG_MAX_BODY_KB was read by getChatLogMaxBodyBytes() but
   undocumented in .env.example and docs/reference/ENVIRONMENT.md --
   tests/unit/check-env-doc-sync.test.ts flags any env var read in code
   but missing from both doc files. Documented it (both required --
   the same test enforces the pairing).

2. truncateForLog()'s summary only computed messageCount from
   obj.messages (OpenAI-chat/Gemini field name) -- a large /v1/responses
   request (which uses input[], not messages[]) got summarized with no
   count at all, leaving the dashboard's "Full Conversation" panel
   nothing to base its "N messages not shown" placeholder on for any
   Responses-API conversation, even though the same summarization logic
   applies to it.

Test plan:
- TDD: tests/unit/chatcore-log-truncation.test.ts's new regression test
  ("captures a message count for Responses API bodies too") confirmed
  failing against the pre-fix code, passing after.
- tests/unit/check-env-doc-sync.test.ts confirms CHAT_LOG_MAX_BODY_KB no
  longer appears in codeMissingEnv (remaining drift in that test is
  pre-existing/unrelated -- ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS,
  COMMANDCODE_API_URL, OMNIROUTE_STRICT_SYSTEM_PROVIDERS,
  TLS_FINGERPRINT_PROVIDERS -- confirmed identical on a pristine
  upstream/release/v3.8.50 checkout, base-red inherited: #9985).
- tests/unit/chatcore-log-truncation.test.ts -- 19/19 passing.
- npx tsc --noEmit / npm run lint -- clean.

⚠️ base-red inherited: #9985

* docs(logging): consolidate CHAT_LOG_MAX_BODY_KB into a single entry per file

The variable was already documented (with a stale src/lib/chatLogTruncation.ts
reference in .env.example); keep the new richer entries next to the CHAT_LOG_*
family and drop the old duplicates.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-13 04:02:42 -03:00
Markus Hartung
2f264d96dc fix(dashboard): expose OpenAI Responses store toggle for non-Codex connections (#10121)
* fix(dashboard): expose OpenAI Responses store toggle for non-Codex connections

`EditConnectionModal` only rendered and saved the "OpenAI Responses store"
toggle (providerSpecificData.openaiStoreEnabled) inside the Codex-only
settings block, even though the backend policy that reads this flag
(open-sse/utils/responsesStatePolicy.ts::isOpenAIResponsesStoreEnabled,
applyResponsesPreviousResponseIdPolicy) is already fully provider-agnostic,
and the component already computes a generic `isResponsesConnection` flag
(provider === "openai" or any openai-compatible-responses-* connection, in
addition to codex) that the sibling `preserveEncryptedReasoning` toggle
already correctly uses.

Net effect: an operator with a plain OpenAI API-key connection, or any
generic OpenAI-Responses-compatible proxy connection, had no way anywhere in
the dashboard to opt that connection into `store`/`previous_response_id`
continuation — the policy layer was ready, the control just never rendered
for anything but Codex.

Move the toggle (and its save-time write) out of the isCodex-only block and
gate it on isResponsesConnection instead, matching preserveEncryptedReasoning.
Renamed the local formData field from codexOpenaiStoreEnabled to
openaiResponsesStoreEnabled since it is no longer Codex-specific.

Regression test added (TDD): renders the modal for a plain provider:"openai"
connection and asserts the toggle is present and reflects a persisted flag —
fails on the pre-fix code, passes after.

* fix(responses): stop store-marker leak into Chat Completions requests

The OpenAI Responses store toggle exposed in the previous commit was only
half the fix: the actual store functionality was broken for any model
routed to /v1/chat/completions instead of /v1/responses (e.g. gpt-5-nano,
which lacks the responses-only targetFormat capability). translateRequest
stashes the client's Responses-shaped store intent under an internal
_omnirouteResponsesStore marker so a later re-conversion back to Responses
shape can restore it as store -- but when the destination stays in Chat
Completions shape, that re-conversion never runs, nothing else consumed
the marker, and it leaked verbatim into the real upstream request body.
OpenAI's own API rejects it with 'Unknown parameter: _omnirouteResponsesStore'.
Confirmed live against the real OpenAI API.

Fix: drop the marker unconditionally at the end of translateRequest once
translation is complete, regardless of destination format. Chat Completions'
own store field means something different (dashboard eval storage, not
Responses-style previous_response_id continuation), so the client's intent
must not be silently remapped onto it either -- it's simply dropped.

Also fixes a real crash discovered while live-testing store-enabled
requests: src/sse/handlers/chat.ts referenced isProviderBreakerFailureStatus
without importing it (only the unused PROVIDER_BREAKER_FAILURE_STATUSES
constant was imported), turning a clean 429/'no credits' response into an
uncaught ReferenceError whenever all provider accounts were rate-limited.
Confirmed live (container logs showed the exact ReferenceError before the
fix, and clean error responses after).

Plus two small unrelated base-red fixes needed to get the test suite
running at all on this branch: a broken relative import in
conol-web/index.ts (one path segment short, pointed at a nonexistent
directory), and a real syntax error in gateways.ts (missing closing brace)
that broke esbuild's TypeScript transform for every test file that
transitively imports it, including the pre-existing combo-breaker-429
suite used to verify the isProviderBreakerFailureStatus fix doesn't
regress breaker classification.

Regression test: tests/unit/responses-store-marker-leak.test.ts (confirmed
failing before the translator/index.ts fix, passing after).

⚠️ base-red inherited: migration 143_job_registry.sql duplicated an
already-existing 146_job_registry.sql (byte-identical migration body,
confirmed via diff); the 143 file is deleted since 146 is canonical per
SCHEMA_VERSION_RENAMES. Needed for translateRequest's DB-backed model
capability lookup to run at all in tests.
2026-08-13 04:02:38 -03:00
Markus Hartung
c9daf99e37 fix(combo): clear LKGP pin when its target fails, not only set it on success (#10034)
setLKGP() was only ever called on success — nothing invalidated a "last
known good provider" pin once that provider started failing, so a
*separate* subsequent request kept re-selecting the same just-failed
target via applyStrategyOrdering.ts's LKGP reordering.

Live incident: an OpenClaw request to combo "default" (routerStrategy:
lkgp) got a real reasoning + apply_patch tool call from
opencode-zen/big-pickle, then 3 separate follow-up requests over the
next ~2 minutes each independently re-selected the same big-pickle
target and each timed out with "504 Stream produced no non-ping SSE
event within 95000ms" before the client gave up — instead of failing
over to any of the combo's other 12 models.

Root cause confirmed via code read: circuit breaker and model lockout
deliberately don't react to this failure class (isStreamReadinessFailureErrorBody
exempts STREAM_READINESS_TIMEOUT/combo_target_timeout 504s from tripping
the provider breaker, and REQUEST_SCOPED_UPSTREAM_ERROR_CODES suppresses
model-lockout recording for the same class — both intentional, to avoid
poisoning a healthy provider on request-specific timing). Nothing else
in the system was clearing the stale LKGP pin, so it kept winning
target-selection ordering for every new top-level request.

Fix: add clearLKGP(comboName, modelId) to src/lib/db/settings/lkgp.ts,
export it through settings.ts/localDb.ts, and call it (mirroring the
existing setLKGP-on-success call pattern exactly, same two keys) in both
combo.ts's per-target failure paths -- handleComboChat's "Done retrying
this model" block and handleRoundRobinCombo's structurally identical
twin -- right where a target is finally given up on and the loop moves
to the next one.

TDD: new regression test in tests/unit/combo-routing-engine.test.ts
("clears LKGP after the last-known-good target fails") reproduces the
exact live scenario -- confirmed failing against the pre-fix code,
passing after. Added direct unit coverage for clearLKGP itself in
tests/unit/db-settings-crud.test.ts (deletes only the targeted key,
sibling keys survive; no-op on an unset key doesn't throw) and
registered the new export in db-settings-split.test.ts's public API
surface characterization test.

Test plan:
- Full combo/LKGP-related suite (combo-routing-engine, db-settings-crud,
  db-settings-split, combo-strategy-fallbacks,
  combo-selected-connection-success,
  delete-provider-connection-invalidates-lkgp-8887, db-read-cache) --
  183/183 passing.
- npx tsc --noEmit -- clean for all changed files (pre-existing unrelated
  errors elsewhere in the same test files confirmed identical against a
  pristine upstream/release/v3.8.50 checkout, zero diff at those lines).
- npm run lint -- clean (new test's any usage properly typed, not left
  to inflate the file's frozen any-budget suppression).

⚠️ base-red inherited: #9985
2026-08-13 04:02:34 -03:00
Markus Hartung
4bda22583e fix(sse): provider-response summary format bugs (dashboard Provider Response panel) (#10037)
* fix(sse): provider-response summary reconstructed from truncated events

The dashboard's "Provider Response" panel showed a stale, incomplete
snapshot for long streamed responses. Root cause: open-sse/utils/stream.ts
reconstructed the summary from
buildStreamSummaryFromEvents(providerPayloadCollector.getEvents(), ...)
-- but getEvents() only returns whatever survived the collector's
maxEvents/maxBytes cap, so once a stream exceeded it (easy with a
reasoning + tool-calling model), everything after the cutoff (final
finish_reason, tool_calls, rest of reasoning_content, usage) was
silently dropped from the reconstruction, even though the client
actually received the correct, complete response.

Fix: streamPayloadCollector.ts's per-format summary builders
(buildOpenAISummary/buildResponsesSummary/buildClaudeSummary/
buildGeminiSummary) are now also available as incremental reducers
(createXReducer: ingest one chunk at a time, finalize at the end).
createStructuredSSECollector accepts a format + fallbackModel and feeds
the reducer on every push() -- including chunks that get dropped from
the retained event array once the cap is hit -- via a new getSummary()
method. stream.ts's error-path call site now uses
collector.getSummary() instead of reconstructing from the (possibly
truncated) getEvents().

Extracted from a squashed commit (originally authored alongside a
conversation-tracking continuation fix in the same commit) -- only the
files relevant to this SSE-summary bug are included here
(stream.ts/streamPayloadCollector.ts + their test); the unrelated
conversationTracker.ts continuation fix stays with the conversation-
tracking PR it belongs to.

Test plan:
- New TDD regression tests in tests/unit/stream-payload-collector.test.ts,
  confirmed failing before the fix and passing after.

* fix(sse): provider-response summary used the client's format, not the provider's

providerPayloadCollector (dashboard "Provider Response" panel) was keyed on
sourceFormat (the CLIENT's wire format) instead of targetFormat (the
PROVIDER's — see createSSEStream's own @param doc: "targetFormat - Provider
format", "sourceFormat - Client format"). Whenever a request translates
between two different formats — e.g. a Responses-API client routed to a
plain-OpenAI-chat-completions upstream, the common OpenClaw/opencode-zen
shape — the reducer picked for sourceFormat could never recognize the
provider's actual raw event shape, so it stayed stuck at its empty initial
state. The dashboard's "Provider Response" panel showed a permanently empty
`output: []` while "Client Response" (built from separately-accumulated
state, unaffected by this bug) correctly showed full content — reading as
if the two panels simply disagreed about the same request.

Confirmed live via a wire-level pcap capture (scripts/sre/tcp-close-
analyzer.py) cross-referenced against the dashboard log
(1786032832181-1c6275): the actual response was complete and correct: this
was purely a logging/summary bug, never a wire-format bug.

Fix is mode-aware: TRANSLATE mode uses targetFormat (the provider's true
format); PASSTHROUGH mode keeps sourceFormat, since passthrough has no
separate provider/client format split — nothing gets translated there, and
real passthrough callers (createPassthroughStreamWithLogger) don't even
pass targetFormat.

New regression test reproduces the exact live scenario (Responses-API
source, OpenAI target, real chat.completion.chunk deltas) and asserts the
provider summary reflects them — confirmed it fails with the old
`sourceFormat`-keyed code (reproducing the live `output: []`-style
symptom) and passes with the fix.

Co-authored-by: Markus Hartung <markus.hartung@gmail.com>

* fix(sse): stamp object: chat.completion on the provider-summary fallback

createSSEStream's providerPayloadCollector.build() falls back to the
synthesized responseBody as the "Provider Response" dashboard summary
whenever sourceFormat/targetFormat isn't OPENAI_RESPONSES (in both the
passthrough and translate branches) -- but responseBody is built purely
for the client and never carries an `object` field at all, so the
summary ended up with `object: undefined` instead of the expected
"chat.completion", even though everything else (choices, usage) was
correct.

Caught by this PR's own new regression test ("createSSEStream translate
mode: providerPayload summary reflects the PROVIDER's format, not the
client's") -- the code itself was unchanged by the rebase (applied
cleanly from the original commit), so this was a latent gap in the
original fix, not a rebase regression.

Fix: stamp `object: "chat.completion"` on a shallow copy used only for
the provider summary in both branches; responseBody itself (sent to the
client elsewhere) stays untouched.

Verified: tests/unit/stream-utils.test.ts 51/52 passing (the one
remaining failure is an unrelated, pre-existing v3.6.6-era test,
confirmed present and failing identically on a pristine
upstream/release/v3.8.50 checkout -- base-red inherited: #9985).
typecheck/lint clean (pre-existing unrelated errors elsewhere in the
file, confirmed identical to upstream).

---------

Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
2026-08-13 04:02:30 -03:00
Markus Hartung
ae54c6221c fix(responses-api): tool call after reasoning collided on the same output_index (#10025)
emitToolCallAdded/closeToolCall used the provider's raw Chat Completions
tool_calls[].index directly as the Responses API output_index. That index
is scoped only to the tool_calls array and legitimately restarts at 0 for
the first tool call, but a reasoning item (and/or a text message) emitted
earlier in the same turn may already have claimed output_index 0 (and 1).
A client that tracks response items by output_index (as the Responses API
spec expects) then sees the tool call's added/delta/done events land on an
index it already marked complete, and silently drops the tool call --
producing an "incomplete turn" that never dispatches it.

Reported live: OpenClaw on combo default -> opencode-zen/big-pickle sent a
reasoning block immediately followed by a function call in the same turn
(no text message in between); the function call's output_index collided
with the reasoning item's.

A similar collision (tool call after a *text message*) was already fixed
in open-sse/translator/response/openai-responses.ts (#9822/#9843), but
that file is only used by the zed-hosted executor -- the general
/v1/responses path (wired via responsesHandler.ts) goes through this file,
which never received the equivalent fix.

Fix: compute the tool call's output_index once (offset past any reasoning/
message item already emitted this turn) and cache it in
state.funcOutputIndex, so every added/delta/done event for that call --
including ones emitted later from the finish_reason handler or flush() --
shares exactly the same output_index.

TDD: new regression tests in
tests/unit/responses-transformer-tool-call-reasoning-collision.test.ts
reproduce the exact live scenario (reasoning immediately followed by a
tool call, and multiple tool calls after reasoning) -- confirmed failing
against the pre-fix code, passing after. Full transformer test suite
(responses-transformer*.test.ts, responses-replay-fixes.test.ts,
responses-api-truncation.test.ts, responses-request-translation.test.ts)
-- 24/24 passing, no regressions.

⚠️ base-red inherited: #9985
2026-08-13 04:02:25 -03:00
Markus Hartung
0a72988bde fix(responses-api): explicit function-tool declaration must win over apply_patch-is-custom fallback (#10041)
open-sse/translator/response/openai-responses.ts's isCustomTool check
unconditionally treats any tool named "apply_patch" as a Codex-style
custom tool: `toolName === "apply_patch" || state.customToolNames?.has?.(toolName)`.
This overrides a client's own explicit declaration whenever it registers
apply_patch as a plain `type:"function"` tool (with its own JSON-schema
parameters) instead of `type:"custom"`.

Live incident: OpenClaw (combo "default" -> opencode-zen/big-pickle)
declared apply_patch as `type:"function"` with `{input:string}`
parameters. The model correctly produced valid JSON matching that
schema (`{"input":"*** Begin Patch..."}`), but OmniRoute unwrapped it
into a custom_tool_call with raw-text `input` instead of the
function_call/`arguments` shape the client actually registered.
OpenClaw's own dispatcher only implements function_call handling for a
name it declared as type:"function", so it silently never recognized
the tool call at all -- no error, no execution, no follow-up request
ever carrying a result back to the model.

Traced the exact live code path (chatCore.ts -> createSSEStream
translate mode -> translator/index.ts's hub-and-spoke openai ->
openai-responses conversion) to confirm this file -- not
transformer/responsesTransformer.ts -- is what handles combo-routed
streaming for this client/provider format pair.

PR #7905 ("Restore Responses API custom tool calls") already states
this exact precedence should hold ("...while preserving explicit
function-tool precedence") but its unconditional `toolName ===
"apply_patch"` OR never actually implemented that carve-out for
apply_patch specifically -- this fixes the gap between that PR's
stated intent and its actual behavior.

Fix: state.toolSchemas (populated from body.tools by
extractToolSchemaMap(), already threaded through stream.ts's translate
state for a different purpose -- #6951's stripEmptyOptionalToolArgs)
only contains an entry for a tool name when the client's request
declared it with a `parameters` JSON schema, i.e. as type:"function".
Gate the apply_patch fallback on NOT finding it there: apply_patch
still defaults to custom (native Codex CLI convention -- the model
emits it without the client ever declaring it as a tool) unless the
client explicitly registered it as a function tool, in which case that
explicit declaration wins.

Test plan:
- TDD: two new regression tests in
  tests/unit/translator-openai-responses-custom-tool-1007.test.ts --
  "...with tool defined" (function_call, arguments stay raw JSON) and
  "...without tool defined" (unchanged custom_tool_call fallback,
  mirroring the existing #1007 coverage). The "with" test is confirmed
  failing against the pre-fix code, passing after; the "without" test
  passed before and after (regression guard for the existing fallback
  behavior).
- Full related suite (translator-openai-responses-custom-tool-1007,
  responses-handler, responses-active-stream-custom-tool,
  translator-resp-openai-responses,
  translator-resp-openai-responses-namespace-identity,
  translator-openai-responses-image-output-8459,
  responses-transformer) -- 64/64 passing, no regressions to PR #7905's
  own custom-tool coverage.
- npx tsc --noEmit -- clean (pre-existing loose-typing errors in this
  test file confirmed identical on a pristine upstream checkout).
- npm run lint -- clean.

⚠️ base-red inherited: #9985
2026-08-13 04:02:21 -03:00
Diego Rodrigues de Sa e Souza
32da2a1afe fix: repair Audio Bridge runtime multipart self-loop (#10229)
* fix(guardrails): serialize audio bridge multipart safely

* docs(changelog): record Audio Bridge multipart fix

---------

Co-authored-by: backryun <bakryun0718@proton.me>
2026-08-13 03:43:44 -03:00
222 changed files with 14337 additions and 5449 deletions

View File

@@ -1232,6 +1232,13 @@ CURSOR_USER_AGENT="Cursor/3.4"
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
# OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS=120000
# ── Provider probe (credential validation / model discovery) ──
# Timeout in ms for provider validationRead and modelsProbe presets.
# Default: 8000 (was 5000). Raise it if slow endpoints (Cerebras, Cloudflare AI, Groq)
# cause flapping between active/error in the dashboard.
# Used by: src/shared/network/safeOutboundFetch.ts — centralized timeout resolution.
# OMNIROUTE_PROVIDER_PROBE_TIMEOUT_MS=8000
# ── Proxy/relay fetch (connection pooling, #9158) ──
# Used by: open-sse/utils/proxyFetch.ts.
# A hung relay must fail BEFORE the client/agent timeout (typically 30s) so the
@@ -1324,6 +1331,28 @@ CURSOR_USER_AGENT="Cursor/3.4"
# OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD=2
# OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS=15000
# ── Provider-level circuit breaker thresholds and cooldowns ──
# Used by: open-sse/config/constants.ts (PROVIDER_PROFILES → accountFallback).
# These control the provider-level fuse (entire provider cooldown after repeated
# failures) — distinct from the per-key breaker above. Defaults match the
# historical PROVIDER_PROFILES values. Raise to tolerate transient upstream
# sheds without blacklisting the provider; lower to fail over faster.
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_THRESHOLD=10
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_WINDOW_MS=900000
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_COOLDOWN_MS=300000
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_DEGRADATION_THRESHOLD=5
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_MAX_BACKOFF_MULTIPLIER=8
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_BACKOFF_ESCALATION_COUNT=2
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_THRESHOLD=15
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS=1800000
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_COOLDOWN_MS=600000
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_DEGRADATION_THRESHOLD=7
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_MAX_BACKOFF_MULTIPLIER=4
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT=3
# OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_THRESHOLD=2
# OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_WINDOW_MS=300000
# OMNIROUTE_PROVIDER_BREAKER_LOCAL_COOLDOWN_MS=60000
# ── Context-cache pin health gate ──
# Used by: open-sse/services/combo.ts. When a context-cache pin points at a
# provider that is durably unhealthy, the pin is dropped to allow failover.
@@ -1449,6 +1478,11 @@ APP_LOG_TO_FILE=true
# CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128)
# CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6)
# CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit)
# CHAT_LOG_MAX_BODY_KB=1024 # Whole request/response body size before it's replaced by a bare
# {_truncated, messageCount, ...} summary instead of the full clone
# (default: 1024 KB / 1MB). Raise this if the dashboard's "Full
# Conversation" transcript panel shows a placeholder instead of the
# actual messages for long agentic conversations.
# Maximum rows in the proxy_logs SQLite table.
# Default: 100000
@@ -2350,7 +2384,8 @@ INSPECTOR_INTERNAL_INGEST_TOKEN=
# unset): path to a file whose trimmed content is the token.
# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE=
# Quota Sharing (Group B — planos 16+22)
QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# sqlite | redis
QUOTA_STORE_DRIVER=sqlite
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
# QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo)
# QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa
@@ -2626,10 +2661,6 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# Used by: src/app/api/jobs/[id]/run-now/route.ts. Default: 30000 (30 seconds)
# OMNIROUTE_RUNNOW_TIMEOUT_MS=30000
# Maximum request/response body size before chat-log summarization, in KiB.
# Used by: src/lib/chatLogTruncation.ts. Default: 1024
# CHAT_LOG_MAX_BODY_KB=1024
# Adobe Firefly browser renewal and durable session cache (enabled by default).
# Used by: open-sse/services/adobeFireflySession.ts.
# ADOBE_FIREFLY_BROWSER_REFRESH=1

View File

@@ -1256,6 +1256,63 @@ jobs:
- run: npm run check:node-runtime
- run: npm run test:security
# Live-server E2E. Both suites boot a real OmniRoute via their own runner and
# drive it over HTTP; neither needs provider credentials. They were documented in
# AGENTS.md's test matrix but wired to NO workflow, and had additionally been
# unrunnable (vitest.config.ts excluded the very files their runners passed as a
# positional filter) — so nothing had executed them for as long as that was true.
test-ecosystem:
name: Ecosystem E2E (live server)
runs-on: ubuntu-latest
timeout-minutes: 20
# needs: changes (not build) — the runner boots its own dev server.
needs: changes
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run check:node-runtime
- run: npm run test:ecosystem
test-protocols-e2e:
name: Protocol Clients E2E (live server, advisory)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: changes
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
# ADVISORY until #10049 is resolved. Restoring this suite immediately surfaced a
# real discrepancy that had been invisible while it could not run: GET
# /api/mcp/audit answers 403 over loopback where the suite expects 200|401. That
# is a pre-existing contract question, not a defect introduced by wiring the job
# up, so it must not block every PR in the meantime. Flip to blocking (drop this
# continue-on-error) the moment #10049 lands.
continue-on-error: true
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run check:node-runtime
- run: npm run test:protocols:e2e
ci-summary:
name: CI Dashboard
runs-on: ubuntu-latest
@@ -1278,6 +1335,8 @@ jobs:
- test-e2e
- test-integration
- test-security
- test-ecosystem
- test-protocols-e2e
steps:
- name: Download i18n results
continue-on-error: true
@@ -1358,6 +1417,8 @@ jobs:
echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Integration | $(status '${{ needs.test-integration.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Security Tests | $(status '${{ needs.test-security.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Ecosystem E2E | $(status '${{ needs.test-ecosystem.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Protocol Clients E2E (advisory, #10049) | $(status '${{ needs.test-protocols-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "## 🌍 Translations" >> "$GITHUB_STEP_SUMMARY"

View File

@@ -23,7 +23,7 @@
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts",
"prepublishOnly": "npm run clean && npm run build && npm test"
},
"keywords": [

View File

@@ -57,7 +57,12 @@ import type { AuthHook, Config, Plugin, PluginOptions, ProviderHook } from "@ope
import { tool } from "@opencode-ai/plugin";
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import { z } from "zod";
import { logger as _logger, setLogLevel, type LogLevel as _LogLevel } from "./logger.js";
import {
createLogger,
logger as _logger,
type Logger as _Logger,
type LogLevel as _LogLevel,
} from "./logger.js";
import {
PROVIDER_TAG_SEPARATOR as _PROVIDER_TAG_SEPARATOR,
shortProviderLabel as _shortProviderLabel,
@@ -717,6 +722,7 @@ export async function forceSyncOmniRouteModels(args: {
compressionMetaFetcher?: OmniRouteCompressionMetaFetcher;
providersFetcher?: OmniRouteProvidersFetcher;
now?: () => number;
logger?: _Logger;
}): Promise<{
ok: boolean;
count: number;
@@ -737,6 +743,11 @@ export async function forceSyncOmniRouteModels(args: {
const compressionMetaFetcher =
args.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher;
const providersFetcher = args.providersFetcher ?? defaultOmniRouteProvidersFetcher;
const logger =
args.logger ??
createLogger(
resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")
);
const features = resolved.features ?? {};
const wantCombos = features.combos !== false;
const wantAutoCombos = features.autoCombos !== false;
@@ -847,8 +858,8 @@ export async function forceSyncOmniRouteModels(args: {
}
}
console.warn(
`[omniroute-plugin] force sync ok providerId=${resolved.providerId} ` +
logger.info(
`force sync ok providerId=${resolved.providerId} ` +
`models=${rawModels.length} combos=${rawCombos.length} ` +
`clearedMemory=${clearedMemory + clearedAll} disk=${clearedDisk}`
);
@@ -880,6 +891,7 @@ export async function forceSyncOmniRouteModels(args: {
export function createOmniRouteSyncModelsTool(args: {
resolved: ResolvedOmniRoutePluginOptions;
cache: OmniRouteFetchCache;
logger?: _Logger;
}): ReturnType<typeof tool> {
const { resolved, cache } = args;
return tool({
@@ -893,7 +905,7 @@ export function createOmniRouteSyncModelsTool(args: {
.describe("Optional reason for the sync (logging only)"),
},
async execute(toolArgs) {
const result = await forceSyncOmniRouteModels({ resolved, cache });
const result = await forceSyncOmniRouteModels({ resolved, cache, logger: args.logger });
const reason = toolArgs.reason ? ` reason=${toolArgs.reason}` : "";
if (!result.ok) {
return {
@@ -932,10 +944,16 @@ export function startOmniRouteAutoSync(args: {
resolved: ResolvedOmniRoutePluginOptions;
cache: OmniRouteFetchCache;
intervalMs?: number;
logger?: _Logger;
}): () => void {
const resolved = args.resolved;
const cache = args.cache;
const intervalMs = args.intervalMs ?? resolved.autoSyncIntervalMs;
const logger =
args.logger ??
createLogger(
resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")
);
if (!intervalMs || intervalMs <= 0) {
return () => {};
}
@@ -948,11 +966,9 @@ export function startOmniRouteAutoSync(args: {
if (stopped) return;
if (inFlight) return;
inFlight = (async () => {
const result = await forceSyncOmniRouteModels({ resolved, cache });
const result = await forceSyncOmniRouteModels({ resolved, cache, logger });
if (!result.ok) {
console.warn(
`[omniroute-plugin] auto-sync failed providerId=${resolved.providerId}: ${result.error}`
);
logger.error(`auto-sync failed providerId=${resolved.providerId}: ${result.error}`);
return;
}
if (lastCount === undefined) {
@@ -960,15 +976,15 @@ export function startOmniRouteAutoSync(args: {
return;
}
if (result.count !== lastCount) {
console.warn(
`[omniroute-plugin] auto-sync catalog size changed ${lastCount}${result.count} ` +
logger.info(
`auto-sync catalog size changed ${lastCount}${result.count} ` +
`(providerId=${resolved.providerId})`
);
lastCount = result.count;
}
})()
.catch((err) => {
console.warn("[omniroute-plugin] auto-sync tick error", err);
logger.error(`auto-sync tick error: ${err instanceof Error ? err.message : String(err)}`);
})
.finally(() => {
inFlight = null;
@@ -982,9 +998,7 @@ export function startOmniRouteAutoSync(args: {
timer.unref();
}
console.warn(
`[omniroute-plugin] auto-sync enabled intervalMs=${intervalMs} providerId=${resolved.providerId}`
);
logger.info(`auto-sync enabled intervalMs=${intervalMs} providerId=${resolved.providerId}`);
return () => {
stopped = true;
@@ -994,6 +1008,9 @@ export function startOmniRouteAutoSync(args: {
export const OmniRoutePlugin: Plugin = async (_input, options) => {
const resolved = resolveOmniRoutePluginOptions(coercePluginOptions(options));
const logger = createLogger(
resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")
);
// T-07: a single per-plugin-instance cache shared between the provider
// hook (T-03/T-05) and the config-shim hook (T-07). On OC ≥1.14.49 both
// hooks fire within the same Plugin invocation, so a shared cache keeps
@@ -1010,7 +1027,7 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
const _hash: string =
((globalThis as Record<string, unknown>).__PLUGIN_GIT_HASH__ as string) ?? "unknown";
const _prefixes = resolved.features?.apiFormat?.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES;
_logger.always(
logger.info(
`v${_ver} (${_hash}) initialized` +
` providerId=${resolved.providerId}` +
` baseURL=${resolved.baseURL ?? "(from auth.json)"}` +
@@ -1020,14 +1037,11 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
` logLevel=${resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")}`
);
// Wire log level: startupDebug:true → "debug", explicit logLevel wins.
setLogLevel(resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn"));
// Background auto-discovery while the harness is running (Pi parity).
// Interval 0 disables. TTL on-demand discovery still works via modelCacheTtl.
startOmniRouteAutoSync({ resolved, cache: sharedCache });
startOmniRouteAutoSync({ resolved, cache: sharedCache, logger });
const syncTool = createOmniRouteSyncModelsTool({ resolved, cache: sharedCache });
const syncTool = createOmniRouteSyncModelsTool({ resolved, cache: sharedCache, logger });
const bareProviderId = resolved.omnirouteProviderId;
// Config hook: keep existing catalog shim, and register slash command
@@ -1035,6 +1049,7 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
// Pi-style registerCommand API; tools + command templates are the native path).
const baseConfigHook = createOmniRouteConfigHook(resolved, {
cache: sharedCache,
logger,
diskSnapshotReader: defaultDiskSnapshotReader,
diskSnapshotWriter: defaultDiskSnapshotWriter,
});
@@ -4256,6 +4271,7 @@ export function buildStaticProviderEntry(
rawAutoCombos?: OmniRouteRawAutoCombo[]
): OmniRouteStaticProviderEntry {
const models: Record<string, OmniRouteStaticModelEntry> = {};
const rawModelKeys = new Set<string>();
// usableOnly filter — compute once when feature enabled AND we have
// connection data to filter against. Soft-fail (empty connections list)
@@ -4412,7 +4428,9 @@ export function buildStaticProviderEntry(
// provider prefix (`<providerId>/<raw-id>`) is unreachable. Keys are the
// raw id verbatim; ids that already contain `/` (e.g. `cc/claude-opus-4-7`)
// keep it because the slash is part of the upstream model id itself.
models[raw.id] = entry;
const key = raw.id;
models[key] = entry;
rawModelKeys.add(key);
}
// Combo entries → stripped LCD shape. Each combo is keyed as
@@ -4609,8 +4627,9 @@ export function buildStaticProviderEntry(
// (`opencode-omniroute/opencode-omniroute/<slug>`), and `parseModel()`
// resolves credentials for the nonexistent provider `opencode-omniroute`
// instead of `omniroute`. See #7976.
models[buildComboKey(combo, usedComboKeys, opts.omnirouteProviderId).split("/").pop()!] =
entry;
const key = buildComboKey(combo, usedComboKeys, opts.omnirouteProviderId).split("/").pop()!;
models[key] = entry;
rawModelKeys.delete(key);
// Make this combo's resolved entry available to parent combos
// that reference it via combo-ref. Use the friendly name since
@@ -4645,8 +4664,10 @@ export function buildStaticProviderEntry(
// Use the variant as the key: "auto", "auto/coding", etc.
const key = autoComboModelId(autoCombo.variant);
if (models[key]) {
// Collision with a raw model or DB combo — auto combo wins (log once)
if (!reportedCollisions.has(key)) {
// `/v1/models` mirrors auto combos under the same stable id. Replacing
// that expected raw twin is silent; every other collision still warns.
const isExpectedRawTwin = autoCombo.id === key && rawModelKeys.has(key);
if (!isExpectedRawTwin && !reportedCollisions.has(key)) {
reportedCollisions.add(key);
console.warn(
`[omniroute-plugin] auto combo key "${key}" collides with an existing model; auto combo wins.`
@@ -4654,6 +4675,7 @@ export function buildStaticProviderEntry(
}
}
models[key] = entry;
rawModelKeys.delete(key);
}
}
@@ -5136,13 +5158,13 @@ export const defaultReadAuthJson: OmniRouteReadAuthJson = async () => {
* `auth.json[providerId].baseURL`),
* (e) `input.provider[providerId]` is ALREADY set (operator override
* wins — we never clobber manually-curated catalogs).
* Each no-op path emits ONE debug-level breadcrumb to `console.warn`
* Each no-op path emits ONE debug-level breadcrumb through the leveled logger
* so the operator can diagnose without log spam. Malformed `auth.json`
* warns once and continues as if the file were missing.
* - Fail-open on fetcher errors: a `/v1/models` failure → still publish
* a stub `{models: {}}` provider block (so OC has a complete-shape
* entry to render). A `/api/combos` failure → publish models-only.
* Both paths emit ONE `console.warn`.
* Both paths emit ONE error-level logger message.
* - When the provider hook (T-03/T-05) has ALREADY populated the shared
* cache for this (baseURL, apiKey) tuple, we reuse the raw payloads
* directly — no second fetch. (And vice-versa: the config hook fires
@@ -5165,8 +5187,8 @@ export const defaultReadAuthJson: OmniRouteReadAuthJson = async () => {
* - `cache` — shared fetch-result cache (see
* `OmniRouteFetchCache`). Pass the same Map the
* provider hook owns to dedupe round-trips.
* - `logger` — `{warn}` sink for breadcrumb capture in tests.
* Defaults to `console`.
* - `logger` — injected sink for breadcrumb capture in tests.
* Defaults to the plugin's leveled logger.
*/
export function createOmniRouteConfigHook(
opts?: OmniRoutePluginOptions,
@@ -5182,7 +5204,11 @@ export function createOmniRouteConfigHook(
diskSnapshotWriter?: OmniRouteDiskSnapshotWriter;
now?: () => number;
cache?: OmniRouteFetchCache;
logger?: { warn: (...args: unknown[]) => void };
logger?: {
error?: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
debug?: (message: string, ...args: unknown[]) => void;
};
} = {}
): (input: Config) => Promise<void> {
const resolved = resolveOmniRoutePluginOptions(opts);
@@ -5198,7 +5224,11 @@ export function createOmniRouteConfigHook(
const diskSnapshotWriter = deps.diskSnapshotWriter ?? noopDiskSnapshotWriter;
const now = deps.now ?? Date.now;
const cache: OmniRouteFetchCache = deps.cache ?? new Map();
const logger = deps.logger ?? console;
const logger = deps.logger ?? _logger;
const logAt = (level: "error" | "warn" | "debug", message: string): void => {
const sink = logger[level] ?? logger.warn;
sink.call(logger, message);
};
const features = resolved.features ?? {};
const wantAutoCombos = features.autoCombos !== false;
const wantEnrichment = features.enrichment !== false;
@@ -5213,9 +5243,7 @@ export function createOmniRouteConfigHook(
// generated block. Detect-and-respect before any I/O.
const existingProviders = (input as { provider?: Record<string, unknown> }).provider;
if (existingProviders && existingProviders[resolved.providerId] !== undefined) {
logger.warn(
`[omniroute-plugin] config shim skipped: provider.${resolved.providerId} already set by user`
);
logAt("debug", `config shim skipped: provider.${resolved.providerId} already set by user`);
return;
}
@@ -5230,7 +5258,7 @@ export function createOmniRouteConfigHook(
}
if (authJson === null) {
logger.warn("[omniroute-plugin] config shim: auth.json failed to parse; treating as missing");
logAt("warn", "config shim: auth.json failed to parse; treating as missing");
authJson = undefined;
}
@@ -5257,9 +5285,7 @@ export function createOmniRouteConfigHook(
// (c) no apiKey — silent no-op (with debug breadcrumb). The operator
// hasn't run `/connect <providerId>` yet, OR the stored credential
// isn't api-flavored. OC will handle the `/connect` flow at runtime.
logger.warn(
`[omniroute-plugin] config shim skipped: no apiKey for providerId=${resolved.providerId}`
);
logAt("debug", `config shim skipped: no apiKey for providerId=${resolved.providerId}`);
return;
}
// Management-plane catalog reads may use a narrower read-only token.
@@ -5272,9 +5298,7 @@ export function createOmniRouteConfigHook(
const storedBaseURL = entry && typeof entry.baseURL === "string" ? entry.baseURL : undefined;
const baseURL = resolved.baseURL ?? storedBaseURL ?? "";
if (!baseURL) {
logger.warn(
`[omniroute-plugin] config shim skipped: no baseURL for providerId=${resolved.providerId}`
);
logAt("debug", `config shim skipped: no baseURL for providerId=${resolved.providerId}`);
return;
}
@@ -5319,8 +5343,9 @@ export function createOmniRouteConfigHook(
// Log snapshot age (accept any age — instant beats empty).
const age = (snapshotResult as { writtenAt?: number }).writtenAt;
const ageLabel = typeof age === "number" ? `${Math.round((Date.now() - age) / 3_600_000)}h` : "unknown";
logger.warn(
`[omniroute-plugin] config shim: warm startup from disk snapshot (${snapshotResult.rawModels.length} models, age ${ageLabel})`
logAt(
"warn",
`config shim: warm startup from disk snapshot (${snapshotResult.rawModels.length} models, age ${ageLabel})`
);
}
}
@@ -5346,9 +5371,9 @@ export function createOmniRouteConfigHook(
try {
localRawModels = await fetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /v1/models fetch failed; publishing stub provider entry",
err
logAt(
"error",
`config shim: /v1/models fetch failed; publishing stub provider entry: ${err instanceof Error ? err.message : String(err)}`
);
localRawModels = [];
modelsFetchThrew = true;
@@ -5359,9 +5384,9 @@ export function createOmniRouteConfigHook(
try {
localRawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog",
err
logAt(
"error",
`config shim: /api/combos fetch failed; publishing models-only static catalog: ${err instanceof Error ? err.message : String(err)}`
);
}
};
@@ -5380,9 +5405,9 @@ export function createOmniRouteConfigHook(
try {
localRawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog",
err
logAt(
"error",
`config shim: /api/pricing/models fetch failed; publishing raw-id static catalog: ${err instanceof Error ? err.message : String(err)}`
);
}
};
@@ -5392,9 +5417,9 @@ export function createOmniRouteConfigHook(
try {
localRawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/context/combos fetch failed; publishing combos without compression suffix",
err
logAt(
"error",
`config shim: /api/context/combos fetch failed; publishing combos without compression suffix: ${err instanceof Error ? err.message : String(err)}`
);
}
};
@@ -5404,9 +5429,9 @@ export function createOmniRouteConfigHook(
try {
localRawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh",
err
logAt(
"error",
`config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh: ${err instanceof Error ? err.message : String(err)}`
);
}
};
@@ -5429,8 +5454,9 @@ export function createOmniRouteConfigHook(
if (modelsFetchThrew && wantDiskCache && !warmSnapshot) {
const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint);
if (snapshot && snapshot.rawModels.length > 0) {
logger.warn(
`[omniroute-plugin] config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
logAt(
"warn",
`config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
);
localRawModels = snapshot.rawModels;
localRawCombos = snapshot.rawCombos;
@@ -5452,6 +5478,7 @@ export function createOmniRouteConfigHook(
rawConnections: localRawConnections,
expiresAt: now() + resolved.modelCacheTtl,
});
});
// Startup diagnostics (file-based) — fires at startup via config hook
if (resolved.features?.startupDebug === true) {
@@ -5530,7 +5557,10 @@ export function createOmniRouteConfigHook(
} else {
const refreshP = doRefresh()
.catch((err: unknown) => {
logger.warn("[omniroute-plugin] config shim: background refresh failed", err);
logAt(
"error",
`config shim: background refresh failed: ${err instanceof Error ? err.message : String(err)}`
);
})
.finally(() => {
_inflightRefresh.delete(cacheKey);
@@ -5556,7 +5586,10 @@ export function createOmniRouteConfigHook(
} else {
const refreshP = doRefresh()
.catch((err: unknown) => {
logger.warn("[omniroute-plugin] config shim: refresh failed", err);
logAt(
"error",
`config shim: refresh failed: ${err instanceof Error ? err.message : String(err)}`
);
})
.finally(() => {
_inflightRefresh.delete(cacheKey);
@@ -5611,8 +5644,9 @@ export function createOmniRouteConfigHook(
if (features.mcpAutoEmit === true) {
const mcpKey = features.mcpToken ?? apiKey;
if (!mcpKey) {
logger.warn(
`[omniroute-plugin] mcp auto-emit skipped: no Bearer token for providerId=${resolved.providerId}`
logAt(
"debug",
`mcp auto-emit skipped: no Bearer token for providerId=${resolved.providerId}`
);
} else {
const inputWithMcp = input as { mcp?: Record<string, unknown> };
@@ -5620,9 +5654,7 @@ export function createOmniRouteConfigHook(
inputWithMcp.mcp = {};
}
if (inputWithMcp.mcp[resolved.providerId] !== undefined) {
logger.warn(
`[omniroute-plugin] mcp auto-emit skipped: mcp.${resolved.providerId} already set by user`
);
logAt("debug", `mcp auto-emit skipped: mcp.${resolved.providerId} already set by user`);
} else {
// Strip a trailing `/v1` from baseURL when present so we land on
// the MCP transport at /api/mcp/stream, not /v1/api/mcp/stream.

View File

@@ -36,39 +36,47 @@ function fmt(level: LogLevel, msg: string, tag?: string): string {
return `${prefix} [${level.toUpperCase()}] ${msg}`;
}
export const logger = {
error(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "error")) console.error(fmt("error", msg), ...args);
},
warn(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "warn")) console.warn(fmt("warn", msg), ...args);
},
info(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "info")) console.warn(fmt("info", msg), ...args);
},
debug(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "debug")) console.warn(fmt("debug", msg), ...args);
},
/** Always emit regardless of level (for critical init breadcrumbs). */
always(msg: string, ...args: unknown[]): void {
console.warn(TAG, msg, ...args);
},
function buildLogger(getLevel: () => LogLevel) {
return {
error(msg: string, ...args: unknown[]): void {
if (shouldLog(getLevel(), "error")) console.error(fmt("error", msg), ...args);
},
warn(msg: string, ...args: unknown[]): void {
if (shouldLog(getLevel(), "warn")) console.warn(fmt("warn", msg), ...args);
},
info(msg: string, ...args: unknown[]): void {
if (shouldLog(getLevel(), "info")) console.warn(fmt("info", msg), ...args);
},
debug(msg: string, ...args: unknown[]): void {
if (shouldLog(getLevel(), "debug")) console.warn(fmt("debug", msg), ...args);
},
/** Always emit regardless of level (for critical init breadcrumbs). */
always(msg: string, ...args: unknown[]): void {
console.warn(TAG, msg, ...args);
},
// ── Tagged child loggers ──────────────────────────────────────────────
child(tag: string) {
return {
error: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "error") &&
console.error(fmt("error", msg, tag), ...args),
warn: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "warn") &&
console.warn(fmt("warn", msg, tag), ...args),
info: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "info") &&
console.warn(fmt("info", msg, tag), ...args),
debug: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "debug") &&
console.warn(fmt("debug", msg, tag), ...args),
};
},
};
// ── Tagged child loggers ────────────────────────────────────────────
child(tag: string) {
return {
error: (msg: string, ...args: unknown[]) =>
shouldLog(getLevel(), "error") && console.error(fmt("error", msg, tag), ...args),
warn: (msg: string, ...args: unknown[]) =>
shouldLog(getLevel(), "warn") && console.warn(fmt("warn", msg, tag), ...args),
info: (msg: string, ...args: unknown[]) =>
shouldLog(getLevel(), "info") && console.warn(fmt("info", msg, tag), ...args),
debug: (msg: string, ...args: unknown[]) =>
shouldLog(getLevel(), "debug") && console.warn(fmt("debug", msg, tag), ...args),
};
},
};
}
export type Logger = ReturnType<typeof buildLogger>;
/** Create an instance-scoped logger whose level cannot be changed by other plugin instances. */
export function createLogger(level: LogLevel): Logger {
return buildLogger(() => level);
}
/** Backward-compatible module-global logger controlled by setLogLevel(). */
export const logger: Logger = buildLogger(() => _level);

View File

@@ -13,6 +13,22 @@ import {
forceSyncOmniRouteModels,
type OmniRouteFetchCache,
} from "../src/index.js";
import { getLogLevel, setLogLevel } from "../src/logger.js";
async function captureConsole(run: () => Promise<void>): Promise<string[]> {
const lines: string[] = [];
const originalError = console.error;
const originalWarn = console.warn;
console.error = (...args: unknown[]) => lines.push(args.map(String).join(" "));
console.warn = (...args: unknown[]) => lines.push(args.map(String).join(" "));
try {
await run();
} finally {
console.error = originalError;
console.warn = originalWarn;
}
return lines;
}
test("sanitizeAutoSyncIntervalMs: unset → default 300000", () => {
assert.equal(sanitizeAutoSyncIntervalMs(undefined), DEFAULT_AUTO_SYNC_INTERVAL_MS);
@@ -35,7 +51,10 @@ test("sanitizeAutoSyncIntervalMs: keeps valid values", () => {
test("parseOmniRoutePluginOptions accepts autoSyncIntervalMs including 0", () => {
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 0 }).autoSyncIntervalMs, 0);
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 120_000 }).autoSyncIntervalMs, 120_000);
assert.equal(
parseOmniRoutePluginOptions({ autoSyncIntervalMs: 120_000 }).autoSyncIntervalMs,
120_000
);
});
test("resolveOmniRoutePluginOptions defaults autoSyncIntervalMs to 300000", () => {
@@ -112,6 +131,76 @@ test("forceSyncOmniRouteModels: fetches, populates cache, returns count", async
assert.equal(entry.expiresAt, 1_000_000 + resolved.modelCacheTtl);
});
test("forceSyncOmniRouteModels suppresses successful lifecycle output at error level", async () => {
const previousLevel = getLogLevel();
const cache: OmniRouteFetchCache = new Map();
const resolved = resolveOmniRoutePluginOptions({
providerId: "omniroute",
baseURL: "https://omniroute.example/v1",
features: {
autoCombos: false,
combos: false,
compressionMetadata: false,
diskCache: false,
enrichment: false,
logLevel: "error",
usableOnly: false,
},
});
try {
setLogLevel("error");
const lines = await captureConsole(async () => {
const result = await forceSyncOmniRouteModels({
resolved,
cache,
readAuthJson: async () => ({ omniroute: { type: "api", key: "test-key" } }),
fetcher: async () => [{ id: "model-a", object: "model" }],
});
assert.equal(result.ok, true);
});
assert.deepEqual(lines, []);
} finally {
setLogLevel(previousLevel);
}
});
test("forceSyncOmniRouteModels preserves successful lifecycle output at info level", async () => {
const previousLevel = getLogLevel();
const cache: OmniRouteFetchCache = new Map();
const resolved = resolveOmniRoutePluginOptions({
providerId: "omniroute",
baseURL: "https://omniroute.example/v1",
features: {
autoCombos: false,
combos: false,
compressionMetadata: false,
diskCache: false,
enrichment: false,
logLevel: "info",
usableOnly: false,
},
});
try {
setLogLevel("info");
const lines = await captureConsole(async () => {
const result = await forceSyncOmniRouteModels({
resolved,
cache,
readAuthJson: async () => ({ omniroute: { type: "api", key: "test-key" } }),
fetcher: async () => [{ id: "model-a", object: "model" }],
});
assert.equal(result.ok, true);
});
assert.equal(lines.filter((line) => line.includes("force sync ok")).length, 1);
} finally {
setLogLevel(previousLevel);
}
});
test("forceSyncOmniRouteModels: missing auth returns error", async () => {
const cache: OmniRouteFetchCache = new Map();
const resolved = resolveOmniRoutePluginOptions({

View File

@@ -763,6 +763,37 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => {
assert.ok(block.models["claude-sonnet-4-6"]);
});
test("buildStaticProviderEntry: expected raw auto twin does not warn and auto combo wins", () => {
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(" "));
let block: OmniRouteStaticProviderEntry;
try {
block = buildStaticProviderEntry(
[{ id: "auto/coding" }],
[],
resolved,
"https://or.example/v1",
"sk-test",
undefined,
undefined,
undefined,
[{ id: "auto/coding", name: "Auto Coding", variant: "coding", candidateCount: 5 }]
);
} finally {
console.warn = originalWarn;
}
assert.equal(Object.keys(block.models).filter((key) => key === "auto/coding").length, 1);
assert.equal(block.models["auto/coding"].tool_call, true, "auto-combo entry wins over raw twin");
assert.deepEqual(
warnings.filter((warning) => warning.includes("collides with an existing model")),
[]
);
});
// ────────────────────────────────────────────────────────────────────────────
// Schema parity (modalities / cost / release_date / limit cleanup)
// ────────────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,218 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { Config } from "@opencode-ai/plugin";
import { createOmniRouteConfigHook, OmniRoutePlugin } from "../src/index.js";
import { getLogLevel, logger, setLogLevel, type LogLevel } from "../src/logger.js";
type ConsoleMethod = "error" | "info" | "log" | "warn";
type ConsoleEntries = Record<ConsoleMethod, unknown[][]>;
const fakeInput = {} as Parameters<typeof OmniRoutePlugin>[0];
const consoleMethods: ConsoleMethod[] = ["error", "info", "log", "warn"];
async function captureConsole(run: () => Promise<void>): Promise<ConsoleEntries> {
const entries: ConsoleEntries = { error: [], info: [], log: [], warn: [] };
const originals = Object.fromEntries(
consoleMethods.map((method) => [method, console[method]])
) as Record<ConsoleMethod, typeof console.warn>;
for (const method of consoleMethods) {
console[method] = (...args: unknown[]) => {
entries[method].push(args);
};
}
try {
await run();
} finally {
for (const method of consoleMethods) console[method] = originals[method];
}
return entries;
}
function rendered(entries: ConsoleEntries): string[] {
return consoleMethods.flatMap((method) =>
entries[method].map((args) => args.map((arg) => String(arg)).join(" "))
);
}
async function capturePluginLifecycle(args: {
level: LogLevel;
autoSyncIntervalMs: number;
invokeConfig?: boolean;
}): Promise<string[]> {
const previousDataDir = process.env.OPENCODE_DATA_DIR;
const previousLevel = getLogLevel();
const dataDir = await mkdtemp(join(tmpdir(), "omniroute-log-level-"));
process.env.OPENCODE_DATA_DIR = dataDir;
try {
const entries = await captureConsole(async () => {
const hooks = await OmniRoutePlugin(fakeInput, {
autoSyncIntervalMs: args.autoSyncIntervalMs,
features: { logLevel: args.level },
});
if (args.invokeConfig) {
assert.equal(typeof hooks.config, "function");
await hooks.config!({} as Config);
}
});
return rendered(entries);
} finally {
setLogLevel(previousLevel);
if (previousDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = previousDataDir;
await rm(dataDir, { recursive: true, force: true });
}
}
test("logLevel error suppresses the initialization banner", async () => {
const lines = await capturePluginLifecycle({ level: "error", autoSyncIntervalMs: 0 });
assert.equal(lines.filter((line) => line.includes("initialized")).length, 0);
});
test("logLevel error suppresses the auto-sync enabled lifecycle message", async () => {
const lines = await capturePluginLifecycle({ level: "error", autoSyncIntervalMs: 60_000 });
assert.equal(lines.filter((line) => line.includes("auto-sync enabled")).length, 0);
});
test("logLevel error suppresses factory config-shim diagnostics", async () => {
const lines = await capturePluginLifecycle({
level: "error",
autoSyncIntervalMs: 0,
invokeConfig: true,
});
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 0);
});
test("logLevel debug preserves startup and config-shim diagnostics", async () => {
const lines = await capturePluginLifecycle({
level: "debug",
autoSyncIntervalMs: 60_000,
invokeConfig: true,
});
assert.ok(
lines.some((line) => line.includes("initialized")),
"initialization banner emitted"
);
assert.ok(
lines.some((line) => line.includes("auto-sync enabled")),
"auto-sync message emitted"
);
assert.ok(
lines.some((line) => line.includes("config shim skipped")),
"config breadcrumb emitted"
);
});
test("debug instance retains config diagnostics after an error instance is created", async () => {
const lines = rendered(
await captureConsole(async () => {
const debugHooks = await OmniRoutePlugin(fakeInput, {
autoSyncIntervalMs: 0,
features: { logLevel: "debug" },
});
await OmniRoutePlugin(fakeInput, {
autoSyncIntervalMs: 0,
features: { logLevel: "error" },
});
await debugHooks.config!({} as Config);
})
);
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 1);
});
test("error instance keeps config diagnostics suppressed after a debug instance is created", async () => {
const lines = rendered(
await captureConsole(async () => {
const errorHooks = await OmniRoutePlugin(fakeInput, {
autoSyncIntervalMs: 0,
features: { logLevel: "error" },
});
await OmniRoutePlugin(fakeInput, {
autoSyncIntervalMs: 0,
features: { logLevel: "debug" },
});
await errorHooks.config!({} as Config);
})
);
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 0);
});
test("error-level config fetch failures remain visible as concise injected-logger messages", async () => {
const entries: unknown[][] = [];
const hook = createOmniRouteConfigHook(
{
baseURL: "https://omniroute.example/v1",
features: {
autoCombos: false,
diskCache: false,
enrichment: false,
logLevel: "error",
},
},
{
readAuthJson: async () => ({
"opencode-omniroute": { type: "api", key: "test-key" },
}),
fetcher: async () => {
throw new Error("models unavailable");
},
combosFetcher: async () => {
throw new Error("combos unavailable");
},
logger: {
warn: (...args: unknown[]) => {
entries.push(args);
},
},
}
);
await hook({} as Config);
assert.equal(entries.length, 2, "both genuine fetch failures remain visible");
assert.deepEqual(
entries.map((args) => args.length),
[1, 1],
"each failure is emitted as one concise argument"
);
const lines = entries.map(([message]) => String(message));
assert.ok(
lines.some((line) => line.includes("/v1/models") && line.includes("models unavailable"))
);
assert.ok(
lines.some((line) => line.includes("/api/combos") && line.includes("combos unavailable"))
);
assert.equal(
entries.flat().some((arg) => arg instanceof Error),
false,
"no raw Error object emitted"
);
});
test("logger error output remains visible at error level", async () => {
const previousLevel = getLogLevel();
try {
setLogLevel("error");
const lines = rendered(
await captureConsole(async () => {
logger.error("genuine startup failure");
})
);
assert.ok(lines.some((line) => line.includes("genuine startup failure")));
} finally {
setLogLevel(previousLevel);
}
});

View File

@@ -434,16 +434,16 @@ For any non-trivial change, read the matching deep-dive first:
## Testing
| What | Command |
| ----------------------- | --------------------------------------------------------------------------- |
| Unit tests | `npm run test:unit` |
| Single file | `node --import tsx/esm --test tests/unit/your-file.test.ts` |
| Vitest (MCP, autoCombo) | `npm run test:vitest` |
| E2E (Playwright) | `npm run test:e2e` |
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` |
| Ecosystem | `npm run test:ecosystem` |
| Coverage gate | `npm run test:coverage` (60/60/60/60 — statements/lines/functions/branches) |
| Coverage report | `npm run coverage:report` |
| What | Command |
| ----------------------- | ----------------------------------------------------------------------------- |
| Unit tests | `npm run test:unit` |
| Single file | `node --import tsx/esm --test tests/unit/your-file.test.ts` |
| Vitest (MCP, autoCombo) | `npm run test:vitest` |
| E2E (Playwright) | `npm run test:e2e` |
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` (CI job `test-protocols-e2e`, advisory — #10049) |
| Ecosystem | `npm run test:ecosystem` (CI job `test-ecosystem`, blocking) |
| Coverage gate | `npm run test:coverage` (60/60/60/60 — statements/lines/functions/branches) |
| Coverage report | `npm run coverage:report` |
**PR rule**: If you change production code in `src/`, `open-sse/`, `electron/`, or `bin/`, you must include or update tests in the same PR.

View File

@@ -238,7 +238,7 @@ curl http://localhost:20128/v1/chat/completions \
</div>
<p align="center">
<a href="https://platform.kimi.ai?aff=omniroute">
<a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">
<img src="public/sponsors/kimi-k3-banner.png" width="100%" alt="Kimi K3 — Open Frontier Intelligence · 2.8T parameters · 1M-token context"/>
</a>
</p>
@@ -248,7 +248,7 @@ curl http://localhost:20128/v1/chat/completions \
<table>
<tr>
<td align="center" width="150">
<a href="https://platform.kimi.ai?aff=omniroute">
<a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="public/providers/kimi-logomark-dark.svg">
<img src="public/providers/kimi-logomark-light.svg" width="64" alt="Kimi (Moonshot AI)"/>
@@ -260,7 +260,7 @@ curl http://localhost:20128/v1/chat/completions \
<td>
Thanks to <b>Kimi (Moonshot AI)</b>, our founding Open Source Friend, for backing this project! Kimi is the AI lab behind the open-weight K2 and K3 model families — <b>Kimi K3</b> delivers a 1M-token context window, native vision and frontier-level coding at a fraction of closed-model prices, and works out of the box with Claude Code, Codex and every coding tool OmniRoute serves.
<br/><br/>
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?aff=omniroute"><b>Get a Kimi API key →</b></a>
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute"><b>Get a Kimi API key with 15% extra credits →</b></a>
</td>
</tr>
<tr>

View File

@@ -139,6 +139,21 @@ export function shouldRetryError(err, opts = {}) {
return false;
}
/**
* True when a non-2xx status means "this server does not serve this route"
* rather than "your request was wrong".
*
* Commands that keep a local SQLite fallback must not treat these as fatal:
* a CLI newer (or older) than the server it is talking to will hit routes that
* simply are not mounted, and aborting there strands the user with an
* unactionable `HTTP 404` even though the local path would have worked.
* Genuine client errors (400/401/403/409/422 …) stay fatal — retrying them
* locally would paper over a real problem.
*/
export function isRouteUnavailableStatus(status) {
return status === 404 || status === 405 || status === 501;
}
export function statusToExitCode(status) {
if (status >= 200 && status < 300) return 0;
if (status === 408) return 124;

View File

@@ -152,6 +152,7 @@ export async function runComboListCommand(opts = {}) {
return await withRuntime(async ({ kind, api, db }) => {
let combos = [];
let activeCombo = null;
let listError = null;
if (kind === "http") {
const [listRes, activeRes] = await Promise.all([
@@ -161,6 +162,12 @@ export async function runComboListCommand(opts = {}) {
if (listRes.ok) {
const data = await listRes.json();
combos = Array.isArray(data) ? data : (data.combos ?? []);
} else {
// The server answered, but not with a combo list. Falling through to
// an empty array here rendered "No combos configured" — which is
// indistinguishable from genuine emptiness and reads as real state,
// so a transport/auth failure looked like a wiped configuration.
listError = listRes.status;
}
if (activeRes.ok) {
const settings = await activeRes.json();
@@ -171,11 +178,25 @@ export async function runComboListCommand(opts = {}) {
}
if (opts.json || opts.output === "json") {
console.log(JSON.stringify({ combos, active: activeCombo }, null, 2));
return 0;
console.log(
JSON.stringify(
{ combos, active: activeCombo, error: listError && `HTTP ${listError}` },
null,
2
)
);
return listError ? 1 : 0;
}
printHeading(t("combo.title"));
if (listError) {
console.error(
t("common.error", {
message: `could not list combos from the server (HTTP ${listError})`,
})
);
return 1;
}
if (combos.length === 0) {
console.log(t("combo.noCombos"));
return 0;

View File

@@ -288,27 +288,44 @@ async function checkNodeRuntime(rootDir) {
}
}
/**
* Name of the prebuilt binary better-sqlite3 ships for this platform, e.g.
* `linux-x64.node`. Musl-based Linux uses a distinct `linuxmusl-` prefix.
* Mirrors the lookup `prebuild-install`/`node-gyp-build` perform at require time.
*/
export function prebuiltBinaryName(
platform = process.platform,
arch = process.arch,
report = process.report
) {
let prefix = platform;
if (platform === "linux") {
let isMusl = false;
try {
// glibc builds expose `glibcVersionRuntime`; musl builds do not.
isMusl = !report?.getReport?.()?.header?.glibcVersionRuntime;
} catch {
isMusl = false;
}
prefix = isMusl ? "linuxmusl" : "linux";
}
return `${prefix}-${arch}.node`;
}
async function checkNativeBinary(rootDir) {
// node-gyp layout — present only when better-sqlite3 was compiled locally.
const buildRoots = [
path.join(rootDir, "app", "node_modules", "better-sqlite3"),
path.join(rootDir, "dist", "node_modules", "better-sqlite3"),
path.join(rootDir, "node_modules", "better-sqlite3"),
];
const prebuildName = prebuiltBinaryName();
const candidates = [
path.join(
rootDir,
"app",
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
),
path.join(
rootDir,
"dist",
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
),
path.join(rootDir, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"),
...buildRoots.map((root) => path.join(root, "build", "Release", "better_sqlite3.node")),
// Prebuilt layout — what `npm i -g omniroute` actually installs. Without
// these, doctor warns on every prebuilt install even though the binary is
// present and loading fine.
...buildRoots.map((root) => path.join(root, "prebuilds", prebuildName)),
];
const binaryPath = candidates.find((candidate) => fs.existsSync(candidate));
if (!binaryPath) {

View File

@@ -8,7 +8,7 @@ import {
} from "../provider-store.mjs";
import { openOmniRouteDb } from "../sqlite.mjs";
import { loadAvailableProviders } from "../provider-catalog.mjs";
import { apiFetch, isServerUp } from "../api.mjs";
import { apiFetch, isServerUp, isRouteUnavailableStatus } from "../api.mjs";
import { t } from "../i18n.mjs";
function getValidProviderIds() {
@@ -184,7 +184,10 @@ export async function runKeysAddCommand(provider, apiKey, opts = {}) {
console.log(t("keys.added", { provider: providerLower }));
return 0;
}
if (res.status >= 400 && res.status < 500) {
// A missing route means this server does not implement the endpoint —
// fall through to the local SQLite path below rather than stranding the
// user. Real client errors still abort.
if (res.status >= 400 && res.status < 500 && !isRouteUnavailableStatus(res.status)) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
}

View File

@@ -41,11 +41,83 @@ function toYaml(obj, indent = 0) {
.trimStart();
}
// Keys that live alongside operations inside a Path Item Object but are not
// themselves operations (OpenAPI 3.x Path Item fields).
const NON_OPERATION_PATH_KEYS = new Set([
"parameters",
"summary",
"description",
"servers",
"$ref",
]);
/**
* `GET /api/openapi/spec` answers with a compact catalog
* (`{ info, servers, tags, endpoints[], schemas }`) rather than an OpenAPI
* document with a `paths` object, while `dist/docs/openapi.yaml` is a real
* spec. Normalize either shape into the flat rows the CLI renders so the
* commands work against both instead of silently printing nothing.
*/
export function extractEndpoints(spec) {
if (!spec || typeof spec !== "object") return [];
if (spec.paths && typeof spec.paths === "object") {
const rows = [];
for (const [path, pathItem] of Object.entries(spec.paths)) {
if (!pathItem || typeof pathItem !== "object") continue;
for (const [method, def] of Object.entries(pathItem)) {
if (NON_OPERATION_PATH_KEYS.has(method)) continue;
if (!def || typeof def !== "object") continue;
rows.push({
method: method.toUpperCase(),
path,
summary: def.summary ?? def.description ?? "",
operationId: def.operationId,
});
}
}
return rows;
}
if (Array.isArray(spec.endpoints)) {
return spec.endpoints
.filter((entry) => entry && typeof entry === "object" && entry.path)
.map((entry) => ({
method: String(entry.method ?? "GET").toUpperCase(),
path: entry.path,
summary: entry.summary ?? entry.description ?? "",
operationId: entry.operationId,
}));
}
return [];
}
/** Sorted, de-duplicated list of paths across either shape. */
export function extractPaths(spec) {
return [...new Set(extractEndpoints(spec).map((row) => row.path))].sort();
}
function matchesSearch(row, query) {
if (!query) return true;
const needle = query.toLowerCase();
return row.path.includes(query) || String(row.summary).toLowerCase().includes(needle);
}
function validateBasic(spec) {
if (!spec || typeof spec !== "object") throw new Error("spec is not an object");
if (!spec.openapi && !spec.swagger) throw new Error("missing openapi/swagger version field");
if (!spec.info) throw new Error("missing info object");
if (!spec.paths) throw new Error("missing paths object");
// A real OpenAPI document must carry a version field and a paths object.
if (spec.openapi || spec.swagger) {
if (!spec.paths) throw new Error("missing paths object");
return;
}
// The compact catalog served by /api/openapi/spec carries endpoints[] instead.
if (Array.isArray(spec.endpoints)) return;
throw new Error("missing openapi/swagger version field and no endpoints[] catalog");
}
const endpointSchema = [
@@ -132,20 +204,7 @@ export function registerOpenapi(program) {
process.exit(1);
}
const spec = await res.json();
const rows = [];
for (const [path, methods] of Object.entries(spec.paths ?? {})) {
for (const [method, def] of Object.entries(methods)) {
if (["parameters", "summary"].includes(method)) continue;
const summary = def.summary ?? def.description ?? "";
if (
opts.search &&
!path.includes(opts.search) &&
!summary.toLowerCase().includes(opts.search.toLowerCase())
)
continue;
rows.push({ method: method.toUpperCase(), path, summary, operationId: def.operationId });
}
}
const rows = extractEndpoints(spec).filter((row) => matchesSearch(row, opts.search));
emit(rows, cmd.optsWithGlobals(), endpointSchema);
});
@@ -159,9 +218,8 @@ export function registerOpenapi(program) {
process.exit(1);
}
const spec = await res.json();
const paths = Object.keys(spec.paths ?? {}).sort();
emit(
paths.map((p) => ({ path: p })),
extractPaths(spec).map((p) => ({ path: p })),
cmd.optsWithGlobals()
);
});

View File

@@ -129,9 +129,33 @@ function buildTestInput(connection, apiKey) {
}
async function runProviderTest(db, connection) {
// Only API-key connections can be probed with a stored credential. OAuth /
// no-auth connections have nothing for testProviderApiKey() to send, and
// getProviderApiKey() throws for them by design — reporting that as a FAILED
// test marked perfectly healthy OAuth connections as broken *and* persisted
// that verdict to provider_connections.test_status.
if (connection.authType !== "apikey") {
return {
connection: publicConnection(connection),
valid: false,
skipped: true,
error: `No API-key probe for ${connection.authType || "unknown"} connections`,
};
}
try {
const apiKey = getProviderApiKey(connection);
const result = await testProviderApiKey(buildTestInput(connection, apiKey));
// PROVIDER_TEST_CONFIGS only knows a handful of providers; "unsupported"
// means the CLI has no probe recipe, not that the provider is unhealthy.
// Persisting it would overwrite a good test_status with a failure.
if (result.unsupported) {
return {
connection: publicConnection(connection),
...result,
skipped: true,
};
}
updateProviderTestResult(db, connection.id, result);
return {
connection: publicConnection(connection),

View File

@@ -1,11 +1,9 @@
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const CLI_DIR = dirname(fileURLToPath(import.meta.url));
const DEFAULT_ROOT_DIR = join(CLI_DIR, "..", "..");
const require = createRequire(import.meta.url);
export const COMMON_PROVIDERS = [
{ id: "openai", name: "OpenAI" },
@@ -17,94 +15,201 @@ export const COMMON_PROVIDERS = [
];
function normalizeCatalogCategory(exportName) {
const raw = exportName
.replace(/_PROVIDERS$/, "")
.toLowerCase()
.replaceAll("_", "-");
const raw = exportName.split("_PROVIDERS")[0].toLowerCase().replaceAll("_", "-");
if (raw === "apikey") return "api-key";
return raw;
}
function loadTypeScript() {
try {
return require("typescript");
} catch {
return null;
}
}
/**
* Advance past a string literal, template literal, or comment starting at `i`.
* Returns the index just after it, or -1 when `i` does not start one. Keeping
* the scanner string/comment aware is what lets it walk braces safely — provider
* notes routinely contain `{`, `}` and apostrophes.
*/
function skipNonCode(source, i) {
const c = source[i];
function getPropertyName(ts, name) {
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
return name.text;
}
return null;
}
function getObjectProperty(ts, objectLiteral, propertyName) {
return objectLiteral.properties.find(
(property) =>
ts.isPropertyAssignment(property) && getPropertyName(ts, property.name) === propertyName
);
}
function getStringProperty(ts, objectLiteral, propertyName) {
const property = getObjectProperty(ts, objectLiteral, propertyName);
const initializer = property?.initializer;
if (!initializer) return null;
if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) {
return initializer.text;
}
return null;
}
function getBooleanProperty(ts, objectLiteral, propertyName) {
const property = getObjectProperty(ts, objectLiteral, propertyName);
const initializer = property?.initializer;
return initializer?.kind === ts.SyntaxKind.TrueKeyword;
}
function extractProviderBlocks(source, filePath) {
const ts = loadTypeScript();
if (!ts) return [];
const providers = [];
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
sourceFile.forEachChild((node) => {
if (!ts.isVariableStatement(node)) return;
for (const declaration of node.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name)) continue;
const exportName = declaration.name.text;
if (!exportName.endsWith("_PROVIDERS")) continue;
if (!declaration.initializer || !ts.isObjectLiteralExpression(declaration.initializer)) {
if (c === '"' || c === "'" || c === "`") {
for (let j = i + 1; j < source.length; j++) {
if (source[j] === "\\") {
j++;
continue;
}
const category = normalizeCatalogCategory(exportName);
for (const property of declaration.initializer.properties) {
if (!ts.isPropertyAssignment(property)) continue;
if (!ts.isObjectLiteralExpression(property.initializer)) continue;
const key = getPropertyName(ts, property.name);
if (!key) continue;
const id = getStringProperty(ts, property.initializer, "id") || key;
const name = getStringProperty(ts, property.initializer, "name") || id;
providers.push({
id,
name,
category,
alias: getStringProperty(ts, property.initializer, "alias"),
website: getStringProperty(ts, property.initializer, "website"),
deprecated: getBooleanProperty(ts, property.initializer, "deprecated"),
hasFree: getBooleanProperty(ts, property.initializer, "hasFree"),
passthroughModels: getBooleanProperty(ts, property.initializer, "passthroughModels"),
});
}
if (source[j] === c) return j + 1;
}
});
return source.length;
}
if (c === "/" && source[i + 1] === "/") {
const nl = source.indexOf("\n", i);
return nl === -1 ? source.length : nl;
}
if (c === "/" && source[i + 1] === "*") {
const close = source.indexOf("*/", i + 2);
return close === -1 ? source.length : close + 2;
}
return -1;
}
/** Index of the `}` matching the `{` at `openIdx`, or -1. */
function findMatchingBrace(source, openIdx) {
let depth = 0;
for (let i = openIdx; i < source.length; i++) {
const skipped = skipNonCode(source, i);
if (skipped !== -1) {
i = skipped - 1;
continue;
}
if (source[i] === "{") depth++;
else if (source[i] === "}") {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
const MEMBER_KEY = /(?:([A-Za-z_$][\w$]*)|"([^"]*)"|'([^']*)')\s*:/y;
/**
* Parse the direct members of the object literal whose `{` is at `openIdx`.
* Returns `[{ key, value }]` with `value` as the raw source slice.
*/
function parseObjectMembers(source, openIdx) {
// An unbalanced literal (a missing `},` in a large data file — see #10093)
// should not blank the whole catalog: scan to end-of-source so the entries
// before the damage are still recovered.
const matching = findMatchingBrace(source, openIdx);
const close = matching === -1 ? source.length : matching;
const members = [];
let i = openIdx + 1;
while (i < close) {
if (/[\s,;]/.test(source[i])) {
i++;
continue;
}
// The key match MUST be attempted before skipNonCode: quoted keys such as
// `"duckduckgo-web":` start with a quote, and skipping them as string
// literals both loses the entry and desynchronizes the walk, which then
// reports nested keys (`notice`, …) as top-level providers.
MEMBER_KEY.lastIndex = i;
const match = MEMBER_KEY.exec(source);
if (!match) {
const skipped = skipNonCode(source, i);
i = skipped !== -1 ? skipped : i + 1;
continue;
}
const key = match[1] ?? match[2] ?? match[3];
let valueStart = MEMBER_KEY.lastIndex;
while (valueStart < close && /\s/.test(source[valueStart])) valueStart++;
let valueEnd;
if (source[valueStart] === "{" || source[valueStart] === "[") {
const openChar = source[valueStart];
const closeChar = openChar === "{" ? "}" : "]";
let depth = 0;
let j = valueStart;
for (; j < close; j++) {
const s2 = skipNonCode(source, j);
if (s2 !== -1) {
j = s2 - 1;
continue;
}
if (source[j] === openChar) depth++;
else if (source[j] === closeChar) {
depth--;
if (depth === 0) break;
}
}
valueEnd = j + 1;
} else {
let j = valueStart;
for (; j < close; j++) {
const s2 = skipNonCode(source, j);
if (s2 !== -1) {
j = s2 - 1;
continue;
}
if (source[j] === ",") break;
}
valueEnd = j;
}
members.push({ key, value: source.slice(valueStart, valueEnd), valueStart });
// Guarantee forward progress even on malformed input.
i = valueEnd > i ? valueEnd : i + 1;
}
return members;
}
/** First string literal in a raw value (handles `"a" + "b"` continuations). */
function readString(raw) {
if (raw == null) return null;
const match = raw.match(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/);
if (!match) return null;
return (match[1] ?? match[2]).replace(/\\(.)/g, "$1");
}
function readBoolean(raw) {
return String(raw).trim() === "true";
}
const PROVIDER_EXPORT =
/(?:export\s+)?const\s+([A-Z0-9_]*_PROVIDERS[A-Z0-9_]*)\s*(?::[^=]+)?=\s*\{/g;
/**
* Extract provider entries from a catalog source file.
*
* Deliberately dependency-free: `typescript` is a devDependency, so requiring it
* at runtime made this silently return [] on every published install (#10080).
* These files are pure data literals, so a string/comment-aware brace walk is
* both sufficient and stable.
*/
export function extractProviderBlocks(source) {
const providers = [];
PROVIDER_EXPORT.lastIndex = 0;
let exportMatch;
while ((exportMatch = PROVIDER_EXPORT.exec(source)) !== null) {
const exportName = exportMatch[1];
const openIdx = source.indexOf("{", exportMatch.index + exportMatch[0].length - 1);
if (openIdx === -1) continue;
const category = normalizeCatalogCategory(exportName);
for (const entry of parseObjectMembers(source, openIdx)) {
if (!entry.value.startsWith("{")) continue; // spread / non-object member
const fields = new Map(
parseObjectMembers(source, entry.valueStart).map((f) => [f.key, f.value])
);
const id = readString(fields.get("id")) || entry.key;
providers.push({
id,
name: readString(fields.get("name")) || id,
category,
alias: readString(fields.get("alias")),
website: readString(fields.get("website")),
deprecated: readBoolean(fields.get("deprecated")),
hasFree: readBoolean(fields.get("hasFree")),
passthroughModels: readBoolean(fields.get("passthroughModels")),
});
}
// An unbalanced literal (see #10093) yields -1 here. Resetting lastIndex to
// 0 would restart the scan from the top forever, so stop instead — the
// entries recovered above are still returned.
const closeIdx = findMatchingBrace(source, openIdx);
if (closeIdx === -1) break;
PROVIDER_EXPORT.lastIndex = closeIdx + 1;
}
return providers;
}
@@ -126,9 +231,31 @@ function resolveProviderCatalogPath(rootDir, options = {}) {
if (configuredPath) {
return isAbsolute(configuredPath) ? configuredPath : resolve(rootDir, configuredPath);
}
// The catalog used to be one god-file at constants/providers.ts. It was
// decomposed into constants/providers/**, leaving the barrel with nothing but
// re-exports and an empty `FREE_PROVIDERS = {}` — so parsing it alone yielded
// zero providers and the CLI silently fell back to COMMON_PROVIDERS (#10080).
// Prefer the directory; keep the legacy file for older trees.
const catalogDir = join(rootDir, "src", "shared", "constants", "providers");
if (existsSync(catalogDir)) return catalogDir;
return join(rootDir, "src", "shared", "constants", "providers.ts");
}
/** Every .ts catalog file under `dir`, one level of subdirectories deep. */
function collectCatalogFiles(dir) {
const files = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...collectCatalogFiles(full));
} else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
files.push(full);
}
}
return files.sort();
}
export function loadAvailableProviders(options = {}) {
const rootDir = typeof options === "string" ? options : options.rootDir || DEFAULT_ROOT_DIR;
const providersPath = resolveProviderCatalogPath(rootDir, options);
@@ -138,8 +265,10 @@ export function loadAvailableProviders(options = {}) {
}
try {
const source = readFileSync(providersPath, "utf-8");
const providers = extractProviderBlocks(source, providersPath);
const sources = statSync(providersPath).isDirectory()
? collectCatalogFiles(providersPath)
: [providersPath];
const providers = sources.flatMap((file) => extractProviderBlocks(readFileSync(file, "utf-8")));
if (providers.length === 0) return fallbackAvailableProviders();
const seen = new Set();

View File

@@ -93,6 +93,28 @@ function migrateElectronServerEnv(dataDir) {
}
}
/**
* Parse a `.env` value with dotenv-compatible comment handling.
*
* Without this, `KEY=value # note` stored the comment text as part of the
* value. The shipped .env ships exactly such a line for QUOTA_STORE_DRIVER, and
* consumers compare it with `===`, so annotating a variable inline silently
* disabled it (#10100).
*
* Quoted values are returned verbatim — a `#` inside quotes is data. For
* unquoted values a `#` *preceded by whitespace* starts a comment, so
* `pass#word` is preserved.
*/
function parseEnvValue(raw) {
const value = String(raw).trim();
const quoted = value.match(/^(['"])([\s\S]*)\1\s*(?:#.*)?$/);
if (quoted) return quoted[2];
const commentIdx = value.search(/\s#/);
return (commentIdx === -1 ? value : value.slice(0, commentIdx)).trim();
}
function loadEnvFile() {
const envPaths = [];
const loadedEnvPaths = [];
@@ -128,9 +150,8 @@ function loadEnvFile() {
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim();
if (process.env[key] === undefined) {
process.env[key] = value.replace(/^["']|["']$/g, "");
process.env[key] = parseEnvValue(trimmed.slice(eqIdx + 1));
}
}
}

View File

@@ -0,0 +1 @@
- **feat(providers):** publish Poolside's Laguna Preview catalog statically — `poolside/laguna-xs-2.1` and `poolside/laguna-s-2.1` (262144 context, 32768 max completion, tools + reasoning, text-only), so the models are routable and visible before a key is configured instead of only after live discovery. Pins the catalog form of the XS id against the `laguna-xs.2` variant carried by third-party listings. ([#9085](https://github.com/diegosouzapw/OmniRoute/issues/9085))

View File

@@ -0,0 +1 @@
- feat(crof): advertise reasoning-effort tiers (none/low/medium/high/max) for live-discovered and seed models, so the catalog, Playground, and Combo Builder surface <model>-<tier> aliases and requests resolve max upstream

View File

@@ -0,0 +1 @@
- **perf(logging):** bound each scheduled call-log rotation pass to incremental database and filesystem work (#10125)

View File

@@ -0,0 +1 @@
- **fix(streaming):** start early SSE heartbeats when Responses or Messages requests opt into streaming through the request body (#10127)

View File

@@ -0,0 +1 @@
- **fix(combo):** scope session-stickiness bindings to their owning Combo so identical first messages cannot carry a successful target into another priority chain and bypass its configured order (fixes #10136)

View File

@@ -0,0 +1 @@
- **fix(translator):** resolve the Claude thinking output cap with the routed provider so a provider-scoped-only `max_output_tokens` override is no longer invisible to `fitThinkingToMaxTokens()`, which previously let the synthesized `max_tokens` (caller room + thinking budget) go out unbounded and 400 upstream ([#10139](https://github.com/diegosouzapw/OmniRoute/issues/10139))

View File

@@ -0,0 +1 @@
- **docs(settings):** document Thinking Budget modes (passthrough vs auto-strip); fix dashboard i18n key collision that showed Auto Combo routing copy on the thinking tab; clarify independence from compression/cache ([#10169](https://github.com/diegosouzapw/OmniRoute/pull/10169))

View File

@@ -0,0 +1 @@
- **fix(guardrails):** Vision Bridge handles OpenAI Responses `input`/`input_image` requests before combo vision filtering ([#10202](https://github.com/diegosouzapw/OmniRoute/pull/10202)) — thanks @Zartharas

View File

@@ -0,0 +1 @@
- **fix(dashboard):** model-level allowed/blocked param edits now persist when the compatibility popover is closed by clicking outside, and a failed save no longer clears the edit or reports success ([#9013](https://github.com/diegosouzapw/OmniRoute/pull/9013))

View File

@@ -0,0 +1 @@
- **fix(test):** remove live `npm pack` from MCP files unit test (it stalled concurrent `test:unit` via prepare→husky + monorepo pack walk); keep the static #3578 `files` allowlist + negation guards in unit and fold #3821 pack assertions into `check:pack-artifact` / `check:pack-policy` (already `--ignore-scripts`).

View File

@@ -0,0 +1 @@
- fix(discovery): parse upstream reasoning tiers nested under metadata.reasoning.supported_efforts (neuralwatt /v1/models shape) so synced openai-compatible models advertise effort aliases

View File

@@ -0,0 +1 @@
- **fix(providers):** when `OPENCODE_SYNTHESIZE_CLI_HEADERS=true`, a non-CLI client User-Agent (e.g. `curl/8.5.0`, SDKs) on opencode-go/opencode-zen/opencode-free requests is now REPLACED with the synthesized `opencode-cli/1.0.0` instead of being honored — opencode.ai's free tier (`/zen/v1`) returns `FreeUsageLimitError` 429 for generic client UAs egressing from datacenter IPs, which made the #5997 CLI-identity synthesis ineffective for non-CLI clients. Client UAs already matching `opencode-cli/…` are preserved (the real CLI's versioned identity stays intact); all other client-supplied `x-opencode-*` headers keep client-wins. Regression guard: `tests/unit/opencode-cli-headers-synthesis-5997.test.ts` (7, incl. non-CLI UA replaced + CLI UA preserved). (#5997 follow-up)

View File

@@ -127,12 +127,6 @@
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx": {
"TS2322": 2
},
"src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx": {
"TS2739": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx": {
"TS2304": 5
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx": {
"TS2322": 3,
"TS2739": 1,
@@ -147,9 +141,6 @@
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx": {
"TS2503": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx": {
"TS2739": 2
},
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
"TS2322": 1
},

View File

@@ -28,6 +28,7 @@ Simple guides for using OmniRoute — no technical background needed.
- [SETUP_GUIDE.md](guides/SETUP_GUIDE.md) — first-time setup of OmniRoute.
- [USER_GUIDE.md](guides/USER_GUIDE.md) — daily usage of the dashboard and API.
- [THINKING_BUDGET.md](guides/THINKING_BUDGET.md) — thinking/reasoning budget modes (passthrough vs auto-strip).
- [FEATURES.md](guides/FEATURES.md) — dashboard feature gallery.
- [TIERS.md](guides/TIERS.md) — OmniRoute tiers explained (user guide).
- [USAGE_QUOTA_GUIDE.md](guides/USAGE_QUOTA_GUIDE.md) — usage, quota & spend tracking.

View File

@@ -171,6 +171,22 @@ codex -c model_reasoning_effort=low "rename variable x to count"
codex -c model_reasoning_effort=xhigh "design the auth module"
```
Also set a reasoning **summary** so Desktop can render thinking text (not only encrypted blobs):
```toml
# ~/.codex/config.toml
model_reasoning_effort = "xhigh" # or ultra when supported
model_reasoning_summary = "detailed" # auto | concise | detailed | none
```
### OmniRoute Thinking Budget (server setting)
On the OmniRoute host, **Settings → AI → Thinking Budget** must be **`passthrough`** for Codex effort/summary to reach upstream. Mode **`auto` strips** all client `reasoning` / `reasoning_effort` fields and will empty thinking panels even when Codex is configured correctly.
Full guide: [THINKING_BUDGET.md](./THINKING_BUDGET.md).
Compression and prompt cache are independent and keep working under `passthrough`.
---
## Profiles — named configurations per model/workflow

View File

@@ -0,0 +1,89 @@
---
title: "Thinking Budget"
version: 3.8.49
lastUpdated: 2026-08-12
---
# Thinking Budget
> **Dashboard:** Settings → **AI** → Thinking Budget
> **API:** `GET` / `PUT` `/api/settings/thinking-budget`
> **Source:** `open-sse/services/thinkingBudget.ts`
Thinking Budget controls whether OmniRoute **rewrites client thinking/reasoning parameters** on the way to providers. It does **not** turn compression, routing, or prompt cache on or off.
## Modes
| Mode | What OmniRoute does | When to use |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`passthrough`** (default) | Leaves client fields alone (`reasoning`, `reasoning_effort`, Claude `thinking`, Gemini `thinking_config`, etc.). | **Codex / Desktop / any client that should control effort + reasoning summaries.** Required for visible thinking panels when the client requests `reasoning.summary`. |
| **`auto`** | **Strips all** thinking/reasoning fields from the request body before upstream. | Only when you deliberately want the **provider** to invent defaults and you do **not** need client-controlled thinking. **Not** “auto-show thinking”. |
| **`custom`** | Overwrites every request with a fixed thinking token budget. | Hard cap on thinking tokens for all traffic. |
| **`adaptive`** | Scales budget from a base effort using message count, tools, and prompt length. | Soft token control without fully stripping client intent. |
### What `auto` removes
When mode is `auto`, `stripThinkingConfig()` deletes (among others):
- OpenAI / Responses: `reasoning`, `reasoning_effort`
- Claude: `thinking`, and `output_config.effort` when present
- Gemini: `generationConfig.thinking_config` / `thinkingConfig`
If a client (e.g. Codex Desktop) sent `reasoning: { effort: "ultra", summary: "detailed" }`, **auto drops that object**. Upstream may still bill some reasoning tokens, but often returns **empty or encrypted-only** reasoning items — so the UI shows no useful thinking stream.
## What this is **not**
| Feature | Relationship |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| **Compression** (Caveman, RTK, stacked, …) | Separate pipeline. Works under every thinking-budget mode. |
| **Prompt / semantic cache** | Separate. Unaffected by thinking-budget mode. |
| **Combo routing / fallbacks** | Separate. Unaffected. |
| **API-key token limits / cost budgets** | Separate. Unaffected. |
| **Reasoning replay cache** | Multi-turn re-inject for strict providers (DeepSeek, Kimi, Qwen-thinking, …). Not the same as Desktop “show thinking”. |
| **Decrypting `encrypted_content`** | **Impossible.** OpenAI/Codex private reasoning blobs are opaque. OmniRoute never decrypts them (#7095 / #7176 / #7304). |
## Visible thinking (Codex / Responses clients)
For a client to show thinking text you need **all** of:
1. Thinking Budget mode = **`passthrough`** (or custom/adaptive that still leaves summary requests intact enough for the path you use).
2. Client asks for a summary, e.g. Codex `model_reasoning_summary = "detailed"` / `auto` (not `none`).
3. Upstream actually streams `response.reasoning_summary_text.*` (or a non-empty `reasoning.summary` on the item).
If you only get “encrypted private reasoning”, either:
- mode was **`auto`** (client request was stripped), or
- upstream returned `encrypted_content` without summary text (provider limitation; OmniRoute can only surface a placeholder, not plaintext).
## API examples
```bash
# Read
curl -sS https://localhost:20128/api/settings/thinking-budget \
-H "Authorization: Bearer $OMNIROUTE_TOKEN"
# Recommended for Codex / Desktop thinking visibility
curl -sS -X PUT https://localhost:20128/api/settings/thinking-budget \
-H "Authorization: Bearer $OMNIROUTE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"mode":"passthrough","customBudget":10240,"effortLevel":"medium"}'
```
Schema (`updateThinkingBudgetSchema`): `mode``passthrough|auto|custom|adaptive`; optional `customBudget`, `effortLevel`, `baseBudget`, `complexityMultiplier`.
### Persistence / restart
Value is stored under settings key `thinkingBudget` and hydrated at process start (`hydrateThinkingBudgetConfig`). After changing via DB or some non-API paths, **restart the OmniRoute process** so the in-memory singleton matches disk.
## Operator checklist
- [ ] Codex / Desktop users: mode = **passthrough**
- [ ] Compression still enabled if you want token savings on **messages**, not by stripping thinking
- [ ] Do not expect `auto` to “show more thinking”
- [ ] Encrypted-only summaries are a **provider** behavior; passthrough cannot decrypt them
## Related docs
- [REASONING_REPLAY.md](../routing/REASONING_REPLAY.md) — multi-turn `reasoning_content` cache
- [USER_GUIDE.md](./USER_GUIDE.md) — Settings dashboard tabs
- [API_REFERENCE.md](../reference/API_REFERENCE.md) — settings endpoints

View File

@@ -895,15 +895,15 @@ curl -X POST http://localhost:20128/api/db-backups/import \
The settings page is organized into **7 tabs** for easy navigation:
| Tab | Contents |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **General** | System storage tools, default behavior, Endpoint tunnel visibility |
| **Appearance** | Theme controls (light/dark/system), sidebar visibility, panel toggles for Cloudflare/Tailscale/ngrok tunnel cards |
| **AI** | Thinking budget configuration, global system prompt injection, prompt cache stats |
| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, Provider Blocking, prompt-injection guard |
| **Routing** | Global routing strategy (Fill First / Round Robin / P2C / Random / Least Used / Cost Optimized), wildcard model aliases, fallback chains, combo defaults |
| **Resilience** | Request queue, connection cooldown, provider breaker config, and wait-for-cooldown behavior |
| **Advanced** | Global proxy configuration (HTTP/SOCKS5), per-provider proxy overrides |
| Tab | Contents |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **General** | System storage tools, default behavior, Endpoint tunnel visibility |
| **Appearance** | Theme controls (light/dark/system), sidebar visibility, panel toggles for Cloudflare/Tailscale/ngrok tunnel cards |
| **AI** | Thinking budget (passthrough / auto-strip / custom / adaptive — see [THINKING_BUDGET.md](./THINKING_BUDGET.md)), global system prompt, prompt cache stats |
| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, Provider Blocking, prompt-injection guard |
| **Routing** | Global routing strategy (Fill First / Round Robin / P2C / Random / Least Used / Cost Optimized), wildcard model aliases, fallback chains, combo defaults |
| **Resilience** | Request queue, connection cooldown, provider breaker config, and wait-for-cooldown behavior |
| **Advanced** | Global proxy configuration (HTTP/SOCKS5), per-provider proxy overrides |
General no longer duplicates read-only logging and cache notes. Database retention and
optimization settings are persisted through `/api/settings/database`; manual cache clearing uses

View File

@@ -4,6 +4,7 @@
"pages": [
"SETUP_GUIDE",
"USER_GUIDE",
"THINKING_BUDGET",
"DOCKER_GUIDE",
"ELECTRON_GUIDE",
"FEATURES",

View File

@@ -61,24 +61,24 @@ Content-Type: application/json
### Custom Headers
| Header | Direction | Description |
| ------------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-OmniRoute-No-Cache` | Request | Set to `true` to bypass cache |
| `x-omniroute-no-memory` | Request | Set to `true` to skip memory + skills injection for this request (mirrors no-cache; avoids the per-call token/cost overhead) |
| `X-OmniRoute-Progress` | Request | Set to `true` for progress events |
| `X-Session-Id` | Request | Sticky session key for external session affinity |
| `x_session_id` | Request | Underscore variant also accepted (direct HTTP) |
| Header | Direction | Description |
| ------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-OmniRoute-No-Cache` | Request | Set to `true` to bypass cache |
| `x-omniroute-no-memory` | Request | Set to `true` to skip memory + skills injection for this request (mirrors no-cache; avoids the per-call token/cost overhead) |
| `X-OmniRoute-Progress` | Request | Set to `true` for progress events |
| `X-Session-Id` | Request | Sticky session key for external session affinity |
| `x_session_id` | Request | Underscore variant also accepted (direct HTTP) |
| `X-OmniRoute-Session-Id` | Request | Caller-supplied session/conversation tag (also feeds memory). When present, persisted verbatim to `call_logs.session_tag` for per-session cost attribution (#8249) — never synthesized when absent |
| `Idempotency-Key` | Request | Dedup key (5s window) |
| `X-Request-Id` | Request | Alternative dedup key |
| `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) |
| `X-OmniRoute-Idempotent` | Response | `true` if deduplicated |
| `X-OmniRoute-Progress` | Response | `enabled` if progress tracking on |
| `X-OmniRoute-Session-Id` | Response | Effective session ID used by OmniRoute |
| `X-OmniRoute-Request-Id` | Response | Request correlation id (when known) |
| `X-OmniRoute-Version` | Response | OmniRoute build version (always present) |
| `X-OmniRoute-Cost-Saved` | Response | USD the cache avoided on a HIT (cache hits only) |
| `X-OmniRoute-Decision` | Response | Routing trace: `strategy=<name>; provider=<alias>; latency_ms=<n>` (`<name>` is the combo strategy, or `single` for a non-combo request) — always present on completion responses |
| `Idempotency-Key` | Request | Dedup key (5s window) |
| `X-Request-Id` | Request | Alternative dedup key |
| `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) |
| `X-OmniRoute-Idempotent` | Response | `true` if deduplicated |
| `X-OmniRoute-Progress` | Response | `enabled` if progress tracking on |
| `X-OmniRoute-Session-Id` | Response | Effective session ID used by OmniRoute |
| `X-OmniRoute-Request-Id` | Response | Request correlation id (when known) |
| `X-OmniRoute-Version` | Response | OmniRoute build version (always present) |
| `X-OmniRoute-Cost-Saved` | Response | USD the cache avoided on a HIT (cache hits only) |
| `X-OmniRoute-Decision` | Response | Routing trace: `strategy=<name>; provider=<alias>; latency_ms=<n>` (`<name>` is the combo strategy, or `single` for a non-combo request) — always present on completion responses |
> Nginx note: if you rely on underscore headers (for example `x_session_id`), enable `underscores_in_headers on;`.
@@ -349,9 +349,9 @@ Web/search provider abstraction (Tavily, Brave, Exa, Serper, etc.).
Extract content from a URL via a configured web-fetch provider (Firecrawl, Jina
Reader, Tavily Extract, TinyFish Fetch).
| Method | Path | Description |
| ------ | -------------- | ------------------------------------------------------------------------- |
| POST | `/v1/web/fetch` | Fetch/scrape a URL — body validated by `v1WebFetchSchema` |
| Method | Path | Description |
| ------ | --------------- | --------------------------------------------------------- |
| POST | `/v1/web/fetch` | Fetch/scrape a URL — body validated by `v1WebFetchSchema` |
**Auth:** Bearer API key (`extractApiKey` + `isValidApiKey`). Policy enforced via `enforceApiKeyPolicy`.
@@ -561,28 +561,28 @@ X-OmniRoute-No-Cache: true
### Usage & Analytics
| Endpoint | Method | Description |
| --------------------------- | --------------- | ------------------------------- |
| `/api/usage/history` | GET | Usage history |
| `/api/usage/logs` | GET | Usage logs |
| `/api/usage/request-logs` | GET | Request-level logs |
| `/api/usage/[connectionId]` | GET | Per-connection usage |
| `/api/usage/token-limits` | GET/POST/DELETE | Per-API-key token-limit budgets |
| `/api/usage/model-latency-stats` | GET | Rolling per-provider/model latency aggregate (avg/p50/p95/p99, success rate); filters: `windowHours`/`minSamples`/`maxRows`/`provider`/`model` (#6873) |
| `/api/usage/cache-health` | GET | Prompt-cache health summary over `call_logs` — write/read ratio, p50/p90/p99 write-size distribution, heavy-write concentration, per-model split, and a `healthy`/`degraded`/`thrash`/`no-data` verdict; query params `range` (`1h`\|`24h`\|`7d`\|`30d`, default `24h`) and optional `model` (#8827) |
| Endpoint | Method | Description |
| -------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/api/usage/history` | GET | Usage history |
| `/api/usage/logs` | GET | Usage logs |
| `/api/usage/request-logs` | GET | Request-level logs |
| `/api/usage/[connectionId]` | GET | Per-connection usage |
| `/api/usage/token-limits` | GET/POST/DELETE | Per-API-key token-limit budgets |
| `/api/usage/model-latency-stats` | GET | Rolling per-provider/model latency aggregate (avg/p50/p95/p99, success rate); filters: `windowHours`/`minSamples`/`maxRows`/`provider`/`model` (#6873) |
| `/api/usage/cache-health` | GET | Prompt-cache health summary over `call_logs` — write/read ratio, p50/p90/p99 write-size distribution, heavy-write concentration, per-model split, and a `healthy`/`degraded`/`thrash`/`no-data` verdict; query params `range` (`1h`\|`24h`\|`7d`\|`30d`, default `24h`) and optional `model` (#8827) |
### Settings
| Endpoint | Method | Description |
| ------------------------------------- | ------------- | --------------------------------------------------- |
| `/api/settings` | GET/PUT/PATCH | General settings |
| `/api/settings/proxy` | GET/PUT | Network proxy config |
| `/api/settings/proxy/test` | POST | Test proxy connection |
| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist |
| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget |
| `/api/settings/system-prompt` | GET/PUT | Global system prompt |
| `/api/settings/compression` | GET/PUT | Global compression config |
| `/api/settings/purge-request-history` | POST | Clear request log rows and local call-log artifacts |
| Endpoint | Method | Description |
| ------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/api/settings` | GET/PUT/PATCH | General settings |
| `/api/settings/proxy` | GET/PUT | Network proxy config |
| `/api/settings/proxy/test` | POST | Test proxy connection |
| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist |
| `/api/settings/thinking-budget` | GET/PUT | Thinking/reasoning **request** rewrite mode (passthrough / auto-strip / custom / adaptive). Independent of compression. See [THINKING_BUDGET.md](../guides/THINKING_BUDGET.md). |
| `/api/settings/system-prompt` | GET/PUT | Global system prompt |
| `/api/settings/compression` | GET/PUT | Global compression config |
| `/api/settings/purge-request-history` | POST | Clear request log rows and local call-log artifacts |
### Context & Compression

View File

@@ -680,6 +680,7 @@ REQUEST_TIMEOUT_MS (global override)
| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Raw socket timeout (0 = disabled). |
| `SHUTDOWN_TIMEOUT_MS` | `30000` | Grace period on SIGTERM/SIGINT before force-exit. |
| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | Fallback used by `src/shared/utils/fetchTimeout.ts` when `FETCH_TIMEOUT_MS` is unset. |
| `OMNIROUTE_PROVIDER_PROBE_TIMEOUT_MS` | `8000` | Timeout (ms) for the `validationRead` and `modelsProbe` presets in `src/shared/network/safeOutboundFetch.ts`. Raise for slow endpoints (Cerebras, Cloudflare AI, Groq) to prevent flapping between active/error in the dashboard. Falls back to 8000ms for invalid (<1000) or non-numeric values. |
| `OMNIROUTE_RELAY_FETCH_TIMEOUT_MS` | `25000` | Relay-specific fetch timeout in `open-sse/utils/proxyFetch.ts` (#9158). A hung relay must fail before the client/agent timeout (~30s) so callers see a relay-specific failure instead of a generic upstream timeout. Capped at `29000` so it always fires first. |
| `OMNIROUTE_RETRY_BACKOFF_MS` | `10` | Shared retry backoff for the direct/relay/proxy retry-once paths in `open-sse/utils/proxyFetch.ts` (#9158). `0` = retry immediately. |
| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`chatgptTlsClient.ts`). |
@@ -714,6 +715,21 @@ Provider-level circuit breaker tuning. Defaults reflect the scaled values used s
| `OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS` | `30000` | `open-sse/config/constants.ts` | Reset window (ms) for API-key provider breaker. |
| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD` | `2` | `open-sse/config/constants.ts` | Consecutive failure threshold for local providers (Ollama, LM Studio, ...). |
| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS` | `15000` | `open-sse/config/constants.ts` | Reset window (ms) for local provider breaker. |
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_THRESHOLD` | `10` | `open-sse/config/constants.ts` | Provider-level breaker: failures within the window before the entire OAuth provider enters cooldown. |
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_WINDOW_MS` | `900000` | `open-sse/config/constants.ts` | Provider-level breaker: rolling failure-count window (ms) for OAuth providers. |
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_COOLDOWN_MS` | `300000` | `open-sse/config/constants.ts` | Provider-level breaker: cooldown (ms) once the OAuth provider threshold is reached. |
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_DEGRADATION_THRESHOLD` | `5` | `open-sse/config/constants.ts` | OAuth provider enters DEGRADED at this many failures. |
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_MAX_BACKOFF_MULTIPLIER` | `8` | `open-sse/config/constants.ts` | OAuth provider max resetTimeout escalation multiplier. |
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_BACKOFF_ESCALATION_COUNT` | `2` | `open-sse/config/constants.ts` | OAuth provider escalates after this many open cycles. |
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_THRESHOLD` | `15` | `open-sse/config/constants.ts` | Provider-level breaker: failures within the window before the entire API-key provider enters cooldown. |
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS` | `1800000` | `open-sse/config/constants.ts` | Provider-level breaker: rolling failure-count window (ms) for API-key providers. |
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_COOLDOWN_MS` | `600000` | `open-sse/config/constants.ts` | Provider-level breaker: cooldown (ms) once the API-key provider threshold is reached. |
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_DEGRADATION_THRESHOLD` | `7` | `open-sse/config/constants.ts` | API-key provider enters DEGRADED at this many failures. |
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_MAX_BACKOFF_MULTIPLIER` | `4` | `open-sse/config/constants.ts` | API-key provider max resetTimeout escalation multiplier. |
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT` | `3` | `open-sse/config/constants.ts` | API-key provider escalates after this many open cycles. |
| `OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_THRESHOLD` | `2` | `open-sse/config/constants.ts` | Provider-level breaker: failures before the entire local provider enters cooldown. |
| `OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_WINDOW_MS` | `300000` | `open-sse/config/constants.ts` | Provider-level breaker: rolling failure-count window (ms) for local providers. |
| `OMNIROUTE_PROVIDER_BREAKER_LOCAL_COOLDOWN_MS` | `60000` | `open-sse/config/constants.ts` | Provider-level breaker: cooldown (ms) once the local provider threshold is reached. |
| `PIN_DROP_BACKOFF_LEVEL` | `2` | `open-sse/services/combo.ts` | Backoff depth at which a context-cache pin's provider is deemed durably unhealthy and the pin is dropped for failover. |
| `PIN_DROP_GRACE_MS` | `20000` | `open-sse/services/combo.ts` | Anti-flap window (ms) tolerating brief transient cooldowns before dropping a context-cache pin. |
@@ -753,6 +769,7 @@ The logging system writes to both stdout and rotated log files. All configuratio
| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `128` | Number of array items retained from the tail when truncating chat log payloads. |
| `CHAT_LOG_MAX_DEPTH` | `6` | Max nesting depth before chat log payloads are truncated. |
| `CHAT_LOG_MAX_OBJECT_KEYS` | `80` | Max object keys retained in chat log payloads (0 = unlimited). |
| `CHAT_LOG_MAX_BODY_KB` | `1024` | Whole request/response body size (KB) before it's replaced by a bare summary instead of the full clone. Raise this if long agentic conversations show a placeholder instead of the real messages in the dashboard. |
| `CHAT_DEBUG_FILE` | `false` | When true, `serializeArtifactForStorage` skips size-based truncation. Debug only. |
---
@@ -1446,7 +1463,6 @@ These settings were introduced after the previous environment-contract snapshot.
| `OMNIROUTE_CHAT_VIRTUAL_TTL_MS` | `60000` (60 s) | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): idle-lane eviction TTL. |
| `OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). |
| `OMNIROUTE_RUNNOW_TIMEOUT_MS` | `30000` | `src/app/api/jobs/[id]/run-now/route.ts` | Bounds how long a run-now call waits for an in-flight job before starting the queued run. |
| `CHAT_LOG_MAX_BODY_KB` | `1024` | `src/lib/logEnv.ts` | Maximum request or response body size before log summarization, in KiB. |
| `ADOBE_FIREFLY_BROWSER_REFRESH` | enabled | `open-sse/services/adobeFireflySession.ts` | Keeps IMS and browser-risk state fresh through account-scoped Chrome CDP sessions; set `0` to disable. |
| `ADOBE_FIREFLY_SESSION_DISK` | enabled | `open-sse/services/adobeFireflySession.ts` | Persists repaired Adobe sessions under `DATA_DIR`; set `0` for memory-only state. |
| `ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS` | `12000` | `open-sse/services/adobeFireflySession.ts` | Minimum spacing between Adobe Firefly generate submissions. |

View File

@@ -92,6 +92,26 @@ describe prompt, steering the description toward what the user actually asked
(codex-vision-proxy pattern) and asking the vision model to transcribe visible
text. With the flag off — or no user text — the base prompt is used unchanged.
#### Describe output cap (`modalityBridgeVisionMaxChars`)
| Key | Default | Range |
| ------------------------------ | ------- | ---------------- |
| `modalityBridgeVisionMaxChars` | `0` | `0` or 10050000 |
`0` (default) means **no cap** — the description returned by
`callVisionModel()` is passed through unmodified, preserving the existing
behavior. Any value in the 10050000 range truncates the description with a
`…` suffix before it is spliced back as `[Image N]: <description>`
(`VisionBridgeGuardrail.preCall()` in `src/lib/guardrails/visionBridge.ts`).
Raise this for detail-heavy OCR tasks where the downstream model needs the
full transcription; lower it to bound token usage on chatty vision models.
The dashboard field lives on the Vision tab's Advanced panel
(`modality-bridge-max-chars` in `ModalityBridgeVisionTab.tsx`) and clamps any
value between 1 and 99 up to the 100 floor while leaving an explicit `0`
untouched — `0` is a valid Zod value in its own right
(`z.union([z.literal(0), z.number().int().min(100).max(50000)])`), not merely
the "unset" default.
#### Describe cache (`modalityBridge/bridgeCache.ts`)
In-memory LRU + TTL cache for describe outputs, shared process-wide.
@@ -113,9 +133,10 @@ The new `modalityBridge*` keys are Zod-validated in `updateSettingsSchema`
(`src/shared/validation/settingsSchemas.ts`): `modalityBridgeVisionEnabled`,
`modalityBridgeVisionMode`, `modalityBridgeVisionModel`,
`modalityBridgeVisionTaskAware`, `modalityBridgeVisionPrompt`,
`modalityBridgeVisionTimeout`, `modalityBridgeVisionMaxImages`, the
`modalityBridgeCache*` trio, and the `modalityBridgeAudio*` group used by the
Audio Bridge. Migration `141_modality_bridge_settings.sql` copies existing legacy
`modalityBridgeVisionTimeout`, `modalityBridgeVisionMaxImages`,
`modalityBridgeVisionMaxChars`, the `modalityBridgeCache*` trio, and the
`modalityBridgeAudio*` group used by the Audio Bridge. Migration
`141_modality_bridge_settings.sql` copies existing legacy
`visionBridge*` values to the matching new keys (idempotent, never overwrites
an operator-set `modalityBridge*` value); the legacy keys stay accepted as a
read fallback for one release cycle.
@@ -141,7 +162,8 @@ The dedicated dashboard page is
`/dashboard/settings/modality-bridge`. Its URL-addressable `Vision`, `Audio`,
and `Video` tabs preserve query parameters while switching the `tab` value.
The Vision tab exposes enablement, mode, model selection (including the automatic
default), task-aware prompting, advanced timeout/image/cache limits, runtime
default), task-aware prompting, advanced timeout/image/description-length/cache
limits, runtime
counters, and a guarded sample request. The Audio tab is also live: it exposes
enablement, an STT-only model picker with Auto, timeout/max-clip limits, audio
counters, and an `input_audio` sample test. Video remains the explicit placeholder
@@ -435,8 +457,9 @@ store (`getSettings()`), not env vars. Vision's primary keys are
`modalityBridgeVisionEnabled`, `modalityBridgeVisionMode`,
`modalityBridgeVisionModel`, `modalityBridgeVisionTaskAware`,
`modalityBridgeVisionPrompt`, `modalityBridgeVisionTimeout`,
`modalityBridgeVisionMaxImages`, `modalityBridgeCacheEnabled`,
`modalityBridgeCacheTtlMinutes`, and `modalityBridgeCacheMaxEntries`. The legacy
`modalityBridgeVisionMaxImages`, `modalityBridgeVisionMaxChars`,
`modalityBridgeCacheEnabled`, `modalityBridgeCacheTtlMinutes`, and
`modalityBridgeCacheMaxEntries`. The legacy
`visionBridge*` keys are accepted only as the documented one-cycle read
fallback; dashboard writes use the primary keys. Defaults and the fallback
resolver live in `src/shared/constants/modalityBridgeDefaults.ts`, with legacy

View File

@@ -12,7 +12,7 @@
"electron-updater": "^6.8.9"
},
"devDependencies": {
"electron": "^43.2.0",
"electron": "^43.3.0",
"electron-builder": "^26.15.3"
},
"engines": {
@@ -297,45 +297,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@electron/windows-sign": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz",
"integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==",
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
"fs-extra": "^11.1.1",
"minimist": "^1.2.8",
"postject": "^1.0.0-alpha.6"
},
"bin": {
"electron-windows-sign": "bin/electron-windows-sign.js"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/@electron/windows-sign/node_modules/fs-extra": {
"version": "11.4.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz",
"integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -1130,15 +1091,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-dirname": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1415,9 +1367,9 @@
}
},
"node_modules/electron": {
"version": "43.2.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-43.2.0.tgz",
"integrity": "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==",
"version": "43.3.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-43.3.0.tgz",
"integrity": "sha512-nLlvu0WFjftWsSaTkV2B/c4NDuJBspTyXu8vKSQ6vLvFt8uG3NgN49LLKcXddwX0GqVvAQDhciWp+4xOdTdhew==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1459,19 +1411,6 @@
"node": ">=14.0.0"
}
},
"node_modules/electron-builder-squirrel-windows": {
"version": "26.15.3",
"resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz",
"integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.15.3",
"builder-util": "26.15.3",
"electron-winstaller": "5.4.0"
}
},
"node_modules/electron-publish": {
"version": "26.15.3",
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz",
@@ -1506,66 +1445,6 @@
"tiny-typed-emitter": "^2.1.0"
}
},
"node_modules/electron-winstaller": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
"integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
"fs-extra": "^7.0.1",
"lodash": "^4.17.21",
"temp": "^0.9.0"
},
"engines": {
"node": ">=8.0.0"
},
"optionalDependencies": {
"@electron/windows-sign": "^1.1.2"
}
},
"node_modules/electron-winstaller/node_modules/fs-extra": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/electron-winstaller/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"dev": true,
"license": "MIT",
"peer": true,
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/electron-winstaller/node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -2480,20 +2359,6 @@
"node": ">= 18"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2757,36 +2622,6 @@
"node": ">=18"
}
},
"node_modules/postject": {
"version": "1.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
"integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
"bin": {
"postject": "dist/cli.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/postject/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/proc-log": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
@@ -2981,21 +2816,6 @@
"node": ">= 4"
}
},
"node_modules/rimraf": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
"integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
@@ -3251,21 +3071,6 @@
"node": ">=18"
}
},
"node_modules/temp": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz",
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/temp-file": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz",

View File

@@ -28,7 +28,7 @@
"electron-updater": "^6.8.9"
},
"devDependencies": {
"electron": "^43.2.0",
"electron": "^43.3.0",
"electron-builder": "^26.15.3"
},
"overrides": {

View File

@@ -229,13 +229,13 @@ export const PROVIDER_PROFILES = {
circuitBreakerThreshold: envInt("OMNIROUTE_CIRCUIT_BREAKER_OAUTH_THRESHOLD", 8),
circuitBreakerReset: envInt("OMNIROUTE_CIRCUIT_BREAKER_OAUTH_RESET_MS", 60000),
// Provider-level circuit breaker (entire provider cooldown after repeated failures)
providerFailureThreshold: 10, // Scaled for 500+ connections (was 3)
providerFailureWindowMs: 900000, // 15min window (was 10min)
providerCooldownMs: 300000, // 5min cooldown when threshold reached
providerFailureThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_THRESHOLD", 10), // Scaled for 500+ connections (was 3)
providerFailureWindowMs: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_WINDOW_MS", 900000), // 15min window (was 10min)
providerCooldownMs: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_COOLDOWN_MS", 300000), // 5min cooldown when threshold reached
// Adaptive circuit breaker v2 settings
degradationThreshold: 5, // Enter DEGRADED at this many failures
maxBackoffMultiplier: 8, // Max 8x resetTimeout escalation
backoffEscalationCount: 2, // Escalate after 2 open cycles
degradationThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_DEGRADATION_THRESHOLD", 5), // Enter DEGRADED at this many failures
maxBackoffMultiplier: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_MAX_BACKOFF_MULTIPLIER", 8), // Max 8x resetTimeout escalation
backoffEscalationCount: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_BACKOFF_ESCALATION_COUNT", 2), // Escalate after 2 open cycles
},
apikey: {
transientCooldown: 3000, // 3s (API providers recover faster)
@@ -244,12 +244,12 @@ export const PROVIDER_PROFILES = {
circuitBreakerThreshold: envInt("OMNIROUTE_CIRCUIT_BREAKER_API_KEY_THRESHOLD", 12),
circuitBreakerReset: envInt("OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS", 30000),
// Provider-level circuit breaker (entire provider cooldown after repeated failures)
providerFailureThreshold: 15, // Scaled for 500+ connections (was 5)
providerFailureWindowMs: 1800000, // 30min window (was 20min)
providerCooldownMs: 600000, // 10min cooldown when threshold reached
degradationThreshold: 7,
maxBackoffMultiplier: 4,
backoffEscalationCount: 3,
providerFailureThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_THRESHOLD", 15), // Scaled for 500+ connections (was 5)
providerFailureWindowMs: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS", 1800000), // 30min window (was 20min)
providerCooldownMs: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_COOLDOWN_MS", 600000), // 10min cooldown when threshold reached
degradationThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_DEGRADATION_THRESHOLD", 7),
maxBackoffMultiplier: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_MAX_BACKOFF_MULTIPLIER", 4),
backoffEscalationCount: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT", 3),
},
// Local providers (localhost inference backends like Ollama, LM Studio, oMLX).
// Not yet wired into getProviderProfile() — will be used when local provider_nodes
@@ -261,9 +261,9 @@ export const PROVIDER_PROFILES = {
circuitBreakerThreshold: envInt("OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD", 2),
circuitBreakerReset: envInt("OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS", 15000),
// Provider-level circuit breaker (entire provider cooldown after repeated failures)
providerFailureThreshold: 2, // 2 failures trigger provider cooldown
providerFailureWindowMs: 300000, // 5min window for counting failures
providerCooldownMs: 60000, // 1min cooldown when threshold reached
providerFailureThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_THRESHOLD", 2), // 2 failures trigger provider cooldown
providerFailureWindowMs: envInt("OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_WINDOW_MS", 300000), // 5min window for counting failures
providerCooldownMs: envInt("OMNIROUTE_PROVIDER_BREAKER_LOCAL_COOLDOWN_MS", 60000), // 1min cooldown when threshold reached
},
};

View File

@@ -1,4 +1,5 @@
import type { RegistryEntry } from "../../shared.ts";
const CROF_REASONING_EFFORTS = ["none", "low", "medium", "high", "max"] as const;
export const crofProvider: RegistryEntry = {
id: "crof",
@@ -9,30 +10,147 @@ export const crofProvider: RegistryEntry = {
authType: "apikey",
authHeader: "bearer",
// Seed list — runtime /v1/models discovery keeps this fresh.
// Source: GET https://crof.ai/v1/models (2026-05-17).
// Source: GET https://crof.ai/v1/models (2026-08-10; includes models absent from the 2026-05-17 roster).
models: [
{
id: "deepseek-v4-pro-precision",
name: "DeepSeek V4 Pro (Precision)",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "deepseek-v4-pro-lightning",
name: "DeepSeek V4 Pro (Lightning)",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "deepseek-v4-flash-0731",
name: "DeepSeek V4 Flash 0731",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true },
{ id: "deepseek-v3.2", name: "DeepSeek V3.2" },
{ id: "kimi-k2.6-precision", name: "Kimi K2.6 (Precision)", supportsReasoning: true },
{ id: "kimi-k2.6", name: "Kimi K2.6", supportsReasoning: true },
{ id: "kimi-k2.5-lightning", name: "Kimi K2.5 (Lightning)", supportsReasoning: true },
{ id: "kimi-k2.5", name: "Kimi K2.5", supportsReasoning: true },
{ id: "glm-5.1-precision", name: "GLM 5.1 (Precision)", supportsReasoning: true },
{ id: "glm-5.1", name: "GLM 5.1", supportsReasoning: true },
{ id: "glm-4.7", name: "GLM 4.7" },
{ id: "glm-4.7-flash", name: "GLM 4.7 Flash" },
{ id: "mimo-v2.5-pro-precision", name: "Mimo 2.5 Pro (Precision)", supportsReasoning: true },
{ id: "mimo-v2.5-pro", name: "Mimo 2.5 Pro", supportsReasoning: true },
{ id: "gemma-4-31b-it", name: "Gemma 4 31B", supportsReasoning: true },
{
id: "kimi-k2.6-precision",
name: "Kimi K2.6 (Precision)",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "kimi-k2.6",
name: "Kimi K2.6",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "kimi-k2.7-code",
name: "Kimi K2.7 Code",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "kimi-k3",
name: "Kimi K3",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "kimi-k3-eco",
name: "Kimi K3 Eco",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "kimi-k2.5-lightning",
name: "Kimi K2.5 (Lightning)",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "kimi-k2.5",
name: "Kimi K2.5",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "glm-5.1-precision",
name: "GLM 5.1 (Precision)",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "glm-5.1",
name: "GLM 5.1",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "glm-5.2",
name: "GLM 5.2",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "glm-4.7",
name: "GLM 4.7",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "glm-4.7-flash",
name: "GLM 4.7 Flash",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "mimo-v2.5-pro-precision",
name: "Mimo 2.5 Pro (Precision)",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "mimo-v2.5-pro",
name: "Mimo 2.5 Pro",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "gemma-4-31b-it",
name: "Gemma 4 31B",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
{ id: "qwen3.6-27b", name: "Qwen3.6 27B", supportsReasoning: true },
{ id: "qwen3.5-397b-a17b", name: "Qwen3.5 397B A17B", supportsReasoning: true },
{ id: "qwen3.5-9b", name: "Qwen3.5 9B", supportsReasoning: true },
{
id: "qwen3.6-27b",
name: "Qwen3.6 27B",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "qwen3.5-397b-a17b",
name: "Qwen3.5 397B A17B",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "qwen3.5-9b",
name: "Qwen3.5 9B",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
],
};

View File

@@ -1,5 +1,10 @@
import type { RegistryEntry } from "../../shared.ts";
/**
* The key is genuinely optional: probed live 2026-08-11 with no Authorization
* header, /chat/completions still answered 200 (kilo-auto/free routed to
* stepfun/step-3.7-flash) — so authType stays "optional", matching ovhcloud.
*/
export const kilo_gatewayProvider: RegistryEntry = {
id: "kilo-gateway",
alias: "kg",
@@ -7,7 +12,7 @@ export const kilo_gatewayProvider: RegistryEntry = {
executor: "default",
baseUrl: "https://api.kilo.ai/api/gateway/chat/completions",
modelsUrl: "https://api.kilo.ai/api/gateway/models",
authType: "apikey",
authType: "optional",
authHeader: "bearer",
models: [
{ id: "kilo-auto/frontier", name: "Kilo Auto Frontier" },

View File

@@ -1,8 +1,8 @@
import type { RegistryEntry } from "../../../shared.ts";
export const KIMI_WEB_STATIC_MODELS = [
{ id: "k3", name: "K3", supportsReasoning: true },
{ id: "k2d6", name: "K2.6", supportsReasoning: true },
{ id: "k3", name: "K3", supportsReasoning: true, toolCalling: false },
{ id: "k2d6", name: "K2.6", supportsReasoning: true, toolCalling: false },
];
export const kimi_webProvider: RegistryEntry = {

View File

@@ -1,11 +1,46 @@
import type { RegistryEntry } from "../../shared.ts";
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
/**
* Poolside — first-party OpenAI-compatible inference host (inference.poolside.ai).
*
* Keys are self-service (`sky_…`, platform.poolside.ai). The catalog endpoint is
* authenticated: without a key `/v1/models` answers 401 with the body
* `No Authorization header provided`, which is what an earlier generic probe read
* back as "invalid key" and led to the entry being dropped (#2723, #3054).
* With a key it answers 200 and returns exactly the two Preview models below
* (authenticated probe 2026-08-07, #9085).
*
* The IDs here are the ones the live catalog returns — `poolside/laguna-xs-2.1`,
* not the `laguna-xs.2` form carried by third-party listings and by the
* aggregator catalogs in this repo (routeway, cline), whose IDs are namespaced by
* the aggregator and do not address this host. Both models are text-only, report
* `tools` and `reasoning`, and are free during Preview. `passthroughModels` stays
* on so live discovery keeps admitting models the Preview adds later; upstream
* publishes no rate-limit headers.
*/
export const poolsideProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
id: "poolside",
alias: "poolside",
baseUrl: "https://inference.poolside.ai/v1/chat/completions",
modelsUrl: "https://inference.poolside.ai/v1/models",
models: [],
models: [
{
id: "poolside/laguna-xs-2.1",
name: "Laguna XS 2.1",
toolCalling: true,
supportsReasoning: true,
contextLength: 262144,
maxOutputTokens: 32768,
},
{
id: "poolside/laguna-s-2.1",
name: "Laguna S 2.1",
toolCalling: true,
supportsReasoning: true,
contextLength: 262144,
maxOutputTokens: 32768,
},
],
passthroughModels: true,
});

View File

@@ -4,6 +4,7 @@ import {
KIMI_CODING_ANTHROPIC_URL,
KIMI_CODING_OPENAI_URL,
} from "../config/providers/registry/kimi/coding/runtime.ts";
import { flattenOpenAIToolRootAnyOf } from "../services/toolSchemaSanitizer.ts";
import { FORMATS } from "../translator/formats.ts";
import { DefaultExecutor } from "./default.ts";
import type { ProviderCredentials } from "./base.ts";
@@ -182,6 +183,7 @@ function normalizeOpenAIRequest(
delete next.max_tokens;
applyOpenAIThinking(next, policy);
if (Array.isArray(next.tools)) next.tools = flattenOpenAIToolRootAnyOf(next.tools);
if (stream) {
next.stream_options = {

View File

@@ -1,3 +1,4 @@
import { flattenOpenAIToolRootAnyOf } from "../services/toolSchemaSanitizer.ts";
import { DefaultExecutor } from "./default.ts";
import type { ProviderCredentials } from "./base.ts";
@@ -103,6 +104,7 @@ export function normalizeMoonshotRequest(model: string, body: unknown): unknown
if (!normalizedModel.startsWith("kimi-")) return body;
const next: JsonRecord = { ...record };
if (Array.isArray(next.tools)) next.tools = flattenOpenAIToolRootAnyOf(next.tools);
const isK3 = /^kimi-k3(?:$|-)/.test(normalizedModel);
const isK27 = /^kimi-k2\.7-code(?:$|-)/.test(normalizedModel);
const isK26 = /^kimi-k2\.6(?:$|-)/.test(normalizedModel);

View File

@@ -367,7 +367,9 @@ export class OpencodeExecutor extends BaseExecutor {
// value risks upstream rejection (#5720 regressed with "opencode/local"), and this
// is deployment-specific. So it stays OFF by default and the VPS operator enables it
// with OPENCODE_SYNTHESIZE_CLI_HEADERS=true (values env-overridable). Client-supplied
// headers always take precedence.
// headers take precedence, EXCEPT User-Agent: a non-CLI client UA (curl/SDK) is
// replaced with the synthesized CLI UA because opencode.ai's free tier rejects
// generic client UAs from datacenter IPs (FreeUsageLimitError 429).
const synthesizeCli = /^(1|true|yes|on)$/i.test(
process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS?.trim() ?? ""
);

View File

@@ -148,6 +148,36 @@ export interface PplxBlock {
}>;
goals?: Array<{ description?: string }>;
};
// Workflow API (`intended_usage: "workflow_root"`). Perplexity moved the answer
// text here from markdown_block: it now arrives as one WORKFLOW_ITEM_TEXT item
// whose `text_payload.variant` is "answer", nested under a workflow step. Other
// variants ("thinking") and item types (queries, sources) are not answer text.
workflow_block?: PplxWorkflowBlock;
}
export interface PplxWorkflowTextPayload {
text?: string;
chunks?: string[];
variant?: string;
is_streaming?: boolean;
}
export interface PplxWorkflowItem {
type?: string;
variant?: string;
payload?: { text_payload?: PplxWorkflowTextPayload };
}
export interface PplxWorkflowStep {
status?: string;
title?: string;
tool_name?: string;
items?: PplxWorkflowItem[];
}
export interface PplxWorkflowBlock {
status?: string;
steps?: PplxWorkflowStep[];
}
export interface PplxUpsellInformation {
@@ -427,6 +457,134 @@ export function applyMarkdownDiff(acc: MarkdownAccumulator, patches: PplxDiffPat
}
}
/** Answer-text items carry this `variant`; "thinking" and friends are not answer text. */
const WORKFLOW_ANSWER_VARIANT = "answer";
/**
* mdState key for one workflow answer item. Keyed per step+item so the
* `/chunks/<k>` indices of two concurrent items can never overwrite each other.
*/
function workflowUsageKey(stepIdx: number, itemIdx: number): string {
return `workflow_root:${stepIdx}:${itemIdx}`;
}
function isAnswerItem(item: PplxWorkflowItem | undefined): boolean {
if (!item) return false;
const payloadVariant = item.payload?.text_payload?.variant;
return (payloadVariant ?? item.variant) === WORKFLOW_ANSWER_VARIANT;
}
/**
* Seed an accumulator from a materialized answer item. Chunks win over `text`:
* the terminal frame can carry a `text` that lags the chunk track (same
* precedence markdown_block already uses for `chunks` over `answer`).
*/
function seedFromAnswerItem(acc: MarkdownAccumulator, item: PplxWorkflowItem): void {
const tp = item.payload?.text_payload;
if (!tp) return;
if (Array.isArray(tp.chunks) && tp.chunks.length > 0) {
acc.chunks = tp.chunks.map((c) => String(c));
} else if (typeof tp.text === "string" && tp.text.length > 0) {
acc.chunks = [tp.text];
}
}
function ensureAcc(mdState: Map<string, MarkdownAccumulator>, key: string): MarkdownAccumulator {
let acc = mdState.get(key);
if (!acc) {
acc = { chunks: [] };
mdState.set(key, acc);
}
return acc;
}
/**
* Apply a `field: "workflow_block"` diff patch set.
*
* Live shapes (Aug 2026 capture, pplx-auto / mode=copilot):
* {op:"add", path:"/steps/1", value:{items:[…]}}
* {op:"add", path:"/steps/0/items/1", value:{…}}
* {op:"add", path:"/steps/1/items/0/payload/text_payload/chunks/2", value:"…"}
* {op:"replace", path:"/steps/1/items/0/payload/text_payload/text", value:"…"}
*
* Only answer-variant items are accumulated; step/status patches are ignored.
*/
export function applyWorkflowDiff(
mdState: Map<string, MarkdownAccumulator>,
patches: PplxDiffPatch[]
): void {
for (const patch of patches) {
const path = patch.path ?? "";
// Whole step materialized — pick up every answer item it carries.
const stepMatch = /^\/steps\/(\d+)$/.exec(path);
if (stepMatch) {
const stepIdx = Number.parseInt(stepMatch[1], 10);
const step = (patch.value ?? {}) as PplxWorkflowStep;
(step.items ?? []).forEach((item, itemIdx) => {
if (!isAnswerItem(item)) return;
seedFromAnswerItem(ensureAcc(mdState, workflowUsageKey(stepIdx, itemIdx)), item);
});
continue;
}
// Single item appended to an existing step.
const itemMatch = /^\/steps\/(\d+)\/items\/(\d+)$/.exec(path);
if (itemMatch) {
const item = (patch.value ?? {}) as PplxWorkflowItem;
if (!isAnswerItem(item)) continue;
const key = workflowUsageKey(
Number.parseInt(itemMatch[1], 10),
Number.parseInt(itemMatch[2], 10)
);
seedFromAnswerItem(ensureAcc(mdState, key), item);
continue;
}
// Incremental chunk append — the streaming hot path.
const chunkMatch = /^\/steps\/(\d+)\/items\/(\d+)\/payload\/text_payload\/chunks\/(\d+)$/.exec(
path
);
if (chunkMatch && typeof patch.value === "string") {
const key = workflowUsageKey(
Number.parseInt(chunkMatch[1], 10),
Number.parseInt(chunkMatch[2], 10)
);
// Only extend a track already seeded by an answer item: a chunk patch
// carries no variant, so an unseeded key could be a "thinking" track.
const acc = mdState.get(key);
if (!acc) continue;
acc.chunks[Number.parseInt(chunkMatch[3], 10)] = patch.value;
continue;
}
// Terminal `text` materialization — only used when no chunks arrived.
const textMatch = /^\/steps\/(\d+)\/items\/(\d+)\/payload\/text_payload\/text$/.exec(path);
if (textMatch && typeof patch.value === "string" && patch.value.length > 0) {
const key = workflowUsageKey(
Number.parseInt(textMatch[1], 10),
Number.parseInt(textMatch[2], 10)
);
const acc = mdState.get(key);
if (!acc || acc.chunks.join("").length > 0) continue;
acc.chunks = [patch.value];
}
}
}
/** Accumulate every answer item of a materialized workflow_block. */
export function applyWorkflowBlock(
mdState: Map<string, MarkdownAccumulator>,
workflow: PplxWorkflowBlock
): void {
(workflow.steps ?? []).forEach((step, stepIdx) => {
(step.items ?? []).forEach((item, itemIdx) => {
if (!isAnswerItem(item)) return;
seedFromAnswerItem(ensureAcc(mdState, workflowUsageKey(stepIdx, itemIdx)), item);
});
});
}
/**
* Extract the assistant answer from the COMPLETED frame's `text` step-blob.
*
@@ -646,6 +804,18 @@ export async function* extractContent(
}
}
// Content: workflow_block answer items. Perplexity migrated the answer text
// here from markdown_block, so this must run BEFORE the isAnswerTextUsage
// gate — the carrying usage is "workflow_root", which that gate rejects.
if (block.workflow_block) {
applyWorkflowBlock(mdState, block.workflow_block);
continue;
}
if (block.diff_block?.field === "workflow_block") {
applyWorkflowDiff(mdState, block.diff_block.patches ?? []);
continue;
}
// Content: answer-text blocks (schematized diff frames OR materialized
// markdown_block on the final COMPLETED frame).
if (!isAnswerTextUsage(usage)) continue;

View File

@@ -46,11 +46,21 @@ import {
} from "../shared/zedAuth.ts";
import { resolveSuppressThinkClose, THINKING_MARKER_HEADER } from "../utils/thinkCloseMarker.ts";
// Wire values for the `provider` field of POST /completions. These are NOT
// display names: cloud.zed.dev matches them exactly, and an unrecognized value
// fails the whole request with `500 {"message":"An internal server error
// occurred."}` before the model is ever looked at — which is why every model id,
// including invalid ones, produced an identical 500.
//
// The spellings come from Zed's own GET /models catalog, which reports
// `anthropic`, `open_ai` and `google` (note the underscore); `x_ai` follows the
// same convention. Feeding a catalog value back through normalizeZedProvider is
// therefore identity, as it must be.
const ZED_PROVIDER = {
anthropic: "Anthropic",
openai: "OpenAi",
google: "Google",
xai: "XAi",
anthropic: "anthropic",
openai: "open_ai",
google: "google",
xai: "x_ai",
} as const;
type ZedProviderName = (typeof ZED_PROVIDER)[keyof typeof ZED_PROVIDER];

View File

@@ -1,4 +1,7 @@
import { extractRequestToolIdentityMap } from "./chatCore/requestToolIdentity.ts";
import {
extractRequestToolIdentityMap,
toToolNameAliasMap,
} from "./chatCore/requestToolIdentity.ts";
import { injectMemoryAndSkills } from "./chatCore/memorySkillsInjection.ts";
import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts";
import { normalizeOpenAICompatibleTools } from "./chatCore/openAICompatibleTools.ts";
@@ -144,7 +147,11 @@ import {
getExplicitModelOutputCap,
resolveInputTokenCapForGate,
} from "@/lib/modelCapabilities.ts";
import { checkRequestCapabilityFit, deriveRequestCapabilityRequirements, buildCapabilityMismatchMessage } from "@/shared/constants/capabilities/capabilityFilter.ts";
import {
checkRequestCapabilityFit,
deriveRequestCapabilityRequirements,
buildCapabilityMismatchMessage,
} from "@/shared/constants/capabilities/capabilityFilter.ts";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts";
import { toPositiveInteger } from "../services/reasoningTokenBuffer.ts";
import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts";
@@ -418,6 +425,7 @@ export async function handleChatCore({
comboStrategy = null,
isCombo = false,
routingComboId = null,
sessionAffinityKey = null,
comboStepId = null,
comboExecutionKey = null,
cachedSettings = null,
@@ -881,6 +889,10 @@ export async function handleChatCore({
"x-omniroute-session-id"
)) || null;
const pipelineSessionId = explicitSessionIdHeader || skillRequestId;
const reasoningReplaySessionKey = sessionAffinityKey || explicitSessionIdHeader;
const reasoningCacheScope = reasoningReplaySessionKey
? `api-key:${String(apiKeyInfo?.id ?? "local")}\x1f${String(reasoningReplaySessionKey)}`
: null;
// persistAttemptLogs extracted to chatCore/attemptLogging.ts (#3501); bind the per-request context
// once so the 16 call sites keep passing only the per-attempt args (byte-identical).
const persistAttemptLogs = (args: PersistAttemptLogsArgs) =>
@@ -2039,6 +2051,7 @@ export async function handleChatCore({
preserveDeveloperRole,
preserveCacheControl,
copilotClient: copilotCompatibleReasoning,
reasoningCacheScope,
}
);
}
@@ -2203,6 +2216,7 @@ export async function handleChatCore({
preserveCacheControl,
signatureNamespace: connectionId,
copilotClient: copilotCompatibleReasoning,
reasoningCacheScope,
...(preCompressionBody ? { preCompressionBody } : {}),
}
);
@@ -2317,13 +2331,8 @@ export async function handleChatCore({
// response toolNameMap so the response translator can restore tool names
// from their lowercased form (#9568). Only merge string-valued entries
// (tool name aliases), not object-valued namespace identities (#7936).
if (!toolNameMap && requestToolIdentityMap instanceof Map && requestToolIdentityMap.size > 0) {
const hasStringValues = [...requestToolIdentityMap.values()].every(
(v: unknown) => typeof v === "string"
);
if (hasStringValues) {
toolNameMap = requestToolIdentityMap;
}
if (!toolNameMap) {
toolNameMap = toToolNameAliasMap(requestToolIdentityMap);
}
delete translatedBody._toolNameMap;
delete translatedBody._disableToolPrefix;
@@ -2637,8 +2646,11 @@ export async function handleChatCore({
}
// === /Quota Share enforcement PRE-hook ===
if (isFeatureFlagEnabled("CAPABILITY_FILTER_ENABLED")) {
const fit = checkRequestCapabilityFit(getResolvedModelCapabilities({ provider, model: effectiveModel }),
deriveRequestCapabilityRequirements(body as Record<string, unknown>), provider);
const fit = checkRequestCapabilityFit(
getResolvedModelCapabilities({ provider, model: effectiveModel }),
deriveRequestCapabilityRequirements(body as Record<string, unknown>),
provider
);
if (!fit.compatible) {
const msg = buildCapabilityMismatchMessage(fit.terminalReason!, provider, effectiveModel);
log?.warn?.("CAPABILITY", msg);
@@ -4380,16 +4392,23 @@ export async function handleChatCore({
// Reasoning Replay Cache (#1628): Capture reasoning_content from non-streaming responses
// with tool_calls so it can be replayed on subsequent turns (DeepSeek V4, Kimi K2, etc.)
try {
const firstChoice = translatedResponse?.choices?.[0];
const cacheResponse = translatedResponse?.choices?.[0]
? translatedResponse
: needsTranslation(responsePayloadFormat, FORMATS.OPENAI)
? translateNonStreamingResponse(
responseBody,
responsePayloadFormat,
FORMATS.OPENAI,
responseToolNameMap
)
: responseBody;
const firstChoice = cacheResponse?.choices?.[0];
const msg = firstChoice?.message;
// The response being cached now will be replayed as history on the *next*
// turn, where the read side (translator/index.ts) keys the lookup by the
// message's real position in that future `messages` array — i.e. right
// after everything the client sent this turn.
const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages;
const historyMessages = (translatedBody as { messages?: unknown[] } | null | undefined)
?.messages;
cacheReasoningFromAssistantMessage(msg, provider, model, {
requestId: skillRequestId,
messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0,
scope: reasoningCacheScope,
historyMessages: Array.isArray(historyMessages) ? historyMessages : [],
});
} catch {
// Cache capture is non-critical — never block the response
@@ -4815,14 +4834,24 @@ export async function handleChatCore({
if (normalizedStreamStatus === 200 && streamResponseBody) {
try {
const streamBody = streamResponseBody as Record<string, unknown>;
const choices = streamBody.choices as { message?: Record<string, unknown> }[] | undefined;
const cacheStreamBody = Array.isArray(streamBody.choices)
? streamBody
: needsTranslation(clientResponseFormat, FORMATS.OPENAI)
? (translateNonStreamingResponse(
streamBody,
clientResponseFormat,
FORMATS.OPENAI,
responseToolNameMap
) as Record<string, unknown>)
: streamBody;
const choices = cacheStreamBody.choices as
{ message?: Record<string, unknown> }[] | undefined;
const msg = choices?.[0]?.message;
// See the non-streaming capture above: messageIndex must match the
// position this message will occupy in the *next* turn's history.
const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages;
const historyMessages = (translatedBody as { messages?: unknown[] } | null | undefined)
?.messages;
cacheReasoningFromAssistantMessage(msg, provider, model, {
requestId: skillRequestId,
messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0,
scope: reasoningCacheScope,
historyMessages: Array.isArray(historyMessages) ? historyMessages : [],
});
} catch {
// Cache capture is non-critical — never block the stream

View File

@@ -60,9 +60,9 @@ export function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown {
/**
* Truncate a large object for logging. If its JSON representation exceeds
* the configured max body size (getChatLogMaxBodyBytes()), return a
* lightweight summary instead of the full clone. This prevents
* persistAttemptLogs from holding multi-MB references to translatedBody
* getChatLogMaxBodyBytes() (default 1MB; CHAT_LOG_MAX_BODY_KB env override),
* return a lightweight summary instead of the full clone. This prevents
* persistAttemptLogs from holding unbounded references to translatedBody
* across 17 call sites per request.
*
* When the summarized object carries a `tools` definition, re-attach it
@@ -77,6 +77,9 @@ export function truncateForLog(value: unknown): Record<string, unknown> | null |
if (value === null || value === undefined) return value as null | undefined;
if (typeof value !== "object") return value as unknown as Record<string, unknown>;
const maxBodyBytes = getChatLogMaxBodyBytes();
// Pass maxBodyBytes as the early-exit point — otherwise estimateSizeFast's
// own default 256KB early-exit caps what it can ever report, silently
// making any configured threshold above 256KB unreachable (#trunc-limit-config).
const estimatedSize = estimateSizeFast(value, maxBodyBytes);
if (estimatedSize <= maxBodyBytes) return value as Record<string, unknown>;
// Object is too large — return a summary instead of a deep clone
@@ -88,6 +91,11 @@ export function truncateForLog(value: unknown): Record<string, unknown> | null |
if (typeof obj.model === "string") summary.model = obj.model;
if (typeof obj.provider === "string") summary.provider = obj.provider;
if (Array.isArray(obj.messages)) summary.messageCount = obj.messages.length;
// Responses API bodies use `input[]`, not `messages[]` (OpenAI-chat/Gemini-only
// field name) — without this, a large /v1/responses request got summarized
// with no count at all, leaving the dashboard's "Full Conversation" panel
// nothing to base its "N messages not shown" placeholder on.
else if (Array.isArray(obj.input)) summary.messageCount = obj.input.length;
if (Array.isArray(obj.contents)) summary.contentCount = obj.contents.length;
if (typeof obj.stream === "boolean") summary.stream = obj.stream;
if (Array.isArray(obj.tools)) summary.tools = cloneBoundedChatLogPayload(obj.tools);

View File

@@ -5,14 +5,27 @@
* Extracted from handleChatCore's non-streaming success path: assemble the context object passed to
* `guardrailRegistry.runPostCallHooks`. Pure value builder — no side effects, no early-returns. The
* `disabledGuardrails` field is resolved via `resolveDisabledGuardrails` (injectable for tests).
* Behaviour is byte-identical to the previous inline literal, including the `method: "POST"` /
* `stream: false` constants and the headers/endpoint null-coalescing.
* Preserves the previous field mapping and constants while narrowing values from
* the untyped request boundary to the public guardrail contract.
*/
import { resolveDisabledGuardrails as defaultResolveDisabled } from "@/lib/guardrails";
import {
resolveDisabledGuardrails as defaultResolveDisabled,
type GuardrailContext,
} from "@/lib/guardrails";
type LoggerLike = unknown;
type LoggerLike = GuardrailContext["log"];
type HeadersLike = Headers | Record<string, unknown> | null;
function optionalRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function optionalString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
export function buildPostCallGuardrailContext(
args: {
apiKeyInfo: unknown;
@@ -25,23 +38,24 @@ export function buildPostCallGuardrailContext(
clientResponseFormat: unknown;
},
resolveDisabledGuardrails: typeof defaultResolveDisabled = defaultResolveDisabled
) {
): GuardrailContext {
const headers = (args.clientRawRequest?.headers as HeadersLike) ?? null;
const apiKeyInfo = optionalRecord(args.apiKeyInfo);
return {
apiKeyInfo: args.apiKeyInfo,
apiKeyInfo,
disabledGuardrails: resolveDisabledGuardrails({
apiKeyInfo: (args.apiKeyInfo as Record<string, unknown> | null) ?? null,
apiKeyInfo,
body: args.body,
headers,
}),
endpoint: args.clientRawRequest?.endpoint || null,
endpoint: optionalString(args.clientRawRequest?.endpoint),
headers,
log: args.log,
method: "POST",
model: args.model,
provider: args.provider,
sourceFormat: args.responsePayloadFormat,
sourceFormat: optionalString(args.responsePayloadFormat),
stream: false,
targetFormat: args.clientResponseFormat,
} as const;
targetFormat: optionalString(args.clientResponseFormat),
};
}

View File

@@ -1,4 +1,25 @@
type NamespaceIdentity = { namespace: string; name: string };
export type NamespaceIdentity = { namespace: string; name: string };
/**
* Return a string-valued copy only when the complete map is an alias ledger.
*
* The legacy `_toolNameMap` side channel can carry either response aliases or
* namespace identities. Checking every value before copying keeps those two
* contracts separate and gives callers a real `Map<string, string>` instead of
* asserting an identity map into the alias shape.
*/
export function toToolNameAliasMap(
map: ReadonlyMap<string, unknown> | null
): Map<string, string> | null {
if (!map || map.size === 0) return null;
const aliases = new Map<string, string>();
for (const [wireName, originalName] of map) {
if (typeof originalName !== "string") return null;
aliases.set(wireName, originalName);
}
return aliases;
}
/**
* Extract the #7936 request-tool identity map from the translated body and

View File

@@ -85,18 +85,29 @@ describe("runChaosPanel", () => {
});
describe("serializeChaosPart", () => {
it("emits a comment + omni-chaos-part event envelope", () => {
it("emits a comment + omni-chaos-part event envelope when custom event is requested", () => {
const part: ChaosPart = { model: "a/gpt", index: 0, ok: true, text: "hi" };
const s = serializeChaosPart(part, false);
const s = serializeChaosPart(part, false, true);
expect(s).toContain("event: omni-chaos-part");
expect(s).toContain('"type":"omni-chaos-part"');
expect(s).toContain('"model":"a/gpt"');
expect(s).toContain(": chaos 0 ok a/gpt");
});
it("emits ONLY the SSE comment (no event/data) by default for OpenAI-compatible clients", () => {
const part: ChaosPart = { model: "a/gpt", index: 0, ok: true, text: "hi" };
const s = serializeChaosPart(part, false);
// comment line kept (ignored by every SSE parser by spec)
expect(s).toContain(": chaos 0 ok a/gpt");
// NO custom event/data — those break openai-node / @ai-sdk validators
expect(s).not.toContain("event: omni-chaos-part");
expect(s).not.toContain('"type":"omni-chaos-part"');
expect(s).not.toMatch(/^data:/m);
});
});
describe("handleChaosChat", () => {
it("emits broadcast events + final OpenAI chunk", async () => {
it("emits ONLY SSE comments (no custom event) by default + final OpenAI chunk", async () => {
const handle = fakeHandle(async (model) => textResponse(`ans-${model}`));
const res = await handleChaosChat({
body: { messages: [] },
@@ -106,13 +117,27 @@ describe("handleChaosChat", () => {
expect(res.headers.get("X-OmniRoute-Chaos")).toBe("true");
expect(res.headers.get("X-OmniRoute-Chaos-Panel")).toBe("2");
const body = await res.text();
// each model gets a broadcast event
expect(body.match(/event: omni-chaos-part/g)?.length).toBe(2);
// NO custom event by default — OpenAI-compatible parsers choke on it
expect(body.match(/event: omni-chaos-part/g)?.length ?? 0).toBe(0);
expect(body.match(/^: chaos /gm)?.length ?? 0).toBe(2);
// final canonical chunk carries the primary answer
expect(body).toContain("ans-b/opus");
expect(body).toContain("[DONE]");
});
it("emits omni-chaos-part events when stream_options.include_chaos_parts is set", async () => {
const handle = fakeHandle(async (model) => textResponse(`ans-${model}`));
const res = await handleChaosChat({
body: { messages: [], stream_options: { include_chaos_parts: true } },
models: ["a/gpt", "b/opus"],
handleSingleModel: handle,
});
const body = await res.text();
expect(body.match(/event: omni-chaos-part/g)?.length).toBe(2);
expect(body).toContain("ans-b/opus");
expect(body).toContain("[DONE]");
});
it("degrades to a direct call when only one model", async () => {
const handle = fakeHandle(async () => textResponse("solo"));
const res = await handleChaosChat({
@@ -138,8 +163,8 @@ describe("handleChaosChat", () => {
// client learns via the error final chunk rather than a bare 503.
expect(res.status).toBe(200);
const body = await res.text();
// each model gets a broadcast fail event
expect(body.match(/event: omni-chaos-part/g)?.length).toBe(2);
// NO custom events by default (comments only), error conveyed via final chunk
expect(body.match(/event: omni-chaos-part/g)?.length ?? 0).toBe(0);
expect(body).toContain("All chaos panel models failed");
expect(body).toContain("[DONE]");
});

View File

@@ -53,16 +53,28 @@ export type ChaosPart = {
};
/**
* Build the SSE comment/event wrapper for one chaos panel part.
* We emit a custom event name `omni-chaos-part` so a protocol-aware IDE can
* split it out; non-aware clients reading OpenAI-style SSE will simply ignore
* the unknown event and use the final `data:` chunk below.
* Build the SSE wrapper for one chaos panel part.
*
* By DEFAULT only an SSE comment (`: chaos ...`) is emitted — comments are
* ignored by every SSE parser per spec, so OpenAI-compatible clients
* (openai-node, @ai-sdk/openai-compatible, …) never see a non-`choices`
* `data:` payload and their schema validation cannot fail with an
* `invalid_union` error.
*
* When `emitCustomEvent` is true (opt-in via
* `stream_options.include_chaos_parts`), the custom event name
* `omni-chaos-part` + metadata `data:` block is also emitted so a
* protocol-aware IDE can split panels out.
*
* The part's text is NOT included in the metadata event — it arrives in the
* final `data:` chunk for the primary model. This keeps each broadcast event
* small (metadata-only) so SSE buffering stays predictable.
*/
export function serializeChaosPart(part: ChaosPart, isFinal: boolean): string {
export function serializeChaosPart(
part: ChaosPart,
isFinal: boolean,
emitCustomEvent = false
): string {
const meta = {
type: "omni-chaos-part",
model: part.model,
@@ -71,11 +83,11 @@ export function serializeChaosPart(part: ChaosPart, isFinal: boolean): string {
final: isFinal,
...(part.error ? { error: part.error } : {}),
};
return (
`: chaos ${part.index} ${part.ok ? "ok" : "fail"} ${part.model}\n` +
`event: omni-chaos-part\n` +
`data: ${JSON.stringify(meta)}\n\n`
);
const comment = `: chaos ${part.index} ${part.ok ? "ok" : "fail"} ${part.model}\n`;
if (!emitCustomEvent) {
return comment + "\n";
}
return comment + `event: omni-chaos-part\n` + `data: ${JSON.stringify(meta)}\n\n`;
}
/**
@@ -330,9 +342,11 @@ function concatSseText(sse: string): string {
* `config.chaos.enabled` flag is set (the `auto/chaos` virtual combo).
*
* Returns a single Response whose body is an SSE stream:
* - one `omni-chaos-part` event per panel model, enqueued PROGRESSIVELY as
* each model lands (so the client starts receiving answers immediately,
* without waiting for the whole panel to finish)
* - one SSE comment (`: chaos N ...`) per panel model, enqueued
* PROGRESSIVELY as each model lands (comments are ignored by every SSE
* parser, so OpenAI-compatible clients see only the final chunk)
* - when `stream_options.include_chaos_parts: true` is set, the per-panel
* `omni-chaos-part` custom event is emitted instead of the bare comment
* - a final `data:` OpenAI-style chunk carrying the primary model's answer
* (so non-aware clients / IDEs still get a usable completion)
* - a terminating `data: [DONE]`
@@ -354,6 +368,14 @@ export async function handleChaosChat(opts: {
const panel = Array.isArray(models) ? models.filter(Boolean) : [];
const hardTimeout = tuning?.panelHardTimeoutMs ?? CHAOS_DEFAULTS.panelHardTimeoutMs;
const minPanel = tuning?.minPanel ?? CHAOS_DEFAULTS.minPanel;
// Opt-in gate: only protocol-aware clients request the custom event. OpenAI
// SDK validators choke on any `data:` payload without `choices`/`error`, so
// the default MUST be comment-only output.
const streamOptions = (body as Record<string, unknown> | null | undefined)?.stream_options;
const emitCustomEvent =
typeof streamOptions === "object" &&
streamOptions !== null &&
(streamOptions as Record<string, unknown>).include_chaos_parts === true;
if (panel.length === 0) {
return errorResponse(400, "Chaos combo has no models");
}
@@ -396,7 +418,7 @@ export async function handleChaosChat(opts: {
hardTimeout,
log,
onResult: async (part) => {
await safeEnqueue(serializeChaosPart(part, false));
await safeEnqueue(serializeChaosPart(part, false, emitCustomEvent));
},
});
});

View File

@@ -1908,7 +1908,23 @@ export async function handleComboChat({
!isTokenLimitBreach &&
!scopedFailure &&
[408, 429, 500, 502, 503, 504].includes(result.status);
if (retry < maxRetries && isTransient && !providerExhausted) {
// failoverBeforeRetry means what it says: prefer the next sibling
// target over hammering this one again. Without this check, a
// transient error always re-hit the SAME model up to maxRetries
// times regardless of the setting — config.failoverBeforeRetry was
// threaded through to skipUpstreamRetry (a different, lower-level
// retry mechanism) but never consulted here, so a rate-limited
// model got maxRetries+1 back-to-back attempts on itself before
// this loop's own fallback-to-next-target ever ran (#2417). Only
// skip the same-model retry when `nextTarget` (computed above)
// actually gives us somewhere to fail over to — with no sibling
// left, skipping just burns the last attempt for nothing.
if (
retry < maxRetries &&
isTransient &&
!providerExhausted &&
(!config.failoverBeforeRetry || !nextTarget)
) {
if (
!protectedPriorityTarget &&
provider &&
@@ -1996,6 +2012,24 @@ export async function handleComboChat({
strategy,
target: toRecordedTarget(target),
});
// LKGP (#919) mirror of the success-path set below: a just-failed target
// must not keep re-pinning itself as the "last known good" choice for the
// *next* separate request. Circuit breaker / model lockout deliberately
// don't react to request-scoped failure classes (see scopedFailure below),
// so nothing else clears this stale pin.
void (async () => {
try {
const { clearLKGP } = await import("../../src/lib/localDb");
await Promise.all([
clearLKGP(combo.name, target.executionKey),
clearLKGP(combo.name, combo.id || combo.name),
]);
} catch (err) {
log.warn("COMBO", "Failed to clear Last Known Good Provider. This is non-fatal.", {
err,
});
}
})();
recordedAttempts++;
lastError = errorText || String(result.status);
comboErrors.push({
@@ -2626,7 +2660,8 @@ async function handleRoundRobinCombo({
filteredTargets,
// #7270: normalize both wire shapes (.messages / Responses-API .input) so RR
// stickiness engages on the /v1/responses surface, not just Chat Completions.
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown })
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }),
combo.name
);
const rrAffinity = applyPromptCacheAffinity(
filteredTargets,
@@ -3123,7 +3158,18 @@ async function handleRoundRobinCombo({
!isTokenLimitBreach &&
!scopedFailure &&
[408, 429, 500, 502, 503, 504].includes(result.status);
if (retry < maxRetries && isTransient && !providerExhausted) {
// See the same guard's comment in the "auto" strategy loop above —
// failoverBeforeRetry must prevent this same-model retry too, not
// just the lower-level skipUpstreamRetry mechanism. Only skip when
// `offset + 1 < modelCount` means a sibling target is actually left
// in this rotation; with none left, skipping just wastes the attempt.
const hasNextRrTarget = offset + 1 < modelCount;
if (
retry < maxRetries &&
isTransient &&
!providerExhausted &&
(!config.failoverBeforeRetry || !hasNextRrTarget)
) {
continue;
}
@@ -3135,6 +3181,22 @@ async function handleRoundRobinCombo({
strategy: "round-robin",
target: toRecordedTarget(target),
});
// LKGP (#919) mirror of handleComboChat's failure-path clear above — see
// that comment for why this must happen (nothing else clears a pin left
// by a request-scoped failure class like a stream-readiness timeout).
void (async () => {
try {
const { clearLKGP } = await import("../../src/lib/localDb");
await Promise.all([
clearLKGP(combo.name, target.executionKey),
clearLKGP(combo.name, combo.id || combo.name),
]);
} catch (err) {
log.warn("COMBO-RR", "Failed to clear Last Known Good Provider. This is non-fatal.", {
err,
});
}
})();
recordedAttempts++;
lastError = errorText || String(result.status);
lastStatus = result.status;

View File

@@ -8,7 +8,8 @@
*
* Design
* ──────
* • Hash key: SHA-256 of the FIRST user message → first 16 hex chars.
* • Hash key: SHA-256 of the FIRST user message, namespaced by Combo identity
* at production call sites → first 16 hex chars.
* Using only the first message gives a stable key that does not change as
* the conversation grows, yet still identifies the conversation reliably.
* • Headroom gate: before reusing the sticky connection we re-check that its
@@ -317,6 +318,23 @@ export function deriveMessageHash(
return createHash("sha256").update(text).digest("hex").slice(0, 16);
}
/**
* Keep one conversation's prompt-cache affinity local to the Combo that learned
* it. Without this namespace, two different Combos receiving the same first
* user message share a binding and can silently reorder each other's targets.
* The unscoped form remains available for direct callers and backwards-compatible
* unit seams; production dispatchers always provide their Combo name.
*/
function scopeMessageHash(messageHash: string, namespace?: string): string {
if (!namespace) return messageHash;
return createHash("sha256")
.update(namespace)
.update("\0")
.update(messageHash)
.digest("hex")
.slice(0, 16);
}
/** Evict expired entries and enforce the hard cap. */
function evict(): void {
const now = Date.now();
@@ -424,19 +442,22 @@ export interface ApplyStickinessResult {
*
* @param orderedTargets Targets already ordered by the combo strategy.
* @param messages Request body.messages.
* @param namespace Combo identity that owns this sticky binding.
* @returns Result with (possibly reordered) targets.
*/
export async function applySessionStickiness(
orderedTargets: ResolvedComboTarget[],
messages: Array<{ role?: string; content?: unknown }> | null | undefined
messages: Array<{ role?: string; content?: unknown }> | null | undefined,
namespace?: string
): Promise<ApplyStickinessResult> {
const noOp: ApplyStickinessResult = { targets: orderedTargets, messageHash: null, stuck: false };
try {
if (orderedTargets.length <= 1) return noOp;
const messageHash = deriveMessageHash(messages);
if (!messageHash) return noOp;
const rawMessageHash = deriveMessageHash(messages);
if (!rawMessageHash) return noOp;
const messageHash = scopeMessageHash(rawMessageHash, namespace);
const existing = stickyMap.get(messageHash);
if (!existing) return { targets: orderedTargets, messageHash, stuck: false };

View File

@@ -498,7 +498,8 @@ async function applyContinuityFilters(
initialOrderedTargets,
// #7270: normalize both wire shapes (.messages / Responses-API .input) so the
// stickiness key is derivable on the /v1/responses surface, not just Chat Completions.
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown })
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }),
combo.name
);
let orderedTargets = sticky.targets;
if (!cacheStrategyAffinityApplied) {

View File

@@ -13,6 +13,7 @@
* @see Issue #1628
*/
import { createHash } from "node:crypto";
import {
clearAllReasoningCache,
cleanupExpiredReasoning,
@@ -137,8 +138,8 @@ type AssistantMessageLike = {
};
type AssistantMessageCacheContext = {
requestId?: string;
messageIndex?: number;
scope?: string;
historyMessages?: AssistantMessageLike[];
};
type ToolCallLike = {
@@ -234,8 +235,79 @@ export function cacheReasoningByKey(
}
}
function buildAssistantMessageCacheKey(requestId: string, messageIndex: number): string {
return `request:${requestId}:message:${messageIndex}`;
function stableCacheValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableCacheValue);
if (!value || typeof value !== "object") return value;
const record = value as Record<string, unknown>;
return Object.fromEntries(
Object.keys(record)
.filter((key) => key !== "reasoning" && key !== "reasoning_content")
.sort()
.map((key) => [key, stableCacheValue(record[key])])
);
}
function canonicalizeMessageContent(content: unknown): unknown {
if (!Array.isArray(content)) return stableCacheValue(content ?? null);
const textParts: string[] = [];
for (const part of content) {
if (typeof part === "string") {
textParts.push(part);
continue;
}
if (!part || typeof part !== "object") return stableCacheValue(content);
const record = part as Record<string, unknown>;
if (
(record.type === "text" || record.type === "input_text" || record.type === "output_text") &&
typeof record.text === "string"
) {
textParts.push(record.text);
continue;
}
return stableCacheValue(content);
}
return textParts.join("");
}
function canonicalizeHistoryMessage(message: AssistantMessageLike): unknown {
const record = message as Record<string, unknown>;
const toolCalls = Array.isArray(record.tool_calls)
? record.tool_calls.map((toolCall) => {
const call = toolCall as Record<string, unknown>;
const fn = (call.function ?? {}) as Record<string, unknown>;
return stableCacheValue({
type: call.type,
function: { name: fn.name, arguments: fn.arguments },
});
})
: undefined;
return stableCacheValue({
role: record.role,
name: record.name,
content: canonicalizeMessageContent(record.content),
tool_calls: toolCalls,
});
}
export function buildAssistantMessageCacheKey(
scope: string | null | undefined,
messages: AssistantMessageLike[],
messageIndex: number
): string {
const normalizedScope = scope?.trim();
if (!normalizedScope || !Number.isInteger(messageIndex) || messageIndex < 0) return "";
const message = messages[messageIndex];
if (!message || message.role !== "assistant") return "";
const transcript = messages.slice(0, messageIndex + 1).map(canonicalizeHistoryMessage);
const digest = createHash("sha256")
.update(normalizedScope)
.update("\x1f")
.update(JSON.stringify(transcript))
.digest("hex");
return `conversation:${digest}`;
}
/**
@@ -282,18 +354,15 @@ export function cacheReasoningFromAssistantMessage(
.filter((id) => id.length > 0)
: [];
if (toolCallIds.length === 0) {
const requestId = context?.requestId?.trim();
const messageIndex = context?.messageIndex;
if (!requestId || typeof messageIndex !== "number" || !Number.isInteger(messageIndex)) {
return 0;
}
const scope = context?.scope?.trim();
const historyMessages = context?.historyMessages;
if (!scope || !Array.isArray(historyMessages)) return 0;
cacheReasoningByKey(
buildAssistantMessageCacheKey(requestId, messageIndex),
provider,
model,
reasoning
);
const messages = [...historyMessages, message];
const cacheKey = buildAssistantMessageCacheKey(scope, messages, messages.length - 1);
if (!cacheKey) return 0;
cacheReasoningByKey(cacheKey, provider, model, reasoning);
return 1;
}
@@ -329,7 +398,8 @@ export function lookupReasoning(toolCallId: string): string | null {
}
// 2. Fallback to DB
let dbResult: { reasoning: string; provider: string; model: string } | null = null;
let dbResult: { reasoning: string; provider: string; model: string; expiresAt: string } | null =
null;
try {
dbResult = getReasoningCache(toolCallId);
} catch {
@@ -341,6 +411,11 @@ export function lookupReasoning(toolCallId: string): string | null {
misses++;
return null;
}
const persistedExpiresAt = Date.parse(dbResult.expiresAt);
if (!Number.isFinite(persistedExpiresAt) || persistedExpiresAt <= Date.now()) {
misses++;
return null;
}
hits++;
let promotedReasoning = dbResult.reasoning;
if (promotedReasoning.length > MAX_ENTRY_BYTES) {
@@ -351,7 +426,7 @@ export function lookupReasoning(toolCallId: string): string | null {
reasoning: promotedReasoning,
provider: dbResult.provider,
model: dbResult.model,
expiresAt: Date.now() + TTL_MS,
expiresAt: persistedExpiresAt,
createdAt: Date.now(),
});
return promotedReasoning;

View File

@@ -1,14 +1,23 @@
/**
* Thinking Budget Control — Phase 2
*
* Provides proxy-level control over AI thinking/reasoning budgets.
* Modes: auto, passthrough, custom, adaptive
* Proxy-level control of **client thinking/reasoning request fields**
* (`reasoning`, `reasoning_effort`, Claude `thinking`, Gemini thinking_config).
*
* Modes (see Dashboard → Settings → AI → Thinking Budget):
* - passthrough: leave client fields unchanged (required for Codex visible thinking)
* - auto: STRIP all thinking/reasoning fields before upstream (not “auto-show thinking”)
* - custom: force a fixed token budget on every request
* - adaptive: scale budget from a base effort by request complexity
*
* Independent of compression, prompt cache, combo routing, and API-key token limits.
* Does **not** decrypt OpenAI/Codex `encrypted_content` reasoning blobs.
*/
// Thinking budget modes
export const ThinkingMode = {
AUTO: "auto", // Let provider decide (remove client's budget)
PASSTHROUGH: "passthrough", // No changes (current behavior)
AUTO: "auto", // Strip all client thinking/reasoning fields (provider invents defaults)
PASSTHROUGH: "passthrough", // No changes — client fully controls thinking
CUSTOM: "custom", // Set fixed budget
ADAPTIVE: "adaptive", // Scale based on request complexity
};
@@ -247,7 +256,9 @@ export function applyThinkingBudget(
}
/**
* AUTO mode: strip all thinking configuration, let provider decide
* AUTO mode: strip all thinking/reasoning configuration from the request body.
* Upstream then runs without client-requested effort/summary — this can hide
* thinking panels in Codex/Desktop and is the opposite of “show thinking”.
*/
function stripThinkingConfig(body: unknown) {
const result: JsonRecord = { ...toRecord(body) };

View File

@@ -163,3 +163,20 @@ export function sanitizeOpenAITool(tool: unknown): unknown {
export function sanitizeOpenAITools(tools: unknown[]): unknown[] {
return tools.map(sanitizeOpenAITool);
}
export function flattenOpenAIToolRootAnyOf(tools: unknown): unknown {
if (!Array.isArray(tools)) return tools;
return tools.map((tool) => {
if (!isPlainObject(tool)) return tool;
const next = { ...tool };
const fn = isPlainObject(next.function) ? { ...next.function } : next;
if (!isPlainObject(fn.parameters) || !hasOwn(fn.parameters, "anyOf")) return tool;
const parameters = { ...fn.parameters };
delete parameters.anyOf;
fn.parameters = parameters;
if (fn !== next) next.function = fn;
return next;
});
}

View File

@@ -209,6 +209,12 @@ export function createResponsesApiTransformStream(
funcItemTypes: {},
funcArgsDone: {},
funcItemDone: {},
// Cached at first computation (see toolCallOutputIndexBase) so every
// added/delta/done event for a given tool call — including ones emitted
// later from the finish_reason handler or flush(), where the reasoning/
// message state used to derive the base is no longer meaningful to
// recompute — shares exactly the same output_index.
funcOutputIndex: {} as Record<string, number>,
completedOutputItems: [] as Array<{
output_index: number;
item: Record<string, unknown>;
@@ -380,6 +386,27 @@ export function createResponsesApiTransformStream(
}
};
// Tool calls sit after reasoning (if any) AND after a text message (if one
// was actually emitted this turn). The provider's own tool_calls[].index is
// scoped only to the tool_calls array and legitimately restarts at 0 — using
// it directly as the Responses API output_index collides with whatever
// reasoning/message item already claimed that slot, and a client that
// tracks response items by output_index silently drops the tool call.
//
// Computed once per tcIdx (from the chunk's own choice index, `chunkIdx`)
// and cached in state.funcOutputIndex so every added/delta/done event for
// that call — including ones emitted later from the finish_reason handler
// or flush(), which have no fresh chunk/reasoning/message state to
// recompute from — shares exactly the same output_index.
const computeToolCallOutputIndex = (chunkIdx, tcIdx) => {
if (state.funcOutputIndex[tcIdx] === undefined) {
const msgIdx = state.reasoningId ? state.reasoningIndex + 1 : chunkIdx;
const base = state.msgItemAdded[msgIdx] ? msgIdx + 1 : msgIdx;
state.funcOutputIndex[tcIdx] = base + normalizeOutputIndex(tcIdx);
}
return state.funcOutputIndex[tcIdx];
};
const emitToolCallAdded = (controller, idx) => {
if (state.funcItemAdded[idx] || !state.funcCallIds[idx]) return false;
@@ -390,7 +417,7 @@ export function createResponsesApiTransformStream(
emit(controller, "response.output_item.added", {
type: "response.output_item.added",
output_index: idx,
output_index: state.funcOutputIndex[idx],
item: {
id: `fc_${state.funcCallIds[idx]}`,
type: itemType,
@@ -406,7 +433,7 @@ export function createResponsesApiTransformStream(
const closeToolCall = (controller, idx, recordAsCompleted = true) => {
const callId = state.funcCallIds[idx];
if (callId && !state.funcItemDone[idx]) {
const normalizedIndex = normalizeOutputIndex(idx);
const normalizedIndex = state.funcOutputIndex[idx];
let args = state.funcArgsBuf[idx] || "{}";
const toolName = state.funcNames[idx] || "";
emitToolCallAdded(controller, idx);
@@ -750,6 +777,7 @@ export function createResponsesApiTransformStream(
for (const tc of delta.tool_calls) {
const tcIdx = tc.index ?? 0;
const outputIndex = computeToolCallOutputIndex(idx, tcIdx);
const newCallId = tc.id;
const funcName = tc.function?.name;
@@ -765,6 +793,10 @@ export function createResponsesApiTransformStream(
delete state.funcItemTypes[tcIdx];
delete state.funcArgsDone[tcIdx];
delete state.funcItemDone[tcIdx];
// Deliberately keep funcOutputIndex[tcIdx]: the replacement call
// reuses the same positional slot, so it should keep the same
// output_index rather than recomputing (which could drift if
// msgItemAdded state shifted mid-turn).
}
if (funcName) state.funcNames[tcIdx] = funcName;
@@ -786,7 +818,7 @@ export function createResponsesApiTransformStream(
emit(controller, "response.function_call_arguments.delta", {
type: "response.function_call_arguments.delta",
item_id: `fc_${state.funcCallIds[tcIdx]}`,
output_index: tcIdx,
output_index: outputIndex,
delta: state.funcArgsBuf[tcIdx],
});
}
@@ -825,7 +857,7 @@ export function createResponsesApiTransformStream(
emit(controller, "response.function_call_arguments.delta", {
type: "response.function_call_arguments.delta",
item_id: `fc_${refCallId}`,
output_index: tcIdx,
output_index: outputIndex,
delta: emittedDelta,
});
}

View File

@@ -30,11 +30,15 @@ import { getResolvedModelCapabilities, supportsReasoning } from "../services/mod
import { normalizeRoles } from "../services/roleNormalizer.ts";
import { hoistLeadingSystemMessage } from "./helpers/strictSystemHoist.ts";
import {
buildAssistantMessageCacheKey,
lookupReasoning,
recordReplay,
requiresReasoningReplay,
} from "../services/reasoningCache.ts";
import { normalizeResponsesReasoningEffort } from "./request/openai-responses/helpers.ts";
import {
normalizeResponsesReasoningEffort,
RESPONSES_STORE_MARKER,
} from "./request/openai-responses/helpers.ts";
bootstrapTranslatorRegistry();
export { register } from "./registry.ts";
@@ -146,25 +150,6 @@ function normalizeOpenAIResponsesRequest(body) {
return normalized;
}
function getReasoningCacheRequestId(body: Record<string, unknown> | null | undefined): string {
if (!body || typeof body !== "object") return "";
const requestId =
body._reasoningCacheRequestId ??
body.reasoningCacheRequestId ??
body.request_id ??
body.requestId;
return typeof requestId === "string" ? requestId.trim() : "";
}
function getAssistantMessageCacheKey(
body: Record<string, unknown> | null | undefined,
messageIndex: number
): string {
const requestId = getReasoningCacheRequestId(body);
return requestId ? `request:${requestId}:message:${messageIndex}` : "";
}
function hasNonEmptyReasoningContent(message: Record<string, unknown>): boolean {
return typeof message.reasoning_content === "string" && message.reasoning_content.length > 0;
}
@@ -250,6 +235,7 @@ export function translateRequest(
preserveCacheControl?: boolean;
signatureNamespace?: string | null;
preCompressionBody?: Record<string, unknown> | null;
reasoningCacheScope?: string | null;
/** UA-detected GitHub Copilot client. Forwarded to translators via the
* transient `_copilotClient` credential flag (see openai-responses → openai). */
copilotClient?: boolean;
@@ -265,13 +251,6 @@ export function translateRequest(
const normalizedModel = String(model ?? "");
const isKimiCoding =
normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey";
const requiresExplicitReasoningReplay = requiresReasoningReplay({
provider: normalizedProvider,
model: normalizedModel,
allowLegacyFallback: false,
});
const preserveResponsesReasoning =
sourceFormat === FORMATS.OPENAI_RESPONSES && requiresExplicitReasoningReplay;
// Phase 2: Apply thinking budget control before normalization
result = applyThinkingBudget(result);
@@ -282,6 +261,29 @@ export function translateRequest(
// Normalize thinking config: remove if lastMessage is not user
normalizeThinkingConfig(result);
// Resolve the replay contract before Responses input is converted: conversion
// must know whether reasoning items are protocol history rather than display metadata.
const resolvedCapabilities = getResolvedModelCapabilities({
provider: normalizedProvider,
model: normalizedModel,
});
const replayRequirements = {
provider: normalizedProvider,
model: normalizedModel,
thinkingEnabled: hasThinkingConfig(result),
supportsReasoning: supportsReasoning({
provider: normalizedProvider,
model: normalizedModel,
}),
interleavedField: resolvedCapabilities?.interleavedField ?? null,
};
const isReasoner = requiresReasoningReplay(replayRequirements);
const requiresExplicitReasoningReplay = requiresReasoningReplay({
...replayRequirements,
allowLegacyFallback: false,
});
const preserveResponsesReasoning = sourceFormat === FORMATS.OPENAI_RESPONSES && isReasoner;
// Ensure tool_calls have id; optionally normalize to 9-char for providers like Mistral
ensureToolCallIds(result, { use9CharId });
@@ -421,24 +423,6 @@ export function translateRequest(
}
}
// Resolve reasoning-replay status up-front: it gates both the reasoning_content
// strip in filterToOpenAIFormat below (#4849 must NOT strip client reasoning for
// replay providers) and the cache re-injection further down.
const resolvedCapabilities = getResolvedModelCapabilities({
provider: normalizedProvider,
model: normalizedModel,
});
const isReasoner = requiresReasoningReplay({
provider: normalizedProvider,
model: normalizedModel,
thinkingEnabled: hasThinkingConfig(result),
supportsReasoning: supportsReasoning({
provider: normalizedProvider,
model: normalizedModel,
}),
interleavedField: resolvedCapabilities?.interleavedField ?? null,
});
// Always normalize to clean OpenAI format when target is OpenAI
// This handles hybrid requests (e.g., OpenAI messages + Claude tools)
if (targetFormat === FORMATS.OPENAI) {
@@ -650,7 +634,11 @@ export function translateRequest(
const cacheKey = hasToolCalls
? msg.tool_calls[0]?.id
: getAssistantMessageCacheKey(result, messageIndex);
: buildAssistantMessageCacheKey(
options?.reasoningCacheScope,
result.messages,
messageIndex
);
if (cacheKey) {
const cached = lookupReasoning(cacheKey);
if (cached) {
@@ -700,6 +688,19 @@ export function translateRequest(
}
}
// #<store-marker-leak>: a Responses-source request stashes the client's
// `store` intent under this internal marker (see the Responses -> OpenAI
// step above) so a later OpenAI -> Responses re-conversion can restore it
// as `store`. When the destination stays in Chat Completions shape (no
// such re-conversion happens), nothing else consumes the marker, and it
// was leaking verbatim into the real upstream request body — e.g. OpenAI
// itself rejects it with "Unknown parameter: '_omnirouteResponsesStore'".
// Always drop it here: any handler that still needs the client's original
// `store` value would have already read the marker before this point.
if (RESPONSES_STORE_MARKER in result) {
delete result[RESPONSES_STORE_MARKER];
}
return result;
}

View File

@@ -235,21 +235,23 @@ export function openaiResponsesToOpenAIRequest(
if (itemType === "message") {
const role = toString(item.role);
// Flush pending assistant message with tool calls
if (currentAssistantMsg) {
messages.push(currentAssistantMsg);
currentAssistantMsg = null;
}
if (role !== "assistant" && pendingReasoningContent) {
messages.push({
role: "assistant",
content: null,
reasoning_content: pendingReasoningContent,
});
pendingReasoningContent = "";
if (role !== "assistant") {
if (currentAssistantMsg) {
messages.push(currentAssistantMsg);
currentAssistantMsg = null;
}
if (pendingReasoningContent) {
messages.push({
role: "assistant",
content: null,
reasoning_content: pendingReasoningContent,
});
pendingReasoningContent = "";
}
}
// Flush pending tool results
// Flush pending tool results before the next explicit message boundary.
if (pendingToolResults.length > 0) {
for (const toolResult of pendingToolResults) {
messages.push(toolResult);
@@ -292,12 +294,29 @@ export function openaiResponsesToOpenAIRequest(
})
: item.content;
const message: JsonRecord = { role, content };
if (role === "assistant" && pendingReasoningContent) {
message.reasoning_content = pendingReasoningContent;
pendingReasoningContent = "";
if (role === "assistant") {
if (!currentAssistantMsg) {
currentAssistantMsg = { role, content };
} else if (currentAssistantMsg.content == null && content != null) {
currentAssistantMsg.content = content;
} else if (content != null) {
const existingContent = currentAssistantMsg.content;
currentAssistantMsg.content = [
...(Array.isArray(existingContent) ? existingContent : [existingContent]),
...(Array.isArray(content) ? content : [content]),
];
}
if (pendingReasoningContent) {
currentAssistantMsg.reasoning_content = appendReasoningContent(
currentAssistantMsg.reasoning_content,
pendingReasoningContent
);
pendingReasoningContent = "";
}
continue;
}
messages.push(message);
messages.push({ role, content });
continue;
}

View File

@@ -240,7 +240,12 @@ export function openaiToClaudeRequest(model, body, stream, credentials = null) {
// could exceed model caps (e.g. Opus 4.7's 128000 ceiling) and trigger
// HTTP 400 from Anthropic.
if (!isKimiCoding) {
const fitted = fitThinkingToMaxTokens(model, Number(result.max_tokens) || 0, result.thinking);
const fitted = fitThinkingToMaxTokens(
model,
Number(result.max_tokens) || 0,
result.thinking,
routedProvider
);
result.max_tokens = fitted.maxTokens;
if (fitted.thinking === undefined) {
delete result.thinking;

View File

@@ -7,9 +7,9 @@ import { capMaxOutputTokens } from "../../../../src/lib/modelCapabilities.ts";
const MIN_CLAUDE_THINKING_BUDGET = 1024;
const MIN_RESPONSE_ROOM = 1024;
function safeCapMaxOutputTokens(model: string): number | null {
function safeCapMaxOutputTokens(model: string, provider?: string | null): number | null {
try {
const cap = capMaxOutputTokens(model);
const cap = capMaxOutputTokens(provider ? { provider, model } : model);
return typeof cap === "number" && cap > 0 ? cap : null;
} catch {
return null;
@@ -31,6 +31,12 @@ function safeCapMaxOutputTokens(model: string): number | null {
* responseRoom shrunk to MIN_RESPONSE_ROOM; if still below MIN, disable
* thinking entirely (cap too tight for any reasoning).
*
* `provider` scopes the cap lookup to a provider-specific override (e.g. a
* dashboard-set `max_output_tokens` for `opencode-go/qwen3.7-plus`) when the
* model-only entry has no cap of its own. Without it, a model whose real
* ceiling is only known per-provider resolves to no cap at all and the
* synthesized `max_tokens` goes out unbounded (#10139).
*
* Worked example (real-world Opus 4.7 case that previously 400'd):
* caller max_tokens = 32000, reasoning_effort=high → budget = 131072,
* model cap = 128000.
@@ -42,9 +48,10 @@ function safeCapMaxOutputTokens(model: string): number | null {
export function fitThinkingToMaxTokens(
model: string,
callerMaxTokens: number,
thinking: Record<string, unknown> | undefined
thinking: Record<string, unknown> | undefined,
provider?: string | null
): { maxTokens: number; thinking: Record<string, unknown> | undefined } {
const modelCap = safeCapMaxOutputTokens(model);
const modelCap = safeCapMaxOutputTokens(model, provider);
const requestedBudget = Number(thinking?.budget_tokens) || 0;
// No budgeted thinking — just cap max_tokens to the model output ceiling.

View File

@@ -528,9 +528,21 @@ function emitToolCall(state, emit, tc) {
// Custom tools are surfaced as custom_tool_call items and stream raw input instead of the
// function_call_arguments.* events used for regular function tools. (#1007)
//
// apply_patch defaults to custom (native Codex CLI convention: the model emits it
// without the client ever declaring it as a tool) UNLESS the client's own request
// explicitly declared it with a `parameters` JSON schema — i.e. as a plain
// `type:"function"` tool (state.toolSchemas, populated from body.tools by
// extractToolSchemaMap()). Live incident: a client that registers apply_patch as a
// function tool and only implements function_call dispatch never recognized the
// custom_tool_call item this produced, so the tool call was silently never executed
// and no follow-up request ever carried a result back. PR #7905 already intended this
// precedence ("...while preserving explicit function-tool precedence") but its
// unconditional `toolName === "apply_patch"` OR never actually implemented the carve-out.
const toolName = state.funcNames[tcIdx] || funcName || "";
const isCustomTool =
toolName === "apply_patch" || state.customToolNames?.has?.(toolName) === true;
(toolName === "apply_patch" && !state.toolSchemas?.has?.(toolName)) ||
state.customToolNames?.has?.(toolName) === true;
if (!state.funcCallIds[tcIdx] && newCallId) state.funcCallIds[tcIdx] = newCallId;
const callId = state.funcCallIds[tcIdx];
@@ -597,8 +609,11 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) {
const normalizedIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(idx);
const args = state.funcArgsBuf[idx] || "{}";
const toolName = state.funcNames[idx] || "";
// See emitToolCall()'s isCustomTool comment — must stay in sync (both compute the
// same classification independently for their respective add/close call sites).
const isCustomTool =
toolName === "apply_patch" || state.customToolNames?.has?.(toolName) === true;
(toolName === "apply_patch" && !state.toolSchemas?.has?.(toolName)) ||
state.customToolNames?.has?.(toolName) === true;
let funcItem;
if (isCustomTool) {

View File

@@ -47,7 +47,10 @@ function findHeader(headers: Record<string, string>, name: string): string | und
* the OpenCode CLI identity headers that Cloudflare requires on VPS egress
* (User-Agent, x-opencode-client, x-opencode-project) plus fresh request/session
* UUIDs, but ONLY for keys the client did not already supply. Client values always
* win; these defaults only fill gaps. (#5997)
* win; these defaults only fill gaps. User-Agent is the one exception: a client UA
* that is not already the OpenCode CLI (e.g. curl/8.5.0) is REPLACED with the
* synthesized CLI UA, because opencode.ai's free tier rejects generic client UAs
* from datacenter IPs with FreeUsageLimitError 429. (#5997, follow-up #10229)
*/
export function forwardOpencodeClientHeaders(
headers: Record<string, string>,
@@ -100,14 +103,22 @@ export function forwardOpencodeClientHeaders(
}
/**
* Fill the OpenCode CLI identity headers Cloudflare requires on VPS egress, but only for
* keys the client did not already supply (client values always win). (#5997)
* Fill the OpenCode CLI identity headers Cloudflare requires on VPS egress. For
* x-opencode-* headers, client values always win (defaults only fill gaps). The
* User-Agent is the exception: a non-CLI client UA (curl, python, SDKs) is replaced
* with the synthesized CLI UA, because opencode.ai's free tier flags generic client
* UAs from datacenter IPs (FreeUsageLimitError 429). A client UA that already looks
* like the OpenCode CLI (opencode-cli/...) is preserved so the real CLI's versioned
* identity stays intact. (#5997, follow-up)
*/
function applyCliDefaults(
headers: Record<string, string>,
cliDefaults: { userAgent: string; client: string; project: string }
): void {
if (!headers["User-Agent"] && !headers["user-agent"]) {
const existingUa = headers["User-Agent"] || headers["user-agent"];
const clientUaIsCliLike =
typeof existingUa === "string" && /^opencode-cli\//i.test(existingUa.trim());
if (!clientUaIsCliLike) {
setUserAgentHeader(headers, cliDefaults.userAgent);
}
headers["x-opencode-client"] ||= cliDefaults.client;

View File

@@ -74,6 +74,7 @@ import {
hasUnsupportedReasoningSignal,
} from "./reasoningFields.ts";
import { applyThinkTag, flushThink, initThinkState } from "./thinkTagParser.ts";
import { sseCommentsEnabled } from "./sseHeartbeat.ts";
import {
caseInsensitiveToolNameLookup,
restoreOpenAIToolNames,
@@ -787,6 +788,29 @@ export function createSSEStream(options: StreamOptions = {}) {
let upstreamErrorForwarded = false;
const providerPayloadCollector = createStructuredSSECollector({
stage: "provider_response",
// #9315: compute the summary live from every pushed chunk (not just the
// ones that survive the storage cap below) so a long stream never shows a
// stale/incomplete "provider response" in the dashboard.
//
// Real bug: this was unconditionally `sourceFormat` (the CLIENT's wire
// format — see this function's own @param doc above). In TRANSLATE mode
// the chunks pushed here are the RAW PROVIDER response, whose format is
// `targetFormat` (@param "Provider format (for translate mode)"), not
// sourceFormat. Whenever a client's format differs from the provider's
// (e.g. a Responses-API client routed to a plain-OpenAI-chat-completions
// upstream — the OpenClaw/opencode-zen case that surfaced this live), the
// reducer picked for `sourceFormat` could never recognize the provider's
// actual event shape, so it never left its empty initial state — the
// dashboard's "Provider Response" panel permanently showed
// `output: []`/empty while "Client Response" (built from
// separately-accumulated state, unaffected by this) correctly showed full
// content, reading as if the two panels simply disagreed. PASSTHROUGH
// mode has no separate provider/client format split — nothing gets
// translated, so the provider's raw chunks genuinely ARE in sourceFormat
// (and real passthrough callers, e.g. createPassthroughStreamWithLogger,
// don't even pass targetFormat) — keep using sourceFormat there.
format: mode === STREAM_MODE.TRANSLATE ? targetFormat : sourceFormat,
fallbackModel: model,
});
const clientPayloadCollector = createStructuredSSECollector({
stage: "client_response",
@@ -1641,7 +1665,9 @@ export function createSSEStream(options: StreamOptions = {}) {
// retry." with finish_reason: "stop" — clients (Goose/opencode) feed that
// text back as a turn and spin in a retry loop. This restores the #3400
// behavior that #3422 inadvertently reverted (regression #3388/#3502).
if (Array.isArray(parsed.choices) && (parsed.choices.length === 0 ||
if (
Array.isArray(parsed.choices) &&
(parsed.choices.length === 0 ||
(parsed.choices.length === 1 &&
parsed.choices[0]?.delta &&
typeof parsed.choices[0].delta === "object" &&
@@ -2483,7 +2509,11 @@ export function createSSEStream(options: StreamOptions = {}) {
// #9315 switched the summary to the accumulated responseBody to avoid
// stale/truncated event data — but responseBody here is synthesized in
// chat-completion shape, which loses the Responses API `response` object.
// Keep the events-derived summary for OPENAI_RESPONSES only.
// Keep the events-derived summary for OPENAI_RESPONSES only. responseBody
// itself never carries an `object` marker (it's built purely for the
// client, which doesn't need one) — the dashboard's Provider Response
// panel does, so stamp `object: "chat.completion"` on a shallow copy
// used only for this summary, leaving responseBody itself untouched.
providerPayload: providerPayloadCollector.build(
sourceFormat === FORMATS.OPENAI_RESPONSES
? buildStreamSummaryFromEvents(
@@ -2491,7 +2521,7 @@ export function createSSEStream(options: StreamOptions = {}) {
sourceFormat,
model
)
: responseBody,
: { object: "chat.completion", ...responseBody },
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(responseBody, {
@@ -2600,11 +2630,7 @@ export function createSSEStream(options: StreamOptions = {}) {
error: err.message,
errorCode: err.code,
providerPayload: providerPayloadCollector.build(
buildStreamSummaryFromEvents(
providerPayloadCollector.getEvents(),
targetFormat,
model
),
providerPayloadCollector.getSummary(),
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(errorBody, {
@@ -2783,7 +2809,11 @@ export function createSSEStream(options: StreamOptions = {}) {
usage: state?.usage,
responseBody,
// Same OPENAI_RESPONSES carve-out as the passthrough branch above —
// the synthesized chat-shaped responseBody drops the `response` object.
// the synthesized chat-shaped responseBody drops the `response` object,
// and (like the passthrough branch) never carries an `object` marker at
// all — stamp `object: "chat.completion"` on a shallow copy used only
// for this summary; responseBody itself (sent to the client / below)
// stays untouched.
providerPayload: providerPayloadCollector.build(
targetFormat === FORMATS.OPENAI_RESPONSES
? buildStreamSummaryFromEvents(
@@ -2791,7 +2821,7 @@ export function createSSEStream(options: StreamOptions = {}) {
targetFormat,
model
)
: responseBody,
: { object: "chat.completion", ...responseBody },
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(responseBody, {

View File

@@ -12,6 +12,16 @@ type CollectorOptions = {
maxEvents?: number;
maxBytes?: number;
stage?: string;
// When set, every pushed payload — even ones dropped from the retained
// `events` array once maxEvents/maxBytes is hit — is also fed to a live
// per-format summary reducer, so build()'s summary reflects the FULL
// stream, not just the surviving (possibly truncated) event slice.
// See #9315: reconstructing the summary from getEvents() after the fact
// means a long stream that exceeds the cap gets a stale/incomplete
// "provider response" (missing tool_calls, wrong finish_reason, cut-off
// content) even though the actual served response was correct.
format?: string | null;
fallbackModel?: string | null;
};
type BuildOptions = {
@@ -20,6 +30,11 @@ type BuildOptions = {
type JsonRecord = Record<string, unknown>;
interface SummaryReducer {
ingest(payload: JsonRecord): void;
finalize(): unknown;
}
function getEventName(payload: unknown): string | undefined {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
@@ -113,13 +128,15 @@ function tryParseJson(raw: string): unknown {
}
}
function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const payloads = events
.map((evt) => asRecord(evt.data))
.filter((payload) => Object.keys(payload).length);
if (payloads.length === 0) return null;
// ─── Per-format live reducers ────────────────────────────────────────────────
// Each reducer mirrors the corresponding build*Summary()'s original for-loop
// body exactly (ingest = one loop iteration, finalize = the post-loop return),
// just restructured so it can be fed one payload at a time as chunks arrive —
// including chunks that will later be dropped from the retained event array
// once the collector's storage cap is hit.
const first = payloads[0];
function createOpenAIReducer(fallbackModel?: string | null): SummaryReducer {
let first: JsonRecord | null = null;
const contentParts: string[] = [];
const reasoningParts: string[] = [];
type ToolCall = {
@@ -156,124 +173,126 @@ function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string
return `seq:${unknownToolCallSeq}`;
};
for (const chunk of payloads) {
const choice = asRecord(Array.isArray(chunk.choices) ? chunk.choices[0] : null);
const delta = asRecord(choice.delta);
return {
ingest(chunk: JsonRecord) {
if (Object.keys(chunk).length === 0) return;
if (!first) first = chunk;
if (typeof delta.content === "string" && delta.content.length > 0) {
contentParts.push(delta.content);
}
if (Array.isArray(delta.content)) {
for (const part of delta.content) {
const partObj = asRecord(part);
if (typeof partObj.text === "string" && partObj.text.length > 0) {
contentParts.push(partObj.text);
const choice = asRecord(Array.isArray(chunk.choices) ? chunk.choices[0] : null);
const delta = asRecord(choice.delta);
if (typeof delta.content === "string" && delta.content.length > 0) {
contentParts.push(delta.content);
}
if (Array.isArray(delta.content)) {
for (const part of delta.content) {
const partObj = asRecord(part);
if (typeof partObj.text === "string" && partObj.text.length > 0) {
contentParts.push(partObj.text);
}
}
}
}
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
reasoningParts.push(delta.reasoning_content);
}
// Normalize `reasoning` alias (NVIDIA kimi-k2.5 etc.)
if (
typeof delta.reasoning === "string" &&
delta.reasoning.length > 0 &&
!delta.reasoning_content
) {
reasoningParts.push(delta.reasoning);
}
if (Array.isArray(delta.tool_calls)) {
for (const item of delta.tool_calls) {
const toolCall = asRecord(item);
const key = getToolCallKey(toolCall);
const existing = toolCalls.get(key);
const deltaArgs =
typeof asRecord(toolCall.function).arguments === "string"
? String(asRecord(toolCall.function).arguments)
: "";
if (!existing) {
toolCalls.set(key, {
id: typeof toolCall.id === "string" ? toolCall.id : null,
index: Number.isInteger(toolCall.index) ? Number(toolCall.index) : toolCalls.size,
type: toString(toolCall.type, "function"),
function: {
name: toString(asRecord(toolCall.function).name, "unknown"),
arguments: deltaArgs,
},
});
continue;
}
existing.id = existing.id || (typeof toolCall.id === "string" ? toolCall.id : null);
if (
(!Number.isInteger(existing.index) || existing.index < 0) &&
Number.isInteger(toolCall.index)
) {
existing.index = Number(toolCall.index);
}
if (typeof asRecord(toolCall.function).name === "string" && !existing.function.name) {
existing.function.name = String(asRecord(toolCall.function).name);
}
existing.function.arguments += deltaArgs;
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
reasoningParts.push(delta.reasoning_content);
}
// Normalize `reasoning` alias (NVIDIA kimi-k2.5 etc.)
if (
typeof delta.reasoning === "string" &&
delta.reasoning.length > 0 &&
!delta.reasoning_content
) {
reasoningParts.push(delta.reasoning);
}
}
if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) {
finishReason = choice.finish_reason;
}
if (chunk.usage && typeof chunk.usage === "object") {
usage = { ...asRecord(chunk.usage) };
}
}
if (Array.isArray(delta.tool_calls)) {
for (const item of delta.tool_calls) {
const toolCall = asRecord(item);
const key = getToolCallKey(toolCall);
const existing = toolCalls.get(key);
const deltaArgs =
typeof asRecord(toolCall.function).arguments === "string"
? String(asRecord(toolCall.function).arguments)
: "";
const joinedContent = contentParts.length > 0 ? contentParts.join("").trim() : null;
const joinedReasoning = reasoningParts.length > 0 ? reasoningParts.join("").trim() : null;
const message: JsonRecord = {
role: "assistant",
content: joinedContent || null,
if (!existing) {
toolCalls.set(key, {
id: typeof toolCall.id === "string" ? toolCall.id : null,
index: Number.isInteger(toolCall.index) ? Number(toolCall.index) : toolCalls.size,
type: toString(toolCall.type, "function"),
function: {
name: toString(asRecord(toolCall.function).name, "unknown"),
arguments: deltaArgs,
},
});
continue;
}
existing.id = existing.id || (typeof toolCall.id === "string" ? toolCall.id : null);
if (
(!Number.isInteger(existing.index) || existing.index < 0) &&
Number.isInteger(toolCall.index)
) {
existing.index = Number(toolCall.index);
}
if (typeof asRecord(toolCall.function).name === "string" && !existing.function.name) {
existing.function.name = String(asRecord(toolCall.function).name);
}
existing.function.arguments += deltaArgs;
}
}
if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) {
finishReason = choice.finish_reason;
}
if (chunk.usage && typeof chunk.usage === "object") {
usage = { ...asRecord(chunk.usage) };
}
},
finalize(): unknown {
if (!first) return null;
const joinedContent = contentParts.length > 0 ? contentParts.join("").trim() : null;
const joinedReasoning = reasoningParts.length > 0 ? reasoningParts.join("").trim() : null;
const message: JsonRecord = {
role: "assistant",
content: joinedContent || null,
};
if (joinedReasoning) {
message.reasoning_content = joinedReasoning;
}
const finalToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
if (finalToolCalls.length > 0) {
finishReason = "tool_calls";
message.tool_calls = finalToolCalls;
}
const result: JsonRecord = {
id: toString(first.id, `chatcmpl-${Date.now()}`),
object: "chat.completion",
created: toNumber(first.created, Math.floor(Date.now() / 1000)),
model: toString(first.model, fallbackModel || "unknown"),
choices: [
{
index: 0,
message,
finish_reason: finishReason,
},
],
};
if (usage && Object.keys(usage).length > 0) {
result.usage = usage;
}
return result;
},
};
if (joinedReasoning) {
message.reasoning_content = joinedReasoning;
}
const finalToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
if (finalToolCalls.length > 0) {
finishReason = "tool_calls";
message.tool_calls = finalToolCalls;
}
const result: JsonRecord = {
id: toString(first.id, `chatcmpl-${Date.now()}`),
object: "chat.completion",
created: toNumber(first.created, Math.floor(Date.now() / 1000)),
model: toString(first.model, fallbackModel || "unknown"),
choices: [
{
index: 0,
message,
finish_reason: finishReason,
},
],
};
if (usage && Object.keys(usage).length > 0) {
result.usage = usage;
}
return result;
}
function buildResponsesSummary(
events: StructuredSSEEvent[],
fallbackModel?: string | null
): unknown {
const payloads = events
.map((evt) => asRecord(evt.data))
.filter((payload) => Object.keys(payload).length);
if (payloads.length === 0) return null;
function createResponsesReducer(fallbackModel?: string | null): SummaryReducer {
let sawAny = false;
let completed: JsonRecord | null = null;
let latestResponse: JsonRecord | null = null;
let usage: JsonRecord | null = null;
@@ -289,67 +308,72 @@ function buildResponsesSummary(
]
: [];
for (const payload of payloads) {
const eventType = toString(payload.type);
if (
eventType === "response.completed" &&
payload.response &&
typeof payload.response === "object"
) {
completed = asRecord(payload.response);
}
if (payload.response && typeof payload.response === "object") {
latestResponse = asRecord(payload.response);
} else if (payload.object === "response") {
latestResponse = payload;
}
if (
eventType === "response.output_text.delta" &&
typeof payload.delta === "string" &&
payload.delta.length > 0
) {
textParts.push(payload.delta);
}
if (payload.usage && typeof payload.usage === "object") {
usage = { ...asRecord(payload.usage) };
} else if (payload.response && typeof asRecord(payload.response).usage === "object") {
usage = { ...asRecord(asRecord(payload.response).usage) };
}
}
const picked = completed || latestResponse;
if (picked && Object.keys(picked).length > 0) {
const pickedOutput = Array.isArray(picked.output) ? picked.output : [];
return {
id: toString(picked.id, `resp_${Date.now()}`),
object: "response",
model: toString(picked.model, fallbackModel || "unknown"),
output: pickedOutput.length > 0 ? pickedOutput : buildOutputFromText(),
usage: picked.usage ?? usage ?? null,
status: toString(picked.status, completed ? "completed" : "in_progress"),
created_at: toNumber(picked.created_at, Math.floor(Date.now() / 1000)),
metadata: asRecord(picked.metadata),
};
}
return {
id: `resp_${Date.now()}`,
object: "response",
model: fallbackModel || "unknown",
output: buildOutputFromText(),
usage: usage ?? null,
status: "completed",
created_at: Math.floor(Date.now() / 1000),
metadata: {},
ingest(payload: JsonRecord) {
if (Object.keys(payload).length === 0) return;
sawAny = true;
const eventType = toString(payload.type);
if (
eventType === "response.completed" &&
payload.response &&
typeof payload.response === "object"
) {
completed = asRecord(payload.response);
}
if (payload.response && typeof payload.response === "object") {
latestResponse = asRecord(payload.response);
} else if (payload.object === "response") {
latestResponse = payload;
}
if (
eventType === "response.output_text.delta" &&
typeof payload.delta === "string" &&
payload.delta.length > 0
) {
textParts.push(payload.delta);
}
if (payload.usage && typeof payload.usage === "object") {
usage = { ...asRecord(payload.usage) };
} else if (payload.response && typeof asRecord(payload.response).usage === "object") {
usage = { ...asRecord(asRecord(payload.response).usage) };
}
},
finalize(): unknown {
if (!sawAny) return null;
const picked = completed || latestResponse;
if (picked && Object.keys(picked).length > 0) {
const pickedOutput = Array.isArray(picked.output) ? picked.output : [];
return {
id: toString(picked.id, `resp_${Date.now()}`),
object: "response",
model: toString(picked.model, fallbackModel || "unknown"),
output: pickedOutput.length > 0 ? pickedOutput : buildOutputFromText(),
usage: picked.usage ?? usage ?? null,
status: toString(picked.status, completed ? "completed" : "in_progress"),
created_at: toNumber(picked.created_at, Math.floor(Date.now() / 1000)),
metadata: asRecord(picked.metadata),
};
}
return {
id: `resp_${Date.now()}`,
object: "response",
model: fallbackModel || "unknown",
output: buildOutputFromText(),
usage: usage ?? null,
status: "completed",
created_at: Math.floor(Date.now() / 1000),
metadata: {},
};
},
};
}
function buildClaudeSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const payloads = events
.map((evt) => asRecord(evt.data))
.filter((payload) => Object.keys(payload).length);
if (payloads.length === 0) return null;
function createClaudeReducer(fallbackModel?: string | null): SummaryReducer {
let sawAny = false;
type ClaudeBlock =
| { type: "text"; index: number; text: string }
| { type: "thinking"; index: number; thinking: string; signature?: string }
@@ -379,172 +403,177 @@ function buildClaudeSummary(events: StructuredSSEEvent[], fallbackModel?: string
// non-streaming JSON path. Last-writer-wins: the final snapshot is authoritative.
let contextManagement: JsonRecord | null = null;
for (const payload of payloads) {
const eventType = toString(payload.type);
if (
payload.context_management &&
typeof payload.context_management === "object" &&
!Array.isArray(payload.context_management)
) {
contextManagement = asRecord(payload.context_management);
}
if (eventType === "message_start") {
const message = asRecord(payload.message);
messageId = toString(message.id, messageId || `msg_${Date.now()}`);
model = toString(message.model, model);
role = toString(message.role, role);
mergeUsage(usage, message.usage);
continue;
}
return {
ingest(payload: JsonRecord) {
if (Object.keys(payload).length === 0) return;
sawAny = true;
if (eventType === "content_block_start") {
const index = toNumber(payload.index, blocks.size);
const contentBlock = asRecord(payload.content_block);
const blockType = toString(contentBlock.type);
if (blockType === "thinking") {
blocks.set(index, {
type: "thinking",
index,
thinking: toString(contentBlock.thinking),
signature:
typeof contentBlock.signature === "string" ? contentBlock.signature : undefined,
});
} else if (blockType === "tool_use") {
blocks.set(index, {
type: "tool_use",
index,
id: toString(contentBlock.id, `toolu_${Date.now()}_${index}`),
name: toString(contentBlock.name),
input: cloneLogPayload(contentBlock.input ?? {}),
inputJson: "",
});
} else {
blocks.set(index, {
type: "text",
index,
text: toString(contentBlock.text),
});
const eventType = toString(payload.type);
if (
payload.context_management &&
typeof payload.context_management === "object" &&
!Array.isArray(payload.context_management)
) {
contextManagement = asRecord(payload.context_management);
}
if (eventType === "message_start") {
const message = asRecord(payload.message);
messageId = toString(message.id, messageId || `msg_${Date.now()}`);
model = toString(message.model, model);
role = toString(message.role, role);
mergeUsage(usage, message.usage);
return;
}
continue;
}
if (eventType === "content_block_delta") {
const index = toNumber(payload.index, 0);
const delta = asRecord(payload.delta);
const deltaType = toString(delta.type);
const existing = blocks.get(index);
if (eventType === "content_block_start") {
const index = toNumber(payload.index, blocks.size);
const contentBlock = asRecord(payload.content_block);
const blockType = toString(contentBlock.type);
if (deltaType === "input_json_delta") {
const toolUse =
existing && existing.type === "tool_use"
if (blockType === "thinking") {
blocks.set(index, {
type: "thinking",
index,
thinking: toString(contentBlock.thinking),
signature:
typeof contentBlock.signature === "string" ? contentBlock.signature : undefined,
});
} else if (blockType === "tool_use") {
blocks.set(index, {
type: "tool_use",
index,
id: toString(contentBlock.id, `toolu_${Date.now()}_${index}`),
name: toString(contentBlock.name),
input: cloneLogPayload(contentBlock.input ?? {}),
inputJson: "",
});
} else {
blocks.set(index, {
type: "text",
index,
text: toString(contentBlock.text),
});
}
return;
}
if (eventType === "content_block_delta") {
const index = toNumber(payload.index, 0);
const delta = asRecord(payload.delta);
const deltaType = toString(delta.type);
const existing = blocks.get(index);
if (deltaType === "input_json_delta") {
const toolUse =
existing && existing.type === "tool_use"
? existing
: {
type: "tool_use" as const,
index,
id: `toolu_${Date.now()}_${index}`,
name: "",
input: {},
inputJson: "",
};
toolUse.inputJson += toString(delta.partial_json);
blocks.set(index, toolUse);
return;
}
if (deltaType === "thinking_delta" || typeof delta.thinking === "string") {
const thinking =
existing && existing.type === "thinking"
? existing
: { type: "thinking" as const, index, thinking: "", signature: undefined };
thinking.thinking += toString(delta.thinking);
blocks.set(index, thinking);
return;
}
const textBlock =
existing && existing.type === "text"
? existing
: {
type: "tool_use" as const,
type: "text" as const,
index,
id: `toolu_${Date.now()}_${index}`,
name: "",
input: {},
inputJson: "",
text: "",
};
toolUse.inputJson += toString(delta.partial_json);
blocks.set(index, toolUse);
continue;
textBlock.text += toString(delta.text);
blocks.set(index, textBlock);
return;
}
if (deltaType === "thinking_delta" || typeof delta.thinking === "string") {
const thinking =
existing && existing.type === "thinking"
? existing
: { type: "thinking" as const, index, thinking: "", signature: undefined };
thinking.thinking += toString(delta.thinking);
blocks.set(index, thinking);
continue;
if (eventType === "message_delta") {
const delta = asRecord(payload.delta);
stopReason = toString(delta.stop_reason, stopReason);
stopSequence =
typeof delta.stop_sequence === "string" ? String(delta.stop_sequence) : stopSequence;
mergeUsage(usage, payload.usage);
return;
}
const textBlock =
existing && existing.type === "text"
? existing
: {
type: "text" as const,
index,
text: "",
};
textBlock.text += toString(delta.text);
blocks.set(index, textBlock);
continue;
}
if (eventType === "message_delta") {
const delta = asRecord(payload.delta);
stopReason = toString(delta.stop_reason, stopReason);
stopSequence =
typeof delta.stop_sequence === "string" ? String(delta.stop_sequence) : stopSequence;
mergeUsage(usage, payload.usage);
continue;
}
},
mergeUsage(usage, payload.usage);
}
finalize(): unknown {
if (!sawAny) return null;
const content = [...blocks.values()]
.sort((a, b) => a.index - b.index)
.flatMap<ClaudeContentBlock>((block) => {
if (block.type === "text") {
return block.text
? [
{
type: "text",
text: block.text,
},
]
: [];
}
if (block.type === "thinking") {
return block.thinking
? [
{
type: "thinking",
thinking: block.thinking,
...(block.signature ? { signature: block.signature } : {}),
},
]
: [];
}
const content = [...blocks.values()]
.sort((a, b) => a.index - b.index)
.flatMap<ClaudeContentBlock>((block) => {
if (block.type === "text") {
return block.text
? [
{
type: "text",
text: block.text,
},
]
: [];
}
if (block.type === "thinking") {
return block.thinking
? [
{
type: "thinking",
thinking: block.thinking,
...(block.signature ? { signature: block.signature } : {}),
},
]
: [];
}
const parsedInput =
block.inputJson.trim().length > 0
? tryParseJson(block.inputJson)
: cloneLogPayload(block.input);
return [
{
type: "tool_use",
id: block.id,
name: block.name,
input: parsedInput,
},
];
});
const parsedInput =
block.inputJson.trim().length > 0
? tryParseJson(block.inputJson)
: cloneLogPayload(block.input);
return [
{
type: "tool_use",
id: block.id,
name: block.name,
input: parsedInput,
},
];
});
return {
id: messageId || `msg_${Date.now()}`,
type: "message",
role,
model,
content,
stop_reason: stopReason,
...(stopSequence ? { stop_sequence: stopSequence } : {}),
...(Object.keys(usage).length > 0 ? { usage } : {}),
...(contextManagement ? { context_management: contextManagement } : {}),
return {
id: messageId || `msg_${Date.now()}`,
type: "message",
role,
model,
content,
stop_reason: stopReason,
...(stopSequence ? { stop_sequence: stopSequence } : {}),
...(Object.keys(usage).length > 0 ? { usage } : {}),
...(contextManagement ? { context_management: contextManagement } : {}),
};
},
};
}
function buildGeminiSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const payloads = events
.map((evt) => asRecord(evt.data))
.filter((payload) => Object.keys(payload).length);
if (payloads.length === 0) return null;
function createGeminiReducer(fallbackModel?: string | null): SummaryReducer {
let sawAny = false;
const parts: JsonRecord[] = [];
const usageMetadata: JsonRecord = {};
let modelVersion = fallbackModel || "gemini";
@@ -565,54 +594,110 @@ function buildGeminiSummary(events: StructuredSSEEvent[], fallbackModel?: string
parts.push(part);
};
for (const payload of payloads) {
if (typeof payload.modelVersion === "string" && payload.modelVersion.length > 0) {
modelVersion = payload.modelVersion;
}
mergeUsage(usageMetadata, payload.usageMetadata);
const candidate = asRecord(Array.isArray(payload.candidates) ? payload.candidates[0] : null);
if (typeof candidate.finishReason === "string" && candidate.finishReason.length > 0) {
finishReason = candidate.finishReason;
}
const content = asRecord(candidate.content);
if (typeof content.role === "string" && content.role.length > 0) {
role = content.role;
}
if (!Array.isArray(content.parts)) continue;
for (const item of content.parts) {
const part = asRecord(item);
if (part.functionCall && typeof part.functionCall === "object") {
parts.push({
functionCall: cloneLogPayload(part.functionCall),
});
} else if (typeof part.text === "string" && part.text.length > 0) {
appendPart({
text: part.text,
...(part.thought === true ? { thought: true } : {}),
});
}
}
}
return {
candidates: [
{
index: 0,
content: {
role,
parts,
},
finishReason,
},
],
...(Object.keys(usageMetadata).length > 0 ? { usageMetadata } : {}),
modelVersion,
ingest(payload: JsonRecord) {
if (Object.keys(payload).length === 0) return;
sawAny = true;
if (typeof payload.modelVersion === "string" && payload.modelVersion.length > 0) {
modelVersion = payload.modelVersion;
}
mergeUsage(usageMetadata, payload.usageMetadata);
const candidate = asRecord(Array.isArray(payload.candidates) ? payload.candidates[0] : null);
if (typeof candidate.finishReason === "string" && candidate.finishReason.length > 0) {
finishReason = candidate.finishReason;
}
const content = asRecord(candidate.content);
if (typeof content.role === "string" && content.role.length > 0) {
role = content.role;
}
if (!Array.isArray(content.parts)) return;
for (const item of content.parts) {
const part = asRecord(item);
if (part.functionCall && typeof part.functionCall === "object") {
parts.push({
functionCall: cloneLogPayload(part.functionCall),
});
} else if (typeof part.text === "string" && part.text.length > 0) {
appendPart({
text: part.text,
...(part.thought === true ? { thought: true } : {}),
});
}
}
},
finalize(): unknown {
if (!sawAny) return null;
return {
candidates: [
{
index: 0,
content: {
role,
parts,
},
finishReason,
},
],
...(Object.keys(usageMetadata).length > 0 ? { usageMetadata } : {}),
modelVersion,
};
},
};
}
function createSummaryReducer(
format: string | null | undefined,
fallbackModel?: string | null
): SummaryReducer | undefined {
const normalized = normalizeFormat(format);
if (!normalized) return undefined;
switch (normalized) {
case FORMATS.OPENAI_RESPONSES:
return createResponsesReducer(fallbackModel);
case FORMATS.CLAUDE:
return createClaudeReducer(fallbackModel);
case FORMATS.GEMINI:
case FORMATS.ANTIGRAVITY:
return createGeminiReducer(fallbackModel);
default:
return createOpenAIReducer(fallbackModel);
}
}
function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const reducer = createOpenAIReducer(fallbackModel);
for (const evt of events) reducer.ingest(asRecord(evt.data));
return reducer.finalize();
}
function buildResponsesSummary(
events: StructuredSSEEvent[],
fallbackModel?: string | null
): unknown {
const reducer = createResponsesReducer(fallbackModel);
for (const evt of events) reducer.ingest(asRecord(evt.data));
return reducer.finalize();
}
function buildClaudeSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const reducer = createClaudeReducer(fallbackModel);
for (const evt of events) reducer.ingest(asRecord(evt.data));
return reducer.finalize();
}
function buildGeminiSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const reducer = createGeminiReducer(fallbackModel);
for (const evt of events) reducer.ingest(asRecord(evt.data));
return reducer.finalize();
}
export function buildStreamSummaryFromEvents(
events: StructuredSSEEvent[],
fallbackFormat?: string | null,
@@ -666,19 +751,25 @@ export function compactStructuredStreamPayload(payload: unknown): unknown {
}
export function createStructuredSSECollector(options: CollectorOptions = {}) {
const { maxEvents = 200, maxBytes = 49152, stage } = options;
const { maxEvents = 200, maxBytes = 49152, stage, format, fallbackModel } = options;
const events: StructuredSSEEvent[] = [];
let usedBytes = 0;
let droppedEvents = 0;
// Live-updated on every push() regardless of the storage cap above — see
// the CollectorOptions.format doc comment for why (#9315).
const reducer = createSummaryReducer(format, fallbackModel);
return {
push(payload: unknown, explicitEvent?: string) {
if (payload === null || payload === undefined) return;
const clonedData = cloneLogPayload(payload);
reducer?.ingest(asRecord(clonedData));
const event: StructuredSSEEvent = {
index: events.length + droppedEvents,
timestamp: new Date().toISOString(),
data: cloneLogPayload(payload),
data: clonedData,
};
const eventName = explicitEvent || getEventName(payload);
@@ -700,6 +791,17 @@ export function createStructuredSSECollector(options: CollectorOptions = {}) {
return events.map((event) => cloneLogPayload(event));
},
// The reducer-computed summary, built incrementally from EVERY pushed
// payload (see CollectorOptions.format) — unlike
// buildStreamSummaryFromEvents(getEvents(), ...), this is correct even
// once the collector has truncated its retained event array. Returns
// undefined if no format was configured (e.g. the client-response
// collector, which builds its summary from independently-accumulated
// response state instead).
getSummary(): unknown {
return reducer?.finalize();
},
build(summary?: unknown, buildOptions: BuildOptions = {}) {
const { includeEvents = true } = buildOptions;
return {

2056
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -350,54 +350,54 @@
"devDependencies": {
"@axe-core/playwright": "^4.11.3",
"@cyclonedx/cyclonedx-npm": "6.0.0",
"@playwright/test": "^1.60.0",
"@size-limit/file": "^12.1.0",
"@playwright/test": "^1.62.1",
"@size-limit/file": "^13.0.3",
"@stryker-mutator/core": "^9.6.1",
"@stryker-mutator/tap-runner": "^9.6.1",
"@tailwindcss/postcss": "^4.3.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@types/better-sqlite3": "^7.6.13",
"@types/better-sqlite3": "^9.6.0",
"@types/bun": "latest",
"@types/node": "^26.1.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/safe-regex": "^1.1.6",
"@types/ws": "^8.18.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-react": "^6.0.5",
"bun": "1.3.14",
"c8": "^12.0.0",
"concurrently": "^10.0.3",
"concurrently": "^10.0.4",
"cross-env": "^10.1.0",
"ctrf": "^0.2.1",
"dpdm": "^4.2.0",
"dpdm": "^4.3.0",
"eslint": "^9.39.4",
"eslint-config-next": "16.2.10",
"eslint-config-next": "16.3.0",
"eslint-plugin-sonarjs": "^4.1.0",
"fast-check": "^4.8.0",
"fumadocs-mdx": "^15.0.7",
"fumadocs-mdx": "^15.2.2",
"glob": "^13.0.6",
"httpyac": "^6.16.7",
"husky": "^9.1.7",
"jscpd": "^4.2.5",
"jsdom": "^29.1.1",
"jsdom": "^30.0.1",
"junit-to-ctrf": "^0.0.14",
"knip": "^6.18.0",
"knip": "^6.32.0",
"license-checker-rseidelsohn": "^5.0.1",
"lint-staged": "^17.0.8",
"lint-staged": "^17.3.0",
"lockfile-lint": "^5.0.0",
"node-loader": "^2.1.0",
"opencode-ai": "1.18.8",
"opencode-ai": "1.18.15",
"playwright-ctrf-json-reporter": "^0.0.29",
"prettier": "^3.8.3",
"promptfoo": "^0.121.18",
"size-limit": "^12.1.0",
"prettier": "^3.9.6",
"promptfoo": "^0.122.0",
"size-limit": "^13.0.3",
"tailwindcss": "^4.3.0",
"type-coverage": "^2.29.7",
"type-coverage": "^2.30.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"typescript-eslint": "^8.66.0",
"vitest": "^4.1.7",
"wait-on": "^9.0.10",
"wait-on": "^9.1.0",
"wtfnode": "^0.10.1"
},
"lint-staged": {

View File

@@ -0,0 +1,132 @@
/**
* Shared MCP publish-path helpers (#3578 / #3821).
*
* Unit tests use the static `files` allowlist walker (no subprocess).
* The pack-artifact gate uses the same helpers against a real
* `npm pack --dry-run --ignore-scripts` file list so concurrent unit
* suites never shell out to `npm pack`.
*/
import fs from "node:fs";
import path from "node:path";
import { normalizeArtifactPath } from "./pack-artifact-policy.ts";
/** Co-located test / spec paths that must never ship in the npm tarball. */
export const PACK_ARTIFACT_TEST_FILE_RE = /(?:^|\/)__tests__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
/** Negations that must stay in package.json `files` (static unit guard). */
export const REQUIRED_PACKAGE_FILES_TEST_NEGATIONS: readonly string[] = [
"!**/__tests__/**",
"!**/*.test.ts",
"!**/*.test.tsx",
"!**/*.test.js",
"!**/*.test.mjs",
"!**/*.spec.ts",
"!**/*.spec.tsx",
];
/** Spot-check file from the original #3578 bug report. */
export const MCP_CLOSURE_SPOT_CHECK_PATH = "src/lib/combos/steps.ts";
function resolveImport(root: string, fromFile: string, spec: string): string | null {
let base: string;
if (spec.startsWith("@/")) base = path.join("src", spec.slice(2));
else if (spec.startsWith("@omniroute/open-sse/"))
base = path.join("open-sse", spec.slice("@omniroute/open-sse/".length));
else if (spec === "@omniroute/open-sse") base = path.join("open-sse", "index");
else if (spec.startsWith("./") || spec.startsWith("../"))
base = path.join(path.dirname(fromFile), spec);
else return null; // bare package — not our source
base = base.replace(/\.(ts|tsx|js|mjs)$/, "");
const cands = [
base + ".ts",
base + ".tsx",
path.join(base, "index.ts"),
path.join(base, "index.tsx"),
base + ".js",
base + ".mjs",
];
for (const c of cands) if (fs.existsSync(path.join(root, c))) return c;
return null;
}
/**
* Transitive import closure of the MCP server entrypoints under `src/` + `open-sse/`.
*/
export function computeMcpClosure(root: string = process.cwd()): string[] {
const roots: string[] = [];
for (const f of fs.readdirSync(path.join(root, "open-sse/mcp-server"))) {
if (f.endsWith(".ts")) roots.push("open-sse/mcp-server/" + f);
}
for (const d of ["open-sse/mcp-server/tools", "open-sse/mcp-server/schemas"]) {
const abs = path.join(root, d);
if (fs.existsSync(abs))
for (const f of fs.readdirSync(abs)) if (f.endsWith(".ts")) roots.push(d + "/" + f);
}
const seen = new Set<string>();
const stack = [...roots];
const importRe =
/(?:import|export)[^"']*?from\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)/g;
while (stack.length) {
const f = stack.pop() as string;
if (seen.has(f)) continue;
seen.add(f);
let src: string;
try {
src = fs.readFileSync(path.join(root, f), "utf8");
} catch {
continue;
}
let m: RegExpExecArray | null;
while ((m = importRe.exec(src))) {
const spec = m[1] || m[2];
if (!spec) continue;
const r = resolveImport(root, f, spec);
if (r && !seen.has(r)) stack.push(r);
}
}
return [...seen].filter((f) => f.startsWith("src/") || f.startsWith("open-sse/"));
}
/** Whether `file` is covered by a package.json `files` allowlist entry. */
export function isCoveredByFiles(file: string, filesEntries: string[]): boolean {
for (const entry of filesEntries) {
if (entry.startsWith("!")) continue; // negations are not positive coverage
if (entry.endsWith("/")) {
if (file === entry.slice(0, -1) || file.startsWith(entry)) return true;
} else if (file === entry || file.startsWith(entry + "/")) {
return true;
}
}
return false;
}
/** Packed paths that look like test / spec files (over-inclusion). */
export function findLeakedTestArtifactPaths(filePaths: string[]): string[] {
return filePaths
.map(normalizeArtifactPath)
.filter(Boolean)
.filter((filePath) => PACK_ARTIFACT_TEST_FILE_RE.test(filePath))
.sort();
}
/** MCP closure members missing from a packed (or candidate) path set. */
export function findMissingMcpClosurePaths(
packedPaths: string[],
closurePaths: string[] = computeMcpClosure()
): string[] {
const packed = new Set(packedPaths.map(normalizeArtifactPath).filter(Boolean));
return closurePaths
.map(normalizeArtifactPath)
.filter(Boolean)
.filter((filePath) => !packed.has(filePath))
.sort();
}
/** Required `files` negation entries that are absent from package.json. */
export function findMissingPackageFilesTestNegations(filesEntries: string[]): string[] {
const present = new Set(filesEntries);
return REQUIRED_PACKAGE_FILES_TEST_NEGATIONS.filter((entry) => !present.has(entry));
}

View File

@@ -228,6 +228,59 @@ export function normalizeArtifactPath(filePath: string): string {
.replace(/\/{2,}/g, "/");
}
/** Extract complete JSON values from npm's mixed stdout/stderr-style output. */
export function parseJsonValuesOutput(output: string): unknown[] {
const values: unknown[] = [];
for (let start = 0; start < output.length; start++) {
if (output[start] !== "[" && output[start] !== "{") continue;
const stack: string[] = [];
let inString = false;
let escaped = false;
for (let end = start; end < output.length; end++) {
const char = output[end];
if (inString) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') inString = false;
continue;
}
if (char === '"') {
inString = true;
} else if (char === "[" || char === "{") {
stack.push(char);
} else if (char === "]" || char === "}") {
const expectedOpen = char === "]" ? "[" : "{";
if (stack.at(-1) !== expectedOpen) break;
stack.pop();
if (stack.length === 0) {
try {
const parsed: unknown = JSON.parse(output.slice(start, end + 1));
values.push(parsed);
start = end;
} catch {
// This bracket pair was not a complete JSON value; continue scanning.
}
break;
}
}
}
}
return values;
}
/** Extract the first matching JSON array from npm's mixed stdout/stderr-style output. */
export function parseJsonArrayOutput(
output: string,
matches: (parsed: unknown[]) => boolean = () => true
): unknown[] {
const parsed = parseJsonValuesOutput(output).find(
(value): value is unknown[] => Array.isArray(value) && matches(value)
);
if (!parsed) throw new Error("Expected a valid JSON array in command output.");
return parsed;
}
/**
* Paths that are NEVER publishable, whatever the allowlist says.
*

View File

@@ -1,16 +1,23 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
MCP_CLOSURE_SPOT_CHECK_PATH,
computeMcpClosure,
findLeakedTestArtifactPaths,
findMissingMcpClosurePaths,
} from "./mcpPublishedFilesClosure.ts";
import {
PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
PACK_ARTIFACT_REQUIRED_PATHS,
findMissingArtifactPaths,
findUnexpectedArtifactPaths,
parseJsonValuesOutput,
} from "./pack-artifact-policy.ts";
const __filename: string = fileURLToPath(import.meta.url);
@@ -24,12 +31,29 @@ function runNpm(args: string[], stdio: "inherit" | "pipe" = "pipe"): string {
const command = npmExecPath && !isBunRuntime ? process.execPath : npmCommand;
const commandArgs = npmExecPath && !isBunRuntime ? [npmExecPath, ...args] : args;
return execFileSync(command, commandArgs, {
if (stdio === "inherit") {
execFileSync(command, commandArgs, {
cwd: ROOT,
encoding: "utf8",
stdio: "inherit",
maxBuffer: 64 * 1024 * 1024,
});
return "";
}
const result = spawnSync(command, commandArgs, {
cwd: ROOT,
encoding: "utf8",
stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"],
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 64 * 1024 * 1024,
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(
(result.stderr || result.stdout || `npm exited with status ${result.status}`).trim()
);
}
return `${result.stdout || ""}\n${result.stderr || ""}`;
}
function ensureAppStagingReady(): void {
@@ -43,15 +67,39 @@ function ensureAppStagingReady(): void {
runNpm(["run", "build:cli"], "inherit");
}
function runPackDryRun(): any {
type PackReport = {
files: Array<{ path: string }>;
filename?: string;
entryCount?: number;
size?: number;
unpackedSize?: number;
};
function findPackReport(value: unknown): PackReport | null {
if (Array.isArray(value)) {
for (const item of value) {
const report = findPackReport(item);
if (report) return report;
}
return null;
}
if (typeof value !== "object" || value === null) return null;
const record = value as Record<string, unknown>;
if (Array.isArray(record.files)) return record as unknown as PackReport;
for (const child of Object.values(record)) {
const report = findPackReport(child);
if (report) return report;
}
return null;
}
function runPackDryRun(): PackReport {
const output = runNpm(["pack", "--dry-run", "--json", "--ignore-scripts"]);
const jsonStart = output.indexOf("[");
const jsonEnd = output.lastIndexOf("]");
const jsonPayload =
jsonStart >= 0 && jsonEnd > jsonStart ? output.slice(jsonStart, jsonEnd + 1) : output;
const parsed = JSON.parse(jsonPayload);
const packReport = Array.isArray(parsed) ? parsed[0] : null;
const packReport = parseJsonValuesOutput(output)
.map(findPackReport)
.find((report): report is PackReport => report !== null);
if (!packReport || !Array.isArray(packReport.files)) {
throw new Error("npm pack --dry-run --json did not return the expected files[] payload.");
@@ -78,17 +126,17 @@ function formatBytes(bytes: number): string {
}
// --policy-only: skip the build (ensureAppStagingReady → build:cli) and the
// required-runtime-files check (which needs the built dist/), running ONLY the
// unexpected-files allowlist check. The unexpected files (e.g. stray bin/*.sh) are
// SOURCE files that `npm pack --dry-run` lists regardless of build, so this catches
// the "new file leaked into the tarball" regression cheaply on the fast-path (PR→release),
// instead of only on the release PR's full Package Artifact job. See incident v3.8.36 (#5029).
// required-runtime-files check (which needs the built dist/). Source-side policy checks
// still run against the real `npm pack --dry-run` file list: unexpected files (e.g. stray
// bin/*.sh), test/spec leaks, and missing MCP closure files. This catches source regressions
// cheaply on the fast-path (PR→release), instead of only on the release PR's full Package
// Artifact job. See incident v3.8.36 (#5029).
const POLICY_ONLY = process.argv.includes("--policy-only");
try {
if (!POLICY_ONLY) ensureAppStagingReady();
const packReport = runPackDryRun();
const artifactPaths: string[] = packReport.files.map((file: any) => file.path);
const artifactPaths: string[] = packReport.files.map((file) => file.path);
const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, {
exactPaths: PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
prefixPaths: PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
@@ -97,11 +145,20 @@ try {
? []
: findMissingArtifactPaths(artifactPaths, PACK_ARTIFACT_REQUIRED_PATHS);
// #3821 — broad `files` prefixes (open-sse/, src/lib/, ...) would otherwise allow
// co-located *.test.* / __tests__ leaks; ban them explicitly on the real pack list.
const leakedTestPaths: string[] = findLeakedTestArtifactPaths(artifactPaths);
// #3578 — MCP runs from published TypeScript source; every reachable file must pack.
const mcpClosure: string[] = computeMcpClosure(ROOT);
const missingMcpPaths: string[] = findMissingMcpClosurePaths(artifactPaths, mcpClosure);
console.log("📦 npm pack artifact summary");
console.log(` File: ${packReport.filename}`);
console.log(` Entry count: ${packReport.entryCount}`);
console.log(` Packed size: ${formatBytes(packReport.size)}`);
console.log(` Unpacked size: ${formatBytes(packReport.unpackedSize)}`);
console.log(` MCP closure: ${mcpClosure.length} source files checked`);
if (unexpectedPaths.length > 0) {
console.error("\n❌ Unexpected files were found in the npm publish artifact:");
@@ -117,7 +174,33 @@ try {
}
}
if (unexpectedPaths.length > 0 || missingRequiredPaths.length > 0) {
if (leakedTestPaths.length > 0) {
console.error(
"\n❌ Test/spec files leaked into the npm publish artifact (tighten package.json files negations):"
);
for (const leakedPath of leakedTestPaths) {
console.error(` - ${leakedPath}`);
}
}
if (missingMcpPaths.length > 0) {
console.error(
"\n❌ MCP-reachable source files are missing from the npm publish artifact (would 404 --mcp):"
);
for (const missingPath of missingMcpPaths) {
console.error(` - ${missingPath}`);
}
if (missingMcpPaths.includes(MCP_CLOSURE_SPOT_CHECK_PATH)) {
console.error(` (includes the #3578 bug file ${MCP_CLOSURE_SPOT_CHECK_PATH})`);
}
}
if (
unexpectedPaths.length > 0 ||
missingRequiredPaths.length > 0 ||
leakedTestPaths.length > 0 ||
missingMcpPaths.length > 0
) {
process.exit(1);
}

View File

@@ -74,7 +74,15 @@ async function main() {
const vitestProcess = spawn(
process.execPath,
["./node_modules/vitest/vitest.mjs", "run", "tests/e2e/ecosystem.test.ts"],
[
"./node_modules/vitest/vitest.mjs",
"run",
// Without --config, Vitest loads vitest.config.ts, whose exclude list drops
// this file — the run then dies with "No test files found".
"--config",
"vitest.e2e-live.config.ts",
"tests/e2e/ecosystem.test.ts",
],
{
stdio: "inherit",
env: testEnv,

View File

@@ -73,8 +73,11 @@ async function main() {
[
"./node_modules/vitest/vitest.mjs",
"run",
"--environment",
"node",
// Without --config, Vitest loads vitest.config.ts, whose exclude list drops
// this file — the run then dies with "No test files found". The config also
// sets environment: node, so the flag is no longer needed here.
"--config",
"vitest.e2e-live.config.ts",
"tests/e2e/protocol-clients.test.ts",
],
{

View File

@@ -1,143 +0,0 @@
#!/usr/bin/env node
// One-shot: FASE 3 helper, safe to delete after merge.
//
// Moves existing i18n mirror docs from `docs/i18n/<lang>/docs/X.md` into the
// matching subfolder `docs/i18n/<lang>/docs/<sub>/X.md`, mirroring the new
// docs/ layout. Uses `git mv` to preserve history.
//
// Usage:
// node scripts/docs/move-i18n-mirrors.mjs [--dry]
//
// Notes:
// - Skips files that don't appear in DOC_TO_SUBFOLDER (e.g., the legacy
// `cloudflare-zero-trust-guide.md` or `features/` subfolder — those will be
// handled in FASE 5 when translations are regenerated).
// - Idempotent: if the target already lives under a subfolder, the entry is
// skipped.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const I18N_DIR = path.join(ROOT, "docs", "i18n");
const DRY = process.argv.includes("--dry");
const DOC_TO_SUBFOLDER = {
// architecture
"ARCHITECTURE.md": "architecture",
"CODEBASE_DOCUMENTATION.md": "architecture",
"REPOSITORY_MAP.md": "architecture",
"AUTHZ_GUIDE.md": "architecture",
"RESILIENCE_GUIDE.md": "architecture",
// guides
"SETUP_GUIDE.md": "guides",
"USER_GUIDE.md": "guides",
"DOCKER_GUIDE.md": "guides",
"ELECTRON_GUIDE.md": "guides",
"TERMUX_GUIDE.md": "guides",
"PWA_GUIDE.md": "guides",
"TROUBLESHOOTING.md": "guides",
"UNINSTALL.md": "guides",
"I18N.md": "guides",
"FEATURES.md": "guides",
// reference
"API_REFERENCE.md": "reference",
"PROVIDER_REFERENCE.md": "reference",
"openapi.yaml": "reference",
"ENVIRONMENT.md": "reference",
"CLI-TOOLS.md": "reference",
"FREE_TIERS.md": "reference",
// frameworks
"MCP-SERVER.md": "frameworks",
"A2A-SERVER.md": "frameworks",
"AGENT_PROTOCOLS_GUIDE.md": "frameworks",
"CLOUD_AGENT.md": "frameworks",
"SKILLS.md": "frameworks",
"MEMORY.md": "frameworks",
"WEBHOOKS.md": "frameworks",
"EVALS.md": "frameworks",
// routing
"AUTO-COMBO.md": "routing",
"REASONING_REPLAY.md": "routing",
// security
"GUARDRAILS.md": "security",
"COMPLIANCE.md": "security",
"STEALTH_GUIDE.md": "security",
// compression
"COMPRESSION_GUIDE.md": "compression",
"COMPRESSION_ENGINES.md": "compression",
"COMPRESSION_RULES_FORMAT.md": "compression",
"COMPRESSION_LANGUAGE_PACKS.md": "compression",
"RTK_COMPRESSION.md": "compression",
// ops
"RELEASE_CHECKLIST.md": "ops",
"COVERAGE_PLAN.md": "ops",
"FLY_IO_DEPLOYMENT_GUIDE.md": "ops",
"VM_DEPLOYMENT_GUIDE.md": "ops",
"PROXY_GUIDE.md": "ops",
"TUNNELS_GUIDE.md": "ops",
};
let moved = 0;
let skipped = 0;
const seenLocales = [];
for (const locale of fs.readdirSync(I18N_DIR)) {
const localeDir = path.join(I18N_DIR, locale);
const stat = fs.statSync(localeDir);
if (!stat.isDirectory()) continue;
const docsDir = path.join(localeDir, "docs");
if (!fs.existsSync(docsDir)) continue;
seenLocales.push(locale);
for (const fname of fs.readdirSync(docsDir)) {
const sub = DOC_TO_SUBFOLDER[fname];
if (!sub) continue; // not in our mapping (e.g. features/, cloudflare-zero-trust-guide.md)
const src = path.join(docsDir, fname);
if (!fs.statSync(src).isFile()) continue;
const subDir = path.join(docsDir, sub);
const dst = path.join(subDir, fname);
if (fs.existsSync(dst)) {
skipped++;
continue;
}
if (DRY) {
console.log(`would move: ${path.relative(ROOT, src)} -> ${path.relative(ROOT, dst)}`);
moved++;
continue;
}
if (!fs.existsSync(subDir)) fs.mkdirSync(subDir, { recursive: true });
const relSrc = path.relative(ROOT, src);
const relDst = path.relative(ROOT, dst);
try {
execFileSync("git", ["mv", "-k", "--", relSrc, relDst], {
cwd: ROOT,
stdio: "pipe",
});
moved++;
} catch {
// fallback: copy + delete; emulate `|| true` for the rm by ignoring its failure
fs.renameSync(src, dst);
try {
execFileSync("git", ["rm", "--cached", "--", relSrc], { cwd: ROOT, stdio: "pipe" });
} catch {
// file may not be tracked yet — safe to ignore
}
execFileSync("git", ["add", "--", relDst], { cwd: ROOT, stdio: "pipe" });
moved++;
}
}
}
console.log(
`[i18n-mirrors] locales=${seenLocales.length} moved=${moved} skipped=${skipped}${DRY ? " (dry-run)" : ""}`
);

View File

@@ -319,7 +319,15 @@ curl -X PUT https://localhost:20128/api/settings/system-prompt \
Get thinking budget configuration
Returns the current thinking/reasoning budget settings for AI models.
Returns proxy-level thinking/reasoning **request rewrite** settings:
| Field | Meaning |
|-------|---------|
| `mode` | `passthrough` (leave client reasoning alone — **required for Codex visible thinking**), `auto` (**strips** all client thinking fields), `custom`, `adaptive` |
| `customBudget` | Fixed budget when `mode=custom` |
| `effortLevel` | Base effort when `mode=adaptive` |
**Not** compression and **not** “decrypt encrypted reasoning”. Full guide: `docs/guides/THINKING_BUDGET.md`.
```bash
curl https://localhost:20128/api/settings/thinking-budget \
@@ -330,13 +338,17 @@ curl https://localhost:20128/api/settings/thinking-budget \
Update thinking budget configuration
Example — keep client-controlled reasoning (Codex/Desktop):
```bash
curl -X PUT https://localhost:20128/api/settings/thinking-budget \
-H "Authorization: Bearer $OMNIROUTE_TOKEN"
-H "Authorization: Bearer $OMNIROUTE_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
-d '{"mode":"passthrough","customBudget":10240,"effortLevel":"medium"}'
```
Warning: `mode=auto` deletes `reasoning` / `reasoning_effort` / Claude `thinking` from the outbound body before upstream. That can empty thinking panels even when the client requested Ultra + summary.
### GET /api/tags
List Ollama-compatible model tags

View File

@@ -12,7 +12,11 @@ import { shouldShowKimiSponsorBanner } from "./kimiSponsorBannerGate";
// plan (kimi.com/code) to the API platform at Moonshot's request: coding plan
// subscriptions are closed to most new users, so that traffic could not
// convert.
const KIMI_PLATFORM_AFF_URL = "https://platform.kimi.ai?aff=omniroute";
// Dedicated tracked link issued by Moonshot 2026-08 for the 15% first-top-up
// bonus campaign (offer valid through 2026-09-30 — revisit the 15% copy in the
// i18n `kimiSponsorBanner.description` strings after that date if not renewed).
const KIMI_PLATFORM_AFF_URL =
"https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute";
// Versioned dismissal key — bump the suffix (e.g. `-v3`) if the banner's
// offer/copy ever changes materially enough to warrant re-showing it to

View File

@@ -713,6 +713,8 @@ export default function CustomModelsSection({
</button>
<ModelCompatPopover
t={t}
providerId={providerId}
modelId={model.id!}
effectiveModelNormalize={(p) =>
effectiveNormalizeForProtocol(model.id!, p, customMap, overrideMap)
}

View File

@@ -27,6 +27,34 @@ function recordToHeaderRows(rec: Record<string, string>, genId: () => string): H
return entries.map(([name, value]) => ({ id: genId(), name, value }));
}
// Bounded re-run budget for the model param-filter save: if the draft changed while the PUT was
// in flight, the save repeats with the newer draft instead of clearing the dirty flag on a
// payload that no longer matches what the user typed (#8910).
const PARAM_SAVE_MAX_ATTEMPTS = 3;
// Param filters are stored as one document per provider. Model rows render independent popover
// instances, so their GET -> whole-document PUT transactions must share a provider-level queue;
// instance-local saving refs cannot prevent sibling rows from overwriting each other's updates.
const paramFilterSaveQueues = new Map<string, Promise<void>>();
async function serializeProviderParamFilterSave<T>(
providerId: string,
save: () => Promise<T>
): Promise<T> {
const previous = paramFilterSaveQueues.get(providerId) ?? Promise.resolve();
const result = previous.then(save);
const tail = result.then(
() => undefined,
() => undefined
);
paramFilterSaveQueues.set(providerId, tail);
try {
return await result;
} finally {
if (paramFilterSaveQueues.get(providerId) === tail) paramFilterSaveQueues.delete(providerId);
}
}
function parseCommaList(text: string): string[] {
return text
? text
@@ -43,6 +71,21 @@ interface ParamFilterConfigLike {
autoLearn?: boolean;
}
// An unsaved param-filter draft, bound to the provider/model it was typed for. The save path
// writes through THIS target instead of the props the callback happens to close over, so a draft
// can never be persisted under a provider/model the user never edited (#8910).
interface ParamFilterDraft {
key: string;
providerId: string;
modelId: string;
block: string;
allow: string;
}
function paramTargetKeyOf(providerId: string, modelId: string): string {
return `${providerId}\u0000${modelId}`;
}
// Builds the PUT body for the model-level block/allow save. Extracted so the
// caller's async handler stays simple — this is pure payload-shaping logic.
function buildModelParamFilterPayload(
@@ -97,6 +140,8 @@ export interface ModelCompatPopoverProps {
export default function ModelCompatPopover({
t,
providerId,
modelId,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
getUpstreamHeadersRecord,
@@ -110,8 +155,8 @@ export default function ModelCompatPopover({
const [headerRows, setHeaderRows] = useState<HeaderDraftRow[]>([]);
const [blockText, setBlockText] = useState("");
const [allowText, setAllowText] = useState("");
const [paramDirty, setParamDirty] = useState(false);
const [paramSaving, setParamSaving] = useState(false);
const [paramSaveFailed, setParamSaveFailed] = useState(false);
const [valuePeekRowId, setValuePeekRowId] = useState<string | null>(null);
const [valueFocusRowId, setValueFocusRowId] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
@@ -126,6 +171,78 @@ export default function ModelCompatPopover({
const headerRowsRef = useRef<HeaderDraftRow[]>([]);
headerRowsRef.current = headerRows;
// Param-filter drafts are mirrored into a ref so the close/unmount save path reads the
// latest typed values instead of the values captured when the handler was created (#8910).
const paramSavingRef = useRef(false);
// Every unsaved draft, keyed by the provider/model it was typed for. A per-target map (rather
// than a single slot) is required because a live popover can be re-pointed at another target
// while a draft is still unsaved: with one slot the next keystroke on the new target destroyed
// the previous target's unsaved work, and the new target's successful save then cleared the
// failure indicator — a green UI over data that was never written, i.e. exactly the silent
// data loss reported in #8910. Drafts are recorded when edited — never derived from a completed
// load — so an unsaved draft survives even when no load ever succeeded for that target, and
// every write lands on the draft's own target rather than whatever the popover now points at.
const paramDraftsRef = useRef<Map<string, ParamFilterDraft>>(new Map());
const paramTargetKey = paramTargetKeyOf(providerId, modelId);
const paramTargetRef = useRef<{ key: string; providerId: string; modelId: string }>({
key: paramTargetKey,
providerId,
modelId,
});
paramTargetRef.current = { key: paramTargetKey, providerId, modelId };
// Mirrors of the displayed text, so an edit can snapshot both fields synchronously.
const blockTextRef = useRef("");
const allowTextRef = useRef("");
blockTextRef.current = blockText;
allowTextRef.current = allowText;
// Which target the values currently in the fields belong to. Guards the invariant that
// blockTextRef/allowTextRef never hold content belonging to a target other than the one being
// displayed — the desync that let one model's server values be saved under another (#8910).
const fieldsTargetKeyRef = useRef<string | null>(null);
const applyParamFields = useCallback((targetKey: string, block: string, allow: string) => {
fieldsTargetKeyRef.current = targetKey;
blockTextRef.current = block;
allowTextRef.current = allow;
setBlockText(block);
setAllowText(allow);
}, []);
// Every edit rewrites the draft for the CURRENT target. The counterpart field is only trusted
// when the values on screen belong to this target; otherwise it is taken from this target's own
// pending draft (or empty), so another target's value can never be captured into this draft and
// then persisted here (#8910). Object identity doubles as the draft revision an in-flight save
// compares against.
const editParamDraft = useCallback(
(field: "block" | "allow", value: string) => {
const target = paramTargetRef.current;
const fieldsOwned = fieldsTargetKeyRef.current === target.key;
const pending = paramDraftsRef.current.get(target.key);
const block =
field === "block" ? value : fieldsOwned ? blockTextRef.current : (pending?.block ?? "");
const allow =
field === "allow" ? value : fieldsOwned ? allowTextRef.current : (pending?.allow ?? "");
applyParamFields(target.key, block, allow);
paramDraftsRef.current.set(target.key, {
key: target.key,
providerId: target.providerId,
modelId: target.modelId,
block,
allow,
});
},
[applyParamFields]
);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const genHeaderRowId = () => {
headerRowIdRef.current += 1;
return `uh-${headerRowIdRef.current}`;
@@ -168,40 +285,140 @@ export default function ModelCompatPopover({
// Load model-level block/allow from param-filters API
useEffect(() => {
if (!open) return;
const draftKey = paramTargetKeyOf(providerId, modelId);
const draftForThisTarget = () => paramDraftsRef.current.get(draftKey);
// The fields must always show THIS target's content, and nothing else may ever be read back
// out of them. A draft that is still pending for this exact provider/model was never persisted
// (failed or exhausted save): restore it into the inputs instead of loading server state over
// it, because reloading here would silently revert it — the very complaint behind #8910.
const pending = draftForThisTarget();
if (pending) {
applyParamFields(draftKey, pending.block, pending.allow);
return;
}
// No draft for this target: drop whatever the previously displayed target left on screen so
// the fields can never present (or contribute) another target's values.
if (fieldsTargetKeyRef.current !== draftKey) applyParamFields(draftKey, "", "");
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/providers/${providerId}/param-filters`);
if (!res.ok) throw new Error(`param-filters GET failed: ${res.status}`);
const data = await res.json();
if (cancelled || !mountedRef.current) return;
// Re-check after the await: the user may have typed while the GET was in flight, and a
// load result must never overwrite (or acknowledge) a draft that is not on the server.
if (draftForThisTarget()) return;
const modelCfg = data?.models?.[modelId];
setBlockText(modelCfg ? (modelCfg.block ?? []).join(", ") : "");
setAllowText(modelCfg ? (modelCfg.allow ?? []).join(", ") : "");
applyParamFields(
draftKey,
modelCfg ? (modelCfg.block ?? []).join(", ") : "",
modelCfg ? (modelCfg.allow ?? []).join(", ") : ""
);
// A load never clears a draft: any draft still pending here belongs to a DIFFERENT
// target and is still owed a write to that target (#8910). The failure indicator is
// only cleared once nothing is left unsaved anywhere.
if (paramDraftsRef.current.size === 0) setParamSaveFailed(false);
} catch {
setBlockText("");
setAllowText("");
// Keep whatever the user has in the fields (and its dirty flag) on load failure.
}
setParamDirty(false);
})();
}, [open]);
return () => {
cancelled = true;
};
// Reload only when opening or when the popover targets a different provider/model.
}, [open, providerId, modelId, applyParamFields]);
// Drains EVERY pending draft, each written through its OWN provider/model — never the props this
// callback happens to be bound to — so a draft typed for target A can never be persisted under a
// target B the user never edited, and re-pointing the popover cannot destroy target A's unsaved
// work (#8910). Each draft is re-read after every await for the same reason the load effect
// re-checks its guard: the user can keep typing while a write is in flight.
const saveModelParamFilters = useCallback(async () => {
if (!paramDirty) return;
setParamSaving(true);
if (paramDraftsRef.current.size === 0 || paramSavingRef.current) return;
paramSavingRef.current = true;
if (mountedRef.current) setParamSaving(true);
// Returns true once nothing is owed for this target any more.
const saveDraftForTarget = async (key: string): Promise<boolean> => {
// Re-run while the draft changed under the in-flight write: the payload is snapshotted
// before the PUT resolves, so a keystroke landing in that window would otherwise be
// acknowledged (draft dropped) but never persisted — the #8910 lost update.
for (let attempt = 0; attempt < PARAM_SAVE_MAX_ATTEMPTS; attempt += 1) {
const draft = paramDraftsRef.current.get(key);
if (!draft) return true;
try {
const wroteDraft = await serializeProviderParamFilterSave(draft.providerId, async () => {
const res = await fetch(`/api/providers/${draft.providerId}/param-filters`);
if (!res.ok) throw new Error(`param-filters GET failed: ${res.status}`);
const current = await res.json();
// The fetched config belongs to draft.providerId; if the draft was replaced by a newer
// one while the GET (or this instance's queue wait) was in flight, restart fresh.
if (paramDraftsRef.current.get(key) !== draft) return false;
const payload = buildModelParamFilterPayload(
current,
draft.modelId,
draft.block,
draft.allow
);
const putRes = await fetch(`/api/providers/${draft.providerId}/param-filters`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!putRes.ok) throw new Error(`param-filters PUT failed: ${putRes.status}`);
return true;
});
if (!wroteDraft) continue;
// Only the exact draft that was written may be discarded.
if (paramDraftsRef.current.get(key) === draft) {
paramDraftsRef.current.delete(key);
return true;
}
} catch {
// Save failed — the draft (and the target it belongs to) is intentionally preserved so
// the next blur/close/unmount save retries it against its own provider/model.
return false;
}
}
// Budget exhausted (the user is still typing): stay dirty for this target.
return false;
};
try {
const res = await fetch(`/api/providers/${providerId}/param-filters`);
const current = await res.json();
const payload = buildModelParamFilterPayload(current, modelId, blockText, allowText);
await fetch(`/api/providers/${providerId}/param-filters`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
setParamDirty(false);
} catch {
// Silently ignore save error
// Do not snapshot the keys once: a second target can become dirty while an earlier target's
// PUT is in flight. Keep selecting live drafts until only revisions that already failed in
// this drain remain. Remember the failed object (not just its key), so a newer edit for that
// target that lands while another request is pending still gets one save attempt.
const failedDrafts = new Map<string, ParamFilterDraft>();
while (true) {
const next = Array.from(paramDraftsRef.current.entries()).find(
([key, draft]) => failedDrafts.get(key) !== draft
);
if (!next) break;
const [key] = next;
if (!(await saveDraftForTarget(key))) {
const failedDraft = paramDraftsRef.current.get(key);
if (failedDraft) failedDrafts.set(key, failedDraft);
}
}
// The indicator tracks unsaved work across ALL targets: a successful write for the target
// now on screen must not signal "saved" while another target's draft is still owed a write.
if (mountedRef.current) setParamSaveFailed(paramDraftsRef.current.size > 0);
} finally {
setParamSaving(false);
paramSavingRef.current = false;
if (mountedRef.current) setParamSaving(false);
}
}, [paramDirty, blockText, allowText]);
}, []);
// Persist pending param-filter drafts when the popover closes, unmounts, or is re-pointed at a
// different provider/model (#8910).
useEffect(() => {
if (!open) return;
return () => {
void saveModelParamFilters();
};
}, [open, paramTargetKey, saveModelParamFilters]);
useEffect(() => {
setValuePeekRowId(null);
@@ -360,10 +577,7 @@ export default function ModelCompatPopover({
<input
type="text"
value={blockText}
onChange={(e) => {
setBlockText(e.target.value);
setParamDirty(true);
}}
onChange={(e) => editParamDraft("block", e.target.value)}
onBlur={() => saveModelParamFilters()}
placeholder={t("compatBlockedParamsPlaceholder")}
disabled={disabled}
@@ -376,16 +590,22 @@ export default function ModelCompatPopover({
"Blocked params (stripped from requests)"
)}
{paramSaving && `${t("compatSaving")}`}
{paramSaveFailed && !paramSaving && (
<span
role="alert"
className="ml-1 font-medium text-red-600 dark:text-red-400"
title={t("failedSaveConnectionRetry")}
>
{t("failed")}
</span>
)}
</p>
</div>
<div>
<input
type="text"
value={allowText}
onChange={(e) => {
setAllowText(e.target.value);
setParamDirty(true);
}}
onChange={(e) => editParamDraft("allow", e.target.value)}
onBlur={() => saveModelParamFilters()}
placeholder={t("compatAllowedParamsPlaceholder")}
disabled={disabled}

View File

@@ -0,0 +1,208 @@
// @vitest-environment jsdom
// Regression coverage for the concurrency defects found while fixing #8910:
// 1. an edit landing after the PUT payload snapshot but before the PUT resolves must still
// be persisted (lost update);
// 2. a save that failed must not be silently reverted on reopen, and the failure must be
// visible in the panel.
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
interface ParamFilterState {
block: string[];
allow: string[];
models?: Record<string, { block: string[]; allow: string[] }>;
autoLearn: boolean;
}
let container: HTMLDivElement;
let root: Root;
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects() {
await act(async () => {
for (let i = 0; i < 8; i += 1) await Promise.resolve();
});
}
function blockInput() {
return document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement | null;
}
function renderPopover() {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId="openai"
modelId="gpt-test"
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
}
async function openPopover() {
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
}
async function closePopoverByOutsideClick() {
await act(async () => {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
});
await flushEffects();
}
describe("ModelCompatPopover param-filter save concurrency (#8910)", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("persists an edit typed after the payload snapshot but before the PUT resolves", async () => {
let serverState: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
let releasePut: (() => void) | null = null;
let holdPut = false;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
serverState = JSON.parse(String(init.body)) as ParamFilterState;
if (holdPut) {
await new Promise<void>((resolve) => {
releasePut = resolve;
});
}
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(serverState) } as Response;
})
);
renderPopover();
await openPopover();
await act(async () => setInputValue(blockInput()!, "temperature"));
holdPut = true;
// Blur starts the save: GET resolves, payload is snapshotted, PUT is issued and held open.
await act(async () => {
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
expect(releasePut).not.toBeNull();
// The user keeps typing while that PUT is still in flight, then closes the popover.
await act(async () => setInputValue(blockInput()!, "temperature, seed"));
await closePopoverByOutsideClick();
holdPut = false;
await act(async () => {
releasePut?.();
await Promise.resolve();
});
await flushEffects();
expect(serverState.models).toEqual({
"gpt-test": { block: ["temperature", "seed"], allow: [] },
});
});
it("keeps the draft and surfaces the failure when the save fails, instead of reverting on reopen", async () => {
const serverState: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
let putAttempts = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
putAttempts += 1;
return { ok: false, status: 500, json: async () => ({}) } as Response;
}
return { ok: true, json: async () => structuredClone(serverState) } as Response;
})
);
renderPopover();
await openPopover();
await act(async () => setInputValue(blockInput()!, "temperature"));
await closePopoverByOutsideClick();
expect(putAttempts).toBe(1);
// Reopening must NOT clobber the unsaved draft with server state (#8910 complaint class).
await openPopover();
expect(blockInput()!.value).toBe("temperature");
// The failure is visible to the user rather than silently swallowed.
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
// ...and the retained draft is actually retryable through the normal close path.
await closePopoverByOutsideClick();
expect(putAttempts).toBe(2);
});
it("clears the failure indicator once a later save succeeds", async () => {
let serverState: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
let failNextPut = true;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
if (failNextPut) return { ok: false, status: 500, json: async () => ({}) } as Response;
serverState = JSON.parse(String(init.body)) as ParamFilterState;
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(serverState) } as Response;
})
);
renderPopover();
await openPopover();
await act(async () => setInputValue(blockInput()!, "temperature"));
await act(async () => {
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
expect(document.querySelector('[role="alert"]')).not.toBeNull();
failNextPut = false;
await act(async () => setInputValue(blockInput()!, "temperature, seed"));
await act(async () => {
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
expect(document.querySelector('[role="alert"]')).toBeNull();
expect(serverState.models).toEqual({
"gpt-test": { block: ["temperature", "seed"], allow: [] },
});
});
});

View File

@@ -0,0 +1,137 @@
// @vitest-environment jsdom
// Regression coverage for #8910: sibling model-row popovers for the same provider must serialize
// their whole-document param-filter updates so one successful save cannot erase the other.
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
type ParamFilterState = {
block: string[];
allow: string[];
models?: Record<string, { block: string[]; allow: string[] }>;
autoLearn: boolean;
};
let container: HTMLDivElement;
let root: Root;
let releaseFirstPut: (() => void) | null;
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects(rounds = 60) {
await act(async () => {
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
});
}
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
releaseFirstPut = null;
});
afterEach(async () => {
await act(async () => {
releaseFirstPut?.();
await Promise.resolve();
});
act(() => root.unmount());
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("preserves both model updates when sibling popovers save the same provider concurrently", async () => {
let server: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
let putCount = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
putCount += 1;
const body = JSON.parse(String(init.body)) as ParamFilterState;
if (putCount === 1) {
await new Promise<void>((resolve) => {
releaseFirstPut = resolve;
});
}
server = { ...body, models: body.models ?? {} };
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(server) } as Response;
})
);
const props = (modelId: string) => ({
t: (key: string) => key,
providerId: "openai",
modelId,
effectiveModelNormalize: () => false,
effectiveModelPreserveDeveloper: () => true,
getUpstreamHeadersRecord: () => ({}),
onCompatPatch: vi.fn(),
});
act(() => {
root.render(
<div>
<div id="model-a">
<ModelCompatPopover {...props("model-a")} />
</div>
<div id="model-b">
<ModelCompatPopover {...props("model-b")} />
</div>
</div>
);
});
const triggerA = container.querySelector("#model-a button") as HTMLButtonElement;
const triggerB = container.querySelector("#model-b button") as HTMLButtonElement;
const blockInput = () =>
document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement;
await act(async () => triggerA.click());
await flushEffects();
await act(async () => setInputValue(blockInput(), "aaa"));
await act(async () => {
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
expect(releaseFirstPut).not.toBeNull();
await act(async () => {
triggerB.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
});
await flushEffects();
await act(async () => triggerB.click());
await flushEffects();
await act(async () => setInputValue(blockInput(), "bbb"));
await act(async () => {
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
await act(async () => {
releaseFirstPut?.();
await Promise.resolve();
});
await flushEffects();
expect(server.models).toEqual({
"model-a": { block: ["aaa"], allow: [] },
"model-b": { block: ["bbb"], allow: [] },
});
});

View File

@@ -0,0 +1,192 @@
// @vitest-environment jsdom
// Regression coverage for the cross-target write defect found while fixing #8910.
//
// ModelCompatPopover instances are not always keyed by a stable identity (CompatibleModelsSection
// keys by `${alias}:${modelId}`, PassthroughModelsSection by the full model string, and providerId
// is threaded from route/page state), so a re-render can re-point a LIVE, mounted popover at a
// different provider/model. When the draft for the old target failed to save, the save callback —
// now bound to the new target — used to PUT the old draft into the new target's config,
// destructively overwriting a model/provider the user never edited.
//
// Contract asserted here: a write always lands on the provider/model the draft was typed for, and
// an orphaned draft whose target is no longer displayed is preserved (retried later) rather than
// silently dropped or redirected.
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
interface ParamFilterState {
block: string[];
allow: string[];
models: Record<string, { block: string[]; allow: string[] }>;
autoLearn: boolean;
}
let container: HTMLDivElement;
let root: Root;
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects(rounds = 40) {
await act(async () => {
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
});
}
function blockInput() {
return document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement | null;
}
function renderPopover(props: { providerId: string; modelId: string }) {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId={props.providerId}
modelId={props.modelId}
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
}
async function openPopover() {
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
}
async function closePopoverByOutsideClick() {
await act(async () => {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
});
await flushEffects();
}
describe("ModelCompatPopover param-filter cross-target writes (#8910)", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
try {
act(() => root.unmount());
} catch {
// already unmounted by the test
}
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("never writes a draft typed for one model under a different model", async () => {
const server: ParamFilterState = {
block: [],
allow: [],
models: { "model-b": { block: ["bval"], allow: [] } },
autoLearn: false,
};
const puts: { url: string; models?: Record<string, unknown> }[] = [];
let getFails = true;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (init?.method === "PUT") {
const body = JSON.parse(String(init.body));
puts.push({ url, models: body.models });
server.models = body.models ?? {};
return { ok: true, json: async () => ({ success: true }) } as Response;
}
if (getFails) return { ok: false, status: 503, json: async () => ({}) } as Response;
return { ok: true, json: async () => structuredClone(server) } as Response;
})
);
renderPopover({ providerId: "openai", modelId: "model-a" });
await openPopover();
// The load failed, so the fields are empty; the user types a draft for model-a.
await act(async () => setInputValue(blockInput()!, "aaa"));
// A re-render re-points this live popover at model-b while model-a's draft is still dirty.
// The close-time save for model-a runs here and fails (its GET is still 503).
renderPopover({ providerId: "openai", modelId: "model-b" });
await flushEffects();
// The network recovers and the popover closes: the retried save must target model-a.
getFails = false;
await closePopoverByOutsideClick();
// model-b's real server config is untouched...
expect(server.models["model-b"]).toEqual({ block: ["bval"], allow: [] });
// ...and the orphaned model-a draft is not silently dropped either — it lands on model-a.
expect(server.models["model-a"]).toEqual({ block: ["aaa"], allow: [] });
expect(puts.every((p) => p.url.includes("/openai/"))).toBe(true);
});
it("never writes a draft typed for one provider under a different provider", async () => {
const byProvider: Record<string, ParamFilterState> = {
alpha: { block: [], allow: [], models: {}, autoLearn: false },
beta: {
block: [],
allow: [],
models: { "gpt-test": { block: ["betaval"], allow: [] } },
autoLearn: false,
},
};
const puts: { providerId: string; models?: Record<string, unknown> }[] = [];
let getFails = true;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const providerId = url.match(/providers\/([^/]+)\//)![1];
if (init?.method === "PUT") {
const body = JSON.parse(String(init.body));
puts.push({ providerId, models: body.models });
byProvider[providerId].models = body.models ?? {};
return { ok: true, json: async () => ({ success: true }) } as Response;
}
if (getFails) return { ok: false, status: 503, json: async () => ({}) } as Response;
return { ok: true, json: async () => structuredClone(byProvider[providerId]) } as Response;
})
);
renderPopover({ providerId: "alpha", modelId: "gpt-test" });
await openPopover();
await act(async () => setInputValue(blockInput()!, "alpha-only"));
// Re-point the live popover at provider beta while alpha's draft is dirty and its save fails.
renderPopover({ providerId: "beta", modelId: "gpt-test" });
await flushEffects();
getFails = false;
await closePopoverByOutsideClick();
// beta must receive no write at all; its stored config survives intact.
expect(puts.filter((p) => p.providerId === "beta")).toEqual([]);
expect(byProvider.beta.models).toEqual({ "gpt-test": { block: ["betaval"], allow: [] } });
// The alpha draft is preserved and eventually persisted under alpha.
expect(byProvider.alpha.models).toEqual({ "gpt-test": { block: ["alpha-only"], allow: [] } });
});
});

View File

@@ -0,0 +1,186 @@
// @vitest-environment jsdom
// Regression coverage for the load-effect clobber defects found while fixing #8910:
// 1. a draft typed while the initial load GET is still in flight must survive the load
// result and still be persisted on close (otherwise keystrokes vanish silently);
// 2. after a failed initial load, the retained draft must not be destroyed by the next
// successful reopen load — that reopen is the user's retry.
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
interface ParamFilterState {
block: string[];
allow: string[];
models?: Record<string, { block: string[]; allow: string[] }>;
autoLearn: boolean;
}
let container: HTMLDivElement;
let root: Root;
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects(rounds = 12) {
await act(async () => {
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
});
}
function blockInput() {
return document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement | null;
}
function renderPopover() {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId="openai"
modelId="gpt-test"
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
}
async function openPopover() {
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
}
async function closePopoverByOutsideClick() {
await act(async () => {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
});
await flushEffects();
}
describe("ModelCompatPopover param-filter load clobber (#8910)", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
try {
act(() => root.unmount());
} catch {
// already unmounted by the test
}
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("keeps and saves a draft typed while the initial load GET is still in flight", async () => {
const serverState: ParamFilterState = {
block: [],
allow: [],
models: { "gpt-test": { block: ["old"], allow: [] } },
autoLearn: false,
};
const puts: ParamFilterState[] = [];
let releaseGet: (() => void) | null = null;
let heldFirstGet = false;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
puts.push(JSON.parse(String(init.body)) as ParamFilterState);
return { ok: true, json: async () => ({ success: true }) } as Response;
}
if (!heldFirstGet) {
heldFirstGet = true;
await new Promise<void>((resolve) => {
releaseGet = resolve;
});
}
return { ok: true, json: async () => structuredClone(serverState) } as Response;
})
);
renderPopover();
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
// The field is already rendered while the load GET is still pending — the user types into it.
await act(async () => setInputValue(blockInput()!, "temperature"));
await act(async () => {
releaseGet?.();
await Promise.resolve();
});
await flushEffects();
// The load result must not overwrite the dirty draft.
expect(blockInput()!.value).toBe("temperature");
await closePopoverByOutsideClick();
expect(puts.length).toBe(1);
expect(puts[0]?.models).toEqual({ "gpt-test": { block: ["temperature"], allow: [] } });
});
it("does not clobber a retained draft with the successful reopen load after a failed initial load", async () => {
const serverState: ParamFilterState = {
block: [],
allow: [],
models: { "gpt-test": { block: ["serverval"], allow: [] } },
autoLearn: false,
};
const puts: ParamFilterState[] = [];
let failGet = true;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
puts.push(JSON.parse(String(init.body)) as ParamFilterState);
return { ok: true, json: async () => ({ success: true }) } as Response;
}
if (failGet) return { ok: false, status: 503, json: async () => ({}) } as Response;
return { ok: true, json: async () => structuredClone(serverState) } as Response;
})
);
renderPopover();
// First open: the load GET fails, so nothing is loaded and the field stays empty.
await openPopover();
expect(blockInput()!.value).toBe("");
await act(async () => setInputValue(blockInput()!, "temperature"));
// Close: the save's own GET still fails, so the draft is retained and flagged as failed.
await closePopoverByOutsideClick();
expect(puts.length).toBe(0);
// The network recovers and the user reopens the popover to retry the save.
failGet = false;
await openPopover();
expect(blockInput()!.value).toBe("temperature");
// The unsaved-state indicator must still be visible — nothing was persisted.
expect(document.querySelector('[role="alert"]')).not.toBeNull();
await closePopoverByOutsideClick();
expect(puts.length).toBe(1);
expect(puts[0]?.models).toEqual({ "gpt-test": { block: ["temperature"], allow: [] } });
});
});

View File

@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects(rounds = 40) {
await act(async () => {
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
});
}
it("preserves a new target edited while the older target save is in flight", async () => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let server = {
block: [] as string[],
allow: [] as string[],
models: {} as Record<string, { block: string[]; allow: string[] }>,
autoLearn: false,
};
let releaseFirstPut: (() => void) | null = null;
let putCount = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
putCount += 1;
const body = JSON.parse(String(init.body)) as typeof server;
if (putCount === 1) {
await new Promise<void>((resolve) => {
releaseFirstPut = resolve;
});
}
server = body;
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(server) } as Response;
})
);
const renderFor = (modelId: string) => {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId="openai"
modelId={modelId}
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
};
const blockInput = () =>
document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement;
const blurBlock = async () => {
await act(async () => {
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
};
renderFor("model-a");
await act(async () => (container.querySelector("button") as HTMLButtonElement).click());
await flushEffects();
await act(async () => setInputValue(blockInput(), "aaa"));
await blurBlock();
expect(releaseFirstPut).not.toBeNull();
renderFor("model-b");
await flushEffects();
expect(blockInput().value).toBe("");
await act(async () => setInputValue(blockInput(), "bbb"));
await blurBlock();
await act(async () => {
releaseFirstPut?.();
await Promise.resolve();
});
await flushEffects();
expect(server.models).toEqual({
"model-a": { block: ["aaa"], allow: [] },
"model-b": { block: ["bbb"], allow: [] },
});
expect(document.querySelector('[role="alert"]')).toBeNull();
act(() => root.unmount());
document.body.innerHTML = "";
vi.unstubAllGlobals();
});

View File

@@ -0,0 +1,110 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects(rounds = 40) {
await act(async () => {
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
});
}
it("does not lose the new target when unmounted during the older target save", async () => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let server = {
block: [] as string[],
allow: [] as string[],
models: {} as Record<string, { block: string[]; allow: string[] }>,
autoLearn: false,
};
let releaseFirstPut: (() => void) | null = null;
let putCount = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
putCount += 1;
const body = JSON.parse(String(init.body)) as typeof server;
if (putCount === 1) {
await new Promise<void>((resolve) => {
releaseFirstPut = resolve;
});
}
server = body;
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(server) } as Response;
})
);
const renderFor = (modelId: string) => {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId="openai"
modelId={modelId}
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
};
const blockInput = () =>
document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement;
const blurBlock = async () => {
await act(async () => {
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
};
renderFor("model-a");
await act(async () => (container.querySelector("button") as HTMLButtonElement).click());
await flushEffects();
await act(async () => setInputValue(blockInput(), "aaa"));
await blurBlock();
expect(releaseFirstPut).not.toBeNull();
renderFor("model-b");
await flushEffects();
await act(async () => setInputValue(blockInput(), "bbb"));
await blurBlock();
// Every close/unmount save is rejected while model-a owns paramSavingRef. The active save took
// its key snapshot before model-b existed, so model-b has no later save scheduled.
act(() => root.unmount());
await act(async () => {
releaseFirstPut?.();
await Promise.resolve();
});
await flushEffects();
expect(server.models).toEqual({
"model-a": { block: ["aaa"], allow: [] },
"model-b": { block: ["bbb"], allow: [] },
});
document.body.innerHTML = "";
vi.unstubAllGlobals();
});

View File

@@ -0,0 +1,261 @@
// @vitest-environment jsdom
// Regression coverage for the two target-re-point defects found while fixing #8910.
//
// A ModelCompatPopover instance is not always keyed by a stable identity, so a re-render can
// re-point a LIVE, still-open popover at a different provider/model while a draft for the previous
// target is unsaved. Two failures followed from that:
//
// 1. (C10) The inputs are driven by blockText/allowText, which used to be written only by the
// load effect — and that effect early-returned whenever a draft was dirty. Re-pointing
// A -> B -> A therefore left B's server values on screen under A, and the next keystroke
// snapshotted them into A's draft, persisting B's content into A's entry.
// 2. (C11) The pending draft lived in a single slot that every edit overwrote, so typing into
// the new target destroyed the previous target's unsaved work, and the new target's
// successful save cleared the failure indicator — a green UI over data never written.
//
// Contract asserted here: the fields always show the displayed target's own content (its pending
// draft when it has one), never another target's; and every target's unsaved draft survives until
// it is actually persisted to its own provider/model.
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
interface ParamFilterState {
block: string[];
allow: string[];
models: Record<string, { block: string[]; allow: string[] }>;
autoLearn: boolean;
}
let container: HTMLDivElement;
let root: Root;
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects(rounds = 40) {
await act(async () => {
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
});
}
function blockInput() {
return document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement | null;
}
function renderPopover(modelId: string) {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId="openai"
modelId={modelId}
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
}
// React 19 delegates onBlur through focusout — a bare "blur" event does not reach the handler.
async function blurBlockInput() {
await act(async () => {
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
}
describe("ModelCompatPopover param-filter target re-point (#8910)", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
try {
act(() => root.unmount());
} catch {
// already unmounted by the test
}
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("restores the dirty draft into the fields on return, instead of showing the other model's values", async () => {
const server: ParamFilterState = {
block: [],
allow: [],
models: { "model-b": { block: ["secret-b"], allow: [] } },
autoLearn: false,
};
let putFails = true;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
if (putFails) return { ok: false, status: 500, json: async () => ({}) } as Response;
const body = JSON.parse(String(init.body));
server.models = body.models ?? {};
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(server) } as Response;
})
);
renderPopover("model-a");
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
// model-a has no server entry, so the field loads empty; the user types a draft whose save fails.
await act(async () => setInputValue(blockInput()!, "aaa"));
await blurBlockInput();
expect(blockInput()!.value).toBe("aaa");
// The live popover is re-pointed at model-b, which does have a server value...
renderPopover("model-b");
await flushEffects();
expect(blockInput()!.value).toBe("secret-b");
// ...and back to model-a, whose draft is still unsaved: the field must show model-a's draft.
renderPopover("model-a");
await flushEffects();
expect(blockInput()!.value).toBe("aaa");
// The user appends to what they can see and closes; the write must stay inside model-a.
putFails = false;
await act(async () => setInputValue(blockInput()!, `${blockInput()!.value}, extra`));
await act(async () => {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
});
await flushEffects();
expect(server.models["model-a"]).toEqual({ block: ["aaa", "extra"], allow: [] });
expect(JSON.stringify(server.models["model-a"])).not.toContain("secret-b");
expect(server.models["model-b"]).toEqual({ block: ["secret-b"], allow: [] });
});
it("keeps one model's unsaved draft alive while the user edits another model", async () => {
const server: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
let putFails = true;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
if (putFails) return { ok: false, status: 500, json: async () => ({}) } as Response;
const body = JSON.parse(String(init.body));
server.models = body.models ?? {};
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(server) } as Response;
})
);
renderPopover("model-a");
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
await act(async () => setInputValue(blockInput()!, "aaa"));
await blurBlockInput();
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
// Re-pointed at model-b; the network recovers and the user edits model-b.
renderPopover("model-b");
await flushEffects();
putFails = false;
await act(async () => setInputValue(blockInput()!, "bbb"));
await blurBlockInput();
// model-a's draft must not have been destroyed by the model-b edit: both are persisted...
expect(server.models["model-a"]).toEqual({ block: ["aaa"], allow: [] });
expect(server.models["model-b"]).toEqual({ block: ["bbb"], allow: [] });
// ...and with nothing left unsaved anywhere the failure indicator is finally cleared.
expect(document.querySelector('[role="alert"]')).toBeNull();
});
it("does not report success while another target's draft is still unsaved", async () => {
const byProvider: Record<string, ParamFilterState> = {
alpha: { block: [], allow: [], models: {}, autoLearn: false },
beta: { block: [], allow: [], models: {}, autoLearn: false },
};
let failing = new Set(["alpha"]);
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const providerId = url.match(/providers\/([^/]+)\//)![1];
if (init?.method === "PUT") {
if (failing.has(providerId)) {
return { ok: false, status: 503, json: async () => ({}) } as Response;
}
const body = JSON.parse(String(init.body));
byProvider[providerId].models = body.models ?? {};
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(byProvider[providerId]) } as Response;
})
);
const renderFor = (providerId: string) => {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId={providerId}
modelId="gpt-test"
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
};
renderFor("alpha");
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
await act(async () => setInputValue(blockInput()!, "alpha-draft"));
await blurBlockInput();
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
// beta saves fine, but alpha is still broken: the indicator must stay up.
renderFor("beta");
await flushEffects();
await act(async () => setInputValue(blockInput()!, "beta-draft"));
await blurBlockInput();
expect(byProvider.beta.models).toEqual({ "gpt-test": { block: ["beta-draft"], allow: [] } });
expect(byProvider.alpha.models).toEqual({});
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
// Once alpha recovers, the preserved draft is written to alpha and the indicator clears.
failing = new Set();
await act(async () => {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
});
await flushEffects();
expect(byProvider.alpha.models).toEqual({ "gpt-test": { block: ["alpha-draft"], allow: [] } });
});
});

View File

@@ -0,0 +1,146 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
interface ParamFilterState {
block: string[];
allow: string[];
models: Record<string, { block: string[]; allow: string[] }>;
autoLearn: boolean;
}
let container: HTMLDivElement;
let root: Root;
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects() {
await act(async () => {
await Promise.resolve();
});
}
async function openPopover() {
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
}
describe("ModelCompatPopover model param filters (#8910)", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("persists the latest block and allow drafts when an outside mousedown closes the popover", async () => {
const apiPath = "/api/providers/openai/param-filters";
let serverState: ParamFilterState = {
block: ["provider-block"],
allow: ["provider-allow"],
models: {
"other-model": { block: ["keep-block"], allow: ["keep-allow"] },
},
autoLearn: true,
};
const putBodies: unknown[] = [];
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input) !== apiPath) throw new Error(`Unexpected param-filter URL: ${input}`);
if (init?.method === "PUT") {
const body = JSON.parse(String(init.body)) as ParamFilterState;
putBodies.push(body);
serverState = body;
}
return {
ok: true,
json: async () => structuredClone(serverState),
} as Response;
});
vi.stubGlobal("fetch", fetchMock);
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId="openai"
modelId="gpt-test"
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
await openPopover();
const blockInput = document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement;
const allowInput = document.querySelector(
'input[placeholder="compatAllowedParamsPlaceholder"]'
) as HTMLInputElement;
await act(async () => {
setInputValue(blockInput, "temperature, top_p");
setInputValue(allowInput, "tools, response_format");
});
await act(async () => {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
});
await flushEffects();
expect(
document.querySelector('input[placeholder="compatBlockedParamsPlaceholder"]')
).toBeNull();
expect(putBodies).toEqual([
{
block: ["provider-block"],
allow: ["provider-allow"],
models: {
"other-model": { block: ["keep-block"], allow: ["keep-allow"] },
"gpt-test": {
block: ["temperature", "top_p"],
allow: ["tools", "response_format"],
},
},
autoLearn: true,
},
]);
await openPopover();
expect(
(
document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement
).value
).toBe("temperature, top_p");
expect(
(
document.querySelector(
'input[placeholder="compatAllowedParamsPlaceholder"]'
) as HTMLInputElement
).value
).toBe("tools, response_format");
});
});

View File

@@ -177,6 +177,8 @@ describe("phase-1d extractions (#3501)", () => {
const c = renderComponent(
<ModelCompatPopover
t={(k: string) => k}
providerId="openai"
modelId="gpt-test"
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
@@ -190,6 +192,8 @@ describe("phase-1d extractions (#3501)", () => {
const c = renderComponent(
<ModelCompatPopover
t={(k: string) => k}
providerId="openai"
modelId="gpt-test"
effectiveModelNormalize={() => true}
effectiveModelPreserveDeveloper={() => false}
getUpstreamHeadersRecord={() => ({ "X-Custom": "value" })}

View File

@@ -123,7 +123,7 @@ export default function EditConnectionModal({
accountId: "",
codexReasoningEffort: "medium",
codexServiceTier: "default" as CodexServiceTier,
codexOpenaiStoreEnabled: false,
openaiResponsesStoreEnabled: false,
preserveEncryptedReasoning: false,
consoleApiKey: "",
newApiUserId: "",
@@ -330,7 +330,7 @@ export default function EditConnectionModal({
accountId: existingAccountId,
codexReasoningEffort: codexRequestDefaults.reasoningEffort,
codexServiceTier: codexRequestDefaults.serviceTier ?? "default",
codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true,
openaiResponsesStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true,
preserveEncryptedReasoning:
connection.providerSpecificData?.preserveEncryptedReasoning === true,
consoleApiKey: existingConsoleApiKey,
@@ -634,8 +634,6 @@ export default function EditConnectionModal({
? { serviceTier: formData.codexServiceTier }
: {}),
};
updates.providerSpecificData.openaiStoreEnabled =
formData.codexOpenaiStoreEnabled === true;
}
if (isAntigravityFamily) {
updates.providerSpecificData.projectId = trimmedCloudCodeProjectId || null;
@@ -662,6 +660,8 @@ export default function EditConnectionModal({
if (isResponsesConnection && updates.providerSpecificData) {
updates.providerSpecificData.preserveEncryptedReasoning =
formData.preserveEncryptedReasoning === true;
updates.providerSpecificData.openaiStoreEnabled =
formData.openaiResponsesStoreEnabled === true;
}
const freeOnlyChanged =
showFreeModelsToggle &&
@@ -704,6 +704,16 @@ export default function EditConnectionModal({
)}
/>
) : null;
const openaiResponsesStoreToggle = isResponsesConnection ? (
<Toggle
checked={formData.openaiResponsesStoreEnabled}
onChange={(checked) =>
setFormData({ ...formData, openaiResponsesStoreEnabled: checked })
}
label={t("openaiResponsesStoreLabel")}
description={t("openaiResponsesStoreDescription")}
/>
) : null;
return (
<Modal isOpen={isOpen} title={t("editConnection")} onClose={onClose}>
<div className="flex flex-col gap-4">
@@ -759,12 +769,6 @@ export default function EditConnectionModal({
"Default uses the normal Codex tier. Priority shows as Fast; Flex uses the flex service tier when available."
)}
/>
<Toggle
checked={formData.codexOpenaiStoreEnabled}
onChange={(checked) => setFormData({ ...formData, codexOpenaiStoreEnabled: checked })}
label={t("openaiResponsesStoreLabel")}
description={t("openaiResponsesStoreDescription")}
/>
</div>
)}
{isClaude && (
@@ -798,6 +802,7 @@ export default function EditConnectionModal({
/>
)}
{preserveEncryptedReasoningToggle}
{openaiResponsesStoreToggle}
<Toggle
checked={formData.disableCooling}
onChange={(checked) => setFormData({ ...formData, disableCooling: checked })}

View File

@@ -4,29 +4,31 @@ import { useState, useEffect } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
// Dedicated i18n keys — do NOT reuse settings.auto / autoDesc (those are routing
// "Auto Combo" strings and previously made Thinking Budget look like a routing mode).
const MODES = [
{
value: "passthrough",
labelKey: "passthrough",
descKey: "passthroughDesc",
labelKey: "thinkingModePassthrough",
descKey: "thinkingModePassthroughDesc",
icon: "arrow_forward",
},
{
value: "auto",
labelKey: "auto",
descKey: "autoDesc",
labelKey: "thinkingModeAuto",
descKey: "thinkingModeAutoDesc",
icon: "auto_awesome",
},
{
value: "custom",
labelKey: "custom",
descKey: "customDesc",
labelKey: "thinkingModeCustom",
descKey: "thinkingModeCustomDesc",
icon: "tune",
},
{
value: "adaptive",
labelKey: "adaptive",
descKey: "adaptiveDesc",
labelKey: "thinkingModeAdaptive",
descKey: "thinkingModeAdaptiveDesc",
icon: "trending_up",
},
];
@@ -94,6 +96,9 @@ export default function ThinkingBudgetTab() {
<div>
<h3 className="text-lg font-semibold">{t("thinkingBudgetTitle")}</h3>
<p className="text-sm text-text-muted">{t("thinkingBudgetDesc")}</p>
<p className="text-xs text-text-muted mt-1 leading-relaxed">
{t("thinkingBudgetIndependenceHint")}
</p>
</div>
{status === "saved" && (
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">

View File

@@ -23,6 +23,7 @@ interface VisionState {
modalityBridgeVisionPrompt: string;
modalityBridgeVisionTimeout: number;
modalityBridgeVisionMaxImages: number;
modalityBridgeVisionMaxChars: number;
modalityBridgeCacheEnabled: boolean;
modalityBridgeCacheTtlMinutes: number;
modalityBridgeCacheMaxEntries: number;
@@ -39,6 +40,7 @@ function fromApi(data: Record<string, unknown>): VisionState {
modalityBridgeVisionPrompt: runtime.prompt,
modalityBridgeVisionTimeout: runtime.timeoutMs,
modalityBridgeVisionMaxImages: runtime.maxImages,
modalityBridgeVisionMaxChars: runtime.maxChars,
modalityBridgeCacheEnabled: runtime.cacheEnabled,
modalityBridgeCacheTtlMinutes: runtime.cacheTtlMinutes,
modalityBridgeCacheMaxEntries: runtime.cacheMaxEntries,
@@ -107,6 +109,18 @@ export default function ModalityBridgeVisionTab() {
void update({ [key]: value });
};
const commitMaxChars = (raw: string) => {
const parsed = Number.parseInt(raw, 10);
// 0 disables the cap and is a valid value in its own right — only values
// between 1 and 99 (below the schema's floor) get pulled up to 100.
const value =
Number.isFinite(parsed) && parsed <= 0
? 0
: clampNumber(raw, 100, 50000, MODALITY_BRIDGE_DEFAULTS.visionMaxChars);
setLocal({ modalityBridgeVisionMaxChars: value });
void update({ modalityBridgeVisionMaxChars: value });
};
return (
<Card
title={t("modalityBridgeVisionTitle")}
@@ -227,6 +241,19 @@ export default function ModalityBridgeVisionTab() {
)
}
/>
<div>
<NumberField
testId="modality-bridge-max-chars"
label={t("visionMaxCharsLabel")}
min={0}
max={50000}
placeholder="0"
value={settings.modalityBridgeVisionMaxChars}
onChange={(value) => setLocal({ modalityBridgeVisionMaxChars: value })}
onBlur={(raw) => commitMaxChars(raw)}
/>
<p className="mt-1 text-xs text-text-muted">{t("visionMaxCharsHint")}</p>
</div>
<div className="md:col-span-2">
<Toggle
checked={settings.modalityBridgeCacheEnabled}
@@ -291,9 +318,19 @@ interface NumberFieldProps {
value: number;
onChange: (value: number) => void;
onBlur: (raw: string) => void;
placeholder?: string;
}
function NumberField({ testId, label, min, max, value, onChange, onBlur }: NumberFieldProps) {
function NumberField({
testId,
label,
min,
max,
value,
onChange,
onBlur,
placeholder,
}: NumberFieldProps) {
return (
<label className="block text-sm font-medium">
{label}
@@ -302,6 +339,7 @@ function NumberField({ testId, label, min, max, value, onChange, onBlur }: Numbe
data-testid={testId}
min={min}
max={max}
placeholder={placeholder}
value={value}
onChange={(event) => onChange(Number.parseInt(event.currentTarget.value, 10) || 0)}
onBlur={(event) => onBlur(event.currentTarget.value)}

View File

@@ -7,6 +7,7 @@ import {
ANTHROPIC_PING_FRAME,
} from "@omniroute/open-sse/utils/earlyStreamKeepalive";
import { resolveKeepaliveThreshold } from "@omniroute/open-sse/utils/keepaliveThreshold";
import { resolveStreamFlag } from "@omniroute/open-sse/utils/aiSdkCompat";
let initialized = false;
@@ -54,22 +55,27 @@ async function postHandler(request: any, context: any, preParsedBody: any = null
// /v1/responses (#2544). Anthropic clients ignore SSE comments for their watchdog, so
// emit a real `event: ping` (ANTHROPIC_PING_FRAME). Non-streaming callers keep the
// verbatim path.
const accept = String(request.headers?.get?.("accept") || "").toLowerCase();
if (accept.includes("text/event-stream")) {
let model;
let body = preParsedBody;
if (body == null) {
try {
const body = preParsedBody ?? (await request.clone().json().catch(() => null));
model = body?.model;
body = await request
.clone()
.json()
.catch(() => null);
} catch {
// body unavailable / non-JSON — fall back to the default keepalive threshold
// body unavailable / non-JSON — handleChat will return its normal validation error
}
return await withEarlyStreamKeepalive(handleChat(request, null, preParsedBody), {
}
const accept = String(request.headers?.get?.("accept") || "");
const wantsStreaming = resolveStreamFlag(body?.stream, accept, "claude");
if (wantsStreaming) {
return await withEarlyStreamKeepalive(handleChat(request, null, body), {
signal: request.signal,
thresholdMs: resolveKeepaliveThreshold(model),
thresholdMs: resolveKeepaliveThreshold(body?.model),
keepaliveFrame: ANTHROPIC_PING_FRAME,
});
}
return await handleChat(request, null, preParsedBody);
return await handleChat(request, null, body);
}
export const POST = withInjectionGuard(postHandler);

View File

@@ -6,9 +6,9 @@ import {
} from "@omniroute/open-sse/utils/earlyStreamKeepalive";
import { withInjectionGuard } from "@/middleware/promptInjectionGuard";
import { resolveResponsesApiModel } from "@/app/api/internal/codex-responses-ws/modelResolution";
import { getModelInfo } from "@/sse/services/model";
import { getComboByName } from "@/lib/db/combos";
import { getModelInfo, getComboForModel } from "@/sse/services/model";
import { resolveKeepaliveThreshold } from "@omniroute/open-sse/utils/keepaliveThreshold";
import { resolveStreamFlag } from "@omniroute/open-sse/utils/aiSdkCompat";
// NOTE: We do NOT call initTranslators() here — the translator registry is
// bootstrapped at module level inside open-sse/translator/index.ts when it
@@ -57,7 +57,7 @@ export async function withCodexPreferredModel(
const { model, changed } = await resolveResponsesApiModel(
body.model,
getModelInfo,
async (name) => !!(await getComboByName(name))
async (name) => !!(await getComboForModel(name))
);
if (!changed) return { request, body };
@@ -92,8 +92,9 @@ async function postHandler(request: any, context: any, preParsedBody: any = null
request,
preParsedBody
);
const accept = String(request.headers?.get?.("accept") || "").toLowerCase();
if (accept.includes("text/event-stream")) {
const accept = String(request.headers?.get?.("accept") || "");
const wantsStreaming = resolveStreamFlag(resolvedBody?.stream, accept, "openai-responses");
if (wantsStreaming) {
// Adaptive threshold: web-session and anonymous-fallback providers are slower
// to produce the first byte, so use a longer keepalive threshold (15s vs 2s).
// Reuse resolvedBody.model — no extra clone/parse needed (#4041).

View File

@@ -1,5 +0,0 @@
export {
getVscodeModelDisplayName,
getVscodeModelGroupingKey,
resolveVscodeModelMetadata,
} from "@/lib/vscode/modelPresentation";

View File

@@ -6821,8 +6821,17 @@
"chars": "{count} حرف",
"thinkingBudgetTitle": "ميزانية التفكير",
"thinkingBudgetDesc": "التحكم في استخدام الرمز المميز لاستدلال الذكاء الاصطناعي عبر جميع الطلبات",
"thinkingBudgetIndependenceHint": "تستمر ميزات الضغط والتوجيه وحدود الرموز في العمل في كل وضع. الوضع التلقائي لا يعني \"إظهار التفكير تلقائيًا\" - بل يقوم بإزالة حقول التفكير الخاصة بالعميل.",
"passthrough": "العبور",
"passthroughDesc": "لا توجد تغييرات - يتحكم العميل في ميزانية التفكير",
"thinkingModePassthrough": "تمرير",
"thinkingModePassthroughDesc": "اترك سبب العميل دون تغيير (الجهد، الملخص، كتل التفكير). مطلوب لرؤية التفكير في Codex/Desktop. افتراضي.",
"thinkingModeAuto": "تلقائي (شريط)",
"thinkingModeAutoDesc": "قم بإزالة جميع حقول تفكير/تفكير العميل (reasoning, reasoning_effort, Claude thinking, Gemini thinking_config) ودع المزود يخترع القيم الافتراضية. يمكن إخفاء لوحات التفكير وكسر طلبات ملخص العميل.",
"thinkingModeCustom": "مخصص",
"thinkingModeCustomDesc": "استبدل كل طلب بميزانية ثابتة من رموز التفكير التي تحددها أدناه.",
"thinkingModeAdaptive": "تكييفي",
"thinkingModeAdaptiveDesc": "قم بتوسيع ميزانية التفكير من جهد أساسي باستخدام عدد الرسائل، والأدوات، وطول المطالبة.",
"auto": "تلقائي",
"autoDesc": "قم بتجريد كل تكوينات التفكير - دع مقدم الخدمة يقرر",
"custom": "مخصص",
@@ -8031,6 +8040,8 @@
"modalityBridgeAdvanced": "متقدم",
"modalityBridgeTimeoutMs": "مهلة (مللي ثانية)",
"modalityBridgeMaxImages": "أقصى عدد من الصور لكل طلب",
"visionMaxCharsLabel": "أقصى عدد من أحرف الوصف",
"visionMaxCharsHint": "حدد حد طول وصف نموذج الرؤية. 0 = غير محدود — ارفع للمهام التي تتطلب تفاصيل كثيفة في التعرف الضوئي على الحروف.",
"modalityBridgeCacheEnabled": "وصف التخزين المؤقت",
"modalityBridgeCacheEnabledDesc": "إعادة استخدام الأوصاف للصور المتطابقة (مفتاح SHA-256، في الذاكرة).",
"modalityBridgeCacheTtlMinutes": "مدة صلاحية التخزين المؤقت (دقائق)",
@@ -13055,7 +13066,7 @@
},
"kimiSponsorBanner": {
"foundingFriendTitle": "Kimi (Moonshot AI) هو صديق المصدر المفتوح المؤسس لـ OmniRoute",
"description": "استخدم Kimi K3 في OmniRoute عبر واجهة Kimi API الرسمية. استمتع بذكاء متقدم بتكلفة أقل.",
"description": "المستخدمون الجدد يحصلون على 15% رصيد API إضافي عند أول شحن. استخدم Kimi K3 في OmniRoute عبر واجهة Kimi API الرسمية.",
"cta": "احصل على مفتاح Kimi API",
"partnerLinkNote": "رابط شريك",
"dismissAriaLabel": "تجاهل"

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