Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f7b5ae0
fix(zed): relay OpenAI tool calls instead of ending on the first sent…
andrew05060414 Oct 23, 2026
9c5e22a
fix(zed): dedupe completed messages and keep incomplete usage
andrew05060414 Oct 23, 2026
e62d54b
Merge branch 'dev' of github.com:lidge-jun/opencodex into fix/zed-ope…
andrew05060414 Oct 24, 2026
e2f87f6
fix(zed): keep interleaved tool calls and promote current-turn tools
andrew05060414 Oct 24, 2026
585f5cd
fix(zed): charge retained stream buffers and correct the stream doc
andrew05060414 Oct 24, 2026
5ba497f
test(zed): cover cancellation with open tool calls
andrew05060414 Oct 24, 2026
21a112d
Merge remote-tracking branch 'origin/dev' into ld1-post/7599
lidge-jun Oct 26, 2026
a9a664a
fix(zed): preserve relay identity, truncation and bounded stream owne…
lidge-jun Oct 26, 2026
75d6ee6
Merge remote-tracking branch 'origin/dev' into ld1-post/7599
lidge-jun Oct 26, 2026
ea538e1
style(zed): normalize relay file ending
lidge-jun Oct 26, 2026
194a33b
fix(zed): retain explicitly incomplete tool item status
lidge-jun Oct 26, 2026
45b77da
fix(zed): scrub native diagnostics and fence unfinished sidecar calls
lidge-jun Oct 26, 2026
88541dd
fix(zed): await authoritative tool status and terminate native errors
lidge-jun Oct 26, 2026
5b0dcaa
fix(zed): filter failure usage and reject conflicting tool completion
lidge-jun Oct 26, 2026
7c3cebb
fix(zed): retain tool input authority and reconcile semantic arguments
lidge-jun Oct 26, 2026
b7f89fb
fix(zed): compare ordered lossless JSON tokens incrementally
lidge-jun Oct 26, 2026
d99622e
Merge remote-tracking branch 'origin/dev' into l1/7599
lidge-jun Oct 27, 2026
11812ee
Merge remote-tracking branch 'origin/dev' into l1/7599
lidge-jun Oct 27, 2026
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
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,13 @@ revokes it, run `ocx login zed` again.

For Zed-hosted OpenAI models, OpenCodex translates `developer` instructions to `system` messages and removes OpenAI-private fields and unsupported built-in tools such as `web_search`. Function and custom tools remain available through the delegate; a tool choice with no supported tools left becomes `none`.

Current-turn tool declarations carried in Codex's `additional_tools` input are promoted into the
supported tool catalog. Function calls and custom-tool input are relayed in order, including
interleaved calls, so a tool-using turn can continue after its introductory text. Incomplete calls
keep only received arguments and remain incomplete; cancellation releases buffered data. Conflicting
completed custom input, including later deltas, fails the turn with incomplete calls. Function arguments accept JSON whitespace differences outside strings, decoded string escapes, and compatible partial snapshots while preserving delivered bytes. Number literals must match exactly (`1` and `1.0` conflict); object-key order and duplicate members must match too. These conservative conflicts fail the turn. JSON nesting is limited to 65,536 containers; deeper input fails with a clear depth-limit message. Late fragments are validated incrementally. On upstream failure, received text and
reasoning remain visible and usage retains numeric counters only. The relay is covered by synthetic stream fixtures; live hosted-stream compatibility remains unverified.

For the canonical Kimi Coding Plan presets (`kimi` account login and `kimi-code` API key),
opencodex forwards only a caller-supplied stable `prompt_cache_key` to the Chat Completions request
(the Responses wire accepts the same field, but opencodex does not send it there today);
Expand Down
4 changes: 4 additions & 0 deletions scripts/test-layout/layout-adapters.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"zed-openai-contract.test.ts": "providers",
"zed-failure-relay.test.ts": "providers",
"zed-argument-consistency.test.ts": "providers",
"zed-lossless-arguments.test.ts": "providers",
"qoder-image-input.test.ts": "providers",
"qoder-cn-image-validation.test.ts": "providers",
"coding-agent-history-projection.test.ts": "adapters",
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/openai-chat/required-tool-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function terminalGuard(required: boolean): (event: AdapterEvent) => AdapterEvent
let failed = false;
return event => {
if (event.type === "tool_call_start") pending = true;
if (event.type === "tool_call_end") { fulfilled ||= pending; pending = false; }
if (event.type === "tool_call_end") { fulfilled ||= pending && !event.incomplete; pending = false; }
if (event.type === "error" || event.type === "incomplete") failed = true;
if (event.type !== "done" || !required || fulfilled || failed || isTruncatedStopReason(event.stopReason)) return event;
// Preserve delivered output and consumption. A mandatory call is a contract, not a hint
Expand Down
33 changes: 1 addition & 32 deletions src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { repairUrlCitationAnnotations, scrubOcxCompactionItems, stripCanonicalOn
import { notePlaintextReasoningDropped } from "./plaintext-reasoning-notice";
import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache";
import { isPlainObject } from "./internal";
import { usageFromResponsesPayload } from "./usage";
import { normalizeToolSchemas, promoteClientLoadedTools, stripUnsupportedHostedTools } from "./tool-schema";
import { annotateEmptyResponsesToolOutputs, backfillWebSearchQueries, normalizeResponsesToolResultAdjacency, repairOrphanedInputItems, repairOversizedReplayCallIds, repairUnidentifiedToolOutputItems, restoreBridgedWebSearchCalls } from "./tool-output-recovery";
import { bridgeSearchReplayScope } from "../../responses/bridge-search-replay-cache";
Expand Down Expand Up @@ -124,38 +125,6 @@ function buildRoutedCompactionBody(body: unknown, responsesLite: boolean): unkno
};
}

