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
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/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ working. OpenCodex only writes two variables into the `env` block of `~/.claude/

Claude Desktop first-party routes its Code tab and subagents through OpenCodex. The standalone Claude Code CLI has a separate first-party switch. Both clients read the same `~/.claude/settings.json` proxy and CA settings: if only one switch is on, the other client still transits the local proxy, where TLS terminates, but its Messages requests relay to Anthropic unchanged. Other Anthropic paths relay unchanged and unrelated hosts remain blind tunnels.

Subagents on routed (non-Claude) models do not use Claude Code's server-side message threads, because only Anthropic stores that state. OpenCodex declines a threaded request for such a model, and Claude Code resends that turn, and the turns after it, with the full conversation.

Mode is persisted as `claudeCode.desktopMode`. Installs that already applied either mode retain it,
including first-party installs from before the mode was persisted. An explicit mode takes priority;
otherwise an owned selected gateway row, an applied gateway fingerprint, or owned first-party
Expand Down
8 changes: 8 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,14 @@ those providers, but `ocx login codex --reauth` routes to their account-pool rea
the dashboard Codex account pool also performs. See
[`ocx status` / `ocx doctor`](/reference/cli/) in the CLI reference.

### Kiro request credits

When Kiro emits credit metering, request logs preserve the reported spend as
`usage.providerCredits`, including in the persisted usage ledger. These are Kiro credits;
token counts may still be estimated, and the credit value does not replace USD cost estimates.
Completion fallback requests add their reported credits. An absent value means Kiro did not
report credit usage; an explicit zero means it reported no spend.

### Kiro credential import

Kiro login expects the Kiro CLI: on Unix, install it with `curl -fsSL https://cli.kiro.dev/install | bash`;
Expand Down
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/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,12 @@ previous catalog from memory.

### `ocx service [install|repair|restart|start|stop|status|uninstall|remove]`

On Windows Task Scheduler, the service wrapper restarts the proxy after five seconds
even when an external tool terminates it with exit code 0. If another opencodex proxy
already owns the port, the wrapper exits deliberately. Use `ocx stop` or
`ocx service stop` to stop the service and its restart loop. After upgrading an
existing installation, run `ocx service repair` to refresh the generated wrapper.

