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
9 changes: 2 additions & 7 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AdapterRequest, ProviderAdapter } from "./base";
import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types";
import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types";
import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort";
import { debugProviderDiagnostic } from "../lib/debug";
import { sseFieldValue } from "../lib/sse-decoder";
Expand Down Expand Up @@ -612,12 +612,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown

function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
const allowed = isAllowedToolChoice(parsed.options.toolChoice)
? new Set(parsed.options.toolChoice.allowedTools)
: undefined;
const tools = allowed
? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed))
: parsed.context.tools;
const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice));
if (tools.length === 0) return undefined;
const xaiTarget = isXaiSchemaTarget(provider);
const formatted = tools.flatMap(t => {
Expand Down
13 changes: 2 additions & 11 deletions src/adapters/tool-catalog-nudge.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import {
isAllowedToolChoice,
namespacedToolName,
toolAllowedByChoice,
toolChoiceAliases,
toolChoiceToolPredicate,
type OcxRequestOptions,
type OcxTool,
type OcxProviderConfig,
Expand All @@ -18,13 +16,6 @@ function uniqueNames(names: readonly string[]): string[] {
return [...new Set(names.filter(name => name.trim().length > 0))];
}

function toolChoiceAllows(tool: Pick<OcxTool, "namespace" | "name">, toolChoice: OcxRequestOptions["toolChoice"] | undefined): boolean {
if (!toolChoice || toolChoice === "auto" || toolChoice === "required") return true;
if (toolChoice === "none") return false;
if (isAllowedToolChoice(toolChoice)) return toolAllowedByChoice(tool, new Set(toolChoice.allowedTools));
return toolChoiceAliases(tool).includes(toolChoice.name);
}

function isOpenAIOrChatGPTHost(hostname: string): boolean {
return hostname === "openai.com"
|| hostname.endsWith(".openai.com")
Expand Down Expand Up @@ -65,7 +56,7 @@ export function buildNonOpenAIToolCatalogNudgeForTools(
toWireName: (tool: Pick<OcxTool, "namespace" | "name">) => string = tool => namespacedToolName(tool.namespace, tool.name),
): string | undefined {
const visibleNames = tools
?.filter(tool => toolChoiceAllows(tool, toolChoice))
?.filter(toolChoiceToolPredicate(toolChoice))
.map(toWireName);
return buildNonOpenAIToolCatalogNudgeFromNames(visibleNames);
}
4 changes: 3 additions & 1 deletion src/images/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { existsSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { createAdapterEventQueue } from "../adapters/run-turn-queue";
import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderContinuationState, OcxRequestOptions, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types";
import { namespacedToolName } from "../types";
import { namespacedToolName, toolChoiceToolPredicate } from "../types";
import type { AttemptRecoveryKind } from "../usage/log";
import { bridgeToResponsesSSE } from "../bridge";
import { clearableDeadline, idleDeadline } from "../lib/abort";
Expand Down Expand Up @@ -662,7 +662,9 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
const toolNsMap = new Map<string, { namespace: string; name: string }>();
const freeform = new Set<string>();
const toolSearch = new Set<string>();
const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice);
for (const t of parsed.context.tools ?? []) {
if (!toolAllowed(t)) continue;
if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name });
if (t.freeform) freeform.add(t.name);
if (t.toolSearch) toolSearch.add(t.name);
Expand Down
36 changes: 23 additions & 13 deletions src/images/plan.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
import { toolChoiceToolPredicate } from "../types";
import type { ImageBridgePlan, VideoBridgePlan } from "./types";
import { resolveEnvValue } from "../config";
import { getProviderRegistryEntry } from "../providers/registry";
Expand Down Expand Up @@ -46,6 +47,12 @@ export async function planImageBridge(
): Promise<ImageBridgePlan | undefined> {
if (config.images?.bridgeEnabled !== true) return undefined;
if (!parsed._imageGeneration) return undefined;
const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice);
const toolNames = new Set(
[...parsed._imageGeneration.toolNames, IMAGE_GEN_TOOL_NAME]
.filter(name => toolAllowed({ name })),
);
if (toolNames.size === 0) return undefined;
// Don't intercept for OpenAI native passthrough
const host = (() => { try { return new URL(routedProvider.baseUrl).hostname; } catch { return ""; } })();
if (host === "api.openai.com") return undefined;
Expand All @@ -57,9 +64,7 @@ export async function planImageBridge(
const registryEntry = getProviderRegistryEntry("xai");
const pinnedBaseUrl = (registryEntry?.baseUrl ?? "https://api.x.ai/v1").replace(/\/+$/, "");
// The synthetic tool injected into the conversation is named IMAGE_GEN_TOOL_NAME,
// which is what the model will actually call. Merge it with any original hosted tool names.
const toolNames = new Set(parsed._imageGeneration.toolNames);
toolNames.add(IMAGE_GEN_TOOL_NAME);
// which is what the model will actually call. toolNames also retains authorized hosted aliases.
const original = parsed._imageGeneration.originalTool;
const hostedSize = typeof original?.size === "string" ? original.size : undefined;
const hostedQuality = typeof original?.quality === "string" ? original.quality : undefined;
Expand Down Expand Up @@ -95,16 +100,6 @@ export async function planVideoBridge(
routedProvider: OcxProviderConfig,
): Promise<VideoBridgePlan | undefined> {
if (config.images?.videoBridgeEnabled !== true) return undefined;
// Don't intercept for OpenAI native passthrough
const host = (() => { try { return new URL(routedProvider.baseUrl).hostname; } catch { return ""; } })();
if (host === "api.openai.com") return undefined;
const found = findXaiProvider(config);
if (!found) return undefined;
const token = resolveXaiImageApiKey(found.provider);
if (!token) return undefined;
// Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override.
const registryEntry = getProviderRegistryEntry("xai");
const pinnedBaseUrl = (registryEntry?.baseUrl ?? "https://api.x.ai/v1").replace(/\/+$/, "");
const toolNames = new Set<string>();
toolNames.add(VIDEO_GEN_TOOL_NAME);
// Collect any existing function tools whose name matches a video_gen alias
Expand All @@ -118,6 +113,21 @@ export async function planVideoBridge(
toolNames.add(fnName);
}
}
const toolAllowed = toolChoiceToolPredicate(parsed.options?.toolChoice);
for (const name of toolNames) {
if (!toolAllowed({ name })) toolNames.delete(name);
}
if (toolNames.size === 0) return undefined;
// Don't intercept for OpenAI native passthrough
const host = (() => { try { return new URL(routedProvider.baseUrl).hostname; } catch { return ""; } })();
if (host === "api.openai.com") return undefined;
const found = findXaiProvider(config);
if (!found) return undefined;
const token = resolveXaiImageApiKey(found.provider);
if (!token) return undefined;
// Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override.
const registryEntry = getProviderRegistryEntry("xai");
const pinnedBaseUrl = (registryEntry?.baseUrl ?? "https://api.x.ai/v1").replace(/\/+$/, "");
const timeoutMs = clampImageTimeoutMs(config.images?.videoTimeoutMs);
const keepRaw = config.images?.artifactsKeepCount;
const artifactsKeepCount =
Expand Down
5 changes: 4 additions & 1 deletion src/server/responses/collaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
} from "../../combos";
import { isInjectionDebugEnabled } from "../../lib/debug-settings";
import { injectionDebugLog } from "../../lib/injection-debug-log";
import { modelInList, namespacedToolName } from "../../types";
import { modelInList, namespacedToolName, toolChoiceToolPredicate } from "../../types";
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
import {
forceRefreshOAuthAccessSnapshot,
Expand Down Expand Up @@ -108,7 +108,10 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
const toolNsMap = new Map<string, { namespace: string; name: string }>();
const freeformToolNames = new Set<string>();
const toolSearchToolNames = new Set<string>();
const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice);
for (const t of parsed.context.tools ?? []) {
// Upstream output is untrusted: only restore calls for tools the caller authorized.
if (!toolAllowed(t)) continue;
if (t.namespace) {
const wireName = namespacedToolName(t.namespace, t.name);
budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([wireName, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
Expand Down
13 changes: 13 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,19 @@ export function isAllowedToolChoice(value: OcxToolChoice | undefined): value is
return typeof value === "object" && value !== null && "allowedTools" in value;
}

/** Compile the request's tool-choice policy into a reusable advertisement/restoration predicate. */
export function toolChoiceToolPredicate(
choice: OcxToolChoice | undefined,
): (tool: Pick<OcxTool, "namespace" | "name">) => boolean {
if (!choice || choice === "auto" || choice === "required") return () => true;
if (choice === "none") return () => false;
if (isAllowedToolChoice(choice)) {
const allowed = new Set(choice.allowedTools);
return tool => toolAllowedByChoice(tool, allowed);
}
return tool => toolChoiceAliases(tool).includes(choice.name);
}

export interface OcxRequestOptions {
maxOutputTokens?: number;
temperature?: number;
Expand Down
6 changes: 4 additions & 2 deletions src/web-search/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
import { modelInList } from "../types";
import { modelInList, toolChoiceToolPredicate } from "../types";
import type { SidecarSettings } from "./executor";
import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
import { getAccountSet } from "../oauth/store";
import { DEFAULT_STALL_TIMEOUT_SEC } from "../stall-timeout";
import { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";

export { runWithWebSearch } from "./loop";
export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME };
export { runAnthropicWebSearch, parseAnthropicSidecarSSE } from "./anthropic-executor";

const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna";
Expand Down Expand Up @@ -146,6 +147,7 @@ export function planWebSearch(
openAiSidecar?: ResolvedOpenAiForwardSidecar,
): SidecarPlan | undefined {
if (!parsed._webSearch || isPassthrough) return undefined;
if (!toolChoiceToolPredicate(parsed.options.toolChoice)(buildWebSearchTool())) return undefined;
const cfg = config.webSearchSidecar ?? {};
if (cfg.enabled === false) return undefined;
const timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
Expand Down
4 changes: 3 additions & 1 deletion src/web-search/loop.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "../adapters/base";
import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types";
import { namespacedToolName } from "../types";
import { namespacedToolName, toolChoiceToolPredicate } from "../types";
import type { AttemptRecoveryKind } from "../usage/log";
import { bridgeToResponsesSSE } from "../bridge";
import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
Expand Down Expand Up @@ -693,7 +693,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
const toolNsMap = new Map<string, { namespace: string; name: string }>();
const freeform = new Set<string>();
const toolSearch = new Set<string>();
const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice);
for (const t of parsed.context.tools ?? []) {
if (!toolAllowed(t)) continue;
if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name });
if (t.freeform) freeform.add(t.name);
if (t.toolSearch) toolSearch.add(t.name);
Expand Down
28 changes: 28 additions & 0 deletions tests/images/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,34 @@ describe("planImageBridge", () => {
expect(plan!.auth.baseUrl).toBe("https://api.x.ai/v1");
});

test("tool_choice cannot arm an excluded image sidecar", async () => {
const cfg = makeConfig({ xai: { baseUrl: "https://api.x.ai", apiKey: "test-token" } }, { bridgeEnabled: true });
const parsed = makeParsed(true);

parsed.options.toolChoice = "none";
expect(await planImageBridge(cfg, parsed, routed)).toBeUndefined();
parsed.options.toolChoice = { name: "read_file" };
expect(await planImageBridge(cfg, parsed, routed)).toBeUndefined();
parsed.options.toolChoice = { allowedTools: ["read_file"], mode: "required" };
expect(await planImageBridge(cfg, parsed, routed)).toBeUndefined();

parsed.options.toolChoice = { name: "image_gen" };
expect(await planImageBridge(cfg, parsed, routed)).toBeDefined();

parsed._imageGeneration?.toolNames.add("generate_image");
parsed.options.toolChoice = { name: "image_gen" };
const canonicalPlan = await planImageBridge(cfg, parsed, routed);
expect(canonicalPlan).toBeDefined();
expect(canonicalPlan!.toolNames.has("image_gen")).toBe(true);
expect(canonicalPlan!.toolNames.has("generate_image")).toBe(false);

parsed.options.toolChoice = { name: "generate_image" };
const aliasPlan = await planImageBridge(cfg, parsed, routed);
expect(aliasPlan).toBeDefined();
expect(aliasPlan!.toolNames.has("image_gen")).toBe(false);
expect(aliasPlan!.toolNames.has("generate_image")).toBe(true);
});

test("xAI provider with OAuth only (no API key) → undefined (API-key-only bridge)", async () => {
tokenResult = "fake-oauth-123";
const cfg = makeConfig({ xai: { baseUrl: "https://api.x.ai" } }, { bridgeEnabled: true });
Expand Down
61 changes: 50 additions & 11 deletions tests/reasoning-effort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,28 @@ describe("provider-specific reasoning effort mapping", () => {
expect(body).not.toHaveProperty("tool_choice");
});

test("OpenAI-compatible chat keeps tool_choice when tools are present", () => {
test("OpenAI-compatible chat omits tools and tool_choice when tool_choice is none", () => {
const provider: OcxProviderConfig = {
adapter: "openai-chat",
baseUrl: "https://api.neuralwatt.com/v1",
};

const req = createOpenAIChatAdapter(provider).buildRequest({
modelId: "glm-5.2",
context: {
messages: [{ role: "user", content: "hello", timestamp: 0 }],
tools: [{ name: "read_secret", description: "Read", parameters: { type: "object" } }],
},
stream: false,
options: { toolChoice: "none" },
});
const body = JSON.parse(req.body as string) as Record<string, unknown>;

expect(body).not.toHaveProperty("tools");
expect(body).not.toHaveProperty("tool_choice");
});

test("OpenAI-compatible chat advertises only the named tool when the provider downgrades the selector", () => {
const provider: OcxProviderConfig = {
adapter: "openai-chat",
baseUrl: "https://api.moonshot.ai/v1",
Expand All @@ -340,14 +361,20 @@ describe("provider-specific reasoning effort mapping", () => {
modelId: "kimi-k2.7-code",
context: {
messages: [{ role: "user", content: "hello", timestamp: 0 }],
tools: [{ name: "run_tests", description: "Run tests", parameters: { type: "object", properties: {} } }],
tools: [
{ name: "run_tests", description: "Run tests", parameters: { type: "object", properties: {} } },
{ name: "read_secret", description: "Read", parameters: { type: "object", properties: {} } },
],
},
stream: false,
options: { toolChoice: { name: "run_tests" } },
});
const body = JSON.parse(req.body as string) as Record<string, unknown>;
const body = JSON.parse(req.body as string) as {
tools: Array<{ function: { name: string } }>;
tool_choice: string;
};

expect(body).toHaveProperty("tools");
expect(body.tools.map(tool => tool.function.name)).toEqual(["run_tests"]);
expect(body.tool_choice).toBe("auto");
});

Expand Down Expand Up @@ -411,18 +438,30 @@ describe("provider-specific reasoning effort mapping", () => {
modelId: "umans-kimi-k2.7",
context: {
messages: [{ role: "user", content: "run it", timestamp: 0 }],
tools: [{
namespace: "functions",
name: "exec_command",
description: "Run a command",
parameters: { type: "object", properties: { cmd: { type: "string" } }, required: ["cmd"] },
}],
tools: [
{
namespace: "functions",
name: "exec_command",
description: "Run a command",
parameters: { type: "object", properties: { cmd: { type: "string" } }, required: ["cmd"] },
},
{
namespace: "mcp__secrets",
name: "read_secret",
description: "Read",
parameters: { type: "object" },
},
],
},
stream: false,
options: { toolChoice: { name: "functions.exec_command" } },
});
const body = JSON.parse(req.body as string) as { tool_choice: { function: { name: string } } };
const body = JSON.parse(req.body as string) as {
tools: Array<{ function: { name: string } }>;
tool_choice: { function: { name: string } };
};

expect(body.tools.map(tool => tool.function.name)).toEqual(["functions__exec_command"]);
expect(body.tool_choice.function.name).toBe("functions__exec_command");
});

Expand Down
Loading
Loading