Skip to content
Closed
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
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -904,7 +904,7 @@ OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code C
- **Region Isolation:** `codebuddy` and `codebuddy-cn` use separate canonical endpoints (`https://www.codebuddy.ai` and `https://www.codebuddy.cn`) and isolated child environments (`CODEBUDDY_INTERNET_ENVIRONMENT=public` vs `internal`). Credentials are strictly region-scoped and never exchanged across environments. Overriding the canonical base URL fails closed.
- **Model Discovery:** the proxy requests the CodeBuddy product configuration (`GET {baseUrl}/v3/config`) with the configured key as the `X-API-Key` header, and the roster in that answer is the authoritative roster of discovered models: it is the key's own account configuration, so it is proven to belong to the key — a different or wrong key answers the anonymous envelope with no roster instead of another account's models. The authenticated roster is the same list the CLI prints for `--model` (the "Currently supported" line of a signed-in CLI), can differ from the static manifest bundled with the CLI, and the vendor default selectors (`default` for CN, `default-model` for Global) never appear in it but remain callable: the catalog retains them during live discovery and on every fallback path. On start/sync the proxy binds the cached roster to an irreversible fingerprint of the configured key, so a key switch never observes a roster cached for the previous key, and degrades to the stale provider/key-fingerprint-scoped cache, then to the static seed in `src/providers/codebuddy-models.ts`, when the key does not authenticate or the request fails. Discovery failure logs contain only a category and HTTP status, without the gateway's message or a raw transport exception.
The credentialed discovery request does not follow redirects; a 3xx response degrades the roster without forwarding the key to another origin.
- **Tool Ownership and the Tool Bridge:** The CLI is always spawned with `--tools ""` and `--strict-mcp-config`, so it has no built-in or user-configured tools of its own. When a request carries a Codex tool catalog, the provider arms a capture-only MCP bridge: the validated catalog and MCP config are written to a private temp dir, the CLI is launched with `--mcp-config` and an exact `--allowedTools` list, and the `system/init` frame must report exactly that bridge server as connected or the turn fails closed. The bridge advertises the Codex tools and captures proposed calls but never executes anything: a completed tool-call batch is returned as `function_call` items (names mapped back to the request's wire names, at most 16 calls per assistant message), the process tree is terminated at `message_stop`, and the external Codex client alone performs approval, sandboxing, and execution. Tool results come back as the next request's input, and the conversation continues. Requests without tools keep the plain text-and-reasoning shape. If the CLI writes an unquoted DSML `calls` control line followed by a `functions.*` invoke control line into text or reasoning, OpenCodex refuses the turn instead of forwarding the scaffold or interpreting it as an executable call. DSML discussed or quoted in prose, inline code, fenced code, or source examples remains ordinary answer text.
- **Tool Ownership and the Tool Bridge:** The CLI is always spawned with `--tools ""` and `--strict-mcp-config`, so it has no built-in or user-configured tools of its own. When a request carries a Codex tool catalog, the provider arms a capture-only MCP bridge: the validated catalog and MCP config are written to a private temp dir, the CLI is launched with `--mcp-config` and an exact `--allowedTools` list, and the `system/init` frame must report exactly that bridge server as connected or the turn fails closed. The bridge advertises the Codex tools and captures proposed calls but never executes anything: a completed tool-call batch is returned as `function_call` items (names mapped back to the request's wire names, at most 16 calls per assistant message), the process tree is terminated at `message_stop`, and the external Codex client alone performs approval, sandboxing, and execution. Parallel tool-use blocks are buffered and returned as complete calls even when their fragments interleave or CodeBuddy reuses a content-block index. A block left open at turn end, a same-index replacement before complete JSON arguments, or an argument fragment that cannot be attributed to an open block fails the turn instead of forwarding a partial or empty call. Tool results come back as the next request's input, and the conversation continues. Requests without tools keep the plain text-and-reasoning shape. If the CLI writes an unquoted DSML `calls` control line followed by a `functions.*` invoke control line into text or reasoning, OpenCodex refuses the turn instead of forwarding the scaffold or interpreting it as an executable call. DSML discussed or quoted in prose, inline code, fenced code, or source examples remains ordinary answer text.
- **Tool Choice Enforcement:** When a request specifies `tool_choice: "required"` or selects a specific named tool, the bridge expects a tool call from the model. If the CLI completes the turn with plain text instead of capturing a tool call, OpenCodex fails closed with a 502 `tool_call_required` error rather than returning an invalid text completion.
- **Governance Status:** Whether routing this vendor automation surface behind a proxy for a third-party agent satisfies CodeBuddy's acceptable-use terms is an open question flagged for maintainer security review (see the governance note in the provider registry entry). Treat this provider as pending that review, and keep the tool bridge's ownership boundary in mind: the nested CLI advertises tools but never executes them, and approval, sandboxing, and execution remain with the external Codex client.
- **Entitlements and Billing:** The provider uses the same vendor-documented CodeBuddy account/CLI authentication surface. Availability and billing of free, promotional, trial, or subscription credits remain determined by the user's CodeBuddy account entitlement.
Expand Down
48 changes: 39 additions & 9 deletions src/adapters/coding-agent/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ export interface StreamParseState {
toolBlockStarts?: number;
/** Completed tool_use content blocks observed in this stream. */
completedToolCalls?: number;
/** CodeBuddy's capture-only bridge requires complete JSON and matching block indices. */
strictToolBlockCapture?: boolean;
/** Tool IDs already captured through partial events, for complete-assistant deduplication. */
partialToolCallIds?: Set<string>;
/** A complete assistant tool block had no matching partial capture. */
Expand Down Expand Up @@ -351,6 +353,7 @@ export interface OpenToolBlock {
id: string;
name: string;
argParts: string[];
indexed: boolean;
}

/** Key a tool_use start frame by content-block index, falling back to a synthetic key. */
Expand All @@ -366,8 +369,9 @@ function toolBlockKey(state: StreamParseState, event: StreamMessage): number {
* Resolve a delta/stop frame to an open tool block. An indexed frame only matches a block
* opened under the same index — CodeBuddy skips stop frames for thinking blocks, and such a
* stop must not close a tool block that happens to be open. An index-less frame resolves
* only when exactly one block is open. Ambiguous argument deltas are rejected before
* resolution; an unmatched stop cannot close a tool block.
* only when exactly one block is open. Ambiguous argument deltas with multiple open blocks
* fail the turn. On the CodeBuddy capture path, a missing-index delta or stop cannot match
* an indexed block; an unmatched stop leaves the block open for terminal accounting.
*/
function resolveToolBlockKey(state: StreamParseState, event: StreamMessage): number | undefined {
const index = event.index;
Expand All @@ -376,16 +380,33 @@ function resolveToolBlockKey(state: StreamParseState, event: StreamMessage): num
}
const blocks = state.openToolBlocks;
if (!blocks || blocks.size !== 1) return undefined;
return blocks.keys().next().value;
const [key, block] = blocks.entries().next().value!;
return state.strictToolBlockCapture && block.indexed ? undefined : key;
}

/**
* Emit a closed block atomically — start, the buffered fragments in arrival order, end —
* so the strictly sequential downstream bridge never sees two calls open at once.
*/
function closeToolBlock(state: StreamParseState, key: number, events: AdapterEvent[]): void {
function closeToolBlock(state: StreamParseState, key: number, events: AdapterEvent[], implicit = false): void {
const block = state.openToolBlocks?.get(key);
if (!block || !state.openToolBlocks) return;
if (state.strictToolBlockCapture && (implicit || block.argParts.length > 0)) {
// A second start on this index is an implicit stop only when the previous call's
// arguments are already complete. Otherwise a later delta could be assigned to the
// wrong call and still produce a superficially successful tool-use turn. An explicit
// stop with no deltas retains the CLI's existing empty-arguments representation.
const argumentsJson = block.argParts.join("");
let parsedArguments: unknown;
try {
parsedArguments = JSON.parse(argumentsJson);
} catch {
throw new CodingAgentProtocolError("Coding-agent CLI ended a tool call with incomplete JSON arguments.");
}
if (!asRecord(parsedArguments)) {
throw new CodingAgentProtocolError("Coding-agent CLI ended a tool call with non-object JSON arguments.");
}
}
state.openToolBlocks.delete(key);
events.push({ type: "tool_call_start", id: block.id, name: block.name });
for (const part of block.argParts) events.push({ type: "tool_call_delta", arguments: part });
Expand Down Expand Up @@ -428,6 +449,11 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt
if (partial) {
const key = resolveToolBlockKey(state, event);
const block = key === undefined ? undefined : state.openToolBlocks?.get(key);
if (state.strictToolBlockCapture && !block) {
throw new CodingAgentProtocolError(
"Coding-agent CLI sent a tool argument delta that cannot be attributed to an open tool block.",
);
}
if (block) block.argParts.push(partial);
}
}
Expand All @@ -445,12 +471,16 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt
// CodeBuddy reuses one content-block index for a parallel batch: every call in the
// batch starts on the same index, intermediate blocks never receive a stop, and only
// the final block does (observed 2026-09-26: START 2 alpha, A's complete args, START 2
// beta, B's complete args, one STOP 2). Parallel calls stream their arguments
// sequentially — never interleaved — so the block already open on this index is
// complete, and the new start implicitly closes it.
closeToolBlock(state, key, events);
// beta, B's complete args, one STOP 2). A new start implicitly closes the previous
// block only after the capture path verifies its arguments form a complete object.
closeToolBlock(state, key, events, true);
}
(state.openToolBlocks ??= new Map()).set(key, { id, name, argParts: [] });
(state.openToolBlocks ??= new Map()).set(key, {
id,
name,
argParts: [],
indexed: typeof event.index === "number" && Number.isInteger(event.index),
});
state.toolBlockStarts = (state.toolBlockStarts ?? 0) + 1;
state.partialToolCallIds?.add(id);
}
Expand Down
37 changes: 21 additions & 16 deletions src/adapters/coding-agent/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
sawPartialThinking: false,
sawTerminalResult: false,
openToolBlocks: new Map(),
strictToolBlockCapture: Boolean(toolBridge),
partialToolCallIds: toolBridge ? new Set<string>() : undefined,
};

Expand Down Expand Up @@ -459,6 +460,25 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
kill();
break;
}
if (toolBridge && (state.toolBlockStarts ?? 0) > toolCallStarts) {
// The parser buffers a block until its stop (or same-index replacement), so the
// per-turn limit must be checked when the block opens, not when its buffered
// tool_call_start is finally emitted. The init handshake is already gated above.
toolCallStarts = state.toolBlockStarts!;
if (toolCallStarts > toolBridge.maxTurnToolCalls) {
emitOnce({
type: "error",
message: `Coding-agent CLI returned more than the ${toolBridge.maxTurnToolCalls}-tool-call turn limit.`,
status: 502,
errorType: "upstream_error",
code: "tool_call_limit",
retryable: false,
});
failClosed = true;
kill();
break;
}
}
for (const event of mappedEvents) {
if (toolBridge && !initValidated && event.type === "done") {
emitOnce({
Expand All @@ -474,20 +494,6 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
break;
}
if (toolBridge && event.type === "tool_call_start") {
toolCallStarts += 1;
if (toolCallStarts > toolBridge.maxTurnToolCalls) {
emitOnce({
type: "error",
message: `Coding-agent CLI returned more than the ${toolBridge.maxTurnToolCalls}-tool-call turn limit.`,
status: 502,
errorType: "upstream_error",
code: "tool_call_limit",
retryable: false,
});
failClosed = true;
kill();
break;
}
const wireName = toolBridge.emittedNameMap.get(event.name);
if (wireName === undefined) {
emitOnce({
Expand Down Expand Up @@ -593,8 +599,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
break;
}
if (toolBridge && !terminalEmitted && state.sawMessageStop && (state.completedToolCalls ?? 0) > 0) {
// Raw tool_use starts are gated before buffering, so every completed call was admitted
// after the bridge init handshake.
// The raw-start gate above already refused any call opened before the handshake.
// The capture-only MCP handler never answers, so the CLI parks after message_stop.
// The completed tool_use blocks are this turn's structured output: end the leg here
// and terminate the tree; the client executes, and the next request continues.
Expand Down
8 changes: 6 additions & 2 deletions structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
# Providers And Adapters

The coding-agent stream parser buffers each tool-use block by its content-block index
and emits a complete start/delta/end sequence on closure. A new start on an occupied
index closes the previous block; distinct indices can interleave. Turn completion
and emits a complete start/delta/end sequence on closure. Distinct indices can interleave.
For the CodeBuddy capture-only bridge, the init handshake and turn-call limit are checked
when a block opens, before its buffered events can be emitted. A new start on an occupied
index closes the previous block only when its arguments form a complete JSON object;
an unindexed delta or stop cannot be attributed to an indexed block, and a nonempty
argument delta that cannot be attributed fails immediately. Turn completion
requires every opened block to close, preserving the downstream single-open-call contract.
An indexless argument delta belongs to the sole open block; with multiple blocks open,
the parser fails the turn before releasing their buffered calls.
Expand Down
51 changes: 51 additions & 0 deletions tests/providers/codebuddy-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
mapStreamMessageToEvents,
projectedHistoryCharLimit,
readJsonLines,
type StreamParseState,
usageFromResult,
} from "../../src/adapters/coding-agent/protocol";
import type { OcxParsedRequest } from "../../src/types";
Expand Down Expand Up @@ -311,6 +312,56 @@ describe("codebuddy stream-json event mapping", () => {
expect(state.openToolBlocks?.size ?? 0).toBe(0);
});

test.each(["", "{\"value\":"])("same-index reuse rejects incomplete arguments %j before closing the previous call", partial => {
const state: StreamParseState = {
sawPartialText: false,
sawPartialThinking: false,
sawTerminalResult: false,
strictToolBlockCapture: true,
};
const feed = (event: unknown) => mapStreamMessageToEvents({ type: "stream_event", event: event as Record<string, unknown> }, state);
feed({ type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "tu_a", name: "alpha" } });
if (partial) feed({ type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: partial } });

expect(() => feed({ type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "tu_b", name: "beta" } }))
.toThrow("incomplete JSON arguments");
expect(state.completedToolCalls ?? 0).toBe(0);
expect(state.openToolBlocks?.get(2)?.id).toBe("tu_a");
});

test("same-index reuse rejects complete JSON that is not an argument object", () => {
const state: StreamParseState = {
sawPartialText: false,
sawPartialThinking: false,
sawTerminalResult: false,
strictToolBlockCapture: true,
};
const feed = (event: unknown) => mapStreamMessageToEvents({ type: "stream_event", event: event as Record<string, unknown> }, state);
feed({ type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "tu_a", name: "alpha" } });
feed({ type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: "[]" } });

expect(() => feed({ type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "tu_b", name: "beta" } }))
.toThrow("non-object JSON arguments");
expect(state.completedToolCalls ?? 0).toBe(0);
expect(state.openToolBlocks?.get(2)?.id).toBe("tu_a");
});

test("an unindexed argument delta cannot be dropped from the sole indexed CodeBuddy tool block", () => {
const state: StreamParseState = {
sawPartialText: false,
sawPartialThinking: false,
sawTerminalResult: false,
strictToolBlockCapture: true,
};
const feed = (event: unknown) => mapStreamMessageToEvents({ type: "stream_event", event: event as Record<string, unknown> }, state);
feed({ type: "content_block_start", index: 2, content_block: { type: "tool_use", id: "tu_a", name: "alpha" } });

expect(() => feed({ type: "content_block_delta", delta: { type: "input_json_delta", partial_json: "{\"wrong\":true}" } }))
.toThrow("tool argument delta that cannot be attributed");
expect(state.openToolBlocks?.get(2)?.argParts).toEqual([]);
expect(state.completedToolCalls ?? 0).toBe(0);
});

test("usageFromResult returns undefined when no usage is present", () => {
expect(usageFromResult({ type: "result" })).toBeUndefined();
});
Expand Down
Loading
Loading