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
30 changes: 30 additions & 0 deletions design-debt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Scoped design debt

This audit covers the Meta Muse Responses tool-choice change only. All three modules below were swept against the design-review flags. It records confirmed findings only.

## Inventory

| Module | Role | Review |
| --- | --- | --- |
| `src/adapters/openai-responses/muse-tool-choice.ts` | Validates the caller's original and effective selector, then normalizes Muse's final request body. | Swept against the design-review flags. No confirmed findings. |
| `src/adapters/openai-responses/passthrough.ts` | Applies the normalizer at the final Meta Responses body boundary. | Full module swept. No confirmed findings. |
| `src/server/responses/passthrough-dispatch.ts` | Maps the compatibility error to an initial HTTP 400 before dispatch. | Full module swept. No confirmed findings. |

Provider registry and configuration modules are excluded. The selected design adds no provider
flag or persisted setting. Other adapters, transports, and request-selection paths are outside this
behavior's scope.

## Confirmed findings

| ID | Severity | Flag | Evidence | Smallest redesign | Status |
| --- | --- | --- | --- | --- | --- |
| - | - | - | No confirmed findings in the three-module sweep. | - | - |

Severity counts are S1: 0, S2: 0, S3: 0. No findings were refuted because none were raised. No
security findings are recorded here.

Audit date: 2026-09-27. Modules swept: 3. Modules inconclusive: 0.

The nose comparison covered 60 files in Responses normalization and provider registry code.
It retained 109 duplication families, with no new family. One recheck contained two unchanged
streaming accumulator regions whose line locations moved. Both regions were compared with `origin/dev`.
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ko/reference/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ macOS에서는 `muse login` 뒤 Muse Code CLI가 이미 저장한 API 키를 ope

