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 000000000..824452c0c Binary files /dev/null and b/docs-site/public/screenshots/minimax-code-integration.png differ 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); + } + }); +});