From befd076f601f1c77a57406b93dabe10347013edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=98=E6=89=AC?= Date: Fri, 14 Aug 2026 01:22:26 +0800 Subject: [PATCH] add MiniMax client integrations --- docs-site/astro.config.mjs | 1 + .../screenshots/minimax-code-integration.png | Bin 0 -> 64194 bytes .../src/content/docs/guides/integrations.md | 42 +- docs-site/src/content/docs/guides/minimax.md | 110 +++++ .../content/docs/tr/guides/integrations.md | 40 +- .../content/docs/zh-tw/guides/integrations.md | 33 +- .../client-config-clients.ts | 3 +- gui/src/i18n/de.ts | 3 + gui/src/i18n/en.ts | 3 + gui/src/i18n/ja.ts | 3 + gui/src/i18n/ko.ts | 3 + gui/src/i18n/ru.ts | 3 + gui/src/i18n/tr.ts | 3 + gui/src/i18n/zh-TW.ts | 3 + gui/src/i18n/zh.ts | 3 + gui/src/pages/Integrations.tsx | 2 + .../integrations/FileIntegrationPage.tsx | 2 + gui/src/pages/integrations/integration-api.ts | 1 + .../pages/integrations/overview-clients.ts | 1 + src/cli/dispatch.ts | 8 + src/cli/export-command.ts | 4 +- src/cli/help.ts | 2 + src/cli/minimax.ts | 380 ++++++++++++++++++ src/cli/registry.ts | 24 +- src/clients/config-export.ts | 88 +++- src/integrations/registry.ts | 7 + structure/01_runtime.md | 2 +- structure/04_transports-and-sidecars.md | 19 + tests/cli-export-command.test.ts | 2 +- .../client-config-export-new-clients.test.ts | 4 +- tests/client-config-export.test.ts | 5 +- tests/integrations-invariants.test.ts | 7 +- tests/integrations-state.test.ts | 4 +- tests/minimax-clients.test.ts | 283 +++++++++++++ 34 files changed, 1066 insertions(+), 32 deletions(-) create mode 100644 docs-site/public/screenshots/minimax-code-integration.png create mode 100644 docs-site/src/content/docs/guides/minimax.md create mode 100644 src/cli/minimax.ts create mode 100644 tests/minimax-clients.test.ts diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index c1dfc8361..5c2fcb028 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -95,6 +95,7 @@ export default defineConfig({ { label: "opencode", translations: { ko: "opencode", "zh-CN": "opencode", "zh-TW": "opencode", ru: "opencode", ja: "opencode", tr: "opencode" }, slug: "guides/opencode" }, { label: "Pi", translations: { ko: "Pi", "zh-CN": "Pi", "zh-TW": "Pi", ru: "Pi", ja: "Pi", tr: "Pi" }, slug: "guides/pi" }, { label: "Integrations", translations: { ko: "연동", "zh-CN": "集成", "zh-TW": "整合", ru: "Интеграции", ja: "連携", tr: "Entegrasyonlar" }, slug: "guides/integrations" }, + { label: "MiniMax clients", translations: { ko: "MiniMax 클라이언트", "zh-CN": "MiniMax 客户端", "zh-TW": "MiniMax 客戶端", ru: "Клиенты MiniMax", ja: "MiniMax クライアント", tr: "MiniMax İstemcileri" }, slug: "guides/minimax" }, { label: "Sidecars: Web Search & Vision", translations: { ko: "사이드카: 웹 검색 & 비전", "zh-CN": "边车:网络搜索与视觉", "zh-TW": "邊車:網路搜尋與視覺", ru: "Сайдкары: веб-поиск и зрение", ja: "サイドカー: ウェブ検索 & ビジョン", tr: "Sidecar'lar: Web Arama ve Görme" }, slug: "guides/sidecars" }, { label: "Image Bridge", translations: { ko: "이미지 브릿지", "zh-CN": "图像桥接", "zh-TW": "圖像橋接", ru: "Image Bridge", ja: "画像ブリッジ", tr: "Image Bridge" }, slug: "guides/image-bridge" }, { label: "Video Bridge", translations: { ko: "비디오 브릿지", "zh-CN": "视频桥接", "zh-TW": "影片橋接", ru: "Video Bridge", ja: "動画ブリッジ", tr: "Video Bridge" }, slug: "guides/video-bridge" }, diff --git a/docs-site/public/screenshots/minimax-code-integration.png b/docs-site/public/screenshots/minimax-code-integration.png new file mode 100644 index 0000000000000000000000000000000000000000..824452c0cb24c28df8fff1130c88894b5b839973 GIT binary patch literal 64194 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 YQ?2q#Q&P;r;9n4T(Avs{iuVHk4@{Qi2><{9 literal 0 HcmV?d00001 diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 92e35af06..f8b2c578d 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 000000000..b7ad0fdd2 --- /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/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index 88de3e041..4ddd5e0bf 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-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 0f9de23e2..944c6d20f 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/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index d1527a5b1..2143b999f 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/i18n/de.ts b/gui/src/i18n/de.ts index f4e25636e..01b7980a2 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -813,6 +813,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", @@ -924,6 +925,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", @@ -1206,6 +1208,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…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 11338b919..e8730d23a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1281,6 +1281,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", @@ -1392,6 +1393,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", @@ -1682,6 +1684,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…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c4bc0321c..58c3a8181 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1223,6 +1223,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": "サービス制御を開く", @@ -1334,6 +1335,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", @@ -1621,6 +1623,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": "クライアント設定を生成中…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index e717e7a5c..ec4cf8c6b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -837,6 +837,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": "서비스 제어 열기", @@ -948,6 +949,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", @@ -1233,6 +1235,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": "클라이언트 설정 생성 중…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b6f8c390d..a1e18ed3b 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1265,6 +1265,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": "Открыть управление сервисом", @@ -1376,6 +1377,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", @@ -1663,6 +1665,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": "Формируется конфигурация клиента…", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 434805749..698c8793b 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1272,6 +1272,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ç", @@ -1382,6 +1383,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", @@ -1670,6 +1672,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…", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index e194a06e2..4c33667ea 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1786,6 +1786,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": "開啟服務控制", @@ -1897,6 +1898,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 模式中要求輸入", @@ -1934,6 +1936,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 5d198f5be..ee912bca1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -830,6 +830,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": "打开服务控制", @@ -941,6 +942,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", @@ -1226,6 +1228,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": "正在生成客户端配置…", diff --git a/gui/src/pages/Integrations.tsx b/gui/src/pages/Integrations.tsx index 4cdb78a97..026239c93 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 ead9e8cc9..ba9e67f39 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 154ed1c6d..571c4cca0 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 d17d20e13..525bca3fe 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/src/cli/dispatch.ts b/src/cli/dispatch.ts index 6e516d21f..854c56564 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 f8773e685..d6c899624 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 f729c1d81..b7cc0b7f5 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/minimax.ts b/src/cli/minimax.ts new file mode 100644 index 000000000..34f92a642 --- /dev/null +++ b/src/cli/minimax.ts @@ -0,0 +1,380 @@ +/** + * 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 { spawn } 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 { 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 { + headerTimeoutMs?: number; +} + +const MMX_BRIDGE_HEADER_TIMEOUT_MS = 30_000; + +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, + MMX_CONFIG_DIR: configDir, + 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. + MINIMAX_REGION: "global", + }; + // 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. + for (const key of [ + "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy", + // The isolated config already supplies the public loopback placeholder. + // Do not expose a user's real MiniMax environment credential to the child. + "MINIMAX_API_KEY", + ]) delete env[key]; + 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 headerTimeoutMs = options.headerTimeoutMs ?? MMX_BRIDGE_HEADER_TIMEOUT_MS; + 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"); + const headerDeadline = clearableDeadline(headerTimeoutMs, req.signal); + try { + return await fetch(new Request(target, { + method: "POST", + headers, + body: req.body, + signal: headerDeadline.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, [process.argv[1], "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"; +} + +function spawnClient( + command: "mcode" | "mmx", + args: readonly string[], + env: NodeJS.ProcessEnv, + installHint: string, +): Promise { + return new Promise(resolve => { + const inv = commandInvocation(command, [...args]); + const child = spawn(inv.file, inv.args, { stdio: "inherit", env, ...inv.options }); + 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 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.`); + const onTerminationSignal = () => { + void cleanup().catch(error => { + console.error(`❌ Failed to clean up the MMX bridge after a termination signal: ${String(error)}`); + }); + }; + process.once("SIGINT", onTerminationSignal); + process.once("SIGTERM", onTerminationSignal); + removeTerminationHandlers = () => { + process.off("SIGINT", onTerminationSignal); + process.off("SIGTERM", onTerminationSignal); + }; + return await spawnClient("mmx", args, env, MMX_INSTALL_HINT); + } finally { + removeTerminationHandlers(); + await cleanup(); + } +} diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 3437b0b7d..24d10d1f0 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 44240c9ef..d9484be8b 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; @@ -832,6 +850,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. @@ -1115,6 +1150,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 @@ -1164,6 +1225,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 }] }; @@ -1220,6 +1287,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", @@ -1329,6 +1401,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/integrations/registry.ts b/src/integrations/registry.ts index 26402cd2b..82800c077 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/structure/01_runtime.md b/structure/01_runtime.md index e0977229a..372e816f7 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 42f72945a..1db9a6fe1 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 a00c0708d..e8f3447af 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/client-config-export-new-clients.test.ts b/tests/client-config-export-new-clients.test.ts index 8842af56d..231975eca 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 be8491924..950b70c86 100644 --- a/tests/client-config-export.test.ts +++ b/tests/client-config-export.test.ts @@ -468,8 +468,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); @@ -619,6 +619,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/integrations-invariants.test.ts b/tests/integrations-invariants.test.ts index a6fa42749..f48254184 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 1ddb55104..521341a95 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 000000000..22e11e94e --- /dev/null +++ b/tests/minimax-clients.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, test } from "bun:test"; +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, + 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", + KEEP_ME: "yes", + HTTP_PROXY: "http://proxy.example.test:8080", + http_proxy: "http://proxy.example.test:8080", + HTTPS_PROXY: "http://proxy.example.test:8080", + https_proxy: "http://proxy.example.test:8080", + ALL_PROXY: "socks5://proxy.example.test:1080", + all_proxy: "socks5://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", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"]) { + expect(env[key]).toBeUndefined(); + expect(base[key]).toContain("proxy.example.test"); + } + expect(env.MINIMAX_API_KEY).toBeUndefined(); + expect(base.MINIMAX_API_KEY).toBe("real-user-secret"); + }); + + 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); + } + }); +});