다른 플랫폼에서는 키를 붙여넣도록 요청합니다. Meta는 네이티브 Windows CLI를 제공하지 않습니다. Linux에는 CLI가 있지만 자격 증명을 저장하는 위치가 검증되지 않아 opencodex가 저장소를 추측하지 않습니다. 같은 키는 [Meta 개발자 콘솔](https://dev.meta.ai)에서도 볼 수 있습니다. 붙여넣은 키도 가져온 키와 똑같은 형식 검사와 Model API에 대한 실시간 검증을 거칩니다.

Meta로 보내는 Responses 요청은 tool 선택을 생략하거나 `auto`로 지정할 수 있습니다. 명시적인 `none`은 `tools: []`로 보내고 `input` 안의 `additional_tools` 항목을 제거합니다. 강제 선택, 함수 이름 지정, `allowed_tools` 선택은 Muse가 `auto`만 지원하므로 Meta로 보내기 전에 HTTP 400으로 거부합니다. 다른 Responses 대상의 tool 선택 동작은 바뀌지 않습니다.

## Windows 참고 사항

Windows 서비스는 Task Scheduler 또는 네이티브 WinSW 서비스로 실행할 수 있으며 둘을 동시에 사용할 수는 없습니다. `ocx service repair`가 두 방식의 상태를 모두 발견하면 진행을 거부합니다. 어느 쪽을 원하는지 추측하면 한 컴퓨터에서 두 프록시가 같은 포트를 놓고 충돌할 수 있기 때문입니다.
Expand Down
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/reference/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ offers an input surface. Pasted keys use the same format and Model API validatio
as imported ones. Management login requires a dashboard session before either
credential-acquisition path; see the [provider guide](/guides/providers/).

For Responses requests to Meta, omitted or `auto` tool selection is supported.
Explicit `none` sends `tools: []` and removes `additional_tools` items from the
input. Forced, named, and `allowed_tools` selections return HTTP 400 before the
request reaches Meta because Muse accepts only `auto`. Other Responses
destinations keep their existing tool-selection behavior.

## Windows notes

The Windows service can run under Task Scheduler or as a native WinSW service,
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 @@ -1558,6 +1558,7 @@
"responses-json-events.test.ts": "responses",
"responses-legacy-dotted-tool-name-repair.test.ts": "responses",
"responses-muse-tool-name-alias.test.ts": "responses",
"responses-muse-tool-choice.test.ts": "responses",
"responses-native-main-refresh.test.ts": "responses",
"responses-opaque-blob-recovery.test.ts": "responses",
"responses-parser-agent-message.test.ts": "responses",
Expand Down
31 changes: 31 additions & 0 deletions src/adapters/openai-responses/muse-tool-choice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { isPlainObject } from "./internal";

export class MuseToolChoiceCompatibilityError extends Error {
constructor() {
super("This tool_choice cannot be preserved for Meta Responses; use auto or none.");
this.name = "MuseToolChoiceCompatibilityError";
}
}

function selectorKind(choice: unknown): "auto" | "none" {
if (choice === undefined || choice === "auto") return "auto";
if (choice === "none") return "none";
throw new MuseToolChoiceCompatibilityError();
}

export function normalizeMuseToolChoice(body: unknown, originalChoice: unknown): unknown {
const originalKind = selectorKind(originalChoice);
const effectiveKind = selectorKind(isPlainObject(body) ? body.tool_choice : undefined);
if (originalKind !== "none" && effectiveKind !== "none") return body;
if (!isPlainObject(body)) return body;

const input = Array.isArray(body.input)
? body.input.filter(item => !isPlainObject(item) || item.type !== "additional_tools")
: body.input;
const {
tool_choice: _toolChoice,
parallel_tool_calls: _parallelToolCalls,
...rest
} = body;
return { ...rest, tools: [], ...(input !== body.input ? { input } : {}) };
}
7 changes: 6 additions & 1 deletion src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { applyTierDecisionToResponsesBody, normalizeCanonicalForwardContinuation
import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./image-gen";
import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search";
import { observeOutbound } from "../../usage/cache-diagnostic";
import { normalizeMuseToolChoice } from "./muse-tool-choice";

/**
* Identifies DeepSeek's strict Responses replay contract: tool-bearing continuations need
Expand Down Expand Up @@ -495,14 +496,18 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
parsed.modelId,
);
// Normalize the wire model before deriving model-dependent transport metadata.
const finalBody =
let finalBody =
provider.modelSuffixBracketStrip
&& unnormalizedBody !== null
&& typeof unnormalizedBody === "object"
&& !Array.isArray(unnormalizedBody)
&& typeof (unnormalizedBody as { model?: unknown }).model === "string"
? { ...(unnormalizedBody as Record<string, unknown>), model: stripBracketedModelSuffix((unnormalizedBody as { model: string }).model) }
: unnormalizedBody;
if (isMetaAiResponsesDestination(url)) {
const originalChoice = isPlainObject(parsed._rawBody) ? parsed._rawBody.tool_choice : undefined;
finalBody = normalizeMuseToolChoice(finalBody, originalChoice);
}
if (isCanonicalOpenAiForwardProvider(provider)) {
const routingHeaders = new Headers(headers);
applyCodexRoutingHint(routingHeaders, finalBody);
Expand Down
2 changes: 2 additions & 0 deletions src/server/responses/passthrough-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
} from "../../responses/namespace-tool-compat";
import { restoreRoutedCustomCalls, RoutedCustomToolCompatError } from "../../responses/custom-tool-compat";
import { XaiToolSchemaCompatibilityError } from "../../adapters/xai-tool-schema";
import { MuseToolChoiceCompatibilityError } from "../../adapters/openai-responses/muse-tool-choice";
import { formatErrorResponse } from "../../bridge";
import { redactSecretString } from "../../lib/redact";
import {
Expand Down Expand Up @@ -337,6 +338,7 @@ export async function preparePassthroughExchange(
error instanceof NamespaceToolCollisionError
|| error instanceof XaiToolSchemaCompatibilityError
|| error instanceof RoutedCustomToolCompatError
|| error instanceof MuseToolChoiceCompatibilityError
) {
return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message));
}
Expand Down
12 changes: 12 additions & 0 deletions structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,18 @@ this field in the tree, so a value that survives file load still cannot be spent
silently by design; config-time is where the operator is told why. The planner requires the
provider name for that assessment, so `planPassthroughWebSearchBridge` takes it explicitly.

## Meta Responses tool selection

At the final request boundary for `api.meta.ai`, `src/adapters/openai-responses/passthrough.ts`
uses `src/adapters/openai-responses/muse-tool-choice.ts` to normalize `tool_choice`. Omitted or
`auto` selection keeps its meaning. Explicit `none` sets `tools` to an empty list, removes
`additional_tools` items from `input`, and omits `tool_choice` and `parallel_tool_calls` from the
outgoing body. Forced, named, and `allowed_tools` selections fail with HTTP 400 before the
upstream send because Muse supports only `auto`. Filtering a required tool never changes the
caller's obligation into `none` or `auto`. This rule applies only to the Meta Responses
destination. The input body, historical tool calls and results, and non-Meta requests keep their
existing meaning.

## Shared type declarations

`src/types/` holds the declarations every layer imports: config types (`src/types/config.ts`),
Expand Down
2 changes: 1 addition & 1 deletion structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ echoed bare name to its namespaced identity before authorizing anything
echo is a guess rather than a nomination. The bridges check the declared set before consulting
`toolNsMap`, so there a bare helper echo is refused either way. A genuine namespace-free
declaration is untouched throughout: that is the caller declaring the tool, not a namespace being
discarded to manufacture a bare name.
discarded to manufacture a bare name. Meta Responses also applies [tool-selection compatibility](../providers-and-adapters.md#meta-responses-tool-selection).

Function-call wrappers around freeform bodies are restored by
`src/responses/apply-patch-envelope.ts`. The declared `input` field is authoritative. For bare
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -1384,6 +1384,7 @@
"responses-json-events.test.ts": "responses",
"responses-legacy-dotted-tool-name-repair.test.ts": "responses",
"responses-muse-tool-name-alias.test.ts": "responses",
"responses-muse-tool-choice.test.ts": "responses",
"responses-native-main-refresh.test.ts": "responses",
"responses-opaque-blob-recovery.test.ts": "responses",
"responses-parser-agent-message.test.ts": "responses",
Expand Down
6 changes: 3 additions & 3 deletions tests/providers/muse-tool-name-alias.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ describe("#4410 Meta Muse 64-char tool-name aliasing", () => {
expect(aliases?.size).toBe(20);
});

test("history function_call and tool_choice are aliased on api.meta.ai", () => {
test("history function_call is aliased while auto tool_choice remains unchanged", () => {
const longName = LONG_ISSUE_NAMES[0]!;
const wire = hashedName(longName);
const { body, aliases } = buildForProvider(META_PROVIDER, "muse-spark-1.3-contributor", {
Expand All @@ -133,15 +133,15 @@ describe("#4410 Meta Muse 64-char tool-name aliasing", () => {
{ type: "function_call", name: longName, call_id: "c1", arguments: "{\"q\":\"hub\"}" },
{ type: "function_call_output", call_id: "c1", output: "ok" },
],
tool_choice: { type: "function", name: longName },
tool_choice: "auto",
});
expect((body.tools as Array<{ name: string }>)[0]!.name).toBe(wire);
expect((body.input as Array<Record<string, unknown>>)[0]).toMatchObject({
type: "function_call",
name: wire,
arguments: "{\"q\":\"hub\"}",
});
expect((body.tool_choice as { name: string }).name).toBe(wire);
expect(body.tool_choice).toBe("auto");
expect(aliases?.get(wire)).toBe(longName);
});

Expand Down
195 changes: 195 additions & 0 deletions tests/responses/responses-muse-tool-choice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { afterEach, describe, expect, test } from "bun:test";
import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses";
import { handleResponses, handleResponsesCompact } from "../../src/server/responses";
import type { OcxConfig, OcxProviderConfig } from "../../src/types";
import { acquireOwnedSpendHome } from "../helpers/owned-spend-home";
import { withTestTranslatorBudget } from "../helpers/translator-budget";

const createAdapter = (...args: Parameters<typeof createResponsesPassthroughAdapterProduction>) =>
withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args));

const meta = { adapter: "openai-responses", baseUrl: "https://api.meta.ai/v1", apiKey: "test-key" } as OcxProviderConfig;
const xai = { ...meta, baseUrl: "https://api.x.ai/v1" } as OcxProviderConfig;
const longName = "mcp__plugin_huggingface-skills_huggingface-skills__hub_repo_search";
const longWireName = "mcp__plugin_huggingface-skills_huggingface-skills__hub__dec57ce4";
const tool = { type: "function", name: longName, parameters: { type: "object", properties: {} } };
let releaseSpendHome: (() => void) | undefined;

afterEach(() => {
releaseSpendHome?.();
releaseSpendHome = undefined;
});

function build(provider: OcxProviderConfig, rawBody: Record<string, unknown>) {
return createAdapter(provider).buildRequest({
modelId: "muse-spark-1.3",
context: { messages: [] },
stream: false,
options: {},
_rawBody: { model: "muse-spark-1.3", input: "continue", ...rawBody },
}, { headers: new Headers() });
}

describe("Meta Muse tool choice", () => {
test("none removes declaration carriers while preserving history and caller input", () => {
const raw = {
input: [
{ type: "function_call", name: longName, call_id: "c1", arguments: "{}" },
{ type: "function_call_output", call_id: "c1", output: "done" },
{ type: "additional_tools", tools: [tool] },
],
tools: [tool],
tool_choice: "none",
parallel_tool_calls: true,
};
const original = structuredClone(raw);
const body = JSON.parse(build(meta, raw).body) as Record<string, unknown>;

expect(body.tools).toEqual([]);
expect(body.input).toEqual([
{ ...original.input[0], name: longWireName },
original.input[1],
]);
expect(body).not.toHaveProperty("tool_choice");
expect(body).not.toHaveProperty("parallel_tool_calls");
expect(raw).toEqual(original);
});

test("auto and omitted choice remain unchanged", () => {
for (const choice of [undefined, "auto"] as const) {
const raw = { tools: [tool], ...(choice ? { tool_choice: choice } : {}) };
const original = structuredClone(raw);
const body = JSON.parse(build(meta, raw).body) as Record<string, unknown>;
expect(body.tools).toHaveLength(1);
expect((body.tools as Array<{ name: string }>)[0]!.name).toHaveLength(64);
expect(body.tool_choice).toBe(choice);
expect(raw).toEqual(original);
}
});

test("raw forced hosted choice fails if provider filtering removes its only tool", () => {
const provider = { ...meta, unsupportedHostedTools: ["web_search"] };
for (const choice of ["required", { type: "web_search" }]) {
expect(() => build(provider, {
tools: [{ type: "web_search" }],
tool_choice: choice,
})).toThrow("This tool_choice cannot be preserved for Meta Responses; use auto or none.");
}
});

test("allowed_tools, null and unknown selectors fail with the compatibility error", () => {
for (const choice of [
{ type: "allowed_tools", mode: "auto", tools: [tool] },
null,
{ type: "unknown" },
]) {
expect(() => build(meta, { tools: [tool], tool_choice: choice }))
.toThrow("This tool_choice cannot be preserved for Meta Responses; use auto or none.");
}
});

test("non-Meta Responses destinations retain tool choices", () => {
const raw = { tools: [tool], tool_choice: { type: "function", name: longName } };
const body = JSON.parse(build(xai, raw).body) as Record<string, unknown>;
expect(body.tool_choice).toEqual(raw.tool_choice);
expect(body.tools).toEqual(raw.tools);
});

test("compaction does not bypass forced-choice rejection for a namespaced tool", async () => {
const config = {
port: 0,
defaultProvider: "fixture",
providers: { fixture: { ...meta, authMode: "key" } },
} as OcxConfig;
const savedFetch = globalThis.fetch;
let sends = 0;
globalThis.fetch = (async () => {
sends += 1;
return Response.json({ id: "unexpected", status: "completed", output: [] });
}) as typeof fetch;
try {
releaseSpendHome ??= acquireOwnedSpendHome();
const response = await handleResponsesCompact(new Request("http://localhost/v1/responses/compact", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "fixture/muse-spark-1.3",
input: [{ type: "function_call", name: longName, call_id: "c1", arguments: "{}" }],
tools: [tool],
tool_choice: { type: "function", name: longName },
}),
}), config, { model: "", provider: "" });
expect(response.status).toBe(400);
const error = await response.json() as { error: { message: string } };
expect(error.error.message).toBe("This tool_choice cannot be preserved for Meta Responses; use auto or none.");
expect(sends).toBe(0);
} finally {
globalThis.fetch = savedFetch;
}
});

test("handleResponses returns a client 400 before any upstream send", async () => {
const config = {
port: 0,
defaultProvider: "fixture",
providers: { fixture: { ...meta, authMode: "key" } },
} as OcxConfig;
const savedFetch = globalThis.fetch;
let sends = 0;
globalThis.fetch = (async () => {
sends += 1;
return new Response("unexpected upstream send", { status: 200 });
}) as typeof fetch;
try {
releaseSpendHome ??= acquireOwnedSpendHome();
for (const toolChoice of ["required", { type: "function", name: longName }]) {
const response = await handleResponses(new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "fixture/muse-spark-1.3",
input: "use the selected tool",
tools: [tool],
tool_choice: toolChoice,
}),
}), config, { model: "", provider: "" });
expect(response.status).toBe(400);
const error = await response.json() as { error: { type: string; code: string; message: string } };
expect(error).toMatchObject({ error: { type: "invalid_request_error", code: "invalid_request_error" } });
expect(error.error.message).toBe("This tool_choice cannot be preserved for Meta Responses; use auto or none.");
}
expect(sends).toBe(0);
} finally {
globalThis.fetch = savedFetch;
}
});

test("none succeeds through handleResponses with no declared tools", async () => {
const config = {
port: 0,
defaultProvider: "fixture",
providers: { fixture: { ...meta, authMode: "key" } },
} as OcxConfig;
const savedFetch = globalThis.fetch;
let outbound: Record<string, unknown> | undefined;
globalThis.fetch = (async (_input, init) => {
outbound = JSON.parse(String(init?.body)) as Record<string, unknown>;
return new Response(JSON.stringify({ id: "resp_none", status: "completed", output: [] }), {
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
try {
releaseSpendHome ??= acquireOwnedSpendHome();
const response = await handleResponses(new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "fixture/muse-spark-1.3", input: "no tools", tools: [tool], tool_choice: "none" }),
}), config, { model: "", provider: "" });
expect(response.status).toBe(200);
expect(outbound?.tools).toEqual([]);
expect(outbound).not.toHaveProperty("tool_choice");
} finally {
globalThis.fetch = savedFetch;
}
});
});
Loading
Loading