Compare commits

...

7 Commits

Author SHA1 Message Date
Markus Hartung
d925f6bf73 fix(logging): document CHAT_LOG_MAX_BODY_KB, capture messageCount for Responses API bodies (#10038)
* fix(logging): document CHAT_LOG_MAX_BODY_KB, capture messageCount for Responses API bodies

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

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

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

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

⚠️ base-red inherited: #9985

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

* docs(changelog): record Audio Bridge multipart fix

---------

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

View File

@@ -1449,6 +1449,11 @@ APP_LOG_TO_FILE=true
# CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128)
# CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6)
# CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit)
# CHAT_LOG_MAX_BODY_KB=1024 # Whole request/response body size before it's replaced by a bare
# {_truncated, messageCount, ...} summary instead of the full clone
# (default: 1024 KB / 1MB). Raise this if the dashboard's "Full
# Conversation" transcript panel shows a placeholder instead of the
# actual messages for long agentic conversations.
# Maximum rows in the proxy_logs SQLite table.
# Default: 100000
@@ -2626,10 +2631,6 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# Used by: src/app/api/jobs/[id]/run-now/route.ts. Default: 30000 (30 seconds)
# OMNIROUTE_RUNNOW_TIMEOUT_MS=30000
# Maximum request/response body size before chat-log summarization, in KiB.
# Used by: src/lib/chatLogTruncation.ts. Default: 1024
# CHAT_LOG_MAX_BODY_KB=1024
# Adobe Firefly browser renewal and durable session cache (enabled by default).
# Used by: open-sse/services/adobeFireflySession.ts.
# ADOBE_FIREFLY_BROWSER_REFRESH=1

View File

@@ -0,0 +1 @@
- **Audio Bridge:** fix production transcription self-loop uploads so real audio reaches the configured STT provider instead of falling back to an unavailable-provider stub ([#10229](https://github.com/diegosouzapw/OmniRoute/pull/10229)).

View File

@@ -753,6 +753,7 @@ The logging system writes to both stdout and rotated log files. All configuratio
| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `128` | Number of array items retained from the tail when truncating chat log payloads. |
| `CHAT_LOG_MAX_DEPTH` | `6` | Max nesting depth before chat log payloads are truncated. |
| `CHAT_LOG_MAX_OBJECT_KEYS` | `80` | Max object keys retained in chat log payloads (0 = unlimited). |
| `CHAT_LOG_MAX_BODY_KB` | `1024` | Whole request/response body size (KB) before it's replaced by a bare summary instead of the full clone. Raise this if long agentic conversations show a placeholder instead of the real messages in the dashboard. |
| `CHAT_DEBUG_FILE` | `false` | When true, `serializeArtifactForStorage` skips size-based truncation. Debug only. |
---
@@ -1446,7 +1447,6 @@ These settings were introduced after the previous environment-contract snapshot.
| `OMNIROUTE_CHAT_VIRTUAL_TTL_MS` | `60000` (60 s) | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): idle-lane eviction TTL. |
| `OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). |
| `OMNIROUTE_RUNNOW_TIMEOUT_MS` | `30000` | `src/app/api/jobs/[id]/run-now/route.ts` | Bounds how long a run-now call waits for an in-flight job before starting the queued run. |
| `CHAT_LOG_MAX_BODY_KB` | `1024` | `src/lib/logEnv.ts` | Maximum request or response body size before log summarization, in KiB. |
| `ADOBE_FIREFLY_BROWSER_REFRESH` | enabled | `open-sse/services/adobeFireflySession.ts` | Keeps IMS and browser-risk state fresh through account-scoped Chrome CDP sessions; set `0` to disable. |
| `ADOBE_FIREFLY_SESSION_DISK` | enabled | `open-sse/services/adobeFireflySession.ts` | Persists repaired Adobe sessions under `DATA_DIR`; set `0` for memory-only state. |
| `ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS` | `12000` | `open-sse/services/adobeFireflySession.ts` | Minimum spacing between Adobe Firefly generate submissions. |

View File

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

View File

@@ -1996,6 +1996,24 @@ export async function handleComboChat({
strategy,
target: toRecordedTarget(target),
});
// LKGP (#919) mirror of the success-path set below: a just-failed target
// must not keep re-pinning itself as the "last known good" choice for the
// *next* separate request. Circuit breaker / model lockout deliberately
// don't react to request-scoped failure classes (see scopedFailure below),
// so nothing else clears this stale pin.
void (async () => {
try {
const { clearLKGP } = await import("../../src/lib/localDb");
await Promise.all([
clearLKGP(combo.name, target.executionKey),
clearLKGP(combo.name, combo.id || combo.name),
]);
} catch (err) {
log.warn("COMBO", "Failed to clear Last Known Good Provider. This is non-fatal.", {
err,
});
}
})();
recordedAttempts++;
lastError = errorText || String(result.status);
comboErrors.push({
@@ -3135,6 +3153,22 @@ async function handleRoundRobinCombo({
strategy: "round-robin",
target: toRecordedTarget(target),
});
// LKGP (#919) mirror of handleComboChat's failure-path clear above — see
// that comment for why this must happen (nothing else clears a pin left
// by a request-scoped failure class like a stream-readiness timeout).
void (async () => {
try {
const { clearLKGP } = await import("../../src/lib/localDb");
await Promise.all([
clearLKGP(combo.name, target.executionKey),
clearLKGP(combo.name, combo.id || combo.name),
]);
} catch (err) {
log.warn("COMBO-RR", "Failed to clear Last Known Good Provider. This is non-fatal.", {
err,
});
}
})();
recordedAttempts++;
lastError = errorText || String(result.status);
lastStatus = result.status;

View File

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

View File

@@ -34,7 +34,10 @@ import {
recordReplay,
requiresReasoningReplay,
} from "../services/reasoningCache.ts";
import { normalizeResponsesReasoningEffort } from "./request/openai-responses/helpers.ts";
import {
normalizeResponsesReasoningEffort,
RESPONSES_STORE_MARKER,
} from "./request/openai-responses/helpers.ts";
bootstrapTranslatorRegistry();
export { register } from "./registry.ts";
@@ -700,6 +703,19 @@ export function translateRequest(
}
}
// #<store-marker-leak>: a Responses-source request stashes the client's
// `store` intent under this internal marker (see the Responses -> OpenAI
// step above) so a later OpenAI -> Responses re-conversion can restore it
// as `store`. When the destination stays in Chat Completions shape (no
// such re-conversion happens), nothing else consumes the marker, and it
// was leaking verbatim into the real upstream request body — e.g. OpenAI
// itself rejects it with "Unknown parameter: '_omnirouteResponsesStore'".
// Always drop it here: any handler that still needs the client's original
// `store` value would have already read the marker before this point.
if (RESPONSES_STORE_MARKER in result) {
delete result[RESPONSES_STORE_MARKER];
}
return result;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -815,7 +815,7 @@ export {
resetAllPricing,
} from "./settings/pricing";
export { type LKGPRecord, getLKGP, setLKGP, clearAllLKGP } from "./settings/lkgp";
export { type LKGPRecord, getLKGP, setLKGP, clearAllLKGP, clearLKGP } from "./settings/lkgp";
export {
type CacheTrendPoint,

View File

@@ -48,6 +48,25 @@ export function clearAllLKGP(): void {
db.prepare("DELETE FROM key_value WHERE namespace = 'lkgp'").run();
}
/**
* Delete one persisted LKGP pin after its target fails. `setLKGP` is only ever
* called on success — nothing previously invalidated a pin once its provider
* started failing, so a *separate* subsequent request kept re-selecting the
* same just-failed provider via `applyStrategyOrdering.ts`'s LKGP reordering
* (live incident: 3 consecutive requests all picked the same timed-out
* opencode-zen/big-pickle target instead of failing over to another combo
* model). Circuit breaker / model lockout deliberately don't react to this
* failure class (request-scoped timeouts, see comboPredicates.ts), so nothing
* else clears the stale pin.
*/
export async function clearLKGP(comboName: string, modelId: string): Promise<void> {
const db = getDbInstance();
const key = `${comboName}:${modelId}`;
db.prepare("DELETE FROM key_value WHERE namespace = 'lkgp' AND key = ?").run(key);
const { invalidateCachedLKGP } = await import("../readCache");
invalidateCachedLKGP(key);
}
/**
* Delete persisted LKGP pins whose connectionId references a removed provider
* connection. Provider-level pins and legacy/unparseable values are preserved.

View File

@@ -1,3 +1,5 @@
import { randomUUID } from "node:crypto";
import { AUDIO_TRANSCRIPTION_PROVIDERS } from "@omniroute/open-sse/config/audioRegistry.ts";
import { detectMediaParts } from "@omniroute/open-sse/utils/mediaParts";
@@ -201,10 +203,25 @@ export async function callAudioTranscription(
(detectedMime?.startsWith("audio/") ? detectedMime : undefined) ??
AUDIO_FORMAT_MIME[format] ??
"application/octet-stream";
const file = new Blob([Uint8Array.from(bytes)], { type: mime });
const form = new FormData();
form.set("file", file, `audio.${format.replace(/[^a-z0-9]/g, "") || "wav"}`);
form.set("model", config.model);
const safeMime = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(mime)
? mime
: "application/octet-stream";
const fileName = `audio.${format.replace(/[^a-z0-9]/g, "") || "wav"}`;
const boundary = `----OmniRouteAudioBridge${randomUUID().replace(/-/g, "")}`;
const CRLF = "\r\n";
const multipartBody = Buffer.concat([
Buffer.from(
`--${boundary}${CRLF}` +
`Content-Disposition: form-data; name="file"; filename="${fileName}"${CRLF}` +
`Content-Type: ${safeMime}${CRLF}${CRLF}`
),
bytes,
Buffer.from(
`${CRLF}--${boundary}${CRLF}` +
`Content-Disposition: form-data; name="model"${CRLF}${CRLF}` +
`${config.model}${CRLF}--${boundary}--${CRLF}`
),
]);
const port = (deps.getPort ?? (() => getRuntimePorts().port))();
const bearer = (deps.getBearer ?? resolveSelfLoopBearer)();
@@ -216,8 +233,9 @@ export async function callAudioTranscription(
headers: {
Accept: "application/json",
Authorization: `Bearer ${bearer}`,
"Content-Type": `multipart/form-data; boundary=${boundary}`,
},
body: form,
body: multipartBody,
}
);
if (!response.ok) {

View File

@@ -144,6 +144,7 @@ export {
// LKGP (Last Known Good Provider) (#919)
getLKGP,
setLKGP,
clearLKGP,
// Pricing
getPricing,

View File

@@ -179,10 +179,17 @@ export function getChatLogMaxObjectKeys(): number {
}
/**
* Was a hardcoded/default 8KB — trivially exceeded by any real multi-turn
* agentic conversation, meaning the dashboard's "Full Conversation" panel
* could only ever show a placeholder instead of the actual messages for
* nearly every logged row of any conversation with real substance.
* Cap for a single logged request/response body before it gets replaced by a
* bare {_truncated, _originalBytes, messageCount, ...} summary instead of the
* full clone (open-sse/handlers/chatCore/logTruncation.ts::truncateForLog()).
* Was a hardcoded 8KB — trivially exceeded by any real multi-turn agentic
* conversation, which meant the dashboard's "Full Conversation" transcript
* panel could only ever show a placeholder instead of the actual messages
* for nearly every logged row. Bumped 128x (to 1MB) by default and exposed
* as an operator override for anyone who needs it even larger (or smaller,
* on a memory-constrained box) — see the same "protect memory across many
* call sites per request" reasoning truncateForLog()'s own doc comment
* explains for why some cap must still exist.
*/
export function getChatLogMaxBodyBytes(): number {
return parsePositiveInt(process.env.CHAT_LOG_MAX_BODY_KB, 1024) * 1024;

View File

@@ -89,7 +89,6 @@ import { buildModalityBridgeHeader } from "@/lib/guardrails/modalityBridge/bridg
import {
isAntigravityMissingProjectError,
isProviderBreakerFailureStatus,
PROVIDER_BREAKER_FAILURE_STATUSES,
resolveStreamReadinessClassificationError,
shouldTripProviderBreakerForResult,
} from "./chatPredicates";

View File

@@ -134,7 +134,7 @@ test("truncateForLog summarizes oversized payloads instead of cloning", () => {
provider: "openai",
stream: true,
// distinct object references so estimateSizeFast (WeakSet-dedup) counts each one
messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(64) })),
messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(500) })),
contents: [{ a: 1 }],
};
const summary = truncateForLog(huge) as Record<string, unknown>;
@@ -150,6 +150,22 @@ test("truncateForLog summarizes oversized payloads instead of cloning", () => {
assert.notEqual(summary, huge);
});
test("truncateForLog captures a message count for Responses API bodies too (input[], not messages[])", () => {
// Live bug: a large /v1/responses request got summarized with NO count at
// all (messages/contents are OpenAI-chat/Gemini-only field names), so the
// "Full Conversation" dashboard panel had nothing to base its "N messages
// not shown" placeholder on for any Responses-API conversation, even
// though the exact same 8KB summarization applies to it.
const huge = {
model: "gpt-5",
stream: true,
input: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(500) })),
};
const summary = truncateForLog(huge) as Record<string, unknown>;
assert.equal(summary._truncated, true);
assert.equal(summary.messageCount, 50000);
});
test("truncateForLog keeps a bounded `tools` field alive when the request is summarized", () => {
// A request whose message history alone blows well past the 8KB summary
// threshold, but which also carries `tools` — a field that used to be
@@ -184,7 +200,7 @@ test("truncateForLog keeps a bounded `tools` field alive when the request is sum
model: "gpt-4o",
provider: "openai",
stream: true,
messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(64) })),
messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(500) })),
tools,
};
@@ -216,7 +232,7 @@ test("truncateForLog bounds an oversized `tools` array to the configured tail-it
}));
const huge = {
model: "gpt-4o",
messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(64) })),
messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(500) })),
tools: manyTools,
};

View File

@@ -2540,10 +2540,59 @@ test("handleComboChat standalone lkgp strategy updates LKGP after a successful c
}
assert.equal(result.ok, true);
// getLKGP now returns LKGPRecord | null — source: src/lib/db/settings.ts getLKGP()
assert.equal(persistedProvider?.provider, "openai");
});
test("handleComboChat standalone lkgp strategy clears LKGP after the last-known-good target fails", async () => {
// A prior successful request pinned "openai" as the last known good provider —
// exactly the state left behind by the previous (success) test's own scenario.
await settingsDb.setLKGP("standalone-lkgp-clear", "standalone-lkgp-clear", "openai");
const calls: string[] = [];
const result = await handleComboChat({
body: {},
combo: {
id: "standalone-lkgp-clear",
name: "standalone-lkgp-clear",
strategy: "lkgp",
// maxRetries: 0 below means this single target is tried exactly once,
// then the combo loop gives up on it (and on the whole combo, since it's
// the only model) — the exact "Done retrying this model" failure path.
models: ["openai/gpt-4o-mini"],
config: { maxRetries: 0 },
},
handleSingleModel: async (_body: Record<string, unknown>, modelStr: string) => {
calls.push(modelStr);
return errorResponse(504, "Stream produced no non-ping SSE event within 95000ms");
},
isModelAvailable: async () => true,
log: createLog(),
settings: null,
relayOptions: null,
allCombos: null,
});
// Give the async fire-and-forget LKGP clear a chance to execute
let persistedProvider: Awaited<ReturnType<typeof settingsDb.getLKGP>> = null;
for (let i = 0; i < 20; i++) {
persistedProvider = await settingsDb.getLKGP("standalone-lkgp-clear", "standalone-lkgp-clear");
if (persistedProvider === null) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.equal(result.ok, false, "the only target failed, so the whole combo call fails");
assert.deepEqual(calls, ["openai/gpt-4o-mini"]);
// The bug this guards: without clearing, a *separate* subsequent request would
// keep re-selecting "openai" via LKGP reordering even though it just failed.
assert.equal(
persistedProvider,
null,
"LKGP must be cleared after its target fails, not left pointing at a just-failed provider"
);
});
test("handleComboChat auto strategy falls back to the full pool when tool filtering empties candidates", async () => {
await settingsDb.updatePricing({
openai: {

View File

@@ -0,0 +1,119 @@
// @vitest-environment jsdom
//
// Regression guard: EditConnectionModal only exposed the "OpenAI Responses
// store" toggle (providerSpecificData.openaiStoreEnabled) for provider ===
// "codex" connections, even though:
// - `isResponsesConnection` (component-local) already generically covers
// provider === "openai" and openai-compatible-responses-* connections,
// exactly like the sibling `preserveEncryptedReasoning` toggle already
// correctly uses it.
// - `isOpenAIResponsesStoreEnabled()` / `applyResponsesPreviousResponseIdPolicy()`
// (open-sse/utils/responsesStatePolicy.ts) are provider-agnostic and
// already read this same flag off ANY connection's providerSpecificData.
//
// Net effect of the bug: an operator with a plain `provider: "openai"`
// connection (or any openai-compatible-responses-* connection) had no way,
// anywhere in the dashboard, to opt that connection into OpenAI Responses
// `store`/`previous_response_id` continuation — the backend policy was ready,
// the UI simply never rendered the control for anything but Codex.
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/store/notificationStore", () => ({
useNotificationStore: () => ({ notify: vi.fn() }),
}));
vi.mock("@/store/emailPrivacyStore", () => ({
default: () => ({ hidden: false, toggle: vi.fn() }),
}));
const { default: EditConnectionModal } = await import(
"../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx"
);
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.clearAllMocks();
});
function renderModal(connection: Record<string, unknown>) {
act(() => {
root.render(
<EditConnectionModal
isOpen={true}
connection={connection}
providerId={connection.provider as string}
onSave={vi.fn().mockResolvedValue(undefined)}
onClose={vi.fn()}
/>
);
});
}
function findStoreToggleLabel(): Element | null {
return (
Array.from(container.querySelectorAll("span")).find(
(el) => el.textContent === "openaiResponsesStoreLabel"
) ?? null
);
}
function findStoreToggleSwitch(): Element | null {
const label = findStoreToggleLabel();
return label?.closest("div")?.parentElement?.querySelector('button[role="switch"]') ?? null;
}
describe("EditConnectionModal — OpenAI Responses store toggle provider gating", () => {
it("renders the store toggle for a codex connection (control)", () => {
renderModal({
id: "conn-codex-1",
provider: "codex",
authType: "oauth",
name: "Codex account",
providerSpecificData: {},
});
expect(findStoreToggleLabel()).not.toBeNull();
});
it("renders the store toggle for a plain openai connection", () => {
renderModal({
id: "conn-openai-1",
provider: "openai",
authType: "api_key",
name: "OpenAI key",
providerSpecificData: {},
});
expect(findStoreToggleLabel()).not.toBeNull();
});
it("preserves a previously-enabled openaiStoreEnabled flag in form state for a plain openai connection", () => {
renderModal({
id: "conn-openai-2",
provider: "openai",
authType: "api_key",
name: "OpenAI key",
providerSpecificData: { openaiStoreEnabled: true },
});
expect(findStoreToggleLabel()).not.toBeNull();
// The Toggle's checked state should reflect the persisted flag — if the
// control isn't wired to formData at all for this provider, this would
// be the unchecked default instead.
const toggleSwitch = findStoreToggleSwitch();
expect(toggleSwitch?.getAttribute("aria-checked")).toBe("true");
});
});

View File

@@ -234,6 +234,22 @@ test("LKGP overwrites connectionId when updated without one", async () => {
assert.deepEqual(record, { provider: "openai" });
});
test("clearLKGP deletes only the targeted combo/model key", async () => {
await settingsDb.setLKGP("combo-f", "model-f", "openai");
await settingsDb.setLKGP("combo-f", "model-g", "anthropic");
await settingsDb.clearLKGP("combo-f", "model-f");
assert.equal(await settingsDb.getLKGP("combo-f", "model-f"), null);
// A sibling key under the same combo must survive.
assert.deepEqual(await settingsDb.getLKGP("combo-f", "model-g"), { provider: "anthropic" });
});
test("clearLKGP on a key with no existing pin does not throw", async () => {
await assert.doesNotReject(() => settingsDb.clearLKGP("combo-never-set", "model-never-set"));
assert.equal(await settingsDb.getLKGP("combo-never-set", "model-never-set"), null);
});
test("pricing helpers ignore malformed synced data and LKGP falls back to raw values", async () => {
const db = core.getDbInstance();

View File

@@ -71,6 +71,7 @@ describe("settings.ts public API surface", () => {
"getLKGP",
"setLKGP",
"clearAllLKGP",
"clearLKGP",
// Cache metrics (re-exported from ./settings/cacheMetrics)
"getCacheMetrics",
"updateCacheMetrics",

View File

@@ -9,6 +9,33 @@ import {
type AudioPart,
} from "../../../src/lib/guardrails/audioBridgeHelpers.ts";
function assertMultipartFile(
init: RequestInit | undefined,
expectedFileName: string,
expectedMime: string,
expectedBytes: Buffer
): void {
const contentType = new Headers(init?.headers).get("content-type");
assert.match(contentType ?? "", /^multipart\/form-data; boundary=/);
const boundary = contentType?.split("boundary=", 2)[1];
assert.ok(boundary);
assert.ok(Buffer.isBuffer(init?.body));
const body = init?.body as Buffer;
assert.ok(
body.includes(
Buffer.concat([
Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${expectedFileName}"\r\n` +
`Content-Type: ${expectedMime}\r\n\r\n`
),
expectedBytes,
Buffer.from("\r\n"),
])
)
);
}
test("fixed STT model is honored when its credential is usable", async () => {
const checked: string[] = [];
const selected = await selectAudioBridgeModel("deepgram/nova-2", async (model) => {
@@ -62,13 +89,20 @@ test("input_audio is posted as multipart to the authenticated transcription self
assert.equal(capturedUrl, "http://localhost:3210/v1/audio/transcriptions");
assert.equal(capturedInit?.method, "POST");
assert.equal(new Headers(capturedInit?.headers).get("authorization"), "Bearer internal-test-key");
const form = capturedInit?.body as FormData;
assert.equal(form.get("model"), "deepgram/nova-3");
const file = form.get("file") as File;
assert.equal(file.name, "audio.wav");
assert.equal(file.type, "audio/wav");
assert.equal(Buffer.from(await file.arrayBuffer()).toString(), "RIFF test audio");
const contentType = new Headers(capturedInit?.headers).get("content-type");
const boundary = contentType?.split("boundary=", 2)[1];
assert.ok(boundary);
assertMultipartFile(capturedInit, "audio.wav", "audio/wav", Buffer.from("RIFF test audio"));
const body = capturedInit?.body as Buffer;
assert.ok(
body.includes(
Buffer.from(
`--${boundary}\r\n` +
'Content-Disposition: form-data; name="model"\r\n\r\n' +
`deepgram/nova-3\r\n--${boundary}--\r\n`
)
)
);
});
test("audio extraction and replacement cover the full history without dropping failed clips", () => {
@@ -127,7 +161,7 @@ test("audio extraction and replacement cover the full history without dropping f
});
test("audio_url data URIs are decoded before multipart upload", async () => {
let uploaded: File | null = null;
let uploaded: RequestInit | undefined;
await callAudioTranscription(
{
messageIndex: 0,
@@ -139,7 +173,7 @@ test("audio_url data URIs are decoded before multipart upload", async () => {
{ model: "deepgram/nova-3", timeoutMs: 1_000 },
{
fetchImpl: async (_input, init) => {
uploaded = (init?.body as FormData).get("file") as File;
uploaded = init;
return Response.json({ text: "ok" });
},
getPort: () => 3210,
@@ -147,14 +181,12 @@ test("audio_url data URIs are decoded before multipart upload", async () => {
}
);
assert.ok(uploaded);
assert.equal(uploaded.type, "audio/mpeg");
assert.equal(Buffer.from(await uploaded.arrayBuffer()).toString(), "ID3");
assertMultipartFile(uploaded, "audio.mp3", "audio/mpeg", Buffer.from("ID3"));
});
test("remote audio_url uses the guarded remote fetch before self-loop upload", async () => {
let fetchedUrl = "";
let uploaded: File | null = null;
let uploaded: RequestInit | undefined;
await callAudioTranscription(
{
messageIndex: 0,
@@ -174,7 +206,7 @@ test("remote audio_url uses the guarded remote fetch before self-loop upload", a
};
},
fetchImpl: async (_input, init) => {
uploaded = (init?.body as FormData).get("file") as File;
uploaded = init;
return Response.json({ text: "ok" });
},
getPort: () => 3210,
@@ -183,6 +215,5 @@ test("remote audio_url uses the guarded remote fetch before self-loop upload", a
);
assert.equal(fetchedUrl, "https://media.example.test/clip.ogg");
assert.ok(uploaded);
assert.equal(Buffer.from(await uploaded.arrayBuffer()).toString(), "OggS remote audio");
assertMultipartFile(uploaded, "audio.ogg", "audio/ogg", Buffer.from("OggS remote audio"));
});

View File

@@ -0,0 +1,43 @@
/**
* The Responses -> Chat Completions translator stashes a client's `store`
* intent under the internal `_omnirouteResponsesStore` marker (see
* open-sse/translator/request/openai-responses.ts) so a later Chat
* Completions -> Responses re-conversion can restore it as `store`. When the
* resolved destination stays in Chat Completions shape (e.g. a plain
* `openai` connection routed to a model without the responses-only
* `targetFormat` capability, like `gpt-5-nano`), that re-conversion never
* runs, nothing else consumed the marker, and it leaked verbatim into the
* real upstream request body. OpenAI's own `/v1/chat/completions` rejects
* it with `Unknown parameter: '_omnirouteResponsesStore'` -- confirmed live
* against the real API.
*/
import test from "node:test";
import assert from "node:assert/strict";
test("translateRequest never leaks the internal _omnirouteResponsesStore marker into a Chat Completions destination", async () => {
const { translateRequest } = await import("../../open-sse/translator/index.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const body: Record<string, unknown> = {
model: "gpt-5-nano",
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
store: true,
};
const credentials = { providerSpecificData: { openaiStoreEnabled: true } };
const result = translateRequest(
FORMATS.OPENAI_RESPONSES,
FORMATS.OPENAI,
"gpt-5-nano",
body,
true,
credentials,
"openai"
);
assert.equal("_omnirouteResponsesStore" in result, false);
// Chat Completions' own `store` field means something different (dashboard
// eval storage, not Responses-style previous_response_id continuation) --
// the client's Responses-shaped store intent must not leak onto it either.
assert.equal("store" in result, false);
});

View File

@@ -0,0 +1,158 @@
/**
* Regression test for a tool call landing on the same `output_index` as a
* preceding reasoning item in the Responses API stream.
*
* `emitToolCallAdded`/`closeToolCall` in responsesTransformer.ts used the
* provider's raw Chat Completions `tool_calls[].index` directly as the
* Responses API `output_index`. That index is scoped only to the tool_calls
* array and legitimately restarts at 0 for the first tool call — but by the
* time a tool call arrives, a reasoning item (and/or a text message) may
* already have claimed output_index 0 (and 1). A client that tracks response
* items by output_index (as the Responses API spec expects) then sees the
* tool call's added/delta/done events land on an index it already marked
* complete, and silently drops the tool call — producing an "incomplete
* turn" that never dispatches the tool.
*
* Reported live: OpenClaw on combo `default` -> opencode-zen/big-pickle,
* a reasoning block immediately followed by a function call in the same
* turn (no text message in between).
*/
import test from "node:test";
import assert from "node:assert/strict";
const { createResponsesApiTransformStream } =
await import("../../open-sse/transformer/responsesTransformer.ts");
const encoder = new TextEncoder();
const decoder = new TextDecoder();
async function runTransformStream(chunks) {
const stream = createResponsesApiTransformStream();
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
const output = [];
const readerTask = (async () => {
while (true) {
const { value, done } = await reader.read();
if (done) break;
output.push(decoder.decode(value));
}
})();
for (const chunk of chunks) {
await writer.write(encoder.encode(chunk));
}
await writer.close();
await readerTask;
return output.join("");
}
function parseSseOutput(output) {
return output
.trim()
.split("\n\n")
.map((entry) => {
const lines = entry.split("\n");
const eventLine = lines.find((line) => line.startsWith("event: "));
const dataLine = lines.find((line) => line.startsWith("data: "));
return {
event: eventLine ? eventLine.slice("event: ".length) : null,
data: dataLine ? dataLine.slice("data: ".length) : null,
};
});
}
test("tool call immediately after reasoning must not collide on output_index", async () => {
const output = await runTransformStream([
// Reasoning content — claims output_index 0.
`data: {"id":"chatcmpl-collide","choices":[{"index":0,"delta":{"reasoning_content":"thinking..."}}]}\n\n`,
// A tool call starts. The provider scopes tool_calls[].index to 0 for the
// first (and only) call here, same as reasoning's own output_index.
`data: {"id":"chatcmpl-collide","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"run","arguments":""}}]}}]}\n\n`,
`data: {"id":"chatcmpl-collide","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"cmd\\":\\"ls\\"}"}}]}}]}\n\n`,
`data: {"id":"chatcmpl-collide","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n`,
]);
const events = parseSseOutput(output);
const reasoningAdded = events.find(
(e) => e.event === "response.output_item.added" && JSON.parse(e.data).item.type === "reasoning"
);
const toolCallAdded = events.find(
(e) =>
e.event === "response.output_item.added" && JSON.parse(e.data).item.type === "function_call"
);
assert.ok(reasoningAdded, "reasoning output_item.added must be emitted");
assert.ok(toolCallAdded, "function_call output_item.added must be emitted");
const reasoningIndex = JSON.parse(reasoningAdded.data).output_index;
const toolCallIndex = JSON.parse(toolCallAdded.data).output_index;
assert.notEqual(
toolCallIndex,
reasoningIndex,
`function_call output_index (${toolCallIndex}) must not collide with reasoning's output_index (${reasoningIndex})`
);
// All function_call-related events for this call must share one consistent
// output_index across added/delta/done — a client tracking by output_index
// must be able to follow the whole lifecycle at a single index.
const funcCallEvents = events.filter((e) => {
if (
e.event !== "response.function_call_arguments.delta" &&
e.event !== "response.function_call_arguments.done" &&
e.event !== "response.output_item.done"
) {
return false;
}
if (!e.data) return false;
const parsed = JSON.parse(e.data);
return e.event !== "response.output_item.done" || parsed.item?.type === "function_call";
});
assert.ok(funcCallEvents.length > 0, "expected function_call lifecycle events");
for (const e of funcCallEvents) {
assert.equal(
JSON.parse(e.data).output_index,
toolCallIndex,
`event ${e.event} must use the same output_index as the tool call's added event`
);
}
// response.completed output must contain both items at distinct indices.
const completed = JSON.parse(events.find((e) => e.event === "response.completed").data).response;
const reasoningItem = completed.output.find((item) => item.type === "reasoning");
const funcItem = completed.output.find((item) => item.type === "function_call");
assert.ok(reasoningItem, "completed output must include the reasoning item");
assert.ok(funcItem, "completed output must include the function_call item");
assert.equal(funcItem.call_id, "call_1");
assert.equal(funcItem.arguments, '{"cmd":"ls"}');
});
test("multiple tool calls after reasoning use sequential output_index values, none colliding with reasoning", async () => {
const output = await runTransformStream([
`data: {"id":"chatcmpl-multi","choices":[{"index":0,"delta":{"reasoning_content":"planning"}}]}\n\n`,
`data: {"id":"chatcmpl-multi","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"first","arguments":"{}"}}]}}]}\n\n`,
`data: {"id":"chatcmpl-multi","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_b","function":{"name":"second","arguments":"{}"}}]}}]}\n\n`,
`data: {"id":"chatcmpl-multi","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n`,
]);
const events = parseSseOutput(output);
const addedEvents = events.filter((e) => e.event === "response.output_item.added");
const indexByType = addedEvents.map((e) => {
const parsed = JSON.parse(e.data);
return { type: parsed.item.type, output_index: parsed.output_index };
});
const reasoningIdx = indexByType.find((i) => i.type === "reasoning").output_index;
const funcIndices = indexByType.filter((i) => i.type === "function_call").map((i) => i.output_index);
assert.equal(funcIndices.length, 2);
assert.ok(new Set(funcIndices).size === 2, "the two tool calls must not share an output_index");
for (const fi of funcIndices) {
assert.notEqual(fi, reasoningIdx, "no tool call may collide with the reasoning output_index");
}
});

View File

@@ -110,7 +110,9 @@ test("buildStreamSummaryFromEvents merges tool_call deltas when every chunk carr
],
}),
toolCallEvent({
tool_calls: [{ index: 0, id: "call_a", type: "function", function: { arguments: '{"x":1}' } }],
tool_calls: [
{ index: 0, id: "call_a", type: "function", function: { arguments: '{"x":1}' } },
],
}),
toolCallEvent({}, "tool_calls"),
];
@@ -215,3 +217,99 @@ test("buildStreamSummaryFromEvents keeps two genuinely different interleaved too
assert.equal(toolCalls[1].function.name, "Read");
assert.equal(toolCalls[1].function.arguments, '{"path":"b"}');
});
type OpenAIStreamSummary = {
choices: Array<{
finish_reason: string;
message: {
tool_calls?: Array<{ function: { name: string; arguments: string } }>;
reasoning_content?: string;
};
}>;
usage?: { total_tokens: number };
};
// #9315 — the dashboard's "Provider Response" panel went stale/incomplete for
// long streamed responses because it was reconstructed from
// buildStreamSummaryFromEvents(collector.getEvents(), ...) — and getEvents()
// only returns whatever survived the collector's maxEvents/maxBytes cap. Once
// a stream exceeded that cap, every chunk after the cutoff (final
// finish_reason, tool_calls, rest of reasoning_content, usage) was silently
// dropped from the reconstruction, even though the client actually received
// the complete, correct response.
test("#9315: collector.getSummary() reflects the full stream even after maxEvents truncation", () => {
const c = collector.createStructuredSSECollector({
maxEvents: 3,
format: "openai",
fallbackModel: "test-model",
});
// First 3 chunks fill the cap.
c.push({
id: "chatcmpl-1",
object: "chat.completion.chunk",
created: 1,
model: "test-model",
choices: [{ index: 0, delta: { role: "assistant", content: "Thinking" } }],
});
c.push({ choices: [{ index: 0, delta: { content: " about it" } }] });
c.push({ choices: [{ index: 0, delta: { reasoning_content: "step one. " } }] });
// These all arrive AFTER the cap is full — the OLD reconstruction-from-
// getEvents() approach silently loses every one of them.
c.push({ choices: [{ index: 0, delta: { reasoning_content: "step two." } }] });
c.push({
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: "call_1",
type: "function",
function: { name: "Bash", arguments: '{"cmd":"date"}' },
},
],
},
},
],
});
c.push({ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] });
c.push({
choices: [{ index: 0, delta: {} }],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
});
// Sanity check: this test is only meaningful if truncation genuinely happened.
const retained = c.getEvents();
assert.equal(retained.length, 3, "expected the raw event array to be capped at maxEvents");
// Characterize the pre-fix bug: reconstructing from the truncated retained
// events (the old approach every call site in stream.ts used) misses
// everything that arrived after the cap.
const staleSummary = collector.buildStreamSummaryFromEvents(
retained,
"openai",
"test-model"
) as OpenAIStreamSummary;
assert.equal(staleSummary.choices[0].finish_reason, "stop");
assert.equal(staleSummary.choices[0].message.tool_calls, undefined);
assert.equal(staleSummary.choices[0].message.reasoning_content, "step one.");
// The fix: getSummary() was fed every pushed chunk, truncated from storage
// or not, so it reflects the true final state.
const liveSummary = c.getSummary() as OpenAIStreamSummary;
assert.equal(liveSummary.choices[0].finish_reason, "tool_calls");
assert.equal(liveSummary.choices[0].message.tool_calls.length, 1);
assert.equal(liveSummary.choices[0].message.tool_calls[0].function.name, "Bash");
assert.equal(liveSummary.choices[0].message.tool_calls[0].function.arguments, '{"cmd":"date"}');
assert.equal(liveSummary.choices[0].message.reasoning_content, "step one. step two.");
assert.equal(liveSummary.usage.total_tokens, 30);
});
test("#9315: getSummary() returns undefined when no format was configured (unaffected client-response collector)", () => {
const c = collector.createStructuredSSECollector({ maxEvents: 200 });
c.push({ choices: [{ index: 0, delta: { content: "hi" } }] });
assert.equal(c.getSummary(), undefined);
});

