@oh-my-pi/pi-ai 17.2.4 → 17.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.5] - 2026-08-03
6
+
7
+ ### Changed
8
+
9
+ - Standardized tool-call examples in `renderToolExamples` and `renderToolInventory` to use Python keyword-argument syntax (`name(key="value")`) across all models, removing the model-specific dialect parameter and the `DialectRenderOptions.example` flag.
10
+ - Updated `renderToolInventory` to render the tool catalog as a unified OpenAI-Harmony-style `## functions` block using TypeScript type declarations and comments, replacing the previous per-tool Markdown sections.
11
+ - Added a `style: "harmony"` option to `jsonSchemaToTypeScript` for generating compact, comma-delimited TypeScript definitions.
12
+
13
+ ### Fixed
14
+
15
+ - Fixed a session-blocking issue where unescaped Harmony control tokens in replayed assistant responses and tool inputs caused subsequent requests to be rejected with `invalid_prompt` errors.
16
+ - Fixed an issue where Codex Responses dropped native image-generation results from assistant content and replays due to stale `generating` statuses.
17
+ - Fixed Anthropic stream truncation handling where unexpected connection closures were incorrectly treated as clean stops, causing the agent loop to halt silently mid-sentence.
18
+ - Optimized Anthropic prompt caching to prevent unnecessary cache invalidation of the entire system prefix when volatile project footer details (such as current working directory, date, or workspace tree) change.
19
+
5
20
  ## [17.2.4] - 2026-08-01
6
21
 
7
22
  ### Fixed
@@ -1,2 +1,10 @@
1
- import type { Dialect, InbandTool } from "./types.js";
2
- export declare function renderToolExamples(tool: InbandTool, dialect: Dialect, intentField?: string): string;
1
+ import type { InbandTool } from "./types.js";
2
+ /**
3
+ * Render a tool's examples as an `<examples>` block. Calls render in Python
4
+ * keyword-argument syntax (`name(key="value", n=1)`) regardless of the model's
5
+ * tool-call dialect, so example bytes stay identical across models. Multiline
6
+ * string args render as verbatim `"""…"""` blocks, and a call whose only
7
+ * argument is a string renders as the bare value — the block already names the
8
+ * tool, and payload args (commands, code, patches) read best verbatim.
9
+ */
10
+ export declare function renderToolExamples(tool: InbandTool, intentField?: string): string;
@@ -1,12 +1,9 @@
1
1
  import type { InbandTool } from "./types.js";
2
2
  /**
3
- * Human-readable per-tool inventory: each tool renders as a `# Tool: <name>`
4
- * section with its description, a simplified TypeScript-style parameter
5
- * signature (derived from the wire JSON Schema), and examples in the model's
6
- * native dialect. Shared by the verbose system-prompt inventory and
7
- * `/dump` so both render the catalog the same way.
8
- *
9
- * `model` is a model id; the native example dialect is resolved from it
10
- * (`preferredDialect`, which falls back to XML for empty/unknown ids).
3
+ * Tool catalog in the OpenAI-Harmony `namespace functions { }` shape: each
4
+ * tool renders as its full description (and Python-syntax examples) as `//`
5
+ * comment lines above a flat `type <name> = (_: {…});` declaration. Shared by
6
+ * the verbose system-prompt inventory and `/dump` so both render the catalog
7
+ * the same way.
11
8
  */