/** Read the Responses `usage` block, if the gateway sent one. */
function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined {
if (!isPlainObject(payload) || !isPlainObject(payload.usage)) return undefined;
const usage = payload.usage;
const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
// openai/codex#41980: the raw usage object is wire data a rebuilt response.completed must keep —
// unknown keys (subscription metadata, future counters) ride along even when the token counts
// themselves are zero or absent (metadata-only usage).
const knownKeys = new Set(["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"]);
const hasExtras = Object.keys(usage).some(key => !knownKeys.has(key))
|| (isPlainObject(usage.input_tokens_details)
&& Object.keys(usage.input_tokens_details).some(key => key !== "cached_tokens" && key !== "cache_write_tokens"
&& key !== "cache_write_1h_tokens"))
|| (isPlainObject(usage.output_tokens_details)
&& Object.keys(usage.output_tokens_details).some(key => key !== "reasoning_tokens"));
if (inputTokens === 0 && outputTokens === 0 && !hasExtras) return undefined;
const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined;
const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined;
return {
inputTokens,
outputTokens,
...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}),
...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}),
...(typeof inputDetails?.cache_write_tokens === "number" ? { cacheCreationInputTokens: inputDetails.cache_write_tokens } : {}),
...(typeof inputDetails?.cache_write_1h_tokens === "number"
? { cacheCreation1hInputTokens: inputDetails.cache_write_1h_tokens } : {}),
...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}),
...(hasExtras ? { rawUsage: { ...usage } } : {}),
};
}

function responsesPayloadText(response: unknown): string {
if (!isPlainObject(response) || !Array.isArray(response.output)) return "";
return response.output
Expand Down
34 changes: 34 additions & 0 deletions src/adapters/openai-responses/usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { OcxUsage } from "../../types";
import { isPlainObject } from "./internal";

/** Read the Responses `usage` block, if the gateway sent one. */
export function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined {
if (!isPlainObject(payload) || !isPlainObject(payload.usage)) return undefined;
const usage = payload.usage;
const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
// openai/codex#41980: the raw usage object is wire data a rebuilt response.completed must keep —
// unknown keys (subscription metadata, future counters) ride along even when the token counts
// themselves are zero or absent (metadata-only usage).
const knownKeys = new Set(["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"]);
const hasExtras = Object.keys(usage).some(key => !knownKeys.has(key))
|| (isPlainObject(usage.input_tokens_details)
&& Object.keys(usage.input_tokens_details).some(key => key !== "cached_tokens" && key !== "cache_write_tokens"
&& key !== "cache_write_1h_tokens"))
|| (isPlainObject(usage.output_tokens_details)
&& Object.keys(usage.output_tokens_details).some(key => key !== "reasoning_tokens"));
if (inputTokens === 0 && outputTokens === 0 && !hasExtras) return undefined;
const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined;
const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined;
return {
inputTokens,
outputTokens,
...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}),
...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}),
...(typeof inputDetails?.cache_write_tokens === "number" ? { cacheCreationInputTokens: inputDetails.cache_write_tokens } : {}),
...(typeof inputDetails?.cache_write_1h_tokens === "number"
? { cacheCreation1hInputTokens: inputDetails.cache_write_1h_tokens } : {}),
...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}),
...(hasExtras ? { rawUsage: { ...usage } } : {}),
};
}
51 changes: 51 additions & 0 deletions src/adapters/zed-additional-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { isPlainObject as isRecord } from "./openai-responses/internal";

function toolKey(tool: Record<string, unknown>): string | undefined {
if (typeof tool.type !== "string" || !tool.type) return undefined;
const name = typeof tool.name === "string" ? tool.name : "";
return tool.type + "\0" + name;
}

