From 11bbc17d9ed40ed27121a526dbe80039c2b951cd Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 13:26:17 +0900 Subject: [PATCH 01/52] docs(providers): update TokenLab documentation links (carry #6474) Replace six relocated guide and API-reference URLs with their direct destinations. Provider endpoints, routing, and configuration remain unchanged. Carries #6474 by @hedging8563. Co-authored-by: hedging8563 <45883076+hedging8563@users.noreply.github.com> --- docs-site/src/content/docs/guides/providers.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 18f52701c82..1e358755184 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -602,9 +602,9 @@ OpenAI-compatible API gateway at [tokenlab.sh](https://tokenlab.sh/r/OPENCODEX), operated by TOKENLAB AI INC. Create a workspace [API key](https://tokenlab.sh/dashboard/api?tab=keys), then run `ocx provider add tokenlab` or select **TokenLab** in the dashboard's **Add provider** picker. -TokenLab maintains a step-by-step [OpenCodex integration guide](https://docs.tokenlab.sh/integrations/opencodex) -([한국어](https://docs.tokenlab.sh/ko/integrations/opencodex)) covering setup and per-model routing. -The preset uses [Chat Completions](https://docs.tokenlab.sh/quickstart) and discovers models at +TokenLab maintains a step-by-step [OpenCodex integration guide](https://tokenlab.sh/docs/en/integrations/opencodex) +([한국어](https://tokenlab.sh/docs/ko/integrations/opencodex)) covering setup and per-model routing. +The preset uses [Chat Completions](https://tokenlab.sh/docs/en/quickstart) and discovers models at `GET /v1/models?category=chat`, keeping only entries that declare `tool-use` capability. Image, video, audio, embedding and decision models are excluded from this chat preset. @@ -613,8 +613,8 @@ Each model then uses the request format TokenLab declares for it | Models | Codex (Responses clients) | Chat clients | Claude Code (Anthropic clients) | | --- | --- | --- | --- | -| `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6-sol`, `gpt-6-luna`, `grok-4.7`, `deepseek-v4.1-flash`, `deepseek-v4-pro`, `kimi-k3`, `glm-5.3` | [Responses](https://docs.tokenlab.sh/api-reference/responses/create-response) | Chat Completions | Chat Completions | -| `claude-*` | [Messages](https://docs.tokenlab.sh/api-reference/messages/create-message) | Messages | Messages | +| `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6-sol`, `gpt-6-luna`, `grok-4.7`, `deepseek-v4.1-flash`, `deepseek-v4-pro`, `kimi-k3`, `glm-5.3` | [Responses](https://tokenlab.sh/docs/en/api-reference/responses/create-response) | Chat Completions | Chat Completions | +| `claude-*` | [Messages](https://tokenlab.sh/docs/en/api-reference/messages/create-message) | Messages | Messages | | Every other model, including `gemini-3.8-flash` | Chat Completions | Chat Completions | Chat Completions | To keep a model on Chat Completions, add it to the provider's `modelAdapters`, for example @@ -622,7 +622,7 @@ To keep a model on Chat Completions, add it to the provider's `modelAdapters`, f provider points at `https://api.tokenlab.sh/v1`. OpenCodex sends no delivery-policy header, so your API key's own delivery policy decides how TokenLab serves each request. -The [model catalog](https://docs.tokenlab.sh/api-reference/models/list-models) is public without +The [model catalog](https://tokenlab.sh/docs/en/api-reference/models/list-models) is public without a key, but a supplied key is validated and scopes results to its model permissions and delivery policy. Use a valid key with a funded workspace for inference. `gpt-5.6-terra` is the seeded default; choose another discovered model if your key does not allow it. The provider and model From f5572a0031471b933a4bafe0236fb509ab78ddfd Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 13:26:47 +0900 Subject: [PATCH 02/52] fix(chatgpt): recognize snake-case quota usage windows (carry #6463) Read used_percent as exhaustion evidence alongside usedPercent. Preserve usage values and existing spend-control and non-quota blockers. Carries #6463 by @lcxhh521. Co-authored-by: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> --- src/chatgpt/app-server-shim/gate-rewrite.ts | 3 ++ structure/clients/chatgpt-desktop.md | 3 +- tests/clients/desktop-app-server-shim.test.ts | 38 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/chatgpt/app-server-shim/gate-rewrite.ts b/src/chatgpt/app-server-shim/gate-rewrite.ts index 0ae8dcfe8c7..4864a7bece3 100644 --- a/src/chatgpt/app-server-shim/gate-rewrite.ts +++ b/src/chatgpt/app-server-shim/gate-rewrite.ts @@ -38,7 +38,10 @@ export function unlockRateLimitGate(value: unknown): boolean { return { cleared, exhausted, blocked }; } if (!isRecord(node)) return { cleared, exhausted, blocked }; + // A usage window at 100%, in either spelling the gate fields come in: `usedPercent` in the + // app-server's JSON-RPC, `used_percent` in the web usage snapshot. if (typeof node.usedPercent === "number" && node.usedPercent >= 100) exhausted = true; + if (typeof node.used_percent === "number" && node.used_percent >= 100) exhausted = true; if (node.spendControlReached !== undefined && node.spendControlReached !== null && node.spendControlReached !== false) blocked = true; if (isRecord(node.spend_control) && node.spend_control.reached === true) blocked = true; const reachedType = node.rate_limit_reached_type; diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index 9f3f1328e54..deae2e8b88e 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -27,7 +27,8 @@ The pure gate rewrite changes known plain-quota fields only in eligible JSON-RPC rate-limit notifications and top-level rate-limit results. Workspace, credit, unknown reached-type and spend-control restrictions preserve closed gate flags. Both the rate-limit flags and `ordinaryUsageAllowed` open only where the subtree shows -plain-quota evidence: a cleared plain reached type or a usage window at 100%. +plain-quota evidence: a cleared plain reached type or a usage window at 100% (`usedPercent` in the +RPC, `used_percent` in the web usage snapshot; every gate field is read in both spellings). Usage percentages, resets and window durations remain accurate. Unrelated messages and malformed lines remain byte-identical; changed lines are reserialized. A per-line rewrite exception preserves that line. A failure in the framing/rewrite diff --git a/tests/clients/desktop-app-server-shim.test.ts b/tests/clients/desktop-app-server-shim.test.ts index d0974969cb8..b801884fbaf 100644 --- a/tests/clients/desktop-app-server-shim.test.ts +++ b/tests/clients/desktop-app-server-shim.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { rewriteAppServerLine } from "../../src/chatgpt/app-server-shim/app-server-rewrite"; +import { unlockRateLimitGate } from "../../src/chatgpt/app-server-shim/gate-rewrite"; import { createRpcLineFilter, runStdoutFilter, runChatgptAppServerFilter } from "../../src/chatgpt/app-server-shim/filter"; /** @@ -126,6 +127,43 @@ describe("app-server line rewrite", () => { }); }); +describe("gate rewrite on the web usage snapshot's spelling", () => { + // The rewrite reads every gate field in both spellings (rate_limit/rateLimit, + // limit_reached/limitReached, spend_control/spendControlReached, ...). The usage window must be + // read in both too, or a snake_case snapshot never shows the plain quota as the reason. + const snapshot = (usedPercent: number, spendReached = false) => ({ + usage: { + plan_type: "pro", + rate_limit: { + allowed: false, + limit_reached: true, + primary_window: { used_percent: usedPercent, limit_window_seconds: 604800, reset_after_seconds: 205162 }, + }, + spend_control: { reached: spendReached }, + }, + }); + + test("a window at used_percent 100 is plain-quota evidence: the flags open, the window stays as sent", () => { + const value = snapshot(100); + expect(unlockRateLimitGate(value)).toBe(true); + expect(value.usage.rate_limit.allowed).toBe(true); + expect(value.usage.rate_limit.limit_reached).toBe(false); + expect(value.usage.rate_limit.primary_window).toEqual(snapshot(100).usage.rate_limit.primary_window); + }); + + test("below 100% the payload shows no quota reason, so the flags stay closed", () => { + const value = snapshot(42); + expect(unlockRateLimitGate(value)).toBe(false); + expect(value).toEqual(snapshot(42)); + }); + + test("a reached spend control keeps the flags closed even with the window at 100%", () => { + const value = snapshot(100, true); + expect(unlockRateLimitGate(value)).toBe(false); + expect(value).toEqual(snapshot(100, true)); + }); +}); + describe("app-server line filter", () => { const collect = (chunks: Uint8Array[], filter = createRpcLineFilter()) => { const parts: Uint8Array[] = []; From 763dda2117e6d1817f62e0c142d8a45c3b5c9c1a Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 13:27:18 +0900 Subject: [PATCH 03/52] fix(cli): escape terminal controls in human output (carry #6459) Suggestion rationale could emit terminal controls in human-readable output. Escape shared human lines while preserving JSON values and multiline exports. Add public guidance alongside the contributor regression tests. Carries #6459 by @luvs01. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../src/content/docs/reference/cli/agents.md | 3 +++ src/cli/export-command.ts | 4 +++- src/cli/runtime-api.ts | 2 +- structure/runtime.md | 2 +- tests/cli/cli-export-command.test.ts | 4 ++++ tests/cli/cli-headless-parity.test.ts | 17 +++++++++++++++-- 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 727a5699fc8..9b3e6e8821b 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -34,6 +34,9 @@ prints a proposed model and effort per role without writing anything. `--apply` through the same write as `set`, skipping and naming the roles whose model and effort already match. See [Auto-assign](/guides/integrations/#auto-assign). +Human-readable suggestion output displays terminal control characters as visible escapes. +Use `--json` when you need the original suggestion values without presentation escaping. + `ocx agent injection suggest ` does the same for the delegation model: it sizes the described work, proposes the cheapest sufficient model and an effort from the delegation picker's list, and writes nothing unless `--apply` is given, which saves through the same write as `injection set`. See diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index ca9eeba54ea..d1dd3e9c6ca 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -194,7 +194,9 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep // `--out` is the path that writes the selected client's native format. // Format metadata rides in the human lines below. printData(clientConfig, wantsJson, [ - text.trimEnd(), + // `lines` entries print one console line each and are control-escaped, so + // the document goes in as individual lines rather than one multi-line blob. + ...text.trimEnd().split("\n"), "", ...(out !== undefined ? [`Wrote ${out}`] : []), `Destination: ${spec.destination(process.env)}`, diff --git a/src/cli/runtime-api.ts b/src/cli/runtime-api.ts index 570e60680a1..478c2424878 100644 --- a/src/cli/runtime-api.ts +++ b/src/cli/runtime-api.ts @@ -471,7 +471,7 @@ export async function readSecretBytes( export function printData(value: unknown, wantsJson: boolean, lines?: string[]): void { if (wantsJson || !lines) console.log(JSON.stringify(value, null, 2)); - else for (const line of lines) console.log(line); + else for (const line of lines) console.log(terminalSafeText(line)); } /** diff --git a/structure/runtime.md b/structure/runtime.md index f988c76a0f6..4eb406d2a4a 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -53,7 +53,7 @@ this wire projection does not change the usage ledger. ## CLI readiness diagnostics -Catalog-derived reasoning-level diagnostics are escaped only at the human-output boundary, which `src/cli/runtime-api.ts` owns alongside the human/JSON print split. Every CLI path that prints a hub-supplied catalog value renders it there: the first-time refusal in `src/cli/connect.ts` and the connected `ocx sync` refusal in `src/cli/dispatch.ts`. C0/C1 controls, DEL, and Unicode line/paragraph separators print as visible hexadecimal escapes; structured status retains the exact reason, and a rendered failure keeps the domain error as its `cause`. The ready/unverified/incompatible classification and exit policy are unchanged. `src/cli/capabilities-command.ts` rejects leftover positional arguments, unknown or repeated flags, and blank route filters with exit 64 before emitting a capability index; a valid unmatched route remains exit 4. +Catalog-derived reasoning-level diagnostics are escaped only at the human-output boundary, which `src/cli/runtime-api.ts` owns alongside the human/JSON print split. Every CLI path that prints a hub-supplied catalog value renders it there: the first-time refusal in `src/cli/connect.ts` and the connected `ocx sync` refusal in `src/cli/dispatch.ts`. C0/C1 controls, DEL, and Unicode line/paragraph separators print as visible hexadecimal escapes; structured status retains the exact reason, and a rendered failure keeps the domain error as its `cause`. The ready/unverified/incompatible classification and exit policy are unchanged. `src/cli/capabilities-command.ts` rejects leftover positional arguments, unknown or repeated flags, and blank route filters with exit 64 before emitting a capability index; a valid unmatched route remains exit 4. `printData` in `src/cli/runtime-api.ts` escapes each human-output line at the shared renderer, including role and delegation-model suggestion rationale. JSON output retains the exact original values. `src/cli/export-command.ts` passes native serialized documents as individual lines so their multiline layout is preserved. `tests/cli/cli-headless-parity.test.ts` checks both suggestion paths independently from their parsed JSON output; `tests/cli/cli-export-command.test.ts` covers export framing. ## CLI resolve and stop contracts for embedding shells diff --git a/tests/cli/cli-export-command.test.ts b/tests/cli/cli-export-command.test.ts index b5a8571b768..1ef331c59df 100644 --- a/tests/cli/cli-export-command.test.ts +++ b/tests/cli/cli-export-command.test.ts @@ -249,6 +249,10 @@ describe("ocx export human output (accept criterion 2)", () => { expect(result.code).toBe(0); expect(result.stdout.startsWith("{\n")).toBe(true); + logs.length = 0; + const structured = await run(["--client", "opencode", "--json"], { baseUrl: proxy.baseUrl }); + expect(structured.code).toBe(0); + expect(JSON.parse(result.stdout.split("\n\n")[0]!)).toEqual(JSON.parse(structured.stdout)); expect(result.stdout).toContain(join("opencode", "opencode.json")); expect(result.stdout).toContain("Merge this generated configuration into that file; do not replace it."); expect(result.stdout).toContain("export OPENCODEX_OPENCODE_API_KEY="); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 2d1177c2297..c613ab3d3c9 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -1000,15 +1000,19 @@ describe("headless GUI parity CLI", () => { sizingModel: "gpt-5.5", proposals: [ { role: "explorer", model: "gpt-5.5", status: "proposed", tier: "fast", effortIntent: "glance", proposedModel: "a/small", proposedEffort: "low" }, - { role: "worker", model: null, status: "proposed", tier: "standard", effortIntent: "measured", proposedModel: "a/mid", proposedEffort: null }, + { role: "worker", model: null, status: "proposed", tier: "standard", effortIntent: "measured", rationale: "Use \x1b]52;c;cG9pc29uZWQ=\x07 carefully.", proposedModel: "a/mid", proposedEffort: null }, { role: "vague", model: null, status: "unsized", reason: "no JSON" }, ], }; const runtime = fakeRuntime(req => req.method === "POST" ? proposals : { ok: true }); const logSpy = spyOn(console, "log").mockImplementation(() => {}); + let output = ""; try { expect(await handleAgentCommand(["roles", "suggest", "--model", "a/sizer", "--json"], runtime.deps)).toBe(0); + expect(JSON.parse(logSpy.mock.calls.flat().join("\n"))).toEqual(proposals); + logSpy.mockClear(); expect(await handleAgentCommand(["roles", "suggest", "--apply"], runtime.deps)).toBe(0); + output = logSpy.mock.calls.flat().join("\n"); } finally { logSpy.mockRestore(); } @@ -1018,6 +1022,8 @@ describe("headless GUI parity CLI", () => { { path: "/api/codex-agent-roles/explorer", method: "PUT", body: { model: "a/small", effort: "low" } }, { path: "/api/codex-agent-roles/worker", method: "PUT", body: { model: "a/mid" } }, ]); + expect(output).not.toMatch(/[\x07\x1b]/); + expect(output).toContain("Use \\x1b]52;c;cG9pc29uZWQ=\\x07 carefully."); }); test("agent roles suggest --apply skips proposals that already match the role's pin and says so", async () => { @@ -1102,13 +1108,20 @@ describe("headless GUI parity CLI", () => { test("agent injection suggest prints the proposal, and --apply writes it through PUT /api/injection-model", async () => { const suggestion = { sizingModel: "gpt-5.5", - proposal: { model: "a/big", effort: "high", status: "proposed", tier: "fast", effortIntent: "glance", rationale: "Bounded edits.", moveUpIf: "It crosses modules.", moveDownIf: "Never.", proposedModel: "a/small", proposedEffort: "low", reason: null }, + proposal: { model: "a/big", effort: "high", status: "proposed", tier: "fast", effortIntent: "glance", rationale: "Bounded\x1b]52;c;payload\x07 edits.", moveUpIf: "It crosses\rmodules.", moveDownIf: "Never\u009b31m.", proposedModel: "a/small", proposedEffort: "low", reason: null }, }; const runtime = fakeRuntime(req => req.method === "POST" ? suggestion : { ok: true }); const logSpy = spyOn(console, "log").mockImplementation(() => {}); try { expect(await handleAgentCommand(["injection", "suggest", "rename", "symbols", "--model", "a/sizer", "--json"], runtime.deps)).toBe(0); + expect(JSON.parse(logSpy.mock.calls.flat().join("\n"))).toEqual(suggestion); + logSpy.mockClear(); expect(await handleAgentCommand(["injection", "suggest", "rename symbols", "--apply"], runtime.deps)).toBe(0); + const output = logSpy.mock.calls.flat().join("\n"); + expect(output).not.toMatch(/[\x07\x1b\r\u009b]/); + expect(output).toContain("Bounded\\x1b]52;c;payload\\x07 edits."); + expect(output).toContain("It crosses\\x0dmodules."); + expect(output).toContain("Never\\u009b31m."); expect(await handleAgentCommand(["injection", "suggest"], runtime.deps)).not.toBe(0); } finally { logSpy.mockRestore(); From 10975564724b93789493522154919f2f6d5e8e19 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 13:27:49 +0900 Subject: [PATCH 04/52] fix(providers): declare MiniMax M3 image input for combos (carry #6445) Seed image capabilities for M3 and M3.1 in both Coding Plan regions. Exercise saved overrides, ID-only discovery, static fallback, and Combo gating. Carries #6445 by @xianhongtao. Co-authored-by: xianhongtao <74975356+xianhongtao@users.noreply.github.com> --- docs-site/src/content/docs/guides/minimax.md | 5 + .../src/content/docs/zh-cn/guides/minimax.md | 4 + scripts/test-layout/layout.json | 1 + src/providers/registry/entries-extended.ts | 3 + src/providers/registry/model-seeds.ts | 5 + structure/providers-and-adapters.md | 7 +- tests/fixtures/test-layout-expected.json | 1 + .../minimax-combo-image-input.test.ts | 101 ++++++++++++++++++ 8 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 tests/providers/minimax-combo-image-input.test.ts diff --git a/docs-site/src/content/docs/guides/minimax.md b/docs-site/src/content/docs/guides/minimax.md index b09247dabfc..03a837406e8 100644 --- a/docs-site/src/content/docs/guides/minimax.md +++ b/docs-site/src/content/docs/guides/minimax.md @@ -12,6 +12,11 @@ the protocol boundary it actually exposes: ## MiniMax Code +In the OpenCodex Models page, both MiniMax Coding Plan regions advertise image input for +`MiniMax-M3` and `MiniMax-M3.1-Flash-Preview`. A Combo's **Image / multimodal** switch is +available only when every selected model supports image input. The switch controls image +attachments; it does not enable video uploads. + Install and sign in to MiniMax Code using MiniMax's instructions first. Then start OpenCodex and connect the reversible file integration: diff --git a/docs-site/src/content/docs/zh-cn/guides/minimax.md b/docs-site/src/content/docs/zh-cn/guides/minimax.md index 48e633e63e7..4ab74ee3a00 100644 --- a/docs-site/src/content/docs/zh-cn/guides/minimax.md +++ b/docs-site/src/content/docs/zh-cn/guides/minimax.md @@ -10,6 +10,10 @@ MiniMax 发布两种不同的命令行产品。OpenCodex 在它们实际提供 ## MiniMax Code +在 OpenCodex 的模型管理页面,MiniMax Coding Plan 两个区域的 `MiniMax-M3` 和 +`MiniMax-M3.1-Flash-Preview` 均声明支持图片输入。只有所有选定模型均支持图片输入时, +Combo 的“图片 / 多模态”开关才可用。该开关控制图片附件,不提供视频上传能力。 + 先按照 MiniMax 的说明安装并登录 MiniMax Code,然后启动 OpenCodex 并连接可逆的文件集成: ```bash diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index fde377d7131..fa1ccf01bc1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -11,6 +11,7 @@ "desktop-app-server-shim-launcher.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", "owner-registry-acl.test.ts": "config", + "minimax-combo-image-input.test.ts": "providers", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index a463dba9bf4..b001339a1bc 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -35,6 +35,7 @@ import { ZAI_GLM_5X_REASONING_EFFORTS, MINIMAX_MODELS, MINIMAX_MODEL_CONTEXT_WINDOWS, + MINIMAX_MODEL_INPUT_MODALITIES, MINIMAX_M3_REASONING_EFFORTS, MINIMAX_M3_REASONING_EFFORT_MAP, MINIMAX_M31_FLASH_PREVIEW, @@ -1024,6 +1025,7 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ id: "minimax", label: "MiniMax — Coding Plan", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS, modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS, + modelInputModalities: MINIMAX_MODEL_INPUT_MODALITIES, modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS, [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_REASONING_EFFORTS }, modelDefaultReasoningEfforts: { "MiniMax-M3": "medium", [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_DEFAULT_REASONING_EFFORT }, modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP }, @@ -1049,6 +1051,7 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ id: "minimax-cn", label: "MiniMax — Coding Plan (CN)", baseUrl: "https://api.minimaxi.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.minimaxi.com", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS, modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS, + modelInputModalities: MINIMAX_MODEL_INPUT_MODALITIES, modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS, [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_REASONING_EFFORTS }, modelDefaultReasoningEfforts: { "MiniMax-M3": "medium", [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_DEFAULT_REASONING_EFFORT }, modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP }, diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index 9b480828a4a..b5004e81e5c 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -172,6 +172,11 @@ export const MINIMAX_REASONING_SPLIT_MODELS = MINIMAX_MODELS_BEFORE_M31; export const MINIMAX_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( MINIMAX_MODELS.map(id => [id, id === "MiniMax-M3" || id === MINIMAX_M31_FLASH_PREVIEW ? 1_000_000 : 204_800]), ); +/** MiniMax's OpenAI-compatible M3 endpoints accept image_url; keep video off Codex's input enum. */ +export const MINIMAX_MODEL_INPUT_MODALITIES: Record = { + "MiniMax-M3": ["text", "image"], + [MINIMAX_M31_FLASH_PREVIEW]: ["text", "image"], +}; export const MINIMAX_M3_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; /** Identity efforts on the wire; no map, so none omits the field instead of disabling thinking. */ export const MINIMAX_M31_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 4a46a1b674f..91b15f681af 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -366,10 +366,9 @@ axis that outranks every source here, so it is where a deliberate text-only over (`ocx provider edit --model --text-only` writes it) and the one declaration a restart cannot take back. -Roster additions share the blind spot when the vendor's `/models` omits the new id (MiniMax-M3.1-Flash-Preview): -`src/providers/stale-model-roster-migration.ts` replaces a saved roster only while it is byte-for-byte the previous -seed, filling the added id's window and default effort only inside records the row already has, in the same startup -pass; `CALLABLE_CONFIGURED_COMPATIBILITY_MODELS` (`src/codex/catalog/model-hints.ts`) keeps it in the live catalog. +When `/models` omits MiniMax-M3.1-Flash-Preview, `src/providers/stale-model-roster-migration.ts` replaces only a saved roster identical to the old seed and fills existing window and default-effort records during startup; `CALLABLE_CONFIGURED_COMPATIBILITY_MODELS` (`src/codex/catalog/model-hints.ts`) retains it in the live catalog. + +Both MiniMax Coding Plan presets declare `text` and `image` for M3 and M3.1 in `src/providers/registry/model-seeds.ts`; registry enrichment fills missing saved declarations, so id-only live rows enable the Combo image switch when every target supports images. Explicit overrides still win; video is outside Codex's catalog input enum. The BigModel Coding Plan Responses preset uses the separately documented `https://open.bigmodel.cn/api/v1` transport and a static catalog. Its provider row diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 46c16b4174a..e6d4a5665be 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -8,6 +8,7 @@ "desktop-app-server-shim-launcher.test.ts": "clients", "desktop-chatgpt-config.test.ts": "clients", "owner-registry-acl.test.ts": "config", + "minimax-combo-image-input.test.ts": "providers", "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", diff --git a/tests/providers/minimax-combo-image-input.test.ts b/tests/providers/minimax-combo-image-input.test.ts new file mode 100644 index 00000000000..9949fbd37c9 --- /dev/null +++ b/tests/providers/minimax-combo-image-input.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { comboImagesSupported } from "../../gui/src/combo-capabilities"; +import { applyProviderConfigHints, deriveComboCatalogModel, gatherRoutedModels } from "../../src/codex/catalog"; +import { clearModelCache } from "../../src/codex/model-cache"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { listManagementModelRows } from "../../src/server/management/model-rows"; +import { isModelVisionSidecarConsumer } from "../../src/vision/eligibility"; +import type { CatalogModel, OcxConfig, OcxProviderConfig } from "../../src/types"; + +const M3 = "MiniMax-M3"; +const PREVIEW = "MiniMax-M3.1-Flash-Preview"; +const REGIONS = ["minimax", "minimax-cn"] as const; +const originalFetch = globalThis.fetch; + +function seeded(region: typeof REGIONS[number]): OcxProviderConfig { + const entry = getProviderRegistryEntry(region); + if (!entry) throw new Error(`missing ${region} registry entry`); + return { ...providerConfigSeed(entry), apiKey: "test-key" }; +} + +function config(region: typeof REGIONS[number], provider = seeded(region)): OcxConfig { + return { port: 10100, defaultProvider: region, providers: { [region]: provider } } as OcxConfig; +} + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache(); +}); + +describe("MiniMax Coding Plan Combo image input", () => { + test("both presets seed M3 image input and enrich missing saved declarations without replacing overrides", () => { + for (const region of REGIONS) { + const fresh = seeded(region); + expect(fresh.modelInputModalities?.[M3]).toEqual(["text", "image"]); + expect(fresh.modelInputModalities?.[PREVIEW]).toEqual(["text", "image"]); + expect(fresh.modelInputModalities?.["MiniMax-M2.7"]).toBeUndefined(); + + const saved: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: fresh.baseUrl, + modelInputModalities: { "MiniMax-M2.7": ["text"] }, + modelCapabilities: { [M3]: { inputModalities: ["text"] } }, + }; + enrichProviderFromRegistry(region, saved); + expect(saved.modelInputModalities).toMatchObject({ + [M3]: ["text", "image"], [PREVIEW]: ["text", "image"], "MiniMax-M2.7": ["text"], + }); + expect(saved.modelCapabilities?.[M3]?.inputModalities).toEqual(["text"]); + expect(isModelVisionSidecarConsumer(saved, M3)).toBe(true); + expect(applyProviderConfigHints(region, saved, { provider: region, id: M3 }).inputModalities) + .toEqual(["text", "image"]); // explicit text-only uses the existing vision sidecar + expect(isModelVisionSidecarConsumer(fresh, M3)).toBe(false); + } + }); + + test("id-only discovery and a warm cache yield management rows accepted by the Combo picker", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (!String(input).includes("/models")) throw new Error("unexpected request"); + return Response.json({ data: [{ id: M3 }] }); + }) as typeof fetch; + + for (const region of REGIONS) { + const live = config(region); + const first = await gatherRoutedModels(live); + const cached = await gatherRoutedModels(live); + for (const models of [first, cached]) { + const m3 = models.find(model => model.provider === region && model.id === M3); + expect(m3?.inputModalities).toEqual(["text", "image"]); + const rows = await listManagementModelRows(live, { models }); + const row = rows.find(model => model.provider === region && model.id === M3); + expect(row?.inputModalities).toEqual(["text", "image"]); + expect(comboImagesSupported([{ provider: region, model: M3 }], rows)).toBe(true); + expect(comboImagesSupported( + [{ provider: region, model: M3 }, { provider: "other", model: "vision" }], + [...rows, { provider: "other", id: "vision", inputModalities: ["text", "image"] }], + )).toBe(true); + expect(comboImagesSupported( + [{ provider: region, model: M3 }, { provider: "other", model: "blind" }], + [...rows, { provider: "other", id: "blind", inputModalities: ["text"] }], + )).toBe(false); + } + } + }); + + test("static fallback and Combo catalog retain image unless the operator disables it", async () => { + for (const region of REGIONS) { + const provider = seeded(region); + provider.liveModels = false; + const models = await gatherRoutedModels(config(region, provider)); + const member = models.find(model => model.provider === region && model.id === M3); + expect(member?.inputModalities).toEqual(["text", "image"]); + const combo = (imageInput: "auto" | "disabled") => ({ + targets: [{ provider: region, model: M3 }], imageInput, defaultEffort: "medium", + }) as never; + expect(deriveComboCatalogModel("minimax-image", combo("auto"), [member as CatalogModel])?.inputModalities) + .toContain("image"); + expect(deriveComboCatalogModel("minimax-image", combo("disabled"), [member as CatalogModel])?.inputModalities) + .toEqual(["text"]); + } + }); +}); From 6d4e40443c451039f7d215743aea925852565c49 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 13:27:51 +0900 Subject: [PATCH 05/52] fix(gui): queue optimistic model visibility saves (carry #6426) Model toggles now respond immediately while writes retain click order. Keep newer intent through reconciliation and cancel stale target observations. Carries #6426 by @andrew05060414. Closes #6425 Co-authored-by: andrew05060414 <59988150+andrew05060414@users.noreply.github.com> --- .../src/content/docs/guides/web-dashboard.md | 5 + .../docs/zh-cn/guides/web-dashboard.md | 4 + gui/src/model-visibility.ts | 2 + gui/src/pages/Models.tsx | 63 +++--- gui/src/use-model-visibility.ts | 109 +++++++++ gui/tests/models-visibility-queue.test.tsx | 207 ++++++++++++++++++ structure/gui-and-management-api.md | 11 + 7 files changed, 363 insertions(+), 38 deletions(-) create mode 100644 gui/src/use-model-visibility.ts create mode 100644 gui/tests/models-visibility-queue.test.tsx diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 3a4c01848ff..f3593a1644d 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -201,6 +201,11 @@ new or that every upstream measurement was refreshed. The **Models** switches show final Codex visibility: a routed model is on only when its provider allowlist includes it (or no allowlist is set) and it is not disabled. Turning a model on reconciles both filters atomically; **All on** clears the provider allowlist so newly discovered models are also on. +Switches respond immediately so you can keep changing models while saves run in the background in +click order. Saved feedback appears after the queue finishes and the list is reconciled with the +server. Failed saves restore the server's state when it can be read and show an error. Wait for that +feedback before leaving Models or changing servers: unsent queued changes are discarded on departure. + ### Managing models in a provider workspace In a provider’s **Models** tab, **Delete** removes the stored custom definition. An underlying diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 5b1a432af82..e2235e720bb 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -78,6 +78,10 @@ Logs 可组合界面、被拦截请求、提供商、完整模型名、状态、 ## 模型可见性 +开关会立即响应,保存则按点击顺序在后台执行,因此可以连续调整多个模型。队列处理完并与服务器 +核对列表后,才显示保存结果。保存失败会提示错误,并在能读取服务器状态时恢复实际选择。 +请等保存结果出现后再离开模型页或切换服务器;离开时会丢弃尚未发送的排队修改。 + **Models** 开关表示 Codex 中的最终可见状态。路由模型只有在 provider allowlist 中(或未设置 allowlist)且未被禁用时才会开启。开启模型会原子地协调两个过滤条件;**全部开启** 会清除 allowlist,因此以后新发现的模型也会开启。 ### 在提供方工作区管理模型 diff --git a/gui/src/model-visibility.ts b/gui/src/model-visibility.ts index df73ff51862..2cece6b7115 100644 --- a/gui/src/model-visibility.ts +++ b/gui/src/model-visibility.ts @@ -82,11 +82,13 @@ export async function putModelVisibility( targets: ModelVisibilityTarget[], enabled: boolean, fetchImpl: typeof fetch = fetch, + signal?: AbortSignal, ): Promise { return fetchImpl(`${apiBase}/api/model-visibility`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, provider, targets, enabled }), + ...(signal ? { signal } : {}), }); } diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index cfd1d89f057..6c0a446fb81 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -26,6 +26,7 @@ import { type ModelPickerOrderMode, type PickerOrderSettings, type PickerOrderSaved, type ModelPickerUsage, } from "../model-picker-order"; import { startVisibilityPoll } from "../visibility-poll"; +import { useModelVisibility } from "../use-model-visibility"; import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import ErrorBoundary from "../components/ErrorBoundary"; @@ -48,8 +49,7 @@ import { } from "../models-groups"; import { fetchSelectedModels, - modelVisible, - putModelVisibility, + modelVisible as savedModelVisible, clientCatalogRefreshFailures, type ClientCatalogRefreshFailure, shouldApplyLoadGeneration, @@ -340,6 +340,18 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c const catalogMutationRef = useRef(false); const loadGenerationRef = useRef(0); const loadPendingRef = useRef(false); + const visibility = useModelVisibility(apiBase, { + onQueued: () => { ++loadGenerationRef.current; setStatus(""); }, + onBusy: value => { ++loadGenerationRef.current; loadPendingRef.current = false; catalogMutationRef.current = value; busyRef.current = value; setBusy(value); }, + onResponse: body => { + const failures = clientCatalogRefreshFailures(body); + if (failures !== undefined) setIntegrationFailures(failures); + }, + refresh: signal => load(true, signal), + onSettled: error => { setOk(!error); setStatus(t(error ?? "models.applied")); }, + }); + const modelVisible = (selected: ProviderModelMap, provider: string, id: string, native: boolean, blocked: boolean) => + visibility.visible(provider, id, native, savedModelVisible(selected, provider, id, native, blocked)); // multi_agent_v2 / ultra gate. null = endpoint unavailable (older proxy build) -> section hidden. const [v2, setV2] = useState(null); // #2465: per-provider model-preset state. Keyed by provider so one card's busy state cannot @@ -497,6 +509,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c }, [apiBase]); const fetchCatalog = useCallback(async (signal: AbortSignal): Promise => { + const generation = loadGenerationRef.current; const [modelsRes, capsRes, providersRes, selectionData] = await Promise.all([ // Every request carries the resource signal, so leaving the catalog tab cancels // the work rather than only discarding its result. @@ -528,7 +541,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c contextCapValues: capsData.values ?? capsData.caps ?? {}, contextCapValue: nextCapValue, } satisfies CachedModelsPage; - writeSessionListCache(cacheKey, next); + if (generation === loadGenerationRef.current) writeSessionListCache(cacheKey, next); return next; }, [apiBase, cacheKey]); @@ -552,11 +565,12 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c cacheKey, [apiBase], async (signal) => { + const generation = loadGenerationRef.current; const next = await fetchCatalog(signal); // A manual mutation refresh may have invalidated this request while its JSON was decoding. // Do not let the aborted catalog repaint controls after the newer result is applied. if (signal.aborted) throw new Error("models request aborted"); - applyCatalog(next); + if (!catalogMutationRef.current && generation === loadGenerationRef.current) applyCatalog(next); return next; }, // Gated on the catalog tab: a 10-second poll that keeps running while the user @@ -888,7 +902,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c model.native === true, disabled.has(model.namespaced), )).length; - }, [disabled, models, selectedModels]); + }, [disabled, models, selectedModels, visibility.overrides]); /* * Quiet per-tab counts. A count is omitted, never zeroed, while it is unknown: the @@ -910,35 +924,8 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c targets: ModelVisibilityTarget[], enabled: boolean, ) => { - if (catalogMutationRef.current) return; - catalogMutationRef.current = true; - ++loadGenerationRef.current; - setBusy(true); - busyRef.current = true; - setStatus(""); - let errorKey: "models.saveFailed" | "models.networkError" | null = null; - try { - const response = await putModelVisibility(apiBase, scope, provider, targets, enabled); - if (!response.ok) errorKey = "models.saveFailed"; - else { - const failures = clientCatalogRefreshFailures(await response.json()); - if (failures !== undefined) setIntegrationFailures(failures); - } - } catch { - errorKey = "models.networkError"; - } finally { - const refreshed = await load(true); - if (errorKey) { - setOk(false); - setStatus(t(errorKey)); - } else if (refreshed) { - setOk(true); - setStatus(t("models.applied")); - } - setBusy(false); - busyRef.current = false; - catalogMutationRef.current = false; - } + if (busyRef.current && !visibility.isRunning()) return; + visibility.enqueue(scope, provider, targets, enabled); }; const toggleProviderCap = async (provider: string) => { @@ -1589,8 +1576,8 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c ); })()} - - + +
{/* The label names the FUNCTION. It used to be `models.capValue` - "기본 128k" - which is a value masquerading as a name: even a @@ -1730,7 +1717,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c }} >
- void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={busy || m.initialSelectionPending} label={m.native ? m.id : m.namespaced} /> + void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={(busy && !visibility.pending) || m.initialSelectionPending} label={m.native ? m.id : m.namespaced} /> {m.initialSelectionPending && {t("models.initialSelectionPending")}} {/* #1711: listed and selectable, but every usable target is out of credit. Not a visibility change and not the operator's disable flag — the row is @@ -2568,7 +2555,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c pickerMode: modelPickerOrderMode(pickerSettings?.pickerAvailable ?? [], pickerSettings?.pickerOrder ?? [], pickerSettings?.pickerOrderMode) })}> {controlsBlock} -
+