Skip to content

Add Model Manager mini app by ocoomber - #4

Open
ocoomber wants to merge 4 commits into
MiniMax-AI:mainfrom
ocoomber:add-openrouter-model-manager
Open

ocoomber wants to merge 4 commits into
MiniMax-AI:mainfrom
ocoomber:add-openrouter-model-manager

Conversation

@ocoomber

@ocoomber ocoomber commented Sep 21, 2026

Copy link
Copy Markdown

New MiniApp: Model Manager (openrouter-model-manager)

A MiniApp for browsing, searching, and enabling/disabling the models in your MiniMax Code config.yaml — no more find-and-replace in a text editor.

What it does

  • Works with any provider (OpenRouter, custom providers, local endpoints like Ollama/LM Studio). Every model block with enabled: flags is picked up automatically and merged into one list grouped by model family — there is no provider-switching UI. (The plugin ID openrouter-model-manager is kept for stability, but the app is not OpenRouter-specific.)
  • Instant save per toggle; filter chips (All / Enabled / Disabled); bulk "Enable matching / Disable matching" scoped to the current search results; per-family enable/disable buttons.
  • One-level Undo with a stale-change guard: it refuses instead of overwriting if the file changed outside the app since the mutation. A multi-provider bulk action is reverted as one operation, not provider-by-provider.
  • Automatic timestamped backups in backups/ under the MiniApp's own data directory (per the runtime's "store durable state under dataDir" guidance), pruned to the newest 20 — nothing in the data-root namespace is touched.
  • Prominent restart reminder banner after any change — the host app must be restarted for config changes to take effect (a MiniApp cannot restart the host).
  • Collapsible model families grouped by ID prefix; a subtle green dot marks collapsed families that contain enabled models.
  • OpenRouter model page links only for OpenRouter providers, with a strict URL allowlist.
  • Atomic, line-based YAML editing: only the toggled model's enabled: line is rewritten. Each line's own terminator is preserved (mixed CRLF/LF/CR files stay intact), the original file mode is kept, concurrent mutations are serialized, and colon-tolerant keys (llama3.1:latest, :free) are supported.

Checklist

  • Complete plugin package under plugins/ocoomber/openrouter-model-manager/, including the hidden .minimax-plugin/ directory
  • Works standalone when copied to ~/.minimax/plugins/openrouter-model-manager/
  • English + Simplified Chinese READMEs with cross-links
  • MIT LICENSE
  • Entry added to the app tables in both root READMEs

File access, process spawning & network

  • Reads and writes only config.yaml. Resolution walks ancestors of the injected data directory looking for a config.yaml (the Host injects a plugin-owned subdirectory several levels below the data root, and the MiniMax Code config lives at the data root or one of its ancestors in the current Host layout), then falls back to the default ~/.minimax/config.yaml. The parent walk is an implementation detail of the current Host layout, not a guaranteed API — the default fallback is what protects non-default installs.
  • Writes go through a temp file .config.yaml.mm-tmp created next to the config, then renamed; the temp file is removed if anything fails.
  • No network requests from the app itself. The only OS-level action is opening https://openrouter.ai/<vendor>/<model> pages in the user's own browser via the OS opener (rundll32/cmd/explorer on Windows, open on macOS, xdg-open on Linux); anything outside the allowlist is rejected server-side.
  • Secrets: the server returns only model id / name / enabled / contextLimit to the UI. API key material is never read into the UI, returned by the API, or displayed.

Test environment & results

  • Windows 11 (build 10.0.26200), MiniMax Code 3.0.73, plugin v1.2.4.
  • Automated tests: tests/parser.test.mjs and tests/resolveConfigPath.test.mjs, 10 tests via node --test tests/ — EOL round-trip (including mixed CRLF/LF/CR), provider discovery, blocks without enabled: flags skipped, colon-in-id keys, commented lines ignored, enabled-state extraction, the enabled:-line rewrite helper, multi-provider toggle invariants, the four-ancestor parent walk, the default fallback, and a sibling-ancestor config. These cover the parser and resolve logic; the HTTP server itself is exercised manually.
  • Manual end-to-end against a real ~5,900-line config (446 toggleable OpenRouter models): toggling, search/filters, bulk enable/disable, undo, backups, and the restart flow, verified in the running desktop app.
  • Unverified / known limits: no automated UI tests (rendered-page visuals confirmed manually by the author); macOS/Linux code paths are untested — feedback welcome.

