Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/next/added-agent-150443d5.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add opt-in MTPLX local MTP provider presets
7 changes: 4 additions & 3 deletions client/src/components/cos/TaskAddForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -696,7 +697,7 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa
</div>
{apiOnlyProviders && (
<div className="px-3 py-2 bg-port-warning/10 border border-port-warning/40 rounded-lg text-xs text-port-warning">
Your enabled providers (Ollama / LM Studio) are HTTP API providers with no file-writing harness, so they can't run agent tasks. Enable the <span className="font-semibold">Claude Ollama</span> provider (a <code>claude</code> 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 <span className="font-semibold">Claude Ollama</span> for Ollama, or <span className="font-semibold">OpenCode MTPLX</span> for a separately running MTPLX server, on the AI Providers page to run file-writing tasks on a local model.
</div>
)}
{/* Screenshot and Attachment Upload */}
Expand Down
9 changes: 5 additions & 4 deletions client/src/utils/providers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
});
});
Expand Down
49 changes: 49 additions & 0 deletions data.reference/providers.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
53 changes: 53 additions & 0 deletions docs/features/mtplx.md
Original file line number Diff line number Diff line change
@@ -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.
77 changes: 77 additions & 0 deletions scripts/migrations/272-mtplx-providers.js
Original file line number Diff line number Diff line change
@@ -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],
});
86 changes: 86 additions & 0 deletions scripts/migrations/272-mtplx-providers.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 2 additions & 2 deletions server/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<cwd-slug>/*.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. |
Expand Down
Loading