Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 13 additions & 23 deletions devlog/_plan/260914_provider_parity_stack/050_residuals.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,19 @@ provider B, and a cache lifetime. `src/responses/reasoning-replay-cache.ts`
already solves a narrower version of this inside one provider's session and is the
natural starting point. It is a design unit, not a line change.

## R2 — real audio/file transport, and any adapter-level refusal (from F5)

**F5 is PRESENCE-ONLY and is not fixed.** Phase 4 records that an audio attachment
existed and explicitly does not add audio support. `OcxContentPart` has no audio
member, no adapter consumes one, and per-provider audio capability is not recorded
anywhere in the catalog — `src/providers/registry.ts:1062` notes exactly this when it
omits audio from the Baseten hints.

Two things are residual, not delivered:

- **Transport.** A carrier type, capability data across the provider set, and a wire
mapping per vendor. Guessing any one of those produces a request that fails at call
time instead of a modality that works.
- **Refusal.** There is no adapter-level rejection of audio. By final dispatch the part
is already a text marker, so every adapter continues. Doing this properly needs a
typed unsupported-modality signal that survives to final adapter dispatch — including
`runTurn`, compaction and sidecar paths — while raw Responses passthrough stays
untouched. An early throw in the shared parser is not acceptable: raw passthrough
runs through `parseRequest` before the adapter forwards `_rawBody`.

`input_file` keeps its existing filename-only marker, and Chat inbound has no file or
audio translation at all, so a Chat request can lose media before the Responses parser
sees it. Neither is addressed here.
## R2 — native audio/file transport (from F5)

Layer 4 added presence markers but could still report successful translation after losing
an attachment. Layer 5 closes that silent-success gap: registered translated adapters inspect
the original content before dispatch, and Chat projection rejects recognized audio/file parts
before losing them. Build, stateful runTurn and local-completion hooks share that contract;
native Responses/Azure and native Chat retain their existing wire behavior. See the
[current registry contract](../../../structure/adapters/registry.md#untranslated-input-media).

**Native audio/file transport through the normalized IR remains unimplemented.** This stack
does not add a carrier type, per-model capability data, file-ID resolution, URL fetching or
new upstream mappings. Unsupported translation now fails explicitly rather than pretending
to consume an attachment. A filename/audio marker alone is still not the attachment.

## R3 — Kiro remote images stay uninlined

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Direct implementation: media admission

This fifth layer follows PR #4539. ChatGPT authored the production changes and regression
tests directly in an isolated worktree, rather than handing this implementation to the prior
native authoring session. The outcome closes the silent-success part of F5, not native media
transport: recognized audio/file inputs either stay on an existing native wire or receive an
explicit conversion error. Legacy function-image conversion also refuses instead of losing
its result. The canonical current contract is in
[adapter registry](../../../structure/adapters/registry.md#untranslated-input-media).

The pure scanner inspects typed content arrays only. The registry owns final translated
build/runTurn/local-completion admission, and Chat owns rejection before a lossy projection.
No new fetch, decoding, credential access, provider capability declarations or vendor CLI
permissions are introduced. Desired regression coverage includes unchanged native Responses
and Azure bodies, final hook ordering, typed runTurn error, legacy media failure, and real HTTP
rejection with zero upstream sends. Public Pi documentation records the pending behavior.

The connected Mac runs no product verification by explicit user instruction. Tests are
written for hosted CI; their presence alone is not a passing result. This direct layer does
not reuse another session's PABCD identity or claim unperformed formal phase transitions.
26 changes: 26 additions & 0 deletions docs-site/src/content/docs/guides/pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,32 @@ through, translate it (wire aliases), clamp it to the configured ladder, emulate
entirely (e.g. `noReasoningModels`). The boolean only controls whether Pi offers the control at
all.

## Attachment and request compatibility

:::note[Pending development behavior]
The provider-parity changes described here are on the development PR stack; an older installed
release may still have the previous conversion behavior.
:::

OpenCodex normalizes Pi/MCP and Anthropic-shaped user images before choosing the native Chat
or translated route. Images returned by tools use a translated user-message carrier after the
paired tool results; ordinary user images and text-only tool results can keep the native path.
Use modern `tool_calls` and `role: "tool"` with `tool_call_id`: legacy `function`-result image
translation is rejected instead of silently discarding the result.

An explicit reasoning effort of `none` survives Chat conversion. Output limits and sampling
controls are preserved for generic API-key Responses targets; the canonical ChatGPT target
still applies its own restrictions. This does not make all providers' controls equivalent.

**Audio and files need a native input wire that supports them.** OpenCodex does not yet have
a lossless audio/file carrier for translated requests. When Chat requires projection, or a
Responses request targets a translated adapter, recognized audio/file attachments return an
explicit error rather than succeeding without the attachment. File-ID-only images have the
same restriction because translated adapters cannot resolve those IDs. Convert the attachment
to text first, or use a native wire and model that support it. Native Chat and raw Responses
(including Azure) retain their existing behavior; this is not a promise of every model's
upstream media support. Video conversion limits remain adapter-specific.

## Schema status

:::note[Unverified against a real install]
Expand Down
4 changes: 3 additions & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1433,7 +1433,9 @@
"usage-log-ws-stage.test.ts": "usage",
"main-device-reauth.test.ts": "codex-integration",
"main-device-reauth-api.test.ts": "codex-integration",
"main-device-reauth-ui.test.ts": "gui"
"main-device-reauth-ui.test.ts": "gui",
"adapter-input-media-guard.test.ts": "adapters",
"chat-media-translation.test.ts": "responses"
},
"migrated": [
"adapters",
Expand Down
45 changes: 45 additions & 0 deletions src/adapters/input-media-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ProviderAdapter } from "./base";
import { untranslatedInputMediaMessage, untranslatedResponsesInputMedia } from "../responses/input-media";

/**
* Refuse unrepresentable input at the final translated-adapter boundary. The registry
* applies this after wire resolution; Responses passthrough (including Azure) opts
* out because it uses the original body rather than the lossy normalized content.
*/
export function withInputMediaGuard<T extends ProviderAdapter>(adapter: T): T {
const build = adapter.buildRequest.bind(adapter);
adapter.buildRequest = (parsed, incoming) => {
const kind = untranslatedResponsesInputMedia(parsed._rawBody);
if (kind) throw new Error(untranslatedInputMediaMessage(kind));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve media rejection status through bridge loops

When the same request activates the web-search or image bridge, those loops call this guarded builder at src/web-search/loop.ts:467 and src/images/loop.ts:575 before the normal core build-error catch. Because the guard throws a plain Error, both loops classify it as 502 Provider unreachable at lines 607 and 702; the runTurn image path similarly discards the emitted 400 status at src/images/loop.ts:758. A known nonretryable client input error is therefore reported as an upstream outage, inviting retries and misleading diagnostics. Use a shared typed client-input failure that the bridges preserve, or perform admission before entering them.

AGENTS.md reference: src/AGENTS.md:L17-L19

Useful? React with 👍 / 👎.

return build(parsed, incoming);
};

const runTurn = adapter.runTurn?.bind(adapter);
if (runTurn) {
adapter.runTurn = async (parsed, incoming, emit) => {
const kind = untranslatedResponsesInputMedia(parsed._rawBody);
if (kind) {
emit({
type: "error",
status: 400,
errorType: "invalid_request_error",
code: "unsupported_input_modality",
retryable: false,
message: untranslatedInputMediaMessage(kind),
});
return;
}
await runTurn(parsed, incoming, emit);
};
}

const localTerminal = adapter.localTerminal?.bind(adapter);
if (localTerminal) {
// This hook is outside the builder's error catch. Decline its success shortcut;
// the ordinary buildRequest path then returns the established client-safe 400.
adapter.localTerminal = parsed => untranslatedResponsesInputMedia(parsed._rawBody)
? undefined
: localTerminal(parsed);
}
return adapter;
}
4 changes: 4 additions & 0 deletions src/adapters/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createOllamaNativeAdapter } from "./ollama-native";
import { createResponsesPassthroughAdapter } from "./openai-responses";
import type { OcxProviderConfig } from "../types";
import { createAdapterTierMetadata } from "../providers/fastwire";
import { withInputMediaGuard } from "./input-media-guard";

export type AdapterCacheRetention = "none" | "short" | "long";