A MiniApp for browsing, searching, and enabling/disabling models in ~/.minimax/config.yaml directly from MiniMax Code.

- Works with any provider (OpenRouter, custom, local endpoints)
- Instant save per toggle; bulk enable/disable with one-level undo
- Automatic timestamped backups before bulk changes
- Atomic, line-based YAML editing preserving indentation and line endings
- Restart reminder banner; OpenRouter model page links
- English and Simplified Chinese READMEs; MIT licensed
The UI navigates by model family (ID prefix) only. All manageable model blocks in config.yaml are merged into a single family-grouped list; blocks without enabled flags (e.g. the built-in always-on provider) are skipped as before.
@MyPrototypeWhat

Copy link
Copy Markdown
Collaborator

Thanks for the contribution — this is the first Mini App in the repo that writes to the host config, and the line-based approach that preserves indentation and unknown keys is a sensible choice.

Package-level checks all pass: .minimax-plugin/plugin.json, package.json#mcode and miniapp/miniapp.json validate against the host's strict readers, miniapp-api.ts is byte-identical to the canonical copy, the icon is a real 128×128 PNG, the plugin ID is unique, and both root READMEs are updated. So structurally this is a valid Mini App package.

Because this plugin modifies config.yaml (which holds the user's API keys), we're applying a slightly higher bar than for the read-only dashboards already in the repo. Findings are grouped by what we need before merging vs. what is optional.

1. Required before merge (CONTRIBUTING / disclosure)

a. README is missing the tested environment. CONTRIBUTING #3 asks each README to state tested client versions and operating systems. The PR description has "Windows 10.0.26200 x64, MiniMax Code Desktop, plugin v1.2.2" — please add that (with the MiniMax Code version) to both READMEs, and note that macOS/Linux are untested if that's the case.

b. File access / process disclosure is incomplete. The Privacy section says the app "reads and writes only your ~/.minimax/config.yaml", but:

  • /api/open-external spawns OS processes (rundll32 / cmd / explorer on Windows, open on macOS, xdg-open on Linux) — server.mjs L215-250.
  • Writes go through a temp file .config.yaml.mm-tmp created next to config.yaml — L30, L133.

Both are fine, but the repo's model is "users read the README to decide what to install", so please list them.

c. Two statements in the PR description have no counterpart in the diff.

  • "secrets render as [SECRET:<key-name>] placeholders" — there's no such code. The server only returns id / name / enabled / contextLimit, which is actually the better property; please describe it that way instead.
  • "24/24 parser tests passed" — no test files are in the PR. splitConfigText and parseProviders are exported (presumably for tests) but nothing consumes them. Either include the tests (outside miniapp/ so they don't become part of the runtime payload) or rephrase as local-only, unpublished testing.

d. Hardcoded ~/.minimax/config.yaml (server.mjs L15). The root README tells users "If you have configured a different data directory, use that directory instead" — with a non-default data directory or a profile, this plugin will read/write the wrong file. The existing plugins/amszuidas/mcode-token-usage-board/miniapp/node/server.mjs (L22-36) shows the pattern: walk up from context.dataDir to the data root and fall back to ~/.minimax only if nothing is found. At minimum, please state in the README that only the default data directory is supported.

2. Please fix or disclose (data safety on config.yaml)

a. Atomic write drops the original file mode. writeFile(tmp) + rename (L29-33, L133-135) replaces config.yaml with a file created under the default umask — if the user's config was 0600, it silently becomes 0644. Suggest const { mode } = await stat(CONFIG_PATH) and writeFile(tmp, data, { mode }), and remove the temp file if rename fails.

b. Lost writes with multiple providers. The client fires one POST /api/bulk per provider in parallel (index.html L553-563); each handler does a full-file read → modify → write (server.mjs L164-181), so the last writer wins and earlier providers' changes are dropped. lastSnapshot is overwritten the same way, so Undo can't recover it. Suggest a single request carrying all provider groups, or a server-side promise queue that serializes mutations.

c. The line-ending claim doesn't hold for mixed EOLs. splitConfigText (L22-26) picks one EOL for the whole file, so a file with mixed CRLF/LF is normalized on the first write — that contradicts "only the toggled model's enabled: line is rewritten" and "line endings … preserved". Either preserve per-line EOL (e.g. split with a capturing regex and re-join per line) or soften the README/PR wording.

d. Undo semantics.

  • undo() (L131-138) writes the whole snapshot back, discarding any edits the user made in an external editor after the snapshot. Consider comparing the current file to the post-mutation text and refusing (or warning) when it has changed underneath.
  • When the server returns { changed: false } it takes no snapshot, but the client still calls setUndo(true) (index.html L529) — pressing Undo then reverts to an older, unrelated state. Only enable Undo when d.changed is true.

3. Suggestions (non-blocking)

  • exampleQueries: [] in plugin.json — the zh-CN README tells users to say "打开模型管理器", but the Agent can only route that if the manifest lists example queries. Consider adding "Open Model Manager" / "打开模型管理器".
  • .ghead is a div[role="button"] that contains real <button>s (index.html L401-460). Nested interactive controls are not reachable for assistive tech; make the header itself a <button> and place the group actions next to it.
  • Emoji icons (🔄, L162/L181) and the infinite banner-pulse animation: the built-in miniapp-creator Skill's visual baseline asks for inline SVG icons and restrained motion. Also --mcode-warning-bg is not part of the baseline token set.
  • The right-click menu (L319-355) has role="menu" but no keyboard entry, Escape handling, or focus return.
  • /api/providers returns configPath (an absolute path the client never uses) and 500 responses include the raw error message; both leak internal paths to the page.
  • backups/ grows without bound — consider keeping the last N.
  • Plugin ID openrouter-model-manager vs. "works with any provider": keeping the ID is fine (IDs should be stable), but a one-line note in the README would avoid confusion.

Happy to re-review once §1 and §2 are addressed. Thanks again!

…1.2.3)

server.mjs:
- resolve config.yaml from the runtime dataDir first, ~/.minimax fallback
- preserve each line's own terminator (CRLF/LF/CR mixed files stay intact)
- atomic write keeps the original file mode; temp file removed on failure
- serialize mutations behind a promise queue so overlapping requests cannot clobber each other or the undo snapshot
- one-level undo now refuses (stale) if the file changed outside the app since the snapshot
- backups moved next to config.yaml (backups/), pruned to newest 20
- 500 responses no longer leak raw error text; configPath removed from /api/providers

client (index.html):
- Undo enabled only when something actually changed; stale-undo shows a reload hint
- group headers are real buttons with aria-controls (no nested interactive elements)
- context menu closes on Escape and returns focus to the link
- inline SVG icons (no emoji), no infinite pulse animation, warning colors from the visual-baseline tokens

repo:
- add tests/parser.test.mjs (node --test) covering EOL round-trip, provider discovery, colon ids, enabled flags
- README/README.zh-CN: tested environment (Windows 11, MiniMax Code 3.0.73), macOS/Linux untested, process-spawning + temp-file + backups disclosures, corrected secrets wording, plugin-ID note
- plugin.json: v1.2.3 + exampleQueries
@ocoomber

Copy link
Copy Markdown
Author

Thanks for the careful review, @MyPrototypeWhat — everything is addressed in db10720 (v1.2.3). Point by point:

Required fixes

  • Hardcoded config path (server.mjs ~L15): resolveConfigPath(dataDir) now tries the runtime-provided data directory first and falls back to ~/.minimax/config.yaml.
  • Line endings (~L22–26): splitConfigText captures each line's own terminator (CRLF/LF/CR, including mixed files) and joinConfigText restores the text byte-for-byte outside the edited enabled: line. Covered by tests.
  • Atomic write (~L29–33): writes go to .config.yaml.mm-tmp next to the config and are renamed; the write now preserves the original file mode and the temp file is removed if rename fails.
  • Real tests (was overstated in the PR body): tests/parser.test.mjs ships in the repo (outside the miniapp/ install payload) — 6 tests run with node --test, covering EOL round-trip, provider discovery, colon-in-id keys, commented lines, and enabled-flag handling. The old "24/24 tests" claim was wrong and has been removed from the PR body; the honest split is now: 6 automated parser tests + manual end-to-end verification against a real ~5,900-line config.
  • Secrets wording: corrected in both the READMEs and the PR body — the server returns only id / name / enabled / contextLimit; key material is never read into or returned by the UI. The incorrect [SECRET:...] placeholder claim is gone.

Data-safety fixes

  • Undo race (~L131–138): /api/set, /api/bulk, and /api/undo now run through a mutation queue (withMutationLock), so overlapping requests can't interleave read→modify→write cycles. The snapshot stores {prev, after} and undo() returns {stale: true} — the client shows a "config changed elsewhere, reload" message — if the file changed underneath since the mutation.
  • Bulk race (~L164–181): same queue; bulk requests are serialized instead of racing.
  • Path leak (~L274): /api/providers no longer returns configPath.
  • 500 leak (~L366): responses now return {error: 'internal_error'}; details go only to the runtime logger.
  • open-external (~L215–250): URLs must match ^https://openrouter\.ai/<vendor>/<model>$ (no extra path segments); the OS-spawning behavior (rundll32/cmd/explorer on Windows, open on macOS, xdg-open on Linux) is now disclosed in the READMEs.

Suggestions taken

  • Backups moved to a backups/ folder next to config.yaml (follows the config location), pruned to the newest 20.
  • READMEs (EN + zh) gained a "Tested environment" section — Windows 11 (build 26200), MiniMax Code 3.0.73; macOS/Linux explicitly marked untested — plus temp-file, backups, and process-spawning disclosures, and a note that the legacy plugin ID still works with any provider.
  • plugin.json: exampleQueries added ("Open Model Manager" / "打开模型管理器"), version bumped to 1.2.3.
  • Client a11y/visual polish: group headers are now real <button>s with aria-controls (no nested interactive elements), the context menu closes on Escape and returns focus to the link, Undo is enabled only when something actually changed, and the restart pill/banner use inline SVG icons with the visual baseline's warning tokens (no pulsing animation).

Happy to adjust anything further — thanks again.

@MyPrototypeWhat

MyPrototypeWhat commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the fast and thorough turnaround — I went through db10720 item by item against the code rather than the summary, and also ran node --test tests/parser.test.mjs here (macOS, Node v24.14.0): 6/6 pass, including the mixed CRLF/LF/CR round-trip.

Confirmed resolved: tested-environment section in both READMEs; process-spawning and temp-file disclosure; PR body no longer claims [SECRET:…] or "24/24"; file mode preserved + temp file removed on failure (server.mjs L69-88); withMutationLock serializing /api/set, /api/bulk, /api/undo; per-line EOL preservation; {prev, after} snapshot with stale refusal; Undo enabled only on d.changed; configPath and raw error text no longer returned; exampleQueries; real <button> group headers with aria-controls; Escape/focus handling on the context menu; inline SVG + baseline warning tokens, no pulse. Nice work.

Two things remain — one required, one your call.

A. Required (pick either option): README vs. actual config resolution

The README now says: "the runtime resolves config.yaml from its data directory first and falls back to the default ~/.minimax/config.yaml." The first branch never fires, so the sentence describes behavior that doesn't exist.

Why: context.dataDir is not the MiniMax Code data root. The Host hands each Mini App an isolated, plugin-owned subdirectory several levels below the data root. The Host's own config.yaml lives at <dataRoot>/config.yaml. So join(context.dataDir, 'config.yaml') (server.mjs L26) points at a file nothing ever writes, existsSync is always false, and the code always takes the ~/.minimax fallback — exactly the v1.2.2 behavior. It looks fine on a default layout because the fallback hits every time.

Reproduction (real server.mjs, Host-shaped context, HOME pointed at an empty directory, config.yaml placed only at <dataRoot>/config.yaml):

GET /api/providers -> 500 {"error":"internal_error"}
logger.error miniapp.request.failed:
  ENOENT: open '<context.dataDir>/config.yaml'

Note the path: neither candidate existed, so L31 return candidates[0] resolved CONFIG_PATH inside the plugin data dir rather than to the real config one level up.

Either of these is fine with us:

  1. Reword the README (both languages) to say only the default ~/.minimax layout is supported, and drop the dataDir candidate.
  2. Walk up from context.dataDir to the first ancestor that contains config.yaml (the same pattern plugins/amszuidas/mcode-token-usage-board/miniapp/node/server.mjs L22-36 uses for v2/sessions), then fall back to ~/.minimax/config.yaml. To be transparent: the Mini App contract doesn't promise that the data root is an ancestor of context.dataDir — that's the current Host layout, not an API — so if you go this route, keep the fallback and say in the README that non-default directories rely on that layout. Also change the "neither found" case to fall back to ~/.minimax/config.yaml (or fail with a clear message) instead of candidates[0].

B. Suggestion (your call): backups location

v1.2.3 moved backups from context.dataDir/backups to join(dirname(CONFIG_PATH), 'backups') (L181), i.e. ~/.minimax/backups/ — a new top-level directory inside the Host's data root. Verified: after one /api/bulk, the backup lands in $HOME/.minimax/backups/ and nothing is created under context.dataDir.

The README discloses this accurately, so it's not blocking. But from the Host side we'd prefer the v1.2.2 location (context.dataDir/backups): the Mini App runtime guidance is "store durable state under the injected dataDir"; the Host owns the data-root namespace and may use backups/ itself later; the directory outlives an uninstall; and the prune (L185-189) deletes any config-*.yaml in that shared directory, not just files this plugin wrote. Your decision — if you keep it, the current disclosure is sufficient.

Minor (non-blocking)

  • With serialization in place, a cross-provider bulk still sends one request per provider, and lastSnapshot only keeps the last one — Undo reverts the last provider's batch, not the whole action. Either merge the groups into a single request or note the limit in the README.
  • "not shipped in the install payload" for tests/ is slightly off: it is copied when users copy the directory; it just isn't part of the Host's runtime payload roots (miniapp/client, miniapp/node). Wording only.

Once A is settled, this looks good to merge from our side. Thanks again!

…e whole action (v1.2.4)

server.mjs:
- resolveConfigPath now walks ancestors of context.dataDir looking for config.yaml (matches the current Host layout where context.dataDir is several levels below the data root) and falls back to ~/.minimax/config.yaml; if neither is found it returns the default rather than a missing path
- DATA_DIR captured at startup so backups land under the plugin-owned namespace (context.dataDir/backups), pruned to the newest 20 — matches the 'store durable state under dataDir' runtime guidance and avoids touching anything in the data-root namespace
- /api/bulk now accepts {enabled, providers: [{provider, models}]} so a single bulk request takes one snapshot and one write; Undo reverts the whole action regardless of how many providers it touched

client (index.html):
- bulk() sends one multi-provider request to /api/bulk (instead of one Promise.all per provider); Undo enabled iff any model changed

tests/:
- parser.test.mjs: +1 test covering the multi-provider toggle invariant (ids remain valid across providers in a single write)
- resolveConfigPath.test.mjs (new): 3 tests covering the four-ancestor walk, the default fallback, and a sibling-ancestor config

READMEs (EN + zh):
- config-location: rewrote to describe the parent walk accurately, called out that the walk matches an implementation detail of the current Host layout, and noted the default fallback is what protects non-default installs
- backups: relocated disclosure to dataDir/backups (plugin-owned)
- Undo: noted that multi-provider bulk is reverted as one operation
- Tests/-folder wording: clarified it lives outside the Host's runtime payload roots (miniapp/client, miniapp/node) rather than being excluded from a directory copy
- Tested env: bumped to v1.2.4

plugin.json: 1.2.4
@ocoomber

Copy link
Copy Markdown
Author

Thanks again for the close read — both items addressed in ff05789 (v1.2.4), plus the two minor nits.

A. Config resolution (required) — went with option 2 (walk up) so non-default data directories still work.

resolveConfigPath(dataDir) now walks parents from context.dataDir looking for an ancestor containing config.yaml (the same pattern mcode-token-usage-board uses for its v2/sessions lookup), and falls back to ~/.minimax/config.yaml. Verified locally with a synthetic layout:

<root>/config.yaml
<root>/v2/plugin-data/liveboards/openrouter-model-manager/

The walk finds <root>/config.yaml four ancestors above the plugin data dir. The README disclosure is honest about the boundary: "the parent walk matches an implementation detail of the current Host layout, not a guaranteed API, so the default fallback is what protects non-default installs." The "neither found" branch now returns the default path instead of candidates[0] — same ENOENT-on-first-read as before, just no surprise path inside dataDir.

Added tests/resolveConfigPath.test.mjs (3 tests) covering the four-ancestor walk, the default fallback when nothing is found, and a sibling-ancestor config being preferred over the default. Total tests now: 10/10 pass.

B. Backups location (your call) — moved back to context.dataDir/backups per the runtime's "store durable state under the injected dataDir" guidance. Captured DATA_DIR once at startup so takeSnapshot writes to join(DATA_DIR, 'backups'). The prune logic now only ever touches files this plugin wrote, in a directory only this plugin uses. Disclosure in both READMEs updated.

Minor items

  • Cross-provider bulk Undo: /api/bulk now accepts {enabled, providers: [{provider, models}]} and does one read → one snapshot → one write, so Undo reverts the whole batch regardless of how many providers it touched. Client bulk() sends one request instead of Promise.all per provider. README notes the guarantee.
  • tests/ wording: reworded — "lives outside the Host's runtime payload roots (miniapp/client, miniapp/node), so the app never loads it — it just rides along if you copy the directory" — which is the actual invariant.

Ready for re-review whenever you are. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants