From 140d0141d9221d9c654bb8297ed8c4e35e7a74fd Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Wed, 26 Aug 2026 09:06:46 +0000 Subject: [PATCH] fix(responses): normalize Posit continuations --- .../src/content/docs/reference/adapters.md | 5 + .../content/docs/reference/proxy-formats.md | 2 + src/adapters/openai-responses.ts | 75 ++++++++++ src/compatibility/openai-responses.ts | 18 ++- structure/11_compatibility-contracts.md | 21 +++ .../openai-codex-forward-gpt56-sol-v1.json | 12 +- ...sponses-forward-posit-continuation.test.ts | 134 ++++++++++++++++++ 7 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 tests/responses-forward-posit-continuation.test.ts diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 18179f1b6d..7149f8dc78 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -67,6 +67,11 @@ This rewrite is destination-scoped. Key-auth public/custom Responses providers a forward gateways keep both fields unchanged; a multimodal system message is never partially folded or silently dropped. +For canonical forward continuations, client-only `prompt_cache_breakpoint` properties are removed +recursively within bounded traversal limits. When `store: false`, `item_reference` rows are also +omitted because the destination cannot resolve an item it did not persist. Function/tool `call_id` +pairs and `reasoning.effort` are preserved. + For `key` auth, [`retryOn429`](/reference/configuration/) applies here too: a pre-stream 429 waits and replays the identical request on the same key before any other handling, exactly like the translated `openai-chat` / Anthropic request path. Custom `runTurn` transports are not part diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 094591b008..1a68e78436 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -57,6 +57,8 @@ handle only the item types they recognize, and may reject a feature their provid On the canonical ChatGPT Codex forward route, text-only `system` input messages are folded into top-level `instructions`, and `truncation` is removed because that destination rejects both public Responses shapes. Other Responses destinations preserve them. +The same canonical boundary removes nested client-only `prompt_cache_breakpoint` markers and drops +`item_reference` entries only on `store: false` continuations; tool call/result pairing is unchanged. ### JSON and SSE output diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 2294c083c5..305cc5d755 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1167,6 +1167,80 @@ function normalizeCanonicalForwardPromptEnvelope(body: unknown): unknown { return next; } +const POSIT_CACHE_MARKER_MAX_DEPTH = 64; +const POSIT_CACHE_MARKER_MAX_NODES = 100_000; + +type PromptCacheMarkerRewrite = { + value: unknown; + changed: boolean; + complete: boolean; +}; + +/** + * Remove Posit/Anthropic-style prompt-cache markers without trusting request nesting. The walk + * aborts atomically when its depth or node budget is exceeded, so a hostile extension object can + * neither overflow the stack nor receive a partially rewritten subtree. + */ +function stripPromptCacheBreakpoints( + value: unknown, + state: { nodes: number }, + depth = 0, +): PromptCacheMarkerRewrite { + state.nodes += 1; + if (depth > POSIT_CACHE_MARKER_MAX_DEPTH || state.nodes > POSIT_CACHE_MARKER_MAX_NODES) { + return { value, changed: false, complete: false }; + } + if (Array.isArray(value)) { + let changed = false; + const next: unknown[] = []; + for (const entry of value) { + const rewritten = stripPromptCacheBreakpoints(entry, state, depth + 1); + if (!rewritten.complete) return { value, changed: false, complete: false }; + changed ||= rewritten.changed; + next.push(rewritten.value); + } + return { value: changed ? next : value, changed, complete: true }; + } + if (!isPlainObject(value)) return { value, changed: false, complete: true }; + + let changed = Object.hasOwn(value, "prompt_cache_breakpoint"); + const next: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (key === "prompt_cache_breakpoint") continue; + const rewritten = stripPromptCacheBreakpoints(entry, state, depth + 1); + if (!rewritten.complete) return { value, changed: false, complete: false }; + changed ||= rewritten.changed; + next[key] = rewritten.value; + } + return { value: changed ? next : value, changed, complete: true }; +} + +/** + * Posit Assistant can replay client-only cache markers and stored-item references on a + * `store: false` continuation. The canonical ChatGPT Codex backend rejects both. Remove the + * markers recursively and drop only `item_reference` rows that cannot name persisted state; + * ordinary item ids are handled later by stripItemIdsWhenUnstored and tool call_id pairs remain. + */ +function normalizeCanonicalForwardContinuationEnvelope(body: unknown): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + let input: unknown[] = body.input; + let changed = false; + if (body.store === false) { + const withoutReferences = input.filter(item => !isPlainObject(item) || item.type !== "item_reference"); + if (withoutReferences.length !== input.length) { + input = withoutReferences; + changed = true; + } + } + + const markerRewrite = stripPromptCacheBreakpoints(input, { nodes: 0 }); + if (markerRewrite.complete && markerRewrite.changed) { + input = markerRewrite.value as unknown[]; + changed = true; + } + return changed ? { ...body, input } : body; +} + const IMAGE_GEN_NAMESPACE = "image_gen"; const HOSTED_IMAGE_GENERATION_TOOL = "image_generation"; const IMAGE_GEN_DOTTED_PREFIX = `${IMAGE_GEN_NAMESPACE}.`; @@ -1858,6 +1932,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (isCanonicalOpenAiForwardProvider(provider)) { outBody = stripDeprecatedPromptCacheRetention(outBody, parsed.modelId); outBody = normalizeCanonicalForwardPromptEnvelope(outBody); + outBody = normalizeCanonicalForwardContinuationEnvelope(outBody); } } else { outBody = preferConfiguredHostedTools( diff --git a/src/compatibility/openai-responses.ts b/src/compatibility/openai-responses.ts index fd9393d028..75384dfaf2 100644 --- a/src/compatibility/openai-responses.ts +++ b/src/compatibility/openai-responses.ts @@ -9,7 +9,7 @@ const FIXTURE_ID = "openai-codex-forward-gpt56-sol-v1"; export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManifest({ schemaVersion: 1, id: "openai.codex-forward.gpt-5-6-sol.responses", - version: "1.1.0", + version: "1.2.0", subject: { providerId: "openai", baseUrl: "https://chatgpt.com/backend-api/codex", @@ -85,6 +85,22 @@ export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManife limitation: "The field is removed only for the canonical forward destination; public and custom Responses providers keep it.", evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["truncation-removed"] }], }, + { + id: "prompt-cache-breakpoint", + feature: "request.prompt_cache_breakpoint", + disposition: "unsupported", + summary: "Client-only prompt cache breakpoint markers do not reach the forward backend.", + limitation: "Markers are removed recursively from input within bounded depth and node budgets.", + evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["prompt-cache-breakpoint-removed"] }], + }, + { + id: "unstored-item-reference", + feature: "continuation.item_reference", + disposition: "degraded", + summary: "Unpersisted item references are omitted from store-false continuations.", + limitation: "The referenced item cannot exist at the backend when store is false; tool call_id pairs remain unchanged.", + evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["unstored-item-reference-removed"] }], + }, { id: "prompt-cache-retention", feature: "request.prompt_cache_retention", diff --git a/structure/11_compatibility-contracts.md b/structure/11_compatibility-contracts.md index 483825852a..d8be62e253 100644 --- a/structure/11_compatibility-contracts.md +++ b/structure/11_compatibility-contracts.md @@ -39,6 +39,27 @@ Compatibility manifests are passive data. The Responses request path, router, an not import them. A future `ocx compatibility explain` or GUI reader may load the catalog on demand, but adding a manifest must not activate Compatibility Lab or alter dispatch behavior. +## Canonical forward continuation extensions + +The canonical ChatGPT Codex forward boundary removes client-only +`prompt_cache_breakpoint` properties from `input` recursively. The traversal is bounded by depth +and node count; exceeding either bound leaves the marker-bearing input unchanged instead of +publishing a partially transformed continuation. When the request explicitly sets `store: false`, +top-level `item_reference` input rows are omitted because the destination cannot resolve state that +it did not persist. Function and tool-result `call_id` pairs and `reasoning.effort` remain intact. + +This is destination-scoped compatibility behavior. Key-auth public Responses providers and custom +forward gateways keep both extensions unchanged because their contracts may accept or interpret +them independently. + +[Decision Log] +- 목적과 의도: Preserve Posit Assistant tool continuation semantics while preventing canonical ChatGPT Codex forwarding from sending client-only cache markers or unresolvable stored-item references. +- 기존 구현 및 제약 조건: The existing `store: false` sanitizer removed item ids but left `item_reference` shells, and no bounded pass recognized markers nested inside content; tool `call_id` pairing and reasoning effort are continuation-critical. +- 검토한 주요 대안: Strip the extensions for every Responses destination; delete only reference ids; expand references from local state; or normalize only the canonical forward destination with bounded recursive marker removal. +- 선택한 방식: Apply the bounded marker pass only to canonical forward `input`, and omit `item_reference` rows only when `store` is exactly `false`. +- 다른 대안 대신 이 방식을 선택한 이유: Public and custom gateways may implement these extensions, while id-only deletion creates an invalid reference shell and local expansion would invent unavailable persistence authority. +- 장점, 단점 및 영향: Posit continuations retain tool pairing and reasoning controls without widening public-provider behavior; hostile nesting fails closed to the original input, so an over-limit request may still be rejected upstream rather than partially rewritten. + [Decision Log] - 목적과 의도: Make provider compatibility explicit and machine-readable before larger routing or Responses refactors. - 기존 구현 및 제약 조건: Adapter-wide conformance tests already protect tool translation, and Compatibility Lab owns broader protocol evidence, but neither publishes an exact provider/destination/auth/model claim table. Lab must remain outside the ordinary request import graph. diff --git a/tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json b/tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json index 7852a38bac..bc33f244e9 100644 --- a/tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json +++ b/tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json @@ -24,7 +24,15 @@ { "type": "message", "role": "user", - "content": [{ "type": "input_text", "text": "ping" }] + "content": [{ + "type": "input_text", + "text": "ping", + "prompt_cache_breakpoint": { "type": "ephemeral" } + }] + }, + { + "type": "item_reference", + "id": "rs_unpersisted" } ], "instructions": "Fixture instructions", @@ -79,6 +87,8 @@ } }, { "id": "truncation-removed", "operator": "absent", "path": "/body/truncation" }, + { "id": "prompt-cache-breakpoint-removed", "operator": "absent", "path": "/body/input/0/content/0/prompt_cache_breakpoint" }, + { "id": "unstored-item-reference-removed", "operator": "absent", "path": "/body/input/1" }, { "id": "previous-response-id-removed", "operator": "absent", "path": "/body/previous_response_id" }, { "id": "prompt-cache-key-preserved", "operator": "equals", "path": "/body/prompt_cache_key", "expected": "project-cache-v1" }, { "id": "prompt-cache-retention-removed", "operator": "absent", "path": "/body/prompt_cache_retention" } diff --git a/tests/responses-forward-posit-continuation.test.ts b/tests/responses-forward-posit-continuation.test.ts new file mode 100644 index 0000000000..59b7e71c11 --- /dev/null +++ b/tests/responses-forward-posit-continuation.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createProductionAdapter } from "../src/adapters/openai-responses"; +import { parseRequest } from "../src/responses/parser"; +import type { OcxProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createAdapter = (provider: OcxProviderConfig) => + withTestTranslatorBudget(createProductionAdapter(provider)); + +const canonicalForward: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", +}; + +function outboundBody(provider: OcxProviderConfig, rawBody: Record): Record { + const request = createAdapter(provider).buildRequest( + parseRequest(structuredClone(rawBody)), + { headers: new Headers({ authorization: "Bearer test-token" }) }, + ); + try { + return JSON.parse(request.body) as Record; + } finally { + request.releaseBodyObservation?.(); + } +} + +function positContinuation(store: boolean): Record { + return { + model: "gpt-5.6-luna", + store, + stream: true, + reasoning: { effort: "high" }, + input: [ + { + type: "message", + role: "user", + content: [{ + type: "input_text", + text: "continue", + prompt_cache_breakpoint: { type: "ephemeral" }, + }], + }, + { + type: "item_reference", + id: "rs_unpersisted", + }, + { + type: "function_call", + id: "fc_pair", + call_id: "call_pair", + name: "list_files", + arguments: "{}", + }, + { + type: "function_call_output", + call_id: "call_pair", + output: [{ + type: "input_text", + text: "file.R", + metadata: { + nested: { + prompt_cache_breakpoint: true, + keep: "visible", + }, + }, + }], + }, + ], + }; +} + +describe("canonical ChatGPT forward Posit continuation normalization", () => { + test("removes cache markers and unstored references without changing call_id or reasoning", () => { + const body = outboundBody(canonicalForward, positContinuation(false)); + const input = body.input as Array>; + + expect(JSON.stringify(input)).not.toContain("prompt_cache_breakpoint"); + expect(input.some(item => item.type === "item_reference")).toBe(false); + expect(input.find(item => item.type === "function_call")?.call_id).toBe("call_pair"); + expect(input.find(item => item.type === "function_call_output")?.call_id).toBe("call_pair"); + expect(body.reasoning).toEqual({ effort: "high" }); + expect(JSON.stringify(input)).toContain('"keep":"visible"'); + }); + + test("keeps item_reference when storage is enabled but still removes client-only markers", () => { + const body = outboundBody(canonicalForward, positContinuation(true)); + const input = body.input as Array>; + + expect(input.find(item => item.type === "item_reference")).toEqual({ + type: "item_reference", + id: "rs_unpersisted", + }); + expect(JSON.stringify(input)).not.toContain("prompt_cache_breakpoint"); + }); + + test.each([ + { + name: "key-auth public Responses provider", + provider: { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authMode: "key" as const, + apiKey: "test-key", + }, + }, + { + name: "noncanonical forward gateway", + provider: { + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "forward" as const, + }, + }, + ])("preserves the continuation extensions for $name", ({ provider }) => { + const body = outboundBody(provider, positContinuation(false)); + const serialized = JSON.stringify(body.input); + + expect(serialized).toContain("prompt_cache_breakpoint"); + expect((body.input as Array>).some(item => item.type === "item_reference")).toBe(true); + }); + + test("an over-depth marker subtree is kept atomically instead of partially rewritten", () => { + let nested: Record = { prompt_cache_breakpoint: true, keep: "deep" }; + for (let depth = 0; depth < 70; depth += 1) nested = { nested }; + const body = outboundBody(canonicalForward, { + model: "gpt-5.6-luna", + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi", nested }] }], + }); + + expect(JSON.stringify(body.input)).toContain("prompt_cache_breakpoint"); + expect(JSON.stringify(body.input)).toContain('"keep":"deep"'); + }); +});