diff --git a/.changelog/next/added-agent-150443d5.md b/.changelog/next/added-agent-150443d5.md
new file mode 100644
index 0000000000..89509194ec
--- /dev/null
+++ b/.changelog/next/added-agent-150443d5.md
@@ -0,0 +1 @@
+- Add opt-in MTPLX local MTP provider presets
diff --git a/client/src/components/cos/TaskAddForm.jsx b/client/src/components/cos/TaskAddForm.jsx
index 7000bb9197..0ca8724a12 100644
--- a/client/src/components/cos/TaskAddForm.jsx
+++ b/client/src/components/cos/TaskAddForm.jsx
@@ -98,8 +98,9 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa
// Memoize enabled providers for the dropdown — restricted to CODING providers
// (CLI/TUI agents with a file-writing harness). HTTP `api` providers (raw
// Ollama / LM Studio / nvidia-kimi) return plain text and can't write files, so
- // they're not valid task runners; a user who only has those should use the
- // "Claude Ollama" sample (a `claude` CLI/TUI pointed at Ollama) instead.
+ // they're not valid task runners; a user who only has those should use a local
+ // coding preset: Claude Ollama or OpenCode MTPLX for a separately running
+ // MTPLX server.
const enabledProviders = useMemo(() =>
providers?.filter(p => p.enabled && isProcessProvider(p)) || [],
[providers]
@@ -696,7 +697,7 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa
{apiOnlyProviders && (
- Your enabled providers (Ollama / LM Studio) are HTTP API providers with no file-writing harness, so they can't run agent tasks. Enable the Claude Ollama provider (a claude CLI/TUI pointed at your local model) on the AI Providers page to run file-writing tasks on a local model.
+ Your enabled providers are HTTP API providers with no file-writing harness, so they can't run agent tasks. Enable Claude Ollama for Ollama, or OpenCode MTPLX for a separately running MTPLX server, on the AI Providers page to run file-writing tasks on a local model.
)}
{/* Screenshot and Attachment Upload */}
diff --git a/client/src/utils/providers.test.js b/client/src/utils/providers.test.js
index 877b88a928..844c69fd11 100644
--- a/client/src/utils/providers.test.js
+++ b/client/src/utils/providers.test.js
@@ -840,13 +840,14 @@ describe('supportsModelRefresh', () => {
expect(decorated.length).toBeGreaterThan(20);
const withButton = decorated.filter(supportsModelRefresh).map((p) => p.id).sort();
- // Frozen from the pre-#3620 dispatch chains — the refactor must not change
- // WHICH shipped provider offers the button.
+ // Intentional shipped-catalog contract: a newly seeded provider must either
+ // have a usable fetcher or stay out of this list.
expect(withButton).toEqual([
'antigravity-cli', 'antigravity-tui', 'cerebras', 'claude-code',
'claude-code-bedrock', 'claude-ollama', 'claude-ollama-tui', 'cursor-cli',
- 'cursor-tui', 'grok', 'lmstudio', 'nvidia-kimi', 'ollama',
- 'opencode-ollama', 'opencode-ollama-tui',
+ 'cursor-tui', 'grok', 'lmstudio', 'mtplx', 'nvidia-kimi', 'ollama',
+ 'opencode-mtplx', 'opencode-mtplx-tui', 'opencode-ollama',
+ 'opencode-ollama-tui',
]);
});
});
diff --git a/data.reference/providers.json b/data.reference/providers.json
index 08ab5aab27..bd1f4f7a38 100644
--- a/data.reference/providers.json
+++ b/data.reference/providers.json
@@ -191,6 +191,43 @@
"tuiPromptDelayMs": 2500,
"tuiIdleTimeoutMs": 180000
},
+ "opencode-mtplx": {
+ "id": "opencode-mtplx",
+ "name": "OpenCode MTPLX (local MTP)",
+ "type": "cli",
+ "command": "opencode",
+ "args": ["run"],
+ "endpoint": "http://127.0.0.1:8000/v1",
+ "models": ["mtplx"],
+ "defaultModel": "mtplx",
+ "mtplxBacked": true,
+ "timeout": 600000,
+ "enabled": false,
+ "envVars": {
+ "OPENCODE_CONFIG_CONTENT": "{\"permission\":\"allow\",\"provider\":{\"mtplx\":{\"npm\":\"@ai-sdk/openai-compatible\",\"name\":\"MTPLX (local MTP)\",\"options\":{\"baseURL\":\"http://127.0.0.1:8000/v1\"}}}}"
+ },
+ "secretEnvVars": [],
+ "headlessArgs": []
+ },
+ "opencode-mtplx-tui": {
+ "id": "opencode-mtplx-tui",
+ "name": "OpenCode MTPLX TUI (local MTP)",
+ "type": "tui",
+ "command": "opencode",
+ "args": [],
+ "endpoint": "http://127.0.0.1:8000/v1",
+ "models": ["mtplx"],
+ "defaultModel": "mtplx",
+ "mtplxBacked": true,
+ "timeout": 600000,
+ "enabled": false,
+ "envVars": {
+ "OPENCODE_CONFIG_CONTENT": "{\"permission\":\"allow\",\"provider\":{\"mtplx\":{\"npm\":\"@ai-sdk/openai-compatible\",\"name\":\"MTPLX (local MTP)\",\"options\":{\"baseURL\":\"http://127.0.0.1:8000/v1\"}}}}"
+ },
+ "secretEnvVars": [],
+ "tuiPromptDelayMs": 2500,
+ "tuiIdleTimeoutMs": 180000
+ },
"antigravity-tui": {
"id": "antigravity-tui",
"name": "Antigravity TUI",
@@ -233,6 +270,18 @@
"enabled": false,
"envVars": {}
},
+ "mtplx": {
+ "id": "mtplx",
+ "name": "MTPLX (local MTP)",
+ "type": "api",
+ "endpoint": "http://127.0.0.1:8000/v1",
+ "apiKey": "",
+ "models": ["mtplx"],
+ "defaultModel": "mtplx",
+ "timeout": 300000,
+ "enabled": false,
+ "envVars": {}
+ },
"nvidia-kimi": {
"id": "nvidia-kimi",
"name": "NVIDIA Kimi K2.5",
diff --git a/docs/README.md b/docs/README.md
index df0385f78e..0048c914cb 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -31,7 +31,7 @@ Start with the [product surface map](./features/product-surfaces.md) for a compl
App management: [app-wizard](./features/app-wizard.md) · [autofixer](./features/autofixer.md) · [browser](./features/browser.md) · [error-handling](./features/error-handling.md) · [jira-sprint-manager](./features/jira-sprint-manager.md)
-Chief of Staff: [chief-of-staff](./features/chief-of-staff.md) · [cos-agent-runner](./features/cos-agent-runner.md) · [cos-enhancement](./features/cos-enhancement.md) · [agent-skills](./features/agent-skills.md) · [memory-system](./features/memory-system.md) · [claude-ollama](./features/claude-ollama.md) · [prompt-manager](./features/prompt-manager.md)
+Chief of Staff: [chief-of-staff](./features/chief-of-staff.md) · [cos-agent-runner](./features/cos-agent-runner.md) · [cos-enhancement](./features/cos-enhancement.md) · [agent-skills](./features/agent-skills.md) · [memory-system](./features/memory-system.md) · [claude-ollama](./features/claude-ollama.md) · [mtplx](./features/mtplx.md) · [prompt-manager](./features/prompt-manager.md)
Identity & self: [digital-twin](./features/digital-twin.md) · [identity-system](./features/identity-system.md) · [soul-system](./features/soul-system.md) · [post](./features/post.md) (insights design spike: [plans/2026-06-03](./plans/2026-06-03-cross-domain-insights-engine.md))
diff --git a/docs/features/mtplx.md b/docs/features/mtplx.md
new file mode 100644
index 0000000000..af5acabccb
--- /dev/null
+++ b/docs/features/mtplx.md
@@ -0,0 +1,53 @@
+# MTPLX — native-MTP Qwen on Apple Silicon
+
+[MTPLX](https://github.com/youssofal/MTPLX) is a separately managed local
+runtime for Apple Silicon that can run Qwen checkpoints with native
+multi-token-prediction (MTP) decoding. It exposes OpenAI-compatible and
+Anthropic-compatible local APIs; PortOS uses its OpenAI-compatible endpoint.
+
+This is an additional runtime, not an Ollama replacement. PortOS continues to
+offer **Qwen3.8 27B** in the Ollama catalog using its GGUF model path. MTPLX's
+native-MTP checkpoints and Ollama GGUF models are distinct formats, so neither
+PortOS nor Ollama attempts to load one as the other.
+
+## What PortOS adds
+
+After this version is installed, the **AI Providers** page includes three
+disabled presets:
+
+- **MTPLX (local MTP)** — an `api` provider for ordinary text-generation tasks.
+- **OpenCode MTPLX (local MTP)** — a headless `cli` coding-agent provider.
+- **OpenCode MTPLX TUI (local MTP)** — an attachable `tui` coding-agent provider.
+
+The two OpenCode variants give CoS agents a file-writing tool harness. The API
+variant returns text only, like the existing Ollama API provider, so it is not a
+valid CoS coding-agent runner.
+
+## Setup
+
+1. Install and validate MTPLX independently using its upstream documentation.
+ PortOS does not download model weights, launch its installer, enable optional
+ thermal-management helpers, or start a daemon.
+2. Start an MTPLX server for your verified Qwen MTP model on its documented
+ loopback OpenAI-compatible endpoint, `http://127.0.0.1:8000/v1`.
+3. On **AI Providers**, enable the matching preset. Use **Refresh Models** only
+ after the server is running; PortOS then reads `/v1/models` on demand.
+4. Choose **MTPLX (local MTP)** for supported non-coding tasks, or choose an
+ **OpenCode MTPLX** CLI/TUI preset for a CoS coding task. The seed model alias
+ is `mtplx`; refresh it if your running server publishes a different alias.
+
+All presets are disabled by default. Merely updating PortOS does not make a
+network request, invoke a model, tune speculative decoding, or alter the active
+provider. MTPLX tuning remains an explicit operator action outside PortOS.
+
+## Operational notes
+
+- MTPLX can offer a faster path for an MTP-capable Qwen checkpoint; benchmark it
+ on the target machine rather than assuming it improves the existing Ollama
+ model.
+- Keep the MTPLX endpoint local. The provided presets use a loopback address;
+ if you intentionally change it, treat the server and model weights as a
+ separate trusted runtime.
+- The source audit that motivated this integration found privileged optional
+ thermal-helper and installer paths upstream. The PortOS integration is
+ protocol-only so those paths never run as part of PortOS setup or boot.
diff --git a/scripts/migrations/272-mtplx-providers.js b/scripts/migrations/272-mtplx-providers.js
new file mode 100644
index 0000000000..7fe8adca6a
--- /dev/null
+++ b/scripts/migrations/272-mtplx-providers.js
@@ -0,0 +1,77 @@
+/**
+ * Ship disabled MTPLX provider presets to existing installs.
+ *
+ * MTPLX is an independently managed Apple Silicon runtime for Qwen native
+ * multi-token prediction (MTP). It is not an Ollama model format, so PortOS
+ * keeps the existing Ollama Qwen path intact and offers MTPLX through its
+ * documented local OpenAI-compatible endpoint instead. The API preset serves
+ * ordinary text tasks; the two OpenCode presets provide the file-writing CLI
+ * and attachable TUI harnesses for CoS agent tasks.
+ *
+ * This migration deliberately does not install MTPLX, download a model, start a
+ * daemon, tune a runtime, or contact an endpoint. All three providers are
+ * disabled by default. An install that already owns one of these ids is left
+ * untouched, preserving refreshed models and local endpoint edits.
+ *
+ * Kept in lockstep with data.reference/providers.json and
+ * server/lib/aiToolkit/defaults/providers.sample.json. These frozen literals
+ * are the historical upgrade payload; later default changes require a new
+ * migration rather than rewriting this record.
+ */
+
+import { makeProviderSeedMigration } from './_lib.js';
+
+const OPENCODE_CONFIG_CONTENT = '{"permission":"allow","provider":{"mtplx":{"npm":"@ai-sdk/openai-compatible","name":"MTPLX (local MTP)","options":{"baseURL":"http://127.0.0.1:8000/v1"}}}}';
+
+const MTPLX_API = {
+ id: 'mtplx',
+ name: 'MTPLX (local MTP)',
+ type: 'api',
+ endpoint: 'http://127.0.0.1:8000/v1',
+ apiKey: '',
+ models: ['mtplx'],
+ defaultModel: 'mtplx',
+ timeout: 300000,
+ enabled: false,
+ envVars: {},
+};
+
+const OPENCODE_MTPLX_CLI = {
+ id: 'opencode-mtplx',
+ name: 'OpenCode MTPLX (local MTP)',
+ type: 'cli',
+ command: 'opencode',
+ args: ['run'],
+ endpoint: 'http://127.0.0.1:8000/v1',
+ models: ['mtplx'],
+ defaultModel: 'mtplx',
+ mtplxBacked: true,
+ timeout: 600000,
+ enabled: false,
+ envVars: { OPENCODE_CONFIG_CONTENT },
+ secretEnvVars: [],
+ headlessArgs: [],
+};
+
+const OPENCODE_MTPLX_TUI = {
+ id: 'opencode-mtplx-tui',
+ name: 'OpenCode MTPLX TUI (local MTP)',
+ type: 'tui',
+ command: 'opencode',
+ args: [],
+ endpoint: 'http://127.0.0.1:8000/v1',
+ models: ['mtplx'],
+ defaultModel: 'mtplx',
+ mtplxBacked: true,
+ timeout: 600000,
+ enabled: false,
+ envVars: { OPENCODE_CONFIG_CONTENT },
+ secretEnvVars: [],
+ tuiPromptDelayMs: 2500,
+ tuiIdleTimeoutMs: 180000,
+};
+
+export default makeProviderSeedMigration({
+ label: 'MTPLX',
+ defs: [MTPLX_API, OPENCODE_MTPLX_CLI, OPENCODE_MTPLX_TUI],
+});
diff --git a/scripts/migrations/272-mtplx-providers.test.js b/scripts/migrations/272-mtplx-providers.test.js
new file mode 100644
index 0000000000..7240e84733
--- /dev/null
+++ b/scripts/migrations/272-mtplx-providers.test.js
@@ -0,0 +1,86 @@
+/**
+ * Test for migration 272 — add MTPLX provider presets to existing installs.
+ * The shared idempotent write shell is asserted in _lib.test.js; this test pins
+ * migration 272's frozen payload and its disabled-by-default contract.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+
+import migration from './272-mtplx-providers.js';
+
+const writeJson = (path, value) => writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
+const readJson = (path) => JSON.parse(readFileSync(path, 'utf-8'));
+
+describe('migration 272 — MTPLX providers', () => {
+ let rootDir;
+ let providersPath;
+
+ beforeEach(() => {
+ rootDir = mkdtempSync(join(tmpdir(), 'migration-272-'));
+ mkdirSync(join(rootDir, 'data'), { recursive: true });
+ providersPath = join(rootDir, 'data/providers.json');
+ });
+
+ afterEach(() => {
+ rmSync(rootDir, { recursive: true, force: true });
+ });
+
+ it('adds disabled API, OpenCode CLI, and OpenCode TUI presets without changing existing state', async () => {
+ writeJson(providersPath, {
+ activeProvider: 'claude-code',
+ providers: { 'claude-code': { id: 'claude-code', type: 'cli', command: 'claude' } },
+ });
+
+ await migration.up({ rootDir });
+
+ const out = readJson(providersPath);
+ const api = out.providers.mtplx;
+ const cli = out.providers['opencode-mtplx'];
+ const tui = out.providers['opencode-mtplx-tui'];
+
+ expect(api).toMatchObject({
+ type: 'api',
+ endpoint: 'http://127.0.0.1:8000/v1',
+ models: ['mtplx'],
+ defaultModel: 'mtplx',
+ enabled: false,
+ });
+ expect(cli).toMatchObject({
+ type: 'cli',
+ command: 'opencode',
+ args: ['run'],
+ mtplxBacked: true,
+ enabled: false,
+ });
+ expect(tui).toMatchObject({
+ type: 'tui',
+ command: 'opencode',
+ mtplxBacked: true,
+ tuiPromptDelayMs: 2500,
+ tuiIdleTimeoutMs: 180000,
+ enabled: false,
+ });
+
+ for (const provider of [cli, tui]) {
+ const config = JSON.parse(provider.envVars.OPENCODE_CONFIG_CONTENT);
+ expect(config.provider.mtplx).toMatchObject({
+ npm: '@ai-sdk/openai-compatible',
+ options: { baseURL: 'http://127.0.0.1:8000/v1' },
+ });
+ }
+
+ expect(out.providers['claude-code']).toBeDefined();
+ expect(out.activeProvider).toBe('claude-code');
+ });
+
+ it('preserves an existing MTPLX provider instead of replacing its local edits', async () => {
+ const existing = { id: 'mtplx', name: 'My MTPLX', type: 'api', endpoint: 'http://127.0.0.1:9000/v1', enabled: true };
+ writeJson(providersPath, { providers: { mtplx: existing } });
+
+ await migration.up({ rootDir });
+
+ expect(readJson(providersPath).providers.mtplx).toEqual(existing);
+ });
+});
diff --git a/server/lib/README.md b/server/lib/README.md
index 4e36c77ce5..f5b65a03ae 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -125,9 +125,9 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `modelPricing.js` | Per-model API billing rates for the /devtools/usage cost estimates — `resolveModelRates(providerId, model)` (exact → family regex → provider default → blended fallback, with a `matched` tier; also derives `cacheReadPer1M`/`cacheWritePer1M` from the input rate via per-family multipliers), `isFreeProvider` (ollama/lmstudio/`ollamaBacked`/localhost = free), `estimateCostUsd(tokensIn, tokensOut, rates, cache?)` — `tokensIn` is UNCACHED input; cache tiers are priced separately via the optional 4th arg — and `PRICING_AS_OF`. Informational only (PortOS runs on subscriptions); still excludes batch/long-context tiers. |
| `usageRange.js` | `resolveUsageRange({ period, from, to })` — pure period→inclusive-YYYY-MM-DD range resolution for the usage cost report (explicit dates win; `all` unbounded; default 7d). |
| `subscriptionSavings.js` | Subscription-vs-API savings math for the usage page — `resolveSavingsWindow` (clamps an open-ended report range to today / first activity day), `prorateMonthlyCost` (monthly plan price → this window's share, `DAYS_PER_MONTH`, capped by `MAX_MONTHLY_COST`), `savingsPercent` / `costMultiplier` (null, never 0, when the comparison is undefined), `attributeReportCostToFamilies` (groups report rows by their stamped `family`), `roundCents` (the one money rounder), and `buildSubscriptionSavings({ entries, range, unmatchedApiCost })` → per-family rows + totals. Pure. |
-| `providerFamilies.js` | Subscription-quota FAMILY identity — `PROVIDER_FAMILIES` (`{ id, label, matches }` for claude/codex/agy/grok), `PROVIDER_FAMILY_IDS`, `familyLabel`, `familyForProvider(config)` → family id or null (Ollama-backed wrappers and API-only providers belong to none). The pure half of the registry `services/providerUsage.js` attaches quota `fetch`ers to, so cost attribution and route validation can ask "which plan is this provider on?" without importing the PTY-scrape graph. Distinct from `providerVendors.js`, which is argv-shaped and includes vendors with no subscription quota. |
+| `providerFamilies.js` | Subscription-quota FAMILY identity — `PROVIDER_FAMILIES` (`{ id, label, matches }` for claude/codex/agy/grok), `PROVIDER_FAMILY_IDS`, `familyLabel`, `familyForProvider(config)` → family id or null (local-runtime wrappers and API-only providers belong to none). The pure half of the registry `services/providerUsage.js` attaches quota `fetch`ers to, so cost attribution and route validation can ask "which plan is this provider on?" without importing the PTY-scrape graph. Distinct from `providerVendors.js`, which is argv-shaped and includes vendors with no subscription quota. |
| `providerTranscriptUsage.js` | Parsers for the real per-message token counts the coding CLIs write to disk (0 tokens to read) — `parseClaudeTranscript` (`~/.claude/projects//*.jsonl`), `parseCodexRollout` (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`), `claudeProjectSlug`, `totalTranscriptTokens`. Both de-duplicate a format hazard that otherwise inflates counts badly: Claude repeats one response across several lines sharing a `message.id`, and Codex's `total_token_usage` is cumulative and repeated. Each parser returns per-model buckets (`byModel`) plus the message keys it counted (`countedKeys`), and accepts an `exclude` set — that is what stops two overlapping PortOS runs from both billing the same messages. Tolerant of truncated (mid-write) files; consumed by `services/usageReconciler.js`. |
-| `opencodeConfig.js` | OpenCode config builder — `buildOpencodeEnvVars(provider, model)` builds dynamic `OPENCODE_CONFIG_CONTENT` declaring the models map under `provider.ollama.models` (bare ids) for Ollama-backed OpenCode providers. Fixes --model rejection. |
+| `opencodeConfig.js` | OpenCode config builder — `buildOpencodeEnvVars(provider, model)` builds dynamic `OPENCODE_CONFIG_CONTENT` declaring bare model ids under the selected local provider (`ollama` or `mtplx`) for marked OpenCode providers. Fixes --model rejection. |
| `cliChildEnv.js` | The one place the AI-CLI child environment is composed, replacing the hand-rolled copy every spawn site carried — which made each env-level fix an N-file sweep (#3194). `buildCliChildEnv({ baseEnv, before, provider, model, cwd, extra, guard })` returns a COMPLETE env for `spawn`: layers `baseEnv → before → provider.envVars → buildOpencodeEnvVars → extra`, pins `PWD` to `cwd`, strips `CLAUDECODE`, and (with `guard: true`) prepends the pm2 guard shim onto the final `PATH`. `composeProviderEnv({ before, provider, model, extra })` returns just the ordered provider layers, for sites that build a DELTA someone else bases and spawns (the CoS runner payload, a shell-session overlay). The two slots are not interchangeable: `before` sits UNDER `provider.envVars` (forgeTokenEnv/claudeSettingsEnv, so a provider override still wins), `extra` sits OVER it (TERM/COLORTERM for a PTY). `cliChildEnv.test.js` asserts the composed order per call site and **discovers** any new site that hand-rolls the tuple instead of calling these — so the call-site list stays in the test, not in prose here. |
| `cliProviderArgs.js` | Per-CLI argv conventions (`buildCliArgs`) for stdin prompt delivery — dependency-light extraction from runner.js so out-of-process callers (autofixer) can import it. |
| `cliProviderRun.js` | One-shot CLI provider invocation (`pickCliProvider` + `runCliProviderPrompt`) — lightweight path for the autofixer + calendar MCP sync to honor the configured provider/model. |
diff --git a/server/lib/aiToolkit/defaults/providers.sample.json b/server/lib/aiToolkit/defaults/providers.sample.json
index c6cf0270ad..3e3f03f210 100644
--- a/server/lib/aiToolkit/defaults/providers.sample.json
+++ b/server/lib/aiToolkit/defaults/providers.sample.json
@@ -112,6 +112,43 @@
"tuiPromptDelayMs": 2500,
"tuiIdleTimeoutMs": 180000
},
+ "opencode-mtplx": {
+ "id": "opencode-mtplx",
+ "name": "OpenCode MTPLX (local MTP)",
+ "type": "cli",
+ "command": "opencode",
+ "args": ["run"],
+ "endpoint": "http://127.0.0.1:8000/v1",
+ "models": ["mtplx"],
+ "defaultModel": "mtplx",
+ "mtplxBacked": true,
+ "timeout": 600000,
+ "enabled": false,
+ "envVars": {
+ "OPENCODE_CONFIG_CONTENT": "{\"permission\":\"allow\",\"provider\":{\"mtplx\":{\"npm\":\"@ai-sdk/openai-compatible\",\"name\":\"MTPLX (local MTP)\",\"options\":{\"baseURL\":\"http://127.0.0.1:8000/v1\"}}}}"
+ },
+ "secretEnvVars": [],
+ "headlessArgs": []
+ },
+ "opencode-mtplx-tui": {
+ "id": "opencode-mtplx-tui",
+ "name": "OpenCode MTPLX TUI (local MTP)",
+ "type": "tui",
+ "command": "opencode",
+ "args": [],
+ "endpoint": "http://127.0.0.1:8000/v1",
+ "models": ["mtplx"],
+ "defaultModel": "mtplx",
+ "mtplxBacked": true,
+ "timeout": 600000,
+ "enabled": false,
+ "envVars": {
+ "OPENCODE_CONFIG_CONTENT": "{\"permission\":\"allow\",\"provider\":{\"mtplx\":{\"npm\":\"@ai-sdk/openai-compatible\",\"name\":\"MTPLX (local MTP)\",\"options\":{\"baseURL\":\"http://127.0.0.1:8000/v1\"}}}}"
+ },
+ "secretEnvVars": [],
+ "tuiPromptDelayMs": 2500,
+ "tuiIdleTimeoutMs": 180000
+ },
"codex": {
"id": "codex",
"name": "Codex CLI",
@@ -280,6 +317,19 @@
"envVars": {},
"secretEnvVars": []
},
+ "mtplx": {
+ "id": "mtplx",
+ "name": "MTPLX (local MTP)",
+ "type": "api",
+ "endpoint": "http://127.0.0.1:8000/v1",
+ "apiKey": "",
+ "models": ["mtplx"],
+ "defaultModel": "mtplx",
+ "timeout": 300000,
+ "enabled": false,
+ "envVars": {},
+ "secretEnvVars": []
+ },
"grok": {
"id": "grok",
"name": "xAI Grok",
diff --git a/server/lib/aiToolkit/internal/modelFetchers.js b/server/lib/aiToolkit/internal/modelFetchers.js
index 2511809ce4..111a0fa0b6 100644
--- a/server/lib/aiToolkit/internal/modelFetchers.js
+++ b/server/lib/aiToolkit/internal/modelFetchers.js
@@ -60,6 +60,16 @@ export const MODEL_FETCHERS = [
tuiMatch: (p) => isOllamaBackedProvider(p),
fetch: '_fetchOllamaToolCapableModels',
},
+ {
+ key: 'mtplx',
+ // MTPLX exposes the selected native-MTP model through its OpenAI-compatible
+ // endpoint. Its OpenCode CLI/TUI variants need that endpoint probe rather
+ // than `opencode models`, which describes the harness instead of its local
+ // provider.
+ cliMatch: (p) => p?.mtplxBacked === true,
+ tuiMatch: (p) => p?.mtplxBacked === true,
+ fetch: '_fetchMtplxModels',
+ },
{
key: 'cursor',
// No `cliNameMatch` on purpose — see the column notes above.
diff --git a/server/lib/aiToolkit/internal/modelFetchers.test.js b/server/lib/aiToolkit/internal/modelFetchers.test.js
index a8ac7814a7..c3d38b2c74 100644
--- a/server/lib/aiToolkit/internal/modelFetchers.test.js
+++ b/server/lib/aiToolkit/internal/modelFetchers.test.js
@@ -17,8 +17,8 @@ const SHIPPED = JSON.parse(readFileSync(resolve(__dirname, '../../../../data.ref
const SHIPPED_REFRESHABLE = [
'antigravity-cli', 'antigravity-tui', 'cerebras', 'claude-code',
'claude-code-bedrock', 'claude-ollama', 'claude-ollama-tui', 'cursor-cli',
- 'cursor-tui', 'grok', 'lmstudio', 'nvidia-kimi', 'ollama', 'opencode-ollama',
- 'opencode-ollama-tui',
+ 'cursor-tui', 'grok', 'lmstudio', 'mtplx', 'nvidia-kimi', 'ollama',
+ 'opencode-mtplx', 'opencode-mtplx-tui', 'opencode-ollama', 'opencode-ollama-tui',
];
const SHIPPED_NOT_REFRESHABLE = [
'claude-code-tui', 'claude-code-tui-bedrock', 'codex', 'codex-tui',
@@ -124,6 +124,11 @@ describe('resolveModelFetcher — the ordering the old chains encoded in prose',
expect(resolveModelFetcher(p).fetch).toBe('_fetchOllamaToolCapableModels');
});
+ it('routes an MTPLX-backed OpenCode CLI to the MTPLX endpoint fetcher', () => {
+ const p = { id: 'opencode-mtplx', type: 'cli', command: 'opencode', mtplxBacked: true };
+ expect(resolveModelFetcher(p).fetch).toBe('_fetchMtplxModels');
+ });
+
it('lets a command beat a display name — a renamed cursor still reaches cursor-agent', () => {
// Renaming a cursor provider "Cursor Claude Opus" must not persist
// Anthropic ids that cursor-agent will reject.
@@ -175,6 +180,11 @@ describe('resolveModelFetcher — the TUI arm never consults the display name',
.toBe('_fetchCursorModels');
});
+ it('serves an MTPLX-backed OpenCode TUI from its local endpoint', () => {
+ expect(resolveModelFetcher({ id: 'opencode-mtplx-tui', type: 'tui', command: 'opencode', mtplxBacked: true }).fetch)
+ .toBe('_fetchMtplxModels');
+ });
+
it('admits a shipped TUI id repointed at a wrapper script', () => {
// An EXACT shipped-id match, never a name substring: it can only admit the
// provider PortOS itself seeds, and probing the user's wrapper is right.
diff --git a/server/lib/aiToolkit/providers.js b/server/lib/aiToolkit/providers.js
index 8c350e7a6f..9034b86445 100644
--- a/server/lib/aiToolkit/providers.js
+++ b/server/lib/aiToolkit/providers.js
@@ -546,6 +546,10 @@ export function createProviderService(config = {}) {
// Claude Ollama marker — preserve so adopting the sample via POST drives
// ollama-backed model refresh (see isOllamaBackedProvider).
...(providerData.ollamaBacked === true ? { ollamaBacked: true } : {}),
+ // MTPLX's native MTP runtime is a separate local OpenAI-compatible
+ // backend. Preserve this marker so OpenCode receives the `mtplx/`
+ // namespace and model refresh probes its local endpoint.
+ ...(providerData.mtplxBacked === true ? { mtplxBacked: true } : {}),
// Explicit opt-in to send the API key to an arbitrary (non-local,
// non-allowlisted) endpoint — see internal/endpointGuard.js. Only
// persisted when true so existing keyless/local providers stay clean.
@@ -761,8 +765,9 @@ export function createProviderService(config = {}) {
try {
// A TUI provider's model is normally fixed by its CLI/config, so only
// the vendors whose `--model` flag also applies to the interactive
- // session carry a `tuiMatch` column in the table (ollama-backed,
- // antigravity, cursor today). One lookup replaces the per-vendor
+ // session carry a `tuiMatch` column in the table (Ollama-backed,
+ // MTPLX-backed, Antigravity, Cursor today). One lookup replaces the
+ // per-vendor
// `else if` chain this used to be — see internal/modelFetchers.js.
const tuiFetcher = provider.type === 'tui' ? resolveModelFetcher(provider) : null;
@@ -1016,6 +1021,23 @@ export function createProviderService(config = {}) {
throw new Error('Model list response had no recognizable "data" or "models" array');
},
+ /**
+ * Fetch the catalog from a local MTPLX server for its OpenCode CLI/TUI
+ * wrappers. MTPLX publishes its active native-MTP model through the same
+ * OpenAI-compatible `/v1/models` contract as an API provider, so reuse the
+ * guarded generic parser instead of executing an OpenCode model-list command
+ * (which would inventory the harness, not the MTPLX runtime).
+ *
+ * This only runs from an explicit refresh request; seeding the disabled
+ * provider never starts MTPLX, downloads a model, or issues an LLM call.
+ *
+ * @param {object} provider
+ * @returns {Promise}
+ */
+ async _fetchMtplxModels(provider) {
+ return this._refreshAPIProviderModels(provider);
+ },
+
async _refreshCLIProviderModels(provider) {
// One lookup, not a per-vendor `if` chain. The table
// (internal/modelFetchers.js) also owns the ORDER this used to encode in
diff --git a/server/lib/aiToolkit/providers.test.js b/server/lib/aiToolkit/providers.test.js
index 25c2eea074..0d8a266bbd 100644
--- a/server/lib/aiToolkit/providers.test.js
+++ b/server/lib/aiToolkit/providers.test.js
@@ -139,6 +139,7 @@ describe('Provider Service', () => {
contextWindow: 1000000,
timeout: 600000,
enabled: false,
+ mtplxBacked: true,
envVars: { OPENAI_BASE_URL: 'https://example.com', LOG_LEVEL: 'debug' },
secretEnvVars: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'],
headlessArgs: ['--quiet', '--no-color'],
@@ -1177,6 +1178,38 @@ describe('Provider Service', () => {
});
});
+ describe('MTPLX model refresh', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('uses the local OpenAI-compatible model endpoint for both OpenCode modes', async () => {
+ const fetchSpy = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ data: [{ id: 'mtplx' }, { id: 'qwen3.8-mtp' }] }),
+ });
+ vi.stubGlobal('fetch', fetchSpy);
+
+ for (const [name, type] of [['OpenCode MTPLX', 'cli'], ['OpenCode MTPLX TUI', 'tui']]) {
+ const provider = await providerService.createProvider({
+ name,
+ type,
+ command: 'opencode',
+ endpoint: 'http://127.0.0.1:8000/v1',
+ mtplxBacked: true,
+ models: ['stale-model'],
+ });
+ const updated = await providerService.refreshProviderModels(provider.id);
+ expect(updated.models).toEqual(['mtplx', 'qwen3.8-mtp']);
+ }
+
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ for (const [url] of fetchSpy.mock.calls) {
+ expect(url).toBe('http://127.0.0.1:8000/v1/models');
+ }
+ });
+ });
+
describe('_refreshAPIProviderModels — network layer', () => {
afterEach(() => {
vi.unstubAllGlobals();
diff --git a/server/lib/aiToolkit/validation.js b/server/lib/aiToolkit/validation.js
index a4f5ecbc6f..8001343692 100644
--- a/server/lib/aiToolkit/validation.js
+++ b/server/lib/aiToolkit/validation.js
@@ -96,6 +96,10 @@ export const providerSchema = z.object({
// local Ollama daemon — the "Claude Ollama" pattern. Drives model refresh to
// pull tool-use-capable Ollama models instead of the static Anthropic list.
ollamaBacked: z.boolean().optional(),
+ // Marks an OpenCode CLI/TUI wrapper for a separately started MTPLX native-MTP
+ // server. This is intentionally distinct from `ollamaBacked`: model weights
+ // and runtime protocol configuration are not interchangeable.
+ mtplxBacked: z.boolean().optional(),
// Explicit opt-in to attach the provider's API key to an arbitrary
// (non-local, non-allowlisted) endpoint. Guards against SSRF / key
// exfiltration to a hostile or mistyped host — see
diff --git a/server/lib/aiToolkit/validation.test.js b/server/lib/aiToolkit/validation.test.js
index 459b676aad..d435f59ae9 100644
--- a/server/lib/aiToolkit/validation.test.js
+++ b/server/lib/aiToolkit/validation.test.js
@@ -71,6 +71,11 @@ describe('providerSchema', () => {
expect(providerSchema.safeParse(minimalProvider).success).toBe(true);
});
+ it('accepts the explicit MTPLX marker and rejects a non-boolean value', () => {
+ expect(providerSchema.safeParse({ ...minimalProvider, mtplxBacked: true }).success).toBe(true);
+ expect(providerSchema.safeParse({ ...minimalProvider, mtplxBacked: 'true' }).success).toBe(false);
+ });
+
describe('endpoint empty-string/null → undefined coercion', () => {
it('coerces endpoint: "" to undefined so the URL check is skipped for CLI providers', () => {
const r = providerSchema.safeParse({ ...minimalProvider, endpoint: '' });
diff --git a/server/lib/cliChildEnv.js b/server/lib/cliChildEnv.js
index 216b671b9e..675bafb328 100644
--- a/server/lib/cliChildEnv.js
+++ b/server/lib/cliChildEnv.js
@@ -54,7 +54,7 @@ import { agentGuardEnv } from './agentGuard/index.js';
* @param {object} options
* @param {object|null} [options.before] - layered first, so `provider.envVars`
* overrides it (forgeTokenEnv, claudeSettingsEnv).
- * @param {{command?:string, envVars?:object, models?:string[], defaultModel?:string|null, ollamaBacked?:boolean}|null} [options.provider]
+ * @param {{command?:string, envVars?:object, models?:string[], defaultModel?:string|null, ollamaBacked?:boolean, mtplxBacked?:boolean}|null} [options.provider]
* @param {string|null} [options.model] - the model being run this invocation,
* unioned into the OpenCode declared-models map. Omit when the site has no
* per-call model — `provider.defaultModel` is always declared regardless.
@@ -67,8 +67,8 @@ export function composeProviderEnv({ before = null, provider = null, model = nul
...(before || {}),
...(provider?.envVars || {}),
// Rebuilds OPENCODE_CONFIG_CONTENT with a declared models map for OpenCode
- // Ollama providers (an empty object for everyone else) so the injected
- // `--model ollama/` isn't rejected as "not valid" — see #2190. It lands
+ // local providers (an empty object for everyone else) so the injected
+ // namespaced `--model` isn't rejected as "not valid" — see #2190. It lands
// after provider.envVars to override the provider's STATIC
// OPENCODE_CONFIG_CONTENT, which it was built from.
...buildOpencodeEnvVars(provider, model),
diff --git a/server/lib/cliChildEnv.test.js b/server/lib/cliChildEnv.test.js
index ca29be7d4d..14b7531c3a 100644
--- a/server/lib/cliChildEnv.test.js
+++ b/server/lib/cliChildEnv.test.js
@@ -17,6 +17,7 @@ const OLLAMA_OPENCODE = {
};
const declaredModels = (env) => Object.keys(JSON.parse(env.OPENCODE_CONFIG_CONTENT).provider.ollama.models);
+const declaredMtplxModels = (env) => Object.keys(JSON.parse(env.OPENCODE_CONFIG_CONTENT).provider.mtplx.models);
describe('buildCliChildEnv — layering', () => {
it('layers baseEnv < before < provider.envVars < extra', () => {
@@ -57,6 +58,16 @@ describe('buildCliChildEnv — layering', () => {
expect(JSON.parse(env.OPENCODE_CONFIG_CONTENT).permission).toBe('deny');
});
+ it('declares the MTPLX models map for a marked OpenCode provider', () => {
+ const env = buildCliChildEnv({
+ baseEnv: {},
+ provider: { command: 'opencode', mtplxBacked: true, models: ['mtplx'], envVars: {} },
+ model: 'mtplx',
+ });
+ expect(declaredMtplxModels(env)).toEqual(['mtplx']);
+ expect(JSON.parse(env.OPENCODE_CONFIG_CONTENT).provider.mtplx.options.baseURL).toBe('http://127.0.0.1:8000/v1');
+ });
+
it('is a no-op OpenCode layer for a non-OpenCode provider', () => {
const env = buildCliChildEnv({ baseEnv: {}, provider: { command: 'claude', envVars: { A: '1' } }, model: 'opus' });
expect(env.OPENCODE_CONFIG_CONTENT).toBeUndefined();
diff --git a/server/lib/modelPricing.js b/server/lib/modelPricing.js
index ff766f24f8..898524ca22 100644
--- a/server/lib/modelPricing.js
+++ b/server/lib/modelPricing.js
@@ -287,7 +287,7 @@ export function isFreeModelId(model) {
/**
* True when a provider's usage is free — local inference (Ollama, LM Studio,
- * any `ollamaBacked` CLI wrapper, or an API provider pointed at localhost).
+ * any Ollama-/MTPLX-backed CLI wrapper, or an API provider pointed at localhost).
* Accepts a provider config object or a bare provider-id string (usage records
* can outlive their provider config).
* @param {object|string|null|undefined} providerOrId
@@ -297,7 +297,7 @@ export function isFreeProvider(providerOrId) {
if (providerOrId == null) return false;
if (typeof providerOrId === 'string') return FREE_ID.test(providerOrId);
const p = providerOrId;
- if (p.ollamaBacked === true) return true;
+ if (p.ollamaBacked === true || p.mtplxBacked === true) return true;
if (FREE_ID.test(p.id || '') || FREE_ID.test(p.command || '')) return true;
if (typeof p.endpoint === 'string' && LOCALHOST_ENDPOINT.test(p.endpoint.trim())) return true;
return false;
diff --git a/server/lib/modelPricing.test.js b/server/lib/modelPricing.test.js
index 9449aeb8a1..1f54eb3b36 100644
--- a/server/lib/modelPricing.test.js
+++ b/server/lib/modelPricing.test.js
@@ -152,6 +152,10 @@ describe('isFreeProvider', () => {
expect(isFreeProvider({ id: 'claude-ollama', ollamaBacked: true, command: 'claude' })).toBe(true);
});
+ it('classifies MTPLX-backed OpenCode wrappers as free', () => {
+ expect(isFreeProvider({ id: 'opencode-mtplx', mtplxBacked: true, command: 'opencode' })).toBe(true);
+ });
+
it('classifies localhost API endpoints as free', () => {
expect(isFreeProvider({ id: 'my-local', type: 'api', endpoint: 'http://localhost:1234/v1' })).toBe(true);
expect(isFreeProvider({ id: 'my-local', type: 'api', endpoint: 'http://127.0.0.1:11434/v1' })).toBe(true);
diff --git a/server/lib/opencodeConfig.js b/server/lib/opencodeConfig.js
index 3b7aca58e5..525698bc7f 100644
--- a/server/lib/opencodeConfig.js
+++ b/server/lib/opencodeConfig.js
@@ -3,7 +3,8 @@
*
* OpenCode (the CLI) requires every model addressable via `--model` to be
* declared in its config. For a custom `@ai-sdk/openai-compatible` provider
- * (how we wire the local Ollama daemon), models live UNDER the provider entry
+ * (how we wire the local Ollama daemon and MTPLX), models live UNDER the
+ * provider entry
* as `provider..models.` — there is no top-level `models` map
* in OpenCode's schema, and the keys are the BARE model id (the part after the
* `provider/` namespace), NOT the `ollama/`-prefixed form passed to `--model`.
@@ -14,77 +15,94 @@
* agent produced zero output, and the idle reaper marked it complete (issue
* -2190). This module builds the config dynamically at spawn time, declaring the
* provider's configured models (+ the model being run) under
- * `provider.ollama.models` with bare ids.
+ * `provider..models` with bare ids.
*/
-import { isOpencodeCommand } from './providerModels.js';
+import { getOpencodeLocalProviderNamespace, isOpencodeCommand } from './providerModels.js';
/**
- * Base OpenCode provider entry for the local Ollama daemon (openai-compatible).
- * The per-run models map is added under this by `buildOpencodeConfig`.
+ * Base OpenCode provider entries for the local OpenAI-compatible daemons.
+ * The per-run models map is added under the selected entry by
+ * `buildOpencodeConfig`.
*/
-const OPENCODE_OLLAMA_BASE_PROVIDER = {
- npm: '@ai-sdk/openai-compatible',
- name: 'Ollama (local)',
- options: { baseURL: 'http://localhost:11434/v1' },
+const OPENCODE_LOCAL_BASE_PROVIDERS = {
+ ollama: {
+ npm: '@ai-sdk/openai-compatible',
+ name: 'Ollama (local)',
+ options: { baseURL: 'http://localhost:11434/v1' },
+ },
+ mtplx: {
+ npm: '@ai-sdk/openai-compatible',
+ name: 'MTPLX (local MTP)',
+ options: { baseURL: 'http://127.0.0.1:8000/v1' },
+ },
};
-// Strip a leading `ollama/` namespace so a model id can key the config `models`
-// map (which lives under the `ollama` provider — keys are bare ids). Idempotent
-// for an already-bare id. A `/`-bearing Ollama id (`hf.co/user/model:tag`)
-// namespaced as `ollama/hf.co/...` strips back to the correct bare key since
-// only the leading namespace is removed.
-const stripOllamaPrefix = (id) =>
- typeof id === 'string' && id.startsWith('ollama/') ? id.slice('ollama/'.length) : id;
+const localProviderBase = (providerKey) => {
+ if (!Object.hasOwn(OPENCODE_LOCAL_BASE_PROVIDERS, providerKey)) {
+ throw new Error(`Unsupported OpenCode local provider '${providerKey}'`);
+ }
+ return OPENCODE_LOCAL_BASE_PROVIDERS[providerKey];
+};
+
+// Strip the selected provider namespace so a model id can key that provider's
+// config `models` map. Idempotent for an already-bare id. A slash-bearing model
+// id (`hf.co/user/model:tag`) retains every slash after the leading namespace.
+const stripProviderPrefix = (id, providerKey) =>
+ typeof id === 'string' && id.startsWith(`${providerKey}/`)
+ ? id.slice(providerKey.length + 1)
+ : id;
/**
* Normalize an id or list of ids to the unique, non-empty, prefix-stripped bare
* model ids that key the OpenCode `models` map.
* @param {string|string[]|null|undefined} models
+ * @param {'ollama'|'mtplx'} [providerKey='ollama']
* @returns {string[]}
*/
-export function toBareModelIds(models) {
+export function toBareModelIds(models, providerKey = 'ollama') {
+ localProviderBase(providerKey);
const list = Array.isArray(models) ? models : [models];
return [...new Set(
list
.filter((m) => typeof m === 'string' && m.length > 0)
- .map(stripOllamaPrefix)
+ .map((id) => stripProviderPrefix(id, providerKey))
.filter((m) => typeof m === 'string' && m.length > 0),
)];
}
/**
- * Build an OpenCode config object declaring the given models under the Ollama
- * provider. Accepts a single id or a list (bare or `ollama/`-prefixed — both are
- * normalized to bare keys) and, optionally, a `base` config to merge into
- * (typically the provider's already-stored `OPENCODE_CONFIG_CONTENT`, parsed).
+ * Build an OpenCode config object declaring the given models under the selected
+ * local provider. Accepts a single id or a list (bare or namespace-prefixed —
+ * both are normalized to bare keys) and, optionally, a `base` config to merge
+ * into (typically the provider's already-stored `OPENCODE_CONFIG_CONTENT`, parsed).
*
- * The base is PRESERVED, not replaced: a custom `permission`, a custom Ollama
- * `baseURL`, extra provider keys, and any hand-maintained
- * `provider.ollama.models` entries all survive — this call only unions the given
- * models into `provider.ollama.models`. When no usable id is provided the base
- * is returned unchanged (no `models` key is invented), identical to the shipped
- * base — no regression. When `base` is absent/unusable, the canonical
- * localhost-Ollama default is used.
+ * The base is PRESERVED, not replaced: a custom `permission`, a custom local
+ * `baseURL`, extra provider keys, and any hand-maintained models entries all
+ * survive — this call only unions the given models into the selected provider.
+ * When no usable id is provided the base is returned unchanged (no `models` key
+ * is invented), identical to the shipped base. When `base` is absent/unusable,
+ * the canonical endpoint for the selected local runtime is used.
*
* @param {string|string[]|null|undefined} models
* @param {object|null} [base] - existing config to merge into (a fresh clone is made)
+ * @param {'ollama'|'mtplx'} [providerKey='ollama']
* @returns {object} OpenCode config object
*/
-export function buildOpencodeConfig(models, base = null) {
- const bareIds = toBareModelIds(models);
+export function buildOpencodeConfig(models, base = null, providerKey = 'ollama') {
+ const bareIds = toBareModelIds(models, providerKey);
const config = (base && typeof base === 'object')
? structuredClone(base)
: { permission: 'allow', provider: {} };
if (!config.provider || typeof config.provider !== 'object') config.provider = {};
- if (!config.provider.ollama || typeof config.provider.ollama !== 'object') {
- config.provider.ollama = { ...OPENCODE_OLLAMA_BASE_PROVIDER };
+ if (!config.provider[providerKey] || typeof config.provider[providerKey] !== 'object') {
+ config.provider[providerKey] = structuredClone(localProviderBase(providerKey));
}
if (bareIds.length > 0) {
- const existing = (config.provider.ollama.models && typeof config.provider.ollama.models === 'object')
- ? config.provider.ollama.models
+ const existing = (config.provider[providerKey].models && typeof config.provider[providerKey].models === 'object')
+ ? config.provider[providerKey].models
: {};
- config.provider.ollama.models = {
+ config.provider[providerKey].models = {
...existing,
...Object.fromEntries(bareIds.map((id) => [id, { name: id, tool_call: true }])),
};
@@ -94,34 +112,38 @@ export function buildOpencodeConfig(models, base = null) {
/**
* Build the `OPENCODE_CONFIG_CONTENT` env var value (JSON string) declaring the
- * given models under the Ollama provider, merging into `base` when provided.
+ * given models under the selected local provider, merging into `base` when
+ * provided.
*
* @param {string|string[]|null|undefined} models
* @param {object|null} [base] - existing config to merge into
+ * @param {'ollama'|'mtplx'} [providerKey='ollama']
* @returns {string} JSON string for OPENCODE_CONFIG_CONTENT
*/
-export function buildOpencodeConfigContent(models, base = null) {
- return JSON.stringify(buildOpencodeConfig(models, base));
+export function buildOpencodeConfigContent(models, base = null, providerKey = 'ollama') {
+ return JSON.stringify(buildOpencodeConfig(models, base, providerKey));
}
/**
- * Build dynamic env vars for an OpenCode Ollama provider spawn. Returns an
- * object with `OPENCODE_CONFIG_CONTENT` (models map declared) for Ollama-backed
- * OpenCode providers, otherwise an empty object (caller keeps existing env).
+ * Build dynamic env vars for an OpenCode local-provider spawn. Returns an
+ * object with `OPENCODE_CONFIG_CONTENT` (models map declared) for Ollama- or
+ * MTPLX-backed OpenCode providers, otherwise an empty object (caller keeps
+ * existing env).
*
* The provider's already-stored `OPENCODE_CONFIG_CONTENT` is used as the base and
* PRESERVED — a customized `baseURL`, `permission`, or hand-maintained models
- * survive; this only unions the runtime models into `provider.ollama.models`. The
+ * survive; this only unions the runtime models into the selected provider. The
* declared models are the union of the provider's configured models, its default
- * model, and the model being run this invocation — so whichever
- * `--model ollama/` the spawner passes is always accepted.
+ * model, and the model being run this invocation — so whichever namespaced
+ * `--model` the spawner passes is always accepted.
*
- * @param {{command?:string, ollamaBacked?:boolean, models?:string[], defaultModel?:string|null, envVars?:object}} provider
+ * @param {{command?:string, ollamaBacked?:boolean, mtplxBacked?:boolean, models?:string[], defaultModel?:string|null, envVars?:object}} provider
* @param {string|null|undefined} model - the model being run (may differ from defaultModel)
* @returns {{OPENCODE_CONFIG_CONTENT?: string}} env vars to merge
*/
export function buildOpencodeEnvVars(provider, model) {
- if (!isOpencodeCommand(provider?.command) || provider?.ollamaBacked !== true) {
+ const providerKey = getOpencodeLocalProviderNamespace(provider);
+ if (!isOpencodeCommand(provider?.command) || !providerKey) {
return {};
}
// Parse the provider's stored config as the base so any user customization
@@ -142,6 +164,6 @@ export function buildOpencodeEnvVars(provider, model) {
model,
];
return {
- OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent(ids, base),
+ OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent(ids, base, providerKey),
};
}
diff --git a/server/lib/opencodeConfig.test.js b/server/lib/opencodeConfig.test.js
index 4ca8b7fa3f..223bcfd465 100644
--- a/server/lib/opencodeConfig.test.js
+++ b/server/lib/opencodeConfig.test.js
@@ -19,6 +19,15 @@ describe('toBareModelIds', () => {
it('keeps a slash-bearing id intact after stripping only the leading namespace', () => {
expect(toBareModelIds('ollama/hf.co/user/model:tag')).toEqual(['hf.co/user/model:tag']);
});
+
+ it('strips the MTPLX namespace without treating slash-bearing ids as providers', () => {
+ expect(toBareModelIds(['mtplx/mtplx', 'mtplx/qwen/model'], 'mtplx'))
+ .toEqual(['mtplx', 'qwen/model']);
+ });
+
+ it('rejects an unknown local provider key instead of silently using Ollama', () => {
+ expect(() => toBareModelIds('model', 'unknown')).toThrow(/unsupported opencode local provider/i);
+ });
});
describe('buildOpencodeConfig', () => {
@@ -75,6 +84,19 @@ describe('buildOpencodeConfig', () => {
// does not mutate the caller's base object
expect(base.provider.ollama.models['qwen2.5:7b']).toBeUndefined();
});
+
+ it('declares MTPLX models under provider.mtplx with its local endpoint', () => {
+ const cfg = buildOpencodeConfig('mtplx/mtplx', null, 'mtplx');
+ expect(cfg.provider.ollama).toBeUndefined();
+ expect(cfg.provider.mtplx).toMatchObject({
+ npm: '@ai-sdk/openai-compatible',
+ name: 'MTPLX (local MTP)',
+ options: { baseURL: 'http://127.0.0.1:8000/v1' },
+ });
+ expect(cfg.provider.mtplx.models).toEqual({
+ mtplx: { name: 'mtplx', tool_call: true },
+ });
+ });
});
describe('buildOpencodeConfigContent', () => {
@@ -114,6 +136,13 @@ describe('buildOpencodeEnvVars', () => {
expect(cfg.provider.ollama.models['qwen2.5:7b']).toEqual({ name: 'qwen2.5:7b', tool_call: true });
});
+ it('declares the run model under provider.mtplx.models for MTPLX-backed OpenCode', () => {
+ const result = buildOpencodeEnvVars({ command: 'opencode', mtplxBacked: true, models: ['mtplx'] }, 'mtplx');
+ const cfg = JSON.parse(result.OPENCODE_CONFIG_CONTENT);
+ expect(cfg.provider.mtplx.options.baseURL).toBe('http://127.0.0.1:8000/v1');
+ expect(cfg.provider.mtplx.models.mtplx).toEqual({ name: 'mtplx', tool_call: true });
+ });
+
it('unions the provider models, defaultModel, and the run model (deduped, bare)', () => {
const provider = {
command: 'opencode', ollamaBacked: true,
diff --git a/server/lib/providerFamilies.js b/server/lib/providerFamilies.js
index 9b2be3f7e8..16e2d0ba9f 100644
--- a/server/lib/providerFamilies.js
+++ b/server/lib/providerFamilies.js
@@ -18,8 +18,8 @@ import { isGrokCommand } from './grok.js';
/**
* A provider config belongs to at most one family. CLI/TUI commands are matched
* by binary basename; the Grok/Kimi-style API providers by id or endpoint.
- * Ollama-backed CLI wrappers are local/free and have no subscription quota, so
- * they map to no family (see `familyForProvider`).
+ * Local-runtime CLI wrappers (Ollama or MTPLX) are local/free and have no
+ * subscription quota, so they map to no family (see `familyForProvider`).
*/
export const PROVIDER_FAMILIES = [
{
@@ -52,11 +52,11 @@ export const familyLabel = (id) => PROVIDER_FAMILIES.find((f) => f.id === id)?.l
/**
* The family id a single provider config belongs to, or null for one that
- * belongs to none (an Ollama-backed wrapper, a pay-as-you-go API provider).
+ * belongs to none (a local-runtime wrapper, a pay-as-you-go API provider).
* The inverse of `resolveEnabledFamilies`, so cost reporting can attribute a
* provider's spend to the subscription that actually covered it.
*/
export function familyForProvider(provider) {
- if (!provider || provider.ollamaBacked === true) return null;
+ if (!provider || provider.ollamaBacked === true || provider.mtplxBacked === true) return null;
return PROVIDER_FAMILIES.find((f) => f.matches(provider))?.id ?? null;
}
diff --git a/server/lib/providerModels.js b/server/lib/providerModels.js
index 450b8007a4..f53bcce8fe 100644
--- a/server/lib/providerModels.js
+++ b/server/lib/providerModels.js
@@ -425,19 +425,34 @@ export function isOpencodeCommand(command) {
* (`hf.co/user/model:tag`) is namespaced as `ollama/hf.co/...` since OpenCode
* splits provider/model on the FIRST slash only.
*
- * Gated on the `ollamaBacked` marker, NOT just `command === 'opencode'`: a
+ * Gated on a local-runtime marker, NOT just `command === 'opencode'`: a
* user-configured OpenCode provider pointed at a different backend stores an
* already-qualified id (`openai/gpt-4o`, `anthropic/claude-sonnet`), and blindly
* prefixing `ollama/` would route it to the wrong backend. No-op for
- * non-Ollama-backed / non-OpenCode providers and empty models.
- * @param {{command?:string, ollamaBacked?:boolean}} provider
+ * non-local / non-OpenCode providers and empty models.
+ * @param {{command?:string, ollamaBacked?:boolean, mtplxBacked?:boolean}} provider
* @param {string|null|undefined} model
* @returns {string|null|undefined}
*/
export function prefixOpencodeModel(provider, model) {
- if (!isOpencodeCommand(provider?.command) || provider?.ollamaBacked !== true || !model) return model;
+ const namespace = getOpencodeLocalProviderNamespace(provider);
+ if (!isOpencodeCommand(provider?.command) || !namespace || !model) return model;
const id = String(model);
- return id.startsWith('ollama/') ? id : `ollama/${id}`;
+ return id.startsWith(`${namespace}/`) ? id : `${namespace}/${id}`;
+}
+
+/**
+ * OpenCode's local OpenAI-compatible provider namespace, if this provider has
+ * opted into one. Structural markers avoid deriving a backend from an editable
+ * display name or endpoint and preserve the legacy Ollama outcome if a malformed
+ * record carries both markers.
+ * @param {{ollamaBacked?:boolean, mtplxBacked?:boolean}|null|undefined} provider
+ * @returns {'ollama'|'mtplx'|null}
+ */
+export function getOpencodeLocalProviderNamespace(provider) {
+ if (provider?.ollamaBacked === true) return 'ollama';
+ if (provider?.mtplxBacked === true) return 'mtplx';
+ return null;
}
/**
@@ -575,7 +590,7 @@ export function resolveBedrockCliModel(id, { env = process.env, providerId } = {
* every shipped provider.)
*
* @param {string} model - the already-resolved (non-sentinel) model id
- * @param {{id?:string, command?:string, ollamaBacked?:boolean, envVars?:Record}|null|undefined} provider
+ * @param {{id?:string, command?:string, ollamaBacked?:boolean, mtplxBacked?:boolean, envVars?:Record}|null|undefined} provider
* @param {string} [command] - resolved launch command (may differ from provider.command)
* @returns {string}
*/
diff --git a/server/lib/providerModels.test.js b/server/lib/providerModels.test.js
index 7d15fec059..e76becf48a 100644
--- a/server/lib/providerModels.test.js
+++ b/server/lib/providerModels.test.js
@@ -15,6 +15,7 @@ import {
toBedrockModelId,
resolveBedrockCliModel,
prefixOpencodeModel,
+ getOpencodeLocalProviderNamespace,
isOpencodeCommand,
isClaudeCommand,
isOllamaClaudeProvider,
@@ -132,6 +133,12 @@ describe('providerModels', () => {
expect(prefixOpencodeModel(oc, 'qwen2.5:7b')).toBe('ollama/qwen2.5:7b');
});
+ it('namespaces a bare MTPLX id under mtplx/ for MTPLX-backed OpenCode providers', () => {
+ const mtplx = { command: 'opencode', mtplxBacked: true };
+ expect(prefixOpencodeModel(mtplx, 'mtplx')).toBe('mtplx/mtplx');
+ expect(prefixOpencodeModel(mtplx, 'mtplx/mtplx')).toBe('mtplx/mtplx');
+ });
+
it('is idempotent — an already-namespaced id is returned unchanged', () => {
expect(prefixOpencodeModel(oc, 'ollama/qwen2.5:7b')).toBe('ollama/qwen2.5:7b');
});
@@ -160,6 +167,15 @@ describe('providerModels', () => {
});
});
+ describe('getOpencodeLocalProviderNamespace', () => {
+ it('uses explicit markers and keeps Ollama as the malformed dual-marker fallback', () => {
+ expect(getOpencodeLocalProviderNamespace({ ollamaBacked: true })).toBe('ollama');
+ expect(getOpencodeLocalProviderNamespace({ mtplxBacked: true })).toBe('mtplx');
+ expect(getOpencodeLocalProviderNamespace({ ollamaBacked: true, mtplxBacked: true })).toBe('ollama');
+ expect(getOpencodeLocalProviderNamespace({})).toBeNull();
+ });
+ });
+
describe('resolveCliModel', () => {
it('returns null for configured-default sentinels so --model is omitted', () => {
expect(resolveCliModel(CODEX_CONFIGURED_DEFAULT)).toBeNull();
@@ -756,6 +772,11 @@ describe('providerModels', () => {
expect(resolveInjectedTuiModel('qwen2.5:7b', provider, 'opencode')).toBe('ollama/qwen2.5:7b');
});
+ it('namespaces an MTPLX id for OpenCode TUI and never Bedrock-maps it', () => {
+ const provider = { id: 'opencode-mtplx-tui', command: 'opencode', mtplxBacked: true };
+ expect(resolveInjectedTuiModel('mtplx', provider, 'opencode')).toBe('mtplx/mtplx');
+ });
+
// The regression this helper exists for: cursor labels Anthropic models with
// its OWN ids, which match toBedrockModelId's /claude/i gate.
it('passes a cursor model through verbatim on a Bedrock box', () => {
diff --git a/server/lib/validation.js b/server/lib/validation.js
index e6d08d8a89..d0341e0702 100644
--- a/server/lib/validation.js
+++ b/server/lib/validation.js
@@ -407,6 +407,9 @@ export const providerSchema = z.object({
defaultModel: z.string().nullable().optional(),
timeout: z.number().int().min(AI_RUN_TIMEOUT_MIN_MS).max(AI_RUN_TIMEOUT_MAX_MS).optional(),
enabled: z.boolean().optional(),
+ // Kept in schema parity with aiToolkit's provider schema. Marks OpenCode
+ // wrappers for a separately started local MTPLX native-MTP server.
+ mtplxBacked: z.boolean().optional(),
// Explicit opt-in to attach the API key to an arbitrary (non-local,
// non-allowlisted) endpoint — mirrors the aiToolkit providerSchema. Guards
// SSRF / key exfiltration (server/lib/aiToolkit/internal/endpointGuard.js).
diff --git a/server/lib/validation.test.js b/server/lib/validation.test.js
index 05c9cf9a71..a87c63c209 100644
--- a/server/lib/validation.test.js
+++ b/server/lib/validation.test.js
@@ -638,6 +638,11 @@ describe('validation.js', () => {
const result = providerSchema.safeParse(provider);
expect(result.success).toBe(true);
});
+
+ it('should allow the explicit MTPLX provider marker', () => {
+ const result = providerSchema.safeParse({ name: 'MTPLX', type: 'cli', mtplxBacked: true });
+ expect(result.success).toBe(true);
+ });
});
describe('runSchema', () => {
diff --git a/server/services/agentCliSpawning.test.js b/server/services/agentCliSpawning.test.js
index 7e33d7d6a9..6c7b2b6f94 100644
--- a/server/services/agentCliSpawning.test.js
+++ b/server/services/agentCliSpawning.test.js
@@ -259,6 +259,17 @@ describe('buildCliSpawnConfig', () => {
expect(config.streamFormat).toBeUndefined();
});
+ it('runs `opencode run -m mtplx/` for a headless OpenCode MTPLX agent', () => {
+ const config = buildCliSpawnConfig(
+ { id: 'opencode-mtplx', command: 'opencode', args: ['run'], mtplxBacked: true },
+ 'mtplx',
+ );
+
+ expect(config.command).toBe('opencode');
+ expect(config.args).toEqual(['run', '-m', 'mtplx/mtplx']);
+ expect(config.stdinMode).toBe('prompt');
+ });
+
it('prepends the run subcommand for OpenCode even if saved args dropped it', () => {
const config = buildCliSpawnConfig({ id: 'opencode-ollama', command: 'opencode', args: [], ollamaBacked: true }, 'qwen2.5:7b');
diff --git a/server/services/agentProviderResolution.js b/server/services/agentProviderResolution.js
index 7145c472c9..1020d13eb8 100644
--- a/server/services/agentProviderResolution.js
+++ b/server/services/agentProviderResolution.js
@@ -118,8 +118,8 @@ export async function resolveAgentProviderAndModel(task) {
// child process that writes nothing to disk. Fail clearly instead. This catches
// an api provider arriving via a task pin OR via the fallback chain (the default
// fallback priority includes lmstudio/ollama). The fix for users: add a CLI
- // coding provider — e.g. the "Claude Ollama" sample (a `claude` CLI/TUI pointed at
- // Ollama) gives the full file-writing harness on a local model.
+ // coding provider — e.g. Claude Ollama, or OpenCode MTPLX when a separate
+ // MTPLX runtime is already running locally.
if (provider.type === 'api') {
return {
ok: false,
@@ -130,7 +130,7 @@ export async function resolveAgentProviderAndModel(task) {
// back from a CLI primary (directProviderType 'cli') is instead TRANSIENT:
// the primary may recover, so the task stays retryable.
permanent: directProviderType === 'api',
- error: `Provider "${provider.id}" is an HTTP API provider with no file-writing harness — CoS agent tasks need a CLI/TUI coding provider (claude, codex, or the "Claude Ollama" Claude-on-Ollama sample).`,
+ error: `Provider "${provider.id}" is an HTTP API provider with no file-writing harness — CoS agent tasks need a CLI/TUI coding provider (claude, codex, "Claude Ollama", or "OpenCode MTPLX").`,
providerId: provider.id
};
}
diff --git a/server/services/providerUsage.js b/server/services/providerUsage.js
index 1ec5bec4a7..a32085380d 100644
--- a/server/services/providerUsage.js
+++ b/server/services/providerUsage.js
@@ -608,13 +608,13 @@ const FAMILIES = PROVIDER_FAMILIES.map((family) => ({ ...family, fetch: FAMILY_F
/**
* Distinct quota families among the enabled providers, in registry order.
- * Ollama-backed wrappers are excluded up front regardless of which CLI binary
+ * Local-runtime wrappers are excluded up front regardless of which CLI binary
* they launch — a local model has no subscription quota, so e.g. an enabled
* `claude-ollama` must not surface a Claude Code card (nor a codex/agy/grok
* wrapper its family's card).
*/
export function resolveEnabledFamilies(providers) {
- const enabled = (providers || []).filter((p) => p?.enabled && p.ollamaBacked !== true);
+ const enabled = (providers || []).filter((p) => p?.enabled && p.ollamaBacked !== true && p.mtplxBacked !== true);
return FAMILIES.filter((family) => enabled.some((p) => family.matches(p)));
}
@@ -651,7 +651,7 @@ const fetchFamilyQuota = (family, { wait, providers }) =>
export async function getProviderQuotas({ wait = WAIT.CACHED, family = null } = {}) {
const result = await getAllProviders();
const providers = Array.isArray(result) ? result : (result?.providers || []);
- const enabled = providers.filter((p) => p?.enabled && p.ollamaBacked !== true);
+ const enabled = providers.filter((p) => p?.enabled && p.ollamaBacked !== true && p.mtplxBacked !== true);
const families = resolveEnabledFamilies(providers).filter((f) => !family || f.id === family);
const familyCards = await Promise.all(families.map((f) =>
fetchFamilyQuota(f, { wait, providers: enabled.filter((p) => f.matches(p)) })));
diff --git a/server/services/providerUsage.test.js b/server/services/providerUsage.test.js
index 8b232852f7..398c7348d4 100644
--- a/server/services/providerUsage.test.js
+++ b/server/services/providerUsage.test.js
@@ -282,6 +282,7 @@ describe('resolveEnabledFamilies', () => {
{ id: 'claude-code', enabled: true, type: 'cli', command: 'claude' },
{ id: 'claude-code-tui', enabled: true, type: 'tui', command: 'claude' },
{ id: 'claude-ollama', enabled: true, type: 'cli', command: 'claude', ollamaBacked: true },
+ { id: 'claude-mtplx', enabled: true, type: 'cli', command: 'claude', mtplxBacked: true },
{ id: 'codex', enabled: true, type: 'cli', command: 'codex' },
{ id: 'antigravity-cli', enabled: false, type: 'cli', command: 'agy' },
{ id: 'grok', enabled: true, type: 'api', endpoint: 'https://api.x.ai/v1' },
@@ -293,11 +294,14 @@ describe('resolveEnabledFamilies', () => {
expect(families).toEqual(['claude', 'codex', 'grok']); // agy disabled; ollama maps to no family
});
- it('does not map ollama-backed wrappers to ANY family (local models have no subscription quota)', () => {
+ it('does not map local-runtime wrappers to ANY family (local models have no subscription quota)', () => {
const families = resolveEnabledFamilies([
{ id: 'claude-ollama', enabled: true, type: 'cli', command: 'claude', ollamaBacked: true },
{ id: 'codex-ollama', enabled: true, type: 'cli', command: 'codex', ollamaBacked: true },
- { id: 'grok-ollama', enabled: true, type: 'cli', command: 'grok', ollamaBacked: true }
+ { id: 'grok-ollama', enabled: true, type: 'cli', command: 'grok', ollamaBacked: true },
+ { id: 'claude-mtplx', enabled: true, type: 'cli', command: 'claude', mtplxBacked: true },
+ { id: 'codex-mtplx', enabled: true, type: 'cli', command: 'codex', mtplxBacked: true },
+ { id: 'grok-mtplx', enabled: true, type: 'cli', command: 'grok', mtplxBacked: true }
]);
expect(families).toEqual([]);
});
diff --git a/server/services/quotaBurnJobs/providerPick.js b/server/services/quotaBurnJobs/providerPick.js
index 5989650f31..4a9b82d2d4 100644
--- a/server/services/quotaBurnJobs/providerPick.js
+++ b/server/services/quotaBurnJobs/providerPick.js
@@ -33,14 +33,14 @@ import { commandBasename } from '../../lib/providerModels.js';
* Two exclusions, both about what a burn is FOR — spending a subscription window
* that would otherwise expire unused:
* - API-type providers bill per token rather than drawing down a window.
- * - Ollama-backed wrappers run a LOCAL model, so there is no window to spend and
+ * - Local-runtime wrappers run a LOCAL model, so there is no window to spend and
* burning through one accomplishes nothing. `resolveEnabledFamilies` drops
* them from the quota cards for exactly this reason; a `claude-ollama-tui`
* would otherwise be a perfectly good match for the `claude` family.
*/
export function providerForFamily(providers, { familyId, providerId, prefer = 'tui' }) {
const available = (providers || []).filter((provider) =>
- provider?.enabled && provider.ollamaBacked !== true
+ provider?.enabled && provider.ollamaBacked !== true && provider.mtplxBacked !== true
&& (provider.type === 'cli' || provider.type === 'tui'));
if (providerId) return available.find((provider) => provider.id === providerId) || null;
const inFamily = available.filter((provider) => matchesFamily(provider, familyId));
diff --git a/server/services/quotaBurnJobs/providerPick.test.js b/server/services/quotaBurnJobs/providerPick.test.js
index 78b652ae8f..5b8ecb2c6d 100644
--- a/server/services/quotaBurnJobs/providerPick.test.js
+++ b/server/services/quotaBurnJobs/providerPick.test.js
@@ -64,12 +64,14 @@ describe('providerForFamily', () => {
// unused. Same exclusion `resolveEnabledFamilies` applies to the cards.
const providers = [
{ id: 'claude-ollama-tui', type: 'tui', enabled: true, ollamaBacked: true },
+ { id: 'opencode-mtplx-tui', type: 'tui', enabled: true, mtplxBacked: true },
{ id: 'claude-code-tui', type: 'tui', enabled: true },
{ id: 'claude-code', type: 'cli', enabled: true },
];
expect(providerForFamily(providers, { familyId: 'claude' })?.id).toBe('claude-code-tui');
// Not even by explicit pin — it cannot do the one thing the job is for.
expect(providerForFamily(providers, { familyId: 'claude', providerId: 'claude-ollama-tui' })).toBeNull();
+ expect(providerForFamily(providers, { familyId: 'claude', providerId: 'opencode-mtplx-tui' })).toBeNull();
expect(providerForFamily([providers[0]], { familyId: 'claude' })).toBeNull();
});
});