mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-09-01 14:52:19 +08:00
Add Codex multi-agent bridge and provider model descriptions
This commit is contained in:
@@ -137,7 +137,7 @@ function codexModelCatalogItem(
|
||||
default_reasoning_level: profile.defaultReasoningLevel,
|
||||
default_reasoning_effort: profile.defaultReasoningLevel,
|
||||
default_reasoning_summary: profile.defaultReasoningSummary,
|
||||
description: `CCR gateway model ${model}`,
|
||||
description: profile.description ?? `CCR gateway model ${model}`,
|
||||
displayName: model,
|
||||
display_name: model,
|
||||
effective_context_window_percent: effectiveContextWindowPercent,
|
||||
@@ -179,6 +179,7 @@ type CodexCapabilityProfile = {
|
||||
applyPatchToolType: string | null;
|
||||
catalogEntry?: ModelCatalogEntry;
|
||||
contextWindow?: number;
|
||||
description?: string;
|
||||
defaultReasoningLevel: string | null;
|
||||
defaultReasoningSummary: string;
|
||||
effectiveContextWindowPercent?: number;
|
||||
@@ -250,6 +251,9 @@ function codexModelCapabilityProfile(
|
||||
applyPatchToolType,
|
||||
catalogEntry,
|
||||
contextWindow: providerModelMetadata?.contextWindow,
|
||||
description: provider
|
||||
? providerModelDescriptionFor(provider, providerModel)
|
||||
: undefined,
|
||||
defaultReasoningLevel: resolveDefaultReasoningLevel(
|
||||
providerModelMetadata?.defaultReasoningLevel !== undefined
|
||||
? providerModelMetadata.defaultReasoningLevel
|
||||
@@ -281,6 +285,17 @@ function providerModelMetadataFor(provider: GatewayProviderConfig, model: string
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function providerModelDescriptionFor(provider: GatewayProviderConfig, model: string): string | undefined {
|
||||
const descriptions = provider.modelDescriptions ?? {};
|
||||
const direct = descriptions[model]?.trim();
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const normalized = model.trim().toLowerCase();
|
||||
const match = Object.entries(descriptions).find(([candidate]) => candidate.trim().toLowerCase() === normalized);
|
||||
return match?.[1]?.trim() || undefined;
|
||||
}
|
||||
|
||||
function codexProviderModelMetadataFor(provider: GatewayProviderConfig, model: string): ProviderModelMetadata | undefined {
|
||||
const metadata = providerModelMetadataFor(provider, model) ?? localCodexModelMetadataFor(provider, model);
|
||||
if (!isLocalCodexProvider(provider)) {
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import type { IncomingHttpHeaders } from "node:http";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import type { AppConfig } from "@ccr/core/contracts/app";
|
||||
import { normalizeRouteSelector } from "@ccr/core/routing/model-registry";
|
||||
import { isRecord, rawStringValue, stringValue } from "@ccr/core/gateway/internal/value";
|
||||
import { readHeader } from "@ccr/core/gateway/http/io";
|
||||
import { parseJsonObjectSafe, serializeJsonBody } from "@ccr/core/gateway/http/body";
|
||||
import { requestProtocolForPath } from "@ccr/core/routing/protocol-endpoints";
|
||||
import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution";
|
||||
|
||||
const multiAgentNamespaceName = "multi_agent_v1";
|
||||
const multiAgentFunctionPrefix = `${multiAgentNamespaceName}_`;
|
||||
const multiAgentToolNames = new Set(["close_agent", "resume_agent", "send_input", "spawn_agent", "wait_agent"]);
|
||||
|
||||
export function prepareCodexMultiAgentBridgeRequest(input: {
|
||||
body?: Buffer;
|
||||
config: AppConfig;
|
||||
headers: IncomingHttpHeaders;
|
||||
method: string;
|
||||
path: string;
|
||||
routedModel?: string;
|
||||
}): { body: Buffer; diagnostic: string } | undefined {
|
||||
if (!codexMultiAgentBridgeEnabled(input.headers, input.method, input.path)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsedBody = parseJsonObjectSafe(input.body);
|
||||
if (!parsedBody) {
|
||||
return undefined;
|
||||
}
|
||||
const model = input.routedModel || stringValue(parsedBody.model);
|
||||
if (!codexMultiAgentBridgeModelEligible(model, input.config)) {
|
||||
return undefined;
|
||||
}
|
||||
const transformed = transformCodexMultiAgentBridgeRequestBody(parsedBody);
|
||||
if (!transformed.changed) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
body: serializeJsonBody(transformed.body),
|
||||
diagnostic: `${model ?? "unknown"}:${transformed.changedParts.join(",")}`
|
||||
};
|
||||
}
|
||||
|
||||
export function transformCodexMultiAgentBridgeRequestBody(body: Record<string, unknown>): {
|
||||
body: Record<string, unknown>;
|
||||
changed: boolean;
|
||||
changedParts: string[];
|
||||
} {
|
||||
const next = { ...body };
|
||||
const changedParts: string[] = [];
|
||||
const tools = transformCodexMultiAgentBridgeTools(body.tools);
|
||||
if (tools.changed) {
|
||||
next.tools = tools.value;
|
||||
changedParts.push("tools");
|
||||
const toolChoice = transformCodexMultiAgentBridgeToolChoice(body.tool_choice);
|
||||
if (toolChoice.changed) {
|
||||
if (toolChoice.value === undefined) {
|
||||
delete next.tool_choice;
|
||||
} else {
|
||||
next.tool_choice = toolChoice.value;
|
||||
}
|
||||
changedParts.push("tool_choice");
|
||||
}
|
||||
const input = transformCodexMultiAgentBridgeInput(body.input);
|
||||
if (input.changed) {
|
||||
next.input = input.value;
|
||||
changedParts.push("input");
|
||||
}
|
||||
}
|
||||
return {
|
||||
body: next,
|
||||
changed: changedParts.length > 0,
|
||||
changedParts
|
||||
};
|
||||
}
|
||||
|
||||
function transformCodexMultiAgentBridgeTools(value: unknown): { value: unknown; changed: boolean } {
|
||||
if (!Array.isArray(value)) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
let changed = false;
|
||||
const tools = value.flatMap((tool) => {
|
||||
if (!isRecord(tool) || tool.type !== "namespace" || tool.name !== multiAgentNamespaceName) {
|
||||
return [tool];
|
||||
}
|
||||
const namespaceTools = Array.isArray(tool.tools) ? tool.tools : [];
|
||||
const flattened = namespaceTools
|
||||
.filter((item) => isRecord(item) && item.type === "function" && multiAgentToolNames.has(stringValue(item.name) ?? ""))
|
||||
.map((item) => codexMultiAgentFunctionTool(item as Record<string, unknown>));
|
||||
if (flattened.length === 0) {
|
||||
return [tool];
|
||||
}
|
||||
changed = true;
|
||||
return flattened;
|
||||
});
|
||||
return { value: tools, changed };
|
||||
}
|
||||
|
||||
function codexMultiAgentFunctionTool(tool: Record<string, unknown>): Record<string, unknown> {
|
||||
const name = stringValue(tool.name) ?? "";
|
||||
const description = rawStringValue(tool.description) ?? "";
|
||||
return {
|
||||
...tool,
|
||||
name: codexMultiAgentFunctionName(name),
|
||||
description: description
|
||||
? `Namespaced ${multiAgentNamespaceName}.${name} tool.\n\n${description}`
|
||||
: `Namespaced ${multiAgentNamespaceName}.${name} tool.`
|
||||
};
|
||||
}
|
||||
|
||||
function transformCodexMultiAgentBridgeToolChoice(value: unknown): { value: unknown; changed: boolean } {
|
||||
const name = toolChoiceName(value);
|
||||
if (!name) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
if (normalizeMultiAgentToolName(name) === undefined && name !== multiAgentNamespaceName) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
if (name === multiAgentNamespaceName) {
|
||||
return { value: undefined, changed: true };
|
||||
}
|
||||
const mappedName = normalizeMultiAgentToolName(name);
|
||||
if (!mappedName || mappedName === name) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
if (isRecord(value) && isRecord(value.function)) {
|
||||
return {
|
||||
value: {
|
||||
...value,
|
||||
function: {
|
||||
...value.function,
|
||||
name: mappedName
|
||||
}
|
||||
},
|
||||
changed: true
|
||||
};
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return {
|
||||
value: {
|
||||
...value,
|
||||
name: mappedName,
|
||||
type: value.type === "tool" ? "function" : value.type
|
||||
},
|
||||
changed: true
|
||||
};
|
||||
}
|
||||
return { value, changed: false };
|
||||
}
|
||||
|
||||
function toolChoiceName(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return stringValue(value.name) ?? (isRecord(value.function) ? stringValue(value.function.name) : undefined);
|
||||
}
|
||||
|
||||
function transformCodexMultiAgentBridgeInput(value: unknown): { value: unknown; changed: boolean } {
|
||||
if (!Array.isArray(value)) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
let changed = false;
|
||||
const items = value.map((item) => {
|
||||
const transformed = transformCodexMultiAgentBridgeRequestItem(item);
|
||||
changed ||= transformed.changed;
|
||||
return transformed.value;
|
||||
});
|
||||
return { value: items, changed };
|
||||
}
|
||||
|
||||
function transformCodexMultiAgentBridgeRequestItem(value: unknown): { value: unknown; changed: boolean } {
|
||||
if (!isRecord(value) || value.type !== "function_call") {
|
||||
return { value, changed: false };
|
||||
}
|
||||
const mappedName = normalizeMultiAgentToolName(stringValue(value.name) ?? "");
|
||||
if (!mappedName || mappedName === value.name) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
const { namespace: _namespace, ...rest } = value;
|
||||
return {
|
||||
value: {
|
||||
...rest,
|
||||
name: mappedName
|
||||
},
|
||||
changed: true
|
||||
};
|
||||
}
|
||||
|
||||
function codexMultiAgentBridgeEnabled(headers: IncomingHttpHeaders, method: string, path: string): boolean {
|
||||
return (method || "GET").toUpperCase() === "POST" &&
|
||||
requestProtocolForPath(path) === "openai_responses" &&
|
||||
isCodexUserAgent(headers);
|
||||
}
|
||||
|
||||
function isCodexUserAgent(headers: IncomingHttpHeaders): boolean {
|
||||
return readHeader(headers["user-agent"])?.toLowerCase().includes("codex") ?? false;
|
||||
}
|
||||
|
||||
function codexMultiAgentBridgeModelEligible(model: string | undefined, config: AppConfig): boolean {
|
||||
const modelName = modelNameForMultiAgentBridge(model);
|
||||
if (!modelName || modelName.toLowerCase().includes("gpt")) {
|
||||
return false;
|
||||
}
|
||||
const baseModelName = modelNameForMultiAgentBridge(resolveUsageModelAttribution(config, model).model);
|
||||
return !baseModelName.toLowerCase().includes("gpt");
|
||||
}
|
||||
|
||||
function modelNameForMultiAgentBridge(model: string | undefined): string {
|
||||
const normalized = normalizeRouteSelector(model) ?? "";
|
||||
const slashIndex = normalized.lastIndexOf("/");
|
||||
return slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized;
|
||||
}
|
||||
|
||||
function codexMultiAgentFunctionName(name: string): string {
|
||||
return `${multiAgentFunctionPrefix}${name}`;
|
||||
}
|
||||
|
||||
function normalizeMultiAgentToolName(name: string): string | undefined {
|
||||
const normalized = name.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized.startsWith(multiAgentFunctionPrefix)) {
|
||||
const inner = normalized.slice(multiAgentFunctionPrefix.length);
|
||||
return multiAgentToolNames.has(inner) ? codexMultiAgentFunctionName(inner) : undefined;
|
||||
}
|
||||
const dottedPrefix = `${multiAgentNamespaceName}.`;
|
||||
if (normalized.startsWith(dottedPrefix)) {
|
||||
const inner = normalized.slice(dottedPrefix.length);
|
||||
return multiAgentToolNames.has(inner) ? codexMultiAgentFunctionName(inner) : undefined;
|
||||
}
|
||||
return multiAgentToolNames.has(normalized) ? codexMultiAgentFunctionName(normalized) : undefined;
|
||||
}
|
||||
|
||||
function nativeMultiAgentToolName(name: string): string | undefined {
|
||||
const normalized = name.trim();
|
||||
if (!normalized.startsWith(multiAgentFunctionPrefix)) {
|
||||
return undefined;
|
||||
}
|
||||
const inner = normalized.slice(multiAgentFunctionPrefix.length);
|
||||
return multiAgentToolNames.has(inner) ? inner : undefined;
|
||||
}
|
||||
|
||||
export function codexMultiAgentBridgeResponseStream(input: Readable, headers: Headers): Readable {
|
||||
const contentType = headers.get("content-type")?.toLowerCase() ?? "";
|
||||
if (contentType.includes("text/event-stream")) {
|
||||
return input.pipe(new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
transformSseChunk(this, chunk);
|
||||
callback();
|
||||
},
|
||||
flush(callback) {
|
||||
flushSseTransform(this);
|
||||
callback();
|
||||
}
|
||||
}));
|
||||
}
|
||||
if (contentType.includes("application/json")) {
|
||||
const chunks: Buffer[] = [];
|
||||
return input.pipe(new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
callback();
|
||||
},
|
||||
flush(callback) {
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const transformed = transformCodexMultiAgentBridgeResponseValue(parsed);
|
||||
this.push(Buffer.from(`${JSON.stringify(transformed.value)}\n`, "utf8"));
|
||||
} catch {
|
||||
this.push(Buffer.from(raw, "utf8"));
|
||||
}
|
||||
callback();
|
||||
}
|
||||
}));
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function transformCodexMultiAgentBridgeResponseValue(value: unknown): { value: unknown; changed: boolean } {
|
||||
if (!isRecord(value)) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
let changed = false;
|
||||
const next = { ...value };
|
||||
if (isRecord(value.item)) {
|
||||
const item = transformMultiAgentFunctionCall(value.item);
|
||||
if (item.changed) {
|
||||
next.item = item.value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(value.output)) {
|
||||
const output = transformCodexMultiAgentBridgeResponseItems(value.output);
|
||||
if (output.changed) {
|
||||
next.output = output.value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (isRecord(value.response) && Array.isArray(value.response.output)) {
|
||||
const output = transformCodexMultiAgentBridgeResponseItems(value.response.output);
|
||||
if (output.changed) {
|
||||
next.response = {
|
||||
...value.response,
|
||||
output: output.value
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
const item = transformMultiAgentFunctionCall(next);
|
||||
if (item.changed) {
|
||||
return item;
|
||||
}
|
||||
return { value: next, changed };
|
||||
}
|
||||
|
||||
function transformCodexMultiAgentBridgeResponseItems(items: unknown[]): { value: unknown[]; changed: boolean } {
|
||||
let changed = false;
|
||||
const value = items.map((item) => {
|
||||
const transformed = isRecord(item)
|
||||
? transformMultiAgentFunctionCall(item)
|
||||
: { value: item, changed: false };
|
||||
changed ||= transformed.changed;
|
||||
return transformed.value;
|
||||
});
|
||||
return { value, changed };
|
||||
}
|
||||
|
||||
function transformMultiAgentFunctionCall(item: Record<string, unknown>): { value: unknown; changed: boolean } {
|
||||
if (item.type !== "function_call") {
|
||||
return { value: item, changed: false };
|
||||
}
|
||||
const nativeName = nativeMultiAgentToolName(stringValue(item.name) ?? "");
|
||||
if (!nativeName) {
|
||||
return { value: item, changed: false };
|
||||
}
|
||||
return {
|
||||
value: {
|
||||
...item,
|
||||
name: nativeName,
|
||||
namespace: multiAgentNamespaceName
|
||||
},
|
||||
changed: true
|
||||
};
|
||||
}
|
||||
|
||||
function transformSseChunk(stream: Transform, chunk: Buffer | string): void {
|
||||
const state = stream as Transform & { __ccrCodexMultiAgentBridgeSsePending?: string };
|
||||
state.__ccrCodexMultiAgentBridgeSsePending = (state.__ccrCodexMultiAgentBridgeSsePending ?? "") + chunk.toString();
|
||||
while (state.__ccrCodexMultiAgentBridgeSsePending) {
|
||||
const match = /\r?\n\r?\n/.exec(state.__ccrCodexMultiAgentBridgeSsePending);
|
||||
if (!match || match.index === undefined) {
|
||||
break;
|
||||
}
|
||||
const block = state.__ccrCodexMultiAgentBridgeSsePending.slice(0, match.index);
|
||||
const delimiter = match[0];
|
||||
state.__ccrCodexMultiAgentBridgeSsePending = state.__ccrCodexMultiAgentBridgeSsePending.slice(match.index + delimiter.length);
|
||||
stream.push(transformCodexMultiAgentBridgeSseEvent(block) + delimiter);
|
||||
}
|
||||
}
|
||||
|
||||
function flushSseTransform(stream: Transform): void {
|
||||
const state = stream as Transform & { __ccrCodexMultiAgentBridgeSsePending?: string };
|
||||
if (state.__ccrCodexMultiAgentBridgeSsePending) {
|
||||
stream.push(transformCodexMultiAgentBridgeSseEvent(state.__ccrCodexMultiAgentBridgeSsePending));
|
||||
state.__ccrCodexMultiAgentBridgeSsePending = "";
|
||||
}
|
||||
}
|
||||
|
||||
export function transformCodexMultiAgentBridgeSseEvent(block: string): string {
|
||||
const lines = block.split(/\r?\n/);
|
||||
const dataIndex = lines.findIndex((line) => line.startsWith("data: "));
|
||||
if (dataIndex < 0) {
|
||||
return block;
|
||||
}
|
||||
const raw = lines[dataIndex].slice("data: ".length);
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const transformed = transformCodexMultiAgentBridgeResponseValue(parsed);
|
||||
if (!transformed.changed) {
|
||||
return block;
|
||||
}
|
||||
lines[dataIndex] = `data: ${JSON.stringify(transformed.value)}`;
|
||||
return lines.join("\n");
|
||||
} catch {
|
||||
return block;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { adaptRouteRequestBody, restoreRouteRequestBody } from "@ccr/core/routin
|
||||
import { reserveApiKeyLimits } from "@ccr/core/gateway/auth/api-key-authorizer";
|
||||
import { recordProviderCredentialOutcome } from "@ccr/core/providers/credential-pool";
|
||||
import { codexApplyPatchBridgeResponseStream, prepareCodexApplyPatchBridgeRequest } from "@ccr/core/gateway/features/codex-patch-bridge";
|
||||
import { codexMultiAgentBridgeResponseStream, prepareCodexMultiAgentBridgeRequest } from "@ccr/core/gateway/features/codex-multi-agent-bridge";
|
||||
import { prepareCursorOpenAICompatChatBody } from "@ccr/core/gateway/features/cursor-compat";
|
||||
import { filteredResponseHeaders, formatError, formatUpstreamErrorForLog, forwardHeaders, inferGatewayClient, readRequestBody, sendJson, shouldCaptureGatewayUsage, shouldSendBody, stripLocalGatewayAuthHeaders } from "@ccr/core/gateway/http/io";
|
||||
import { serializeJsonBody, takeJsonObject } from "@ccr/core/gateway/http/body";
|
||||
@@ -155,6 +156,7 @@ export class GatewayRequestPipeline {
|
||||
let routeFallback = this.config.Router.fallback;
|
||||
let routedModel: string | undefined;
|
||||
let codexApplyPatchBridgeActive = false;
|
||||
let codexMultiAgentBridgeActive = false;
|
||||
const claudeModelRewriteStartedAt = Date.now();
|
||||
const claudeModelRewrite = prepareClaudeCodeDiscoveredModelRequest(this.config, request.headers, method, path, bodyToForward);
|
||||
if (claudeModelRewrite) {
|
||||
@@ -380,6 +382,34 @@ export class GatewayRequestPipeline {
|
||||
});
|
||||
}
|
||||
|
||||
const codexMultiAgentBridgeStartedAt = Date.now();
|
||||
const codexMultiAgentBridgeRequest = prepareCodexMultiAgentBridgeRequest({
|
||||
body: bodyToForward,
|
||||
config: this.config,
|
||||
headers: request.headers,
|
||||
method,
|
||||
path,
|
||||
routedModel
|
||||
});
|
||||
if (codexMultiAgentBridgeRequest) {
|
||||
bodyToForward = codexMultiAgentBridgeRequest.body;
|
||||
codexMultiAgentBridgeActive = true;
|
||||
headers["x-ccr-codex-multi-agent-bridge"] = sanitizeHeaderValue(codexMultiAgentBridgeRequest.diagnostic);
|
||||
headers["content-type"] = "application/json";
|
||||
routeTrace?.capture({
|
||||
changes: [
|
||||
{ operation: "replace", path: "/body", scope: "body" },
|
||||
{ after: headers["x-ccr-codex-multi-agent-bridge"], operation: "add", path: "/headers/x-ccr-codex-multi-agent-bridge", scope: "headers" },
|
||||
{ after: headers["content-type"], operation: "replace", path: "/headers/content-type", scope: "headers" }
|
||||
],
|
||||
durationMs: Date.now() - codexMultiAgentBridgeStartedAt,
|
||||
kind: "mutation",
|
||||
name: "compatibility.codex-multi-agent",
|
||||
phase: "compatibility",
|
||||
startedAtMs: codexMultiAgentBridgeStartedAt
|
||||
});
|
||||
}
|
||||
|
||||
const hostedWebSearchProtocolContext = createHostedWebSearchProtocolContext({
|
||||
body: bodyToForward,
|
||||
config: this.config,
|
||||
@@ -682,7 +712,7 @@ export class GatewayRequestPipeline {
|
||||
const appendContextArchiveFooter = Boolean(contextArchiveRecord && upstreamResponse.ok);
|
||||
const transformCodexCompactResponse = Boolean(!contextArchiveRecord && codexCompactCompatResponseMode && upstreamResponse.ok);
|
||||
const contextArchiveSourceContentType = responseHeaders.get("content-type") ?? undefined;
|
||||
if (codexApplyPatchBridgeActive || appendContextArchiveFooter || transformCodexCompactResponse) {
|
||||
if (codexApplyPatchBridgeActive || codexMultiAgentBridgeActive || appendContextArchiveFooter || transformCodexCompactResponse) {
|
||||
responseHeaders.delete("content-length");
|
||||
}
|
||||
if ((appendContextArchiveFooter || transformCodexCompactResponse) && contextArchiveResponseContentType) {
|
||||
@@ -734,14 +764,17 @@ export class GatewayRequestPipeline {
|
||||
const patchedResponseBody = codexApplyPatchBridgeActive
|
||||
? codexApplyPatchBridgeResponseStream(upstreamBody, responseHeaders)
|
||||
: upstreamBody;
|
||||
const multiAgentResponseBody = codexMultiAgentBridgeActive
|
||||
? codexMultiAgentBridgeResponseStream(patchedResponseBody, responseHeaders)
|
||||
: patchedResponseBody;
|
||||
const hostedWebSearchResponseBody = hostedWebSearchProtocolContext
|
||||
? hostedWebSearchProtocolResponseStream(
|
||||
patchedResponseBody,
|
||||
multiAgentResponseBody,
|
||||
responseHeaders,
|
||||
hostedWebSearchProtocolContext,
|
||||
this.browserWebSearchMcpIntegration
|
||||
)
|
||||
: patchedResponseBody;
|
||||
: multiAgentResponseBody;
|
||||
const archiveResponseProtocol = requestProtocolForPath(upstreamPath) ?? requestProtocol ?? "anthropic_messages";
|
||||
const responseBody = appendContextArchiveFooter && contextArchiveRecord
|
||||
? contextArchiveHandoffResponseStream(
|
||||
@@ -759,7 +792,7 @@ export class GatewayRequestPipeline {
|
||||
codexCompactCompatResponseMode
|
||||
)
|
||||
: hostedWebSearchResponseBody;
|
||||
const responseStreams = uniqueStreams([upstreamBody, patchedResponseBody, hostedWebSearchResponseBody, responseBody]);
|
||||
const responseStreams = uniqueStreams([upstreamBody, patchedResponseBody, multiAgentResponseBody, hostedWebSearchResponseBody, responseBody]);
|
||||
const sampler = createBodySampler();
|
||||
const sseErrorDetector = createSseErrorDetector(responseHeaders.get("content-type") ?? undefined);
|
||||
let streamDetectedError: string | undefined;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
export { gatewayService } from "@ccr/core/gateway/application/gateway-service";
|
||||
export { prepareCodexApplyPatchBridgeRequest, transformCodexApplyPatchBridgeRequestBody, transformCodexApplyPatchBridgeResponseValue, transformCodexApplyPatchBridgeSseEvent } from "@ccr/core/gateway/features/codex-patch-bridge";
|
||||
export { prepareCodexMultiAgentBridgeRequest, transformCodexMultiAgentBridgeRequestBody, transformCodexMultiAgentBridgeResponseValue, transformCodexMultiAgentBridgeSseEvent } from "@ccr/core/gateway/features/codex-multi-agent-bridge";
|
||||
export { appendContextArchiveToolOutputsForTest, contextArchiveFunctionCallsForTest, parseContextArchiveToolResponseBodyForTest, prepareCodexCompactCompatRequest, prepareContextArchiveToolContinuationRequestForTest } from "@ccr/core/gateway/features/context-archive-continuation";
|
||||
export { normalizeClaudeCodeOauthProviderPlugins, normalizeCoreGatewayVirtualModelProfiles } from "@ccr/core/gateway/core-runtime/config-compiler";
|
||||
export { fusionBuiltinToolArtifactsForTest, fusionFallbackToolDefinitions, fusionToolNamesBackedByMcpServers } from "@ccr/core/mcp/fusion-config";
|
||||
|
||||
@@ -917,8 +917,8 @@ function buildCodexConfigToml(
|
||||
managedContextArchiveMcpEnd
|
||||
]);
|
||||
content = removeCodexProviderTable(content, values.providerId);
|
||||
content = removeCodexMcpServerTable(content, TOOL_HUB_MCP_SERVER_NAME);
|
||||
content = removeCodexMcpServerTable(content, CONTEXT_ARCHIVE_MCP_SERVER_NAME);
|
||||
content = removeCodexMcpServerTable(content, TOOL_HUB_MCP_SERVER_NAME, { includeChildTables: !values.toolHubMcp });
|
||||
content = removeCodexMcpServerTable(content, CONTEXT_ARCHIVE_MCP_SERVER_NAME, { includeChildTables: !values.contextArchiveMcp });
|
||||
if (values.configFormat === "separate_profile_files") {
|
||||
content = removeCodexProfileTable(content, values.providerId);
|
||||
}
|
||||
@@ -1034,7 +1034,9 @@ function buildSeparateCodexProfileToml(
|
||||
): string {
|
||||
const firstTableIndex = firstTomlTableIndex(source);
|
||||
const rootSource = firstTableIndex === -1 ? source : source.slice(0, firstTableIndex);
|
||||
const restSource = firstTableIndex === -1 ? "" : source.slice(firstTableIndex);
|
||||
let restSource = firstTableIndex === -1 ? "" : source.slice(firstTableIndex);
|
||||
restSource = removeCodexMcpServerTable(restSource, TOOL_HUB_MCP_SERVER_NAME, { includeChildTables: true });
|
||||
restSource = removeCodexMcpServerTable(restSource, CONTEXT_ARCHIVE_MCP_SERVER_NAME, { includeChildTables: true });
|
||||
const modelAssignment = managedModelAssignment(rootSource, values.model);
|
||||
const showAllSessionsAssignment = rootTomlAssignment(rootSource, "show_all_sessions")
|
||||
?? (values.showAllSessions ? "show_all_sessions = true" : undefined);
|
||||
@@ -2323,7 +2325,11 @@ function removeCodexProfileTable(source: string, providerId: string): string {
|
||||
return removeTomlTable(source, "profiles", providerId);
|
||||
}
|
||||
|
||||
function removeCodexMcpServerTable(source: string, serverName: string): string {
|
||||
function removeCodexMcpServerTable(
|
||||
source: string,
|
||||
serverName: string,
|
||||
options: { includeChildTables?: boolean } = {}
|
||||
): string {
|
||||
const lines = source.split(/(?<=\n)/);
|
||||
const headers = new Set([
|
||||
`[mcp_servers.${serverName}]`,
|
||||
@@ -2338,7 +2344,8 @@ function removeCodexMcpServerTable(source: string, serverName: string): string {
|
||||
const kept: string[] = [];
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (!headers.has(line.trim())) {
|
||||
const trimmed = line.trim();
|
||||
if (!headers.has(trimmed) && !isCodexMcpServerChildTable(trimmed, serverName, Boolean(options.includeChildTables))) {
|
||||
kept.push(line);
|
||||
continue;
|
||||
}
|
||||
@@ -2352,6 +2359,14 @@ function removeCodexMcpServerTable(source: string, serverName: string): string {
|
||||
return kept.join("");
|
||||
}
|
||||
|
||||
function isCodexMcpServerChildTable(trimmedLine: string, serverName: string, enabled: boolean): boolean {
|
||||
if (!enabled) {
|
||||
return false;
|
||||
}
|
||||
return trimmedLine.startsWith(`[mcp_servers.${serverName}.`) ||
|
||||
trimmedLine.startsWith(`[mcp_servers.${tomlQuotedKey(serverName)}.`);
|
||||
}
|
||||
|
||||
function removeTomlTable(source: string, section: string, name: string): string {
|
||||
const lines = source.split(/(?<=\n)/);
|
||||
const headers = new Set([
|
||||
|
||||
@@ -756,7 +756,14 @@ test("profile service injects ToolHub MCP into Codex config", { skip: !process.e
|
||||
separateProfileFile,
|
||||
initialSeparateProfile
|
||||
.replace('model = "Provider/model"', 'model = "User/selected-in-cli"')
|
||||
.replace(/\s*$/, '\nmodel_reasoning_effort = "ultra"\n')
|
||||
.replace(/\s*$/, [
|
||||
"",
|
||||
'model_reasoning_effort = "ultra"',
|
||||
"",
|
||||
'[mcp_servers.ccr-toolhub.tools."tool_hub.resolve"]',
|
||||
'approval_mode = "approve"',
|
||||
""
|
||||
].join("\n"))
|
||||
);
|
||||
|
||||
await applyProfileConfig(config);
|
||||
@@ -773,6 +780,7 @@ test("profile service injects ToolHub MCP into Codex config", { skip: !process.e
|
||||
const preservedSeparateProfile = readFileSync(separateProfileFile, "utf8");
|
||||
assert.match(preservedSeparateProfile, /model = "User\/selected-in-cli"/);
|
||||
assert.match(preservedSeparateProfile, /model_reasoning_effort = "ultra"/);
|
||||
assert.equal(preservedSeparateProfile.includes("[mcp_servers.ccr-toolhub"), false);
|
||||
|
||||
config.Providers[0].models.push("model-2");
|
||||
config.profile.profiles[0].model = "Provider/model-2";
|
||||
|
||||
@@ -39,6 +39,23 @@ test("codex catalog treats unknown models as text-only while enabling apply_patc
|
||||
assert.equal(model.apply_patch_tool_type, "freeform");
|
||||
});
|
||||
|
||||
test("codex catalog publishes configured provider model descriptions", () => {
|
||||
const model = catalogModelFor({
|
||||
Providers: [
|
||||
{
|
||||
modelDescriptions: {
|
||||
"MODEL-A": "Fast sidecar model for simple code search."
|
||||
},
|
||||
models: ["model-a"],
|
||||
name: "Custom",
|
||||
type: "openai_chat_completions"
|
||||
}
|
||||
]
|
||||
}, "Custom/model-a");
|
||||
|
||||
assert.equal(model.description, "Fast sidecar model for simple code search.");
|
||||
});
|
||||
|
||||
test("codex catalog uses model catalog capabilities for known text models", () => {
|
||||
const model = catalogModelFor({
|
||||
Providers: [
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
prepareCodexMultiAgentBridgeRequest,
|
||||
transformCodexMultiAgentBridgeResponseValue,
|
||||
transformCodexMultiAgentBridgeSseEvent
|
||||
} from "@ccr/core/gateway/service.ts";
|
||||
|
||||
const config = {
|
||||
Providers: [],
|
||||
Router: {
|
||||
builtInRules: {
|
||||
"claude-code": { enabled: true },
|
||||
codex: { enabled: true }
|
||||
},
|
||||
fallback: { mode: "off", models: [], retryCount: 1 },
|
||||
rules: []
|
||||
}
|
||||
};
|
||||
|
||||
function multiAgentNamespaceTool() {
|
||||
return {
|
||||
type: "namespace",
|
||||
name: "multi_agent_v1",
|
||||
description: "Tools for spawning and managing sub-agents.",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
name: "spawn_agent",
|
||||
description: "Spawn a sub-agent.",
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string" },
|
||||
model: { type: "string" }
|
||||
},
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
name: "wait_agent",
|
||||
description: "Wait for agents.",
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
targets: { type: "array", items: { type: "string" } }
|
||||
},
|
||||
required: ["targets"],
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
test("Codex multi-agent bridge expands namespace tools for non-GPT models", () => {
|
||||
const result = prepareCodexMultiAgentBridgeRequest({
|
||||
body: Buffer.from(JSON.stringify({
|
||||
input: [
|
||||
{
|
||||
arguments: JSON.stringify({ message: "inspect tests" }),
|
||||
call_id: "call_agent",
|
||||
name: "spawn_agent",
|
||||
namespace: "multi_agent_v1",
|
||||
type: "function_call"
|
||||
}
|
||||
],
|
||||
model: "Provider/claude-sonnet",
|
||||
parallel_tool_calls: true,
|
||||
tool_choice: { type: "tool", name: "multi_agent_v1" },
|
||||
tools: [
|
||||
{ type: "function", name: "exec_command" },
|
||||
multiAgentNamespaceTool()
|
||||
]
|
||||
})),
|
||||
config,
|
||||
headers: { "user-agent": "codex-test" },
|
||||
method: "POST",
|
||||
path: "/v1/responses"
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
const body = JSON.parse(result.body.toString("utf8"));
|
||||
assert.deepEqual(
|
||||
body.tools.map((tool) => `${tool.type}:${tool.name}`),
|
||||
["function:exec_command", "function:multi_agent_v1_spawn_agent", "function:multi_agent_v1_wait_agent"]
|
||||
);
|
||||
assert.match(body.tools[1].description, /multi_agent_v1\.spawn_agent/);
|
||||
assert.equal(body.tool_choice, undefined);
|
||||
assert.equal(body.parallel_tool_calls, true);
|
||||
assert.equal(body.input[0].name, "multi_agent_v1_spawn_agent");
|
||||
assert.equal(body.input[0].namespace, undefined);
|
||||
});
|
||||
|
||||
test("Codex multi-agent bridge leaves GPT models untouched", () => {
|
||||
const result = prepareCodexMultiAgentBridgeRequest({
|
||||
body: Buffer.from(JSON.stringify({
|
||||
model: "openai/gpt-5-codex",
|
||||
tools: [multiAgentNamespaceTool()]
|
||||
})),
|
||||
config,
|
||||
headers: { "user-agent": "codex-test" },
|
||||
method: "POST",
|
||||
path: "/v1/responses"
|
||||
});
|
||||
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
test("Codex multi-agent bridge rewrites flattened function response items to namespace calls", () => {
|
||||
const result = transformCodexMultiAgentBridgeResponseValue({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
arguments: JSON.stringify({ message: "inspect tests" }),
|
||||
call_id: "call_agent",
|
||||
name: "multi_agent_v1_spawn_agent",
|
||||
type: "function_call"
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.value.item, {
|
||||
arguments: JSON.stringify({ message: "inspect tests" }),
|
||||
call_id: "call_agent",
|
||||
name: "spawn_agent",
|
||||
namespace: "multi_agent_v1",
|
||||
type: "function_call"
|
||||
});
|
||||
});
|
||||
|
||||
test("Codex multi-agent bridge rewrites flattened function SSE events", () => {
|
||||
const event = transformCodexMultiAgentBridgeSseEvent([
|
||||
"event: response.output_item.done",
|
||||
`data: ${JSON.stringify({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
arguments: JSON.stringify({ targets: ["agent-1"] }),
|
||||
call_id: "call_wait",
|
||||
name: "multi_agent_v1_wait_agent",
|
||||
type: "function_call"
|
||||
}
|
||||
})}`
|
||||
].join("\n"));
|
||||
|
||||
assert.match(event, /^event: response\.output_item\.done\n/);
|
||||
const data = JSON.parse(event.split("\ndata: ")[1]);
|
||||
assert.equal(data.item.type, "function_call");
|
||||
assert.equal(data.item.name, "wait_agent");
|
||||
assert.equal(data.item.namespace, "multi_agent_v1");
|
||||
});
|
||||
Reference in New Issue
Block a user