View File

@@ -1098,6 +1098,63 @@ test("createSSEStream passthrough preserves Responses API events and completion
assert.equal(onCompletePayload.providerPayload.summary.object, "response");
});
// Real bug found live (dashboard log id 1786032832181-1c6275, #9315 follow-up):
// providerPayloadCollector was keyed on `sourceFormat` (the CLIENT's format)
// instead of `targetFormat` (the PROVIDER's format — see createSSEStream's own
// @param doc). A Responses-API client routed to a plain-OpenAI-chat-completions
// upstream (exactly this OpenClaw/opencode-zen combo) fed the provider's real
// chat.completion.chunk deltas into the Responses-API reducer, which never
// recognizes them — so the dashboard's "Provider Response" panel stayed stuck
// empty (`output: []`) forever while "Client Response" correctly showed full
// content, reading as if the two panels disagreed about the same request.
test("createSSEStream translate mode: providerPayload summary reflects the PROVIDER's format, not the client's", async () => {
let onCompletePayload = null;
await readTransformed(
[
`data: ${JSON.stringify({
id: "chatcmpl-1",
object: "chat.completion.chunk",
created: 1,
model: "big-pickle",
choices: [
{ index: 0, delta: { role: "assistant", content: "Hello " }, finish_reason: null },
],
})}\n\n`,
`data: ${JSON.stringify({
id: "chatcmpl-1",
object: "chat.completion.chunk",
created: 1,
model: "big-pickle",
choices: [{ index: 0, delta: { content: "world" }, finish_reason: "stop" }],
usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
})}\n\n`,
`data: [DONE]\n\n`,
],
{
mode: "translate",
// Client speaks Responses API; the upstream provider (opencode-zen-style)
// speaks plain OpenAI chat-completions — exactly the OpenClaw combo that
// surfaced this live.
sourceFormat: FORMATS.OPENAI_RESPONSES,
targetFormat: FORMATS.OPENAI,
provider: "opencode-zen",
model: "big-pickle",
body: { input: "hi" },
onComplete(payload) {
onCompletePayload = payload;
},
}
);
const summary = onCompletePayload.providerPayload.summary;
assert.ok(summary, "providerPayload.summary must not be null/undefined");
// The bug's exact symptom: a Responses-API reducer fed chat-completion chunks
// never recognizes them, so it stays at "no output" — assert the OPPOSITE.
assert.equal(summary.object, "chat.completion");
assert.equal(summary.choices?.[0]?.message?.content, "Hello world");
assert.equal(summary.choices?.[0]?.finish_reason, "stop");
});
test("createSSEStream passthrough drops leaked empty chat bootstrap chunks for Responses clients", async () => {
const text = await readTransformed(
[

View File

@@ -8,9 +8,10 @@ const { openaiToOpenAIResponsesResponse } =
const { initState } = await import("../../open-sse/translator/index.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
function collectEvents(chunks, customToolNames = new Set()) {
function collectEvents(chunks, customToolNames = new Set(), toolSchemas = null) {
const state = initState(FORMATS.OPENAI_RESPONSES);
state.customToolNames = customToolNames;
if (toolSchemas) state.toolSchemas = toolSchemas;
const events = [];
for (const chunk of chunks) {
const result = openaiToOpenAIResponsesResponse(chunk, state);
@@ -166,6 +167,130 @@ test("OpenAI -> Responses: apply_patch streams as custom_tool_call with raw inpu
assert.equal(customItem.input, "PATCH_BODY");
});
// Regression (live incident): a client (OpenClaw) that explicitly declares apply_patch
// as a plain `type:"function"` tool with its own `{input:string}` JSON-schema parameters
// must get a `function_call` item back with `arguments` as the raw JSON string it
// registered — NOT the apply_patch-is-always-custom fallback below. PR #7905 ("Restore
// Responses API custom tool calls") states this precedence should already hold ("...
// while preserving explicit function-tool precedence") but its `toolName ===
// "apply_patch"` unconditional OR never actually implemented that carve-out for
// apply_patch specifically. Forcing custom_tool_call onto a client that registered a
// function tool means the client's own dispatcher — which only knows how to handle
// function_call items for a name it declared as type:"function" — never recognizes the
// item at all: no error, no execution, no follow-up request with the tool result.
test("OpenAI -> Responses: apply_patch streams as function_call when the client declared it as a function tool (with tool defined)", () => {
const toolSchemas = new Map([
[
"apply_patch",
{
type: "object",
properties: { input: { type: "string" } },
required: ["input"],
},
],
]);
const events = collectEvents(
[
{
id: "chatcmpl-fn-apply-patch",
model: "big-pickle",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: "call_1",
type: "function",
function: { name: "apply_patch", arguments: '{"input":"PATCH_BODY"}' },
},
],
},
finish_reason: "tool_calls",
},
],
},
null,
],
new Set(), // client did not declare apply_patch as type:"custom"
toolSchemas // ...but DID declare it as type:"function" with a parameters schema
);
const added = events.find((e) => e.event === "response.output_item.added");
assert.ok(added);
assert.equal(
added.data.item.type,
"function_call",
"explicit function-tool declaration must win over the apply_patch-is-custom fallback"
);
assert.equal(added.data.item.name, "apply_patch");
assert.ok(
events.some((e) => e.event === "response.function_call_arguments.delta"),
"expected function_call_arguments.delta events, not custom_tool_call_input.*"
);
assert.ok(!events.some((e) => e.event === "response.custom_tool_call_input.delta"));
const done = events.find(
(e) => e.event === "response.output_item.done" && e.data.item.type === "function_call"
);
assert.ok(done);
// arguments must stay the raw JSON string the model produced — NOT unwrapped to the
// bare patch text the way a genuine custom tool call would be.
assert.equal(done.data.item.arguments, '{"input":"PATCH_BODY"}');
const completed = events.find((e) => e.event === "response.completed");
const finalItem = completed.data.response.output.find((o) => o.name === "apply_patch");
assert.equal(finalItem.type, "function_call");
assert.equal(finalItem.arguments, '{"input":"PATCH_BODY"}');
});
// Sibling of the test above (without tool defined): when the client's request never
// declares apply_patch as a tool at all (native Codex CLI convention — the model just
// emits it), the original #1007 fallback behavior must be unchanged: still custom_tool_call
// with the raw patch string unwrapped from the model's {"input":"..."} JSON.
test("OpenAI -> Responses: apply_patch still streams as custom_tool_call when the client never declared it (without tool defined)", () => {
const events = collectEvents(
[
{
id: "chatcmpl-no-decl-apply-patch",
model: "gpt-5.3-codex",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: "call_1",
type: "function",
function: { name: "apply_patch", arguments: '{"input":"PATCH_BODY"}' },
},
],
},
finish_reason: "tool_calls",
},
],
},
null,
]
// no customToolNames, no toolSchemas — apply_patch was never declared by the client
);
const added = events.find((e) => e.event === "response.output_item.added");
assert.ok(added);
assert.equal(added.data.item.type, "custom_tool_call");
assert.equal(added.data.item.name, "apply_patch");
assert.ok(events.some((e) => e.event === "response.custom_tool_call_input.delta"));
const done = events.find(
(e) => e.event === "response.output_item.done" && e.data.item.type === "custom_tool_call"
);
assert.ok(done);
assert.equal(done.data.item.input, "PATCH_BODY");
});
test("OpenAI -> Responses: declared custom tools round-trip through the active translator", () => {
const events = collectEvents(
[