Commit Graph

113 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
dd35750e5f fix(sse): defer OpenAI-to-Claude finish emission until real usage arrives (#11915 follow-up on #11883) (#11933)
Merges #11883's already-merged usage-harvesting extraction with #11915's finish-deferral mechanism, verified to fix a real remaining bug: the client-visible message_delta carried stale/zero usage when finish_reason arrived before the trailing usage chunk. 86/86 tests passing across 16 translator regression files.
2026-08-28 16:11:38 -03:00
NoxzRCW
b8c7ee599d fix(translator): keep upstream usage from trailing empty-choices chunks (#11883)
openaiToClaudeResponse() returned early on !chunk.choices?.[0], dropping the trailing usage-only chunk many OpenAI-compatible upstreams send when stream_options.include_usage is set (confirmed on Fireworks kimi-k3) — state.usage stayed undefined and billing fell back to an uncached token estimate. 154/154 focused assertions across the fix + regression suite. Thanks for tracking down the billing impact!
2026-08-28 15:49:14 -03:00
Bob.Hou
1b39873ea9 fix(sse): preserve coherent markdown token boundaries across stream translation chunks (#11606) (#11851)
Preserves markdown token boundaries (bold/italic markers, code fences/spans, escape-backslash parity) split across stream translation chunks by tracking boundary state and flushing held buffers correctly instead of emitting broken markdown mid-stream. Closes #11606. 38/38 + 74/74 focused tests passing. Thanks!
2026-08-28 12:22:09 -03:00
Webman
6e96057f1d fix(sse): explicit types for openai-responses pureHelpers — clears last failing core typecheck gate (#11567)
Merged via /merge-batch (lote 2026-08-26, v3.8.51). Boarded no worktree combinado junto com outras ~30 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e ~370 testes focados (unit + vitest) passando. Obrigado pela contribuição.
2026-08-26 08:11:00 -03:00
Markus Hartung
04dba0460e fix(responses-continuation): recover a real id/output for passthrough and translate-mode replies (#11434)
Retargetado para release/v3.8.51 (release/v3.8.50 está congelada — freeze issue #11439). Validado em lote combinado (batch-0824h2, junto de #11435/#11436/#11437) contra o tip de release/v3.8.51: typecheck:core limpo, gates estáticos OK, 127/127 testes focados passando.

Investigação sólida com repro real via container isolado, três causas independentes identificadas e corrigidas com testes de regressão dedicados para cada uma. Obrigado pela contribuição!
2026-08-24 19:57:12 -03:00
Óscar Fonseca
a3c3117254 fix(translator): preserve omitted subagent session IDs (#11297)
Validated on a 3-PR combined board: openai-responses-opencode-subagent-sessionid within the board's 20/20, typecheck:core + check:open-sse-typecheck clean, gates within baseline. Uses null as the omission sentinel for OpenCode's optional default-less subagent.sessionID under Responses/Codex strict mode (which required every declared property) — models can now omit the field to spawn a new child instead of inventing filler IDs; the null is stripped before OpenCode sees the call, real child IDs pass through unchanged. Thank you @ofonseca-pyming!
2026-08-23 21:03:16 -03:00
Dizzle
1dd0173468 fix(sse): assign a stable index/id to parallel function_call items in Responses->Chat translation (#11144)
Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean, typecheck:core clean, focused tests green.

TDD red->green: parallel function_call items now get distinct stable index/id at .added time via a per-call Map, interleaved argument deltas no longer glue, dual item_id/output_index correlation. 120-test translator suite green. Thank you @maxmad64bis!
2026-08-22 14:40:45 -03:00
Nahuel Saruf
00aba8ef16 fix(sse): include prompt cache usage fields on message_stop fallback path (#10545)
Mirrors the existing prompt_tokens_details cache-field mapping from the message_delta finish path into the message_stop fallback, so OpenAI-compatible clients see cache_read/cache_creation counters when the finish signal arrives without usage on the same event. Fixes #10535.

Validated in an isolated worktree boarded onto origin/release/v3.8.50 (0 conflicts, 2 files):
- 16/16 focused tests pass (translator-resp-claude-to-openai.test.ts), including the new regression test for this exact fallback path.
- check-file-size, check-changelog-integrity: OK.
- typecheck:core: clean.
- check-complexity / check-cognitive-complexity: OK, both under baseline.

Co-authored-by: NahuSaruf <NahuSaruf@users.noreply.github.com>
2026-08-20 22:33:39 -03:00
Ke Jin
769ab62fa3 fix(reasoning): preserve compatible response state (#10574)
Preserves authentic plaintext reasoning continuations across Chat Completions and Responses (streaming + non-streaming), applying one target-aware reasoning transport policy before protocol translation. Fixes #10550.

Validated in an isolated worktree boarded onto origin/release/v3.8.50 (0 conflicts, 39 files):
- 446/446 focused node:test tests pass (chat-route-coverage, chatcore-translation-paths, combo-attempt-body-isolation-7847, combo-config, executor-codex, kimi-coding-translator, moonshot-k3, reasoning-cache, response-sanitizer, responses-handler, responses-translation-fixes, strip-reasoning-blobs-agentic-context-1599, translator-openai-responses-req).
- 12/12 vitest tests pass (edit-connection-modal-free-models.test.tsx).
- check-changelog-integrity: OK.
- typecheck:core: clean.
- check-complexity / check-cognitive-complexity: OK, both under baseline.
- file-size: chatHelpers.ts crossed the frozen cap by +2 lines (irreducible reasoningTransportFallback option threading) — rebaselined 1017->1019 with justification, pushed to the PR branch (fix-in-place), re-validated after a base-drift re-merge against the latest release tip.

Co-authored-by: jackjinke <jackjinke@users.noreply.github.com>
2026-08-20 22:20:54 -03:00
Ravi Tharuma
2cd14b1696 fix(providers): rename Freepik slug to Magnific and validate Magnific API keys (#10594)
Canonical provider id renamed freepik → magnific (Magnific Mystic official API), with a permanent redirect + runtime alias so old freepik/<model> traffic and /dashboard/providers/freepik URLs keep working. Existing provider=freepik connection rows are rewritten to magnific by migration 160.

Closes #10604.

Validated in an isolated worktree boarded onto origin/release/v3.8.50 (0 conflicts, 138 files):
- Focused suite: 54/54 tests pass (magnific-image-handler, provider-validation-image-only, provider-alias-uniqueness, redirects-cli-renames).
- check-file-size, check-changelog-integrity: OK.
- typecheck:core: clean.
- check-complexity / check-cognitive-complexity: OK, both under baseline.

Co-authored-by: RaviTharuma <RaviTharuma@users.noreply.github.com>
2026-08-20 22:15:48 -03:00
GiauPhan
548316a2c4 fix(translator): Normalize tool call names from lowercase to PascalCase when translating upstream responses to Claude Messages API format (#10392)
* fix(translator): Normalize tool call names from lowercase to PascalCase (#1)

* Fix: Map lowercase tool names from Antigravity (Gemini format) to Claude Code expected PascalCase

* Fix: toolNameMap in fun restoreClaudePassthroughToolUseName

* fix(translator): Normalize tool call names from lowercase to PascalCase when translating upstream responses (OpenAI, Gemini, Antigravity) to Claude Messages API format

This resolves `Error: No such tool available: read`/`bash`/`write` errors when using Claude Code CLI with third-party providers that emit lowercase tool names. The fix adds case-insensitive tool name lookups in `openai-to-claude.ts`, `gemini-to-claude.ts`, and related translators, ensuring tool names like `read`/`bash` are mapped to `Read`/`Bash` before being sent to Claude Code. Includes unit tests and comprehensive changelog notes ([#10250](https://github.com/diegosouzapw/OmniRoute/pull/10250))

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(translator): Parse <tool_call> JSON and TOOL_CALL text formats fr… (#2)

* fix(translator): Parse <tool_call> JSON and TOOL_CALL text formats from model output

Some models (DeepSeek, Qwen) emit tool calls as text instead of proper
tool_calls JSON: either <tool_call>{...}</tool_call> or TOOL_CALL Name: {...}.
Extend extractXmlInvokeBlocks to handle all 3 formats in a single scan pass,
picking whichever pattern appears first. Includes unit tests for all formats.

* fix(translator): Parse text-format tool calls in gemini-to-claude translator

Extend the Gemini->Claude translator to detect <invoke>, <tool_call> JSON,
and TOOL_CALL text formats emitted inline in text parts (Antigravity/Gemini
models), converting them to proper tool_use content blocks instead of leaking
raw text to Claude Code.

* docs(changelog): Add changelog entry for text tool call parsing fix

* fix(translator): consolidate tool name casing normalization and restore thought-signature persistence (#3)

* fix(translator): sanitize tool_use.id and tool_result.tool_use_id to match Anthropic schema (#4)

Ensure tool IDs from OpenAI-compatible upstreams (which may contain dots, colons, or special characters) are sanitized to ^[a-zA-Z0-9_-]+$ in response translators and passthrough requests before reaching Claude endpoints.

* fix(responses): preserve native tools for openai-compatible Responses targets (#5)

A Responses-shaped request to a custom openai-compatible connection whose
outbound protocol is Responses took a Responses -> Chat -> Responses round
trip, so Codex custom tools lost their grammar (`exec`), namespace groups were
flattened (`collaboration`), and tool invocations failed upstream.

Gate a native Responses passthrough on the connection's configured protocol
(`apiType: "responses"` / `_omnirouteForceResponsesUpstream`) so the original
tool definitions reach a Responses-capable upstream unchanged. Chat-only
connections keep the existing downgrade.

Closes #10374

* fix(translator): add support for 'applypatch' tool name in tool call checks

* test(translator): add unit test for apply_patch and applypatch tool name remapping

* fix(translator): remove no-explicit-any lint errors in tool-use-id-sanitization test

Type the openaiToClaudeResponse/translateNonStreamingResponse return
values with narrow local shapes instead of `any`, satisfying the
repo's no-explicit-any = error rule for tests/. No behavior change —
the same 3 assertions still pass.

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

* test: update 9568 casing regression to match #10392's consolidated fix

restoreClaudeToolName's static casing map now normalizes known
lowercase tool names to canonical PascalCase unconditionally on the
gemini-to-claude and openai-to-claude Claude Messages API paths (not
gated behind toolNameMap), superseding the earlier per-map-only fix
that the original #9568 regression test locked in as "expected" (it
was previously labeled a known bug case). The gemini-to-openai
passthrough path is unaffected by #10392 and keeps its original
pass-through assertion.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:49:48 -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
mtb-ninja
3b41e795fc fix(translator): preserve Responses custom tools for OpenAI-compatible providers (#10114)
* fix(translator): preserve Responses custom tools

* fix(translator): preserve Responses namespace tools

* fix(translator): restore dotted namespace tool aliases

---------

Co-authored-by: mtb-ninja <mtb-ninja@users.noreply.github.com>
2026-08-13 00:59:37 -03:00
Diego Rodrigues de Sa e Souza
d259d9fcba fix(ci): clear base-reds on release/v3.8.50 (round 3) (#10213)
* fix(ci): clear base-reds on release/v3.8.50 (round 3)

- CHANGELOG.md: restore the top [Unreleased] section dropped by the #10189
  reconcile (docs-sync gate: first section must be Unreleased)
- env-doc-sync: document CONDUCTOR_ORCHESTRATOR_TOKEN + CONDUCTOR_SPOKESPERSON_URL
  in .env.example/ENVIRONMENT.md; allowlist the CI-only GITHUB_STEP_SUMMARY and
  TS7_BASE_REF (ts7 ratchet signals); drop a stray merge artifact line
- providers: restore the audited chatanywhere metadata entry that base-reds
  round 2 dropped together with its duplicate — the provider was half-wired
  (registry+endpoint without APIKEY metadata), which is what the wave3 test
  catches; re-pin providers-constants-split at the measured 228
- docs counts: 338 -> 339 (today's +2 void-ai/helixmind, -1 Puter) via
  gen:provider-reference + README/AGENTS/llm.txt/package.json/diagrams/i18n mirrors
- file-size ratchet: annotated rebaseline for the two pre-existing drifts
  (ModelSelectModal 1138, gateways 1250) following the 2026-08-11 precedent

Refs #9985

* fix(ci): base-reds round 3b — stale sibling tests + mode-pack weight contract

- check-docs-counts-sync.test.ts: drop the imports/subtests of the four helpers
  #10196 removed from the gate script (readMcpFactsFromSource, listLocalizedDocs,
  makeRequiredCountsValidator, checkFreeTierInventory) — the new-API tests that
  #10196 added stay; the file now loads again under the node runner
- quota-connection-recovery.test.ts: convert from vitest APIs to node:test —
  the file lives in tests/unit/*.test.ts (node-runner glob) and the vitest
  runtime crashes when imported outside vitest, killing the whole shard entry
- modePacks.ts: re-normalize all six mode packs to sum 1.0 — #8940 added
  sessionAvailability: 0.05 to every pack without rebalancing (1.05 total);
  ratios preserved exactly (÷1.05), so post-normalizeScoringWeights behavior
  is unchanged; restores the declared sum-to-1.0 contract the 4235 test pins

Refs #9985

* fix(ci): base-reds round 3c — vitest siblings, weights default, secrets FP, mutation tap

- DistributeProxiesButton.test.tsx: wrap renders in NextIntlClientProvider —
  #9245 localized the component (useTranslations) and left the test without
  the intl context, failing all 14 cases
- scoring.ts: re-normalize DEFAULT_WEIGHTS to sum 1.0 (same #8940 class as the
  mode packs — sessionAvailability added without rebalancing; ratios preserved)
- .gitleaks.toml: generalize the kimi sponsor-banner localStorage-key allowlist
  to -v\d+ — #10200 bumped v1→v2 and the stale regex regressed the secrets
  ratchet with a false positive
- stryker.conf.json: register 6 covering unit tests in tap.testFiles (4 modules)
  so their mutant kills count — unblocks check:mutation-test-coverage --strict

Refs #9985

* fix(ci): base-reds round 3d — inspector factor gap, stale registry/gap tests, i18n key sync

- comboScoringInspector: add cacheAffinity/sessionAvailability/connectionDensity
  to FACTOR_KEYS + the factor-key type — calculateScore() weighs them but the
  breakdown omitted them, so the explained contributions never summed to the
  reported score (inspector bug, red on the pure tip)
- combo-scoring-inspector.test: make the explicit-weights override sum-neutral
  (±0.05 shift) so it stays valid for any DEFAULT_WEIGHTS values — the hardcoded
  override only summed to 1.0 against the pre-#8940 defaults, which is also why
  explicit weights silently fell back to 'default' on the tip
- unorouter-registry.test: align to the canonical .com host (api.unorouter.ai
  301-redirects there, verified live) and to wave4's live model discovery
  (passthrough, no static seed) — the .ai/auto-model expectations were stale
- check-migration-numbering.test: 147 left KNOWN_GAPS when
  147_api_keys_model_access_mode.sql landed — assert absent (same as 143)
- i18n: sync-ui pass — 35,914 missing UI keys stamped as __MISSING__ placeholders
  across 42 locales (mechanical; greens the pt-BR key-presence integrity test;
  coverage pct unchanged by design — translation is a separate workstream)

Refs #9985

* fix(ci): base-reds round 3e — 2 real defects + 14 stale sibling tests (waves A-E)

Real defects fixed:
- src/lib/db/apiKeys.ts: #9313's empty-allowlist early return bypassed the group
  permission check, silently disabling group deny rules (#8817) for every key
  without a per-key allowlist; fall-through restored, restricted+[] deny-all kept
- open-sse/utils/proxyFetch.ts: #10032 re-appended the raw transport error to the
  propagated message, reintroducing the proxy user:password leak #9837 closed;
  new redactProxyDetailsInMessage() keeps the reason, redacts URL/credentials
- .github/workflows/quality.yml: #10134 added the TS7 ratchet as a separate
  blocking step AFTER the aggregated gates — the exact #8542 masking mechanism;
  folded into the non-fail-fast loop (still blocking, still PR-only) ⚠️ CI edit,
  gate-strengthening — explicit owner sign-off requested on the PR
- src/i18n/messages/ko.json: 3 machine-mistranslation regressions caught by the
  #8244 glossary checker (장애인→비활성화됨, 양말5://→socks5://, 비클로드→Claude가 아닌)

Stale sibling tests aligned to deliberately-moved contracts (each cites its mover):
request-log-detail-layout + -stream (#9245 intl provider), repro-8542 pin update,
quality-rail-gate-membership (#10134 shape), agentSkills-routes 45→46 (#9058),
cloudflare-ai-catalog-8717 (#8804 supersedes #8808), executor-xai (#9994),
vision-bridge-claude-wire (#9463 minimax→openai), sse-auth forced-pin (#8893),
tls-proxy-context (strengthened leak guards), rate-limit-local-error-classification
(#9164/#9342), minimax-thinking-signature (#9463), codebuddy-cn (#9723 +1 test),
github-copilot-custom-model (#9050), providers-g4f-batch3 (#9584),
synced-capability-warmup (#9199, stricter), sidebar-tools-group (#8221),
oauth-modal-grok-cli-paste (#9245); agentSkills/catalog.ts comment 45→46;
file-size rebaseline for proxyFetch (+19, annotated)

Refs #9985

* fix(ci): base-reds round 3f — waves F-J: 9 more real defects + stale sibling sweep

Real production defects fixed (all red on the pure tip, each with its origin):
- routeGuard.ts: #8949 accidentally DELETED the /api/providers/[id]/login
  local-only pattern — the route spawns a browser, so the loopback gate for a
  process-spawning route was gone (Hard Rules #15/#17); restored (314 guard
  tests green)
- agentSkills generator: #9058's category dispatch gave the config category an
  empty body, wiping skills/config-codex-cli/SKILL.md at the #10131 sync;
  fixed + SKILL.md regenerated via the official generator
- imageRegistry: #9982 broke same-provider bare aliasing (antigravity preview
  id sent upstream unresolved); new resolveSameProviderBareAlias() keeps the
  fal cross-provider fix intact
- imageRegistry: #9982's prefix strip handed the bare nano-banana ids to fal-ai,
  violating the pinned 2026-07-31 operator decision (adobe-firefly owns them);
  fal entries made prefix-only (dispatch already re-prefixes)
- mediaGeneration/fal.ts: the missing-credential 401 guard was lost when #10198
  deleted the superseded falHandler — tests were hitting the live network
- bottleneckPatch/rateLimitManager: #9041's merge clobbered #9604, resurrecting
  the Bottleneck v2.19.5 heartbeat bug (reservoir never refills); patched the
  library defect at the root and re-aligned chat-rate-limit-body-lock to the
  working reservoir contract
- processSupervisor.mjs: #9761 regressed the Node spawn to bare "node" (the
  #9156 launchd bug) and dropped #9209's ipv4first args; both restored
- openai-responses/pureHelpers: #9423's Agent null-sentinel was unreachable on
  the schemaless JSON-string path; gate extended
- i18n en.json: #8222's regen reverted the #9976 unclosed-tag fix and #8559's
  combo-cooldown copy; #9038 shipped 40 t() calls with no messages (runtime
  MISSING_MESSAGE); all restored/added + official sync-ui stamps, and vi's
  zero-marker policy re-established via the sanctioned translation backend

Stale sibling tests aligned (movers cited inline): chat-helpers (#9447),
executor-antigravity (#9351), video-fal-grok (#9982), visionBridge (#9759),
web-session-credentials (#8974), production-build-module-integrity (positive
anchor added), agentSkills-generator/skillManifestsLint/skills-injection/
agentSkillTools-mcp/listCapabilities-a2a (#9058), memory-settings (#10010),
model-catalog-policy-invalidation (#8906), model-alias-seed (#9485),
reactive-context-compaction (#8949), combo-provider-wildcard (broken upsert
helper), oauth-google-loopback (43-locale resurrected-key removal)

Validation: 501/501 across the 47 touched test files; typecheck:core, lint,
file-size, docs-sync all green.

Refs #9985

* fix(ci): base-reds round 3g — wave K/L: 4 more real defects + stale alignments

Real defects:
- base/reasoningEffort.ts: the stale duplicate cherry-pick #9612 re-added the
  codex minimal→low rewrite that #9883 had deliberately removed (OMP minimal
  passthrough); block removed again
- cursorImages.ts: #9840 wired prepareCursorImageForWire (sharp re-encode,
  fail-closed) into the SHARED resolveCursorImages, breaking zai-web and
  conol-web image uploads (HTTP 400 'undecodable'); new prepareForWire opt-out,
  Cursor default path unchanged (8 cursor suites green)
- modelCapabilities/snapshot: catalog prepare still issued 323 per-model reads
  of model_context_overrides + max_input_tokens overrides, violating #9199's
  bulk-load contract; both now resolve from the snapshot single pass
- v1-models-discovery-conformance: re-pinned to the bounded 30s SWR window
  (#9199/#10198) — the old 'stale-first regardless of age' contract is gone

Stale tests aligned (movers cited inline): codex-tools-strict-default (#9828
redundant-oneOf strip), devin-providers (#9245 i18n), db-migrationrunner-
constants-split (147→151 renumber #8228), gitlab-duo-oauth-setup (#9245),
chatcore-extracted-modules (#9161 outbound-protocol keying)

compression-api CI failures were cascade artifacts of codex-tools-strict-default
failing in the same force-exit shard process — no own defect (171/171 local).

Refs #9985

* fix(test): compression-api — register both describes before the runner starts

The DATA_DIR setup + route/db top-level awaits sat BETWEEN the two describes;
under --test-force-exit (the CI unit-runner flag) the process exits once the
already-registered tests finish, so on slow CI machines the whole second
describe died as 'Promise resolution is still pending' — the recurring
CI-only shard-2 failure that never reproduced locally without the flag.
Moved to the top of the file; 10/10 under --test-force-exit locally.

Refs #9985

* fix(quality): freeze modelCapabilities.ts at 1006 (annotated) — snapshot routing growth

Refs #9985

* fix(quality): move the modelCapabilities freeze into the frozen map (nested schema)

Refs #9985

* fix(i18n): translate all 39,718 pending UI keys across 42 locales (owner-approved)

Mass-translated every __MISSING__ placeholder via the official i18n:sync-ui
--translate-markers pipeline (operator backend), restoring i18nUiCoverage to the
100 baseline (was 89.9 after the merge-storm UI landings + the 42 keys #9038
never shipped).

Post-pass repairs, all caught by the existing gates:
- glossary: retired renderings the machine reintroduced normalized again
  (提供商→提供者 zh-CN/zh-TW, 鏈接→連結, 文檔→文件, 調用→呼叫, 供應商→提供者,
  響應→回應, 不活躍→未啟用 zh-TW; 클로드→Claude, 옴니루트→OmniRoute ko);
  DATA_DIR forbidden rendering avoided via 数据文件夹 rephrase
- ICU integrity: 120 values with renamed/dropped {params} repaired (39
  positional renames, 81 reset to the en source — functional over fluent)

Validation: glossary/pt-BR/vi/deno-relay/settings-keys/value-drift/google-
loopback suites 76/76; placeholder diff en×42 locales = 0; worst-locale
coverage = 100.0%.

Refs #9985

---------

Co-authored-by: backryun <bakryun0718@proton.me>
2026-08-13 00:02:25 -03:00
Kittisak Tangsiri
b13c3cd202 fix(translator): normalize streamed optional tool arguments (#9423)
* fix: preserve Codex cache usage for Claude suggestions

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: normalize streamed optional tool arguments

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Kittisak Tangsiri <kittisak@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-11 04:35:30 -03:00
Prudhvi Vuda
835de9d749 fix(sse): preserve Claude Code tool-name casing via Gemini/Antigravity (#9008) (#9016)
Stop blindly lowercasing PascalCase tool_use names on the Gemini→Claude path so Claude Code no longer rejects Read/WebSearch as missing tools.
2026-08-11 04:29:52 -03:00
Diego Rodrigues de Sa e Souza
807a0d2022 maint: follow-up cherry-pick fix-in-place #9704 (conflict-resolved fallback) (#9889)
* fix(sse): persist per-tool-call JSON escape state across SSE delta chunks

escapeJsonStringValues() reset its inString/pendingEscape state on every
call instead of carrying it forward per tool-call index, so a raw newline
byte (or an already-escaped \n) split across two delta chunks got corrupted
in transit — the model's own output was correctly escaped, OmniRoute broke
it. Root-caused via a dispatched investigation into real OpenClaw traffic
that looked like model-generation quality but wasn't.

Fix: escapeJsonStringValues now takes and mutates a persistent per-call
state object (JsonStringEscapeState), keyed per tool-call index in the
translator's init state and cleared when a tool call is superseded.

* chore(quality): rebaseline openai-responses.ts for the escape-state fix

Own growth from the extracted per-tool-call JSON escape-state fix
(previous commit): open-sse/translator/response/openai-responses.ts
1204->1249 (+45).

---------

Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-09 10:07:36 -03:00
Diego Rodrigues de Sa e Souza
05940f4c7f fix(responses-api): tool call after a text message collided on the same output_index (#9843)
Live incident (2026-08-08): an OpenClaw agent sent a short preamble line
("Kör nu, på riktigt — apply_patch på vibe-scriptet:") followed by an
apply_patch tool call in the same turn. The client only spoke the preamble
and never executed the patch, even though OmniRoute's own recorded
responseBody had a complete, valid tool_calls entry.

Root cause: emitToolCall/closeToolCall computed a tool call's output_index
as `reasoningIndex + 1 + tcIdx`, assuming reasoningIndex + 1 was free for
the first tool call (tcIdx=0). But a text message emitted in the same turn
ALSO claims reasoningIndex + 1 (or index 0 with no reasoning) — so a
turn with reasoning + text content + a tool call collided the tool call's
added/delta/done events onto the same output_index as the just-closed
message. A client that tracks response items by output_index (as expected
for the Responses API) sees the tool call events land on an index it
already marked complete and can silently drop them.

Fix: track whether a message item was actually emitted at that index
(state.msgItemAdded) and, if so, tool calls start one slot after it.
Extracted a shared toolCallOutputIndexBase() helper so emitToolCall and
closeToolCall can no longer compute this independently and drift apart.

Confirmed via the live call log artifact (id 1786223153235-770a1c):
response.output_item.done for the text message and response.output_item.added
for the tool call both carried output_index=1 in the raw SSE stream, 1.84s
apart, exactly matching the reported symptom.

Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-09 09:50:09 -03:00
Diego Rodrigues de Sa e Souza
492f9ddc4a fix: buffer and normalize Responses tool-call argument deltas, stripping optional null before reaching the client (#9168)
Refs: base-red #9737
2026-08-08 12:07:24 -03:00
Diego Rodrigues de Sa e Souza
e7d9055314 fix(translator): avoid double-normalizing tool names in Gemini-to-Claude response path (#9177)
Refs: base-red #9737
2026-08-08 12:07:14 -03:00
Diego Rodrigues de Sa e Souza
c9a3361e5a fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575)
Closes #9575
2026-08-06 22:58:51 -03:00
Diego Rodrigues de Sa e Souza
c9debe92bd fix(translator): restore original tool name casing in Gemini response translators (#9568)
Closes #9568
2026-08-06 22:55:50 -03:00
Diego Rodrigues de Sa e Souza
e7f6b1d130 feat(radar): flag-gated signed free-model catalog overlay (#9515)
* feat(dashboard): add RADAR_ENABLED flag (default off)

* feat(db): radar feed cache + settings with encrypted supporter key

* feat(radar): signed feed sync with pinned key and version floor

- feedSchema.ts: Zod v4 schema mirroring the server feed format
  (discriminated union on budget.kind, enum constraints, etc.)
- pinnedKeys.ts: Ed25519 SPKI-DER pinned key + env override for forks
- verify.ts: signature verification over exact wire bytes, never throws
- sync.ts: full download/verify/validate/cache pipeline with injectable
  deps, feature-flag gate, opt-in gate, version floor (numeric compare),
  and sanitized error reasons (no stack traces)
- 40 tests covering: contract hash, key handling, sig verification,
  schema validation, version compare, all sync paths (disabled, opt_out,
  invalid_signature, invalid_schema, stale, updated, error), auth header
  injection, and cache-untouched assertions for every failure mode

* feat(radar): read-time overlay merge rules over the free catalog

Pure function applyFeed() merges the cached Radar feed over the static
baseline catalog at read time, honoring 4 rules:

1. Feed never overwrites a local override field.
2. enabled:false disables the entry with disabledBy:"radar" provenance.
3. User-added entry NOT in the feed survives untouched.
4. User deletion tombstone prevents feed resurrection.

getRadarCatalog() accessor in index.ts: flag off / no cache / corrupt
payload all fall back to baseline. Valid cache applies the overlay and
returns feed metadata (version, tier, fetchedAt).

TDD: 19 tests (4 rules + dedup + origin + accessor flag/cache/corrupt/
valid/bad-feed + baselineToMergedEntries converter).

* feat(dashboard): radar catalog and guided setup screens

- API routes: GET /api/radar/catalog, POST /api/radar/sync, POST /api/radar/settings
  - All gated on RADAR_ENABLED flag (404 when off)
  - Error responses via buildErrorBody(), never raw stack/message
  - Settings never echoes clear supporter key (masked omr_****<last4>)
  - Sync delegates to syncRadar() server-side, never proxies feed URL
- Dashboard pages:
  - /dashboard/radar: 4 states (flag off, opt-in pending, empty, populated)
  - /dashboard/radar/setup?provider=X: guided setup with steps, key URL, test connection
  - Uses existing Card component and next-intl patterns
- Sidebar: radar entry in costs group with icon
- i18n: pt-BR and en keys for radarPage and radarSetupPage namespaces
- Tests:
  - radar-api-routes.test.ts: 11 tests (flag-off 404, flag-on shape, error sanitization)
  - radar-page-state.test.ts: 5 tests (pure state logic)
  - All 90 radar tests pass (including prior 74)

* docs(radar): module doc and flag-off inertia test

Add docs/frameworks/RADAR.md covering the flag gate, the separate data-sync
opt-in and privacy promise, the Ed25519 signature/pinned-key security model,
tiers, the read-time overlay merge rules, and the self-hosting env vars —
plus index entries in CLAUDE.md/AGENTS.md/docs/README.md/REPOSITORY_MAP.md.

Document RADAR_FEED_URL and RADAR_FEED_PUBKEY in .env.example and
docs/reference/ENVIRONMENT.md to satisfy check:env-doc-sync, which was
failing on this branch since the sync.ts commit added the reads.

Add tests/unit/radar-inertia.test.ts as the single canonical place asserting
the "RADAR_ENABLED off => zero behavioral delta" claim end to end: the three
/api/radar/* routes 404, the flag resolves to the definition default with no
override, getRadarCatalog() returns exactly the baseline without touching the
cache, and computeFreeModelTotals() keeps its pinned values with the Radar
module imported alongside it.

* fix(db): renumber radar migration to 135 after collision with 134

The base branch introduced 134_proxy_logs_egress_ip while this branch carried
134_radar_cache_settings; the migration runner rejects duplicate numeric prefixes.
This migration has never been applied to a real database (the PR is unmerged), so
no retroactive isSchemaAlreadyApplied guard is needed.

* i18n(radar): translate radar catalog and setup strings to all locales

The UI-coverage ratchet measures (present - placeholder) / total_en, so the
__MISSING__ sentinels that i18n:sync-ui writes do not count as covered — only
real translations restore the metric. Scoped to this PR's namespaces
(radarPage, radarSetupPage, sidebar.radar*) instead of a bulk sync, which would
have pulled ~978 unrelated pending keys into this diff.

Placeholders and code identifiers verified preserved across all 1682 strings.

* fix(radar): trust the served-tier header instead of the signed body field

The signed feed body always carries tier:"live" by design (one signed
artifact per version — rewriting the field server-side per request
would break the exact-bytes Ed25519 signature). The server now returns
the tier ACTUALLY served via the x-omniroute-feed-tier response
header, so free users on a delayed community snapshot no longer see
"Ao vivo (tempo real)" in the UI.

sync.ts now reads and validates that header (falling back to the
body's tier only when the header is absent or holds an unrecognized
value) and stores the served tier in the cache; index.ts already
surfaces cache.tier to the UI unchanged.

* test(combo): shorten an assert message that exceeded the line limit

The assertion added by #9507 was 104 chars, so prettier reformatted it into
five lines on the next commit that touched the file, pushing it past its
frozen size (3449) and failing check:file-size. The message is shortened
(the issue reference stays in the comment directly above); the assertion
itself is unchanged, and the file is back to 3448 lines and prettier-clean.

* i18n(radar): use the canonical zh-TW glossary terms

The machine translation produced retired renderings the glossary gate blocks:
供應商 for provider (canonical 提供者) and 文檔 for documentation (canonical 文件).
Fixed across the 11 affected radar strings; tests/unit/i18n-glossary-consistency-check.test.ts
is back to 17/17.

* fix(radar): point the default feed URL at the domain that exists

radar.omniroute.dev was a placeholder for a domain that was never registered,
so an out-of-the-box sync would fail DNS resolution for every user. The live
feed is served from radar.omniroute.online (the subdomain the design always
specified), now behind Cloudflare TLS. Forks still override it via
RADAR_FEED_URL.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-06 05:58:58 -03:00
Prudhvi Vuda
7c0dba222c [v3.8.50] fix(sse): stop fabricating encrypted Codex reasoning summary text (#8807)
Validated in local merge-train T6 (ungrouped batch 1)
2026-08-06 05:23:53 -03:00
Diego Rodrigues de Sa e Souza
8180b49ce1 fix(quality): 2 production bugs + 24 unit base-reds + measured gate ceilings (#9529)
* fix(quality): resolve net-new lint errors and allowlist #9343 assert rewrite

Two `no-explicit-any` errors landed with #9407 and #9320 after the
suppressions inventory was generated. Project policy is to fix new
violations rather than freeze them, so both are typed instead:
  - #9407: `executor as unknown as Record<string, unknown>`
  - #9320: `(k: { name?: string })`

Also allowlists the net-assert reduction in web-tools-translation-2820
(39->35). #9343 inverted the contract — bare JSON must no longer be
promoted to tool_calls without an explicit <tool> envelope — so the
tests were rewritten to assert non-promotion, which costs fewer asserts
than validating a promoted object. More restrictive, not weaker.

* fix(quality): raise integration ceiling to 40min and unpin codex-cli version in test

The integration gate's 20min ceiling killed a healthy run: measured 22m08s
hermetic on an idle 16-core box (935 tests across 112 files, strictly serial
at --test-concurrency=1 because ~16 of them bind a port or share a DB). The
"~3-10min" estimate in the code was stale by ~3x. 40min keeps the ceiling's
real purpose — turning a genuine hang into a visible failure — without
failing a long-but-healthy suite.

Also fixes a base-red in chat-pipeline: 564c204efe bumped
DEFAULT_CODEX_CLIENT_VERSION to 0.146.0 but the User-Agent assertion still
pinned 0.144.1. The line two above already read the constant via
getCodexClientVersion(); this one duplicated the literal. Deriving it from
the same source stops the next bump from breaking the test again.

* fix(ratelimit): re-arm Bottleneck reservoir heartbeat after updateSettings

Bottleneck 2.19.5 (frozen upstream dependency, no release since 2019) has a
bug in LocalDatastore#_startHeartbeat() (node_modules/bottleneck/lib/
LocalDatastore.js:29,56): the guard `if (this.heartbeat == null && ...)`
only (re)creates the periodic reservoir-refresh interval the first time it
runs. Every later call -- including the one updateSettings() itself
triggers internally -- falls into the else branch and does
clearInterval(this.heartbeat) WITHOUT resetting the reference back to
null. Because the stale reference sticks around, every future
_startHeartbeat() call keeps taking the same dead else branch: the
periodic reservoir refresh is gone forever after the first manual
updateSettings() call on a limiter.

Every limiter created by this file starts with a live heartbeat
(buildLimiterDefaults() always sets reservoirRefreshInterval/
reservoirRefreshAmount), so the very first updateFromHeaders() /
updateFromResponseBody() / applyRequestQueueSettings() call against a
limiter permanently kills its refresh. In production this wedges the
request queue once the reservoir hits 0: an auto-enrolled apikey
connection accumulates its default 60 requests, the reservoir zeroes, the
queue freezes for ~120s, the watchdog fires a synthetic 502
(RATE_LIMIT_QUEUE_WEDGED), the connection cools down and gets excluded
from weighted combo pools -- turning a configured 70/30 split into
~50/50.

Add applyLimiterSettings(), a module-local wrapper around
limiter.updateSettings() that nulls the stale heartbeat reference and
re-invokes _startHeartbeat() afterward so it takes the "start a fresh
interval" branch again. Route all 5 updateSettings() call sites through
it (updateAllLimiterSettings, both updateFromHeaders() branches,
loadPersistedLimits(), and updateFromResponseBody()). updateAllLimiterSettings
is now async and awaited by its two callers (initializeRateLimits,
applyRequestQueueSettings); the sync call sites use the existing
trackAsyncOperation() fire-and-forget tracking pattern.

tests/integration/combo-matrix/weighted.test.ts is the E2E proof: the
"weighted: 70/30" case now passes with zero WEDGED/RATE_LIMIT_QUEUE/502
log lines across 200 sequential requests (previously the wedge/recovery
cycle inflated its runtime and skewed the distribution toward ~50/50).

Refs #8213

* fix(tests): remove stray TDD probes committed by accident in f4e93f339d

Three TDD repro/probe test files landed on the release tip via
f4e93f339d (docs: add management authentication terminology guide,
files from a worktree. Each file is a pre-fix TDD probe that belongs
to a *different*, still-in-flight fix branch/PR and duplicates a file
path that PR already owns and will properly update on merge:

- tests/unit/authz/probe-9033-repro.test.ts: probe for #9033 (IP
  blacklist direct-connection bypass). 3/4 asserts fail against this
  tree (D1, D2, Bonus — all assert the not-yet-implemented target
  behavior); D3 passes (pre-existing behavior). Owned by PR #9385
  (open, unmerged), which modifies this exact path.
- tests/unit/repro-8522.test.ts: probe for #8522 (absolute file-size
  baseline reds innocent PRs on inherited drift). First test fails
  against this tree's evaluateFileSizes (still absolute-only); second
  (sanity: real growth still flags) passes. #8522 is actually CLOSED
  upstream — PR #9355 merged the real fix into release/v3.8.50 today
  (2026-08-05T15:53Z) modifying this exact path — but this branch's
  merge-base with release/v3.8.50 (6b0e11e378) predates that merge,
  so the fix has not synced into this tree yet.
- tests/unit/repro-8956.test.ts: probe for #8956 (resolveProjectRoot
  stops at synthetic Next.js standalone package.json). First test
  fails against this tree; second (sanity: named package.json still
  resolves) passes. Owned by PR #9354 (open, unmerged), which
  modifies this exact path.

Each deleted file's real implementation + passing version already
exists in its owning PR and will land normally through that PR's own
merge — deleting the premature copy here does not lose any coverage.

No config/quality/test-masking-allowlist.json entry was added: the
_deletedWithReplacement schema only supports `replacement` (a test
file that must already exist in this tree's HEAD — none does, the
real versions live in the unmerged sibling PRs above) or `sourceRemoved`
(production files that must be absent from HEAD — they are not, none
of the three issues are implemented in this tree). Neither shape fits
an "owned by an in-flight sibling PR" deletion, so the CI test-masking
gate will flag these 3 deletions for mandatory human review on this
branch's next PR diff against release/v3.8.50 — flagged for the owner
rather than inventing a new allowlist shape.

Refs #9033, #8522, #8956, #7786

* fix(tests): align 8189-classifier-compat with #9276 always-mode semantics

tests/unit/8189-classifier-compat-auto-narrow.test.ts was a test-sibling
forgotten when #9276 (commit 6b531fbacd) removed the unconditional
`if (mode === "always") return true` branch from
shouldDefaultAllowClassifier(). tests/unit/claude-classifier-compat.test.ts
was updated in that same commit; this file was not.

Old contract: 'always' mode short-circuited every Claude-format request
unconditionally (operator opt-in was treated as sufficient on its own).
New contract: 'always' now requires the same SECURITY_MONITOR_MARKER
system-prompt text as 'auto' — the marker-optional behavior let a normal
chat request through /v1/messages be silently swallowed by an operator's
'always' opt-in.

The single 'always' test (1 assert, no-marker body expecting true) is
replaced by two tests mirroring the depth already used for 'auto' mode
in the same file: no-marker/false and marker-present/true. Net effect is
+1 assert, not a reduction — the new pair verifies both directions of
the narrowed contract instead of only the now-incorrect unconditional
case.

Before: 3/4 pass (the 'always' test failed: expected true, got false).
After: 5/5 pass.

Refs #9276

* fix(tests): align deepseek-web-tools-execute with #9343 tool envelope contract

tests/unit/deepseek-web-tools-execute-2820.test.ts (executor level) was a
test-sibling forgotten when #9343 (commit d969555417) hardened tool-call
parsing: bare JSON with no explicit <tool>/<tool_call> envelope is never
promoted to tool_calls anymore (previously it was, whenever a tools[] set
was requested — a security gap allowing prose/code-fenced JSON echoed
back by the model, or a copy-attack, to trigger real tool execution).

Three siblings were updated in the same commit: web-tools-translation.test.ts
and web-tools-translation-2820.test.ts (parseToolCallsFromText, the shared
translator), and deepseek-web-tools-variants.test.ts (parseDeepSeekToolCalls,
deepseek-specific parser) — all inverted their bare-JSON assertions to
`toolCalls === null` + `content === text` (preserved verbatim, not stripped).
This file calls the executor's execute() (full HTTP round trip through
buildToolAwareResult), so it was not touched by that diff and kept
asserting the old contract (finish_reason: "tool_calls", content: null).

Verified against source (open-sse/executors/deepseek-web.ts
buildToolAwareResult): when parseDeepSeekToolCalls returns toolCalls=null,
hasCalls is false, so finish_reason is "stop", message.tool_calls is never
set, and message.content is the parser's returned content — which for text
with no <tool>/<tool_call> tag at all is the original string, unchanged
(parseToolCallsFromText's early-return branch). The test now asserts
exactly that shape, at the same executor level as the rest of the file's
tool_calls that make sense at that level as the rest of the file's tool_calls
Refs #9343

* fix(tests): align visionBridge tests with #8430 contract (partial — see note)

Two test-siblings were forgotten when #8430 (commit 7e55abbc41) hardened
Vision Bridge's vision-model selection: getBestVisionModel() now validates
that a candidate has a usable active connection (hasUsableCredentialsForModel,
DB-backed) before returning it, instead of unconditionally returning the
fixedModel or a hardcoded "openai/gpt-4o-mini" default. Three siblings were
updated in the same commit (visionBridgeRouter.test.ts, the new
repro-8430.test.ts, vision-bridge-preserve-on-failure-4012.test.ts); these two
were not.

tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts (8 failures,
all "No vision-capable provider connected"): callVisionModel()'s `routerConfig`
param only merges into getBestVisionModel's CONFIG argument, never its `deps`
argument, so there is no way to inject a credentials stub through this
function's public signature (unlike the guardrail class and getBestVisionModel
itself, which do accept an injectable `hasUsableCredentials`). These tests
exercise callVisionModel's own request/response handling, not credential
routing (already covered elsewhere), so the fix seeds one real usable
`provider_connections` row per provider the file exercises (openai, anthropic)
via createProviderConnection in a test.before() hook, with resetDbInstance()
in test.after() per the DB-handle-cleanup convention. All 8 now pass.

tests/unit/guardrails/visionBridge.test.ts (7 failures): 1 of the 7 (VB-S03)
is a genuine forgotten-contract case, fixed here — same semantic flip already
applied to vision-bridge-preserve-on-failure-4012.test.ts: in the combo
describe path, when EVERY describe call fails, the raw image is now replaced
with an "(unavailable)" stub instead of preserved, because that path is only
reached for confirmed non-vision targets. Assertions inverted to match
(imagePart undefined, unavailable-stub present), same assert count, no
weakening.

*** THE OTHER 6 (VB-S12, VB-S12b, VB-S01, VB-S13, VB-S07, VB-S10) ARE
DELIBERATELY LEFT FAILING. *** These are NOT a #8430 contract change — root-
caused to what looks like a separate, unintentional regression: the ONE call
to getBestVisionModel() in visionBridge.ts's whole-request-reroute path
(line 244, `getBestVisionModel({ fixedModel: configuredModel })`) does not
pass a `deps` second argument, so it always uses the real DB-backed
hasUsableCredentialsForModel instead of this.deps.hasUsableCredentials — even
though the two adjacent checks in the very same function (`checkCreds(model)`
at line 226, `checkCreds(bestModel)` at line 246) DO honor the injectable
override. In this suite's empty-but-readable isolated test DB, that real
check deterministically returns `false` (not the indeterminate `null` the
file's own createGuardrail() comment says these tests rely on: "Fail-open
(null) so classic VB-S01/S07/S10 reroute tests keep working without a live
credential DB"), so getBestVisionModel silently returns null, the reroute
branch's `if (bestModel && ...)` guard never fires, and every test that
expects a reroute observes a silent no-op instead.

Evidence this is a source gap, not a test that needs updating:
- The file's own pre-existing comment names VB-S01/S07/S10 as tests the
  `null` fail-open default is SUPPOSED to keep green.
- VB-CRED-01/02 (the file's only two tests that actually inject a non-default
  hasUsableCredentials mock) both pass today, but neither one's assertions
  distinguish "mock honored" from "mock ignored, real check also says no" —
  they don't prove the threading works, they just don't happen to notice it's
  missing.
- visionBridgeRouter.test.ts, repro-8430.test.ts, and
  vision-bridge-preserve-on-failure-4012.test.ts (22 tests, all green) all
  either call getBestVisionModel directly with explicit deps, or mock
  callVisionModel wholesale (bypassing getBestVisionModel entirely) — none of
  them exercises this exact call site through the guardrail's own deps.

Per instructions, this was intentionally NOT "fixed" by weakening these 6
tests' assertions (that would mask the gap) or by seeding fake DB credentials
to route around it (that would hide a real production DI inconsistency behind
a test-only workaround) or by touching src/lib/guardrails/visionBridge.ts
(a production behavior change outside a test-alignment task's scope, and
Hard Rule #18 requires its own TDD/validation cycle). Flagging for the owner:
the likely one-line fix is threading `{ hasUsableCredentials:
this.deps.hasUsableCredentials }` as getBestVisionModel's second argument at
visionBridge.ts:244, mirroring the two adjacent call sites in the same
function.

Before: 15 failures (7 + 8). After: 9 pass added (1 + 8), 6 still fail
(unchanged, by design).

Refs #8430

* feat(quality): add strayFromCommit deletion allowlist form to test-masking gate

The deletion allowlist supported two shapes: replacement (test rewritten
elsewhere) and sourceRemoved (feature deleted). Neither fits a third
legitimate case surfaced today: test files that entered the repo BY
ACCIDENT — commit f4e93f339d (#7786 docs) swept another session's
worktree artifacts into the release, including TDD probes owned by open
fix PRs (probe-9033-repro -> PR #9385, repro-8956 -> PR #9354,
repro-8522 -> PR #9355). Those probes fail by design until their owning
PR merges, so every unit run on the release tip broke on them.

The new strayFromCommit form is verified, not trusted: the gate asks git
which commit actually ADDED the file (git log --diff-filter=A) and only
exempts the deletion when it matches the declared hash; a non-empty
reason naming the owning PR/issue is mandatory. Also allowlists the
deepseek-web-tools-execute assert reduction (23->21) from ed661f2126 —
same #9343 contract-inversion class as the existing
web-tools-translation entry.

Gate unit tests: 55/55 pass. Full gate vs main: OK.

* fix(guardrails): pass credential deps to getBestVisionModel at reroute call site

The individual-model reroute path in VisionBridgeGuardrail.preCall() calls
getBestVisionModel({ fixedModel: configuredModel }) without its second
`deps` argument, so the router always falls back to the real DB-backed
hasUsableCredentialsForModel instead of an injected
`deps.hasUsableCredentials` override. The two adjacent credential checks in
the same function (the original-model check and the best-model check,
both via the local `checkCreds` binding) already thread deps correctly —
only this middle call, added in #8430, was left out.

Pass the same resolved `checkCreds` used by those two adjacent checks as
`getBestVisionModel`'s deps argument so all three credential checks in this
reroute path stay consistent.

Fixes 6 tests in tests/unit/guardrails/visionBridge.test.ts that depended
on the injected hasUsableCredentials mock being honored on this path:
VB-S12, VB-S12b, VB-S01, VB-S13, VB-S07, VB-S10.

Refs #8430

* fix(quality): raise unit ceiling to 100min and align 2 more forgotten sibling tests

Unit ceiling 45->100min: a hermetic-env measurement on the loaded devbox
(load 7-26) was still inside invocation 1 of 3 at 76min when killed;
contention factor 2-3x measured, no idle measurement exists. The
pre-flight's real condition is exactly that contended one (unit runs in
Promise.all with integration+vitest), and there 45min provably killed a
healthy suite and fabricated a false base-red. The 45min value came from
v3.8.43 as an estimate never validated by measurement. TODO in-code:
re-tighten after an idle run on the .113 box.

Also aligns the 6th and 7th occurrences of the same systemic pattern
(behavior change merged updating only part of the sibling tests):
- issue-7859-gemini-web-redirect-valid: #9407 refined ServiceLogin
  redirects to mean expired session; the #7859 regression coverage is
  preserved via a non-ServiceLogin public redirect variant.
- provider-validation-specialty claude-web 429: #9406 inverted the
  contract (rate-limited session is unhealthy); the dedicated repro file
  owns the full contract, this sibling now matches it.

Also carries the file-size rebaseline for #9323's base.ts growth
(1578->1623, WAF retry + burst guard) and the eslintWarnings baseline
tightened 5000->0 (real measured value with the TS7 suppressions in
place — 5000 left the ratchet inert).

Refs #9407, #9406, #9323

* fix(tests): restore the 3 TDD probes now owned by merged fixes and drop their stray allowlist entries

The base advanced while this PR was open: the real fixes for the three
issues behind the stray probes all merged into release/v3.8.50 —
#9385 (issue 9033), #9355 (issue 8522) and #9354 (issue 8956).

- probe-9033-repro / repro-8522: the base rewrote both probes into the
  regression tests of their merged fixes, so the delete side of the
  rebase conflict was dropped and the base versions kept.
- repro-8956: #9354 only realigned one fixture line in
  auto-update.test.ts (package.json marker now needs a name field) and
  added no test for the new skip-synthetic behavior — the probe is the
  ONLY regression coverage of that merged fix (2/2 green on the base),
  so deleting it would remove real coverage. Restored.

With no test-file deletions left in the PR diff, the three
strayFromCommit allowlist entries are stale and removed. The
strayFromCommit form support in check-test-masking.mjs stays (covered
by its own fixtures).

* fix(quality): rebaseline file-size for PR #9529 own growth

The base sits exactly at the old frozen values, so the base-relative
mode (#8522) does not cover this growth — it is this PR's own:

- open-sse/services/rateLimitManager.ts 1060->1105: the
  applyLimiterSettings() helper that re-arms the reservoir heartbeat
  after updateSettings (Bottleneck 2.19.5 fix, TDD in
  ratelimit-reservoir-refresh.test.ts).
- tests/integration/chat-pipeline.test.ts 1592->1598: codex User-Agent
  derived from getCodexClientVersion() instead of a pinned literal.
- tests/unit/provider-validation-specialty.test.ts 2980->2985: new
  claude-web 429 -> valid:false coverage (#9406).

* fix(docs): sync provider count to 291 in README and CLAUDE

The live catalog counts 291 providers but README.md/CLAUDE.md still
said 290, so the STRICT 'Docs Gates (fast-path)' check reds EVERY open
PR against release/v3.8.50 (verified on #9537/#9539 as well — inherited
base-red, not introduced by this PR). Updated all provider-count
mentions including the section anchor.

* fix(tests): align launch-codex 6312 guard with the async #9454 spawn contract

#9454 made resolveCodexSpawn async (PATH-probes a native codex.exe before
the .cmd shim) and updated its own tests, but left this older sibling
calling the function synchronously — destructuring the Promise yields
undefined and reds Unit fast-path (1/4) for EVERY open PR against the
release (verified on #9537/#9539; inherited base-red). Realigned to the
async contract with an injected probe; keeps the original #6312 fallback
guard plus the only non-Windows codex coverage (now also asserting the
probe never runs off Windows).

* fix(translator): move state-mutating reasoning summary helper out of the pure leaf

#9500 added buildResponsesReasoningSummaryDelta(state, ...) to
pureHelpers.ts, but the function reads AND mutates stream state
(reasoningSummaryIndex map) — violating the leaf contract declared in
the file header ('no host imports, no stream state') and guarded by
response-openai-responses-purehelpers-split.test.ts, which reds Unit
fast-path (4/4) for every open PR (inherited base-red, verified on
#9537/#9539). Moved verbatim to the host next to the other stream-state
helpers (markResponsesReasoningDeltaEmitted); the host was its only
consumer. Behavior unchanged: repro-9500-reasoning-separator 3/3 green,
leaf/host architecture tests green.

* fix(quality): rebaseline openai-responses.ts for the leaf-state relocation

The #9500 helper moved from pureHelpers.ts into the host (previous
commit) grows the host file 1174->1204 while the leaf shrinks by the
same amount — net-zero LOC across the pair, but the per-file frozen
ratchet only sees the growing side.

* fix(tests): let the 9442 cert-mode test see past the harness trust-store guard

tests/_setup/isolateDataDir.ts sets OMNIROUTE_SKIP_SYSTEM_TRUST=1
globally, which makes installCert() return before issuing any command —
so the #9442 install-gap test captured nothing and could NEVER pass
under npm run test:unit (it only passed invoked directly, harness-less;
inherited base-red on Unit fast-path 3/4, verified on #9537/#9539).
Clear the flag for this file only (restored in test.after): safe because
every spawned command is a logging stub on PATH and OMNIROUTE_NO_SUDO=1
strips sudo, so nothing touches the real trust store. 6/6 under the CI
harness including system-trust-test-guard.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-05 22:52:50 -03:00
Diego Rodrigues de Sa e Souza
3a1e42d985 fix(nvidia): normalize tool names and call IDs (#9236)
Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates — only pre-existing audit.test.ts flake).
2026-08-05 22:41:06 -03:00
Diego Rodrigues de Sa e Souza
5cf9a33d85 feat(usage): surface Claude thinking token counts to clients (#9214)
Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates — only pre-existing audit.test.ts flake).
2026-08-05 22:40:19 -03:00
Prudhvi Vuda
c485c5e0a5 fix(sse): preserve Gemini thought_signature on Claude Desktop tool turns (#9015)
Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates: typecheck/complexity/cognitive/changelog/vitest — only pre-existing audit.test.ts flake). Evidence: /home/diegosouzapw/dev/proxys/OmniRoute/.claude/worktrees/merge-train-20260805-222248-suite.log
2026-08-05 22:32:09 -03:00
Diego Rodrigues de Sa e Souza
0a0fdad001 fix(translator): join reasoning summary segments with newline separators (#9500)
Closes #9500
2026-08-05 16:49:37 -03:00
Xiangzhe
455906c181 fix(reasoning): forward Ollama Cloud thinking (#9290) 2026-08-04 14:34:02 -03:00
Austin Liu
f93da3f5ca Train 1D: merge via --admin on .113 validation
Squash merge from local merge-train (Hard Rule owner-approved). Tip 029cdf4215cf465f0e1716ac9f84a84692b1e881 validated on 192.168.0.113: 26631/26653 pass.
2026-07-27 11:29:38 -03:00
Markus Hartung
11f7ba2c72 fix(sse): record tool calls into shared state for openai->openai-responses call-log summary (#8462)
The openai-responses translator tracked tool calls in its own funcCallIds/
funcNames/funcArgsBuf bookkeeping without ever writing to the shared
state.toolCalls Map that stream.ts's completion-log summary builder reads.
Every openai->openai-responses translated stream with a tool call was
persisted with finish_reason "stop" and no tool_calls, even though the
actual SSE events sent to the client were correct.
2026-07-25 02:52:49 -03:00
Markus Hartung
ebe086ebb5 fix(sse): surface OpenRouter mid-stream error chunks instead of a false empty success (#8210)
* fix(sse): surface OpenRouter mid-stream error chunks instead of a false empty success

OpenRouter (and similar OpenAI-compatible aggregators) can send an HTTP 200
SSE stream whose body carries a chat.completion.chunk with empty choices and
a top-level error object instead of any delta -- e.g. the underlying
provider hitting its own capacity limit mid-request. The Responses-API
response translator's `!chunk.choices?.length` branch treated this exactly
like a legitimate trailing-usage/no-op chunk, so the stream silently ended
with response.completed / error: null / output: [] -- a false "successful
but empty" response masking a real 502 provider_unavailable failure.

Live repro: request 1784726796287-a45bb3 (OpenClaw via the default combo,
nvidia/nemotron-3-ultra-550b-a55b:free via OpenRouter) got HTTP 200 with 0
tokens in/out after Nvidia returned "Worker local total request limit
reached (33/32)" mid-stream; the client saw an empty completed response
with no indication anything failed.

Mirrors the Gemini mid-stream error fix (#4177): set state.upstreamError
from the in-band error chunk so stream.ts's existing upstreamError handling
takes over (sendCompleted emits status: "failed" with a real error object).

Co-Authored-By: Markus Hartung <markus.hartream@gmail.com>

* chore(quality): file-size baseline for own-growth (#8210)

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

---------

Co-authored-by: Markus Hartung <markus.hartream@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 05:14:14 -03:00
Markus Hartung
4ea08f520b fix(sse): Gemini malformed function-call handling + tool_choice translation (#8211)
* fix(sse): synthesize tool_calls for Gemini's malformed function-call abort reasons

Live incident (dashboard log id 1784489701456-d8c0e9): Gemini terminates a
stream with finishReason MALFORMED_FUNCTION_CALL/UNEXPECTED_TOOL_CALL when its
own parser rejects an attempted tool call — there's no real functionCall part,
only a human-readable finishMessage. gemini-to-openai.ts passed this through
raw as finish_reason (9router#2462's fix, correctly keeping it off a clean
"stop"/Claude end_turn), but a raw "malformed_function_call" isn't one of
OpenAI's 5 documented finish_reason values, so a real OpenAI-format client
(OpenClaw) has no handling for it at all and silently never notices the turn
failed — confirmed live via tests/integration/live-gemini-workload.test.ts's
[28] streaming case after the Gemini TPM/rebase work on this branch.

Fix: synthesize a tool_calls entry (arguments carry the error code + Gemini's
finishMessage, valid JSON) and finish_reason: "tool_calls" instead, routing
the failure into the ordinary "tool call arguments didn't parse" path every
OpenAI-compatible agent loop already handles. Defers to a real tool call if
one already completed earlier in the same turn — the real call wins, no
synthetic entry piles on top of it.

Tests (TDD, each confirmed red-before-green):
- 5 new unit tests in the existing 9router#2462 regression file, covering the
  synthesis itself, UNEXPECTED_TOOL_CALL, the real-tool-call-wins edge case,
  and no-regression on a clean STOP.
- New fixture (tests/fixtures/translation/gemini-malformed-function-call-stream.json):
  the real 6-chunk event series from the live incident, sanitized (personal
  paths/URLs replaced with generic placeholders, structure preserved exactly).
- New integration test chains the real translator into the real Responses API
  transformer using that same fixture, proving correct behavior on BOTH
  /v1/chat/completions and /v1/responses from one shared ground-truth event
  series.

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

* fix(sse): don't drop a malformed tool-call failure when it lands beside a real one

Live incident (dashboard log id 1784589106014-2a42f8), analyzing why the
prior malformed-function-call fix (3568c7259) still wasn't reaching the
client in this case: Gemini can emit a REAL, valid functionCall AND finish
the SAME candidate with MALFORMED_FUNCTION_CALL — the model attempted
multiple tool calls in one turn (here: a real status-check call plus a
malformed "exec"+"cron" multi-call attempt), one parsed cleanly and the
other didn't.

The first fix version skipped synthesizing a failure signal whenever a real
tool call already existed (state.toolCalls.size > 0), on the assumption
that meant the model was retrying a LATER, separate attempt after an
earlier one already succeeded. That's indistinguishable, from the
translator's state, from this same-turn case — so it silently discarded
the malformed attempt's information entirely: the client saw the real call
succeed and never learned the other tool calls were attempted and rejected.

Fix: always synthesize the failure entry when a malformed abort reason is
seen, appending it alongside any real tool call rather than skipping it.
Multiple tool_calls in one response is normal, well-supported OpenAI
behavior (parallel tool calls), so this adds the failure as an additional
entry instead of replacing or hiding the real one.

Tests (TDD, confirmed red-before-green):
- Rewrote the unit test that encoded the old (wrong) assumption to assert
  both the real and synthesized calls are present.
- New fixture (gemini-malformed-function-call-parallel-real-call-stream.json):
  the real event series from this incident, sanitized.
- New integration tests (same file as 3568c7259's) prove both
  /v1/chat/completions and /v1/responses surface both tool calls correctly
  from this fixture.

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

* feat(sse): honor tool_choice when translating OpenAI requests to Gemini

Investigating a live report that gemini-3.1-flash-lite frequently narrates
an intended tool call in plain text instead of actually emitting one
(dashboard log id 1784591483850-49c408 — 9 raw provider chunks, all plain
text, zero functionCall parts, clean finishReason STOP): body.tool_choice
was never read anywhere in the OpenAI->Gemini request translator.
result.toolConfig was unconditionally hardcoded to
{ functionCallingConfig: { mode: "VALIDATED" } } whenever tools were
present, regardless of what the caller sent. VALIDATED lets the model
respond with plain text OR a schema-validated function call at its own
discretion — it never forces a call the way OpenAI's tool_choice:
"required" (Gemini's ANY mode) does, so a caller had no way to compel a
tool call even when explicitly requesting one.

Added convertOpenAIToolChoiceToGemini(), mirroring the existing
convertOpenAIToolChoice() in openai-to-claude.ts for the same OpenAI
tool_choice shapes (string "auto"/"none"/"required", or
{type:"function",function:{name}} to force one specific tool):
  - unset/"auto"        -> VALIDATED (unchanged default, no regression)
  - "required"/"any"    -> ANY (forces a call)
  - "none"              -> NONE (disables function calling)
  - {type:"function",...} -> ANY + allowedFunctionNames: [name]

Wired into both Gemini request paths: the direct/base translator
(openaiToGeminiBase) and the Antigravity/Cloud Code envelope
(wrapInCloudCodeEnvelope), which now reuses the base translator's already-
computed toolConfig instead of re-deriving its own hardcoded VALIDATED.

This unblocks (but does not itself resolve) the live question — a
tool_choice: "required" A/B test against gemini-3.1-flash-lite follows to
confirm ANY mode actually changes the narrate-vs-act behavior in practice.

Also updates the T11 any-budget allowlist for this file: the "any" string
comparisons (tool_choice value "any", not a TypeScript type) are the same
documented false-positive pattern already carved out for executors/base.ts.

Tests (TDD, confirmed red-before-green): 9 new unit tests covering all
tool_choice shapes on both the direct and Antigravity/Cloud Code paths,
plus the no-tools and unset-default no-regression cases.

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

* chore(quality): file-size baseline for own-growth (#8211)

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

---------

Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 05:03:44 -03:00
Austin Liu
07dada6b81 fix: strip internal reasoning placeholder from user-visible content (#8081) (#8162)
* fix: strip internal reasoning placeholder from user-visible content (#8081)

The internal reasoning replay sentinel '(prior reasoning summary unavailable)'
can leak into user-visible assistant content when a model echoes it through
ordinary message.content / delta.content. Existing suppression only checked
reasoning_content fields and reasoning-specific events.

Changes:
- Add stripInternalReasoningPlaceholder() to reasoningPlaceholder.ts —
  removes all occurrences of the sentinel and trims; returns '' when
  nothing meaningful remains
- Streaming: strip in responsesTransformer.ts, openai-responses.ts, and
  openai-to-claude.ts at the delta.content entry point; skip emission
  entirely when only the placeholder was present
- Non-streaming: strip in responseSanitizer.ts sanitizeMessageContent()
  and sanitizeResponsesMessageContent() (all three text paths)

translateText is unaffected (uses mode='translate' via plain newsClient).
The per-provider reasoning_content check remains as defense-in-depth.

* fix: skip only empty content block on reasoning-placeholder, keep finish_reason/tool_calls (#8081)

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

* chore(quality): rebaseline openai-responses.ts own-growth (#8081 guard)

---------

Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local>
Co-authored-by: Probe Test <probe@example.com>
Co-authored-by: Dingding-leo <Dingding-leo@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-22 19:04:51 -03:00
RCrushMe
89bad0fa52 fix(responses): close namespace round-trip for Responses-Chat translation (#7936) (#8151)
* fix(responses): close namespace round-trip for Responses-Chat translation (#7936)

The #7905 custom-tool-call path landed in release/v3.8.49 but left #7936
open: Responses namespace sub-tools were flattened to a bare leaf on the
Chat wire with no response-side closure, so Codex's adjudicator rejected
every namespace sub-tool call with `unsupported call` -- it only has a
dispatch entry for the header bits (namespace+name), no entry for the
bare leaf.

This patch closes the round-trip without mutating the Chat wire name
(alignment with #7905's bare-leaf contract and with the issue author's
proposed fix):

* request side (openai-responses.ts): keep tool.function.name as the bare
  leaf, populate a side-band namespaceToolIdentityMap keyed on that leaf,
  and thread it through translatedBody._toolNameMap.
* request -> response seam (chatCore.ts): extract the identity map before
  dispatch and pass it through to the non-stream completion path and to
  all three stream pipelines (translate openai-responses, translate
  other, passthrough).
* response translator (response/openai-responses.ts): in emitToolCall
  (response.output_item.added) and closeToolCall (custom_tool_call /
  function_call output_item.done), call resolveRequestToolIdentity() to
  rewrite the bare leaf back to {namespace,name} and emit codex-compatible
  independent fields.
* passthrough (utils/stream.ts): add a response passthrough rewriter
  restoreResponsesPassthroughFunctionCallIdentity that intercepts
  response.output_item.added, response.output_item.done, and
  response.completed and stamps the same {namespace,name} tuple.
* helper (requestToolIdentity.ts): a 20-line stateless resolver; never
  parses a name.

The wire-visible Chat tool.function.name stays the bare leaf -- non-OpenAI
providers (NVIDIA, GLM, Kimi, Gemini, ...) frequently truncate or rewrite
long __-dotted names; bare leaves avoid that failure mode entirely. The
codex ResponseItem::FunctionCall schema (models.rs) declares an
independent namespace: Option<String> field and has a
function_call_deserializes_optional_namespace round-trip test, so
emitting it separately matches the codex adjudicator dispatch.

Includes 19 new test cases across 4 files:
- request-side bare-leaf wire + side-band ledger construction
- response-side tuple emit + unmapped passthrough + apply_patch exclusion
- ambiguous-leaf collision safety (entry dropped, leaf emits verbatim)
- per-request isolation between concurrent streams
- a precompiled Atlassian-style nested namespace override

* fix(responses): skip tool_search_call input items instead of 400 (#7936 addendum)

Codex 0.42+ emits `tool_search_call` (and later `tool_search_result`) input
items when the model uses the dynamic tool-search optimization. They are
metadata-only: they record that the model queried a subset of the
available tools, and carry nothing that OpenAI Chat Completions can
represent.

Without an explicit skip in openai-responses.ts, the input loop threw
Unsupported Responses API feature: input item type 'tool_search_call'
cannot be represented in Chat Completions -- and because these items
stay in the Responses API `input` for every follow-up turn, the whole
server returned 400 on EVERY subsequent /v1/responses in the same
session until the user cleared history.

Observed in the wild:
  /v1/responses 400 "Unsupported Responses API feature: input item
  type 'tool_search_call' cannot be represented in Chat Completions
  [longcat/LongCat-2.0 (400), longcat/LongCat-2.0 (400)]"

The meituan combo (longcat fallback) was the most visible victim, but
the underlying throw is source-format-side and hits any Responses-API
consumer whose upstream does not natively support Responses.

Fix: stop on the item type the same way `reasoning` is skipped --
display-only metadata, no chat side-effect. Covers both
`tool_search_call` and the follow-up `tool_search_result` shapes.

Adds 3 unit tests:
  - tool_search_call is silently skipped (no 400)
  - tool_search_result is silently skipped
  - tool_search_call items interspersed with real messages are skipped
    in order; real messages survive

* chore(quality): rebaseline openai-responses.ts + stream.ts own-growth (#7936 namespace round-trip)

---------

Co-authored-by: TonPro <hello@tonpro.fu>
Co-authored-by: RCrushMe <RCrushMe@users.noreply.github.com>
2026-07-22 17:38:30 -03:00
Navaneet Ramabadran
eab59d4048 fix(translators): normalize TitleCase tool names for non-Anthropic models (#7926)
* fix(translators): normalize TitleCase tool names for non-Anthropic models

* fix(translator): parse XML <invoke> blocks in OpenAI→Claude response translator

* fix(gemini-web): mark gemini-3.1-flash-lite as toolCalling: false

* fix(gemini-web): mark all models as toolCalling: false, add to no-tools combo

* fix(web-providers): mark all web-cookie models as toolCalling: false

* fix(nvidia): disable tool calling on models that can't handle it

* fix(lint): drop explicit any annotation on extractXmlInvokeBlocks state param

Removes the no-explicit-any violation introduced alongside the TitleCase
tool-name normalization work so the merged branch stays lint-clean
(state stays implicitly typed like its sibling helpers in this file).

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

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-21 13:40:57 -03:00
Jan Leon
c1bdd91e7b Hide internal reasoning replay placeholders (#7912)
* fix: hide internal reasoning replay placeholder

* fix: hide internal reasoning replay placeholder in OpenAI→Claude translation too

Mirror the isInternalReasoningPlaceholder() guard already applied to the
Responses-API and OpenAI-Responses reasoning paths in
openaiToClaudeResponse() (open-sse/translator/response/openai-to-claude.ts).
The internal reasoning-replay sentinel was still leaking into the Claude
"thinking" content block on this translation path.

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

* test: close both-add merge of #7912 and #7905 reasoning/tool tests

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

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-20 22:52:54 -03:00
Jan Leon
6b59e814da Restore Responses API custom tool calls (#7905)
* fix: restore Responses custom tool calls

* fix: preserve nested Responses custom tool calls

* fix: reset superseded tool call state

* fix: preserve tool precedence and buffered Responses tool arguments

* test: verify top-level tool descriptions take precedence

* fix: preserve custom Responses tool streaming semantics

* test: cover declared custom tool streaming round trips

* test: cover Responses custom tool metadata collection

* test: cover active Responses custom tool stream

* test: isolate Responses active stream regression

* fix: complete Responses custom tool round trips
2026-07-20 22:22:24 -03:00
CitrusIce
747bce2f10 [defer] fix(grok): align responses tool-call shape for grok models (#6937)
* fix(grok): align responses tool-call shape for grok models

* test(grok): cover responses tool call indexes

---------

Co-authored-by: minisforum <no@mail.com>
2026-07-19 20:52:28 -03:00
Erick Kinnee
d6e86f413b fix(translator): synthesize tool call chunks from response.completed batched output (#7613)
* fix(translator): synthesize tool call chunks from response.completed output[]

When an upstream provider sends a batched response.completed event carrying
function_call items in its data.response.output[] array — without having
sent the individual response.output_item.added / .delta / .done events —
the state variables toolCallIndex and currentToolCallId were never set,
causing computeFinishReason to return 'stop' instead of 'tool_calls'.

This broke the agent loop for downstream Chat Completions clients
(OpenCode, Hermes, etc.) when routing through providers that batch their
output into the completed event.

Fix: parse data.response.output[] for function_call items in the
response.completed handler, synthesize the tool call header + arguments
delta chunks, advance state, and emit finish_reason: 'tool_calls'.

Also updates withAssistantRoleOnFirstDelta to handle array results.

Fixes #180, #3980
Refs: https://github.com/diegosouzapw/OmniRoute/issues/180
Refs: https://github.com/diegosouzapw/OmniRoute/issues/3980

* fix(translator): guard against double-emission for incrementally-streamed tool calls

Add a guard that skips response.completed synthesis for call_ids already
tracked via incremental output_item.added/.done events. Without this,
providers that stream incrementally AND echo function_call items in the
response.completed output[] snapshot get duplicate tool call chunks.

Also adds a regression test combining both incremental events and a
response.completed snapshot in the same turn.

Refs: diegosouzapw/OmniRoute#7613

* chore: add docker-compose.yml.bak to gitignore

* refactor(translator): extract response.completed synthesis, fix ratchets

Fixes the file-size and complexity/cognitive-complexity ratchet
regressions the dedup-guard commit (6bbff5ea) introduced, so the PR's
own validation block (typecheck, eslint, file-size, complexity,
cognitive-complexity, changelog-integrity, test-discovery) is fully
green, not just its own tests:

- Extract the response.completed batched-tool-call synthesis body into
  a new leaf module
  open-sse/translator/response/openai-responses/synthesizeCompletedToolCalls.ts,
  mirroring this file's own established eventEmitter.ts/toolSchemas.ts/
  pureHelpers.ts extraction pattern, further split internally
  (buildToolCallChunks/buildFinalChunk/resolveArgsStr/baseChunk) to
  keep synthesizeCompletedToolCalls() itself under the complexity/
  cognitive-complexity/max-lines-per-function thresholds.
- computeFinishReason moves alongside it (not into the sibling
  pureHelpers.ts) because it takes stream `state` — pureHelpers.ts is
  guarded by tests/unit/response-openai-responses-purehelpers-split.test.ts
  to have NO state coupling at all.
- DRY the withAssistantRoleOnFirstDelta array/single-result branches
  into a shared setAssistantRoleIfEligible(state, delta) helper — the
  array branch alone pushed this function's cyclomatic complexity to
  16 (over the 15 threshold).
- Split the 5 new response.completed tests out of
  tests/unit/translator-resp-openai-responses.test.ts (which would
  have exceeded the 800-line test-file-size cap) into a new sibling
  tests/unit/translator-resp-openai-responses-completed-synthesis.test.ts.
- Bump the frozen open-sse/translator/response/openai-responses.ts
  file-size baseline for the small irreducible remainder (dedup-guard
  state tracking + call-site wiring) with a justification entry in
  config/quality/file-size-baseline.json.

Independently re-verified red-first (temporarily reintroducing the
pre-guard filter reproduces exactly one failure — the dedup test — no
collateral damage) and confirmed check:complexity-ratchets is back to
exactly baseline (2058/890) and check:file-size is fully green.

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

* refactor(translator): move completed-tool-call glue into module (file-size cap)

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

---------

Co-authored-by: Erick Kinnee <erick@ekinnee.dev>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-18 21:20:01 -03:00
Diego Rodrigues de Sa e Souza
046dad5bee feat(sse): add optional-enum null-omission idiom for codex strict-mode tools (#7023) (#7233)
Codex Responses API strict mode forces every "optional" tool property into
`required`, so a model that intends to OMIT an optional enum property (no
declared `default`, e.g. Agent.isolation: enum["worktree","remote"]) must
still emit a concrete value. Neither of the two ops shipped in #6992
(drop-if-default, generalized drop-if-empty) can catch this: drop-if-default
needs a declared default (none exists); drop-if-empty needs an empty
string/array (the emitted value is non-empty).

Adds a paired request/response transform scoped strictly to
targetFormat === OPENAI_RESPONSES:
- Request side: injectOptionalEnumOmissionSentinel/-ForTools widen no-default
  optional enum properties to accept `null` (OpenAI's own documented
  nullable-union idiom for this exact strict-mode limitation).
- Response side: isDroppableNullEntry drops the key when the model emits
  `null` for a non-required, schema-declared property, reusing the existing
  #6992 toolSchemas plumbing (no new tracking structure needed).
2026-07-17 10:40:25 -03:00
Diego Rodrigues de Sa e Souza
e9f784676d fix(translator): register openai response projection for gemini clients (#7207)
* fix(translator): register openai response projection for gemini clients

The response-translator registry had an OpenAI -> Antigravity response
projection registered, but no OpenAI -> Gemini one. When a client request is
detected as Gemini format (body-shape match on `contents: [...]`, per
detectFormat()) and combo routing lands the request on an OpenAI-native
provider, translateResponse() fell through its hub-and-spoke path with no
`openai -> gemini` translator registered, so the raw OpenAI
`chat.completion.chunk` shape reached the client unchanged instead of the
shared Gemini `response.candidates[]` envelope.

Registers FORMATS.OPENAI -> FORMATS.GEMINI reusing the existing
openaiToAntigravityResponse projection — Gemini and Antigravity already
share the same wrapped `{ response: { candidates: [...] } }` envelope
elsewhere in the pipeline (see the unwrapGeminiChunk callers in
open-sse/utils/stream.ts, which treat FORMATS.GEMINI and FORMATS.ANTIGRAVITY
identically), so no new conversion logic is introduced.

Co-authored-by: W ARELIK <warelik@users.noreply.github.com>
Inspired-by: https://github.com/decolua/9router/pull/2399

* chore(changelog): fragment for #7207

---------

Co-authored-by: W ARELIK <warelik@users.noreply.github.com>
2026-07-17 10:39:35 -03:00
Diego Rodrigues de Sa e Souza
07e1011d3b fix(stream): reconcile encrypted Codex reasoning visibility without mutating upstream item (#7304)
* fix(stream): reconcile encrypted Codex reasoning visibility without mutating upstream item

Resolves the collision between two open PRs on ensureVisibleResponsesReasoningSummary:
#7095 (xz-dev) found that chat clients see nothing when Codex exposes reasoning
only as encrypted_content, and added a visible placeholder — but did so by
mutating item.summary in place. #7176 (JxnLexn) found that same mutation
corrupts the forwarded response item, discarding the encrypted_content shape
Codex needs for follow-up requests, and removed the mutation — but that also
silently dropped the placeholder, so chat clients went back to seeing nothing.

The mutation existed only so a later line could read the summary text back off
the same item. getVisibleResponsesReasoningSummaryText() computes that text
without touching the item, so:
  - synthetic response.reasoning_summary_text.delta / .part.done events still
    carry the placeholder for chat clients (#7095's goal), and
  - the forwarded response.output_item.done payload keeps its original
    encrypted_content intact with no fabricated summary field (#7176's goal).

Applied at both call sites #7095 identified: the native Responses passthrough
in stream.ts/passthroughTailProcessor.ts, and the Responses-to-Chat-Completions
translator in openai-responses.ts.

Closes #7095, closes #7176.

Co-authored-by: Xiangzhe <xiangzhedev@gmail.com>
Co-authored-by: Jan Leon <Jan.gaschler@gmail.com>

* test(stream): guard the encrypted-reasoning mutation via the completed backfill path

The output_item.done line is echoed verbatim on the wire, so a re-introduced
item.summary mutation does NOT surface in that event — verified by re-injecting
the mutation, which left the existing assertion green. The mutation does surface
in the response.completed snapshot, where the captured reasoning item is
re-serialized when upstream sends an empty output (store: false).

Adds that case, which fails as expected when the mutation is re-introduced,
making the #7176 half of the reconciliation an enforced regression guard rather
than an incidental property of the current code path.

Co-authored-by: Xiangzhe <xiangzhedev@gmail.com>
Co-authored-by: Jan Leon <Jan.gaschler@gmail.com>

---------

Co-authored-by: Xiangzhe <xiangzhedev@gmail.com>
Co-authored-by: Jan Leon <Jan.gaschler@gmail.com>
2026-07-15 23:49:22 -03:00
Diego Rodrigues de Sa e Souza
e078664ddd fix(sse): schema-aware optional tool-arg normalization for Codex routes (#6951) (#6992)
stripEmptyOptionalToolArgs was allowlist-only (Read/Subagent) and only
stripped empty-string/empty-array values, so Responses API strict mode
(every property forced into `required`) could forward a forced non-empty
value (e.g. Agent.isolation) or a schema-declared default value verbatim
to the client. Add schema-aware drop-if-default and generalized
drop-if-empty (any tool, gated on schema.required), and thread each
tool's JSON Schema from the request's tools[] into the two streaming
call sites (response.output_item.done handling).

Closes #6951
2026-07-12 18:08:07 -03:00
Diego Rodrigues de Sa e Souza
45d38bc4b2 fix(sse): defer response.completed until trailing usage-only chunk (#6906) (#6965)
Real OpenAI-compatible upstreams with stream_options.include_usage=true
send finish_reason in one chunk (usage: null) and the actual token counts
in a separate, trailing usage-only chunk (choices: [], usage: {...}).
Both the live translator (openai-responses.ts) and the legacy transformer
(responsesTransformer.ts) fired response.completed as soon as they saw
finish_reason, so the trailing usage chunk's token counts were captured
into state but never emitted -- Codex CLI and other /v1/responses
consumers saw response.completed with no usage field (permanent 0%
context-used).

Both translators now defer response.completed via an
awaitingTrailingUsage state flag when finish_reason arrives without
usage already captured, and complete on the next usage-only chunk (or
at stream end via the existing flush fallback) instead. Extracted the
duplicated events/emit boilerplate into a new
openai-responses/eventEmitter.ts leaf to keep the frozen
openai-responses.ts file under its file-size baseline.

Fixes 3 existing tests that encoded the old chunk ordering and adds a
permanent regression test (tests/unit/responses-usage-trailing-6906.ts)
covering both translators.
2026-07-12 10:23:33 -03:00
Markus Hartung
49e0b7d667 fix(responses): escape literal control chars in tool call JSON; emit … (#6786)
* fix(responses): escape literal control chars in tool call JSON; emit status=failed on upstream error #6785

Two bugfixes in the Responses API translator:

1. escapeJsonStringValues() sanitizes tool call arguments containing
   literal 0x0A/0x0D/0x09 bytes (emitted by Gemma4 models) into valid
   JSON \n/\r/\t escapes, preventing SSE framing corruption. Only
   escapes inside JSON string contexts — already-escaped sequences
   and structural JSON pass through unchanged.

2. sendCompleted() checks state.upstreamError and emits status="failed"
   with error.code + error.message instead of silently hardcoding
   status="completed" + error=null, so mid-stream errors (e.g. Gemini
   503 after partial content) are properly surfaced to the client.

3. stream.ts: calls translateResponse(null,...) before controller.error()
   so the translator can emit close events (reasoning item done,
   response.completed) before the stream is terminated.

* test(boundary): fix ESLint no-explicit-any warnings and quality gates

Green the PR against release/v3.8.47 quality gates without weakening tests:

- Replace @typescript-eslint/no-explicit-any in the new boundary/gemma4
  tests with proper interfaces (ResponseBody, ToolDef, ToolArgs, SseEvent
  item accessors) — fixes the "No new ESLint warnings" gate.
- Split tests/unit/translator-resp-openai-responses.test.ts (1079 LOC) by
  extracting the round-trip suite into a sibling file so both stay under
  the 800-line test cap — fixes check:file-size.
- Rename the 5 live boundary tests to *.live.test.ts, gate them behind
  RUN_BOUNDARY_LIVE=1, add a test:boundary:live npm script and register the
  glob in check-test-discovery COLLECTORS — fixes check:test-discovery
  (they hit a live remote and must never run unopted in CI).

Co-authored-by: Markus Hartung <mail@hartmark.se>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-07-12 02:00:14 -03:00
Diego Rodrigues de Sa e Souza
1045e57aa1 feat(codex): echo requested effort-suffixed model id in Responses payloads (#3697) (#6820)
* feat(codex): echo requested effort-suffixed model id in Responses payloads (#3697)

Codex CLI compatibility shim: the Responses API response.created/
response.in_progress/response.completed payloads now carry a `model`
field (previously absent), and for Codex-CLI-originated requests it
echoes the client-requested effort-suffixed model id (e.g.
gpt-5.5-xhigh) instead of the bare upstream id (gpt-5.5), so the Codex
CLI status line/model button shows the active reasoning effort.

- openai-responses.ts translator threads the upstream model into the
  Responses event objects (additive, omitted when unknown).
- New isCodexOriginatedHeaders() (codexIdentity.ts) reuses PR #3481's
  originator/User-Agent detection, header-based so it still fires when
  a combo routes codex/gpt-5.5-xhigh to a non-codex upstream.
- chatCore's existing opt-in #1311 echoModel pipeline now also fires
  automatically for Codex clients on the Responses API, regardless of
  the echoRequestedModelName setting.
- responseModelEcho.ts now also rewrites the nested response.model
  field the Responses API uses (previously only top-level model).
- /v1/models keeps returning models: [] for Codex (unchanged, #3481).

Regression guard: tests/unit/codex-effort-model-echo-3697.test.ts.

Closes #3697

* chore(merge): re-sync with release/v3.8.47 (restore CHANGELOG, keep own bullet)

* chore(merge): re-sync with release/v3.8.47; move changelog bullet to changelog.d fragment (merge-storm proof)
2026-07-11 07:39:09 -03:00
Diego Rodrigues de Sa e Souza
6904484f51 fix(translator): defer content_block_start until GLM streams the tool name (#6730)
* fix(translator): defer content_block_start until GLM streams the tool name (port from 9router#2077)

GLM 5.2 (and similar OpenAI-compatible upstreams) stream a tool call's id and
function.name across separate SSE delta chunks. The openai-to-claude streaming
translator emitted content_block_start immediately on the id-only chunk with an empty
name; the Claude SSE protocol cannot patch a block after emission, so the later
name-only chunk was dropped and Claude Code rejected the tool_use with an empty tool
name / "No such tool available:". Defer content_block_start until the name arrives
(start on args if they arrive first), and emit a start for any orphaned id-only tool
call at finish so content_block_stop is never orphaned.

Reported-by: itiwant (https://github.com/decolua/9router/issues/2077)

* chore(6730): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first)
2026-07-10 19:39:56 -03:00
Diego Rodrigues de Sa e Souza
0969b56951 fix(translator): strip empty cloud_base_branch from Cursor Subagent tool call (#6729)
* fix(translator): strip empty cloud_base_branch from Cursor Subagent tool call (port from 9router#2446)

The Responses->Chat tool-arg cleanup (stripEmptyOptionalToolArgs) only stripped
empty-string/empty-array optional args for Claude Code's Read tool. Cursor's local
Subagent tool call therefore passed through with the cloud-only field
cloud_base_branch: "", which Cursor rejects ("cloud_base_branch may only be specified
when environment equals cloud") before starting the subagent. Extend the cleanup to an
allowlist of Read + Subagent; arbitrary tools stay untouched.

Reported-by: like3213934360-lab (https://github.com/decolua/9router/issues/2446)

* chore(6729): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first)
2026-07-10 19:39:50 -03:00