Run opencodex as a login-managed background service (macOS **launchd**, Linux **systemd user unit**,
Windows **Task Scheduler**) that auto-starts on login and auto-restarts on crash. Service runs set
`OCX_SERVICE=1` so a restart does not churn the Codex config.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity
| `xaiResponsesXSearch?` | `boolean` | Disabled by default. On an xAI Responses destination, append the provider-hosted `x_search` declaration only when a live `web_search` tool survives final request normalization. Existing declarations are not duplicated, caller `tool_choice`/`allowed_tools` selectors are never widened, and this is separate from the web-search sidecar's `search.xSearch` options. |
| `modelPreferHostedTools?` | `Record<string,string[]>` | Exact-model opt-in for non-forward Responses gateways that reserve a hosted-tool namespace. Currently accepts only `["image_generation"]`; a matching model must use the `openai-responses` wire and support that hosted tool. It removes colliding client `image_gen` declarations and rewrites their selectors to preserve caller tool choice. For OpenAI API virtual `-pro` models, the selected public ID is matched first and the resolved base wire-model ID is a fallback. `modelAdapters` resolves the public ID first, then the base ID; the second resolution determines the final wire. Other models retain normal alias behavior. |
| `annotateEmptyToolOutputs?` | `boolean` | Replace a present-but-empty tool result with a short marker before it reaches the model, so a blank result is not read as a missing one. Applies to blank strings and text-only part arrays; image, file, and encrypted parts are never touched. Defaults to `true` for DeepSeek from the built-in registry and is otherwise unset. Set `false` to opt a provider out — an explicit `false` is preserved across later edits that omit the field. `PATCH /api/providers?name=<provider>` accepts `true`, `false`, or `null` to clear the override and return to registry-default behavior. |
| `unsupportedHostedTools?` | `string[]` | Hosted tool declarations this Responses destination rejects, so they are stripped from `tools`, from client-loaded `additional_tools`, and from `tool_choice` instead of being forwarded and rejected upstream. Use it for an OpenAI-compatible gateway with a narrower capability set — one that accepts plain Responses requests and `function` tools but returns HTTP 400 for hosted `web_search` — so a text-only prompt is not failed by a capability it never needed. Accepts only hosted tool type names (`web_search`, `web_search_preview`, `file_search`, `computer_use_preview`, `computer_use`, `code_interpreter`, `image_generation`, `image_gen`, `mcp`, `tool_search`, `local_shell`, `x_search`); an unrecognized name is rejected rather than silently ignored. Spelling variants of one capability are aliased, so `["web_search"]` also denies `web_search_preview`. A provider cannot both deny a hosted tool here and prefer it in `modelPreferHostedTools`. This is independent of `supportsResponsesCustomTools`; set both for a gateway that also rejects native custom tools. `PATCH /api/providers?name=<provider>` accepts an array or `null` to clear it. |
| `unsupportedHostedTools?` | `string[]` | Hosted tool declarations this Responses destination rejects, so they are stripped from `tools`, from client-loaded `additional_tools`, and from `tool_choice` instead of being forwarded and rejected upstream. Use it for an OpenAI-compatible gateway with a narrower capability set — one that accepts plain Responses requests and `function` tools but returns HTTP 400 for hosted `web_search` — so a text-only prompt is not failed by a capability it never needed. Accepts only hosted tool type names (`web_search`, `web_search_preview`, `file_search`, `computer_use_preview`, `computer_use`, `code_interpreter`, `image_generation`, `image_gen`, `mcp`, `tool_search`, `local_shell`, `x_search`); an unrecognized name is rejected rather than silently ignored. Spelling variants of one capability are aliased, so `["web_search"]` also denies `web_search_preview`. A provider cannot both deny a hosted tool here and prefer it in `modelPreferHostedTools`. Xiaomi MiMo Responses destinations (`xiaomimimo.com` and its subdomains, including `api.xiaomimimo.com` and `token-plan-cn.xiaomimimo.com`) automatically strip `web_search` and `web_search_preview` while preserving function tools; no declaration is needed for these hosts. This is independent of `supportsResponsesCustomTools`; set both for a gateway that also rejects native custom tools. `PATCH /api/providers?name=<provider>` accepts an array or `null` to clear it. |
| `reasoningEffortMap?` | `Record<string, string>` | Provider-wide wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely: `reasoning_effort` on an OpenAI-compatible wire, and Ollama's native `think` field on the Ollama native adapter (#2356). |
| `modelReasoningEffortMap?` | `Record<string, Record<string, string>>` | Per-model wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely. |
| `reasoningWireFormat?` | `"gateway-object"` | For OpenAI-compatible gateways that accept `reasoning: { enabled, effort }` instead of `reasoning_effort`. The ClinePass preset sets this automatically. A provider save that keeps the destination keeps it; see [What a provider save keeps](#what-a-provider-save-keeps). `PATCH` accepts `"gateway-object"` or `null` to clear it. |
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
4 changes: 4 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@
"claude-management-api.test.ts": "claude-integration",
"claude-manual-env.test.ts": "gui",
"claude-messages-endpoint.test.ts": "claude-integration",
"claude-messages-thread.test.ts": "claude-integration",
"messages-native.test.ts": "claude-integration",
"messages-native-decline-trace.test.ts": "claude-integration",
"messages-native-oauth.test.ts": "claude-integration",
Expand Down Expand Up @@ -1079,6 +1080,8 @@
"kiro-remote-image.test.ts": "providers/kiro",
"kiro-retry.test.ts": "providers/kiro",
"kiro-review-regressions.test.ts": "providers/kiro",
"kiro-metering-events.test.ts": "providers/kiro",
"kiro-metering-usage.test.ts": "providers/kiro",
"kiro-stream.test.ts": "providers/kiro",
"kiro-transport-parity.test.ts": "providers/kiro",
"kiro-usage-quota.test.ts": "providers/kiro",
Expand Down Expand Up @@ -1562,6 +1565,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
28 changes: 27 additions & 1 deletion src/adapters/kiro-events.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { OcxUsage } from "../types";
import { debugProviderDiagnostic } from "../lib/debug";
import { kiroTruncationReason } from "./kiro-truncation";

export type ParsedKiroEvent =
| { type: "content"; data?: string; modelId?: string }
| { type: "reasoning"; data?: string; signature?: string; redactedContent?: string }
| { type: "context_usage"; contextUsagePercentage: number }
| { type: "metering"; unit: string; usage: number; unitPlural?: string }
| { type: "tool"; name?: string; toolUseId?: string; input?: string; stop?: boolean }
| { type: "truncation"; data: string }
| { type: "metadata"; usage?: OcxUsage; contextUsagePercentage?: number; stopReason?: string }
Expand All @@ -17,10 +19,12 @@ const KNOWN_EVENT_TYPES = new Set([
"reasoningContentEvent",
"toolUseEvent",
"messageMetadataEvent",
"initial-response",
"metadataEvent",
// Authoritative context pressure. Every capture (kiro-cli 2.14.1 and 2.16.0) put the percentage
// HERE and left `metadataEvent` carrying only `stopReason`; metadataEvent's own
// contextUsagePercentage stays supported as a fallback rather than being dropped.
"meteringEvent",
"contextUsageEvent",
"invalidStateEvent",
"error",
Expand Down Expand Up @@ -111,7 +115,11 @@ function parseTokenUsage(eventType: string, value: unknown): OcxUsage | undefine
/** Decode a known Kiro event using its Smithy `:event-type` header. */
export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKiroEvent | null {
// Unknown event types are intentionally ignored without parsing or logging their payload.
if (!KNOWN_EVENT_TYPES.has(eventType)) return null;
if (!KNOWN_EVENT_TYPES.has(eventType)) {
// The Smithy header is upstream-controlled too; a raw value can contain private data.
debugProviderDiagnostic("kiro", "unknown_event", { eventTypeLength: eventType.length });
return null;
}
const parsed = parseObject(eventType, payload);
// A metadataEvent's `stopReason` is Kiro's own terminal verdict and must reach the parser
// intact. The generic truncation sniffer matches substrings ("max_tokens", "length",
Expand Down Expand Up @@ -174,7 +182,25 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi
? { stop: optionalBoolean(eventType, parsed, "stop") }
: {}),
};
case "meteringEvent": {
const unit = optionalString(eventType, parsed, "unit");
if (unit === undefined) {
return malformed(eventType, "unit must be a string");
}
const unitPlural = optionalString(eventType, parsed, "unitPlural");
const rawUsage = parsed.usage !== undefined ? parsed.usage : parsed.amount;
if (typeof rawUsage !== "number" || !Number.isFinite(rawUsage) || rawUsage < 0) {
return malformed(eventType, "usage must be a finite non-negative number");
}
return {
type: "metering",
unit,
usage: rawUsage,
...(unitPlural !== undefined ? { unitPlural } : {}),
};
}
case "messageMetadataEvent":
case "initial-response":
return {
type: "message_metadata",
conversationId:
Expand Down
11 changes: 10 additions & 1 deletion src/adapters/kiro/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ function mergeKiroUsage(
...(sumOptional("cachedInputTokens") !== undefined ? { cachedInputTokens: sumOptional("cachedInputTokens") } : {}),
...(sumOptional("cacheReadInputTokens") !== undefined ? { cacheReadInputTokens: sumOptional("cacheReadInputTokens") } : {}),
...(sumOptional("cacheCreationInputTokens") !== undefined ? { cacheCreationInputTokens: sumOptional("cacheCreationInputTokens") } : {}),
...(sumOptional("providerCredits") !== undefined ? { providerCredits: sumOptional("providerCredits") } : {}),
...(sumOptional("reasoningOutputTokens") !== undefined ? { reasoningOutputTokens: sumOptional("reasoningOutputTokens") } : {}),
...(first.estimated || second.estimated ? { estimated: true } : {}),
};
Expand Down Expand Up @@ -320,6 +321,7 @@ async function* parseKiroAttemptEvents(
let completionAnswer: string | undefined;
let completionCalls = 0;
let authoritativeUsage: OcxUsage | undefined;
let providerCredits: number | undefined;
let stopReason: string | undefined;
const fallbackEvents: AdapterEvent[] = [];
const thinking = new InlineThinkTagParser(budget);
Expand Down Expand Up @@ -380,7 +382,11 @@ async function* parseKiroAttemptEvents(
contextUsageTotalFloor() ?? 0,
authoritativeTurnTotal,
);
return contextTotal > 0 ? { ...base, contextTotalTokens: contextTotal } : base;
return {
...base,
...(contextTotal > 0 ? { contextTotalTokens: contextTotal } : {}),
...(providerCredits !== undefined ? { providerCredits } : {}),
};
};

const classifiedTerminal = (failure: KiroErrorClassification): AdapterEvent => {
Expand Down Expand Up @@ -600,6 +606,9 @@ async function* parseKiroAttemptEvents(
const ev = parseKiroEvent(eventType, msg.payload);
if (!ev) continue;
switch (ev.type) {
case "metering":
if (ev.unit === "credit" || ev.unit === "credits") providerCredits = ev.usage;
break;
case "metadata":
if (ev.usage) authoritativeUsage = ev.usage;
if (ev.contextUsagePercentage !== undefined && ev.contextUsagePercentage > 0) {
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 } : {}) };
}
Loading
Loading