Expand Down Expand Up @@ -180,6 +181,9 @@ export function createRegisteredAdapter(
const definition = getAdapterDefinition(provider.adapter);
if (!definition) throw new Error(`Unknown adapter: ${provider.adapter}`);
const adapter = definition.create(provider, context);
if (effectiveAdapterContract(provider.adapter).wire !== "openai-responses") {
withInputMediaGuard(adapter);
}
const buildRequest = adapter.buildRequest.bind(adapter);
adapter.buildRequest = (parsed, incoming) => {
const attachTierMetadata = (request: Awaited<ReturnType<ProviderAdapter["buildRequest"]>>) => {
Expand Down
18 changes: 18 additions & 0 deletions src/chat/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* responsesRequestSchema so routing/OAuth/pool/sidecars are inherited unchanged.
*/
import { chatImageUrlFromPart } from "./image-parts";
import { untranslatedChatInputMedia, untranslatedInputMediaMessage } from "../responses/input-media";

export class ChatCompletionsRequestError extends Error {}

Expand Down Expand Up @@ -277,6 +278,13 @@ function resolveReasoningSummary(raw: Rec): string | undefined {
*/
export function chatCompletionsToResponsesBody(raw: unknown): Rec {
assertChatCompletionsRoutingBody(raw);
// Only the translated path reaches this function. Native Chat can retain its
// provider-specific file/audio blocks; projecting them here would discard them.
const unsupportedMedia = untranslatedChatInputMedia(raw);
if (unsupportedMedia) {
throw new ChatCompletionsRequestError(untranslatedInputMediaMessage(unsupportedMedia));
}


const systemParts: string[] = [];
const input: Rec[] = [];
Expand Down Expand Up @@ -312,6 +320,16 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
if (msg.tool_calls !== undefined) toolCallsToItems(msg.tool_calls, input, knownNameByCallId);
break;
}
case "function": {
// Native eligibility diverts legacy image results too, but this translator
// has no legacy function_call/name pairing. Never silently discard them.
if (Array.isArray(msg.content) && msg.content.some(part => isRec(part) && imageUrlFromPart(part))) {
throw new ChatCompletionsRequestError(
"Legacy function-result image translation is not implemented. Use tool_calls and role:tool with tool_call_id.",
);
}
break;
}
case "tool": {
const callId = typeof msg.tool_call_id === "string" ? msg.tool_call_id
: typeof msg.tool_use_id === "string" ? msg.tool_use_id
Expand Down
65 changes: 65 additions & 0 deletions src/responses/input-media.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/** Input kinds for which the normalized request has no lossless content carrier. */
export type UntranslatedInputMedia = "audio" | "file";

type RecordValue = Record<string, unknown>;

function isRecord(value: unknown): value is RecordValue {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function mediaKind(value: unknown): UntranslatedInputMedia | undefined {
if (!isRecord(value)) return undefined;
if (value.type === "input_audio" || value.type === "audio") return "audio";
if (value.type === "input_file" || value.type === "file" || value.type === "document") return "file";
// A file-id-only image is not pixels: translated adapters cannot dereference it.
if (value.type === "input_image" && typeof value.file_id === "string" && value.file_id.length > 0
&& !(typeof value.image_url === "string" && value.image_url.length > 0)) return "file";
return undefined;
}

function contentMedia(content: unknown): UntranslatedInputMedia | undefined {
if (!Array.isArray(content)) return undefined;
for (const part of content) {
const kind = mediaKind(part);
if (kind) return kind;
}
return undefined;
}

/**
* Inspect only typed input items and their content arrays, never strings, tool
* arguments, schema properties, or arbitrary nested objects. No payload is copied,
* decoded, fetched or included in the returned value.
*/
export function untranslatedResponsesInputMedia(body: unknown): UntranslatedInputMedia | undefined {
if (!isRecord(body) || !Array.isArray(body.input)) return undefined;
for (const item of body.input) {
if (!isRecord(item)) continue;
const direct = mediaKind(item);
if (direct) return direct;
if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
const kind = contentMedia(item.output);
if (kind) return kind;
} else if (item.type === "message" || item.type === undefined) {
const kind = contentMedia(item.content);
if (kind) return kind;
Comment on lines +43 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scan agent_message media before translating it

When a Responses request carries a Codex agent_message whose content contains input_audio, input_file, or a file-ID-only input_image, this branch skips it because its type is neither message nor undefined. The explicit agent_message path in src/responses/parser.ts:215-225 then passes that content through inputContentParts, which reduces populated attachments to presence text, allowing a translated adapter to return success without the attachment. Include agent_message among the content-bearing item types and add a regression covering this established replay shape.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

}
}
return undefined;
}

/** Used only when Chat is actually projected, not on the native Chat fast path. */
export function untranslatedChatInputMedia(body: unknown): UntranslatedInputMedia | undefined {
if (!isRecord(body) || !Array.isArray(body.messages)) return undefined;
for (const message of body.messages) {
if (!isRecord(message)) continue;
const kind = contentMedia(message.content);
if (kind) return kind;
}
return undefined;
}

/** Fixed vocabulary only: never interpolate filenames, URLs or client metadata. */
export function untranslatedInputMediaMessage(kind: UntranslatedInputMedia): string {
return `OpenCodex cannot translate ${kind} input on this route. Use a native input wire that supports the attachment, or convert it to text first.`;
}
26 changes: 26 additions & 0 deletions structure/adapters/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,29 @@ medium/high/max UID before accepting a suffix already present in the model id.
The merged `devin` provider uses this resolver for every account, whichever login
path minted the credential. Omitted effort preserves an explicit
variant; unrelated model families retain their existing suffix precedence.


## Untranslated input media

`src/responses/input-media.ts` inspects actual content blocks and typed tool-output arrays
without parsing text or function arguments, copying attachment payloads, resolving file IDs,
or fetching URLs. Audio, files/documents and file-ID-only images have no lossless normalized
carrier. The scanner returns only an input-kind name, never client content.

`src/adapters/input-media-guard.ts` guards adapters created by the registry after effective
wire selection. A translated `buildRequest` refuses these inputs through the existing 400
error path; `runTurn` emits one nonretryable `unsupported_input_modality` error without
starting its underlying transport. `localTerminal` declines a success shortcut for such a
request, letting the guarded builder return the error instead. The original raw body stays
unchanged, including when another final adapter is selected after a failed attempt.

The effective Responses wire, including both Azure aliases, is excluded: it forwards the
original body and leaves native media acceptance to its upstream. This exception does not
claim that every Responses model supports every attachment. Native Chat also keeps its
existing wire; only an actual Chat-to-Responses projection rejects audio/file blocks before
losing them. Legacy function-result images fail explicitly because that projection does not
implement legacy call/result pairing. Modern tool-image carriers are unchanged.

`tests/adapters/adapter-input-media-guard.test.ts` covers hook ordering, error events and
raw passthrough; `tests/responses/chat-media-translation.test.ts` reaches the real HTTP
translation boundary and verifies that rejection sends no upstream request.
11 changes: 11 additions & 0 deletions structure/data-planes/inbound-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,14 @@ omitting the wire parameter, and the Pi client export maps Pi's `off` thinking l
onto it. Dropping it let a provider default re-enable reasoning the caller had
explicitly turned off, which is not neutral for the Anthropic families that think by
default and require an explicit `thinking:{type:"disabled"}` to stop.


## Media at the Chat translation boundary

The native Chat path retains provider-native file/audio blocks. When a request instead needs
Chat-to-Responses projection, `src/chat/inbound.ts` rejects recognized audio/file content
before it can become empty text, regardless of message role. Legacy `function`-role images
also return an explicit error; their call/result pairing is not implemented by this projection.
Modern `tool` images continue through the existing following-user carrier. These errors state
an OpenCodex conversion limit, not a provider capability claim. Final Responses-to-adapter
admission follows the [registry contract](../adapters/registry.md#untranslated-input-media).
14 changes: 7 additions & 7 deletions structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,13 +323,13 @@ the IR has no audio carrier and no adapter consumes one. The parser stays non-th
because the native Responses passthrough also runs through `parseRequest` before the
adapter forwards `_rawBody`, so refusing there would regress raw passthrough.

**There is no adapter-level refusal for audio today.** By the time an adapter sees the
turn the part is already a text marker, so it continues rather than rejecting. A typed
unsupported-modality signal that survives to final adapter dispatch — leaving raw
passthrough untouched — is a separate, recorded residual. `input_file` likewise keeps
its existing filename-only marker, and Chat inbound still has no file or audio
translation, so a Chat request can lose media before this parser sees it. No payload
bytes and no media URL ever enter a marker or an error message.
The final registered adapter also checks the original input under the
[untranslated-media contract](../adapters/registry.md#untranslated-input-media). Audio/file
attachments cannot succeed merely because the normalized representation retained a text
marker: translated adapters refuse them, while native Responses retains the original body.
Chat conversion rejects recognized audio/file parts before projection; the native Chat wire
is unchanged. No audio/file transport or automatic URL fetch is added, and no client filename,
payload, URL or metadata is included in the new error messages.

The shared coding-agent projection (CodeBuddy, Qoder) carries tool-result images as
real image blocks rather than flattening them to the text `[image]`, and orders image
Expand Down
2 changes: 2 additions & 0 deletions structure/providers/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,5 @@ Combo child requests normalize effort and thinking controls against the selected
`src/adapters/cursor.ts` surfaces the first bare context overflow before attempting conversation remint on later eligible requests. `cursorClientThreadOwner` recognizes both client thread aliases; `src/adapters/cursor/thread-continuity.ts` limits recovery to three remints per retained identity-scoped owner, with a one-hour idle TTL and 2,048-entry bound. Conversation-only requests have no stable owner and do not automatically remint. Quota/rate errors, tool-result resumes, partial output, local side effects, isolated helper/shadow requests and compaction remain fail-closed. Isolated requests neither consume the parent allowance nor invalidate its checkpoint. Eligible overflow checks refresh existing retention timestamps and LRU position even after the cap is exhausted, without allocating absent scopes. Retention expiry, eviction or process restart resets the in-memory allowance; this is not a persistent lifetime cap or semantic-progress policy.

Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached.

Translated audio/file admission follows the [final-adapter input contract](../adapters/registry.md#untranslated-input-media); native raw passthrough remains separate.
Loading
Loading