diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4116f2433808..ce84ab827702 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1559,6 +1559,7 @@ jobs: env: HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }} FORMAT_CHECK: ${{ needs.preflight.outputs.run_format_check }} + LOC_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || (github.event_name == 'push' && github.event.before || '') }} OPENCLAW_LOCAL_CHECK: "0" TASK: ${{ matrix.task }} PR_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} @@ -1568,12 +1569,17 @@ jobs: case "$TASK" in guards) pnpm check:no-conflict-markers + if [ -n "$LOC_BASE_SHA" ]; then + git fetch --no-tags --depth=1 origin "+${LOC_BASE_SHA}:refs/remotes/origin/loc-base" + pnpm check:loc --base-ref refs/remotes/origin/loc-base + else + pnpm check:loc + fi pnpm tool-display:check pnpm check:host-env-policy:swift pnpm dup:check:coverage if [ -n "$PR_BASE_SHA" ]; then - git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/remotes/origin/pr-base" - node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/pr-base --head HEAD --no-merge-base + node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/loc-base --head HEAD --no-merge-base fi pnpm deps:patches:check pnpm lint:webhook:no-low-level-body-read diff --git a/package.json b/package.json index 7d11ffb592ca..f9e809414953 100644 --- a/package.json +++ b/package.json @@ -1554,6 +1554,7 @@ "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:import-cycles": "node --import tsx scripts/check-import-cycles.ts", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", + "check:loc:update": "node --import tsx scripts/check-ts-max-loc.ts --max 500 --write-baseline", "check:madge-import-cycles": "node --import tsx scripts/check-madge-import-cycles.ts", "check:media-download-helpers": "node scripts/check-media-download-helper-roundtrip.mjs", "check:no-conflict-markers": "node scripts/check-no-conflict-markers.mjs", diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index a86308357d04..5feb35dc7aa6 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -314,6 +314,7 @@ export function createChangedCheckPlan(result, options = {}) { }; add("conflict markers", ["check:no-conflict-markers"]); + add("TypeScript LOC ratchet", ["check:loc"]); add("changelog attributions", ["check:changelog-attributions"]); add("guarded extension wildcard re-exports", ["lint:extensions:no-guarded-wildcard-reexports"]); add("plugin-sdk wildcard re-exports", ["lint:extensions:no-plugin-sdk-wildcard-reexports"]); diff --git a/scripts/check-ts-max-loc.ts b/scripts/check-ts-max-loc.ts index 44ac8fcfef16..893e822ae728 100644 --- a/scripts/check-ts-max-loc.ts +++ b/scripts/check-ts-max-loc.ts @@ -1,18 +1,28 @@ -// Check Ts Max Loc script supports OpenClaw repository automation. +// Enforces the TypeScript file-size ceiling while grandfathering the existing backlog. import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const DEFAULT_BASELINE_PATH = "scripts/ts-max-loc-baseline.json"; function writeStdoutLine(message: string): void { process.stdout.write(`${message}\n`); } -type ParsedArgs = { +export type ParsedArgs = { + baseRef?: string; + baselinePath: string; maxLines: number; + writeBaseline: boolean; }; -function parseArgs(argv: string[]): ParsedArgs { +export function parseArgs(argv: string[]): ParsedArgs { + let baseRef: string | undefined; + let baselinePath = DEFAULT_BASELINE_PATH; let maxLines = 500; + let writeBaseline = false; for (let index = 0; index < argv.length; index++) { const arg = argv[index]; @@ -28,14 +38,36 @@ function parseArgs(argv: string[]): ParsedArgs { index++; continue; } + if (arg === "--baseline") { + const next = argv[index + 1]; + if (!next) { + throw new Error("--baseline requires a path"); + } + baselinePath = next; + index++; + continue; + } + if (arg === "--base-ref") { + const next = argv[index + 1]; + if (!next || next.startsWith("-") || !/^[A-Za-z0-9_./-]+$/u.test(next)) { + throw new Error("--base-ref requires a git ref"); + } + baseRef = next; + index++; + continue; + } + if (arg === "--write-baseline") { + writeBaseline = true; + continue; + } throw new Error(`Unknown argument: ${arg}`); } - return { maxLines }; + return { baseRef, baselinePath, maxLines, writeBaseline }; } function gitLsFilesAll(): string[] { - // Include untracked files too so local refactors don’t “pass” by accident. + // Include untracked files too so local refactors do not pass by accident. const stdout = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], { encoding: "utf8", }); @@ -45,14 +77,198 @@ function gitLsFilesAll(): string[] { .filter(Boolean); } -async function countLines(filePath: string): Promise { - const content = await readFile(filePath, "utf8"); - // Count physical lines. Keeps the rule simple + predictable. - return content.split("\n").length; +export function isProductionTypeScriptFile(filePath: string): boolean { + return ( + /\.(?:ts|tsx|mts|cts)$/u.test(filePath) && + !/(^|\/)(test|tests|__tests__|test-helpers?|test-support)(\/|$)|\.(test|spec|suite)\.[cm]?tsx?$|(?:^|[/.-])test-(?:helpers?|support|harness)(?:[/.-]|$)/u.test( + filePath, + ) + ); } -async function main(argv = process.argv.slice(2)): Promise { - // Makes `... | head` safe. +export function countPhysicalLines(content: string): number { + if (content.length === 0) { + return 0; + } + const splitCount = content.split("\n").length; + return content.endsWith("\n") ? splitCount - 1 : splitCount; +} + +async function countLines(filePath: string): Promise { + const content = await readFile(filePath, "utf8"); + return countPhysicalLines(content); +} + +type LocResult = { + filePath: string; + lines: number; +}; + +type LocBaseline = Record; + +export type LocRatchetViolation = LocResult & { + baselineLines?: number; + reason: "baseline-missing" | "baseline-stale" | "grew"; +}; + +export function findLocRatchetViolations(params: { + baseline: LocBaseline; + maxLines: number; + results: LocResult[]; +}): LocRatchetViolation[] { + const currentByPath = new Map(params.results.map((result) => [result.filePath, result.lines])); + const violations: LocRatchetViolation[] = []; + + for (const result of params.results) { + const baselineLines = params.baseline[result.filePath]; + if (result.lines <= params.maxLines) { + if (baselineLines !== undefined) { + violations.push({ ...result, baselineLines, reason: "baseline-stale" }); + } + continue; + } + if (baselineLines === undefined) { + violations.push({ ...result, reason: "baseline-missing" }); + } else if (result.lines > baselineLines) { + violations.push({ ...result, baselineLines, reason: "grew" }); + } else if (result.lines < baselineLines) { + // Require the baseline to move down with every successful split. + violations.push({ ...result, baselineLines, reason: "baseline-stale" }); + } + } + + for (const [filePath, baselineLines] of Object.entries(params.baseline)) { + if (!currentByPath.has(filePath)) { + violations.push({ filePath, lines: 0, baselineLines, reason: "baseline-stale" }); + } + } + + return violations.toSorted( + (left, right) => right.lines - left.lines || left.filePath.localeCompare(right.filePath), + ); +} + +export function findLocBaselineUpdateViolations(params: { + baseline: LocBaseline; + maxLines: number; + results: LocResult[]; +}): LocRatchetViolation[] { + const violations: LocRatchetViolation[] = []; + for (const result of params.results) { + if (result.lines <= params.maxLines) { + continue; + } + const baselineLines = params.baseline[result.filePath]; + if (baselineLines === undefined) { + violations.push({ ...result, reason: "baseline-missing" }); + } else if (result.lines > baselineLines) { + violations.push({ ...result, baselineLines, reason: "grew" }); + } + } + return violations.toSorted( + (left, right) => right.lines - left.lines || left.filePath.localeCompare(right.filePath), + ); +} + +export function findVersionedBaselineViolations(params: { + baseline: LocBaseline; + baseBaseline: LocBaseline; +}): LocRatchetViolation[] { + const violations: LocRatchetViolation[] = []; + for (const [filePath, lines] of Object.entries(params.baseline)) { + const baselineLines = params.baseBaseline[filePath]; + if (baselineLines === undefined) { + violations.push({ filePath, lines, reason: "baseline-missing" }); + } else if (lines > baselineLines) { + violations.push({ filePath, lines, baselineLines, reason: "grew" }); + } + } + return violations.toSorted( + (left, right) => right.lines - left.lines || left.filePath.localeCompare(right.filePath), + ); +} + +async function readBaseline(filePath: string): Promise { + return parseBaseline(await readFile(filePath, "utf8"), filePath); +} + +function parseBaseline(content: string, source: string): LocBaseline { + const parsed = JSON.parse(content) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Invalid TypeScript LOC baseline: ${source}`); + } + const baseline: LocBaseline = {}; + for (const [entryPath, value] of Object.entries(parsed)) { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new Error(`Invalid TypeScript LOC baseline entry: ${entryPath}`); + } + baseline[entryPath] = value as number; + } + return baseline; +} + +function tryGitOutput(args: string[]): string | undefined { + try { + return execFileSync("git", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return undefined; + } +} + +function resolveComparisonBaseRef( + baselinePath: string, + explicitBaseRef?: string, +): string | undefined { + if (explicitBaseRef) { + return explicitBaseRef; + } + const head = tryGitOutput(["rev-parse", "HEAD"]); + const mergeBase = tryGitOutput(["merge-base", "HEAD", "origin/main"]); + if (mergeBase && mergeBase !== head) { + return mergeBase; + } + const changedBaselinePath = tryGitOutput(["diff", "--name-only", "HEAD", "--", baselinePath]); + if (changedBaselinePath?.split("\n").includes(baselinePath)) { + return "HEAD"; + } + return tryGitOutput(["rev-parse", "--verify", "HEAD^"]) ? "HEAD^" : undefined; +} + +function readBaselineAtRef( + baseRef: string | undefined, + baselinePath: string, +): LocBaseline | undefined { + if (!baseRef) { + return undefined; + } + if (!tryGitOutput(["rev-parse", "--verify", `${baseRef}^{commit}`])) { + throw new Error(`Invalid TypeScript LOC comparison ref: ${baseRef}`); + } + const content = tryGitOutput(["show", `${baseRef}:${baselinePath}`]); + return content === undefined ? undefined : parseBaseline(content, `${baseRef}:${baselinePath}`); +} + +function buildBaseline(results: LocResult[], maxLines: number): LocBaseline { + return Object.fromEntries( + results + .filter((result) => result.lines > maxLines) + .toSorted((left, right) => left.filePath.localeCompare(right.filePath)) + .map((result) => [result.filePath, result.lines]), + ); +} + +function reportViolations(violations: LocRatchetViolation[]): void { + for (const violation of violations) { + writeStdoutLine( + `${violation.lines}\t${violation.baselineLines ?? "-"}\t${violation.reason}\t${violation.filePath}`, + ); + } +} + +export async function main(argv = process.argv.slice(2)): Promise { process.stdout.on("error", (error: NodeJS.ErrnoException) => { if (error.code === "EPIPE") { process.exit(0); @@ -60,37 +276,58 @@ async function main(argv = process.argv.slice(2)): Promise { throw error; }); - const { maxLines } = parseArgs(argv); + const { baseRef, baselinePath, maxLines, writeBaseline } = parseArgs(argv); const files = gitLsFilesAll() .filter((filePath) => existsSync(filePath)) - .filter((filePath) => filePath.endsWith(".ts") || filePath.endsWith(".tsx")); - + .filter(isProductionTypeScriptFile); const results = await Promise.all( files.map(async (filePath) => ({ filePath, lines: await countLines(filePath) })), ); - const offenders = results - .filter((result) => result.lines > maxLines) - .toSorted((a, b) => b.lines - a.lines); - - if (!offenders.length) { + if (writeBaseline) { + const baseline = await readBaseline(baselinePath); + const comparisonBaseRef = resolveComparisonBaseRef(baselinePath, baseRef); + if (!comparisonBaseRef) { + throw new Error("Unable to resolve a comparison ref for the TypeScript LOC baseline update"); + } + const baseBaseline = readBaselineAtRef(comparisonBaseRef, baselinePath); + // A missing baseline at a valid base ref is the one-time initialization path. + const violations = [ + ...(baseBaseline ? findVersionedBaselineViolations({ baseline, baseBaseline }) : []), + ...findLocBaselineUpdateViolations({ baseline, maxLines, results }), + ]; + reportViolations(violations); + if (violations.length > 0) { + return 1; + } + const updatedBaseline = buildBaseline(results, maxLines); + await writeFile(baselinePath, `${JSON.stringify(updatedBaseline, null, 2)}\n`, "utf8"); + writeStdoutLine(`updated ${baselinePath} (${Object.keys(updatedBaseline).length} files)`); return 0; } - // Minimal, grep-friendly output. - for (const offender of offenders) { - writeStdoutLine(`${offender.lines}\t${offender.filePath}`); - } - - return 1; + const baseline = await readBaseline(baselinePath); + const baseBaseline = readBaselineAtRef( + resolveComparisonBaseRef(baselinePath, baseRef), + baselinePath, + ); + const violations = [ + ...(baseBaseline ? findVersionedBaselineViolations({ baseline, baseBaseline }) : []), + ...findLocRatchetViolations({ baseline, maxLines, results }), + ]; + reportViolations(violations); + return violations.length === 0 ? 0 : 1; } -try { - const exitCode = await main(); - if (exitCode !== 0) { - process.exit(exitCode); +const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined; +if (invokedPath === import.meta.url) { + try { + const exitCode = await main(); + if (exitCode !== 0) { + process.exit(exitCode); + } + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); } -} catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exit(1); } diff --git a/scripts/check.mjs b/scripts/check.mjs index f5d99bb23642..52940c731729 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -87,6 +87,7 @@ export async function main(argv = process.argv.slice(2)) { parallel: true, commands: [ { name: "conflict markers", args: ["check:no-conflict-markers"] }, + { name: "TypeScript LOC ratchet", args: ["check:loc"] }, { name: "changelog attributions", args: ["check:changelog-attributions"] }, { name: "database-first legacy-store guard", args: ["check:database-first-legacy-stores"] }, { diff --git a/scripts/ts-max-loc-baseline.json b/scripts/ts-max-loc-baseline.json new file mode 100644 index 000000000000..f864001b1cab --- /dev/null +++ b/scripts/ts-max-loc-baseline.json @@ -0,0 +1,1274 @@ +{ + "extensions/acpx/src/codex-auth-bridge.ts": 790, + "extensions/acpx/src/runtime.ts": 1402, + "extensions/active-memory/index.ts": 4182, + "extensions/amazon-bedrock-mantle/discovery.ts": 523, + "extensions/amazon-bedrock/discovery.ts": 715, + "extensions/amazon-bedrock/register.sync.runtime.ts": 724, + "extensions/amazon-bedrock/stream.runtime.ts": 1185, + "extensions/anthropic/cli-shared.ts": 501, + "extensions/anthropic/register.runtime.ts": 938, + "extensions/anthropic/session-catalog.ts": 1491, + "extensions/anthropic/usage.ts": 517, + "extensions/bonjour/src/advertiser.ts": 787, + "extensions/brave/src/brave-web-search-provider.runtime.ts": 560, + "extensions/browser/src/browser-tool.actions.ts": 730, + "extensions/browser/src/browser-tool.ts": 1166, + "extensions/browser/src/browser/cdp.helpers.ts": 598, + "extensions/browser/src/browser/cdp.ts": 942, + "extensions/browser/src/browser/chrome-mcp.ts": 2499, + "extensions/browser/src/browser/chrome.executables.ts": 907, + "extensions/browser/src/browser/chrome.ts": 1489, + "extensions/browser/src/browser/config.ts": 772, + "extensions/browser/src/browser/extension-relay/relay-bridge.ts": 900, + "extensions/browser/src/browser/pw-role-snapshot.ts": 516, + "extensions/browser/src/browser/pw-session.ts": 2469, + "extensions/browser/src/browser/pw-tools-core.interactions.ts": 2141, + "extensions/browser/src/browser/pw-tools-core.snapshot.ts": 636, + "extensions/browser/src/browser/routes/agent.act.ts": 888, + "extensions/browser/src/browser/routes/agent.snapshot.ts": 902, + "extensions/browser/src/browser/routes/agent.storage.ts": 619, + "extensions/browser/src/browser/routes/basic.ts": 570, + "extensions/browser/src/browser/server-context.availability.ts": 605, + "extensions/browser/src/browser/server-context.lifecycle.ts": 546, + "extensions/browser/src/cli/browser-cli-manage.ts": 895, + "extensions/canvas/src/host/server.ts": 593, + "extensions/chutes/models.ts": 590, + "extensions/codex/src/app-server/approval-bridge.ts": 1395, + "extensions/codex/src/app-server/attempt-context.ts": 1064, + "extensions/codex/src/app-server/attempt-startup.ts": 713, + "extensions/codex/src/app-server/attempt-turn-watches.ts": 517, + "extensions/codex/src/app-server/auth-bridge.ts": 1016, + "extensions/codex/src/app-server/bounded-turn.ts": 535, + "extensions/codex/src/app-server/client.ts": 1071, + "extensions/codex/src/app-server/compact.ts": 916, + "extensions/codex/src/app-server/computer-use.ts": 1266, + "extensions/codex/src/app-server/config.ts": 2595, + "extensions/codex/src/app-server/context-engine-projection.ts": 514, + "extensions/codex/src/app-server/dynamic-tool-build.ts": 1000, + "extensions/codex/src/app-server/dynamic-tool-execution.ts": 578, + "extensions/codex/src/app-server/dynamic-tools.ts": 1550, + "extensions/codex/src/app-server/elicitation-bridge.ts": 993, + "extensions/codex/src/app-server/event-projector.ts": 3499, + "extensions/codex/src/app-server/native-subagent-monitor.ts": 1631, + "extensions/codex/src/app-server/native-subagent-task-mirror.ts": 590, + "extensions/codex/src/app-server/plugin-thread-config.ts": 751, + "extensions/codex/src/app-server/protocol.ts": 752, + "extensions/codex/src/app-server/rate-limits.ts": 776, + "extensions/codex/src/app-server/run-attempt.ts": 4181, + "extensions/codex/src/app-server/runtime-artifact.ts": 835, + "extensions/codex/src/app-server/sandbox-exec-server/http.ts": 506, + "extensions/codex/src/app-server/session-binding.ts": 1431, + "extensions/codex/src/app-server/shared-client.ts": 1232, + "extensions/codex/src/app-server/side-question.ts": 1324, + "extensions/codex/src/app-server/thread-lifecycle.ts": 3197, + "extensions/codex/src/app-server/transcript-mirror.ts": 771, + "extensions/codex/src/app-server/turn-router.ts": 595, + "extensions/codex/src/command-account.ts": 578, + "extensions/codex/src/command-formatters.ts": 533, + "extensions/codex/src/command-handlers.ts": 2594, + "extensions/codex/src/conversation-binding.ts": 1243, + "extensions/codex/src/migration/apply.ts": 541, + "extensions/codex/src/migration/auth.ts": 678, + "extensions/codex/src/migration/plan.ts": 572, + "extensions/codex/src/migration/session-binding-sidecars.ts": 898, + "extensions/codex/src/migration/source.ts": 654, + "extensions/codex/src/native-thread-tool.ts": 501, + "extensions/codex/src/node-cli-sessions.ts": 715, + "extensions/codex/src/session-catalog.ts": 1902, + "extensions/codex/src/supervision-tools.ts": 1225, + "extensions/comfy/workflow-runtime.ts": 879, + "extensions/copilot/harness.ts": 1134, + "extensions/copilot/src/attempt.ts": 1796, + "extensions/copilot/src/event-bridge.ts": 640, + "extensions/copilot/src/tool-bridge.ts": 886, + "extensions/crabbox/src/crabbox-worker-provider.ts": 780, + "extensions/device-pair/index.ts": 950, + "extensions/diagnostics-otel/src/service.ts": 4131, + "extensions/diagnostics-prometheus/src/service.ts": 1068, + "extensions/diffs/src/browser.ts": 585, + "extensions/diffs/src/render.ts": 793, + "extensions/diffs/src/tool.ts": 529, + "extensions/discord/src/actions/runtime.guild.ts": 737, + "extensions/discord/src/actions/runtime.messaging.shared.ts": 697, + "extensions/discord/src/approval-handler.runtime.ts": 697, + "extensions/discord/src/channel.ts": 791, + "extensions/discord/src/doctor-contract.ts": 619, + "extensions/discord/src/internal/gateway.ts": 523, + "extensions/discord/src/internal/rest-scheduler.ts": 593, + "extensions/discord/src/monitor/allow-list.ts": 632, + "extensions/discord/src/monitor/listeners.reactions.ts": 593, + "extensions/discord/src/monitor/message-handler.preflight.ts": 864, + "extensions/discord/src/monitor/message-handler.process.ts": 1367, + "extensions/discord/src/monitor/message-media.ts": 536, + "extensions/discord/src/monitor/model-picker.state.ts": 664, + "extensions/discord/src/monitor/model-picker.view.ts": 1014, + "extensions/discord/src/monitor/native-command-model-picker-interaction.ts": 780, + "extensions/discord/src/monitor/native-command.ts": 736, + "extensions/discord/src/monitor/provider.lifecycle.ts": 583, + "extensions/discord/src/monitor/provider.ts": 691, + "extensions/discord/src/monitor/thread-bindings.manager.ts": 556, + "extensions/discord/src/monitor/thread-bindings.state.ts": 563, + "extensions/discord/src/send.outbound.ts": 515, + "extensions/discord/src/send.shared.ts": 557, + "extensions/discord/src/voice/manager.ts": 1917, + "extensions/discord/src/voice/realtime.ts": 1815, + "extensions/elevenlabs/speech-provider.ts": 596, + "extensions/exa/src/exa-web-search-provider.runtime.ts": 632, + "extensions/fal/image-generation-provider.ts": 839, + "extensions/fal/video-generation-provider.ts": 714, + "extensions/feishu/src/bitable.ts": 785, + "extensions/feishu/src/bot-content.ts": 537, + "extensions/feishu/src/bot.ts": 1883, + "extensions/feishu/src/card-action.ts": 504, + "extensions/feishu/src/channel.ts": 1887, + "extensions/feishu/src/doctor.ts": 972, + "extensions/feishu/src/docx.ts": 1609, + "extensions/feishu/src/drive.ts": 900, + "extensions/feishu/src/media.ts": 983, + "extensions/feishu/src/monitor.account.ts": 538, + "extensions/feishu/src/monitor.comment.ts": 1389, + "extensions/feishu/src/monitor.transport.ts": 515, + "extensions/feishu/src/outbound.ts": 826, + "extensions/feishu/src/reply-dispatcher.ts": 947, + "extensions/feishu/src/send.ts": 848, + "extensions/feishu/src/setup-surface.ts": 616, + "extensions/feishu/src/streaming-card.ts": 737, + "extensions/file-transfer/src/shared/node-invoke-policy.ts": 1021, + "extensions/file-transfer/src/tools/dir-fetch-tool.ts": 713, + "extensions/firecrawl/src/firecrawl-client.ts": 620, + "extensions/github-copilot/index.ts": 714, + "extensions/google-meet/index.ts": 1320, + "extensions/google-meet/src/cli.ts": 2444, + "extensions/google-meet/src/config.ts": 598, + "extensions/google-meet/src/meet.ts": 1027, + "extensions/google-meet/src/node-host.ts": 520, + "extensions/google-meet/src/realtime-node.ts": 771, + "extensions/google-meet/src/realtime.ts": 1355, + "extensions/google-meet/src/runtime.ts": 1556, + "extensions/google-meet/src/transports/chrome.ts": 1729, + "extensions/google/realtime-voice-provider.ts": 1101, + "extensions/google/speech-provider.ts": 677, + "extensions/google/transport-stream.ts": 1499, + "extensions/google/video-generation-provider.ts": 634, + "extensions/googlechat/src/google-auth.runtime.ts": 570, + "extensions/googlechat/src/monitor.ts": 537, + "extensions/imessage/src/actions.runtime.ts": 612, + "extensions/imessage/src/actions.ts": 910, + "extensions/imessage/src/approval-reactions.ts": 991, + "extensions/imessage/src/monitor-reply-cache.ts": 585, + "extensions/imessage/src/monitor/catchup.ts": 564, + "extensions/imessage/src/monitor/inbound-processing.ts": 1117, + "extensions/imessage/src/monitor/monitor-provider.ts": 1878, + "extensions/imessage/src/send.ts": 1214, + "extensions/line/src/bot-handlers.ts": 646, + "extensions/line/src/bot-message-context.ts": 609, + "extensions/line/src/flex-templates/media-control-cards.ts": 556, + "extensions/line/src/monitor.ts": 520, + "extensions/line/src/send.ts": 529, + "extensions/lmstudio/src/models.ts": 574, + "extensions/lmstudio/src/setup.ts": 881, + "extensions/logbook/src/service.ts": 696, + "extensions/logbook/src/store.ts": 583, + "extensions/matrix/doctor-contract-api.ts": 538, + "extensions/matrix/src/approval-handler.runtime.ts": 609, + "extensions/matrix/src/channel.ts": 679, + "extensions/matrix/src/cli.ts": 2318, + "extensions/matrix/src/matrix/actions/verification.ts": 590, + "extensions/matrix/src/matrix/client/config.ts": 845, + "extensions/matrix/src/matrix/client/file-sync-store.ts": 604, + "extensions/matrix/src/matrix/client/storage.ts": 692, + "extensions/matrix/src/matrix/crypto-state-store.ts": 631, + "extensions/matrix/src/matrix/monitor/config.ts": 570, + "extensions/matrix/src/matrix/monitor/handler.ts": 2585, + "extensions/matrix/src/matrix/monitor/index.ts": 551, + "extensions/matrix/src/matrix/monitor/room-history.ts": 564, + "extensions/matrix/src/matrix/monitor/verification-events.ts": 636, + "extensions/matrix/src/matrix/sdk.ts": 2200, + "extensions/matrix/src/matrix/sdk/verification-manager.ts": 801, + "extensions/matrix/src/matrix/send.ts": 699, + "extensions/matrix/src/matrix/thread-bindings.ts": 717, + "extensions/matrix/src/onboarding.ts": 776, + "extensions/matrix/src/tool-actions.ts": 566, + "extensions/mattermost/src/channel.ts": 961, + "extensions/mattermost/src/mattermost/client.ts": 736, + "extensions/mattermost/src/mattermost/interactions.ts": 677, + "extensions/mattermost/src/mattermost/monitor.ts": 2476, + "extensions/mattermost/src/mattermost/send.ts": 554, + "extensions/mattermost/src/mattermost/slash-commands.ts": 620, + "extensions/mattermost/src/mattermost/slash-http.ts": 948, + "extensions/memory-core/doctor-contract-api.ts": 1262, + "extensions/memory-core/src/cli.runtime.ts": 2133, + "extensions/memory-core/src/concept-vocabulary.ts": 509, + "extensions/memory-core/src/dreaming-narrative.ts": 1040, + "extensions/memory-core/src/dreaming-phases.ts": 2095, + "extensions/memory-core/src/dreaming.ts": 1011, + "extensions/memory-core/src/memory/manager-embedding-ops.ts": 1040, + "extensions/memory-core/src/memory/manager-search.ts": 990, + "extensions/memory-core/src/memory/manager-sync-ops.ts": 2955, + "extensions/memory-core/src/memory/manager.ts": 1671, + "extensions/memory-core/src/memory/qmd-manager.ts": 3993, + "extensions/memory-core/src/memory/search-manager.ts": 787, + "extensions/memory-core/src/rem-evidence.ts": 1100, + "extensions/memory-core/src/short-term-promotion.ts": 2929, + "extensions/memory-core/src/tools.ts": 869, + "extensions/memory-lancedb/index.ts": 2026, + "extensions/memory-wiki/src/chatgpt-import.ts": 927, + "extensions/memory-wiki/src/cli.ts": 1267, + "extensions/memory-wiki/src/compile.ts": 1528, + "extensions/memory-wiki/src/lint.ts": 543, + "extensions/memory-wiki/src/markdown.ts": 761, + "extensions/memory-wiki/src/okf.ts": 719, + "extensions/memory-wiki/src/query.ts": 1663, + "extensions/microsoft-foundry/onboard.ts": 646, + "extensions/microsoft-foundry/shared.ts": 774, + "extensions/migrate-hermes/auth.ts": 532, + "extensions/minimax/music-generation-provider.ts": 524, + "extensions/minimax/video-generation-provider.ts": 578, + "extensions/moonshot/src/kimi-web-search-provider.runtime.ts": 520, + "extensions/msteams/doctor-contract-api.ts": 570, + "extensions/msteams/src/attachments/shared.ts": 742, + "extensions/msteams/src/channel.ts": 1344, + "extensions/msteams/src/graph-messages.ts": 528, + "extensions/msteams/src/messenger.ts": 576, + "extensions/msteams/src/monitor-handler/message-handler.ts": 1117, + "extensions/msteams/src/monitor.ts": 715, + "extensions/msteams/src/reply-dispatcher.ts": 572, + "extensions/msteams/src/resolve-allowlist.ts": 557, + "extensions/msteams/src/send.ts": 672, + "extensions/nostr/src/nostr-bus.ts": 799, + "extensions/nostr/src/nostr-profile-http.ts": 551, + "extensions/oc-path/src/cli.ts": 593, + "extensions/oc-path/src/oc-path/find.ts": 829, + "extensions/oc-path/src/oc-path/oc-path.ts": 827, + "extensions/oc-path/src/oc-path/universal.ts": 1013, + "extensions/ollama/index.ts": 772, + "extensions/ollama/src/node-inference.ts": 551, + "extensions/ollama/src/setup.ts": 777, + "extensions/ollama/src/stream.ts": 1465, + "extensions/openai/image-generation-provider.ts": 1079, + "extensions/openai/openai-chatgpt-oauth-flow.runtime.ts": 632, + "extensions/openai/openai-chatgpt-provider.ts": 718, + "extensions/openai/openai-provider.ts": 1068, + "extensions/openai/provider-policy-api.ts": 552, + "extensions/openai/realtime-voice-provider.ts": 1587, + "extensions/opencode/provider-catalog.ts": 525, + "extensions/openrouter/video-generation-provider.ts": 590, + "extensions/openshell/src/backend.ts": 1005, + "extensions/openshell/src/fs-bridge.ts": 641, + "extensions/perplexity/src/perplexity-web-search-provider.runtime.ts": 560, + "extensions/phone-control/index.ts": 803, + "extensions/pixverse/video-generation-provider.ts": 521, + "extensions/policy/src/doctor/metadata.ts": 552, + "extensions/policy/src/doctor/register.ts": 5594, + "extensions/policy/src/policy-conformance.ts": 631, + "extensions/policy/src/policy-state.ts": 3083, + "extensions/qa-lab/src/agentic-parity-report.ts": 800, + "extensions/qa-lab/src/character-eval.ts": 735, + "extensions/qa-lab/src/cli.runtime.ts": 1783, + "extensions/qa-lab/src/cli.ts": 999, + "extensions/qa-lab/src/confidence-report.ts": 1302, + "extensions/qa-lab/src/coverage-report.ts": 577, + "extensions/qa-lab/src/evidence-gallery.ts": 976, + "extensions/qa-lab/src/evidence-summary.ts": 832, + "extensions/qa-lab/src/gateway-child.ts": 1606, + "extensions/qa-lab/src/gateway-process-boundary.ts": 894, + "extensions/qa-lab/src/lab-server.ts": 891, + "extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts": 1964, + "extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts": 651, + "extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts": 3787, + "extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts": 2063, + "extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts": 4470, + "extensions/qa-lab/src/mantis/cli.ts": 507, + "extensions/qa-lab/src/mantis/desktop-browser-smoke.runtime.ts": 509, + "extensions/qa-lab/src/mantis/run.runtime.ts": 646, + "extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts": 1583, + "extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.ts": 832, + "extensions/qa-lab/src/mantis/visual-task.runtime.ts": 820, + "extensions/qa-lab/src/multipass.runtime.ts": 698, + "extensions/qa-lab/src/providers/mock-openai/server.ts": 4072, + "extensions/qa-lab/src/qa-credentials-admin.runtime.ts": 532, + "extensions/qa-lab/src/runtime-parity.ts": 1167, + "extensions/qa-lab/src/runtime-tool-fixture.ts": 864, + "extensions/qa-lab/src/scenario-catalog.ts": 557, + "extensions/qa-lab/src/scorecard-taxonomy.ts": 1162, + "extensions/qa-lab/src/suite-launch.runtime.ts": 785, + "extensions/qa-lab/src/suite-runtime-agent-process.ts": 602, + "extensions/qa-lab/src/suite.ts": 2085, + "extensions/qa-lab/src/test-file-scenario-runner.ts": 614, + "extensions/qa-lab/src/tool-search-gateway.fixture.ts": 517, + "extensions/qa-lab/web/src/app.ts": 1771, + "extensions/qa-lab/web/src/ui-render.ts": 4222, + "extensions/qa-matrix/src/runners/contract/runtime.ts": 1352, + "extensions/qa-matrix/src/runners/contract/scenario-catalog.ts": 1191, + "extensions/qa-matrix/src/runners/contract/scenario-runtime-approval.ts": 731, + "extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee-destructive.ts": 1731, + "extensions/qa-matrix/src/runners/contract/scenario-runtime-e2ee.ts": 3669, + "extensions/qa-matrix/src/runners/contract/scenario-runtime-room.ts": 1256, + "extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts": 709, + "extensions/qa-matrix/src/runners/contract/scenario-runtime-state-files.ts": 573, + "extensions/qa-matrix/src/substrate/client.ts": 929, + "extensions/qa-matrix/src/substrate/config.ts": 710, + "extensions/qa-matrix/src/substrate/e2ee-client.ts": 588, + "extensions/qa-matrix/src/substrate/recording-proxy.ts": 732, + "extensions/qqbot/src/engine/api/media-chunked.ts": 635, + "extensions/qqbot/src/engine/gateway/outbound-dispatch.ts": 861, + "extensions/qqbot/src/engine/messaging/markdown-table-chunking.ts": 622, + "extensions/qqbot/src/engine/messaging/outbound-deliver.ts": 954, + "extensions/qqbot/src/engine/messaging/outbound-media-send.ts": 963, + "extensions/qqbot/src/engine/messaging/reply-dispatcher.ts": 715, + "extensions/qqbot/src/engine/messaging/sender.ts": 787, + "extensions/qqbot/src/engine/messaging/streaming-c2c.ts": 1212, + "extensions/qqbot/src/engine/messaging/streaming-media-send.ts": 569, + "extensions/qqbot/src/engine/utils/audio.ts": 531, + "extensions/signal/src/approval-reactions.ts": 1000, + "extensions/signal/src/channel.ts": 774, + "extensions/signal/src/client-container.ts": 864, + "extensions/signal/src/monitor.ts": 759, + "extensions/signal/src/monitor/event-handler.ts": 1320, + "extensions/slack/src/action-runtime.ts": 951, + "extensions/slack/src/actions.ts": 706, + "extensions/slack/src/approval-handler.runtime.ts": 526, + "extensions/slack/src/blocks-render.ts": 646, + "extensions/slack/src/channel.ts": 950, + "extensions/slack/src/monitor/auth.ts": 639, + "extensions/slack/src/monitor/context.ts": 801, + "extensions/slack/src/monitor/events/interactions.block-actions.ts": 1155, + "extensions/slack/src/monitor/media.ts": 507, + "extensions/slack/src/monitor/message-handler/dispatch.ts": 2232, + "extensions/slack/src/monitor/message-handler/prepare.ts": 1715, + "extensions/slack/src/monitor/provider-support.ts": 510, + "extensions/slack/src/monitor/provider.ts": 801, + "extensions/slack/src/monitor/replies.ts": 623, + "extensions/slack/src/monitor/slash.ts": 1098, + "extensions/slack/src/reply-blocks.ts": 505, + "extensions/slack/src/send.ts": 1422, + "extensions/sms/src/twilio.ts": 566, + "extensions/synology-chat/src/webhook-handler.ts": 675, + "extensions/telegram/src/action-runtime.ts": 918, + "extensions/telegram/src/bot-handlers.runtime.ts": 4200, + "extensions/telegram/src/bot-message-context.body.ts": 521, + "extensions/telegram/src/bot-message-context.session.ts": 723, + "extensions/telegram/src/bot-message-context.ts": 668, + "extensions/telegram/src/bot-message-dispatch.ts": 3196, + "extensions/telegram/src/bot-native-command-menu.ts": 593, + "extensions/telegram/src/bot-native-commands.ts": 1970, + "extensions/telegram/src/bot/body-helpers.ts": 600, + "extensions/telegram/src/bot/delivery.replies.ts": 1047, + "extensions/telegram/src/bot/delivery.resolve-media.ts": 524, + "extensions/telegram/src/bot/helpers.ts": 686, + "extensions/telegram/src/channel.ts": 1229, + "extensions/telegram/src/doctor.ts": 629, + "extensions/telegram/src/draft-stream.ts": 914, + "extensions/telegram/src/fetch.ts": 900, + "extensions/telegram/src/format.ts": 1676, + "extensions/telegram/src/message-cache.ts": 884, + "extensions/telegram/src/polling-session.ts": 1795, + "extensions/telegram/src/send.ts": 2719, + "extensions/telegram/src/state-migrations.ts": 677, + "extensions/telegram/src/thread-bindings.ts": 1033, + "extensions/telegram/src/webhook.ts": 1112, + "extensions/tlon/src/monitor/index.ts": 1524, + "extensions/tlon/src/urbit/sse-client.ts": 565, + "extensions/tts-local-cli/speech-provider.ts": 539, + "extensions/twitch/src/setup-surface.ts": 524, + "extensions/vault/src/cli.ts": 581, + "extensions/voice-call/index.ts": 906, + "extensions/voice-call/src/cli.ts": 929, + "extensions/voice-call/src/config.ts": 940, + "extensions/voice-call/src/manager/outbound.ts": 543, + "extensions/voice-call/src/media-stream.ts": 867, + "extensions/voice-call/src/providers/plivo.ts": 628, + "extensions/voice-call/src/providers/twilio.ts": 867, + "extensions/voice-call/src/runtime.ts": 576, + "extensions/voice-call/src/webhook-security.ts": 944, + "extensions/voice-call/src/webhook.ts": 1031, + "extensions/voice-call/src/webhook/realtime-handler.ts": 1501, + "extensions/webhooks/src/http.ts": 833, + "extensions/whatsapp/src/approval-reactions.ts": 655, + "extensions/whatsapp/src/auth-store.ts": 512, + "extensions/whatsapp/src/auto-reply/monitor.ts": 727, + "extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts": 909, + "extensions/whatsapp/src/auto-reply/monitor/process-message.ts": 622, + "extensions/whatsapp/src/connection-controller.ts": 885, + "extensions/whatsapp/src/inbound/extract.ts": 506, + "extensions/whatsapp/src/inbound/monitor.ts": 1865, + "extensions/whatsapp/src/login-qr.ts": 598, + "extensions/whatsapp/src/qa-driver.runtime.ts": 580, + "extensions/whatsapp/src/session.ts": 565, + "extensions/workboard/src/gateway.ts": 704, + "extensions/workboard/src/sqlite-store.ts": 1430, + "extensions/workboard/src/store.ts": 4366, + "extensions/workboard/src/tools.ts": 1044, + "extensions/workspaces/src/gateway.ts": 830, + "extensions/workspaces/src/tools.ts": 905, + "extensions/xai/video-generation-provider.ts": 591, + "extensions/xai/xai-oauth.ts": 754, + "extensions/zalo/src/monitor.ts": 1030, + "extensions/zalouser/src/monitor.ts": 1061, + "extensions/zalouser/src/setup-surface.ts": 504, + "extensions/zalouser/src/text-styles.ts": 547, + "extensions/zalouser/src/zalo-js.ts": 1995, + "packages/agent-core/src/agent-loop.ts": 1133, + "packages/agent-core/src/agent.ts": 622, + "packages/agent-core/src/harness/agent-harness.ts": 1214, + "packages/agent-core/src/harness/compaction/compaction.ts": 925, + "packages/agent-core/src/harness/env/nodejs.ts": 651, + "packages/agent-core/src/harness/types.ts": 842, + "packages/agent-core/src/types.ts": 545, + "packages/ai/src/providers/agent-tools-parameter-schema.ts": 971, + "packages/ai/src/providers/anthropic.ts": 1811, + "packages/ai/src/providers/google-shared.ts": 936, + "packages/ai/src/providers/mistral.ts": 1031, + "packages/ai/src/providers/openai-chatgpt-responses.ts": 1824, + "packages/ai/src/providers/openai-completions.ts": 1457, + "packages/ai/src/providers/openai-responses-shared.ts": 1315, + "packages/gateway-client/src/client.ts": 1664, + "packages/gateway-protocol/src/connect-error-details.ts": 528, + "packages/gateway-protocol/src/index.ts": 2456, + "packages/gateway-protocol/src/schema/agents-models-skills.ts": 1171, + "packages/gateway-protocol/src/schema/audit-activity.ts": 637, + "packages/gateway-protocol/src/schema/channels.ts": 924, + "packages/gateway-protocol/src/schema/cron.ts": 683, + "packages/gateway-protocol/src/schema/protocol-schemas.ts": 982, + "packages/gateway-protocol/src/schema/sessions.ts": 838, + "packages/gateway-protocol/src/schema/worker-admission.ts": 839, + "packages/gateway-protocol/src/schema/worker-inference.ts": 648, + "packages/llm-core/src/types.ts": 677, + "packages/markdown-core/src/ir.ts": 1161, + "packages/memory-host-sdk/src/host/backend-config.ts": 510, + "packages/memory-host-sdk/src/host/internal.ts": 555, + "packages/memory-host-sdk/src/host/memory-schema.ts": 667, + "packages/memory-host-sdk/src/host/query-expansion.ts": 776, + "packages/memory-host-sdk/src/host/session-files.ts": 900, + "packages/model-catalog-core/src/model-catalog-normalize.ts": 765, + "packages/sdk/src/client.ts": 976, + "packages/speech-core/src/tts.ts": 2138, + "packages/terminal-core/src/table.ts": 732, + "packages/tool-call-repair/src/payload.ts": 743, + "packages/tool-call-repair/src/stream-normalizer.ts": 1631, + "qa/convex-credential-broker/convex/credentials.ts": 817, + "scripts/android-app-i18n.ts": 1293, + "scripts/anthropic-prompt-probe.ts": 1001, + "scripts/apple-app-i18n.ts": 965, + "scripts/bench-cli-startup.ts": 1316, + "scripts/bench-gateway-restart.ts": 1697, + "scripts/bench-gateway-startup.ts": 1035, + "scripts/bench-sqlite-state.ts": 674, + "scripts/control-ui-i18n.ts": 1749, + "scripts/control-ui-mock-dev.ts": 1401, + "scripts/debug-claude-usage.ts": 562, + "scripts/dev/discord-acp-plain-language-smoke.ts": 1129, + "scripts/dev/realtime-talk-live-smoke.ts": 858, + "scripts/dev/tui-pty-test-watch.ts": 528, + "scripts/e2e/parallels/guest-transports.ts": 614, + "scripts/e2e/parallels/host-command.ts": 783, + "scripts/e2e/parallels/linux-smoke.ts": 904, + "scripts/e2e/parallels/macos-smoke.ts": 1295, + "scripts/e2e/parallels/npm-update-smoke.ts": 1605, + "scripts/e2e/parallels/windows-smoke.ts": 868, + "scripts/e2e/telegram-user-crabbox-proof.ts": 2945, + "scripts/e2e/telegram-user-credential.ts": 720, + "scripts/label-open-issues.ts": 1066, + "scripts/lib/plugin-clawhub-release.ts": 711, + "scripts/lib/plugin-npm-release.ts": 783, + "scripts/lib/release-beta-verifier.ts": 1436, + "scripts/lib/sqlite-reliability-runner.ts": 638, + "scripts/native-app-i18n.ts": 1667, + "scripts/openclaw-cross-os-release-checks.ts": 4969, + "scripts/openclaw-npm-postpublish-verify.ts": 1242, + "scripts/openclaw-npm-release-check.ts": 794, + "scripts/plugin-boundary-report.ts": 698, + "scripts/protocol-gen-swift.ts": 736, + "scripts/qa/render-maturity-docs.ts": 1288, + "scripts/qa/ux-matrix-evidence-producer.ts": 826, + "scripts/release-check.ts": 1394, + "scripts/repro/code-mode-namespace-live.ts": 662, + "scripts/test-skip-inventory.ts": 528, + "scripts/update-clawtributors.ts": 978, + "scripts/write-cli-startup-metadata.ts": 954, + "src/acp/control-plane/manager.core.ts": 617, + "src/acp/event-ledger.ts": 976, + "src/acp/runtime/session-meta.ts": 659, + "src/acp/translator.ts": 1783, + "src/agents/acp-spawn-parent-stream.ts": 775, + "src/agents/acp-spawn.ts": 1729, + "src/agents/agent-bundle-lsp-runtime.ts": 596, + "src/agents/agent-bundle-mcp-materialize.ts": 556, + "src/agents/agent-bundle-mcp-runtime.ts": 1307, + "src/agents/agent-command.ts": 3061, + "src/agents/agent-hooks/compaction-safeguard.ts": 1364, + "src/agents/agent-scope.ts": 628, + "src/agents/agent-tool-definition-adapter.ts": 566, + "src/agents/agent-tools.before-tool-call.ts": 2084, + "src/agents/agent-tools.policy.ts": 530, + "src/agents/agent-tools.read.ts": 1193, + "src/agents/agent-tools.ts": 1219, + "src/agents/anthropic-transport-stream.ts": 1885, + "src/agents/apply-patch.ts": 716, + "src/agents/auth-profiles/oauth-manager.ts": 848, + "src/agents/auth-profiles/oauth.ts": 511, + "src/agents/auth-profiles/order.ts": 502, + "src/agents/auth-profiles/persisted.ts": 832, + "src/agents/auth-profiles/store.ts": 1706, + "src/agents/auth-profiles/usage.ts": 973, + "src/agents/bash-tools.exec-host-gateway.ts": 1264, + "src/agents/bash-tools.exec-host-node-phases.ts": 673, + "src/agents/bash-tools.exec-host-node.ts": 693, + "src/agents/bash-tools.exec-host-shared.ts": 569, + "src/agents/bash-tools.exec-runtime.ts": 1025, + "src/agents/bash-tools.exec.ts": 2141, + "src/agents/bash-tools.process.ts": 781, + "src/agents/btw.ts": 1299, + "src/agents/cli-backends.ts": 524, + "src/agents/cli-credentials.ts": 651, + "src/agents/cli-executable-identity.ts": 580, + "src/agents/cli-output.ts": 1532, + "src/agents/cli-runner.ts": 1431, + "src/agents/cli-runner/claude-live-session.ts": 1592, + "src/agents/cli-runner/execute.ts": 2081, + "src/agents/cli-runner/helpers.ts": 596, + "src/agents/cli-runner/prepare.ts": 1451, + "src/agents/cli-runner/session-history.ts": 620, + "src/agents/code-mode-namespaces.ts": 1127, + "src/agents/code-mode.ts": 1753, + "src/agents/code-mode.worker.ts": 793, + "src/agents/command/attempt-execution.helpers.ts": 542, + "src/agents/command/attempt-execution.ts": 1417, + "src/agents/command/cli-compaction.ts": 809, + "src/agents/command/delivery.ts": 968, + "src/agents/command/session-store.ts": 566, + "src/agents/embedded-agent-helpers/errors.ts": 1781, + "src/agents/embedded-agent-helpers/openai.ts": 524, + "src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts": 524, + "src/agents/embedded-agent-message-tool-source-reply.ts": 570, + "src/agents/embedded-agent-runner/compact.hooks.harness.ts": 1065, + "src/agents/embedded-agent-runner/compact.queued.ts": 1080, + "src/agents/embedded-agent-runner/compact.ts": 2021, + "src/agents/embedded-agent-runner/context-engine-maintenance.ts": 728, + "src/agents/embedded-agent-runner/extra-params.ts": 1159, + "src/agents/embedded-agent-runner/google-prompt-cache.ts": 634, + "src/agents/embedded-agent-runner/model.static-catalog.ts": 618, + "src/agents/embedded-agent-runner/model.ts": 1995, + "src/agents/embedded-agent-runner/replay-history.ts": 937, + "src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts": 953, + "src/agents/embedded-agent-runner/run.ts": 5080, + "src/agents/embedded-agent-runner/run/attempt.llm-boundary.ts": 637, + "src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.ts": 894, + "src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts": 721, + "src/agents/embedded-agent-runner/run/attempt.session-lock.ts": 2209, + "src/agents/embedded-agent-runner/run/attempt.tool-call-argument-repair.ts": 798, + "src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts": 1211, + "src/agents/embedded-agent-runner/run/attempt.ts": 5898, + "src/agents/embedded-agent-runner/run/auth-controller.ts": 676, + "src/agents/embedded-agent-runner/run/images.ts": 665, + "src/agents/embedded-agent-runner/run/incomplete-turn.ts": 823, + "src/agents/embedded-agent-runner/run/llm-idle-timeout.ts": 615, + "src/agents/embedded-agent-runner/run/payloads.ts": 1018, + "src/agents/embedded-agent-runner/runs.ts": 950, + "src/agents/embedded-agent-runner/thinking.ts": 767, + "src/agents/embedded-agent-runner/tool-result-context-guard.ts": 573, + "src/agents/embedded-agent-runner/tool-result-truncation.ts": 1561, + "src/agents/embedded-agent-runner/transcript-file-state.ts": 1013, + "src/agents/embedded-agent-runner/transcript-rewrite.ts": 585, + "src/agents/embedded-agent-subscribe.handlers.messages.ts": 1508, + "src/agents/embedded-agent-subscribe.handlers.tools.ts": 1729, + "src/agents/embedded-agent-subscribe.tools.ts": 1159, + "src/agents/embedded-agent-subscribe.ts": 1512, + "src/agents/failover-error.ts": 831, + "src/agents/harness/compaction.ts": 542, + "src/agents/harness/native-hook-relay.ts": 2486, + "src/agents/harness/selection.ts": 773, + "src/agents/harness/tool-result-middleware.ts": 557, + "src/agents/live-cache-regression-runner.ts": 883, + "src/agents/main-session-restart-recovery.ts": 1333, + "src/agents/memory-search.ts": 521, + "src/agents/model-auth-availability.ts": 1020, + "src/agents/model-auth.ts": 2041, + "src/agents/model-catalog.ts": 998, + "src/agents/model-fallback.ts": 2074, + "src/agents/model-provider-auth.ts": 723, + "src/agents/model-scan.ts": 562, + "src/agents/model-selection-shared.ts": 1673, + "src/agents/models-config.providers.implicit.ts": 580, + "src/agents/modes/interactive/theme/theme.ts": 857, + "src/agents/openai-completions-transport.ts": 1948, + "src/agents/openai-responses-transport.ts": 2604, + "src/agents/openclaw-tools.ts": 712, + "src/agents/provider-attribution.ts": 864, + "src/agents/provider-local-service.ts": 786, + "src/agents/provider-request-config.ts": 846, + "src/agents/provider-transport-fetch.ts": 925, + "src/agents/runtime-plan/prepare-auth.ts": 581, + "src/agents/runtime-plan/types.ts": 585, + "src/agents/runtime/proxy.ts": 569, + "src/agents/sandbox/browser.ts": 545, + "src/agents/sandbox/docker.ts": 702, + "src/agents/sandbox/fs-bridge-mutation-helper.ts": 526, + "src/agents/sandbox/registry.ts": 726, + "src/agents/sandbox/remote-fs-bridge.ts": 739, + "src/agents/sandbox/ssh.ts": 963, + "src/agents/session-file-repair.ts": 983, + "src/agents/session-tool-result-guard.ts": 944, + "src/agents/session-transcript-repair.ts": 827, + "src/agents/session-write-lock.ts": 1103, + "src/agents/sessions/agent-session.ts": 3338, + "src/agents/sessions/auth-storage.ts": 566, + "src/agents/sessions/extensions/loader.ts": 720, + "src/agents/sessions/extensions/runner.ts": 1147, + "src/agents/sessions/extensions/types.ts": 1696, + "src/agents/sessions/model-registry.ts": 955, + "src/agents/sessions/model-resolver.ts": 635, + "src/agents/sessions/package-manager.ts": 1528, + "src/agents/sessions/resource-loader.ts": 1038, + "src/agents/sessions/sdk.ts": 510, + "src/agents/sessions/session-manager.ts": 3342, + "src/agents/sessions/settings-manager.ts": 1134, + "src/agents/sessions/tools/edit-diff.ts": 798, + "src/agents/sessions/tools/edit.ts": 608, + "src/agents/shell-snapshot.ts": 503, + "src/agents/subagent-announce-delivery.ts": 1982, + "src/agents/subagent-announce-output.ts": 592, + "src/agents/subagent-announce.ts": 662, + "src/agents/subagent-control.ts": 936, + "src/agents/subagent-orphan-recovery.ts": 656, + "src/agents/subagent-registry-lifecycle.ts": 2070, + "src/agents/subagent-registry-queries.ts": 532, + "src/agents/subagent-registry-run-manager.ts": 1043, + "src/agents/subagent-registry.ts": 2051, + "src/agents/subagent-spawn.ts": 1759, + "src/agents/system-prompt.ts": 1442, + "src/agents/tool-call-id.ts": 557, + "src/agents/tool-display-common.ts": 818, + "src/agents/tool-display-config.ts": 665, + "src/agents/tool-display-exec-shell.ts": 573, + "src/agents/tool-display-exec.ts": 623, + "src/agents/tool-loop-detection.ts": 820, + "src/agents/tool-mutation.ts": 584, + "src/agents/tool-search.ts": 2420, + "src/agents/tools/common.ts": 585, + "src/agents/tools/computer-tool.ts": 965, + "src/agents/tools/cron-tool.ts": 1333, + "src/agents/tools/gateway-tool.ts": 652, + "src/agents/tools/gateway.ts": 538, + "src/agents/tools/image-generate-tool.ts": 1222, + "src/agents/tools/image-tool.ts": 1095, + "src/agents/tools/media-generate-background-shared.ts": 865, + "src/agents/tools/media-tool-shared.ts": 689, + "src/agents/tools/message-tool.ts": 1756, + "src/agents/tools/music-generate-tool.ts": 853, + "src/agents/tools/pdf-tool.ts": 563, + "src/agents/tools/session-status-tool.ts": 934, + "src/agents/tools/sessions-send-tool.ts": 813, + "src/agents/tools/sessions-spawn-tool.ts": 513, + "src/agents/tools/transcripts-tool.ts": 569, + "src/agents/tools/video-generate-tool.ts": 1318, + "src/agents/tools/web-fetch-utils.ts": 701, + "src/agents/tools/web-fetch.ts": 884, + "src/agents/transcript-redact.ts": 761, + "src/agents/workspace.ts": 1328, + "src/agents/worktrees/service.ts": 851, + "src/audit/audit-event-store.ts": 703, + "src/auto-reply/chunk.ts": 557, + "src/auto-reply/command-auth.ts": 710, + "src/auto-reply/commands-registry.shared.ts": 1061, + "src/auto-reply/dispatch.ts": 737, + "src/auto-reply/reply/abort.ts": 524, + "src/auto-reply/reply/acp-projector.ts": 530, + "src/auto-reply/reply/agent-runner-cli-dispatch.ts": 731, + "src/auto-reply/reply/agent-runner-execution.ts": 3412, + "src/auto-reply/reply/agent-runner-memory.ts": 1586, + "src/auto-reply/reply/agent-runner-payloads.ts": 514, + "src/auto-reply/reply/agent-runner.ts": 2858, + "src/auto-reply/reply/commands-acp/lifecycle.ts": 895, + "src/auto-reply/reply/commands-acp/shared.ts": 539, + "src/auto-reply/reply/commands-allowlist.ts": 607, + "src/auto-reply/reply/commands-diagnostics.ts": 644, + "src/auto-reply/reply/commands-models.ts": 769, + "src/auto-reply/reply/commands-plugins.ts": 612, + "src/auto-reply/reply/commands-session.ts": 824, + "src/auto-reply/reply/commands-tts.ts": 510, + "src/auto-reply/reply/context-treemap.ts": 517, + "src/auto-reply/reply/directive-handling.impl.ts": 815, + "src/auto-reply/reply/directive-handling.model.ts": 529, + "src/auto-reply/reply/directive-handling.persist.ts": 503, + "src/auto-reply/reply/dispatch-acp-delivery.ts": 542, + "src/auto-reply/reply/dispatch-acp.ts": 812, + "src/auto-reply/reply/dispatch-from-config.ts": 4591, + "src/auto-reply/reply/followup-runner.ts": 2065, + "src/auto-reply/reply/get-reply-directives-apply.ts": 537, + "src/auto-reply/reply/get-reply-directives.ts": 738, + "src/auto-reply/reply/get-reply-inline-actions.ts": 641, + "src/auto-reply/reply/get-reply-run.ts": 1715, + "src/auto-reply/reply/get-reply.ts": 1166, + "src/auto-reply/reply/inbound-meta.ts": 845, + "src/auto-reply/reply/model-selection.ts": 696, + "src/auto-reply/reply/queue/drain.ts": 1368, + "src/auto-reply/reply/reply-dispatcher.ts": 626, + "src/auto-reply/reply/reply-run-registry.ts": 1309, + "src/auto-reply/reply/session.ts": 1218, + "src/channels/conversation-resolution.ts": 526, + "src/channels/inbound-event/context.ts": 547, + "src/channels/message-access/runtime.ts": 671, + "src/channels/message/ingress-queue.ts": 1245, + "src/channels/plugins/bundled.ts": 959, + "src/channels/plugins/catalog.ts": 554, + "src/channels/plugins/outbound/presentation-limits.ts": 632, + "src/channels/plugins/read-only.ts": 1042, + "src/channels/plugins/setup-helpers.ts": 547, + "src/channels/plugins/setup-wizard-helpers.ts": 1593, + "src/channels/plugins/setup-wizard.ts": 645, + "src/channels/plugins/types.adapters.ts": 886, + "src/channels/plugins/types.core.ts": 859, + "src/channels/progress-draft-compositor.ts": 595, + "src/channels/streaming.ts": 1321, + "src/channels/turn/kernel.ts": 835, + "src/cli/argv.ts": 635, + "src/cli/capability-cli.ts": 3019, + "src/cli/command-secret-gateway.ts": 1182, + "src/cli/command-secret-targets.ts": 1026, + "src/cli/completion-cli.ts": 620, + "src/cli/config-cli.ts": 2838, + "src/cli/cron-cli/register.cron-edit.ts": 643, + "src/cli/cron-cli/shared.ts": 585, + "src/cli/daemon-cli/lifecycle-core.ts": 724, + "src/cli/daemon-cli/restart-health.ts": 684, + "src/cli/daemon-cli/status.gather.ts": 848, + "src/cli/daemon-cli/status.print.ts": 567, + "src/cli/devices-cli.runtime.ts": 1211, + "src/cli/exec-approvals-cli.ts": 910, + "src/cli/gateway-cli/pre-bootstrap.ts": 712, + "src/cli/gateway-cli/register.ts": 927, + "src/cli/gateway-cli/run-loop.ts": 994, + "src/cli/gateway-cli/run.ts": 1101, + "src/cli/hooks-cli.ts": 595, + "src/cli/logs-cli.ts": 829, + "src/cli/mcp-cli.ts": 1347, + "src/cli/models-cli.ts": 528, + "src/cli/nodes-cli/register.status.ts": 622, + "src/cli/plugins-authoring-command.ts": 812, + "src/cli/plugins-cli.runtime.ts": 936, + "src/cli/plugins-install-command.ts": 1373, + "src/cli/program/register.status-health-sessions.ts": 765, + "src/cli/run-main.ts": 1429, + "src/cli/skills-cli.ts": 1109, + "src/cli/update-cli/update-command.ts": 4658, + "src/commands/agent-via-gateway.ts": 1018, + "src/commands/agents.commands.add.ts": 511, + "src/commands/backup-verify.ts": 831, + "src/commands/channels/add.ts": 521, + "src/commands/configure.wizard.ts": 886, + "src/commands/daemon-install-helpers.ts": 851, + "src/commands/doctor-auth-flat-profiles.ts": 1761, + "src/commands/doctor-auth.ts": 596, + "src/commands/doctor-device-pairing.ts": 659, + "src/commands/doctor-gateway-daemon-flow.ts": 505, + "src/commands/doctor-gateway-services.ts": 934, + "src/commands/doctor-memory-search.ts": 787, + "src/commands/doctor-plugin-registry.ts": 662, + "src/commands/doctor-sandbox.ts": 501, + "src/commands/doctor-session-snapshots.ts": 657, + "src/commands/doctor-session-sqlite-migration-run.ts": 1001, + "src/commands/doctor-session-sqlite.ts": 1254, + "src/commands/doctor-session-state-providers.ts": 583, + "src/commands/doctor-session-transcripts.ts": 534, + "src/commands/doctor-state-integrity.ts": 1499, + "src/commands/doctor.e2e-harness.ts": 704, + "src/commands/doctor/cron/index.ts": 694, + "src/commands/doctor/cron/legacy-store-migration.ts": 678, + "src/commands/doctor/cron/store-migration.ts": 648, + "src/commands/doctor/shared/codex-route-warnings.ts": 3235, + "src/commands/doctor/shared/legacy-config-core-normalizers.ts": 1537, + "src/commands/doctor/shared/legacy-config-migrations.channels.ts": 580, + "src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts": 1467, + "src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts": 1740, + "src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts": 553, + "src/commands/doctor/shared/missing-configured-plugin-install.ts": 2201, + "src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts": 606, + "src/commands/doctor/shared/preview-warnings.ts": 918, + "src/commands/doctor/shared/stale-auth-order.ts": 618, + "src/commands/doctor/shared/stale-plugin-config.ts": 518, + "src/commands/health.ts": 1079, + "src/commands/migrate.ts": 548, + "src/commands/migrate/selection.ts": 533, + "src/commands/models/auth.ts": 1134, + "src/commands/models/list.probe.ts": 709, + "src/commands/models/list.rows.ts": 793, + "src/commands/models/list.status-command.ts": 1625, + "src/commands/onboard-custom-config.ts": 749, + "src/commands/onboarding-plugin-install.ts": 1513, + "src/commands/sessions-tail.ts": 697, + "src/commands/sessions.ts": 553, + "src/commands/status-all/channels.ts": 568, + "src/commands/status-all/format.ts": 539, + "src/commands/status.command-sections.ts": 518, + "src/commands/status.summary.ts": 586, + "src/commands/tasks.ts": 671, + "src/commitments/store.ts": 607, + "src/config/config-env-vars.ts": 606, + "src/config/defaults.ts": 562, + "src/config/doc-baseline.ts": 686, + "src/config/env-preserve.ts": 835, + "src/config/group-policy.ts": 518, + "src/config/includes.ts": 525, + "src/config/io.audit.ts": 532, + "src/config/io.observe-recovery.ts": 989, + "src/config/io.ts": 3258, + "src/config/io.write-prepare.ts": 1274, + "src/config/mutate.ts": 1240, + "src/config/plugin-auto-enable.shared.ts": 1158, + "src/config/redact-snapshot.ts": 890, + "src/config/schema.help.ts": 2089, + "src/config/schema.labels.ts": 1133, + "src/config/schema.ts": 847, + "src/config/sessions/cleanup-service.ts": 694, + "src/config/sessions/disk-budget.ts": 848, + "src/config/sessions/session-accessor.sqlite.ts": 5856, + "src/config/sessions/session-accessor.ts": 3263, + "src/config/sessions/store-load.ts": 584, + "src/config/sessions/store-maintenance.ts": 578, + "src/config/sessions/store.ts": 2098, + "src/config/sessions/targets.ts": 523, + "src/config/sessions/transcript.ts": 868, + "src/config/sessions/types.ts": 821, + "src/config/types.agent-defaults.ts": 606, + "src/config/types.gateway.ts": 604, + "src/config/types.tools.ts": 784, + "src/config/validation.ts": 2130, + "src/config/zod-schema.agent-runtime.ts": 1165, + "src/config/zod-schema.core.ts": 1157, + "src/config/zod-schema.providers-core.ts": 1748, + "src/config/zod-schema.ts": 1649, + "src/context-engine/registry.ts": 1075, + "src/context-engine/types.ts": 523, + "src/crestodian/chat-engine.ts": 1104, + "src/crestodian/operations.ts": 1529, + "src/crestodian/setup-apply.ts": 607, + "src/crestodian/setup-inference.ts": 2941, + "src/crestodian/tui-backend.ts": 503, + "src/crestodian/verified-inference.ts": 928, + "src/cron/isolated-agent/delivery-dispatch.ts": 1570, + "src/cron/isolated-agent/delivery-target.ts": 512, + "src/cron/isolated-agent/run-executor.ts": 823, + "src/cron/isolated-agent/run.ts": 1862, + "src/cron/normalize.ts": 772, + "src/cron/service/jobs.ts": 1489, + "src/cron/service/ops.ts": 1419, + "src/cron/service/timer.ts": 2483, + "src/daemon/inspect.ts": 556, + "src/daemon/launchd.ts": 1355, + "src/daemon/schtasks.ts": 2062, + "src/daemon/service-audit.ts": 672, + "src/daemon/service-env.ts": 548, + "src/daemon/systemd.ts": 1550, + "src/fleet/backup.runtime.ts": 816, + "src/fleet/containers.runtime.ts": 879, + "src/fleet/service-support.runtime.ts": 687, + "src/fleet/service.runtime.ts": 800, + "src/flows/channel-setup.status.ts": 571, + "src/flows/channel-setup.ts": 835, + "src/flows/doctor-core-checks.runtime.ts": 1173, + "src/flows/doctor-core-checks.ts": 1194, + "src/flows/doctor-health-contributions.ts": 2281, + "src/flows/model-picker.ts": 1691, + "src/flows/search-setup.ts": 712, + "src/gateway/auth.ts": 626, + "src/gateway/call.ts": 1336, + "src/gateway/chat-abort.ts": 623, + "src/gateway/chat-display-projection.ts": 1932, + "src/gateway/cli-session-history.claude.ts": 602, + "src/gateway/config-reload.ts": 1022, + "src/gateway/control-ui-session-prs.ts": 648, + "src/gateway/control-ui.ts": 1190, + "src/gateway/exec-approval-manager.ts": 1141, + "src/gateway/gateway-cli-backend.live-helpers.ts": 648, + "src/gateway/gateway-cli-backend.live-probe-helpers.ts": 513, + "src/gateway/hooks-mapping.ts": 570, + "src/gateway/managed-image-attachments.ts": 1156, + "src/gateway/mcp-http.ts": 509, + "src/gateway/model-pricing-cache.ts": 1454, + "src/gateway/net.ts": 538, + "src/gateway/node-command-policy.ts": 531, + "src/gateway/node-invoke-system-run-approval.ts": 528, + "src/gateway/node-registry.ts": 1035, + "src/gateway/openai-http.ts": 1437, + "src/gateway/openresponses-http.ts": 1372, + "src/gateway/operator-approval-store.ts": 1231, + "src/gateway/probe.ts": 540, + "src/gateway/server-aux-handlers.ts": 544, + "src/gateway/server-channels.ts": 1090, + "src/gateway/server-chat.ts": 1614, + "src/gateway/server-close.ts": 1049, + "src/gateway/server-cron-notifications.ts": 512, + "src/gateway/server-cron.ts": 895, + "src/gateway/server-http.ts": 1100, + "src/gateway/server-methods.ts": 981, + "src/gateway/server-methods/agent-job.ts": 622, + "src/gateway/server-methods/agent.ts": 4226, + "src/gateway/server-methods/agents.ts": 888, + "src/gateway/server-methods/approval-shared.ts": 766, + "src/gateway/server-methods/approval.ts": 602, + "src/gateway/server-methods/artifacts.ts": 673, + "src/gateway/server-methods/channels.ts": 712, + "src/gateway/server-methods/chat.ts": 5552, + "src/gateway/server-methods/config.ts": 1020, + "src/gateway/server-methods/cron.ts": 996, + "src/gateway/server-methods/devices.ts": 822, + "src/gateway/server-methods/doctor.ts": 1072, + "src/gateway/server-methods/exec-approval.ts": 520, + "src/gateway/server-methods/models-auth-status.ts": 547, + "src/gateway/server-methods/nodes.ts": 1760, + "src/gateway/server-methods/send.ts": 1053, + "src/gateway/server-methods/sessions-files.ts": 780, + "src/gateway/server-methods/sessions.ts": 3570, + "src/gateway/server-methods/skills.ts": 810, + "src/gateway/server-methods/talk-session.ts": 722, + "src/gateway/server-methods/talk.ts": 885, + "src/gateway/server-methods/tools-effective.ts": 626, + "src/gateway/server-methods/usage.ts": 1723, + "src/gateway/server-node-events.ts": 956, + "src/gateway/server-plugins.ts": 891, + "src/gateway/server-reload-handlers.ts": 2246, + "src/gateway/server-restart-sentinel.ts": 674, + "src/gateway/server-startup-config.ts": 624, + "src/gateway/server-startup-post-attach.ts": 1471, + "src/gateway/server.impl.ts": 2370, + "src/gateway/server/ws-connection.ts": 699, + "src/gateway/server/ws-connection/message-handler.ts": 2866, + "src/gateway/server/ws-connection/worker-connection.ts": 616, + "src/gateway/session-compaction-checkpoints.ts": 975, + "src/gateway/session-create-service.ts": 744, + "src/gateway/session-reset-service.ts": 1317, + "src/gateway/session-transcript-files.fs.ts": 539, + "src/gateway/session-transcript-readers.ts": 993, + "src/gateway/session-utils.fs.ts": 2129, + "src/gateway/session-utils.ts": 2929, + "src/gateway/sessions-patch.ts": 773, + "src/gateway/talk-realtime-relay.ts": 1492, + "src/gateway/terminal/session-manager.ts": 537, + "src/gateway/watch-node-http.ts": 1088, + "src/gateway/worker-environments/bootstrap.ts": 766, + "src/gateway/worker-environments/bundle.ts": 608, + "src/gateway/worker-environments/inference-runtime.ts": 782, + "src/gateway/worker-environments/inference.ts": 684, + "src/gateway/worker-environments/live-events.ts": 778, + "src/gateway/worker-environments/service.ts": 1270, + "src/gateway/worker-environments/store.ts": 888, + "src/gateway/worker-environments/tunnel.ts": 558, + "src/hooks/install.ts": 775, + "src/hooks/message-hook-mappers.ts": 614, + "src/infra/agent-events.ts": 741, + "src/infra/approval-handler-runtime.ts": 737, + "src/infra/backup-create.ts": 976, + "src/infra/bonjour-discovery.ts": 629, + "src/infra/clawhub-install-trust.ts": 1098, + "src/infra/clawhub.ts": 2001, + "src/infra/command-analysis/inline-eval.ts": 526, + "src/infra/command-explainer/extract.ts": 1391, + "src/infra/device-bootstrap.ts": 509, + "src/infra/device-pairing.ts": 1543, + "src/infra/diagnostic-events.ts": 1476, + "src/infra/dispatch-wrapper-resolution.ts": 619, + "src/infra/exec-approval-forwarder.ts": 837, + "src/infra/exec-approval-reply.ts": 567, + "src/infra/exec-approvals-allowlist.ts": 1502, + "src/infra/exec-approvals.ts": 2897, + "src/infra/exec-authorization-plan.ts": 920, + "src/infra/heartbeat-runner.ts": 2889, + "src/infra/http-body.ts": 604, + "src/infra/net/fetch-guard.ts": 724, + "src/infra/net/proxy/proxy-validation.ts": 616, + "src/infra/net/ssrf.ts": 780, + "src/infra/node-pairing.ts": 602, + "src/infra/npm-managed-root.ts": 1235, + "src/infra/outbound/deliver.ts": 2572, + "src/infra/outbound/delivery-queue-recovery.ts": 1017, + "src/infra/outbound/message-action-params.ts": 753, + "src/infra/outbound/message-action-runner.ts": 1745, + "src/infra/outbound/message.ts": 594, + "src/infra/outbound/target-resolver.ts": 592, + "src/infra/outbound/targets.ts": 554, + "src/infra/package-dist-inventory.ts": 520, + "src/infra/package-update-steps.ts": 767, + "src/infra/ports-inspect.ts": 597, + "src/infra/provider-usage.auth.ts": 575, + "src/infra/push-apns.ts": 1202, + "src/infra/restart-stale-pids.ts": 673, + "src/infra/restart.ts": 1261, + "src/infra/session-cost-usage.ts": 3036, + "src/infra/sqlite-schema-contract.ts": 541, + "src/infra/sqlite-snapshot.ts": 864, + "src/infra/state-migrations.debug-proxy.ts": 578, + "src/infra/state-migrations.ts": 6443, + "src/infra/unhandled-rejections.ts": 566, + "src/infra/update-check.ts": 746, + "src/infra/update-global.ts": 1015, + "src/infra/update-managed-service-handoff.ts": 830, + "src/infra/update-runner.ts": 1874, + "src/infra/update-startup.ts": 809, + "src/infra/windows-gateway-firewall-diagnostics.ts": 1028, + "src/interactive/payload.ts": 1007, + "src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.ts": 553, + "src/llm/providers/stream-wrappers/openai.ts": 840, + "src/llm/utils/oauth/github-copilot.ts": 596, + "src/logging/diagnostic-run-activity.ts": 693, + "src/logging/diagnostic-stability-bundle.ts": 1423, + "src/logging/diagnostic-stability.ts": 791, + "src/logging/diagnostic-support-export.ts": 816, + "src/logging/diagnostic.ts": 1409, + "src/logging/logger.ts": 837, + "src/logging/redact.ts": 1242, + "src/logging/subsystem.ts": 520, + "src/mcp/channel-bridge.ts": 680, + "src/media-generation/runtime-shared.ts": 665, + "src/media-understanding/apply.ts": 718, + "src/media-understanding/attachments.cache.ts": 523, + "src/media-understanding/image.ts": 678, + "src/media-understanding/runner.entries.ts": 1122, + "src/media-understanding/runner.ts": 1117, + "src/media-understanding/shared.ts": 689, + "src/media/fetch.ts": 689, + "src/media/parse.ts": 725, + "src/media/store.ts": 757, + "src/media/web-media.ts": 1159, + "src/memory-host-sdk/dreaming.ts": 655, + "src/node-host/invoke-system-run-plan.ts": 1176, + "src/node-host/invoke-system-run.ts": 1097, + "src/node-host/invoke.ts": 1210, + "src/plugin-sdk/api-baseline.ts": 792, + "src/plugin-sdk/approval-native-helpers.ts": 975, + "src/plugin-sdk/approval-reaction-runtime.ts": 642, + "src/plugin-sdk/channel-config-helpers.ts": 678, + "src/plugin-sdk/channel-entry-contract.ts": 627, + "src/plugin-sdk/channel-ingress.ts": 685, + "src/plugin-sdk/core.ts": 870, + "src/plugin-sdk/persistent-dedupe.ts": 773, + "src/plugin-sdk/provider-auth.ts": 664, + "src/plugin-sdk/provider-onboard.ts": 666, + "src/plugin-sdk/provider-stream-shared.ts": 995, + "src/plugin-sdk/provider-tools.ts": 576, + "src/plugin-sdk/qa-runtime.ts": 741, + "src/plugin-sdk/reply-payload.ts": 554, + "src/plugin-sdk/session-store-runtime.ts": 542, + "src/plugin-state/plugin-state-store.sqlite.ts": 1007, + "src/plugin-state/plugin-state-store.ts": 510, + "src/plugins/bundled-capability-runtime.ts": 544, + "src/plugins/capability-provider-runtime.ts": 674, + "src/plugins/channel-presence-policy.ts": 577, + "src/plugins/clawhub.ts": 1511, + "src/plugins/compat/registry.ts": 1159, + "src/plugins/contracts/tts-contract-suites.ts": 1330, + "src/plugins/conversation-binding.ts": 1032, + "src/plugins/discovery.ts": 1723, + "src/plugins/doctor-contract-registry.ts": 539, + "src/plugins/gateway-startup-plugin-ids.ts": 2327, + "src/plugins/git-install.ts": 526, + "src/plugins/hook-types.ts": 1318, + "src/plugins/hooks.ts": 1721, + "src/plugins/host-hook-runtime.ts": 644, + "src/plugins/host-hook-state.ts": 507, + "src/plugins/install-persistence.ts": 531, + "src/plugins/install-security-scan.runtime.ts": 1306, + "src/plugins/install.ts": 3273, + "src/plugins/installed-plugin-index-store.ts": 533, + "src/plugins/loader.ts": 3059, + "src/plugins/management-service.ts": 979, + "src/plugins/manifest-registry-installed.ts": 643, + "src/plugins/manifest-registry.ts": 1227, + "src/plugins/manifest.ts": 2113, + "src/plugins/marketplace.ts": 1334, + "src/plugins/official-external-plugin-catalog.ts": 1455, + "src/plugins/openai-compatible-embedding-provider.ts": 506, + "src/plugins/package-entry-resolution.ts": 687, + "src/plugins/plugin-metadata-snapshot.ts": 757, + "src/plugins/plugin-registry-snapshot.ts": 710, + "src/plugins/provider-auth-choice.ts": 666, + "src/plugins/provider-discovery.runtime.ts": 530, + "src/plugins/provider-runtime.ts": 1081, + "src/plugins/provider-self-hosted-setup.ts": 569, + "src/plugins/providers.ts": 903, + "src/plugins/registry-registrars-host.ts": 710, + "src/plugins/registry-registrars-providers.ts": 501, + "src/plugins/registry-registrars-tools-hooks.ts": 515, + "src/plugins/registry-runtime.ts": 750, + "src/plugins/registry-types.ts": 532, + "src/plugins/runtime/runtime-agent.ts": 516, + "src/plugins/sdk-alias.ts": 2174, + "src/plugins/setup-registry.ts": 933, + "src/plugins/status.ts": 570, + "src/plugins/tools.ts": 1573, + "src/plugins/types.ts": 3071, + "src/plugins/uninstall.ts": 780, + "src/plugins/update.ts": 2745, + "src/process/command-queue.ts": 700, + "src/process/exec.ts": 773, + "src/proxy-capture/runtime.ts": 668, + "src/proxy-capture/store.sqlite.ts": 943, + "src/realtime-transcription/websocket-session.ts": 512, + "src/routing/resolve-route.ts": 821, + "src/secrets/apply.ts": 1044, + "src/secrets/audit.ts": 727, + "src/secrets/configure.ts": 1079, + "src/secrets/resolve.ts": 1041, + "src/secrets/runtime-command-secrets.ts": 567, + "src/secrets/runtime-config-collectors-core.ts": 697, + "src/secrets/runtime-state.ts": 1021, + "src/secrets/runtime-web-tools.shared.ts": 663, + "src/secrets/runtime-web-tools.ts": 861, + "src/secrets/runtime.ts": 543, + "src/secrets/target-registry-data.ts": 548, + "src/secrets/target-registry-query.ts": 542, + "src/security/audit-extra.async.ts": 946, + "src/security/audit-extra.sync.ts": 1261, + "src/security/audit-plugins-trust.ts": 553, + "src/security/audit.ts": 1548, + "src/security/install-policy.ts": 885, + "src/sessions/session-lifecycle-admission.ts": 545, + "src/sessions/session-state-events.ts": 989, + "src/sessions/user-turn-transcript.ts": 746, + "src/shared/json-schema-defaults.ts": 1319, + "src/shared/text/assistant-visible-text.ts": 1098, + "src/skills/lifecycle/clawhub.ts": 1681, + "src/skills/lifecycle/install.ts": 830, + "src/skills/lifecycle/upload-store.ts": 610, + "src/skills/loading/workspace.ts": 1805, + "src/skills/runtime/refresh.ts": 745, + "src/skills/runtime/remote.ts": 743, + "src/skills/security/scanner.ts": 816, + "src/skills/workshop/curator.ts": 665, + "src/skills/workshop/service.ts": 1016, + "src/skills/workshop/store.ts": 563, + "src/snapshot/local-repository.ts": 1479, + "src/state/openclaw-agent-db.ts": 1100, + "src/state/openclaw-state-db.generated.d.ts": 1279, + "src/state/openclaw-state-db.ts": 1697, + "src/state/openclaw-state-schema.generated.ts": 1668, + "src/status/status-message.ts": 1160, + "src/status/status-text.ts": 666, + "src/tasks/task-executor.ts": 633, + "src/tasks/task-flow-registry.ts": 830, + "src/tasks/task-registry.maintenance.ts": 1247, + "src/tasks/task-registry.ts": 2788, + "src/trajectory/export.ts": 1218, + "src/tui/embedded-backend.ts": 1331, + "src/tui/gateway-chat.ts": 534, + "src/tui/tui-command-handlers.ts": 987, + "src/tui/tui-event-handlers.ts": 1210, + "src/tui/tui-formatters.ts": 533, + "src/tui/tui-plugin-approvals.ts": 550, + "src/tui/tui-session-actions.ts": 666, + "src/tui/tui.ts": 1744, + "src/utils/usage-format.ts": 744, + "src/web-search/runtime.ts": 512, + "src/wizard/clack-navigation-prompts.ts": 824, + "src/wizard/i18n/locales/en.ts": 1112, + "src/wizard/i18n/locales/zh-CN.ts": 1072, + "src/wizard/i18n/locales/zh-TW.ts": 1073, + "src/wizard/setup.finalize.ts": 923, + "src/wizard/setup.migration-import.ts": 607, + "src/wizard/setup.ts": 656, + "tsdown.config.ts": 800, + "ui/src/api/gateway.ts": 1211, + "ui/src/api/types.ts": 865, + "ui/src/app/app-host.ts": 1204, + "ui/src/app/custom-theme.ts": 648, + "ui/src/app/overlays.ts": 714, + "ui/src/app/settings.ts": 711, + "ui/src/components/app-sidebar.ts": 2999, + "ui/src/components/browser/browser-panel.ts": 1412, + "ui/src/components/command-palette.ts": 687, + "ui/src/components/config-form.node.ts": 1169, + "ui/src/components/file-preview-modal.ts": 803, + "ui/src/components/github-link-hovercard.ts": 582, + "ui/src/components/icons.ts": 696, + "ui/src/components/lobster-pet.ts": 1797, + "ui/src/components/markdown.ts": 1493, + "ui/src/components/terminal/terminal-panel.ts": 1182, + "ui/src/components/tooltip.ts": 502, + "ui/src/i18n/locales/ar.ts": 3749, + "ui/src/i18n/locales/de.ts": 3820, + "ui/src/i18n/locales/en.ts": 3754, + "ui/src/i18n/locales/es.ts": 3818, + "ui/src/i18n/locales/fa.ts": 3774, + "ui/src/i18n/locales/fr.ts": 3840, + "ui/src/i18n/locales/hi.ts": 3744, + "ui/src/i18n/locales/id.ts": 3782, + "ui/src/i18n/locales/it.ts": 3821, + "ui/src/i18n/locales/ja-JP.ts": 3791, + "ui/src/i18n/locales/ko.ts": 3762, + "ui/src/i18n/locales/nl.ts": 3796, + "ui/src/i18n/locales/pl.ts": 3808, + "ui/src/i18n/locales/pt-BR.ts": 3791, + "ui/src/i18n/locales/ru.ts": 3810, + "ui/src/i18n/locales/th.ts": 3719, + "ui/src/i18n/locales/tr.ts": 3805, + "ui/src/i18n/locales/uk.ts": 3793, + "ui/src/i18n/locales/vi.ts": 3768, + "ui/src/i18n/locales/zh-CN.ts": 3705, + "ui/src/i18n/locales/zh-TW.ts": 3711, + "ui/src/lib/agents/display.ts": 661, + "ui/src/lib/chat/commands.ts": 537, + "ui/src/lib/chat/message-normalizer.ts": 530, + "ui/src/lib/config/index.ts": 951, + "ui/src/lib/cron/index.ts": 1290, + "ui/src/lib/nodes/index.ts": 900, + "ui/src/lib/sessions/index.ts": 1457, + "ui/src/lib/skills/index.ts": 757, + "ui/src/lib/workboard/index.ts": 4121, + "ui/src/lib/workspace/index.ts": 779, + "ui/src/pages/agents/agents-page.ts": 964, + "ui/src/pages/agents/memory/dreaming.ts": 1288, + "ui/src/pages/agents/memory/memory-panel.ts": 503, + "ui/src/pages/agents/memory/view.ts": 1549, + "ui/src/pages/agents/panels-status-files.ts": 704, + "ui/src/pages/agents/panels-tools-skills.ts": 976, + "ui/src/pages/approval/approval-page.ts": 670, + "ui/src/pages/channels/channels-page.ts": 514, + "ui/src/pages/chat/chat-command-executor.ts": 818, + "ui/src/pages/chat/chat-history.ts": 1230, + "ui/src/pages/chat/chat-page.ts": 575, + "ui/src/pages/chat/chat-pane.ts": 2195, + "ui/src/pages/chat/chat-queue.ts": 714, + "ui/src/pages/chat/chat-send.ts": 2536, + "ui/src/pages/chat/chat-session.ts": 544, + "ui/src/pages/chat/chat-state.ts": 1954, + "ui/src/pages/chat/chat-thread.ts": 1940, + "ui/src/pages/chat/chat-view.ts": 605, + "ui/src/pages/chat/components/chat-background-tasks.ts": 558, + "ui/src/pages/chat/components/chat-composer.ts": 2794, + "ui/src/pages/chat/components/chat-message.ts": 2493, + "ui/src/pages/chat/components/chat-model-controls.ts": 761, + "ui/src/pages/chat/components/chat-session-workspace.ts": 1268, + "ui/src/pages/chat/components/chat-sidebar.ts": 1320, + "ui/src/pages/chat/components/chat-thread.ts": 1019, + "ui/src/pages/chat/components/chat-tool-cards.ts": 957, + "ui/src/pages/chat/composer-persistence.ts": 1746, + "ui/src/pages/chat/realtime-talk-shared.ts": 627, + "ui/src/pages/chat/realtime-talk-webrtc.ts": 532, + "ui/src/pages/chat/stream-reconciliation.ts": 644, + "ui/src/pages/chat/tool-stream.ts": 792, + "ui/src/pages/config/config-page.ts": 962, + "ui/src/pages/config/quick.ts": 1178, + "ui/src/pages/config/view.ts": 1986, + "ui/src/pages/cron/view.ts": 2069, + "ui/src/pages/new-session/new-session-page.ts": 1194, + "ui/src/pages/nodes/view-exec-approvals.ts": 658, + "ui/src/pages/nodes/view-inventory.ts": 549, + "ui/src/pages/plugin/workspace-view.ts": 990, + "ui/src/pages/plugins/plugins-page.ts": 943, + "ui/src/pages/plugins/presentation.ts": 599, + "ui/src/pages/plugins/view.ts": 1395, + "ui/src/pages/profile/profile-page.ts": 545, + "ui/src/pages/sessions/sessions-page.ts": 1249, + "ui/src/pages/sessions/view.ts": 1820, + "ui/src/pages/skill-workshop/proposals.ts": 600, + "ui/src/pages/skill-workshop/skill-workshop-page.ts": 700, + "ui/src/pages/skill-workshop/view.ts": 1137, + "ui/src/pages/skills/view.ts": 836, + "ui/src/pages/usage/metrics.ts": 862, + "ui/src/pages/usage/usage-page.ts": 673, + "ui/src/pages/usage/view-details.ts": 1246, + "ui/src/pages/usage/view-overview.ts": 1145, + "ui/src/pages/usage/view.ts": 926, + "ui/src/pages/workboard/view.ts": 2962 +} diff --git a/src/agents/embedded-agent-runner/run/attempt-finalize.ts b/src/agents/embedded-agent-runner/run/attempt-finalize.ts new file mode 100644 index 000000000000..9feac3ab9a55 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-finalize.ts @@ -0,0 +1,116 @@ +import { formatErrorMessage } from "../../../infra/errors.js"; +import { buildTrajectoryArtifacts } from "../../../trajectory/metadata.js"; +import { + resolveAttemptTrajectoryTerminal, + resolveTerminalAssistantTexts, +} from "./attempt-trajectory-status.js"; +import { resolveFinalAssistantVisibleText } from "./helpers.js"; +import type { EmbeddedRunAttemptResult, EmbeddedRunAttemptTrajectoryRecorder } from "./types.js"; + +type FinalizeEmbeddedAttemptParams = { + result: EmbeddedRunAttemptResult; + trajectoryRecorder?: EmbeddedRunAttemptTrajectoryRecorder | null; + synthesizedPayloadCount: number; + emptyAssistantReplyIsSilent: boolean; + hasTerminalOutput: boolean; + silentExpected?: boolean; +}; + +/** Classifies the completed attempt and records its terminal trajectory artifacts. */ +export function finalizeEmbeddedAttempt( + params: FinalizeEmbeddedAttemptParams, +): EmbeddedRunAttemptResult { + const { result, trajectoryRecorder } = params; + const terminalAssistantTexts = resolveTerminalAssistantTexts({ + assistantTexts: result.assistantTexts, + lastAssistantStopReason: result.lastAssistant?.stopReason, + lastAssistantVisibleText: resolveFinalAssistantVisibleText(result.lastAssistant), + }); + const terminal = resolveAttemptTrajectoryTerminal({ + promptError: result.promptError, + aborted: result.aborted, + externalAbort: result.externalAbort, + timedOut: result.timedOut, + assistantTexts: terminalAssistantTexts, + toolMetas: result.toolMetas, + didSendViaMessagingTool: result.didSendViaMessagingTool, + didSendDeterministicApprovalPrompt: result.didSendDeterministicApprovalPrompt === true, + messagingToolSentTexts: result.messagingToolSentTexts, + messagingToolSentMediaUrls: result.messagingToolSentMediaUrls, + messagingToolSentTargets: result.messagingToolSentTargets, + successfulCronAdds: result.successfulCronAdds ?? 0, + synthesizedPayloadCount: params.synthesizedPayloadCount, + acceptedSessionSpawns: result.acceptedSessionSpawns, + heartbeatToolResponse: result.heartbeatToolResponse, + clientToolCalls: result.clientToolCalls, + yieldDetected: result.yieldDetected, + lastToolError: result.lastToolError, + silentExpected: params.silentExpected, + emptyAssistantReplyIsSilent: params.emptyAssistantReplyIsSilent, + lastAssistantStopReason: result.lastAssistant?.stopReason, + hasTerminalOutput: params.hasTerminalOutput, + }); + const promptError = result.promptError ? formatErrorMessage(result.promptError) : undefined; + + trajectoryRecorder?.recordEvent("model.completed", { + aborted: result.aborted, + externalAbort: result.externalAbort, + timedOut: result.timedOut, + idleTimedOut: result.idleTimedOut, + timedOutDuringCompaction: result.timedOutDuringCompaction, + timedOutDuringToolExecution: result.timedOutDuringToolExecution, + timedOutByRunBudget: result.timedOutByRunBudget, + promptError, + promptErrorSource: result.promptErrorSource, + terminalError: terminal.terminalError, + usage: result.attemptUsage, + promptCache: result.promptCache, + compactionCount: result.compactionCount, + assistantTexts: result.assistantTexts, + finalPromptText: result.finalPromptText, + messagesSnapshot: result.messagesSnapshot, + }); + trajectoryRecorder?.recordEvent( + "trace.artifacts", + buildTrajectoryArtifacts({ + status: terminal.status, + aborted: result.aborted, + externalAbort: result.externalAbort, + timedOut: result.timedOut, + idleTimedOut: result.idleTimedOut, + timedOutDuringCompaction: result.timedOutDuringCompaction, + timedOutDuringToolExecution: result.timedOutDuringToolExecution === true, + timedOutByRunBudget: result.timedOutByRunBudget === true, + promptError, + promptErrorSource: result.promptErrorSource, + terminalError: terminal.terminalError, + usage: result.attemptUsage, + promptCache: result.promptCache, + compactionCount: result.compactionCount ?? 0, + assistantTexts: result.assistantTexts, + finalPromptText: result.finalPromptText, + itemLifecycle: result.itemLifecycle, + toolMetas: result.toolMetas, + didSendViaMessagingTool: result.didSendViaMessagingTool, + successfulCronAdds: result.successfulCronAdds ?? 0, + messagingToolSentTexts: result.messagingToolSentTexts, + messagingToolSentMediaUrls: result.messagingToolSentMediaUrls, + messagingToolSentTargets: result.messagingToolSentTargets, + lastToolError: result.lastToolError, + }), + ); + trajectoryRecorder?.recordEvent("session.ended", { + status: terminal.status, + aborted: result.aborted, + externalAbort: result.externalAbort, + timedOut: result.timedOut, + idleTimedOut: result.idleTimedOut, + timedOutDuringCompaction: result.timedOutDuringCompaction, + timedOutDuringToolExecution: result.timedOutDuringToolExecution, + timedOutByRunBudget: result.timedOutByRunBudget, + promptError, + terminalError: terminal.terminalError, + }); + + return result; +} diff --git a/src/agents/embedded-agent-runner/run/attempt-setup.ts b/src/agents/embedded-agent-runner/run/attempt-setup.ts new file mode 100644 index 000000000000..00bb50b7c2d6 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-setup.ts @@ -0,0 +1,191 @@ +/** + * Resolves workspace, sandbox, provider runtime, and phase reporting for an embedded attempt. + */ +import fs from "node:fs/promises"; +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { getCurrentPluginMetadataSnapshot } from "../../../plugins/current-plugin-metadata-snapshot.js"; +import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.types.js"; +import { + resolveProviderRuntimePluginHandle, + type ProviderRuntimePluginHandle, +} from "../../../plugins/provider-hook-runtime.js"; +import { resolveUserPath } from "../../../utils.js"; +import { resolveSessionAgentIds } from "../../agent-scope.js"; +import { resolveSandboxContext } from "../../sandbox.js"; +import { log } from "../logger.js"; +import { mapThinkingLevel, mapThinkingLevelForProvider } from "../utils.js"; +import { configureEmbeddedAttemptHttpRuntime } from "./attempt-http-runtime.js"; +import { + createEmbeddedRunStageTracker, + formatEmbeddedRunStageSummary, + shouldWarnEmbeddedRunStageSummary, +} from "./attempt-stage-timing.js"; +import { resolveAttemptFsWorkspaceOnly } from "./attempt.prompt-helpers.js"; +import type { EmbeddedRunAttemptParams } from "./types.js"; + +function pluginMetadataSnapshotCoversProvider( + snapshot: PluginMetadataSnapshot | undefined, + provider: string, +): snapshot is PluginMetadataSnapshot { + const normalizedProvider = normalizeProviderId(provider); + if (!snapshot || !normalizedProvider) { + return false; + } + return snapshot.manifestRegistry.plugins.some((plugin) => { + const ownsProvider = plugin.providers.some( + (providerId) => normalizeProviderId(providerId) === normalizedProvider, + ); + if (ownsProvider) { + return true; + } + const modelCatalogProviderIds = [ + ...Object.keys(plugin.modelCatalog?.providers ?? {}), + ...Object.keys(plugin.modelCatalog?.aliases ?? {}), + ]; + return modelCatalogProviderIds.some( + (providerId) => normalizeProviderId(providerId) === normalizedProvider, + ); + }); +} + +export async function prepareEmbeddedAttemptSetup(params: EmbeddedRunAttemptParams) { + const resolvedWorkspace = resolveUserPath(params.workspaceDir); + // Ultra is a logical orchestration mode, not a provider effort. Preserve it for + // prompt/status surfaces, then lower only at agent-core and provider boundaries. + const agentCoreThinkingLevel = mapThinkingLevel(params.thinkLevel); + const providerThinkingLevel = mapThinkingLevelForProvider(params.thinkLevel); + const proactiveSubagentOrchestration = params.thinkLevel === "ultra"; + configureEmbeddedAttemptHttpRuntime({ timeoutMs: params.timeoutMs }); + + log.debug( + `embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${params.provider} model=${params.modelId} thinking=${params.thinkLevel} messageChannel=${params.messageChannel ?? params.messageProvider ?? "unknown"}`, + ); + const prepStages = createEmbeddedRunStageTracker(); + const emitPrepStageSummary = (phase: string) => { + const summary = prepStages.snapshot(); + const shouldWarn = shouldWarnEmbeddedRunStageSummary(summary); + if (!shouldWarn && !log.isEnabled("trace")) { + return; + } + const message = formatEmbeddedRunStageSummary( + `[trace:embedded-run] prep stages: runId=${params.runId} sessionId=${params.sessionId} phase=${phase}`, + summary, + ); + if (shouldWarn) { + log.warn(message); + } else { + log.trace(message); + } + }; + const emitCorePluginToolStageSummary = ( + phase: string, + summary: ReturnType, + ) => { + if (summary.stages.length === 0) { + return; + } + const shouldWarn = shouldWarnEmbeddedRunStageSummary(summary, { + totalThresholdMs: 5_000, + stageThresholdMs: 2_000, + }); + if (!shouldWarn && !log.isEnabled("trace")) { + return; + } + const message = formatEmbeddedRunStageSummary( + `[trace:embedded-run] core-plugin-tool stages: runId=${params.runId} sessionId=${params.sessionId} phase=${phase}`, + summary, + ); + if (shouldWarn) { + log.warn(message); + } else { + log.trace(message); + } + }; + + await fs.mkdir(resolvedWorkspace, { recursive: true }); + const sandboxSessionKey = + params.sandboxSessionKey?.trim() || params.sessionKey?.trim() || params.sessionId; + const sandbox = await resolveSandboxContext({ + config: params.config, + execOverrides: params.execOverrides, + sessionKey: sandboxSessionKey, + workspaceDir: resolvedWorkspace, + }); + const effectiveWorkspace = sandbox?.enabled + ? sandbox.workspaceAccess === "rw" + ? resolvedWorkspace + : sandbox.workspaceDir + : resolvedWorkspace; + const requestedCwd = params.cwd ? resolveUserPath(params.cwd) : undefined; + if (sandbox?.enabled && requestedCwd && requestedCwd !== resolvedWorkspace) { + throw new Error( + "cwd override is not supported for sandboxed embedded agent runs; omit cwd or use the agent workspace as cwd", + ); + } + const effectiveCwd = sandbox?.enabled ? effectiveWorkspace : (requestedCwd ?? effectiveWorkspace); + await fs.mkdir(effectiveWorkspace, { recursive: true }); + + let currentPluginMetadataSnapshotResolved = false; + let currentPluginMetadataSnapshot: PluginMetadataSnapshot | undefined; + const getCurrentAttemptPluginMetadataSnapshot = () => { + if (!currentPluginMetadataSnapshotResolved) { + currentPluginMetadataSnapshot = getCurrentPluginMetadataSnapshot({ + allowScopedSnapshot: true, + config: params.config, + env: process.env, + workspaceDir: effectiveWorkspace, + }); + currentPluginMetadataSnapshotResolved = true; + } + return currentPluginMetadataSnapshot; + }; + let providerRuntimeHandle: ProviderRuntimePluginHandle | undefined; + const getProviderRuntimeHandle = () => { + if (providerRuntimeHandle?.plugin) { + return providerRuntimeHandle; + } + const pluginMetadataSnapshot = getCurrentAttemptPluginMetadataSnapshot(); + const resolvedHandle = resolveProviderRuntimePluginHandle({ + provider: params.provider, + modelId: params.modelId, + config: params.config, + workspaceDir: effectiveWorkspace, + env: process.env, + ...(pluginMetadataSnapshotCoversProvider(pluginMetadataSnapshot, params.provider) + ? { pluginMetadataSnapshot } + : {}), + }); + if (resolvedHandle.plugin) { + providerRuntimeHandle = resolvedHandle; + } + return resolvedHandle; + }; + const { sessionAgentId } = resolveSessionAgentIds({ + sessionKey: params.sessionKey, + config: params.config, + agentId: params.agentId, + }); + const effectiveFsWorkspaceOnly = resolveAttemptFsWorkspaceOnly({ + config: params.config, + sessionAgentId, + }); + prepStages.mark("workspace-sandbox"); + + return { + agentCoreThinkingLevel, + effectiveCwd, + effectiveFsWorkspaceOnly, + effectiveWorkspace, + emitCorePluginToolStageSummary, + emitPrepStageSummary, + getCurrentAttemptPluginMetadataSnapshot, + getProviderRuntimeHandle, + prepStages, + proactiveSubagentOrchestration, + providerThinkingLevel, + resolvedWorkspace, + sandbox, + sandboxSessionKey, + sessionAgentId, + }; +} diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-transport.ts b/src/agents/embedded-agent-runner/run/attempt-stream-transport.ts new file mode 100644 index 000000000000..dcc26c251faa --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-stream-transport.ts @@ -0,0 +1,192 @@ +/** + * Selects and configures the provider transport for one embedded attempt. + */ +import { createCodexNativeWebSearchWrapper } from "../../../llm/providers/stream-wrappers/openai.js"; +import type { ProviderRuntimePluginHandle } from "../../../plugins/provider-hook-runtime.js"; +import { resolveProviderTextTransforms } from "../../../plugins/provider-runtime.js"; +import { wrapStreamFnTextTransforms } from "../../plugin-text-transforms.js"; +import { registerProviderStreamForModel } from "../../provider-stream.js"; +import type { SandboxContext } from "../../sandbox/types.js"; +import type { AgentSession, SettingsManager } from "../../sessions/index.js"; +import { + applyExtraParamsToAgent, + resolveAgentTransportOverride, + resolveExplicitSettingsTransport, + resolveExtraParams, + resolvePreparedExtraParams, +} from "../extra-params.js"; +import { log } from "../logger.js"; +import { resolveCacheRetention } from "../prompt-cache-retention.js"; +import { + describeEmbeddedAgentStreamStrategy, + resolveEmbeddedAgentBaseStreamFn, + resolveEmbeddedAgentStreamFn, +} from "../stream-resolution.js"; +import type { ProviderThinkLevel } from "../utils.js"; +import { + resolveAttemptStreamAuthProfileId, + resolveAttemptToolPolicyMessageProvider, +} from "./attempt.run-decisions.js"; +import type { EmbeddedRunAttemptParams } from "./types.js"; + +export function prepareEmbeddedAttemptTransport(input: { + attempt: EmbeddedRunAttemptParams; + session: AgentSession; + settingsManager: SettingsManager; + providerThinkingLevel: ProviderThinkLevel | undefined; + sessionAgentId: string; + workspaceDir: string; + agentDir: string; + abortSignal: AbortSignal; + getProviderRuntimeHandle: () => ProviderRuntimePluginHandle; + sandboxSessionKey: string; + sandbox?: SandboxContext | null; + codeModeControlsEnabled: boolean; +}) { + const attempt = input.attempt; + const session = input.session; + // Rebuild each turn from the session's original stream base so prior-turn + // wrappers do not pin us to stale provider/API transport behavior. + const defaultSessionStreamFn = resolveEmbeddedAgentBaseStreamFn({ + session, + }); + const resolvedTransport = resolveExplicitSettingsTransport({ + settingsManager: input.settingsManager, + sessionTransport: session.agent.transport, + }); + const streamExtraParamsOverride = { + ...attempt.streamParams, + fastMode: attempt.fastMode, + }; + const preparedRuntimeExtraParams = attempt.runtimePlan?.transport.resolveExtraParams({ + extraParamsOverride: streamExtraParamsOverride, + thinkingLevel: input.providerThinkingLevel, + agentId: input.sessionAgentId, + workspaceDir: input.workspaceDir, + model: attempt.model, + resolvedTransport, + }); + const resolvedExtraParams = resolveExtraParams({ + cfg: attempt.config, + provider: attempt.provider, + modelId: attempt.modelId, + agentId: input.sessionAgentId, + }); + const effectiveExtraParams = + preparedRuntimeExtraParams ?? + resolvePreparedExtraParams({ + cfg: attempt.config, + provider: attempt.provider, + modelId: attempt.modelId, + extraParamsOverride: streamExtraParamsOverride, + thinkingLevel: input.providerThinkingLevel, + agentId: input.sessionAgentId, + agentDir: input.agentDir, + workspaceDir: input.workspaceDir, + resolvedExtraParams, + model: attempt.model, + resolvedTransport, + }); + const providerStreamFn = registerProviderStreamForModel({ + model: attempt.model, + cfg: attempt.config, + agentDir: input.agentDir, + workspaceDir: input.workspaceDir, + }); + const streamStrategy = describeEmbeddedAgentStreamStrategy({ + currentStreamFn: defaultSessionStreamFn, + providerStreamFn, + model: attempt.model, + resolvedApiKey: attempt.resolvedApiKey, + }); + session.agent.streamFn = resolveEmbeddedAgentStreamFn({ + currentStreamFn: defaultSessionStreamFn, + providerStreamFn, + sessionId: attempt.sessionId, + promptCacheKey: attempt.promptCacheKey, + signal: input.abortSignal, + model: attempt.model, + resolvedApiKey: attempt.resolvedApiKey, + authProfileId: resolveAttemptStreamAuthProfileId(attempt), + authStorage: attempt.authStorage, + }); + const providerTextTransforms = resolveProviderTextTransforms({ + provider: attempt.provider, + config: attempt.config, + workspaceDir: input.workspaceDir, + runtimeHandle: input.getProviderRuntimeHandle(), + }); + if (providerTextTransforms?.input?.length) { + session.agent.streamFn = wrapStreamFnTextTransforms({ + streamFn: session.agent.streamFn, + input: providerTextTransforms.input, + transformSystemPrompt: false, + }); + } + const nativeWebSearchPolicyContext = { + sessionKey: input.sandboxSessionKey, + sandboxToolPolicy: input.sandbox?.tools, + messageProvider: resolveAttemptToolPolicyMessageProvider(attempt), + agentAccountId: attempt.agentAccountId, + groupId: attempt.groupId, + groupChannel: attempt.groupChannel, + groupSpace: attempt.groupSpace, + spawnedBy: attempt.spawnedBy, + senderId: attempt.senderId, + senderName: attempt.senderName, + senderUsername: attempt.senderUsername, + senderE164: attempt.senderE164, + }; + + applyExtraParamsToAgent( + session.agent, + attempt.config, + attempt.provider, + attempt.modelId, + streamExtraParamsOverride, + input.providerThinkingLevel, + input.sessionAgentId, + input.workspaceDir, + attempt.model, + input.agentDir, + resolvedTransport, + { + preparedExtraParams: effectiveExtraParams, + nativeWebSearchPolicyContext, + }, + ); + if (input.codeModeControlsEnabled) { + session.agent.streamFn = createCodexNativeWebSearchWrapper(session.agent.streamFn, { + config: attempt.config, + agentDir: input.agentDir, + agentId: input.sessionAgentId, + ...nativeWebSearchPolicyContext, + codeModeToolSurfaceEnabled: true, + }); + } + const effectivePromptCacheRetention = resolveCacheRetention( + effectiveExtraParams, + attempt.provider, + attempt.model.api, + attempt.modelId, + ); + const agentTransportOverride = resolveAgentTransportOverride({ + settingsManager: input.settingsManager, + effectiveExtraParams, + }); + const effectiveAgentTransport = agentTransportOverride ?? session.agent.transport; + if (agentTransportOverride && session.agent.transport !== agentTransportOverride) { + const previousTransport = session.agent.transport; + log.debug( + `embedded agent transport override: ${previousTransport} -> ${agentTransportOverride} ` + + `(${attempt.provider}/${attempt.modelId})`, + ); + } + return { + effectiveAgentTransport, + effectiveExtraParams, + effectivePromptCacheRetention, + providerTextTransforms, + streamStrategy, + }; +} diff --git a/src/agents/embedded-agent-runner/run/attempt-stream.ts b/src/agents/embedded-agent-runner/run/attempt-stream.ts new file mode 100644 index 000000000000..f6fb1144ceaf --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-stream.ts @@ -0,0 +1,350 @@ +/** + * Installs replay, tool-call, timeout, and diagnostic guards around an embedded stream. + */ +import { resolveDiagnosticModelContentCapturePolicy } from "../../../infra/diagnostic-llm-content.js"; +import type { DiagnosticTraceContext } from "../../../infra/diagnostic-trace-context.js"; +import { resolveToolCallArgumentsEncoding } from "../../../plugins/provider-model-compat.js"; +import type { resolveProviderTextTransforms } from "../../../plugins/provider-runtime.js"; +import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js"; +import { createCacheTrace } from "../../cache-trace.js"; +import { wrapStreamFnTextTransforms } from "../../plugin-text-transforms.js"; +import type { AgentSession, SessionManager } from "../../sessions/index.js"; +import { resolveAgentTimeoutMs } from "../../timeout.js"; +import type { TranscriptPolicy } from "../../transcript-policy.js"; +import { shouldAllowProviderOwnedThinkingReplay } from "../../transcript-policy.js"; +import { log } from "../logger.js"; +import { collectPromptCacheToolNames } from "../prompt-cache-observability.js"; +import { repairRejectedThinkingReplayInSessionManager } from "../thinking-replay-repair.js"; +import { + dropReasoningFromHistory, + dropThinkingBlocks, + wrapAnthropicStreamWithRecovery, +} from "../thinking.js"; +import { wrapStreamFnWithDiagnosticModelCallEvents } from "./attempt.model-diagnostic-events.js"; +import { resolveUnknownToolGuardThreshold } from "./attempt.run-decisions.js"; +import type { createEmbeddedAttemptSessionLockController } from "./attempt.session-lock.js"; +import { createYieldAbortedResponse } from "./attempt.sessions-yield.js"; +import { wrapStreamFnHandleSensitiveStopReason } from "./attempt.stop-reason-recovery.js"; +import { + shouldRepairMalformedToolCallArguments, + wrapStreamFnDecodeXaiToolCallArguments, + wrapStreamFnRepairMalformedToolCallArguments, +} from "./attempt.tool-call-argument-repair.js"; +import { + sanitizeOpenAIResponsesReplayForStream, + sanitizeReplayToolCallIdsForStream, + shouldApplyReplayToolCallIdSanitizer, + wrapStreamFnPromoteStandaloneTextToolCalls, + wrapStreamFnSanitizeMalformedToolCalls, + wrapStreamFnTrimToolCallNames, +} from "./attempt.tool-call-normalization.js"; +import { + resolveLlmFirstEventTimeoutMs, + resolveLlmIdleTimeoutMs, + streamWithIdleTimeout, +} from "./llm-idle-timeout.js"; +import { wrapStreamFnWithMessageTransform } from "./message-transform-stream-wrapper.js"; +import type { EmbeddedRunAttemptParams } from "./types.js"; + +type CacheTrace = ReturnType; +type AnthropicPayloadLogger = ReturnType; +type AttemptSessionLockController = Awaited< + ReturnType +>; + +export function installEmbeddedAttemptStreamGuards(input: { + attempt: EmbeddedRunAttemptParams; + session: AgentSession; + sessionAgentId: string; + cacheTrace: CacheTrace; + allCustomTools: Array<{ name?: string }>; + systemPromptText: string; + transcriptPolicy: TranscriptPolicy; + sessionManager: SessionManager | undefined; + sessionLockController: AttemptSessionLockController; + isOpenAIResponsesApi: boolean; + replayAllowedToolNames: Set; + liveAllowedToolNames: Set; + isYieldDetected: () => boolean; + clientToolLoopDetection: ReturnType< + typeof import("../../agent-tools.js").resolveToolLoopDetectionConfig + >; + anthropicPayloadLogger: AnthropicPayloadLogger; + onRejectedThinkingReplayRepaired: () => void; + onIdleTimeout: (error: Error) => void; + effectiveAgentTransport: AgentSession["agent"]["transport"]; + providerTextTransforms: ReturnType; + abortSignal: AbortSignal; + runTrace: DiagnosticTraceContext; +}) { + const attempt = input.attempt; + const session = input.session; + const cacheObservabilityEnabled = Boolean(input.cacheTrace) || log.isEnabled("debug"); + const promptCacheToolNames = collectPromptCacheToolNames( + input.allCustomTools as Array<{ name?: string }>, + ); + if (input.cacheTrace) { + input.cacheTrace.recordStage("session:loaded", { + messages: session.messages, + system: input.systemPromptText, + note: "after session create", + }); + session.agent.streamFn = input.cacheTrace.wrapStreamFn(session.agent.streamFn); + } + + // Anthropic Claude endpoints can reject replayed `thinking` blocks on + // any follow-up provider call, including tool continuations. Sanitize + // outbound messages where policy allows rewriting; otherwise preserve + // latest thinking and let the recovery wrapper retry once without it. + if ( + input.transcriptPolicy.dropThinkingBlocks || + input.transcriptPolicy.dropReasoningFromHistory + ) { + session.agent.streamFn = wrapStreamFnWithMessageTransform( + session.agent.streamFn, + (messages) => { + const reasoningSanitized = input.transcriptPolicy.dropReasoningFromHistory + ? dropReasoningFromHistory(messages) + : messages; + return input.transcriptPolicy.dropThinkingBlocks + ? dropThinkingBlocks(reasoningSanitized) + : reasoningSanitized; + }, + ); + } + if ( + input.transcriptPolicy.preserveSignatures || + input.transcriptPolicy.dropThinkingBlocks || + input.transcriptPolicy.dropReasoningFromHistory + ) { + session.agent.streamFn = wrapAnthropicStreamWithRecovery(session.agent.streamFn, { + id: session.sessionId, + onRecoveredAnthropicThinking: () => { + if (!input.sessionManager) { + log.warn( + `[session-recovery] unable to repair rejected thinking replay: session manager unavailable sessionId=${session.sessionId}`, + ); + return; + } + const repair = repairRejectedThinkingReplayInSessionManager({ + sessionManager: input.sessionManager, + sessionFile: attempt.sessionFile, + sessionId: attempt.sessionId, + sessionKey: attempt.sessionKey, + agentId: input.sessionAgentId, + }); + if (repair.repaired) { + input.onRejectedThinkingReplayRepaired(); + input.sessionLockController.refreshAfterOwnedSessionWrite(); + return; + } + log.warn( + `[session-recovery] rejected thinking replay retry succeeded but transcript repair made no changes: ` + + `sessionId=${session.sessionId} reason=${repair.reason ?? "unknown"}`, + ); + }, + }); + } + + // Mistral (and other strict providers) reject tool call IDs that don't match their + // format requirements (e.g. [a-zA-Z0-9]{9}). sanitizeSessionHistory only processes + // historical messages at attempt start, but the agent loop's internal tool call → + // tool result cycles bypass that path. Wrap streamFn so every outbound request + // sees sanitized tool call IDs. + const replayToolCallIdSanitizerDecision = { + sanitizeToolCallIds: input.transcriptPolicy.sanitizeToolCallIds, + toolCallIdMode: input.transcriptPolicy.toolCallIdMode, + isOpenAIResponsesApi: input.isOpenAIResponsesApi, + }; + if (shouldApplyReplayToolCallIdSanitizer(replayToolCallIdSanitizerDecision)) { + const mode = replayToolCallIdSanitizerDecision.toolCallIdMode; + session.agent.streamFn = wrapStreamFnWithMessageTransform( + session.agent.streamFn, + (messages, model) => + sanitizeReplayToolCallIdsForStream({ + messages, + mode, + allowedToolNames: input.replayAllowedToolNames, + preserveNativeAnthropicToolUseIds: + input.transcriptPolicy.preserveNativeAnthropicToolUseIds, + duplicateToolCallIdStyle: input.transcriptPolicy.duplicateToolCallIdStyle, + preserveReplaySafeThinkingToolCallIds: shouldAllowProviderOwnedThinkingReplay({ + modelApi: (model as { api?: unknown })?.api as string | null | undefined, + provider: attempt.provider, + policy: input.transcriptPolicy, + }), + repairToolUseResultPairing: input.transcriptPolicy.repairToolUseResultPairing, + }), + ); + } + + if (input.isOpenAIResponsesApi) { + session.agent.streamFn = wrapStreamFnWithMessageTransform(session.agent.streamFn, (messages) => + sanitizeOpenAIResponsesReplayForStream(messages), + ); + } + + const innerStreamFn = session.agent.streamFn; + session.agent.streamFn = (model, context, options) => { + const signal = input.abortSignal as AbortSignal & { reason?: unknown }; + if (input.isYieldDetected() && signal.aborted && signal.reason === "sessions_yield") { + return createYieldAbortedResponse(model) as unknown as Awaited< + ReturnType + >; + } + return innerStreamFn(model, context, options); + }; + + // Some models emit tool names with surrounding whitespace (e.g. " read "). + // agent runtime dispatches tool calls with exact string matching, so normalize + // names on the live response stream before tool execution. + session.agent.streamFn = wrapStreamFnSanitizeMalformedToolCalls( + session.agent.streamFn, + input.replayAllowedToolNames, + input.transcriptPolicy, + attempt.provider, + ); + session.agent.streamFn = wrapStreamFnPromoteStandaloneTextToolCalls( + session.agent.streamFn, + input.liveAllowedToolNames, + ); + session.agent.streamFn = wrapStreamFnTrimToolCallNames( + session.agent.streamFn, + input.liveAllowedToolNames, + { + unknownToolThreshold: resolveUnknownToolGuardThreshold(input.clientToolLoopDetection), + }, + ); + + if ( + shouldRepairMalformedToolCallArguments({ + provider: attempt.provider, + modelApi: attempt.model.api, + }) + ) { + session.agent.streamFn = wrapStreamFnRepairMalformedToolCallArguments(session.agent.streamFn); + } + + if (resolveToolCallArgumentsEncoding(attempt.model) === "html-entities") { + session.agent.streamFn = wrapStreamFnDecodeXaiToolCallArguments(session.agent.streamFn); + } + + // Tool-call repair can replace structured arguments from fragmented deltas. + // Restore provider-masked text afterward so executable args stay canonical. + if (input.providerTextTransforms?.output?.length) { + session.agent.streamFn = wrapStreamFnTextTransforms({ + streamFn: session.agent.streamFn, + output: input.providerTextTransforms.output, + }); + } + + if (input.anthropicPayloadLogger) { + session.agent.streamFn = input.anthropicPayloadLogger.wrapStreamFn(session.agent.streamFn); + } + // Anthropic-compatible providers can add new stop reasons before shared model runtime maps them. + // Recover the known "sensitive" stop reason here so a model refusal does not + // bubble out as an uncaught runner error and stall channel polling. + session.agent.streamFn = wrapStreamFnHandleSensitiveStopReason(session.agent.streamFn); + + // Wrap stream with idle timeout detection. + // + // Prefer the caller's explicit `runTimeoutOverrideMs` when provided — + // it carries the "this run was launched with a deliberate per-run + // timeout" signal without losing it when the value numerically equals + // `agents.defaults.timeoutSeconds`. Fall back to the value-equality + // heuristic for callers that haven't been migrated to plumb the flag. + const configuredRunTimeoutMs = resolveAgentTimeoutMs({ + cfg: attempt.config, + }); + const resolvedRunTimeoutMs = + attempt.runTimeoutOverrideMs ?? + (attempt.timeoutMs !== configuredRunTimeoutMs ? attempt.timeoutMs : undefined); + const idleTimeoutMs = resolveLlmIdleTimeoutMs({ + cfg: attempt.config, + trigger: attempt.trigger, + runTimeoutMs: resolvedRunTimeoutMs, + modelRequestTimeoutMs: (attempt.model as { requestTimeoutMs?: number }).requestTimeoutMs, + model: { + baseUrl: attempt.model.baseUrl, + id: attempt.modelId, + provider: attempt.provider, + }, + }); + const firstEventTimeoutMs = resolveLlmFirstEventTimeoutMs({ + cfg: attempt.config, + runTimeoutMs: resolvedRunTimeoutMs, + modelRequestTimeoutMs: (attempt.model as { requestTimeoutMs?: number }).requestTimeoutMs, + model: { + baseUrl: attempt.model.baseUrl, + id: attempt.modelId, + provider: attempt.provider, + }, + }); + if (idleTimeoutMs > 0) { + session.agent.streamFn = streamWithIdleTimeout( + session.agent.streamFn, + idleTimeoutMs, + (error) => input.onIdleTimeout(error), + { runId: attempt.runId }, + ); + } else if (firstEventTimeoutMs > 0) { + // Local providers opt out of gap policing, but the transport first-event + // guard only arms after stream creation. A request whose headers never + // arrive would otherwise wedge until the run budget with no watchdog. + session.agent.streamFn = streamWithIdleTimeout( + session.agent.streamFn, + firstEventTimeoutMs, + (error) => input.onIdleTimeout(error), + { runId: attempt.runId, scope: "creation-only" }, + ); + } + if (firstEventTimeoutMs > 0) { + const baseStreamFn = session.agent.streamFn; + session.agent.streamFn = (model, context, options) => { + type FirstEventStreamOptions = { + firstEventTimeoutMs?: number; + onFirstEventTimeout?: (error: Error) => void; + }; + const optionsWithFirstEvent = options as FirstEventStreamOptions | undefined; + return baseStreamFn(model, context, { + ...options, + firstEventTimeoutMs: optionsWithFirstEvent?.firstEventTimeoutMs ?? firstEventTimeoutMs, + onFirstEventTimeout: optionsWithFirstEvent?.onFirstEventTimeout ?? input.onIdleTimeout, + } as typeof options); + }; + } + let diagnosticModelCallSeq = 0; + session.agent.streamFn = wrapStreamFnWithDiagnosticModelCallEvents(session.agent.streamFn, { + runId: attempt.runId, + ...(attempt.sessionKey && { sessionKey: attempt.sessionKey }), + ...(attempt.sessionId && { sessionId: attempt.sessionId }), + provider: attempt.provider, + model: attempt.modelId, + api: attempt.model.api, + transport: input.effectiveAgentTransport, + ...(attempt.contextWindowInfo?.tokens + ? { contextTokenBudget: attempt.contextWindowInfo.tokens } + : {}), + ...(attempt.contextWindowInfo?.source + ? { contextWindowSource: attempt.contextWindowInfo.source } + : {}), + ...(attempt.contextWindowInfo?.referenceTokens + ? { contextWindowReferenceTokens: attempt.contextWindowInfo.referenceTokens } + : {}), + trace: input.runTrace, + contentCapture: resolveDiagnosticModelContentCapturePolicy(attempt.config), + nextCallId: () => `${attempt.runId}:model:${(diagnosticModelCallSeq += 1)}`, + onStarted: () => { + attempt.onExecutionPhase?.({ + phase: "model_call_started", + provider: attempt.provider, + model: attempt.modelId, + firstModelCallStarted: true, + }); + }, + }); + return { + cacheObservabilityEnabled, + promptCacheToolNames, + }; +} diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index a3b97a05d276..a5322438c5ab 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -5,7 +5,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import { ensureSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared"; import { MAX_IMAGE_BYTES } from "@openclaw/media-core/constants"; -import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { isAcpRuntimeSpawnAvailable } from "../../../acp/runtime/availability.js"; import { buildHierarchyReinforcementMessage } from "../../../auto-reply/handoff-summarizer.js"; @@ -45,7 +44,6 @@ import { emitTrustedDiagnosticEvent, emitTrustedDiagnosticEventWithPrivateData, } from "../../../infra/diagnostic-events.js"; -import { resolveDiagnosticModelContentCapturePolicy } from "../../../infra/diagnostic-llm-content.js"; import { createChildDiagnosticTraceContext, createDiagnosticTraceContext, @@ -57,28 +55,17 @@ import { formatErrorMessage, toErrorObject } from "../../../infra/errors.js"; import { resolveHeartbeatSummaryForAgent } from "../../../infra/heartbeat-summary.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js"; import { resolveRuntimeOsLabel } from "../../../infra/os-summary.js"; -import { createCodexNativeWebSearchWrapper } from "../../../llm/providers/stream-wrappers/openai.js"; import type { AssistantMessage, UserMessage } from "../../../llm/types.js"; import { listRegisteredPluginAgentPromptGuidance } from "../../../plugins/command-registry-state.js"; -import { getCurrentPluginMetadataSnapshot } from "../../../plugins/current-plugin-metadata-snapshot.js"; import { buildAgentHookContextChannelFields, buildAgentHookContextIdentityFields, } from "../../../plugins/hook-agent-context.js"; import { resolveBlockMessage } from "../../../plugins/hook-decision-types.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; -import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.types.js"; -import { - resolveProviderRuntimePluginHandle, - type ProviderRuntimePluginHandle, -} from "../../../plugins/provider-hook-runtime.js"; -import { - extractModelCompat, - resolveToolCallArgumentsEncoding, -} from "../../../plugins/provider-model-compat.js"; +import { extractModelCompat } from "../../../plugins/provider-model-compat.js"; import { resolveProviderSystemPromptContribution, - resolveProviderTextTransforms, transformProviderSystemPrompt, } from "../../../plugins/provider-runtime.js"; import { copyPluginToolMeta, getPluginToolMeta } from "../../../plugins/tools.js"; @@ -91,15 +78,11 @@ import { applySkillEnvOverrides, applySkillEnvOverridesFromSnapshot, } from "../../../skills/runtime/env-overrides.js"; -import { - buildTrajectoryArtifacts, - buildTrajectoryRunMetadata, -} from "../../../trajectory/metadata.js"; +import { buildTrajectoryRunMetadata } from "../../../trajectory/metadata.js"; import { createTrajectoryRuntimeRecorder, toTrajectoryToolDefinitions, } from "../../../trajectory/runtime.js"; -import { resolveUserPath } from "../../../utils.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; import { createBundleLspToolRuntime } from "../../agent-bundle-lsp-runtime.js"; @@ -201,10 +184,8 @@ import { import { resolveModelAuthMode } from "../../model-auth.js"; import { resolveDefaultModelForAgent } from "../../model-selection.js"; import { supportsModelTools } from "../../model-tool-support.js"; -import { wrapStreamFnTextTransforms } from "../../plugin-text-transforms.js"; import { resolveAgentPromptSurfaceForSessionKey } from "../../prompt-surface.js"; import { describeProviderRequestRoutingSummary } from "../../provider-attribution.js"; -import { registerProviderStreamForModel } from "../../provider-stream.js"; import { AGENT_RUN_RESTART_ABORT_STOP_REASON, createAgentRunRestartAbortError, @@ -216,7 +197,6 @@ import { normalizeAgentRuntimeTools, } from "../../runtime-plan/tools.js"; import type { AgentMessage } from "../../runtime/index.js"; -import { resolveSandboxContext } from "../../sandbox.js"; import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js"; import { invalidateSessionFileRepairCache, @@ -246,7 +226,6 @@ import { appendModelIdentitySystemPrompt, buildModelIdentityPromptLine, } from "../../system-prompt.js"; -import { resolveAgentTimeoutMs } from "../../timeout.js"; import { buildEmptyExplicitToolAllowlistError, collectExplicitToolAllowlistSources, @@ -285,7 +264,6 @@ import { replaceWithEffectiveCronCreatorToolAllowlist, type CronCreatorToolAllowlistEntry, } from "../../tools/cron-tool.js"; -import { shouldAllowProviderOwnedThinkingReplay } from "../../transcript-policy.js"; import { normalizeUsage, type NormalizedUsage } from "../../usage.js"; import { DEFAULT_BOOTSTRAP_FILENAME, @@ -302,25 +280,16 @@ import { import { runContextEngineMaintenance } from "../context-engine-maintenance.js"; import { applyFinalEffectiveToolPolicy } from "../effective-tool-policy.js"; import { buildEmbeddedExtensionFactories } from "../extensions.js"; -import { - applyExtraParamsToAgent, - resolveAgentTransportOverride, - resolveExplicitSettingsTransport, - resolveExtraParams, - resolvePreparedExtraParams, -} from "../extra-params.js"; import { prepareGooglePromptCacheStreamFn } from "../google-prompt-cache.js"; import { getHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js"; import { log } from "../logger.js"; import { buildEmbeddedMessageActionDiscoveryInput } from "../message-action-discovery-input.js"; import { - collectPromptCacheToolNames, beginPromptCacheObservation, completePromptCacheObservation, type PromptCacheBreak, type PromptCacheChange, } from "../prompt-cache-observability.js"; -import { resolveCacheRetention } from "../prompt-cache-retention.js"; import { normalizeAssistantReplayContent, sanitizeSessionHistory, @@ -351,19 +320,12 @@ import { markSessionUserTurnsSent, } from "../session-prompt-state.js"; import { - describeEmbeddedAgentStreamStrategy, resetEmbeddedAgentBaseStreamFnCacheForTest, resolveEmbeddedAgentApiKey, resolveEmbeddedAgentBaseStreamFn, resolveEmbeddedAgentStreamFn, } from "../stream-resolution.js"; import { applySystemPromptToSession } from "../system-prompt.js"; -import { repairRejectedThinkingReplayInSessionManager } from "../thinking-replay-repair.js"; -import { - dropReasoningFromHistory, - dropThinkingBlocks, - wrapAnthropicStreamWithRecovery, -} from "../thinking.js"; import { collectCoreBuiltinToolNames, collectRegisteredToolNames, @@ -381,17 +343,15 @@ import { truncateOversizedToolResultsInSessionManager, } from "../tool-result-truncation.js"; import { splitSdkTools } from "../tool-split.js"; -import { mapThinkingLevel, mapThinkingLevelForProvider } from "../utils.js"; import { flushPendingToolResultsAfterIdle } from "../wait-for-idle-before-flush.js"; import { abortable as abortableWithSignal } from "./abortable.js"; import { releaseEmbeddedAttemptSessionLockForAbort } from "./attempt-abort.js"; -import { configureEmbeddedAttemptHttpRuntime } from "./attempt-http-runtime.js"; +import { finalizeEmbeddedAttempt } from "./attempt-finalize.js"; import { createEmbeddedAgentSessionWithResourceLoader } from "./attempt-session.js"; -import { - createEmbeddedRunStageTracker, - formatEmbeddedRunStageSummary, - shouldWarnEmbeddedRunStageSummary, -} from "./attempt-stage-timing.js"; +import { prepareEmbeddedAttemptSetup } from "./attempt-setup.js"; +import { createEmbeddedRunStageTracker } from "./attempt-stage-timing.js"; +import { prepareEmbeddedAttemptTransport } from "./attempt-stream-transport.js"; +import { installEmbeddedAttemptStreamGuards } from "./attempt-stream.js"; import { buildAttemptSystemPrompt } from "./attempt-system-prompt.js"; import { applyEmbeddedAttemptToolsAllow, @@ -401,10 +361,6 @@ import { shouldCreateBundleMcpRuntimeForAttempt, } from "./attempt-tool-construction-plan.js"; import { flushEmbeddedAttemptTrajectoryRecorder } from "./attempt-trajectory-flush-cleanup.js"; -import { - resolveAttemptTrajectoryTerminal, - resolveTerminalAssistantTexts, -} from "./attempt-trajectory-status.js"; import { requiresCompletionRequiredAsyncTaskWait, shouldWaitForCompletionRequiredAsyncTasks, @@ -430,12 +386,10 @@ import { normalizeMessagesForCurrentPromptBoundary, normalizeMessagesForLlmBoundary, } from "./attempt.llm-boundary.js"; -import { wrapStreamFnWithDiagnosticModelCallEvents } from "./attempt.model-diagnostic-events.js"; import { buildAfterTurnRuntimeContext, buildAfterTurnRuntimeContextFromUsage, prependSystemPromptAddition, - resolveAttemptFsWorkspaceOnly, resolveAttemptMediaTaskSystemPromptAddition, resolvePromptBuildHookResult, resolvePromptModeForSession, @@ -445,10 +399,8 @@ import { } from "./attempt.prompt-helpers.js"; import { steerActiveSessionWithOptionalDeliveryWait } from "./attempt.queue-message.js"; import { - resolveAttemptStreamAuthProfileId, resolveAttemptToolPolicyMessageProvider, resolveEmbeddedAttemptSessionWriteLockOptions, - resolveUnknownToolGuardThreshold, shouldRunLlmOutputHooksForAttempt, } from "./attempt.run-decisions.js"; import { @@ -459,13 +411,11 @@ import { installPromptSubmissionLockRelease, } from "./attempt.session-lock.js"; import { - createYieldAbortedResponse, persistSessionsYieldContextMessage, queueSessionsYieldInterruptMessage, stripSessionsYieldArtifacts, waitForSessionsYieldAbortSettle, } from "./attempt.sessions-yield.js"; -import { wrapStreamFnHandleSensitiveStopReason } from "./attempt.stop-reason-recovery.js"; import { buildEmbeddedSubscriptionParams, cleanupEmbeddedAttemptResources, @@ -476,19 +426,6 @@ import { resolveAttemptSpawnWorkspaceDir, shouldPersistCompletedBootstrapTurn, } from "./attempt.thread-helpers.js"; -import { - shouldRepairMalformedToolCallArguments, - wrapStreamFnDecodeXaiToolCallArguments, - wrapStreamFnRepairMalformedToolCallArguments, -} from "./attempt.tool-call-argument-repair.js"; -import { - sanitizeOpenAIResponsesReplayForStream, - sanitizeReplayToolCallIdsForStream, - shouldApplyReplayToolCallIdSanitizer, - wrapStreamFnPromoteStandaloneTextToolCalls, - wrapStreamFnSanitizeMalformedToolCalls, - wrapStreamFnTrimToolCallNames, -} from "./attempt.tool-call-normalization.js"; import { buildEmbeddedAttemptToolRunContext } from "./attempt.tool-run-context.js"; import { buildToolSearchRunPlan, @@ -522,11 +459,6 @@ import { resolveSilentToolResultReplyPayload, shouldTreatEmptyAssistantReplyAsSilent, } from "./incomplete-turn.js"; -import { - resolveLlmFirstEventTimeoutMs, - resolveLlmIdleTimeoutMs, - streamWithIdleTimeout, -} from "./llm-idle-timeout.js"; import { resolveMessageMergeStrategy, type MessageMergeStrategy, @@ -595,31 +527,6 @@ export { const MAX_BTW_SNAPSHOT_MESSAGES = 100; const aggregateToolResultPressureWarnings = new Set(); -function pluginMetadataSnapshotCoversProvider( - snapshot: PluginMetadataSnapshot | undefined, - provider: string, -): snapshot is PluginMetadataSnapshot { - const normalizedProvider = normalizeProviderId(provider); - if (!snapshot || !normalizedProvider) { - return false; - } - return snapshot.manifestRegistry.plugins.some((plugin) => { - const ownsProvider = plugin.providers.some( - (providerId) => normalizeProviderId(providerId) === normalizedProvider, - ); - if (ownsProvider) { - return true; - } - const modelCatalogProviderIds = [ - ...Object.keys(plugin.modelCatalog?.providers ?? {}), - ...Object.keys(plugin.modelCatalog?.aliases ?? {}), - ]; - return modelCatalogProviderIds.some( - (providerId) => normalizeProviderId(providerId) === normalizedProvider, - ); - }); -} - function summarizeMessagePayload(msg: AgentMessage): { textChars: number; imageBlocks: number } { const content = (msg as { content?: unknown }).content; if (typeof content === "string") { @@ -1096,128 +1003,24 @@ async function resolveExistingAttemptTranscriptState(params: { export async function runEmbeddedAttempt( params: EmbeddedRunAttemptParams, ): Promise { - const resolvedWorkspace = resolveUserPath(params.workspaceDir); const runAbortController = new AbortController(); - // Ultra is a logical orchestration mode, not a provider effort. Preserve it for - // prompt/status surfaces, then lower only at agent-core and provider boundaries. - const agentCoreThinkingLevel = mapThinkingLevel(params.thinkLevel); - const providerThinkingLevel = mapThinkingLevelForProvider(params.thinkLevel); - const proactiveSubagentOrchestration = params.thinkLevel === "ultra"; - configureEmbeddedAttemptHttpRuntime({ timeoutMs: params.timeoutMs }); - - log.debug( - `embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${params.provider} model=${params.modelId} thinking=${params.thinkLevel} messageChannel=${params.messageChannel ?? params.messageProvider ?? "unknown"}`, - ); - const prepStages = createEmbeddedRunStageTracker(); - const emitPrepStageSummary = (phase: string) => { - const summary = prepStages.snapshot(); - const shouldWarn = shouldWarnEmbeddedRunStageSummary(summary); - if (!shouldWarn && !log.isEnabled("trace")) { - return; - } - const message = formatEmbeddedRunStageSummary( - `[trace:embedded-run] prep stages: runId=${params.runId} sessionId=${params.sessionId} phase=${phase}`, - summary, - ); - if (shouldWarn) { - log.warn(message); - } else { - log.trace(message); - } - }; - const emitCorePluginToolStageSummary = ( - phase: string, - summary: ReturnType, - ) => { - if (summary.stages.length === 0) { - return; - } - const shouldWarn = shouldWarnEmbeddedRunStageSummary(summary, { - totalThresholdMs: 5_000, - stageThresholdMs: 2_000, - }); - if (!shouldWarn && !log.isEnabled("trace")) { - return; - } - const message = formatEmbeddedRunStageSummary( - `[trace:embedded-run] core-plugin-tool stages: runId=${params.runId} sessionId=${params.sessionId} phase=${phase}`, - summary, - ); - if (shouldWarn) { - log.warn(message); - } else { - log.trace(message); - } - }; - - await fs.mkdir(resolvedWorkspace, { recursive: true }); - - const sandboxSessionKey = - params.sandboxSessionKey?.trim() || params.sessionKey?.trim() || params.sessionId; - const sandbox = await resolveSandboxContext({ - config: params.config, - execOverrides: params.execOverrides, - sessionKey: sandboxSessionKey, - workspaceDir: resolvedWorkspace, - }); - const effectiveWorkspace = sandbox?.enabled - ? sandbox.workspaceAccess === "rw" - ? resolvedWorkspace - : sandbox.workspaceDir - : resolvedWorkspace; - const requestedCwd = params.cwd ? resolveUserPath(params.cwd) : undefined; - if (sandbox?.enabled && requestedCwd && requestedCwd !== resolvedWorkspace) { - throw new Error( - "cwd override is not supported for sandboxed embedded agent runs; omit cwd or use the agent workspace as cwd", - ); - } - const effectiveCwd = sandbox?.enabled ? effectiveWorkspace : (requestedCwd ?? effectiveWorkspace); - await fs.mkdir(effectiveWorkspace, { recursive: true }); - let currentPluginMetadataSnapshotResolved = false; - let currentPluginMetadataSnapshot: PluginMetadataSnapshot | undefined; - const getCurrentAttemptPluginMetadataSnapshot = () => { - if (!currentPluginMetadataSnapshotResolved) { - currentPluginMetadataSnapshot = getCurrentPluginMetadataSnapshot({ - allowScopedSnapshot: true, - config: params.config, - env: process.env, - workspaceDir: effectiveWorkspace, - }); - currentPluginMetadataSnapshotResolved = true; - } - return currentPluginMetadataSnapshot; - }; - let providerRuntimeHandle: ProviderRuntimePluginHandle | undefined; - const getProviderRuntimeHandle = () => { - if (providerRuntimeHandle?.plugin) { - return providerRuntimeHandle; - } - const pluginMetadataSnapshot = getCurrentAttemptPluginMetadataSnapshot(); - const resolvedHandle = resolveProviderRuntimePluginHandle({ - provider: params.provider, - modelId: params.modelId, - config: params.config, - workspaceDir: effectiveWorkspace, - env: process.env, - ...(pluginMetadataSnapshotCoversProvider(pluginMetadataSnapshot, params.provider) - ? { pluginMetadataSnapshot } - : {}), - }); - if (resolvedHandle.plugin) { - providerRuntimeHandle = resolvedHandle; - } - return resolvedHandle; - }; - const { sessionAgentId } = resolveSessionAgentIds({ - sessionKey: params.sessionKey, - config: params.config, - agentId: params.agentId, - }); - const effectiveFsWorkspaceOnly = resolveAttemptFsWorkspaceOnly({ - config: params.config, + const { + agentCoreThinkingLevel, + effectiveCwd, + effectiveFsWorkspaceOnly, + effectiveWorkspace, + emitCorePluginToolStageSummary, + emitPrepStageSummary, + getCurrentAttemptPluginMetadataSnapshot, + getProviderRuntimeHandle, + prepStages, + proactiveSubagentOrchestration, + providerThinkingLevel, + resolvedWorkspace, + sandbox, + sandboxSessionKey, sessionAgentId, - }); - prepStages.mark("workspace-sandbox"); + } = await prepareEmbeddedAttemptSetup(params); let restoreSkillEnv: (() => void) | undefined; let aborted = Boolean(params.abortSignal?.aborted); @@ -3296,427 +3099,56 @@ export async function runEmbeddedAttempt( }), ); - // Rebuild each turn from the session's original stream base so prior-turn - // wrappers do not pin us to stale provider/API transport behavior. - const defaultSessionStreamFn = resolveEmbeddedAgentBaseStreamFn({ + const { + effectiveAgentTransport, + effectiveExtraParams, + effectivePromptCacheRetention, + providerTextTransforms, + streamStrategy, + } = prepareEmbeddedAttemptTransport({ + attempt: params, session: activeSession, - }); - const resolvedTransport = resolveExplicitSettingsTransport({ settingsManager, - sessionTransport: activeSession.agent.transport, - }); - const streamExtraParamsOverride = { - ...params.streamParams, - fastMode: params.fastMode, - }; - const preparedRuntimeExtraParams = params.runtimePlan?.transport.resolveExtraParams({ - extraParamsOverride: streamExtraParamsOverride, - thinkingLevel: providerThinkingLevel, - agentId: sessionAgentId, - workspaceDir: effectiveWorkspace, - model: params.model, - resolvedTransport, - }); - const resolvedExtraParams = resolveExtraParams({ - cfg: params.config, - provider: params.provider, - modelId: params.modelId, - agentId: sessionAgentId, - }); - const effectiveExtraParams = - preparedRuntimeExtraParams ?? - resolvePreparedExtraParams({ - cfg: params.config, - provider: params.provider, - modelId: params.modelId, - extraParamsOverride: streamExtraParamsOverride, - thinkingLevel: providerThinkingLevel, - agentId: sessionAgentId, - agentDir, - workspaceDir: effectiveWorkspace, - resolvedExtraParams, - model: params.model, - resolvedTransport, - }); - const providerStreamFn = registerProviderStreamForModel({ - model: params.model, - cfg: params.config, - agentDir, - workspaceDir: effectiveWorkspace, - }); - const streamStrategy = describeEmbeddedAgentStreamStrategy({ - currentStreamFn: defaultSessionStreamFn, - providerStreamFn, - model: params.model, - resolvedApiKey: params.resolvedApiKey, - }); - activeSession.agent.streamFn = resolveEmbeddedAgentStreamFn({ - currentStreamFn: defaultSessionStreamFn, - providerStreamFn, - sessionId: params.sessionId, - promptCacheKey: params.promptCacheKey, - signal: runAbortController.signal, - model: params.model, - resolvedApiKey: params.resolvedApiKey, - authProfileId: resolveAttemptStreamAuthProfileId(params), - authStorage: params.authStorage, - }); - const providerTextTransforms = resolveProviderTextTransforms({ - provider: params.provider, - config: params.config, - workspaceDir: effectiveWorkspace, - runtimeHandle: getProviderRuntimeHandle(), - }); - if (providerTextTransforms?.input?.length) { - activeSession.agent.streamFn = wrapStreamFnTextTransforms({ - streamFn: activeSession.agent.streamFn, - input: providerTextTransforms.input, - transformSystemPrompt: false, - }); - } - const nativeWebSearchPolicyContext = { - sessionKey: sandboxSessionKey, - sandboxToolPolicy: sandbox?.tools, - messageProvider: resolveAttemptToolPolicyMessageProvider(params), - agentAccountId: params.agentAccountId, - groupId: params.groupId, - groupChannel: params.groupChannel, - groupSpace: params.groupSpace, - spawnedBy: params.spawnedBy, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - }; - - applyExtraParamsToAgent( - activeSession.agent, - params.config, - params.provider, - params.modelId, - streamExtraParamsOverride, providerThinkingLevel, sessionAgentId, - effectiveWorkspace, - params.model, + workspaceDir: effectiveWorkspace, agentDir, - resolvedTransport, - { - preparedExtraParams: effectiveExtraParams, - nativeWebSearchPolicyContext, - }, - ); - if (codeModeControlsEnabledForRun) { - activeSession.agent.streamFn = createCodexNativeWebSearchWrapper( - activeSession.agent.streamFn, - { - config: params.config, - agentDir, - agentId: sessionAgentId, - ...nativeWebSearchPolicyContext, - codeModeToolSurfaceEnabled: true, - }, - ); - } - const effectivePromptCacheRetention = resolveCacheRetention( - effectiveExtraParams, - params.provider, - params.model.api, - params.modelId, - ); - const agentTransportOverride = resolveAgentTransportOverride({ - settingsManager, - effectiveExtraParams, + abortSignal: runAbortController.signal, + getProviderRuntimeHandle, + sandboxSessionKey, + sandbox, + codeModeControlsEnabled: codeModeControlsEnabledForRun, }); - const effectiveAgentTransport = agentTransportOverride ?? activeSession.agent.transport; - if (agentTransportOverride && activeSession.agent.transport !== agentTransportOverride) { - const previousTransport = activeSession.agent.transport; - log.debug( - `embedded agent transport override: ${previousTransport} -> ${agentTransportOverride} ` + - `(${params.provider}/${params.modelId})`, - ); - } + const { cacheObservabilityEnabled, promptCacheToolNames } = + installEmbeddedAttemptStreamGuards({ + attempt: params, + session: activeSession, + sessionAgentId, + cacheTrace, + allCustomTools, + systemPromptText, + transcriptPolicy, + sessionManager, + sessionLockController, + isOpenAIResponsesApi, + replayAllowedToolNames, + liveAllowedToolNames, + isYieldDetected: () => yieldDetected, + clientToolLoopDetection, + anthropicPayloadLogger, + onRejectedThinkingReplayRepaired: () => { + repairedRejectedThinkingReplay = true; + }, + onIdleTimeout: (error) => idleTimeoutTrigger?.(error), + effectiveAgentTransport, + providerTextTransforms, + abortSignal: runAbortController.signal, + runTrace, + }); prepStages.mark("stream-setup"); emitPrepStageSummary("stream-ready"); - - const cacheObservabilityEnabled = Boolean(cacheTrace) || log.isEnabled("debug"); - const promptCacheToolNames = collectPromptCacheToolNames( - allCustomTools as Array<{ name?: string }>, - ); let promptCacheChangesForTurn: PromptCacheChange[] | null = null; - if (cacheTrace) { - cacheTrace.recordStage("session:loaded", { - messages: activeSession.messages, - system: systemPromptText, - note: "after session create", - }); - activeSession.agent.streamFn = cacheTrace.wrapStreamFn(activeSession.agent.streamFn); - } - - // Anthropic Claude endpoints can reject replayed `thinking` blocks on - // any follow-up provider call, including tool continuations. Sanitize - // outbound messages where policy allows rewriting; otherwise preserve - // latest thinking and let the recovery wrapper retry once without it. - if (transcriptPolicy.dropThinkingBlocks || transcriptPolicy.dropReasoningFromHistory) { - activeSession.agent.streamFn = wrapStreamFnWithMessageTransform( - activeSession.agent.streamFn, - (messages) => { - const reasoningSanitized = transcriptPolicy.dropReasoningFromHistory - ? dropReasoningFromHistory(messages) - : messages; - return transcriptPolicy.dropThinkingBlocks - ? dropThinkingBlocks(reasoningSanitized) - : reasoningSanitized; - }, - ); - } - if ( - transcriptPolicy.preserveSignatures || - transcriptPolicy.dropThinkingBlocks || - transcriptPolicy.dropReasoningFromHistory - ) { - activeSession.agent.streamFn = wrapAnthropicStreamWithRecovery( - activeSession.agent.streamFn, - { - id: activeSession.sessionId, - onRecoveredAnthropicThinking: () => { - if (!sessionManager) { - log.warn( - `[session-recovery] unable to repair rejected thinking replay: session manager unavailable sessionId=${activeSession.sessionId}`, - ); - return; - } - const repair = repairRejectedThinkingReplayInSessionManager({ - sessionManager, - sessionFile: params.sessionFile, - sessionId: params.sessionId, - sessionKey: params.sessionKey, - agentId: sessionAgentId, - }); - if (repair.repaired) { - repairedRejectedThinkingReplay = true; - sessionLockController.refreshAfterOwnedSessionWrite(); - return; - } - log.warn( - `[session-recovery] rejected thinking replay retry succeeded but transcript repair made no changes: ` + - `sessionId=${activeSession.sessionId} reason=${repair.reason ?? "unknown"}`, - ); - }, - }, - ); - } - - // Mistral (and other strict providers) reject tool call IDs that don't match their - // format requirements (e.g. [a-zA-Z0-9]{9}). sanitizeSessionHistory only processes - // historical messages at attempt start, but the agent loop's internal tool call → - // tool result cycles bypass that path. Wrap streamFn so every outbound request - // sees sanitized tool call IDs. - const replayToolCallIdSanitizerDecision = { - sanitizeToolCallIds: transcriptPolicy.sanitizeToolCallIds, - toolCallIdMode: transcriptPolicy.toolCallIdMode, - isOpenAIResponsesApi, - }; - if (shouldApplyReplayToolCallIdSanitizer(replayToolCallIdSanitizerDecision)) { - const mode = replayToolCallIdSanitizerDecision.toolCallIdMode; - activeSession.agent.streamFn = wrapStreamFnWithMessageTransform( - activeSession.agent.streamFn, - (messages, model) => - sanitizeReplayToolCallIdsForStream({ - messages, - mode, - allowedToolNames: replayAllowedToolNames, - preserveNativeAnthropicToolUseIds: transcriptPolicy.preserveNativeAnthropicToolUseIds, - duplicateToolCallIdStyle: transcriptPolicy.duplicateToolCallIdStyle, - preserveReplaySafeThinkingToolCallIds: shouldAllowProviderOwnedThinkingReplay({ - modelApi: (model as { api?: unknown })?.api as string | null | undefined, - provider: params.provider, - policy: transcriptPolicy, - }), - repairToolUseResultPairing: transcriptPolicy.repairToolUseResultPairing, - }), - ); - } - - if (isOpenAIResponsesApi) { - activeSession.agent.streamFn = wrapStreamFnWithMessageTransform( - activeSession.agent.streamFn, - (messages) => sanitizeOpenAIResponsesReplayForStream(messages), - ); - } - - const innerStreamFn = activeSession.agent.streamFn; - activeSession.agent.streamFn = (model, context, options) => { - const signal = runAbortController.signal as AbortSignal & { reason?: unknown }; - if (yieldDetected && signal.aborted && signal.reason === "sessions_yield") { - return createYieldAbortedResponse(model) as unknown as Awaited< - ReturnType - >; - } - return innerStreamFn(model, context, options); - }; - - // Some models emit tool names with surrounding whitespace (e.g. " read "). - // agent runtime dispatches tool calls with exact string matching, so normalize - // names on the live response stream before tool execution. - activeSession.agent.streamFn = wrapStreamFnSanitizeMalformedToolCalls( - activeSession.agent.streamFn, - replayAllowedToolNames, - transcriptPolicy, - params.provider, - ); - activeSession.agent.streamFn = wrapStreamFnPromoteStandaloneTextToolCalls( - activeSession.agent.streamFn, - liveAllowedToolNames, - ); - activeSession.agent.streamFn = wrapStreamFnTrimToolCallNames( - activeSession.agent.streamFn, - liveAllowedToolNames, - { - unknownToolThreshold: resolveUnknownToolGuardThreshold(clientToolLoopDetection), - }, - ); - - if ( - shouldRepairMalformedToolCallArguments({ - provider: params.provider, - modelApi: params.model.api, - }) - ) { - activeSession.agent.streamFn = wrapStreamFnRepairMalformedToolCallArguments( - activeSession.agent.streamFn, - ); - } - - if (resolveToolCallArgumentsEncoding(params.model) === "html-entities") { - activeSession.agent.streamFn = wrapStreamFnDecodeXaiToolCallArguments( - activeSession.agent.streamFn, - ); - } - - // Tool-call repair can replace structured arguments from fragmented deltas. - // Restore provider-masked text afterward so executable args stay canonical. - if (providerTextTransforms?.output?.length) { - activeSession.agent.streamFn = wrapStreamFnTextTransforms({ - streamFn: activeSession.agent.streamFn, - output: providerTextTransforms.output, - }); - } - - if (anthropicPayloadLogger) { - activeSession.agent.streamFn = anthropicPayloadLogger.wrapStreamFn( - activeSession.agent.streamFn, - ); - } - // Anthropic-compatible providers can add new stop reasons before shared model runtime maps them. - // Recover the known "sensitive" stop reason here so a model refusal does not - // bubble out as an uncaught runner error and stall channel polling. - activeSession.agent.streamFn = wrapStreamFnHandleSensitiveStopReason( - activeSession.agent.streamFn, - ); - - // Wrap stream with idle timeout detection. - // - // Prefer the caller's explicit `runTimeoutOverrideMs` when provided — - // it carries the "this run was launched with a deliberate per-run - // timeout" signal without losing it when the value numerically equals - // `agents.defaults.timeoutSeconds`. Fall back to the value-equality - // heuristic for callers that haven't been migrated to plumb the flag. - const configuredRunTimeoutMs = resolveAgentTimeoutMs({ - cfg: params.config, - }); - const resolvedRunTimeoutMs = - params.runTimeoutOverrideMs ?? - (params.timeoutMs !== configuredRunTimeoutMs ? params.timeoutMs : undefined); - const idleTimeoutMs = resolveLlmIdleTimeoutMs({ - cfg: params.config, - trigger: params.trigger, - runTimeoutMs: resolvedRunTimeoutMs, - modelRequestTimeoutMs: (params.model as { requestTimeoutMs?: number }).requestTimeoutMs, - model: { - baseUrl: params.model.baseUrl, - id: params.modelId, - provider: params.provider, - }, - }); - const firstEventTimeoutMs = resolveLlmFirstEventTimeoutMs({ - cfg: params.config, - runTimeoutMs: resolvedRunTimeoutMs, - modelRequestTimeoutMs: (params.model as { requestTimeoutMs?: number }).requestTimeoutMs, - model: { - baseUrl: params.model.baseUrl, - id: params.modelId, - provider: params.provider, - }, - }); - if (idleTimeoutMs > 0) { - activeSession.agent.streamFn = streamWithIdleTimeout( - activeSession.agent.streamFn, - idleTimeoutMs, - (error) => idleTimeoutTrigger?.(error), - { runId: params.runId }, - ); - } else if (firstEventTimeoutMs > 0) { - // Local providers opt out of gap policing, but the transport first-event - // guard only arms after stream creation. A request whose headers never - // arrive would otherwise wedge until the run budget with no watchdog. - activeSession.agent.streamFn = streamWithIdleTimeout( - activeSession.agent.streamFn, - firstEventTimeoutMs, - (error) => idleTimeoutTrigger?.(error), - { runId: params.runId, scope: "creation-only" }, - ); - } - if (firstEventTimeoutMs > 0) { - const baseStreamFn = activeSession.agent.streamFn; - activeSession.agent.streamFn = (model, context, options) => { - type FirstEventStreamOptions = { - firstEventTimeoutMs?: number; - onFirstEventTimeout?: (error: Error) => void; - }; - const optionsWithFirstEvent = options as FirstEventStreamOptions | undefined; - return baseStreamFn(model, context, { - ...options, - firstEventTimeoutMs: optionsWithFirstEvent?.firstEventTimeoutMs ?? firstEventTimeoutMs, - onFirstEventTimeout: optionsWithFirstEvent?.onFirstEventTimeout ?? idleTimeoutTrigger, - } as typeof options); - }; - } - let diagnosticModelCallSeq = 0; - activeSession.agent.streamFn = wrapStreamFnWithDiagnosticModelCallEvents( - activeSession.agent.streamFn, - { - runId: params.runId, - ...(params.sessionKey && { sessionKey: params.sessionKey }), - ...(params.sessionId && { sessionId: params.sessionId }), - provider: params.provider, - model: params.modelId, - api: params.model.api, - transport: effectiveAgentTransport, - ...(params.contextWindowInfo?.tokens - ? { contextTokenBudget: params.contextWindowInfo.tokens } - : {}), - ...(params.contextWindowInfo?.source - ? { contextWindowSource: params.contextWindowInfo.source } - : {}), - ...(params.contextWindowInfo?.referenceTokens - ? { contextWindowReferenceTokens: params.contextWindowInfo.referenceTokens } - : {}), - trace: runTrace, - contentCapture: resolveDiagnosticModelContentCapturePolicy(params.config), - nextCallId: () => `${params.runId}:model:${(diagnosticModelCallSeq += 1)}`, - onStarted: () => { - params.onExecutionPhase?.({ - phase: "model_call_started", - provider: params.provider, - model: params.modelId, - firstModelCallStarted: true, - }); - }, - }, - ); - try { if (isRawModelRun) { activeSession.agent.reset(); @@ -6262,97 +5694,7 @@ export async function runEmbeddedAttempt( timedOutDuringCompaction, }, }); - const terminalAssistantTexts = resolveTerminalAssistantTexts({ - assistantTexts, - lastAssistantStopReason: lastAssistant?.stopReason, - lastAssistantVisibleText: resolveFinalAssistantVisibleText(lastAssistant), - }); - const attemptTrajectoryTerminal = resolveAttemptTrajectoryTerminal({ - promptError, - aborted, - externalAbort, - timedOut, - assistantTexts: terminalAssistantTexts, - toolMetas: toolMetasNormalized, - didSendViaMessagingTool: didSendViaMessagingTool(), - didSendDeterministicApprovalPrompt: didSendDeterministicApprovalPromptNow, - messagingToolSentTexts: getMessagingToolSentTexts(), - messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(), - messagingToolSentTargets: getMessagingToolSentTargets(), - successfulCronAdds: getSuccessfulCronAdds(), - synthesizedPayloadCount, - acceptedSessionSpawns, - heartbeatToolResponse, - clientToolCalls: completedClientToolCalls, - yieldDetected, - lastToolError, - silentExpected: params.silentExpected, - emptyAssistantReplyIsSilent, - lastAssistantStopReason: lastAssistant?.stopReason, - hasTerminalOutput, - }); - trajectoryRecorder?.recordEvent("model.completed", { - aborted, - externalAbort, - timedOut, - idleTimedOut, - timedOutDuringCompaction, - timedOutDuringToolExecution, - timedOutByRunBudget, - promptError: promptError ? formatErrorMessage(promptError) : undefined, - promptErrorSource, - terminalError: attemptTrajectoryTerminal.terminalError, - usage: attemptUsage, - promptCache, - compactionCount: getCompactionCount(), - assistantTexts, - finalPromptText, - messagesSnapshot, - }); - trajectoryRecorder?.recordEvent( - "trace.artifacts", - buildTrajectoryArtifacts({ - status: attemptTrajectoryTerminal.status, - aborted, - externalAbort, - timedOut, - idleTimedOut, - timedOutDuringCompaction, - timedOutDuringToolExecution, - timedOutByRunBudget, - promptError: promptError ? formatErrorMessage(promptError) : undefined, - promptErrorSource, - terminalError: attemptTrajectoryTerminal.terminalError, - usage: attemptUsage, - promptCache, - compactionCount: getCompactionCount(), - assistantTexts, - finalPromptText, - itemLifecycle: getItemLifecycle(), - toolMetas: toolMetasNormalized, - didSendViaMessagingTool: didSendViaMessagingTool(), - successfulCronAdds: getSuccessfulCronAdds(), - messagingToolSentTexts: getMessagingToolSentTexts(), - messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(), - messagingToolSentTargets: getMessagingToolSentTargets(), - lastToolError, - }), - ); - trajectoryRecorder?.recordEvent("session.ended", { - status: attemptTrajectoryTerminal.status, - aborted, - externalAbort, - timedOut, - idleTimedOut, - timedOutDuringCompaction, - timedOutDuringToolExecution, - timedOutByRunBudget, - promptError: promptError ? formatErrorMessage(promptError) : undefined, - terminalError: attemptTrajectoryTerminal.terminalError, - }); - trajectoryEndRecorded = true; - - return { + const result: EmbeddedRunAttemptResult = { replayMetadata, currentAttemptReplayMetadata, itemLifecycle: getItemLifecycle(), @@ -6410,6 +5752,16 @@ export async function runEmbeddedAttempt( clientToolCalls: completedClientToolCalls.length > 0 ? completedClientToolCalls : undefined, yieldDetected: yieldDetected || undefined, }; + const finalizedResult = finalizeEmbeddedAttempt({ + result, + trajectoryRecorder, + synthesizedPayloadCount, + emptyAssistantReplyIsSilent, + hasTerminalOutput, + silentExpected: params.silentExpected, + }); + trajectoryEndRecorded = true; + return finalizedResult; } finally { if (trajectoryRecorder && !trajectoryEndRecorded) { trajectoryRecorder.recordEvent("session.ended", { diff --git a/src/agents/openai-completions-transport.ts b/src/agents/openai-completions-transport.ts new file mode 100644 index 000000000000..47f4b2250101 --- /dev/null +++ b/src/agents/openai-completions-transport.ts @@ -0,0 +1,1948 @@ +/** + * OpenAI Chat Completions streaming transport. + */ +import { randomUUID } from "node:crypto"; +import { + convertMessages, + isOpenAIGpt54MiniModel, + isOpenAIGpt55Model, + isOpenAIGpt56Model, + mapOpenAIStopReason, + normalizeOpenAIStrictToolParameters, + projectOpenAITools, + reconcileOpenAICompletionsToolChoice, + resolveOpenAIReasoningEffortForModel, + type OpenAIReasoningEffort, +} from "@openclaw/ai/internal/openai"; +import { + applyProviderReportedUsageCost, + calculateCost, + createFirstStreamEventAbortController, + createReasoningTagTextPartitioner, + getEnvApiKey, + getFirstStreamEventTimeoutHandler, + getFirstStreamEventTimeoutMs, + parseStreamingJson, + withFirstStreamEventTimeout, +} from "@openclaw/ai/internal/runtime"; +import { stripSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import OpenAI from "openai"; +import type { ChatCompletionChunk } from "openai/resources/chat/completions.js"; +import type { Context, Model } from "../llm/types.js"; +import "../llm/ai-transport-host.js"; +import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"; +import { + isGoogleGemini3FlashModel, + isGoogleGemini3ProModel, +} from "../plugin-sdk/provider-stream-shared.js"; +import { CHARS_PER_TOKEN_ESTIMATE, estimateStringChars } from "../utils/cjk-chars.js"; +import { createDeepSeekTextFilter } from "./deepseek-text-filter.js"; +import { resolveMaxTokensParam } from "./model-max-tokens-params.js"; +import { supportsModelTools } from "./model-tool-support.js"; +import { emitModelTransportDebug } from "./model-transport-debug.js"; +import { hasOpenAICompatibleConversationTurn } from "./openai-compatible-conversation-turn.js"; +import { detectOpenAICompletionsCompat } from "./openai-completions-compat.js"; +import { + flattenCompletionMessagesToStringContent, + stripCompletionMessagesToRoleContent, +} from "./openai-completions-string-content.js"; +import { resolveOpenAIStrictToolSetting } from "./openai-strict-tool-setting.js"; +import { + assertCodeModeResponsesToolSurface, + buildOpenAIClientHeaders, + buildOpenAISdkClientOptions, + buildOpenAISdkRequestOptions, + enforceCodeModeResponsesToolSurface, + getCompat, + resolveOpenAIStrictToolFlagWithDiagnostics, +} from "./openai-transport-params.js"; +import { + GEMINI_THOUGHT_SIGNATURE_VALIDATOR_SKIP, + createModelStreamCooperativeScheduler, + log, + resolveCacheRetention, + resolvePromptCacheKey, + sortTransportToolsByName, + throwIfModelStreamAborted, + type MutableAssistantOutput, + type OpenAICompletionsOptions, + type OpenAIModeModel, +} from "./openai-transport-shared.js"; +import { resolveProviderEndpoint } from "./provider-attribution.js"; +import { buildGuardedModelFetch } from "./provider-transport-fetch.js"; +import type { StreamFn } from "./runtime/index.js"; +import { failTransportStream, finalizeTransportStream } from "./transport-stream-shared.js"; + +function hasToolHistory(messages: Context["messages"]): boolean { + return messages.some( + (message) => + message.role === "toolResult" || + // Assistant content can be a raw string from transcript replay; a string + // never carries tool calls, so it should not count toward tool history. + (message.role === "assistant" && + Array.isArray(message.content) && + message.content.some((block) => block.type === "toolCall")), + ); +} + +function assertOpenAICompletionsPayloadHasConversationTurn( + params: Record, + model: Model, +): void { + const messages = params.messages; + if (!Array.isArray(messages) || hasOpenAICompatibleConversationTurn(messages)) { + return; + } + throw new Error( + `OpenAI-compatible chat payload for ${model.provider}/${model.id} contains no non-empty user or assistant messages after compaction and transport transforms; refusing to send a system/tool-only request. Start a new user turn or repair the compacted session history.`, + ); +} + +const SSE_DONE_LINE_RE = /^data:[ \t]*\[DONE\][ \t]*$/i; +const SSE_DONE_MAX_LINE_CHARS = 1_024; + +function createSseDoneDetector() { + const decoder = new TextDecoder(); + let line = ""; + let lineOverflowed = false; + let sawDone = false; + + const finishLine = () => { + if (!lineOverflowed && SSE_DONE_LINE_RE.test(line)) { + sawDone = true; + } + line = ""; + lineOverflowed = false; + }; + const observeText = (text: string) => { + for (const char of text) { + if (char === "\n" || char === "\r") { + finishLine(); + continue; + } + if (!lineOverflowed && line.length < SSE_DONE_MAX_LINE_CHARS) { + line += char; + } else { + // Never let truncation turn a suffix of a large data line into a + // standalone terminal marker. + lineOverflowed = true; + } + } + }; + + return { + observe(chunk: Uint8Array) { + if (!sawDone) { + observeText(decoder.decode(chunk, { stream: true })); + } + }, + finish() { + if (sawDone) { + return; + } + observeText(decoder.decode()); + if (line || lineOverflowed) { + finishLine(); + } + }, + sawDone: () => sawDone, + }; +} + +function createOpenAICompletionsClient( + model: Model, + context: Context, + apiKey: string, + optionHeaders?: Record, + opts?: { fetch?: typeof globalThis.fetch }, +) { + const clientConfig = buildOpenAICompletionsClientConfig(model, context, optionHeaders); + return new OpenAI({ + apiKey, + baseURL: clientConfig.baseURL, + dangerouslyAllowBrowser: true, + defaultHeaders: clientConfig.defaultHeaders, + defaultQuery: clientConfig.defaultQuery, + fetch: opts?.fetch ?? buildGuardedModelFetch(model), + ...buildOpenAISdkClientOptions(model), + }); +} + +function isAzureOpenAICompatibleHost(hostname: string): boolean { + return ( + hostname.endsWith(".openai.azure.com") || + hostname.endsWith(".services.ai.azure.com") || + hostname.endsWith(".cognitiveservices.azure.com") + ); +} + +function isKnownOpenAICompletionsEndpoint(model: Pick): boolean { + if (!model.baseUrl.trim()) { + return true; + } + const endpointClass = resolveProviderEndpoint(model.baseUrl).endpointClass; + if (endpointClass === "openai-public" || endpointClass === "azure-openai") { + return true; + } + try { + return isAzureOpenAICompatibleHost(new URL(model.baseUrl).hostname.toLowerCase()); + } catch { + return false; + } +} + +function buildOpenAICompletionsClientConfig( + model: Model, + context: Context, + optionHeaders?: Record, +): { + baseURL: string; + defaultHeaders: Record; + defaultQuery?: Record; +} { + const headers = buildOpenAIClientHeaders(model, context, optionHeaders); + const defaultQuery: Record = {}; + let baseURL = model.baseUrl; + let isAzureHost = false; + + try { + const parsed = new URL(model.baseUrl); + isAzureHost = isAzureOpenAICompatibleHost(parsed.hostname.toLowerCase()); + parsed.searchParams.forEach((value, key) => { + if (value) { + defaultQuery[key] = value; + } + }); + parsed.search = ""; + baseURL = parsed.toString().replace(/\/$/, ""); + } catch { + // Keep the configured base URL unchanged; the OpenAI SDK will surface invalid URLs. + } + + if (isAzureHost) { + const apiVersionHeader = Object.keys(headers).find( + (key) => key.toLowerCase() === "api-version", + ); + if (apiVersionHeader) { + const apiVersion = headers[apiVersionHeader]?.trim(); + delete headers[apiVersionHeader]; + if (apiVersion && !defaultQuery["api-version"]) { + defaultQuery["api-version"] = apiVersion; + } + } + } + + return { + baseURL, + defaultHeaders: headers, + defaultQuery: Object.keys(defaultQuery).length > 0 ? defaultQuery : undefined, + }; +} + +export function createOpenAICompletionsTransportStreamFn(): StreamFn { + return (model, context, options) => { + const eventStream = createAssistantMessageEventStream(); + const stream = eventStream as unknown as { push(event: unknown): void; end(): void }; + void (async () => { + const output: MutableAssistantOutput = { + role: "assistant" as const, + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + let firstEventAbort: ReturnType | undefined; + try { + const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + // The OpenAI SDK consumes the SSE terminal without yielding it. Observe + // the raw body so native tool calls can distinguish clean DONE from EOF. + const doneDetector = createSseDoneDetector(); + const baseFetch = buildGuardedModelFetch(model); + const doneDetectingFetch: typeof globalThis.fetch = async (url, init) => { + const response = await baseFetch(url as never, init); + if (!response.body || !response.ok) { + return response; + } + if (typeof TransformStream === "undefined" || !response.body.pipeThrough) { + return response; + } + const transformed = response.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + doneDetector.observe(chunk); + controller.enqueue(chunk); + }, + flush() { + doneDetector.finish(); + }, + }), + ); + return new Response(transformed, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); + }; + const client = createOpenAICompletionsClient(model, context, apiKey, options?.headers, { + fetch: doneDetectingFetch, + }); + let params = buildOpenAICompletionsParams( + model as OpenAIModeModel, + context, + options as OpenAICompletionsOptions | undefined, + ); + const nextParams = await options?.onPayload?.(params, model); + if (nextParams !== undefined) { + params = nextParams as typeof params; + } + if ( + (options as { openclawCodeModeToolSurface?: unknown } | undefined) + ?.openclawCodeModeToolSurface === true + ) { + enforceCodeModeResponsesToolSurface(params); + assertCodeModeResponsesToolSurface(params); + } + const compat = getCompat(model as OpenAIModeModel); + if (compat.requiresNonEmptyUserOrAssistantMessage) { + assertOpenAICompletionsPayloadHasConversationTurn(params, model); + } + const emitReasoning = shouldEmitOpenAICompletionsReasoning( + model as OpenAIModeModel, + options as OpenAICompletionsOptions | undefined, + ); + firstEventAbort = createFirstStreamEventAbortController(options?.signal); + const responseStream = (await client.chat.completions.create( + params as never, + buildOpenAISdkRequestOptions(model, firstEventAbort.signal), + )) as unknown as AsyncIterable; + stream.push({ type: "start", partial: output as never }); + await processOpenAICompletionsStream(responseStream, output, model, stream, { + signal: options?.signal, + emitReasoning, + firstEventTimeoutMs: getFirstStreamEventTimeoutMs(options), + abortFirstEventStream: firstEventAbort.abort, + onFirstEventTimeout: getFirstStreamEventTimeoutHandler(options), + sawStreamDONE: doneDetector.sawDone, + }); + finalizeTransportStream({ stream, output, signal: options?.signal }); + } catch (error) { + failTransportStream({ stream, output, signal: options?.signal, error }); + } finally { + firstEventAbort?.dispose(); + } + })(); + return eventStream as unknown as ReturnType; + }; +} + +async function processOpenAICompletionsStream( + responseStream: AsyncIterable, + output: MutableAssistantOutput, + model: Model, + stream: { push(event: unknown): void }, + options?: { + signal?: AbortSignal; + emitReasoning?: boolean; + firstEventTimeoutMs?: number; + abortFirstEventStream?: (reason: Error) => void; + onFirstEventTimeout?: (reason: Error) => void; + sawStreamDONE?: () => boolean; + }, +) { + const MAX_POST_TOOL_CALL_BUFFER_BYTES = 256_000; + const MAX_TOOL_CALL_ARGUMENT_BUFFER_BYTES = 256_000; + const emitReasoning = options?.emitReasoning ?? true; + const compat = getCompat(model as OpenAIModeModel); + const deepSeekTextFilter = shouldFilterDeepSeekDsmlText(compat) + ? createDeepSeekTextFilter() + : null; + const deepSeekToolCallRecoverer = shouldFilterDeepSeekDsmlText(compat) + ? createDeepSeekDsmlToolCallRecoverer() + : null; + const reasoningTagTextPartitioner = createReasoningTagTextPartitioner(); + type ToolCallBlock = { + type: "toolCall"; + id: string; + name: string; + arguments: Record; + partialArgs: string; + thoughtSignature?: string; + }; + let currentBlock: + | { type: "text"; text: string } + | { type: "thinking"; thinking: string; thinkingSignature?: string } + | ToolCallBlock + | null = null; + let pendingPostToolCallDeltas: CompletionsReasoningDelta[] = []; + let pendingPostToolCallBytes = 0; + let isFlushingPendingPostToolCallDeltas = false; + const toolCallBlocksByIndex = new Map(); + const toolCallBlocksById = new Map(); + const toolCallBlockBytes = new WeakMap(); + const toolCallBlockIndices = new WeakMap(); + let sawStopFinishReason = false; + let sawNativeToolCallDelta = false; + const blockIndex = () => output.content.length - 1; + const measureUtf8Bytes = (text: string) => Buffer.byteLength(text, "utf8"); + let chunkPushedEvent = false; + const pushStreamEvent = (event: unknown) => { + chunkPushedEvent = true; + stream.push(event); + }; + const finishCurrentBlock = () => { + if (!currentBlock) { + return; + } + if (currentBlock.type === "toolCall") { + currentBlock.arguments = parseStreamingJson(currentBlock.partialArgs); + } + }; + const finishAllToolCallBlocks = () => { + for (const block of toolCallBlocksByIndex.values()) { + block.arguments = parseStreamingJson(block.partialArgs); + } + }; + const queuePostToolCallDelta = (next: CompletionsReasoningDelta) => { + const nextBytes = measureUtf8Bytes(next.text); + if (pendingPostToolCallBytes + nextBytes > MAX_POST_TOOL_CALL_BUFFER_BYTES) { + throw new Error("Exceeded post-tool-call delta buffer limit"); + } + pendingPostToolCallBytes += nextBytes; + const previous = pendingPostToolCallDeltas[pendingPostToolCallDeltas.length - 1]; + if (!previous || previous.kind !== next.kind) { + pendingPostToolCallDeltas.push(next); + return; + } + if (next.kind === "thinking" && previous.kind === "thinking") { + if (previous.signature !== next.signature) { + pendingPostToolCallDeltas.push(next); + return; + } + previous.text += next.text; + return; + } + previous.text += next.text; + }; + const appendThinkingDeltaInternal = (reasoningDelta: { signature: string; text: string }) => { + if (!currentBlock || currentBlock.type !== "thinking") { + finishCurrentBlock(); + currentBlock = { + type: "thinking", + thinking: "", + ...(reasoningDelta.signature ? { thinkingSignature: reasoningDelta.signature } : {}), + }; + output.content.push(currentBlock); + pushStreamEvent({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); + } + currentBlock.thinking += reasoningDelta.text; + pushStreamEvent({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: reasoningDelta.text, + partial: output, + }); + }; + const appendTextDeltaInternal = (text: string) => { + if (!currentBlock || currentBlock.type !== "text") { + finishCurrentBlock(); + currentBlock = { type: "text", text: "" }; + output.content.push(currentBlock); + pushStreamEvent({ type: "text_start", contentIndex: blockIndex(), partial: output }); + } + currentBlock.text += text; + pushStreamEvent({ + type: "text_delta", + contentIndex: blockIndex(), + delta: text, + }); + }; + const flushPendingPostToolCallDeltas = () => { + if ( + isFlushingPendingPostToolCallDeltas || + currentBlock?.type === "toolCall" || + pendingPostToolCallDeltas.length === 0 + ) { + return; + } + isFlushingPendingPostToolCallDeltas = true; + const bufferedDeltas = pendingPostToolCallDeltas; + pendingPostToolCallDeltas = []; + pendingPostToolCallBytes = 0; + for (const delta of bufferedDeltas) { + if (delta.kind === "text") { + appendTextDeltaInternal(delta.text); + } else if (emitReasoning) { + appendThinkingDeltaInternal(delta); + } + } + isFlushingPendingPostToolCallDeltas = false; + }; + const appendThinkingDelta = (reasoningDelta: { signature: string; text: string }) => { + flushPendingPostToolCallDeltas(); + appendThinkingDeltaInternal(reasoningDelta); + }; + const appendTextDelta = (text: string) => { + flushPendingPostToolCallDeltas(); + appendTextDeltaInternal(text); + }; + const appendVisibleTextDelta = (text: string) => { + if (!text) { + return; + } + if (currentBlock?.type === "toolCall") { + queuePostToolCallDelta({ kind: "text", text }); + } else { + appendTextDelta(text); + } + }; + const appendRecoveredToolCall = (toolCall: RecoveredDeepSeekDsmlToolCall) => { + const switchingToolCall = currentBlock?.type === "toolCall"; + finishCurrentBlock(); + if (switchingToolCall) { + currentBlock = null; + flushPendingPostToolCallDeltas(); + } + const block: ToolCallBlock = { + type: "toolCall", + // DSML has no provider call id. A response-local counter would alias a + // later assistant response and could collapse distinct mutating calls. + id: `call_${randomUUID().replaceAll("-", "").slice(0, 24)}`, + name: toolCall.name, + arguments: toolCall.arguments, + partialArgs: toolCall.partialArgs, + }; + currentBlock = block; + output.content.push(block); + toolCallBlockIndices.set(block, output.content.length - 1); + pushStreamEvent({ + type: "toolcall_start", + contentIndex: toolCallBlockIndices.get(block) ?? -1, + partial: output, + }); + pushStreamEvent({ + type: "toolcall_delta", + contentIndex: toolCallBlockIndices.get(block) ?? -1, + delta: toolCall.partialArgs, + partial: output, + }); + }; + const appendFilteredVisibleTextDelta = (text: string) => { + const recoveredParts = deepSeekToolCallRecoverer?.push(text) ?? [ + { kind: "text" as const, text }, + ]; + for (const recoveredPart of recoveredParts) { + if (recoveredPart.kind === "toolCall") { + appendRecoveredToolCall(recoveredPart); + continue; + } + const parts = deepSeekTextFilter?.push(recoveredPart.text) ?? [recoveredPart.text]; + for (const part of parts) { + appendVisibleTextDelta(part); + } + } + }; + const flushDeepSeekToolCallRecovererAtEnd = () => { + const recoveredParts = deepSeekToolCallRecoverer?.flush(); + if (!recoveredParts) { + return; + } + for (const recoveredPart of recoveredParts) { + if (recoveredPart.kind === "toolCall") { + appendRecoveredToolCall(recoveredPart); + continue; + } + const parts = deepSeekTextFilter?.push(recoveredPart.text) ?? [recoveredPart.text]; + for (const part of parts) { + appendVisibleTextDelta(part); + } + } + }; + const flushDeepSeekTextFilterAtEnd = () => { + const parts = deepSeekTextFilter?.flush(); + if (!parts) { + return; + } + for (const part of parts) { + appendVisibleTextDelta(part); + } + }; + const appendRoutedContentDelta = (delta: CompletionsReasoningDelta) => { + if (delta.kind === "text") { + appendFilteredVisibleTextDelta(delta.text); + return; + } + if (!emitReasoning) { + return; + } + if (currentBlock?.type === "toolCall") { + queuePostToolCallDelta(delta); + } else { + appendThinkingDelta(delta); + } + }; + const appendPartitionedVisibleDelta = (delta: { kind: "text" | "thinking"; text: string }) => { + if (delta.kind === "text") { + appendFilteredVisibleTextDelta(delta.text); + } + }; + const emitReasoningUsageActivity = (hasReasoningUsageActivity: boolean) => { + if (!hasReasoningUsageActivity || chunkPushedEvent || !emitReasoning) { + return; + } + const latestBlock = output.content[output.content.length - 1]; + if (currentBlock?.type === "text" || currentBlock?.type === "toolCall") { + return; + } + if (latestBlock?.type === "text" || latestBlock?.type === "toolCall") { + return; + } + appendThinkingDelta({ signature: "", text: "" }); + }; + const flushReasoningTagTextPartitionerAtEnd = () => { + for (const delta of reasoningTagTextPartitioner.flush()) { + appendPartitionedVisibleDelta(delta); + } + }; + const cooperativeScheduler = createModelStreamCooperativeScheduler(options?.signal); + const guardedStream = withFirstStreamEventTimeout(responseStream as AsyncIterable, { + provider: model.provider, + api: model.api, + model: model.id, + timeoutMs: options?.firstEventTimeoutMs ?? 0, + stage: "completions", + abort: options?.abortFirstEventStream, + onTimeout: options?.onFirstEventTimeout, + hint: "The provider may be stalled while parsing the tool payload; retry with a smaller tool surface or enable OPENCLAW_DEBUG_MODEL_PAYLOAD=tools to inspect exposed tools.", + }); + for await (const rawChunk of guardedStream) { + throwIfModelStreamAborted(options?.signal); + chunkPushedEvent = false; + if (!rawChunk || typeof rawChunk !== "object") { + await cooperativeScheduler.afterEvent(); + continue; + } + const chunk = rawChunk as ChatCompletionChunk; + output.responseId ||= chunk.id; + let hasReasoningUsageActivity = false; + if (chunk.usage) { + output.usage = parseTransportChunkUsage(chunk.usage, model); + hasReasoningUsageActivity = hasOpenAICompletionsReasoningUsageActivity(chunk.usage); + } + const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined; + if (!choice) { + emitReasoningUsageActivity(hasReasoningUsageActivity); + await cooperativeScheduler.afterEvent(); + continue; + } + const choiceUsage = (choice as unknown as { usage?: ChatCompletionChunk["usage"] }).usage; + if (!chunk.usage && choiceUsage) { + output.usage = parseTransportChunkUsage(choiceUsage, model); + hasReasoningUsageActivity = hasOpenAICompletionsReasoningUsageActivity(choiceUsage); + } + if (choice.finish_reason) { + const finishReasonResult = mapOpenAIStopReason(choice.finish_reason, { + allowSingularToolCall: true, + }); + output.stopReason = finishReasonResult.stopReason; + if (finishReasonResult.stopReason === "stop") { + sawStopFinishReason = true; + } + if (finishReasonResult.errorMessage) { + output.errorMessage = finishReasonResult.errorMessage; + } + } + const choiceDelta = + choice.delta ?? + (choice as unknown as { message?: ChatCompletionChunk["choices"][number]["delta"] }).message; + if (!choiceDelta) { + emitReasoningUsageActivity(hasReasoningUsageActivity); + await cooperativeScheduler.afterEvent(); + continue; + } + const reasoningDeltas = getCompletionsReasoningDeltas( + choiceDelta as Record, + compat.visibleReasoningDetailTypes, + ); + const hasMirroredReasoning = reasoningDeltas.some((delta) => delta.kind === "thinking"); + if (hasMirroredReasoning) { + reasoningTagTextPartitioner.markStrict(); + } + if (choiceDelta.content) { + // Structured content can contain visible text and thinking blocks in the + // same delta, so route each extracted block through the normal stream path. + const contentDeltas = getCompletionsContentDeltas(choiceDelta.content); + for (const contentDelta of contentDeltas) { + if (contentDelta.kind === "text") { + const routedDeltas = hasMirroredReasoning + ? reasoningTagTextPartitioner.push(contentDelta.text) + : reasoningTagTextPartitioner.pushVisible(contentDelta.text); + for (const routedDelta of routedDeltas) { + appendPartitionedVisibleDelta(routedDelta); + } + } else { + reasoningTagTextPartitioner.markStrict(); + appendRoutedContentDelta(contentDelta); + } + } + } + // Chat Completions can put safety/structured-output refusals in a top-level + // `refusal` field with content null. Surface that as visible text so the + // assistant turn is not empty (Responses path already routes refusal deltas). + const refusalText = typeof choiceDelta.refusal === "string" ? choiceDelta.refusal : ""; + if (refusalText) { + const routedDeltas = hasMirroredReasoning + ? reasoningTagTextPartitioner.push(refusalText) + : reasoningTagTextPartitioner.pushVisible(refusalText); + for (const routedDelta of routedDeltas) { + appendPartitionedVisibleDelta(routedDelta); + } + } + for (const reasoningDelta of reasoningDeltas) { + if (reasoningDelta.kind === "thinking" && !emitReasoning) { + continue; + } + if (currentBlock?.type === "toolCall") { + queuePostToolCallDelta({ ...reasoningDelta }); + continue; + } + if (reasoningDelta.kind === "text") { + appendTextDelta(reasoningDelta.text); + } else if (emitReasoning) { + appendThinkingDelta(reasoningDelta); + } + } + if (choiceDelta.tool_calls && choiceDelta.tool_calls.length > 0) { + sawNativeToolCallDelta = true; + flushReasoningTagTextPartitionerAtEnd(); + for (const toolCall of choiceDelta.tool_calls) { + const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined; + let block = streamIndex !== undefined ? toolCallBlocksByIndex.get(streamIndex) : undefined; + if (!block && toolCall.id) { + block = toolCallBlocksById.get(toolCall.id); + } + if (!block) { + const switchingToolCall = currentBlock?.type === "toolCall"; + finishCurrentBlock(); + if (switchingToolCall) { + currentBlock = null; + flushPendingPostToolCallDeltas(); + } + const initialSig = extractGoogleThoughtSignature(toolCall); + block = { + type: "toolCall", + id: toolCall.id || "", + name: toolCall.function?.name || "", + arguments: {}, + partialArgs: "", + ...(initialSig ? { thoughtSignature: initialSig } : {}), + }; + output.content.push(block); + toolCallBlockIndices.set(block, output.content.length - 1); + pushStreamEvent({ + type: "toolcall_start", + contentIndex: toolCallBlockIndices.get(block) ?? -1, + partial: output, + }); + } + if (streamIndex !== undefined && !toolCallBlocksByIndex.has(streamIndex)) { + toolCallBlocksByIndex.set(streamIndex, block); + } + if (toolCall.id) { + block.id = toolCall.id; + toolCallBlocksById.set(toolCall.id, block); + } + currentBlock = block; + if (toolCall.function?.name) { + block.name = toolCall.function.name; + } + const deltaSig = extractGoogleThoughtSignature(toolCall); + if (deltaSig) { + block.thoughtSignature = deltaSig; + } + if (toolCall.function?.arguments) { + const nextArgumentBytes = measureUtf8Bytes(toolCall.function.arguments); + const currentBlockArgBytes = toolCallBlockBytes.get(block) ?? 0; + if (currentBlockArgBytes + nextArgumentBytes > MAX_TOOL_CALL_ARGUMENT_BUFFER_BYTES) { + throw new Error("Exceeded tool-call argument buffer limit"); + } + toolCallBlockBytes.set(block, currentBlockArgBytes + nextArgumentBytes); + block.partialArgs += toolCall.function.arguments; + block.arguments = parseStreamingJson(block.partialArgs); + pushStreamEvent({ + type: "toolcall_delta", + contentIndex: toolCallBlockIndices.get(block) ?? -1, + delta: toolCall.function.arguments, + partial: output, + }); + } + } + } + flushPendingPostToolCallDeltas(); + emitReasoningUsageActivity(hasReasoningUsageActivity); + await cooperativeScheduler.afterEvent(); + } + flushReasoningTagTextPartitionerAtEnd(); + flushDeepSeekToolCallRecovererAtEnd(); + flushDeepSeekTextFilterAtEnd(); + finishAllToolCallBlocks(); + currentBlock = null; + flushPendingPostToolCallDeltas(); + const hasToolCalls = output.content.some((block) => block.type === "toolCall"); + const hasVisibleText = output.content.some( + (block) => + block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0, + ); + if (output.stopReason === "toolUse" && !hasToolCalls) { + output.stopReason = "stop"; + } + // Promote complete silent tool-call-only responses when the stream finished + // cleanly (reached post-loop). Two paths: + // sawStopFinishReason: explicit provider terminal (legacy DSML / #88791) + // sawNativeToolCallDelta + sawStreamDONE: structured delta.tool_calls with + // a clean SSE [DONE] terminal but no finish_reason (e.g. Evolink + // DeepSeek V4). [DONE] tracking distinguishes clean termination from + // connection drops (EOF without [DONE] remains fail-closed). + // Truncated streams throw before reaching this code. + if ( + output.stopReason === "stop" && + hasToolCalls && + !hasVisibleText && + (sawStopFinishReason || (sawNativeToolCallDelta && (options?.sawStreamDONE?.() ?? false))) + ) { + output.stopReason = "toolUse"; + } + if (hasToolCalls && output.stopReason !== "toolUse") { + output.content = output.content.filter((block) => block.type !== "toolCall"); + } +} + +type CompletionsReasoningDelta = + | { + kind: "thinking"; + signature: string; + text: string; + } + | { + kind: "text"; + text: string; + }; + +function shouldFilterDeepSeekDsmlText(compat: ReturnType) { + return compat.thinkingFormat === "deepseek"; +} + +type RecoveredDeepSeekDsmlToolCall = { + kind: "toolCall"; + name: string; + arguments: Record; + partialArgs: string; +}; + +type DeepSeekDsmlRecoveredPart = { kind: "text"; text: string } | RecoveredDeepSeekDsmlToolCall; + +const DEEPSEEK_DSML_BARS = ["|", "|"] as const; +const DEEPSEEK_DSML_TOOL_KINDS = ["tool_calls", "tool_call", "function_calls"] as const; +const DEEPSEEK_DSML_TOOL_OPEN_TOKENS = DEEPSEEK_DSML_BARS.flatMap((bar) => + DEEPSEEK_DSML_TOOL_KINDS.map((kind) => `<${bar}DSML${bar}${kind}>`), +); +const DEEPSEEK_DSML_TOOL_CLOSE_TOKENS = DEEPSEEK_DSML_BARS.flatMap((bar) => + DEEPSEEK_DSML_TOOL_KINDS.map((kind) => ``), +); +const DEEPSEEK_DSML_TOOL_MAX_OPEN_TOKEN_LEN = Math.max( + ...DEEPSEEK_DSML_TOOL_OPEN_TOKENS.map((token) => token.length), +); + +function createDeepSeekDsmlToolCallRecoverer() { + let buffer = ""; + + const consume = (final: boolean): DeepSeekDsmlRecoveredPart[] => { + const output: DeepSeekDsmlRecoveredPart[] = []; + while (buffer) { + const open = findEarliestStringToken(buffer, DEEPSEEK_DSML_TOOL_OPEN_TOKENS); + if (!open) { + if (final) { + output.push({ kind: "text", text: buffer }); + buffer = ""; + return output; + } + const keep = longestDeepSeekDsmlToolOpenPrefixSuffixLength(buffer); + const emitLength = buffer.length - keep; + if (emitLength > 0) { + output.push({ kind: "text", text: buffer.slice(0, emitLength) }); + buffer = buffer.slice(emitLength); + } + return output; + } + + if (open.index > 0) { + output.push({ kind: "text", text: buffer.slice(0, open.index) }); + buffer = buffer.slice(open.index); + } + + const afterOpen = buffer.slice(open.token.length); + const close = findEarliestStringToken(afterOpen, DEEPSEEK_DSML_TOOL_CLOSE_TOKENS); + if (!close) { + if (final) { + output.push({ kind: "text", text: buffer }); + buffer = ""; + } + return output; + } + + const body = afterOpen.slice(0, close.index); + const blockLength = open.token.length + close.index + close.token.length; + const recoveredToolCalls = parseDeepSeekDsmlToolCallBlock(body); + if (recoveredToolCalls.length > 0) { + output.push(...recoveredToolCalls); + } else { + output.push({ kind: "text", text: buffer.slice(0, blockLength) }); + } + buffer = buffer.slice(blockLength); + } + return output; + }; + + return { + push(chunk: string) { + buffer += chunk; + return consume(false); + }, + flush() { + return consume(true); + }, + }; +} + +function parseDeepSeekDsmlToolCallBlock(body: string): RecoveredDeepSeekDsmlToolCall[] { + const toolCalls: RecoveredDeepSeekDsmlToolCall[] = []; + const invokeOpenRegex = /<[||]DSML[||]invoke\b([^>]*)>/g; + let openMatch: RegExpExecArray | null; + while ((openMatch = invokeOpenRegex.exec(body)) !== null) { + const invokeName = parseXmlAttribute(openMatch[1] ?? "", "name"); + if (!invokeName) { + continue; + } + const invokeBodyStart = openMatch.index + openMatch[0].length; + const invokeClose = findEarliestStringToken(body.slice(invokeBodyStart), [ + "", + "", + ]); + if (!invokeClose) { + continue; + } + const invokeBody = body.slice(invokeBodyStart, invokeBodyStart + invokeClose.index); + invokeOpenRegex.lastIndex = invokeBodyStart + invokeClose.index + invokeClose.token.length; + const parsedArguments = parseDeepSeekDsmlInvokeArguments(invokeBody); + if (!parsedArguments) { + continue; + } + toolCalls.push({ + kind: "toolCall", + name: invokeName, + arguments: parsedArguments, + partialArgs: JSON.stringify(parsedArguments), + }); + } + return toolCalls; +} + +function parseDeepSeekDsmlInvokeArguments(body: string): Record | null { + const args: Record = {}; + const parameterRegex = /<[||]DSML[||]parameter\b([^>]*)>([\s\S]*?)<\/[||]DSML[||]parameter>/g; + let parameterMatch: RegExpExecArray | null; + while ((parameterMatch = parameterRegex.exec(body)) !== null) { + const name = parseXmlAttribute(parameterMatch[1] ?? "", "name"); + if (!name) { + continue; + } + const rawValue = parameterMatch[2] ?? ""; + if (rawValue.length === 0) { + continue; + } + args[name] = decodeDeepSeekDsmlText(rawValue); + } + if (Object.keys(args).length > 0) { + return args; + } + + const trimmed = body.trim(); + if (!trimmed.startsWith("{")) { + return null; + } + try { + const parsed = JSON.parse(trimmed) as unknown; + if (isRecord(parsed) && Object.keys(parsed).length > 0) { + return parsed; + } + } catch { + return null; + } + return null; +} + +// Cache compiled attribute matchers by name so the streaming parser does not +// recompile a RegExp on every chunk/parameter it scans. +const xmlAttributeRegexCache = new Map(); + +function xmlAttributeRegex(name: string): RegExp { + const cached = xmlAttributeRegexCache.get(name); + if (cached) { + return cached; + } + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp(`\\b${escaped}=("([^"]*)"|'([^']*)'|([^\\s>]+))`); + xmlAttributeRegexCache.set(name, pattern); + return pattern; +} + +function parseXmlAttribute(attributes: string, name: string): string | null { + const match = xmlAttributeRegex(name).exec(attributes); + const value = match?.[2] ?? match?.[3] ?? match?.[4]; + return value ? decodeDeepSeekDsmlText(value) : null; +} + +function decodeDeepSeekDsmlText(value: string): string { + return value + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); +} + +function findEarliestStringToken(text: string, tokens: readonly string[]) { + let best: { index: number; token: string } | null = null; + for (const token of tokens) { + const index = text.indexOf(token); + if (index !== -1 && (!best || index < best.index)) { + best = { index, token }; + } + } + return best; +} + +function longestDeepSeekDsmlToolOpenPrefixSuffixLength(text: string) { + const maxLength = Math.min(text.length, DEEPSEEK_DSML_TOOL_MAX_OPEN_TOKEN_LEN - 1); + for (let length = maxLength; length > 0; length -= 1) { + const suffix = text.slice(text.length - length); + if (DEEPSEEK_DSML_TOOL_OPEN_TOKENS.some((token) => token.startsWith(suffix))) { + return length; + } + } + return 0; +} + +function getCompletionsContentDeltas(content: unknown): CompletionsReasoningDelta[] { + if (typeof content === "string") { + return content ? [{ kind: "text", text: content }] : []; + } + if (Array.isArray(content)) { + return content.flatMap((item) => getCompletionsContentDeltas(item)); + } + if (!content || typeof content !== "object") { + return []; + } + const record = content as Record; + const type = typeof record.type === "string" ? record.type.toLowerCase() : ""; + // Some OpenAI-compatible providers, notably Mistral thinking models, stream + // `delta.content` as typed objects. Never coerce those objects directly or + // they become persisted visible text like "[object Object]". + const extractText = (value: unknown): string => { + if (typeof value === "string") { + return value; + } + if (Array.isArray(value)) { + return value.map((item) => extractText(item)).join(""); + } + if (value && typeof value === "object") { + const nested = value as Record; + return extractText(nested.text ?? nested.content ?? nested.thinking); + } + return ""; + }; + const text = extractText(record.text ?? record.content ?? record.thinking); + if (!text) { + return []; + } + // Preserve provider reasoning as OpenClaw thinking blocks so channel/UI + // surfaces can decide whether to show it instead of leaking it as answer text. + if (type.includes("thinking") || type.includes("reasoning")) { + return [{ kind: "thinking", signature: "content", text }]; + } + if (type === "text" || type === "output_text" || type.endsWith(".output_text")) { + return [{ kind: "text", text }]; + } + return []; +} + +function getCompletionsReasoningDeltas( + delta: Record, + visibleReasoningDetailTypes: readonly string[], +): CompletionsReasoningDelta[] { + const output: CompletionsReasoningDelta[] = []; + const pushDelta = (next: CompletionsReasoningDelta) => { + const previous = output[output.length - 1]; + if (!previous || previous.kind !== next.kind) { + output.push(next); + return; + } + if (next.kind === "thinking" && previous.kind === "thinking") { + if (previous.signature !== next.signature) { + output.push(next); + return; + } + previous.text += next.text; + return; + } + previous.text += next.text; + }; + const reasoningDetails = delta.reasoning_details; + let usedReasoningThinkingDetails = false; + if (Array.isArray(reasoningDetails)) { + const visibleTypes = new Set(visibleReasoningDetailTypes); + for (const item of reasoningDetails) { + const detail = item as { type?: unknown; text?: unknown }; + if (typeof detail.text !== "string" || !detail.text) { + continue; + } + if (detail.type === "reasoning.text") { + usedReasoningThinkingDetails = true; + pushDelta({ kind: "thinking", signature: "reasoning_details", text: detail.text }); + continue; + } + if (typeof detail.type === "string" && visibleTypes.has(detail.type)) { + pushDelta({ kind: "text", text: detail.text }); + } + } + } + if (!usedReasoningThinkingDetails) { + const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"] as const; + for (const field of reasoningFields) { + const value = delta[field]; + if (typeof value === "string" && value.length > 0) { + pushDelta({ kind: "thinking", signature: field, text: value }); + break; + } + } + } + return output; +} + +function resolveOpenAICompletionsReasoningEffort(options: OpenAICompletionsOptions | undefined) { + return options?.reasoningEffort ?? options?.reasoning ?? "high"; +} + +function shouldEmitOpenAICompletionsReasoning( + model: OpenAIModeModel, + options: OpenAICompletionsOptions | undefined, +) { + if (!model.reasoning) { + return false; + } + const effort = resolveOpenAICompletionsReasoningEffort(options); + if (!effort || !isOpenAICompletionsThinkingEnabled(effort)) { + return false; + } + return true; +} + +function shouldEmitOpenAICompletionsReasoningForModel( + model: OpenAIModeModel, + options: OpenAICompletionsOptions | undefined, +) { + return shouldEmitOpenAICompletionsReasoning(model, options); +} + +function resolveOpenAICompletionsMaxTokens( + model: OpenAIModeModel, + options: OpenAICompletionsOptions | undefined, +): { maxTokens: number | undefined; clampToModelMaxTokens: boolean } { + if (options?.maxTokens) { + return { maxTokens: options.maxTokens, clampToModelMaxTokens: true }; + } + const paramsMaxTokens = resolveMaxTokensParam( + (model as { params?: Record }).params, + ); + if (paramsMaxTokens) { + return { maxTokens: paramsMaxTokens, clampToModelMaxTokens: false }; + } + return { maxTokens: model.maxTokens, clampToModelMaxTokens: false }; +} + +function resolveOpenAICompletionsModelMaxTokens(model: OpenAIModeModel): number | undefined { + return typeof model.maxTokens === "number" && + Number.isFinite(model.maxTokens) && + model.maxTokens > 0 + ? Math.floor(model.maxTokens) + : undefined; +} + +const OPENAI_COMPLETIONS_INPUT_TOKEN_SAFETY_MARGIN = 1.25; +const OPENAI_COMPLETIONS_IMAGE_CHAR_ESTIMATE = 8_000; + +// Used only to bound `max_completion_tokens` below the effective context cap +// for strict OpenAI-compatible servers (e.g. vLLM, StepFun). The CJK-aware +// helper avoids undercounting non-Latin prompts enough to trigger server-side +// context rejections; wrong-high here just trims output a little. Estimate the +// final shaped payload, not the raw context, so compat transforms and dropped +// replay turns are reflected in the output cap. +function estimateOpenAICompletionsInputTokens(payload: { + messages?: unknown; + tools?: unknown; + response_format?: unknown; +}): number { + let adjustedChars = 0; + adjustedChars += estimateOpenAICompletionsMessagesChars(payload.messages); + if (Array.isArray(payload.tools) && payload.tools.length > 0) { + try { + adjustedChars += estimateStringChars(JSON.stringify(payload.tools)); + } catch { + adjustedChars += 1024; + } + } + if (payload.response_format !== undefined) { + try { + adjustedChars += estimateStringChars(JSON.stringify(payload.response_format)); + } catch { + adjustedChars += 256; + } + } + return Math.ceil( + (adjustedChars / CHARS_PER_TOKEN_ESTIMATE) * OPENAI_COMPLETIONS_INPUT_TOKEN_SAFETY_MARGIN, + ); +} + +function estimateOpenAICompletionsMessagesChars(messages: unknown): number { + if (!Array.isArray(messages)) { + return 0; + } + let adjustedChars = 0; + for (const message of messages) { + if (!message || typeof message !== "object") { + continue; + } + const record = message as Record; + adjustedChars += estimateOpenAICompletionsContentChars(record.content); + for (const field of COMPLETIONS_REASONING_REPLAY_FIELDS) { + adjustedChars += estimateOpenAICompletionsContentChars(record[field]); + } + if (record.tool_calls !== undefined) { + try { + adjustedChars += estimateStringChars(JSON.stringify(record.tool_calls)); + } catch { + adjustedChars += 256; + } + } + } + return adjustedChars; +} + +function estimateOpenAICompletionsContentChars(value: unknown): number { + if (typeof value === "string") { + return estimateStringChars(value); + } + if (!Array.isArray(value)) { + return 0; + } + let adjustedChars = 0; + for (const block of value) { + if (!block || typeof block !== "object") { + continue; + } + const record = block as Record; + if (record.type === "image_url" || record.type === "input_image") { + adjustedChars += OPENAI_COMPLETIONS_IMAGE_CHAR_ESTIMATE; + continue; + } + const text = record.text; + if (typeof text === "string") { + adjustedChars += estimateStringChars(text); + continue; + } + try { + adjustedChars += estimateStringChars(JSON.stringify(block)); + } catch { + adjustedChars += 256; + } + } + return adjustedChars; +} + +function resolveOpenAICompletionsEffectiveContextTokens( + model: OpenAIModeModel, +): number | undefined { + const contextTokens = (model as { contextTokens?: number }).contextTokens; + if (typeof contextTokens === "number" && Number.isFinite(contextTokens) && contextTokens > 0) { + return contextTokens; + } + return typeof model.contextWindow === "number" && + Number.isFinite(model.contextWindow) && + model.contextWindow > 0 + ? model.contextWindow + : undefined; +} + +function isQwenOpenAICompletionsThinkingFormat(format: string): boolean { + return format === "qwen" || format === "qwen-chat-template"; +} + +function isOpenAICompletionsThinkingEnabled(effort: OpenAIReasoningEffort): boolean { + const normalized = effort.trim().toLowerCase(); + return normalized !== "off" && normalized !== "none"; +} + +function setQwenChatTemplateThinking(params: Record, enabled: boolean): void { + const existing = params.chat_template_kwargs; + params.chat_template_kwargs = + existing && typeof existing === "object" && !Array.isArray(existing) + ? { ...(existing as Record), enable_thinking: enabled } + : { enable_thinking: enabled }; +} + +function applyQwenOpenAICompletionsThinkingParams(params: { + compatThinkingFormat: string; + modelReasoning: boolean; + payload: Record; + requestedEffort: OpenAIReasoningEffort; +}): boolean { + if ( + !params.modelReasoning || + !isQwenOpenAICompletionsThinkingFormat(params.compatThinkingFormat) + ) { + return false; + } + const enabled = isOpenAICompletionsThinkingEnabled(params.requestedEffort); + if (params.compatThinkingFormat === "qwen-chat-template") { + setQwenChatTemplateThinking(params.payload, enabled); + } else { + params.payload.enable_thinking = enabled; + } + return true; +} + +function applyTogetherOpenAICompletionsThinkingParams(params: { + compatThinkingFormat: string; + modelReasoning: boolean; + payload: Record; + requestedEffort: OpenAIReasoningEffort; +}): boolean { + if (!params.modelReasoning || params.compatThinkingFormat !== "together") { + return false; + } + params.payload.reasoning = { + enabled: isOpenAICompletionsThinkingEnabled(params.requestedEffort), + }; + return true; +} + +function convertTools( + tools: NonNullable, + compat: ReturnType, + model: OpenAIModeModel, +) { + const projection = projectOpenAITools(tools); + const strict = resolveOpenAIStrictToolFlagWithDiagnostics( + projection, + resolveOpenAIStrictToolSetting(model, { + transport: "stream", + supportsStrictMode: compat?.supportsStrictMode, + }), + { + transport: "completions", + model, + }, + ); + return { + projection, + tools: sortTransportToolsByName(projection.tools).map((tool) => { + const functionTool: { + name: string; + description: string | undefined; + parameters: ReturnType; + strict?: boolean; + } = { + name: tool.name, + description: tool.description, + parameters: normalizeOpenAIStrictToolParameters( + tool.parameters, + strict === true, + model.compat, + ), + }; + if (strict !== undefined) { + functionTool.strict = strict; + } + return { + type: "function", + function: functionTool, + }; + }), + }; +} + +function extractGoogleThoughtSignature(toolCall: unknown): string | undefined { + const tc = toolCall as Record | undefined; + if (!tc) { + return undefined; + } + const extra = (tc.extra_content as Record | undefined)?.google as + | Record + | undefined; + const fromExtra = extra?.thought_signature; + if (typeof fromExtra === "string" && fromExtra.length > 0) { + return fromExtra; + } + const fromFunction = (tc.function as { thought_signature?: unknown } | undefined) + ?.thought_signature; + return typeof fromFunction === "string" && fromFunction.length > 0 ? fromFunction : undefined; +} + +function isGoogleOpenAICompatModel(model: OpenAIModeModel): boolean { + const endpointClass = detectOpenAICompletionsCompat(model as Model<"openai-completions">) + .capabilities.endpointClass; + return ( + model.provider === "google" || + endpointClass === "google-generative-ai" || + endpointClass === "google-vertex" + ); +} + +function requiresGoogleCompatToolCallThoughtSignature(model: OpenAIModeModel): boolean { + return isGoogleGemini3ProModel(model.id) || isGoogleGemini3FlashModel(model.id); +} + +const GOOGLE_COMPAT_THOUGHT_SIGNATURE_ELLIPSIS_RE = /[\u2026]|\.\.\./; +const GOOGLE_COMPAT_THOUGHT_SIGNATURE_BASE64_RE = /^[A-Za-z0-9+/=]+$/; + +function hasGoogleCompatThoughtSignatureTruncationFootprint(value: string): boolean { + return ( + GOOGLE_COMPAT_THOUGHT_SIGNATURE_ELLIPSIS_RE.test(value) || + (GOOGLE_COMPAT_THOUGHT_SIGNATURE_BASE64_RE.test(value) && value.length % 4 !== 0) + ); +} + +function injectToolCallThoughtSignatures( + outgoingMessages: unknown[], + context: Context, + model: OpenAIModeModel, +): void { + if (!isGoogleOpenAICompatModel(model)) { + return; + } + const sigById = new Map(); + const fallbackSig = requiresGoogleCompatToolCallThoughtSignature(model) + ? GEMINI_THOUGHT_SIGNATURE_VALIDATOR_SKIP + : undefined; + for (const msg of context.messages ?? []) { + if ((msg as { role?: string }).role !== "assistant") { + continue; + } + const source = msg as { api?: string; provider?: string; model?: string; content?: unknown }; + if (!Array.isArray(source.content)) { + continue; + } + for (const block of source.content as Array>) { + if (block.type !== "toolCall") { + continue; + } + const id = block.id; + const sig = block.thoughtSignature; + if (typeof id === "string" && typeof sig === "string" && sig.length > 0) { + const isSameRoute = + source.api === model.api && + source.provider === model.provider && + source.model === model.id; + if (!isSameRoute && !fallbackSig) { + continue; + } + sigById.set(id, isSameRoute ? sig : (fallbackSig ?? sig)); + } + } + } + if (sigById.size === 0 && !fallbackSig) { + return; + } + for (const message of outgoingMessages) { + const toolCalls = (message as { tool_calls?: unknown }).tool_calls; + if (!Array.isArray(toolCalls)) { + continue; + } + for (const toolCall of toolCalls as Array>) { + const id = toolCall.id; + if (typeof id !== "string") { + continue; + } + let sig: string | undefined = sigById.get(id) ?? fallbackSig; + if (typeof sig === "string" && sig.length > 0) { + const trimmed = sig.trim(); + if (hasGoogleCompatThoughtSignatureTruncationFootprint(trimmed)) { + sig = fallbackSig; + } + } + if (typeof sig !== "string" || sig.length === 0) { + continue; + } + const extra = + toolCall.extra_content && typeof toolCall.extra_content === "object" + ? (toolCall.extra_content as Record) + : {}; + toolCall.extra_content = extra; + const google = + extra.google && typeof extra.google === "object" + ? (extra.google as Record) + : {}; + extra.google = google; + google.thought_signature = sig; + } + } +} + +const COMPLETIONS_REASONING_REPLAY_FIELDS = [ + "reasoning_details", + "reasoning_content", + "reasoning", + "reasoning_text", +] as const; + +function stripCompletionsReasoningReplayFields(record: Record): void { + for (const field of COMPLETIONS_REASONING_REPLAY_FIELDS) { + if (field in record) { + delete record[field]; + } + } +} + +function sanitizeOpenRouterReasoningReplayFields(record: Record): void { + const reasoningDetails = record.reasoning_details; + if (typeof reasoningDetails === "string") { + if (reasoningDetails.length > 0 && typeof record.reasoning !== "string") { + record.reasoning = reasoningDetails; + } + delete record.reasoning_details; + } else if (reasoningDetails !== undefined && !Array.isArray(reasoningDetails)) { + delete record.reasoning_details; + } + + // Empty reasoning artifacts are rejected by OpenRouter/DeepSeek replay. + if ("reasoning" in record && (typeof record.reasoning !== "string" || record.reasoning === "")) { + delete record.reasoning; + } + if ( + "reasoning_content" in record && + (typeof record.reasoning_content !== "string" || record.reasoning_content === "") + ) { + delete record.reasoning_content; + } + + const reasoningText = record.reasoning_text; + if ( + typeof reasoningText === "string" && + reasoningText.length > 0 && + typeof record.reasoning !== "string" && + typeof record.reasoning_content !== "string" + ) { + record.reasoning = reasoningText; + } + if ("reasoning_text" in record) { + delete record.reasoning_text; + } +} + +function sanitizeReasoningContentReplayFields(record: Record): void { + if ("reasoning_content" in record && typeof record.reasoning_content !== "string") { + delete record.reasoning_content; + } + delete record.reasoning_details; + delete record.reasoning; + delete record.reasoning_text; +} + +const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([ + "deepseek-v4-flash", + "deepseek-v4-pro", + "kimi-for-coding", + "kimi-k2.5", + "kimi-k2.6", + "kimi-k2.7-code", + "kimi-k2-thinking", + "kimi-k2-thinking-turbo", + "mimo-v2-pro", + "mimo-v2-omni", + "mimo-v2.5", + "mimo-v2.5-pro", + "mimo-v2.6-pro", +]); + +// Tier/access suffixes that some providers append to otherwise identical model +// ids (OpenCode Zen exposes `deepseek-v4-flash-free`, OpenRouter exposes +// `:free` / `:cloud`, etc.). The base model id before the suffix still owns +// the same DeepSeek-style reasoning_content replay contract, so reasoning +// replay must not be stripped just because the catalog id grew a marketing +// suffix (#87575). +const REASONING_CONTENT_REPLAY_TIER_SUFFIXES = ["-free", "-paid", "-trial"] as const; + +function stripReasoningContentReplayTierSuffix(modelId: string): string { + for (const suffix of REASONING_CONTENT_REPLAY_TIER_SUFFIXES) { + if (modelId.length > suffix.length && modelId.endsWith(suffix)) { + return modelId.slice(0, -suffix.length); + } + } + return modelId; +} + +function getReasoningContentReplayModelIdCandidates(modelId: unknown): string[] { + if (typeof modelId !== "string") { + return []; + } + const normalized = modelId.trim().toLowerCase(); + if (!normalized) { + return []; + } + const parts = normalized.split("/").filter(Boolean); + const finalPart = parts[parts.length - 1] ?? normalized; + const candidates = [finalPart]; + const colonParts = finalPart.split(":").filter(Boolean); + if (colonParts.length > 1) { + candidates.push(colonParts[0] ?? "", colonParts[colonParts.length - 1] ?? ""); + } + const baseCount = candidates.length; + for (let index = 0; index < baseCount; index += 1) { + const candidate = candidates[index]; + if (typeof candidate !== "string") { + continue; + } + const stripped = stripReasoningContentReplayTierSuffix(candidate); + if (stripped !== candidate) { + candidates.push(stripped); + } + } + return uniqueStrings(candidates.filter(Boolean)); +} + +function shouldPreserveReasoningContentReplay( + model: OpenAIModeModel, + compat: { requiresReasoningContentOnAssistantMessages: boolean; thinkingFormat: string }, +): boolean { + if ( + compat.requiresReasoningContentOnAssistantMessages || + compat.thinkingFormat === "deepseek" || + compat.thinkingFormat === "zai" || + shouldTrustReasoningContentReplayMetadata(model) + ) { + return true; + } + return getReasoningContentReplayModelIdCandidates(model.id).some((modelId) => + REASONING_CONTENT_REPLAY_MODEL_IDS.has(modelId), + ); +} + +function shouldPreserveOpenRouterReasoningReplay(model: OpenAIModeModel): boolean { + if (model.provider !== "openrouter") { + return true; + } + const normalizedModelId = model.id.trim().toLowerCase(); + return !(normalizedModelId.startsWith("anthropic/") || normalizedModelId.startsWith("x-ai/")); +} + +function shouldTrustReasoningContentReplayMetadata(model: OpenAIModeModel): boolean { + if (!model.reasoning) { + return false; + } + const provider = model.provider.trim().toLowerCase(); + if (provider === "openai") { + return false; + } + return shouldPreserveOpenRouterReasoningReplay(model); +} + +// OpenAI Chat Completions assistant-message input does not define reasoning +// replay fields, while OpenRouter and DeepSeek-style providers document +// compatible pass-back contracts. Keep valid provider-owned replay fields, but +// strip them for stock OpenAI before a follow-up request hits the wire. +function sanitizeCompletionsReasoningReplayFields( + messages: unknown, + options: { preserveOpenRouterReasoning: boolean; preserveReasoningContent: boolean }, +): void { + if (!Array.isArray(messages)) { + return; + } + for (const msg of messages) { + if (!msg || typeof msg !== "object") { + continue; + } + const record = msg as Record; + if (record.role !== "assistant") { + continue; + } + if (options.preserveOpenRouterReasoning) { + sanitizeOpenRouterReasoningReplayFields(record); + } else if (options.preserveReasoningContent) { + sanitizeReasoningContentReplayFields(record); + } else { + stripCompletionsReasoningReplayFields(record); + } + } +} + +export function buildOpenAICompletionsParams( + model: OpenAIModeModel, + context: Context, + options: OpenAICompletionsOptions | undefined, +) { + const compat = getCompat(model); + const compatDetection = detectOpenAICompletionsCompat(model); + const completionsContext = context.systemPrompt + ? { + ...context, + systemPrompt: stripSystemPromptCacheBoundary(context.systemPrompt), + } + : context; + let messages = convertMessages(model as never, completionsContext, compat as never); + injectToolCallThoughtSignatures(messages as unknown[], context, model); + sanitizeCompletionsReasoningReplayFields(messages, { + preserveOpenRouterReasoning: + compat.thinkingFormat === "openrouter" && shouldPreserveOpenRouterReasoningReplay(model), + preserveReasoningContent: shouldPreserveReasoningContentReplay(model, compat), + }); + if (compat.strictMessageKeys) { + messages = stripCompletionMessagesToRoleContent(messages) as typeof messages; + } + const cacheRetention = resolveCacheRetention(options?.cacheRetention); + const promptCacheKey = resolvePromptCacheKey(options, cacheRetention); + const params: Record = { + model: model.id, + messages: compat.requiresStringContent + ? flattenCompletionMessagesToStringContent(messages) + : messages, + stream: true, + }; + if (compat.supportsUsageInStreaming) { + params.stream_options = { include_usage: true }; + } + if (compat.supportsStore) { + params.store = false; + } + if (compat.supportsPromptCacheKey && promptCacheKey) { + params.prompt_cache_key = promptCacheKey; + // When the caller explicitly opted into long retention, forward the + // canonical prompt_cache_retention value alongside the cache key so + // OpenAI-compatible completions backends (oMLX, llama.cpp, official + // OpenAI, etc.) can honor the 24h prefix-cache lifetime. Without this + // the key reaches the wire but the retention preference is silently + // dropped (issue #81281). + if (cacheRetention === "long" && compat.supportsLongCacheRetention) { + params.prompt_cache_retention = "24h"; + } + } + if (options?.temperature !== undefined) { + params.temperature = options.temperature; + } + if (options?.topP !== undefined) { + params.top_p = options.topP; + } + if (options?.responseFormat !== undefined) { + params.response_format = options.responseFormat; + } + if (options?.frequencyPenalty !== undefined) { + params.frequency_penalty = options.frequencyPenalty; + } + if (options?.presencePenalty !== undefined) { + params.presence_penalty = options.presencePenalty; + } + if (options?.seed !== undefined) { + params.seed = options.seed; + } + if (options?.stop !== undefined && options.stop.length > 0) { + params.stop = options.stop; + } + if (supportsModelTools(model)) { + if (context.tools) { + const converted = convertTools(context.tools, compat, model); + if ( + converted.tools.length > 0 || + (converted.projection.inputToolCount === 0 && converted.projection.diagnostics.length === 0) + ) { + params.tools = converted.tools; + } else if (hasToolHistory(context.messages)) { + params.tools = []; + } + if (options?.toolChoice) { + const toolChoice = reconcileOpenAICompletionsToolChoice( + options.toolChoice, + converted.projection, + ); + if (toolChoice !== undefined) { + params.tool_choice = toolChoice; + } + } else if ( + compatDetection.capabilities.usesExplicitProxyLikeEndpoint && + Array.isArray(params.tools) && + params.tools.length > 0 + ) { + params.tool_choice = "auto"; + } + } else if (hasToolHistory(context.messages)) { + params.tools = []; + } + if ( + compatDetection.capabilities.usesExplicitProxyLikeEndpoint && + Array.isArray(params.tools) && + params.tools.length === 0 + ) { + delete params.tools; + delete params.tool_choice; + } + } + { + const maxTokenBudget = resolveOpenAICompletionsMaxTokens(model, options); + const effectiveMaxTokens = maxTokenBudget.maxTokens; + const effectiveContextTokens = resolveOpenAICompletionsEffectiveContextTokens(model); + let clampedMaxTokens = effectiveMaxTokens; + const modelMaxTokens = resolveOpenAICompletionsModelMaxTokens(model); + if ( + maxTokenBudget.clampToModelMaxTokens && + clampedMaxTokens !== undefined && + modelMaxTokens !== undefined && + clampedMaxTokens > modelMaxTokens + ) { + clampedMaxTokens = modelMaxTokens; + emitModelTransportDebug( + log, + `[completions] clamp_max_tokens provider=${model.provider} api=${model.api} ` + + `model=${model.id} requested=${effectiveMaxTokens} output=${clampedMaxTokens} ` + + `modelMaxTokens=${modelMaxTokens}`, + ); + } + if ( + compatDetection.capabilities.usesExplicitProxyLikeEndpoint && + clampedMaxTokens !== undefined && + effectiveContextTokens !== undefined + ) { + const estimatedInputTokens = estimateOpenAICompletionsInputTokens(params); + const remainingBudget = Math.max(1, effectiveContextTokens - estimatedInputTokens - 1); + if (clampedMaxTokens > remainingBudget) { + clampedMaxTokens = remainingBudget; + emitModelTransportDebug( + log, + `[completions] clamp_max_tokens provider=${model.provider} api=${model.api} ` + + `model=${model.id} requested=${effectiveMaxTokens} output=${clampedMaxTokens} ` + + `effectiveContext=${effectiveContextTokens} estimatedInput=${estimatedInputTokens}`, + ); + } + } + if (clampedMaxTokens) { + if (compat.maxTokensField === "max_tokens") { + params.max_tokens = clampedMaxTokens; + } else { + params.max_completion_tokens = clampedMaxTokens; + } + } + } + const completionsReasoningEffort = resolveOpenAICompletionsReasoningEffort(options); + const resolvedCompletionsReasoningEffort = completionsReasoningEffort + ? resolveOpenAIReasoningEffortForModel({ + model, + effort: completionsReasoningEffort, + fallbackMap: compat.reasoningEffortMap, + }) + : undefined; + const omitChatCompletionsToolReasoningEffort = + Array.isArray(params.tools) && + params.tools.length > 0 && + (isOpenAIGpt54MiniModel(model) || + (isOpenAIGpt55Model(model) && isKnownOpenAICompletionsEndpoint(model))); + const disableChatCompletionsToolReasoning = + Array.isArray(params.tools) && + params.tools.length > 0 && + isOpenAIGpt56Model(model) && + isKnownOpenAICompletionsEndpoint(model); + const handledQwenThinkingFormat = applyQwenOpenAICompletionsThinkingParams({ + compatThinkingFormat: compat.thinkingFormat, + modelReasoning: model.reasoning, + payload: params, + requestedEffort: completionsReasoningEffort, + }); + applyTogetherOpenAICompletionsThinkingParams({ + compatThinkingFormat: compat.thinkingFormat, + modelReasoning: model.reasoning, + payload: params, + requestedEffort: completionsReasoningEffort, + }); + if (disableChatCompletionsToolReasoning) { + // GPT-5.6 Chat Completions defaults reasoning on, but rejects function + // tools unless reasoning is explicitly disabled. + params.reasoning_effort = "none"; + } else if ( + compat.thinkingFormat === "openrouter" && + model.reasoning && + resolvedCompletionsReasoningEffort + ) { + params.reasoning = { + effort: resolvedCompletionsReasoningEffort, + }; + } else if ( + resolvedCompletionsReasoningEffort && + model.reasoning && + compat.supportsReasoningEffort && + !handledQwenThinkingFormat && + !omitChatCompletionsToolReasoningEffort + ) { + params.reasoning_effort = resolvedCompletionsReasoningEffort; + } + return params; +} + +export function parseTransportChunkUsage( + rawUsage: NonNullable & { cost?: unknown }, + model: Model, +): MutableAssistantOutput["usage"] { + const cachedTokens = rawUsage.prompt_tokens_details?.cached_tokens || 0; + const promptTokens = rawUsage.prompt_tokens || 0; + const input = Math.max(0, promptTokens - cachedTokens); + const outputTokens = rawUsage.completion_tokens || 0; + const reasoningTokens = rawUsage.completion_tokens_details?.reasoning_tokens; + const usage: MutableAssistantOutput["usage"] = { + input, + output: outputTokens, + cacheRead: cachedTokens, + cacheWrite: 0, + ...(typeof reasoningTokens === "number" && Number.isFinite(reasoningTokens) + ? { reasoningTokens } + : {}), + totalTokens: input + outputTokens + cachedTokens, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + calculateCost(model as never, usage as never); + applyProviderReportedUsageCost(usage, rawUsage.cost); + return usage; +} + +function hasOpenAICompletionsReasoningUsageActivity( + rawUsage: NonNullable, +) { + const reasoningTokens = rawUsage.completion_tokens_details?.reasoning_tokens; + return ( + typeof reasoningTokens === "number" && Number.isFinite(reasoningTokens) && reasoningTokens > 0 + ); +} + +export const completionsTesting = { + getCompat, + createSseDoneDetector, + createOpenAICompletionsClient, + buildOpenAICompletionsClientConfig, + processOpenAICompletionsStream, + shouldEmitOpenAICompletionsReasoningForModel, +}; diff --git a/src/agents/openai-responses-transport.ts b/src/agents/openai-responses-transport.ts new file mode 100644 index 000000000000..3c2d7df85331 --- /dev/null +++ b/src/agents/openai-responses-transport.ts @@ -0,0 +1,2604 @@ +/** + * OpenAI Responses streaming transport. + * + * Handles Responses, Azure variants, tool-call replay, reasoning events, and provider-specific + * payload policy before converting SDK streams into OpenClaw assistant events. + */ +import { randomUUID } from "node:crypto"; +import { + isOpenAICompatibleAzureResponsesBaseUrl, + isResponsesTextContentPartType, + isResponsesTextDeltaEventType, + normalizeOpenAIReasoningEffort, + normalizeOpenAIStrictToolParameters, + projectOpenAITools, + reconcileOpenAIResponsesToolChoice, + resolveAzureDeploymentNameFromMap, + resolveOpenAIReasoningEffortForModel, + resolveResponsesMessageSnapshotCollapse, + type OpenAIApiReasoningEffort, + type OpenAIReasoningEffort, + type OpenAIToolProjection, +} from "@openclaw/ai/internal/openai"; +import { + calculateCost, + createFirstStreamEventAbortController, + getEnvApiKey, + getFirstStreamEventTimeoutHandler, + getFirstStreamEventTimeoutMs, + parseStreamingJson, + withFirstStreamEventTimeout, +} from "@openclaw/ai/internal/runtime"; +import { + describeToolResultMediaPlaceholder, + extractToolResultText, + stripSystemPromptCacheBoundary, +} from "@openclaw/ai/internal/shared"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import OpenAI, { AzureOpenAI } from "openai"; +import type { + FunctionTool, + ResponseCreateParamsStreaming, + ResponseFormatTextConfig, + ResponseFunctionCallOutputItemList, + ResponseInput, + ResponseInputItem, + ResponseInputMessageContentList, + ResponseOutputMessage, + ResponseReasoningItem, +} from "openai/resources/responses/responses.js"; +import { sha256HexPrefix } from "../infra/crypto-digest.js"; +import type { Api, Context, Model } from "../llm/types.js"; +import "../llm/ai-transport-host.js"; +import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"; +import { redactIdentifier } from "../logging/redact-identifier.js"; +import { redactSensitiveText } from "../logging/redact.js"; +import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js"; +import { resolveProviderTransportTurnStateWithPlugin } from "../plugins/provider-runtime.js"; +import { + emitModelTransportDebug, + resolveModelPayloadDebugMode, + resolveModelSseDebugMode, +} from "./model-transport-debug.js"; +import { formatModelTransportDebugBaseUrl } from "./model-transport-url.js"; +import { + applyOpenAIResponsesPayloadPolicy, + resolveOpenAIResponsesPayloadPolicy, +} from "./openai-responses-payload-policy.js"; +import { resolveReplayableResponsesMessageId } from "./openai-responses-replay.js"; +import { resolveOpenAIStrictToolSetting } from "./openai-strict-tool-setting.js"; +import { + assertCodeModeResponsesToolSurface, + buildOpenAIClientHeaders, + buildOpenAISdkClientOptions, + buildOpenAISdkRequestOptions, + enforceCodeModeResponsesToolSurface, + getCompat, + isOpenAICodexResponsesModel, + resolveOpenAIStrictToolFlagWithDiagnostics, + usesNativeOpenAICodexResponsesBackend, +} from "./openai-transport-params.js"; +import { + createModelStreamCooperativeScheduler, + log, + resolveCacheRetention, + resolvePromptCacheKey, + sortTransportToolsByName, + throwIfModelStreamAborted, + type BaseOpenAIStreamOptions, + type MutableAssistantOutput, + type OpenAIModeModel, +} from "./openai-transport-shared.js"; +import { buildGuardedModelFetch } from "./provider-transport-fetch.js"; +import { sanitizeResponsesImagePayload } from "./responses-image-payload-sanitizer.js"; +import type { StreamFn } from "./runtime/index.js"; +import { transformTransportMessages } from "./transport-message-transform.js"; +import { + assignTransportErrorDetails, + mergeTransportMetadata, + sanitizeNonEmptyTransportPayloadText, + sanitizeTransportPayloadText, +} from "./transport-stream-shared.js"; + +const DEFAULT_AZURE_OPENAI_API_VERSION = "preview"; +const OPENAI_CODEX_RESPONSES_EMPTY_INPUT_TEXT = " "; +const OPENAI_CODEX_RESPONSES_DEFAULT_INSTRUCTIONS = "Follow the user request."; +const AZURE_RESPONSES_FIRST_EVENT_TIMEOUT_MS = 30_000; +const RESPONSE_FAILED_NO_DETAILS_MESSAGE = "Unknown error (no error details in response)"; +const OPENAI_RESPONSES_REASONING_REPLAY_META_KEY = "__openclaw_replay"; +const OPENAI_RESPONSES_REASONING_REPLAY_BLOCK_META_KEY = "openclawReasoningReplay"; +const OPENAI_RESPONSES_REPLAY_ITEM_ID_MAX_LENGTH = 64; + +type ReplayableResponseOutputMessage = Omit & { id?: string }; +type OpenAIResponsesReasoningReplayMetadata = { + v: 1; + source: "openai-responses"; + provider: string; + api: Api; + model: string; + baseUrlHash?: string; + sessionHash?: string; + authProfileHash?: string; +}; +type ReplayableResponseReasoningItem = Omit & { + id?: string; + [OPENAI_RESPONSES_REASONING_REPLAY_META_KEY]?: OpenAIResponsesReasoningReplayMetadata; +}; +type ResponsesClientLike = ReturnType; + +type OpenAIResponsesOptions = BaseOpenAIStreamOptions & { + reasoning?: OpenAIReasoningEffort; + reasoningEffort?: OpenAIReasoningEffort; + reasoningSummary?: "auto" | "detailed" | "concise" | null; + replayResponsesItemIds?: boolean; + serviceTier?: ResponseCreateParamsStreaming["service_tier"]; + toolChoice?: ResponseCreateParamsStreaming["tool_choice"]; +}; + +type OpenAIResponsesReplayContext = { + provider: string; + api: Api; + model: string; + baseUrlHash?: string; + sessionHash?: string; + authProfileHash?: string; +}; + +export { sanitizeTransportPayloadText } from "./transport-stream-shared.js"; + +function stringifyUnknown(value: unknown, fallback = ""): string { + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return fallback; +} + +function stringifyJsonLike(value: unknown, fallback = ""): string { + if (typeof value === "string") { + return value; + } + if (value && typeof value === "object") { + return JSON.stringify(value); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return fallback; +} + +function getServiceTierCostMultiplier(serviceTier: ResponseCreateParamsStreaming["service_tier"]) { + switch (serviceTier) { + case "flex": + return 0.5; + case "priority": + return 2; + default: + return 1; + } +} + +function applyServiceTierPricing( + usage: MutableAssistantOutput["usage"], + serviceTier?: ResponseCreateParamsStreaming["service_tier"], +): void { + const multiplier = getServiceTierCostMultiplier(serviceTier); + if (multiplier === 1) { + return; + } + usage.cost.input *= multiplier; + usage.cost.output *= multiplier; + usage.cost.cacheRead *= multiplier; + usage.cost.cacheWrite *= multiplier; + usage.cost.total = + usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; +} + +function safeDebugValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (value === null) { + return "null"; + } + if (value === undefined) { + return "undefined"; + } + return Array.isArray(value) ? "array" : typeof value; +} + +function responseInputTextChars(input: unknown): number { + if (typeof input === "string") { + return input.length; + } + if (Array.isArray(input)) { + return input.reduce((total, item) => total + responseInputTextChars(item), 0); + } + if (!input || typeof input !== "object") { + return 0; + } + const record = input as Record; + let total = 0; + if (typeof record.text === "string") { + total += record.text.length; + } + if (typeof record.content === "string") { + total += record.content.length; + } else if (Array.isArray(record.content)) { + total += responseInputTextChars(record.content); + } + return total; +} + +function responseInputRoles(input: unknown): string { + if (!Array.isArray(input)) { + return ""; + } + const roles = new Set(); + for (const item of input) { + if (item && typeof item === "object") { + const role = (item as Record).role; + if (typeof role === "string" && role.trim()) { + roles.add(role.trim()); + } + } + } + return [...roles].toSorted().join(","); +} + +function readToolPayloadField(record: Record, field: string): unknown { + try { + return record[field]; + } catch { + return undefined; + } +} + +function readResponsesToolDisplayName(tool: unknown): string { + if (!tool || typeof tool !== "object") { + return ""; + } + const record = tool as Record; + const name = readToolPayloadField(record, "name"); + if (typeof name === "string") { + return name; + } + const fn = readToolPayloadField(record, "function"); + if (fn && typeof fn === "object") { + const fnName = readToolPayloadField(fn as Record, "name"); + if (typeof fnName === "string") { + return fnName; + } + } + const type = readToolPayloadField(record, "type"); + return typeof type === "string" && type !== "function" ? type : ""; +} + +function summarizeResponsesTools(tools: unknown): string { + if (!Array.isArray(tools)) { + return "count=0"; + } + const names = tools.map(readResponsesToolDisplayName).filter(Boolean); + const mode = resolveModelPayloadDebugMode(); + const maxNames = mode === "tools" || mode === "full-redacted" ? names.length : 12; + const label = maxNames >= names.length ? "names" : "sample"; + const shown = names.slice(0, maxNames).join(","); + return `count=${tools.length}${shown ? ` ${label}=${shown}` : ""}`; +} + +function stringifyRedactedPayload(value: unknown): string { + try { + const encoded = JSON.stringify(value); + if (!encoded) { + return ""; + } + const redacted = redactSensitiveText(encoded, { mode: "tools" }); + return redacted.length > 8000 ? `${truncateUtf16Safe(redacted, 8000)}…` : redacted; + } catch { + return ""; + } +} + +function stringifyRedactedEvent(value: unknown): string { + const redacted = stringifyRedactedPayload(value); + return redacted.length > 2000 ? `${truncateUtf16Safe(redacted, 2000)}…` : redacted; +} + +type ResponsesFailedNoDetailsObservation = { + event: "openai_responses_response_failed_without_details"; + provider: string; + api: Api; + transportModel: string; + providerRuntimeFailureKind: "no_error_details"; + responseId: string; + responseStatus: string; + responseModel: string; + responseObject: string; + metadataKeys: string[]; + requestIdHashes: string[]; + failureFieldsPreview: string; + responsePreview: string; +}; + +type ResponsesFailedEventSummary = { + message: string; + responseId?: string; + observation?: ResponsesFailedNoDetailsObservation; +}; + +const RESPONSE_FAILED_FAILURE_FIELD_KEYS = [ + "error", + "incomplete_details", + "status_details", + "failure_reason", + "last_error", + "provider_error", + "error_details", +] as const; + +function readResponseFailedString( + record: Record | undefined, + key: string, +): string { + return stringifyUnknown(record?.[key]); +} + +function buildResponsesFailedEventSummary( + message: string, + responseId: string | undefined, + observation?: ResponsesFailedNoDetailsObservation, +): ResponsesFailedEventSummary { + const summary: ResponsesFailedEventSummary = { message }; + if (responseId) { + summary.responseId = responseId; + } + if (observation) { + summary.observation = observation; + } + return summary; +} + +function isResponseFailedIdentifierKey(key: string): boolean { + const normalized = key.replace(/[-_\s]/g, "").toLowerCase(); + return ( + normalized === "requestid" || + normalized === "xrequestid" || + normalized === "providerrequestid" || + normalized === "providerresponseid" || + normalized === "litellmrequestid" || + (normalized.includes("request") && normalized.endsWith("id")) || + (normalized.includes("provider") && normalized.endsWith("id")) + ); +} + +function collectResponseFailedIdentifierHashes( + value: unknown, + opts: { + path?: string; + depth?: number; + identifierKey?: string; + out?: string[]; + seen?: WeakSet; + } = {}, +): string[] { + const path = opts.path ?? ""; + const depth = opts.depth ?? 0; + const identifierKey = opts.identifierKey ?? ""; + const out = opts.out ?? []; + const seen = opts.seen ?? new WeakSet(); + if (out.length >= 12 || depth > 4 || !value || typeof value !== "object") { + return out; + } + if (seen.has(value)) { + return out; + } + seen.add(value); + if (Array.isArray(value)) { + for (const [index, item] of value.entries()) { + if (index >= 8 || out.length >= 12) { + break; + } + const itemString = + typeof item === "string" || typeof item === "number" ? String(item).trim() : ""; + if (identifierKey && isResponseFailedIdentifierKey(identifierKey) && itemString) { + out.push(`${path}[${index}]=${redactIdentifier(itemString, { len: 12 })}`); + continue; + } + collectResponseFailedIdentifierHashes(item, { + path: `${path}[${index}]`, + depth: depth + 1, + identifierKey, + out, + seen, + }); + } + return out; + } + for (const [key, child] of Object.entries(value as Record)) { + if (out.length >= 12) { + break; + } + const childPath = path ? `${path}.${key}` : key; + const childString = + typeof child === "string" || typeof child === "number" ? String(child).trim() : ""; + if (isResponseFailedIdentifierKey(key) && childString) { + out.push(`${childPath}=${redactIdentifier(childString, { len: 12 })}`); + continue; + } + collectResponseFailedIdentifierHashes(child, { + path: childPath, + depth: depth + 1, + identifierKey: isResponseFailedIdentifierKey(key) ? key : undefined, + out, + seen, + }); + } + return out; +} + +function redactResponseFailedDiagnosticValue( + value: unknown, + opts: { + key?: string; + depth?: number; + seen?: WeakSet; + } = {}, +): unknown { + const key = opts.key ?? ""; + const depth = opts.depth ?? 0; + if (typeof value === "string" || typeof value === "number") { + return key && isResponseFailedIdentifierKey(key) + ? redactIdentifier(String(value), { len: 12 }) + : value; + } + if (depth > 6 || !value || typeof value !== "object") { + return value; + } + const seen = opts.seen ?? new WeakSet(); + if (seen.has(value)) { + return ""; + } + seen.add(value); + if (Array.isArray(value)) { + return value.slice(0, 16).map((item) => + redactResponseFailedDiagnosticValue(item, { + key, + depth: depth + 1, + seen, + }), + ); + } + const out: Record = {}; + for (const [childKey, child] of Object.entries(value as Record)) { + out[childKey] = redactResponseFailedDiagnosticValue(child, { + key: childKey, + depth: depth + 1, + seen, + }); + } + return out; +} + +function buildResponsesFailedFailureFields( + response: Record | undefined, +): Record { + if (!response) { + return {}; + } + const fields: Record = {}; + for (const key of RESPONSE_FAILED_FAILURE_FIELD_KEYS) { + if (response[key] !== undefined && response[key] !== null) { + fields[key] = response[key]; + } + } + return fields; +} + +function buildResponsesFailedNoDetailsObservation( + event: Record, + model: Model, + response: Record | undefined = isRecord(event.response) + ? event.response + : undefined, +): ResponsesFailedNoDetailsObservation { + const failureFields = redactResponseFailedDiagnosticValue( + buildResponsesFailedFailureFields(response), + ) as Record; + const metadataKeys = isRecord(response?.metadata) + ? Object.keys(response.metadata).toSorted() + : []; + const responsePreview = { + id: readResponseFailedString(response, "id"), + status: readResponseFailedString(response, "status"), + model: readResponseFailedString(response, "model"), + object: readResponseFailedString(response, "object"), + failureFields, + metadataKeys, + }; + return { + event: "openai_responses_response_failed_without_details", + provider: model.provider, + api: model.api, + transportModel: model.id, + providerRuntimeFailureKind: "no_error_details", + responseId: responsePreview.id, + responseStatus: responsePreview.status, + responseModel: responsePreview.model, + responseObject: responsePreview.object, + metadataKeys, + requestIdHashes: collectResponseFailedIdentifierHashes(event), + failureFieldsPreview: stringifyRedactedEvent(failureFields), + responsePreview: stringifyRedactedEvent(responsePreview), + }; +} + +function summarizeResponsesFailedNoDetailsObservation( + observation: ResponsesFailedNoDetailsObservation, +): string { + const requestIds = observation.requestIdHashes.join(","); + const metadataKeys = observation.metadataKeys.join(","); + return ( + `responseId=${safeDebugValue(observation.responseId || undefined)} ` + + `responseStatus=${safeDebugValue(observation.responseStatus || undefined)} ` + + `responseModel=${safeDebugValue(observation.responseModel || undefined)} ` + + `requestIds=${requestIds || "none"} metadataKeys=${metadataKeys || "none"} ` + + `failureFields=${observation.failureFieldsPreview}` + ); +} + +function normalizeResponsesFailedEvent( + event: Record, + model: Model, +): ResponsesFailedEventSummary { + const response = isRecord(event.response) ? event.response : undefined; + const responseId = readResponseFailedString(response, "id") || undefined; + const error = isRecord(response?.error) ? response.error : undefined; + if (error) { + const code = readResponseFailedString(error, "code").trim(); + const message = readResponseFailedString(error, "message").trim(); + if (code || message) { + return buildResponsesFailedEventSummary( + `${code || "unknown"}: ${message || "no message"}`, + responseId, + ); + } + } + const incompleteDetails = isRecord(response?.incomplete_details) + ? response.incomplete_details + : undefined; + const incompleteReason = readResponseFailedString(incompleteDetails, "reason"); + if (incompleteReason) { + return buildResponsesFailedEventSummary(`incomplete: ${incompleteReason}`, responseId); + } + return buildResponsesFailedEventSummary( + RESPONSE_FAILED_NO_DETAILS_MESSAGE, + responseId, + buildResponsesFailedNoDetailsObservation(event, model, response), + ); +} + +function logResponsesFailedNoDetails(observation: ResponsesFailedNoDetailsObservation): void { + log.warn( + `[responses] response.failed missing error details provider=${observation.provider} ` + + `api=${observation.api} model=${observation.transportModel} ` + + summarizeResponsesFailedNoDetailsObservation(observation), + observation, + ); +} + +function summarizeResponsesPayload(params: unknown): string { + if (!params || typeof params !== "object") { + return "payload=non-object"; + } + const record = params as Record; + const input = record.input; + const reasoning = + record.reasoning && typeof record.reasoning === "object" + ? (record.reasoning as Record) + : undefined; + const text = + record.text && typeof record.text === "object" + ? (record.text as Record) + : undefined; + const parts = [ + `fields=${Object.keys(record).toSorted().join(",")}`, + `model=${safeDebugValue(record.model)}`, + `stream=${safeDebugValue(record.stream)}`, + `inputItems=${Array.isArray(input) ? input.length : typeof input}`, + `inputRoles=${responseInputRoles(input) || "none"}`, + `inputTextChars=${responseInputTextChars(input)}`, + `tools=${summarizeResponsesTools(record.tools)}`, + `reasoningEffort=${safeDebugValue(reasoning?.effort)}`, + `reasoningSummary=${safeDebugValue(reasoning?.summary)}`, + `textVerbosity=${safeDebugValue(text?.verbosity)}`, + `serviceTier=${safeDebugValue(record.service_tier)}`, + `store=${safeDebugValue(record.store)}`, + `promptCacheKey=${record.prompt_cache_key === undefined ? "absent" : "present"}`, + `metadataKeys=${ + record.metadata && typeof record.metadata === "object" + ? Object.keys(record.metadata).toSorted().join(",") + : "none" + }`, + ]; + if (resolveModelPayloadDebugMode() === "full-redacted") { + parts.push(`payload=${stringifyRedactedPayload(record)}`); + } + return parts.join(" "); +} + +function summarizeOpenAITransportError(error: unknown): string { + if (!error || typeof error !== "object") { + return `type=${typeof error} message=${safeDebugValue(error)}`; + } + const record = error as Record; + const cause = + record.cause && typeof record.cause === "object" + ? (record.cause as Record) + : undefined; + return [ + `name=${safeDebugValue(record.name)}`, + `status=${safeDebugValue(record.status)}`, + `code=${safeDebugValue(record.code)}`, + `type=${safeDebugValue(record.type)}`, + `causeName=${safeDebugValue(cause?.name)}`, + `causeCode=${safeDebugValue(cause?.code)}`, + `message=${error instanceof Error ? error.message : safeDebugValue(error)}`, + ].join(" "); +} + +function isInvalidEncryptedContentError(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + const record = error as { code?: unknown; message?: unknown; status?: unknown }; + if (record.code === "invalid_encrypted_content" || record.code === "thinking_signature_invalid") { + return true; + } + const message = typeof record.message === "string" ? record.message : ""; + return ( + message.includes("invalid_encrypted_content") || + message.includes("thinking_signature_invalid") || + // xAI reports this exact prose contract without an error code. + (record.status === 400 && + message.toLowerCase().includes("could not decrypt the provided encrypted_content")) + ); +} + +function stripEncryptedContentFields(value: unknown): { value: unknown; changed: boolean } { + if (!value || typeof value !== "object") { + return { value, changed: false }; + } + if (Array.isArray(value)) { + let changed = false; + const next = value.map((item) => { + const stripped = stripEncryptedContentFields(item); + changed ||= stripped.changed; + return stripped.value; + }); + return changed ? { value: next, changed: true } : { value, changed: false }; + } + + let changed = false; + const next: Record = {}; + for (const [key, child] of Object.entries(value as Record)) { + if (key === "encrypted_content") { + changed = true; + continue; + } + const stripped = stripEncryptedContentFields(child); + changed ||= stripped.changed; + next[key] = stripped.value; + } + return changed ? { value: next, changed: true } : { value, changed: false }; +} + +function stripResponsesRequestEncryptedContent( + params: OpenAIResponsesRequestParams, +): OpenAIResponsesRequestParams { + const stripped = stripEncryptedContentFields(params.input); + if (!stripped.changed) { + return params; + } + return { + ...params, + input: stripped.value as ResponseInput, + }; +} + +function hashOptionalReplayContextValue(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? shortHash(normalized) : undefined; +} + +function buildOpenAIResponsesReplayContext( + model: Model, + options?: Pick, +): OpenAIResponsesReplayContext { + return { + provider: model.provider, + api: model.api, + model: model.id, + baseUrlHash: hashOptionalReplayContextValue(model.baseUrl), + sessionHash: hashOptionalReplayContextValue(options?.sessionId), + authProfileHash: hashOptionalReplayContextValue(options?.authProfileId), + }; +} + +function buildOpenAIResponsesReasoningReplayMetadata( + model: Model, + options?: Pick, +): OpenAIResponsesReasoningReplayMetadata { + return { + v: 1, + source: "openai-responses", + ...buildOpenAIResponsesReplayContext(model, options), + }; +} + +function tagOpenAIResponsesReasoningReplayItem( + item: Record, + model: Model, + options?: Pick, +): Record { + if (!("encrypted_content" in item)) { + return item; + } + return { + ...item, + [OPENAI_RESPONSES_REASONING_REPLAY_META_KEY]: buildOpenAIResponsesReasoningReplayMetadata( + model, + options, + ), + }; +} + +function isOpenAIResponsesReasoningReplayMetadata( + value: unknown, +): value is OpenAIResponsesReasoningReplayMetadata { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + return ( + record.v === 1 && + record.source === "openai-responses" && + typeof record.provider === "string" && + typeof record.api === "string" && + typeof record.model === "string" && + (record.baseUrlHash === undefined || typeof record.baseUrlHash === "string") && + (record.sessionHash === undefined || typeof record.sessionHash === "string") && + (record.authProfileHash === undefined || typeof record.authProfileHash === "string") + ); +} + +function encryptedReasoningReplayMetadataMatches( + metadata: OpenAIResponsesReasoningReplayMetadata | undefined, + context: OpenAIResponsesReplayContext, +): boolean { + if (!metadata) { + return false; + } + return ( + metadata.provider === context.provider && + metadata.api === context.api && + metadata.model === context.model && + metadata.baseUrlHash === context.baseUrlHash && + metadata.sessionHash === context.sessionHash && + metadata.authProfileHash === context.authProfileHash + ); +} + +function readOpenAIResponsesReasoningReplayBlockMetadata( + block: Record, +): OpenAIResponsesReasoningReplayMetadata | undefined { + const value = block[OPENAI_RESPONSES_REASONING_REPLAY_BLOCK_META_KEY]; + return isOpenAIResponsesReasoningReplayMetadata(value) ? value : undefined; +} + +function normalizeOpenAIResponsesReasoningReplayItem( + item: ReplayableResponseReasoningItem, +): ReplayableResponseReasoningItem { + const record = item as ReplayableResponseReasoningItem & Record; + if (record.type !== "reasoning" || Array.isArray(record.summary)) { + return item; + } + return { ...record, summary: [] } as ReplayableResponseReasoningItem; +} + +function prepareOpenAIResponsesReasoningItemForReplay( + item: ReplayableResponseReasoningItem, + context: OpenAIResponsesReplayContext, + blockMetadata?: OpenAIResponsesReasoningReplayMetadata, +): ReplayableResponseReasoningItem { + const { [OPENAI_RESPONSES_REASONING_REPLAY_META_KEY]: rawMetadata, ...rest } = + item as ReplayableResponseReasoningItem & Record; + if (!("encrypted_content" in rest)) { + return normalizeOpenAIResponsesReasoningReplayItem(rest as ReplayableResponseReasoningItem); + } + const metadata = + blockMetadata ?? + (isOpenAIResponsesReasoningReplayMetadata(rawMetadata) ? rawMetadata : undefined); + if (encryptedReasoningReplayMetadataMatches(metadata, context)) { + return normalizeOpenAIResponsesReasoningReplayItem(rest as ReplayableResponseReasoningItem); + } + const stripped = stripEncryptedContentFields(rest); + return normalizeOpenAIResponsesReasoningReplayItem( + stripped.value as ReplayableResponseReasoningItem, + ); +} + +async function createResponsesStreamWithEncryptedContentRetry(params: { + client: ResponsesClientLike; + request: OpenAIResponsesRequestParams; + requestOptions: unknown; + model: Model; +}): Promise> { + try { + return (await params.client.responses.create( + params.request as never, + params.requestOptions as never, + )) as unknown as AsyncIterable; + } catch (error) { + const retryRequest = stripResponsesRequestEncryptedContent(params.request); + if (!isInvalidEncryptedContentError(error) || retryRequest === params.request) { + throw error; + } + log.warn( + `[responses] retrying without encrypted reasoning content provider=${params.model.provider} ` + + `api=${params.model.api} model=${params.model.id}`, + ); + return (await params.client.responses.create( + retryRequest as never, + params.requestOptions as never, + )) as unknown as AsyncIterable; + } +} + +export function resolveAzureOpenAIApiVersion(env = process.env): string { + return env.AZURE_OPENAI_API_VERSION?.trim() || DEFAULT_AZURE_OPENAI_API_VERSION; +} + +function shortHash(value: string): string { + return sha256HexPrefix(value, 16); +} + +function normalizeResponsesReplayItemId( + id: string | undefined, + prefix: string, +): string | undefined { + if (!id) { + return undefined; + } + if (id.length <= OPENAI_RESPONSES_REPLAY_ITEM_ID_MAX_LENGTH) { + return id; + } + return `${prefix}_${shortHash(id)}`; +} + +function isSafeResponsesReplayItemId(id: unknown): id is string { + return ( + typeof id === "string" && + id.length > 0 && + id.length <= OPENAI_RESPONSES_REPLAY_ITEM_ID_MAX_LENGTH + ); +} + +function encodeTextSignatureV1(id: string, phase?: "commentary" | "final_answer"): string { + return JSON.stringify({ v: 1, id, ...(phase ? { phase } : {}) }); +} + +function parseTextSignature( + signature: string | undefined, +): { id?: string; phase?: "commentary" | "final_answer" } | undefined { + if (!signature) { + return undefined; + } + if (signature.startsWith("{")) { + try { + const parsed = JSON.parse(signature) as { v?: unknown; id?: unknown; phase?: unknown }; + if (parsed.v === 1) { + const id = typeof parsed.id === "string" ? parsed.id : undefined; + const phase = + parsed.phase === "commentary" || parsed.phase === "final_answer" + ? parsed.phase + : undefined; + // A reasoning-dropped replay keeps the phase but omits the paired id. + if (id !== undefined || phase !== undefined) { + return { id, phase }; + } + return undefined; + } + } catch { + // Keep legacy plain-string behavior below. + } + } + return { id: signature }; +} + +function buildResponsesInputMessage( + role: "user" | "system" | "developer", + content: ResponseInputMessageContentList, +): ResponseInputItem.Message { + return { type: "message", role, content }; +} + +function convertResponsesMessages( + model: Model, + context: Context, + allowedToolCallProviders: Set, + options?: { + includeSystemPrompt?: boolean; + supportsDeveloperRole?: boolean; + replayReasoningItems?: boolean; + replayResponsesItemIds?: boolean; + sessionId?: string; + authProfileId?: string; + }, +): ResponseInput { + const messages: ResponseInput = []; + const shouldReplayReasoningItems = options?.replayReasoningItems ?? true; + const shouldReplayResponsesItemIds = options?.replayResponsesItemIds ?? true; + const replayContext = buildOpenAIResponsesReplayContext(model, { + sessionId: options?.sessionId, + authProfileId: options?.authProfileId, + }); + const shouldNormalizeSameModelToolCallIds = model.provider === "github-copilot"; + const sanitizeIdPart = (part: string) => part.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+$/, ""); + const normalizeIdPart = (part: string) => { + const sanitized = sanitizeIdPart(part); + const normalized = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized; + return normalized.replace(/_+$/, ""); + }; + const buildForeignResponsesItemId = (itemId: string) => { + const normalized = `fc_${shortHash(itemId)}`; + return normalized.length > 64 ? normalized.slice(0, 64) : normalized; + }; + const buildSameProviderCopilotResponsesItemId = (itemId: string) => { + const sanitized = sanitizeIdPart(itemId); + const candidate = sanitized.startsWith("fc_") ? sanitized : `fc_${sanitized}`; + return candidate.length > 64 ? buildForeignResponsesItemId(itemId) : candidate; + }; + const normalizeToolCallId = ( + id: string, + _targetModel: Model, + source: { provider: string; api: Api }, + ) => { + if (!allowedToolCallProviders.has(model.provider)) { + return normalizeIdPart(id); + } + if (!id.includes("|")) { + return normalizeIdPart(id); + } + const separatorIndex = id.indexOf("|"); + const callId = id.slice(0, separatorIndex); + const itemId = id.slice(separatorIndex + 1); + const normalizedCallId = normalizeIdPart(callId); + const isForeignToolCall = source.provider !== model.provider || source.api !== model.api; + let normalizedItemId = isForeignToolCall + ? buildForeignResponsesItemId(itemId) + : model.provider === "github-copilot" + ? buildSameProviderCopilotResponsesItemId(itemId) + : normalizeIdPart(itemId); + if (!normalizedItemId.startsWith("fc_")) { + normalizedItemId = normalizeIdPart(`fc_${normalizedItemId}`); + } + return `${normalizedCallId}|${normalizedItemId}`; + }; + const transformedMessages = transformTransportMessages( + context.messages, + model, + normalizeToolCallId, + { normalizeSameModelToolCallIds: shouldNormalizeSameModelToolCallIds }, + ); + const includeSystemPrompt = options?.includeSystemPrompt ?? true; + if (includeSystemPrompt && context.systemPrompt) { + messages.push( + buildResponsesInputMessage( + model.reasoning && options?.supportsDeveloperRole !== false ? "developer" : "system", + [ + { + type: "input_text", + text: sanitizeTransportPayloadText( + stripSystemPromptCacheBoundary(context.systemPrompt), + ), + }, + ], + ), + ); + } + let msgIndex = 0; + for (const msg of transformedMessages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + messages.push( + buildResponsesInputMessage("user", [ + { type: "input_text", text: sanitizeTransportPayloadText(msg.content) }, + ]), + ); + } else { + const content = ( + msg.content.map((item) => + item.type === "text" + ? { type: "input_text", text: sanitizeTransportPayloadText(item.text) } + : { + type: "input_image", + detail: "auto", + image_url: `data:${item.mimeType};base64,${item.data}`, + }, + ) as ResponseInputMessageContentList + ).filter((item) => model.input.includes("image") || item.type !== "input_image"); + if (content.length > 0) { + messages.push(buildResponsesInputMessage("user", content)); + } + } + } else if (msg.role === "assistant") { + const output: ResponseInput = []; + let textFallbackOrdinal = 0; + let previousReplayItemWasReasoning = false; + const isDifferentModel = + msg.model !== model.id && msg.provider === model.provider && msg.api === model.api; + for (const block of msg.content) { + if (block.type === "thinking") { + if ( + shouldReplayReasoningItems && + block.thinkingSignature && + block.thinkingSignature.startsWith("{") + ) { + // openai-completions plain-text reasoning paths persist a + // provenance tag (e.g. "reasoning", "reasoning_details", "content") + // as thinkingSignature rather than a JSON-encoded reasoning item. + // Replaying those values would corrupt the next request payload + // (OpenRouter returns HTTP 500), so skip non-JSON signatures. + const reasoningItem = JSON.parse( + block.thinkingSignature, + ) as ReplayableResponseReasoningItem; + const replayableReasoningItem = prepareOpenAIResponsesReasoningItemForReplay( + reasoningItem, + replayContext, + readOpenAIResponsesReasoningReplayBlockMetadata( + block as unknown as Record, + ), + ); + if (!shouldReplayResponsesItemIds) { + delete replayableReasoningItem.id; + } + if ( + shouldReplayResponsesItemIds && + model.provider === "github-copilot" && + !isSafeResponsesReplayItemId(replayableReasoningItem.id) + ) { + continue; + } + output.push(replayableReasoningItem as ResponseInputItem); + previousReplayItemWasReasoning = true; + } + } else if (block.type === "text") { + const textSignature = parseTextSignature(block.textSignature); + let msgId = resolveReplayableResponsesMessageId({ + replayResponsesItemIds: shouldReplayResponsesItemIds, + textSignatureId: textSignature?.id, + fallbackId: `msg_${msgIndex}`, + fallbackOrdinal: textFallbackOrdinal, + previousReplayItemWasReasoning, + }); + if (!textSignature?.id) { + textFallbackOrdinal += 1; + } + msgId = normalizeResponsesReplayItemId(msgId, "msg"); + const messageItem: ReplayableResponseOutputMessage = { + type: "message", + role: "assistant", + content: [ + { + type: "output_text", + text: sanitizeTransportPayloadText(block.text), + annotations: [], + }, + ], + status: "completed", + ...(msgId ? { id: msgId } : {}), + phase: textSignature?.phase, + }; + output.push(messageItem as ResponseInputItem); + previousReplayItemWasReasoning = false; + } else if (block.type === "toolCall") { + const separatorIndex = block.id.indexOf("|"); + const callId = separatorIndex === -1 ? block.id : block.id.slice(0, separatorIndex); + const itemIdRaw = separatorIndex === -1 ? undefined : block.id.slice(separatorIndex + 1); + const itemId = + shouldReplayResponsesItemIds && !(isDifferentModel && itemIdRaw?.startsWith("fc_")) + ? itemIdRaw + : undefined; + output.push({ + type: "function_call", + ...(itemId ? { id: itemId } : {}), + call_id: callId, + name: block.name, + arguments: + typeof block.arguments === "string" + ? block.arguments + : JSON.stringify(block.arguments ?? {}), + }); + previousReplayItemWasReasoning = false; + } + } + if (output.length > 0) { + messages.push(...output); + } + } else if (msg.role === "toolResult") { + const textResult = extractToolResultText(msg.content); + const sanitizedTextResult = sanitizeTransportPayloadText(textResult); + const hasText = sanitizedTextResult.trim().length > 0; + const mediaPlaceholder = describeToolResultMediaPlaceholder(msg.content); + const hasImages = msg.content.some((item) => item.type === "image"); + const separatorIndex = msg.toolCallId.indexOf("|"); + const callId = + separatorIndex === -1 ? msg.toolCallId : msg.toolCallId.slice(0, separatorIndex); + messages.push({ + type: "function_call_output", + call_id: callId, + output: + hasImages && model.input.includes("image") + ? ([ + ...(hasText + ? [{ type: "input_text", text: sanitizedTextResult }] + : mediaPlaceholder === "(see attached media)" + ? [{ type: "input_text", text: mediaPlaceholder }] + : []), + ...msg.content + .filter((item) => item.type === "image") + .map((item) => ({ + type: "input_image", + detail: "auto", + image_url: `data:${item.mimeType};base64,${item.data}`, + })), + ] as ResponseFunctionCallOutputItemList) + : sanitizeNonEmptyTransportPayloadText(textResult, mediaPlaceholder ?? "(no output)"), + }); + } + msgIndex += 1; + } + return messages; +} + +function convertResponsesTools( + tools: NonNullable, + model: OpenAIModeModel, + options?: { strict?: boolean | null }, +): { projection: OpenAIToolProjection; tools: FunctionTool[] } { + const projection = projectOpenAITools(tools); + const strict = resolveOpenAIStrictToolFlagWithDiagnostics(projection, options?.strict, { + transport: "responses", + model, + }); + return { + projection, + tools: sortTransportToolsByName(projection.tools).map((tool): FunctionTool => { + const result = { + type: "function" as const, + name: tool.name, + description: tool.description, + parameters: normalizeOpenAIStrictToolParameters( + tool.parameters, + strict === true, + model.compat, + ), + } as FunctionTool; + if (strict !== undefined) { + result.strict = strict; + } + return result; + }), + }; +} + +async function processResponsesStream( + openaiStream: AsyncIterable, + output: MutableAssistantOutput, + stream: { push(event: unknown): void }, + model: Model, + options?: { + serviceTier?: ResponseCreateParamsStreaming["service_tier"]; + applyServiceTierPricing?: ( + usage: MutableAssistantOutput["usage"], + serviceTier?: ResponseCreateParamsStreaming["service_tier"], + ) => void; + firstEventTimeoutMs?: number; + abortFirstEventStream?: (reason: Error) => void; + onFirstEventTimeout?: (reason: Error) => void; + signal?: AbortSignal; + sessionId?: string; + authProfileId?: string; + }, +) { + const resolveToolCallId = (item: Record, fallbackId?: string): string => { + const callId = stringifyUnknown(item.call_id).trim(); + const itemId = stringifyUnknown(item.id).trim(); + const [fallbackCallId = "", fallbackItemId = ""] = (fallbackId ?? "").split("|"); + const resolvedCallId = callId || fallbackCallId; + const resolvedItemId = itemId || fallbackItemId; + if (resolvedCallId) { + return resolvedItemId ? `${resolvedCallId}|${resolvedItemId}` : resolvedCallId; + } + const generatedCallId = `call_${randomUUID().replaceAll("-", "").slice(0, 24)}`; + return resolvedItemId ? `${generatedCallId}|${resolvedItemId}` : generatedCallId; + }; + let currentItem: Record | null = null; + let currentBlock: Record | null = null; + type StreamingToolCallIdentity = { itemId?: string; callId?: string }; + type StreamingToolCallState = StreamingToolCallIdentity & { + block: Record; + contentIndex: number; + argumentStreamReliable: boolean; + }; + const toolCallsByOutputIndex = new Map(); + const unindexedToolCalls = new Set(); + let lastTextBlock: { + block: Record; + index: number; + phase: "commentary" | "final_answer" | undefined; + } | null = null; + // While a message item may still be a cumulative snapshot of lastTextBlock, + // its public block is deferred so a collapsed item never leaves an + // unbalanced text_start behind (#91959). null = no deferral in progress. + let pendingMessageText: string | null = null; + const streamStartedAt = Date.now(); + let eventCount = 0; + const eventTypes = new Map(); + const sseDebugMode = resolveModelSseDebugMode(); + const blockIndex = () => output.content.length - 1; + const readOutputIndex = (event: Record): number | undefined => + typeof event.output_index === "number" && + Number.isInteger(event.output_index) && + event.output_index >= 0 + ? event.output_index + : undefined; + const readIdentityValue = (value: unknown): string | undefined => { + const identity = typeof value === "string" ? value.trim() : ""; + return identity || undefined; + }; + const readEventToolCallIdentity = ( + event: Record, + ): StreamingToolCallIdentity => ({ itemId: readIdentityValue(event.item_id) }); + const readItemToolCallIdentity = (item: Record): StreamingToolCallIdentity => ({ + itemId: readIdentityValue(item.id), + callId: readIdentityValue(item.call_id), + }); + const identitiesConflict = ( + state: StreamingToolCallState, + identity: StreamingToolCallIdentity, + ): boolean => + Boolean( + (state.itemId && identity.itemId && state.itemId !== identity.itemId) || + (state.callId && identity.callId && state.callId !== identity.callId), + ); + const sharesIdentity = ( + state: StreamingToolCallState, + identity: StreamingToolCallIdentity, + ): boolean => + Boolean( + (state.itemId && identity.itemId && state.itemId === identity.itemId) || + (state.callId && identity.callId && state.callId === identity.callId), + ); + const adoptToolCallIdentity = ( + state: StreamingToolCallState, + identity: StreamingToolCallIdentity, + ): StreamingToolCallState => { + state.itemId ??= identity.itemId; + state.callId ??= identity.callId; + return state; + }; + const resolveCompatibleToolCall = ( + candidates: Iterable, + identity: StreamingToolCallIdentity, + ): StreamingToolCallState | undefined => { + const uniqueCandidates = [...new Set(candidates)]; + if (!identity.itemId && !identity.callId) { + return uniqueCandidates.length === 1 ? uniqueCandidates[0] : undefined; + } + const compatible = uniqueCandidates.filter((state) => !identitiesConflict(state, identity)); + const matches = compatible.filter((state) => sharesIdentity(state, identity)); + if (matches.length === 1) { + const match = matches.at(0); + return match ? adoptToolCallIdentity(match, identity) : undefined; + } + // Only a sole active call may adopt an identity it did not already know. + // Parallel calls require a positive match so missing indices stay fail-closed. + if (uniqueCandidates.length !== 1 || compatible.length !== 1 || matches.length !== 0) { + return undefined; + } + const candidate = compatible.at(0); + return candidate ? adoptToolCallIdentity(candidate, identity) : undefined; + }; + const resolveStreamingToolCall = ( + event: Record, + identity: StreamingToolCallIdentity = readEventToolCallIdentity(event), + ): StreamingToolCallState | undefined => { + const outputIndex = readOutputIndex(event); + if (outputIndex !== undefined) { + const indexed = toolCallsByOutputIndex.get(outputIndex); + if (indexed) { + return !identitiesConflict(indexed, identity) + ? adoptToolCallIdentity(indexed, identity) + : undefined; + } + // A compatibility stream may add calls without indices, then start + // including them. Bind only the one identity-matched (or sole) candidate. + const unindexed = resolveCompatibleToolCall(unindexedToolCalls, identity); + if (unindexed) { + unindexedToolCalls.delete(unindexed); + toolCallsByOutputIndex.set(outputIndex, unindexed); + } + return unindexed; + } + + return resolveCompatibleToolCall( + [...toolCallsByOutputIndex.values(), ...unindexedToolCalls], + identity, + ); + }; + const forgetStreamingToolCall = (toolCall: StreamingToolCallState) => { + for (const [trackedIndex, tracked] of toolCallsByOutputIndex) { + if (tracked === toolCall) { + toolCallsByOutputIndex.delete(trackedIndex); + } + } + unindexedToolCalls.delete(toolCall); + }; + const markActiveToolCallArgumentsUnreliable = () => { + // An unrouteable argument event may belong to any active call. Only an + // authoritative full argument snapshot can recover that call. + for (const toolCall of new Set([...toolCallsByOutputIndex.values(), ...unindexedToolCalls])) { + toolCall.argumentStreamReliable = false; + } + }; + const hasActiveStreamingToolCall = () => + toolCallsByOutputIndex.size > 0 || unindexedToolCalls.size > 0; + // Opening fragments may carry the only function name. A conflicting + // completion must never retarget an already-started call. + const resolveCompletedToolCallName = ( + toolCall: StreamingToolCallState | undefined, + value: unknown, + ): string => { + const streamedName = readIdentityValue(toolCall?.block.name); + const completedName = readIdentityValue(value); + if (streamedName && completedName && streamedName !== completedName) { + throw new Error( + `Responses stream changed tool-call function name from ${streamedName} to ${completedName}`, + ); + } + const name = completedName ?? streamedName; + if (!name) { + throw new Error("Responses stream completed tool call without a function name"); + } + return name; + }; + const appendPendingMessageDelta = (delta: string) => { + pendingMessageText = `${pendingMessageText ?? ""}${delta}`; + const priorText = stringifyUnknown(lastTextBlock?.block.text); + if (priorText.startsWith(pendingMessageText) || pendingMessageText.startsWith(priorText)) { + return; + } + // Diverged from the prior text: this is a distinct message, so open its + // block now and replay the withheld text as one delta. + const phase = + currentItem?.type === "message" + ? ((currentItem.phase as "commentary" | "final_answer" | undefined) ?? undefined) + : undefined; + currentBlock = { + type: "text", + text: pendingMessageText, + ...(currentItem?.type === "message" && phase + ? { + textSignature: encodeTextSignatureV1(stringifyUnknown(currentItem.id), phase), + } + : {}), + }; + output.content.push(currentBlock); + stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + stream.push({ type: "text_delta", contentIndex: blockIndex(), delta: pendingMessageText }); + pendingMessageText = null; + }; + const appendCompletedResponseTextItem = (item: Record) => { + const text = readResponsesOutputMessageText(item); + if (!text) { + return; + } + const phase = (item.phase as "commentary" | "final_answer" | undefined) ?? undefined; + const collapse = resolveResponsesMessageSnapshotCollapse({ + prior: lastTextBlock && { + text: stringifyUnknown(lastTextBlock.block.text), + phase: lastTextBlock.phase, + }, + nextText: text, + nextPhase: phase, + }); + if (collapse.kind === "extend" && lastTextBlock) { + // Cumulative snapshot of the prior message item: replace, don't append; + // the newest item's signature carries the content for replay (#91959). + lastTextBlock.block.text = collapse.text; + lastTextBlock.block.textSignature = encodeTextSignatureV1(stringifyUnknown(item.id), phase); + stream.push({ + type: "text_end", + contentIndex: lastTextBlock.index, + content: collapse.text, + partial: output, + }); + return; + } + const block: Record = { + type: "text", + text, + textSignature: encodeTextSignatureV1(stringifyUnknown(item.id), phase), + }; + output.content.push(block); + lastTextBlock = { block, index: blockIndex(), phase }; + stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + stream.push({ + type: "text_end", + contentIndex: blockIndex(), + content: text, + partial: output, + }); + }; + const appendCompletedResponseToolCallItem = (item: Record) => { + const args = parseStreamingJson(stringifyJsonLike(item.arguments, "{}")); + const name = resolveCompletedToolCallName(undefined, item.name); + const block = { + type: "toolCall", + id: resolveToolCallId(item), + name, + arguments: args, + partialJson: stringifyJsonLike(item.arguments, "{}"), + }; + output.content.push(block); + stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); + stream.push({ + type: "toolcall_end", + contentIndex: blockIndex(), + toolCall: { + type: "toolCall", + id: block.id, + name: block.name, + arguments: args, + }, + partial: output, + }); + }; + const backfillCompletedResponseOutput = (response: Record | undefined) => { + if (output.content.length > 0 || !Array.isArray(response?.output)) { + return; + } + for (const rawItem of response.output) { + if (!isRecord(rawItem)) { + continue; + } + if (rawItem.type === "message") { + appendCompletedResponseTextItem(rawItem); + continue; + } + // Any non-message item (reasoning, tool call) is a real boundary; a later + // message must not collapse across it, mirroring the streaming path. + lastTextBlock = null; + if (rawItem.type === "function_call") { + appendCompletedResponseToolCallItem(rawItem); + } + } + }; + const guardedStream = withFirstStreamEventTimeout(openaiStream, { + provider: model.provider, + api: model.api, + model: model.id, + timeoutMs: options?.firstEventTimeoutMs ?? 0, + stage: "responses", + abort: options?.abortFirstEventStream, + onTimeout: options?.onFirstEventTimeout, + hint: "The provider may be stalled while parsing the tool payload; retry with a smaller tool surface or enable OPENCLAW_DEBUG_MODEL_PAYLOAD=tools to inspect exposed tools.", + }); + const cooperativeScheduler = createModelStreamCooperativeScheduler(options?.signal); + for await (const rawEvent of guardedStream) { + throwIfModelStreamAborted(options?.signal); + const event = rawEvent as Record; + const type = stringifyUnknown(event.type); + eventCount += 1; + eventTypes.set(type, (eventTypes.get(type) ?? 0) + 1); + if (eventCount === 1) { + emitModelTransportDebug( + log, + `[responses] first_event provider=${model.provider} api=${model.api} model=${model.id} ` + + `elapsedMs=${Date.now() - streamStartedAt} type=${type}`, + ); + } + if (sseDebugMode === "peek" && eventCount <= 5) { + emitModelTransportDebug( + log, + `[responses] event_peek provider=${model.provider} api=${model.api} model=${model.id} ` + + `index=${eventCount} type=${type} event=${stringifyRedactedEvent(event)}`, + ); + } + if (type === "response.created") { + output.responseId = stringifyUnknown((event.response as { id?: string } | undefined)?.id); + } else if (type === "response.output_item.added") { + const item = event.item as Record; + if (item.type !== "message") { + // Snapshot collapse only applies to back-to-back message items; any + // other item is a real boundary (see resolveResponsesMessageSnapshotCollapse). + lastTextBlock = null; + pendingMessageText = null; + } + if (item.type === "reasoning") { + currentItem = item; + currentBlock = { type: "thinking", thinking: "" }; + output.content.push(currentBlock); + stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); + } else if (item.type === "message") { + currentItem = item; + if (lastTextBlock) { + currentBlock = null; + pendingMessageText = ""; + } else { + const phase = (item.phase as "commentary" | "final_answer" | undefined) ?? undefined; + currentBlock = { + type: "text", + text: "", + ...(phase + ? { textSignature: encodeTextSignatureV1(stringifyUnknown(item.id), phase) } + : {}), + }; + output.content.push(currentBlock); + stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + } + } else if (item.type === "function_call") { + const outputIndex = readOutputIndex(event); + if (outputIndex !== undefined && toolCallsByOutputIndex.has(outputIndex)) { + throw new Error(`Responses stream reused active tool-call output index ${outputIndex}`); + } + currentItem = item; + currentBlock = { + type: "toolCall", + id: resolveToolCallId(item), + name: readIdentityValue(item.name) ?? "", + arguments: {}, + partialJson: stringifyJsonLike(item.arguments), + }; + output.content.push(currentBlock); + const contentIndex = blockIndex(); + const toolCallState = { + block: currentBlock, + contentIndex, + argumentStreamReliable: true, + ...readItemToolCallIdentity(item), + }; + if (outputIndex !== undefined) { + toolCallsByOutputIndex.set(outputIndex, toolCallState); + } else { + unindexedToolCalls.add(toolCallState); + } + stream.push({ type: "toolcall_start", contentIndex, partial: output }); + } + } else if (type === "response.reasoning_summary_text.delta") { + if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") { + currentBlock.thinking = `${stringifyUnknown(currentBlock.thinking)}${stringifyUnknown(event.delta)}`; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: stringifyUnknown(event.delta), + partial: output, + }); + } + } else if (isResponsesTextDeltaEventType(type) || type === "response.refusal.delta") { + if (currentItem?.type === "message") { + if (pendingMessageText !== null) { + appendPendingMessageDelta(stringifyUnknown(event.delta)); + } else if (currentBlock?.type === "text") { + currentBlock.text = `${stringifyUnknown(currentBlock.text)}${stringifyUnknown(event.delta)}`; + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: stringifyUnknown(event.delta), + }); + } + } + } else if (type === "response.function_call_arguments.delta") { + const toolCall = resolveStreamingToolCall(event); + if (toolCall) { + toolCall.block.partialJson = `${stringifyJsonLike(toolCall.block.partialJson)}${stringifyJsonLike(event.delta)}`; + toolCall.block.arguments = parseStreamingJson( + stringifyJsonLike(toolCall.block.partialJson), + ); + stream.push({ + type: "toolcall_delta", + contentIndex: toolCall.contentIndex, + delta: stringifyJsonLike(event.delta), + partial: output, + }); + } else if (hasActiveStreamingToolCall()) { + markActiveToolCallArgumentsUnreliable(); + } + } else if (type === "response.function_call_arguments.done") { + const toolCall = resolveStreamingToolCall(event); + if (toolCall) { + const previousPartialJson = stringifyJsonLike(toolCall.block.partialJson); + const doneArguments = typeof event.arguments === "string" ? event.arguments : undefined; + if ( + doneArguments !== undefined && + (doneArguments.length > 0 || previousPartialJson === "") + ) { + toolCall.block.partialJson = doneArguments; + toolCall.block.arguments = parseStreamingJson(doneArguments); + toolCall.argumentStreamReliable = true; + } + if (doneArguments?.startsWith(previousPartialJson)) { + const delta = doneArguments.slice(previousPartialJson.length); + if (delta.length > 0) { + stream.push({ + type: "toolcall_delta", + contentIndex: toolCall.contentIndex, + delta, + partial: output, + }); + } + } + } else if (hasActiveStreamingToolCall()) { + markActiveToolCallArgumentsUnreliable(); + } + } else if (type === "response.output_item.done") { + const item = event.item as Record; + if (item.type !== "message") { + lastTextBlock = null; + pendingMessageText = null; + } + if (item.type === "reasoning" && currentBlock?.type === "thinking") { + const summary = Array.isArray(item.summary) + ? item.summary + .map((part) => { + const summaryPart = part as { text?: string }; + return summaryPart.text ?? ""; + }) + .join("\n\n") + : ""; + currentBlock.thinking = summary; + currentBlock.thinkingSignature = JSON.stringify(item); + if ("encrypted_content" in item) { + currentBlock[OPENAI_RESPONSES_REASONING_REPLAY_BLOCK_META_KEY] = + buildOpenAIResponsesReasoningReplayMetadata(model, { + authProfileId: options?.authProfileId, + sessionId: options?.sessionId, + }); + } + stream.push({ + type: "thinking_end", + contentIndex: blockIndex(), + content: stringifyUnknown(currentBlock.thinking), + partial: output, + }); + currentBlock = null; + } else if ( + item.type === "message" && + (currentBlock?.type === "text" || pendingMessageText !== null) + ) { + const content = Array.isArray(item.content) ? item.content : []; + const finalText = content + .map((part) => { + const contentPart = part as { type?: string; text?: string; refusal?: string }; + return isResponsesTextContentPartType(contentPart.type) + ? (contentPart.text ?? "") + : (contentPart.refusal ?? ""); + }) + .join(""); + const phase = (item.phase as "commentary" | "final_answer" | undefined) ?? undefined; + const collapse = + pendingMessageText !== null + ? resolveResponsesMessageSnapshotCollapse({ + prior: lastTextBlock && { + text: stringifyUnknown(lastTextBlock.block.text), + phase: lastTextBlock.phase, + }, + nextText: finalText, + nextPhase: phase, + }) + : ({ kind: "keep" } as const); + pendingMessageText = null; + if (collapse.kind === "extend" && lastTextBlock) { + // Cumulative snapshot of the prior message item: replace its text + // instead of appending another copy. The deferred block was never + // started publicly, and the newest item's signature is kept so + // replay carries the item that produced this content (#91959). + lastTextBlock.block.text = collapse.text; + lastTextBlock.block.textSignature = encodeTextSignatureV1( + stringifyUnknown(item.id), + phase, + ); + stream.push({ + type: "text_end", + contentIndex: lastTextBlock.index, + content: collapse.text, + partial: output, + }); + } else { + if (currentBlock?.type !== "text") { + // Deferred distinct message: open its block now, balanced with the + // text_end below. + currentBlock = { + type: "text", + text: "", + ...(phase + ? { textSignature: encodeTextSignatureV1(stringifyUnknown(item.id), phase) } + : {}), + }; + output.content.push(currentBlock); + stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + } + currentBlock.text = finalText; + currentBlock.textSignature = encodeTextSignatureV1(stringifyUnknown(item.id), phase); + lastTextBlock = { block: currentBlock, index: blockIndex(), phase }; + stream.push({ + type: "text_end", + contentIndex: blockIndex(), + content: stringifyUnknown(currentBlock.text), + partial: output, + }); + } + currentBlock = null; + } else if (item.type === "function_call") { + const streamingToolCall = resolveStreamingToolCall(event, readItemToolCallIdentity(item)); + // Do not turn an unresolved completion into a second public call while + // an indexed call is still open. Its identity or index must match. + if (!streamingToolCall && hasActiveStreamingToolCall()) { + await cooperativeScheduler.afterEvent(); + continue; + } + const completedName = resolveCompletedToolCallName(streamingToolCall, item.name); + const streamedPartialJson = streamingToolCall + ? stringifyJsonLike(streamingToolCall.block.partialJson) + : ""; + const completedArguments = typeof item.arguments === "string" ? item.arguments : undefined; + if (streamingToolCall && !streamingToolCall.argumentStreamReliable && !completedArguments) { + await cooperativeScheduler.afterEvent(); + continue; + } + const finalPartialJson = + completedArguments !== undefined && + (completedArguments.length > 0 || !streamedPartialJson) + ? completedArguments + : streamedPartialJson || "{}"; + const args = parseStreamingJson(finalPartialJson); + let toolCallBlock: Record; + let contentIndex: number; + if (streamingToolCall) { + toolCallBlock = streamingToolCall.block; + contentIndex = streamingToolCall.contentIndex; + } else { + toolCallBlock = { + type: "toolCall", + id: resolveToolCallId(item), + name: completedName, + arguments: args, + partialJson: finalPartialJson, + }; + output.content.push(toolCallBlock); + contentIndex = blockIndex(); + stream.push({ type: "toolcall_start", contentIndex, partial: output }); + } + const provisionalId = typeof toolCallBlock.id === "string" ? toolCallBlock.id : undefined; + const currentToolCallId = resolveToolCallId(item, provisionalId); + toolCallBlock.id = currentToolCallId; + toolCallBlock.name = completedName; + toolCallBlock.arguments = args; + toolCallBlock.partialJson = finalPartialJson; + stream.push({ + type: "toolcall_end", + contentIndex, + toolCall: { + type: "toolCall", + id: currentToolCallId, + name: completedName, + arguments: args, + }, + partial: output, + }); + if (streamingToolCall) { + forgetStreamingToolCall(streamingToolCall); + } + if (currentBlock === toolCallBlock) { + currentBlock = null; + currentItem = null; + } + } + } else if (type === "response.completed") { + if (hasActiveStreamingToolCall()) { + throw new Error("Responses stream completed with unresolved tool calls"); + } + const response = event.response as Record | undefined; + if (typeof response?.id === "string") { + output.responseId = response.id; + } + backfillCompletedResponseOutput(response); + const usage = response?.usage as + | { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + input_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number }; + output_tokens_details?: { reasoning_tokens?: number }; + service_tier?: ResponseCreateParamsStreaming["service_tier"]; + status?: string; + } + | undefined; + if (usage) { + const cachedTokens = usage.input_tokens_details?.cached_tokens || 0; + const cacheWriteTokens = usage.input_tokens_details?.cache_write_tokens || 0; + const inputTokens = usage.input_tokens || 0; + const outputTokens = usage.output_tokens || 0; + const reasoningTokens = usage.output_tokens_details?.reasoning_tokens; + const input = Math.max(0, inputTokens - cachedTokens - cacheWriteTokens); + output.usage = { + input, + output: outputTokens, + cacheRead: cachedTokens, + cacheWrite: cacheWriteTokens, + ...(typeof reasoningTokens === "number" && Number.isFinite(reasoningTokens) + ? { reasoningTokens } + : {}), + totalTokens: input + outputTokens + cachedTokens + cacheWriteTokens, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + } + calculateCost(model as never, output.usage as never); + if (options?.applyServiceTierPricing) { + options.applyServiceTierPricing( + output.usage, + (response?.service_tier as ResponseCreateParamsStreaming["service_tier"] | undefined) ?? + options.serviceTier, + ); + } + output.stopReason = mapResponsesStopReason(response?.status as string | undefined); + if ( + output.content.some((block) => block.type === "toolCall") && + output.stopReason === "stop" + ) { + output.stopReason = "toolUse"; + } + } else if (type === "error") { + throw new Error( + `Error Code ${stringifyUnknown(event.code, "unknown")}: ${stringifyUnknown(event.message, "Unknown error")}`, + ); + } else if (type === "response.failed") { + const failure = normalizeResponsesFailedEvent(event, model); + if (failure.responseId) { + output.responseId = failure.responseId; + } + if (failure.observation) { + logResponsesFailedNoDetails(failure.observation); + } + throw new Error(failure.message); + } + await cooperativeScheduler.afterEvent(); + } + if (hasActiveStreamingToolCall()) { + throw new Error("Responses stream ended with unresolved tool calls"); + } + const eventTypeSummary = [...eventTypes.entries()] + .slice(0, 12) + .map(([eventType, count]) => `${eventType}:${count}`) + .join(","); + emitModelTransportDebug( + log, + `[responses] stream_done provider=${model.provider} api=${model.api} model=${model.id} ` + + `elapsedMs=${Date.now() - streamStartedAt} events=${eventCount} types=${eventTypeSummary} ` + + `stopReason=${output.stopReason ?? "unset"} contentBlocks=${output.content.length}`, + ); +} + +function mapResponsesStopReason(status: string | undefined): string { + if (!status) { + return "stop"; + } + switch (status) { + case "completed": + return "stop"; + case "incomplete": + return "length"; + case "failed": + case "cancelled": + return "error"; + case "in_progress": + case "queued": + return "stop"; + default: + throw new Error(`Unhandled stop reason: ${status}`); + } +} + +function readResponsesOutputMessageText(item: Record): string { + const content = Array.isArray(item.content) ? item.content : []; + return content + .map((part) => { + if (!isRecord(part)) { + return ""; + } + if (part.type === "output_text" || part.type === "text") { + return stringifyUnknown(part.text); + } + if (part.type === "refusal") { + return stringifyUnknown(part.refusal); + } + return ""; + }) + .join(""); +} + +export function resolveProviderTransportTurnState( + model: Model, + params: { + sessionId?: string; + turnId: string; + attempt: number; + transport: "stream" | "websocket"; + }, +) { + const normalizedProvider = model.provider.trim().toLowerCase(); + const allowRuntimePluginLoad = + normalizedProvider === "openai" || + normalizedProvider === "azure-openai" || + normalizedProvider === "azure-openai-responses"; + return resolveProviderTransportTurnStateWithPlugin({ + provider: model.provider, + modelId: model.id, + allowRuntimePluginLoad, + context: { + provider: model.provider, + modelId: model.id, + model: model as ProviderRuntimeModel, + sessionId: params.sessionId, + turnId: params.turnId, + attempt: params.attempt, + transport: params.transport, + }, + }); +} + +function createOpenAIResponsesClient( + model: Model, + context: Context, + apiKey: string, + optionHeaders?: Record, + turnHeaders?: Record, + sessionId?: string, +) { + return new OpenAI({ + apiKey, + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders: buildOpenAIClientHeaders(model, context, optionHeaders, turnHeaders, sessionId), + fetch: buildGuardedModelFetch(model), + ...buildOpenAISdkClientOptions(model), + }); +} + +export function createOpenAIResponsesTransportStreamFn(): StreamFn { + return (model, context, options) => { + const responsesOptions = options as OpenAIResponsesOptions | undefined; + const eventStream = createAssistantMessageEventStream(); + const stream = eventStream as unknown as { push(event: unknown): void; end(): void }; + void (async () => { + const output: MutableAssistantOutput = { + role: "assistant" as const, + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + let firstEventAbort: ReturnType | undefined; + try { + const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const turnState = resolveProviderTransportTurnState(model, { + sessionId: options?.sessionId, + turnId: randomUUID(), + attempt: 1, + transport: "stream", + }); + const client = createOpenAIResponsesClient( + model, + context, + apiKey, + options?.headers, + turnState?.headers, + options?.sessionId, + ); + let params = buildOpenAIResponsesParams( + model, + context, + responsesOptions, + turnState?.metadata, + ); + const nextParams = await options?.onPayload?.(params, model); + if (nextParams !== undefined) { + params = nextParams as typeof params; + } + if (!isOpenAICodexResponsesModel(model)) { + params = mergeTransportMetadata(params, turnState?.metadata); + } + params = sanitizeOpenAICodexResponsesParams( + model, + params as Record, + ) as typeof params; + params = sanitizeResponsesImagePayload(params as Record) as typeof params; + if ( + (options as { openclawCodeModeToolSurface?: unknown } | undefined) + ?.openclawCodeModeToolSurface === true + ) { + enforceCodeModeResponsesToolSurface(params); + assertCodeModeResponsesToolSurface(params); + } + const requestStartedAt = Date.now(); + firstEventAbort = createFirstStreamEventAbortController(options?.signal); + const requestOptions = buildOpenAISdkRequestOptions(model, firstEventAbort.signal, { + stream: true, + }); + emitModelTransportDebug( + log, + `[responses] start provider=${model.provider} api=${model.api} model=${model.id} ` + + `baseUrl=${formatModelTransportDebugBaseUrl(model.baseUrl)} timeoutMs=${safeDebugValue(requestOptions?.timeout)} ` + + `apiKey=${apiKey ? "present" : "missing"} ${summarizeResponsesPayload(params)}`, + ); + const responseStream = await createResponsesStreamWithEncryptedContentRetry({ + client, + request: params, + requestOptions, + model, + }); + emitModelTransportDebug( + log, + `[responses] headers provider=${model.provider} api=${model.api} model=${model.id} ` + + `elapsedMs=${Date.now() - requestStartedAt}`, + ); + stream.push({ type: "start", partial: output as never }); + await processResponsesStream(responseStream, output, stream, model, { + serviceTier: responsesOptions?.serviceTier, + applyServiceTierPricing, + firstEventTimeoutMs: getFirstStreamEventTimeoutMs(options), + abortFirstEventStream: firstEventAbort.abort, + onFirstEventTimeout: getFirstStreamEventTimeoutHandler(options), + signal: options?.signal, + authProfileId: responsesOptions?.authProfileId, + sessionId: options?.sessionId, + }); + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error("An unknown error occurred"); + } + stream.push({ type: "done", reason: output.stopReason as never, message: output as never }); + stream.end(); + } catch (error) { + log.warn( + `[responses] error provider=${model.provider} api=${model.api} model=${model.id} ` + + summarizeOpenAITransportError(error), + ); + assignTransportErrorDetails(output, error, options?.signal); + stream.push({ type: "error", reason: output.stopReason as never, error: output as never }); + stream.end(); + } finally { + firstEventAbort?.dispose(); + } + })(); + return eventStream as unknown as ReturnType; + }; +} + +function getPromptCacheRetention( + baseUrl: string | undefined, + cacheRetention: "short" | "long" | "none", +) { + if (cacheRetention !== "long") { + return undefined; + } + return baseUrl?.includes("api.openai.com") ? "24h" : undefined; +} + +function resolveOpenAIReasoningEffort( + options: OpenAIResponsesOptions | undefined, +): OpenAIApiReasoningEffort { + return normalizeOpenAIReasoningEffort( + options?.reasoningEffort ?? options?.reasoning ?? "high", + ) as OpenAIApiReasoningEffort; +} + +function hasResponsesWebSearchTool(tools: unknown): boolean { + if (!Array.isArray(tools)) { + return false; + } + return tools.some((tool) => { + if (!isRecord(tool)) { + return false; + } + if (tool.type === "web_search") { + return true; + } + if (tool.type === "function" && tool.name === "web_search") { + return true; + } + const fn = tool.function; + return isRecord(fn) && fn.name === "web_search"; + }); +} + +function raiseMinimalReasoningForResponsesWebSearch(params: { + model: Model; + effort: OpenAIApiReasoningEffort; + tools: unknown; +}): OpenAIApiReasoningEffort { + if (params.effort !== "minimal" || !hasResponsesWebSearchTool(params.tools)) { + return params.effort; + } + for (const effort of ["low", "medium", "high"] as const) { + const resolved = resolveOpenAIReasoningEffortForModel({ + model: params.model, + effort, + }); + if (resolved && resolved !== "none" && resolved !== "minimal") { + return resolved; + } + } + return params.effort; +} + +const OPENAI_CODEX_RESPONSES_UNSUPPORTED_PARAMS = [ + "max_output_tokens", + "metadata", + "prompt_cache_retention", + "service_tier", + "temperature", + "top_p", +] as const; + +function stripOpenAICodexResponsesUnsupportedTextFields(params: Record): void { + const text = params.text; + if (!text || typeof text !== "object" || Array.isArray(text)) { + return; + } + const sanitizedText = { ...(text as Record) }; + delete sanitizedText.format; + if (Object.keys(sanitizedText).length > 0) { + params.text = sanitizedText; + } else { + delete params.text; + } +} + +function sanitizeOpenAICodexResponsesParams>( + model: Model, + params: T, +): T { + if (!usesNativeOpenAICodexResponsesBackend(model)) { + return params; + } + for (const key of OPENAI_CODEX_RESPONSES_UNSUPPORTED_PARAMS) { + delete params[key]; + } + stripOpenAICodexResponsesUnsupportedTextFields(params); + return params; +} + +function buildOpenAICodexResponsesInstructions(context: Context): string | undefined { + if (!context.systemPrompt) { + return undefined; + } + return sanitizeTransportPayloadText(stripSystemPromptCacheBoundary(context.systemPrompt)); +} + +function resolveOpenAICodexResponsesInstructions( + model: Model, + context: Context, +): string | undefined { + const instructions = buildOpenAICodexResponsesInstructions(context); + if (instructions && instructions.trim().length > 0) { + return instructions; + } + return usesNativeOpenAICodexResponsesBackend(model) + ? OPENAI_CODEX_RESPONSES_DEFAULT_INSTRUCTIONS + : undefined; +} + +function ensureOpenAICodexResponsesInput(messages: ResponseInput, context: Context): void { + if (messages.length > 0 || !context.systemPrompt) { + return; + } + const text = buildOpenAICodexResponsesInstructions(context); + if (!text) { + throw new Error( + "OpenAI Codex Responses requires non-empty input when only systemPrompt is provided.", + ); + } + messages.push( + buildResponsesInputMessage("user", [ + { type: "input_text", text: OPENAI_CODEX_RESPONSES_EMPTY_INPUT_TEXT }, + ]), + ); +} + +function resolveOpenAIResponsesTextFormat( + responseFormat: Record, +): ResponseFormatTextConfig { + if ( + responseFormat.type === "json_schema" && + responseFormat.json_schema && + typeof responseFormat.json_schema === "object" && + !Array.isArray(responseFormat.json_schema) + ) { + return { + ...(responseFormat.json_schema as Record), + type: "json_schema", + } as unknown as ResponseFormatTextConfig; + } + return responseFormat as unknown as ResponseFormatTextConfig; +} + +export function buildOpenAIResponsesParams( + model: Model, + context: Context, + options: OpenAIResponsesOptions | undefined, + metadata?: Record, +) { + const isCodexResponses = isOpenAICodexResponsesModel(model); + const isNativeCodexResponses = usesNativeOpenAICodexResponsesBackend(model); + const compat = getCompat(model as OpenAIModeModel); + const supportsDeveloperRole = + typeof compat.supportsDeveloperRole === "boolean" ? compat.supportsDeveloperRole : undefined; + const payloadPolicy = resolveOpenAIResponsesPayloadPolicy(model, { + storeMode: "disable", + }); + const policyAllowsReplayIds = + payloadPolicy.explicitStore !== false && !payloadPolicy.shouldStripStore; + const replayResponsesItemIds = + !isNativeCodexResponses && (options?.replayResponsesItemIds ?? policyAllowsReplayIds); + const messages = convertResponsesMessages( + model, + context, + new Set(["openai", "opencode", "azure-openai-responses", "github-copilot"]), + { + includeSystemPrompt: !isCodexResponses, + supportsDeveloperRole, + replayReasoningItems: true, + replayResponsesItemIds, + authProfileId: options?.authProfileId, + sessionId: options?.sessionId, + }, + ); + if (isCodexResponses) { + ensureOpenAICodexResponsesInput(messages, context); + } + const cacheRetention = resolveCacheRetention(options?.cacheRetention); + const promptCacheKey = resolvePromptCacheKey(options, cacheRetention); + const params: OpenAIResponsesRequestParams = { + model: model.id, + input: messages, + stream: true, + prompt_cache_key: promptCacheKey, + prompt_cache_retention: getPromptCacheRetention(model.baseUrl, cacheRetention), + ...(isCodexResponses + ? { instructions: resolveOpenAICodexResponsesInstructions(model, context) } + : {}), + ...(metadata ? { metadata } : {}), + }; + const effectiveMaxTokens = options?.maxTokens || model.maxTokens; + if (effectiveMaxTokens) { + params.max_output_tokens = effectiveMaxTokens; + } + if (options?.temperature !== undefined) { + params.temperature = options.temperature; + } + if (options?.topP !== undefined) { + params.top_p = options.topP; + } + if (options?.responseFormat !== undefined) { + params.text = { + ...params.text, + format: resolveOpenAIResponsesTextFormat(options.responseFormat), + }; + } + if (options?.serviceTier !== undefined && payloadPolicy.allowsServiceTier) { + params.service_tier = options.serviceTier; + } + if (context.tools) { + const converted = convertResponsesTools(context.tools, model as OpenAIModeModel, { + strict: resolveOpenAIStrictToolSetting(model as OpenAIModeModel, { + transport: "stream", + }), + }); + if ( + converted.tools.length > 0 || + (converted.projection.inputToolCount === 0 && converted.projection.diagnostics.length === 0) + ) { + params.tools = converted.tools; + } + if (options?.toolChoice) { + const toolChoice = reconcileOpenAIResponsesToolChoice( + options.toolChoice, + converted.projection, + ); + if (toolChoice !== undefined) { + params.tool_choice = toolChoice; + } + } + } + if (model.reasoning) { + if (options?.reasoningEffort || options?.reasoning || options?.reasoningSummary) { + const requestedReasoningEffort = resolveOpenAIReasoningEffort(options); + const resolvedReasoningEffort = resolveOpenAIReasoningEffortForModel({ + model, + effort: requestedReasoningEffort, + }); + const reasoningEffort = resolvedReasoningEffort + ? raiseMinimalReasoningForResponsesWebSearch({ + model, + effort: resolvedReasoningEffort, + tools: params.tools, + }) + : undefined; + if (reasoningEffort) { + params.reasoning = { + effort: reasoningEffort, + ...(reasoningEffort === "none" ? {} : { summary: options?.reasoningSummary || "auto" }), + }; + if (reasoningEffort !== "none") { + params.include = ["reasoning.encrypted_content"]; + } + } + } else if (model.provider !== "github-copilot") { + const reasoningEffort = resolveOpenAIReasoningEffortForModel({ + model, + effort: "none", + }); + if (reasoningEffort) { + params.reasoning = { + effort: reasoningEffort, + }; + } + } + } + applyOpenAIResponsesPayloadPolicy(params as Record, payloadPolicy); + return sanitizeOpenAICodexResponsesParams( + model, + params as Record, + ) as typeof params; +} + +export function createAzureOpenAIResponsesTransportStreamFn(): StreamFn { + return (model, context, options) => { + const responsesOptions = options as OpenAIResponsesOptions | undefined; + const eventStream = createAssistantMessageEventStream(); + const stream = eventStream as unknown as { push(event: unknown): void; end(): void }; + void (async () => { + const output: MutableAssistantOutput = { + role: "assistant" as const, + content: [], + api: "azure-openai-responses", + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + let firstEventAbort: ReturnType | undefined; + try { + const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + const turnState = resolveProviderTransportTurnState(model, { + sessionId: options?.sessionId, + turnId: randomUUID(), + attempt: 1, + transport: "stream", + }); + const client = createAzureOpenAIClient( + model, + context, + apiKey, + options?.headers, + turnState?.headers, + ); + const deploymentName = resolveAzureDeploymentName(model); + let params = buildAzureOpenAIResponsesParams( + model, + context, + responsesOptions, + deploymentName, + turnState?.metadata, + ); + const nextParams = await options?.onPayload?.(params, model); + if (nextParams !== undefined) { + params = nextParams as typeof params; + } + if (!isOpenAICodexResponsesModel(model)) { + params = mergeTransportMetadata(params, turnState?.metadata); + } + params = sanitizeOpenAICodexResponsesParams( + model, + params as Record, + ) as typeof params; + params = sanitizeResponsesImagePayload(params as Record) as typeof params; + if ( + (options as { openclawCodeModeToolSurface?: unknown } | undefined) + ?.openclawCodeModeToolSurface === true + ) { + enforceCodeModeResponsesToolSurface(params); + assertCodeModeResponsesToolSurface(params); + } + const requestStartedAt = Date.now(); + firstEventAbort = createFirstStreamEventAbortController(options?.signal); + const requestOptions = buildOpenAISdkRequestOptions(model, firstEventAbort.signal); + emitModelTransportDebug( + log, + `[responses] start provider=${model.provider} api=${model.api} model=${model.id} ` + + `baseUrl=${formatModelTransportDebugBaseUrl(model.baseUrl)} timeoutMs=${safeDebugValue(requestOptions?.timeout)} ` + + `apiKey=${apiKey ? "present" : "missing"} ${summarizeResponsesPayload(params)}`, + ); + const responseStream = (await client.responses.create( + params as never, + requestOptions, + )) as unknown as AsyncIterable; + emitModelTransportDebug( + log, + `[responses] headers provider=${model.provider} api=${model.api} model=${model.id} ` + + `elapsedMs=${Date.now() - requestStartedAt}`, + ); + stream.push({ type: "start", partial: output as never }); + await processResponsesStream(responseStream, output, stream, model, { + firstEventTimeoutMs: + getFirstStreamEventTimeoutMs(options) ?? AZURE_RESPONSES_FIRST_EVENT_TIMEOUT_MS, + abortFirstEventStream: firstEventAbort.abort, + onFirstEventTimeout: getFirstStreamEventTimeoutHandler(options), + signal: options?.signal, + authProfileId: responsesOptions?.authProfileId, + sessionId: options?.sessionId, + }); + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error("An unknown error occurred"); + } + stream.push({ type: "done", reason: output.stopReason as never, message: output as never }); + stream.end(); + } catch (error) { + log.warn( + `[responses] error provider=${model.provider} api=${model.api} model=${model.id} ` + + summarizeOpenAITransportError(error), + ); + assignTransportErrorDetails(output, error, options?.signal); + stream.push({ type: "error", reason: output.stopReason as never, error: output as never }); + stream.end(); + } finally { + firstEventAbort?.dispose(); + } + })(); + return eventStream as unknown as ReturnType; + }; +} + +function normalizeAzureBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ""); +} + +function resolveAzureDeploymentName(model: Model): string { + return resolveAzureDeploymentNameFromMap({ + modelId: model.id, + deploymentMap: process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP, + }); +} + +function createAzureOpenAIClient( + model: Model, + context: Context, + apiKey: string, + optionHeaders?: Record, + turnHeaders?: Record, +) { + const baseURL = normalizeAzureBaseUrl(model.baseUrl); + const clientOptions = { + apiKey, + dangerouslyAllowBrowser: true, + defaultHeaders: buildOpenAIClientHeaders(model, context, optionHeaders, turnHeaders), + baseURL, + fetch: buildGuardedModelFetch(model), + ...buildOpenAISdkClientOptions(model), + }; + + if (isOpenAICompatibleAzureResponsesBaseUrl(baseURL)) { + return new OpenAI(clientOptions); + } + + return new AzureOpenAI({ + ...clientOptions, + apiVersion: resolveAzureOpenAIApiVersion(), + }); +} + +function buildAzureOpenAIResponsesParams( + model: Model, + context: Context, + options: OpenAIResponsesOptions | undefined, + deploymentName: string, + metadata?: Record, +) { + const params = buildOpenAIResponsesParams(model, context, options, metadata); + params.model = deploymentName; + delete params.store; + return params; +} + +export type OpenAIResponsesRequestParams = { + model: string; + input: ResponseInput; + stream: true; + instructions?: string; + prompt_cache_key?: string; + prompt_cache_retention?: "24h"; + metadata?: Record; + store?: boolean; + max_output_tokens?: number; + temperature?: number; + top_p?: number; + text?: ResponseCreateParamsStreaming["text"]; + service_tier?: ResponseCreateParamsStreaming["service_tier"]; + tools?: FunctionTool[]; + tool_choice?: ResponseCreateParamsStreaming["tool_choice"]; + reasoning?: + | { effort: OpenAIApiReasoningEffort } + | { + effort: OpenAIApiReasoningEffort; + summary: NonNullable; + }; + include?: string[]; +}; + +export const responsesTesting = { + getCompat, + assertCodeModeResponsesToolSurface, + buildOpenAIClientHeaders, + buildOpenAISdkClientOptions, + buildOpenAISdkRequestOptions, + createAzureOpenAIClient, + createOpenAIResponsesClient, + enforceCodeModeResponsesToolSurface, + sanitizeOpenAICodexResponsesParams, + processResponsesStream, + formatModelTransportDebugBaseUrl, + buildResponsesFailedNoDetailsObservation, + buildOpenAIResponsesReasoningReplayMetadata, + isInvalidEncryptedContentError, + normalizeResponsesFailedEvent, + prepareOpenAIResponsesReasoningItemForReplay, + createResponsesStreamWithEncryptedContentRetry, + stripResponsesRequestEncryptedContent, + tagOpenAIResponsesReasoningReplayItem, + summarizeResponsesFailedNoDetailsObservation, + summarizeResponsesPayload, + summarizeResponsesTools, + stringifyRedactedEvent, + stringifyRedactedPayload, +}; diff --git a/src/agents/openai-transport-params.ts b/src/agents/openai-transport-params.ts new file mode 100644 index 000000000000..08b20a39551f --- /dev/null +++ b/src/agents/openai-transport-params.ts @@ -0,0 +1,315 @@ +import { + findOpenAIStrictToolProjectionDiagnostics, + resolveOpenAIProjectedToolsStrictToolFlag, + type OpenAIToolProjection, +} from "@openclaw/ai/internal/openai"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { sha256Hex } from "../infra/crypto-digest.js"; +import type { Context, Model } from "../llm/types.js"; +import { isCodeModeModelVisibleToolName } from "./code-mode-control-tools.js"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./copilot-dynamic-headers.js"; +import { detectOpenAICompletionsCompat } from "./openai-completions-compat.js"; +import { resolveOpenAIReasoningEffortMap } from "./openai-reasoning-compat.js"; +import { log, type OpenAIModeModel } from "./openai-transport-shared.js"; +import { resolveProviderRequestPolicyConfig } from "./provider-request-config.js"; +import { resolveModelRequestTimeoutMs } from "./provider-transport-fetch.js"; + +const MAX_OPENAI_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS = 256; +const OPENAI_CODEX_RESPONSES_PROVIDERS = new Set(["openai"]); +const loggedOpenAIStrictToolDowngradeDiagnosticKeys = new Set(); + +function readToolPayloadField(record: Record, field: string): unknown { + try { + return record[field]; + } catch { + return undefined; + } +} + +function transportPayloadToolName(tool: unknown): string | undefined { + if (!isRecord(tool)) { + return undefined; + } + const name = readToolPayloadField(tool, "name"); + if (typeof name === "string") { + return name; + } + const fn = readToolPayloadField(tool, "function"); + if (!isRecord(fn)) { + return undefined; + } + const fnName = readToolPayloadField(fn, "name"); + return typeof fnName === "string" ? fnName : undefined; +} + +export function enforceCodeModeResponsesToolSurface(payload: unknown): void { + if (!isRecord(payload) || !Array.isArray(payload.tools)) { + return; + } + payload.tools = payload.tools.filter((tool) => { + const name = transportPayloadToolName(tool); + return typeof name === "string" && isCodeModeModelVisibleToolName(name); + }); +} + +export function assertCodeModeResponsesToolSurface(payload: unknown): void { + if (!isRecord(payload) || !Array.isArray(payload.tools)) { + throw new Error("Code mode payload tool surface violation: expected exec,wait; got no tools"); + } + const names = payload.tools + .map(transportPayloadToolName) + .filter((name): name is string => typeof name === "string" && name.length > 0) + .toSorted((left, right) => left.localeCompare(right)); + if ( + names.length >= 2 && + new Set(names).size === names.length && + names.filter((name) => name === "exec").length === 1 && + names.filter((name) => name === "wait").length === 1 && + names.every(isCodeModeModelVisibleToolName) + ) { + return; + } + throw new Error( + `Code mode payload tool surface violation: expected exec,wait plus direct-only tools; got ${ + names.length > 0 ? names.join(",") : "none" + }`, + ); +} + +function buildOpenAIStrictToolDowngradeDiagnosticKey( + diagnostics: ReturnType, + context: { transport: "responses" | "completions"; model: OpenAIModeModel }, +): string { + return sha256Hex( + JSON.stringify({ + transport: context.transport, + provider: context.model.provider ?? null, + model: context.model.id ?? null, + diagnostics: diagnostics.map((entry) => ({ + toolIndex: entry.toolIndex, + toolName: entry.toolName ?? null, + violations: entry.violations, + })), + }), + ); +} + +function shouldLogOpenAIStrictToolDowngradeDiagnostic( + diagnostics: ReturnType, + context: { transport: "responses" | "completions"; model: OpenAIModeModel }, +): boolean { + const key = buildOpenAIStrictToolDowngradeDiagnosticKey(diagnostics, context); + if (loggedOpenAIStrictToolDowngradeDiagnosticKeys.has(key)) { + return false; + } + if ( + loggedOpenAIStrictToolDowngradeDiagnosticKeys.size >= + MAX_OPENAI_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS + ) { + loggedOpenAIStrictToolDowngradeDiagnosticKeys.clear(); + } + loggedOpenAIStrictToolDowngradeDiagnosticKeys.add(key); + return true; +} + +export function resolveOpenAIStrictToolFlagWithDiagnostics( + projection: OpenAIToolProjection, + strictSetting: boolean | null | undefined, + context: { transport: "responses" | "completions"; model: OpenAIModeModel }, +): boolean | undefined { + const strict = resolveOpenAIProjectedToolsStrictToolFlag(projection, strictSetting); + if (strictSetting === true && strict === false && log.isEnabled("debug", "any")) { + const diagnostics = findOpenAIStrictToolProjectionDiagnostics(projection); + if (!shouldLogOpenAIStrictToolDowngradeDiagnostic(diagnostics, context)) { + return strict; + } + const sample = diagnostics.slice(0, 5).map((entry) => ({ + tool: entry.toolName ?? `tool[${entry.toolIndex}]`, + violations: entry.violations.slice(0, 8), + })); + log.debug( + `OpenAI ${context.transport} tool schema strict mode downgraded to strict=false for ` + + `${context.model.provider ?? "unknown"}/${context.model.id ?? "unknown"} ` + + `because ${diagnostics.length} tool schema(s) are not strict-compatible`, + { + transport: context.transport, + provider: context.model.provider, + model: context.model.id, + incompatibleToolCount: diagnostics.length, + sample, + }, + ); + } + return strict; +} + +export function isOpenAICodexResponsesModel(model: Model): boolean { + return ( + OPENAI_CODEX_RESPONSES_PROVIDERS.has(model.provider) && + (model.api === "openai-chatgpt-responses" || + model.api === "openclaw-openai-responses-transport") + ); +} + +function isNativeOpenAICodexResponsesBaseUrl(baseUrl?: string): boolean { + const trimmed = typeof baseUrl === "string" ? baseUrl.trim() : ""; + if (!trimmed) { + return false; + } + try { + const url = new URL(trimmed); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return false; + } + if (url.hostname.toLowerCase() !== "chatgpt.com") { + return false; + } + const pathname = url.pathname.replace(/\/+$/u, "").toLowerCase(); + return [ + "/backend-api", + "/backend-api/v1", + "/backend-api/codex", + "/backend-api/codex/v1", + ].includes(pathname); + } catch { + return false; + } +} + +export function usesNativeOpenAICodexResponsesBackend(model: Model): boolean { + return isOpenAICodexResponsesModel(model) && isNativeOpenAICodexResponsesBaseUrl(model.baseUrl); +} + +export function buildOpenAIClientHeaders( + model: Model, + context: Context, + optionHeaders?: Record, + turnHeaders?: Record, + sessionId?: string, +): Record { + const providerHeaders = { ...model.headers }; + if (model.provider === "github-copilot") { + Object.assign( + providerHeaders, + buildCopilotDynamicHeaders({ + messages: context.messages, + hasImages: hasCopilotVisionInput(context.messages), + }), + ); + } + const callerHeaders = { ...optionHeaders, ...turnHeaders }; + const headers = resolveProviderRequestPolicyConfig({ + provider: model.provider, + api: model.api, + baseUrl: model.baseUrl, + capability: "llm", + transport: "stream", + providerHeaders, + callerHeaders: Object.keys(callerHeaders).length > 0 ? callerHeaders : undefined, + precedence: "caller-wins", + }).headers; + const resolvedHeaders = headers ?? {}; + // Preserve ChatGPT Responses session affinity; the native backend accepts this spelling. + if ( + sessionId && + !Object.keys(resolvedHeaders).some( + (key) => normalizeLowercaseStringOrEmpty(key) === "session_id", + ) && + usesNativeOpenAICodexResponsesBackend(model) + ) { + resolvedHeaders.session_id = sessionId; + } + return resolvedHeaders; +} + +function resolveOpenAISdkTimeoutMs(model: Model): number | undefined { + return resolveModelRequestTimeoutMs(model, undefined); +} + +export function buildOpenAISdkClientOptions(model: Model): { timeout?: number } { + const timeout = resolveOpenAISdkTimeoutMs(model); + return timeout === undefined ? {} : { timeout }; +} + +export function buildOpenAISdkRequestOptions( + model: Model, + signal?: AbortSignal, + options?: { stream?: boolean }, +): { signal?: AbortSignal; timeout?: number; headers?: Record } | undefined { + const timeout = resolveOpenAISdkTimeoutMs(model); + const headers = + options?.stream === true && usesNativeOpenAICodexResponsesBackend(model) + ? { Accept: "text/event-stream" } + : undefined; + if (timeout === undefined && !signal && !headers) { + return undefined; + } + return { + ...(headers ? { headers } : {}), + ...(signal ? { signal } : {}), + ...(timeout !== undefined ? { timeout } : {}), + }; +} + +function detectCompat(model: OpenAIModeModel) { + const { defaults } = detectOpenAICompletionsCompat(model); + return { + supportsStore: defaults.supportsStore, + supportsDeveloperRole: defaults.supportsDeveloperRole, + supportsReasoningEffort: defaults.supportsReasoningEffort, + reasoningEffortMap: {}, + supportsUsageInStreaming: defaults.supportsUsageInStreaming, + maxTokensField: defaults.maxTokensField, + requiresToolResultName: false, + requiresAssistantAfterToolResult: false, + requiresThinkingAsText: false, + thinkingFormat: defaults.thinkingFormat, + visibleReasoningDetailTypes: defaults.visibleReasoningDetailTypes, + openRouterRouting: {}, + vercelGatewayRouting: {}, + supportsStrictMode: defaults.supportsStrictMode, + requiresReasoningContentOnAssistantMessages: + defaults.requiresReasoningContentOnAssistantMessages, + requiresNonEmptyUserOrAssistantMessage: defaults.requiresNonEmptyUserOrAssistantMessage, + }; +} + +export function getCompat(model: OpenAIModeModel) { + const detected = detectCompat(model); + const compat = model.compat ?? {}; + const supportsStore = + typeof compat.supportsStore === "boolean" ? compat.supportsStore : detected.supportsStore; + const supportsReasoningEffort = + typeof compat.supportsReasoningEffort === "boolean" + ? compat.supportsReasoningEffort + : detected.supportsReasoningEffort; + return { + supportsStore, + supportsDeveloperRole: compat.supportsDeveloperRole ?? detected.supportsDeveloperRole, + supportsReasoningEffort, + reasoningEffortMap: resolveOpenAIReasoningEffortMap(model, detected.reasoningEffortMap), + supportsUsageInStreaming: compat.supportsUsageInStreaming ?? detected.supportsUsageInStreaming, + maxTokensField: (compat.maxTokensField as string | undefined) ?? detected.maxTokensField, + requiresToolResultName: compat.requiresToolResultName ?? detected.requiresToolResultName, + requiresAssistantAfterToolResult: + compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult, + requiresThinkingAsText: compat.requiresThinkingAsText ?? detected.requiresThinkingAsText, + thinkingFormat: compat.thinkingFormat ?? detected.thinkingFormat, + openRouterRouting: (compat.openRouterRouting as Record | undefined) ?? {}, + vercelGatewayRouting: + (compat.vercelGatewayRouting as Record | undefined) ?? + detected.vercelGatewayRouting, + supportsStrictMode: compat.supportsStrictMode ?? detected.supportsStrictMode, + supportsPromptCacheKey: compat.supportsPromptCacheKey === true, + supportsLongCacheRetention: compat.supportsLongCacheRetention !== false, + requiresStringContent: compat.requiresStringContent ?? false, + strictMessageKeys: compat.strictMessageKeys === true, + visibleReasoningDetailTypes: + compat.visibleReasoningDetailTypes ?? detected.visibleReasoningDetailTypes, + requiresReasoningContentOnAssistantMessages: + compat.requiresReasoningContentOnAssistantMessages ?? + detected.requiresReasoningContentOnAssistantMessages, + requiresNonEmptyUserOrAssistantMessage: detected.requiresNonEmptyUserOrAssistantMessage, + }; +} diff --git a/src/agents/openai-transport-shared.ts b/src/agents/openai-transport-shared.ts new file mode 100644 index 000000000000..2f1e69b0cfc0 --- /dev/null +++ b/src/agents/openai-transport-shared.ts @@ -0,0 +1,155 @@ +/** Shared options, usage shape, cache identity, ordering, and stream scheduling for OpenAI APIs. */ +import { + clampOpenAIPromptCacheKey, + type OpenAICompletionsToolChoice, + type OpenAIReasoningEffort, +} from "@openclaw/ai/internal/openai"; +import type { ModelCompatConfig } from "../config/types.models.js"; +import type { Api, Model, Usage } from "../llm/types.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; + +const MODEL_STREAM_COOPERATIVE_YIELD_INTERVAL_MS = 12; +const MODEL_STREAM_COOPERATIVE_YIELD_MAX_EVENTS = 64; + +export const GEMINI_THOUGHT_SIGNATURE_VALIDATOR_SKIP = "skip_thought_signature_validator"; +export const log = createSubsystemLogger("openai-transport"); + +export type BaseOpenAIStreamOptions = { + temperature?: number; + topP?: number; + maxTokens?: number; + stop?: string[]; + signal?: AbortSignal; + apiKey?: string; + cacheRetention?: "none" | "short" | "long"; + sessionId?: string; + promptCacheKey?: string; + authProfileId?: string; + onPayload?: (payload: unknown, model: Model) => unknown; + headers?: Record; + firstEventTimeoutMs?: number; + onFirstEventTimeout?: (reason: Error) => void; + openclawCodeModeToolSurface?: boolean; + responseFormat?: Record; + frequencyPenalty?: number; + presencePenalty?: number; + seed?: number; +}; + +export type OpenAICompletionsOptions = BaseOpenAIStreamOptions & { + toolChoice?: OpenAICompletionsToolChoice; + reasoning?: OpenAIReasoningEffort; + reasoningEffort?: OpenAIReasoningEffort; +}; + +type OpenAIModeCompatInput = Omit & { + thinkingFormat?: string; +}; + +export type OpenAIModeModel = Omit & { + compat?: OpenAIModeCompatInput | null; +}; + +export type MutableAssistantOutput = { + role: "assistant"; + content: Array>; + api: Api; + provider: string; + model: string; + usage: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + reasoningTokens?: number; + totalTokens: number; + cost: Usage["cost"]; + }; + stopReason: string; + timestamp: number; + responseId?: string; + errorMessage?: string; + errorCode?: string; + errorType?: string; + errorBody?: string; +}; + +type ModelStreamCooperativeScheduler = { + afterEvent: () => Promise; +}; + +export function throwIfModelStreamAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new Error("Request was aborted"); + } +} + +export function createModelStreamCooperativeScheduler( + signal?: AbortSignal, +): ModelStreamCooperativeScheduler { + let lastYieldedAt = Date.now(); + let eventsSinceYield = 0; + return { + async afterEvent() { + throwIfModelStreamAborted(signal); + eventsSinceYield += 1; + const now = Date.now(); + if ( + eventsSinceYield < MODEL_STREAM_COOPERATIVE_YIELD_MAX_EVENTS && + now - lastYieldedAt < MODEL_STREAM_COOPERATIVE_YIELD_INTERVAL_MS + ) { + return; + } + eventsSinceYield = 0; + lastYieldedAt = now; + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + throwIfModelStreamAborted(signal); + }, + }; +} + +export function resolveCacheRetention( + cacheRetention: string | undefined, +): "short" | "long" | "none" { + if (cacheRetention === "short" || cacheRetention === "long" || cacheRetention === "none") { + return cacheRetention; + } + if (typeof process !== "undefined" && process.env.OPENCLAW_CACHE_RETENTION === "long") { + return "long"; + } + return "short"; +} + +export function resolvePromptCacheKey( + options: Pick | undefined, + cacheRetention: "short" | "long" | "none", +): string | undefined { + if (cacheRetention === "none") { + return undefined; + } + return clampOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId); +} + +function compareTransportToolText(left: string | undefined, right: string | undefined): number { + const leftText = left ?? ""; + const rightText = right ?? ""; + if (leftText < rightText) { + return -1; + } + if (leftText > rightText) { + return 1; + } + return 0; +} + +export function sortTransportToolsByName( + tools: readonly T[], +): T[] { + return tools.toSorted( + (left, right) => + compareTransportToolText(left.name, right.name) || + compareTransportToolText(left.description, right.description), + ); +} diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 3cb03dc30d3e..ace2c862782c 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -1,4947 +1,41 @@ /** - * OpenAI-compatible streaming transport. + * Public OpenAI transport surface. * - * Handles Chat Completions, Responses, Azure variants, tool-call replay, reasoning events, and - * provider-specific payload policy before converting SDK streams into OpenClaw assistant events. + * Responses and Chat Completions own independent streaming implementations. This facade keeps the + * established imports stable while sharing only transport-neutral primitives between them. */ -import { randomUUID } from "node:crypto"; +import type { Context } from "../llm/types.js"; import { - clampOpenAIPromptCacheKey, - convertMessages, - findOpenAIStrictToolProjectionDiagnostics, - isOpenAICompatibleAzureResponsesBaseUrl, - isOpenAIGpt54MiniModel, - isOpenAIGpt55Model, - isOpenAIGpt56Model, - isResponsesTextContentPartType, - isResponsesTextDeltaEventType, - mapOpenAIStopReason, - normalizeOpenAIReasoningEffort, - normalizeOpenAIStrictToolParameters, - projectOpenAITools, - reconcileOpenAICompletionsToolChoice, - reconcileOpenAIResponsesToolChoice, - resolveAzureDeploymentNameFromMap, - resolveOpenAIProjectedToolsStrictToolFlag, - resolveOpenAIReasoningEffortForModel, - resolveResponsesMessageSnapshotCollapse, - type OpenAIApiReasoningEffort, - type OpenAICompletionsToolChoice, - type OpenAIReasoningEffort, - type OpenAIToolProjection, -} from "@openclaw/ai/internal/openai"; -import { - applyProviderReportedUsageCost, - calculateCost, - createFirstStreamEventAbortController, - createReasoningTagTextPartitioner, - getEnvApiKey, - getFirstStreamEventTimeoutHandler, - getFirstStreamEventTimeoutMs, - parseStreamingJson, - withFirstStreamEventTimeout, -} from "@openclaw/ai/internal/runtime"; -import { - describeToolResultMediaPlaceholder, - extractToolResultText, - stripSystemPromptCacheBoundary, -} from "@openclaw/ai/internal/shared"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -import OpenAI, { AzureOpenAI } from "openai"; -import type { ChatCompletionChunk } from "openai/resources/chat/completions.js"; -import type { - FunctionTool, - ResponseCreateParamsStreaming, - ResponseFormatTextConfig, - ResponseFunctionCallOutputItemList, - ResponseInput, - ResponseInputItem, - ResponseInputMessageContentList, - ResponseOutputMessage, - ResponseReasoningItem, -} from "openai/resources/responses/responses.js"; -import type { ModelCompatConfig } from "../config/types.models.js"; -import { sha256Hex, sha256HexPrefix } from "../infra/crypto-digest.js"; -import type { Api, Context, Model, Usage } from "../llm/types.js"; -import "../llm/ai-transport-host.js"; -import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"; -import { redactIdentifier } from "../logging/redact-identifier.js"; -import { redactSensitiveText } from "../logging/redact.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { - isGoogleGemini3FlashModel, - isGoogleGemini3ProModel, -} from "../plugin-sdk/provider-stream-shared.js"; -import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js"; -import { resolveProviderTransportTurnStateWithPlugin } from "../plugins/provider-runtime.js"; -import { CHARS_PER_TOKEN_ESTIMATE, estimateStringChars } from "../utils/cjk-chars.js"; -import { isCodeModeModelVisibleToolName } from "./code-mode-control-tools.js"; -import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./copilot-dynamic-headers.js"; -import { createDeepSeekTextFilter } from "./deepseek-text-filter.js"; -import { resolveMaxTokensParam } from "./model-max-tokens-params.js"; -import { supportsModelTools } from "./model-tool-support.js"; -import { - emitModelTransportDebug, - resolveModelPayloadDebugMode, - resolveModelSseDebugMode, -} from "./model-transport-debug.js"; -import { formatModelTransportDebugBaseUrl } from "./model-transport-url.js"; -import { hasOpenAICompatibleConversationTurn } from "./openai-compatible-conversation-turn.js"; -import { detectOpenAICompletionsCompat } from "./openai-completions-compat.js"; -import { - flattenCompletionMessagesToStringContent, - stripCompletionMessagesToRoleContent, -} from "./openai-completions-string-content.js"; -import { resolveOpenAIReasoningEffortMap } from "./openai-reasoning-compat.js"; -import { - applyOpenAIResponsesPayloadPolicy, - resolveOpenAIResponsesPayloadPolicy, -} from "./openai-responses-payload-policy.js"; -import { resolveReplayableResponsesMessageId } from "./openai-responses-replay.js"; -import { resolveOpenAIStrictToolSetting } from "./openai-strict-tool-setting.js"; -import { resolveProviderEndpoint } from "./provider-attribution.js"; -import { resolveProviderRequestPolicyConfig } from "./provider-request-config.js"; -import { - buildGuardedModelFetch, - resolveModelRequestTimeoutMs, -} from "./provider-transport-fetch.js"; -import { sanitizeResponsesImagePayload } from "./responses-image-payload-sanitizer.js"; -import type { StreamFn } from "./runtime/index.js"; -import { transformTransportMessages } from "./transport-message-transform.js"; -import { - assignTransportErrorDetails, - failTransportStream, - finalizeTransportStream, - mergeTransportMetadata, - sanitizeNonEmptyTransportPayloadText, - sanitizeTransportPayloadText, -} from "./transport-stream-shared.js"; - -const DEFAULT_AZURE_OPENAI_API_VERSION = "preview"; -const OPENAI_CODEX_RESPONSES_EMPTY_INPUT_TEXT = " "; -const OPENAI_CODEX_RESPONSES_DEFAULT_INSTRUCTIONS = "Follow the user request."; -const GEMINI_THOUGHT_SIGNATURE_VALIDATOR_SKIP = "skip_thought_signature_validator"; -const AZURE_RESPONSES_FIRST_EVENT_TIMEOUT_MS = 30_000; -const MODEL_STREAM_COOPERATIVE_YIELD_INTERVAL_MS = 12; -const MODEL_STREAM_COOPERATIVE_YIELD_MAX_EVENTS = 64; -const RESPONSE_FAILED_NO_DETAILS_MESSAGE = "Unknown error (no error details in response)"; -const MAX_OPENAI_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS = 256; -const OPENAI_RESPONSES_REASONING_REPLAY_META_KEY = "__openclaw_replay"; -const OPENAI_RESPONSES_REASONING_REPLAY_BLOCK_META_KEY = "openclawReasoningReplay"; -const OPENAI_RESPONSES_REPLAY_ITEM_ID_MAX_LENGTH = 64; -const OPENAI_CODEX_RESPONSES_PROVIDERS = new Set(["openai"]); -const log = createSubsystemLogger("openai-transport"); -const loggedOpenAIStrictToolDowngradeDiagnosticKeys = new Set(); - -type ReplayableResponseOutputMessage = Omit & { id?: string }; -type OpenAIResponsesReasoningReplayMetadata = { - v: 1; - source: "openai-responses"; - provider: string; - api: Api; - model: string; - baseUrlHash?: string; - sessionHash?: string; - authProfileHash?: string; -}; -type ReplayableResponseReasoningItem = Omit & { - id?: string; - [OPENAI_RESPONSES_REASONING_REPLAY_META_KEY]?: OpenAIResponsesReasoningReplayMetadata; -}; -type ResponsesClientLike = ReturnType; - -type BaseStreamOptions = { - temperature?: number; - topP?: number; - maxTokens?: number; - stop?: string[]; - signal?: AbortSignal; - apiKey?: string; - cacheRetention?: "none" | "short" | "long"; - sessionId?: string; - promptCacheKey?: string; - authProfileId?: string; - onPayload?: (payload: unknown, model: Model) => unknown; - headers?: Record; - firstEventTimeoutMs?: number; - onFirstEventTimeout?: (reason: Error) => void; - openclawCodeModeToolSurface?: boolean; - responseFormat?: Record; - frequencyPenalty?: number; - presencePenalty?: number; - seed?: number; -}; - -type ModelStreamCooperativeScheduler = { - afterEvent: () => Promise; -}; - -function throwIfModelStreamAborted(signal?: AbortSignal): void { - if (signal?.aborted) { - throw new Error("Request was aborted"); - } -} - -function createModelStreamCooperativeScheduler( - signal?: AbortSignal, -): ModelStreamCooperativeScheduler { - let lastYieldedAt = Date.now(); - let eventsSinceYield = 0; - return { - async afterEvent() { - throwIfModelStreamAborted(signal); - eventsSinceYield += 1; - const now = Date.now(); - if ( - eventsSinceYield < MODEL_STREAM_COOPERATIVE_YIELD_MAX_EVENTS && - now - lastYieldedAt < MODEL_STREAM_COOPERATIVE_YIELD_INTERVAL_MS - ) { - return; - } - eventsSinceYield = 0; - lastYieldedAt = now; - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - throwIfModelStreamAborted(signal); - }, - }; -} - -type OpenAIResponsesOptions = BaseStreamOptions & { - reasoning?: OpenAIReasoningEffort; - reasoningEffort?: OpenAIReasoningEffort; - reasoningSummary?: "auto" | "detailed" | "concise" | null; - replayResponsesItemIds?: boolean; - serviceTier?: ResponseCreateParamsStreaming["service_tier"]; - toolChoice?: ResponseCreateParamsStreaming["tool_choice"]; -}; - -type OpenAIResponsesReplayContext = { - provider: string; - api: Api; - model: string; - baseUrlHash?: string; - sessionHash?: string; - authProfileHash?: string; -}; - -type OpenAICompletionsOptions = BaseStreamOptions & { - toolChoice?: OpenAICompletionsToolChoice; - reasoning?: OpenAIReasoningEffort; - reasoningEffort?: OpenAIReasoningEffort; -}; - -type OpenAIModeCompatInput = Omit & { - thinkingFormat?: string; -}; - -type OpenAIModeModel = Omit & { - compat?: OpenAIModeCompatInput | null; -}; - -type MutableAssistantOutput = { - role: "assistant"; - content: Array>; - api: Api; - provider: string; - model: string; - usage: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - reasoningTokens?: number; - totalTokens: number; - cost: Usage["cost"]; - }; - stopReason: string; - timestamp: number; - responseId?: string; - errorMessage?: string; - errorCode?: string; - errorType?: string; - errorBody?: string; -}; + buildOpenAICompletionsParams as buildOpenAICompletionsParamsImpl, + completionsTesting, +} from "./openai-completions-transport.js"; +import { responsesTesting } from "./openai-responses-transport.js"; +import type { OpenAICompletionsOptions, OpenAIModeModel } from "./openai-transport-shared.js"; +export { + createOpenAICompletionsTransportStreamFn, + parseTransportChunkUsage, +} from "./openai-completions-transport.js"; +export { + buildOpenAIResponsesParams, + createAzureOpenAIResponsesTransportStreamFn, + createOpenAIResponsesTransportStreamFn, + resolveAzureOpenAIApiVersion, +} from "./openai-responses-transport.js"; export { sanitizeTransportPayloadText } from "./transport-stream-shared.js"; -function stringifyUnknown(value: unknown, fallback = ""): string { - if (typeof value === "string") { - return value; - } - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - return fallback; -} - -function stringifyJsonLike(value: unknown, fallback = ""): string { - if (typeof value === "string") { - return value; - } - if (value && typeof value === "object") { - return JSON.stringify(value); - } - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - return fallback; -} - -function getServiceTierCostMultiplier(serviceTier: ResponseCreateParamsStreaming["service_tier"]) { - switch (serviceTier) { - case "flex": - return 0.5; - case "priority": - return 2; - default: - return 1; - } -} - -function applyServiceTierPricing( - usage: MutableAssistantOutput["usage"], - serviceTier?: ResponseCreateParamsStreaming["service_tier"], -): void { - const multiplier = getServiceTierCostMultiplier(serviceTier); - if (multiplier === 1) { - return; - } - usage.cost.input *= multiplier; - usage.cost.output *= multiplier; - usage.cost.cacheRead *= multiplier; - usage.cost.cacheWrite *= multiplier; - usage.cost.total = - usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; -} - -function safeDebugValue(value: unknown): string { - if (typeof value === "string") { - return value; - } - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - if (value === null) { - return "null"; - } - if (value === undefined) { - return "undefined"; - } - return Array.isArray(value) ? "array" : typeof value; -} - -function responseInputTextChars(input: unknown): number { - if (typeof input === "string") { - return input.length; - } - if (Array.isArray(input)) { - return input.reduce((total, item) => total + responseInputTextChars(item), 0); - } - if (!input || typeof input !== "object") { - return 0; - } - const record = input as Record; - let total = 0; - if (typeof record.text === "string") { - total += record.text.length; - } - if (typeof record.content === "string") { - total += record.content.length; - } else if (Array.isArray(record.content)) { - total += responseInputTextChars(record.content); - } - return total; -} - -function responseInputRoles(input: unknown): string { - if (!Array.isArray(input)) { - return ""; - } - const roles = new Set(); - for (const item of input) { - if (item && typeof item === "object") { - const role = (item as Record).role; - if (typeof role === "string" && role.trim()) { - roles.add(role.trim()); - } - } - } - return [...roles].toSorted().join(","); -} - -function readToolPayloadField(record: Record, field: string): unknown { - try { - return record[field]; - } catch { - return undefined; - } -} - -function readResponsesToolDisplayName(tool: unknown): string { - if (!tool || typeof tool !== "object") { - return ""; - } - const record = tool as Record; - const name = readToolPayloadField(record, "name"); - if (typeof name === "string") { - return name; - } - const fn = readToolPayloadField(record, "function"); - if (fn && typeof fn === "object") { - const fnName = readToolPayloadField(fn as Record, "name"); - if (typeof fnName === "string") { - return fnName; - } - } - const type = readToolPayloadField(record, "type"); - return typeof type === "string" && type !== "function" ? type : ""; -} - -function summarizeResponsesTools(tools: unknown): string { - if (!Array.isArray(tools)) { - return "count=0"; - } - const names = tools.map(readResponsesToolDisplayName).filter(Boolean); - const mode = resolveModelPayloadDebugMode(); - const maxNames = mode === "tools" || mode === "full-redacted" ? names.length : 12; - const label = maxNames >= names.length ? "names" : "sample"; - const shown = names.slice(0, maxNames).join(","); - return `count=${tools.length}${shown ? ` ${label}=${shown}` : ""}`; -} - -function responsesPayloadToolName(tool: unknown): string | undefined { - if (!isRecord(tool)) { - return undefined; - } - const name = readToolPayloadField(tool, "name"); - if (typeof name === "string") { - return name; - } - const fn = readToolPayloadField(tool, "function"); - if (!isRecord(fn)) { - return undefined; - } - const fnName = readToolPayloadField(fn, "name"); - return typeof fnName === "string" ? fnName : undefined; -} - -function enforceCodeModeResponsesToolSurface(payload: unknown): void { - if (!isRecord(payload) || !Array.isArray(payload.tools)) { - return; - } - payload.tools = payload.tools.filter((tool) => { - const name = responsesPayloadToolName(tool); - return typeof name === "string" && isCodeModeModelVisibleToolName(name); - }); -} - -function assertCodeModeResponsesToolSurface(payload: unknown): void { - if (!isRecord(payload) || !Array.isArray(payload.tools)) { - throw new Error("Code mode payload tool surface violation: expected exec,wait; got no tools"); - } - const names = payload.tools - .map(responsesPayloadToolName) - .filter((name): name is string => typeof name === "string" && name.length > 0) - .toSorted((a, b) => a.localeCompare(b)); - if ( - names.length >= 2 && - new Set(names).size === names.length && - names.filter((name) => name === "exec").length === 1 && - names.filter((name) => name === "wait").length === 1 && - names.every(isCodeModeModelVisibleToolName) - ) { - return; - } - throw new Error( - `Code mode payload tool surface violation: expected exec,wait plus direct-only tools; got ${ - names.length > 0 ? names.join(",") : "none" - }`, - ); -} - -function stringifyRedactedPayload(value: unknown): string { - try { - const encoded = JSON.stringify(value); - if (!encoded) { - return ""; - } - const redacted = redactSensitiveText(encoded, { mode: "tools" }); - return redacted.length > 8000 ? `${truncateUtf16Safe(redacted, 8000)}…` : redacted; - } catch { - return ""; - } -} - -function stringifyRedactedEvent(value: unknown): string { - const redacted = stringifyRedactedPayload(value); - return redacted.length > 2000 ? `${truncateUtf16Safe(redacted, 2000)}…` : redacted; -} - -type ResponsesFailedNoDetailsObservation = { - event: "openai_responses_response_failed_without_details"; - provider: string; - api: Api; - transportModel: string; - providerRuntimeFailureKind: "no_error_details"; - responseId: string; - responseStatus: string; - responseModel: string; - responseObject: string; - metadataKeys: string[]; - requestIdHashes: string[]; - failureFieldsPreview: string; - responsePreview: string; -}; - -type ResponsesFailedEventSummary = { - message: string; - responseId?: string; - observation?: ResponsesFailedNoDetailsObservation; -}; - -const RESPONSE_FAILED_FAILURE_FIELD_KEYS = [ - "error", - "incomplete_details", - "status_details", - "failure_reason", - "last_error", - "provider_error", - "error_details", -] as const; - -function readResponseFailedString( - record: Record | undefined, - key: string, -): string { - return stringifyUnknown(record?.[key]); -} - -function buildResponsesFailedEventSummary( - message: string, - responseId: string | undefined, - observation?: ResponsesFailedNoDetailsObservation, -): ResponsesFailedEventSummary { - const summary: ResponsesFailedEventSummary = { message }; - if (responseId) { - summary.responseId = responseId; - } - if (observation) { - summary.observation = observation; - } - return summary; -} - -function isResponseFailedIdentifierKey(key: string): boolean { - const normalized = key.replace(/[-_\s]/g, "").toLowerCase(); - return ( - normalized === "requestid" || - normalized === "xrequestid" || - normalized === "providerrequestid" || - normalized === "providerresponseid" || - normalized === "litellmrequestid" || - (normalized.includes("request") && normalized.endsWith("id")) || - (normalized.includes("provider") && normalized.endsWith("id")) - ); -} - -function collectResponseFailedIdentifierHashes( - value: unknown, - opts: { - path?: string; - depth?: number; - identifierKey?: string; - out?: string[]; - seen?: WeakSet; - } = {}, -): string[] { - const path = opts.path ?? ""; - const depth = opts.depth ?? 0; - const identifierKey = opts.identifierKey ?? ""; - const out = opts.out ?? []; - const seen = opts.seen ?? new WeakSet(); - if (out.length >= 12 || depth > 4 || !value || typeof value !== "object") { - return out; - } - if (seen.has(value)) { - return out; - } - seen.add(value); - if (Array.isArray(value)) { - for (const [index, item] of value.entries()) { - if (index >= 8 || out.length >= 12) { - break; - } - const itemString = - typeof item === "string" || typeof item === "number" ? String(item).trim() : ""; - if (identifierKey && isResponseFailedIdentifierKey(identifierKey) && itemString) { - out.push(`${path}[${index}]=${redactIdentifier(itemString, { len: 12 })}`); - continue; - } - collectResponseFailedIdentifierHashes(item, { - path: `${path}[${index}]`, - depth: depth + 1, - identifierKey, - out, - seen, - }); - } - return out; - } - for (const [key, child] of Object.entries(value as Record)) { - if (out.length >= 12) { - break; - } - const childPath = path ? `${path}.${key}` : key; - const childString = - typeof child === "string" || typeof child === "number" ? String(child).trim() : ""; - if (isResponseFailedIdentifierKey(key) && childString) { - out.push(`${childPath}=${redactIdentifier(childString, { len: 12 })}`); - continue; - } - collectResponseFailedIdentifierHashes(child, { - path: childPath, - depth: depth + 1, - identifierKey: isResponseFailedIdentifierKey(key) ? key : undefined, - out, - seen, - }); - } - return out; -} - -function redactResponseFailedDiagnosticValue( - value: unknown, - opts: { - key?: string; - depth?: number; - seen?: WeakSet; - } = {}, -): unknown { - const key = opts.key ?? ""; - const depth = opts.depth ?? 0; - if (typeof value === "string" || typeof value === "number") { - return key && isResponseFailedIdentifierKey(key) - ? redactIdentifier(String(value), { len: 12 }) - : value; - } - if (depth > 6 || !value || typeof value !== "object") { - return value; - } - const seen = opts.seen ?? new WeakSet(); - if (seen.has(value)) { - return ""; - } - seen.add(value); - if (Array.isArray(value)) { - return value.slice(0, 16).map((item) => - redactResponseFailedDiagnosticValue(item, { - key, - depth: depth + 1, - seen, - }), - ); - } - const out: Record = {}; - for (const [childKey, child] of Object.entries(value as Record)) { - out[childKey] = redactResponseFailedDiagnosticValue(child, { - key: childKey, - depth: depth + 1, - seen, - }); - } - return out; -} - -function buildResponsesFailedFailureFields( - response: Record | undefined, -): Record { - if (!response) { - return {}; - } - const fields: Record = {}; - for (const key of RESPONSE_FAILED_FAILURE_FIELD_KEYS) { - if (response[key] !== undefined && response[key] !== null) { - fields[key] = response[key]; - } - } - return fields; -} - -function buildResponsesFailedNoDetailsObservation( - event: Record, - model: Model, - response: Record | undefined = isRecord(event.response) - ? event.response - : undefined, -): ResponsesFailedNoDetailsObservation { - const failureFields = redactResponseFailedDiagnosticValue( - buildResponsesFailedFailureFields(response), - ) as Record; - const metadataKeys = isRecord(response?.metadata) - ? Object.keys(response.metadata).toSorted() - : []; - const responsePreview = { - id: readResponseFailedString(response, "id"), - status: readResponseFailedString(response, "status"), - model: readResponseFailedString(response, "model"), - object: readResponseFailedString(response, "object"), - failureFields, - metadataKeys, - }; - return { - event: "openai_responses_response_failed_without_details", - provider: model.provider, - api: model.api, - transportModel: model.id, - providerRuntimeFailureKind: "no_error_details", - responseId: responsePreview.id, - responseStatus: responsePreview.status, - responseModel: responsePreview.model, - responseObject: responsePreview.object, - metadataKeys, - requestIdHashes: collectResponseFailedIdentifierHashes(event), - failureFieldsPreview: stringifyRedactedEvent(failureFields), - responsePreview: stringifyRedactedEvent(responsePreview), - }; -} - -function summarizeResponsesFailedNoDetailsObservation( - observation: ResponsesFailedNoDetailsObservation, -): string { - const requestIds = observation.requestIdHashes.join(","); - const metadataKeys = observation.metadataKeys.join(","); - return ( - `responseId=${safeDebugValue(observation.responseId || undefined)} ` + - `responseStatus=${safeDebugValue(observation.responseStatus || undefined)} ` + - `responseModel=${safeDebugValue(observation.responseModel || undefined)} ` + - `requestIds=${requestIds || "none"} metadataKeys=${metadataKeys || "none"} ` + - `failureFields=${observation.failureFieldsPreview}` - ); -} - -function normalizeResponsesFailedEvent( - event: Record, - model: Model, -): ResponsesFailedEventSummary { - const response = isRecord(event.response) ? event.response : undefined; - const responseId = readResponseFailedString(response, "id") || undefined; - const error = isRecord(response?.error) ? response.error : undefined; - if (error) { - const code = readResponseFailedString(error, "code").trim(); - const message = readResponseFailedString(error, "message").trim(); - if (code || message) { - return buildResponsesFailedEventSummary( - `${code || "unknown"}: ${message || "no message"}`, - responseId, - ); - } - } - const incompleteDetails = isRecord(response?.incomplete_details) - ? response.incomplete_details - : undefined; - const incompleteReason = readResponseFailedString(incompleteDetails, "reason"); - if (incompleteReason) { - return buildResponsesFailedEventSummary(`incomplete: ${incompleteReason}`, responseId); - } - return buildResponsesFailedEventSummary( - RESPONSE_FAILED_NO_DETAILS_MESSAGE, - responseId, - buildResponsesFailedNoDetailsObservation(event, model, response), - ); -} - -function logResponsesFailedNoDetails(observation: ResponsesFailedNoDetailsObservation): void { - log.warn( - `[responses] response.failed missing error details provider=${observation.provider} ` + - `api=${observation.api} model=${observation.transportModel} ` + - summarizeResponsesFailedNoDetailsObservation(observation), - observation, - ); -} - -function summarizeResponsesPayload(params: unknown): string { - if (!params || typeof params !== "object") { - return "payload=non-object"; - } - const record = params as Record; - const input = record.input; - const reasoning = - record.reasoning && typeof record.reasoning === "object" - ? (record.reasoning as Record) - : undefined; - const text = - record.text && typeof record.text === "object" - ? (record.text as Record) - : undefined; - const parts = [ - `fields=${Object.keys(record).toSorted().join(",")}`, - `model=${safeDebugValue(record.model)}`, - `stream=${safeDebugValue(record.stream)}`, - `inputItems=${Array.isArray(input) ? input.length : typeof input}`, - `inputRoles=${responseInputRoles(input) || "none"}`, - `inputTextChars=${responseInputTextChars(input)}`, - `tools=${summarizeResponsesTools(record.tools)}`, - `reasoningEffort=${safeDebugValue(reasoning?.effort)}`, - `reasoningSummary=${safeDebugValue(reasoning?.summary)}`, - `textVerbosity=${safeDebugValue(text?.verbosity)}`, - `serviceTier=${safeDebugValue(record.service_tier)}`, - `store=${safeDebugValue(record.store)}`, - `promptCacheKey=${record.prompt_cache_key === undefined ? "absent" : "present"}`, - `metadataKeys=${ - record.metadata && typeof record.metadata === "object" - ? Object.keys(record.metadata).toSorted().join(",") - : "none" - }`, - ]; - if (resolveModelPayloadDebugMode() === "full-redacted") { - parts.push(`payload=${stringifyRedactedPayload(record)}`); - } - return parts.join(" "); -} - -function summarizeOpenAITransportError(error: unknown): string { - if (!error || typeof error !== "object") { - return `type=${typeof error} message=${safeDebugValue(error)}`; - } - const record = error as Record; - const cause = - record.cause && typeof record.cause === "object" - ? (record.cause as Record) - : undefined; - return [ - `name=${safeDebugValue(record.name)}`, - `status=${safeDebugValue(record.status)}`, - `code=${safeDebugValue(record.code)}`, - `type=${safeDebugValue(record.type)}`, - `causeName=${safeDebugValue(cause?.name)}`, - `causeCode=${safeDebugValue(cause?.code)}`, - `message=${error instanceof Error ? error.message : safeDebugValue(error)}`, - ].join(" "); -} - -function isInvalidEncryptedContentError(error: unknown): boolean { - if (!error || typeof error !== "object") { - return false; - } - const record = error as { code?: unknown; message?: unknown; status?: unknown }; - if (record.code === "invalid_encrypted_content" || record.code === "thinking_signature_invalid") { - return true; - } - const message = typeof record.message === "string" ? record.message : ""; - return ( - message.includes("invalid_encrypted_content") || - message.includes("thinking_signature_invalid") || - // xAI reports this exact prose contract without an error code. - (record.status === 400 && - message.toLowerCase().includes("could not decrypt the provided encrypted_content")) - ); -} - -function stripEncryptedContentFields(value: unknown): { value: unknown; changed: boolean } { - if (!value || typeof value !== "object") { - return { value, changed: false }; - } - if (Array.isArray(value)) { - let changed = false; - const next = value.map((item) => { - const stripped = stripEncryptedContentFields(item); - changed ||= stripped.changed; - return stripped.value; - }); - return changed ? { value: next, changed: true } : { value, changed: false }; - } - - let changed = false; - const next: Record = {}; - for (const [key, child] of Object.entries(value as Record)) { - if (key === "encrypted_content") { - changed = true; - continue; - } - const stripped = stripEncryptedContentFields(child); - changed ||= stripped.changed; - next[key] = stripped.value; - } - return changed ? { value: next, changed: true } : { value, changed: false }; -} - -function stripResponsesRequestEncryptedContent( - params: OpenAIResponsesRequestParams, -): OpenAIResponsesRequestParams { - const stripped = stripEncryptedContentFields(params.input); - if (!stripped.changed) { - return params; - } - return { - ...params, - input: stripped.value as ResponseInput, - }; -} - -function hashOptionalReplayContextValue(value: string | undefined): string | undefined { - const normalized = value?.trim(); - return normalized ? shortHash(normalized) : undefined; -} - -function buildOpenAIResponsesReplayContext( - model: Model, - options?: Pick, -): OpenAIResponsesReplayContext { - return { - provider: model.provider, - api: model.api, - model: model.id, - baseUrlHash: hashOptionalReplayContextValue(model.baseUrl), - sessionHash: hashOptionalReplayContextValue(options?.sessionId), - authProfileHash: hashOptionalReplayContextValue(options?.authProfileId), - }; -} - -function buildOpenAIResponsesReasoningReplayMetadata( - model: Model, - options?: Pick, -): OpenAIResponsesReasoningReplayMetadata { - return { - v: 1, - source: "openai-responses", - ...buildOpenAIResponsesReplayContext(model, options), - }; -} - -function tagOpenAIResponsesReasoningReplayItem( - item: Record, - model: Model, - options?: Pick, -): Record { - if (!("encrypted_content" in item)) { - return item; - } - return { - ...item, - [OPENAI_RESPONSES_REASONING_REPLAY_META_KEY]: buildOpenAIResponsesReasoningReplayMetadata( - model, - options, - ), - }; -} - -function isOpenAIResponsesReasoningReplayMetadata( - value: unknown, -): value is OpenAIResponsesReasoningReplayMetadata { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record; - return ( - record.v === 1 && - record.source === "openai-responses" && - typeof record.provider === "string" && - typeof record.api === "string" && - typeof record.model === "string" && - (record.baseUrlHash === undefined || typeof record.baseUrlHash === "string") && - (record.sessionHash === undefined || typeof record.sessionHash === "string") && - (record.authProfileHash === undefined || typeof record.authProfileHash === "string") - ); -} - -function encryptedReasoningReplayMetadataMatches( - metadata: OpenAIResponsesReasoningReplayMetadata | undefined, - context: OpenAIResponsesReplayContext, -): boolean { - if (!metadata) { - return false; - } - return ( - metadata.provider === context.provider && - metadata.api === context.api && - metadata.model === context.model && - metadata.baseUrlHash === context.baseUrlHash && - metadata.sessionHash === context.sessionHash && - metadata.authProfileHash === context.authProfileHash - ); -} - -function readOpenAIResponsesReasoningReplayBlockMetadata( - block: Record, -): OpenAIResponsesReasoningReplayMetadata | undefined { - const value = block[OPENAI_RESPONSES_REASONING_REPLAY_BLOCK_META_KEY]; - return isOpenAIResponsesReasoningReplayMetadata(value) ? value : undefined; -} - -function normalizeOpenAIResponsesReasoningReplayItem( - item: ReplayableResponseReasoningItem, -): ReplayableResponseReasoningItem { - const record = item as ReplayableResponseReasoningItem & Record; - if (record.type !== "reasoning" || Array.isArray(record.summary)) { - return item; - } - return { ...record, summary: [] } as ReplayableResponseReasoningItem; -} - -function prepareOpenAIResponsesReasoningItemForReplay( - item: ReplayableResponseReasoningItem, - context: OpenAIResponsesReplayContext, - blockMetadata?: OpenAIResponsesReasoningReplayMetadata, -): ReplayableResponseReasoningItem { - const { [OPENAI_RESPONSES_REASONING_REPLAY_META_KEY]: rawMetadata, ...rest } = - item as ReplayableResponseReasoningItem & Record; - if (!("encrypted_content" in rest)) { - return normalizeOpenAIResponsesReasoningReplayItem(rest as ReplayableResponseReasoningItem); - } - const metadata = - blockMetadata ?? - (isOpenAIResponsesReasoningReplayMetadata(rawMetadata) ? rawMetadata : undefined); - if (encryptedReasoningReplayMetadataMatches(metadata, context)) { - return normalizeOpenAIResponsesReasoningReplayItem(rest as ReplayableResponseReasoningItem); - } - const stripped = stripEncryptedContentFields(rest); - return normalizeOpenAIResponsesReasoningReplayItem( - stripped.value as ReplayableResponseReasoningItem, - ); -} - -async function createResponsesStreamWithEncryptedContentRetry(params: { - client: ResponsesClientLike; - request: OpenAIResponsesRequestParams; - requestOptions: unknown; - model: Model; -}): Promise> { - try { - return (await params.client.responses.create( - params.request as never, - params.requestOptions as never, - )) as unknown as AsyncIterable; - } catch (error) { - const retryRequest = stripResponsesRequestEncryptedContent(params.request); - if (!isInvalidEncryptedContentError(error) || retryRequest === params.request) { - throw error; - } - log.warn( - `[responses] retrying without encrypted reasoning content provider=${params.model.provider} ` + - `api=${params.model.api} model=${params.model.id}`, - ); - return (await params.client.responses.create( - retryRequest as never, - params.requestOptions as never, - )) as unknown as AsyncIterable; - } -} - -export function resolveAzureOpenAIApiVersion(env = process.env): string { - return env.AZURE_OPENAI_API_VERSION?.trim() || DEFAULT_AZURE_OPENAI_API_VERSION; -} - -function shortHash(value: string): string { - return sha256HexPrefix(value, 16); -} - -function normalizeResponsesReplayItemId( - id: string | undefined, - prefix: string, -): string | undefined { - if (!id) { - return undefined; - } - if (id.length <= OPENAI_RESPONSES_REPLAY_ITEM_ID_MAX_LENGTH) { - return id; - } - return `${prefix}_${shortHash(id)}`; -} - -function isSafeResponsesReplayItemId(id: unknown): id is string { - return ( - typeof id === "string" && - id.length > 0 && - id.length <= OPENAI_RESPONSES_REPLAY_ITEM_ID_MAX_LENGTH - ); -} - -function encodeTextSignatureV1(id: string, phase?: "commentary" | "final_answer"): string { - return JSON.stringify({ v: 1, id, ...(phase ? { phase } : {}) }); -} - -function parseTextSignature( - signature: string | undefined, -): { id?: string; phase?: "commentary" | "final_answer" } | undefined { - if (!signature) { - return undefined; - } - if (signature.startsWith("{")) { - try { - const parsed = JSON.parse(signature) as { v?: unknown; id?: unknown; phase?: unknown }; - if (parsed.v === 1) { - const id = typeof parsed.id === "string" ? parsed.id : undefined; - const phase = - parsed.phase === "commentary" || parsed.phase === "final_answer" - ? parsed.phase - : undefined; - // A reasoning-dropped replay keeps the phase but omits the paired id. - if (id !== undefined || phase !== undefined) { - return { id, phase }; - } - return undefined; - } - } catch { - // Keep legacy plain-string behavior below. - } - } - return { id: signature }; -} - -function buildResponsesInputMessage( - role: "user" | "system" | "developer", - content: ResponseInputMessageContentList, -): ResponseInputItem.Message { - return { type: "message", role, content }; -} - -function convertResponsesMessages( - model: Model, - context: Context, - allowedToolCallProviders: Set, - options?: { - includeSystemPrompt?: boolean; - supportsDeveloperRole?: boolean; - replayReasoningItems?: boolean; - replayResponsesItemIds?: boolean; - sessionId?: string; - authProfileId?: string; - }, -): ResponseInput { - const messages: ResponseInput = []; - const shouldReplayReasoningItems = options?.replayReasoningItems ?? true; - const shouldReplayResponsesItemIds = options?.replayResponsesItemIds ?? true; - const replayContext = buildOpenAIResponsesReplayContext(model, { - sessionId: options?.sessionId, - authProfileId: options?.authProfileId, - }); - const shouldNormalizeSameModelToolCallIds = model.provider === "github-copilot"; - const sanitizeIdPart = (part: string) => part.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+$/, ""); - const normalizeIdPart = (part: string) => { - const sanitized = sanitizeIdPart(part); - const normalized = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized; - return normalized.replace(/_+$/, ""); - }; - const buildForeignResponsesItemId = (itemId: string) => { - const normalized = `fc_${shortHash(itemId)}`; - return normalized.length > 64 ? normalized.slice(0, 64) : normalized; - }; - const buildSameProviderCopilotResponsesItemId = (itemId: string) => { - const sanitized = sanitizeIdPart(itemId); - const candidate = sanitized.startsWith("fc_") ? sanitized : `fc_${sanitized}`; - return candidate.length > 64 ? buildForeignResponsesItemId(itemId) : candidate; - }; - const normalizeToolCallId = ( - id: string, - _targetModel: Model, - source: { provider: string; api: Api }, - ) => { - if (!allowedToolCallProviders.has(model.provider)) { - return normalizeIdPart(id); - } - if (!id.includes("|")) { - return normalizeIdPart(id); - } - const separatorIndex = id.indexOf("|"); - const callId = id.slice(0, separatorIndex); - const itemId = id.slice(separatorIndex + 1); - const normalizedCallId = normalizeIdPart(callId); - const isForeignToolCall = source.provider !== model.provider || source.api !== model.api; - let normalizedItemId = isForeignToolCall - ? buildForeignResponsesItemId(itemId) - : model.provider === "github-copilot" - ? buildSameProviderCopilotResponsesItemId(itemId) - : normalizeIdPart(itemId); - if (!normalizedItemId.startsWith("fc_")) { - normalizedItemId = normalizeIdPart(`fc_${normalizedItemId}`); - } - return `${normalizedCallId}|${normalizedItemId}`; - }; - const transformedMessages = transformTransportMessages( - context.messages, - model, - normalizeToolCallId, - { normalizeSameModelToolCallIds: shouldNormalizeSameModelToolCallIds }, - ); - const includeSystemPrompt = options?.includeSystemPrompt ?? true; - if (includeSystemPrompt && context.systemPrompt) { - messages.push( - buildResponsesInputMessage( - model.reasoning && options?.supportsDeveloperRole !== false ? "developer" : "system", - [ - { - type: "input_text", - text: sanitizeTransportPayloadText( - stripSystemPromptCacheBoundary(context.systemPrompt), - ), - }, - ], - ), - ); - } - let msgIndex = 0; - for (const msg of transformedMessages) { - if (msg.role === "user") { - if (typeof msg.content === "string") { - messages.push( - buildResponsesInputMessage("user", [ - { type: "input_text", text: sanitizeTransportPayloadText(msg.content) }, - ]), - ); - } else { - const content = ( - msg.content.map((item) => - item.type === "text" - ? { type: "input_text", text: sanitizeTransportPayloadText(item.text) } - : { - type: "input_image", - detail: "auto", - image_url: `data:${item.mimeType};base64,${item.data}`, - }, - ) as ResponseInputMessageContentList - ).filter((item) => model.input.includes("image") || item.type !== "input_image"); - if (content.length > 0) { - messages.push(buildResponsesInputMessage("user", content)); - } - } - } else if (msg.role === "assistant") { - const output: ResponseInput = []; - let textFallbackOrdinal = 0; - let previousReplayItemWasReasoning = false; - const isDifferentModel = - msg.model !== model.id && msg.provider === model.provider && msg.api === model.api; - for (const block of msg.content) { - if (block.type === "thinking") { - if ( - shouldReplayReasoningItems && - block.thinkingSignature && - block.thinkingSignature.startsWith("{") - ) { - // openai-completions plain-text reasoning paths persist a - // provenance tag (e.g. "reasoning", "reasoning_details", "content") - // as thinkingSignature rather than a JSON-encoded reasoning item. - // Replaying those values would corrupt the next request payload - // (OpenRouter returns HTTP 500), so skip non-JSON signatures. - const reasoningItem = JSON.parse( - block.thinkingSignature, - ) as ReplayableResponseReasoningItem; - const replayableReasoningItem = prepareOpenAIResponsesReasoningItemForReplay( - reasoningItem, - replayContext, - readOpenAIResponsesReasoningReplayBlockMetadata( - block as unknown as Record, - ), - ); - if (!shouldReplayResponsesItemIds) { - delete replayableReasoningItem.id; - } - if ( - shouldReplayResponsesItemIds && - model.provider === "github-copilot" && - !isSafeResponsesReplayItemId(replayableReasoningItem.id) - ) { - continue; - } - output.push(replayableReasoningItem as ResponseInputItem); - previousReplayItemWasReasoning = true; - } - } else if (block.type === "text") { - const textSignature = parseTextSignature(block.textSignature); - let msgId = resolveReplayableResponsesMessageId({ - replayResponsesItemIds: shouldReplayResponsesItemIds, - textSignatureId: textSignature?.id, - fallbackId: `msg_${msgIndex}`, - fallbackOrdinal: textFallbackOrdinal, - previousReplayItemWasReasoning, - }); - if (!textSignature?.id) { - textFallbackOrdinal += 1; - } - msgId = normalizeResponsesReplayItemId(msgId, "msg"); - const messageItem: ReplayableResponseOutputMessage = { - type: "message", - role: "assistant", - content: [ - { - type: "output_text", - text: sanitizeTransportPayloadText(block.text), - annotations: [], - }, - ], - status: "completed", - ...(msgId ? { id: msgId } : {}), - phase: textSignature?.phase, - }; - output.push(messageItem as ResponseInputItem); - previousReplayItemWasReasoning = false; - } else if (block.type === "toolCall") { - const separatorIndex = block.id.indexOf("|"); - const callId = separatorIndex === -1 ? block.id : block.id.slice(0, separatorIndex); - const itemIdRaw = separatorIndex === -1 ? undefined : block.id.slice(separatorIndex + 1); - const itemId = - shouldReplayResponsesItemIds && !(isDifferentModel && itemIdRaw?.startsWith("fc_")) - ? itemIdRaw - : undefined; - output.push({ - type: "function_call", - ...(itemId ? { id: itemId } : {}), - call_id: callId, - name: block.name, - arguments: - typeof block.arguments === "string" - ? block.arguments - : JSON.stringify(block.arguments ?? {}), - }); - previousReplayItemWasReasoning = false; - } - } - if (output.length > 0) { - messages.push(...output); - } - } else if (msg.role === "toolResult") { - const textResult = extractToolResultText(msg.content); - const sanitizedTextResult = sanitizeTransportPayloadText(textResult); - const hasText = sanitizedTextResult.trim().length > 0; - const mediaPlaceholder = describeToolResultMediaPlaceholder(msg.content); - const hasImages = msg.content.some((item) => item.type === "image"); - const separatorIndex = msg.toolCallId.indexOf("|"); - const callId = - separatorIndex === -1 ? msg.toolCallId : msg.toolCallId.slice(0, separatorIndex); - messages.push({ - type: "function_call_output", - call_id: callId, - output: - hasImages && model.input.includes("image") - ? ([ - ...(hasText - ? [{ type: "input_text", text: sanitizedTextResult }] - : mediaPlaceholder === "(see attached media)" - ? [{ type: "input_text", text: mediaPlaceholder }] - : []), - ...msg.content - .filter((item) => item.type === "image") - .map((item) => ({ - type: "input_image", - detail: "auto", - image_url: `data:${item.mimeType};base64,${item.data}`, - })), - ] as ResponseFunctionCallOutputItemList) - : sanitizeNonEmptyTransportPayloadText(textResult, mediaPlaceholder ?? "(no output)"), - }); - } - msgIndex += 1; - } - return messages; -} - -function convertResponsesTools( - tools: NonNullable, - model: OpenAIModeModel, - options?: { strict?: boolean | null }, -): { projection: OpenAIToolProjection; tools: FunctionTool[] } { - const projection = projectOpenAITools(tools); - const strict = resolveOpenAIStrictToolFlagWithDiagnostics(projection, options?.strict, { - transport: "responses", - model, - }); - return { - projection, - tools: sortTransportToolsByName(projection.tools).map((tool): FunctionTool => { - const result = { - type: "function" as const, - name: tool.name, - description: tool.description, - parameters: normalizeOpenAIStrictToolParameters( - tool.parameters, - strict === true, - model.compat, - ), - } as FunctionTool; - if (strict !== undefined) { - result.strict = strict; - } - return result; - }), - }; -} - -function resolveOpenAIStrictToolFlagWithDiagnostics( - projection: OpenAIToolProjection, - strictSetting: boolean | null | undefined, - context: { transport: "responses" | "completions"; model: OpenAIModeModel }, -): boolean | undefined { - const strict = resolveOpenAIProjectedToolsStrictToolFlag(projection, strictSetting); - if (strictSetting === true && strict === false && log.isEnabled("debug", "any")) { - const diagnostics = findOpenAIStrictToolProjectionDiagnostics(projection); - if (!shouldLogOpenAIStrictToolDowngradeDiagnostic(diagnostics, context)) { - return strict; - } - const sample = diagnostics.slice(0, 5).map((entry) => ({ - tool: entry.toolName ?? `tool[${entry.toolIndex}]`, - violations: entry.violations.slice(0, 8), - })); - log.debug( - `OpenAI ${context.transport} tool schema strict mode downgraded to strict=false for ` + - `${context.model.provider ?? "unknown"}/${context.model.id ?? "unknown"} ` + - `because ${diagnostics.length} tool schema(s) are not strict-compatible`, - { - transport: context.transport, - provider: context.model.provider, - model: context.model.id, - incompatibleToolCount: diagnostics.length, - sample, - }, - ); - } - return strict; -} - -function buildOpenAIStrictToolDowngradeDiagnosticKey( - diagnostics: ReturnType, - context: { transport: "responses" | "completions"; model: OpenAIModeModel }, -): string { - return sha256Hex( - JSON.stringify({ - transport: context.transport, - provider: context.model.provider ?? null, - model: context.model.id ?? null, - diagnostics: diagnostics.map((entry) => ({ - toolIndex: entry.toolIndex, - toolName: entry.toolName ?? null, - violations: entry.violations, - })), - }), - ); -} - -function shouldLogOpenAIStrictToolDowngradeDiagnostic( - diagnostics: ReturnType, - context: { transport: "responses" | "completions"; model: OpenAIModeModel }, -): boolean { - const key = buildOpenAIStrictToolDowngradeDiagnosticKey(diagnostics, context); - if (loggedOpenAIStrictToolDowngradeDiagnosticKeys.has(key)) { - return false; - } - if ( - loggedOpenAIStrictToolDowngradeDiagnosticKeys.size >= - MAX_OPENAI_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS - ) { - loggedOpenAIStrictToolDowngradeDiagnosticKeys.clear(); - } - loggedOpenAIStrictToolDowngradeDiagnosticKeys.add(key); - return true; -} - -async function processResponsesStream( - openaiStream: AsyncIterable, - output: MutableAssistantOutput, - stream: { push(event: unknown): void }, - model: Model, - options?: { - serviceTier?: ResponseCreateParamsStreaming["service_tier"]; - applyServiceTierPricing?: ( - usage: MutableAssistantOutput["usage"], - serviceTier?: ResponseCreateParamsStreaming["service_tier"], - ) => void; - firstEventTimeoutMs?: number; - abortFirstEventStream?: (reason: Error) => void; - onFirstEventTimeout?: (reason: Error) => void; - signal?: AbortSignal; - sessionId?: string; - authProfileId?: string; - }, -) { - const resolveToolCallId = (item: Record, fallbackId?: string): string => { - const callId = stringifyUnknown(item.call_id).trim(); - const itemId = stringifyUnknown(item.id).trim(); - const [fallbackCallId = "", fallbackItemId = ""] = (fallbackId ?? "").split("|"); - const resolvedCallId = callId || fallbackCallId; - const resolvedItemId = itemId || fallbackItemId; - if (resolvedCallId) { - return resolvedItemId ? `${resolvedCallId}|${resolvedItemId}` : resolvedCallId; - } - const generatedCallId = `call_${randomUUID().replaceAll("-", "").slice(0, 24)}`; - return resolvedItemId ? `${generatedCallId}|${resolvedItemId}` : generatedCallId; - }; - let currentItem: Record | null = null; - let currentBlock: Record | null = null; - type StreamingToolCallIdentity = { itemId?: string; callId?: string }; - type StreamingToolCallState = StreamingToolCallIdentity & { - block: Record; - contentIndex: number; - argumentStreamReliable: boolean; - }; - const toolCallsByOutputIndex = new Map(); - const unindexedToolCalls = new Set(); - let lastTextBlock: { - block: Record; - index: number; - phase: "commentary" | "final_answer" | undefined; - } | null = null; - // While a message item may still be a cumulative snapshot of lastTextBlock, - // its public block is deferred so a collapsed item never leaves an - // unbalanced text_start behind (#91959). null = no deferral in progress. - let pendingMessageText: string | null = null; - const streamStartedAt = Date.now(); - let eventCount = 0; - const eventTypes = new Map(); - const sseDebugMode = resolveModelSseDebugMode(); - const blockIndex = () => output.content.length - 1; - const readOutputIndex = (event: Record): number | undefined => - typeof event.output_index === "number" && - Number.isInteger(event.output_index) && - event.output_index >= 0 - ? event.output_index - : undefined; - const readIdentityValue = (value: unknown): string | undefined => { - const identity = typeof value === "string" ? value.trim() : ""; - return identity || undefined; - }; - const readEventToolCallIdentity = ( - event: Record, - ): StreamingToolCallIdentity => ({ itemId: readIdentityValue(event.item_id) }); - const readItemToolCallIdentity = (item: Record): StreamingToolCallIdentity => ({ - itemId: readIdentityValue(item.id), - callId: readIdentityValue(item.call_id), - }); - const identitiesConflict = ( - state: StreamingToolCallState, - identity: StreamingToolCallIdentity, - ): boolean => - Boolean( - (state.itemId && identity.itemId && state.itemId !== identity.itemId) || - (state.callId && identity.callId && state.callId !== identity.callId), - ); - const sharesIdentity = ( - state: StreamingToolCallState, - identity: StreamingToolCallIdentity, - ): boolean => - Boolean( - (state.itemId && identity.itemId && state.itemId === identity.itemId) || - (state.callId && identity.callId && state.callId === identity.callId), - ); - const adoptToolCallIdentity = ( - state: StreamingToolCallState, - identity: StreamingToolCallIdentity, - ): StreamingToolCallState => { - state.itemId ??= identity.itemId; - state.callId ??= identity.callId; - return state; - }; - const resolveCompatibleToolCall = ( - candidates: Iterable, - identity: StreamingToolCallIdentity, - ): StreamingToolCallState | undefined => { - const uniqueCandidates = [...new Set(candidates)]; - if (!identity.itemId && !identity.callId) { - return uniqueCandidates.length === 1 ? uniqueCandidates[0] : undefined; - } - const compatible = uniqueCandidates.filter((state) => !identitiesConflict(state, identity)); - const matches = compatible.filter((state) => sharesIdentity(state, identity)); - if (matches.length === 1) { - const match = matches.at(0); - return match ? adoptToolCallIdentity(match, identity) : undefined; - } - // Only a sole active call may adopt an identity it did not already know. - // Parallel calls require a positive match so missing indices stay fail-closed. - if (uniqueCandidates.length !== 1 || compatible.length !== 1 || matches.length !== 0) { - return undefined; - } - const candidate = compatible.at(0); - return candidate ? adoptToolCallIdentity(candidate, identity) : undefined; - }; - const resolveStreamingToolCall = ( - event: Record, - identity: StreamingToolCallIdentity = readEventToolCallIdentity(event), - ): StreamingToolCallState | undefined => { - const outputIndex = readOutputIndex(event); - if (outputIndex !== undefined) { - const indexed = toolCallsByOutputIndex.get(outputIndex); - if (indexed) { - return !identitiesConflict(indexed, identity) - ? adoptToolCallIdentity(indexed, identity) - : undefined; - } - // A compatibility stream may add calls without indices, then start - // including them. Bind only the one identity-matched (or sole) candidate. - const unindexed = resolveCompatibleToolCall(unindexedToolCalls, identity); - if (unindexed) { - unindexedToolCalls.delete(unindexed); - toolCallsByOutputIndex.set(outputIndex, unindexed); - } - return unindexed; - } - - return resolveCompatibleToolCall( - [...toolCallsByOutputIndex.values(), ...unindexedToolCalls], - identity, - ); - }; - const forgetStreamingToolCall = (toolCall: StreamingToolCallState) => { - for (const [trackedIndex, tracked] of toolCallsByOutputIndex) { - if (tracked === toolCall) { - toolCallsByOutputIndex.delete(trackedIndex); - } - } - unindexedToolCalls.delete(toolCall); - }; - const markActiveToolCallArgumentsUnreliable = () => { - // An unrouteable argument event may belong to any active call. Only an - // authoritative full argument snapshot can recover that call. - for (const toolCall of new Set([...toolCallsByOutputIndex.values(), ...unindexedToolCalls])) { - toolCall.argumentStreamReliable = false; - } - }; - const hasActiveStreamingToolCall = () => - toolCallsByOutputIndex.size > 0 || unindexedToolCalls.size > 0; - // Opening fragments may carry the only function name. A conflicting - // completion must never retarget an already-started call. - const resolveCompletedToolCallName = ( - toolCall: StreamingToolCallState | undefined, - value: unknown, - ): string => { - const streamedName = readIdentityValue(toolCall?.block.name); - const completedName = readIdentityValue(value); - if (streamedName && completedName && streamedName !== completedName) { - throw new Error( - `Responses stream changed tool-call function name from ${streamedName} to ${completedName}`, - ); - } - const name = completedName ?? streamedName; - if (!name) { - throw new Error("Responses stream completed tool call without a function name"); - } - return name; - }; - const appendPendingMessageDelta = (delta: string) => { - pendingMessageText = `${pendingMessageText ?? ""}${delta}`; - const priorText = stringifyUnknown(lastTextBlock?.block.text); - if (priorText.startsWith(pendingMessageText) || pendingMessageText.startsWith(priorText)) { - return; - } - // Diverged from the prior text: this is a distinct message, so open its - // block now and replay the withheld text as one delta. - const phase = - currentItem?.type === "message" - ? ((currentItem.phase as "commentary" | "final_answer" | undefined) ?? undefined) - : undefined; - currentBlock = { - type: "text", - text: pendingMessageText, - ...(currentItem?.type === "message" && phase - ? { - textSignature: encodeTextSignatureV1(stringifyUnknown(currentItem.id), phase), - } - : {}), - }; - output.content.push(currentBlock); - stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); - stream.push({ type: "text_delta", contentIndex: blockIndex(), delta: pendingMessageText }); - pendingMessageText = null; - }; - const appendCompletedResponseTextItem = (item: Record) => { - const text = readResponsesOutputMessageText(item); - if (!text) { - return; - } - const phase = (item.phase as "commentary" | "final_answer" | undefined) ?? undefined; - const collapse = resolveResponsesMessageSnapshotCollapse({ - prior: lastTextBlock && { - text: stringifyUnknown(lastTextBlock.block.text), - phase: lastTextBlock.phase, - }, - nextText: text, - nextPhase: phase, - }); - if (collapse.kind === "extend" && lastTextBlock) { - // Cumulative snapshot of the prior message item: replace, don't append; - // the newest item's signature carries the content for replay (#91959). - lastTextBlock.block.text = collapse.text; - lastTextBlock.block.textSignature = encodeTextSignatureV1(stringifyUnknown(item.id), phase); - stream.push({ - type: "text_end", - contentIndex: lastTextBlock.index, - content: collapse.text, - partial: output, - }); - return; - } - const block: Record = { - type: "text", - text, - textSignature: encodeTextSignatureV1(stringifyUnknown(item.id), phase), - }; - output.content.push(block); - lastTextBlock = { block, index: blockIndex(), phase }; - stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); - stream.push({ - type: "text_end", - contentIndex: blockIndex(), - content: text, - partial: output, - }); - }; - const appendCompletedResponseToolCallItem = (item: Record) => { - const args = parseStreamingJson(stringifyJsonLike(item.arguments, "{}")); - const name = resolveCompletedToolCallName(undefined, item.name); - const block = { - type: "toolCall", - id: resolveToolCallId(item), - name, - arguments: args, - partialJson: stringifyJsonLike(item.arguments, "{}"), - }; - output.content.push(block); - stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); - stream.push({ - type: "toolcall_end", - contentIndex: blockIndex(), - toolCall: { - type: "toolCall", - id: block.id, - name: block.name, - arguments: args, - }, - partial: output, - }); - }; - const backfillCompletedResponseOutput = (response: Record | undefined) => { - if (output.content.length > 0 || !Array.isArray(response?.output)) { - return; - } - for (const rawItem of response.output) { - if (!isRecord(rawItem)) { - continue; - } - if (rawItem.type === "message") { - appendCompletedResponseTextItem(rawItem); - continue; - } - // Any non-message item (reasoning, tool call) is a real boundary; a later - // message must not collapse across it, mirroring the streaming path. - lastTextBlock = null; - if (rawItem.type === "function_call") { - appendCompletedResponseToolCallItem(rawItem); - } - } - }; - const guardedStream = withFirstStreamEventTimeout(openaiStream, { - provider: model.provider, - api: model.api, - model: model.id, - timeoutMs: options?.firstEventTimeoutMs ?? 0, - stage: "responses", - abort: options?.abortFirstEventStream, - onTimeout: options?.onFirstEventTimeout, - hint: "The provider may be stalled while parsing the tool payload; retry with a smaller tool surface or enable OPENCLAW_DEBUG_MODEL_PAYLOAD=tools to inspect exposed tools.", - }); - const cooperativeScheduler = createModelStreamCooperativeScheduler(options?.signal); - for await (const rawEvent of guardedStream) { - throwIfModelStreamAborted(options?.signal); - const event = rawEvent as Record; - const type = stringifyUnknown(event.type); - eventCount += 1; - eventTypes.set(type, (eventTypes.get(type) ?? 0) + 1); - if (eventCount === 1) { - emitModelTransportDebug( - log, - `[responses] first_event provider=${model.provider} api=${model.api} model=${model.id} ` + - `elapsedMs=${Date.now() - streamStartedAt} type=${type}`, - ); - } - if (sseDebugMode === "peek" && eventCount <= 5) { - emitModelTransportDebug( - log, - `[responses] event_peek provider=${model.provider} api=${model.api} model=${model.id} ` + - `index=${eventCount} type=${type} event=${stringifyRedactedEvent(event)}`, - ); - } - if (type === "response.created") { - output.responseId = stringifyUnknown((event.response as { id?: string } | undefined)?.id); - } else if (type === "response.output_item.added") { - const item = event.item as Record; - if (item.type !== "message") { - // Snapshot collapse only applies to back-to-back message items; any - // other item is a real boundary (see resolveResponsesMessageSnapshotCollapse). - lastTextBlock = null; - pendingMessageText = null; - } - if (item.type === "reasoning") { - currentItem = item; - currentBlock = { type: "thinking", thinking: "" }; - output.content.push(currentBlock); - stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); - } else if (item.type === "message") { - currentItem = item; - if (lastTextBlock) { - currentBlock = null; - pendingMessageText = ""; - } else { - const phase = (item.phase as "commentary" | "final_answer" | undefined) ?? undefined; - currentBlock = { - type: "text", - text: "", - ...(phase - ? { textSignature: encodeTextSignatureV1(stringifyUnknown(item.id), phase) } - : {}), - }; - output.content.push(currentBlock); - stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); - } - } else if (item.type === "function_call") { - const outputIndex = readOutputIndex(event); - if (outputIndex !== undefined && toolCallsByOutputIndex.has(outputIndex)) { - throw new Error(`Responses stream reused active tool-call output index ${outputIndex}`); - } - currentItem = item; - currentBlock = { - type: "toolCall", - id: resolveToolCallId(item), - name: readIdentityValue(item.name) ?? "", - arguments: {}, - partialJson: stringifyJsonLike(item.arguments), - }; - output.content.push(currentBlock); - const contentIndex = blockIndex(); - const toolCallState = { - block: currentBlock, - contentIndex, - argumentStreamReliable: true, - ...readItemToolCallIdentity(item), - }; - if (outputIndex !== undefined) { - toolCallsByOutputIndex.set(outputIndex, toolCallState); - } else { - unindexedToolCalls.add(toolCallState); - } - stream.push({ type: "toolcall_start", contentIndex, partial: output }); - } - } else if (type === "response.reasoning_summary_text.delta") { - if (currentItem?.type === "reasoning" && currentBlock?.type === "thinking") { - currentBlock.thinking = `${stringifyUnknown(currentBlock.thinking)}${stringifyUnknown(event.delta)}`; - stream.push({ - type: "thinking_delta", - contentIndex: blockIndex(), - delta: stringifyUnknown(event.delta), - partial: output, - }); - } - } else if (isResponsesTextDeltaEventType(type) || type === "response.refusal.delta") { - if (currentItem?.type === "message") { - if (pendingMessageText !== null) { - appendPendingMessageDelta(stringifyUnknown(event.delta)); - } else if (currentBlock?.type === "text") { - currentBlock.text = `${stringifyUnknown(currentBlock.text)}${stringifyUnknown(event.delta)}`; - stream.push({ - type: "text_delta", - contentIndex: blockIndex(), - delta: stringifyUnknown(event.delta), - }); - } - } - } else if (type === "response.function_call_arguments.delta") { - const toolCall = resolveStreamingToolCall(event); - if (toolCall) { - toolCall.block.partialJson = `${stringifyJsonLike(toolCall.block.partialJson)}${stringifyJsonLike(event.delta)}`; - toolCall.block.arguments = parseStreamingJson( - stringifyJsonLike(toolCall.block.partialJson), - ); - stream.push({ - type: "toolcall_delta", - contentIndex: toolCall.contentIndex, - delta: stringifyJsonLike(event.delta), - partial: output, - }); - } else if (hasActiveStreamingToolCall()) { - markActiveToolCallArgumentsUnreliable(); - } - } else if (type === "response.function_call_arguments.done") { - const toolCall = resolveStreamingToolCall(event); - if (toolCall) { - const previousPartialJson = stringifyJsonLike(toolCall.block.partialJson); - const doneArguments = typeof event.arguments === "string" ? event.arguments : undefined; - if ( - doneArguments !== undefined && - (doneArguments.length > 0 || previousPartialJson === "") - ) { - toolCall.block.partialJson = doneArguments; - toolCall.block.arguments = parseStreamingJson(doneArguments); - toolCall.argumentStreamReliable = true; - } - if (doneArguments?.startsWith(previousPartialJson)) { - const delta = doneArguments.slice(previousPartialJson.length); - if (delta.length > 0) { - stream.push({ - type: "toolcall_delta", - contentIndex: toolCall.contentIndex, - delta, - partial: output, - }); - } - } - } else if (hasActiveStreamingToolCall()) { - markActiveToolCallArgumentsUnreliable(); - } - } else if (type === "response.output_item.done") { - const item = event.item as Record; - if (item.type !== "message") { - lastTextBlock = null; - pendingMessageText = null; - } - if (item.type === "reasoning" && currentBlock?.type === "thinking") { - const summary = Array.isArray(item.summary) - ? item.summary - .map((part) => { - const summaryPart = part as { text?: string }; - return summaryPart.text ?? ""; - }) - .join("\n\n") - : ""; - currentBlock.thinking = summary; - currentBlock.thinkingSignature = JSON.stringify(item); - if ("encrypted_content" in item) { - currentBlock[OPENAI_RESPONSES_REASONING_REPLAY_BLOCK_META_KEY] = - buildOpenAIResponsesReasoningReplayMetadata(model, { - authProfileId: options?.authProfileId, - sessionId: options?.sessionId, - }); - } - stream.push({ - type: "thinking_end", - contentIndex: blockIndex(), - content: stringifyUnknown(currentBlock.thinking), - partial: output, - }); - currentBlock = null; - } else if ( - item.type === "message" && - (currentBlock?.type === "text" || pendingMessageText !== null) - ) { - const content = Array.isArray(item.content) ? item.content : []; - const finalText = content - .map((part) => { - const contentPart = part as { type?: string; text?: string; refusal?: string }; - return isResponsesTextContentPartType(contentPart.type) - ? (contentPart.text ?? "") - : (contentPart.refusal ?? ""); - }) - .join(""); - const phase = (item.phase as "commentary" | "final_answer" | undefined) ?? undefined; - const collapse = - pendingMessageText !== null - ? resolveResponsesMessageSnapshotCollapse({ - prior: lastTextBlock && { - text: stringifyUnknown(lastTextBlock.block.text), - phase: lastTextBlock.phase, - }, - nextText: finalText, - nextPhase: phase, - }) - : ({ kind: "keep" } as const); - pendingMessageText = null; - if (collapse.kind === "extend" && lastTextBlock) { - // Cumulative snapshot of the prior message item: replace its text - // instead of appending another copy. The deferred block was never - // started publicly, and the newest item's signature is kept so - // replay carries the item that produced this content (#91959). - lastTextBlock.block.text = collapse.text; - lastTextBlock.block.textSignature = encodeTextSignatureV1( - stringifyUnknown(item.id), - phase, - ); - stream.push({ - type: "text_end", - contentIndex: lastTextBlock.index, - content: collapse.text, - partial: output, - }); - } else { - if (currentBlock?.type !== "text") { - // Deferred distinct message: open its block now, balanced with the - // text_end below. - currentBlock = { - type: "text", - text: "", - ...(phase - ? { textSignature: encodeTextSignatureV1(stringifyUnknown(item.id), phase) } - : {}), - }; - output.content.push(currentBlock); - stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); - } - currentBlock.text = finalText; - currentBlock.textSignature = encodeTextSignatureV1(stringifyUnknown(item.id), phase); - lastTextBlock = { block: currentBlock, index: blockIndex(), phase }; - stream.push({ - type: "text_end", - contentIndex: blockIndex(), - content: stringifyUnknown(currentBlock.text), - partial: output, - }); - } - currentBlock = null; - } else if (item.type === "function_call") { - const streamingToolCall = resolveStreamingToolCall(event, readItemToolCallIdentity(item)); - // Do not turn an unresolved completion into a second public call while - // an indexed call is still open. Its identity or index must match. - if (!streamingToolCall && hasActiveStreamingToolCall()) { - await cooperativeScheduler.afterEvent(); - continue; - } - const completedName = resolveCompletedToolCallName(streamingToolCall, item.name); - const streamedPartialJson = streamingToolCall - ? stringifyJsonLike(streamingToolCall.block.partialJson) - : ""; - const completedArguments = typeof item.arguments === "string" ? item.arguments : undefined; - if (streamingToolCall && !streamingToolCall.argumentStreamReliable && !completedArguments) { - await cooperativeScheduler.afterEvent(); - continue; - } - const finalPartialJson = - completedArguments !== undefined && - (completedArguments.length > 0 || !streamedPartialJson) - ? completedArguments - : streamedPartialJson || "{}"; - const args = parseStreamingJson(finalPartialJson); - let toolCallBlock: Record; - let contentIndex: number; - if (streamingToolCall) { - toolCallBlock = streamingToolCall.block; - contentIndex = streamingToolCall.contentIndex; - } else { - toolCallBlock = { - type: "toolCall", - id: resolveToolCallId(item), - name: completedName, - arguments: args, - partialJson: finalPartialJson, - }; - output.content.push(toolCallBlock); - contentIndex = blockIndex(); - stream.push({ type: "toolcall_start", contentIndex, partial: output }); - } - const provisionalId = typeof toolCallBlock.id === "string" ? toolCallBlock.id : undefined; - const currentToolCallId = resolveToolCallId(item, provisionalId); - toolCallBlock.id = currentToolCallId; - toolCallBlock.name = completedName; - toolCallBlock.arguments = args; - toolCallBlock.partialJson = finalPartialJson; - stream.push({ - type: "toolcall_end", - contentIndex, - toolCall: { - type: "toolCall", - id: currentToolCallId, - name: completedName, - arguments: args, - }, - partial: output, - }); - if (streamingToolCall) { - forgetStreamingToolCall(streamingToolCall); - } - if (currentBlock === toolCallBlock) { - currentBlock = null; - currentItem = null; - } - } - } else if (type === "response.completed") { - if (hasActiveStreamingToolCall()) { - throw new Error("Responses stream completed with unresolved tool calls"); - } - const response = event.response as Record | undefined; - if (typeof response?.id === "string") { - output.responseId = response.id; - } - backfillCompletedResponseOutput(response); - const usage = response?.usage as - | { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - input_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number }; - output_tokens_details?: { reasoning_tokens?: number }; - service_tier?: ResponseCreateParamsStreaming["service_tier"]; - status?: string; - } - | undefined; - if (usage) { - const cachedTokens = usage.input_tokens_details?.cached_tokens || 0; - const cacheWriteTokens = usage.input_tokens_details?.cache_write_tokens || 0; - const inputTokens = usage.input_tokens || 0; - const outputTokens = usage.output_tokens || 0; - const reasoningTokens = usage.output_tokens_details?.reasoning_tokens; - const input = Math.max(0, inputTokens - cachedTokens - cacheWriteTokens); - output.usage = { - input, - output: outputTokens, - cacheRead: cachedTokens, - cacheWrite: cacheWriteTokens, - ...(typeof reasoningTokens === "number" && Number.isFinite(reasoningTokens) - ? { reasoningTokens } - : {}), - totalTokens: input + outputTokens + cachedTokens + cacheWriteTokens, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }; - } - calculateCost(model as never, output.usage as never); - if (options?.applyServiceTierPricing) { - options.applyServiceTierPricing( - output.usage, - (response?.service_tier as ResponseCreateParamsStreaming["service_tier"] | undefined) ?? - options.serviceTier, - ); - } - output.stopReason = mapResponsesStopReason(response?.status as string | undefined); - if ( - output.content.some((block) => block.type === "toolCall") && - output.stopReason === "stop" - ) { - output.stopReason = "toolUse"; - } - } else if (type === "error") { - throw new Error( - `Error Code ${stringifyUnknown(event.code, "unknown")}: ${stringifyUnknown(event.message, "Unknown error")}`, - ); - } else if (type === "response.failed") { - const failure = normalizeResponsesFailedEvent(event, model); - if (failure.responseId) { - output.responseId = failure.responseId; - } - if (failure.observation) { - logResponsesFailedNoDetails(failure.observation); - } - throw new Error(failure.message); - } - await cooperativeScheduler.afterEvent(); - } - if (hasActiveStreamingToolCall()) { - throw new Error("Responses stream ended with unresolved tool calls"); - } - const eventTypeSummary = [...eventTypes.entries()] - .slice(0, 12) - .map(([eventType, count]) => `${eventType}:${count}`) - .join(","); - emitModelTransportDebug( - log, - `[responses] stream_done provider=${model.provider} api=${model.api} model=${model.id} ` + - `elapsedMs=${Date.now() - streamStartedAt} events=${eventCount} types=${eventTypeSummary} ` + - `stopReason=${output.stopReason ?? "unset"} contentBlocks=${output.content.length}`, - ); -} - -function mapResponsesStopReason(status: string | undefined): string { - if (!status) { - return "stop"; - } - switch (status) { - case "completed": - return "stop"; - case "incomplete": - return "length"; - case "failed": - case "cancelled": - return "error"; - case "in_progress": - case "queued": - return "stop"; - default: - throw new Error(`Unhandled stop reason: ${status}`); - } -} - -function readResponsesOutputMessageText(item: Record): string { - const content = Array.isArray(item.content) ? item.content : []; - return content - .map((part) => { - if (!isRecord(part)) { - return ""; - } - if (part.type === "output_text" || part.type === "text") { - return stringifyUnknown(part.text); - } - if (part.type === "refusal") { - return stringifyUnknown(part.refusal); - } - return ""; - }) - .join(""); -} - -function buildOpenAIClientHeaders( - model: Model, - context: Context, - optionHeaders?: Record, - turnHeaders?: Record, - sessionId?: string, -): Record { - const providerHeaders = { ...model.headers }; - if (model.provider === "github-copilot") { - Object.assign( - providerHeaders, - buildCopilotDynamicHeaders({ - messages: context.messages, - hasImages: hasCopilotVisionInput(context.messages), - }), - ); - } - const callerHeaders = { ...optionHeaders, ...turnHeaders }; - const headers = resolveProviderRequestPolicyConfig({ - provider: model.provider, - api: model.api, - baseUrl: model.baseUrl, - capability: "llm", - transport: "stream", - providerHeaders, - callerHeaders: Object.keys(callerHeaders).length > 0 ? callerHeaders : undefined, - precedence: "caller-wins", - }).headers; - const resolvedHeaders = headers ?? {}; - // This header routes ChatGPT Responses session affinity; without it requests land - // on arbitrary machines and prompt cache misses. codex-rs sends "session-id" - // (codex-rs/codex-api/src/requests/headers.rs), but backend accepts "session_id"; align with packages/ai openai-chatgpt-responses. - if ( - sessionId && - !Object.keys(resolvedHeaders).some( - (key) => normalizeLowercaseStringOrEmpty(key) === "session_id", - ) && - usesNativeOpenAICodexResponsesBackend(model) - ) { - resolvedHeaders.session_id = sessionId; - } - return resolvedHeaders; -} - -function resolveProviderTransportTurnState( - model: Model, - params: { - sessionId?: string; - turnId: string; - attempt: number; - transport: "stream" | "websocket"; - }, -) { - const normalizedProvider = model.provider.trim().toLowerCase(); - const allowRuntimePluginLoad = - normalizedProvider === "openai" || - normalizedProvider === "azure-openai" || - normalizedProvider === "azure-openai-responses"; - return resolveProviderTransportTurnStateWithPlugin({ - provider: model.provider, - modelId: model.id, - allowRuntimePluginLoad, - context: { - provider: model.provider, - modelId: model.id, - model: model as ProviderRuntimeModel, - sessionId: params.sessionId, - turnId: params.turnId, - attempt: params.attempt, - transport: params.transport, - }, - }); -} - -function resolveOpenAISdkTimeoutMs(model: Model): number | undefined { - return resolveModelRequestTimeoutMs(model, undefined); -} - -function buildOpenAISdkClientOptions(model: Model): { timeout?: number } { - const timeout = resolveOpenAISdkTimeoutMs(model); - return timeout === undefined ? {} : { timeout }; -} - -function buildOpenAISdkRequestOptions( - model: Model, - signal?: AbortSignal, - options?: { stream?: boolean }, -): { signal?: AbortSignal; timeout?: number; headers?: Record } | undefined { - const timeout = resolveOpenAISdkTimeoutMs(model); - const headers = - options?.stream === true && usesNativeOpenAICodexResponsesBackend(model) - ? { Accept: "text/event-stream" } - : undefined; - if (timeout === undefined && !signal && !headers) { - return undefined; - } - return { - ...(headers ? { headers } : {}), - ...(signal ? { signal } : {}), - ...(timeout !== undefined ? { timeout } : {}), - }; -} - -function createOpenAIResponsesClient( - model: Model, - context: Context, - apiKey: string, - optionHeaders?: Record, - turnHeaders?: Record, - sessionId?: string, -) { - return new OpenAI({ - apiKey, - baseURL: model.baseUrl, - dangerouslyAllowBrowser: true, - defaultHeaders: buildOpenAIClientHeaders(model, context, optionHeaders, turnHeaders, sessionId), - fetch: buildGuardedModelFetch(model), - ...buildOpenAISdkClientOptions(model), - }); -} - -export function createOpenAIResponsesTransportStreamFn(): StreamFn { - return (model, context, options) => { - const responsesOptions = options as OpenAIResponsesOptions | undefined; - const eventStream = createAssistantMessageEventStream(); - const stream = eventStream as unknown as { push(event: unknown): void; end(): void }; - void (async () => { - const output: MutableAssistantOutput = { - role: "assistant" as const, - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; - let firstEventAbort: ReturnType | undefined; - try { - const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; - const turnState = resolveProviderTransportTurnState(model, { - sessionId: options?.sessionId, - turnId: randomUUID(), - attempt: 1, - transport: "stream", - }); - const client = createOpenAIResponsesClient( - model, - context, - apiKey, - options?.headers, - turnState?.headers, - options?.sessionId, - ); - let params = buildOpenAIResponsesParams( - model, - context, - responsesOptions, - turnState?.metadata, - ); - const nextParams = await options?.onPayload?.(params, model); - if (nextParams !== undefined) { - params = nextParams as typeof params; - } - if (!isOpenAICodexResponsesModel(model)) { - params = mergeTransportMetadata(params, turnState?.metadata); - } - params = sanitizeOpenAICodexResponsesParams( - model, - params as Record, - ) as typeof params; - params = sanitizeResponsesImagePayload(params as Record) as typeof params; - if ( - (options as { openclawCodeModeToolSurface?: unknown } | undefined) - ?.openclawCodeModeToolSurface === true - ) { - enforceCodeModeResponsesToolSurface(params); - assertCodeModeResponsesToolSurface(params); - } - const requestStartedAt = Date.now(); - firstEventAbort = createFirstStreamEventAbortController(options?.signal); - const requestOptions = buildOpenAISdkRequestOptions(model, firstEventAbort.signal, { - stream: true, - }); - emitModelTransportDebug( - log, - `[responses] start provider=${model.provider} api=${model.api} model=${model.id} ` + - `baseUrl=${formatModelTransportDebugBaseUrl(model.baseUrl)} timeoutMs=${safeDebugValue(requestOptions?.timeout)} ` + - `apiKey=${apiKey ? "present" : "missing"} ${summarizeResponsesPayload(params)}`, - ); - const responseStream = await createResponsesStreamWithEncryptedContentRetry({ - client, - request: params, - requestOptions, - model, - }); - emitModelTransportDebug( - log, - `[responses] headers provider=${model.provider} api=${model.api} model=${model.id} ` + - `elapsedMs=${Date.now() - requestStartedAt}`, - ); - stream.push({ type: "start", partial: output as never }); - await processResponsesStream(responseStream, output, stream, model, { - serviceTier: responsesOptions?.serviceTier, - applyServiceTierPricing, - firstEventTimeoutMs: getFirstStreamEventTimeoutMs(options), - abortFirstEventStream: firstEventAbort.abort, - onFirstEventTimeout: getFirstStreamEventTimeoutHandler(options), - signal: options?.signal, - authProfileId: responsesOptions?.authProfileId, - sessionId: options?.sessionId, - }); - if (options?.signal?.aborted) { - throw new Error("Request was aborted"); - } - if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error("An unknown error occurred"); - } - stream.push({ type: "done", reason: output.stopReason as never, message: output as never }); - stream.end(); - } catch (error) { - log.warn( - `[responses] error provider=${model.provider} api=${model.api} model=${model.id} ` + - summarizeOpenAITransportError(error), - ); - assignTransportErrorDetails(output, error, options?.signal); - stream.push({ type: "error", reason: output.stopReason as never, error: output as never }); - stream.end(); - } finally { - firstEventAbort?.dispose(); - } - })(); - return eventStream as unknown as ReturnType; - }; -} - -function resolveCacheRetention(cacheRetention: string | undefined): "short" | "long" | "none" { - if (cacheRetention === "short" || cacheRetention === "long" || cacheRetention === "none") { - return cacheRetention; - } - if (typeof process !== "undefined" && process.env.OPENCLAW_CACHE_RETENTION === "long") { - return "long"; - } - return "short"; -} - -function resolvePromptCacheKey( - options: Pick | undefined, - cacheRetention: "short" | "long" | "none", -): string | undefined { - if (cacheRetention === "none") { - return undefined; - } - return clampOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId); -} - -function getPromptCacheRetention( - baseUrl: string | undefined, - cacheRetention: "short" | "long" | "none", -) { - if (cacheRetention !== "long") { - return undefined; - } - return baseUrl?.includes("api.openai.com") ? "24h" : undefined; -} - -function resolveOpenAIReasoningEffort( - options: OpenAIResponsesOptions | undefined, -): OpenAIApiReasoningEffort { - return normalizeOpenAIReasoningEffort( - options?.reasoningEffort ?? options?.reasoning ?? "high", - ) as OpenAIApiReasoningEffort; -} - -function hasResponsesWebSearchTool(tools: unknown): boolean { - if (!Array.isArray(tools)) { - return false; - } - return tools.some((tool) => { - if (!isRecord(tool)) { - return false; - } - if (tool.type === "web_search") { - return true; - } - if (tool.type === "function" && tool.name === "web_search") { - return true; - } - const fn = tool.function; - return isRecord(fn) && fn.name === "web_search"; - }); -} - -function raiseMinimalReasoningForResponsesWebSearch(params: { - model: Model; - effort: OpenAIApiReasoningEffort; - tools: unknown; -}): OpenAIApiReasoningEffort { - if (params.effort !== "minimal" || !hasResponsesWebSearchTool(params.tools)) { - return params.effort; - } - for (const effort of ["low", "medium", "high"] as const) { - const resolved = resolveOpenAIReasoningEffortForModel({ - model: params.model, - effort, - }); - if (resolved && resolved !== "none" && resolved !== "minimal") { - return resolved; - } - } - return params.effort; -} - -function isOpenAICodexResponsesModel(model: Model): boolean { - return ( - OPENAI_CODEX_RESPONSES_PROVIDERS.has(model.provider) && - (model.api === "openai-chatgpt-responses" || - model.api === "openclaw-openai-responses-transport") - ); -} - -function isNativeOpenAICodexResponsesBaseUrl(baseUrl?: string): boolean { - const trimmed = typeof baseUrl === "string" ? baseUrl.trim() : ""; - if (!trimmed) { - return false; - } - try { - const url = new URL(trimmed); - if (url.protocol !== "http:" && url.protocol !== "https:") { - return false; - } - if (url.hostname.toLowerCase() !== "chatgpt.com") { - return false; - } - const pathname = url.pathname.replace(/\/+$/u, "").toLowerCase(); - return [ - "/backend-api", - "/backend-api/v1", - "/backend-api/codex", - "/backend-api/codex/v1", - ].includes(pathname); - } catch { - return false; - } -} - -function usesNativeOpenAICodexResponsesBackend(model: Model): boolean { - return isOpenAICodexResponsesModel(model) && isNativeOpenAICodexResponsesBaseUrl(model.baseUrl); -} - -const OPENAI_CODEX_RESPONSES_UNSUPPORTED_PARAMS = [ - "max_output_tokens", - "metadata", - "prompt_cache_retention", - "service_tier", - "temperature", - "top_p", -] as const; - -function stripOpenAICodexResponsesUnsupportedTextFields(params: Record): void { - const text = params.text; - if (!text || typeof text !== "object" || Array.isArray(text)) { - return; - } - const sanitizedText = { ...(text as Record) }; - delete sanitizedText.format; - if (Object.keys(sanitizedText).length > 0) { - params.text = sanitizedText; - } else { - delete params.text; - } -} - -function sanitizeOpenAICodexResponsesParams>( - model: Model, - params: T, -): T { - if (!usesNativeOpenAICodexResponsesBackend(model)) { - return params; - } - for (const key of OPENAI_CODEX_RESPONSES_UNSUPPORTED_PARAMS) { - delete params[key]; - } - stripOpenAICodexResponsesUnsupportedTextFields(params); - return params; -} - -function buildOpenAICodexResponsesInstructions(context: Context): string | undefined { - if (!context.systemPrompt) { - return undefined; - } - return sanitizeTransportPayloadText(stripSystemPromptCacheBoundary(context.systemPrompt)); -} - -function resolveOpenAICodexResponsesInstructions( - model: Model, - context: Context, -): string | undefined { - const instructions = buildOpenAICodexResponsesInstructions(context); - if (instructions && instructions.trim().length > 0) { - return instructions; - } - return usesNativeOpenAICodexResponsesBackend(model) - ? OPENAI_CODEX_RESPONSES_DEFAULT_INSTRUCTIONS - : undefined; -} - -function ensureOpenAICodexResponsesInput(messages: ResponseInput, context: Context): void { - if (messages.length > 0 || !context.systemPrompt) { - return; - } - const text = buildOpenAICodexResponsesInstructions(context); - if (!text) { - throw new Error( - "OpenAI Codex Responses requires non-empty input when only systemPrompt is provided.", - ); - } - messages.push( - buildResponsesInputMessage("user", [ - { type: "input_text", text: OPENAI_CODEX_RESPONSES_EMPTY_INPUT_TEXT }, - ]), - ); -} - -function resolveOpenAIResponsesTextFormat( - responseFormat: Record, -): ResponseFormatTextConfig { - if ( - responseFormat.type === "json_schema" && - responseFormat.json_schema && - typeof responseFormat.json_schema === "object" && - !Array.isArray(responseFormat.json_schema) - ) { - return { - ...(responseFormat.json_schema as Record), - type: "json_schema", - } as unknown as ResponseFormatTextConfig; - } - return responseFormat as unknown as ResponseFormatTextConfig; -} - -export function buildOpenAIResponsesParams( - model: Model, - context: Context, - options: OpenAIResponsesOptions | undefined, - metadata?: Record, -) { - const isCodexResponses = isOpenAICodexResponsesModel(model); - const isNativeCodexResponses = usesNativeOpenAICodexResponsesBackend(model); - const compat = getCompat(model as OpenAIModeModel); - const supportsDeveloperRole = - typeof compat.supportsDeveloperRole === "boolean" ? compat.supportsDeveloperRole : undefined; - const payloadPolicy = resolveOpenAIResponsesPayloadPolicy(model, { - storeMode: "disable", - }); - const policyAllowsReplayIds = - payloadPolicy.explicitStore !== false && !payloadPolicy.shouldStripStore; - const replayResponsesItemIds = - !isNativeCodexResponses && (options?.replayResponsesItemIds ?? policyAllowsReplayIds); - const messages = convertResponsesMessages( - model, - context, - new Set(["openai", "opencode", "azure-openai-responses", "github-copilot"]), - { - includeSystemPrompt: !isCodexResponses, - supportsDeveloperRole, - replayReasoningItems: true, - replayResponsesItemIds, - authProfileId: options?.authProfileId, - sessionId: options?.sessionId, - }, - ); - if (isCodexResponses) { - ensureOpenAICodexResponsesInput(messages, context); - } - const cacheRetention = resolveCacheRetention(options?.cacheRetention); - const promptCacheKey = resolvePromptCacheKey(options, cacheRetention); - const params: OpenAIResponsesRequestParams = { - model: model.id, - input: messages, - stream: true, - prompt_cache_key: promptCacheKey, - prompt_cache_retention: getPromptCacheRetention(model.baseUrl, cacheRetention), - ...(isCodexResponses - ? { instructions: resolveOpenAICodexResponsesInstructions(model, context) } - : {}), - ...(metadata ? { metadata } : {}), - }; - const effectiveMaxTokens = options?.maxTokens || model.maxTokens; - if (effectiveMaxTokens) { - params.max_output_tokens = effectiveMaxTokens; - } - if (options?.temperature !== undefined) { - params.temperature = options.temperature; - } - if (options?.topP !== undefined) { - params.top_p = options.topP; - } - if (options?.responseFormat !== undefined) { - params.text = { - ...params.text, - format: resolveOpenAIResponsesTextFormat(options.responseFormat), - }; - } - if (options?.serviceTier !== undefined && payloadPolicy.allowsServiceTier) { - params.service_tier = options.serviceTier; - } - if (context.tools) { - const converted = convertResponsesTools(context.tools, model as OpenAIModeModel, { - strict: resolveOpenAIStrictToolSetting(model as OpenAIModeModel, { - transport: "stream", - }), - }); - if ( - converted.tools.length > 0 || - (converted.projection.inputToolCount === 0 && converted.projection.diagnostics.length === 0) - ) { - params.tools = converted.tools; - } - if (options?.toolChoice) { - const toolChoice = reconcileOpenAIResponsesToolChoice( - options.toolChoice, - converted.projection, - ); - if (toolChoice !== undefined) { - params.tool_choice = toolChoice; - } - } - } - if (model.reasoning) { - if (options?.reasoningEffort || options?.reasoning || options?.reasoningSummary) { - const requestedReasoningEffort = resolveOpenAIReasoningEffort(options); - const resolvedReasoningEffort = resolveOpenAIReasoningEffortForModel({ - model, - effort: requestedReasoningEffort, - }); - const reasoningEffort = resolvedReasoningEffort - ? raiseMinimalReasoningForResponsesWebSearch({ - model, - effort: resolvedReasoningEffort, - tools: params.tools, - }) - : undefined; - if (reasoningEffort) { - params.reasoning = { - effort: reasoningEffort, - ...(reasoningEffort === "none" ? {} : { summary: options?.reasoningSummary || "auto" }), - }; - if (reasoningEffort !== "none") { - params.include = ["reasoning.encrypted_content"]; - } - } - } else if (model.provider !== "github-copilot") { - const reasoningEffort = resolveOpenAIReasoningEffortForModel({ - model, - effort: "none", - }); - if (reasoningEffort) { - params.reasoning = { - effort: reasoningEffort, - }; - } - } - } - applyOpenAIResponsesPayloadPolicy(params as Record, payloadPolicy); - return sanitizeOpenAICodexResponsesParams( - model, - params as Record, - ) as typeof params; -} - -export function createAzureOpenAIResponsesTransportStreamFn(): StreamFn { - return (model, context, options) => { - const responsesOptions = options as OpenAIResponsesOptions | undefined; - const eventStream = createAssistantMessageEventStream(); - const stream = eventStream as unknown as { push(event: unknown): void; end(): void }; - void (async () => { - const output: MutableAssistantOutput = { - role: "assistant" as const, - content: [], - api: "azure-openai-responses", - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; - let firstEventAbort: ReturnType | undefined; - try { - const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; - const turnState = resolveProviderTransportTurnState(model, { - sessionId: options?.sessionId, - turnId: randomUUID(), - attempt: 1, - transport: "stream", - }); - const client = createAzureOpenAIClient( - model, - context, - apiKey, - options?.headers, - turnState?.headers, - ); - const deploymentName = resolveAzureDeploymentName(model); - let params = buildAzureOpenAIResponsesParams( - model, - context, - responsesOptions, - deploymentName, - turnState?.metadata, - ); - const nextParams = await options?.onPayload?.(params, model); - if (nextParams !== undefined) { - params = nextParams as typeof params; - } - if (!isOpenAICodexResponsesModel(model)) { - params = mergeTransportMetadata(params, turnState?.metadata); - } - params = sanitizeOpenAICodexResponsesParams( - model, - params as Record, - ) as typeof params; - params = sanitizeResponsesImagePayload(params as Record) as typeof params; - if ( - (options as { openclawCodeModeToolSurface?: unknown } | undefined) - ?.openclawCodeModeToolSurface === true - ) { - enforceCodeModeResponsesToolSurface(params); - assertCodeModeResponsesToolSurface(params); - } - const requestStartedAt = Date.now(); - firstEventAbort = createFirstStreamEventAbortController(options?.signal); - const requestOptions = buildOpenAISdkRequestOptions(model, firstEventAbort.signal); - emitModelTransportDebug( - log, - `[responses] start provider=${model.provider} api=${model.api} model=${model.id} ` + - `baseUrl=${formatModelTransportDebugBaseUrl(model.baseUrl)} timeoutMs=${safeDebugValue(requestOptions?.timeout)} ` + - `apiKey=${apiKey ? "present" : "missing"} ${summarizeResponsesPayload(params)}`, - ); - const responseStream = (await client.responses.create( - params as never, - requestOptions, - )) as unknown as AsyncIterable; - emitModelTransportDebug( - log, - `[responses] headers provider=${model.provider} api=${model.api} model=${model.id} ` + - `elapsedMs=${Date.now() - requestStartedAt}`, - ); - stream.push({ type: "start", partial: output as never }); - await processResponsesStream(responseStream, output, stream, model, { - firstEventTimeoutMs: - getFirstStreamEventTimeoutMs(options) ?? AZURE_RESPONSES_FIRST_EVENT_TIMEOUT_MS, - abortFirstEventStream: firstEventAbort.abort, - onFirstEventTimeout: getFirstStreamEventTimeoutHandler(options), - signal: options?.signal, - authProfileId: responsesOptions?.authProfileId, - sessionId: options?.sessionId, - }); - if (options?.signal?.aborted) { - throw new Error("Request was aborted"); - } - if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error("An unknown error occurred"); - } - stream.push({ type: "done", reason: output.stopReason as never, message: output as never }); - stream.end(); - } catch (error) { - log.warn( - `[responses] error provider=${model.provider} api=${model.api} model=${model.id} ` + - summarizeOpenAITransportError(error), - ); - assignTransportErrorDetails(output, error, options?.signal); - stream.push({ type: "error", reason: output.stopReason as never, error: output as never }); - stream.end(); - } finally { - firstEventAbort?.dispose(); - } - })(); - return eventStream as unknown as ReturnType; - }; -} - -function normalizeAzureBaseUrl(baseUrl: string): string { - return baseUrl.replace(/\/+$/, ""); -} - -function resolveAzureDeploymentName(model: Model): string { - return resolveAzureDeploymentNameFromMap({ - modelId: model.id, - deploymentMap: process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP, - }); -} - -function createAzureOpenAIClient( - model: Model, - context: Context, - apiKey: string, - optionHeaders?: Record, - turnHeaders?: Record, -) { - const baseURL = normalizeAzureBaseUrl(model.baseUrl); - const clientOptions = { - apiKey, - dangerouslyAllowBrowser: true, - defaultHeaders: buildOpenAIClientHeaders(model, context, optionHeaders, turnHeaders), - baseURL, - fetch: buildGuardedModelFetch(model), - ...buildOpenAISdkClientOptions(model), - }; - - if (isOpenAICompatibleAzureResponsesBaseUrl(baseURL)) { - return new OpenAI(clientOptions); - } - - return new AzureOpenAI({ - ...clientOptions, - apiVersion: resolveAzureOpenAIApiVersion(), - }); -} - -function buildAzureOpenAIResponsesParams( - model: Model, - context: Context, - options: OpenAIResponsesOptions | undefined, - deploymentName: string, - metadata?: Record, -) { - const params = buildOpenAIResponsesParams(model, context, options, metadata); - params.model = deploymentName; - delete params.store; - return params; -} - -function hasToolHistory(messages: Context["messages"]): boolean { - return messages.some( - (message) => - message.role === "toolResult" || - // Assistant content can be a raw string from transcript replay; a string - // never carries tool calls, so it should not count toward tool history. - (message.role === "assistant" && - Array.isArray(message.content) && - message.content.some((block) => block.type === "toolCall")), - ); -} - -function assertOpenAICompletionsPayloadHasConversationTurn( - params: Record, - model: Model, -): void { - const messages = params.messages; - if (!Array.isArray(messages) || hasOpenAICompatibleConversationTurn(messages)) { - return; - } - throw new Error( - `OpenAI-compatible chat payload for ${model.provider}/${model.id} contains no non-empty user or assistant messages after compaction and transport transforms; refusing to send a system/tool-only request. Start a new user turn or repair the compacted session history.`, - ); -} - -const SSE_DONE_LINE_RE = /^data:[ \t]*\[DONE\][ \t]*$/i; -const SSE_DONE_MAX_LINE_CHARS = 1_024; - -function createSseDoneDetector() { - const decoder = new TextDecoder(); - let line = ""; - let lineOverflowed = false; - let sawDone = false; - - const finishLine = () => { - if (!lineOverflowed && SSE_DONE_LINE_RE.test(line)) { - sawDone = true; - } - line = ""; - lineOverflowed = false; - }; - const observeText = (text: string) => { - for (const char of text) { - if (char === "\n" || char === "\r") { - finishLine(); - continue; - } - if (!lineOverflowed && line.length < SSE_DONE_MAX_LINE_CHARS) { - line += char; - } else { - // Never let truncation turn a suffix of a large data line into a - // standalone terminal marker. - lineOverflowed = true; - } - } - }; - - return { - observe(chunk: Uint8Array) { - if (!sawDone) { - observeText(decoder.decode(chunk, { stream: true })); - } - }, - finish() { - if (sawDone) { - return; - } - observeText(decoder.decode()); - if (line || lineOverflowed) { - finishLine(); - } - }, - sawDone: () => sawDone, - }; -} - -function createOpenAICompletionsClient( - model: Model, - context: Context, - apiKey: string, - optionHeaders?: Record, - opts?: { fetch?: typeof globalThis.fetch }, -) { - const clientConfig = buildOpenAICompletionsClientConfig(model, context, optionHeaders); - return new OpenAI({ - apiKey, - baseURL: clientConfig.baseURL, - dangerouslyAllowBrowser: true, - defaultHeaders: clientConfig.defaultHeaders, - defaultQuery: clientConfig.defaultQuery, - fetch: opts?.fetch ?? buildGuardedModelFetch(model), - ...buildOpenAISdkClientOptions(model), - }); -} - -function isAzureOpenAICompatibleHost(hostname: string): boolean { - return ( - hostname.endsWith(".openai.azure.com") || - hostname.endsWith(".services.ai.azure.com") || - hostname.endsWith(".cognitiveservices.azure.com") - ); -} - -function isKnownOpenAICompletionsEndpoint(model: Pick): boolean { - if (!model.baseUrl.trim()) { - return true; - } - const endpointClass = resolveProviderEndpoint(model.baseUrl).endpointClass; - if (endpointClass === "openai-public" || endpointClass === "azure-openai") { - return true; - } - try { - return isAzureOpenAICompatibleHost(new URL(model.baseUrl).hostname.toLowerCase()); - } catch { - return false; - } -} - -function buildOpenAICompletionsClientConfig( - model: Model, - context: Context, - optionHeaders?: Record, -): { - baseURL: string; - defaultHeaders: Record; - defaultQuery?: Record; -} { - const headers = buildOpenAIClientHeaders(model, context, optionHeaders); - const defaultQuery: Record = {}; - let baseURL = model.baseUrl; - let isAzureHost = false; - - try { - const parsed = new URL(model.baseUrl); - isAzureHost = isAzureOpenAICompatibleHost(parsed.hostname.toLowerCase()); - parsed.searchParams.forEach((value, key) => { - if (value) { - defaultQuery[key] = value; - } - }); - parsed.search = ""; - baseURL = parsed.toString().replace(/\/$/, ""); - } catch { - // Keep the configured base URL unchanged; the OpenAI SDK will surface invalid URLs. - } - - if (isAzureHost) { - const apiVersionHeader = Object.keys(headers).find( - (key) => key.toLowerCase() === "api-version", - ); - if (apiVersionHeader) { - const apiVersion = headers[apiVersionHeader]?.trim(); - delete headers[apiVersionHeader]; - if (apiVersion && !defaultQuery["api-version"]) { - defaultQuery["api-version"] = apiVersion; - } - } - } - - return { - baseURL, - defaultHeaders: headers, - defaultQuery: Object.keys(defaultQuery).length > 0 ? defaultQuery : undefined, - }; -} - -export function createOpenAICompletionsTransportStreamFn(): StreamFn { - return (model, context, options) => { - const eventStream = createAssistantMessageEventStream(); - const stream = eventStream as unknown as { push(event: unknown): void; end(): void }; - void (async () => { - const output: MutableAssistantOutput = { - role: "assistant" as const, - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; - let firstEventAbort: ReturnType | undefined; - try { - const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; - // The OpenAI SDK consumes the SSE terminal without yielding it. Observe - // the raw body so native tool calls can distinguish clean DONE from EOF. - const doneDetector = createSseDoneDetector(); - const baseFetch = buildGuardedModelFetch(model); - const doneDetectingFetch: typeof globalThis.fetch = async (url, init) => { - const response = await baseFetch(url as never, init); - if (!response.body || !response.ok) { - return response; - } - if (typeof TransformStream === "undefined" || !response.body.pipeThrough) { - return response; - } - const transformed = response.body.pipeThrough( - new TransformStream({ - transform(chunk, controller) { - doneDetector.observe(chunk); - controller.enqueue(chunk); - }, - flush() { - doneDetector.finish(); - }, - }), - ); - return new Response(transformed, { - headers: response.headers, - status: response.status, - statusText: response.statusText, - }); - }; - const client = createOpenAICompletionsClient(model, context, apiKey, options?.headers, { - fetch: doneDetectingFetch, - }); - let params = buildOpenAICompletionsParams( - model as OpenAIModeModel, - context, - options as OpenAICompletionsOptions | undefined, - ); - const nextParams = await options?.onPayload?.(params, model); - if (nextParams !== undefined) { - params = nextParams as typeof params; - } - if ( - (options as { openclawCodeModeToolSurface?: unknown } | undefined) - ?.openclawCodeModeToolSurface === true - ) { - enforceCodeModeResponsesToolSurface(params); - assertCodeModeResponsesToolSurface(params); - } - const compat = getCompat(model as OpenAIModeModel); - if (compat.requiresNonEmptyUserOrAssistantMessage) { - assertOpenAICompletionsPayloadHasConversationTurn(params, model); - } - const emitReasoning = shouldEmitOpenAICompletionsReasoning( - model as OpenAIModeModel, - options as OpenAICompletionsOptions | undefined, - ); - firstEventAbort = createFirstStreamEventAbortController(options?.signal); - const responseStream = (await client.chat.completions.create( - params as never, - buildOpenAISdkRequestOptions(model, firstEventAbort.signal), - )) as unknown as AsyncIterable; - stream.push({ type: "start", partial: output as never }); - await processOpenAICompletionsStream(responseStream, output, model, stream, { - signal: options?.signal, - emitReasoning, - firstEventTimeoutMs: getFirstStreamEventTimeoutMs(options), - abortFirstEventStream: firstEventAbort.abort, - onFirstEventTimeout: getFirstStreamEventTimeoutHandler(options), - sawStreamDONE: doneDetector.sawDone, - }); - finalizeTransportStream({ stream, output, signal: options?.signal }); - } catch (error) { - failTransportStream({ stream, output, signal: options?.signal, error }); - } finally { - firstEventAbort?.dispose(); - } - })(); - return eventStream as unknown as ReturnType; - }; -} - -async function processOpenAICompletionsStream( - responseStream: AsyncIterable, - output: MutableAssistantOutput, - model: Model, - stream: { push(event: unknown): void }, - options?: { - signal?: AbortSignal; - emitReasoning?: boolean; - firstEventTimeoutMs?: number; - abortFirstEventStream?: (reason: Error) => void; - onFirstEventTimeout?: (reason: Error) => void; - sawStreamDONE?: () => boolean; - }, -) { - const MAX_POST_TOOL_CALL_BUFFER_BYTES = 256_000; - const MAX_TOOL_CALL_ARGUMENT_BUFFER_BYTES = 256_000; - const emitReasoning = options?.emitReasoning ?? true; - const compat = getCompat(model as OpenAIModeModel); - const deepSeekTextFilter = shouldFilterDeepSeekDsmlText(compat) - ? createDeepSeekTextFilter() - : null; - const deepSeekToolCallRecoverer = shouldFilterDeepSeekDsmlText(compat) - ? createDeepSeekDsmlToolCallRecoverer() - : null; - const reasoningTagTextPartitioner = createReasoningTagTextPartitioner(); - type ToolCallBlock = { - type: "toolCall"; - id: string; - name: string; - arguments: Record; - partialArgs: string; - thoughtSignature?: string; - }; - let currentBlock: - | { type: "text"; text: string } - | { type: "thinking"; thinking: string; thinkingSignature?: string } - | ToolCallBlock - | null = null; - let pendingPostToolCallDeltas: CompletionsReasoningDelta[] = []; - let pendingPostToolCallBytes = 0; - let isFlushingPendingPostToolCallDeltas = false; - const toolCallBlocksByIndex = new Map(); - const toolCallBlocksById = new Map(); - const toolCallBlockBytes = new WeakMap(); - const toolCallBlockIndices = new WeakMap(); - let sawStopFinishReason = false; - let sawNativeToolCallDelta = false; - const blockIndex = () => output.content.length - 1; - const measureUtf8Bytes = (text: string) => Buffer.byteLength(text, "utf8"); - let chunkPushedEvent = false; - const pushStreamEvent = (event: unknown) => { - chunkPushedEvent = true; - stream.push(event); - }; - const finishCurrentBlock = () => { - if (!currentBlock) { - return; - } - if (currentBlock.type === "toolCall") { - currentBlock.arguments = parseStreamingJson(currentBlock.partialArgs); - } - }; - const finishAllToolCallBlocks = () => { - for (const block of toolCallBlocksByIndex.values()) { - block.arguments = parseStreamingJson(block.partialArgs); - } - }; - const queuePostToolCallDelta = (next: CompletionsReasoningDelta) => { - const nextBytes = measureUtf8Bytes(next.text); - if (pendingPostToolCallBytes + nextBytes > MAX_POST_TOOL_CALL_BUFFER_BYTES) { - throw new Error("Exceeded post-tool-call delta buffer limit"); - } - pendingPostToolCallBytes += nextBytes; - const previous = pendingPostToolCallDeltas[pendingPostToolCallDeltas.length - 1]; - if (!previous || previous.kind !== next.kind) { - pendingPostToolCallDeltas.push(next); - return; - } - if (next.kind === "thinking" && previous.kind === "thinking") { - if (previous.signature !== next.signature) { - pendingPostToolCallDeltas.push(next); - return; - } - previous.text += next.text; - return; - } - previous.text += next.text; - }; - const appendThinkingDeltaInternal = (reasoningDelta: { signature: string; text: string }) => { - if (!currentBlock || currentBlock.type !== "thinking") { - finishCurrentBlock(); - currentBlock = { - type: "thinking", - thinking: "", - ...(reasoningDelta.signature ? { thinkingSignature: reasoningDelta.signature } : {}), - }; - output.content.push(currentBlock); - pushStreamEvent({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); - } - currentBlock.thinking += reasoningDelta.text; - pushStreamEvent({ - type: "thinking_delta", - contentIndex: blockIndex(), - delta: reasoningDelta.text, - partial: output, - }); - }; - const appendTextDeltaInternal = (text: string) => { - if (!currentBlock || currentBlock.type !== "text") { - finishCurrentBlock(); - currentBlock = { type: "text", text: "" }; - output.content.push(currentBlock); - pushStreamEvent({ type: "text_start", contentIndex: blockIndex(), partial: output }); - } - currentBlock.text += text; - pushStreamEvent({ - type: "text_delta", - contentIndex: blockIndex(), - delta: text, - }); - }; - const flushPendingPostToolCallDeltas = () => { - if ( - isFlushingPendingPostToolCallDeltas || - currentBlock?.type === "toolCall" || - pendingPostToolCallDeltas.length === 0 - ) { - return; - } - isFlushingPendingPostToolCallDeltas = true; - const bufferedDeltas = pendingPostToolCallDeltas; - pendingPostToolCallDeltas = []; - pendingPostToolCallBytes = 0; - for (const delta of bufferedDeltas) { - if (delta.kind === "text") { - appendTextDeltaInternal(delta.text); - } else if (emitReasoning) { - appendThinkingDeltaInternal(delta); - } - } - isFlushingPendingPostToolCallDeltas = false; - }; - const appendThinkingDelta = (reasoningDelta: { signature: string; text: string }) => { - flushPendingPostToolCallDeltas(); - appendThinkingDeltaInternal(reasoningDelta); - }; - const appendTextDelta = (text: string) => { - flushPendingPostToolCallDeltas(); - appendTextDeltaInternal(text); - }; - const appendVisibleTextDelta = (text: string) => { - if (!text) { - return; - } - if (currentBlock?.type === "toolCall") { - queuePostToolCallDelta({ kind: "text", text }); - } else { - appendTextDelta(text); - } - }; - const appendRecoveredToolCall = (toolCall: RecoveredDeepSeekDsmlToolCall) => { - const switchingToolCall = currentBlock?.type === "toolCall"; - finishCurrentBlock(); - if (switchingToolCall) { - currentBlock = null; - flushPendingPostToolCallDeltas(); - } - const block: ToolCallBlock = { - type: "toolCall", - // DSML has no provider call id. A response-local counter would alias a - // later assistant response and could collapse distinct mutating calls. - id: `call_${randomUUID().replaceAll("-", "").slice(0, 24)}`, - name: toolCall.name, - arguments: toolCall.arguments, - partialArgs: toolCall.partialArgs, - }; - currentBlock = block; - output.content.push(block); - toolCallBlockIndices.set(block, output.content.length - 1); - pushStreamEvent({ - type: "toolcall_start", - contentIndex: toolCallBlockIndices.get(block) ?? -1, - partial: output, - }); - pushStreamEvent({ - type: "toolcall_delta", - contentIndex: toolCallBlockIndices.get(block) ?? -1, - delta: toolCall.partialArgs, - partial: output, - }); - }; - const appendFilteredVisibleTextDelta = (text: string) => { - const recoveredParts = deepSeekToolCallRecoverer?.push(text) ?? [ - { kind: "text" as const, text }, - ]; - for (const recoveredPart of recoveredParts) { - if (recoveredPart.kind === "toolCall") { - appendRecoveredToolCall(recoveredPart); - continue; - } - const parts = deepSeekTextFilter?.push(recoveredPart.text) ?? [recoveredPart.text]; - for (const part of parts) { - appendVisibleTextDelta(part); - } - } - }; - const flushDeepSeekToolCallRecovererAtEnd = () => { - const recoveredParts = deepSeekToolCallRecoverer?.flush(); - if (!recoveredParts) { - return; - } - for (const recoveredPart of recoveredParts) { - if (recoveredPart.kind === "toolCall") { - appendRecoveredToolCall(recoveredPart); - continue; - } - const parts = deepSeekTextFilter?.push(recoveredPart.text) ?? [recoveredPart.text]; - for (const part of parts) { - appendVisibleTextDelta(part); - } - } - }; - const flushDeepSeekTextFilterAtEnd = () => { - const parts = deepSeekTextFilter?.flush(); - if (!parts) { - return; - } - for (const part of parts) { - appendVisibleTextDelta(part); - } - }; - const appendRoutedContentDelta = (delta: CompletionsReasoningDelta) => { - if (delta.kind === "text") { - appendFilteredVisibleTextDelta(delta.text); - return; - } - if (!emitReasoning) { - return; - } - if (currentBlock?.type === "toolCall") { - queuePostToolCallDelta(delta); - } else { - appendThinkingDelta(delta); - } - }; - const appendPartitionedVisibleDelta = (delta: { kind: "text" | "thinking"; text: string }) => { - if (delta.kind === "text") { - appendFilteredVisibleTextDelta(delta.text); - } - }; - const emitReasoningUsageActivity = (hasReasoningUsageActivity: boolean) => { - if (!hasReasoningUsageActivity || chunkPushedEvent || !emitReasoning) { - return; - } - const latestBlock = output.content[output.content.length - 1]; - if (currentBlock?.type === "text" || currentBlock?.type === "toolCall") { - return; - } - if (latestBlock?.type === "text" || latestBlock?.type === "toolCall") { - return; - } - appendThinkingDelta({ signature: "", text: "" }); - }; - const flushReasoningTagTextPartitionerAtEnd = () => { - for (const delta of reasoningTagTextPartitioner.flush()) { - appendPartitionedVisibleDelta(delta); - } - }; - const cooperativeScheduler = createModelStreamCooperativeScheduler(options?.signal); - const guardedStream = withFirstStreamEventTimeout(responseStream as AsyncIterable, { - provider: model.provider, - api: model.api, - model: model.id, - timeoutMs: options?.firstEventTimeoutMs ?? 0, - stage: "completions", - abort: options?.abortFirstEventStream, - onTimeout: options?.onFirstEventTimeout, - hint: "The provider may be stalled while parsing the tool payload; retry with a smaller tool surface or enable OPENCLAW_DEBUG_MODEL_PAYLOAD=tools to inspect exposed tools.", - }); - for await (const rawChunk of guardedStream) { - throwIfModelStreamAborted(options?.signal); - chunkPushedEvent = false; - if (!rawChunk || typeof rawChunk !== "object") { - await cooperativeScheduler.afterEvent(); - continue; - } - const chunk = rawChunk as ChatCompletionChunk; - output.responseId ||= chunk.id; - let hasReasoningUsageActivity = false; - if (chunk.usage) { - output.usage = parseTransportChunkUsage(chunk.usage, model); - hasReasoningUsageActivity = hasOpenAICompletionsReasoningUsageActivity(chunk.usage); - } - const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined; - if (!choice) { - emitReasoningUsageActivity(hasReasoningUsageActivity); - await cooperativeScheduler.afterEvent(); - continue; - } - const choiceUsage = (choice as unknown as { usage?: ChatCompletionChunk["usage"] }).usage; - if (!chunk.usage && choiceUsage) { - output.usage = parseTransportChunkUsage(choiceUsage, model); - hasReasoningUsageActivity = hasOpenAICompletionsReasoningUsageActivity(choiceUsage); - } - if (choice.finish_reason) { - const finishReasonResult = mapOpenAIStopReason(choice.finish_reason, { - allowSingularToolCall: true, - }); - output.stopReason = finishReasonResult.stopReason; - if (finishReasonResult.stopReason === "stop") { - sawStopFinishReason = true; - } - if (finishReasonResult.errorMessage) { - output.errorMessage = finishReasonResult.errorMessage; - } - } - const choiceDelta = - choice.delta ?? - (choice as unknown as { message?: ChatCompletionChunk["choices"][number]["delta"] }).message; - if (!choiceDelta) { - emitReasoningUsageActivity(hasReasoningUsageActivity); - await cooperativeScheduler.afterEvent(); - continue; - } - const reasoningDeltas = getCompletionsReasoningDeltas( - choiceDelta as Record, - compat.visibleReasoningDetailTypes, - ); - const hasMirroredReasoning = reasoningDeltas.some((delta) => delta.kind === "thinking"); - if (hasMirroredReasoning) { - reasoningTagTextPartitioner.markStrict(); - } - if (choiceDelta.content) { - // Structured content can contain visible text and thinking blocks in the - // same delta, so route each extracted block through the normal stream path. - const contentDeltas = getCompletionsContentDeltas(choiceDelta.content); - for (const contentDelta of contentDeltas) { - if (contentDelta.kind === "text") { - const routedDeltas = hasMirroredReasoning - ? reasoningTagTextPartitioner.push(contentDelta.text) - : reasoningTagTextPartitioner.pushVisible(contentDelta.text); - for (const routedDelta of routedDeltas) { - appendPartitionedVisibleDelta(routedDelta); - } - } else { - reasoningTagTextPartitioner.markStrict(); - appendRoutedContentDelta(contentDelta); - } - } - } - // Chat Completions can put safety/structured-output refusals in a top-level - // `refusal` field with content null. Surface that as visible text so the - // assistant turn is not empty (Responses path already routes refusal deltas). - const refusalText = typeof choiceDelta.refusal === "string" ? choiceDelta.refusal : ""; - if (refusalText) { - const routedDeltas = hasMirroredReasoning - ? reasoningTagTextPartitioner.push(refusalText) - : reasoningTagTextPartitioner.pushVisible(refusalText); - for (const routedDelta of routedDeltas) { - appendPartitionedVisibleDelta(routedDelta); - } - } - for (const reasoningDelta of reasoningDeltas) { - if (reasoningDelta.kind === "thinking" && !emitReasoning) { - continue; - } - if (currentBlock?.type === "toolCall") { - queuePostToolCallDelta({ ...reasoningDelta }); - continue; - } - if (reasoningDelta.kind === "text") { - appendTextDelta(reasoningDelta.text); - } else if (emitReasoning) { - appendThinkingDelta(reasoningDelta); - } - } - if (choiceDelta.tool_calls && choiceDelta.tool_calls.length > 0) { - sawNativeToolCallDelta = true; - flushReasoningTagTextPartitionerAtEnd(); - for (const toolCall of choiceDelta.tool_calls) { - const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined; - let block = streamIndex !== undefined ? toolCallBlocksByIndex.get(streamIndex) : undefined; - if (!block && toolCall.id) { - block = toolCallBlocksById.get(toolCall.id); - } - if (!block) { - const switchingToolCall = currentBlock?.type === "toolCall"; - finishCurrentBlock(); - if (switchingToolCall) { - currentBlock = null; - flushPendingPostToolCallDeltas(); - } - const initialSig = extractGoogleThoughtSignature(toolCall); - block = { - type: "toolCall", - id: toolCall.id || "", - name: toolCall.function?.name || "", - arguments: {}, - partialArgs: "", - ...(initialSig ? { thoughtSignature: initialSig } : {}), - }; - output.content.push(block); - toolCallBlockIndices.set(block, output.content.length - 1); - pushStreamEvent({ - type: "toolcall_start", - contentIndex: toolCallBlockIndices.get(block) ?? -1, - partial: output, - }); - } - if (streamIndex !== undefined && !toolCallBlocksByIndex.has(streamIndex)) { - toolCallBlocksByIndex.set(streamIndex, block); - } - if (toolCall.id) { - block.id = toolCall.id; - toolCallBlocksById.set(toolCall.id, block); - } - currentBlock = block; - if (toolCall.function?.name) { - block.name = toolCall.function.name; - } - const deltaSig = extractGoogleThoughtSignature(toolCall); - if (deltaSig) { - block.thoughtSignature = deltaSig; - } - if (toolCall.function?.arguments) { - const nextArgumentBytes = measureUtf8Bytes(toolCall.function.arguments); - const currentBlockArgBytes = toolCallBlockBytes.get(block) ?? 0; - if (currentBlockArgBytes + nextArgumentBytes > MAX_TOOL_CALL_ARGUMENT_BUFFER_BYTES) { - throw new Error("Exceeded tool-call argument buffer limit"); - } - toolCallBlockBytes.set(block, currentBlockArgBytes + nextArgumentBytes); - block.partialArgs += toolCall.function.arguments; - block.arguments = parseStreamingJson(block.partialArgs); - pushStreamEvent({ - type: "toolcall_delta", - contentIndex: toolCallBlockIndices.get(block) ?? -1, - delta: toolCall.function.arguments, - partial: output, - }); - } - } - } - flushPendingPostToolCallDeltas(); - emitReasoningUsageActivity(hasReasoningUsageActivity); - await cooperativeScheduler.afterEvent(); - } - flushReasoningTagTextPartitionerAtEnd(); - flushDeepSeekToolCallRecovererAtEnd(); - flushDeepSeekTextFilterAtEnd(); - finishAllToolCallBlocks(); - currentBlock = null; - flushPendingPostToolCallDeltas(); - const hasToolCalls = output.content.some((block) => block.type === "toolCall"); - const hasVisibleText = output.content.some( - (block) => - block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0, - ); - if (output.stopReason === "toolUse" && !hasToolCalls) { - output.stopReason = "stop"; - } - // Promote complete silent tool-call-only responses when the stream finished - // cleanly (reached post-loop). Two paths: - // sawStopFinishReason: explicit provider terminal (legacy DSML / #88791) - // sawNativeToolCallDelta + sawStreamDONE: structured delta.tool_calls with - // a clean SSE [DONE] terminal but no finish_reason (e.g. Evolink - // DeepSeek V4). [DONE] tracking distinguishes clean termination from - // connection drops (EOF without [DONE] remains fail-closed). - // Truncated streams throw before reaching this code. - if ( - output.stopReason === "stop" && - hasToolCalls && - !hasVisibleText && - (sawStopFinishReason || (sawNativeToolCallDelta && (options?.sawStreamDONE?.() ?? false))) - ) { - output.stopReason = "toolUse"; - } - if (hasToolCalls && output.stopReason !== "toolUse") { - output.content = output.content.filter((block) => block.type !== "toolCall"); - } -} - -type CompletionsReasoningDelta = - | { - kind: "thinking"; - signature: string; - text: string; - } - | { - kind: "text"; - text: string; - }; - -function shouldFilterDeepSeekDsmlText(compat: ReturnType) { - return compat.thinkingFormat === "deepseek"; -} - -type RecoveredDeepSeekDsmlToolCall = { - kind: "toolCall"; - name: string; - arguments: Record; - partialArgs: string; -}; - -type DeepSeekDsmlRecoveredPart = { kind: "text"; text: string } | RecoveredDeepSeekDsmlToolCall; - -const DEEPSEEK_DSML_BARS = ["|", "|"] as const; -const DEEPSEEK_DSML_TOOL_KINDS = ["tool_calls", "tool_call", "function_calls"] as const; -const DEEPSEEK_DSML_TOOL_OPEN_TOKENS = DEEPSEEK_DSML_BARS.flatMap((bar) => - DEEPSEEK_DSML_TOOL_KINDS.map((kind) => `<${bar}DSML${bar}${kind}>`), -); -const DEEPSEEK_DSML_TOOL_CLOSE_TOKENS = DEEPSEEK_DSML_BARS.flatMap((bar) => - DEEPSEEK_DSML_TOOL_KINDS.map((kind) => ``), -); -const DEEPSEEK_DSML_TOOL_MAX_OPEN_TOKEN_LEN = Math.max( - ...DEEPSEEK_DSML_TOOL_OPEN_TOKENS.map((token) => token.length), -); - -function createDeepSeekDsmlToolCallRecoverer() { - let buffer = ""; - - const consume = (final: boolean): DeepSeekDsmlRecoveredPart[] => { - const output: DeepSeekDsmlRecoveredPart[] = []; - while (buffer) { - const open = findEarliestStringToken(buffer, DEEPSEEK_DSML_TOOL_OPEN_TOKENS); - if (!open) { - if (final) { - output.push({ kind: "text", text: buffer }); - buffer = ""; - return output; - } - const keep = longestDeepSeekDsmlToolOpenPrefixSuffixLength(buffer); - const emitLength = buffer.length - keep; - if (emitLength > 0) { - output.push({ kind: "text", text: buffer.slice(0, emitLength) }); - buffer = buffer.slice(emitLength); - } - return output; - } - - if (open.index > 0) { - output.push({ kind: "text", text: buffer.slice(0, open.index) }); - buffer = buffer.slice(open.index); - } - - const afterOpen = buffer.slice(open.token.length); - const close = findEarliestStringToken(afterOpen, DEEPSEEK_DSML_TOOL_CLOSE_TOKENS); - if (!close) { - if (final) { - output.push({ kind: "text", text: buffer }); - buffer = ""; - } - return output; - } - - const body = afterOpen.slice(0, close.index); - const blockLength = open.token.length + close.index + close.token.length; - const recoveredToolCalls = parseDeepSeekDsmlToolCallBlock(body); - if (recoveredToolCalls.length > 0) { - output.push(...recoveredToolCalls); - } else { - output.push({ kind: "text", text: buffer.slice(0, blockLength) }); - } - buffer = buffer.slice(blockLength); - } - return output; - }; - - return { - push(chunk: string) { - buffer += chunk; - return consume(false); - }, - flush() { - return consume(true); - }, - }; -} - -function parseDeepSeekDsmlToolCallBlock(body: string): RecoveredDeepSeekDsmlToolCall[] { - const toolCalls: RecoveredDeepSeekDsmlToolCall[] = []; - const invokeOpenRegex = /<[||]DSML[||]invoke\b([^>]*)>/g; - let openMatch: RegExpExecArray | null; - while ((openMatch = invokeOpenRegex.exec(body)) !== null) { - const invokeName = parseXmlAttribute(openMatch[1] ?? "", "name"); - if (!invokeName) { - continue; - } - const invokeBodyStart = openMatch.index + openMatch[0].length; - const invokeClose = findEarliestStringToken(body.slice(invokeBodyStart), [ - "", - "", - ]); - if (!invokeClose) { - continue; - } - const invokeBody = body.slice(invokeBodyStart, invokeBodyStart + invokeClose.index); - invokeOpenRegex.lastIndex = invokeBodyStart + invokeClose.index + invokeClose.token.length; - const parsedArguments = parseDeepSeekDsmlInvokeArguments(invokeBody); - if (!parsedArguments) { - continue; - } - toolCalls.push({ - kind: "toolCall", - name: invokeName, - arguments: parsedArguments, - partialArgs: JSON.stringify(parsedArguments), - }); - } - return toolCalls; -} - -function parseDeepSeekDsmlInvokeArguments(body: string): Record | null { - const args: Record = {}; - const parameterRegex = /<[||]DSML[||]parameter\b([^>]*)>([\s\S]*?)<\/[||]DSML[||]parameter>/g; - let parameterMatch: RegExpExecArray | null; - while ((parameterMatch = parameterRegex.exec(body)) !== null) { - const name = parseXmlAttribute(parameterMatch[1] ?? "", "name"); - if (!name) { - continue; - } - const rawValue = parameterMatch[2] ?? ""; - if (rawValue.length === 0) { - continue; - } - args[name] = decodeDeepSeekDsmlText(rawValue); - } - if (Object.keys(args).length > 0) { - return args; - } - - const trimmed = body.trim(); - if (!trimmed.startsWith("{")) { - return null; - } - try { - const parsed = JSON.parse(trimmed) as unknown; - if (isRecord(parsed) && Object.keys(parsed).length > 0) { - return parsed; - } - } catch { - return null; - } - return null; -} - -// Cache compiled attribute matchers by name so the streaming parser does not -// recompile a RegExp on every chunk/parameter it scans. -const xmlAttributeRegexCache = new Map(); - -function xmlAttributeRegex(name: string): RegExp { - const cached = xmlAttributeRegexCache.get(name); - if (cached) { - return cached; - } - const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const pattern = new RegExp(`\\b${escaped}=("([^"]*)"|'([^']*)'|([^\\s>]+))`); - xmlAttributeRegexCache.set(name, pattern); - return pattern; -} - -function parseXmlAttribute(attributes: string, name: string): string | null { - const match = xmlAttributeRegex(name).exec(attributes); - const value = match?.[2] ?? match?.[3] ?? match?.[4]; - return value ? decodeDeepSeekDsmlText(value) : null; -} - -function decodeDeepSeekDsmlText(value: string): string { - return value - .replaceAll(""", '"') - .replaceAll("'", "'") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll("&", "&"); -} - -function findEarliestStringToken(text: string, tokens: readonly string[]) { - let best: { index: number; token: string } | null = null; - for (const token of tokens) { - const index = text.indexOf(token); - if (index !== -1 && (!best || index < best.index)) { - best = { index, token }; - } - } - return best; -} - -function longestDeepSeekDsmlToolOpenPrefixSuffixLength(text: string) { - const maxLength = Math.min(text.length, DEEPSEEK_DSML_TOOL_MAX_OPEN_TOKEN_LEN - 1); - for (let length = maxLength; length > 0; length -= 1) { - const suffix = text.slice(text.length - length); - if (DEEPSEEK_DSML_TOOL_OPEN_TOKENS.some((token) => token.startsWith(suffix))) { - return length; - } - } - return 0; -} - -function getCompletionsContentDeltas(content: unknown): CompletionsReasoningDelta[] { - if (typeof content === "string") { - return content ? [{ kind: "text", text: content }] : []; - } - if (Array.isArray(content)) { - return content.flatMap((item) => getCompletionsContentDeltas(item)); - } - if (!content || typeof content !== "object") { - return []; - } - const record = content as Record; - const type = typeof record.type === "string" ? record.type.toLowerCase() : ""; - // Some OpenAI-compatible providers, notably Mistral thinking models, stream - // `delta.content` as typed objects. Never coerce those objects directly or - // they become persisted visible text like "[object Object]". - const extractText = (value: unknown): string => { - if (typeof value === "string") { - return value; - } - if (Array.isArray(value)) { - return value.map((item) => extractText(item)).join(""); - } - if (value && typeof value === "object") { - const nested = value as Record; - return extractText(nested.text ?? nested.content ?? nested.thinking); - } - return ""; - }; - const text = extractText(record.text ?? record.content ?? record.thinking); - if (!text) { - return []; - } - // Preserve provider reasoning as OpenClaw thinking blocks so channel/UI - // surfaces can decide whether to show it instead of leaking it as answer text. - if (type.includes("thinking") || type.includes("reasoning")) { - return [{ kind: "thinking", signature: "content", text }]; - } - if (type === "text" || type === "output_text" || type.endsWith(".output_text")) { - return [{ kind: "text", text }]; - } - return []; -} - -function getCompletionsReasoningDeltas( - delta: Record, - visibleReasoningDetailTypes: readonly string[], -): CompletionsReasoningDelta[] { - const output: CompletionsReasoningDelta[] = []; - const pushDelta = (next: CompletionsReasoningDelta) => { - const previous = output[output.length - 1]; - if (!previous || previous.kind !== next.kind) { - output.push(next); - return; - } - if (next.kind === "thinking" && previous.kind === "thinking") { - if (previous.signature !== next.signature) { - output.push(next); - return; - } - previous.text += next.text; - return; - } - previous.text += next.text; - }; - const reasoningDetails = delta.reasoning_details; - let usedReasoningThinkingDetails = false; - if (Array.isArray(reasoningDetails)) { - const visibleTypes = new Set(visibleReasoningDetailTypes); - for (const item of reasoningDetails) { - const detail = item as { type?: unknown; text?: unknown }; - if (typeof detail.text !== "string" || !detail.text) { - continue; - } - if (detail.type === "reasoning.text") { - usedReasoningThinkingDetails = true; - pushDelta({ kind: "thinking", signature: "reasoning_details", text: detail.text }); - continue; - } - if (typeof detail.type === "string" && visibleTypes.has(detail.type)) { - pushDelta({ kind: "text", text: detail.text }); - } - } - } - if (!usedReasoningThinkingDetails) { - const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"] as const; - for (const field of reasoningFields) { - const value = delta[field]; - if (typeof value === "string" && value.length > 0) { - pushDelta({ kind: "thinking", signature: field, text: value }); - break; - } - } - } - return output; -} - -function detectCompat(model: OpenAIModeModel) { - const { defaults: compatDefaults } = detectOpenAICompletionsCompat(model); - return { - supportsStore: compatDefaults.supportsStore, - supportsDeveloperRole: compatDefaults.supportsDeveloperRole, - supportsReasoningEffort: compatDefaults.supportsReasoningEffort, - reasoningEffortMap: {}, - supportsUsageInStreaming: compatDefaults.supportsUsageInStreaming, - maxTokensField: compatDefaults.maxTokensField, - requiresToolResultName: false, - requiresAssistantAfterToolResult: false, - requiresThinkingAsText: false, - thinkingFormat: compatDefaults.thinkingFormat, - visibleReasoningDetailTypes: compatDefaults.visibleReasoningDetailTypes, - openRouterRouting: {}, - vercelGatewayRouting: {}, - supportsStrictMode: compatDefaults.supportsStrictMode, - requiresReasoningContentOnAssistantMessages: - compatDefaults.requiresReasoningContentOnAssistantMessages, - requiresNonEmptyUserOrAssistantMessage: compatDefaults.requiresNonEmptyUserOrAssistantMessage, - }; -} - -function getCompat(model: OpenAIModeModel): { - supportsStore: boolean; - supportsDeveloperRole: boolean; - supportsReasoningEffort: boolean; - reasoningEffortMap: Record; - supportsUsageInStreaming: boolean; - maxTokensField: string; - requiresToolResultName: boolean; - requiresAssistantAfterToolResult: boolean; - requiresThinkingAsText: boolean; - thinkingFormat: string; - openRouterRouting: Record; - vercelGatewayRouting: Record; - supportsStrictMode: boolean; - supportsPromptCacheKey: boolean; - supportsLongCacheRetention: boolean; - requiresStringContent: boolean; - strictMessageKeys: boolean; - visibleReasoningDetailTypes: string[]; - requiresReasoningContentOnAssistantMessages: boolean; - requiresNonEmptyUserOrAssistantMessage: boolean; -} { - const detected = detectCompat(model); - const compat = model.compat ?? {}; - const supportsStore = - typeof compat.supportsStore === "boolean" ? compat.supportsStore : detected.supportsStore; - const supportsReasoningEffort = - typeof compat.supportsReasoningEffort === "boolean" - ? compat.supportsReasoningEffort - : detected.supportsReasoningEffort; - return { - supportsStore, - supportsDeveloperRole: compat.supportsDeveloperRole ?? detected.supportsDeveloperRole, - supportsReasoningEffort, - reasoningEffortMap: resolveOpenAIReasoningEffortMap(model, detected.reasoningEffortMap), - supportsUsageInStreaming: compat.supportsUsageInStreaming ?? detected.supportsUsageInStreaming, - maxTokensField: (compat.maxTokensField as string | undefined) ?? detected.maxTokensField, - requiresToolResultName: compat.requiresToolResultName ?? detected.requiresToolResultName, - requiresAssistantAfterToolResult: - compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult, - requiresThinkingAsText: compat.requiresThinkingAsText ?? detected.requiresThinkingAsText, - thinkingFormat: compat.thinkingFormat ?? detected.thinkingFormat, - openRouterRouting: (compat.openRouterRouting as Record | undefined) ?? {}, - vercelGatewayRouting: - (compat.vercelGatewayRouting as Record | undefined) ?? - detected.vercelGatewayRouting, - supportsStrictMode: compat.supportsStrictMode ?? detected.supportsStrictMode, - supportsPromptCacheKey: compat.supportsPromptCacheKey === true, - supportsLongCacheRetention: compat.supportsLongCacheRetention !== false, - requiresStringContent: compat.requiresStringContent ?? false, - strictMessageKeys: compat.strictMessageKeys === true, - visibleReasoningDetailTypes: - compat.visibleReasoningDetailTypes ?? detected.visibleReasoningDetailTypes, - requiresReasoningContentOnAssistantMessages: - compat.requiresReasoningContentOnAssistantMessages ?? - detected.requiresReasoningContentOnAssistantMessages, - requiresNonEmptyUserOrAssistantMessage: detected.requiresNonEmptyUserOrAssistantMessage, - }; -} - -type OpenAIResponsesRequestParams = { - model: string; - input: ResponseInput; - stream: true; - instructions?: string; - prompt_cache_key?: string; - prompt_cache_retention?: "24h"; - metadata?: Record; - store?: boolean; - max_output_tokens?: number; - temperature?: number; - top_p?: number; - text?: ResponseCreateParamsStreaming["text"]; - service_tier?: ResponseCreateParamsStreaming["service_tier"]; - tools?: FunctionTool[]; - tool_choice?: ResponseCreateParamsStreaming["tool_choice"]; - reasoning?: - | { effort: OpenAIApiReasoningEffort } - | { - effort: OpenAIApiReasoningEffort; - summary: NonNullable; - }; - include?: string[]; -}; - -function resolveOpenAICompletionsReasoningEffort(options: OpenAICompletionsOptions | undefined) { - return options?.reasoningEffort ?? options?.reasoning ?? "high"; -} - -function shouldEmitOpenAICompletionsReasoning( - model: OpenAIModeModel, - options: OpenAICompletionsOptions | undefined, -) { - if (!model.reasoning) { - return false; - } - const effort = resolveOpenAICompletionsReasoningEffort(options); - if (!effort || !isOpenAICompletionsThinkingEnabled(effort)) { - return false; - } - return true; -} - -function shouldEmitOpenAICompletionsReasoningForModel( - model: OpenAIModeModel, - options: OpenAICompletionsOptions | undefined, -) { - return shouldEmitOpenAICompletionsReasoning(model, options); -} - -function resolveOpenAICompletionsMaxTokens( - model: OpenAIModeModel, - options: OpenAICompletionsOptions | undefined, -): { maxTokens: number | undefined; clampToModelMaxTokens: boolean } { - if (options?.maxTokens) { - return { maxTokens: options.maxTokens, clampToModelMaxTokens: true }; - } - const paramsMaxTokens = resolveMaxTokensParam( - (model as { params?: Record }).params, - ); - if (paramsMaxTokens) { - return { maxTokens: paramsMaxTokens, clampToModelMaxTokens: false }; - } - return { maxTokens: model.maxTokens, clampToModelMaxTokens: false }; -} - -function resolveOpenAICompletionsModelMaxTokens(model: OpenAIModeModel): number | undefined { - return typeof model.maxTokens === "number" && - Number.isFinite(model.maxTokens) && - model.maxTokens > 0 - ? Math.floor(model.maxTokens) - : undefined; -} - -const OPENAI_COMPLETIONS_INPUT_TOKEN_SAFETY_MARGIN = 1.25; -const OPENAI_COMPLETIONS_IMAGE_CHAR_ESTIMATE = 8_000; - -// Used only to bound `max_completion_tokens` below the effective context cap -// for strict OpenAI-compatible servers (e.g. vLLM, StepFun). The CJK-aware -// helper avoids undercounting non-Latin prompts enough to trigger server-side -// context rejections; wrong-high here just trims output a little. Estimate the -// final shaped payload, not the raw context, so compat transforms and dropped -// replay turns are reflected in the output cap. -function estimateOpenAICompletionsInputTokens(payload: { - messages?: unknown; - tools?: unknown; - response_format?: unknown; -}): number { - let adjustedChars = 0; - adjustedChars += estimateOpenAICompletionsMessagesChars(payload.messages); - if (Array.isArray(payload.tools) && payload.tools.length > 0) { - try { - adjustedChars += estimateStringChars(JSON.stringify(payload.tools)); - } catch { - adjustedChars += 1024; - } - } - if (payload.response_format !== undefined) { - try { - adjustedChars += estimateStringChars(JSON.stringify(payload.response_format)); - } catch { - adjustedChars += 256; - } - } - return Math.ceil( - (adjustedChars / CHARS_PER_TOKEN_ESTIMATE) * OPENAI_COMPLETIONS_INPUT_TOKEN_SAFETY_MARGIN, - ); -} - -function estimateOpenAICompletionsMessagesChars(messages: unknown): number { - if (!Array.isArray(messages)) { - return 0; - } - let adjustedChars = 0; - for (const message of messages) { - if (!message || typeof message !== "object") { - continue; - } - const record = message as Record; - adjustedChars += estimateOpenAICompletionsContentChars(record.content); - for (const field of COMPLETIONS_REASONING_REPLAY_FIELDS) { - adjustedChars += estimateOpenAICompletionsContentChars(record[field]); - } - if (record.tool_calls !== undefined) { - try { - adjustedChars += estimateStringChars(JSON.stringify(record.tool_calls)); - } catch { - adjustedChars += 256; - } - } - } - return adjustedChars; -} - -function estimateOpenAICompletionsContentChars(value: unknown): number { - if (typeof value === "string") { - return estimateStringChars(value); - } - if (!Array.isArray(value)) { - return 0; - } - let adjustedChars = 0; - for (const block of value) { - if (!block || typeof block !== "object") { - continue; - } - const record = block as Record; - if (record.type === "image_url" || record.type === "input_image") { - adjustedChars += OPENAI_COMPLETIONS_IMAGE_CHAR_ESTIMATE; - continue; - } - const text = record.text; - if (typeof text === "string") { - adjustedChars += estimateStringChars(text); - continue; - } - try { - adjustedChars += estimateStringChars(JSON.stringify(block)); - } catch { - adjustedChars += 256; - } - } - return adjustedChars; -} - -function resolveOpenAICompletionsEffectiveContextTokens( - model: OpenAIModeModel, -): number | undefined { - const contextTokens = (model as { contextTokens?: number }).contextTokens; - if (typeof contextTokens === "number" && Number.isFinite(contextTokens) && contextTokens > 0) { - return contextTokens; - } - return typeof model.contextWindow === "number" && - Number.isFinite(model.contextWindow) && - model.contextWindow > 0 - ? model.contextWindow - : undefined; -} - -function isQwenOpenAICompletionsThinkingFormat(format: string): boolean { - return format === "qwen" || format === "qwen-chat-template"; -} - -function isOpenAICompletionsThinkingEnabled(effort: OpenAIReasoningEffort): boolean { - const normalized = effort.trim().toLowerCase(); - return normalized !== "off" && normalized !== "none"; -} - -function setQwenChatTemplateThinking(params: Record, enabled: boolean): void { - const existing = params.chat_template_kwargs; - params.chat_template_kwargs = - existing && typeof existing === "object" && !Array.isArray(existing) - ? { ...(existing as Record), enable_thinking: enabled } - : { enable_thinking: enabled }; -} - -function applyQwenOpenAICompletionsThinkingParams(params: { - compatThinkingFormat: string; - modelReasoning: boolean; - payload: Record; - requestedEffort: OpenAIReasoningEffort; -}): boolean { - if ( - !params.modelReasoning || - !isQwenOpenAICompletionsThinkingFormat(params.compatThinkingFormat) - ) { - return false; - } - const enabled = isOpenAICompletionsThinkingEnabled(params.requestedEffort); - if (params.compatThinkingFormat === "qwen-chat-template") { - setQwenChatTemplateThinking(params.payload, enabled); - } else { - params.payload.enable_thinking = enabled; - } - return true; -} - -function applyTogetherOpenAICompletionsThinkingParams(params: { - compatThinkingFormat: string; - modelReasoning: boolean; - payload: Record; - requestedEffort: OpenAIReasoningEffort; -}): boolean { - if (!params.modelReasoning || params.compatThinkingFormat !== "together") { - return false; - } - params.payload.reasoning = { - enabled: isOpenAICompletionsThinkingEnabled(params.requestedEffort), - }; - return true; -} - -function convertTools( - tools: NonNullable, - compat: ReturnType, - model: OpenAIModeModel, -) { - const projection = projectOpenAITools(tools); - const strict = resolveOpenAIStrictToolFlagWithDiagnostics( - projection, - resolveOpenAIStrictToolSetting(model, { - transport: "stream", - supportsStrictMode: compat?.supportsStrictMode, - }), - { - transport: "completions", - model, - }, - ); - return { - projection, - tools: sortTransportToolsByName(projection.tools).map((tool) => { - const functionTool: { - name: string; - description: string | undefined; - parameters: ReturnType; - strict?: boolean; - } = { - name: tool.name, - description: tool.description, - parameters: normalizeOpenAIStrictToolParameters( - tool.parameters, - strict === true, - model.compat, - ), - }; - if (strict !== undefined) { - functionTool.strict = strict; - } - return { - type: "function", - function: functionTool, - }; - }), - }; -} - -function compareTransportToolText(left: string | undefined, right: string | undefined): number { - const leftText = left ?? ""; - const rightText = right ?? ""; - if (leftText < rightText) { - return -1; - } - if (leftText > rightText) { - return 1; - } - return 0; -} - -function sortTransportToolsByName( - tools: readonly T[], -): T[] { - return tools.toSorted( - (left, right) => - compareTransportToolText(left.name, right.name) || - compareTransportToolText(left.description, right.description), - ); -} - -function extractGoogleThoughtSignature(toolCall: unknown): string | undefined { - const tc = toolCall as Record | undefined; - if (!tc) { - return undefined; - } - const extra = (tc.extra_content as Record | undefined)?.google as - | Record - | undefined; - const fromExtra = extra?.thought_signature; - if (typeof fromExtra === "string" && fromExtra.length > 0) { - return fromExtra; - } - const fromFunction = (tc.function as { thought_signature?: unknown } | undefined) - ?.thought_signature; - return typeof fromFunction === "string" && fromFunction.length > 0 ? fromFunction : undefined; -} - -function isGoogleOpenAICompatModel(model: OpenAIModeModel): boolean { - const endpointClass = detectOpenAICompletionsCompat(model as Model<"openai-completions">) - .capabilities.endpointClass; - return ( - model.provider === "google" || - endpointClass === "google-generative-ai" || - endpointClass === "google-vertex" - ); -} - -function requiresGoogleCompatToolCallThoughtSignature(model: OpenAIModeModel): boolean { - return isGoogleGemini3ProModel(model.id) || isGoogleGemini3FlashModel(model.id); -} - -const GOOGLE_COMPAT_THOUGHT_SIGNATURE_ELLIPSIS_RE = /[\u2026]|\.\.\./; -const GOOGLE_COMPAT_THOUGHT_SIGNATURE_BASE64_RE = /^[A-Za-z0-9+/=]+$/; - -function hasGoogleCompatThoughtSignatureTruncationFootprint(value: string): boolean { - return ( - GOOGLE_COMPAT_THOUGHT_SIGNATURE_ELLIPSIS_RE.test(value) || - (GOOGLE_COMPAT_THOUGHT_SIGNATURE_BASE64_RE.test(value) && value.length % 4 !== 0) - ); -} - -function injectToolCallThoughtSignatures( - outgoingMessages: unknown[], - context: Context, - model: OpenAIModeModel, -): void { - if (!isGoogleOpenAICompatModel(model)) { - return; - } - const sigById = new Map(); - const fallbackSig = requiresGoogleCompatToolCallThoughtSignature(model) - ? GEMINI_THOUGHT_SIGNATURE_VALIDATOR_SKIP - : undefined; - for (const msg of context.messages ?? []) { - if ((msg as { role?: string }).role !== "assistant") { - continue; - } - const source = msg as { api?: string; provider?: string; model?: string; content?: unknown }; - if (!Array.isArray(source.content)) { - continue; - } - for (const block of source.content as Array>) { - if (block.type !== "toolCall") { - continue; - } - const id = block.id; - const sig = block.thoughtSignature; - if (typeof id === "string" && typeof sig === "string" && sig.length > 0) { - const isSameRoute = - source.api === model.api && - source.provider === model.provider && - source.model === model.id; - if (!isSameRoute && !fallbackSig) { - continue; - } - sigById.set(id, isSameRoute ? sig : (fallbackSig ?? sig)); - } - } - } - if (sigById.size === 0 && !fallbackSig) { - return; - } - for (const message of outgoingMessages) { - const toolCalls = (message as { tool_calls?: unknown }).tool_calls; - if (!Array.isArray(toolCalls)) { - continue; - } - for (const toolCall of toolCalls as Array>) { - const id = toolCall.id; - if (typeof id !== "string") { - continue; - } - let sig: string | undefined = sigById.get(id) ?? fallbackSig; - if (typeof sig === "string" && sig.length > 0) { - const trimmed = sig.trim(); - if (hasGoogleCompatThoughtSignatureTruncationFootprint(trimmed)) { - sig = fallbackSig; - } - } - if (typeof sig !== "string" || sig.length === 0) { - continue; - } - const extra = - toolCall.extra_content && typeof toolCall.extra_content === "object" - ? (toolCall.extra_content as Record) - : {}; - toolCall.extra_content = extra; - const google = - extra.google && typeof extra.google === "object" - ? (extra.google as Record) - : {}; - extra.google = google; - google.thought_signature = sig; - } - } -} - -const COMPLETIONS_REASONING_REPLAY_FIELDS = [ - "reasoning_details", - "reasoning_content", - "reasoning", - "reasoning_text", -] as const; - -function stripCompletionsReasoningReplayFields(record: Record): void { - for (const field of COMPLETIONS_REASONING_REPLAY_FIELDS) { - if (field in record) { - delete record[field]; - } - } -} - -function sanitizeOpenRouterReasoningReplayFields(record: Record): void { - const reasoningDetails = record.reasoning_details; - if (typeof reasoningDetails === "string") { - if (reasoningDetails.length > 0 && typeof record.reasoning !== "string") { - record.reasoning = reasoningDetails; - } - delete record.reasoning_details; - } else if (reasoningDetails !== undefined && !Array.isArray(reasoningDetails)) { - delete record.reasoning_details; - } - - // Empty reasoning artifacts are rejected by OpenRouter/DeepSeek replay. - if ("reasoning" in record && (typeof record.reasoning !== "string" || record.reasoning === "")) { - delete record.reasoning; - } - if ( - "reasoning_content" in record && - (typeof record.reasoning_content !== "string" || record.reasoning_content === "") - ) { - delete record.reasoning_content; - } - - const reasoningText = record.reasoning_text; - if ( - typeof reasoningText === "string" && - reasoningText.length > 0 && - typeof record.reasoning !== "string" && - typeof record.reasoning_content !== "string" - ) { - record.reasoning = reasoningText; - } - if ("reasoning_text" in record) { - delete record.reasoning_text; - } -} - -function sanitizeReasoningContentReplayFields(record: Record): void { - if ("reasoning_content" in record && typeof record.reasoning_content !== "string") { - delete record.reasoning_content; - } - delete record.reasoning_details; - delete record.reasoning; - delete record.reasoning_text; -} - -const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([ - "deepseek-v4-flash", - "deepseek-v4-pro", - "kimi-for-coding", - "kimi-k2.5", - "kimi-k2.6", - "kimi-k2.7-code", - "kimi-k2-thinking", - "kimi-k2-thinking-turbo", - "mimo-v2-pro", - "mimo-v2-omni", - "mimo-v2.5", - "mimo-v2.5-pro", - "mimo-v2.6-pro", -]); - -// Tier/access suffixes that some providers append to otherwise identical model -// ids (OpenCode Zen exposes `deepseek-v4-flash-free`, OpenRouter exposes -// `:free` / `:cloud`, etc.). The base model id before the suffix still owns -// the same DeepSeek-style reasoning_content replay contract, so reasoning -// replay must not be stripped just because the catalog id grew a marketing -// suffix (#87575). -const REASONING_CONTENT_REPLAY_TIER_SUFFIXES = ["-free", "-paid", "-trial"] as const; - -function stripReasoningContentReplayTierSuffix(modelId: string): string { - for (const suffix of REASONING_CONTENT_REPLAY_TIER_SUFFIXES) { - if (modelId.length > suffix.length && modelId.endsWith(suffix)) { - return modelId.slice(0, -suffix.length); - } - } - return modelId; -} - -function getReasoningContentReplayModelIdCandidates(modelId: unknown): string[] { - if (typeof modelId !== "string") { - return []; - } - const normalized = modelId.trim().toLowerCase(); - if (!normalized) { - return []; - } - const parts = normalized.split("/").filter(Boolean); - const finalPart = parts[parts.length - 1] ?? normalized; - const candidates = [finalPart]; - const colonParts = finalPart.split(":").filter(Boolean); - if (colonParts.length > 1) { - candidates.push(colonParts[0] ?? "", colonParts[colonParts.length - 1] ?? ""); - } - const baseCount = candidates.length; - for (let index = 0; index < baseCount; index += 1) { - const candidate = candidates[index]; - if (typeof candidate !== "string") { - continue; - } - const stripped = stripReasoningContentReplayTierSuffix(candidate); - if (stripped !== candidate) { - candidates.push(stripped); - } - } - return uniqueStrings(candidates.filter(Boolean)); -} - -function shouldPreserveReasoningContentReplay( - model: OpenAIModeModel, - compat: { requiresReasoningContentOnAssistantMessages: boolean; thinkingFormat: string }, -): boolean { - if ( - compat.requiresReasoningContentOnAssistantMessages || - compat.thinkingFormat === "deepseek" || - compat.thinkingFormat === "zai" || - shouldTrustReasoningContentReplayMetadata(model) - ) { - return true; - } - return getReasoningContentReplayModelIdCandidates(model.id).some((modelId) => - REASONING_CONTENT_REPLAY_MODEL_IDS.has(modelId), - ); -} - -function shouldPreserveOpenRouterReasoningReplay(model: OpenAIModeModel): boolean { - if (model.provider !== "openrouter") { - return true; - } - const normalizedModelId = model.id.trim().toLowerCase(); - return !(normalizedModelId.startsWith("anthropic/") || normalizedModelId.startsWith("x-ai/")); -} - -function shouldTrustReasoningContentReplayMetadata(model: OpenAIModeModel): boolean { - if (!model.reasoning) { - return false; - } - const provider = model.provider.trim().toLowerCase(); - if (provider === "openai") { - return false; - } - return shouldPreserveOpenRouterReasoningReplay(model); -} - -// OpenAI Chat Completions assistant-message input does not define reasoning -// replay fields, while OpenRouter and DeepSeek-style providers document -// compatible pass-back contracts. Keep valid provider-owned replay fields, but -// strip them for stock OpenAI before a follow-up request hits the wire. -function sanitizeCompletionsReasoningReplayFields( - messages: unknown, - options: { preserveOpenRouterReasoning: boolean; preserveReasoningContent: boolean }, -): void { - if (!Array.isArray(messages)) { - return; - } - for (const msg of messages) { - if (!msg || typeof msg !== "object") { - continue; - } - const record = msg as Record; - if (record.role !== "assistant") { - continue; - } - if (options.preserveOpenRouterReasoning) { - sanitizeOpenRouterReasoningReplayFields(record); - } else if (options.preserveReasoningContent) { - sanitizeReasoningContentReplayFields(record); - } else { - stripCompletionsReasoningReplayFields(record); - } - } -} - +// Keep this SDK-exported declaration anchored to the long-lived facade while the +// completions implementation remains independently owned. export function buildOpenAICompletionsParams( model: OpenAIModeModel, context: Context, options: OpenAICompletionsOptions | undefined, -) { - const compat = getCompat(model); - const compatDetection = detectOpenAICompletionsCompat(model); - const completionsContext = context.systemPrompt - ? { - ...context, - systemPrompt: stripSystemPromptCacheBoundary(context.systemPrompt), - } - : context; - let messages = convertMessages(model as never, completionsContext, compat as never); - injectToolCallThoughtSignatures(messages as unknown[], context, model); - sanitizeCompletionsReasoningReplayFields(messages, { - preserveOpenRouterReasoning: - compat.thinkingFormat === "openrouter" && shouldPreserveOpenRouterReasoningReplay(model), - preserveReasoningContent: shouldPreserveReasoningContentReplay(model, compat), - }); - if (compat.strictMessageKeys) { - messages = stripCompletionMessagesToRoleContent(messages) as typeof messages; - } - const cacheRetention = resolveCacheRetention(options?.cacheRetention); - const promptCacheKey = resolvePromptCacheKey(options, cacheRetention); - const params: Record = { - model: model.id, - messages: compat.requiresStringContent - ? flattenCompletionMessagesToStringContent(messages) - : messages, - stream: true, - }; - if (compat.supportsUsageInStreaming) { - params.stream_options = { include_usage: true }; - } - if (compat.supportsStore) { - params.store = false; - } - if (compat.supportsPromptCacheKey && promptCacheKey) { - params.prompt_cache_key = promptCacheKey; - // When the caller explicitly opted into long retention, forward the - // canonical prompt_cache_retention value alongside the cache key so - // OpenAI-compatible completions backends (oMLX, llama.cpp, official - // OpenAI, etc.) can honor the 24h prefix-cache lifetime. Without this - // the key reaches the wire but the retention preference is silently - // dropped (issue #81281). - if (cacheRetention === "long" && compat.supportsLongCacheRetention) { - params.prompt_cache_retention = "24h"; - } - } - if (options?.temperature !== undefined) { - params.temperature = options.temperature; - } - if (options?.topP !== undefined) { - params.top_p = options.topP; - } - if (options?.responseFormat !== undefined) { - params.response_format = options.responseFormat; - } - if (options?.frequencyPenalty !== undefined) { - params.frequency_penalty = options.frequencyPenalty; - } - if (options?.presencePenalty !== undefined) { - params.presence_penalty = options.presencePenalty; - } - if (options?.seed !== undefined) { - params.seed = options.seed; - } - if (options?.stop !== undefined && options.stop.length > 0) { - params.stop = options.stop; - } - if (supportsModelTools(model)) { - if (context.tools) { - const converted = convertTools(context.tools, compat, model); - if ( - converted.tools.length > 0 || - (converted.projection.inputToolCount === 0 && converted.projection.diagnostics.length === 0) - ) { - params.tools = converted.tools; - } else if (hasToolHistory(context.messages)) { - params.tools = []; - } - if (options?.toolChoice) { - const toolChoice = reconcileOpenAICompletionsToolChoice( - options.toolChoice, - converted.projection, - ); - if (toolChoice !== undefined) { - params.tool_choice = toolChoice; - } - } else if ( - compatDetection.capabilities.usesExplicitProxyLikeEndpoint && - Array.isArray(params.tools) && - params.tools.length > 0 - ) { - params.tool_choice = "auto"; - } - } else if (hasToolHistory(context.messages)) { - params.tools = []; - } - if ( - compatDetection.capabilities.usesExplicitProxyLikeEndpoint && - Array.isArray(params.tools) && - params.tools.length === 0 - ) { - delete params.tools; - delete params.tool_choice; - } - } - { - const maxTokenBudget = resolveOpenAICompletionsMaxTokens(model, options); - const effectiveMaxTokens = maxTokenBudget.maxTokens; - const effectiveContextTokens = resolveOpenAICompletionsEffectiveContextTokens(model); - let clampedMaxTokens = effectiveMaxTokens; - const modelMaxTokens = resolveOpenAICompletionsModelMaxTokens(model); - if ( - maxTokenBudget.clampToModelMaxTokens && - clampedMaxTokens !== undefined && - modelMaxTokens !== undefined && - clampedMaxTokens > modelMaxTokens - ) { - clampedMaxTokens = modelMaxTokens; - emitModelTransportDebug( - log, - `[completions] clamp_max_tokens provider=${model.provider} api=${model.api} ` + - `model=${model.id} requested=${effectiveMaxTokens} output=${clampedMaxTokens} ` + - `modelMaxTokens=${modelMaxTokens}`, - ); - } - if ( - compatDetection.capabilities.usesExplicitProxyLikeEndpoint && - clampedMaxTokens !== undefined && - effectiveContextTokens !== undefined - ) { - const estimatedInputTokens = estimateOpenAICompletionsInputTokens(params); - const remainingBudget = Math.max(1, effectiveContextTokens - estimatedInputTokens - 1); - if (clampedMaxTokens > remainingBudget) { - clampedMaxTokens = remainingBudget; - emitModelTransportDebug( - log, - `[completions] clamp_max_tokens provider=${model.provider} api=${model.api} ` + - `model=${model.id} requested=${effectiveMaxTokens} output=${clampedMaxTokens} ` + - `effectiveContext=${effectiveContextTokens} estimatedInput=${estimatedInputTokens}`, - ); - } - } - if (clampedMaxTokens) { - if (compat.maxTokensField === "max_tokens") { - params.max_tokens = clampedMaxTokens; - } else { - params.max_completion_tokens = clampedMaxTokens; - } - } - } - const completionsReasoningEffort = resolveOpenAICompletionsReasoningEffort(options); - const resolvedCompletionsReasoningEffort = completionsReasoningEffort - ? resolveOpenAIReasoningEffortForModel({ - model, - effort: completionsReasoningEffort, - fallbackMap: compat.reasoningEffortMap, - }) - : undefined; - const omitChatCompletionsToolReasoningEffort = - Array.isArray(params.tools) && - params.tools.length > 0 && - (isOpenAIGpt54MiniModel(model) || - (isOpenAIGpt55Model(model) && isKnownOpenAICompletionsEndpoint(model))); - const disableChatCompletionsToolReasoning = - Array.isArray(params.tools) && - params.tools.length > 0 && - isOpenAIGpt56Model(model) && - isKnownOpenAICompletionsEndpoint(model); - const handledQwenThinkingFormat = applyQwenOpenAICompletionsThinkingParams({ - compatThinkingFormat: compat.thinkingFormat, - modelReasoning: model.reasoning, - payload: params, - requestedEffort: completionsReasoningEffort, - }); - applyTogetherOpenAICompletionsThinkingParams({ - compatThinkingFormat: compat.thinkingFormat, - modelReasoning: model.reasoning, - payload: params, - requestedEffort: completionsReasoningEffort, - }); - if (disableChatCompletionsToolReasoning) { - // GPT-5.6 Chat Completions defaults reasoning on, but rejects function - // tools unless reasoning is explicitly disabled. - params.reasoning_effort = "none"; - } else if ( - compat.thinkingFormat === "openrouter" && - model.reasoning && - resolvedCompletionsReasoningEffort - ) { - params.reasoning = { - effort: resolvedCompletionsReasoningEffort, - }; - } else if ( - resolvedCompletionsReasoningEffort && - model.reasoning && - compat.supportsReasoningEffort && - !handledQwenThinkingFormat && - !omitChatCompletionsToolReasoningEffort - ) { - params.reasoning_effort = resolvedCompletionsReasoningEffort; - } - return params; -} - -export function parseTransportChunkUsage( - rawUsage: NonNullable & { cost?: unknown }, - model: Model, -): MutableAssistantOutput["usage"] { - const cachedTokens = rawUsage.prompt_tokens_details?.cached_tokens || 0; - const promptTokens = rawUsage.prompt_tokens || 0; - const input = Math.max(0, promptTokens - cachedTokens); - const outputTokens = rawUsage.completion_tokens || 0; - const reasoningTokens = rawUsage.completion_tokens_details?.reasoning_tokens; - const usage: MutableAssistantOutput["usage"] = { - input, - output: outputTokens, - cacheRead: cachedTokens, - cacheWrite: 0, - ...(typeof reasoningTokens === "number" && Number.isFinite(reasoningTokens) - ? { reasoningTokens } - : {}), - totalTokens: input + outputTokens + cachedTokens, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }; - calculateCost(model as never, usage as never); - applyProviderReportedUsageCost(usage, rawUsage.cost); - return usage; -} - -function hasOpenAICompletionsReasoningUsageActivity( - rawUsage: NonNullable, -) { - const reasoningTokens = rawUsage.completion_tokens_details?.reasoning_tokens; - return ( - typeof reasoningTokens === "number" && Number.isFinite(reasoningTokens) && reasoningTokens > 0 - ); +): Record { + return buildOpenAICompletionsParamsImpl(model, context, options); } export const testing = { - getCompat, - assertCodeModeResponsesToolSurface, - buildOpenAIClientHeaders, - buildOpenAISdkClientOptions, - buildOpenAISdkRequestOptions, - createAzureOpenAIClient, - createSseDoneDetector, - createOpenAICompletionsClient, - createOpenAIResponsesClient, - enforceCodeModeResponsesToolSurface, - sanitizeOpenAICodexResponsesParams, - buildOpenAICompletionsClientConfig, - processOpenAICompletionsStream, - processResponsesStream, - shouldEmitOpenAICompletionsReasoningForModel, - formatModelTransportDebugBaseUrl, - buildResponsesFailedNoDetailsObservation, - buildOpenAIResponsesReasoningReplayMetadata, - isInvalidEncryptedContentError, - normalizeResponsesFailedEvent, - prepareOpenAIResponsesReasoningItemForReplay, - createResponsesStreamWithEncryptedContentRetry, - stripResponsesRequestEncryptedContent, - tagOpenAIResponsesReasoningReplayItem, - summarizeResponsesFailedNoDetailsObservation, - summarizeResponsesPayload, - summarizeResponsesTools, - stringifyRedactedEvent, - stringifyRedactedPayload, + ...responsesTesting, + ...completionsTesting, }; export { testing as __testing }; diff --git a/src/gateway/server-methods/chat-assistant-content.ts b/src/gateway/server-methods/chat-assistant-content.ts new file mode 100644 index 000000000000..8dc1364724cc --- /dev/null +++ b/src/gateway/server-methods/chat-assistant-content.ts @@ -0,0 +1,321 @@ +import { expectDefined } from "@openclaw/normalization-core"; +import { + readPairingQrReplyChannelData, + type ReplyPayload, +} from "../../auto-reply/reply-payload.js"; +import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads.js"; +import { renderQrPngDataUrl } from "../../media/qr-image.js"; +import { renderQrTerminal } from "../../media/qr-terminal.js"; +import { stripInlineDirectiveTagsForDisplay } from "../../utils/directive-tags.js"; +import { stripEnvelopeFromMessage } from "../chat-sanitize.js"; +import { + cleanupManagedOutgoingImageRecords, + createManagedOutgoingImageBlocks, +} from "../managed-image-attachments.js"; +import { formatForLog } from "../ws-log.js"; +import { buildWebchatAudioContentBlocksFromReplyPayloads } from "./chat-webchat-media.js"; +import type { GatewayRequestContext } from "./types.js"; + +const MANAGED_OUTGOING_IMAGE_PATH_PREFIX = "/api/chat/media/outgoing/"; +const chatHistoryManagedImageCleanupState = new Map>(); + +export type AssistantDisplayContentBlock = Record; + +export function isMediaBearingPayload(payload: ReplyPayload): boolean { + if (payload.isReasoning === true) { + return false; + } + if (payload.mediaUrl?.trim()) { + return true; + } + return Boolean(payload.mediaUrls?.some((url) => url.trim())); +} + +export function hasSensitiveMediaPayload(payloads: ReplyPayload[]): boolean { + return payloads.some( + (payload) => + payload.sensitiveMedia === true && + (isMediaBearingPayload(payload) || Boolean(readPairingQrReplyChannelData(payload))), + ); +} + +async function buildPairingQrAssistantContentBlock( + payload: ReplyPayload, +): Promise { + const qr = readPairingQrReplyChannelData(payload); + if (!qr) { + return undefined; + } + const [imageUrl, terminalText] = await Promise.all([ + renderQrPngDataUrl(qr.setupCode), + renderQrTerminal(qr.setupCode, { small: true }), + ]); + return { + type: "openclaw_pairing_qr", + image_url: imageUrl, + terminalText, + alt: "OpenClaw pairing QR code", + expiresAtMs: qr.expiresAtMs, + sensitive: true, + }; +} + +export function sanitizeAssistantDisplayText(value?: string | null): string | undefined { + if (!value) { + return undefined; + } + const withoutEnvelope = stripEnvelopeFromMessage(value); + const normalized = typeof withoutEnvelope === "string" ? withoutEnvelope : value; + const stripped = stripInlineDirectiveTagsForDisplay(normalized).text.trim(); + return stripped || undefined; +} + +export function extractAssistantDisplayTextFromContent( + content?: readonly AssistantDisplayContentBlock[] | null, +): string | undefined { + if (!Array.isArray(content) || content.length === 0) { + return undefined; + } + const parts = content + .map((block) => { + if (block?.type !== "text" || typeof block.text !== "string") { + return ""; + } + return block.text.trim(); + }) + .filter(Boolean); + return parts.length > 0 ? parts.join("\n\n") : undefined; +} + +export async function buildAssistantDisplayContentFromReplyPayloads(params: { + sessionKey: string; + agentId?: string; + payloads: ReplyPayload[]; + managedImageLocalRoots?: Parameters[0]["localRoots"]; + includeSensitiveMedia?: boolean; + includeSensitiveDisplay?: boolean; + onLocalAudioAccessDenied?: (message: string) => void; + onManagedImagePrepareError?: (message: string) => void; + onSensitiveDisplayPrepareError?: (message: string) => void; +}): Promise { + const rawTextPayloadCount = params.payloads.filter( + (payload) => + payload.isReasoning !== true && + typeof payload.text === "string" && + payload.text.trim().length > 0, + ).length; + const normalized = normalizeReplyPayloadsForDelivery(params.payloads); + if (normalized.length === 0) { + return rawTextPayloadCount > 0 ? [{ type: "text", text: "" }] : undefined; + } + + const content: AssistantDisplayContentBlock[] = []; + let strippedTextPayloadCount = 0; + for (const payload of normalized) { + const text = sanitizeAssistantDisplayText(payload.text); + if (text) { + content.push({ type: "text", text }); + } else if (typeof payload.text === "string" && payload.text.trim().length > 0) { + strippedTextPayloadCount += 1; + } + if (params.includeSensitiveDisplay === true) { + try { + const pairingQrBlock = await buildPairingQrAssistantContentBlock(payload); + if (pairingQrBlock) { + content.push(pairingQrBlock); + } + } catch (err) { + params.onSensitiveDisplayPrepareError?.(formatForLog(err)); + } + } + if (params.includeSensitiveMedia === false && payload.sensitiveMedia === true) { + continue; + } + const audioBlocks = await buildWebchatAudioContentBlocksFromReplyPayloads([payload], { + localRoots: Array.isArray(params.managedImageLocalRoots) + ? params.managedImageLocalRoots + : undefined, + onLocalAudioAccessDenied: (err) => { + params.onLocalAudioAccessDenied?.(formatForLog(err)); + }, + }); + content.push(...audioBlocks); + + const mediaUrls = Array.from( + new Set([ + ...(Array.isArray(payload.mediaUrls) ? payload.mediaUrls : []), + ...(typeof payload.mediaUrl === "string" ? [payload.mediaUrl] : []), + ]), + ); + const imageBlocks = await createManagedOutgoingImageBlocks({ + sessionKey: params.sessionKey, + ...(params.sessionKey === "global" && params.agentId ? { agentId: params.agentId } : {}), + mediaUrls, + localRoots: params.managedImageLocalRoots, + continueOnPrepareError: true, + onPrepareError: (error) => { + params.onManagedImagePrepareError?.(error.message); + }, + }); + if (imageBlocks.length > 0) { + content.push(...imageBlocks); + } + } + + if (content.length > 0) { + return content; + } + return strippedTextPayloadCount > 0 ? [{ type: "text", text: "" }] : undefined; +} + +export function replaceAssistantContentTextBlocks( + content: readonly AssistantDisplayContentBlock[] | undefined, + transcriptMediaMessage: { content: Array> } | null, +): AssistantDisplayContentBlock[] | undefined { + const transcriptTextBlocks = (transcriptMediaMessage?.content ?? []).filter( + (block): block is AssistantDisplayContentBlock => + Boolean(block) && + typeof block === "object" && + block.type === "text" && + typeof block.text === "string", + ); + if (transcriptTextBlocks.length === 0) { + return content ? [...content] : undefined; + } + if (!content || content.length === 0) { + return [...transcriptTextBlocks]; + } + const merged: AssistantDisplayContentBlock[] = []; + let transcriptTextIndex = 0; + for (const block of content) { + if ( + block?.type === "text" && + typeof block.text === "string" && + transcriptTextIndex < transcriptTextBlocks.length + ) { + merged.push( + expectDefined( + transcriptTextBlocks[transcriptTextIndex++], + "transcript text blocks entry at transcript text index++", + ), + ); + continue; + } + merged.push(block); + } + if (transcriptTextIndex < transcriptTextBlocks.length) { + merged.unshift(...transcriptTextBlocks.slice(transcriptTextIndex)); + } + return merged; +} + +function isManagedOutgoingImageUrl(value: unknown): boolean { + if (typeof value !== "string" || !value.trim()) { + return false; + } + try { + const parsed = new URL(value, "http://localhost"); + return parsed.pathname.startsWith(MANAGED_OUTGOING_IMAGE_PATH_PREFIX); + } catch { + return false; + } +} + +export function stripManagedOutgoingAssistantContentBlocks( + content: readonly AssistantDisplayContentBlock[] | undefined, +): AssistantDisplayContentBlock[] | undefined { + if (!content || content.length === 0) { + return undefined; + } + const filtered = content.filter((block) => { + if (block?.type !== "image") { + return true; + } + return !(isManagedOutgoingImageUrl(block.url) || isManagedOutgoingImageUrl(block.openUrl)); + }); + return filtered.length > 0 ? filtered : undefined; +} + +export function extractAssistantDisplayText( + content: readonly AssistantDisplayContentBlock[] | undefined, +): string | undefined { + if (!content || content.length === 0) { + return undefined; + } + const text = content + .map((block) => (block?.type === "text" && typeof block.text === "string" ? block.text : "")) + .filter(Boolean) + .join("\n\n") + .trim(); + return text || undefined; +} + +export function hasAssistantDisplayMediaContent( + content: readonly AssistantDisplayContentBlock[] | undefined, +): boolean { + return Boolean(content?.some((block) => block?.type !== "text")); +} + +export function hasVisibleAssistantFinalMessage( + message: Record | undefined, +): boolean { + if (!message) { + return false; + } + if (typeof message.text === "string" && message.text.trim()) { + return true; + } + const content = Array.isArray(message.content) ? message.content : []; + return content.some((block) => { + if (!block || typeof block !== "object") { + return false; + } + const record = block as Record; + if (record.type === "text") { + return typeof record.text === "string" && record.text.trim().length > 0; + } + return true; + }); +} + +export function hasManagedOutgoingAssistantContent( + content: readonly AssistantDisplayContentBlock[] | undefined, +): boolean { + return Boolean( + content?.some( + (block) => + block?.type === "image" && + (isManagedOutgoingImageUrl(block.url) || isManagedOutgoingImageUrl(block.openUrl)), + ), + ); +} + +export function scheduleChatHistoryManagedImageCleanup(params: { + sessionKey: string; + agentId?: string; + context: Pick; +}) { + const cleanupKey = + params.sessionKey === "global" && params.agentId + ? `agent:${params.agentId}:global` + : params.sessionKey; + if (chatHistoryManagedImageCleanupState.has(cleanupKey)) { + return; + } + const pending = cleanupManagedOutgoingImageRecords({ + sessionKey: params.sessionKey, + ...(params.sessionKey === "global" && params.agentId ? { agentId: params.agentId } : {}), + }) + .then(() => undefined) + .catch((error: unknown) => { + params.context.logGateway.debug( + `chat.history managed image cleanup skipped sessionKey=${JSON.stringify(params.sessionKey)} error=${formatForLog(error)}`, + ); + }) + .finally(() => { + if (chatHistoryManagedImageCleanupState.get(cleanupKey) === pending) { + chatHistoryManagedImageCleanupState.delete(cleanupKey); + } + }); + chatHistoryManagedImageCleanupState.set(cleanupKey, pending); +} diff --git a/src/gateway/server-methods/chat-history-budget.test.ts b/src/gateway/server-methods/chat-history-budget.test.ts index 9f5a4cbd912c..bb8a5b59029d 100644 --- a/src/gateway/server-methods/chat-history-budget.test.ts +++ b/src/gateway/server-methods/chat-history-budget.test.ts @@ -1,7 +1,7 @@ // Covers the chat.history final byte-budget fallback, including the sentinel // that prevents an empty (blank) transcript from being returned to the dashboard. import { describe, expect, it } from "vitest"; -import { enforceChatHistoryFinalBudget } from "./chat.js"; +import { enforceChatHistoryFinalBudget } from "./chat-history-budget.js"; type DisplayMessage = { role?: string; diff --git a/src/gateway/server-methods/chat-history-budget.ts b/src/gateway/server-methods/chat-history-budget.ts new file mode 100644 index 000000000000..de5d3c3e7cd0 --- /dev/null +++ b/src/gateway/server-methods/chat-history-budget.ts @@ -0,0 +1,129 @@ +import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js"; +import { logLargePayload } from "../../logging/diagnostic-payload.js"; + +export const CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES = 128 * 1024; +const CHAT_HISTORY_OVERSIZED_PLACEHOLDER = "[chat.history omitted: message too large]"; +const CHAT_HISTORY_UNAVAILABLE_SENTINEL = + "[chat.history unavailable: transcript too large to display; the full history is preserved on disk]"; +let chatHistoryOmittedEmitCount = 0; + +function buildChatHistoryUnavailableSentinel(): Record { + return { + role: "assistant", + timestamp: Date.now(), + content: [{ type: "text", text: CHAT_HISTORY_UNAVAILABLE_SENTINEL }], + }; +} + +function buildOversizedHistoryPlaceholder(message?: unknown): Record { + const role = + message && + typeof message === "object" && + typeof (message as { role?: unknown }).role === "string" + ? (message as { role: string }).role + : "assistant"; + const timestamp = + message && + typeof message === "object" && + typeof (message as { timestamp?: unknown }).timestamp === "number" + ? (message as { timestamp: number }).timestamp + : Date.now(); + const rawMetadata = + message && typeof message === "object" + ? (message as Record)["__openclaw"] + : undefined; + const metadata = + rawMetadata && typeof rawMetadata === "object" && !Array.isArray(rawMetadata) + ? (rawMetadata as Record) + : {}; + const metadataId = typeof metadata.id === "string" ? metadata.id : undefined; + const metadataSeq = typeof metadata.seq === "number" ? metadata.seq : undefined; + const metadataIdempotencyKey = + typeof metadata.idempotencyKey === "string" ? metadata.idempotencyKey : undefined; + return { + role, + timestamp, + content: [{ type: "text", text: CHAT_HISTORY_OVERSIZED_PLACEHOLDER }], + __openclaw: { + ...(metadataId ? { id: metadataId } : {}), + ...(metadataSeq !== undefined ? { seq: metadataSeq } : {}), + ...(metadataIdempotencyKey ? { idempotencyKey: metadataIdempotencyKey } : {}), + truncated: true, + reason: "oversized", + }, + }; +} + +export function replaceOversizedChatHistoryMessages(params: { + messages: unknown[]; + maxSingleMessageBytes: number; +}): { messages: unknown[]; replacedCount: number } { + const { messages, maxSingleMessageBytes } = params; + if (messages.length === 0) { + return { messages, replacedCount: 0 }; + } + let replacedCount = 0; + const next = messages.map((message) => { + if (jsonUtf8Bytes(message) <= maxSingleMessageBytes) { + return message; + } + replacedCount += 1; + return buildOversizedHistoryPlaceholder(message); + }); + return { messages: replacedCount > 0 ? next : messages, replacedCount }; +} + +// Preserve a visible terminal record when the complete projected history cannot fit. +export function enforceChatHistoryFinalBudget(params: { messages: unknown[]; maxBytes: number }): { + messages: unknown[]; +} { + const { messages, maxBytes } = params; + if (messages.length === 0) { + return { messages }; + } + if (jsonUtf8Bytes(messages) <= maxBytes) { + return { messages }; + } + const last = messages.at(-1); + if (last && jsonUtf8Bytes([last]) <= maxBytes) { + return { messages: [last] }; + } + const placeholder = buildOversizedHistoryPlaceholder(last); + if (jsonUtf8Bytes([placeholder]) <= maxBytes) { + return { messages: [placeholder] }; + } + return { messages: [buildChatHistoryUnavailableSentinel()] }; +} + +export function reportOmittedChatHistory(params: { + originalMessages: unknown[]; + finalMessages: unknown[]; + normalizedBytes: number; + maxHistoryBytes: number; + logDebug: (message: string) => void; +}): number { + const { originalMessages, finalMessages, normalizedBytes, maxHistoryBytes, logDebug } = params; + const survivors = new Set(finalMessages); + let omittedCount = 0; + for (const message of originalMessages) { + if (!survivors.has(message)) { + omittedCount += 1; + } + } + if (omittedCount === 0) { + return 0; + } + chatHistoryOmittedEmitCount += omittedCount; + logLargePayload({ + surface: "gateway.chat.history", + action: "truncated", + bytes: normalizedBytes, + limitBytes: maxHistoryBytes, + count: omittedCount, + reason: "chat_history_budget", + }); + logDebug( + `chat.history omitted oversized payloads count=${omittedCount} total=${chatHistoryOmittedEmitCount}`, + ); + return omittedCount; +} diff --git a/src/gateway/server-methods/chat-history-omission-logging.test.ts b/src/gateway/server-methods/chat-history-omission-logging.test.ts index fe162c55d5ea..71bd71250c01 100644 --- a/src/gateway/server-methods/chat-history-omission-logging.test.ts +++ b/src/gateway/server-methods/chat-history-omission-logging.test.ts @@ -13,7 +13,7 @@ import { enforceChatHistoryFinalBudget, replaceOversizedChatHistoryMessages, reportOmittedChatHistory, -} from "./chat.js"; +} from "./chat-history-budget.js"; type Captured = DiagnosticPayloadLargeEvent[]; diff --git a/src/gateway/server-methods/chat-origin-routing.ts b/src/gateway/server-methods/chat-origin-routing.ts new file mode 100644 index 000000000000..e75696ab9cb7 --- /dev/null +++ b/src/gateway/server-methods/chat-origin-routing.ts @@ -0,0 +1,327 @@ +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../../../packages/gateway-protocol/src/client-info.js"; +import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../../../packages/gateway-protocol/src/schema.js"; +import { listAgentIds } from "../../agents/agent-scope.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; +import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; +import { isPluginOwnedSessionBindingRecord } from "../../plugins/conversation-binding.js"; +import { normalizeAgentId, scopeLegacySessionKeyToAgent } from "../../routing/session-key.js"; +import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; +import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js"; +import { + INTERNAL_MESSAGE_CHANNEL, + isGatewayCliClient, + isWebchatClient, + normalizeMessageChannel, +} from "../../utils/message-channel.js"; +import { sanitizeChatSendMessageInput } from "../chat-input-sanitize.js"; +import { ADMIN_SCOPE } from "../method-scopes.js"; +import { resolveSessionStoreKey } from "../session-utils.js"; +import type { GatewayRequestHandlerOptions } from "./types.js"; + +const CHANNEL_AGNOSTIC_SESSION_SCOPES = new Set([ + "main", + "direct", + "dm", + "group", + "channel", + "cron", + "run", + "subagent", + "acp", + "thread", + "topic", +]); +const CHANNEL_SCOPED_SESSION_SHAPES = new Set(["direct", "dm", "group", "channel"]); + +export type ChatSendDeliveryEntry = { + route?: ChannelRouteRef; + deliveryContext?: { + channel?: string; + to?: string; + accountId?: string; + threadId?: string | number; + }; + origin?: { + provider?: string; + accountId?: string; + threadId?: string | number; + }; + lastChannel?: string; + lastTo?: string; + lastAccountId?: string; + lastThreadId?: string | number; +}; + +export type ChatSendOriginatingRoute = { + originatingChannel: string; + originatingTo?: string; + accountId?: string; + messageThreadId?: string | number; + explicitDeliverRoute: boolean; +}; + +export type ChatSendExplicitOrigin = { + originatingChannel?: string; + originatingTo?: string; + accountId?: string; + messageThreadId?: string; +}; + +function normalizeOptionalText(value?: string | null): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function validateChatSelectedAgent(params: { + cfg: OpenClawConfig; + requestedSessionKey: string; + agentId?: string; +}): { ok: true; agentId?: string } | { ok: false; error: string } { + const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined; + if (!agentId) { + return { ok: true }; + } + if (!listAgentIds(params.cfg).includes(agentId)) { + return { ok: false, error: `Unknown agent id "${params.agentId}"` }; + } + const requestedSessionKey = params.requestedSessionKey.trim(); + const parsed = parseAgentSessionKey(requestedSessionKey); + if (parsed && normalizeAgentId(parsed.agentId) !== agentId) { + return { + ok: false, + error: `agentId "${params.agentId}" does not match session key "${params.requestedSessionKey}"`, + }; + } + if (requestedSessionKey.toLowerCase() === "global") { + return { ok: true, agentId }; + } + if (resolveSessionStoreKey({ cfg: params.cfg, sessionKey: requestedSessionKey }) === "global") { + return { ok: true, agentId }; + } + if (!parsed || normalizeAgentId(parsed.agentId) !== agentId) { + return { + ok: false, + error: `agentId "${params.agentId}" does not match session key "${params.requestedSessionKey}"`, + }; + } + return { ok: true, agentId }; +} + +export function resolveRequestedChatAgentId(params: { + cfg?: OpenClawConfig; + requestedSessionKey: string; + agentId?: string; +}): string | undefined { + const explicitAgentId = normalizeOptionalText(params.agentId); + if (explicitAgentId) { + return normalizeAgentId(explicitAgentId); + } + if (!params.cfg) { + return undefined; + } + const parsed = parseAgentSessionKey(params.requestedSessionKey.trim()); + if ( + !parsed?.agentId || + resolveSessionStoreKey({ cfg: params.cfg, sessionKey: params.requestedSessionKey }) !== "global" + ) { + return undefined; + } + return normalizeAgentId(parsed.agentId); +} + +export function resolveChatSendActiveScopeKey(params: { + sessionKey: string; + agentId?: string; + mainKey?: string; +}): string { + if (params.sessionKey !== "global" || !params.agentId) { + return params.sessionKey; + } + return ( + scopeLegacySessionKeyToAgent({ + agentId: params.agentId, + sessionKey: params.sessionKey, + mainKey: params.mainKey, + }) ?? params.sessionKey + ); +} + +export function resolveChatSendOriginatingRoute(params: { + client?: { mode?: string | null; id?: string | null } | null; + deliver?: boolean; + entry?: ChatSendDeliveryEntry; + explicitOrigin?: ChatSendExplicitOrigin; + hasConnectedClient?: boolean; + mainKey?: string; + sessionKey: string; +}): ChatSendOriginatingRoute { + if (params.explicitOrigin?.originatingChannel && params.explicitOrigin.originatingTo) { + return { + originatingChannel: params.explicitOrigin.originatingChannel, + originatingTo: params.explicitOrigin.originatingTo, + ...(params.explicitOrigin.accountId ? { accountId: params.explicitOrigin.accountId } : {}), + ...(params.explicitOrigin.messageThreadId + ? { messageThreadId: params.explicitOrigin.messageThreadId } + : {}), + explicitDeliverRoute: params.deliver === true, + }; + } + if (params.deliver !== true) { + return { originatingChannel: INTERNAL_MESSAGE_CHANNEL, explicitDeliverRoute: false }; + } + + const sessionDeliveryContext = deliveryContextFromSession(params.entry); + const routeChannelCandidate = normalizeMessageChannel( + sessionDeliveryContext?.channel ?? params.entry?.lastChannel ?? params.entry?.origin?.provider, + ); + const routeToCandidate = sessionDeliveryContext?.to ?? params.entry?.lastTo; + const routeAccountIdCandidate = + sessionDeliveryContext?.accountId ?? + params.entry?.lastAccountId ?? + params.entry?.origin?.accountId ?? + undefined; + const routeThreadIdCandidate = + sessionDeliveryContext?.threadId ?? + params.entry?.lastThreadId ?? + params.entry?.origin?.threadId; + if (params.sessionKey.length > CHAT_SEND_SESSION_KEY_MAX_LENGTH) { + return { originatingChannel: INTERNAL_MESSAGE_CHANNEL, explicitDeliverRoute: false }; + } + + const parsedSessionKey = parseAgentSessionKey(params.sessionKey); + const sessionScopeParts = (parsedSessionKey?.rest ?? params.sessionKey) + .split(":", 3) + .filter(Boolean); + const sessionScopeHead = sessionScopeParts[0]; + const sessionChannelHint = normalizeMessageChannel(sessionScopeHead); + const normalizedSessionScopeHead = (sessionScopeHead ?? "").trim().toLowerCase(); + const sessionPeerShapeCandidates = [sessionScopeParts[1], sessionScopeParts[2]] + .map((part) => (part ?? "").trim().toLowerCase()) + .filter(Boolean); + const isChannelAgnosticSessionScope = CHANNEL_AGNOSTIC_SESSION_SCOPES.has( + normalizedSessionScopeHead, + ); + const isChannelScopedSession = sessionPeerShapeCandidates.some((part) => + CHANNEL_SCOPED_SESSION_SHAPES.has(part), + ); + const hasLegacyChannelPeerShape = + !isChannelScopedSession && + typeof sessionScopeParts[1] === "string" && + sessionChannelHint === routeChannelCandidate; + const isFromWebchatClient = isWebchatClient(params.client); + const isFromGatewayCliClient = isGatewayCliClient(params.client); + const hasClientMetadata = + (typeof params.client?.mode === "string" && params.client.mode.trim().length > 0) || + (typeof params.client?.id === "string" && params.client.id.trim().length > 0); + const configuredMainKey = (params.mainKey ?? "main").trim().toLowerCase(); + const isConfiguredMainSessionScope = + normalizedSessionScopeHead.length > 0 && normalizedSessionScopeHead === configuredMainKey; + const canInheritConfiguredMainRoute = + isConfiguredMainSessionScope && + params.hasConnectedClient && + (isFromGatewayCliClient || !hasClientMetadata); + + // Webchat never inherits external delivery. Main-session inheritance is CLI-only + // unless an old caller omitted client metadata entirely. + const canInheritDeliverableRoute = Boolean( + !isFromWebchatClient && + sessionChannelHint && + sessionChannelHint !== INTERNAL_MESSAGE_CHANNEL && + ((!isChannelAgnosticSessionScope && (isChannelScopedSession || hasLegacyChannelPeerShape)) || + canInheritConfiguredMainRoute), + ); + const hasDeliverableRoute = + canInheritDeliverableRoute && + routeChannelCandidate && + routeChannelCandidate !== INTERNAL_MESSAGE_CHANNEL && + typeof routeToCandidate === "string" && + routeToCandidate.trim().length > 0; + + if (!hasDeliverableRoute) { + return { originatingChannel: INTERNAL_MESSAGE_CHANNEL, explicitDeliverRoute: false }; + } + + return { + originatingChannel: routeChannelCandidate, + originatingTo: routeToCandidate, + accountId: routeAccountIdCandidate, + messageThreadId: routeThreadIdCandidate, + explicitDeliverRoute: true, + }; +} + +function isAcpSessionKey(sessionKey: string | undefined): boolean { + return Boolean(sessionKey?.split(":").includes("acp")); +} + +export function explicitOriginTargetsAcpSession( + origin: ChatSendExplicitOrigin | undefined, +): boolean { + if (!origin?.originatingChannel || !origin.originatingTo || !origin.accountId) { + return false; + } + const channel = normalizeMessageChannel(origin.originatingChannel); + if (!channel || channel === INTERNAL_MESSAGE_CHANNEL) { + return false; + } + const binding = getSessionBindingService().resolveByConversation({ + channel, + accountId: origin.accountId, + conversationId: origin.originatingTo, + }); + return isAcpSessionKey(binding?.targetSessionKey); +} + +export function explicitOriginTargetsPluginBinding( + origin: ChatSendExplicitOrigin | undefined, +): boolean { + if (!origin?.originatingChannel || !origin.originatingTo || !origin.accountId) { + return false; + } + const channel = normalizeMessageChannel(origin.originatingChannel); + if (!channel || channel === INTERNAL_MESSAGE_CHANNEL) { + return false; + } + const binding = getSessionBindingService().resolveByConversation({ + channel, + accountId: origin.accountId, + conversationId: origin.originatingTo, + }); + return isPluginOwnedSessionBindingRecord(binding); +} + +export function normalizeOptionalChatSystemReceipt( + value: unknown, +): { ok: true; receipt?: string } | { ok: false; error: string } { + if (value == null) { + return { ok: true }; + } + if (typeof value !== "string") { + return { ok: false, error: "systemProvenanceReceipt must be a string" }; + } + const sanitized = sanitizeChatSendMessageInput(value); + if (!sanitized.ok) { + return sanitized; + } + const receipt = sanitized.message.trim(); + return { ok: true, receipt: receipt || undefined }; +} + +export function isAcpBridgeClient(client: GatewayRequestHandlerOptions["client"]): boolean { + const info = client?.connect?.client; + return ( + info?.id === GATEWAY_CLIENT_NAMES.CLI && + info?.mode === GATEWAY_CLIENT_MODES.CLI && + info?.displayName === "ACP" && + info?.version === "acp" + ); +} + +export function hasGatewayAdminScope(client: GatewayRequestHandlerOptions["client"]): boolean { + const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; + return scopes.includes(ADMIN_SCOPE); +} diff --git a/src/gateway/server-methods/chat-server-timing.ts b/src/gateway/server-methods/chat-server-timing.ts new file mode 100644 index 000000000000..c287ad1417a0 --- /dev/null +++ b/src/gateway/server-methods/chat-server-timing.ts @@ -0,0 +1,103 @@ +import { isOperatorUiClient } from "../../utils/message-channel.js"; +import type { GatewayClient, GatewayRequestContext } from "./types.js"; + +export type ChatSendAckServerTiming = { + receivedToAckMs: number; + loadSessionMs: number; + prepareAttachmentsMs?: number; +}; + +export type ChatSendServerTimingPhase = + | "dispatch-started" + | "model-selected" + | "agent-run-started" + | "first-assistant-event" + | "dispatch-completed" + | "post-dispatch-completed"; + +export function roundedChatSendTimingMs(value: number): number { + return Math.max(0, Math.round(value * 1000) / 1000); +} + +export function chatSendAckServerTimingAttributes( + timing: ChatSendAckServerTiming | undefined, +): Record { + if (!timing) { + return {}; + } + return { + serverReceivedToAckMs: timing.receivedToAckMs, + serverLoadSessionMs: timing.loadSessionMs, + ...(timing.prepareAttachmentsMs !== undefined + ? { serverPrepareAttachmentsMs: timing.prepareAttachmentsMs } + : {}), + }; +} + +export function shouldIncludeChatSendAckServerTiming(client?: { + id?: string | null; + mode?: string | null; +}): boolean { + return isOperatorUiClient(client); +} + +const CONTROL_UI_RECONNECT_RESUME_PARAM = "__controlUiReconnectResume"; + +export function resolveControlUiReconnectResumeParams( + params: unknown, + clientInfo?: { id?: string | null; mode?: string | null }, +): { params: unknown; resumeRequested: boolean } { + if (!params || typeof params !== "object" || Array.isArray(params)) { + return { params, resumeRequested: false }; + } + const record = params as Record; + const resumeRequested = + record[CONTROL_UI_RECONNECT_RESUME_PARAM] === true && isOperatorUiClient(clientInfo); + if (!resumeRequested) { + return { params, resumeRequested: false }; + } + const validatedParams = { ...record }; + delete validatedParams[CONTROL_UI_RECONNECT_RESUME_PARAM]; + return { params: validatedParams, resumeRequested: true }; +} + +export function emitOperatorChatSendServerTiming(params: { + context: Pick; + client?: GatewayClient | null; + phase: ChatSendServerTimingPhase; + runId: string; + sessionKey: string; + agentId?: string; + receivedAtMs: number; + ackedAtMs: number; + dispatchStartedAtMs?: number; + extra?: Record; +}) { + const connId = + typeof params.client?.connId === "string" && params.client.connId.trim() + ? params.client.connId.trim() + : undefined; + if (!connId || !isOperatorUiClient(params.client?.connect?.client)) { + return; + } + const nowMs = performance.now(); + params.context.broadcastToConnIds( + "chat.send_timing", + { + phase: params.phase, + runId: params.runId, + sessionKey: params.sessionKey, + ...(params.agentId ? { agentId: params.agentId } : {}), + ackToPhaseMs: roundedChatSendTimingMs(nowMs - params.ackedAtMs), + receivedToPhaseMs: roundedChatSendTimingMs(nowMs - params.receivedAtMs), + ...(params.dispatchStartedAtMs !== undefined + ? { + dispatchStartedToPhaseMs: roundedChatSendTimingMs(nowMs - params.dispatchStartedAtMs), + } + : {}), + ...params.extra, + }, + new Set([connId]), + { dropIfSlow: true }, + ); +} diff --git a/src/gateway/server-methods/chat-tts-markers.ts b/src/gateway/server-methods/chat-tts-markers.ts new file mode 100644 index 000000000000..5a33266bd556 --- /dev/null +++ b/src/gateway/server-methods/chat-tts-markers.ts @@ -0,0 +1,60 @@ +import { createHash } from "node:crypto"; +import { + buildTtsSupplementMediaPayload, + getReplyPayloadTtsSupplement, + isReplyPayloadTtsSupplement, +} from "openclaw/plugin-sdk/reply-payload"; +import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; +import { projectChatDisplayMessage } from "../chat-display-projection.js"; +import { + extractAssistantDisplayTextFromContent, + type AssistantDisplayContentBlock, +} from "./chat-assistant-content.js"; +import type { GatewayInjectedTtsSupplementMarker } from "./chat-transcript-inject.js"; + +export function stripVisibleTextFromTtsSupplement(payload: ReplyPayload): ReplyPayload { + return isReplyPayloadTtsSupplement(payload) ? buildTtsSupplementMediaPayload(payload) : payload; +} + +function resolveTtsSupplementMarkerText(text: string): string { + const trimmed = text.trim(); + const projected = projectChatDisplayMessage( + { + role: "assistant", + content: [{ type: "text", text: trimmed }], + }, + { maxChars: Number.MAX_SAFE_INTEGER }, + ); + const projectedContent = Array.isArray(projected?.content) + ? (projected.content as AssistantDisplayContentBlock[]) + : undefined; + return ( + extractAssistantDisplayTextFromContent(projectedContent) ?? + (typeof projected?.text === "string" ? projected.text.trim() : undefined) ?? + trimmed + ); +} + +export function buildTtsSupplementTranscriptMarker( + payload: ReplyPayload, +): GatewayInjectedTtsSupplementMarker | undefined { + const supplement = getReplyPayloadTtsSupplement(payload); + if (!supplement) { + return undefined; + } + const visibleText = resolveTtsSupplementMarkerText( + payload.text?.trim() || supplement.spokenText.trim(), + ); + return { + textSha256: createHash("sha256").update(visibleText).digest("hex"), + }; +} + +export function buildMediaOnlyTtsSupplementTranscriptMarker( + payload: ReplyPayload, +): GatewayInjectedTtsSupplementMarker | undefined { + if (payload.text?.trim()) { + return undefined; + } + return buildTtsSupplementTranscriptMarker(payload); +} diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index b344ed347153..2069d365c36a 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -10,16 +10,9 @@ import { isFutureDateTimestampMs } from "@openclaw/normalization-core/number-coe import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import type { FastMode } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { - buildTtsSupplementMediaPayload, - getReplyPayloadTtsSupplement, - isReplyPayloadTtsSupplement, - resolveSendableOutboundReplyParts, -} from "openclaw/plugin-sdk/reply-payload"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { GATEWAY_CLIENT_CAPS, - GATEWAY_CLIENT_MODES, - GATEWAY_CLIENT_NAMES, hasGatewayClientCap, } from "../../../packages/gateway-protocol/src/client-info.js"; import { @@ -34,7 +27,6 @@ import { validateChatToolTitlesParams, validateChatSendParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../../../packages/gateway-protocol/src/schema.js"; import { listAgentIds, resolveDefaultAgentId, @@ -52,7 +44,6 @@ import { dispatchInboundMessage } from "../../auto-reply/dispatch.js"; import { getReplyPayloadMetadata, isReplyPayloadStatusNotice, - readPairingQrReplyChannelData, type ReplyPayload, } from "../../auto-reply/reply-payload.js"; import { isBtwRequestText } from "../../auto-reply/reply/btw-command.js"; @@ -90,26 +81,19 @@ import { } from "../../infra/diagnostics-timeline.js"; import { formatErrorMessage, formatUncaughtError } from "../../infra/errors.js"; import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js"; -import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads.js"; -import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; -import { logLargePayload } from "../../logging/diagnostic-payload.js"; import { appendLocalMediaParentRoots, getAgentScopedMediaLocalRoots, } from "../../media/local-roots.js"; import { parseInboundMediaUri } from "../../media/media-reference.js"; import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js"; -import { renderQrPngDataUrl } from "../../media/qr-image.js"; -import { renderQrTerminal } from "../../media/qr-terminal.js"; import { deleteMediaBuffer, MEDIA_MAX_BYTES, type SavedMedia } from "../../media/store.js"; import { createChannelMessageReplyPipeline } from "../../plugin-sdk/channel-outbound.js"; -import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; -import { isPluginOwnedSessionBindingRecord } from "../../plugins/conversation-binding.js"; import { retainGatewayRootWorkAdmissionContinuation, runWithGatewayIndependentRootWorkContinuation, } from "../../process/gateway-work-admission.js"; -import { normalizeAgentId, scopeLegacySessionKeyToAgent } from "../../routing/session-key.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { resolveMissingAgentHarnessSessionError } from "../../sessions/agent-harness-session-key.js"; import { normalizeInputProvenance, type InputProvenance } from "../../sessions/input-provenance.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; @@ -120,7 +104,6 @@ import { type UserTurnInput, type UserTurnTranscriptRecorder, } from "../../sessions/user-turn-transcript.js"; -import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js"; import { parseInlineDirectives, stripInlineDirectiveTagsForDelivery, @@ -129,9 +112,7 @@ import { } from "../../utils/directive-tags.js"; import { INTERNAL_MESSAGE_CHANNEL, - isGatewayCliClient, isOperatorUiClient, - isWebchatClient, normalizeMessageChannel, } from "../../utils/message-channel.js"; import { listGatewayAgentsBasic } from "../agent-list.js"; @@ -174,7 +155,6 @@ import { type QueuedChatTurnEntry, type QueuedChatTurnMap, } from "../chat-queued-turns.js"; -import { stripEnvelopeFromMessage } from "../chat-sanitize.js"; import { resolveClaudeCliBindingSessionId, resolveChatHistoryWithCliSessionImports, @@ -184,11 +164,7 @@ import { isDashboardSessionTitleCandidate, maybeGenerateDashboardSessionTitle, } from "../dashboard-session-title.js"; -import { - attachManagedOutgoingImagesToMessage, - cleanupManagedOutgoingImageRecords, - createManagedOutgoingImageBlocks, -} from "../managed-image-attachments.js"; +import { attachManagedOutgoingImagesToMessage } from "../managed-image-attachments.js"; import { ADMIN_SCOPE } from "../method-scopes.js"; import { chatAbortMarkerTimestampMs, @@ -224,15 +200,58 @@ import { asWorkerInferenceControl } from "../worker-environments/inference-contr import { formatForLog } from "../ws-log.js"; import { setGatewayDedupeEntry } from "./agent-job.js"; import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js"; +import { + buildAssistantDisplayContentFromReplyPayloads, + extractAssistantDisplayText, + extractAssistantDisplayTextFromContent, + hasAssistantDisplayMediaContent, + hasManagedOutgoingAssistantContent, + hasSensitiveMediaPayload, + hasVisibleAssistantFinalMessage, + isMediaBearingPayload, + replaceAssistantContentTextBlocks, + sanitizeAssistantDisplayText, + scheduleChatHistoryManagedImageCleanup, + stripManagedOutgoingAssistantContentBlocks, + type AssistantDisplayContentBlock, +} from "./chat-assistant-content.js"; +import { + CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, + enforceChatHistoryFinalBudget, + replaceOversizedChatHistoryMessages, + reportOmittedChatHistory, +} from "./chat-history-budget.js"; +import { + explicitOriginTargetsAcpSession, + explicitOriginTargetsPluginBinding, + hasGatewayAdminScope, + isAcpBridgeClient, + normalizeOptionalChatSystemReceipt, + resolveChatSendActiveScopeKey, + resolveChatSendOriginatingRoute, + resolveRequestedChatAgentId, + validateChatSelectedAgent, + type ChatSendExplicitOrigin, +} from "./chat-origin-routing.js"; import { normalizeWebchatReplyMediaPathsForDisplay } from "./chat-reply-media.js"; +import { + chatSendAckServerTimingAttributes, + emitOperatorChatSendServerTiming, + roundedChatSendTimingMs, + resolveControlUiReconnectResumeParams, + shouldIncludeChatSendAckServerTiming, + type ChatSendServerTimingPhase, +} from "./chat-server-timing.js"; import { appendInjectedAssistantMessageToTranscript, type GatewayInjectedTtsSupplementMarker, } from "./chat-transcript-inject.js"; import { - buildWebchatAssistantMessageFromReplyPayloads, - buildWebchatAudioContentBlocksFromReplyPayloads, -} from "./chat-webchat-media.js"; + buildMediaOnlyTtsSupplementTranscriptMarker, + buildTtsSupplementTranscriptMarker, + stripVisibleTextFromTtsSupplement, +} from "./chat-tts-markers.js"; +import { buildWebchatAssistantMessageFromReplyPayloads } from "./chat-webchat-media.js"; import { loadOptionalServerMethodModelCatalog, loadOptionalServerMethodModelCatalogSnapshot, @@ -244,7 +263,6 @@ import { } from "./session-active-runs.js"; import { emitSessionsChanged } from "./session-change-event.js"; import type { - GatewayClient, GatewayRequestContext, GatewayRequestHandlerOptions, GatewayRequestHandlers, @@ -312,107 +330,6 @@ type ChatMetadataResult = { models?: unknown[]; }; -type ChatSendAckServerTiming = { - receivedToAckMs: number; - loadSessionMs: number; - prepareAttachmentsMs?: number; -}; - -type ChatSendServerTimingPhase = - | "dispatch-started" - | "model-selected" - | "agent-run-started" - | "first-assistant-event" - | "dispatch-completed" - | "post-dispatch-completed"; - -function roundedChatSendTimingMs(value: number): number { - return Math.max(0, Math.round(value * 1000) / 1000); -} - -function chatSendAckServerTimingAttributes( - timing: ChatSendAckServerTiming | undefined, -): Record { - if (!timing) { - return {}; - } - return { - serverReceivedToAckMs: timing.receivedToAckMs, - serverLoadSessionMs: timing.loadSessionMs, - ...(timing.prepareAttachmentsMs !== undefined - ? { serverPrepareAttachmentsMs: timing.prepareAttachmentsMs } - : {}), - }; -} - -function shouldIncludeChatSendAckServerTiming(client?: { - id?: string | null; - mode?: string | null; -}): boolean { - return isOperatorUiClient(client); -} - -const CONTROL_UI_RECONNECT_RESUME_PARAM = "__controlUiReconnectResume"; - -function resolveControlUiReconnectResumeParams( - params: unknown, - clientInfo?: { id?: string | null; mode?: string | null }, -): { params: unknown; resumeRequested: boolean } { - if (!params || typeof params !== "object" || Array.isArray(params)) { - return { params, resumeRequested: false }; - } - const record = params as Record; - const resumeRequested = - record[CONTROL_UI_RECONNECT_RESUME_PARAM] === true && isOperatorUiClient(clientInfo); - if (!resumeRequested) { - return { params, resumeRequested: false }; - } - const validatedParams = { ...record }; - delete validatedParams[CONTROL_UI_RECONNECT_RESUME_PARAM]; - return { params: validatedParams, resumeRequested: true }; -} - -function emitOperatorChatSendServerTiming(params: { - context: Pick; - client?: GatewayClient | null; - phase: ChatSendServerTimingPhase; - runId: string; - sessionKey: string; - agentId?: string; - receivedAtMs: number; - ackedAtMs: number; - dispatchStartedAtMs?: number; - extra?: Record; -}) { - const connId = - typeof params.client?.connId === "string" && params.client.connId.trim() - ? params.client.connId.trim() - : undefined; - if (!connId || !isOperatorUiClient(params.client?.connect?.client)) { - return; - } - const nowMs = performance.now(); - params.context.broadcastToConnIds( - "chat.send_timing", - { - phase: params.phase, - runId: params.runId, - sessionKey: params.sessionKey, - ...(params.agentId ? { agentId: params.agentId } : {}), - ackToPhaseMs: roundedChatSendTimingMs(nowMs - params.ackedAtMs), - receivedToPhaseMs: roundedChatSendTimingMs(nowMs - params.receivedAtMs), - ...(params.dispatchStartedAtMs !== undefined - ? { - dispatchStartedToPhaseMs: roundedChatSendTimingMs(nowMs - params.dispatchStartedAtMs), - } - : {}), - ...params.extra, - }, - new Set([connId]), - { dropIfSlow: true }, - ); -} - async function handleChatMetadataRequest({ params, respond, @@ -584,67 +501,6 @@ function normalizeUnknownText(value: unknown): string | undefined { return typeof value === "string" ? normalizeOptionalText(value) : undefined; } -/** True when a reply payload carries at least one media reference (mediaUrl or mediaUrls). */ -function isMediaBearingPayload(payload: ReplyPayload): boolean { - if (payload.isReasoning === true) { - return false; - } - if (payload.mediaUrl?.trim()) { - return true; - } - if (payload.mediaUrls?.some((url) => url.trim())) { - return true; - } - return false; -} - -function stripVisibleTextFromTtsSupplement(payload: ReplyPayload): ReplyPayload { - return isReplyPayloadTtsSupplement(payload) ? buildTtsSupplementMediaPayload(payload) : payload; -} - -function resolveTtsSupplementMarkerText(text: string): string { - const trimmed = text.trim(); - const projected = projectChatDisplayMessage( - { - role: "assistant", - content: [{ type: "text", text: trimmed }], - }, - { maxChars: Number.MAX_SAFE_INTEGER }, - ); - const projectedContent = Array.isArray(projected?.content) - ? (projected.content as AssistantDisplayContentBlock[]) - : undefined; - return ( - extractAssistantDisplayTextFromContent(projectedContent) ?? - (typeof projected?.text === "string" ? projected.text.trim() : undefined) ?? - trimmed - ); -} - -function buildTtsSupplementTranscriptMarker( - payload: ReplyPayload, -): GatewayInjectedTtsSupplementMarker | undefined { - const supplement = getReplyPayloadTtsSupplement(payload); - if (!supplement) { - return undefined; - } - const visibleText = resolveTtsSupplementMarkerText( - payload.text?.trim() || supplement.spokenText.trim(), - ); - return { - textSha256: createHash("sha256").update(visibleText).digest("hex"), - }; -} - -function buildMediaOnlyTtsSupplementTranscriptMarker( - payload: ReplyPayload, -): GatewayInjectedTtsSupplementMarker | undefined { - if (payload.text?.trim()) { - return undefined; - } - return buildTtsSupplementTranscriptMarker(payload); -} - function resolveWebchatPromptCacheKey(params: { agentId: string; model: string; @@ -690,71 +546,14 @@ export { sanitizeChatHistoryMessages, } from "../chat-display-projection.js"; export { sanitizeChatSendMessageInput } from "../chat-input-sanitize.js"; +export { + CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, + enforceChatHistoryFinalBudget, + replaceOversizedChatHistoryMessages, + reportOmittedChatHistory, +} from "./chat-history-budget.js"; -export const CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES = 128 * 1024; -const CHAT_HISTORY_OVERSIZED_PLACEHOLDER = "[chat.history omitted: message too large]"; -const CHAT_HISTORY_UNAVAILABLE_SENTINEL = - "[chat.history unavailable: transcript too large to display; the full history is preserved on disk]"; - -/** - * A minimal, metadata-free notice returned when even a single oversized - * placeholder cannot fit the chat-history byte budget. Returning this instead - * of an empty array guarantees the dashboard never renders a blank transcript, - * which otherwise reads to the operator as total history loss. - */ -function buildChatHistoryUnavailableSentinel(): Record { - return { - role: "assistant", - timestamp: Date.now(), - content: [{ type: "text", text: CHAT_HISTORY_UNAVAILABLE_SENTINEL }], - }; -} const CHAT_STARTUP_OPTIONAL_MODEL_CATALOG_TIMEOUT_MS = 25; -const MANAGED_OUTGOING_IMAGE_PATH_PREFIX = "/api/chat/media/outgoing/"; -let chatHistoryOmittedEmitCount = 0; -const chatHistoryManagedImageCleanupState = new Map>(); -const CHANNEL_AGNOSTIC_SESSION_SCOPES = new Set([ - "main", - "direct", - "dm", - "group", - "channel", - "cron", - "run", - "subagent", - "acp", - "thread", - "topic", -]); -const CHANNEL_SCOPED_SESSION_SHAPES = new Set(["direct", "dm", "group", "channel"]); - -type ChatSendDeliveryEntry = { - route?: ChannelRouteRef; - deliveryContext?: { - channel?: string; - to?: string; - accountId?: string; - threadId?: string | number; - }; - origin?: { - provider?: string; - accountId?: string; - threadId?: string | number; - }; - lastChannel?: string; - lastTo?: string; - lastAccountId?: string; - lastThreadId?: string | number; -}; - -type ChatSendOriginatingRoute = { - originatingChannel: string; - originatingTo?: string; - accountId?: string; - messageThreadId?: string | number; - explicitDeliverRoute: boolean; -}; - function buildAbortedChatSendPayload(params: { runId: string; endedAt: number; @@ -769,87 +568,6 @@ function buildAbortedChatSendPayload(params: { }; } -function validateChatSelectedAgent(params: { - cfg: OpenClawConfig; - requestedSessionKey: string; - agentId?: string; -}): { ok: true; agentId?: string } | { ok: false; error: string } { - const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined; - if (!agentId) { - return { ok: true }; - } - if (!listAgentIds(params.cfg).includes(agentId)) { - return { ok: false, error: `Unknown agent id "${params.agentId}"` }; - } - const requestedSessionKey = params.requestedSessionKey.trim(); - const parsed = parseAgentSessionKey(requestedSessionKey); - if (parsed && normalizeAgentId(parsed.agentId) !== agentId) { - return { - ok: false, - error: `agentId "${params.agentId}" does not match session key "${params.requestedSessionKey}"`, - }; - } - if (requestedSessionKey.toLowerCase() === "global") { - return { ok: true, agentId }; - } - if (resolveSessionStoreKey({ cfg: params.cfg, sessionKey: requestedSessionKey }) === "global") { - return { ok: true, agentId }; - } - if (!parsed || normalizeAgentId(parsed.agentId) !== agentId) { - return { - ok: false, - error: `agentId "${params.agentId}" does not match session key "${params.requestedSessionKey}"`, - }; - } - return { ok: true, agentId }; -} - -function resolveRequestedChatAgentId(params: { - cfg?: OpenClawConfig; - requestedSessionKey: string; - agentId?: string; -}): string | undefined { - const explicitAgentId = normalizeOptionalText(params.agentId); - if (explicitAgentId) { - return normalizeAgentId(explicitAgentId); - } - if (!params.cfg) { - return undefined; - } - const parsed = parseAgentSessionKey(params.requestedSessionKey.trim()); - if ( - !parsed?.agentId || - resolveSessionStoreKey({ cfg: params.cfg, sessionKey: params.requestedSessionKey }) !== "global" - ) { - return undefined; - } - return normalizeAgentId(parsed.agentId); -} - -function resolveChatSendActiveScopeKey(params: { - sessionKey: string; - agentId?: string; - mainKey?: string; -}): string { - if (params.sessionKey !== "global" || !params.agentId) { - return params.sessionKey; - } - return ( - scopeLegacySessionKeyToAgent({ - agentId: params.agentId, - sessionKey: params.sessionKey, - mainKey: params.mainKey, - }) ?? params.sessionKey - ); -} - -type ChatSendExplicitOrigin = { - originatingChannel?: string; - originatingTo?: string; - accountId?: string; - messageThreadId?: string; -}; - function formatAttachmentFailureForLog(err: unknown): string { const primary = formatUncaughtError(err); const cause = err instanceof Error ? err.cause : undefined; @@ -931,478 +649,6 @@ function buildTranscriptReplyText(payloads: ReplyPayload[]): string { return chunks.join("\n\n").trim(); } -function hasSensitiveMediaPayload(payloads: ReplyPayload[]): boolean { - return payloads.some( - (payload) => - payload.sensitiveMedia === true && - (isMediaBearingPayload(payload) || Boolean(readPairingQrReplyChannelData(payload))), - ); -} - -type AssistantDisplayContentBlock = Record; - -async function buildPairingQrAssistantContentBlock( - payload: ReplyPayload, -): Promise { - const qr = readPairingQrReplyChannelData(payload); - if (!qr) { - return undefined; - } - const [imageUrl, terminalText] = await Promise.all([ - renderQrPngDataUrl(qr.setupCode), - renderQrTerminal(qr.setupCode, { small: true }), - ]); - return { - type: "openclaw_pairing_qr", - image_url: imageUrl, - terminalText, - alt: "OpenClaw pairing QR code", - expiresAtMs: qr.expiresAtMs, - sensitive: true, - }; -} - -function sanitizeAssistantDisplayText(value?: string | null): string | undefined { - if (!value) { - return undefined; - } - const withoutEnvelope = stripEnvelopeFromMessage(value); - const normalized = typeof withoutEnvelope === "string" ? withoutEnvelope : value; - const stripped = stripInlineDirectiveTagsForDisplay(normalized).text.trim(); - return stripped || undefined; -} - -function extractAssistantDisplayTextFromContent( - content?: readonly AssistantDisplayContentBlock[] | null, -): string | undefined { - if (!Array.isArray(content) || content.length === 0) { - return undefined; - } - const parts = content - .map((block) => { - if (block?.type !== "text" || typeof block.text !== "string") { - return ""; - } - return block.text.trim(); - }) - .filter(Boolean); - return parts.length > 0 ? parts.join("\n\n") : undefined; -} - -async function buildAssistantDisplayContentFromReplyPayloads(params: { - sessionKey: string; - agentId?: string; - payloads: ReplyPayload[]; - managedImageLocalRoots?: Parameters[0]["localRoots"]; - includeSensitiveMedia?: boolean; - includeSensitiveDisplay?: boolean; - onLocalAudioAccessDenied?: (message: string) => void; - onManagedImagePrepareError?: (message: string) => void; - onSensitiveDisplayPrepareError?: (message: string) => void; -}): Promise { - const rawTextPayloadCount = params.payloads.filter( - (payload) => - payload.isReasoning !== true && - typeof payload.text === "string" && - payload.text.trim().length > 0, - ).length; - const normalized = normalizeReplyPayloadsForDelivery(params.payloads); - if (normalized.length === 0) { - return rawTextPayloadCount > 0 ? [{ type: "text", text: "" }] : undefined; - } - - const content: AssistantDisplayContentBlock[] = []; - let strippedTextPayloadCount = 0; - for (const payload of normalized) { - const text = sanitizeAssistantDisplayText(payload.text); - if (text) { - content.push({ type: "text", text }); - } else if (typeof payload.text === "string" && payload.text.trim().length > 0) { - strippedTextPayloadCount += 1; - } - if (params.includeSensitiveDisplay === true) { - try { - const pairingQrBlock = await buildPairingQrAssistantContentBlock(payload); - if (pairingQrBlock) { - content.push(pairingQrBlock); - } - } catch (err) { - params.onSensitiveDisplayPrepareError?.(formatForLog(err)); - } - } - if (params.includeSensitiveMedia === false && payload.sensitiveMedia === true) { - continue; - } - const audioBlocks = await buildWebchatAudioContentBlocksFromReplyPayloads([payload], { - localRoots: Array.isArray(params.managedImageLocalRoots) - ? params.managedImageLocalRoots - : undefined, - onLocalAudioAccessDenied: (err) => { - params.onLocalAudioAccessDenied?.(formatForLog(err)); - }, - }); - content.push(...audioBlocks); - - const mediaUrls = Array.from( - new Set([ - ...(Array.isArray(payload.mediaUrls) ? payload.mediaUrls : []), - ...(typeof payload.mediaUrl === "string" ? [payload.mediaUrl] : []), - ]), - ); - const imageBlocks = await createManagedOutgoingImageBlocks({ - sessionKey: params.sessionKey, - ...(params.sessionKey === "global" && params.agentId ? { agentId: params.agentId } : {}), - mediaUrls, - localRoots: params.managedImageLocalRoots, - continueOnPrepareError: true, - onPrepareError: (error) => { - params.onManagedImagePrepareError?.(error.message); - }, - }); - if (imageBlocks.length > 0) { - content.push(...imageBlocks); - } - } - - if (content.length > 0) { - return content; - } - return strippedTextPayloadCount > 0 ? [{ type: "text", text: "" }] : undefined; -} - -function replaceAssistantContentTextBlocks( - content: readonly AssistantDisplayContentBlock[] | undefined, - transcriptMediaMessage: { content: Array> } | null, -): AssistantDisplayContentBlock[] | undefined { - const transcriptTextBlocks = (transcriptMediaMessage?.content ?? []).filter( - (block): block is AssistantDisplayContentBlock => - Boolean(block) && - typeof block === "object" && - block.type === "text" && - typeof block.text === "string", - ); - if (transcriptTextBlocks.length === 0) { - return content ? [...content] : undefined; - } - if (!content || content.length === 0) { - return [...transcriptTextBlocks]; - } - const merged: AssistantDisplayContentBlock[] = []; - let transcriptTextIndex = 0; - for (const block of content) { - if ( - block?.type === "text" && - typeof block.text === "string" && - transcriptTextIndex < transcriptTextBlocks.length - ) { - merged.push( - expectDefined( - transcriptTextBlocks[transcriptTextIndex++], - "transcript text blocks entry at transcript text index++", - ), - ); - continue; - } - merged.push(block); - } - if (transcriptTextIndex < transcriptTextBlocks.length) { - merged.unshift(...transcriptTextBlocks.slice(transcriptTextIndex)); - } - return merged; -} - -function isManagedOutgoingImageUrl(value: unknown): boolean { - if (typeof value !== "string" || !value.trim()) { - return false; - } - try { - const parsed = new URL(value, "http://localhost"); - return parsed.pathname.startsWith(MANAGED_OUTGOING_IMAGE_PATH_PREFIX); - } catch { - return false; - } -} - -function stripManagedOutgoingAssistantContentBlocks( - content: readonly AssistantDisplayContentBlock[] | undefined, -): AssistantDisplayContentBlock[] | undefined { - if (!content || content.length === 0) { - return undefined; - } - const filtered = content.filter((block) => { - if (block?.type !== "image") { - return true; - } - return !(isManagedOutgoingImageUrl(block.url) || isManagedOutgoingImageUrl(block.openUrl)); - }); - return filtered.length > 0 ? filtered : undefined; -} - -function extractAssistantDisplayText( - content: readonly AssistantDisplayContentBlock[] | undefined, -): string | undefined { - if (!content || content.length === 0) { - return undefined; - } - const text = content - .map((block) => (block?.type === "text" && typeof block.text === "string" ? block.text : "")) - .filter(Boolean) - .join("\n\n") - .trim(); - return text || undefined; -} - -function hasAssistantDisplayMediaContent( - content: readonly AssistantDisplayContentBlock[] | undefined, -): boolean { - return Boolean(content?.some((block) => block?.type !== "text")); -} - -function hasVisibleAssistantFinalMessage(message: Record | undefined): boolean { - if (!message) { - return false; - } - if (typeof message.text === "string" && message.text.trim()) { - return true; - } - const content = Array.isArray(message.content) ? message.content : []; - return content.some((block) => { - if (!block || typeof block !== "object") { - return false; - } - const record = block as Record; - if (record.type === "text") { - return typeof record.text === "string" && record.text.trim().length > 0; - } - return true; - }); -} - -function hasManagedOutgoingAssistantContent( - content: readonly AssistantDisplayContentBlock[] | undefined, -): boolean { - return Boolean( - content?.some( - (block) => - block?.type === "image" && - (isManagedOutgoingImageUrl(block.url) || isManagedOutgoingImageUrl(block.openUrl)), - ), - ); -} - -function scheduleChatHistoryManagedImageCleanup(params: { - sessionKey: string; - agentId?: string; - context: Pick; -}) { - const cleanupKey = - params.sessionKey === "global" && params.agentId - ? `agent:${params.agentId}:global` - : params.sessionKey; - if (chatHistoryManagedImageCleanupState.has(cleanupKey)) { - return; - } - const pending = cleanupManagedOutgoingImageRecords({ - sessionKey: params.sessionKey, - ...(params.sessionKey === "global" && params.agentId ? { agentId: params.agentId } : {}), - }) - .then(() => undefined) - .catch((error: unknown) => { - params.context.logGateway.debug( - `chat.history managed image cleanup skipped sessionKey=${JSON.stringify(params.sessionKey)} error=${formatForLog(error)}`, - ); - }) - .finally(() => { - if (chatHistoryManagedImageCleanupState.get(cleanupKey) === pending) { - chatHistoryManagedImageCleanupState.delete(cleanupKey); - } - }); - chatHistoryManagedImageCleanupState.set(cleanupKey, pending); -} - -function resolveChatSendOriginatingRoute(params: { - client?: { mode?: string | null; id?: string | null } | null; - deliver?: boolean; - entry?: ChatSendDeliveryEntry; - explicitOrigin?: ChatSendExplicitOrigin; - hasConnectedClient?: boolean; - mainKey?: string; - sessionKey: string; -}): ChatSendOriginatingRoute { - if (params.explicitOrigin?.originatingChannel && params.explicitOrigin.originatingTo) { - return { - originatingChannel: params.explicitOrigin.originatingChannel, - originatingTo: params.explicitOrigin.originatingTo, - ...(params.explicitOrigin.accountId ? { accountId: params.explicitOrigin.accountId } : {}), - ...(params.explicitOrigin.messageThreadId - ? { messageThreadId: params.explicitOrigin.messageThreadId } - : {}), - explicitDeliverRoute: params.deliver === true, - }; - } - const shouldDeliverExternally = params.deliver === true; - if (!shouldDeliverExternally) { - return { - originatingChannel: INTERNAL_MESSAGE_CHANNEL, - explicitDeliverRoute: false, - }; - } - - const sessionDeliveryContext = deliveryContextFromSession(params.entry); - const routeChannelCandidate = normalizeMessageChannel( - sessionDeliveryContext?.channel ?? params.entry?.lastChannel ?? params.entry?.origin?.provider, - ); - const routeToCandidate = sessionDeliveryContext?.to ?? params.entry?.lastTo; - const routeAccountIdCandidate = - sessionDeliveryContext?.accountId ?? - params.entry?.lastAccountId ?? - params.entry?.origin?.accountId ?? - undefined; - const routeThreadIdCandidate = - sessionDeliveryContext?.threadId ?? - params.entry?.lastThreadId ?? - params.entry?.origin?.threadId; - if (params.sessionKey.length > CHAT_SEND_SESSION_KEY_MAX_LENGTH) { - return { - originatingChannel: INTERNAL_MESSAGE_CHANNEL, - explicitDeliverRoute: false, - }; - } - - const parsedSessionKey = parseAgentSessionKey(params.sessionKey); - const sessionScopeParts = (parsedSessionKey?.rest ?? params.sessionKey) - .split(":", 3) - .filter(Boolean); - const sessionScopeHead = sessionScopeParts[0]; - const sessionChannelHint = normalizeMessageChannel(sessionScopeHead); - const normalizedSessionScopeHead = (sessionScopeHead ?? "").trim().toLowerCase(); - const sessionPeerShapeCandidates = [sessionScopeParts[1], sessionScopeParts[2]] - .map((part) => (part ?? "").trim().toLowerCase()) - .filter(Boolean); - const isChannelAgnosticSessionScope = CHANNEL_AGNOSTIC_SESSION_SCOPES.has( - normalizedSessionScopeHead, - ); - const isChannelScopedSession = sessionPeerShapeCandidates.some((part) => - CHANNEL_SCOPED_SESSION_SHAPES.has(part), - ); - const hasLegacyChannelPeerShape = - !isChannelScopedSession && - typeof sessionScopeParts[1] === "string" && - sessionChannelHint === routeChannelCandidate; - const isFromWebchatClient = isWebchatClient(params.client); - const isFromGatewayCliClient = isGatewayCliClient(params.client); - const hasClientMetadata = - (typeof params.client?.mode === "string" && params.client.mode.trim().length > 0) || - (typeof params.client?.id === "string" && params.client.id.trim().length > 0); - const configuredMainKey = (params.mainKey ?? "main").trim().toLowerCase(); - const isConfiguredMainSessionScope = - normalizedSessionScopeHead.length > 0 && normalizedSessionScopeHead === configuredMainKey; - const canInheritConfiguredMainRoute = - isConfiguredMainSessionScope && - params.hasConnectedClient && - (isFromGatewayCliClient || !hasClientMetadata); - - // Webchat clients never inherit external delivery routes. Configured-main - // sessions are stricter than channel-scoped sessions: only CLI callers, or - // legacy callers with no client metadata, may inherit the last external route. - const canInheritDeliverableRoute = Boolean( - !isFromWebchatClient && - sessionChannelHint && - sessionChannelHint !== INTERNAL_MESSAGE_CHANNEL && - ((!isChannelAgnosticSessionScope && (isChannelScopedSession || hasLegacyChannelPeerShape)) || - canInheritConfiguredMainRoute), - ); - const hasDeliverableRoute = - canInheritDeliverableRoute && - routeChannelCandidate && - routeChannelCandidate !== INTERNAL_MESSAGE_CHANNEL && - typeof routeToCandidate === "string" && - routeToCandidate.trim().length > 0; - - if (!hasDeliverableRoute) { - return { - originatingChannel: INTERNAL_MESSAGE_CHANNEL, - explicitDeliverRoute: false, - }; - } - - return { - originatingChannel: routeChannelCandidate, - originatingTo: routeToCandidate, - accountId: routeAccountIdCandidate, - messageThreadId: routeThreadIdCandidate, - explicitDeliverRoute: true, - }; -} - -function isAcpSessionKey(sessionKey: string | undefined): boolean { - return Boolean(sessionKey?.split(":").includes("acp")); -} - -function explicitOriginTargetsAcpSession(origin: ChatSendExplicitOrigin | undefined): boolean { - if (!origin?.originatingChannel || !origin.originatingTo || !origin.accountId) { - return false; - } - const channel = normalizeMessageChannel(origin.originatingChannel); - if (!channel || channel === INTERNAL_MESSAGE_CHANNEL) { - return false; - } - const binding = getSessionBindingService().resolveByConversation({ - channel, - accountId: origin.accountId, - conversationId: origin.originatingTo, - }); - return isAcpSessionKey(binding?.targetSessionKey); -} - -function explicitOriginTargetsPluginBinding(origin: ChatSendExplicitOrigin | undefined): boolean { - if (!origin?.originatingChannel || !origin.originatingTo || !origin.accountId) { - return false; - } - const channel = normalizeMessageChannel(origin.originatingChannel); - if (!channel || channel === INTERNAL_MESSAGE_CHANNEL) { - return false; - } - const binding = getSessionBindingService().resolveByConversation({ - channel, - accountId: origin.accountId, - conversationId: origin.originatingTo, - }); - return isPluginOwnedSessionBindingRecord(binding); -} - -function normalizeOptionalChatSystemReceipt( - value: unknown, -): { ok: true; receipt?: string } | { ok: false; error: string } { - if (value == null) { - return { ok: true }; - } - if (typeof value !== "string") { - return { ok: false, error: "systemProvenanceReceipt must be a string" }; - } - const sanitized = sanitizeChatSendMessageInput(value); - if (!sanitized.ok) { - return sanitized; - } - const receipt = sanitized.message.trim(); - return { ok: true, receipt: receipt || undefined }; -} - -function isAcpBridgeClient(client: GatewayRequestHandlerOptions["client"]): boolean { - const info = client?.connect?.client; - return ( - info?.id === GATEWAY_CLIENT_NAMES.CLI && - info?.mode === GATEWAY_CLIENT_MODES.CLI && - info?.displayName === "ACP" && - info?.version === "acp" - ); -} - -function hasGatewayAdminScope(client: GatewayRequestHandlerOptions["client"]): boolean { - const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; - return scopes.includes(ADMIN_SCOPE); -} - async function persistChatSendImages(params: { images: ChatImageContent[]; imageOrder: PromptImageOrderEntry[]; @@ -1682,133 +928,6 @@ function buildChatSendUserTurnMedia(savedMedia: SavedMedia[]): NonNullable { - const role = - message && - typeof message === "object" && - typeof (message as { role?: unknown }).role === "string" - ? (message as { role: string }).role - : "assistant"; - const timestamp = - message && - typeof message === "object" && - typeof (message as { timestamp?: unknown }).timestamp === "number" - ? (message as { timestamp: number }).timestamp - : Date.now(); - const rawMetadata = - message && typeof message === "object" - ? (message as Record)["__openclaw"] - : undefined; - const metadata = - rawMetadata && typeof rawMetadata === "object" && !Array.isArray(rawMetadata) - ? (rawMetadata as Record) - : {}; - const metadataId = typeof metadata.id === "string" ? metadata.id : undefined; - const metadataSeq = typeof metadata.seq === "number" ? metadata.seq : undefined; - const metadataIdempotencyKey = - typeof metadata.idempotencyKey === "string" ? metadata.idempotencyKey : undefined; - return { - role, - timestamp, - content: [{ type: "text", text: CHAT_HISTORY_OVERSIZED_PLACEHOLDER }], - __openclaw: { - ...(metadataId ? { id: metadataId } : {}), - ...(metadataSeq !== undefined ? { seq: metadataSeq } : {}), - ...(metadataIdempotencyKey ? { idempotencyKey: metadataIdempotencyKey } : {}), - truncated: true, - reason: "oversized", - }, - }; -} - -export function replaceOversizedChatHistoryMessages(params: { - messages: unknown[]; - maxSingleMessageBytes: number; -}): { messages: unknown[]; replacedCount: number } { - const { messages, maxSingleMessageBytes } = params; - if (messages.length === 0) { - return { messages, replacedCount: 0 }; - } - let replacedCount = 0; - const next = messages.map((message) => { - if (jsonUtf8Bytes(message) <= maxSingleMessageBytes) { - return message; - } - replacedCount += 1; - return buildOversizedHistoryPlaceholder(message); - }); - return { messages: replacedCount > 0 ? next : messages, replacedCount }; -} - -// Enforces the final byte budget for chat.history. Returns only the surviving -// messages; how many original messages were omitted is measured end-to-end by -// reportOmittedChatHistory, which alone sees the full replace/cap/final pipeline -// and so can count unique omitted originals without double-counting. -export function enforceChatHistoryFinalBudget(params: { messages: unknown[]; maxBytes: number }): { - messages: unknown[]; -} { - const { messages, maxBytes } = params; - if (messages.length === 0) { - return { messages }; - } - if (jsonUtf8Bytes(messages) <= maxBytes) { - return { messages }; - } - const last = messages.at(-1); - if (last && jsonUtf8Bytes([last]) <= maxBytes) { - return { messages: [last] }; - } - const placeholder = buildOversizedHistoryPlaceholder(last); - if (jsonUtf8Bytes([placeholder]) <= maxBytes) { - return { messages: [placeholder] }; - } - // The oversized placeholder still does not fit (e.g. the source message - // carried very large metadata). Never return an empty history — that renders - // as a blank transcript and reads as data loss even though the on-disk - // transcript is intact. Fall back to a small metadata-free sentinel. - return { messages: [buildChatHistoryUnavailableSentinel()] }; -} - -// Counts how many of the original chat.history messages lost their verbatim -// representation by the time the budget pipeline finished — whether they were -// replaced with a placeholder, dropped by the front byte cap, or collapsed by -// the final budget. Identity membership counts each omitted original exactly -// once (a message that is first replaced and then trimmed is not counted twice), -// and emits the truncation diagnostic so operators see when history is omitted. -// Returns the omitted count (0 when nothing was omitted, so no diagnostic fires). -export function reportOmittedChatHistory(params: { - originalMessages: unknown[]; - finalMessages: unknown[]; - normalizedBytes: number; - maxHistoryBytes: number; - logDebug: (message: string) => void; -}): number { - const { originalMessages, finalMessages, normalizedBytes, maxHistoryBytes, logDebug } = params; - const survivors = new Set(finalMessages); - let omittedCount = 0; - for (const message of originalMessages) { - if (!survivors.has(message)) { - omittedCount += 1; - } - } - if (omittedCount === 0) { - return 0; - } - chatHistoryOmittedEmitCount += omittedCount; - logLargePayload({ - surface: "gateway.chat.history", - action: "truncated", - bytes: normalizedBytes, - limitBytes: maxHistoryBytes, - count: omittedCount, - reason: "chat_history_budget", - }); - logDebug( - `chat.history omitted oversized payloads count=${omittedCount} total=${chatHistoryOmittedEmitCount}`, - ); - return omittedCount; -} - type AssistantTranscriptScopeParams = { sessionId: string; storePath: string | undefined; diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index ec7818723e2a..1e7bb980a7c8 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -1276,6 +1276,7 @@ describe("scripts/changed-lanes", () => { }); expect(plan.commands.map((command) => command.name)).toEqual([ "conflict markers", + "TypeScript LOC ratchet", "changelog attributions", "guarded extension wildcard re-exports", "plugin-sdk wildcard re-exports", @@ -1560,6 +1561,7 @@ describe("scripts/changed-lanes", () => { }); expect(plan.commands.map((command) => command.args[0])).toEqual([ "check:no-conflict-markers", + "check:loc", "check:changelog-attributions", "lint:extensions:no-guarded-wildcard-reexports", "lint:extensions:no-plugin-sdk-wildcard-reexports", @@ -2130,6 +2132,7 @@ describe("scripts/changed-lanes", () => { }); expect(plan.commands).toEqual([ { name: "conflict markers", args: ["check:no-conflict-markers"] }, + { name: "TypeScript LOC ratchet", args: ["check:loc"] }, { name: "changelog attributions", args: ["check:changelog-attributions"] }, { name: "guarded extension wildcard re-exports", @@ -2152,6 +2155,7 @@ describe("scripts/changed-lanes", () => { expect(result.docsOnly).toBe(true); expect(plan.commands).toEqual([ { name: "conflict markers", args: ["check:no-conflict-markers"] }, + { name: "TypeScript LOC ratchet", args: ["check:loc"] }, { name: "changelog attributions", args: ["check:changelog-attributions"] }, { name: "guarded extension wildcard re-exports", diff --git a/test/scripts/check-ts-max-loc.test.ts b/test/scripts/check-ts-max-loc.test.ts index 0ee050536c9d..bd6740f01e66 100644 --- a/test/scripts/check-ts-max-loc.test.ts +++ b/test/scripts/check-ts-max-loc.test.ts @@ -1,6 +1,14 @@ // Check Ts Max Loc tests cover CLI argument validation before repository scans. import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; +import { + countPhysicalLines, + findLocBaselineUpdateViolations, + findLocRatchetViolations, + findVersionedBaselineViolations, + isProductionTypeScriptFile, + parseArgs, +} from "../../scripts/check-ts-max-loc.js"; function runCheckTsMaxLoc(args: string[]) { return spawnSync(process.execPath, ["--import", "tsx", "scripts/check-ts-max-loc.ts", ...args], { @@ -27,4 +35,120 @@ describe("scripts/check-ts-max-loc", () => { expect(result.stderr).toBe("--max requires a positive integer\n"); } }); + + it("parses a safe comparison base ref", () => { + expect(parseArgs(["--base-ref", "refs/remotes/origin/pr-base"])).toMatchObject({ + baseRef: "refs/remotes/origin/pr-base", + }); + expect(() => parseArgs(["--base-ref", "main^{tree}"])).toThrow("--base-ref requires a git ref"); + }); + + it("fails closed when a comparison ref does not exist", () => { + const result = runCheckTsMaxLoc(["--base-ref", "refs/heads/__loc-ratchet-missing__"]); + + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + "Invalid TypeScript LOC comparison ref: refs/heads/__loc-ratchet-missing__\n", + ); + }); + + it("grandfathers exact legacy sizes and rejects growth or stale baselines", () => { + const violations = findLocRatchetViolations({ + maxLines: 500, + baseline: { + "src/grew.ts": 700, + "src/shrank.ts": 700, + "src/now-small.ts": 700, + "src/removed.ts": 700, + "src/unchanged.ts": 700, + }, + results: [ + { filePath: "src/grew.ts", lines: 701 }, + { filePath: "src/new.ts", lines: 501 }, + { filePath: "src/now-small.ts", lines: 500 }, + { filePath: "src/shrank.ts", lines: 699 }, + { filePath: "src/unchanged.ts", lines: 700 }, + ], + }); + + expect(violations).toEqual([ + { filePath: "src/grew.ts", lines: 701, baselineLines: 700, reason: "grew" }, + { filePath: "src/shrank.ts", lines: 699, baselineLines: 700, reason: "baseline-stale" }, + { filePath: "src/new.ts", lines: 501, reason: "baseline-missing" }, + { + filePath: "src/now-small.ts", + lines: 500, + baselineLines: 700, + reason: "baseline-stale", + }, + { filePath: "src/removed.ts", lines: 0, baselineLines: 700, reason: "baseline-stale" }, + ]); + }); + + it("counts physical lines without treating a terminal newline as another line", () => { + expect(countPhysicalLines("")).toBe(0); + expect(countPhysicalLines("one")).toBe(1); + expect(countPhysicalLines("one\n")).toBe(1); + expect(countPhysicalLines("one\ntwo\n")).toBe(2); + }); + + it("excludes repository test and test-support naming conventions", () => { + expect(isProductionTypeScriptFile("src/runtime.ts")).toBe(true); + expect(isProductionTypeScriptFile("src/runtime.mts")).toBe(true); + expect(isProductionTypeScriptFile("src/runtime.cts")).toBe(true); + for (const filePath of [ + "src/runtime.test.ts", + "src/runtime.spec.tsx", + "src/runtime.suite.ts", + "src/runtime.test-harness.ts", + "src/runtime.test-support.ts", + "src/runtime-test-helpers.ts", + "src/test-helpers/runtime.ts", + "test/runtime.ts", + ]) { + expect(isProductionTypeScriptFile(filePath), filePath).toBe(false); + } + }); + + it("allows baseline updates only for decreases and removals", () => { + const violations = findLocBaselineUpdateViolations({ + maxLines: 500, + baseline: { + "src/grew.ts": 700, + "src/shrank.ts": 700, + "src/removed.ts": 700, + }, + results: [ + { filePath: "src/grew.ts", lines: 701 }, + { filePath: "src/shrank.ts", lines: 650 }, + { filePath: "src/new.ts", lines: 501 }, + ], + }); + + expect(violations).toEqual([ + { filePath: "src/grew.ts", lines: 701, baselineLines: 700, reason: "grew" }, + { filePath: "src/new.ts", lines: 501, reason: "baseline-missing" }, + ]); + }); + + it("rejects versioned baseline additions and increases", () => { + const violations = findVersionedBaselineViolations({ + baseBaseline: { + "src/grew.ts": 700, + "src/shrank.ts": 700, + "src/removed.ts": 700, + }, + baseline: { + "src/grew.ts": 701, + "src/shrank.ts": 650, + "src/new.ts": 501, + }, + }); + + expect(violations).toEqual([ + { filePath: "src/grew.ts", lines: 701, baselineLines: 700, reason: "grew" }, + { filePath: "src/new.ts", lines: 501, reason: "baseline-missing" }, + ]); + }); });