Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
31 changes: 18 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,10 @@ export async function planImageBridge(
): Promise<ImageBridgePlan | undefined> {
if (config.images?.bridgeEnabled !== true) return undefined;
if (!parsed._imageGeneration) return undefined;
const toolNames = new Set(parsed._imageGeneration.toolNames);
toolNames.add(IMAGE_GEN_TOOL_NAME);
const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice);
if (![...toolNames].some(name => toolAllowed({ name }))) return undefined;
Comment thread
luvs01 marked this conversation as resolved.
Outdated
// 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 +62,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 +98,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 +111,18 @@ export async function planVideoBridge(
toolNames.add(fnName);
}
}
const toolAllowed = toolChoiceToolPredicate(parsed.options?.toolChoice);
if (![...toolNames].some(name => toolAllowed({ name }))) 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
19 changes: 19 additions & 0 deletions tests/images/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,25 @@ 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: "generate_image" };
expect(await planImageBridge(cfg, parsed, routed)).toBeDefined();
});

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