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
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 @@ -771,7 +771,7 @@ OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code C
- Global: [CodeBuddy Global API Keys](https://www.codebuddy.ai/profile/keys)
- CN: [CodeBuddy CN API Keys](https://copilot.tencent.com/profile/keys)
- **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.
- **Tool Ownership:** In v1, the CLI is spawned with `--tools ""` and `--strict-mcp-config`, ensuring Codex maintains exclusive tool ownership. The provider operates in text and reasoning mode; client tool execution is not delegated to the vendor CLI.
- **Tool Ownership:** In v1, the CLI is spawned with `--tools ""` and `--strict-mcp-config`, ensuring Codex maintains exclusive tool ownership. The provider operates in text and reasoning mode; client tool execution is not delegated to the vendor CLI. 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.
- **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.

### Official Qoder CLI (Global & CN)
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,7 @@
"omp-path-contract.test.ts": "clients",
"omp-yaml-source-inline-comments.test.ts": "clients",
"openai-api-virtual-models.test.ts": "adapters/openai",
"openai-chat-bounded-tool-names.test.ts": "adapters/openai",
"openai-chat-dangling-toolcalls.test.ts": "adapters/openai",
"openai-chat-eof.test.ts": "adapters/openai",
"openai-chat-hardening.test.ts": "adapters/openai",
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/codebuddy/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { mapReasoningEffort } from "../../reasoning-effort";
import { buildSystemPrompt } from "../coding-agent/protocol";
import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps, type SpawnFn } from "../coding-agent/turn";
import { CODEBUDDY_PROFILES, type CodeBuddyProfile } from "./profiles";
import { guardCodeBuddyScaffolding } from "./scaffold-guard";

export type { SpawnFn } from "../coding-agent/turn";
export type CodeBuddyAdapterDeps = CodingAgentDeps;
Expand Down Expand Up @@ -75,7 +76,7 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu
provider,
parsed,
incoming,
emit,
emit: guardCodeBuddyScaffolding(emit),
buildArgs: (resolved, req, prov) => buildArgs(resolved as CodeBuddyProfile, req, prov),
buildEnv: (resolved, apiKey) => buildChildEnv(resolved as CodeBuddyProfile, apiKey),
deps,
Expand Down
248 changes: 248 additions & 0 deletions src/adapters/codebuddy/scaffold-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import type { AdapterEvent } from "../../types";

/** Error code for a CodeBuddy turn whose output contains vendor agent scaffolding. */
export const CODEBUDDY_SCAFFOLD_ERROR_CODE = "vendor_scaffold_detected";

// The observed control protocol uses FULLWIDTH VERTICAL LINE (U+FF5C). Detection stays
// deliberately narrower than the marker spelling: a calls control line must be followed by an
// invoke line for a functions.* tool. That distinguishes an agent scaffold from prose quoting or
// discussing one tag.
const DSML_CALLS_LINE = "<||dsml|| calls>";
const DSML_INVOKE_PREFIX = "<||dsml|| invoke name=\"functions.";

export interface CodeBuddyScaffoldFilterResult {
/** Bytes released from a suffix withheld by an earlier event on this channel. */
releasedPending: string;
/** Safe bytes belonging to the event currently being processed. */
text: string;
/** The earlier pending event still owns the extended candidate. */
pendingContinues: boolean;
fail: boolean;
}

interface ScanResult {
safe: string;
held: string;
fail: boolean;
fence: "`" | "~" | null;
lineStart: boolean;
}

function prefixAtEnd(text: string, at: number, expected: string): boolean {
const rest = text.slice(at).toLowerCase();
return rest.length < expected.length && expected.startsWith(rest);
}

/**
* Scan complete bytes and retain only a bounded suffix that can still become a control sequence.
*
* Control tags are recognized only at column zero and outside fenced Markdown. Inline code,
* quoted strings, blockquotes, indented source, and prose all add syntax before the tag and are
* therefore forwarded unchanged. A calls line alone is harmless; refusal requires the observed
* two-line calls-plus-functions-invoke grammar.
*/
function scan(
text: string,
initialFence: "`" | "~" | null,
initialLineStart: boolean,
): ScanResult {
let fence = initialFence;
let lineStart = initialLineStart;
let index = 0;

while (index < text.length) {
if (lineStart) {
const fenceMarkers = fence ? [fence.repeat(3)] : ["```", "~~~"];
const completeFence = fenceMarkers.find(marker => text.startsWith(marker, index));
if (completeFence) {
fence = fence ? null : (completeFence[0] as "`" | "~");
index += completeFence.length;
lineStart = false;
continue;
}
if (fenceMarkers.some(marker => prefixAtEnd(text, index, marker))) {
return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart };
}

if (!fence) {
const lowered = text.slice(index).toLowerCase();
if (lowered.startsWith(DSML_CALLS_LINE)) {
const afterCalls = index + DSML_CALLS_LINE.length;
let invokeAt = -1;
if (text[afterCalls] === "\n") invokeAt = afterCalls + 1;
else if (text[afterCalls] === "\r" && text[afterCalls + 1] === "\n") invokeAt = afterCalls + 2;
else if (afterCalls === text.length || (text[afterCalls] === "\r" && afterCalls + 1 === text.length)) {
return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart };
}

if (invokeAt >= 0) {
const invokeRest = text.slice(invokeAt).toLowerCase();
if (invokeRest.startsWith(DSML_INVOKE_PREFIX)) {
return { safe: text.slice(0, index), held: "", fail: true, fence, lineStart };
}
if (invokeRest.length === 0 || DSML_INVOKE_PREFIX.startsWith(invokeRest)) {
return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart };
}
}
} else if (prefixAtEnd(text, index, DSML_CALLS_LINE)) {
return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart };
}
}
}

