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(); }); });