mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 10:43:43 +03:00
Compare commits
5 Commits
release/v3
...
fix/audio-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d737bfd7a9 | ||
|
|
4b8b4b5a9b | ||
|
|
2e14ec7eda | ||
|
|
d4b8bd9cab | ||
|
|
f91c039cdf |
@@ -1449,11 +1449,6 @@ 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
|
||||
@@ -2631,6 +2626,10 @@ 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
|
||||
|
||||
@@ -753,7 +753,6 @@ 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. |
|
||||
|
||||
---
|
||||
@@ -1447,6 +1446,7 @@ 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. |
|
||||
|
||||
@@ -60,9 +60,9 @@ export function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown {
|
||||
|
||||
/**
|
||||
* Truncate a large object for logging. If its JSON representation exceeds
|
||||
* 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
|
||||
* 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
|
||||
* across 17 call sites per request.
|
||||
*
|
||||
* When the summarized object carries a `tools` definition, re-attach it
|
||||
@@ -77,9 +77,6 @@ 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
|
||||
@@ -91,11 +88,6 @@ 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);
|
||||
|
||||
@@ -1996,24 +1996,6 @@ 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({
|
||||
@@ -3153,22 +3135,6 @@ 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;
|
||||
|
||||
@@ -209,12 +209,6 @@ 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>;
|
||||
@@ -386,27 +380,6 @@ 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;
|
||||
|
||||
@@ -417,7 +390,7 @@ export function createResponsesApiTransformStream(
|
||||
|
||||
emit(controller, "response.output_item.added", {
|
||||
type: "response.output_item.added",
|
||||
output_index: state.funcOutputIndex[idx],
|
||||
output_index: idx,
|
||||
item: {
|
||||
id: `fc_${state.funcCallIds[idx]}`,
|
||||
type: itemType,
|
||||
@@ -433,7 +406,7 @@ export function createResponsesApiTransformStream(
|
||||
const closeToolCall = (controller, idx, recordAsCompleted = true) => {
|
||||
const callId = state.funcCallIds[idx];
|
||||
if (callId && !state.funcItemDone[idx]) {
|
||||
const normalizedIndex = state.funcOutputIndex[idx];
|
||||
const normalizedIndex = normalizeOutputIndex(idx);
|
||||
let args = state.funcArgsBuf[idx] || "{}";
|
||||
const toolName = state.funcNames[idx] || "";
|
||||
emitToolCallAdded(controller, idx);
|
||||
@@ -777,7 +750,6 @@ 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;
|
||||
|
||||
@@ -793,10 +765,6 @@ 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;
|
||||
@@ -818,7 +786,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: outputIndex,
|
||||
output_index: tcIdx,
|
||||
delta: state.funcArgsBuf[tcIdx],
|
||||
});
|
||||
}
|
||||
@@ -857,7 +825,7 @@ export function createResponsesApiTransformStream(
|
||||
emit(controller, "response.function_call_arguments.delta", {
|
||||
type: "response.function_call_arguments.delta",
|
||||
item_id: `fc_${refCallId}`,
|
||||
output_index: outputIndex,
|
||||
output_index: tcIdx,
|
||||
delta: emittedDelta,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,10 +34,7 @@ import {
|
||||
recordReplay,
|
||||
requiresReasoningReplay,
|
||||
} from "../services/reasoningCache.ts";
|
||||
import {
|
||||
normalizeResponsesReasoningEffort,
|
||||
RESPONSES_STORE_MARKER,
|
||||
} from "./request/openai-responses/helpers.ts";
|
||||
import { normalizeResponsesReasoningEffort } from "./request/openai-responses/helpers.ts";
|
||||
|
||||
bootstrapTranslatorRegistry();
|
||||
export { register } from "./registry.ts";
|
||||
@@ -703,19 +700,6 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -528,21 +528,9 @@ 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.toolSchemas?.has?.(toolName)) ||
|
||||
state.customToolNames?.has?.(toolName) === true;
|
||||
toolName === "apply_patch" || state.customToolNames?.has?.(toolName) === true;
|
||||
|
||||
if (!state.funcCallIds[tcIdx] && newCallId) state.funcCallIds[tcIdx] = newCallId;
|
||||
const callId = state.funcCallIds[tcIdx];
|
||||
@@ -609,11 +597,8 @@ 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.toolSchemas?.has?.(toolName)) ||
|
||||
state.customToolNames?.has?.(toolName) === true;
|
||||
toolName === "apply_patch" || state.customToolNames?.has?.(toolName) === true;
|
||||
|
||||
let funcItem;
|
||||
if (isCustomTool) {
|
||||
|
||||
@@ -787,29 +787,6 @@ 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",
|
||||
@@ -1664,9 +1641,7 @@ 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" &&
|
||||
@@ -2508,11 +2483,7 @@ 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. 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.
|
||||
// Keep the events-derived summary for OPENAI_RESPONSES only.
|
||||
providerPayload: providerPayloadCollector.build(
|
||||
sourceFormat === FORMATS.OPENAI_RESPONSES
|
||||
? buildStreamSummaryFromEvents(
|
||||
@@ -2520,7 +2491,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
sourceFormat,
|
||||
model
|
||||
)
|
||||
: { object: "chat.completion", ...responseBody },
|
||||
: responseBody,
|
||||
{ includeEvents: false }
|
||||
),
|
||||
clientPayload: clientPayloadCollector.build(responseBody, {
|
||||
@@ -2629,7 +2600,11 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
error: err.message,
|
||||
errorCode: err.code,
|
||||
providerPayload: providerPayloadCollector.build(
|
||||
providerPayloadCollector.getSummary(),
|
||||
buildStreamSummaryFromEvents(
|
||||
providerPayloadCollector.getEvents(),
|
||||
targetFormat,
|
||||
model
|
||||
),
|
||||
{ includeEvents: false }
|
||||
),
|
||||
clientPayload: clientPayloadCollector.build(errorBody, {
|
||||
@@ -2808,11 +2783,7 @@ 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,
|
||||
// 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.
|
||||
// the synthesized chat-shaped responseBody drops the `response` object.
|
||||
providerPayload: providerPayloadCollector.build(
|
||||
targetFormat === FORMATS.OPENAI_RESPONSES
|
||||
? buildStreamSummaryFromEvents(
|
||||
@@ -2820,7 +2791,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
targetFormat,
|
||||
model
|
||||
)
|
||||
: { object: "chat.completion", ...responseBody },
|
||||
: responseBody,
|
||||
{ includeEvents: false }
|
||||
),
|
||||
clientPayload: clientPayloadCollector.build(responseBody, {
|
||||
|
||||
@@ -12,16 +12,6 @@ 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 = {
|
||||
@@ -30,11 +20,6 @@ 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;
|
||||
|
||||
@@ -128,15 +113,13 @@ function tryParseJson(raw: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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.
|
||||
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;
|
||||
|
||||
function createOpenAIReducer(fallbackModel?: string | null): SummaryReducer {
|
||||
let first: JsonRecord | null = null;
|
||||
const first = payloads[0];
|
||||
const contentParts: string[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
type ToolCall = {
|
||||
@@ -173,126 +156,124 @@ function createOpenAIReducer(fallbackModel?: string | null): SummaryReducer {
|
||||
return `seq:${unknownToolCallSeq}`;
|
||||
};
|
||||
|
||||
return {
|
||||
ingest(chunk: JsonRecord) {
|
||||
if (Object.keys(chunk).length === 0) return;
|
||||
if (!first) first = chunk;
|
||||
for (const chunk of payloads) {
|
||||
const choice = asRecord(Array.isArray(chunk.choices) ? chunk.choices[0] : null);
|
||||
const delta = asRecord(choice.delta);
|
||||
|
||||
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.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 (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 (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 (!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) };
|
||||
}
|
||||
},
|
||||
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;
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
function createResponsesReducer(fallbackModel?: string | null): SummaryReducer {
|
||||
let sawAny = false;
|
||||
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;
|
||||
|
||||
let completed: JsonRecord | null = null;
|
||||
let latestResponse: JsonRecord | null = null;
|
||||
let usage: JsonRecord | null = null;
|
||||
@@ -308,72 +289,67 @@ function createResponsesReducer(fallbackModel?: string | null): SummaryReducer {
|
||||
]
|
||||
: [];
|
||||
|
||||
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 {
|
||||
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: {},
|
||||
};
|
||||
},
|
||||
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 createClaudeReducer(fallbackModel?: string | null): SummaryReducer {
|
||||
let sawAny = false;
|
||||
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;
|
||||
|
||||
type ClaudeBlock =
|
||||
| { type: "text"; index: number; text: string }
|
||||
| { type: "thinking"; index: number; thinking: string; signature?: string }
|
||||
@@ -403,177 +379,172 @@ function createClaudeReducer(fallbackModel?: string | null): SummaryReducer {
|
||||
// non-streaming JSON path. Last-writer-wins: the final snapshot is authoritative.
|
||||
let contextManagement: JsonRecord | null = null;
|
||||
|
||||
return {
|
||||
ingest(payload: JsonRecord) {
|
||||
if (Object.keys(payload).length === 0) return;
|
||||
sawAny = true;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
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),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType === "content_block_start") {
|
||||
const index = toNumber(payload.index, blocks.size);
|
||||
const contentBlock = asRecord(payload.content_block);
|
||||
const blockType = toString(contentBlock.type);
|
||||
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 (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"
|
||||
if (deltaType === "input_json_delta") {
|
||||
const toolUse =
|
||||
existing && existing.type === "tool_use"
|
||||
? existing
|
||||
: {
|
||||
type: "text" as const,
|
||||
type: "tool_use" as const,
|
||||
index,
|
||||
text: "",
|
||||
id: `toolu_${Date.now()}_${index}`,
|
||||
name: "",
|
||||
input: {},
|
||||
inputJson: "",
|
||||
};
|
||||
textBlock.text += toString(delta.text);
|
||||
blocks.set(index, textBlock);
|
||||
return;
|
||||
toolUse.inputJson += toString(delta.partial_json);
|
||||
blocks.set(index, toolUse);
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
finalize(): unknown {
|
||||
if (!sawAny) return null;
|
||||
mergeUsage(usage, payload.usage);
|
||||
}
|
||||
|
||||
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 createGeminiReducer(fallbackModel?: string | null): SummaryReducer {
|
||||
let sawAny = false;
|
||||
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;
|
||||
|
||||
const parts: JsonRecord[] = [];
|
||||
const usageMetadata: JsonRecord = {};
|
||||
let modelVersion = fallbackModel || "gemini";
|
||||
@@ -594,108 +565,52 @@ function createGeminiReducer(fallbackModel?: string | null): SummaryReducer {
|
||||
parts.push(part);
|
||||
};
|
||||
|
||||
return {
|
||||
ingest(payload: JsonRecord) {
|
||||
if (Object.keys(payload).length === 0) return;
|
||||
sawAny = true;
|
||||
for (const payload of payloads) {
|
||||
if (typeof payload.modelVersion === "string" && payload.modelVersion.length > 0) {
|
||||
modelVersion = payload.modelVersion;
|
||||
}
|
||||
mergeUsage(usageMetadata, payload.usageMetadata);
|
||||
|
||||
if (typeof payload.modelVersion === "string" && payload.modelVersion.length > 0) {
|
||||
modelVersion = payload.modelVersion;
|
||||
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 } : {}),
|
||||
});
|
||||
}
|
||||
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();
|
||||
return {
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
content: {
|
||||
role,
|
||||
parts,
|
||||
},
|
||||
finishReason,
|
||||
},
|
||||
],
|
||||
...(Object.keys(usageMetadata).length > 0 ? { usageMetadata } : {}),
|
||||
modelVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStreamSummaryFromEvents(
|
||||
@@ -751,25 +666,19 @@ export function compactStructuredStreamPayload(payload: unknown): unknown {
|
||||
}
|
||||
|
||||
export function createStructuredSSECollector(options: CollectorOptions = {}) {
|
||||
const { maxEvents = 200, maxBytes = 49152, stage, format, fallbackModel } = options;
|
||||
const { maxEvents = 200, maxBytes = 49152, stage } = 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: clonedData,
|
||||
data: cloneLogPayload(payload),
|
||||
};
|
||||
|
||||
const eventName = explicitEvent || getEventName(payload);
|
||||
@@ -791,17 +700,6 @@ 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 {
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function EditConnectionModal({
|
||||
accountId: "",
|
||||
codexReasoningEffort: "medium",
|
||||
codexServiceTier: "default" as CodexServiceTier,
|
||||
openaiResponsesStoreEnabled: false,
|
||||
codexOpenaiStoreEnabled: false,
|
||||
preserveEncryptedReasoning: false,
|
||||
consoleApiKey: "",
|
||||
newApiUserId: "",
|
||||
@@ -330,7 +330,7 @@ export default function EditConnectionModal({
|
||||
accountId: existingAccountId,
|
||||
codexReasoningEffort: codexRequestDefaults.reasoningEffort,
|
||||
codexServiceTier: codexRequestDefaults.serviceTier ?? "default",
|
||||
openaiResponsesStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true,
|
||||
codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true,
|
||||
preserveEncryptedReasoning:
|
||||
connection.providerSpecificData?.preserveEncryptedReasoning === true,
|
||||
consoleApiKey: existingConsoleApiKey,
|
||||
@@ -634,6 +634,8 @@ export default function EditConnectionModal({
|
||||
? { serviceTier: formData.codexServiceTier }
|
||||
: {}),
|
||||
};
|
||||
updates.providerSpecificData.openaiStoreEnabled =
|
||||
formData.codexOpenaiStoreEnabled === true;
|
||||
}
|
||||
if (isAntigravityFamily) {
|
||||
updates.providerSpecificData.projectId = trimmedCloudCodeProjectId || null;
|
||||
@@ -660,8 +662,6 @@ export default function EditConnectionModal({
|
||||
if (isResponsesConnection && updates.providerSpecificData) {
|
||||
updates.providerSpecificData.preserveEncryptedReasoning =
|
||||
formData.preserveEncryptedReasoning === true;
|
||||
updates.providerSpecificData.openaiStoreEnabled =
|
||||
formData.openaiResponsesStoreEnabled === true;
|
||||
}
|
||||
const freeOnlyChanged =
|
||||
showFreeModelsToggle &&
|
||||
@@ -704,16 +704,6 @@ 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">
|
||||
@@ -769,6 +759,12 @@ 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 && (
|
||||
@@ -802,7 +798,6 @@ export default function EditConnectionModal({
|
||||
/>
|
||||
)}
|
||||
{preserveEncryptedReasoningToggle}
|
||||
{openaiResponsesStoreToggle}
|
||||
<Toggle
|
||||
checked={formData.disableCooling}
|
||||
onChange={(checked) => setFormData({ ...formData, disableCooling: checked })}
|
||||
|
||||
@@ -815,7 +815,7 @@ export {
|
||||
resetAllPricing,
|
||||
} from "./settings/pricing";
|
||||
|
||||
export { type LKGPRecord, getLKGP, setLKGP, clearAllLKGP, clearLKGP } from "./settings/lkgp";
|
||||
export { type LKGPRecord, getLKGP, setLKGP, clearAllLKGP } from "./settings/lkgp";
|
||||
|
||||
export {
|
||||
type CacheTrendPoint,
|
||||
|
||||
@@ -48,25 +48,6 @@ 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.
|
||||
|
||||
@@ -144,7 +144,6 @@ export {
|
||||
// LKGP (Last Known Good Provider) (#919)
|
||||
getLKGP,
|
||||
setLKGP,
|
||||
clearLKGP,
|
||||
|
||||
// Pricing
|
||||
getPricing,
|
||||
|
||||
@@ -179,17 +179,10 @@ export function getChatLogMaxObjectKeys(): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function getChatLogMaxBodyBytes(): number {
|
||||
return parsePositiveInt(process.env.CHAT_LOG_MAX_BODY_KB, 1024) * 1024;
|
||||
|
||||
@@ -89,6 +89,7 @@ import { buildModalityBridgeHeader } from "@/lib/guardrails/modalityBridge/bridg
|
||||
import {
|
||||
isAntigravityMissingProjectError,
|
||||
isProviderBreakerFailureStatus,
|
||||
PROVIDER_BREAKER_FAILURE_STATUSES,
|
||||
resolveStreamReadinessClassificationError,
|
||||
shouldTripProviderBreakerForResult,
|
||||
} from "./chatPredicates";
|
||||
|
||||
@@ -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(500) })),
|
||||
messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(64) })),
|
||||
contents: [{ a: 1 }],
|
||||
};
|
||||
const summary = truncateForLog(huge) as Record<string, unknown>;
|
||||
@@ -150,22 +150,6 @@ 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
|
||||
@@ -200,7 +184,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(500) })),
|
||||
messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(64) })),
|
||||
tools,
|
||||
};
|
||||
|
||||
@@ -232,7 +216,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(500) })),
|
||||
messages: Array.from({ length: 50000 }, () => ({ role: "user", content: "x".repeat(64) })),
|
||||
tools: manyTools,
|
||||
};
|
||||
|
||||
|
||||
@@ -2540,59 +2540,10 @@ 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: {
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
// @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");
|
||||
});
|
||||
});
|
||||
@@ -234,22 +234,6 @@ 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();
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@ describe("settings.ts public API surface", () => {
|
||||
"getLKGP",
|
||||
"setLKGP",
|
||||
"clearAllLKGP",
|
||||
"clearLKGP",
|
||||
// Cache metrics (re-exported from ./settings/cacheMetrics)
|
||||
"getCacheMetrics",
|
||||
"updateCacheMetrics",
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
});
|
||||
@@ -110,9 +110,7 @@ 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"),
|
||||
];
|
||||
@@ -217,99 +215,3 @@ 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);
|
||||
});
|
||||
|
||||
@@ -1098,63 +1098,6 @@ 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(
|
||||
[
|
||||
|
||||
@@ -8,10 +8,9 @@ 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(), toolSchemas = null) {
|
||||
function collectEvents(chunks, customToolNames = new Set()) {
|
||||
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);
|
||||
@@ -167,130 +166,6 @@ 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(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user