diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index c1dfc83613..ab56fea23b 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" }, @@ -95,6 +96,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 0000000000..824452c0cb Binary files /dev/null and b/docs-site/public/screenshots/minimax-code-integration.png differ diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 434cdcbb46..6bc5f3b6ee 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"`. `"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/guides/factory-droid.md b/docs-site/src/content/docs/guides/factory-droid.md new file mode 100644 index 0000000000..55a8983792 --- /dev/null +++ b/docs-site/src/content/docs/guides/factory-droid.md @@ -0,0 +1,177 @@ +--- +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 +Text-only Responses client + -> 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. The minimal bridge accepts only these Responses `input` shapes: + +- a non-empty string; or +- an array containing only `message` items. Each message must have a `user`, `developer`, `system`, + or `assistant` role and either string content or text-only content parts (`input_text` for input + roles and `output_text` for assistant history). + +Validate the complete request before invoking Droid. If an input part is an image or file, `tools` +contains any tool definition, or `input` contains a tool call or result (`function_call`, +`function_call_output`, `custom_tool_call`, or `custom_tool_call_output`), return HTTP `400` with a +Responses-style `invalid_request_error`. Use a stable bridge-specific code such as +`unsupported_bridge_input` and identify the rejected field in the message. Do this before starting +SSE, even when `stream: true`; never discard, stringify, or flatten unsupported content into the +prompt. + +```json +{ + "error": { + "type": "invalid_request_error", + "code": "unsupported_bridge_input", + "param": "tools", + "message": "The minimal Droid bridge does not accept tool definitions." + } +} +``` + +For an accepted request, the bridge should: + +1. Convert the accepted Responses `input` 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 + +Create the custom provider with the explicit provider ID `droid`: + +```bash +ocx provider add droid \ + --adapter openai-responses \ + --base-url http://127.0.0.1:11435/v1 \ + --default-model glm-5.2 \ + --allow-private-network +``` + +This creates the `providers.droid` config entry. In the dashboard, open **Providers → droid → Edit +JSON** and replace that provider's value with: + +```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 +ocx access test droid/glm-5.2 --protocol responses +``` + +A provider row or model-picker entry proves only catalog visibility. The integration is working only +after the Responses probe returns 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. Codex App and `codex exec` normally send +tool definitions even when a prompt says not to call tools, and the current Codex CLI has no general +flag that removes those definitions. The minimal bridge must reject those requests with the `400` +contract above. 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 `ocx access test` success as text-path verification, not Codex agent or tool-path +verification. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 92e35af06e..f8b2c578d8 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Integrations -description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code and DeepSeek Harness from the dashboard — one switch per client, with a backup taken before every write. +description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness and MiniMax Code from the dashboard — one switch per client, with a backup taken before every write. --- The **Integrations** tab writes opencodex's provider block into a client's own config -file, and removes it again. Eight clients work this way, each with a switch: +file, and removes it again. Nine clients work this way, each with a switch: | Client | Config file | Format | When the change takes effect | Credential | |---|---|---|---|---| @@ -16,6 +16,7 @@ file, and removes it again. Eight clients work this way, each with a switch: | Kimi Code | `~/.kimi-code/config.toml` | TOML | on restart, or `/reload` | loopback placeholder | | Gajae Code | `~/.gjc/agent/models.yml` | YAML | new sessions, or when you open `/model` |`OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (default `~/.dsh/settings.yaml`) | YAML | hot reload | non-secret loopback bearer placeholder | +| MiniMax Code | `~/.minimax/config.yaml` | YAML | new sessions, or after opening the model picker | loopback placeholder | Managed DSH support has a compatibility floor of **DSH 0.1.0-rc.6**. OpenCodex owns only `llm-pi-ai.providers.opencodex`; Apply and Refresh replace that fragment, Disable removes only that @@ -23,6 +24,12 @@ fragment, and Restore puts back a recorded snapshot. DSH hot reloads provider ch operations do not change the user's default model or the native `deepseek-official` provider. The managed DSH integration is currently loopback-only and never writes a real credential. +MiniMax Code follows `MINIMAX_DATA_DIR`, then `MAVIS_DATA_DIR`, before falling +back to `~/.minimax`. Its managed block owns only `custom_provider.opencodex`. +It does not change `defaultModel`, the selected MiniMax credential source, or +the user's MiniMax login. Choose a `custom_provider:opencodex/` +entry in MCode after connecting it. + Paths honor each client's own environment override where it has one. For OMP, `OMP_PROFILE` wins over `PI_PROFILE` by presence, even when explicitly empty. A named profile uses `PI_CONFIG_DIR` as a directory name relative to the user's home and ignores `PI_CODING_AGENT_DIR`; without a named profile, @@ -83,8 +90,8 @@ than 1000 levels — which locks the switch instead, so nothing is silently chan **OMP** is unaffected by sibling edits too, for a different reason: its writer patches only its own `providers.opencodex` range byte-wise, so the rest of the file is never rewritten. For the remaining formats that can carry comments -(Hermes, OpenClaw, Kimi Code, Gajae Code — YAML, JSON5 and TOML written as whole -documents), or +(Hermes, OpenClaw, Kimi Code, Gajae Code, MiniMax Code — YAML, JSON5 and TOML +written as whole documents), or whenever our own entries were edited, the switch locks and disable refuses rather than guessing which edits were yours. @@ -106,8 +113,8 @@ changed value and calling it success. You will see the file named and nothing on disk will have moved. Editing that file by hand still works; it is only our automatic rewrite that declines. -**Pi, Kimi Code, Gajae Code and the managed DSH integration only work against a loopback bind.** -The first three have no config field for the `x-opencodex-api-key` header a non-loopback bind +**Pi, Kimi Code, Gajae Code, MiniMax Code and the managed DSH integration only work against a loopback bind.** +The first four have no config field for the `x-opencodex-api-key` header a non-loopback bind requires. DSH has a generic headers map, but rc.6 does not document that dedicated admission header as a supported integration contract, so the managed writer fails closed instead of guessing. Give them loopback access through an SSH tunnel or a local forwarder that adds the header. @@ -138,6 +145,29 @@ ocx integration client history --client hermes ocx integration client restore --op [--confirm-drift] ``` +For MiniMax Code, connect the provider once and launch through the checked wrapper: + +```bash +ocx integration client enable --client mcode +ocx mcode +``` + +The separate MiniMax platform CLI (`mmx`) is not a file-toggle integration. Its text +commands use MiniMax's Anthropic-compatible endpoint, so OpenCodex provides a +credential-isolated, loopback-only launcher: + +```bash +ocx mmx text chat --model anthropic/claude-opus-5 --message "Hello" +ocx mmx text repl --model openai/gpt-5.6-sol +``` + +Only `mmx text chat` and `mmx text repl` are proxied. Run plain `mmx` for +MiniMax-native image, video, speech, music, vision, search, quota, auth, config, file +and update commands. The wrapper uses a temporary config containing only a non-secret +loopback placeholder; it never loads your `~/.mmx` OAuth or API-key credentials, and +it refuses `--api-key`, `--base-url` and `--region` overrides. See +[MiniMax clients](/guides/minimax/) for the complete workflow and limits. + `--confirm-drift` is never assumed. If the file changed after the operation you are restoring, the command refuses and tells you, because replacing your newer edits is your decision to make. diff --git a/docs-site/src/content/docs/guides/minimax.md b/docs-site/src/content/docs/guides/minimax.md new file mode 100644 index 0000000000..b7ad0fdd2c --- /dev/null +++ b/docs-site/src/content/docs/guides/minimax.md @@ -0,0 +1,110 @@ +--- +title: MiniMax clients +description: Route MiniMax Code and MiniMax CLI text commands through OpenCodex without exposing MiniMax credentials. +--- + +MiniMax publishes two different command-line products. OpenCodex integrates each at +the protocol boundary it actually exposes: + +- **MiniMax Code** (`mcode`) is a coding agent with custom Anthropic Messages providers. +- **MiniMax CLI** (`mmx`) is a multimodal platform CLI. Only its `text` resource speaks + the Anthropic-compatible API that OpenCodex can route. + +## MiniMax Code + +Install and sign in to MiniMax Code using MiniMax's instructions first. Then start +OpenCodex and connect the reversible file integration: + +```bash +ocx start +ocx integration client enable --client mcode +ocx mcode +``` + +![MiniMax Code integration shown with isolated example data](/screenshots/minimax-code-integration.png) + +The integration merges one block into `~/.minimax/config.yaml`: + +```yaml +custom_provider: + opencodex: + name: OpenCodex + kind: custom + enabled: true + api: anthropic-messages + options: + apiKey: opencodex-loopback + baseURL: http://127.0.0.1:10100 + authMode: api-key + models: + anthropic/claude-opus-5: {} +``` + +The real generated model list comes from the running OpenCodex catalog. The block does +not write a real key, does not replace `defaultModel`, and does not change your MiniMax +login. In MCode, choose a model under `custom_provider:opencodex/...`. + +`ocx mcode` verifies that this provider points at the currently running proxy before it +launches the client. If the port changed, refresh the managed block by running the enable +command again. Disable or restore it through the same audited integration system: + +```bash +ocx integration client disable --client mcode +ocx integration client history --client mcode +ocx integration client restore --op [--confirm-drift] +``` + +`MINIMAX_DATA_DIR` and the legacy `MAVIS_DATA_DIR` are honored. Relative overrides are +refused because OpenCodex and MCode may start in different working directories. + +## MiniMax CLI (`mmx`) + +Install the official CLI separately: + +```bash +npm install -g mmx-cli +mmx --version +``` + +Route a text command through OpenCodex by using the wrapper and an OpenCodex model id: + +```bash +ocx mmx text chat \ + --model anthropic/claude-opus-5 \ + --message "Explain this function" + +ocx mmx --output json text chat \ + --model openai/gpt-5.6-sol \ + --message "Return a JSON summary" +``` + +MMX hard-codes `/anthropic/v1/messages` below its API base URL. The wrapper starts a +temporary loopback bridge for the lifetime of the child process. It accepts only POST +requests to that Messages path and `/anthropic/v1/messages/count_tokens`, mapping them +to OpenCodex's existing `/v1/messages` and `/v1/messages/count_tokens` data plane while +preserving request bodies and query data. Canonical OpenCodex request translation, +usage accounting and configured downstream provider authentication remain in effect; +providers receive `x-api-key` or bearer transport according to their configuration. +Streaming preserves Anthropic message and content events. Before forwarding, the bridge +removes incoming admission credential headers and pins the public +`opencodex-loopback` placeholder. Arbitrary Anthropic resources are not proxied, and +the bridge is never exposed beyond loopback. + +The wrapper also creates a temporary `MMX_CONFIG_DIR` containing only that placeholder, +then deletes it after `mmx` exits. Your `~/.mmx/config.json`, OAuth tokens and MiniMax +API key are never loaded or copied. + +The following limits are intentional: + +- Only `text chat` and `text repl` are routed through OpenCodex. +- `--api-key`, `--base-url` and `--region` are refused by the wrapper so caller + credentials or destination selectors cannot conflict with the isolated bridge. +- The wrapper is loopback-only because MMX cannot send OpenCodex's dedicated + `x-opencodex-api-key` admission header for a remote bind. +- Run plain `mmx` for `image`, `video`, `speech`, `music`, `vision`, `search`, `quota`, + `auth`, `config`, `file` and `update`; those call MiniMax-specific APIs that OpenCodex + does not emulate. + +`mmx` defaults its text model to `MiniMax-M3`. Pass `--model ` when you +want a specific OpenCodex route; otherwise normal OpenCodex model routing rules decide +whether the default id is available. diff --git a/docs-site/src/content/docs/guides/model-ordering.md b/docs-site/src/content/docs/guides/model-ordering.md index 87d209dee7..696f631a58 100644 --- a/docs-site/src/content/docs/guides/model-ordering.md +++ b/docs-site/src/content/docs/guides/model-ordering.md @@ -29,6 +29,7 @@ The relevant no-selector priorities are: | --- | ---: | --- | | `subagentModels[i]` | `i` (`0` through `4`) | The featured rank map in `src/codex/catalog/sync.ts` | | Other routed models | `5` | Routed entry creation in `src/codex/catalog/sync.ts` | +| Non-featured routed models listed in `modelPickerOrder` | `1000 + i` | Display-only picker rank in `src/codex/catalog/sync.ts` | | Native GPT slugs by default | `9` | Native entry creation in `src/codex/catalog/sync.ts` | | Unselected native models while a featured list exists | At least `featured.length + 100` | Native catalog merge in `src/codex/catalog/sync.ts` | @@ -108,14 +109,34 @@ have expanded into selector-qualified groups. ## Changing the order -The supported way to customize leading model order is to reorder `subagentModels`. The dashboard's -**Sub-agents** page can reorder bare native and routed ids. Use `ocx agent subagents set` or edit the -opencodex configuration for exact `/` choices; the dashboard does not -list those choices and omits them if it saves the roster. Use at most five configured ids. With -account selectors, one bare native choice can expand into multiple selector-qualified catalog rows, -so configured choices and advertised rows are not necessarily one-to-one. - -There is currently no general `modelOrder`, `providerOrder`, or priority-map setting in `OcxConfig`. -The supported ordering field is `subagentModels`; `disabledModels` and each provider's -`selectedModels` are visibility fields. Changing the remaining picker order would require a -code-level behavior change rather than a configuration edit. +Use `subagentModels` to choose and order the leading models that Codex also advertises to +`spawn_agent`. The dashboard's **Sub-agents** page can reorder bare native and routed ids. Use +`ocx agent subagents set` or edit the opencodex configuration for exact +`/` choices; the dashboard does not list those choices and omits them +if it saves the roster. Use at most five configured ids. With account selectors, one bare native +choice can expand into multiple selector-qualified catalog rows, so configured choices and +advertised rows are not necessarily one-to-one. + +Use `modelPickerOrder` for display-only ordering of routed `/` rows beyond that +featured block: + +```json +{ + "modelPickerOrder": [ + "tyler/deepseek-v4-pro", + "jd-chat/kimi-k3", + "jd-chat/glm-5.2" + ] +} +``` + +Listed routed rows appear in the configured order. A routed row omitted from the array keeps its +normal priority, so it remains ahead of the `modelPickerOrder` display band; list every routed row +whose relative position you want to control. A row also present in `subagentModels` keeps its +featured priority. Bare native and account-qualified native rows are not reordered by +`modelPickerOrder`; use `subagentModels` for those rows. + +`modelPickerOrder` never changes the `spawn_agent` candidate set. It changes only the +Codex-visible picker priority while opencodex retains each moved row's natural priority for +sub-agent selection. `disabledModels` and each provider's `selectedModels` remain visibility fields, +not ordering controls. There is no separate `modelOrder`, `providerOrder`, or priority-map setting. 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..995602e53a --- /dev/null +++ b/docs-site/src/content/docs/ko/guides/factory-droid.md @@ -0,0 +1,177 @@ +--- +title: Factory Droid 브리지 +description: 로컬 Responses 호환 브리지를 통해 Factory Droid 모델을 opencodex에 연결합니다. +--- + +Factory Droid는 에이전트 런타임이며, 문서화된 OpenAI 호환 추론 엔드포인트가 아닙니다. 내부 +Factory LLM URL을 사용자 지정 프로바이더로 등록했을 때 `403 Forbidden`이 발생한다면, +opencodex 어댑터나 프로바이더 헤더만 바꿔도 그 비공개 경로가 지원되는 공개 API로 바뀌지는 +않습니다. + +검증된 연결 구조는 다음과 같습니다. + +```text +텍스트 전용 Responses 클라이언트 + -> 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를 +제한해야 합니다. 최소 브리지는 다음 Responses `input` 형태만 허용합니다. + +- 비어 있지 않은 문자열 +- `message` item만 들어 있는 배열. 각 메시지의 role은 `user`, `developer`, `system`, + `assistant` 중 하나여야 하며, content는 문자열이거나 텍스트 전용 content part여야 합니다. + 입력 role에는 `input_text`, assistant 이력에는 `output_text`만 허용합니다. + +Droid를 실행하기 전에 요청 전체를 검증해야 합니다. input part에 이미지나 파일이 있거나, +`tools`에 도구 정의가 하나라도 있거나, `input`에 도구 호출 또는 결과(`function_call`, +`function_call_output`, `custom_tool_call`, `custom_tool_call_output`)가 있으면 Responses 형식의 +`invalid_request_error`와 함께 HTTP `400`을 반환합니다. `unsupported_bridge_input`처럼 안정적인 +브리지 전용 code를 사용하고 message에서 거부한 필드를 명시하세요. `stream: true`여도 SSE를 +시작하기 전에 이렇게 거부해야 합니다. 지원하지 않는 내용을 버리거나 문자열로 바꾸거나 +프롬프트에 합치면 안 됩니다. + +```json +{ + "error": { + "type": "invalid_request_error", + "code": "unsupported_bridge_input", + "param": "tools", + "message": "The minimal Droid bridge does not accept tool definitions." + } +} +``` + +허용된 요청에 대해 브리지는 다음 작업을 수행합니다. + +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 프로바이더 설정 + +명시적인 프로바이더 ID `droid`로 사용자 지정 프로바이더를 생성합니다. + +```bash +ocx provider add droid \ + --adapter openai-responses \ + --base-url http://127.0.0.1:11435/v1 \ + --default-model glm-5.2 \ + --allow-private-network +``` + +이 명령은 `providers.droid` 설정 항목을 만듭니다. 대시보드에서 **Providers → droid → 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 +ocx access test droid/glm-5.2 --protocol responses +``` + +프로바이더 행이나 모델 선택기 표시는 카탈로그 노출만 증명합니다. Responses probe가 +`droid/` 경로를 통해 실제 응답을 반환해야 연동 성공입니다. + +## 현재 한계 + +위 최소 브리지는 텍스트와 Responses SSE 수명주기만 변환합니다. Codex App과 `codex exec`는 +프롬프트에서 도구를 호출하지 말라고 해도 일반적으로 도구 정의를 보내며, 현재 Codex CLI에는 그 +정의를 모두 제거하는 범용 플래그가 없습니다. 최소 브리지는 위 계약에 따라 해당 요청을 `400`으로 +거부해야 합니다. 도구 정의, 도구 호출과 결과, 권한, 취소, 풍부한 Droid 이벤트를 처리하려면 +Factory stream JSON-RPC 모드 또는 공식 Droid SDK를 사용하는 상태 유지 브리지가 필요합니다. +`ocx access test` 성공을 Codex 에이전트나 도구 경로 성공으로 간주하지 마세요. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 62203e285d..e4c20b5fc4 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -105,6 +105,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. | | `noStructuredOutputModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model. | | `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. | +| `terminalContinuationGuard?` | `boolean` | Opt in an `openai-chat` provider to one bounded internal re-ask when an actionable turn announces work, then cleanly stops without a tool call. Defaults to `false`; explicit `false` behaves like omission. Combo attempts and routed compaction turns are excluded, and non-`openai-chat` adapters ignore this option. | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Disabled-by-default downstream SSE repair for exact placeholder ids, missing terminal ids, and (with `repairInvalidIds`) message/reasoning ids missing the canonical `msg_`/`rs_` prefix. Function-call ids are never rewritten. Built-in DeepSeek enables the last two by default. | | `responsesSnapshotRepair?` | `boolean` | Disabled-by-default client-facing repair for sparse Responses lifecycle snapshots in SSE and JSON. Fills missing canonical status, output, and tool metadata while raw inspection and persistence remain unchanged. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key providers only (`authMode: "key"`). Opt-in same-target 429 retry: when `retryOn429` is absent the feature is off; object presence enables it unless `enabled: false`. On 429 the proxy waits (upstream `Retry-After` or the fixed interval) and replays the identical request on the same key before any key failover — across the main text-turn recovery loop, the Responses passthrough wire, the image/video bridge, the web-search sidecar, and terminal continuations. Only pre-stream HTTP 429 responses are eligible for replay; custom `runTurn` transports are outside the HTTP retry loop. `attempts` counts same-key replays after the first 429 (total sends = `attempts` + 1) and is one request-wide budget shared by the main recovery loop, the terminal-guard continuation, and bridge retries. Exhausting `attempts` only stops further same-key replays: normal key failover or final-error handling then applies per the available targets — on the key-auth passthrough wire there is no failover, so the exhausted 429 surfaces as-is. Codex itself never retries 429, so this is the only defense for single-key providers. Defaults: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (any single wait is capped at `maxIntervalMs`, itself capped at 600000), `respectRetryAfter: 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..795d2210e4 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"` | `"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/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index 88de3e0412..4ddd5e0bfd 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Entegrasyonlar -description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code ve DeepSeek Harness'ı opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. +description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness ve MiniMax Code'u opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. --- **Entegrasyonlar** sekmesi, opencodex'in sağlayıcı bloğunu istemcinin kendi -yapılandırma dosyasına yazar ve tekrar kaldırır. Sekiz istemci bu şekilde +yapılandırma dosyasına yazar ve tekrar kaldırır. Dokuz istemci bu şekilde çalışır, her biri bir anahtarla: | İstemci | Yapılandırma dosyası | Format | Değişiklik ne zaman geçerli olur? | Kimlik bilgisi | @@ -17,6 +17,7 @@ yapılandırma dosyasına yazar ve tekrar kaldırır. Sekiz istemci bu şekilde | Kimi Code | `~/.kimi-code/config.toml` | TOML | yeniden başlatmada veya `/reload` ile | geri döngü (loopback) yer tutucusu | | Gajae Code | `~/.gjc/agent/models.yml` | YAML | yeni oturumlarda veya `/model` açtığınızda | `OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (varsayılan `~/.dsh/settings.yaml`) | YAML | çalışırken yeniden yükleme | gizli olmayan geri döngü bearer yer tutucusu | +| MiniMax Code | `~/.minimax/config.yaml` | YAML | yeni oturumlarda veya model seçici açıldıktan sonra | geri döngü (loopback) yer tutucusu | Yönetilen DSH desteğinin en düşük uyumlu sürümü **DSH 0.1.0-rc.6**'dır. OpenCodex yalnızca `llm-pi-ai.providers.opencodex` bölümünü yönetir: Uygula ve Yenile bu bölümü değiştirir, Devre Dışı @@ -25,6 +26,12 @@ sağlayıcı değişikliklerini çalışırken yeniden yükler. Bu işlemler kul veya yerel `deepseek-official` sağlayıcısını değiştirmez. Yönetilen DSH entegrasyonu şu anda yalnızca geri döngü içindir ve asla gerçek bir kimlik bilgisi yazmaz. +MiniMax Code önce `MINIMAX_DATA_DIR`, ardından `MAVIS_DATA_DIR` yolunu izler ve +son olarak `~/.minimax` dizinine geri döner. Yönetilen blok yalnızca +`custom_provider.opencodex` alanına sahiptir; `defaultModel` değerini, seçilen +MiniMax kimlik bilgisi kaynağını veya kullanıcının MiniMax oturumunu değiştirmez. +Bağladıktan sonra MCode içinde bir `custom_provider:opencodex/` girdisi seçin. + Yollar, varsa her istemcinin kendi ortam geçersiz kılmalarını dikkate alır. OMP için `OMP_PROFILE`, açıkça boş olduğunda bile varlığıyla `PI_PROFILE`'a üstün gelir. Adlandırılmış bir profil, `PI_CONFIG_DIR`'i kullanıcının ev dizinine göre @@ -100,7 +107,7 @@ hiçbir şey sessizce değiştirilmez veya düşürülmez. **OMP** de yanındaki düzenlemelerden etkilenmez, ama başka bir nedenle: writer'ı yalnızca kendi `providers.opencodex` aralığını bayt bayt yamalar, dosyanın geri kalanı hiçbir zaman yeniden yazılmaz. Yorum taşıyabilen diğer biçimlerde (Hermes, OpenClaw, -Kimi Code, Gajae Code — bütün belge olarak yazılan YAML, JSON5 ve TOML) veya +Kimi Code, Gajae Code, MiniMax Code — bütün belge olarak yazılan YAML, JSON5 ve TOML) veya kendi girdilerimiz düzenlenmişse, anahtar kilitlenir ve hangi düzenlemelerin size ait olduğunu tahmin etmek yerine devre dışı bırakmayı reddeder. @@ -124,8 +131,8 @@ değişen bir değer yazıp buna başarı demek yerine durur ve bunu söyler. Do adlandırıldığını ve diskte hiçbir şeyin taşınmadığını görürsünüz. Bu dosyayı elle düzenlemek hala çalışır; yalnızca otomatik yeniden yazmamız reddeder. -**Pi, Kimi Code, Gajae Code ve yönetilen DSH entegrasyonu yalnızca geri döngü (loopback) bağlantısına karşı -çalışır.** İlk üçünün yapılandırmasında geri döngü olmayan bir bağlantının gerektirdiği +**Pi, Kimi Code, Gajae Code, MiniMax Code ve yönetilen DSH entegrasyonu yalnızca geri döngü (loopback) bağlantısına karşı +çalışır.** İlk dördünün yapılandırmasında geri döngü olmayan bir bağlantının gerektirdiği `x-opencodex-api-key` başlığı için alan yoktur. DSH genel bir headers haritası sunar, ancak rc.6 bu özel kabul başlığını desteklenen bir entegrasyon sözleşmesi olarak belgelememektedir; bu nedenle yönetilen writer tahmin yürütmek yerine kapalı biçimde reddeder. Bunun yerine bir SSH tüneli veya @@ -158,6 +165,29 @@ ocx integration client history --client hermes ocx integration client restore --op [--confirm-drift] ``` +MiniMax Code için sağlayıcıyı bir kez bağlayın ve denetimli başlatıcı üzerinden çalıştırın: + +```bash +ocx integration client enable --client mcode +ocx mcode +``` + +Ayrı MiniMax platform CLI'si (`mmx`) bir dosya anahtarı entegrasyonu değildir. +Metin komutları MiniMax'ın Anthropic uyumlu uç noktasını kullandığı için OpenCodex, +kimlik bilgilerini yalıtan ve yalnızca geri döngüde çalışan bir başlatıcı sağlar: + +```bash +ocx mmx text chat --model anthropic/claude-opus-5 --message "Hello" +ocx mmx text repl --model openai/gpt-5.6-sol +``` + +Yalnızca `mmx text chat` ve `mmx text repl` proxy üzerinden yönlendirilir. MiniMax'a +özgü diğer komutlar için doğrudan `mmx` çalıştırın. Başlatıcı yalnızca gizli olmayan +geri döngü yer tutucusunu içeren geçici bir yapılandırma kullanır; `~/.mmx` OAuth veya +API anahtarı kimlik bilgilerinizi yüklemez ve `--api-key`, `--base-url` ile `--region` +geçersiz kılmalarını reddeder. Tam iş akışı için +[MiniMax istemcileri](/guides/minimax/) sayfasına bakın. + `--confirm-drift` asla varsayılmaz. Geri yüklediğiniz işlemden sonra dosya değiştiyse, komut reddeder ve size bildirir; çünkü daha yeni düzenlemelerinizin üzerine yazmak sizin vereceğiniz bir karardır. 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..fe89fc04c7 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"`。`"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 166a2ce17b..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,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"` | `"auto"` 仅在每个目标都支持图片时发布图片能力;`"disabled"` 强制仅文本(从对外能力中去掉图片,并在分发前拒绝带图请求)。 | | `alias?` | `string` | — | 可选的公开 model id,用于替代规范化的选择器 slug。 | | `nativeAlias?` | `boolean` | `false` | 仅让当前受支持的裸原生 id 对该不带限定前缀的 id 优先;带账号或提供方限定的 OpenAI 路由仍是独立路由。 | | `displayName?` | `string` | — | 仅用于 catalog 展示的标签;native alias 必须提供非空值。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 0f9de23e2f..944c6d20fd 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -1,9 +1,9 @@ --- title: 整合 -description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、Gajae Code 與 DeepSeek Harness——每個客戶端一個開關,每次寫入前都會先備份。 +description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、Gajae Code、DeepSeek Harness 與 MiniMax Code——每個客戶端一個開關,每次寫入前都會先備份。 --- -**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有八個客戶端以這種方式運作,每個都有一個開關: +**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有九個客戶端以這種方式運作,每個都有一個開關: | 客戶端 | 設定檔 | 格式 | 變更生效時機 | 憑證 | |---|---|---|---|---| @@ -15,6 +15,7 @@ description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、 | Kimi Code | `~/.kimi-code/config.toml` | TOML | 重新啟動時,或 `/reload` | loopback 佔位符 | | Gajae Code | `~/.gjc/agent/models.yml` | YAML | 新 sessions,或當你開啟 `/model` 時 | `OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml`(預設 `~/.dsh/settings.yaml`) | YAML | 熱重載 | 非秘密的 loopback bearer 佔位符 | +| MiniMax Code | `~/.minimax/config.yaml` | YAML | 新 sessions,或開啟模型選擇器後 | loopback 佔位符 | 受管理 DSH 支援的相容性下限是 **DSH 0.1.0-rc.6**。OpenCodex 只擁有 `llm-pi-ai.providers.opencodex`:Apply 與 Refresh 會取代該片段,Disable 只移除該片段, @@ -22,6 +23,11 @@ Restore 則放回已記錄的快照。DSH 會熱重載 provider 變更。這些 預設模型,也不會改動原生 `deepseek-official` provider。受管理 DSH 整合目前僅支援 loopback,而且絕不會寫入真實憑證。 +MiniMax Code 依序遵循 `MINIMAX_DATA_DIR`、`MAVIS_DATA_DIR`,最後才回退到 +`~/.minimax`。其受管理區塊只擁有 `custom_provider.opencodex`,不會變更 +`defaultModel`、MiniMax 憑證來源或使用者的 MiniMax 登入。連接後請在 MCode +中選擇 `custom_provider:opencodex/`。 + 路徑遵循客戶端自己的環境覆寫(environment override)。對 OMP 而言,`OMP_PROFILE` 以存在與否優先於 `PI_PROFILE`,即使明確為空也一樣。具名 profile 會把 `PI_CONFIG_DIR` 當作相對於使用者家目錄的目錄名稱,並忽略 `PI_CODING_AGENT_DIR`;沒有具名 profile 時,`PI_CODING_AGENT_DIR` 勝出。OMP 支援 provider 層級的 headers,但這個最初的整合刻意只支援 loopback;遠端 `x-opencodex-api-key` 的連線設定被延後。搬移過的 `HERMES_HOME`、`KIMI_CODE_HOME` 與 `XDG_CONFIG_HOME` 路徑同樣會被遵循,而非猜測。表格列出每個客戶端的預設值。 對原生 OpenAI 模型,產生的 OMP 區塊會選用其模型層級的 Responses API,保留圖片輸入與 reasoning-effort 控制。路由模型則維持 provider 的 Chat Completions 方言,讓它們既有的 adapters 保持相容。 @@ -44,7 +50,7 @@ opencodex 從自己的環境讀取這些變數。如果你的 gateway 以 profil - **Restore this point…** 會出現在較舊的操作上,或當檔案在那次操作之後有變更時。跨過這樣的變更做回復會再詢問一次,才覆蓋你的較新編輯——並且也會備份它們,所以那次的回復本身也可以復原。 - 每個客戶端保留十份備份。超過之後,最舊的快照檔案會被移除,其歷史列顯示為 **Backup expired**。 -停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP** 同樣不受旁邊編輯影響,但原因不同:它的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(Hermes、OpenClaw、Kimi Code、Gajae Code——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 +停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP** 同樣不受旁邊編輯影響,但原因不同:它的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(Hermes、OpenClaw、Kimi Code、Gajae Code、MiniMax Code——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 ## 誠實的預期 @@ -52,7 +58,7 @@ opencodex 從自己的環境讀取這些變數。如果你的 gateway 以 profil **如果某個值無法忠實重寫,開關會拒絕執行。** 往返覆蓋這些格式在實務上會用到的值種類;當它做不到時——例如使用 `inf` 或 `nan` 的 TOML 檔案,我們可用的 parser 無法準確讀回——套用會停止並說明,而不是寫入被改動的值然後宣稱成功。你會看到檔案被指名,磁碟上沒有任何東西被移動。手動編輯那個檔案仍然有效;只有我們的自動重寫會拒絕。 -**Pi、Kimi Code、Gajae Code 與受管理 DSH 整合只能對 loopback bind 運作。** 前三者的設定沒有非 loopback bind 所需的 `x-opencodex-api-key` header 欄位。DSH 雖然提供通用 headers map,但 rc.6 並未把這個專用准入 header 記錄為受支援的整合契約,因此受管理 writer 會選擇安全拒絕,而不自行猜測。請改用 SSH tunnel,或由本機 forwarder 加上該 header 後再以 loopback 存取。 +**Pi、Kimi Code、Gajae Code、MiniMax Code 與受管理 DSH 整合只能對 loopback bind 運作。** 前四者的設定沒有非 loopback bind 所需的 `x-opencodex-api-key` header 欄位。DSH 雖然提供通用 headers map,但 rc.6 並未把這個專用准入 header 記錄為受支援的整合契約,因此受管理 writer 會選擇安全拒絕,而不自行猜測。請改用 SSH tunnel,或由本機 forwarder 加上該 header 後再以 loopback 存取。 **產生的 OMP 整合也刻意只支援 loopback。** OMP 確實支援 provider 層級的 headers,但這個最初的整合不會發出遠端 `x-opencodex-api-key` 憑證連線。手動的遠端 OMP 設定目前不在受管理的整合範圍內。 @@ -72,6 +78,25 @@ ocx integration client history --client hermes ocx integration client restore --op [--confirm-drift] ``` +MiniMax Code 先連接一次 provider,再透過會檢查設定的 launcher 啟動: + +```bash +ocx integration client enable --client mcode +ocx mcode +``` + +另一個 MiniMax 平台 CLI(`mmx`)不是檔案開關整合。其文字命令使用 MiniMax 的 +Anthropic 相容端點,因此 OpenCodex 提供憑證隔離、僅限 loopback 的 launcher: + +```bash +ocx mmx text chat --model anthropic/claude-opus-5 --message "Hello" +ocx mmx text repl --model openai/gpt-5.6-sol +``` + +只有 `mmx text chat` 與 `mmx text repl` 會經過 proxy。MiniMax 原生的其他指令請直接 +執行 `mmx`。wrapper 使用只含非機密 loopback 佔位符的暫存設定,不會讀取 `~/.mmx` +OAuth 或 API key,並拒絕 `--api-key`、`--base-url` 與 `--region` 覆寫。 + `--confirm-drift` 永遠不會被擅自假設。如果檔案在你正要回復的操作之後有變更,指令會拒絕並告訴你,因為覆蓋你較新的編輯是你的決定。 客戶端細節是針對各專案自己的設定格式驗證過的;檢查了什麼、何時檢查,請見 `devlog/_fin/260802_client_toggle_api/002_client_toggle_matrix.md` 中的研究筆記。 diff --git a/gui/src/combo-capabilities.ts b/gui/src/combo-capabilities.ts new file mode 100644 index 0000000000..32f91f66b9 --- /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 selected target advertises image input (incomplete rows fail closed). */ +export function comboImagesSupported(targets: ComboTarget[], models: ModelOption[]): boolean { + 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/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/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index d1527a5b13..2143b999f0 100644 --- a/gui/src/components/apikeys-workspace/client-config-clients.ts +++ b/gui/src/components/apikeys-workspace/client-config-clients.ts @@ -8,7 +8,7 @@ * with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row * until this tuple changes. */ -export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh"] as const; +export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode"] as const; export type ExportClientId = (typeof CLIENTS)[number]; export const CLIENT_LABEL_KEYS = { @@ -20,6 +20,7 @@ export const CLIENT_LABEL_KEYS = { kimi: "api.clientConfig.clientKimi", gajae: "api.clientConfig.clientGajae", dsh: "api.clientConfig.clientDsh", + mcode: "api.clientConfig.clientMcode", } as const; /** 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..85ae253fc9 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -837,6 +837,7 @@ export const de: Record = { "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", "integrations.tab.dsh": "DeepSeek Harness (DSH)", + "integrations.tab.mcode": "MiniMax Code", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Die Codex-Anbindung wird vom Proxy-Dienst verwaltet. Beim Start von opencodex wird sie angewendet; beim Stoppen des Dienstes wird das native Routing wiederhergestellt.", "integrations.codex.openService": "Dienststeuerung öffnen", @@ -948,6 +949,7 @@ export const de: Record = { "integrations.semantics.kimi": "Zum Anwenden neu starten oder /reload ausführen (v2 überwacht die Datei).", "integrations.semantics.gajae": "Gilt für eine neue Sitzung oder beim Öffnen von /model.", "integrations.semantics.dsh": "OpenCodex verwaltet nur llm-pi-ai.providers.opencodex in $DSH_HOME/settings.yaml. DSH lädt diesen Anbieter im laufenden Betrieb neu; Ihr Standardmodell und deepseek-official bleiben unverändert. Derzeit nur über Loopback; es werden keine echten Zugangsdaten geschrieben.", + "integrations.semantics.mcode": "Verwaltet nur custom_provider.opencodex. Standardmodell und MiniMax-Anmeldung bleiben unverändert.", "codexAuth.mainAccount": "Hauptkonto", "codexAuth.logLabel": "Log-Kennung", "codexAuth.codexApp": "Codex App", @@ -1230,6 +1232,7 @@ export const de: Record = { "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", + "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.copy": "Konfiguration kopieren", "api.clientConfig.download": "Herunterladen", "api.clientConfig.loading": "Client-Konfiguration wird erstellt…", @@ -1843,6 +1846,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..beff0e4875 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1305,6 +1305,7 @@ export const en = { "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", "integrations.tab.dsh": "DeepSeek Harness (DSH)", + "integrations.tab.mcode": "MiniMax Code", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex wiring is owned by the proxy service. Starting opencodex applies it; stopping the service restores native routing.", "integrations.codex.openService": "Open service controls", @@ -1416,6 +1417,7 @@ export const en = { "integrations.semantics.kimi": "Restart or run /reload to apply it (v2 watches the file).", "integrations.semantics.gajae": "Applies to a new session or when opening /model.", "integrations.semantics.dsh": "OpenCodex manages only llm-pi-ai.providers.opencodex in $DSH_HOME/settings.yaml. DSH hot reloads this provider; your default model and deepseek-official stay unchanged. Currently loopback-only; no real credential is written.", + "integrations.semantics.mcode": "Manages only custom_provider.opencodex. Your default model and MiniMax login stay unchanged.", "codexAuth.mainAccount": "Main Account", "codexAuth.logLabel": "Log label", "codexAuth.codexApp": "Codex App", @@ -1706,6 +1708,7 @@ export const en = { "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", + "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.copy": "Copy config", "api.clientConfig.download": "Download", "api.clientConfig.loading": "Building client config…", @@ -1883,6 +1886,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..baf1ac39d8 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1238,6 +1238,7 @@ export const ja: Record = { "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", "integrations.tab.dsh": "DeepSeek Harness (DSH)", + "integrations.tab.mcode": "MiniMax Code", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex の接続はプロキシサービスが管理します。opencodex を起動すると適用され、サービスを停止するとネイティブのルーティングに戻ります。", "integrations.codex.openService": "サービス制御を開く", @@ -1349,6 +1350,7 @@ export const ja: Record = { "integrations.semantics.kimi": "再起動するか /reload を実行すると適用されます(v2 はファイルを監視します)。", "integrations.semantics.gajae": "新しいセッション、または /model を開いたときに適用されます。", "integrations.semantics.dsh": "OpenCodex が管理するのは $DSH_HOME/settings.yaml 内の llm-pi-ai.providers.opencodex だけです。DSH はこのプロバイダーをホットリロードし、既定のモデルと deepseek-official は変更しません。現在はループバック専用で、実際の認証情報は書き込みません。", + "integrations.semantics.mcode": "custom_provider.opencodex のみを管理します。既定モデルと MiniMax ログインは変更しません。", "codexAuth.mainAccount": "メインアカウント", "codexAuth.logLabel": "ログラベル", "codexAuth.codexApp": "Codex App", @@ -1636,6 +1638,7 @@ export const ja: Record = { "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", + "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.copy": "設定をコピー", "api.clientConfig.download": "ダウンロード", "api.clientConfig.loading": "クライアント設定を生成中…", @@ -1902,6 +1905,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..61f17667c3 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -861,6 +861,7 @@ export const ko: Record = { "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", "integrations.tab.dsh": "DeepSeek Harness (DSH)", + "integrations.tab.mcode": "MiniMax Code", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 연결은 프록시 서비스가 관리합니다. opencodex를 시작하면 적용되고 서비스를 중지하면 기본 라우팅으로 복원됩니다.", "integrations.codex.openService": "서비스 제어 열기", @@ -972,6 +973,7 @@ export const ko: Record = { "integrations.semantics.kimi": "재시작 또는 /reload 시 적용됩니다 (v2는 파일 변경을 감지합니다).", "integrations.semantics.gajae": "새 세션 또는 /model을 열 때 적용됩니다.", "integrations.semantics.dsh": "OpenCodex는 $DSH_HOME/settings.yaml의 llm-pi-ai.providers.opencodex만 관리합니다. DSH는 이 provider를 hot reload하며 기본 model과 deepseek-official은 변경하지 않습니다. 현재 loopback 전용이며 실제 credential을 기록하지 않습니다.", + "integrations.semantics.mcode": "custom_provider.opencodex만 관리하며 기본 모델과 MiniMax 로그인은 변경하지 않습니다.", "codexAuth.mainAccount": "메인 계정", "codexAuth.logLabel": "로그 라벨", "codexAuth.codexApp": "Codex App", @@ -1257,6 +1259,7 @@ export const ko: Record = { "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", + "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.copy": "설정 복사", "api.clientConfig.download": "다운로드", "api.clientConfig.loading": "클라이언트 설정 생성 중…", @@ -1870,6 +1873,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..c350514259 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1289,6 +1289,7 @@ export const ru: Record = { "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", "integrations.tab.dsh": "DeepSeek Harness (DSH)", + "integrations.tab.mcode": "MiniMax Code", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Подключением Codex управляет прокси-сервис. При запуске opencodex оно применяется, а при остановке сервиса восстанавливается нативная маршрутизация.", "integrations.codex.openService": "Открыть управление сервисом", @@ -1400,6 +1401,7 @@ export const ru: Record = { "integrations.semantics.kimi": "Чтобы применить, перезапустите клиент или выполните /reload (v2 отслеживает файл).", "integrations.semantics.gajae": "Применяется в новом сеансе или при открытии /model.", "integrations.semantics.dsh": "OpenCodex управляет только llm-pi-ai.providers.opencodex в $DSH_HOME/settings.yaml. DSH применяет этот провайдер горячей перезагрузкой; модель по умолчанию и deepseek-official остаются без изменений. Сейчас поддерживается только loopback; реальные учётные данные не записываются.", + "integrations.semantics.mcode": "Управляет только custom_provider.opencodex. Модель по умолчанию и вход MiniMax не меняются.", "codexAuth.mainAccount": "Основной аккаунт", "codexAuth.logLabel": "Метка журнала", "codexAuth.codexApp": "Codex App", @@ -1687,6 +1689,7 @@ export const ru: Record = { "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", + "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.copy": "Копировать конфигурацию", "api.clientConfig.download": "Скачать", "api.clientConfig.loading": "Формируется конфигурация клиента…", @@ -1953,6 +1956,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/tr.ts b/gui/src/i18n/tr.ts index 0cb388d60e..de25ae8b16 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1296,6 +1296,7 @@ export const tr: Record = { "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", "integrations.tab.dsh": "DeepSeek Harness (DSH)", + "integrations.tab.mcode": "MiniMax Code", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex bağlantısı proxy servisine aittir.", "integrations.codex.openService": "Servis kontrollerini aç", @@ -1406,6 +1407,7 @@ export const tr: Record = { "integrations.semantics.kimi": "Uygulamak için yeniden başlatın.", "integrations.semantics.gajae": "Yeni oturuma uygulanır.", "integrations.semantics.dsh": "OpenCodex yalnızca $DSH_HOME/settings.yaml içindeki llm-pi-ai.providers.opencodex bölümünü yönetir. DSH bu sağlayıcıyı çalışırken yeniden yükler; varsayılan modeliniz ve deepseek-official değişmez. Şimdilik yalnızca geri döngü desteklenir; gerçek kimlik bilgisi yazılmaz.", + "integrations.semantics.mcode": "Yalnızca custom_provider.opencodex bölümünü yönetir. Varsayılan model ve MiniMax oturumu değişmez.", "integrations.semantics.omp": "Kataloğu yüklemek için OMP'yi yeniden başlatın.", "codexAuth.mainAccount": "Ana Hesap", "codexAuth.logLabel": "Günlük etiketi", @@ -1694,6 +1696,7 @@ export const tr: Record = { "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", + "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.copy": "JSON Kopyala", "api.clientConfig.download": "İndir", "api.clientConfig.loading": "İstemci konfigürasyonu oluşturuluyor…", @@ -1873,6 +1876,10 @@ export const tr: Record = { "cws.field.defaultEffort": "Varsayılan akıl yürütme", "cws.field.defaultEffortNone": "Yok (hedef varsayılanı)", "cws.field.defaultEffortHint": "Yalnızca istemci akıl yürütme çabasını belirtmediğinde (atladığında) kullanılır. Seçenekler, seçilen hedeflerin duyurulan çabalarının kesişimidir; katalog çaba meta verisi olmayan hedefler hiçbir seçenek sunmaz.", + "cws.capability.imageInputUnavailable": "Seçilen tüm hedefler görsel girişini destekleyene kadar kullanılamaz.", + "cws.capability.imageInputHint": "Tüm hedefler görselleri desteklediğinde varsayılan olarak açıktır. Yalnızca metin kabul etmek için kapatın.", + "cws.capability.imageInput": "Görsel / çok modlu", + "cws.capabilities": "Yetenekler", "cws.field.defaultEffortUnsupported": "Bu çaba hedeflerin ortak merdiveninde yok — istek anında yok sayılacak veya uydurulacaktır.", "cws.field.defaultEffortUnsupportedOption": "kesişimde değil", "cws.targets": "Hedefler", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 1f24a0f8de..ff667bffa3 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1409,6 +1409,10 @@ export const zhTW: Record = { "cws.field.defaultEffort": "預設推理級別", "cws.field.defaultEffortNone": "無(使用目標預設)", "cws.field.defaultEffortHint": "僅在客戶端未指定推理級別時使用。客戶端值優先,每個目標會按自身能力進行處理。", + "cws.capability.imageInputUnavailable": "所有已選目標都支援圖片輸入後才可使用。", + "cws.capability.imageInputHint": "所有目標都支援圖片時預設開啟;關閉後僅接受文字。", + "cws.capability.imageInput": "圖片 / 多模態", + "cws.capabilities": "功能", "cws.field.defaultEffortUnsupported": "此 effort 不在目標的共同階梯中 — 請求時會被忽略或就近對應。", "cws.field.defaultEffortUnsupportedOption": "不在交集中", "cws.targets": "目標", @@ -1810,6 +1814,7 @@ export const zhTW: Record = { "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", "integrations.tab.dsh": "DeepSeek Harness (DSH)", + "integrations.tab.mcode": "MiniMax Code", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 連線由代理服務管理。啟動 opencodex 時套用;停止服務時還原原生路由。", "integrations.codex.openService": "開啟服務控制", @@ -1921,6 +1926,7 @@ export const zhTW: Record = { "integrations.semantics.kimi": "重新啟動或執行 /reload 以套用(v2 會監視該檔案)。", "integrations.semantics.gajae": "在新工作階段中或開啟 /model 時生效。", "integrations.semantics.dsh": "OpenCodex 只管理 $DSH_HOME/settings.yaml 中的 llm-pi-ai.providers.opencodex。DSH 會熱重載該 provider;你的預設模型與 deepseek-official 維持不變。目前僅支援 loopback,且不會寫入真實憑證。", + "integrations.semantics.mcode": "僅管理 custom_provider.opencodex,不會變更預設模型或 MiniMax 登入狀態。", "codexAuth.pinned": "已固定", "codexAuth.pinnedHint": "你手動選取了此帳號,因此較高的選擇順序不會越過它。此固定會持續到該帳號用盡、你改選其他帳號,或你變更任一選擇順序為止。", "codexAuth.requestUserInput": "在 Default 模式中要求輸入", @@ -1958,6 +1964,7 @@ export const zhTW: Record = { "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", + "api.clientConfig.clientMcode": "MiniMax Code", "cws.tabsLabel": "Combo 詳細區段", "cws.field.nativeAlias": "原生 OpenAI 別名", "cws.field.nativeAliasHint": "讓此 combo 擁有受支援的未限定原生 OpenAI 模型 ID。帶有帳號或供應商限定的 OpenAI 路由仍保持獨立。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 88f6a5554b..ad87a7e599 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -854,6 +854,7 @@ export const zh: Record = { "integrations.tab.kimi": "Kimi Code", "integrations.tab.gajae": "Gajae Code", "integrations.tab.dsh": "DeepSeek Harness (DSH)", + "integrations.tab.mcode": "MiniMax Code", "integrations.codex.title": "Codex CLI", "integrations.codex.body": "Codex 连接由代理服务管理。启动 opencodex 时应用该连接;停止服务时恢复原生路由。", "integrations.codex.openService": "打开服务控制", @@ -965,6 +966,7 @@ export const zh: Record = { "integrations.semantics.kimi": "重启或运行 /reload 以应用(v2 会监视该文件)。", "integrations.semantics.gajae": "在新会话中或打开 /model 时生效。", "integrations.semantics.dsh": "OpenCodex 只管理 $DSH_HOME/settings.yaml 中的 llm-pi-ai.providers.opencodex。DSH 会热重载该 provider;你的默认模型和 deepseek-official 保持不变。目前仅支持环回地址,且不会写入真实凭据。", + "integrations.semantics.mcode": "仅管理 custom_provider.opencodex,不会更改默认模型或 MiniMax 登录状态。", "codexAuth.mainAccount": "主账号", "codexAuth.logLabel": "日志标签", "codexAuth.codexApp": "Codex App", @@ -1250,6 +1252,7 @@ export const zh: Record = { "api.clientConfig.clientKimi": "Kimi Code", "api.clientConfig.clientGajae": "Gajae Code", "api.clientConfig.clientDsh": "DeepSeek Harness (DSH)", + "api.clientConfig.clientMcode": "MiniMax Code", "api.clientConfig.copy": "复制配置", "api.clientConfig.download": "下载", "api.clientConfig.loading": "正在生成客户端配置…", @@ -1863,6 +1866,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/pages/Integrations.tsx b/gui/src/pages/Integrations.tsx index 4cdb78a977..026239c934 100644 --- a/gui/src/pages/Integrations.tsx +++ b/gui/src/pages/Integrations.tsx @@ -37,6 +37,7 @@ const TABS: readonly TabDefinition[] = [ { id: "kimi", hash: "integrations/kimi", labelKey: "integrations.tab.kimi" }, { id: "gajae", hash: "integrations/gajae", labelKey: "integrations.tab.gajae" }, { id: "dsh", hash: "integrations/dsh", labelKey: "integrations.tab.dsh" }, + { id: "mcode", hash: "integrations/mcode", labelKey: "integrations.tab.mcode" }, ] as const; const FILE_CLIENTS = new Set([ @@ -48,6 +49,7 @@ const FILE_CLIENTS = new Set([ "kimi", "gajae", "dsh", + "mcode", ]); function readIntegrationTab(hash = window.location.hash): IntegrationTab { diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index ead9e8cc94..ba9e67f392 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -25,6 +25,7 @@ const SEMANTICS_KEY: Record = { kimi: "integrations.semantics.kimi", gajae: "integrations.semantics.gajae", dsh: "integrations.semantics.dsh", + mcode: "integrations.semantics.mcode", }; const TAB_LABEL_KEY: Record = { @@ -36,6 +37,7 @@ const TAB_LABEL_KEY: Record = { kimi: "integrations.tab.kimi", gajae: "integrations.tab.gajae", dsh: "integrations.tab.dsh", + mcode: "integrations.tab.mcode", }; const KIND_KEY: Record = { diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 154ed1c6dd..571c4cca01 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -9,6 +9,7 @@ export const FILE_INTEGRATION_CLIENTS = [ "kimi", "gajae", "dsh", + "mcode", ] as const; export type FileIntegrationClientId = (typeof FILE_INTEGRATION_CLIENTS)[number]; diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index d17d20e13c..525bca3fec 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -142,6 +142,7 @@ const FILE_LABEL_KEY: Record = { kimi: "integrations.tab.kimi", gajae: "integrations.tab.gajae", dsh: "integrations.tab.dsh", + mcode: "integrations.tab.mcode", }; /** A file client's block is in the file for both `current` and `stale`. */ 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/cli/claude.ts b/src/cli/claude.ts index 216213ae93..ccf97e3826 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -19,6 +19,7 @@ import { configuredAdminToken } from "../lib/admin-secrets"; import { PROXY_MARKER, ownAdmissionTokens, defaultAuthDetectDeps, detectClaudeAuth, type AuthDetectDeps } from "../claude/auth-detect"; import { resolveClaudeAuthMode } from "../claude/auth-mode"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { selfLaunchArgv } from "../lib/self-launch-argv"; import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "./launcher-context"; export interface ClaudeLaunchEnv { @@ -269,7 +270,7 @@ async function ensureProxyForClaude(): Promise { if (live) return live.port; const cfgPort = loadConfig().port; const pinPort = typeof cfgPort === "number" && cfgPort > 0 ? cfgPort : 10100; - const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(pinPort)], { + const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(pinPort)]), { detached: true, stdio: "ignore", windowsHide: true, diff --git a/src/cli/combo.ts b/src/cli/combo.ts index 6a4bdc6ac1..de324eed19 100644 --- a/src/cli/combo.ts +++ b/src/cli/combo.ts @@ -91,6 +91,9 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { if (alias !== undefined) combo.alias = alias === "-" ? "" : alias; if (nativeAlias) combo.nativeAlias = true; if (displayName !== undefined) combo.displayName = displayName === "-" ? "" : displayName; + const current = await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", {}, deps); + const existing = (current.combos ?? []).find(row => row.id === (renameFrom ?? id)); + if (existing?.imageInput === "disabled") combo.imageInput = "disabled"; const result = await runtimeRequest("/api/combos", { method: "PUT", body: JSON.stringify({ id, combo, ...(renameFrom ? { renameFrom } : {}) }), diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 6e516d21fc..854c565646 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -488,6 +488,14 @@ const commandRunners: Record = { const { cmdOpencode } = await import("./opencode"); return await cmdOpencode(deps.args.slice(1)); }, + mcode: async deps => { + const { cmdMcode } = await import("./minimax"); + return await cmdMcode(deps.args.slice(1)); + }, + mmx: async deps => { + const { cmdMmx } = await import("./minimax"); + return await cmdMmx(deps.args.slice(1)); + }, help: async () => { printUsage(); return 0; diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index f8773e685e..d6c8996248 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -1,8 +1,8 @@ /** * `ocx export --client ` — print a client config for the live proxy. * - * Seven clients, four formats: opencode and Pi are JSON, OMP, Hermes and Gajae - * are YAML, OpenClaw JSON5, Kimi TOML. + * Eight clients, four formats: OpenCode and Pi are JSON; OMP, Hermes, Gajae and + * MiniMax Code are YAML; OpenClaw is JSON5; Kimi is TOML. * * Two consumers, one payload (devlog 260731_client_config_export/020): * diff --git a/src/cli/help.ts b/src/cli/help.ts index f729c1d81a..b7cc0b7f57 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -67,6 +67,8 @@ Usage: ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile ocx opencode [args...] Launch opencode wired to the proxy (runtime provider config) + ocx mcode [args...] Launch MiniMax Code through its managed provider + ocx mmx text [args] Launch MiniMax CLI text through the proxy ocx help [command] Show help ocx --version | -v Print version diff --git a/src/cli/index.ts b/src/cli/index.ts index aea63f9af2..bbb265d2b5 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -60,6 +60,7 @@ import { setIntegrationEnabled, shouldSyncCodexOnStart, shouldSyncGrokOnStart, s import { removeOwnedConfigState } from "../lib/config-ownership"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { selfLaunchArgv } from "../lib/self-launch-argv"; import { initializeNodeLauncherContext } from "./launcher-context"; import { createLocalAttestationSecret } from "../lib/local-management-attestation"; import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../lib/system-restart-contract"; @@ -121,11 +122,11 @@ function grokSyncFailureMessage(err: unknown): string { /** Argv for detached `start`, optionally hard-pinning the listen port. */ function startArgv(port?: number): string[] { - const args = [process.argv[1], "start"]; + const args = ["start"]; if (typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535) { args.push("--port", String(Math.trunc(port))); } - return args; + return selfLaunchArgv(args); } async function chooseListenPort(requestedPort?: number): Promise { diff --git a/src/cli/minimax.ts b/src/cli/minimax.ts new file mode 100644 index 0000000000..92d2bef311 --- /dev/null +++ b/src/cli/minimax.ts @@ -0,0 +1,491 @@ +/** + * MiniMax client launchers. + * + * `ocx mcode` uses the managed `custom_provider.opencodex` block written by the + * existing file-integration subsystem. `ocx mmx` is intentionally text-only: + * the official platform CLI's text commands speak Anthropic Messages, while its + * image/video/speech/music/search/quota endpoints are MiniMax-specific APIs that + * OpenCodex does not claim to implement. + */ +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ClientPathError, mcodeConfigPath, LOOPBACK_API_KEY_PLACEHOLDER } from "../clients/config-export"; +import { loadConfig } from "../config"; +import { clearableDeadline } from "../lib/abort"; +import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { selfLaunchArgv } from "../lib/self-launch-argv"; +import { commandInvocation } from "../lib/win-exec"; +import { isLoopbackHostname } from "../server/auth-cors"; +import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; +import type { OcxConfig } from "../types"; +import { opencodeProxyStartEnv } from "./opencode"; + +export interface MinimaxLaunchEnv { + [key: string]: string | undefined; +} + +export interface MmxTextBridge { + baseUrl: string; + port: number; + stop(): Promise; +} + +export interface MmxTextBridgeOptions { + /** Optional outer guard; production delegates timeout policy to `/v1/messages`. */ + headerTimeoutMs?: number; +} + +export interface MmxTerminationTarget { + pid?: number; + exitCode: number | null; + signalCode: NodeJS.Signals | null; + kill(signal?: NodeJS.Signals | number): boolean; +} + +export interface MmxTerminationDeps { + platform?: NodeJS.Platform; + killWindowsTree?: (pid: number) => void; +} + +export interface MmxSignalHost { + on(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown; + off(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown; +} + +export interface MmxTerminationHandlersOptions { + getChild: () => MmxTerminationTarget | null; + cleanup: () => Promise; + host?: MmxSignalHost; + now?: () => number; + terminationDeps?: MmxTerminationDeps; + onCleanupError?: (error: unknown) => void; +} + +const MMX_TERMINATION_DUPLICATE_WINDOW_MS = 500; + +const MMX_CHILD_OWNED_ENV_KEYS = new Set([ + "MMX_CONFIG_DIR", + "MINIMAX_BASE_URL", + "MINIMAX_REGION", + "MINIMAX_API_KEY", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", +]); + +const MMX_GLOBAL_BOOLEAN_FLAGS = new Set([ + "--quiet", + "--verbose", + "--no-color", + "--dry-run", + "--non-interactive", + "--yes", + "--async", + "--stream", + "--no-stream", + "--no-wait", + "--help", + "--version", +]); + +/** Mirrors the official mmx command scanner's global-flag skipping behavior. */ +export function mmxCommandPath(argv: readonly string[]): string[] { + const path: string[] = []; + for (let index = 0; index < argv.length;) { + const arg = argv[index]!; + if (arg === "--") break; + if (arg.startsWith("--")) { + const equals = arg.indexOf("="); + const name = equals >= 0 ? arg.slice(0, equals) : arg; + index += equals < 0 && !MMX_GLOBAL_BOOLEAN_FLAGS.has(name) ? 2 : 1; + continue; + } + if (arg.startsWith("-")) { + index += 1; + continue; + } + path.push(arg); + index += 1; + } + return path; +} + +/** Caller credentials and destinations may never override the proxy wrapper. */ +export function mmxUnsafeOverride(argv: readonly string[]): string | null { + for (const arg of argv) { + if (arg === "--api-key" || arg.startsWith("--api-key=")) return "--api-key"; + if (arg === "--base-url" || arg.startsWith("--base-url=")) return "--base-url"; + if (arg === "--region" || arg.startsWith("--region=")) return "--region"; + } + return null; +} + +export function buildMmxEnv( + live: Pick, + configDir: string, + base: MinimaxLaunchEnv = process.env, +): MinimaxLaunchEnv { + const env: MinimaxLaunchEnv = { ...base }; + // The official MMX client installs one ProxyAgent whenever any proxy variable + // is present and does not apply NO_PROXY. Its OpenCodex destination is always + // loopback, so carrying these variables could send the request off-machine. + // Windows environment names are case-insensitive; strip every inherited + // spelling before installing the wrapper-owned values below. + for (const key of Object.keys(env)) { + if (MMX_CHILD_OWNED_ENV_KEYS.has(key.toUpperCase())) delete env[key]; + } + env.MMX_CONFIG_DIR = configDir; + env.MINIMAX_BASE_URL = `http://${probeHostname(live.hostname)}:${live.port}`; + // Prevent a parent-shell region from triggering key detection against the + // official MiniMax hosts. The base URL above remains authoritative. + env.MINIMAX_REGION = "global"; + return env; +} + +/** + * MMX hard-codes `/anthropic/v1/messages` below its configured base URL while + * OpenCodex already exposes the canonical Anthropic data plane at + * `/v1/messages`. Keep that client-specific path adaptation inside the checked + * launcher instead of widening the proxy server's authentication surface. + */ +export function startMmxTextBridge( + live: Pick, + options: MmxTextBridgeOptions = {}, +): MmxTextBridge { + const upstreamOrigin = `http://${probeHostname(live.hostname)}:${live.port}`; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + const incoming = new URL(req.url); + const canonicalPath = incoming.pathname === "/anthropic/v1/messages" + ? "/v1/messages" + : incoming.pathname === "/anthropic/v1/messages/count_tokens" + ? "/v1/messages/count_tokens" + : null; + if (req.method !== "POST" || !canonicalPath) { + return Response.json({ + type: "error", + error: { type: "not_found_error", message: "unsupported MMX bridge route" }, + }, { status: 404 }); + } + + const target = new URL(canonicalPath, `${upstreamOrigin}/`); + target.search = incoming.search; + const headers = new Headers(req.headers); + // The bridge is loopback-only and OpenCodex does not require a real key + // there. Pin the public placeholder even if a future MMX release loads a + // credential from somewhere outside the isolated config directory. + headers.delete("authorization"); + headers.delete("x-opencodex-api-key"); + headers.set("x-api-key", LOOPBACK_API_KEY_PLACEHOLDER); + headers.delete("host"); + headers.delete("content-length"); + // The canonical data plane owns its configured response-header, retry, + // and stream-stall budgets. An extra default here would cut off valid + // non-streaming or failover completions before their real response. + const headerDeadline = options.headerTimeoutMs === undefined + ? null + : clearableDeadline(options.headerTimeoutMs, req.signal); + try { + return await fetch(new Request(target, { + method: "POST", + headers, + body: req.body, + signal: headerDeadline?.signal ?? req.signal, + })); + } catch { + return Response.json({ + type: "error", + error: { type: "api_error", message: "OpenCodex proxy unavailable" }, + }, { status: 502 }); + } finally { + // Once response headers arrive, streaming body cancellation remains + // linked to the client while this response-header timer is disarmed. + headerDeadline?.clear(); + } + }, + }); + const bridgePort = server.port; + if (bridgePort === undefined) { + void server.stop(true); + throw new Error("MMX text bridge did not receive a TCP port"); + } + return { + baseUrl: `http://127.0.0.1:${bridgePort}`, + port: bridgePort, + stop: () => server.stop(true), + }; +} + +function normalizedMcodeBaseUrl(value: string): string | null { + try { + const url = new URL(value); + if ( + url.username + || url.password + || (url.pathname !== "" && url.pathname !== "/") + || url.search + || url.hash + ) return null; + return url.origin; + } catch { + return null; + } +} + +/** Read only the provider destination; never return or log the persisted key. */ +export function mcodeOpenCodexBaseUrl(text: string): string | null { + try { + const parsed = Bun.YAML.parse(text) as { + custom_provider?: { opencodex?: { options?: { baseURL?: unknown } } }; + }; + const baseURL = parsed?.custom_provider?.opencodex?.options?.baseURL; + return typeof baseURL === "string" ? baseURL : null; + } catch { + return null; + } +} + +/** Reject stale runtime metadata that points a loopback-only launcher off-machine. */ +export function usableMinimaxLiveProxy(live: LiveProxy | null): LiveProxy | null { + if (!live) return null; + return isLoopbackHostname(probeHostname(live.hostname)) ? live : null; +} + +async function ensureProxy(config: OcxConfig): Promise { + const live = usableMinimaxLiveProxy(await findLiveProxy()); + if (live) return live; + const pinPort = typeof config.port === "number" && config.port > 0 ? config.port : 10100; + const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(pinPort)]), { + detached: true, + stdio: "ignore", + windowsHide: true, + // Reuse the established service-token lookup so a detached start works + // when admission lives in the hardened token file rather than this shell. + env: withProcessRuntimeProvenance(opencodeProxyStartEnv(process.env) as NodeJS.ProcessEnv), + }); + child.on("error", () => { /* the bounded health poll reports failure */ }); + child.unref(); + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + const started = usableMinimaxLiveProxy(await findLiveProxy()); + if (started) return started; + await new Promise(resolve => setTimeout(resolve, 250)); + } + return null; +} + +/** Only a root-level, single-token info request may bypass proxy isolation. */ +export function isStandaloneInformationalInvocation( + argv: readonly string[], + client: "mcode" | "mmx", +): boolean { + if (argv.length !== 1) return false; + const arg = argv[0]; + if (arg === "--help" || arg === "-h" || arg === "--version") return true; + // MMX 1.0.19 implements -v for version and does not implement -V. Preserve + // MCode's established -V passthrough separately. + return client === "mmx" ? arg === "-v" : arg === "-v" || arg === "-V"; +} + +/** Forward wrapper termination only while the MMX child is still live. */ +export function forwardMmxTerminationSignal( + child: MmxTerminationTarget, + signal: "SIGINT" | "SIGTERM", + deps: MmxTerminationDeps = {}, +): boolean { + if (child.exitCode !== null || child.signalCode !== null) return false; + try { + if ((deps.platform ?? process.platform) === "win32") { + if (!Number.isInteger(child.pid) || child.pid === undefined || child.pid <= 0) return false; + const killWindowsTree = deps.killWindowsTree ?? (pid => { + const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`; + execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + }); + killWindowsTree(child.pid); + return true; + } + return child.kill(signal); + } catch { + return false; + } +} + +/** Install persistent, duplicate-aware wrapper signal handlers. */ +export function installMmxTerminationHandlers( + options: MmxTerminationHandlersOptions, +): () => void { + const host = options.host ?? process; + const now = options.now ?? Date.now; + let lastTerminationSignalAt: number | null = null; + const onTerminationSignal = (signal: "SIGINT" | "SIGTERM") => { + const receivedAt = now(); + // Ctrl-C reaches the foreground Bun process directly and is also + // forwarded by bin/ocx.mjs. Keep the listener installed and coalesce the + // near-simultaneous duplicate so async cleanup cannot be interrupted by + // the default signal action after a once-listener disappears. + if ( + lastTerminationSignalAt !== null + && receivedAt - lastTerminationSignalAt < MMX_TERMINATION_DUPLICATE_WINDOW_MS + ) return; + lastTerminationSignalAt = receivedAt; + const child = options.getChild(); + if (child) forwardMmxTerminationSignal(child, signal, options.terminationDeps); + try { + void options.cleanup().catch(error => { options.onCleanupError?.(error); }); + } catch (error) { + options.onCleanupError?.(error); + } + }; + const onSigint = () => onTerminationSignal("SIGINT"); + const onSigterm = () => onTerminationSignal("SIGTERM"); + host.on("SIGINT", onSigint); + host.on("SIGTERM", onSigterm); + return () => { + host.off("SIGINT", onSigint); + host.off("SIGTERM", onSigterm); + }; +} + +/** Keep signal handlers active until asynchronous bridge cleanup has settled. */ +export async function finishMmxClientCleanup( + cleanup: () => Promise, + removeTerminationHandlers: () => void, +): Promise { + try { + await cleanup(); + } finally { + removeTerminationHandlers(); + } +} + +function spawnClient( + command: "mcode" | "mmx", + args: readonly string[], + env: NodeJS.ProcessEnv, + installHint: string, + onSpawn?: (child: ChildProcess) => void, +): Promise { + return new Promise(resolve => { + const inv = commandInvocation(command, [...args]); + const child = spawn(inv.file, inv.args, { stdio: "inherit", env, ...inv.options }); + onSpawn?.(child); + child.on("error", (error: NodeJS.ErrnoException) => { + console.error(error.code === "ENOENT" ? installHint : `❌ Failed to launch ${command}: ${error.message}`); + resolve(1); + }); + child.on("exit", (code, signal) => { + if (process.platform === "win32" && code === 9009 && !signal) console.error(installHint); + resolve(signal ? 1 : code ?? 0); + }); + }); +} + +const MCODE_INSTALL_HINT = "❌ `mcode` CLI not found. Install MiniMax Code first: https://github.com/MiniMax-AI/minimax-code"; +const MMX_INSTALL_HINT = "❌ `mmx` CLI not found. Install it first: npm install -g mmx-cli"; + +export async function cmdMcode(args: string[]): Promise { + if (isStandaloneInformationalInvocation(args, "mcode")) return spawnClient("mcode", args, process.env, MCODE_INSTALL_HINT); + const config = loadConfig(); + if (!isLoopbackHostname(config.hostname)) { + console.error("❌ MiniMax Code integration is loopback-only; its config cannot carry OpenCodex's dedicated remote-admission header."); + return 2; + } + const live = await ensureProxy(config); + if (!live) { + console.error("❌ Proxy did not become healthy after starting."); + return 1; + } + let configuredBase: string | null = null; + try { + configuredBase = mcodeOpenCodexBaseUrl(readFileSync(mcodeConfigPath(process.env), "utf8")); + } catch (error) { + // Missing or unreadable is reported as not connected below. An unstable + // relative override needs its own message because re-enabling cannot fix it. + if (error instanceof ClientPathError) { + console.error(`❌ ${error.message}`); + return 2; + } + } + if (!configuredBase) { + console.error("❌ MiniMax Code is not connected. Run: ocx integration client enable --client mcode"); + return 2; + } + const expected = `http://${probeHostname(live.hostname)}:${live.port}`; + if (normalizedMcodeBaseUrl(configuredBase) !== normalizedMcodeBaseUrl(expected)) { + console.error("❌ MiniMax Code's OpenCodex provider points at a stale proxy address. Re-run: ocx integration client enable --client mcode"); + return 2; + } + console.error(`✅ MiniMax Code wired to ${expected}; select custom_provider:opencodex/ in MCode.`); + return spawnClient("mcode", args, process.env, MCODE_INSTALL_HINT); +} + +export async function cmdMmx(args: string[]): Promise { + if (isStandaloneInformationalInvocation(args, "mmx")) return spawnClient("mmx", args, process.env, MMX_INSTALL_HINT); + const unsafe = mmxUnsafeOverride(args); + if (unsafe) { + console.error(`❌ ${unsafe} is not accepted by ocx mmx because it could bypass the proxy or expose a caller credential.`); + return 2; + } + const commandPath = mmxCommandPath(args); + if (commandPath[0] !== "text") { + console.error("❌ ocx mmx supports only `mmx text` commands. Use plain `mmx` for MiniMax image, video, speech, music, vision, search, quota, auth, config, file, and update APIs."); + return 2; + } + const config = loadConfig(); + if (!isLoopbackHostname(config.hostname)) { + console.error("❌ ocx mmx is loopback-only; MMX has no field for OpenCodex's dedicated remote-admission header."); + return 2; + } + const live = await ensureProxy(config); + if (!live) { + console.error("❌ Proxy did not become healthy after starting."); + return 1; + } + + const configDir = mkdtempSync(join(tmpdir(), "opencodex-mmx-")); + let bridge: MmxTextBridge | null = null; + let mmxChild: ChildProcess | null = null; + let cleanupPromise: Promise | null = null; + let removeTerminationHandlers = () => {}; + const cleanup = (): Promise => { + cleanupPromise ??= (async () => { + const activeBridge = bridge; + bridge = null; + try { + if (activeBridge) await activeBridge.stop(); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + })(); + return cleanupPromise; + }; + try { + // Isolate MMX from ~/.mmx OAuth/API-key state. The only credential in this + // temporary file is a public loopback placeholder, and the directory is + // removed as soon as the child exits. + writeFileSync(join(configDir, "config.json"), `${JSON.stringify({ + api_key: LOOPBACK_API_KEY_PLACEHOLDER, + region: "global", + }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + bridge = startMmxTextBridge(live); + const env = buildMmxEnv({ hostname: "127.0.0.1", port: bridge.port }, configDir, process.env) as NodeJS.ProcessEnv; + console.error(`✅ MiniMax CLI text bridged to http://${probeHostname(live.hostname)}:${live.port}/v1/messages.`); + removeTerminationHandlers = installMmxTerminationHandlers({ + getChild: () => mmxChild, + cleanup, + onCleanupError: error => { + console.error(`❌ Failed to clean up the MMX bridge after a termination signal: ${String(error)}`); + }, + }); + return await spawnClient("mmx", args, env, MMX_INSTALL_HINT, child => { mmxChild = child; }); + } finally { + await finishMmxClientCleanup(cleanup, removeTerminationHandlers); + } +} diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index efd1eca6a3..7197391e1f 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -41,6 +41,7 @@ import { providerCodexAccountMode } from "../providers/registry"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { selfLaunchArgv } from "../lib/self-launch-argv"; /** * The provider-block serializer, its constants, and the config-path helpers now live in @@ -494,7 +495,7 @@ async function ensureProxyForOpencode(config: OcxConfig): Promise 0 ? cfgPort : 10100; - const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(pinPort)], { + const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(pinPort)]), { detached: true, stdio: "ignore", windowsHide: true, diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 3437b0b7de..24d10d1f00 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -216,8 +216,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "api-key", usage: "ocx api-key ...", summary: "Alias of ocx access key." }, { name: "export", - usage: "ocx export --client [--json] [--out ] [--force]", - summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness) wired to the running proxy.", + usage: "ocx export --client [--json] [--out ] [--force]", + summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code) wired to the running proxy.", details: [ "--json prints the generated document as JSON on stdout; use --out for the client's native format.", "--out writes the native config there and refuses to replace an existing file without --force.", @@ -282,6 +282,26 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Stop using `ocx opencode` and plain `opencode` behaves exactly as before.", ], }, + { + name: "mcode", + usage: "ocx mcode [mcode args...]", + summary: "Launch MiniMax Code through its managed OpenCodex provider.", + details: [ + "First connect the reversible file integration: ocx integration client enable --client mcode", + "The launcher verifies that custom_provider.opencodex targets the current loopback proxy before starting MCode.", + "Select custom_provider:opencodex/ from MCode's model picker.", + ], + }, + { + name: "mmx", + usage: "ocx mmx text [mmx args...]", + summary: "Launch MiniMax CLI text commands through the proxy.", + details: [ + "Only the official MMX Anthropic-compatible text surface is proxied.", + "Use plain mmx for MiniMax-native image, video, speech, music, vision, search, quota, auth, config, file, and update commands.", + "The wrapper isolates ~/.mmx credentials and refuses --api-key/--base-url overrides.", + ], + }, { name: "restart", usage: "ocx restart", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 4c903fa0e6..2c5149a53c 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -399,6 +399,23 @@ export function dshConfigPath(env: OpencodeLaunchEnv = process.env, home: string return join(dshHomeDir(env, home), "settings.yaml"); } +/** + * MiniMax Code stores runtime state under `MINIMAX_DATA_DIR`, then the legacy + * `MAVIS_DATA_DIR`, and finally `~/.minimax`. Relative overrides are refused + * because a background proxy and a foreground client can have different CWDs. + */ +export function mcodeHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + const primary = env.MINIMAX_DATA_DIR?.trim(); + if (primary) return absoluteClientPath(primary, home, "MINIMAX_DATA_DIR"); + const legacy = env.MAVIS_DATA_DIR?.trim(); + if (legacy) return absoluteClientPath(legacy, home, "MAVIS_DATA_DIR"); + return join(home, ".minimax"); +} + +export function mcodeConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(mcodeHomeDir(env, home), "config.yaml"); +} + /** * One proxy-routed model destined for a client config. Deliberately narrower than * `CatalogModel` so a serializer cannot reach for a field that does not survive the @@ -438,7 +455,8 @@ export type ExportClientId = | "openclaw" | "kimi" | "gajae" - | "dsh"; + | "dsh" + | "mcode"; export interface ExportClientSpec { id: ExportClientId; @@ -841,6 +859,23 @@ export interface DshGeneratedConfig { }; } +export interface McodeProviderBlock { + name: "OpenCodex"; + kind: "custom"; + enabled: true; + api: "anthropic-messages"; + options: { + apiKey: string; + baseURL: string; + authMode: "api-key"; + }; + models: Record>; +} + +export interface McodeGeneratedConfig { + custom_provider: Record; +} + /** * Pi's `~/.pi/agent/models.json` shape. `models` is an ARRAY (identity lives in `id`), * unlike OpenCode's keyed object. @@ -1147,6 +1182,32 @@ function buildDshClientConfig(ctx: ExportContext): DshGeneratedConfig { }; } +/** + * MiniMax Code's `provider add` command persists custom providers under + * `custom_provider.`. Do not emit `defaultModel`: connecting a client must + * not silently replace the user's current model selection. + */ +function buildMcodeClientConfig(ctx: ExportContext): McodeGeneratedConfig { + const models: Record> = {}; + for (const model of normalizeExportModels(ctx.models)) models[model.namespaced] = {}; + return { + custom_provider: { + [OPENCODE_PROVIDER_ID]: { + name: "OpenCodex", + kind: "custom", + enabled: true, + api: "anthropic-messages", + options: { + apiKey: LOOPBACK_API_KEY_PLACEHOLDER, + baseURL: ctx.baseUrl.replace(/\/v1\/?$/, ""), + authMode: "api-key", + }, + models, + }, + }, + }; +} + /** * Per-client model counts, read back off the SERIALIZED document rather than * recomputed from the input rows: `modelsWithoutLimits` drives a GUI line about @@ -1196,6 +1257,12 @@ function summarizeDsh(document: unknown): { modelCount: number; modelsWithoutLim return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; } +function summarizeMcode(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const models = Object.values((document as McodeGeneratedConfig | undefined)?.custom_provider?.[OPENCODE_PROVIDER_ID]?.models ?? {}); + // MCode's custom-provider schema does not expose per-model context limits. + return { modelCount: models.length, modelsWithoutLimits: 0 }; +} + /** One fragment at `path`, built from this client's own document. */ function singleFragment(clientId: ExportClientId, path: readonly string[], value: unknown): ManagedContribution { return { clientId, fragments: [{ path, value }] }; @@ -1252,6 +1319,11 @@ function buildDshContribution(ctx: ExportContext): ManagedContribution { return singleFragment("dsh", ["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID], doc["llm-pi-ai"].providers[OPENCODE_PROVIDER_ID]); } +function buildMcodeContribution(ctx: ExportContext): ManagedContribution { + const doc = buildMcodeClientConfig(ctx); + return singleFragment("mcode", ["custom_provider", OPENCODE_PROVIDER_ID], doc.custom_provider[OPENCODE_PROVIDER_ID]); +} + export const EXPORT_CLIENTS: Record = { opencode: { id: "opencode", @@ -1361,6 +1433,20 @@ export const EXPORT_CLIENTS: Record = { buildContribution: buildDshContribution, loopbackOnly: true, }, + mcode: { + id: "mcode", + filename: "mcode-config.yaml", + destination: env => mcodeConfigPath(env), + apiKeyEnv: "", + exportHint: "MiniMax Code reads a non-secret placeholder from config.yaml; loopback needs no key.", + build: buildMcodeClientConfig, + format: "yaml", + summarize: summarizeMcode, + buildContribution: buildMcodeContribution, + // MCode persists this credential and exposes no dedicated proxy-admission + // header field, so real keys are never serialized and remote binds refuse. + loopbackOnly: true, + }, }; export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[]; 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/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index e3a9e56892..894d0959d3 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,39 @@ 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 = Array.isArray(modelPickerOrder) + ? modelPickerOrder.filter((id): id is string => 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 +592,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 +1392,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 +1424,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/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..2b198aae7a 100644 --- a/src/combos/request.ts +++ b/src/combos/request.ts @@ -23,6 +23,36 @@ 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 { + 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( 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/integrations/registry.ts b/src/integrations/registry.ts index 26402cd2b0..82800c0773 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -20,6 +20,8 @@ import { hermesHomeDir, kimiConfigPath, kimiHomeDir, + mcodeConfigPath, + mcodeHomeDir, ompAgentDir, ompModelsConfigPath, opencodeGlobalConfigPath, @@ -112,6 +114,11 @@ export const INTEGRATION_CLIENTS: Record mcodeConfigPath(env, home), + detectDir: (env = process.env, home = homedir()) => mcodeHomeDir(env, home), + }, }; export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] = diff --git a/src/lib/self-launch-argv.ts b/src/lib/self-launch-argv.ts new file mode 100644 index 0000000000..25038e0ca1 --- /dev/null +++ b/src/lib/self-launch-argv.ts @@ -0,0 +1,15 @@ +interface SelfLaunchArgvOptions { + isStandaloneExecutable?: boolean; + sourceEntrypoint?: string; +} + +/** Build argv for re-entering the current CLI in compiled or source mode. */ +export function selfLaunchArgv( + args: readonly string[], + options: SelfLaunchArgvOptions = {}, +): string[] { + const bunStandalone = (Bun as unknown as { isStandaloneExecutable?: boolean }).isStandaloneExecutable; + const isStandaloneExecutable = options.isStandaloneExecutable ?? Boolean(bunStandalone); + if (isStandaloneExecutable) return [...args]; + return [options.sourceEntrypoint ?? process.argv[1], ...args]; +} diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index 9c197d0d74..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 { - const args = [process.argv[1], "start"]; + const args = ["start"]; const expectedPort = typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535 ? Math.trunc(port) : undefined; if (expectedPort !== undefined) { args.push("--port", String(expectedPort)); } + const launchArgs = selfLaunchArgv(args); return new Promise((resolve, reject) => { let child: ReturnType; try { const env: NodeJS.ProcessEnv = { ...process.env }; delete env.OCX_SERVICE; - child = spawn(process.execPath, args, { + child = spawn(process.execPath, launchArgs, { detached: true, stdio: "ignore", windowsHide: true, diff --git a/src/server/relay.ts b/src/server/relay.ts index a456c6124e..6e5d1de7cf 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; @@ -385,8 +386,10 @@ export function trackSseForRequestLog( try { const { done, value } = await reader.read(); if (done) { - inspector.finish(); - if (!terminalReported) reportTerminal("incomplete"); + if (!cancelled) { + inspector.finish(); + if (!terminalReported) reportTerminal("incomplete"); + } inspector.dispose(); controller.close(); return; @@ -394,12 +397,19 @@ export function trackSseForRequestLog( inspector.feed(value); controller.enqueue(value); } catch (err) { - if (!terminalReported) reportTerminal("incomplete"); + // 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 (!cancelled && !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 +1132,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/server/responses/core.ts b/src/server/responses/core.ts index 383509969b..0b21ba180b 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -40,6 +40,7 @@ import { comboDefaultEffort, comboFailureDecision, comboIdFromRawBody, + comboRequestHasImageInput, concreteComboRequestBody, getCombo, isComboTargetInCooldown, @@ -1172,6 +1173,41 @@ export async function handleComboResponses( if (!combo) { return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`); } + // Expand previous_response_id before image policy and child dispatch so a + // continuation that only references prior images still fails closed when + // imageInput is disabled (and so targets see the full replayed input). + const body = expandPreviousResponseInput(rawBody); + if (previousResponseReplayFailure(body)) { + return formatErrorResponse( + 400, + "previous_response_not_found", + "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.", + ); + } + // 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, + "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`); + } + // 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. @@ -1188,7 +1224,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 +1266,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 }), @@ -2668,6 +2704,7 @@ async function handleResponsesInner( } else { logCtx.transportPhase = "mid_stream"; logCtx.terminalSource = "synthetic"; + if (logCtx.activeAttempt) logCtx.activeAttempt.streamAborted = true; reportNativeTerminal("failed", 502); } }, @@ -3621,9 +3658,13 @@ async function handleResponsesInner( cancelBodyOnAbort(upstreamResponse.body, upstream.signal); - // Anthropic-only: one bounded internal continuation re-ask for clean end_turn turns that - // announced an edit without emitting a tool call. - const terminalGuardEnabled = activeAdapter.name === "anthropic" && !options.comboAttempt && !routedCompaction; + // One bounded internal continuation re-ask for clean end_turn turns that announced an edit + // without emitting a tool call. Anthropic gets this by default; openai-chat providers opt in + // per-provider via `terminalContinuationGuard` (the heuristic was tuned on Anthropic turns, + // so it stays off for the shared openai-chat adapter unless a provider enables it). + const terminalGuardEnabled = (activeAdapter.name === "anthropic" + || (activeAdapter.name === "openai-chat" && route.provider.terminalContinuationGuard === true)) + && !options.comboAttempt && !routedCompaction; /** * One bounded internal re-ask for Anthropic end_turn-without-tool-call turns. Replays the * continuation on a 429 with the same-key retry budget (hoisted per request), then falls diff --git a/src/server/responses/terminal-guard.ts b/src/server/responses/terminal-guard.ts index aa2104b4e1..22865c7274 100644 --- a/src/server/responses/terminal-guard.ts +++ b/src/server/responses/terminal-guard.ts @@ -195,7 +195,7 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio for await (const event of source) { if (event.type === "done") { terminalSeen = true; - const analysis = options.adapterName === "anthropic" + const analysis = (options.adapterName === "anthropic" || options.adapterName === "openai-chat") ? analyzeTerminalTurn(parsed, seen) : { decision: "pass" as const }; const normalStop = event.stopReason !== "max_tokens" && event.stopReason !== "content_filter"; diff --git a/src/types.ts b/src/types.ts index 9bca99661f..f98ed13f81 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 @@ -973,6 +987,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 @@ -1475,6 +1495,18 @@ export interface OcxProviderConfig { * No effect unless `parallelToolCalls === false`; ignored by non-`openai-chat` adapters. */ pinParallelToolCallsFalse?: boolean; + /** + * Opt-in: extend the no-tool-call terminal continuation guard to this provider's + * `openai-chat` routed turns. The guard (originally Anthropic-only, see + * devlog/_fin/260706_previous-response-id-400) issues one bounded internal re-ask when a + * model announces work but ends the turn without emitting a tool call. Self-hosted + * OpenAI-compatible gateways (GLM/Kimi-family, etc.) hit the same premature-completion + * pattern, but the heuristic that decides a "suspicious no-tool stop" was tuned on + * Anthropic turns, so it stays OFF by default for the many registry providers that share + * the `openai-chat` adapter. Enable only for a provider whose models are known to stop + * mid-work; non-`openai-chat` adapters ignore this flag. + */ + terminalContinuationGuard?: boolean; /** * Opt-in: forward `prompt_cache_key` to the upstream `/chat/completions` body. * OpenAI-specific extension; strict backends (Groq, Cerebras, etc.) reject unknown diff --git a/src/update/index.ts b/src/update/index.ts index e4a6896281..17b71ff95c 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -10,6 +10,7 @@ import { } from "./npm-cache-preflight.mjs"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { selfLaunchArgv } from "../lib/self-launch-argv"; /** * A `codex-history-backup-*.json` surviving a stop means the native-history restore was @@ -248,7 +249,7 @@ export async function runUpdate(): Promise { if (serviceWasInstalled || readPid() || readRuntimePort()) { console.log("⏹ Stopping the running proxy before updating..."); const stopStdio = updateChildStdio(); - const stop = spawnSync(process.execPath, [process.argv[1], "stop"], { + const stop = spawnSync(process.execPath, selfLaunchArgv(["stop"]), { stdio: stopStdio, encoding: stopStdio === "pipe" ? "utf8" : undefined, windowsHide: true, @@ -298,13 +299,13 @@ export async function runUpdate(): Promise { console.warn(`⚠️ Shim repair skipped: ${e instanceof Error ? e.message : e}`); } if (trayWasInstalled) { - const trayArgs = [process.argv[1], ...planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs]; + const trayArgs = selfLaunchArgv(planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs); const tray = spawnSync(process.execPath, trayArgs, { stdio: "inherit", windowsHide: true }); if (tray.status === 0) { console.log("🔧 Refreshed Windows tray startup paths."); } else { console.warn("⚠️ Windows tray refresh failed. Run 'ocx tray install'."); - if (trayWasRunning) spawnSync(process.execPath, [process.argv[1], "tray", "start"], { stdio: "ignore", windowsHide: true }); + if (trayWasRunning) spawnSync(process.execPath, selfLaunchArgv(["tray", "start"]), { stdio: "ignore", windowsHide: true }); } } // The stop above unloaded any managed service; repair it with the NEW files @@ -328,7 +329,7 @@ export async function runUpdate(): Promise { process.env.OCX_BAKE_PORT = String(capturedListen.port); try { const svcStdio = updateChildStdio(); - const svc = spawnSync(process.execPath, [process.argv[1], ...serviceReinstallArgs()], { + const svc = spawnSync(process.execPath, selfLaunchArgv(serviceReinstallArgs()), { stdio: svcStdio, encoding: svcStdio === "pipe" ? "utf8" : undefined, windowsHide: true, @@ -372,7 +373,7 @@ export async function runUpdate(): Promise { : " Run 'ocx service repair' to refresh the background service and see why it failed."); const env = { ...process.env }; delete env.OCX_SERVICE; - const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(capturedListen.port)], { + const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(capturedListen.port)]), { detached: true, stdio: "ignore", windowsHide: true, diff --git a/src/update/job.ts b/src/update/job.ts index b615e3b607..c95a9ff2d5 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -13,6 +13,7 @@ import { verifyPidIdentity, } from "../config"; import { isProcessAlive, killProxy } from "../lib/process-control"; +import { selfLaunchArgv } from "../lib/self-launch-argv"; import { buildWindowsElevatedArgumentList, resolveTrustedWindowsPowerShellExe, @@ -549,14 +550,12 @@ export function spawnGuiUpdateWorker( channel: Channel, restart: boolean, ): UpdateWorkerProcess { - const script = process.argv[1]; - const args = [ - script, + const args = selfLaunchArgv([ "__gui-update-worker", jobId, channel, restart ? "restart" : "no-restart", - ]; + ]); if (process.platform !== "win32") { return spawn(process.execPath, args, { detached: true, @@ -1866,11 +1865,11 @@ export async function runGuiUpdateWorker( } if (trayWasInstalled) { - const trayArgs = [process.argv[1], ...planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs]; + const trayArgs = selfLaunchArgv(planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs); const tray = runLoggedCommand(job, process.execPath, trayArgs, 20_000); if (tray.status !== 0) { updateJob(job, {}, "Windows tray refresh failed; run 'ocx tray install'."); - if (trayWasRunning) runLoggedCommand(job, process.execPath, [process.argv[1], "tray", "start"], 15_000); + if (trayWasRunning) runLoggedCommand(job, process.execPath, selfLaunchArgv(["tray", "start"]), 15_000); } } diff --git a/src/update/notify.ts b/src/update/notify.ts index e52f0a2995..28dbfcb634 100644 --- a/src/update/notify.ts +++ b/src/update/notify.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { createInterface } from "node:readline/promises"; import { atomicWriteFile, getConfigDir } from "../config"; import { hasStarPromptRun } from "../cli/star-prompt"; +import { selfLaunchArgv } from "../lib/self-launch-argv"; import { type Channel, currentVersion, @@ -166,9 +167,10 @@ function cacheIsStale(cache: VersionCache | null): boolean { export function triggerBackgroundRefreshIfStale(channel: Channel, cache: VersionCache | null): void { if (!cacheIsStale(cache)) return; try { - const entry = process.argv[1]; - if (!entry || !existsSync(entry)) return; - const child = spawn(process.execPath, [entry, "__refresh-version", channel], { + const commandArgs = ["__refresh-version", channel]; + const args = selfLaunchArgv(commandArgs); + if (args.length > commandArgs.length && (!args[0] || !existsSync(args[0]))) return; + const child = spawn(process.execPath, args, { detached: true, stdio: "ignore", windowsHide: true, 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/structure/01_runtime.md b/structure/01_runtime.md index e0977229a3..372e816f7c 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -6,7 +6,7 @@ | --- | --- | | `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | -| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. Configuration: provider, account, models, combo/route, access, integrations, v2. Diagnostics: doctor, debug, observe, health. Windows adds tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | +| `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, and facade re-exports for split server modules. | | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | | `src/config.ts` | `~/.opencodex/config.json`, defaults, PID path, env-value resolution, `websocketsEnabled()`. | diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 42f72945a6..1db9a6fe1d 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -661,6 +661,25 @@ family shared by unrelated upstreams. - 다른 대안 대신 이 방식을 선택한 이유: Global or heuristic rules regress supported providers and make custom gateway names part of the wire contract. - 장점, 단점 및 영향: Compatible siblings retain schema enforcement and explicitly incompatible models avoid the upstream 400; operators must classify each unsupported model they route. +## MiniMax Anthropic-compatible clients + +The MiniMax platform CLI's text resource posts Anthropic Messages to +`/anthropic/v1/messages`. `ocx mmx` adapts that hard-coded client path with a temporary +loopback bridge instead of adding another server route. The bridge accepts only POSTs to the +messages and count-tokens paths, rewrites them to the existing `/v1/messages` data plane, +preserves the query and streaming body, strips all incoming credential headers, and pins the +public loopback placeholder. It stops as soon as the MMX child exits, so the server's +`AUTH_MATRIX` and authentication surface remain unchanged. + +`ocx mmx` exposes only the text resource because the other MMX resources use MiniMax-specific +image, video, speech, music, vision, search, quota and file endpoints. The launcher isolates +`~/.mmx` credentials behind a temporary config, removes ambient proxy variables so loopback +traffic cannot be sent off-machine, owns the temporary bridge lifecycle, and refuses +destination, region and credential overrides. It is +loopback-only because MMX cannot carry the dedicated remote-admission header. MiniMax Code uses +the separate reversible `custom_provider.opencodex` file integration and is likewise +loopback-only; its generated block never changes `defaultModel`. + ## Anthropic structured-output compatibility The Anthropic adapter lowers Responses `text.format` and Chat Completions `response_format` JSON diff --git a/tests/cli-export-command.test.ts b/tests/cli-export-command.test.ts index a00c0708d6..e8f3447af6 100644 --- a/tests/cli-export-command.test.ts +++ b/tests/cli-export-command.test.ts @@ -215,7 +215,7 @@ describe("ocx export argument validation (accept criterion 4)", () => { const proxy = fakeProxy(); const result = await run(["--client", "cursor"], { baseUrl: proxy.baseUrl }); expect(result.code).toBe(2); - for (const id of ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh"]) { + for (const id of ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode"]) { expect(result.stderr).toContain(id); } expect(result.stdout).toBe(""); diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index dbb8479f0a..e051ee9763 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -247,7 +247,7 @@ describe("headless GUI parity CLI", () => { "set", "fast", "--targets", "ark/model-a:2,openai/gpt-5.5", "--strategy", "failover", "--json", ], runtime.deps); expect(code).toBe(0); - expect(runtime.requests[0]?.body).toEqual({ + expect(runtime.requests.find(request => request.method === "PUT")?.body).toEqual({ id: "fast", combo: { strategy: "failover", @@ -271,7 +271,7 @@ describe("headless GUI parity CLI", () => { "--json", ], runtime.deps); expect(code).toBe(0); - expect(runtime.requests[0]?.body).toMatchObject({ + expect(runtime.requests.find(request => request.method === "PUT")?.body).toMatchObject({ id: "nova-sol", combo: { alias: "gpt-5.6-sol", @@ -282,6 +282,35 @@ describe("headless GUI parity CLI", () => { }); }); + test("combo set round-trips an existing disabled image-input capability", async () => { + let persisted: Record = { + id: "text-only", + imageInput: "disabled", + targets: [{ provider: "ark", model: "old-model" }], + }; + const runtime = fakeRuntime((req, body) => { + if (req.method === "GET") return { combos: [persisted] }; + if (req.method === "PUT") { + const update = body as { id: string; combo: Record }; + persisted = { id: update.id, ...update.combo }; + return { combo: persisted }; + } + return undefined; + }); + + expect(await handleComboCommand([ + "set", "text-only", "--targets", "ark/new-model", "--json", + ], runtime.deps)).toBe(0); + expect(await handleComboCommand(["show", "text-only", "--json"], runtime.deps)).toBe(0); + + expect(persisted).toMatchObject({ + id: "text-only", + imageInput: "disabled", + targets: [{ provider: "ark", model: "new-model" }], + }); + expect(runtime.requests.map(request => request.method)).toEqual(["GET", "PUT", "GET"]); + }); + test("agent effort and roster use the same live mutation routes as GUI", async () => { const runtime = fakeRuntime(); expect(await handleAgentCommand(["effort", "set", "--main", "high", "--subagent", "medium", "--json"], runtime.deps)).toBe(0); diff --git a/tests/client-config-export-new-clients.test.ts b/tests/client-config-export-new-clients.test.ts index 8842af56d1..231975eca4 100644 --- a/tests/client-config-export-new-clients.test.ts +++ b/tests/client-config-export-new-clients.test.ts @@ -62,7 +62,7 @@ describe("no secret reaches a client config", () => { // carry provider headers, but remote credential wiring is deliberately // deferred from this initial generated integration. const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode"]); }); test("every client that is not loopback-only carries the header on a remote bind", () => { @@ -267,7 +267,7 @@ describe("gajae", () => { describe("contributions name every fragment we own", () => { test("single-entry clients own exactly one path", () => { - for (const id of ["opencode", "pi", "omp", "hermes", "openclaw", "gajae", "dsh"] as const) { + for (const id of ["opencode", "pi", "omp", "hermes", "openclaw", "gajae", "dsh", "mcode"] as const) { expect(buildClientContribution(id, ctx()).fragments).toHaveLength(1); } }); diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts index a9eaaff321..206c0656b1 100644 --- a/tests/client-config-export.test.ts +++ b/tests/client-config-export.test.ts @@ -514,8 +514,8 @@ describe("stable ordering (accept criterion 4)", () => { }); describe("EXPORT_CLIENTS registry", () => { - test("covers exactly the eight file-toggle clients", () => { - expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh"]); + test("covers exactly the nine file-toggle clients", () => { + expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode"]); for (const id of EXPORT_CLIENT_IDS) expect(isExportClientId(id)).toBe(true); // The exception clients keep their own surfaces and are not export clients. expect(isExportClientId("claude-desktop")).toBe(false); @@ -665,6 +665,7 @@ describe("EXPORT_CLIENTS registry", () => { expect(EXPORT_CLIENTS.openclaw.filename).toBe("openclaw.json5"); expect(EXPORT_CLIENTS.kimi.filename).toBe("kimi-config.toml"); expect(EXPORT_CLIENTS.gajae.filename).toBe("gajae-models.yaml"); + expect(EXPORT_CLIENTS.mcode.filename).toBe("mcode-config.yaml"); }); test("the opencode destination reuses the launcher's XDG resolution", () => { 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..17be566104 --- /dev/null +++ b/tests/codex-catalog-model-picker-order.test.ts @@ -0,0 +1,206 @@ +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?: unknown }) { + const entries = buildCatalogEntriesFromObservedState({ + template: template() as never, + gptSlugs: [], + goModels, + featured: overrides.featured, + modelPickerOrder: overrides.modelPickerOrder as string[] | undefined, + 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.each([ + ["non-array string", "tyler/deepseek-v4-pro"], + ["null", null], + ["number", 42], + ])("malformed %s input is ignored without crashing catalog sync", (_label, modelPickerOrder) => { + const p = build({ modelPickerOrder }); + expect(p["tyler/deepseek-v4-pro"]).toBe(5); + expect(p["jd-chat/kimi-k3"]).toBe(5); + }); + + test("non-string array members are ignored while valid slugs survive", () => { + const p = build({ + modelPickerOrder: ["tyler/deepseek-v4-pro", null, 42, { slug: "jd-chat/kimi-k3" }], + }); + expect(p["tyler/deepseek-v4-pro"]).toBeGreaterThanOrEqual(1000); + expect(p["jd-chat/kimi-k3"]).toBe(5); + }); + + 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); + }); +}); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 60021106f4..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, @@ -154,6 +155,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..75c19f8916 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -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..d621375162 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,70 @@ describe("combo-workspace-data", () => { )).toBe(false); }); }); + + +describe("comboImagesSupported", () => { + 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", () => { + 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); + }); +}); + +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"); + }); +}); 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/integrations-invariants.test.ts b/tests/integrations-invariants.test.ts index a6fa42749c..f48254184f 100644 --- a/tests/integrations-invariants.test.ts +++ b/tests/integrations-invariants.test.ts @@ -66,9 +66,9 @@ afterEach(() => { }); describe("the client registries cannot drift apart", () => { - test("every list of clients holds exactly the same eight ids", async () => { + test("every list of clients holds exactly the same nine ids", async () => { /* - * Five lists name the same eight clients, and two of them are maintained by + * Five lists name the same nine clients, and two of them are maintained by * hand: the GUI cannot import the backend registry, because that would * pull node:os and node:path into the browser bundle. A client added * server-side renders no row until someone remembers the tuple, and the @@ -78,7 +78,7 @@ describe("the client registries cannot drift apart", () => { const guiIntegrations = await import("../gui/src/pages/integrations/integration-api"); const expected = [...EXPORT_CLIENT_IDS].sort(); - expect(expected).toHaveLength(8); + expect(expected).toHaveLength(9); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); @@ -140,6 +140,7 @@ describe("every client survives a full lifecycle", () => { kimi: '[providers.mine]\napi = "http://keep-me"\n', gajae: "providers:\n mine:\n api: http://keep-me\n", dsh: "llm-pi-ai:\n providers:\n mine:\n api: openai-completions\n", + mcode: "custom_provider:\n mine:\n name: Keep Me\n", }; for (const clientId of INTEGRATION_CLIENT_IDS) { diff --git a/tests/integrations-state.test.ts b/tests/integrations-state.test.ts index 1ddb551047..521341a952 100644 --- a/tests/integrations-state.test.ts +++ b/tests/integrations-state.test.ts @@ -714,9 +714,9 @@ describe("installation detection is independent of config state", () => { * from. Rationale and the per-client table: 020 §1 amendment. */ describe("the loopback-only set is one fact, read through one seam", () => { - test("omp, pi, kimi, gajae and dsh are loopback-only and nobody else is", () => { + test("omp, pi, kimi, gajae, dsh and mcode are loopback-only and nobody else is", () => { const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id)); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode"]); }); test("the registry restates nothing — it reads the export spec", () => { diff --git a/tests/minimax-clients.test.ts b/tests/minimax-clients.test.ts new file mode 100644 index 0000000000..4203731747 --- /dev/null +++ b/tests/minimax-clients.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { join } from "node:path"; +import { + ClientPathError, + LOOPBACK_API_KEY_PLACEHOLDER, + OPENCODE_PROVIDER_ID, + buildClientConfig, + buildClientConfigText, + mcodeConfigPath, + mcodeHomeDir, + type ExportContext, + type McodeGeneratedConfig, +} from "../src/clients/config-export"; +import { + buildMmxEnv, + finishMmxClientCleanup, + forwardMmxTerminationSignal, + installMmxTerminationHandlers, + mcodeOpenCodexBaseUrl, + mmxCommandPath, + mmxUnsafeOverride, + isStandaloneInformationalInvocation, + startMmxTextBridge, + usableMinimaxLiveProxy, +} from "../src/cli/minimax"; +import type { OcxConfig } from "../src/types"; + +const CONFIG = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +function context(): ExportContext { + return { + baseUrl: "http://127.0.0.1:10100/v1", + config: CONFIG, + models: [ + { namespaced: "openai/gpt-5.6-sol", provider: "openai", id: "gpt-5.6-sol" }, + { namespaced: "anthropic/claude-opus-5", provider: "anthropic", id: "claude-opus-5" }, + ], + }; +} + +describe("MiniMax Code client config", () => { + test("adds only custom_provider.opencodex and never changes the selected model", () => { + const document = buildClientConfig("mcode", context()) as McodeGeneratedConfig; + expect(Object.keys(document)).toEqual(["custom_provider"]); + expect(document).not.toHaveProperty("defaultModel"); + const provider = document.custom_provider[OPENCODE_PROVIDER_ID]!; + expect(provider).toEqual({ + name: "OpenCodex", + kind: "custom", + enabled: true, + api: "anthropic-messages", + options: { + apiKey: LOOPBACK_API_KEY_PLACEHOLDER, + baseURL: "http://127.0.0.1:10100", + authMode: "api-key", + }, + models: { + "anthropic/claude-opus-5": {}, + "openai/gpt-5.6-sol": {}, + }, + }); + }); + + test("native YAML round-trips and contains no credential-shaped value", () => { + const built = buildClientConfigText("mcode", context()); + expect(built.format).toBe("yaml"); + expect(Bun.YAML.parse(built.text)).toEqual(built.document as never); + expect(built.text).not.toContain("sk-"); + }); + + test("resolves the public data-dir overrides in MCode precedence order", () => { + expect(mcodeHomeDir({}, "/home/u")).toBe(join("/home/u", ".minimax")); + expect(mcodeConfigPath({ MAVIS_DATA_DIR: "/legacy" }, "/home/u")).toBe(join("/legacy", "config.yaml")); + expect(mcodeConfigPath({ MINIMAX_DATA_DIR: "/current", MAVIS_DATA_DIR: "/legacy" }, "/home/u")) + .toBe(join("/current", "config.yaml")); + expect(() => mcodeConfigPath({ MINIMAX_DATA_DIR: "relative" }, "/home/u")).toThrow(ClientPathError); + }); + + test("launcher reads only the managed provider destination", () => { + const { text } = buildClientConfigText("mcode", context()); + expect(mcodeOpenCodexBaseUrl(text)).toBe("http://127.0.0.1:10100"); + expect(mcodeOpenCodexBaseUrl("not: [valid")).toBeNull(); + }); +}); + +describe("MiniMax CLI wrapper", () => { + test("passes through only standalone help and officially supported version invocations", () => { + expect(isStandaloneInformationalInvocation(["--help"], "mmx")).toBeTrue(); + expect(isStandaloneInformationalInvocation(["--version"], "mmx")).toBeTrue(); + expect(isStandaloneInformationalInvocation(["-v"], "mmx")).toBeTrue(); + expect(isStandaloneInformationalInvocation(["-V"], "mmx")).toBeFalse(); + expect(isStandaloneInformationalInvocation(["text", "chat", "--message", "-v"], "mmx")).toBeFalse(); + expect(isStandaloneInformationalInvocation(["text", "chat", "--message", "--version"], "mmx")).toBeFalse(); + expect(isStandaloneInformationalInvocation(["--message", "--version"], "mmx")).toBeFalse(); + expect(isStandaloneInformationalInvocation(["--help", "text", "chat"], "mmx")).toBeFalse(); + expect(isStandaloneInformationalInvocation(["-v"], "mcode")).toBeTrue(); + expect(isStandaloneInformationalInvocation(["-V"], "mcode")).toBeTrue(); + expect(isStandaloneInformationalInvocation(["--version", "extra"], "mcode")).toBeFalse(); + }); + + test("finds text commands with official global flags before or after the path", () => { + expect(mmxCommandPath(["--output", "json", "text", "chat", "--message", "hello"])) + .toEqual(["text", "chat"]); + expect(mmxCommandPath(["--help=false", "text", "chat"])) + .toEqual(["text", "chat"]); + expect(mmxCommandPath(["--yes", "text", "chat", "--message", "hello"])) + .toEqual(["text", "chat"]); + expect(mmxCommandPath(["--stream", "text", "chat", "--message", "hello"])) + .toEqual(["text", "chat"]); + expect(mmxCommandPath(["text", "repl", "--verbose"])).toEqual(["text", "repl"]); + expect(mmxCommandPath(["image", "generate", "--prompt", "cat"])).toEqual(["image", "generate"]); + }); + + test("rejects caller-controlled credentials and destinations in both flag forms", () => { + expect(mmxUnsafeOverride(["text", "chat", "--api-key", "hidden"])).toBe("--api-key"); + expect(mmxUnsafeOverride(["--base-url=https://example.test", "text", "chat"])).toBe("--base-url"); + expect(mmxUnsafeOverride(["--region", "cn", "text", "chat"])).toBe("--region"); + expect(mmxUnsafeOverride(["text", "chat", "--region=cn"])).toBe("--region"); + expect(mmxUnsafeOverride(["text", "chat", "--model", "mock/model"])).toBeNull(); + }); + + test("overrides the MMX config and destination only in the child environment", () => { + const base: Record = { + MMX_CONFIG_DIR: "/real/user/config", + MINIMAX_BASE_URL: "https://api.minimax.io", + MINIMAX_API_KEY: "real-user-secret", + Minimax_Api_Key: "mixed-case-real-user-secret", + Mmx_Config_Dir: "/mixed-case/user/config", + Minimax_Base_Url: "https://mixed-case.example.test", + Minimax_Region: "cn", + KEEP_ME: "yes", + HTTP_PROXY: "http://proxy.example.test:8080", + http_proxy: "http://proxy.example.test:8080", + Http_Proxy: "http://mixed-case-proxy.example.test:8080", + HTTPS_PROXY: "http://proxy.example.test:8080", + https_proxy: "http://proxy.example.test:8080", + Https_Proxy: "http://mixed-case-proxy.example.test:8080", + ALL_PROXY: "socks5://proxy.example.test:1080", + all_proxy: "socks5://proxy.example.test:1080", + All_Proxy: "socks5://mixed-case-proxy.example.test:1080", + }; + const env = buildMmxEnv({ port: 10123, hostname: "0.0.0.0" }, "/isolated/config", base); + expect(env).toMatchObject({ + MMX_CONFIG_DIR: "/isolated/config", + MINIMAX_BASE_URL: "http://127.0.0.1:10123", + MINIMAX_REGION: "global", + KEEP_ME: "yes", + }); + expect(base.MMX_CONFIG_DIR).toBe("/real/user/config"); + expect(base.MINIMAX_BASE_URL).toBe("https://api.minimax.io"); + for (const key of [ + "HTTP_PROXY", "http_proxy", "Http_Proxy", + "HTTPS_PROXY", "https_proxy", "Https_Proxy", + "ALL_PROXY", "all_proxy", "All_Proxy", + "MINIMAX_API_KEY", "Minimax_Api_Key", + "Mmx_Config_Dir", "Minimax_Base_Url", "Minimax_Region", + ]) { + expect(env[key]).toBeUndefined(); + expect(base[key]).toBeDefined(); + } + expect(base.MINIMAX_API_KEY).toBe("real-user-secret"); + }); + + test("forwards wrapper termination signals only to a live MMX child", () => { + const forwarded: Array = []; + const child = { + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + kill(signal?: NodeJS.Signals | number) { + forwarded.push(signal); + return true; + }, + }; + + expect(forwardMmxTerminationSignal(child, "SIGINT", { platform: "linux" })).toBeTrue(); + expect(forwardMmxTerminationSignal(child, "SIGTERM", { platform: "linux" })).toBeTrue(); + expect(forwarded).toEqual(["SIGINT", "SIGTERM"]); + + child.signalCode = "SIGTERM"; + expect(forwardMmxTerminationSignal(child, "SIGINT", { platform: "linux" })).toBeFalse(); + expect(forwarded).toEqual(["SIGINT", "SIGTERM"]); + + expect(forwardMmxTerminationSignal({ + exitCode: null, + signalCode: null, + kill() { throw new Error("already gone"); }, + }, "SIGTERM", { platform: "linux" })).toBeFalse(); + + const killedTrees: number[] = []; + expect(forwardMmxTerminationSignal({ + pid: 4242, + exitCode: null, + signalCode: null, + kill() { throw new Error("must terminate the Windows process tree"); }, + }, "SIGTERM", { + platform: "win32", + killWindowsTree: pid => { killedTrees.push(pid); }, + })).toBeTrue(); + expect(killedTrees).toEqual([4242]); + }); + + test("keeps termination handlers installed while coalescing duplicate launcher signals", () => { + const host = new EventEmitter(); + const events: string[] = []; + let now = 1_000; + const child = { + exitCode: null, + signalCode: null, + kill(signal?: NodeJS.Signals | number) { + events.push(`kill:${String(signal)}`); + return true; + }, + }; + const remove = installMmxTerminationHandlers({ + getChild: () => child, + cleanup: async () => { events.push("cleanup"); }, + host, + now: () => now, + terminationDeps: { platform: "linux" }, + }); + + host.emit("SIGINT"); + expect(events).toEqual(["kill:SIGINT", "cleanup"]); + expect(host.listenerCount("SIGINT")).toBe(1); + + now = 1_100; + host.emit("SIGINT"); + expect(events).toEqual(["kill:SIGINT", "cleanup"]); + + now = 1_600; + host.emit("SIGTERM"); + expect(events).toEqual(["kill:SIGINT", "cleanup", "kill:SIGTERM", "cleanup"]); + + remove(); + expect(host.listenerCount("SIGINT")).toBe(0); + expect(host.listenerCount("SIGTERM")).toBe(0); + }); + + test("removes termination handlers only after asynchronous bridge cleanup settles", async () => { + const events: string[] = []; + let releaseCleanup!: () => void; + const cleanupGate = new Promise(resolve => { releaseCleanup = resolve; }); + const finishing = finishMmxClientCleanup(async () => { + events.push("cleanup:start"); + await cleanupGate; + events.push("cleanup:end"); + }, () => { events.push("handlers:remove"); }); + + await Promise.resolve(); + expect(events).toEqual(["cleanup:start"]); + releaseCleanup(); + await finishing; + expect(events).toEqual(["cleanup:start", "cleanup:end", "handlers:remove"]); + }); + + test("rejects a non-loopback hostname from stale runtime proxy metadata", () => { + const remote = { + pid: 42, + port: 10100, + hostname: "192.0.2.10", + source: "runtime" as const, + }; + const local = { ...remote, hostname: "127.0.0.1" }; + expect(usableMinimaxLiveProxy(remote)).toBeNull(); + expect(usableMinimaxLiveProxy(local)).toBe(local); + }); + + test("bridges only MMX text paths to the canonical data plane without forwarding credentials", async () => { + const seen: Array<{ + path: string; + search: string; + body: string; + authorization: string | null; + dedicated: string | null; + xApiKey: string | null; + }> = []; + const upstream = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + const url = new URL(req.url); + seen.push({ + path: url.pathname, + search: url.search, + body: await req.text(), + authorization: req.headers.get("authorization"), + dedicated: req.headers.get("x-opencodex-api-key"), + xApiKey: req.headers.get("x-api-key"), + }); + return Response.json({ ok: true }); + }, + }); + const bridge = startMmxTextBridge({ hostname: "127.0.0.1", port: upstream.port }); + try { + const messages = await fetch(`${bridge.baseUrl}/anthropic/v1/messages?beta=true`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer must-not-forward", + "x-opencodex-api-key": "must-not-forward", + "x-api-key": "must-be-replaced", + }, + body: JSON.stringify({ model: "mock/model", messages: [] }), + }); + expect(messages.status).toBe(200); + const count = await fetch(`${bridge.baseUrl}/anthropic/v1/messages/count_tokens`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/model", messages: [] }), + }); + expect(count.status).toBe(200); + expect((await fetch(`${bridge.baseUrl}/anthropic/v1/messages`, { method: "GET" })).status).toBe(404); + expect((await fetch(`${bridge.baseUrl}/v1/messages`, { method: "POST" })).status).toBe(404); + expect(seen).toHaveLength(2); + expect(seen.map(row => [row.path, row.search])).toEqual([ + ["/v1/messages", "?beta=true"], + ["/v1/messages/count_tokens", ""], + ]); + expect(JSON.parse(seen[0]!.body)).toEqual({ model: "mock/model", messages: [] }); + for (const row of seen) { + expect(row.authorization).toBeNull(); + expect(row.dedicated).toBeNull(); + expect(row.xApiKey).toBe(LOOPBACK_API_KEY_PLACEHOLDER); + } + } finally { + await bridge.stop(); + await upstream.stop(true); + } + }); + + test("returns 502 when the selected OpenCodex proxy address is unavailable", async () => { + const reservation = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => Response.json({ reserved: true }), + }); + const deadPort = reservation.port; + await reservation.stop(true); + const bridge = startMmxTextBridge({ hostname: "127.0.0.1", port: deadPort }); + try { + const response = await fetch(`${bridge.baseUrl}/anthropic/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/model", messages: [] }), + }); + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + type: "error", + error: { type: "api_error", message: "OpenCodex proxy unavailable" }, + }); + } finally { + await bridge.stop(); + } + }); + + test("bounds the wait for response headers from a stalled OpenCodex proxy", async () => { + const upstream = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch() { + await Bun.sleep(1_000); + return Response.json({ tooLate: true }); + }, + }); + const bridge = startMmxTextBridge( + { hostname: "127.0.0.1", port: upstream.port }, + { headerTimeoutMs: 25 }, + ); + try { + const response = await fetch(`${bridge.baseUrl}/anthropic/v1/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/model", messages: [] }), + }); + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + type: "error", + error: { type: "api_error", message: "OpenCodex proxy unavailable" }, + }); + } finally { + await bridge.stop(); + await upstream.stop(true); + } + }); +}); diff --git a/tests/self-launch-argv.test.ts b/tests/self-launch-argv.test.ts new file mode 100644 index 0000000000..f4555db2c3 --- /dev/null +++ b/tests/self-launch-argv.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { selfLaunchArgv } from "../src/lib/self-launch-argv"; + +describe("selfLaunchArgv", () => { + test("uses command arguments directly for a standalone executable", () => { + const args = ["start", "--port", "10100"]; + + expect(selfLaunchArgv(args, { + isStandaloneExecutable: true, + sourceEntrypoint: "/repo/src/cli/index.ts", + })).toEqual(["start", "--port", "10100"]); + expect(args).toEqual(["start", "--port", "10100"]); + }); + + test("prepends the source entrypoint outside a standalone executable", () => { + expect(selfLaunchArgv(["stop"], { + isStandaloneExecutable: false, + sourceEntrypoint: "/repo/src/cli/index.ts", + })).toEqual(["/repo/src/cli/index.ts", "stop"]); + }); +}); diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index 311d672507..ca46ada550 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -1304,6 +1304,139 @@ 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("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 exactly once before child dispatch", 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.split("earlier text")).toHaveLength(2); + expect(inputText.split("next turn")).toHaveLength(2); + }); + + test("disabled image input rejects an image restored from previous_response_id before dispatch", async () => { + const { rememberResponseState } = await import("../src/responses/state"); + rememberResponseState( + { + model: "combo/free", + input: [{ + role: "user", + content: [{ type: "input_image", image_url: "data:image/png;base64,aGVsbG8=" }], + }], + }, + { + id: "resp_combo_image_prev", + status: "completed", + output: [{ type: "message", role: "assistant", content: "image received" }], + }, + ); + 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_combo_image_prev", + input: [{ role: "user", content: "continue" }], + }); + + 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 => { diff --git a/tests/stream-aborted-marker.test.ts b/tests/stream-aborted-marker.test.ts new file mode 100644 index 0000000000..4fb6697691 --- /dev/null +++ b/tests/stream-aborted-marker.test.ts @@ -0,0 +1,207 @@ +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 readStarted = Promise.withResolvers(); + let upstreamController: ReadableStreamDefaultController | undefined; + const upstream = new ReadableStream({ + start(controller) { upstreamController = controller; }, + pull() { readStarted.resolve(); }, + }); + const cancelFired = Promise.withResolvers(); + const terminals: string[] = []; + let cancels = 0; + const relayed = trackSseForRequestLog( + upstream, + status => { terminals.push(status); }, + () => { + cancels += 1; + cancelFired.resolve(); + }, + logCtx, + ); + const reader = relayed.getReader(); + const pendingRead = reader.read(); + await readStarted.promise; + upstreamController?.error(new Error("socket reset during client cancel")); + await reader.cancel(new DOMException("client closed", "AbortError")); + await cancelFired.promise; + await pendingRead; + 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/terminal-guard-server.test.ts b/tests/terminal-guard-server.test.ts index ba596cb808..999138cf46 100644 --- a/tests/terminal-guard-server.test.ts +++ b/tests/terminal-guard-server.test.ts @@ -37,6 +37,44 @@ const continuationTurn = [ 'event: message_stop\ndata: {"type":"message_stop"}\n\n', ].join(""); +/** Build an OpenAI Chat Completions SSE response from raw frames. */ +function chatSse(body: string): Response { + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +// A clean end-of-turn with only assistant text and no tool call — the suspicious +// no-tool completion the guard is meant to re-ask (mirrors firstTurn for openai-chat). +const chatFirstTurn = [ + 'data: {"choices":[{"delta":{"content":"我接下来会修改相关文件。"}}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n', + "data: [DONE]\n\n", +].join(""); + +// The continuation turn emits the tool call the model should have produced. +const chatContinuationTurn = [ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"exec_command","arguments":""}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n', + "data: [DONE]\n\n", +].join(""); + +function openAiChatConfig(terminalContinuationGuard?: boolean): OcxConfig { + return { + port: 0, + defaultProvider: "glm-gw", + providers: { + "glm-gw": { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "key", + defaultModel: "glm-5.2", + models: ["glm-5.2"], + ...(terminalContinuationGuard !== undefined ? { terminalContinuationGuard } : {}), + }, + }, + } as unknown as OcxConfig; +} + describe("server terminal guard integration", () => { let originalFetch: typeof fetch; let calls: number; @@ -317,4 +355,148 @@ describe("server terminal guard integration", () => { expect(text).not.toContain("upstream_stall_timeout"); }, 5_000); + test("openai-chat provider without terminalContinuationGuard does not re-ask", async () => { + const chatConfig = openAiChatConfig(); + let sends = 0; + globalThis.fetch = (async () => { + sends += 1; + return chatSse(chatFirstTurn); + }) as typeof fetch; + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "glm-gw/glm-5.2", + input: "请检查这个问题并修复代码", + stream: true, + tools: [{ type: "function", name: "exec_command", description: "run a command", parameters: { type: "object" } }], + }), + }), chatConfig, { model: "", provider: "" }); + + const text = await response.text(); + expect(response.status).toBe(200); + // Guard is opt-in for openai-chat: no continuation, so exactly one upstream call. + expect(sends).toBe(1); + expect(text).toContain("response.completed"); + }); + + test("openai-chat provider with terminalContinuationGuard false does not re-ask", async () => { + const chatConfig = openAiChatConfig(false); + let sends = 0; + globalThis.fetch = (async () => { + sends += 1; + return chatSse(chatFirstTurn); + }) as typeof fetch; + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "glm-gw/glm-5.2", + input: "请检查这个问题并修复代码", + stream: true, + tools: [{ type: "function", name: "exec_command", description: "run a command", parameters: { type: "object" } }], + }), + }), chatConfig, { model: "", provider: "" }); + + const text = await response.text(); + expect(response.status).toBe(200); + expect(sends).toBe(1); + expect(text).toContain("response.completed"); + }); + + test("openai-chat provider with terminalContinuationGuard re-asks once and forwards the tool call", async () => { + const chatConfig = openAiChatConfig(true); + let sends = 0; + const bodies: Record[] = []; + globalThis.fetch = (async (_input, init) => { + sends += 1; + bodies.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return chatSse(sends === 1 ? chatFirstTurn : chatContinuationTurn); + }) as typeof fetch; + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "glm-gw/glm-5.2", + input: "请检查这个问题并修复代码", + stream: true, + tools: [{ type: "function", name: "exec_command", description: "run a command", parameters: { type: "object" } }], + }), + }), chatConfig, { model: "", provider: "" }); + + const text = await response.text(); + expect(response.status).toBe(200); + // Opted-in openai-chat provider: one bounded continuation, so two upstream calls. + expect(sends).toBe(2); + expect(text).toContain("response.completed"); + expect(text).toContain("exec_command"); + const messages = bodies[1]?.messages as Array<{ role?: string; content?: unknown }>; + expect(messages.some(m => m.role === "developer" || m.role === "system")).toBe(true); + }); + + test("combo attempts do not run an opted-in openai-chat terminal guard", async () => { + const comboConfig = { + ...openAiChatConfig(true), + combos: { + guarded: { + strategy: "failover", + targets: [{ provider: "glm-gw", model: "glm-5.2" }], + }, + }, + } as OcxConfig; + let sends = 0; + globalThis.fetch = (async () => { + sends += 1; + return chatSse(chatFirstTurn); + }) as typeof fetch; + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "combo/guarded", + input: "请检查这个问题并修复代码", + stream: true, + tools: [{ type: "function", name: "exec_command", description: "run a command", parameters: { type: "object" } }], + }), + }), comboConfig, { model: "", provider: "" }); + + const text = await response.text(); + expect(response.status).toBe(200); + expect(sends).toBe(1); + expect(text).toContain("response.completed"); + }); + + test("routed compaction does not run an opted-in openai-chat terminal guard", async () => { + const chatConfig = openAiChatConfig(true); + let sends = 0; + globalThis.fetch = (async () => { + sends += 1; + return chatSse(chatFirstTurn); + }) as typeof fetch; + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "glm-gw/glm-5.2", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "earlier turn" }] }, + { type: "compaction_trigger" }, + ], + stream: true, + tools: [{ type: "function", name: "exec_command", description: "run a command", parameters: { type: "object" } }], + }), + }), chatConfig, { model: "", provider: "" }); + + const text = await response.text(); + expect(response.status).toBe(200); + expect(sends).toBe(1); + expect(text).toContain('"type":"compaction"'); + expect(text).toContain("response.completed"); + }); + }); diff --git a/tests/terminal-guard.test.ts b/tests/terminal-guard.test.ts index 4cda0b1f69..9fe5b68d4d 100644 --- a/tests/terminal-guard.test.ts +++ b/tests/terminal-guard.test.ts @@ -212,6 +212,54 @@ describe("terminal guard", () => { expect(actual.filter(event => event.type === "done")).toHaveLength(1); }); + test("guards an openai-chat stream (opted-in provider) with one continuation", async () => { + let continuations = 0; + const actual: AdapterEvent[] = []; + for await (const event of guardTerminalEventStream({ + parsed: parsed("请检查这个问题并修复代码"), + firstEvents: (async function* () { + yield { type: "text_delta", text: "我接下来会修改相关文件。" } as AdapterEvent; + yield { type: "done", usage: { inputTokens: 10, outputTokens: 2 } } as AdapterEvent; + })(), + continuation: () => { + continuations += 1; + return (async function* () { + yield { type: "tool_call_start", id: "call_1", name: "exec_command" } as AdapterEvent; + yield { type: "tool_call_end" } as AdapterEvent; + yield { type: "done", usage: { inputTokens: 20, outputTokens: 3 } } as AdapterEvent; + })(); + }, + adapterName: "openai-chat", + })) actual.push(event); + + expect(continuations).toBe(1); + expect(actual.some(event => event.type === "assistant_boundary")).toBe(true); + expect(actual.filter(event => event.type === "done")).toHaveLength(1); + }); + + test("does not guard adapters other than anthropic/openai-chat", async () => { + let continuations = 0; + const actual: AdapterEvent[] = []; + for await (const event of guardTerminalEventStream({ + parsed: parsed("请检查这个问题并修复代码"), + firstEvents: (async function* () { + yield { type: "text_delta", text: "我接下来会修改相关文件。" } as AdapterEvent; + yield { type: "done", usage: { inputTokens: 10, outputTokens: 2 } } as AdapterEvent; + })(), + continuation: () => { + continuations += 1; + return (async function* () { + yield { type: "done" } as AdapterEvent; + })(); + }, + adapterName: "openai-responses", + })) actual.push(event); + + expect(continuations).toBe(0); + expect(actual.some(event => event.type === "assistant_boundary")).toBe(false); + expect(actual.filter(event => event.type === "done")).toHaveLength(1); + }); + test("serializes the guarded boundary as separate assistant output items", () => { const response = buildResponseJSON([ { type: "text_delta", text: "我接下来会修改。" }, diff --git a/tests/update-stop-first.test.ts b/tests/update-stop-first.test.ts index 45afa13170..0508010529 100644 --- a/tests/update-stop-first.test.ts +++ b/tests/update-stop-first.test.ts @@ -21,8 +21,8 @@ describe("update stops the running proxy before replacing files", () => { }); test("bun/source update path gates on the pid file and spawns 'stop' before the package manager", () => { - expect(updateSource).toContain('spawnSync(process.execPath, [process.argv[1], "stop"]'); - const stopAt = updateSource.indexOf('[process.argv[1], "stop"]'); + expect(updateSource).toContain('spawnSync(process.execPath, selfLaunchArgv(["stop"])'); + const stopAt = updateSource.indexOf('selfLaunchArgv(["stop"])'); const updateAt = updateSource.indexOf("spawnSync(target.bin, target.args"); expect(stopAt).toBeGreaterThan(-1); expect(updateAt).toBeGreaterThan(-1); @@ -33,7 +33,7 @@ describe("update stops the running proxy before replacing files", () => { test("integrity pre-flight runs BEFORE the stop so anomalous metadata never unloads the proxy", () => { const gateAt = updateSource.indexOf("const integrity = checkUpdatePackageIntegrity(latest);"); const abortAt = updateSource.indexOf("aborting the update before stopping the proxy"); - const stopAt = updateSource.indexOf('[process.argv[1], "stop"]'); + const stopAt = updateSource.indexOf('selfLaunchArgv(["stop"])'); expect(gateAt).toBeGreaterThan(-1); expect(abortAt).toBeGreaterThan(-1); expect(gateAt).toBeLessThan(stopAt); @@ -42,7 +42,7 @@ describe("update stops the running proxy before replacing files", () => { test("cache access gates in both CLI entry points precede every tray/proxy stop", () => { const runtimeGate = updateSource.indexOf("const cachePreflight = runNpmCachePreflight();"); - const runtimeStop = updateSource.indexOf('[process.argv[1], "stop"]'); + const runtimeStop = updateSource.indexOf('selfLaunchArgv(["stop"])'); const launcherGate = launcherSource.indexOf("const cachePreflight = runNpmCachePreflight();"); const launcherTrayStop = launcherSource.indexOf('runTrayLifecycle(launcher, "stop")'); const launcherProxyStop = launcherSource.indexOf('[launcher, "stop"]'); @@ -67,7 +67,7 @@ describe("update stops the running proxy before replacing files", () => { test("Windows npm paths resolve safely before stop and never use shell:true", () => { const updateResolveAt = updateSource.indexOf("const target = updateSpawnTarget(bin, cmdArgs);"); - const updateStopAt = updateSource.indexOf('[process.argv[1], "stop"]'); + const updateStopAt = updateSource.indexOf('selfLaunchArgv(["stop"])'); const launcherResolveAt = launcherSource.indexOf("const installInvocation = npmInvocation("); const launcherStopAt = launcherSource.indexOf('[launcher, "stop"]'); diff --git a/tests/ws-upstream.test.ts b/tests/ws-upstream.test.ts index 545fc57698..5f8782f271 100644 --- a/tests/ws-upstream.test.ts +++ b/tests/ws-upstream.test.ts @@ -303,7 +303,8 @@ describe("handleResponses Codex WS relay selection", () => { } }); - const response = await handleResponses(request(), forwardConfig(), { model: "", provider: "" }, { + const logCtx = { model: "", provider: "" }; + const response = await handleResponses(request(), forwardConfig(), logCtx, { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME, }); @@ -311,6 +312,7 @@ describe("handleResponses Codex WS relay selection", () => { const text = await response.text(); expect(text).toContain("event: response.failed"); expect(text).toContain("data: [DONE]"); + expect(logCtx.activeAttempt?.streamAborted).toBe(true); expect(FakeWebSocket.instances[0].closed).toBe(true); });