/**
* Zed's OpenAI Responses schema accepts message and call items, not Codex's private
* additional_tools wrapper. Drop every wrapper. Promote only wrappers at or after the
* replayed prefix: earlier ones are history, not the current turn's tool catalog.
* A non-array tools field is left untouched so a malformed catalog is not replaced
* by a partial promotion.
*/
export function liftZedAdditionalTools(providerRequest: Record<string, unknown>, replayPrefixLength: number): void {
if (!Array.isArray(providerRequest.input)) return;
if (providerRequest.tools !== undefined && !Array.isArray(providerRequest.tools)) return;

const currentTurnStart = Number.isFinite(replayPrefixLength)
? Math.min(providerRequest.input.length, Math.max(0, Math.trunc(replayPrefixLength)))
: 0;
const input: unknown[] = [];
const promoted: unknown[] = [];
let changed = false;
for (const [index, item] of providerRequest.input.entries()) {
if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) {
changed = true;
if (index >= currentTurnStart) promoted.push(...item.tools);
continue;
}
input.push(item);
}
if (!changed) return;

const seen = new Set<string>();
const tools: unknown[] = [];
const existing = Array.isArray(providerRequest.tools) ? providerRequest.tools : [];
for (const tool of [...existing, ...promoted]) {
if (!isRecord(tool)) {
tools.push(tool);
continue;
}
const key = toolKey(tool);
if (key && seen.has(key)) continue;
if (key) seen.add(key);
tools.push(tool);
}
providerRequest.input = input;
providerRequest.tools = tools;
}
22 changes: 22 additions & 0 deletions src/adapters/zed-diagnostic-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/** Diagnostic terminals carry counters only; opaque usage extensions are untrusted. */
export function zedDiagnosticUsage(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const source = value as Record<string, unknown>;
const counters = (record: Record<string, unknown>, keys: string[]): Record<string, number> => Object.fromEntries(
keys.flatMap(key => {
const count = record[key];
return typeof count === "number" && Number.isFinite(count) && count >= 0 ? [[key, count]] : [];
}),
);
const usage: Record<string, unknown> = counters(source, ["input_tokens", "output_tokens", "total_tokens"]);
for (const [field, keys] of [
["input_tokens_details", ["cached_tokens", "cache_write_tokens", "cache_write_1h_tokens"]],
["output_tokens_details", ["reasoning_tokens"]],
] as const) {
const details = source[field];
if (details && typeof details === "object" && !Array.isArray(details)) {
usage[field] = counters(details as Record<string, unknown>, [...keys]);
}
}
return usage;
}
166 changes: 166 additions & 0 deletions src/adapters/zed-json-tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/** Containers, including the root, are bounded independently of the JS call stack. */
export const ZED_JSON_MAX_DEPTH = 65_536;

export class ZedJsonError extends Error {
constructor(detail = 'invalid JSON') { super(`Zed tool arguments: ${detail}`); }
}

// Disjoint alphabets preserve token boundaries, exact number spelling and decoded UTF-16.
// A surrogate pair has the same two units whether literal or escaped; lone units stay distinct.
const STRING = 0x10000;
const NUMBER = 0x20000;
const LITERAL = 0x30000;
const whitespace = (c: string) => c === ' ' || c === '\t' || c === '\r' || c === '\n';
const digit = (c: string) => c >= '0' && c <= '9';

/** Single-pass, resumable JSON lexer + iterative grammar validator. No value conversion. */
export class ZedJsonScanner {
steps = 0;
offset = 0;
private frames = [8]; // object: 0..4, array: 5..7, root: 8(value)/9(done)
private mode: 'plain' | 'string' | 'number' | 'literal' = 'plain';
private number = '';
private literal = '';
private literalAt = 0;
private escape = false;
private unicodeLeft = 0;
private unicodeValue = 0;

constructor(private emit: (token: number, end: number) => void) {}

private value(): void {
const top = this.frames.length - 1;
const state = this.frames[top];
if (state === 8) this.frames[top] = 9;
else if (state === 3) this.frames[top] = 4;
else if (state === 5 || state === 6) this.frames[top] = 7;
else throw new ZedJsonError();
}

private numberEnd(): void {
if (!['zero', 'int', 'frac', 'exp'].includes(this.number)) throw new ZedJsonError();
this.mode = 'plain';
}

push(fragment: string): void {
for (let i = 0; i < fragment.length; i++) {
this.steps++;
const c = fragment[i]!;
const code = fragment.charCodeAt(i);
this.offset++;
if (this.mode === 'string') {
if (this.unicodeLeft) {
const hex = code >= 48 && code <= 57 ? code - 48
: code >= 65 && code <= 70 ? code - 55 : code >= 97 && code <= 102 ? code - 87 : -1;
if (hex < 0) throw new ZedJsonError();
this.unicodeValue = this.unicodeValue * 16 + hex;
if (--this.unicodeLeft === 0) this.emit(STRING + this.unicodeValue, this.offset);
} else if (this.escape) {
this.escape = false;
if (c === 'u') { this.unicodeLeft = 4; this.unicodeValue = 0; }
else {
const decoded = '"\\/bfnrt'.indexOf(c);
if (decoded < 0) throw new ZedJsonError();
this.emit(STRING + [34, 92, 47, 8, 12, 10, 13, 9][decoded]!, this.offset);
}
} else if (c === '\\') this.escape = true;
else if (c === '"') { this.mode = 'plain'; this.emit(34, this.offset); }
else {
if (code < 32) throw new ZedJsonError();
this.emit(STRING + code, this.offset);
}
continue;
}
if (this.mode === 'literal') {
if (c !== this.literal[this.literalAt++]) throw new ZedJsonError();
this.emit(LITERAL + code, this.offset);
if (this.literalAt === this.literal.length) this.mode = 'plain';
continue;
}
if (this.mode === 'number') {
let next = '';
if (this.number === 'minus') next = c === '0' ? 'zero' : c >= '1' && c <= '9' ? 'int' : '';
else if (this.number === 'zero' || this.number === 'int') {
next = this.number === 'int' && digit(c) ? 'int' : c === '.' ? 'dot' : /[eE]/.test(c) ? 'e' : '';
} else if (this.number === 'dot' || this.number === 'frac') {
next = digit(c) ? 'frac' : this.number === 'frac' && /[eE]/.test(c) ? 'e' : '';
} else if (this.number === 'e') next = c === '+' || c === '-' ? 'sign' : digit(c) ? 'exp' : '';
else if (this.number === 'sign' || this.number === 'exp') next = digit(c) ? 'exp' : '';
if (next) { this.number = next; this.emit(NUMBER + code, this.offset); continue; }
this.numberEnd();
// The delimiter belongs to the grammar, not to the numeric token.
}
if (whitespace(c)) continue;
const top = this.frames.length - 1;
const state = this.frames[top];
if (c === '}' || c === ']') {
if (c === '}' ? state !== 0 && state !== 4 : state !== 5 && state !== 7) throw new ZedJsonError();
this.frames.pop();
} else if (c === ':') {
if (state !== 2) throw new ZedJsonError();
this.frames[top] = 3;
} else if (c === ',') {
if (state !== 4 && state !== 7) throw new ZedJsonError();
this.frames[top] = state === 4 ? 1 : 6;
} else if (c === '"') {
if (state === 0 || state === 1) this.frames[top] = 2;
else this.value();
this.mode = 'string';
} else {
this.value();
if (c === '{' || c === '[') {
if (this.frames.length > ZED_JSON_MAX_DEPTH) {
throw new ZedJsonError(`JSON nesting depth limit exceeded (${ZED_JSON_MAX_DEPTH})`);
}
this.frames.push(c === '{' ? 0 : 5);
} else if (c === '-' || digit(c)) {
this.mode = 'number';
this.number = c === '-' ? 'minus' : c === '0' ? 'zero' : 'int';
this.emit(NUMBER + code, this.offset);
continue;
} else if (c === 't' || c === 'f' || c === 'n') {
this.mode = 'literal';
this.literal = c === 't' ? 'true' : c === 'f' ? 'false' : 'null';
this.literalAt = 1;
this.emit(LITERAL + code, this.offset);
continue;
} else throw new ZedJsonError();
}
this.emit(code, this.offset);
}
}

finish(): void {
if (this.mode === 'number') this.numberEnd();
if (this.mode !== 'plain' || this.frames.length !== 1 || this.frames[0] !== 9) throw new ZedJsonError('incomplete JSON');
}

get complete(): boolean {
return this.frames.length === 1 && this.frames[0] === 9
&& (this.mode === 'plain' || (this.mode === 'number' && ['zero', 'int', 'frac', 'exp'].includes(this.number)));
}

/** Complete a split escape using the next authoritative decoded string unit. */
escapeSuffix(token: number | undefined): string | undefined {
if (!this.escape && !this.unicodeLeft) return '';
if (token === undefined || token < STRING || token >= NUMBER) return;
const hex = (token - STRING).toString(16).padStart(4, '0');
if (this.escape) return 'u' + hex;
const read = 4 - this.unicodeLeft;
if (parseInt(hex.slice(0, read) || '0', 16) !== this.unicodeValue) return;
return hex.slice(read);
}
}

export function zedJsonTokens(text: string, positions?: Uint32Array): Uint32Array {
const tokens = new Uint32Array(text.length);
let length = 0;
const scanner = new ZedJsonScanner((token, end) => {
tokens[length] = token;
if (positions) positions[length] = end;
length++;
});
scanner.push(text);
scanner.finish();
return tokens.subarray(0, length);
}
Loading
Loading