const char = text[index]!;
index += 1;
lineStart = char === "\n";
}

return { safe: text, held: "", fail: false, fence, lineStart };
}

/** Streaming DSML control-sequence filter for one text or reasoning channel. */
export class CodeBuddyScaffoldFilter {
private pending = "";
private failed = false;
private fence: "`" | "~" | null = null;
private lineStart = true;

/** True while an earlier event owns an unresolved marker or fence prefix. */
hasPending(): boolean {
return this.pending.length > 0;
}

push(chunk: string): CodeBuddyScaffoldFilterResult {
if (this.failed) {
return { releasedPending: "", text: "", pendingContinues: false, fail: false };
}
if (!chunk) {
return {
releasedPending: "",
text: "",
pendingContinues: this.hasPending(),
fail: false,
};
}

const priorPending = this.pending;
const result = scan(priorPending + chunk, this.fence, this.lineStart);
this.pending = result.held;
this.fence = result.fence;
this.lineStart = result.lineStart;
this.failed = result.fail;

const releasedLength = Math.min(priorPending.length, result.safe.length);
return {
releasedPending: result.safe.slice(0, releasedLength),
text: result.safe.slice(releasedLength),
pendingContinues: priorPending.length > 0 && result.safe.length === 0 && result.held.length > 0,
fail: result.fail,
};
}

/** Release a suffix that never completed the two-line control grammar. */
flush(): CodeBuddyScaffoldFilterResult {
if (this.failed) {
return { releasedPending: "", text: "", pendingContinues: false, fail: false };
}
const text = this.pending;
this.pending = "";
return { releasedPending: text, text: "", pendingContinues: false, fail: false };
}
}

function codeBuddyScaffoldErrorMessage(): string {
return "CodeBuddy CLI emitted vendor tool-call markup in an assistant output channel. This route"
+ " runs the CLI with its own tools and MCP servers disabled and Codex owns tool control, so"
+ " the turn was refused rather than forwarding or executing vendor agent scaffolding.";
}