12
- export declare function renderToolInventory(tools: readonly InbandTool[], model: string): string;
9
+ export declare function renderToolInventory(tools: readonly InbandTool[]): string;
@@ -4,6 +4,15 @@ export declare function renderToolResponseResults(results: readonly DialectToolR
4
4
  export declare function kimiCallId(name: string, id: string, index: number): string;
5
5
  export declare function harmonyRecipient(name: string): string;
6
6
  export declare function stringifyJson(value: unknown): string;
7
+ /**
8
+ * Render `name(key=value, …)` with Python-literal argument values. Top-level
9
+ * multiline strings render as verbatim `"""…"""` blocks so payload-carrying
10
+ * args (file content, scripts, patches) keep real newlines instead of `\n`
11
+ * escape soup; nested values always use escaped single-line literals.
12
+ */
13
+ export declare function pyCall(name: string, args: Record<string, unknown>): string;
14
+ /** Render a JSON-ish value as a Python literal (`True`/`False`/`None`, escaped strings, lists, dicts). */
15
+ export declare function pyValue(value: unknown): string;
7
16
  export declare function escapeXmlAttr(value: string): string;
8
17
  export declare function escapeXmlText(value: string): string;
9
18
  export type AssistantTranscriptParts = {
@@ -42,7 +42,6 @@ export interface DialectToolResult {
42
42
  }
43
43
  export interface DialectRenderOptions {
44
44
  readonly tools?: readonly InbandTool[];
45
- readonly example?: boolean;
46
45
  }
47
46
  export interface DialectDefinition {
48
47
  readonly dialect: CatalogDialect;
@@ -40,6 +40,11 @@ export declare function retriable(id: number | undefined, opts?: {
40
40
  }): boolean;
41
41
  export declare function status(error: unknown): number | undefined;
42
42
  export declare function isStreamReadErrorText(text: string): boolean;
43
+ /** Persisted-text form of {@link isStreamEnvelopeError}: recognizes the
44
+ * prefix-tagged envelope diagnostic on an aborted turn's `errorMessage` /
45
+ * `stopDetails.explanation` so loop-level salvage can classify it after the
46
+ * original `Error` instance is gone. */
47
+ export declare function isStreamEnvelopeErrorText(text: string): boolean;
43
48
  export declare function classify(error: unknown, api?: Api): number;
44
49
  /**
45
50
  * Whether an error (or message string) classifies as an account usage/quota
@@ -421,17 +421,27 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
421
421
  preserveAssistantMessageIds?: boolean;
422
422
  }
423
423
  /**
424
- * Escape reserved Harmony control tokens in the client-boundary text of
425
- * replayed Responses input items user/developer/system message text and
426
- * tool-result output. Model-owned items (assistant output, reasoning, tool-call
427
- * arguments) carry no client data and are returned untouched.
424
+ * Escape reserved Harmony control tokens in the free-text fields of replayed
425
+ * Responses input items: user/developer/system text, tool-result output,
426
+ * assistant message text, and tool-call payloads.
427
+ *
428
+ * Tool-call items are covered deliberately. The original #6913 fix skipped
429
+ * model-owned items on the theory that they carry no client data — but a model
430
+ * legitimately writing *about* Harmony samples `<|channel|>` etc. into its own
431
+ * `function_call.arguments`, and a full-transcript replay (stale or blocked
432
+ * previous_response_id, provider fallback) feeds those bytes back as input,
433
+ * which gpt-5.x reject with invalid_prompt / "Request blocked", permanently
434
+ * poisoning the session. `arguments` is a JSON document, so it uses
435
+ * {@link escapeHarmonyControlTokensInJson} to stay parseable. Reasoning items
436
+ * are left untouched: `encrypted_content` is opaque and plaintext summaries
437
+ * are never rendered back into the prompt.
428
438
  *
429
439
  * Native history replay pushes stored `providerPayload` items straight onto the
430
440
  * wire, bypassing {@link convertResponsesInputContent}; without this a stored
431
441
  * `input_text` carrying `<|channel|>analysis` still reaches gpt-5.x raw (#6913).
432
442
  * Callers gate on {@link isHarmonyDialectModel}. Items are copied, not mutated.
433
443
  */
434
- export declare function escapeReplayedClientText(items: ResponseInput): ResponseInput;
444
+ export declare function escapeReplayedControlTokens(items: ResponseInput): ResponseInput;
435
445
  export declare function buildResponsesInput<TApi extends Api>(options: BuildResponsesInputOptions<TApi>): ResponseInput;
436
446
  export declare function convertResponsesAssistantMessage<TApi extends Api>(assistantMsg: AssistantMessage, model: Model<TApi>, msgIndex: number, knownCallIds: Set<string>, includeThinkingSignatures?: boolean, customCallIds?: Set<string>, preserveMessageIds?: boolean, supportsCustomToolCalls?: boolean, customToolWireNameMap?: ReadonlyMap<string, string>, computerCallIds?: Set<string>): ResponseInput;
437
447
  /** Appends one tool result while keeping consecutive outputs ahead of its synthetic image messages. */
@@ -516,6 +526,8 @@ export interface ProcessResponsesStreamOptions {
516
526
  requestServiceTier?: ServiceTier;
517
527
  }
518
528
  export declare function computerCallMetadata(item: ResponseComputerToolCall): ComputerToolCallMetadata;
529
+ /** Append a native Responses image result and emit its completion event. */
530
+ export declare function appendResponsesImageResult(output: AssistantMessage, stream: AssistantMessageEventStream, result: string): void;
519
531
  export declare function processResponsesStream<TApi extends Api>(openaiStream: AsyncIterable<ResponseStreamEvent>, output: AssistantMessage, stream: AssistantMessageEventStream, model: Model<TApi>, options?: ProcessResponsesStreamOptions): Promise<void>;
520
532
  export declare function mapOpenAIResponsesStopReason(status: ResponseStatus | undefined): StopReason;
521
533
  export declare function hasExecutableIncompleteResponsesToolCalls(output: AssistantMessage): boolean;
@@ -13,7 +13,7 @@ import type { Api, AssistantMessage, Message, Model } from "../types.js";
13
13
  * credential redaction is enabled. Exported so hosts can route the same shapes
14
14
  * through reversible obfuscation (keyed placeholders restored before local tool
15
15
  * execution) instead of the irreversible `[*_token_redacted]` rewrite below —
16
- * an irreversible placeholder echoed back in edit-tool `old_text` can never
16
+ * an irreversible placeholder echoed back in edit-tool `old_string` can never
17
17
  * match the real bytes on disk.
18
18
  */
19
19
  export declare const SENSITIVE_TOKEN_RE: RegExp;
@@ -124,10 +124,10 @@ export declare function serviceTierFamily(model: ServiceTierModel): ServiceTierF
124
124
  export declare function resolveModelServiceTier(tiers: ServiceTierByFamily | null | undefined, model: Pick<Model, "provider" | "api" | "id">): ServiceTier | undefined;
125
125
  /**
126
126
  * True when the tier should be sent on the wire as the provider's service-tier
127
- * request field. OpenAI / OpenAI-Codex accept `flex`/`scale`/`priority`; Google
128
- * (Gemini API + Vertex) and OpenRouter accept `flex`/`priority`; Fireworks
129
- * Serverless realizes only its Priority serving path. Anthropic is absent — it
130
- * realizes `priority` via `speed: "fast"`, not a service-tier field.
127
+ * request field. OpenAI / OpenAI-Codex accept every {@link ServiceTier};
128
+ * Google (Gemini API + Vertex) and OpenRouter accept `flex`/`priority`;
129
+ * Fireworks Serverless realizes only its Priority serving path. Anthropic is
130
+ * absent because it realizes `priority` via `speed: "fast"`.
131
131
  */
132
132
  export declare function shouldSendServiceTier(serviceTier: ServiceTier | null | undefined, target: Provider | ServiceTierModel | undefined): boolean;
133
133
  /**
@@ -660,6 +660,8 @@ export interface AssistantRetryRecovery {
660
660
  export interface ContextSnapshot {
661
661
  promptTokens: number;
662
662
  nonMessageTokens: number;
663
+ /** Estimated prompt tokens removed by local history rewrites after this provider snapshot was recorded. */
664
+ historyRewriteTokensRemoved?: number;
663
665
  lastMessageTimestamp?: number;
664
666
  }
665
667
  export interface AssistantMessage {
@@ -8,6 +8,15 @@ import type { AssistantMessage, Model, ToolCall } from "../types.js";
8
8
  * the persisted transcript keeps the byte-for-byte original.
9
9
  */
10
10
  export declare function escapeHarmonyControlTokens(text: string): string;
11
+ /**
12
+ * Escape reserved Harmony control tokens inside a JSON document string (e.g.
13
+ * `function_call.arguments`). Doubles the backslash so the document remains
14
+ * valid JSON whose *decoded* strings carry the inert `<\|token\|>` spelling.
15
+ * `<|` cannot occur outside a string literal in valid JSON, so the blanket
16
+ * replace never corrupts structure; malformed documents are escaped
17
+ * best-effort.
18
+ */
19
+ export declare function escapeHarmonyControlTokensInJson(text: string): string;
11
20
  /**
12
21
  * Whether requests to `model` are served by a Harmony-dialect backend
13
22
  * (gpt-5.x / gpt-oss), which rejects reserved control-token spellings appearing
@@ -9,10 +9,16 @@
9
9
  * literal enums/consts, and descriptions survive.
10
10
  */
11
11
  export interface JsonSchemaToTsOptions {
12
- /** Indentation unit for nested object bodies. Default two spaces. */
12
+ /** Indentation unit for nested object bodies. Default two spaces (none in `harmony` style). */
13
13
  readonly indent?: string;
14
- /** Emit `description` keywords as JSDoc comments on object properties. Default true. */
14
+ /** Emit `description` keywords as comments on object properties. Default true. */
15
15
  readonly comments?: boolean;
16
+ /**
17
+ * Output flavor. `default` renders JSDoc comments, `;` delimiters, and
18
+ * indented bodies; `harmony` renders the flat OpenAI-Harmony convention —
19
+ * `//` line comments, `,` delimiters, no indentation.
20
+ */
21
+ readonly style?: "default" | "harmony";
16
22
  }
17
23
  /** Convert a JSON Schema object into a simplified TypeScript type string. */
18
24
  export declare function jsonSchemaToTypeScript(schema: unknown, options?: JsonSchemaToTsOptions): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "17.2.4",
4
+ "version": "17.2.5",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/pi-catalog": "17.2.4",
42
- "@oh-my-pi/pi-utils": "17.2.4",
43
- "@oh-my-pi/pi-wire": "17.2.4",
41
+ "@oh-my-pi/pi-catalog": "17.2.5",
42
+ "@oh-my-pi/pi-utils": "17.2.5",
43
+ "@oh-my-pi/pi-wire": "17.2.5",
44
44
  "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import * as path from "node:path";
8
8
  import {
9
+ $envExact,
9
10
  getAgentDbPath,
10
11
  getAgentDir,
11
12
  getAuthBrokerSnapshotCachePath,
@@ -55,7 +56,7 @@ export function getAuthBrokerTokenFilePath(): string {
55
56
  */
56
57
  async function defaultResolveConfigValue(config: string): Promise<string | undefined> {
57
58
  if (config.startsWith("!")) return undefined;
58
- const envValue = process.env[config];
59
+ const envValue = $envExact(config);
59
60
  return envValue || config;
60
61
  }
61
62
 
@@ -12,7 +12,7 @@ import { createHash } from "node:crypto";
12
12
  import * as fs from "node:fs/promises";
13
13
  import * as path from "node:path";
14
14
  import { parseAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan";
15
- import { $env, getAgentDbPath, getDbBusyTimeoutMs, logger } from "@oh-my-pi/pi-utils";
15
+ import { $env, $envExact, getAgentDbPath, getDbBusyTimeoutMs, logger } from "@oh-my-pi/pi-utils";
16
16
  import type { ApiKeyResolver } from "./auth-retry";
17
17
  import * as AIError from "./error";
18
18
  import { isUsageLimitOutcome } from "./error/rate-limit";
@@ -643,7 +643,7 @@ export type AuthStorageOptions = {
643
643
  * Does NOT support "!command" syntax (that requires pi-natives).
644
644
  */
645
645
  async function defaultConfigValueResolver(config: string): Promise<string | undefined> {
646
- const envValue = process.env[config];
646
+ const envValue = $envExact(config);
647
647
  return envValue || config;
648
648
  }
649
649
 
@@ -1,25 +1,37 @@
1
- import type { ToolCall } from "../types";
2
- import { getDialectDefinition } from "./factory";
3
- import type { Dialect, InbandTool } from "./types";
1
+ import { pyCall } from "./rendering";
2
+ import type { InbandTool } from "./types";
4
3
 
5
4
  const INTENT_PLACEHOLDER = "…";
6
5
 
7
- export function renderToolExamples(tool: InbandTool, dialect: Dialect, intentField?: string): string {
6
+ /**
7
+ * Render a tool's examples as an `<examples>` block. Calls render in Python
8
+ * keyword-argument syntax (`name(key="value", n=1)`) regardless of the model's
9
+ * tool-call dialect, so example bytes stay identical across models. Multiline
10
+ * string args render as verbatim `"""…"""` blocks, and a call whose only
11
+ * argument is a string renders as the bare value — the block already names the
12
+ * tool, and payload args (commands, code, patches) read best verbatim.
13
+ */
14
+ export function renderToolExamples(tool: InbandTool, intentField?: string): string {
8
15
  const examples = tool.examples;
9
16
  if (!examples?.length) return "";
10
- const definition = getDialectDefinition(dialect);
11
17
  const renderCall = (args: Record<string, unknown>): string => {
18
+ let soleKey: string | undefined;
19
+ let argCount = 0;
20
+ for (const key in args) {
21
+ argCount++;
22
+ soleKey = key;
23
+ }
24
+ if (argCount === 1 && soleKey !== undefined && typeof args[soleKey] === "string") {
25
+ // Bare payload. The intent placeholder still rides on the envelope so
26
+ // intent-traced schemas (where `i` is required) keep teaching it.
27
+ const intentAttr = intentField ? ` ${intentField}="${INTENT_PLACEHOLDER}"` : "";
28
+ return `<example${intentAttr}>\n${args[soleKey]}\n</example>`;
29
+ }
12
30
  // When intent tracing injects `i` into the schema, examples must show a
13
31
  // placeholder so the model learns to emit it. Keep it first, matching the
14
32
  // schema injection order.
15
33
  const finalArgs = intentField ? { [intentField]: INTENT_PLACEHOLDER, ...args } : args;
16
- const call: ToolCall = {
17
- type: "toolCall",
18
- id: "example",
19
- name: tool.name,
20
- arguments: finalArgs,
21
- };
22
- return `<example>\n${definition.renderToolCall(call, { tools: [tool], example: true }).trim()}\n</example>`;
34
+ return `<example>\n${pyCall(tool.name, finalArgs)}\n</example>`;
23
35
  };
24
36
  const parts = examples.map(ex => {
25
37
  const head = ex.caption ? `# ${ex.caption}\n` : "";
@@ -2,7 +2,13 @@ import type { Message, ToolCall } from "../types";
2
2
  import { mintToolCallId, partialSuffixOverlapAny } from "./coercion";
3
3
  import { FencedThinkingScanner } from "./fenced-thinking";
4
4
  import dialectPrompt from "./gemini.md" with { type: "text" };
5
- import { assistantTranscriptParts, collectToolResultRun, joinUserBodies, messageContentText } from "./rendering";
5
+ import {
6
+ assistantTranscriptParts,
7
+ collectToolResultRun,
8
+ joinUserBodies,
9
+ messageContentText,
10
+ pyValue,
11
+ } from "./rendering";
6
12
  import type {
7
13
  DialectDefinition,
8
14
  DialectRenderOptions,
@@ -494,11 +500,15 @@ function topLevelIndexOf(text: string, ch: string): number {
494
500
  return -1;
495
501
  }
496
502
 
497
- function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): string {
498
- const kwargs = Object.entries(call.arguments)
499
- .map(([key, value]) => `${key}=${pyValue(value)}`)
500
- .join(", ");
501
- return options.example ? `${call.name}(${kwargs})` : `default_api.${call.name}(${kwargs})`;
503
+ function renderToolCall(call: ToolCall, _options: DialectRenderOptions = {}): string {
504
+ // Always escaped single-line literals: the scanner round-trips this wire
505
+ // form through unescapePythonString, so pyCall's verbatim `"""` example
506
+ // blocks would corrupt backslash-bearing content.
507
+ let kwargs = "";
508
+ for (const key in call.arguments) {
509
+ kwargs += `${kwargs ? ", " : ""}${key}=${pyValue(call.arguments[key])}`;
510
+ }
511
+ return `default_api.${call.name}(${kwargs})`;
502
512
  }
503
513
 
504
514
  function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string {
@@ -507,8 +517,7 @@ function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRe
507
517
  calls.length === 1
508
518
  ? renderToolCall(calls[0]!, options)
509
519
  : `[${calls.map(call => renderToolCall(call, options)).join(", ")}]`;
510
- // Examples show the bare call; the live wire form fences it as `tool_code`.
511
- return options.example ? body : `${CODE_OPEN}\n${body}\n${FENCE}`;
520
+ return `${CODE_OPEN}\n${body}\n${FENCE}`;
512
521
  }
513
522
 
514
523
  function renderToolResults(results: readonly DialectToolResult[]): string {
@@ -560,29 +569,6 @@ function geminiTurn(role: "model" | "user", body: string): string {
560
569
  return `<start_of_turn>${role}\n${body}<end_of_turn>\n`;
561
570
  }
562
571
 
563
- function pyValue(value: unknown): string {
564
- if (value === null || value === undefined) return "None";
565
- if (typeof value === "boolean") return value ? "True" : "False";
566
- if (typeof value === "number") return Number.isFinite(value) ? String(value) : pyString(String(value));
567
- if (typeof value === "string") return pyString(value);
568
- if (Array.isArray(value)) return `[${value.map(pyValue).join(", ")}]`;
569
- if (typeof value === "object") {
570
- const entries = Object.entries(value as Record<string, unknown>);
571
- return `{${entries.map(([key, val]) => `${pyString(key)}: ${pyValue(val)}`).join(", ")}}`;
572
- }
573
- return pyString(String(value));
574
- }
575
-
576
- function pyString(value: string): string {
577
- const escaped = value
578
- .replaceAll("\\", "\\\\")
579
- .replaceAll('"', '\\"')
580
- .replaceAll("\n", "\\n")
581
- .replaceAll("\r", "\\r")
582
- .replaceAll("\t", "\\t");
583
- return `"${escaped}"`;
584
- }
585
-
586
572
  const definition: DialectDefinition = {
587
573
  dialect: "gemini",
588
574
  prompt: dialectPrompt,
@@ -273,8 +273,7 @@ function parseRecipient(header: string): string {
273
273
  return match?.[1] ?? "";
274
274
  }
275
275
 
276
- function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): string {
277
- if (options.example) return stringifyJson(call.arguments);
276
+ function renderToolCall(call: ToolCall, _options: DialectRenderOptions = {}): string {
278
277
  return `${START}assistant${CHANNEL}commentary to=${harmonyRecipient(call.name)}${MESSAGE}${stringifyJson(call.arguments)}${CALL}`;
279
278
  }
280
279
 
@@ -1,73 +1,30 @@
1
- import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
2
1
  import { jsonSchemaToTypeScript, toolWireSchema } from "../utils/schema";
3
2
  import { renderToolExamples } from "./examples";
4
3
  import type { InbandTool } from "./types";
5
4
 
6
5
  /**
7
- * Human-readable per-tool inventory: each tool renders as a `# Tool: <name>`
8
- * section with its description, a simplified TypeScript-style parameter
9
- * signature (derived from the wire JSON Schema), and examples in the model's
10
- * native dialect. Shared by the verbose system-prompt inventory and
11
- * `/dump` so both render the catalog the same way.
12
- *
13
- * `model` is a model id; the native example dialect is resolved from it
14
- * (`preferredDialect`, which falls back to XML for empty/unknown ids).
6
+ * Tool catalog in the OpenAI-Harmony `namespace functions { }` shape: each
7
+ * tool renders as its full description (and Python-syntax examples) as `//`
8
+ * comment lines above a flat `type <name> = (_: {…});` declaration. Shared by
9
+ * the verbose system-prompt inventory and `/dump` so both render the catalog
10
+ * the same way.
15
11
  */
16
- export function renderToolInventory(tools: readonly InbandTool[], model: string): string {
12
+ export function renderToolInventory(tools: readonly InbandTool[]): string {
17
13
  if (tools.length === 0) return "";
18
- const dialect = preferredDialect(model);
19
- return tools
20
- .map(tool => {
21
- const params = jsonSchemaToTypeScript(toolWireSchema(tool));
22
- const examples = renderToolExamples(tool, dialect);
23
- const description = demoteDescriptionHeaders(tool.description ?? "");
24
- const parts = [`# Tool: ${tool.name}`, description, "", `Parameters: ${params}`];
25
- if (examples) parts.push("", examples);
26
- return parts.join("\n");
27
- })
28
- .join("\n\n");
29
- }
30
-
31
- const FENCE = /^ {0,3}(`{3,}|~{3,})/;
32
- const ATX = /^ {0,3}#{1,6}( |\t|$)/;
33
- const TOP_LEVEL = /^ {0,3}#( |\t|$)/;
34
-
35
- /**
36
- * Each description is rendered under a `# Tool: <name>` heading. When the
37
- * description carries its own top-level (`# `) markdown headers they sit at the
38
- * same level as that wrapper, so the section structure flattens and the
39
- * description's headers read like sibling tools. Demote every ATX header in the
40
- * description by one level so the whole block nests under `# Tool: <name>`.
41
- *
42
- * Only triggered when a level-1 header is actually present — descriptions that
43
- * already start at `##` are left untouched. Headers inside fenced code blocks
44
- * are never rewritten.
45
- */
46
- function demoteDescriptionHeaders(description: string): string {
47
- const lines = description.split("\n");
48
-
49
- let fence: string | undefined;
50
- let collides = false;
51
- for (const line of lines) {
52
- const marker = FENCE.exec(line)?.[1][0];
53
- if (marker) {
54
- fence = fence === undefined ? marker : fence === marker ? undefined : fence;
55
- } else if (fence === undefined && TOP_LEVEL.test(line)) {
56
- collides = true;
57
- break;
14
+ const declarations = tools.map(tool => {
15
+ const params = jsonSchemaToTypeScript(toolWireSchema(tool), { style: "harmony" });
16
+ const lines: string[] = [];
17
+ const description = tool.description ?? "";
18
+ if (description) {
19
+ for (const line of description.split("\n")) lines.push(`// ${line}`.trimEnd());
58
20
  }
59
- }
60
- if (!collides) return description;
61
-
62
- fence = undefined;
63
- for (let i = 0; i < lines.length; i++) {
64
- const line = lines[i];
65
- const marker = FENCE.exec(line)?.[1][0];
66
- if (marker) {
67
- fence = fence === undefined ? marker : fence === marker ? undefined : fence;
68
- } else if (fence === undefined && ATX.test(line)) {
69
- lines[i] = line.replace(/^( {0,3})#/, "$1##");
21
+ const examples = renderToolExamples(tool);
22
+ if (examples) {
23
+ if (description) lines.push("//");
24
+ for (const line of examples.split("\n")) lines.push(`// ${line}`.trimEnd());
70
25
  }
71
- }
72
- return lines.join("\n");
26
+ lines.push(params === "{}" ? `type ${tool.name} = ();` : `type ${tool.name} = (_: ${params});`);
27
+ return lines.join("\n");
28
+ });
29
+ return `## functions\n\nnamespace functions {\n\n${declarations.join("\n\n")}\n\n} // namespace functions`;
73
30
  }
@@ -19,6 +19,60 @@ export function stringifyJson(value: unknown): string {
19
19
  return stringifyJsonValue(value) ?? "null";
20
20
  }
21
21
 
22
+ /**
23
+ * Render `name(key=value, …)` with Python-literal argument values. Top-level
24
+ * multiline strings render as verbatim `"""…"""` blocks so payload-carrying
25
+ * args (file content, scripts, patches) keep real newlines instead of `\n`
26
+ * escape soup; nested values always use escaped single-line literals.
27
+ */
28
+ export function pyCall(name: string, args: Record<string, unknown>): string {
29
+ let kwargs = "";
30
+ for (const key in args) {
31
+ kwargs += `${kwargs ? ", " : ""}${key}=${pyArgValue(args[key])}`;
32
+ }
33
+ return `${name}(${kwargs})`;
34
+ }
35
+
36
+ function pyArgValue(value: unknown): string {
37
+ if (typeof value === "string" && value.includes("\n")) {
38
+ // Verbatim `"""` fencing is only unambiguous when the content cannot
39
+ // collide with the fence: no `"""` inside, no quote butting against a
40
+ // fence edge, no trailing backslash swallowing the closer.
41
+ const fenceSafe =
42
+ !value.includes('"""') && !value.startsWith('"') && !value.endsWith('"') && !value.endsWith("\\");
43
+ if (fenceSafe) return `"""${value}"""`;
44
+ }
45
+ return pyValue(value);
46
+ }
47
+
48
+ /** Render a JSON-ish value as a Python literal (`True`/`False`/`None`, escaped strings, lists, dicts). */
49
+ export function pyValue(value: unknown): string {
50
+ if (value === null || value === undefined) return "None";
51
+ if (typeof value === "boolean") return value ? "True" : "False";
52
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : pyString(String(value));
53
+ if (typeof value === "string") return pyString(value);
54
+ if (Array.isArray(value)) return `[${value.map(pyValue).join(", ")}]`;
55
+ if (typeof value === "object") {
56
+ const record = value as Record<string, unknown>;
57
+ let entries = "";
58
+ for (const key in record) {
59
+ entries += `${entries ? ", " : ""}${pyString(key)}: ${pyValue(record[key])}`;
60
+ }
61
+ return `{${entries}}`;
62
+ }
63
+ return pyString(String(value));
64
+ }
65
+
66
+ function pyString(value: string): string {
67
+ const escaped = value
68
+ .replaceAll("\\", "\\\\")
69
+ .replaceAll('"', '\\"')
70
+ .replaceAll("\n", "\\n")
71
+ .replaceAll("\r", "\\r")
72
+ .replaceAll("\t", "\\t");
73
+ return `"${escaped}"`;
74
+ }
75
+
22
76
  export function escapeXmlAttr(value: string): string {
23
77
  return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
24
78
  }
@@ -27,7 +27,6 @@ export interface DialectToolResult {
27
27
 
28
28
  export interface DialectRenderOptions {
29
29
  readonly tools?: readonly InbandTool[];
30
- readonly example?: boolean;
31
30
  }
32
31
 
33
32
  export interface DialectDefinition {
@@ -270,6 +270,14 @@ export function isStreamReadErrorText(text: string): boolean {
270
270
  return STREAM_READ_ERROR_PATTERN.test(text);
271
271
  }
272
272
 
273
+ /** Persisted-text form of {@link isStreamEnvelopeError}: recognizes the
274
+ * prefix-tagged envelope diagnostic on an aborted turn's `errorMessage` /
275
+ * `stopDetails.explanation` so loop-level salvage can classify it after the
276
+ * original `Error` instance is gone. */
277
+ export function isStreamEnvelopeErrorText(text: string): boolean {
278
+ return text.includes(STREAM_ENVELOPE_ERROR_PREFIX);
279
+ }
280
+
273
281
  function isTransientErrorText(text: string): boolean {
274
282
  return (
275
283
  isUnexpectedSocketCloseMessage(text) ||
@@ -2563,7 +2563,22 @@ const streamAnthropicOnce = (
2563
2563
  if (!sawEvent || !sawMessageStart) {
2564
2564
  throw new AIError.AnthropicStreamEnvelopeError("stream ended before message_start");
2565
2565
  }
2566
+ if (!sawTerminalEnvelope) {
2567
+ // Neither a message_delta stop_reason nor message_stop arrived: the
2568
+ // connection died mid-generation. Finalizing the partial message as
2569
+ // a clean "stop" would make the agent loop treat the truncated turn
2570
+ // as complete (silent mid-sentence halt), so fail the turn. The
2571
+ // envelope error is transparently retried before replay-unsafe
2572
+ // content streams; afterwards it surfaces as an error turn whose
2573
+ // complete tool calls the agent loop salvages
2574
+ // (`recoverTransientErrorToolTurn` recognizes the envelope-error
2575
+ // text and `retainCompletedToolCalls` drops half-streamed calls).
2576
+ throw new AIError.AnthropicStreamEnvelopeError("stream ended before message_stop");
2577
+ }
2566
2578
  if (!sawMessageStop) {
2579
+ // A stop_reason arrived via message_delta, so generation finished;
2580
+ // only the trailing message_stop frame is missing (non-conforming
2581
+ // gateway). Degrade to best-effort instead of discarding the turn.
2567
2582
  reportAnthropicEnvelopeAnomaly("stream ended before message_stop");
2568
2583
  }
2569
2584
  if (openBlocks.size > 0) {
@@ -2780,15 +2795,48 @@ type SystemBlockOptions = {
2780
2795
  cacheControl?: AnthropicCacheControl;
2781
2796
  };
2782
2797
 
2783
- function applyClaudeCodeSystemCache(
2798
+ /**
2799
+ * Place system-block cache breakpoints that survive volatile project context.
2800
+ *
2801
+ * omp normally appends its project footer (cwd, date, workspace tree) after the
2802
+ * stable system prefix. When cwd is outside a single direct child repository,
2803
+ * an active-repo context block follows that footer. Caching up to the last three
2804
+ * eligible blocks therefore covers both layouts:
2805
+ *
2806
+ * - stable prefix, project footer
2807
+ * - stable prefix, project footer, active-repo context
2808
+ *
2809
+ * A footer change can then fall back to the stable-prefix entry instead of
2810
+ * re-writing the entire system cache (issue #7324).
2811
+ *
2812
+ * @returns breakpoints placed, capped by `maxBreakpoints`.
2813
+ */
2814
+ function cacheSystemPrefixBreakpoints(
2784
2815
  blocks: AnthropicSystemBlock[],
2785
2816
  cacheControl: AnthropicCacheControl | undefined,
2817
+ maxBreakpoints: number,
2818
+ firstCacheableIndex: number,
2786
2819
  ): number {
2787
- if (!cacheControl || blocks.length === 0) return 0;
2788
- const lastIndex = blocks.length - 1;
2789
- if (blocks[lastIndex].cache_control != null) return 0;
2790
- blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cloneAnthropicCacheControl(cacheControl) };
2791
- return 1;
2820
+ if (!cacheControl || maxBreakpoints <= 0) return 0;
2821
+ let placed = 0;
2822
+ for (let index = blocks.length - 1; index >= firstCacheableIndex && placed < maxBreakpoints; index--) {
2823
+ if (blocks[index].cache_control != null) continue;
2824
+ blocks[index] = { ...blocks[index], cache_control: cloneAnthropicCacheControl(cacheControl) };
2825
+ placed++;
2826
+ }
2827
+ return placed;
2828
+ }
2829
+
2830
+ /**
2831
+ * First system-block index that may carry a cache breakpoint. Skips the OAuth
2832
+ * cloak blocks that must stay uncached: the CC billing header (block 0, a
2833
+ * per-request fingerprint) and the Claude Code identity instruction (block 1).
2834
+ */
2835
+ function firstCacheableSystemIndex(blocks: readonly AnthropicSystemBlock[]): number {
2836
+ let index = 0;
2837
+ if (blocks[index]?.text?.startsWith(CLAUDE_BILLING_HEADER_PREFIX)) index++;
2838
+ if (blocks[index]?.text === claudeCodeSystemInstruction) index++;
2839
+ return index;
2792
2840
  }
2793
2841
 
2794
2842
  export function buildAnthropicSystemBlocks(
@@ -2812,7 +2860,7 @@ export function buildAnthropicSystemBlocks(
2812
2860
  for (const prompt of sanitizedPrompts) {
2813
2861
  blocks.push({ type: "text", text: prompt });
2814
2862
  }
2815
- applyClaudeCodeSystemCache(blocks, cacheControl);
2863
+ cacheSystemPrefixBreakpoints(blocks, cacheControl, 3, firstCacheableSystemIndex(blocks));
2816
2864
 
2817
2865
  return blocks;
2818
2866
  }
@@ -3083,17 +3131,6 @@ type CacheControlBlock = {
3083
3131
  cache_control?: AnthropicCacheControl | null;
3084
3132
  };
3085
3133
 
3086
- function applyCacheControlToLastBlock<T extends CacheControlBlock>(
3087
- blocks: T[],
3088
- cacheControl: AnthropicCacheControl,
3089
- ): boolean {
3090
- if (blocks.length === 0) return false;
3091
- const lastIndex = blocks.length - 1;
3092
- if (blocks[lastIndex].cache_control != null) return false;
3093
- blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cloneAnthropicCacheControl(cacheControl) };
3094
- return true;
3095
- }
3096
-
3097
3134
  function applyCacheControlToLastTextBlock(
3098
3135
  blocks: Array<ContentBlockParam & CacheControlBlock>,
3099
3136
  cacheControl: AnthropicCacheControl,
@@ -3127,24 +3164,32 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
3127
3164
  let isCCLayout = false;
3128
3165
 
3129
3166
  if (params.system && Array.isArray(params.system) && params.system.length > 0) {
3130
- isCCLayout =
3131
- params.system.length >= 3 &&
3132
- (params.system[0] as { text?: string }).text?.startsWith(CLAUDE_BILLING_HEADER_PREFIX) === true;
3133
- if (isCCLayout) {
3134
- const placed = Math.min(
3135
- MAX_CACHE_BREAKPOINTS - cacheBreakpointsUsed,
3136
- applyClaudeCodeSystemCache(params.system as AnthropicSystemBlock[], cacheControl),
3137
- );
3138
- cacheBreakpointsUsed += placed;
3139
- } else if (applyCacheControlToLastBlock(params.system, cacheControl)) {
3140
- cacheBreakpointsUsed++;
3141
- }
3167
+ isCCLayout = params.system[0]?.text?.startsWith(CLAUDE_BILLING_HEADER_PREFIX) === true;
3168
+ const maxSystemBreakpoints = Math.min(3, MAX_CACHE_BREAKPOINTS - cacheBreakpointsUsed);
3169
+ cacheBreakpointsUsed += cacheSystemPrefixBreakpoints(
3170
+ params.system as AnthropicSystemBlock[],
3171
+ cacheControl,
3172
+ maxSystemBreakpoints,
3173
+ isCCLayout ? firstCacheableSystemIndex(params.system as AnthropicSystemBlock[]) : 0,
3174
+ );
3142
3175
  }
3143
3176
 
3144
3177
  if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) return;
3145
3178
 
3146
- const start = isCCLayout ? Math.max(0, params.messages.length - 1) : Math.max(0, params.messages.length - 2);
3147
- for (let i = start; i < params.messages.length; i++) {
3179
+ // `convertAnthropicMessages` appends this neutral pad after a trailing
3180
+ // assistant because Anthropic rejects assistant-prefill endings. It is absent
3181
+ // from the next normal turn, so caching it wastes a scarce breakpoint; anchor
3182
+ // the cache window on the preceding real assistant instead.
3183
+ const trailingIndex = params.messages.length - 1;
3184
+ const trailingMessage = params.messages[trailingIndex];
3185
+ const hasTrailingAssistantPad =
3186
+ trailingMessage?.role === "user" &&
3187
+ trailingMessage.content === "Continue." &&
3188
+ params.messages[trailingIndex - 1]?.role === "assistant";
3189
+ const messageEnd = hasTrailingAssistantPad ? trailingIndex - 1 : trailingIndex;
3190
+ const messageWindowSize = isCCLayout ? 1 : 2;
3191
+ const start = Math.max(0, messageEnd - messageWindowSize + 1);
3192
+ for (let i = messageEnd; i >= start; i--) {
3148
3193
  if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) break;
3149
3194
  const message = params.messages[i];
3150
3195
  if (!message) continue;
@@ -1816,11 +1816,14 @@ async function handleExecServerMessage(
1816
1816
  case "piEditArgs": {
1817
1817
  const args = execMsg.message.value;
1818
1818
  const toolCallId = crypto.randomUUID();
1819
- // `PiEditReplacement` is the local `edit` tool's replace mode verbatim:
1820
- // snake_case `old_text`/`new_text` entries against one path.
1819
+ // `PiEditReplacement` maps onto the local `edit` tool's replace mode:
1820
+ // one snake_case `old_string`/`new_string` per call. Multi-replacement
1821
+ // frames display the first replacement; the exec handler applies all.
1822
+ const firstEdit = args.edits[0];
1821
1823
  synthesizeCursorExecToolCall(output, stream, state, toolCallId, "edit", {
1822
1824
  path: args.path,
1823
- edits: args.edits.map(edit => ({ old_text: edit.oldText, new_text: edit.newText })),
1825
+ old_string: firstEdit?.oldText ?? "",
1826
+ new_string: firstEdit?.newText ?? "",
1824
1827
  });
1825
1828
  const { execResult } = await resolveExecHandler(
1826
1829
  { args, toolCallId },
@@ -84,6 +84,7 @@ import type {
84
84
  ResponseFunctionToolCall,
85
85
  ResponseInput,
86
86
  ResponseInputContent,
87
+ ResponseOutputItem,
87
88
  ResponseOutputMessage,
88
89
  ResponseReasoningItem,
89
90
  ResponseStatus,
@@ -96,6 +97,7 @@ import {
96
97
  appendReasoningSummaryPart,
97
98
  appendReasoningSummaryPartDone,
98
99
  appendReasoningSummaryTextDelta,
100
+ appendResponsesImageResult,
99
101
  appendResponsesToolResultMessages,
100
102
  applyOpenAIServiceTier,
101
103
  applyReasoningSummaryDone,
@@ -106,7 +108,7 @@ import {
106
108
  createSequentialCutoffSummaryState,
107
109
  encodeResponsesToolCallId,
108
110
  encodeTextSignatureV1,
109
- escapeReplayedClientText,
111
+ escapeReplayedControlTokens,
110
112
  finalizeCustomToolCallInputDone,
111
113
  finalizeMessageText,
112
114
  finalizePendingResponsesToolCalls,
@@ -371,7 +373,8 @@ type CodexEventItem =
371
373
  | ResponseOutputMessage
372
374
  | ResponseFunctionToolCall
373
375
  | ResponseCustomToolCall
374
- | ResponseComputerToolCall;
376
+ | ResponseComputerToolCall
377
+ | ResponseOutputItem.ImageGenerationCall;
375
378
  type CodexOutputBlock =
376
379
  | ThinkingContent
377
380
  | TextContent
@@ -2289,6 +2292,7 @@ class CodexStreamProcessor {
2289
2292
  const rawItem = rawEvent.item;
2290
2293
  if (!rawItem || typeof rawItem !== "object") return;
2291
2294
  const item = structuredCloneJSON(rawItem) as CodexEventItem;
2295
+ if (item.type === "image_generation_call" && item.result) item.status = "completed";
2292
2296
  runtime.nativeOutputItems.push(item as unknown as Record<string, unknown>);
2293
2297
 
2294
2298
  // Match the finalization to the OPEN ITEM that started this block, not the
@@ -2301,6 +2305,12 @@ class CodexStreamProcessor {
2301
2305
  const block = entry?.block ?? null;
2302
2306
  const contentIndex = entry?.contentIndex ?? output.content.length - 1;
2303
2307
 
2308
+ if (item.type === "image_generation_call" && item.result) {
2309
+ appendResponsesImageResult(output, stream, item.result);
2310
+ runtime.closeOpenItem(entry);
2311
+ return;
2312
+ }
2313
+
2304
2314
  if (item.type === "reasoning" && block?.type === "thinking") {
2305
2315
  this.#flushSummaryDeltas(entry);
2306
2316
  block.thinking = finalizeReasoningThinking(
@@ -4339,6 +4349,9 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
4339
4349
  };
4340
4350
 
4341
4351
  const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
4352
+ // gpt-5.x reject raw Harmony control-token spellings anywhere in replayed
4353
+ // input, including the model's own tool-call arguments (#6913).
4354
+ const escapeControlTokens = isHarmonyDialectModel(model);
4342
4355
  let msgIndex = 0;
4343
4356
  // Track call_ids that originated as custom tool calls so paired tool-result
4344
4357
  // messages can be replayed as `custom_tool_call_output` rather than
@@ -4368,7 +4381,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
4368
4381
  knownCallIds.add(item.call_id);
4369
4382
  }
4370
4383
  }
4371
- messages.push(...(isHarmonyDialectModel(model) ? escapeReplayedClientText(replayItems) : replayItems));
4384
+ messages.push(...(escapeControlTokens ? escapeReplayedControlTokens(replayItems) : replayItems));
4372
4385
  msgIndex += 1;
4373
4386
  continue;
4374
4387
  }
@@ -4394,10 +4407,11 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
4394
4407
  if (historyItems) {
4395
4408
  const sanitizedHistoryItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(historyItems);
4396
4409
  if (sanitizedHistoryItems) {
4397
- const replayItems =
4410
+ const rawReplayItems =
4398
4411
  model.supportsComputerUse === true
4399
4412
  ? sanitizedHistoryItems
4400
4413
  : unrollCodexComputerItems(sanitizedHistoryItems, model.compat.supportsImageDetailOriginal);
4414
+ const replayItems = escapeControlTokens ? escapeReplayedControlTokens(rawReplayItems) : rawReplayItems;
4401
4415
  for (const item of replayItems) {
4402
4416
  if (item.type === "custom_tool_call") {
4403
4417
  customCallIds.add(item.call_id);
@@ -4435,7 +4449,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
4435
4449
  ? sanitizeOpenAIResponsesAssistantFallbackItemsForReplay(convertedOutputItems)
4436
4450
  : convertedOutputItems;
4437
4451
  if (outputItems.length > 0) {
4438
- messages.push(...outputItems);
4452
+ messages.push(...(escapeControlTokens ? escapeReplayedControlTokens(outputItems) : outputItems));
4439
4453
  }
4440
4454
  msgIndex += 1;
4441
4455
  continue;
@@ -78,7 +78,11 @@ import {
78
78
  kStreamingPartialJson,
79
79
  } from "../utils/block-symbols";
80
80
  import type { AssistantMessageEventStream } from "../utils/event-stream";
81
- import { escapeHarmonyControlTokens, isHarmonyDialectModel } from "../utils/harmony-leak";
81
+ import {
82
+ escapeHarmonyControlTokens,
83
+ escapeHarmonyControlTokensInJson,
84
+ isHarmonyDialectModel,
85
+ } from "../utils/harmony-leak";
82
86
  import type { CapturedHttpErrorResponse } from "../utils/http-inspector";
83
87
  import { getOpenRouterHeaders } from "../utils/openrouter-headers";
84
88
  import { isForcedToolChoice } from "../utils/tool-choice";
@@ -329,9 +333,7 @@ export function applyOpenAIServiceTier(
329
333
  model: Pick<Model, "provider" | "api" | "id">,
330
334
  ): void {
331
335
  if (!shouldSendServiceTier(serviceTier, model)) return;
332
- if (serviceTier === "flex" || serviceTier === "scale" || serviceTier === "priority") {
333
- params.service_tier = serviceTier;
334
- }
336
+ params.service_tier = serviceTier;
335
337
  }
336
338
 
337
339
  /**
@@ -1636,39 +1638,71 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
1636
1638
  }
1637
1639
 
1638
1640
  /**
1639
- * Escape reserved Harmony control tokens in the client-boundary text of
1640
- * replayed Responses input items user/developer/system message text and
1641
- * tool-result output. Model-owned items (assistant output, reasoning, tool-call
1642
- * arguments) carry no client data and are returned untouched.
1641
+ * Escape reserved Harmony control tokens in the free-text fields of replayed
1642
+ * Responses input items: user/developer/system text, tool-result output,
1643
+ * assistant message text, and tool-call payloads.
1644
+ *
1645
+ * Tool-call items are covered deliberately. The original #6913 fix skipped
1646
+ * model-owned items on the theory that they carry no client data — but a model
1647
+ * legitimately writing *about* Harmony samples `<|channel|>` etc. into its own
1648
+ * `function_call.arguments`, and a full-transcript replay (stale or blocked
1649
+ * previous_response_id, provider fallback) feeds those bytes back as input,
1650
+ * which gpt-5.x reject with invalid_prompt / "Request blocked", permanently
1651
+ * poisoning the session. `arguments` is a JSON document, so it uses
1652
+ * {@link escapeHarmonyControlTokensInJson} to stay parseable. Reasoning items
1653
+ * are left untouched: `encrypted_content` is opaque and plaintext summaries
1654
+ * are never rendered back into the prompt.
1643
1655
  *
1644
1656
  * Native history replay pushes stored `providerPayload` items straight onto the
1645
1657
  * wire, bypassing {@link convertResponsesInputContent}; without this a stored
1646
1658
  * `input_text` carrying `<|channel|>analysis` still reaches gpt-5.x raw (#6913).
1647
1659
  * Callers gate on {@link isHarmonyDialectModel}. Items are copied, not mutated.
1648
1660
  */
1649
- export function escapeReplayedClientText(items: ResponseInput): ResponseInput {
1661
+ export function escapeReplayedControlTokens(items: ResponseInput): ResponseInput {
1650
1662
  return items.map(item => {
1651
1663
  if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
1652
1664
  return typeof item.output === "string" ? { ...item, output: escapeHarmonyControlTokens(item.output) } : item;
1653
1665
  }
1666
+ if (item.type === "function_call") {
1667
+ return typeof item.arguments === "string"
1668
+ ? { ...item, arguments: escapeHarmonyControlTokensInJson(item.arguments) }
1669
+ : item;
1670
+ }
1671
+ if (item.type === "custom_tool_call") {
1672
+ return typeof item.input === "string" ? { ...item, input: escapeHarmonyControlTokens(item.input) } : item;
1673
+ }
1654
1674
  // EasyInputMessage may omit `type` (`{ role, content }`); the responses
1655
1675
  // server persists it verbatim, so treat missing type as a message too.
1656
1676
  const isTypedMessage = item.type === "message" || item.type === undefined;
1657
- if (isTypedMessage && "role" in item && "content" in item) {
1658
- const role = item.role;
1659
- if (role !== "user" && role !== "developer" && role !== "system") return item;
1660
- const content = item.content;
1661
- if (typeof content === "string") {
1662
- return { ...item, content: escapeHarmonyControlTokens(content) };
1663
- }
1664
- if (Array.isArray(content)) {
1677
+ if (!isTypedMessage || !("role" in item) || !("content" in item)) return item;
1678
+ if (item.role === "assistant") {
1679
+ // Assistant output text is model-owned but equally capable of carrying
1680
+ // control tokens as data. `status` discriminates ResponseOutputMessage.
1681
+ if ("status" in item && Array.isArray(item.content)) {
1665
1682
  return {
1666
1683
  ...item,
1667
- content: content.map(part =>
1668
- part.type === "input_text" ? { ...part, text: escapeHarmonyControlTokens(part.text) } : part,
1684
+ content: item.content.map(part =>
1685
+ part.type === "output_text"
1686
+ ? { ...part, text: escapeHarmonyControlTokens(part.text) }
1687
+ : part.type === "refusal"
1688
+ ? { ...part, refusal: escapeHarmonyControlTokens(part.refusal) }
1689
+ : part,
1669
1690
  ),
1670
1691
  };
1671
1692
  }
1693
+ return item;
1694
+ }
1695
+ const content = item.content;
1696
+ if (typeof content === "string") {
1697
+ return { ...item, content: escapeHarmonyControlTokens(content) };
1698
+ }
1699
+ if (Array.isArray(content)) {
1700
+ return {
1701
+ ...item,
1702
+ content: content.map(part =>
1703
+ part.type === "input_text" ? { ...part, text: escapeHarmonyControlTokens(part.text) } : part,
1704
+ ),
1705
+ };
1672
1706
  }
1673
1707
  return item;
1674
1708
  });
@@ -1733,7 +1767,7 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
1733
1767
  customToolWireNameMap,
1734
1768
  options.model.supportsComputerUse === true,
1735
1769
  );
1736
- messages.push(...(escapeControlTokens ? escapeReplayedClientText(replayItems) : replayItems));
1770
+ messages.push(...(escapeControlTokens ? escapeReplayedControlTokens(replayItems) : replayItems));
1737
1771
  knownCallIds = collectKnownCallIds(messages);
1738
1772
  for (const id of collectCustomCallIds(messages)) customCallIds.add(id);
1739
1773
  for (const id of collectComputerCallIds(messages)) computerCallIds.add(id);
@@ -1794,10 +1828,16 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
1794
1828
  )
1795
1829
  : undefined;
1796
1830
  if (nativeReplayEnabled && sanitizedHistoryItems) {
1831
+ // Model-owned replay items can carry reserved control-token
1832
+ // spellings as data (the model writing *about* Harmony); escape the
1833
+ // transport copy just like client turns.
1834
+ const wireItems = escapeControlTokens
1835
+ ? escapeReplayedControlTokens(sanitizedHistoryItems)
1836
+ : sanitizedHistoryItems;
1797
1837
  if (providerPayload?.dt) {
1798
- messages.push(...sanitizedHistoryItems);
1838
+ messages.push(...wireItems);
1799
1839
  } else {
1800
- messages.splice(0, messages.length, ...sanitizedHistoryItems);
1840
+ messages.splice(0, messages.length, ...wireItems);
1801
1841
  customCallIds.clear();
1802
1842
  computerCallIds.clear();
1803
1843
  }
@@ -1826,7 +1866,7 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
1826
1866
  ? sanitizeOpenAIResponsesAssistantFallbackItemsForReplay(convertedOutputItems)
1827
1867
  : convertedOutputItems;
1828
1868
  if (outputItems.length === 0) continue;
1829
- messages.push(...outputItems);
1869
+ messages.push(...(escapeControlTokens ? escapeReplayedControlTokens(outputItems) : outputItems));
1830
1870
  } else if (msg.role === "toolResult") {
1831
1871
  appendResponsesToolResultMessages(
1832
1872
  messages,
@@ -2419,6 +2459,26 @@ export function computerCallMetadata(item: ResponseComputerToolCall): ComputerTo
2419
2459
  };
2420
2460
  }
2421
2461
 
2462
+ /** Append a native Responses image result and emit its completion event. */
2463
+ export function appendResponsesImageResult(
2464
+ output: AssistantMessage,
2465
+ stream: AssistantMessageEventStream,
2466
+ result: string,
2467
+ ): void {
2468
+ const image: ImageContent = {
2469
+ type: "image",
2470
+ data: result,
2471
+ mimeType: parseImageMetadata(Buffer.from(result, "base64"))?.mimeType ?? "image/png",
2472
+ };
2473
+ output.content.push(image);
2474
+ stream.push({
2475
+ type: "image_end",
2476
+ contentIndex: output.content.length - 1,
2477
+ content: image,
2478
+ partial: output,
2479
+ });
2480
+ }
2481
+
2422
2482
  export async function processResponsesStream<TApi extends Api>(
2423
2483
  openaiStream: AsyncIterable<ResponseStreamEvent>,
2424
2484
  output: AssistantMessage,
@@ -2932,18 +2992,7 @@ export async function processResponsesStream<TApi extends Api>(
2932
2992
  closeOpenItem(event.output_index, item.id, entry, item.call_id, prefixedFunctionCallItemKey(item.call_id));
2933
2993
  stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
2934
2994
  } else if (item.type === "image_generation_call" && item.status === "completed" && item.result) {
2935
- const image: ImageContent = {
2936
- type: "image",
2937
- data: item.result,
2938
- mimeType: parseImageMetadata(Buffer.from(item.result, "base64"))?.mimeType ?? "image/png",
2939
- };
2940
- output.content.push(image);
2941
- stream.push({
2942
- type: "image_end",
2943
- contentIndex: output.content.length - 1,
2944
- content: image,
2945
- partial: output,
2946
- });
2995
+ appendResponsesImageResult(output, stream, item.result);
2947
2996
  }
2948
2997
  } else if (terminalEvent) {
2949
2998
  const response = terminalEvent.response;
@@ -300,7 +300,7 @@ function normalizeAnthropicTargetToolCallId<TApi extends Api>(
300
300
  * credential redaction is enabled. Exported so hosts can route the same shapes
301
301
  * through reversible obfuscation (keyed placeholders restored before local tool
302
302
  * execution) instead of the irreversible `[*_token_redacted]` rewrite below —
303
- * an irreversible placeholder echoed back in edit-tool `old_text` can never
303
+ * an irreversible placeholder echoed back in edit-tool `old_string` can never
304
304
  * match the real bytes on disk.
305
305
  */
306
306
  export const SENSITIVE_TOKEN_RE =
package/src/types.ts CHANGED
@@ -214,10 +214,10 @@ export function resolveModelServiceTier(
214
214
 
215
215
  /**
216
216
  * True when the tier should be sent on the wire as the provider's service-tier
217
- * request field. OpenAI / OpenAI-Codex accept `flex`/`scale`/`priority`; Google
218
- * (Gemini API + Vertex) and OpenRouter accept `flex`/`priority`; Fireworks
219
- * Serverless realizes only its Priority serving path. Anthropic is absent — it
220
- * realizes `priority` via `speed: "fast"`, not a service-tier field.
217
+ * request field. OpenAI / OpenAI-Codex accept every {@link ServiceTier};
218
+ * Google (Gemini API + Vertex) and OpenRouter accept `flex`/`priority`;
219
+ * Fireworks Serverless realizes only its Priority serving path. Anthropic is
220
+ * absent because it realizes `priority` via `speed: "fast"`.
221
221
  */
222
222
  export function shouldSendServiceTier(
223
223
  serviceTier: ServiceTier | null | undefined,
@@ -225,12 +225,11 @@ export function shouldSendServiceTier(
225
225
  ): boolean {
226
226
  if (!serviceTier) return false;
227
227
  const provider = typeof target === "string" ? target : target?.provider;
228
- if (provider === "openai" || provider === "openai-codex" || provider === "openrouter") {
229
- return serviceTier === "flex" || serviceTier === "scale" || serviceTier === "priority";
230
- }
231
- if (typeof target !== "string" && target && isOpenAIServiceTierModel(target)) {
228
+ if (provider === "openai" || provider === "openai-codex") return true;
229
+ if (provider === "openrouter") {
232
230
  return serviceTier === "flex" || serviceTier === "scale" || serviceTier === "priority";
233
231
  }
232
+ if (typeof target !== "string" && target && isOpenAIServiceTierModel(target)) return true;
234
233
  if (provider === "google") {
235
234
  return serviceTier === "flex" || serviceTier === "priority";
236
235
  }
@@ -844,6 +843,8 @@ export interface AssistantRetryRecovery {
844
843
  export interface ContextSnapshot {
845
844
  promptTokens: number; // authoritative provider prompt/input tokens
846
845
  nonMessageTokens: number; // estimated non-message total at send time
846
+ /** Estimated prompt tokens removed by local history rewrites after this provider snapshot was recorded. */
847
+ historyRewriteTokensRemoved?: number;
847
848
  lastMessageTimestamp?: number;
848
849
  }
849
850
 
@@ -35,6 +35,18 @@ export function escapeHarmonyControlTokens(text: string): string {
35
35
  return text.replace(HARMONY_CONTROL_TOKEN_ESCAPE_RE, "<\\|$1\\|>");
36
36
  }
37
37
 
38
+ /**
39
+ * Escape reserved Harmony control tokens inside a JSON document string (e.g.
40
+ * `function_call.arguments`). Doubles the backslash so the document remains
41
+ * valid JSON whose *decoded* strings carry the inert `<\|token\|>` spelling.
42
+ * `<|` cannot occur outside a string literal in valid JSON, so the blanket
43
+ * replace never corrupts structure; malformed documents are escaped
44
+ * best-effort.
45
+ */
46
+ export function escapeHarmonyControlTokensInJson(text: string): string {
47
+ return text.replace(HARMONY_CONTROL_TOKEN_ESCAPE_RE, "<\\\\|$1\\\\|>");
48
+ }
49
+
38
50
  /**
39
51
  * Whether requests to `model` are served by a Harmony-dialect backend
40
52
  * (gpt-5.x / gpt-oss), which rejects reserved control-token spellings appearing
@@ -12,15 +12,22 @@
12
12
  import { isJsonObject } from "./types";
13
13
 
14
14
  export interface JsonSchemaToTsOptions {
15
- /** Indentation unit for nested object bodies. Default two spaces. */
15
+ /** Indentation unit for nested object bodies. Default two spaces (none in `harmony` style). */
16
16
  readonly indent?: string;
17
- /** Emit `description` keywords as JSDoc comments on object properties. Default true. */
17
+ /** Emit `description` keywords as comments on object properties. Default true. */
18
18
  readonly comments?: boolean;
19
+ /**
20
+ * Output flavor. `default` renders JSDoc comments, `;` delimiters, and
21
+ * indented bodies; `harmony` renders the flat OpenAI-Harmony convention —
22
+ * `//` line comments, `,` delimiters, no indentation.
23
+ */
24
+ readonly style?: "default" | "harmony";
19
25
  }
20
26
 
21
27
  interface Ctx {
22
28
  readonly indent: string;
23
29
  readonly comments: boolean;
30
+ readonly harmony: boolean;
24
31
  readonly defs: Record<string, unknown> | undefined;
25
32
  readonly seen: Set<unknown>;
26
33
  }
@@ -48,7 +55,11 @@ function joinUnion(parts: readonly string[]): string {
48
55
  return unique.length > 0 ? unique.join(" | ") : "never";
49
56
  }
50
57
 
51
- function emitJsDoc(lines: string[], description: string, pad: string): void {
58
+ function emitDescription(lines: string[], description: string, ctx: Ctx, pad: string): void {
59
+ if (ctx.harmony) {
60
+ for (const line of description.split("\n")) lines.push(`${pad}// ${line}`.trimEnd());
61
+ return;
62
+ }
52
63
  // `* /` keeps a stray closing token inside the description from ending the comment.
53
64
  const safe = description.replace(/\*\//g, "* /");
54
65
  if (!safe.includes("\n")) {
@@ -85,6 +96,7 @@ function convertObject(node: Record<string, unknown>, ctx: Ctx, pad: string): st
85
96
  const required = new Set(
86
97
  Array.isArray(node.required) ? node.required.filter((key): key is string => typeof key === "string") : [],
87
98
  );
99
+ const delimiter = ctx.harmony ? "," : ";";
88
100
  for (const key in properties) {
89
101
  const value = properties[key];
90
102
  if (
@@ -93,11 +105,11 @@ function convertObject(node: Record<string, unknown>, ctx: Ctx, pad: string): st
93
105
  typeof value.description === "string" &&
94
106
  value.description.length > 0
95
107
  ) {
96
- emitJsDoc(body, value.description, childPad);
108
+ emitDescription(body, value.description, ctx, childPad);
97
109
  }
98
110
  const optional = required.has(key) ? "" : "?";
99
111
  const name = SAFE_KEY.test(key) ? key : JSON.stringify(key);
100
- body.push(`${childPad}${name}${optional}: ${convert(value, ctx, childPad)};`);
112
+ body.push(`${childPad}${name}${optional}: ${convert(value, ctx, childPad)}${delimiter}`);
101
113
  }
102
114
  }
103
115
 
@@ -110,7 +122,7 @@ function convertObject(node: Record<string, unknown>, ctx: Ctx, pad: string): st
110
122
 
111
123
  // Named properties alongside a free-form value schema → index signature.
112
124
  if (isJsonObject(additional)) {
113
- body.push(`${childPad}[key: string]: ${convert(additional, ctx, childPad)};`);
125
+ body.push(`${childPad}[key: string]: ${convert(additional, ctx, childPad)}${ctx.harmony ? "," : ";"}`);
114
126
  }
115
127
  return `{\n${body.join("\n")}\n${pad}}`;
116
128
  }
@@ -188,9 +200,11 @@ export function jsonSchemaToTypeScript(schema: unknown, options?: JsonSchemaToTs
188
200
  }
189
201
  }
190
202
  }
203
+ const harmony = options?.style === "harmony";
191
204
  const ctx: Ctx = {
192
- indent: options?.indent ?? " ",
205
+ indent: options?.indent ?? (harmony ? "" : " "),
193
206
  comments: options?.comments ?? true,
207
+ harmony,
194
208
  defs,
195
209
  seen: new Set(),
196
210
  };
package/src/utils.ts CHANGED
@@ -403,7 +403,7 @@ function sanitizeOpenAIResponsesReasoningItemForReplay(
403
403
  function sanitizeOpenAIResponsesImageGenerationCallForReplay(
404
404
  item: Record<string, unknown>,
405
405
  ): ResponseInputItem.ImageGenerationCall | undefined {
406
- if (typeof item.id !== "string" || item.status !== "completed" || typeof item.result !== "string") {
406
+ if (typeof item.id !== "string" || typeof item.result !== "string" || item.result.length === 0) {
407
407
  return undefined;
408
408
  }
409
409
  return {