@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 +15 -0
- package/dist/types/dialect/examples.d.ts +10 -2
- package/dist/types/dialect/inventory.d.ts +6 -9
- package/dist/types/dialect/rendering.d.ts +9 -0
- package/dist/types/dialect/types.d.ts +0 -1
- package/dist/types/error/flags.d.ts +5 -0
- package/dist/types/providers/openai-shared.d.ts +17 -5
- package/dist/types/providers/transform-messages.d.ts +1 -1
- package/dist/types/types.d.ts +6 -4
- package/dist/types/utils/harmony-leak.d.ts +9 -0
- package/dist/types/utils/schema/typescript.d.ts +8 -2
- package/package.json +4 -4
- package/src/auth-broker/discover.ts +2 -1
- package/src/auth-storage.ts +2 -2
- package/src/dialect/examples.ts +24 -12
- package/src/dialect/gemini.ts +17 -31
- package/src/dialect/harmony.ts +1 -2
- package/src/dialect/inventory.ts +20 -63
- package/src/dialect/rendering.ts +54 -0
- package/src/dialect/types.ts +0 -1
- package/src/error/flags.ts +8 -0
- package/src/providers/anthropic.ts +77 -32
- package/src/providers/cursor.ts +6 -3
- package/src/providers/openai-codex-responses.ts +19 -5
- package/src/providers/openai-shared.ts +84 -35
- package/src/providers/transform-messages.ts +1 -1
- package/src/types.ts +9 -8
- package/src/utils/harmony-leak.ts +12 -0
- package/src/utils/schema/typescript.ts +21 -7
- package/src/utils.ts +1 -1
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 {
|
|
2
|
-
|
|
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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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[]
|
|
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 = {
|
|
@@ -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
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
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
|
|
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 `
|
|
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;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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
|
|
128
|
-
* (Gemini API + Vertex) and OpenRouter accept `flex`/`priority`;
|
|
129
|
-
* Serverless realizes only its Priority serving path. Anthropic is
|
|
130
|
-
* realizes `priority` via `speed: "fast"
|
|
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
|
|
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
|
+
"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.
|
|
42
|
-
"@oh-my-pi/pi-utils": "17.2.
|
|
43
|
-
"@oh-my-pi/pi-wire": "17.2.
|
|
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 =
|
|
59
|
+
const envValue = $envExact(config);
|
|
59
60
|
return envValue || config;
|
|
60
61
|
}
|
|
61
62
|
|
package/src/auth-storage.ts
CHANGED
|
@@ -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 =
|
|
646
|
+
const envValue = $envExact(config);
|
|
647
647
|
return envValue || config;
|
|
648
648
|
}
|
|
649
649
|
|
package/src/dialect/examples.ts
CHANGED
|
@@ -1,25 +1,37 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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` : "";
|
package/src/dialect/gemini.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
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,
|
package/src/dialect/harmony.ts
CHANGED
|
@@ -273,8 +273,7 @@ function parseRecipient(header: string): string {
|
|
|
273
273
|
return match?.[1] ?? "";
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
function renderToolCall(call: ToolCall,
|
|
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
|
|
package/src/dialect/inventory.ts
CHANGED
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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[]
|
|
12
|
+
export function renderToolInventory(tools: readonly InbandTool[]): string {
|
|
17
13
|
if (tools.length === 0) return "";
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/dialect/rendering.ts
CHANGED
|
@@ -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("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
|
24
78
|
}
|
package/src/dialect/types.ts
CHANGED
package/src/error/flags.ts
CHANGED
|
@@ -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
|
-
|
|
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 ||
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
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
|
-
|
|
3147
|
-
|
|
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;
|
package/src/providers/cursor.ts
CHANGED
|
@@ -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`
|
|
1820
|
-
// snake_case `
|
|
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
|
-
|
|
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
|
-
|
|
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(...(
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
1640
|
-
*
|
|
1641
|
-
*
|
|
1642
|
-
*
|
|
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
|
|
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
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
if (
|
|
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 === "
|
|
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 ?
|
|
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(...
|
|
1838
|
+
messages.push(...wireItems);
|
|
1799
1839
|
} else {
|
|
1800
|
-
messages.splice(0, messages.length, ...
|
|
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
|
-
|
|
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 `
|
|
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
|
|
218
|
-
* (Gemini API + Vertex) and OpenRouter accept `flex`/`priority`;
|
|
219
|
-
* Serverless realizes only its Priority serving path. Anthropic is
|
|
220
|
-
* realizes `priority` via `speed: "fast"
|
|
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"
|
|
229
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
406
|
+
if (typeof item.id !== "string" || typeof item.result !== "string" || item.result.length === 0) {
|
|
407
407
|
return undefined;
|
|
408
408
|
}
|
|
409
409
|
return {
|