/** Guard both streamed channels while preserving event order around withheld marker prefixes. */
export function guardCodeBuddyScaffolding(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void {
const textFilter = new CodeBuddyScaffoldFilter();
const thinkingFilter = new CodeBuddyScaffoldFilter();
type PendingChannel = "text" | "thinking";
type EventSlot = { resolved: boolean; event?: AdapterEvent };
const eventQueue: EventSlot[] = [];
const pendingSlots = new Map<PendingChannel, EventSlot>();
let closed = false;

const channelEvent = (channel: PendingChannel, text: string): AdapterEvent => channel === "text"
? { type: "text_delta", text }
: { type: "thinking_delta", thinking: text };

const drainResolved = (): void => {
while (eventQueue[0]?.resolved) {
const slot = eventQueue.shift()!;
if (slot.event) emit(slot.event);
}
};

const enqueueResolved = (event: AdapterEvent): void => {
eventQueue.push({ resolved: true, event });
drainResolved();
};

const resolvePendingSlot = (channel: PendingChannel, text: string): void => {
const slot = pendingSlots.get(channel);
if (!slot) return;
slot.resolved = true;
if (text) slot.event = channelEvent(channel, text);
pendingSlots.delete(channel);
drainResolved();
};

const enqueuePendingSlot = (channel: PendingChannel): void => {
const slot: EventSlot = { resolved: false };
eventQueue.push(slot);
pendingSlots.set(channel, slot);
};

const flushAllPending = (): void => {
for (const channel of ["text", "thinking"] as const) {
if (!pendingSlots.has(channel)) continue;
const filter = channel === "text" ? textFilter : thinkingFilter;
resolvePendingSlot(channel, filter.flush().releasedPending);
}
drainResolved();
};

const refuse = (): void => {
if (closed) return;
flushAllPending();
closed = true;
emit({
type: "error",
message: codeBuddyScaffoldErrorMessage(),
status: 502,
errorType: "upstream_error",
code: CODEBUDDY_SCAFFOLD_ERROR_CODE,
retryable: false,
});
};

return (event: AdapterEvent): void => {
if (closed) return;
if (event.type === "text_delta" || event.type === "thinking_delta") {
const channel: PendingChannel = event.type === "text_delta" ? "text" : "thinking";
const filter = channel === "text" ? textFilter : thinkingFilter;
const hadPending = filter.hasPending();
const cleaned = filter.push(event.type === "text_delta" ? event.text : event.thinking);
if (hadPending && !cleaned.pendingContinues) resolvePendingSlot(channel, cleaned.releasedPending);
if (cleaned.text) {
enqueueResolved(event.type === "text_delta"
? { ...event, text: cleaned.text }
: { ...event, thinking: cleaned.text });
}
if (filter.hasPending() && !cleaned.pendingContinues) enqueuePendingSlot(channel);
if (cleaned.fail) refuse();
return;
}
if (event.type === "done" || event.type === "error" || event.type === "incomplete") {
flushAllPending();
closed = true;
emit(event);
return;
}
enqueueResolved(event);
};
}
16 changes: 8 additions & 8 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort";
import { debugProviderDiagnostic } from "../lib/debug";
import { sseFieldValue } from "../lib/sse-decoder";
import { isDebugEnabled } from "../lib/debug-settings";
import { frameAgentRouterMessages } from "./agentrouter";
import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../providers/vercel-gateway-routing";
import { fastPolicyForModel } from "../providers/service-tier";
Expand Down Expand Up @@ -39,6 +38,7 @@ import {
upstreamErrorEvent,
} from "./openai-chat/errors";
import { messagesToChatFormat } from "./openai-chat/messages";
import { withOpenAIChatToolNames } from "./openai-chat/tool-name-registry";
import { isNativeOpenAIChatTarget, openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire";
import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema";

Expand Down Expand Up @@ -88,18 +88,18 @@ function canSerializeOpenAIChatServiceTier(

export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter {
let lastRequestedModelId: string | undefined;
return {
return withOpenAIChatToolNames(toolNames => ({
name: "openai-chat",

formatErrorBody: formatOpenAIChatErrorBody,

buildRequest(parsed: OcxParsedRequest, incoming?: IncomingMeta) {
lastRequestedModelId = parsed.modelId;
const { url, headers, hasCredential } = openAIChatTransport(provider);
const messages = frameAgentRouterMessages(provider.baseUrl, messagesToChatFormat(parsed, provider));
const messages = toolNames.messages(parsed, provider.baseUrl, messagesToChatFormat(parsed, provider));
const finish = (): AdapterRequest => {
const tools = toolsToChatFormatForProvider(parsed, provider);
const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider);
const tools = toolsToChatFormatForProvider(parsed, provider, toolNames.registry());
const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider, toolNames.registry());

const body: Record<string, unknown> = {
model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId,
Expand Down Expand Up @@ -365,7 +365,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
return "terminate";
}
if (!call.id) call.id = `call_${++toolCallSeq}`;
yield { type: "tool_call_start", id: call.id, name: call.name };
yield { type: "tool_call_start", id: call.id, name: toolNames.restore(call.name) };
if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args };
yield { type: "tool_call_end" };
}
Expand Down Expand Up @@ -801,7 +801,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
logInvalidToolCalls("response", rawToolCalls);
return [invalidToolCallsEvent(rawToolCalls, "response", usage)];
}
events.push({ type: "tool_call_start", id, name });
events.push({ type: "tool_call_start", id, name: toolNames.restore(name) });
events.push({ type: "tool_call_delta", arguments: args });
events.push({ type: "tool_call_end" });
}
Expand All @@ -818,5 +818,5 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
budget.releaseRetained(responseBytes, { kind: "retained_collectors" });
}
},
};
}));
}
Loading
Loading