From 2cc231914c051bda5d4ff46c67b5ee12d19f95d8 Mon Sep 17 00:00:00 2001
From: heomin86 <126128010+heomin86@users.noreply.github.com>
Date: Fri, 14 Aug 2026 09:57:26 +0900
Subject: [PATCH 01/17] docs: add Factory Droid bridge guide
---
docs-site/astro.config.mjs | 1 +
.../src/content/docs/guides/factory-droid.md | 138 +++++++++++++++++
.../content/docs/ko/guides/factory-droid.md | 139 ++++++++++++++++++
3 files changed, 278 insertions(+)
create mode 100644 docs-site/src/content/docs/guides/factory-droid.md
create mode 100644 docs-site/src/content/docs/ko/guides/factory-droid.md
diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs
index c1dfc83613..a7cc2576c5 100644
--- a/docs-site/astro.config.mjs
+++ b/docs-site/astro.config.mjs
@@ -85,6 +85,7 @@ export default defineConfig({
translations: { ko: "가이드", "zh-CN": "指南", "zh-TW": "指南", ru: "Руководства", ja: "ガイド", tr: "Kılavuzlar" },
items: [
{ label: "Providers", translations: { ko: "프로바이더", "zh-CN": "提供商", "zh-TW": "供應商", ru: "Провайдеры", ja: "プロバイダー", tr: "Sağlayıcılar" }, slug: "guides/providers" },
+ { label: "Factory Droid Bridge", translations: { ko: "Factory Droid 브리지" }, slug: "guides/factory-droid" },
{ label: "Model Routing", translations: { ko: "모델 라우팅", "zh-CN": "模型路由", "zh-TW": "模型路由", ru: "Маршрутизация моделей", ja: "モデルルーティング", tr: "Model Yönlendirme" }, slug: "guides/model-routing" },
{ label: "Codex Integration", translations: { ko: "Codex 통합", "zh-CN": "Codex 集成", "zh-TW": "Codex 整合", ru: "Интеграция с Codex", ja: "Codex 連携", tr: "Codex Entegrasyonu" }, slug: "guides/codex-integration" },
{ label: "Codex App Model Picker", translations: { ko: "Codex App 모델 선택기", "zh-CN": "Codex App 模型选择器", "zh-TW": "Codex App 模型選擇器", ru: "Выбор модели в Codex App", ja: "Codex App モデルピッカー", tr: "Codex App Model Seçici" }, slug: "guides/codex-app-models" },
diff --git a/docs-site/src/content/docs/guides/factory-droid.md b/docs-site/src/content/docs/guides/factory-droid.md
new file mode 100644
index 0000000000..810b3e5c58
--- /dev/null
+++ b/docs-site/src/content/docs/guides/factory-droid.md
@@ -0,0 +1,138 @@
+---
+title: Factory Droid bridge
+description: Connect Factory Droid models to opencodex through a local Responses-compatible bridge.
+---
+
+Factory Droid is an agent runtime, not a documented OpenAI-compatible inference endpoint. If a
+custom provider pointed at an internal Factory LLM URL returns `403 Forbidden`, changing only the
+opencodex adapter or adding provider headers does not make that private route a supported public API.
+
+The working integration is:
+
+```text
+Codex App or CLI
+ -> opencodex (http://127.0.0.1:10100/v1/responses)
+ -> local Responses bridge (http://127.0.0.1:11435/v1/responses)
+ -> official droid exec command
+ -> Factory account and selected model
+```
+
+This keeps the Factory credential inside the official Droid client. OpenCodex receives a separate,
+local-only bridge token.
+
+## What failed and why
+
+| Symptom | Cause | Fix |
+| --- | --- | --- |
+| `403 Forbidden` from a Factory LLM URL | The URL is not a documented general-purpose OpenAI endpoint for third-party clients | Invoke Factory through the official Droid CLI or SDK |
+| `404` at `/models/models` | The provider base URL already ended in `/models` | Use an API root as `baseUrl`; never include the discovery path |
+| Model search fails | The bridge does not expose a complete live catalog | Set `liveModels: false` and provide a static `models` list |
+| Loopback provider is rejected | Private-network access is denied by default | Set `allowPrivateNetwork: true` only for the loopback bridge |
+| `${DROID_BRIDGE_TOKEN}` is unresolved | The variable is missing from the opencodex service environment | Inject it into the service process, not only an interactive shell |
+| `OutputTextDelta without active item` | The bridge emitted a text delta before opening an output item and content part | Emit the complete Responses SSE lifecycle in order |
+
+The same Factory credential can therefore work in `droid exec` while a direct request to an
+undocumented LLM URL still returns `403`. Those results test different products and should not be
+treated as contradictory.
+
+## Prerequisites
+
+1. Install and sign in to the [Droid CLI](https://docs.factory.ai/droid-cli/quickstart).
+2. Confirm a bounded headless request works:
+
+ ```bash
+ droid exec --model glm-5.2 --output-format json "Reply with DROID_OK only."
+ ```
+
+3. Run a local bridge that invokes `droid exec` (or the official Droid SDK) and exposes:
+
+ - `GET /healthz`
+ - `GET /v1/models`
+ - `POST /v1/responses`
+
+Factory documents `droid exec` as its non-interactive automation surface and recommends JSON output
+for scripts. For a longer-lived integration, Factory also documents stream JSON-RPC and official
+TypeScript and Python SDKs in the
+[Droid Exec guide](https://docs.factory.ai/droid-exec/overview).
+
+## Bridge contract
+
+Bind the bridge to `127.0.0.1`, require a randomly generated bearer token, cap request sizes, and
+allowlist model IDs. A minimal text bridge should:
+
+1. Convert the Responses `input` array to a prompt.
+2. invoke `droid exec --model --output-format json `;
+3. parse the final `result` and `session_id`;
+4. return an OpenAI Responses envelope; and
+5. map `previous_response_id` to the Droid session ID when continuation is required.
+
+For streaming responses, emit this lifecycle in order:
+
+```text
+response.created
+response.output_item.added
+response.content_part.added
+response.output_text.delta
+response.output_text.done
+response.content_part.done
+response.output_item.done
+response.completed
+```
+
+Do not expose the bridge on `0.0.0.0` and do not reuse the Factory credential as the bridge bearer
+token.
+
+## OpenCodex provider configuration
+
+Add a custom provider, then use **Edit JSON** to configure it:
+
+```json
+{
+ "adapter": "openai-responses",
+ "baseUrl": "http://127.0.0.1:11435/v1",
+ "responsesPath": "/responses",
+ "allowPrivateNetwork": true,
+ "authMode": "key",
+ "apiKey": "${DROID_BRIDGE_TOKEN}",
+ "liveModels": false,
+ "models": ["glm-5.2", "glm-5.2-fast", "kimi-k3"],
+ "defaultModel": "glm-5.2"
+}
+```
+
+The model IDs are examples. Keep only models that `droid exec` can use for the signed-in Factory
+account. Do not add Factory-specific inference headers to this provider: its upstream is the local
+bridge, not a Factory HTTP endpoint.
+
+After saving a provider or changing its static catalog, synchronize and restart the Codex
+app-server so new sessions read the updated catalog:
+
+```bash
+ocx sync --restart-codex
+ocx doctor
+```
+
+Restarting Codex app-server processes interrupts active Codex work. Run the restart only after
+finishing or saving those sessions.
+
+## Verify the complete route
+
+Check each boundary separately:
+
+```bash
+curl -fsS http://127.0.0.1:11435/healthz
+ocx doctor
+codex exec --ephemeral --model droid/glm-5.2 \
+ "Reply with CODEX_DROID_OK only. Do not call tools."
+```
+
+A provider row or model-picker entry proves only catalog visibility. The integration is working only
+after a new Codex process returns a response through the `droid/` route.
+
+## Current limitation
+
+The minimal bridge above translates text and the Responses SSE lifecycle. It does **not** implement
+the full bidirectional Codex function/tool-call protocol. Tool definitions, tool calls, tool results,
+permissions, cancellation, and rich Droid events require a stateful bridge built on Factory's stream
+JSON-RPC mode or an official Droid SDK. Treat text success as text-path verification, not tool-path
+verification.
diff --git a/docs-site/src/content/docs/ko/guides/factory-droid.md b/docs-site/src/content/docs/ko/guides/factory-droid.md
new file mode 100644
index 0000000000..3f98d40f7a
--- /dev/null
+++ b/docs-site/src/content/docs/ko/guides/factory-droid.md
@@ -0,0 +1,139 @@
+---
+title: Factory Droid 브리지
+description: 로컬 Responses 호환 브리지를 통해 Factory Droid 모델을 opencodex에 연결합니다.
+---
+
+Factory Droid는 에이전트 런타임이며, 문서화된 OpenAI 호환 추론 엔드포인트가 아닙니다. 내부
+Factory LLM URL을 사용자 지정 프로바이더로 등록했을 때 `403 Forbidden`이 발생한다면,
+opencodex 어댑터나 프로바이더 헤더만 바꿔도 그 비공개 경로가 지원되는 공개 API로 바뀌지는
+않습니다.
+
+검증된 연결 구조는 다음과 같습니다.
+
+```text
+Codex App 또는 CLI
+ -> opencodex (http://127.0.0.1:10100/v1/responses)
+ -> 로컬 Responses 브리지 (http://127.0.0.1:11435/v1/responses)
+ -> 공식 droid exec 명령
+ -> Factory 계정과 선택 모델
+```
+
+이 구조에서는 Factory 자격 증명을 공식 Droid 클라이언트 안에 유지합니다. OpenCodex에는 별도의
+로컬 전용 브리지 토큰만 전달합니다.
+
+## 실패 원인과 수정 방법
+
+| 증상 | 원인 | 해결 |
+| --- | --- | --- |
+| Factory LLM URL에서 `403 Forbidden` | 해당 URL은 서드파티 클라이언트용 범용 OpenAI 엔드포인트로 문서화되지 않음 | 공식 Droid CLI 또는 SDK를 통해 호출 |
+| `/models/models`에서 `404` | 프로바이더 Base URL에 `/models`가 이미 포함됨 | `baseUrl`에는 API 루트만 사용하고 검색 경로는 넣지 않음 |
+| 모델 검색 실패 | 브리지가 완전한 실시간 카탈로그를 제공하지 않음 | `liveModels: false`와 정적 `models` 목록 사용 |
+| 루프백 프로바이더 거부 | 사설 네트워크 접근은 기본적으로 차단됨 | 루프백 브리지에만 `allowPrivateNetwork: true` 설정 |
+| `${DROID_BRIDGE_TOKEN}`을 찾지 못함 | opencodex 서비스 환경에 변수가 없음 | 대화형 셸이 아니라 서비스 프로세스에 변수 주입 |
+| `OutputTextDelta without active item` | 출력 item과 content part를 열기 전에 text delta를 보냄 | Responses SSE 수명주기 전체를 순서대로 전송 |
+
+따라서 같은 Factory 자격 증명으로 `droid exec`는 성공하지만, 문서화되지 않은 LLM URL 직접
+요청은 `403`을 반환할 수 있습니다. 두 결과는 서로 다른 제품 표면을 시험한 것이므로 모순이
+아닙니다.
+
+## 준비 사항
+
+1. [Droid CLI](https://docs.factory.ai/droid-cli/quickstart)를 설치하고 로그인합니다.
+2. 제한된 headless 요청이 성공하는지 확인합니다.
+
+ ```bash
+ droid exec --model glm-5.2 --output-format json "DROID_OK만 답하세요."
+ ```
+
+3. `droid exec` 또는 공식 Droid SDK를 호출하면서 아래 엔드포인트를 제공하는 로컬 브리지를
+ 실행합니다.
+
+ - `GET /healthz`
+ - `GET /v1/models`
+ - `POST /v1/responses`
+
+Factory는 `droid exec`를 비대화형 자동화 표면으로 문서화하며, 스크립트에서는 JSON 출력을
+권장합니다. 장시간 유지되는 통합에는 stream JSON-RPC와 공식 TypeScript/Python SDK도 사용할 수
+있습니다. 자세한 내용은 [Droid Exec 가이드](https://docs.factory.ai/droid-exec/overview)를
+참고하세요.
+
+## 브리지 계약
+
+브리지는 `127.0.0.1`에만 바인딩하고, 무작위 bearer 토큰을 요구하며, 요청 크기와 모델 ID를
+제한해야 합니다. 최소 텍스트 브리지는 다음 작업을 수행합니다.
+
+1. Responses `input` 배열을 프롬프트로 변환합니다.
+2. `droid exec --model --output-format json `를 실행합니다.
+3. 최종 `result`와 `session_id`를 파싱합니다.
+4. OpenAI Responses envelope을 반환합니다.
+5. 대화 연속성이 필요하면 `previous_response_id`를 Droid session ID에 매핑합니다.
+
+스트리밍 응답은 다음 수명주기를 순서대로 보내야 합니다.
+
+```text
+response.created
+response.output_item.added
+response.content_part.added
+response.output_text.delta
+response.output_text.done
+response.content_part.done
+response.output_item.done
+response.completed
+```
+
+브리지를 `0.0.0.0`에 노출하지 말고, Factory 자격 증명을 브리지 bearer 토큰으로 재사용하지
+마세요.
+
+## OpenCodex 프로바이더 설정
+
+사용자 지정 프로바이더를 추가한 뒤 **JSON 편집**에서 다음과 같이 설정합니다.
+
+```json
+{
+ "adapter": "openai-responses",
+ "baseUrl": "http://127.0.0.1:11435/v1",
+ "responsesPath": "/responses",
+ "allowPrivateNetwork": true,
+ "authMode": "key",
+ "apiKey": "${DROID_BRIDGE_TOKEN}",
+ "liveModels": false,
+ "models": ["glm-5.2", "glm-5.2-fast", "kimi-k3"],
+ "defaultModel": "glm-5.2"
+}
+```
+
+모델 ID는 예시입니다. 로그인한 Factory 계정의 `droid exec`에서 실제로 사용할 수 있는 모델만
+남기세요. 이 프로바이더의 업스트림은 Factory HTTP 엔드포인트가 아니라 로컬 브리지이므로
+Factory 추론 전용 헤더를 추가하지 않습니다.
+
+프로바이더를 저장하거나 정적 카탈로그를 바꾼 뒤에는 새 세션이 갱신된 카탈로그를 읽도록 Codex
+app-server를 동기화하고 재시작합니다.
+
+```bash
+ocx sync --restart-codex
+ocx doctor
+```
+
+Codex app-server 재시작은 진행 중인 Codex 작업을 중단합니다. 해당 세션을 끝내거나 저장한 뒤에만
+재시작하세요.
+
+## 전체 경로 검증
+
+각 경계를 따로 확인합니다.
+
+```bash
+curl -fsS http://127.0.0.1:11435/healthz
+ocx doctor
+codex exec --ephemeral --model droid/glm-5.2 \
+ "도구를 호출하지 말고 CODEX_DROID_OK만 답하세요."
+```
+
+프로바이더 행이나 모델 선택기 표시는 카탈로그 노출만 증명합니다. 새 Codex 프로세스가
+`droid/` 경로를 통해 실제 응답을 반환해야 연동 성공입니다.
+
+## 현재 한계
+
+위 최소 브리지는 텍스트와 Responses SSE 수명주기만 변환합니다. Codex의 완전한 양방향
+function/tool-call 프로토콜은 구현하지 않습니다. 도구 정의, 도구 호출과 결과, 권한, 취소, 풍부한
+Droid 이벤트를 처리하려면 Factory stream JSON-RPC 모드 또는 공식 Droid SDK를 사용하는 상태 유지
+브리지가 필요합니다. 텍스트 성공을 도구 경로 성공으로 간주하지 마세요.
From 4d3a776a0ee7e7b41d795e28dfb86f2d041bea87 Mon Sep 17 00:00:00 2001
From: Kartik
Date: Fri, 14 Aug 2026 07:26:22 +0530
Subject: [PATCH 02/17] feat(usage): record streamAborted on mid-stream-death
attempts
Port the codex-router #139 streamAborted metering marker: when an upstream
stream dies after its 200 head was committed, the persisted usage attempt
now carries streamAborted: true alongside the synthetic 502 terminal so the
turn never meters as a success the client did not receive.
- usage/log.ts: optional streamAborted on PersistedUsageAttempt, whitelisted
in normalizeUsageAttempt (only the literal true marker survives the round
trip; ordinary and legacy rows keep their exact shape).
- relay.ts: set the marker on consumeForInspection's mid-stream read-failure
path (failed/502) and on trackSseForRequestLog's read-failure path
(incomplete/502). Client-cancel (499) semantics are untouched and never
carry the marker.
- tests: usage-log.test.ts gains persistence/backward-compat coverage;
stream-aborted-marker.test.ts drives the relay paths end to end through
addFinalRequestLog to the persisted JSONL row.
---
src/server/relay.ts | 12 ++
src/usage/log.ts | 8 ++
tests/stream-aborted-marker.test.ts | 196 ++++++++++++++++++++++++++++
tests/usage-log.test.ts | 103 ++++++++++++++-
4 files changed, 313 insertions(+), 6 deletions(-)
create mode 100644 tests/stream-aborted-marker.test.ts
diff --git a/src/server/relay.ts b/src/server/relay.ts
index a456c6124e..f3f60d6534 100644
--- a/src/server/relay.ts
+++ b/src/server/relay.ts
@@ -366,6 +366,7 @@ export function trackSseForRequestLog(
): ReadableStream {
const reader = body.getReader();
let terminalReported = false;
+ let cancelled = false;
const reportTerminal = (status: ResponsesTerminalStatus) => {
if (terminalReported) return;
@@ -394,12 +395,19 @@ export function trackSseForRequestLog(
inspector.feed(value);
controller.enqueue(value);
} catch (err) {
+ // The upstream read rejected: the 200 body died mid-flight. Client
+ // cancellation is the caller's separate 499 path, so a cancel-drained
+ // pending read (cancelled=true) must not carry the truncation marker.
+ if (!cancelled && !terminalReported && logCtx?.activeAttempt) {
+ logCtx.activeAttempt.streamAborted = true;
+ }
if (!terminalReported) reportTerminal("incomplete");
inspector.dispose();
try { controller.error(err); } catch { /* already torn down */ }
}
},
cancel(reason) {
+ cancelled = true;
inspector.dispose();
onCancel();
reader.cancel(reason).catch(() => {});
@@ -1122,6 +1130,10 @@ export function consumeForInspection(
if (logCtx) {
logCtx.transportPhase = "mid_stream";
logCtx.terminalSource = "synthetic";
+ // A truncated 200 body must not meter as a success the client never
+ // received; the router's equivalent turn carries 502 + streamAborted
+ // (codex-router #139).
+ if (logCtx.activeAttempt) logCtx.activeAttempt.streamAborted = true;
}
onTerminal("failed", 502);
}
diff --git a/src/usage/log.ts b/src/usage/log.ts
index 2067d1e506..7cb6160b11 100644
--- a/src/usage/log.ts
+++ b/src/usage/log.ts
@@ -36,6 +36,12 @@ export interface PersistedUsageAttempt {
adapter: string;
status: number;
durationMs: number;
+ /**
+ * True only when the upstream stream died after its 200 head was committed,
+ * so the row must not meter as a success the client never received.
+ * Absent on ordinary attempts so old rows keep their exact shape.
+ */
+ streamAborted?: boolean;
/** TTFT relative to THIS attempt's start (WP4); unset for non-streaming/tool-only. */
firstOutputMs?: number;
sendCount: number;
@@ -277,6 +283,8 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null {
adapter: attempt.adapter,
status: attempt.status,
durationMs: attempt.durationMs,
+ // Absent by default; only the literal `true` marker survives the round trip.
+ ...(attempt.streamAborted === true ? { streamAborted: true } : {}),
...(isNonNegativeFiniteNumber(attempt.firstOutputMs)
? { firstOutputMs: attempt.firstOutputMs }
: {}),
diff --git a/tests/stream-aborted-marker.test.ts b/tests/stream-aborted-marker.test.ts
new file mode 100644
index 0000000000..d4943dd553
--- /dev/null
+++ b/tests/stream-aborted-marker.test.ts
@@ -0,0 +1,196 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { consumeForInspection, trackSseForRequestLog } from "../src/server/relay";
+import {
+ addFinalRequestLog,
+ addRequestLog,
+ beginRequestAttempt,
+ httpStatusForRequestLogTerminal,
+ type RequestLogContext,
+} from "../src/server/request-log";
+import {
+ readUsageEntries,
+ resetUsageReadCacheForTests,
+ type PersistedUsageAttempt,
+} from "../src/usage/log";
+
+// Port of codex-router #139's streamAborted metering marker: an upstream stream
+// that dies after its 200 head was committed must meter as a truncated turn
+// (synthetic 502 + streamAborted), while a client cancellation keeps opencodex's
+// own 499 client_cancel semantics and never carries the marker.
+
+const encoder = new TextEncoder();
+
+let testDir = "";
+let previousHome: string | undefined;
+
+beforeEach(() => {
+ previousHome = process.env.OPENCODEX_HOME;
+ testDir = mkdtempSync(join(tmpdir(), "ocx-stream-aborted-"));
+ process.env.OPENCODEX_HOME = testDir;
+ resetUsageReadCacheForTests();
+});
+
+afterEach(() => {
+ if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
+ else process.env.OPENCODEX_HOME = previousHome;
+ if (testDir) rmSync(testDir, { recursive: true, force: true });
+});
+
+function makeLogCtx(): { logCtx: RequestLogContext; attempt: PersistedUsageAttempt } {
+ const attempt = beginRequestAttempt(1, "openai", "gpt-test", "openai-responses");
+ const logCtx: RequestLogContext = {
+ provider: "openai",
+ model: "gpt-test",
+ activeAttempt: attempt,
+ activeAttemptStartedAt: Date.now(),
+ attempts: [attempt],
+ };
+ return { logCtx, attempt };
+}
+
+/** Enqueues one event (a 200 head is committed), then the upstream read dies. */
+function streamThatFailsMidStream(): ReadableStream {
+ let reads = 0;
+ return new ReadableStream({
+ pull(controller) {
+ reads += 1;
+ if (reads === 1) {
+ controller.enqueue(encoder.encode('data: {"type":"response.output_text.delta","delta":"hel"}\n\n'));
+ } else {
+ controller.error(new Error("socket reset"));
+ }
+ },
+ });
+}
+
+/** A stream whose read never resolves on its own; only cancel or abort ends it. */
+function pendingStream(): ReadableStream {
+ return new ReadableStream({ start() {}, pull() { /* producer is test-controlled */ } });
+}
+
+describe("streamAborted marker (codex-router #139)", () => {
+ test("mid-stream death after a 200 head meters as 502 + streamAborted", async () => {
+ const { logCtx, attempt } = makeLogCtx();
+ const terminalReported = Promise.withResolvers();
+ const terminals: Array<[string, number | undefined]> = [];
+ let cancels = 0;
+ consumeForInspection(
+ streamThatFailsMidStream(),
+ (status, httpStatusOverride) => {
+ terminals.push([status, httpStatusOverride]);
+ terminalReported.resolve();
+ },
+ undefined,
+ () => {},
+ logCtx,
+ () => { cancels += 1; },
+ );
+ await terminalReported.promise;
+ expect(terminals).toEqual([["failed", 502]]);
+ expect(cancels).toBe(0);
+ expect(attempt.streamAborted).toBe(true);
+
+ // Finalize exactly like the native-passthrough terminal path in index.ts.
+ addFinalRequestLog(
+ "ocx-stream-aborted-e2e",
+ Date.now(),
+ logCtx,
+ httpStatusForRequestLogTerminal("failed", logCtx),
+ { terminalStatus: "failed", closeReason: "terminal" },
+ addRequestLog,
+ );
+ const [row] = readUsageEntries();
+ expect(row?.status).toBe(502);
+ expect(row?.attempts?.[0]?.status).toBe(502);
+ expect(row?.attempts?.[0]?.streamAborted).toBe(true);
+ });
+
+ test("client cancellation keeps 499 and never sets streamAborted", async () => {
+ const { logCtx, attempt } = makeLogCtx();
+ const ac = new AbortController();
+ const cancelFired = Promise.withResolvers();
+ const doneFired = Promise.withResolvers();
+ const terminals: string[] = [];
+ let cancels = 0;
+ consumeForInspection(
+ pendingStream(),
+ status => { terminals.push(status); },
+ ac.signal,
+ () => doneFired.resolve(),
+ logCtx,
+ () => {
+ cancels += 1;
+ cancelFired.resolve();
+ },
+ );
+ ac.abort();
+ await Promise.all([cancelFired.promise, doneFired.promise]);
+ expect(terminals).toEqual([]);
+ expect(cancels).toBe(1);
+ expect(attempt.streamAborted).toBeUndefined();
+
+ addFinalRequestLog(
+ "ocx-cancel-e2e",
+ Date.now(),
+ logCtx,
+ 499,
+ { closeReason: "client_cancel" },
+ addRequestLog,
+ );
+ const [row] = readUsageEntries();
+ expect(row?.status).toBe(499);
+ expect(row?.attempts?.[0]?.streamAborted).toBeUndefined();
+ });
+
+ test("translated SSE read failure marks the attempt streamAborted", async () => {
+ const { logCtx, attempt } = makeLogCtx();
+ const terminals: string[] = [];
+ let cancels = 0;
+ const relayed = trackSseForRequestLog(
+ streamThatFailsMidStream(),
+ status => { terminals.push(status); },
+ () => { cancels += 1; },
+ logCtx,
+ );
+ await expect(new Response(relayed).text()).rejects.toThrow("socket reset");
+ expect(terminals).toEqual(["incomplete"]);
+ expect(cancels).toBe(0);
+ expect(attempt.streamAborted).toBe(true);
+ });
+
+ test("translated SSE client cancel never sets the truncation marker", async () => {
+ const { logCtx, attempt } = makeLogCtx();
+ const cancelFired = Promise.withResolvers();
+ const terminals: string[] = [];
+ let cancels = 0;
+ const relayed = trackSseForRequestLog(
+ pendingStream(),
+ status => { terminals.push(status); },
+ () => {
+ cancels += 1;
+ cancelFired.resolve();
+ },
+ logCtx,
+ );
+ await relayed.getReader().cancel(new DOMException("client closed", "AbortError"));
+ await cancelFired.promise;
+ expect(cancels).toBe(1);
+ expect(terminals).toEqual([]);
+ expect(attempt.streamAborted).toBeUndefined();
+
+ addFinalRequestLog(
+ "ocx-cancel-translated",
+ Date.now(),
+ logCtx,
+ 499,
+ { closeReason: "client_cancel" },
+ addRequestLog,
+ );
+ const [row] = readUsageEntries();
+ expect(row?.status).toBe(499);
+ expect(row?.attempts?.[0]?.streamAborted).toBeUndefined();
+ });
+});
diff --git a/tests/usage-log.test.ts b/tests/usage-log.test.ts
index 5a4b905043..090e02e250 100644
--- a/tests/usage-log.test.ts
+++ b/tests/usage-log.test.ts
@@ -95,24 +95,115 @@ describe("usage log", () => {
provider: "blsc",
model: "blsc/DeepSeek-V4-Flash",
status: 429,
- durationMs: 4,
- usageStatus: "reported",
+ durationMs: 1,
+ usageStatus: "unreported",
attempts: [{
ordinal: 1,
provider: "blsc",
model: "blsc/DeepSeek-V4-Flash",
adapter: "openai-chat",
status: 429,
- durationMs: 4,
- sendCount: 2,
- recoveryKinds: ["rate-limit-429", "rate-limit-429"],
- usageStatus: "reported",
+ durationMs: 1,
+ sendCount: 1,
+ recoveryKinds: ["rate-limit-429"],
+ usageStatus: "unreported",
}],
};
appendUsageEntry(entry);
expect(readUsageEntries()[0]?.attempts?.[0]?.recoveryKinds).toEqual(["rate-limit-429"]);
});
+ test("persists the streamAborted marker on truncated-stream attempts", () => {
+ appendUsageEntry({
+ requestId: "ocx-stream-aborted",
+ timestamp: 1,
+ provider: "openai",
+ model: "gpt-test",
+ status: 502,
+ durationMs: 3,
+ usageStatus: "unreported",
+ terminalStatus: "failed",
+ closeReason: "terminal",
+ attempts: [{
+ ordinal: 1,
+ provider: "openai",
+ model: "gpt-test",
+ adapter: "openai-responses",
+ status: 502,
+ durationMs: 3,
+ sendCount: 1,
+ recoveryKinds: [],
+ usageStatus: "unreported",
+ streamAborted: true,
+ }],
+ });
+ const raw = readFileSync(usageLogPath(), "utf-8");
+ expect(raw).toContain('"streamAborted":true');
+ expect(readUsageEntries()[0]?.attempts?.[0]?.streamAborted).toBe(true);
+ });
+
+ test("ordinary attempts omit the streamAborted marker (backward compatible)", () => {
+ const attempt: PersistedUsageAttempt = {
+ ordinal: 1,
+ provider: "openai",
+ model: "gpt-test",
+ adapter: "openai-responses",
+ status: 200,
+ durationMs: 3,
+ sendCount: 1,
+ recoveryKinds: [],
+ usageStatus: "reported",
+ };
+ const base: PersistedUsageEntry = {
+ requestId: "ocx-stream-aborted-absent",
+ timestamp: 1,
+ provider: "openai",
+ model: "gpt-test",
+ status: 200,
+ durationMs: 3,
+ usageStatus: "reported",
+ attempts: [attempt],
+ };
+ for (const marker of [undefined, false]) {
+ const normalized = normalizeUsageEntryForTest({
+ ...base,
+ attempts: [{ ...attempt, ...(marker === undefined ? {} : { streamAborted: marker }) }],
+ });
+ expect(normalized.attempts?.[0]).not.toHaveProperty("streamAborted");
+ }
+ const marked = normalizeUsageEntryForTest({
+ ...base,
+ attempts: [{ ...attempt, streamAborted: true }],
+ });
+ expect(marked.attempts?.[0]?.streamAborted).toBe(true);
+ });
+
+ test("legacy attempts without streamAborted stay readable and unset", () => {
+ writeFileSync(usageLogPath(), `${JSON.stringify({
+ requestId: "legacy-no-marker",
+ timestamp: 1,
+ provider: "openai",
+ model: "gpt-test",
+ status: 200,
+ durationMs: 3,
+ usageStatus: "reported",
+ attempts: [{
+ ordinal: 1,
+ provider: "openai",
+ model: "gpt-test",
+ adapter: "openai-responses",
+ status: 200,
+ durationMs: 3,
+ sendCount: 1,
+ recoveryKinds: [],
+ usageStatus: "reported",
+ }],
+ })}\n`);
+ const attempt = readUsageEntries()[0]?.attempts?.[0];
+ expect(attempt?.ordinal).toBe(1);
+ expect(attempt).not.toHaveProperty("streamAborted");
+ });
+
/** Build one minimal persisted-usage JSONL line for the given request id. */
const persistedLine = (requestId: string) => JSON.stringify({
requestId,
From 08899736442caa745373827a2d3171269f7e6fcd Mon Sep 17 00:00:00 2001
From: TooSpace
Date: Fri, 14 Aug 2026 13:43:47 +0800
Subject: [PATCH 03/17] feat(catalog): add modelPickerOrder to customize the
Codex model-picker order
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Large routed catalogs (10-20+ models across several providers) have no
supported way to control the Codex model-picker display order beyond the 5-slot
subagentModels list: every non-featured routed row is emitted at the same flat
priority, so the picker order is undefined and reshuffles on each catalog
rebuild (ocx sync / service restart / upgrade).
Add an optional, display-only config.modelPickerOrder: string[]. Listed routed
/ slugs are shown in array order in the picker; unlisted rows
and subagentModels-featured rows keep their positions. When unset, catalog
priority is byte-identical to before (the codex-catalog golden oracle is
unchanged).
Display and spawn_agent candidacy are fully decoupled: modelPickerOrder rewrites
only the Codex-visible `priority`, while each moved row records its natural
priority in an OpenCodex-private catalog field (opencodex_spawn_priority) that
effectiveSubagentRoster uses to pick candidates. The spawn_agent candidate set
is therefore provably unchanged by any display reordering — even reversing every
row. Codex ignores the unknown field (same as opencodex_catalog_kind), so this
is purely a user-facing picker feature.
Fixes #1649
---
src/codex/catalog/sync.ts | 76 ++++++-
src/codex/convergence.ts | 2 +
src/types.ts | 14 ++
.../codex-catalog-model-picker-order.test.ts | 188 ++++++++++++++++++
4 files changed, 276 insertions(+), 4 deletions(-)
create mode 100644 tests/codex-catalog-model-picker-order.test.ts
diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts
index e3a9e56892..1ca3909249 100644
--- a/src/codex/catalog/sync.ts
+++ b/src/codex/catalog/sync.ts
@@ -67,6 +67,18 @@ import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, truste
export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5;
+// Base for config.modelPickerOrder display priorities (#1649). modelPickerOrder is a DISPLAY-ONLY
+// reordering of the Codex model picker: it rewrites a row's Codex-visible `priority` but never the
+// spawn_agent candidate window. The window is derived from SPAWN_PRIORITY_FIELD (the natural
+// priority captured before the override), so display order and spawn candidates are decoupled.
+export const PICKER_ORDER_PRIORITY_BASE = 1_000;
+
+// OpenCodex-private catalog field: the spawn_agent candidate priority a row would have WITHOUT
+// modelPickerOrder. Codex ignores unknown catalog fields (same as opencodex_catalog_kind), so this
+// is invisible to Codex; effectiveSubagentRoster reads it so a display reorder cannot change which
+// rows are spawn_agent candidates. Absent on rows modelPickerOrder did not move.
+export const SPAWN_PRIORITY_FIELD = "opencodex_spawn_priority";
+
export type SpawnAgentSurface = "v1" | "v2";
export type SubagentRosterExclusionReason =
@@ -144,10 +156,17 @@ export function effectiveSubagentRoster(
.filter(({ entry }) => entry.visibility === "list")
.filter(({ entry }) => surface !== "v2" || isEligibleV2SubagentEntry(entry))
.sort((left, right) => {
- const leftPriority = typeof left.entry.priority === "number" && Number.isFinite(left.entry.priority)
- ? left.entry.priority : Number.MAX_SAFE_INTEGER;
- const rightPriority = typeof right.entry.priority === "number" && Number.isFinite(right.entry.priority)
- ? right.entry.priority : Number.MAX_SAFE_INTEGER;
+ // Spawn candidates rank by the natural priority (SPAWN_PRIORITY_FIELD when present), so a
+ // modelPickerOrder display reorder (#1649) can never change candidate membership. Rows the
+ // override did not move fall back to their Codex-visible `priority`.
+ const spawnPriorityOf = (entry: RawEntry): number => {
+ const spawn = entry[SPAWN_PRIORITY_FIELD];
+ if (typeof spawn === "number" && Number.isFinite(spawn)) return spawn;
+ return typeof entry.priority === "number" && Number.isFinite(entry.priority)
+ ? entry.priority : Number.MAX_SAFE_INTEGER;
+ };
+ const leftPriority = spawnPriorityOf(left.entry);
+ const rightPriority = spawnPriorityOf(right.entry);
return leftPriority - rightPriority || left.index - right.index;
})
.slice(0, MAX_SPAWN_AGENT_MODEL_OVERRIDES);
@@ -362,6 +381,8 @@ export interface ObservedCatalogEntryBuildInput {
readonly gptSlugs: readonly string[];
readonly goModels: readonly CatalogModel[];
readonly featured?: readonly string[];
+ /** Optional full picker ordering (config.modelPickerOrder); orders non-featured rows. */
+ readonly modelPickerOrder?: readonly string[];
readonly wsEnabled: boolean;
readonly multiAgentMode: MultiAgentMode;
readonly exactComboSlugs: ReadonlySet;
@@ -416,6 +437,7 @@ export function buildCatalogEntriesFromObservedState({
gptSlugs,
goModels,
featured,
+ modelPickerOrder,
wsEnabled,
multiAgentMode,
exactComboSlugs,
@@ -433,6 +455,37 @@ export function buildCatalogEntriesFromObservedState({
// it sorts to the front. This works for native gpt slugs AND routed slugs alike.
const rank = new Map((featured ?? []).map((slug, i) => [slug, i] as const));
const priorityStride = Math.max(accountSelectors.length, 1);
+ // Optional full picker order (#1649). Independent of the 5-slot spawn_agent cap: it only
+ // rewrites the Codex-visible display `priority` of listed non-featured routed rows so a >5
+ // catalog stays put across rebuilds. Featured rows keep their existing 0..N-1 band; when
+ // modelPickerOrder is unset the helper is a no-op and every priority below is byte-identical to
+ // before. The spawn_agent candidate window is derived separately from SPAWN_PRIORITY_FIELD, so
+ // this display reorder cannot change which rows are spawn candidates.
+ const pickerOrder = (modelPickerOrder ?? []).filter(id => typeof id === "string" && id.length > 0);
+ const pickerOrderRank = new Map(pickerOrder.map((slug, i) => [slug, i] as const));
+ const pickerOrderActive = pickerOrder.length > 0;
+ // The display band reuses the existing high priority tier (>= PICKER_ORDER_PRIORITY_BASE, the
+ // same 1_000+ neighborhood account rows occupy), keeping listed rows visually after the featured
+ // band. Candidate membership does not depend on this — see SPAWN_PRIORITY_FIELD.
+ /**
+ * Priority for a non-featured routed row that is explicitly LISTED in modelPickerOrder. Listed
+ * slugs sort in declared order within the high picker-order display tier
+ * (>= PICKER_ORDER_PRIORITY_BASE). This sets the Codex-visible `priority` only; the caller records
+ * the row's natural priority in SPAWN_PRIORITY_FIELD so the spawn_agent candidate window is
+ * unchanged. Returns undefined when the feature is off or the row is not listed, so those rows
+ * keep their original assignment (default 5 / account 1_000+) untouched.
+ *
+ * Scope: only the generic routed `/` rows call this (see the goModels loop
+ * below). Native passthrough rows and account-qualified native rows keep their own priority
+ * logic and are intentionally not reordered here — this matches the documented contract on
+ * OcxConfig.modelPickerOrder (route native ordering through subagentModels instead).
+ */
+ const pickerOrderPriority = (slug: string, altSlug?: string): number | undefined => {
+ if (!pickerOrderActive) return undefined;
+ const hit = pickerOrderRank.get(slug) ?? (altSlug !== undefined ? pickerOrderRank.get(altSlug) : undefined);
+ if (hit === undefined) return undefined;
+ return PICKER_ORDER_PRIORITY_BASE + hit * priorityStride;
+ };
const out: RawEntry[] = [];
const nativeEntries: RawEntry[] = [];
const collisionSkipped = resolveSlugAliasCollisions([...goModels]);
@@ -537,11 +590,24 @@ export function buildCatalogEntriesFromObservedState({
}
// Featured picks may be stored raw (legacy) or encoded — honor both.
const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`);
+ // Natural priority: what the row would get WITHOUT modelPickerOrder. This is the value the
+ // spawn_agent candidate window is derived from (see effectiveSubagentRoster), so it must never
+ // move when modelPickerOrder reorders the picker.
if (rankHit !== undefined) e.priority = rankHit * priorityStride;
else if (accountSelectors.length > 0) {
// Keep the generated account rows together in Codex's priority-sorted flat picker.
e.priority = 1_000 + (typeof e.priority === "number" ? e.priority : 5);
}
+ // #1649: modelPickerOrder is a DISPLAY-ONLY override. Record the natural priority spawn_agent
+ // must keep using, then let modelPickerOrder move only the Codex-visible `priority`. Featured
+ // rows are never overridden (their rank is authoritative for both display and spawn).
+ if (rankHit === undefined) {
+ const pickerPriority = pickerOrderPriority(slug, `${m.provider}/${m.id}`);
+ if (pickerPriority !== undefined) {
+ e[SPAWN_PRIORITY_FIELD] = typeof e.priority === "number" ? e.priority : 5;
+ e.priority = pickerPriority;
+ }
+ }
out.push(e);
}
// Central capability override (phase 120.4): the advertised flag must match the implemented WS
@@ -1324,6 +1390,7 @@ function writeRetainedCatalogSync({
const enabledGo = filterCatalogVisibleModels(goModels, config);
const featured = config.subagentModels ?? [];
const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
+ const modelPickerOrder = config.modelPickerOrder ?? [];
const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
const exactComboSlugs = exactComboCatalogSlugs(config);
const suppressedBareNativeSlugs = desktopAllowlistSuppressedNativeSlugs(config);
@@ -1355,6 +1422,7 @@ function writeRetainedCatalogSync({
gptSlugs: [],
goModels: orderedGoModels,
featured,
+ modelPickerOrder,
wsEnabled,
multiAgentMode,
exactComboSlugs,
diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts
index 4054e5000c..de35535f0a 100644
--- a/src/codex/convergence.ts
+++ b/src/codex/convergence.ts
@@ -225,6 +225,7 @@ function prepareCatalog(
const enabled = filterCatalogVisibleModels(routedModels, config);
const featured = config.subagentModels ?? [];
const ordered = orderForSubagents(enabled, featured);
+ const modelPickerOrder = config.modelPickerOrder ?? [];
const multiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2"
? config.multiAgentMode : "default";
const exactComboSlugs = exactComboCatalogSlugs(config);
@@ -255,6 +256,7 @@ function prepareCatalog(
gptSlugs: [],
goModels: ordered,
featured,
+ modelPickerOrder,
wsEnabled: websocketsEnabled(config),
multiAgentMode,
exactComboSlugs,
diff --git a/src/types.ts b/src/types.ts
index 9bca99661f..2045931286 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -652,6 +652,20 @@ export interface OcxConfig {
* into a selector-qualified group; Codex still advertises only the first 5 visible rows.
*/
subagentModels?: string[];
+ /**
+ * Optional full picker ordering for the Codex model catalog, independent of the
+ * 5-slot `subagentModels` spawn_agent cap. DISPLAY-ONLY: it controls the visual order of
+ * the Codex model picker for large routed catalogs (10-20+ models) that would otherwise sort
+ * arbitrarily and reshuffle on every rebuild. Values are routed `/` catalog
+ * slugs (matched by exact slug or `provider/id`); native OpenAI passthrough rows and
+ * account-qualified native rows are not reordered (order native rows via `subagentModels`).
+ * Listed routed rows appear in array order; rows not listed keep their normal display order.
+ * `subagentModels`-featured rows keep their top position. When unset or empty, catalog
+ * priority is unchanged. This changes ONLY what the user sees in the picker: the spawn_agent
+ * candidate set is derived from each row's natural priority and is provably unaffected, even
+ * when every routed row is listed (see opencodex_spawn_priority / effectiveSubagentRoster).
+ */
+ modelPickerOrder?: string[];
/**
* Priority-ordered fallback models for spawned sub-agents. When the requested
* model is quota-exhausted or recently failed, opencodex rewrites the child
diff --git a/tests/codex-catalog-model-picker-order.test.ts b/tests/codex-catalog-model-picker-order.test.ts
new file mode 100644
index 0000000000..1bbc02a834
--- /dev/null
+++ b/tests/codex-catalog-model-picker-order.test.ts
@@ -0,0 +1,188 @@
+import { describe, expect, test } from "bun:test";
+import {
+ buildCatalogEntriesFromObservedState,
+ effectiveSubagentRoster,
+ MAX_SPAWN_AGENT_MODEL_OVERRIDES,
+} from "../src/codex/catalog/sync";
+import type { CatalogModel } from "../src/types";
+
+// #1649: config.modelPickerOrder assigns a deterministic priority band to non-featured routed
+// rows so a catalog with more than 5 routed models keeps a stable picker order across rebuilds,
+// independent of the 5-slot subagentModels spawn_agent cap.
+
+function template(): Record {
+ return {
+ slug: "gpt-5.5",
+ display_name: "gpt-5.5",
+ description: "Native GPT model",
+ priority: 1,
+ visibility: "list",
+ tool_mode: "code",
+ };
+}
+
+const goModels = [
+ { id: "glm-5.2", provider: "jd-chat", owned_by: "jd" },
+ { id: "kimi-k3", provider: "jd-chat", owned_by: "jd" },
+ { id: "deepseek-v4-pro", provider: "tyler", owned_by: "tyler" },
+ { id: "sonnet-5", provider: "jd-claude", owned_by: "jd" },
+] as unknown as CatalogModel[];
+
+function build(overrides: { featured?: string[]; modelPickerOrder?: string[] }) {
+ const entries = buildCatalogEntriesFromObservedState({
+ template: template() as never,
+ gptSlugs: [],
+ goModels,
+ featured: overrides.featured,
+ modelPickerOrder: overrides.modelPickerOrder,
+ wsEnabled: false,
+ multiAgentMode: "default",
+ exactComboSlugs: new Set(),
+ accountSelectors: [],
+ suppressedBareNativeSlugs: new Set(),
+ disabledNativeAccountSlugs: new Set(),
+ multiAgentV2Enabled: false,
+ });
+ return Object.fromEntries(entries.map(e => {
+ const r = e as Record;
+ return [r.slug as string, r.priority as number];
+ })) as Record;
+}
+
+describe("modelPickerOrder (#1649)", () => {
+ test("unset leaves every non-featured routed row at the flat default priority", () => {
+ const p = build({});
+ expect(p["jd-chat/glm-5.2"]).toBe(5);
+ expect(p["jd-chat/kimi-k3"]).toBe(5);
+ expect(p["tyler/deepseek-v4-pro"]).toBe(5);
+ expect(p["jd-claude/sonnet-5"]).toBe(5);
+ });
+
+ test("listed rows sort among themselves in declared order, in the high picker tier", () => {
+ const p = build({
+ modelPickerOrder: [
+ "tyler/deepseek-v4-pro",
+ "jd-chat/kimi-k3",
+ "jd-chat/glm-5.2",
+ ],
+ });
+ // Declared order is honored among the listed rows.
+ expect(p["tyler/deepseek-v4-pro"]).toBeLessThan(p["jd-chat/kimi-k3"]);
+ expect(p["jd-chat/kimi-k3"]).toBeLessThan(p["jd-chat/glm-5.2"]);
+ // Listed rows occupy the high picker tier (>= 1000); an unlisted, non-featured row keeps its
+ // default priority (5) and therefore is NOT reordered by modelPickerOrder.
+ expect(p["tyler/deepseek-v4-pro"]).toBeGreaterThanOrEqual(1000);
+ expect(p["jd-claude/sonnet-5"]).toBe(5);
+ });
+
+ test("featured rows keep their top priority ahead of the picker-order band", () => {
+ const p = build({
+ featured: ["jd-claude/sonnet-5"],
+ modelPickerOrder: ["tyler/deepseek-v4-pro", "jd-chat/kimi-k3"],
+ });
+ // Featured wins outright (priority 0).
+ expect(p["jd-claude/sonnet-5"]).toBe(0);
+ // Picker-order rows come after the featured band.
+ expect(p["tyler/deepseek-v4-pro"]).toBeGreaterThan(p["jd-claude/sonnet-5"]);
+ expect(p["tyler/deepseek-v4-pro"]).toBeLessThan(p["jd-chat/kimi-k3"]);
+ });
+
+ // Regression for the review on #1666: modelPickerOrder must not change spawn_agent candidate
+ // eligibility. spawn_agent takes the first MAX_SPAWN_AGENT_MODEL_OVERRIDES picker rows by
+ // ascending priority. The picker-order band lives in the high (>= 1_000) tier, so featured
+ // rows (0..N-1) and any default-tier routed rows (priority 5) fill the candidate window first;
+ // a row that is ONLY placed by modelPickerOrder does not displace a default-tier candidate.
+ test("picker-order-only rows do not displace default-tier spawn_agent candidates", () => {
+ const manyRouted = [
+ // Not in modelPickerOrder -> stay at default priority 5 -> fill the candidate window.
+ { id: "unlisted-a", provider: "jd-chat", owned_by: "jd" },
+ { id: "unlisted-b", provider: "jd-chat", owned_by: "jd" },
+ { id: "unlisted-c", provider: "jd-chat", owned_by: "jd" },
+ { id: "unlisted-d", provider: "jd-chat", owned_by: "jd" },
+ { id: "unlisted-e", provider: "jd-chat", owned_by: "jd" },
+ // Placed only by modelPickerOrder -> high tier -> must stay out of the candidate window.
+ { id: "deepseek-v4-pro", provider: "tyler", owned_by: "tyler" },
+ { id: "kimi-k3", provider: "jd-chat", owned_by: "jd" },
+ ] as unknown as CatalogModel[];
+ const order = ["tyler/deepseek-v4-pro", "jd-chat/kimi-k3"];
+ const entries = buildCatalogEntriesFromObservedState({
+ template: template() as never,
+ gptSlugs: [],
+ goModels: manyRouted,
+ featured: [],
+ modelPickerOrder: order,
+ wsEnabled: false,
+ multiAgentMode: "default",
+ exactComboSlugs: new Set(),
+ accountSelectors: [],
+ suppressedBareNativeSlugs: new Set(),
+ disabledNativeAccountSlugs: new Set(),
+ multiAgentV2Enabled: false,
+ });
+ const candidateSlugs = effectiveSubagentRoster([], "default", entries).candidates.map(c => c.model);
+ expect(candidateSlugs.length).toBe(MAX_SPAWN_AGENT_MODEL_OVERRIDES);
+ // The picker-order-only rows are pushed to the high tier and never enter the window.
+ expect(candidateSlugs).not.toContain("tyler/deepseek-v4-pro");
+ expect(candidateSlugs).not.toContain("jd-chat/kimi-k3");
+ });
+
+ // Documents the scope boundary raised in review: modelPickerOrder targets routed
+ // / rows only. A bare native slug listed here must NOT reorder its native
+ // passthrough row (native ordering goes through subagentModels).
+ test("a bare native slug in modelPickerOrder does not reorder its native row", () => {
+ const entries = buildCatalogEntriesFromObservedState({
+ template: template() as never,
+ gptSlugs: ["gpt-5.5", "gpt-5.4"],
+ goModels: [{ id: "glm-5.2", provider: "jd-chat", owned_by: "jd" }] as unknown as CatalogModel[],
+ featured: [],
+ modelPickerOrder: ["gpt-5.4", "jd-chat/glm-5.2"],
+ wsEnabled: false,
+ multiAgentMode: "default",
+ exactComboSlugs: new Set(),
+ accountSelectors: [],
+ suppressedBareNativeSlugs: new Set(),
+ disabledNativeAccountSlugs: new Set(),
+ multiAgentV2Enabled: false,
+ });
+ const p = Object.fromEntries((entries as Record[]).map(e => [e.slug as string, e.priority as number]));
+ // The native row keeps its native priority (9), untouched by modelPickerOrder.
+ expect(p["gpt-5.4"]).toBe(9);
+ // The routed row IS placed in the high picker tier.
+ expect(p["jd-chat/glm-5.2"]).toBeGreaterThanOrEqual(1000);
+ });
+
+ // Decisive regression for #1666: even when EVERY routed row is listed in modelPickerOrder in
+ // reverse order (exhausting the default tier entirely), the spawn_agent candidate SET is
+ // unchanged. This is the case a single display-priority band cannot satisfy; the candidate
+ // window is derived from the natural priority (opencodex_spawn_priority), not display order.
+ test("candidate set is unchanged when all routed rows are listed in reverse order", () => {
+ const sixRouted = [
+ { id: "m1", provider: "jd-chat", owned_by: "jd" },
+ { id: "m2", provider: "jd-chat", owned_by: "jd" },
+ { id: "m3", provider: "jd-chat", owned_by: "jd" },
+ { id: "m4", provider: "jd-chat", owned_by: "jd" },
+ { id: "m5", provider: "jd-chat", owned_by: "jd" },
+ { id: "m6", provider: "jd-chat", owned_by: "jd" },
+ ] as unknown as CatalogModel[];
+ const buildWith = (modelPickerOrder?: string[]) => buildCatalogEntriesFromObservedState({
+ template: template() as never,
+ gptSlugs: [],
+ goModels: sixRouted,
+ featured: [],
+ modelPickerOrder,
+ wsEnabled: false,
+ multiAgentMode: "default",
+ exactComboSlugs: new Set(),
+ accountSelectors: [],
+ suppressedBareNativeSlugs: new Set(),
+ disabledNativeAccountSlugs: new Set(),
+ multiAgentV2Enabled: false,
+ });
+ const baseline = effectiveSubagentRoster([], "default", buildWith(undefined)).candidates.map(c => c.model);
+ const reversed = ["jd-chat/m6", "jd-chat/m5", "jd-chat/m4", "jd-chat/m3", "jd-chat/m2", "jd-chat/m1"];
+ const withOrder = effectiveSubagentRoster([], "default", buildWith(reversed)).candidates.map(c => c.model);
+ // The candidate SET (membership) is identical regardless of display reordering.
+ expect([...withOrder].sort()).toEqual([...baseline].sort());
+ expect(withOrder.length).toBe(MAX_SPAWN_AGENT_MODEL_OVERRIDES);
+ });
+});
From 1b68c4c91d4bfced85a28f7edfed53c6d816f54d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=B3=E4=BF=8A=E6=B1=9F?=
Date: Fri, 7 Aug 2026 08:31:27 +0800
Subject: [PATCH 04/17] feat(combos): add imageInput auto|disabled capability
control
Per-combo image/multimodal policy: default auto keeps the target
intersection; disabled strips image from catalog modalities and rejects
image-bearing requests (including previous_response_id expansions) with
HTTP 400 before dispatch. Management API sparsifies only explicit
disabled. GUI exposes a capability switch when every target supports
images.
---
docs-site/src/content/docs/guides/combos.md | 9 ++++
.../docs/reference/configuration/routing.md | 1 +
.../src/content/docs/zh-cn/guides/combos.md | 5 +++
.../zh-cn/reference/configuration/routing.md | 1 +
gui/src/combo-capabilities.ts | 14 ++++++
gui/src/combo-workspace-data.ts | 11 +++++
.../components/combo-workspace-add-modal.tsx | 9 +++-
.../components/combo-workspace-controls.tsx | 45 +++++++++++++++++++
.../combo-workspace-detail-panel.tsx | 11 ++++-
gui/src/components/combo-workspace-types.ts | 1 +
gui/src/i18n/de.ts | 4 ++
gui/src/i18n/en.ts | 4 ++
gui/src/i18n/ja.ts | 4 ++
gui/src/i18n/ko.ts | 4 ++
gui/src/i18n/ru.ts | 4 ++
gui/src/i18n/zh.ts | 4 ++
gui/src/pages/Combos.tsx | 10 ++++-
gui/src/styles-combos-workspace.css | 32 +++++++++++++
src/codex/catalog/aggregation.ts | 6 ++-
src/combos/index.ts | 1 +
src/combos/request.ts | 11 +++++
src/combos/types.ts | 6 +++
src/server/management/combo-routes.ts | 3 ++
src/server/responses/core.ts | 19 +++++++-
src/types.ts | 6 +++
tests/codex-catalog.test.ts | 12 +++++
tests/combo-management-api.test.ts | 27 ++++++++++-
tests/combo-workspace-data.test.ts | 37 +++++++++++++++
tests/server-combo-failover-e2e.test.ts | 17 +++++++
29 files changed, 310 insertions(+), 8 deletions(-)
create mode 100644 gui/src/combo-capabilities.ts
diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md
index 434cdcbb46..7c0bd6e39e 100644
--- a/docs-site/src/content/docs/guides/combos.md
+++ b/docs-site/src/content/docs/guides/combos.md
@@ -211,6 +211,14 @@ default and leaves the target's own behavior unchanged. Supported values are `lo
`high`, `xhigh`, `max`, and `ultra`; omit the field or set it to `null` to leave effort entirely to
the caller and target.
+## Image / multimodal capability
+
+By default a combo publishes the **intersection** of its targets' input modalities (image is
+enabled only when every target advertises it). Set `imageInput: "disabled"` to force text-only
+even when every target supports images — the catalog drops `image` from `inputModalities`, and
+image-bearing requests are rejected with HTTP 400 before any target is called. `"auto"` (or
+omitting the field) keeps the automatic intersection.
+
## Encrypted v2 sub-agent tasks
There is one important limitation for Codex v2 sub-agents ([issue #92](https://github.com/lidge-jun/opencodex/issues/92)).
@@ -304,6 +312,7 @@ Combos are stored in the top-level `combos` object, keyed by combo id:
| `strategy` | No | `"failover"` | `"failover"` or `"round-robin"`. |
| `stickyLimit` | No | `1` | Integer from 1 to 100 successful requests per round-robin selection. |
| `defaultEffort` | No | `null` | `low`, `medium`, `high`, `xhigh`, `max`, or `ultra`; applied only when the caller omits effort and the target advertises support. |
+| `imageInput` | No | `"auto"` | `"auto"` or `"disabled"`. `"disabled"` drops image from published modalities and rejects image-bearing requests before dispatch. Cannot enable image when a target lacks it. |
| `alias` | No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. |
| `nativeAlias` | No | `false` | Explicitly permit a currently supported bare native `alias` to take routing and catalog precedence. Never inferred from the alias. |
| `displayName` | No | none | Bounded display-only catalog label. Required and non-empty when `nativeAlias` is true. |
diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md
index a2ba4b01b2..38258543e7 100644
--- a/docs-site/src/content/docs/reference/configuration/routing.md
+++ b/docs-site/src/content/docs/reference/configuration/routing.md
@@ -76,6 +76,7 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`,
| `strategy?` | `"failover" \| "round-robin"` | `"failover"` | Selection strategy. Target order is failover priority; weights shape smooth weighted round-robin. |
| `stickyLimit?` | `number` | `1` | Successful requests retained in one round-robin batch. Range 1–100. |
| `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | Applied only when the caller omits effort and the selected target advertises the requested rung. |
+| `imageInput?` | `"auto" \| "disabled"` | `"auto"` | `"disabled"` drops image from the published modalities and rejects image-bearing requests before dispatch. Cannot enable image when a target lacks it. |
| `alias?` | `string` | — | Optional public model id in place of the canonical picker slug. |
| `nativeAlias?` | `boolean` | `false` | Let a currently supported bare native id take precedence only for that unqualified id. Bare `gpt-5.6-*` ids use Codex Pool/Direct credentials. Account-qualified routes remain distinct. Provider-qualified routes such as `openai-apikey/gpt-5.6-*` use their configured API-key route and never fall through to the native alias. |
| `displayName?` | `string` | — | Display-only catalog label, required and non-empty for a native alias. |
diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md
index 244d3d71ba..ce0706eb4d 100644
--- a/docs-site/src/content/docs/zh-cn/guides/combos.md
+++ b/docs-site/src/content/docs/zh-cn/guides/combos.md
@@ -158,6 +158,10 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。
当目标能力未知,或者不包含配置的 effort 时,opencodex 会省略默认值,并保持目标自身行为不变。支持的值是 `low`、`medium`、`high`、`xhigh`、`max` 和 `ultra`;省略该字段或将其设为 `null`,就会把 effort 完全交给调用方和目标。
+## 图片 / 多模态能力
+
+默认情况下,combo 会发布其目标 **input modalities 的交集**(只有当每个目标都声明支持图片时,图片才会启用)。设置 `imageInput: "disabled"` 可在目标均支持图片时仍强制仅文本——目录会从 `inputModalities` 中去掉 `image`,带图请求会在分发前以 HTTP 400 拒绝。`"auto"`(或省略该字段)保持自动交集。
+
## 加密的 v2 子代理任务
对于 Codex v2 子代理,有一个重要限制([issue #92](https://github.com/lidge-jun/opencodex/issues/92))。原生父进程只能把新启动 worker 的任务,以为原生 ChatGPT 后端生成的密文形式发送出去。外部 provider 无法读取那段负载。
@@ -241,6 +245,7 @@ combo 会存储在顶层的 `combos` 对象中,并以 combo id 作为键:
| `strategy` | 否 | `"failover"` | `"failover"` 或 `"round-robin"`。 |
| `stickyLimit` | 否 | `1` | 每次轮询选择可连续处理的成功请求数,范围为 1 到 100。 |
| `defaultEffort` | 否 | `null` | `low`、`medium`、`high`、`xhigh`、`max` 或 `ultra`;仅当调用方省略 effort 且目标声明支持时才会应用。 |
+| `imageInput` | 否 | `"auto"` | `"auto"` 或 `"disabled"`。`"disabled"` 会从对外能力中去掉图片,并在分发前拒绝带图请求;不能在目标不支持时强开图片。 |
| `alias` | 否 | 无 | 可选的、已修剪的公开模型 id;使用上面的别名规则。空值会以“无别名”形式存储。 |
| `nativeAlias` | 否 | `false` | 显式允许当前受支持的裸原生 alias 接管路由和 catalog 优先级;绝不会根据 alias 自动推断。 |
| `displayName` | 否 | 无 | 仅用于 catalog 展示的有界标签;`nativeAlias` 为 true 时必须非空。 |
diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md
index 166a2ce17b..49cb567970 100644
--- a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md
+++ b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md
@@ -64,6 +64,7 @@ Codex Auth 页面将此 picker 行为作为选择加入项。关闭它会隐藏
| `strategy?` | `"failover" \| "round-robin"` | `"failover"` | 选择策略。目标顺序表示故障切换优先级;权重会影响平滑加权轮询。 |
| `stickyLimit?` | `number` | `1` | 在单个轮询批次中保留的成功请求数。范围 1–100。 |
| `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | 仅在调用方省略 effort 且所选目标声明了请求的档位时应用。 |
+| `imageInput?` | `"auto" \| "disabled"` | `"auto"` | `"disabled"` 会从对外能力中去掉图片,并在分发前拒绝带图请求;不能在目标不支持时强开图片。 |
| `alias?` | `string` | — | 可选的公开 model id,用于替代规范化的选择器 slug。 |
| `nativeAlias?` | `boolean` | `false` | 仅让当前受支持的裸原生 id 对该不带限定前缀的 id 优先;带账号或提供方限定的 OpenAI 路由仍是独立路由。 |
| `displayName?` | `string` | — | 仅用于 catalog 展示的标签;native alias 必须提供非空值。 |
diff --git a/gui/src/combo-capabilities.ts b/gui/src/combo-capabilities.ts
new file mode 100644
index 0000000000..4499416fe8
--- /dev/null
+++ b/gui/src/combo-capabilities.ts
@@ -0,0 +1,14 @@
+import type { ComboTarget } from "./combo-workspace-data";
+import type { ModelOption } from "./components/combo-workspace-types";
+
+/** Whether every complete target advertises image input. */
+export function comboImagesSupported(targets: ComboTarget[], models: ModelOption[]): boolean {
+ const complete = targets.filter((target) => target.provider.trim() && target.model.trim());
+ if (complete.length === 0) return false;
+ return complete.every((target) => {
+ const model = models.find(
+ (row) => row.provider === target.provider.trim() && row.id === target.model.trim(),
+ );
+ return !!model?.inputModalities?.includes("image");
+ });
+}
diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts
index 56ce088238..4f5e96e0b7 100644
--- a/gui/src/combo-workspace-data.ts
+++ b/gui/src/combo-workspace-data.ts
@@ -60,6 +60,11 @@ export function newComboTarget(partial: Partial = {}): ComboTarget
};
}
+
+function normalizeImageInput(value: unknown): "auto" | "disabled" {
+ return value === "disabled" ? "disabled" : "auto";
+}
+
export interface ComboItem {
id: string;
/** Wire id shown to clients, e.g. combo/free */
@@ -73,6 +78,7 @@ export interface ComboItem {
strategy: ComboStrategy;
stickyLimit: number;
defaultEffort: ComboEffort | null;
+ imageInput?: "auto" | "disabled";
targets: ComboTarget[];
}
@@ -177,6 +183,7 @@ export function parseComboList(payload: unknown): ComboItem[] {
strategy: normalizeStrategy(r.strategy),
stickyLimit: normalizeStickyLimit(r.stickyLimit),
defaultEffort: normalizeDefaultEffort(r.defaultEffort),
+ imageInput: normalizeImageInput(r.imageInput),
targets,
});
}
@@ -236,6 +243,7 @@ export function draftEquals(a: ComboItem, b: ComboItem): boolean {
|| a.strategy !== b.strategy
|| a.stickyLimit !== b.stickyLimit
|| a.defaultEffort !== b.defaultEffort
+ || (a.imageInput ?? "auto") !== (b.imageInput ?? "auto")
) return false;
if (a.targets.length !== b.targets.length) return false;
return a.targets.every((t, i) => {
@@ -252,6 +260,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {}
strategy: ComboStrategy;
stickyLimit?: number;
defaultEffort: ComboEffort | null;
+ imageInput?: "disabled";
alias?: string;
nativeAlias?: true;
displayName?: string;
@@ -266,6 +275,7 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {}
: { provider: target.provider.trim(), model: target.model.trim() }),
strategy: item.strategy,
defaultEffort: item.defaultEffort,
+ ...(item.imageInput === "disabled" ? { imageInput: "disabled" as const } : {}),
...(item.strategy === "round-robin" ? { stickyLimit: item.stickyLimit } : {}),
...(item.alias && item.alias.trim() ? { alias: item.alias.trim() } : {}),
...(item.nativeAlias ? { nativeAlias: true } : {}),
@@ -372,6 +382,7 @@ export function emptyDraft(id = ""): ComboItem {
strategy: "failover",
stickyLimit: 1,
defaultEffort: null,
+ imageInput: "auto",
targets: [newComboTarget()],
};
}
diff --git a/gui/src/components/combo-workspace-add-modal.tsx b/gui/src/components/combo-workspace-add-modal.tsx
index cff828c5c8..4d334a759f 100644
--- a/gui/src/components/combo-workspace-add-modal.tsx
+++ b/gui/src/components/combo-workspace-add-modal.tsx
@@ -10,7 +10,7 @@ import { IconX } from "../icons";
import { useT } from "../i18n/shared";
import { Notice } from "../ui";
import type { ModelOption, ProviderOption } from "./combo-workspace-types";
-import { EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls";
+import { ComboCapabilities, EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls";
import { clampedNumberInput } from "./combo-workspace-utils";
export function AddComboModal({
@@ -204,6 +204,13 @@ export function AddComboModal({
{draft.strategy === "failover" ? t("cws.targets.failoverHint") : t("cws.targets.roundRobinHint")}
+ setDraft((d) => ({ ...d, ...patch }))}
+ />
diff --git a/gui/src/components/combo-workspace-controls.tsx b/gui/src/components/combo-workspace-controls.tsx
index 8a7277fd48..0a7ecaa65d 100644
--- a/gui/src/components/combo-workspace-controls.tsx
+++ b/gui/src/components/combo-workspace-controls.tsx
@@ -1,8 +1,10 @@
import { useState } from "react";
import type { ComboEffort, ComboStrategy, ComboTarget } from "../combo-workspace-data";
+import { comboImagesSupported } from "../combo-capabilities";
import { COMBO_EFFORTS, newComboTarget } from "../combo-workspace-data";
import { IconArrowDown, IconArrowUp, IconGrip, IconPlus, IconTrash } from "../icons";
import { useT } from "../i18n/shared";
+import { Switch } from "../ui";
import { formatProviderDisplayName } from "../provider-icons";
import type { ModelOption, ProviderOption } from "./combo-workspace-types";
import { clampedNumberInput, enabledProviders, modelsForProvider } from "./combo-workspace-utils";
@@ -83,6 +85,49 @@ export function EffortSelect({
);
}
+
+export function ComboCapabilities({
+ targets,
+ models,
+ imageInput,
+ disabled,
+ onChange,
+}: {
+ targets: ComboTarget[];
+ models: ModelOption[];
+ imageInput: "auto" | "disabled";
+ disabled?: boolean;
+ onChange: (patch: { imageInput?: "auto" | "disabled" }) => void;
+}) {
+ const t = useT();
+ const imagesSupported = comboImagesSupported(targets, models);
+ // Default: checked (auto) when supported; force off when any target lacks image.
+ const effectiveOn = imagesSupported && imageInput !== "disabled";
+
+ return (
+
+ {t("cws.capabilities")}
+
+
+
{t("cws.capability.imageInput")}
+
+ {imagesSupported ? t("cws.capability.imageInputHint") : t("cws.capability.imageInputUnavailable")}
+
+
+
{
+ if (!imagesSupported) return;
+ onChange({ imageInput: imageInput === "auto" ? "disabled" : "auto" });
+ }}
+ disabled={disabled || !imagesSupported}
+ label={t("cws.capability.imageInput")}
+ />
+
+
+ );
+}
+
export function TargetEditor({
targets,
strategy,
diff --git a/gui/src/components/combo-workspace-detail-panel.tsx b/gui/src/components/combo-workspace-detail-panel.tsx
index 39fa2f70a1..34edb0cf2a 100644
--- a/gui/src/components/combo-workspace-detail-panel.tsx
+++ b/gui/src/components/combo-workspace-detail-panel.tsx
@@ -12,7 +12,7 @@ import { IconChevron, IconTrash } from "../icons";
import { useT } from "../i18n/shared";
import { Notice } from "../ui";
import type { ModelOption, ProviderOption } from "./combo-workspace-types";
-import { EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls";
+import { ComboCapabilities, EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls";
import { clampedNumberInput } from "./combo-workspace-utils";
type DetailTab = "config" | "about";
@@ -81,7 +81,7 @@ export function DetailPanel({
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [copied, setCopied] = useState(false);
const dirty = !draftEquals(draft, baseline);
- const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.nativeAlias}:${baseline.displayName ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`;
+ const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.nativeAlias}:${baseline.displayName ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.imageInput ?? "auto"}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`;
const effortMap = useMemo(() => {
const map = new Map
();
for (const model of models) {
@@ -355,6 +355,13 @@ export function DetailPanel({
{draft.strategy === "failover" ? t("cws.targets.failoverHint") : t("cws.targets.roundRobinHint")}
+ updateDraft((d) => ({ ...d, ...patch }))}
+ />
)}
diff --git a/gui/src/components/combo-workspace-types.ts b/gui/src/components/combo-workspace-types.ts
index 39870e90f2..c4dc1eac6c 100644
--- a/gui/src/components/combo-workspace-types.ts
+++ b/gui/src/components/combo-workspace-types.ts
@@ -13,6 +13,7 @@ export type ModelOption = {
id: string;
namespaced?: string;
reasoningEfforts?: string[];
+ inputModalities?: string[];
};
export interface ComboWorkspaceProps {
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index b3c60d9232..df77d46367 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -1843,6 +1843,10 @@ export const de: Record = {
"cws.field.defaultEffort": "Standard-Reasoning",
"cws.field.defaultEffortNone": "Keine (Ziel-Standard)",
"cws.field.defaultEffortHint": "Nur verwendet, wenn der Client keinen Reasoning-Aufwand sendet. Optionen sind die Schnittmenge der beworbenen Aufwände der gewählten Ziele.",
+ "cws.capability.imageInputUnavailable": "Erst verfügbar, wenn jedes gewählte Ziel Bildeingabe unterstützt.",
+ "cws.capability.imageInputHint": "Standardmäßig aktiv, wenn jedes Ziel Bilder unterstützt. Ausschalten für nur Text.",
+ "cws.capability.imageInput": "Bild / multimodal",
+ "cws.capabilities": "Fähigkeiten",
"cws.field.defaultEffortUnsupported": "Dieser Aufwand liegt nicht in der gemeinsamen Leiter der Ziele — er wird zur Anfragezeit ignoriert oder angepasst.",
"cws.field.defaultEffortUnsupportedOption": "nicht in der Schnittmenge",
"cws.targets": "Ziele",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index 04862b599d..9d695a2550 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -1883,6 +1883,10 @@ export const en = {
"cws.field.defaultEffort": "Default reasoning",
"cws.field.defaultEffortNone": "None (target default)",
"cws.field.defaultEffortHint": "Used only when the client omits reasoning effort. Options are the intersection of the selected targets' advertised efforts; targets without catalog effort metadata offer none.",
+ "cws.capability.imageInputUnavailable": "Unavailable until every selected target supports image input.",
+ "cws.capability.imageInputHint": "On by default when every target supports images. Turn off to accept text only.",
+ "cws.capability.imageInput": "Image / multimodal",
+ "cws.capabilities": "Capabilities",
"cws.field.defaultEffortUnsupported": "This effort is not in the targets' common ladder — it will be ignored or snapped at request time.",
"cws.field.defaultEffortUnsupportedOption": "not in intersection",
"cws.targets": "Targets",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index 49b3187ba6..405d4342ac 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -1902,6 +1902,10 @@ export const ja: Record = {
"cws.field.defaultEffort": "デフォルトの推論",
"cws.field.defaultEffortNone": "なし(ターゲットのデフォルト)",
"cws.field.defaultEffortHint": "クライアントが推論負荷を省略した場合のみ使用されます。選択肢は選択ターゲットが広告する負荷の交差です。",
+ "cws.capability.imageInputUnavailable": "選択した全ターゲットが画像入力に対応すると有効になります。",
+ "cws.capability.imageInputHint": "全ターゲットが画像対応なら既定でオン。オフにするとテキストのみ。",
+ "cws.capability.imageInput": "画像 / マルチモーダル",
+ "cws.capabilities": "能力",
"cws.field.defaultEffortUnsupported": "この負荷はターゲット共通の階段にありません — リクエスト時に無視またはスナップされます。",
"cws.field.defaultEffortUnsupportedOption": "交差に含まれない",
"cws.targets": "ターゲット",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index 03aa89a664..6040891320 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -1870,6 +1870,10 @@ export const ko: Record = {
"cws.field.defaultEffort": "기본 추론 수준",
"cws.field.defaultEffortNone": "없음 (대상 기본값)",
"cws.field.defaultEffortHint": "클라이언트가 추론 수준을 생략한 경우에만 사용합니다. 옵션은 선택한 대상이 광고하는 수준의 교집합입니다.",
+ "cws.capability.imageInputUnavailable": "선택한 모든 대상이 이미지 입력을 지원해야 사용할 수 있습니다.",
+ "cws.capability.imageInputHint": "모든 대상이 이미지를 지원하면 기본으로 켜집니다. 끄면 텍스트만 허용합니다.",
+ "cws.capability.imageInput": "이미지 / 멀티모달",
+ "cws.capabilities": "기능",
"cws.field.defaultEffortUnsupported": "이 수준은 대상의 공통 사다리에 없습니다 — 요청 시 무시되거나 스냅됩니다.",
"cws.field.defaultEffortUnsupportedOption": "교집합에 없음",
"cws.targets": "대상",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 965e50703a..d9b5bc9857 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -1953,6 +1953,10 @@ export const ru: Record = {
"cws.field.defaultEffort": "Рассуждения по умолчанию",
"cws.field.defaultEffortNone": "Нет (по умолчанию для цели)",
"cws.field.defaultEffortHint": "Используется, только если клиент не указал уровень рассуждений. Варианты — пересечение заявленных уровней выбранных целей.",
+ "cws.capability.imageInputUnavailable": "Доступно, когда все выбранные цели поддерживают ввод изображений.",
+ "cws.capability.imageInputHint": "Включено по умолчанию, если все цели поддерживают изображения. Выключите, чтобы принимать только текст.",
+ "cws.capability.imageInput": "Изображения / мультимодальность",
+ "cws.capabilities": "Возможности",
"cws.field.defaultEffortUnsupported": "Этот уровень не входит в общую лестницу целей — при запросе он будет проигнорирован или снижен.",
"cws.field.defaultEffortUnsupportedOption": "нет в пересечении",
"cws.targets": "Цели",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index 88f6a5554b..e939896edf 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -1863,6 +1863,10 @@ export const zh: Record = {
"cws.field.defaultEffort": "默认推理级别",
"cws.field.defaultEffortNone": "无(使用目标默认)",
"cws.field.defaultEffortHint": "仅在客户端未指定推理级别时使用。选项为所选目标已公布努力级别的交集。",
+ "cws.capability.imageInputUnavailable": "所有已选目标均支持图片输入后才可用。",
+ "cws.capability.imageInputHint": "所有目标均支持图片时默认开启;关闭后仅接受文本。",
+ "cws.capability.imageInput": "图片 / 多模态",
+ "cws.capabilities": "能力",
"cws.field.defaultEffortUnsupported": "该级别不在目标的公共阶梯中 — 请求时会被忽略或就近映射。",
"cws.field.defaultEffortUnsupportedOption": "不在交集中",
"cws.targets": "目标",
diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx
index 7b4fc86d67..691ba1146c 100644
--- a/gui/src/pages/Combos.tsx
+++ b/gui/src/pages/Combos.tsx
@@ -21,7 +21,7 @@ type ProviderOption = {
adapter?: string;
baseUrl?: string;
};
-type ModelOption = { provider: string; id: string; namespaced?: string; reasoningEfforts?: string[] };
+type ModelOption = { provider: string; id: string; namespaced?: string; reasoningEfforts?: string[]; inputModalities?: string[] };
type ProviderDto = {
adapter: string;
baseUrl: string;
@@ -148,6 +148,7 @@ export default function Combos({
namespaced?: unknown;
disabled?: unknown;
reasoningEfforts?: unknown;
+ inputModalities?: unknown;
};
if (typeof model.provider !== "string" || typeof model.id !== "string") continue;
const provider = model.provider.trim();
@@ -161,11 +162,18 @@ export default function Combos({
const reasoningEfforts = Array.isArray(model.reasoningEfforts)
? model.reasoningEfforts.filter((effort): effort is string => typeof effort === "string")
: undefined;
+ const inputModalities = Array.isArray(model.inputModalities)
+ ? model.inputModalities
+ .filter((modality): modality is string => typeof modality === "string")
+ .map((modality) => modality.trim())
+ .filter(Boolean)
+ : undefined;
models.push({
provider,
id,
namespaced: typeof model.namespaced === "string" ? model.namespaced : undefined,
...(reasoningEfforts ? { reasoningEfforts } : {}),
+ ...(inputModalities && inputModalities.length > 0 ? { inputModalities } : {}),
});
}
diff --git a/gui/src/styles-combos-workspace.css b/gui/src/styles-combos-workspace.css
index 58279c128f..837f362d2f 100644
--- a/gui/src/styles-combos-workspace.css
+++ b/gui/src/styles-combos-workspace.css
@@ -322,6 +322,34 @@
overflow-wrap: anywhere;
}
+.cwi-capabilities {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 12px;
+ border: 1px solid var(--border-soft);
+ border-radius: var(--radius);
+ background: var(--raised);
+}
+
+.cwi-capability-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.cwi-capability-label {
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.cwi-capability-hint {
+ margin: 3px 0 0;
+ font-size: 12px;
+}
+
+
.cwi-target-list {
display: flex;
flex-direction: column;
@@ -489,6 +517,10 @@
border-bottom: 1px solid var(--border);
}
+ .cwi-capability-row {
+ align-items: flex-start;
+ }
+
.cwi-target-row,
.cwi-target-row--failover {
grid-template-columns: 28px auto 1fr auto;
diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts
index 1031f073a1..7fb2c04892 100644
--- a/src/codex/catalog/aggregation.ts
+++ b/src/codex/catalog/aggregation.ts
@@ -129,9 +129,13 @@ export function deriveComboCatalogModel(
): CatalogModel | null {
if (comboCatalogOmissionReason(combo, members) !== null) return null;
- const inputModalities = intersectStrings(
+ const derivedInputModalities = intersectStrings(
members.map(member => member.inputModalities ?? ["text"]),
);
+ const inputModalities = combo.imageInput === "disabled"
+ ? derivedInputModalities.filter(modality => modality !== "image")
+ : derivedInputModalities;
+ if (inputModalities.length === 0) return null;
// Unknown ladders (`undefined`) are wildcards for catalog derivation — same
// boundary as the GUI picker. An explicit empty ladder still constrains.
const advertisedLadders = members
diff --git a/src/combos/index.ts b/src/combos/index.ts
index 6041427a92..571eb540d5 100644
--- a/src/combos/index.ts
+++ b/src/combos/index.ts
@@ -39,6 +39,7 @@ export {
} from "./failover";
export {
comboIdFromRawBody,
+ comboRequestHasImageInput,
concreteComboRequestBody,
resetComboEffortWarningStateForTests,
} from "./request";
diff --git a/src/combos/request.ts b/src/combos/request.ts
index 7727b7689b..48b5ddff27 100644
--- a/src/combos/request.ts
+++ b/src/combos/request.ts
@@ -23,6 +23,17 @@ export function comboIdFromRawBody(body: unknown, config: OcxConfig): string | n
return resolveComboId(config, model);
}
+export function comboRequestHasImageInput(body: unknown): boolean {
+ const visit = (value: unknown): boolean => {
+ if (!value || typeof value !== "object") return false;
+ if (Array.isArray(value)) return value.some(visit);
+ const record = value as Record;
+ if (record.type === "input_image") return true;
+ return Object.values(record).some(visit);
+ };
+ return visit(body);
+}
+
export function concreteComboRequestBody(
body: unknown,
target: Pick,
diff --git a/src/combos/types.ts b/src/combos/types.ts
index c82c861a02..d1ac034096 100644
--- a/src/combos/types.ts
+++ b/src/combos/types.ts
@@ -37,6 +37,8 @@ export interface NormalizedComboConfig {
strategy: OcxComboStrategy;
stickyLimit: number;
defaultEffort: OcxComboDefaultEffort | null;
+ /** Disable image input; `auto` preserves the intersection derived from all targets. */
+ imageInput: "auto" | "disabled";
/** Trimmed public alias, or null when the combo keeps the default `combo/` slug. */
alias: string | null;
/** Explicit native-family alias opt-in. */
@@ -220,6 +222,9 @@ export function comboConfigIssues(
message: "defaultEffort must be one of: low, medium, high, xhigh, max, ultra",
});
}
+ if (body.imageInput !== undefined && body.imageInput !== "auto" && body.imageInput !== "disabled") {
+ issues.push({ path: ["imageInput"], message: 'imageInput must be "auto" or "disabled"' });
+ }
if (body.alias !== undefined) {
if (typeof body.alias !== "string") {
@@ -339,6 +344,7 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig
strategy: raw.strategy ?? "failover",
stickyLimit: raw.stickyLimit ?? 1,
defaultEffort: raw.defaultEffort ?? null,
+ imageInput: raw.imageInput === "disabled" ? "disabled" : "auto",
alias: alias || null,
nativeAlias: raw.nativeAlias === true,
displayName: displayName || null,
diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts
index 9c197d0d74..9c59d80631 100644
--- a/src/server/management/combo-routes.ts
+++ b/src/server/management/combo-routes.ts
@@ -124,10 +124,12 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise {
// Attempts remain the complete physical history; the logical row mirrors the most recent
// failed target so an exhausted combo still has useful top-level reasoning diagnostics.
@@ -1188,7 +1203,7 @@ export async function handleComboResponses(
};
const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
- (rawBody as { input?: unknown } | undefined)?.input,
+ (body as { input?: unknown } | undefined)?.input,
);
const canDecryptUnreadableAgentTask = (target: (typeof combo.targets)[number]): boolean => {
const provider = config.providers[target.provider];
@@ -1230,7 +1245,7 @@ export async function handleComboResponses(
};
const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`);
const childBody = concreteComboRequestBody(
- rawBody,
+ body,
pick.target,
comboDefaultEffort(config, comboId),
supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
diff --git a/src/types.ts b/src/types.ts
index 9bca99661f..a776360d8e 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -973,6 +973,12 @@ export interface OcxComboConfig {
stickyLimit?: number;
/** Used when the client omits reasoning.effort. null/omitted leaves the target default unchanged. */
defaultEffort?: OcxComboDefaultEffort | null;
+ /**
+ * Disable image input even when every target supports it.
+ * Omitted / `"auto"` keeps automatic capability derivation (default: enabled when
+ * the target intersection includes image).
+ */
+ imageInput?: "auto" | "disabled";
/**
* Optional public model name replacing the default `combo/` slug. Bare names
* without "/" are allowed (e.g. "deepseek-v4-flash") so the combo can answer to a
diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts
index 60021106f4..6632503e99 100644
--- a/tests/codex-catalog.test.ts
+++ b/tests/codex-catalog.test.ts
@@ -154,6 +154,18 @@ describe("live model provenance (#448 custom-model misclassification)", () => {
});
describe("combo catalog capability intersection", () => {
+
+ test("imageInput disabled strips image even when every member supports it", () => {
+ const visionMembers = [
+ { provider: "a", id: "m1", contextWindow: 128_000, maxInputTokens: 100_000, inputModalities: ["text", "image"], reasoningEfforts: ["low"] },
+ { provider: "b", id: "m2", contextWindow: 128_000, maxInputTokens: 100_000, inputModalities: ["text", "image"], reasoningEfforts: ["low"] },
+ ];
+ expect(deriveComboCatalogModel("text-only", normalizedCombo({ imageInput: "disabled" }), visionMembers))
+ .toEqual(expect.objectContaining({ inputModalities: ["text"] }));
+ expect(deriveComboCatalogModel("vision", normalizedCombo({ imageInput: "auto" }), visionMembers))
+ .toEqual(expect.objectContaining({ inputModalities: expect.arrayContaining(["text", "image"]) }));
+ });
+
const memberA = {
provider: "a",
id: "m1",
diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts
index 68d439a2a6..29e04798bc 100644
--- a/tests/combo-management-api.test.ts
+++ b/tests/combo-management-api.test.ts
@@ -254,7 +254,7 @@ describe("combo management API", () => {
success: true,
id: "zeta",
model: "combo/zeta",
- combo: { strategy: "failover", stickyLimit: 1, defaultEffort: null },
+ combo: { strategy: "failover", stickyLimit: 1, defaultEffort: null, imageInput: "auto" },
});
const updated = await comboApi(config, "PUT", "/api/combos", {
id: "zeta",
@@ -268,6 +268,31 @@ describe("combo management API", () => {
const listed = await responseJson(await comboApi(config, "GET", "/api/combos"));
expect((listed.combos as Array<{ id: string }>).map(row => row.id)).toEqual(["alpha", "zeta"]);
expect(listComboIds(config)).toEqual(["alpha", "zeta"]);
+ // Default imageInput is not written to disk — only explicit "disabled" is.
+ expect(config.combos?.zeta).not.toHaveProperty("imageInput");
+ });
+ });
+
+ test("PUT persists explicit imageInput disabled", async () => {
+ await withTempHome(async () => {
+ const config = baseConfig({ combos: undefined });
+ saveConfig(config);
+ const response = await comboApi(config, "PUT", "/api/combos", {
+ id: "limited",
+ combo: {
+ targets: [{ provider: "a", model: "m1" }],
+ imageInput: "disabled",
+ },
+ });
+ expect(response?.status).toBe(200);
+ expect(await responseJson(response)).toMatchObject({
+ combo: { imageInput: "disabled" },
+ });
+ expect(config.combos?.limited).toMatchObject({ imageInput: "disabled" });
+ const listed = await responseJson(await comboApi(config, "GET", "/api/combos"));
+ expect(listed.combos).toEqual([expect.objectContaining({
+ id: "limited", imageInput: "disabled",
+ })]);
});
});
diff --git a/tests/combo-workspace-data.test.ts b/tests/combo-workspace-data.test.ts
index c67fd4cac3..0d67e2b9a1 100644
--- a/tests/combo-workspace-data.test.ts
+++ b/tests/combo-workspace-data.test.ts
@@ -15,6 +15,7 @@ import {
updateComboAliasDraft,
validateComboDraft,
} from "../gui/src/combo-workspace-data";
+import { comboImagesSupported } from "../gui/src/combo-capabilities";
const configuredProviders = {
a: {},
@@ -95,6 +96,7 @@ describe("combo-workspace-data", () => {
strategy: "failover",
stickyLimit: 1,
defaultEffort: null,
+ imageInput: "auto",
targets: [{ provider: "a", model: "m1", weight: 1, clientKey: expect.stringMatching(/^ct-\d+$/) }],
},
{
@@ -106,6 +108,7 @@ describe("combo-workspace-data", () => {
strategy: "round-robin",
stickyLimit: 4,
defaultEffort: "high",
+ imageInput: "auto",
targets: [
{ provider: "a", model: "m1", weight: 3, clientKey: expect.stringMatching(/^ct-\d+$/) },
{ provider: "b", model: "m2", weight: 1, clientKey: expect.stringMatching(/^ct-\d+$/) },
@@ -530,3 +533,37 @@ describe("combo-workspace-data", () => {
)).toBe(false);
});
});
+
+
+describe("comboImagesSupported", () => {
+ test("returns false with no complete targets", () => {
+ expect(comboImagesSupported([], [])).toBe(false);
+ expect(comboImagesSupported([{ provider: "", model: "" }], [])).toBe(false);
+ });
+
+ test("returns true only when every complete target advertises image", () => {
+ const models = [
+ { provider: "a", id: "m1", inputModalities: ["text", "image"] },
+ { provider: "b", id: "m2", inputModalities: ["text", "image"] },
+ ];
+ expect(comboImagesSupported(
+ [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }],
+ models,
+ )).toBe(true);
+ });
+
+ test("returns false when any target is missing from the catalog or lacks image", () => {
+ const models = [
+ { provider: "a", id: "m1", inputModalities: ["text", "image"] },
+ { provider: "b", id: "m2", inputModalities: ["text"] },
+ ];
+ expect(comboImagesSupported(
+ [{ provider: "a", model: "m1" }, { provider: "b", model: "m2" }],
+ models,
+ )).toBe(false);
+ expect(comboImagesSupported(
+ [{ provider: "a", model: "m1" }, { provider: "b", model: "ghost" }],
+ models,
+ )).toBe(false);
+ });
+});
diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts
index 311d672507..35fce68ba1 100644
--- a/tests/server-combo-failover-e2e.test.ts
+++ b/tests/server-combo-failover-e2e.test.ts
@@ -1304,6 +1304,23 @@ describe("server combo failover 030 activation matrix", () => {
expect(bHits).toBe(2);
});
+ test("disabled image input rejects the request before any combo target is called", async () => {
+ let hits = 0;
+ const a = serve(() => {
+ hits += 1;
+ return chatSuccess("unexpected", "m1");
+ });
+ const config = comboConfig({ a: provider("openai-chat", baseUrl(a), "key-a") }, undefined, {
+ imageInput: "disabled",
+ });
+ const response = await post(config, {
+ input: [{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,aGVsbG8=" }] }],
+ });
+ expect(response.status).toBe(400);
+ expect(await response.text()).toContain("does not accept image input");
+ expect(hits).toBe(0);
+ });
+
test("fresh child reparsing recomputes vision and effort per target", async () => {
const bodies: Array<{ provider: string; body: Record }> = [];
const a = serve(async request => {
From 23db3b6bf91b101b4f0e68ad10f60d3bd42cf29c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=B3=E4=BF=8A=E6=B1=9F?=
Date: Fri, 7 Aug 2026 08:46:44 +0800
Subject: [PATCH 05/17] fix(combos): address imageInput review on sparse API
and guards
Fail closed for incomplete targets in comboImagesSupported; omit default
imageInput auto from management GET/PUT responses; reject unresolved
previous_response_id when images are disabled; cover disabled draft
persistence and normalizedCombo fixture.
---
gui/src/combo-capabilities.ts | 14 +++++------
src/server/management/combo-routes.ts | 15 ++++++++++--
src/server/responses/core.ts | 12 +++++++++
tests/codex-catalog.test.ts | 1 +
tests/combo-management-api.test.ts | 2 +-
tests/combo-workspace-data.test.ts | 35 ++++++++++++++++++++++++++-
6 files changed, 68 insertions(+), 11 deletions(-)
diff --git a/gui/src/combo-capabilities.ts b/gui/src/combo-capabilities.ts
index 4499416fe8..32f91f66b9 100644
--- a/gui/src/combo-capabilities.ts
+++ b/gui/src/combo-capabilities.ts
@@ -1,14 +1,14 @@
import type { ComboTarget } from "./combo-workspace-data";
import type { ModelOption } from "./components/combo-workspace-types";
-/** Whether every complete target advertises image input. */
+/** Whether every selected target advertises image input (incomplete rows fail closed). */
export function comboImagesSupported(targets: ComboTarget[], models: ModelOption[]): boolean {
- const complete = targets.filter((target) => target.provider.trim() && target.model.trim());
- if (complete.length === 0) return false;
- return complete.every((target) => {
- const model = models.find(
- (row) => row.provider === target.provider.trim() && row.id === target.model.trim(),
- );
+ if (targets.length === 0) return false;
+ return targets.every((target) => {
+ const provider = target.provider.trim();
+ const modelId = target.model.trim();
+ if (!provider || !modelId) return false;
+ const model = models.find((row) => row.provider === provider && row.id === modelId);
return !!model?.inputModalities?.includes("image");
});
}
diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts
index 9c59d80631..d347bef449 100644
--- a/src/server/management/combo-routes.ts
+++ b/src/server/management/combo-routes.ts
@@ -65,6 +65,16 @@ import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, C
import type { ManagementContext } from "./context";
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
+
+/** Management wire shape: omit default imageInput "auto" (persist/response sparse). */
+function sparseComboConfig(combo: T): Omit & { imageInput?: "disabled" } {
+ const { imageInput, ...rest } = combo;
+ return {
+ ...rest,
+ ...(imageInput === "disabled" ? { imageInput: "disabled" as const } : {}),
+ };
+}
+
export async function handleComboRoutes(ctx: ManagementContext): Promise {
const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
@@ -75,7 +85,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise 0;
+ if (combo.imageInput === "disabled" && unresolvedPrevious) {
+ return formatErrorResponse(
+ 400,
+ "previous_response_not_found",
+ "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
+ );
+ }
if (combo.imageInput === "disabled" && comboRequestHasImageInput(body)) {
return formatErrorResponse(400, "invalid_request_error", `Combo "${comboId}" does not accept image input`);
}
diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts
index 6632503e99..3b4eb00a52 100644
--- a/tests/codex-catalog.test.ts
+++ b/tests/codex-catalog.test.ts
@@ -66,6 +66,7 @@ function normalizedCombo(
strategy: "failover",
stickyLimit: 1,
defaultEffort: "medium",
+ imageInput: "auto",
alias: null,
nativeAlias: false,
displayName: null,
diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts
index 29e04798bc..75c19f8916 100644
--- a/tests/combo-management-api.test.ts
+++ b/tests/combo-management-api.test.ts
@@ -254,7 +254,7 @@ describe("combo management API", () => {
success: true,
id: "zeta",
model: "combo/zeta",
- combo: { strategy: "failover", stickyLimit: 1, defaultEffort: null, imageInput: "auto" },
+ combo: { strategy: "failover", stickyLimit: 1, defaultEffort: null },
});
const updated = await comboApi(config, "PUT", "/api/combos", {
id: "zeta",
diff --git a/tests/combo-workspace-data.test.ts b/tests/combo-workspace-data.test.ts
index 0d67e2b9a1..d621375162 100644
--- a/tests/combo-workspace-data.test.ts
+++ b/tests/combo-workspace-data.test.ts
@@ -536,9 +536,13 @@ describe("combo-workspace-data", () => {
describe("comboImagesSupported", () => {
- test("returns false with no complete targets", () => {
+ test("returns false with no targets or incomplete targets", () => {
expect(comboImagesSupported([], [])).toBe(false);
expect(comboImagesSupported([{ provider: "", model: "" }], [])).toBe(false);
+ expect(comboImagesSupported(
+ [{ provider: "a", model: "vision" }, { provider: "", model: "" }],
+ [{ provider: "a", id: "vision", inputModalities: ["text", "image"] }],
+ )).toBe(false);
});
test("returns true only when every complete target advertises image", () => {
@@ -567,3 +571,32 @@ describe("comboImagesSupported", () => {
)).toBe(false);
});
});
+
+describe("combo imageInput draft persistence", () => {
+ test("parseComboList preserves explicit disabled", () => {
+ const items = parseComboList({
+ combos: [{
+ id: "limited",
+ strategy: "failover",
+ imageInput: "disabled",
+ targets: [{ provider: "a", model: "m1" }],
+ }],
+ });
+ expect(items[0]?.imageInput).toBe("disabled");
+ });
+
+ test("draftEquals distinguishes disabled from auto", () => {
+ const base = emptyDraft("x");
+ const disabled = { ...base, imageInput: "disabled" as const };
+ expect(draftEquals(base, { ...base, imageInput: "auto" })).toBe(true);
+ expect(draftEquals(base, disabled)).toBe(false);
+ });
+
+ test("toPutBody emits imageInput only when disabled", () => {
+ const auto = emptyDraft("x");
+ auto.targets = [{ provider: "a", model: "m1" }];
+ expect(toPutBody(auto).combo).not.toHaveProperty("imageInput");
+ const disabled = { ...auto, imageInput: "disabled" as const };
+ expect(toPutBody(disabled).combo.imageInput).toBe("disabled");
+ });
+});
From e95af3847540b37544cd28efd29ae5ebd9e314a6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=B3=E4=BF=8A=E6=B1=9F?=
Date: Mon, 10 Aug 2026 10:56:10 +0800
Subject: [PATCH 06/17] fix(combos): scan image input only on Responses input
shapes
Restrict image-disabled detection to materialised Responses input after
previous_response_id expansion so tool schemas and metadata cannot false-
positive HTTP 400. Treat unresolved continuations by object identity,
strip previous_response_id after a successful expand to avoid double
replay on child dispatch, and align docs with capability-intersection
wording.
---
docs-site/src/content/docs/guides/combos.md | 2 +-
.../docs/reference/configuration/routing.md | 2 +-
.../src/content/docs/zh-cn/guides/combos.md | 2 +-
.../zh-cn/reference/configuration/routing.md | 2 +-
src/combos/request.ts | 35 ++++++--
src/server/responses/core.ts | 19 +++--
tests/combos.test.ts | 44 ++++++++++
tests/server-combo-failover-e2e.test.ts | 82 +++++++++++++++++++
8 files changed, 171 insertions(+), 17 deletions(-)
diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md
index 7c0bd6e39e..6bc5f3b6ee 100644
--- a/docs-site/src/content/docs/guides/combos.md
+++ b/docs-site/src/content/docs/guides/combos.md
@@ -312,7 +312,7 @@ Combos are stored in the top-level `combos` object, keyed by combo id:
| `strategy` | No | `"failover"` | `"failover"` or `"round-robin"`. |
| `stickyLimit` | No | `1` | Integer from 1 to 100 successful requests per round-robin selection. |
| `defaultEffort` | No | `null` | `low`, `medium`, `high`, `xhigh`, `max`, or `ultra`; applied only when the caller omits effort and the target advertises support. |
-| `imageInput` | No | `"auto"` | `"auto"` or `"disabled"`. `"disabled"` drops image from published modalities and rejects image-bearing requests before dispatch. Cannot enable image when a target lacks it. |
+| `imageInput` | No | `"auto"` | `"auto"` or `"disabled"`. `"auto"` publishes image support only when every target supports images; `"disabled"` forces text-only (drops image from published modalities and rejects image-bearing requests before dispatch). |
| `alias` | No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. |
| `nativeAlias` | No | `false` | Explicitly permit a currently supported bare native `alias` to take routing and catalog precedence. Never inferred from the alias. |
| `displayName` | No | none | Bounded display-only catalog label. Required and non-empty when `nativeAlias` is true. |
diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md
index 38258543e7..795d2210e4 100644
--- a/docs-site/src/content/docs/reference/configuration/routing.md
+++ b/docs-site/src/content/docs/reference/configuration/routing.md
@@ -76,7 +76,7 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`,
| `strategy?` | `"failover" \| "round-robin"` | `"failover"` | Selection strategy. Target order is failover priority; weights shape smooth weighted round-robin. |
| `stickyLimit?` | `number` | `1` | Successful requests retained in one round-robin batch. Range 1–100. |
| `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | Applied only when the caller omits effort and the selected target advertises the requested rung. |
-| `imageInput?` | `"auto" \| "disabled"` | `"auto"` | `"disabled"` drops image from the published modalities and rejects image-bearing requests before dispatch. Cannot enable image when a target lacks it. |
+| `imageInput?` | `"auto" \| "disabled"` | `"auto"` | `"auto"` publishes image only when every target supports images; `"disabled"` forces text-only (drops image from published modalities and rejects image-bearing requests before dispatch). |
| `alias?` | `string` | — | Optional public model id in place of the canonical picker slug. |
| `nativeAlias?` | `boolean` | `false` | Let a currently supported bare native id take precedence only for that unqualified id. Bare `gpt-5.6-*` ids use Codex Pool/Direct credentials. Account-qualified routes remain distinct. Provider-qualified routes such as `openai-apikey/gpt-5.6-*` use their configured API-key route and never fall through to the native alias. |
| `displayName?` | `string` | — | Display-only catalog label, required and non-empty for a native alias. |
diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md
index ce0706eb4d..fe89fc04c7 100644
--- a/docs-site/src/content/docs/zh-cn/guides/combos.md
+++ b/docs-site/src/content/docs/zh-cn/guides/combos.md
@@ -245,7 +245,7 @@ combo 会存储在顶层的 `combos` 对象中,并以 combo id 作为键:
| `strategy` | 否 | `"failover"` | `"failover"` 或 `"round-robin"`。 |
| `stickyLimit` | 否 | `1` | 每次轮询选择可连续处理的成功请求数,范围为 1 到 100。 |
| `defaultEffort` | 否 | `null` | `low`、`medium`、`high`、`xhigh`、`max` 或 `ultra`;仅当调用方省略 effort 且目标声明支持时才会应用。 |
-| `imageInput` | 否 | `"auto"` | `"auto"` 或 `"disabled"`。`"disabled"` 会从对外能力中去掉图片,并在分发前拒绝带图请求;不能在目标不支持时强开图片。 |
+| `imageInput` | 否 | `"auto"` | `"auto"` 或 `"disabled"`。`"auto"` 仅在每个目标都支持图片时发布图片能力;`"disabled"` 强制仅文本(从对外能力中去掉图片,并在分发前拒绝带图请求)。 |
| `alias` | 否 | 无 | 可选的、已修剪的公开模型 id;使用上面的别名规则。空值会以“无别名”形式存储。 |
| `nativeAlias` | 否 | `false` | 显式允许当前受支持的裸原生 alias 接管路由和 catalog 优先级;绝不会根据 alias 自动推断。 |
| `displayName` | 否 | 无 | 仅用于 catalog 展示的有界标签;`nativeAlias` 为 true 时必须非空。 |
diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md
index 49cb567970..11a3ad97ce 100644
--- a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md
+++ b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md
@@ -64,7 +64,7 @@ Codex Auth 页面将此 picker 行为作为选择加入项。关闭它会隐藏
| `strategy?` | `"failover" \| "round-robin"` | `"failover"` | 选择策略。目标顺序表示故障切换优先级;权重会影响平滑加权轮询。 |
| `stickyLimit?` | `number` | `1` | 在单个轮询批次中保留的成功请求数。范围 1–100。 |
| `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | 仅在调用方省略 effort 且所选目标声明了请求的档位时应用。 |
-| `imageInput?` | `"auto" \| "disabled"` | `"auto"` | `"disabled"` 会从对外能力中去掉图片,并在分发前拒绝带图请求;不能在目标不支持时强开图片。 |
+| `imageInput?` | `"auto" \| "disabled"` | `"auto"` | `"auto"` 仅在每个目标都支持图片时发布图片能力;`"disabled"` 强制仅文本(从对外能力中去掉图片,并在分发前拒绝带图请求)。 |
| `alias?` | `string` | — | 可选的公开 model id,用于替代规范化的选择器 slug。 |
| `nativeAlias?` | `boolean` | `false` | 仅让当前受支持的裸原生 id 对该不带限定前缀的 id 优先;带账号或提供方限定的 OpenAI 路由仍是独立路由。 |
| `displayName?` | `string` | — | 仅用于 catalog 展示的标签;native alias 必须提供非空值。 |
diff --git a/src/combos/request.ts b/src/combos/request.ts
index 48b5ddff27..2b198aae7a 100644
--- a/src/combos/request.ts
+++ b/src/combos/request.ts
@@ -23,15 +23,34 @@ export function comboIdFromRawBody(body: unknown, config: OcxConfig): string | n
return resolveComboId(config, model);
}
+/**
+ * Detect image-bearing Responses *input* only.
+ *
+ * Must not walk the full request body: tool JSON schemas, metadata, or extension
+ * payloads can legally contain `{ "type": "input_image" }` without any image
+ * being dispatched. After previous_response_id expansion, scan the materialised
+ * `input` tree (message content and function_call_output.output).
+ */
export function comboRequestHasImageInput(body: unknown): boolean {
- const visit = (value: unknown): boolean => {
- if (!value || typeof value !== "object") return false;
- if (Array.isArray(value)) return value.some(visit);
- const record = value as Record;
- if (record.type === "input_image") return true;
- return Object.values(record).some(visit);
- };
- return visit(body);
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
+ return responsesInputHasImage((body as { input?: unknown }).input);
+}
+
+function responsesInputHasImage(input: unknown): boolean {
+ if (typeof input === "string" || input == null) return false;
+ if (!Array.isArray(input)) return false;
+ return input.some(responsesInputNodeHasImage);
+}
+
+function responsesInputNodeHasImage(value: unknown): boolean {
+ if (!value || typeof value !== "object") return false;
+ if (Array.isArray(value)) return value.some(responsesInputNodeHasImage);
+ const record = value as Record;
+ if (record.type === "input_image") return true;
+ // Message content parts and nested function_call_output content/output arrays.
+ if (record.content !== undefined && responsesInputNodeHasImage(record.content)) return true;
+ if (record.output !== undefined && responsesInputNodeHasImage(record.output)) return true;
+ return false;
}
export function concreteComboRequestBody(
diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts
index 08699a8bf7..162a28de09 100644
--- a/src/server/responses/core.ts
+++ b/src/server/responses/core.ts
@@ -1184,11 +1184,15 @@ export async function handleComboResponses(
"Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
);
}
- // expandPreviousResponseInput leaves previous_response_id in place when state
- // is missing (no failure marker). For image-disabled combos that would let a
- // target resolve prior images out of band — reject unresolved continuations.
- const unresolvedPrevious = typeof (body as { previous_response_id?: unknown } | null)?.previous_response_id === "string"
- && (body as { previous_response_id: string }).previous_response_id.trim().length > 0;
+ // Missing state returns the original body without a failure marker. Reject
+ // that unresolved continuation for image-disabled combos so a target cannot
+ // resolve prior images out of band. A successful expansion yields a new
+ // object (still carrying previous_response_id) and must not be treated as
+ // unresolved — text-only stored continuations remain allowed.
+ const requestedPreviousId = typeof (rawBody as { previous_response_id?: unknown } | null)?.previous_response_id === "string"
+ ? (rawBody as { previous_response_id: string }).previous_response_id.trim()
+ : "";
+ const unresolvedPrevious = requestedPreviousId.length > 0 && body === rawBody;
if (combo.imageInput === "disabled" && unresolvedPrevious) {
return formatErrorResponse(
400,
@@ -1199,6 +1203,11 @@ export async function handleComboResponses(
if (combo.imageInput === "disabled" && comboRequestHasImageInput(body)) {
return formatErrorResponse(400, "invalid_request_error", `Combo "${comboId}" does not accept image input`);
}
+ // Expansion already materialised prior input. Drop the id so the child
+ // handleResponses path does not expand again and double-prepend history.
+ if (body !== rawBody && body && typeof body === "object" && !Array.isArray(body)) {
+ delete (body as Record).previous_response_id;
+ }
const adoptFailedChildLog = (childLog: RequestLogContext): void => {
// Attempts remain the complete physical history; the logical row mirrors the most recent
// failed target so an exhausted combo still has useful top-level reasoning diagnostics.
diff --git a/tests/combos.test.ts b/tests/combos.test.ts
index e46f10a8d3..b20f65efc7 100644
--- a/tests/combos.test.ts
+++ b/tests/combos.test.ts
@@ -16,6 +16,7 @@ import {
comboIdFromRawBody,
comboModelId,
comboPublicModelId,
+ comboRequestHasImageInput,
concreteComboRequestBody,
coolComboTarget,
getCombo,
@@ -214,6 +215,48 @@ describe("combo request cloning", () => {
expect(comboIdFromRawBody(null, config)).toBeNull();
});
+ test("comboRequestHasImageInput scans Responses input only, not tools or metadata", () => {
+ expect(comboRequestHasImageInput({
+ model: "combo/free",
+ input: [{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,aGVsbG8=" }] }],
+ })).toBe(true);
+ expect(comboRequestHasImageInput({
+ model: "combo/free",
+ input: [{ type: "input_image", image_url: "https://example.test/i.png" }],
+ })).toBe(true);
+ expect(comboRequestHasImageInput({
+ model: "combo/free",
+ input: [{
+ type: "function_call_output",
+ call_id: "call_1",
+ output: [{ type: "input_image", image_url: "https://example.test/tool.png" }],
+ }],
+ })).toBe(true);
+ // Tool schemas / metadata may legally mention the same type string without
+ // carrying image content for the model.
+ expect(comboRequestHasImageInput({
+ model: "combo/free",
+ input: [{ role: "user", content: "text only" }],
+ tools: [{
+ type: "function",
+ name: "describe",
+ parameters: {
+ type: "object",
+ properties: {
+ kind: { type: "string", enum: ["input_image", "input_text"] },
+ example: { type: "input_image" },
+ },
+ },
+ }],
+ metadata: { note: { type: "input_image" } },
+ })).toBe(false);
+ expect(comboRequestHasImageInput({
+ model: "combo/free",
+ input: "plain text",
+ tools: [{ type: "function", function: { name: "x", parameters: { type: "input_image" } } }],
+ })).toBe(false);
+ });
+
test("clones the untouched body and injects an omitted combo default", () => {
const raw = { model: "combo/free", input: [{ role: "user", content: "hi" }] };
const concrete = concreteComboRequestBody(raw, target, "high", ["low", "high"]);
@@ -589,6 +632,7 @@ describe("combo validation and normalization", () => {
strategy: "failover",
stickyLimit: 1,
defaultEffort: "high",
+ imageInput: "auto",
alias: null,
nativeAlias: false,
displayName: null,
diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts
index 35fce68ba1..36b304deaa 100644
--- a/tests/server-combo-failover-e2e.test.ts
+++ b/tests/server-combo-failover-e2e.test.ts
@@ -1321,6 +1321,88 @@ describe("server combo failover 030 activation matrix", () => {
expect(hits).toBe(0);
});
+ test("disabled image input ignores tool schemas that only mention input_image", async () => {
+ let hits = 0;
+ const bodies: Array> = [];
+ const a = serve(async request => {
+ hits += 1;
+ bodies.push(await request.json() as Record);
+ return chatSuccess("text only", "m1");
+ });
+ const config = comboConfig({ a: provider("openai-chat", baseUrl(a), "key-a") }, undefined, {
+ imageInput: "disabled",
+ });
+ const response = await post(config, {
+ input: [{ role: "user", content: "describe without images" }],
+ tools: [{
+ type: "function",
+ name: "classify",
+ parameters: {
+ type: "object",
+ properties: {
+ part: { type: "string", enum: ["input_image", "input_text"] },
+ example: { type: "input_image" },
+ },
+ },
+ }],
+ metadata: { sample: { type: "input_image" } },
+ });
+ expect(response.status).toBe(200);
+ expect(hits).toBe(1);
+ // openai-chat upstream receives the bare model id after concrete routing.
+ expect(bodies[0]?.model).toBe("m1");
+ });
+
+ test("disabled image input rejects unavailable previous_response_id before dispatch", async () => {
+ let hits = 0;
+ const a = serve(() => {
+ hits += 1;
+ return chatSuccess("unexpected", "m1");
+ });
+ const config = comboConfig({ a: provider("openai-chat", baseUrl(a), "key-a") }, undefined, {
+ imageInput: "disabled",
+ });
+ const response = await post(config, {
+ previous_response_id: "resp_missing_local_state",
+ input: [{ role: "user", content: "continue" }],
+ });
+ expect(response.status).toBe(400);
+ expect(await response.text()).toContain("Continuation state is unavailable");
+ expect(hits).toBe(0);
+ });
+
+ test("disabled image input expands text-only previous_response_id and dispatches once", async () => {
+ const { rememberResponseState } = await import("../src/responses/state");
+ rememberResponseState(
+ { model: "combo/free", input: [{ role: "user", content: "earlier text" }] },
+ {
+ id: "resp_combo_text_prev",
+ status: "completed",
+ output: [{ type: "message", role: "assistant", content: "ack" }],
+ },
+ );
+ const bodies: Array> = [];
+ const a = serve(async request => {
+ bodies.push(await request.json() as Record);
+ return chatSuccess("continued", "m1");
+ });
+ const config = comboConfig({ a: provider("openai-chat", baseUrl(a), "key-a") }, undefined, {
+ imageInput: "disabled",
+ });
+ const response = await post(config, {
+ previous_response_id: "resp_combo_text_prev",
+ input: [{ role: "user", content: "next turn" }],
+ });
+ expect(response.status).toBe(200);
+ expect(bodies).toHaveLength(1);
+ const child = bodies[0]!;
+ // Parent already expanded; child must not keep previous_response_id (would double-prepend).
+ expect(child.previous_response_id).toBeUndefined();
+ const inputText = JSON.stringify(child.input ?? child.messages ?? child);
+ expect(inputText).toContain("earlier text");
+ expect(inputText).toContain("next turn");
+ });
+
test("fresh child reparsing recomputes vision and effort per target", async () => {
const bodies: Array<{ provider: string; body: Record }> = [];
const a = serve(async request => {
From 1c84515995bc76d4a56e129dc84c331fa102efd1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=98=E6=89=AC?=
Date: Fri, 14 Aug 2026 01:22:26 +0800
Subject: [PATCH 07/17] add MiniMax client integrations
---
docs-site/astro.config.mjs | 1 +
.../screenshots/minimax-code-integration.png | Bin 0 -> 64194 bytes
.../src/content/docs/guides/integrations.md | 42 +-
docs-site/src/content/docs/guides/minimax.md | 110 +++++
.../content/docs/tr/guides/integrations.md | 40 +-
.../content/docs/zh-tw/guides/integrations.md | 33 +-
.../client-config-clients.ts | 3 +-
gui/src/i18n/de.ts | 3 +
gui/src/i18n/en.ts | 3 +
gui/src/i18n/ja.ts | 3 +
gui/src/i18n/ko.ts | 3 +
gui/src/i18n/ru.ts | 3 +
gui/src/i18n/tr.ts | 3 +
gui/src/i18n/zh-TW.ts | 3 +
gui/src/i18n/zh.ts | 3 +
gui/src/pages/Integrations.tsx | 2 +
.../integrations/FileIntegrationPage.tsx | 2 +
gui/src/pages/integrations/integration-api.ts | 1 +
.../pages/integrations/overview-clients.ts | 1 +
src/cli/dispatch.ts | 8 +
src/cli/export-command.ts | 4 +-
src/cli/help.ts | 2 +
src/cli/minimax.ts | 380 ++++++++++++++++++
src/cli/registry.ts | 24 +-
src/clients/config-export.ts | 88 +++-
src/integrations/registry.ts | 7 +
structure/01_runtime.md | 2 +-
structure/04_transports-and-sidecars.md | 19 +
tests/cli-export-command.test.ts | 2 +-
.../client-config-export-new-clients.test.ts | 4 +-
tests/client-config-export.test.ts | 5 +-
tests/integrations-invariants.test.ts | 7 +-
tests/integrations-state.test.ts | 4 +-
tests/minimax-clients.test.ts | 283 +++++++++++++
34 files changed, 1066 insertions(+), 32 deletions(-)
create mode 100644 docs-site/public/screenshots/minimax-code-integration.png
create mode 100644 docs-site/src/content/docs/guides/minimax.md
create mode 100644 src/cli/minimax.ts
create mode 100644 tests/minimax-clients.test.ts
diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs
index c1dfc83613..5c2fcb028d 100644
--- a/docs-site/astro.config.mjs
+++ b/docs-site/astro.config.mjs
@@ -95,6 +95,7 @@ export default defineConfig({
{ label: "opencode", translations: { ko: "opencode", "zh-CN": "opencode", "zh-TW": "opencode", ru: "opencode", ja: "opencode", tr: "opencode" }, slug: "guides/opencode" },
{ label: "Pi", translations: { ko: "Pi", "zh-CN": "Pi", "zh-TW": "Pi", ru: "Pi", ja: "Pi", tr: "Pi" }, slug: "guides/pi" },
{ label: "Integrations", translations: { ko: "연동", "zh-CN": "集成", "zh-TW": "整合", ru: "Интеграции", ja: "連携", tr: "Entegrasyonlar" }, slug: "guides/integrations" },
+ { label: "MiniMax clients", translations: { ko: "MiniMax 클라이언트", "zh-CN": "MiniMax 客户端", "zh-TW": "MiniMax 客戶端", ru: "Клиенты MiniMax", ja: "MiniMax クライアント", tr: "MiniMax İstemcileri" }, slug: "guides/minimax" },
{ label: "Sidecars: Web Search & Vision", translations: { ko: "사이드카: 웹 검색 & 비전", "zh-CN": "边车:网络搜索与视觉", "zh-TW": "邊車:網路搜尋與視覺", ru: "Сайдкары: веб-поиск и зрение", ja: "サイドカー: ウェブ検索 & ビジョン", tr: "Sidecar'lar: Web Arama ve Görme" }, slug: "guides/sidecars" },
{ label: "Image Bridge", translations: { ko: "이미지 브릿지", "zh-CN": "图像桥接", "zh-TW": "圖像橋接", ru: "Image Bridge", ja: "画像ブリッジ", tr: "Image Bridge" }, slug: "guides/image-bridge" },
{ label: "Video Bridge", translations: { ko: "비디오 브릿지", "zh-CN": "视频桥接", "zh-TW": "影片橋接", ru: "Video Bridge", ja: "動画ブリッジ", tr: "Video Bridge" }, slug: "guides/video-bridge" },
diff --git a/docs-site/public/screenshots/minimax-code-integration.png b/docs-site/public/screenshots/minimax-code-integration.png
new file mode 100644
index 0000000000000000000000000000000000000000..824452c0cb24c28df8fff1130c88894b5b839973
GIT binary patch
literal 64194
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