amicus 4.1.2 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +21 -2
  4. package/bin/amicus.js +10 -0
  5. package/electron/ipc-setup-local.js +109 -0
  6. package/electron/ipc-setup.js +14 -2
  7. package/electron/preload-setup.js +3 -1
  8. package/electron/setup-ui-local-script.js +114 -0
  9. package/electron/setup-ui-local.js +75 -0
  10. package/electron/setup-ui-styles.js +15 -1
  11. package/electron/setup-ui.js +6 -1
  12. package/package.json +1 -1
  13. package/scripts/postinstall.js +12 -211
  14. package/src/cli-handlers-doctor.js +12 -0
  15. package/src/cli-handlers-init.js +83 -0
  16. package/src/cli-handlers-key-local.js +144 -0
  17. package/src/cli-handlers-provider.js +212 -0
  18. package/src/cli-handlers.js +31 -3
  19. package/src/cli.js +21 -0
  20. package/src/sidecar/setup-local.js +75 -0
  21. package/src/sidecar/setup.js +99 -3
  22. package/src/utils/api-key-store.js +17 -64
  23. package/src/utils/claude-register.js +267 -0
  24. package/src/utils/config.js +53 -1
  25. package/src/utils/doctor-local-providers-check.js +62 -0
  26. package/src/utils/doctor-summary.js +33 -0
  27. package/src/utils/env-loader.js +12 -0
  28. package/src/utils/env-raw-store.js +120 -0
  29. package/src/utils/gateway-router.js +65 -2
  30. package/src/utils/lifecycle.js +2 -1
  31. package/src/utils/local-probe.js +109 -0
  32. package/src/utils/local-providers.js +141 -0
  33. package/src/utils/model-catalog.js +6 -2
  34. package/src/utils/model-fetcher.js +11 -1
  35. package/src/utils/pricing.js +23 -9
  36. package/src/utils/provider-default-picker.js +17 -2
  37. package/src/utils/provider-default-prompt.js +7 -4
  38. package/src/utils/route-error.js +10 -3
  39. package/src/utils/route-launch.js +8 -65
  40. package/src/utils/route-suggestions.js +85 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Arbitrary-env-var writes to the amicus .env (local-provider bearers, v4.2 §4.6).
3
+ * Split out of api-key-store.js to keep that file under the 300-line gate (B2/D3).
4
+ * The upsertEnvLine/deleteEnvLine merge helpers here are the SINGLE copy of the
5
+ * .env line-merge logic: saveApiKey/removeApiKey in api-key-store.js call them too
6
+ * (deduped, not copy-pasted). getEnvPath is required LAZILY inside each writer so
7
+ * api-key-store.js can re-export from here without a load-time require cycle.
8
+ */
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ /** Env-var names are UPPER_SNAKE, leading letter (mirrors POSIX + saveApiKey inputs). */
15
+ const ENV_VAR_RE = /^[A-Z][A-Z0-9_]*$/;
16
+
17
+ /**
18
+ * Upsert `envVar=value` into the .env at envPath: preserve comments/other lines,
19
+ * dedup by REPLACING an existing `<envVar>=` line rather than appending a second,
20
+ * strip trailing blank lines before an append, write 0600 with a trailing newline.
21
+ * @param {string} envPath
22
+ * @param {string} envVar
23
+ * @param {string} value
24
+ */
25
+ function upsertEnvLine(envPath, envVar, value) {
26
+ // Strip CR/LF: a newline in the value corrupts the line-based .env (splits it,
27
+ // or bakes a trailing CR into the persisted/served token). Bearer/API-key schemes
28
+ // never contain newlines, so stripping cannot damage a legitimate token.
29
+ const clean = String(value).replace(/[\r\n]/g, '');
30
+ fs.mkdirSync(path.dirname(envPath), { recursive: true });
31
+
32
+ let lines = [];
33
+ try {
34
+ if (fs.existsSync(envPath)) {
35
+ lines = fs.readFileSync(envPath, 'utf-8').split('\n');
36
+ }
37
+ } catch (_err) {
38
+ // Start fresh
39
+ }
40
+
41
+ let found = false;
42
+ for (let i = 0; i < lines.length; i++) {
43
+ if (lines[i].trim().startsWith(envVar + '=')) {
44
+ lines[i] = `${envVar}=${clean}`;
45
+ found = true;
46
+ break;
47
+ }
48
+ }
49
+ if (!found) {
50
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
51
+ lines.pop();
52
+ }
53
+ lines.push(`${envVar}=${clean}`);
54
+ }
55
+
56
+ fs.writeFileSync(envPath, lines.join('\n') + '\n', { mode: 0o600 });
57
+ // writeFileSync's mode only applies on CREATE; re-assert 0600 so an existing
58
+ // secrets file whose perms drifted is re-tightened. Best-effort (no-op on Windows).
59
+ try { fs.chmodSync(envPath, 0o600); } catch (_err) { /* perms best-effort */ }
60
+ return clean;
61
+ }
62
+
63
+ /**
64
+ * Remove any `envVar=` line from the .env at envPath (best-effort): preserves the
65
+ * other lines, strips trailing blanks, writes 0600 with a trailing newline (or an
66
+ * empty file when nothing remains). No-op when the file is absent.
67
+ * @param {string} envPath
68
+ * @param {string} envVar
69
+ */
70
+ function deleteEnvLine(envPath, envVar) {
71
+ try {
72
+ if (fs.existsSync(envPath)) {
73
+ const lines = fs.readFileSync(envPath, 'utf-8').split('\n')
74
+ .filter((line) => !line.trim().startsWith(envVar + '='));
75
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
76
+ lines.pop();
77
+ }
78
+ fs.writeFileSync(envPath, lines.length > 0 ? lines.join('\n') + '\n' : '', { mode: 0o600 });
79
+ try { fs.chmodSync(envPath, 0o600); } catch (_err) { /* perms best-effort */ }
80
+ }
81
+ } catch (_err) {
82
+ // Best-effort
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Write an arbitrary env-var line to the amicus .env (local-provider bearers).
88
+ * Keyed on the env-var NAME directly (no PROVIDER_ENV_MAP), so it can persist
89
+ * `LAB_API_KEY`, `VLLM_LAB_API_KEY`, ... Validates the name and returns
90
+ * `{success:false, error}` WITHOUT throwing so callers can bail before saving a
91
+ * config entry that references a rejected name. Mirrors the value into process.env.
92
+ * @param {string} envVar e.g. 'LAB_API_KEY'
93
+ * @param {string} value bearer token
94
+ * @returns {{success: boolean, error?: string}}
95
+ */
96
+ function saveRawEnv(envVar, value) {
97
+ if (typeof envVar !== 'string' || !ENV_VAR_RE.test(envVar)) {
98
+ return { success: false, error: `Invalid env var name: ${envVar}` };
99
+ }
100
+ const { getEnvPath } = require('./api-key-store'); // lazy: avoids a load-time cycle
101
+ const clean = upsertEnvLine(getEnvPath(), envVar, value);
102
+ process.env[envVar] = clean; // mirror the sanitized on-disk value
103
+ return { success: true };
104
+ }
105
+
106
+ /**
107
+ * Remove an arbitrary env-var line from the amicus .env (local-provider bearers).
108
+ * No auth.json reconciliation — that store is keyed on the 5 static vendors, and a
109
+ * local provider never has an entry there.
110
+ * @param {string} envVar e.g. 'LAB_API_KEY'
111
+ * @returns {{success: boolean}}
112
+ */
113
+ function removeRawEnv(envVar) {
114
+ const { getEnvPath } = require('./api-key-store'); // lazy: avoids a load-time cycle
115
+ deleteEnvLine(getEnvPath(), envVar);
116
+ delete process.env[envVar];
117
+ return { success: true };
118
+ }
119
+
120
+ module.exports = { saveRawEnv, removeRawEnv, upsertEnvLine, deleteEnvLine };
@@ -16,6 +16,59 @@ function executableFor(gateway, vendor, model) {
16
16
  return gateway === 'openrouter' ? `openrouter/${vendor}/${model}` : `${vendor}/${model}`;
17
17
  }
18
18
 
19
+ /** Per-flavor remediation hint for an unreachable local endpoint / missing model. */
20
+ function localHint(entry, kind) {
21
+ const f = entry && entry.flavor;
22
+ if (kind === 'unreachable') {
23
+ if (f === 'ollama') { return 'Is Ollama running? `ollama serve`'; }
24
+ if (f === 'lmstudio') { return 'Start the LM Studio server (Developer → Start Server).'; }
25
+ return `Check the server at ${entry && entry.baseURL}.`;
26
+ }
27
+ // model_not_found
28
+ return f === 'ollama' ? `Model not pulled — \`ollama pull ${(entry && entry.model) || '<model>'}\`.`
29
+ : `Load the model in ${f === 'lmstudio' ? 'LM Studio' : 'the server'} first.`;
30
+ }
31
+
32
+ /**
33
+ * Local-provider branch (v4.2 §4.2). The vendor is a configured local provider
34
+ * (key in req.localProviders). Returns a RouteResult; never consults the cached
35
+ * catalog (route-time truth is the injected live probe).
36
+ */
37
+ function resolveLocal(d, req) {
38
+ const entry = req.localProviders[d.vendor];
39
+ const executableId = executableFor('direct', d.vendor, d.model); // `<id>/<model>`, no openrouter/ prefix
40
+ // 1. OpenRouter is never a local route.
41
+ if (d.isExplicitOpenRouter || req.gatewayMode === 'openrouter') {
42
+ return routeError({ requested: d.raw, reason: 'no_openrouter_route', preferredGateway: 'direct', suggestions: [] });
43
+ }
44
+ // 2. Declared-but-missing bearer.
45
+ if (entry.apiKeyEnv && !entry.keyPresent) {
46
+ return routeError({ requested: d.raw, reason: 'no_local_key', preferredGateway: 'direct', suggestions: [] });
47
+ }
48
+ const live = req.localLive || { status: 'skipped', models: [] };
49
+ // 3. Unreachable (never reached under --no-validate-model — status is 'skipped' then).
50
+ if (live.status === 'unreachable') {
51
+ const e = routeError({ requested: d.raw, reason: 'local_endpoint_unreachable', preferredGateway: 'direct', suggestions: [] });
52
+ e.hint = localHint(entry, 'unreachable');
53
+ return e;
54
+ }
55
+ // 4. Reachable but the model is absent from the live roster.
56
+ if (live.status === 'ok' && !live.models.includes(executableId)) {
57
+ if (req.allowSelection) {
58
+ const suggestions = live.models.slice(0, 6).map((id) => ({ model: id, gateway: 'local', note: 'available locally' }));
59
+ return selectionRequired({ requested: d.raw, suggestions });
60
+ }
61
+ const e = routeError({ requested: d.raw, reason: 'model_not_found', preferredGateway: 'direct', suggestions: [] });
62
+ e.hint = localHint({ ...entry, model: d.model }, 'model_not_found');
63
+ return e;
64
+ }
65
+ // 5. Resolve. status 'skipped' → unverified notice (tri-state 'unknown').
66
+ const notice = live.status === 'skipped'
67
+ ? 'Local endpoint unverified (--no-validate-model); attempting anyway.' : undefined;
68
+ return resolved({ model: executableId, gateway: 'local', executableId,
69
+ provenance: { source: req.source, requested: d.raw, gatewayMode: req.gatewayMode }, notice });
70
+ }
71
+
19
72
  /**
20
73
  * Catalog gate: returns { ok:true, notice? } to proceed, or { ok:false, result }
21
74
  * carrying a selection_required/error to return to the caller.
@@ -78,16 +131,26 @@ function resolveRoute(req) {
78
131
  const model = d.model;
79
132
 
80
133
  // 2. Explicit conflict: force-OR literal vs --gateway direct
81
- if (d.isExplicitOpenRouter && rq.gatewayMode === 'direct') {
134
+ if (d.isExplicitOpenRouter && rq.gatewayMode === 'direct' && !(rq.localProviders && Object.prototype.hasOwnProperty.call(rq.localProviders, vendor))) {
82
135
  return routeError({ requested: d.raw, reason: 'gateway_conflict', preferredGateway: 'direct', suggestions: [] });
83
136
  }
84
137
  // 3. Explicit OR literal
85
- if (d.isExplicitOpenRouter) {
138
+ if (d.isExplicitOpenRouter && !(rq.localProviders && Object.prototype.hasOwnProperty.call(rq.localProviders, vendor))) {
86
139
  if (!rq.keys.openrouter) {
87
140
  return routeError({ requested: d.raw, reason: 'no_openrouter_key', preferredGateway: 'openrouter', suggestions: [] });
88
141
  }
89
142
  return finish('openrouter', vendor, model, rq);
90
143
  }
144
+ // 3.5 Local / OpenAI-compatible provider (v4.2 §4.2). Placed before the
145
+ // gateway-only-vendor check so a local id shadows an OR vendor namespace for
146
+ // bare descriptors (resolved Q6). Steps 2 and 3 above are BYPASSED for a local
147
+ // vendor (see the two guards above), so an explicit openrouter/<id>/... literal
148
+ // — with any gatewayMode — falls through to here and resolveLocal returns
149
+ // no_openrouter_route. Grammar still outranks shadowing; it just reports the
150
+ // local-specific reason instead of no_openrouter_key / gateway_conflict.
151
+ if (rq.localProviders && Object.prototype.hasOwnProperty.call(rq.localProviders, vendor)) {
152
+ return resolveLocal(d, rq);
153
+ }
91
154
  // 4. Gateway-only vendor (no direct integration)
92
155
  if (!isDirectProvider(vendor)) {
93
156
  if (rq.gatewayMode === 'direct') {
@@ -12,7 +12,8 @@
12
12
  // when done (F3 #15). Deliberately EXCLUDED: `mcp` (long-lived server), and
13
13
  // `setup`/`update` (no OpenCode server to leak, and `setup` can be a long-lived
14
14
  // interactive Electron flow that must never be force-exited).
15
- const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'status', 'read', 'abort', 'fanout', 'models', 'key', 'council', 'doctor', 'spend' /* local-only: no OpenCode server, no stray handles */]);
15
+ const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'status', 'read', 'abort', 'fanout', 'models', 'key', 'council', 'doctor', 'spend', 'provider', /* provider: runs a 2s socket probe (M11) — arm the watchdog so a stray handle can't hang the shell */
16
+ 'init' /* init (Task 15): runs the full doctor-check suite (network probes, OpenCode binary scan) via summarizeDoctor's runDoctorChecks() call — same lingering-handle risk 'doctor' already guards against */]);
16
17
 
17
18
  /** @param {string} command @returns {boolean} */
18
19
  function isOneShotCommand(command) {
@@ -0,0 +1,109 @@
1
+ // src/utils/local-probe.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module local-probe
6
+ * Scheme-aware (http/https) bounded GET for local / OpenAI-compatible servers
7
+ * (v4.2 §3 http-fix, §4.2 route-time probe, §4.4 catalog rows). Fixes the
8
+ * model-fetcher https-only silent-[] bug for local http endpoints. No redirects
9
+ * followed; bearer attached only to the configured origin; Authorization never
10
+ * logged. Never throws — every failure resolves to unreachable / [].
11
+ */
12
+
13
+ const http = require('http');
14
+ const https = require('https');
15
+
16
+ /** GET JSON with a hard timeout; no redirect follow. Resolves {status, body} or {status:0}. */
17
+ function getJson(url, { timeoutMs, bearer }) {
18
+ return new Promise((resolve) => {
19
+ try {
20
+ const parsed = new URL(url);
21
+ // Scheme allowlist: http:/https: only. Handing a non-http(s) URL straight to
22
+ // http.get/https.get is not safe to rely on — Node validates the URL's own embedded
23
+ // protocol against the module and throws ERR_INVALID_PROTOCOL synchronously for a
24
+ // mismatch (e.g. file:, ftp:, javascript:), which would otherwise reject this promise
25
+ // instead of resolving it. Reject the scheme ourselves, before any module dispatch.
26
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { resolve({ status: 0 }); return; }
27
+ const mod = parsed.protocol === 'https:' ? https : http;
28
+ const headers = bearer ? { Authorization: `Bearer ${bearer}` } : {};
29
+ let body = '';
30
+ const req = mod.get(url, { headers }, (res) => {
31
+ const status = res.statusCode || 0;
32
+ // Non-200 (incl. 3xx redirects — NOT followed) → drain + report status, no body parse.
33
+ if (status !== 200) { res.on('data', () => {}); res.on('end', () => resolve({ status })); return; }
34
+ res.on('data', (c) => { body += c; });
35
+ res.on('end', () => { try { resolve({ status, body: JSON.parse(body) }); } catch { resolve({ status: 0 }); } });
36
+ });
37
+ const timer = setTimeout(() => { req.destroy(); resolve({ status: 0 }); }, timeoutMs);
38
+ req.on('error', () => { clearTimeout(timer); resolve({ status: 0 }); });
39
+ req.on('close', () => clearTimeout(timer));
40
+ } catch {
41
+ // Belt and braces: this module's contract is "never throws". Any other synchronous
42
+ // throw we haven't explicitly enumerated (malformed input, etc.) must still resolve.
43
+ resolve({ status: 0 });
44
+ }
45
+ });
46
+ }
47
+
48
+ /** Trim a trailing `/v1` (or `/`) off the baseURL to get the server origin for /api/tags. */
49
+ function originOf(baseURL) {
50
+ try { const u = new URL(baseURL); return `${u.protocol}//${u.host}`; } catch { return baseURL; }
51
+ }
52
+
53
+ /** OpenAI /v1/models body → ['<id>/<model>', ...]. */
54
+ function idsFromV1(id, body) {
55
+ const data = (body && Array.isArray(body.data)) ? body.data : [];
56
+ return data.map((m) => m && m.id).filter(Boolean).map((name) => `${id}/${name}`);
57
+ }
58
+
59
+ /** Ollama /api/tags body → ['<id>/<model>', ...]. */
60
+ function idsFromTags(id, body) {
61
+ const models = (body && Array.isArray(body.models)) ? body.models : [];
62
+ return models.map((m) => m && m.name).filter(Boolean).map((name) => `${id}/${name}`);
63
+ }
64
+
65
+ /**
66
+ * Route-time reachability probe (spec §4.2). Never throws.
67
+ * @param {{id:string, baseURL:string, flavor?:string}} entry
68
+ * @param {{timeoutMs?:number, bearer?:string}} [opts]
69
+ * @returns {Promise<{status:'ok'|'unreachable', models:string[]}>}
70
+ */
71
+ async function probeLocalProvider(entry, opts = {}) {
72
+ // Finding 1 (CRITICAL): entry.baseURL.replace(...) below ran before getJson was ever
73
+ // called and outside any try/catch — a missing/null/non-string baseURL (or a missing
74
+ // entry altogether) threw synchronously inside this async function, which rejected the
75
+ // returned promise instead of resolving the documented unreachable/[] shape. This is the
76
+ // same defect shape as the scheme bug already fixed in getJson, one field over: that fix
77
+ // guards the URL's *scheme*; this guards entry.baseURL's *shape*, before it ever reaches
78
+ // getJson. listLocalModels (below) inherits the fix by calling through this function.
79
+ if (!entry || typeof entry.baseURL !== 'string' || !entry.baseURL) { return { status: 'unreachable', models: [] }; }
80
+ const timeoutMs = opts.timeoutMs || 2000;
81
+ const bearer = opts.bearer;
82
+ const primary = await getJson(`${entry.baseURL.replace(/\/$/, '')}/models`, { timeoutMs, bearer });
83
+ if (primary.status === 200) { return { status: 'ok', models: idsFromV1(entry.id, primary.body) }; }
84
+ // Ollama-flavor fallback: older servers answer /api/tags, not /v1/models.
85
+ if (entry.flavor === 'ollama' && primary.status === 404) {
86
+ const tags = await getJson(`${originOf(entry.baseURL)}/api/tags`, { timeoutMs, bearer });
87
+ if (tags.status === 200) { return { status: 'ok', models: idsFromTags(entry.id, tags.body) }; }
88
+ }
89
+ return { status: 'unreachable', models: [] };
90
+ }
91
+
92
+ /**
93
+ * Catalog rows (spec §4.4). Never throws; [] on any failure.
94
+ * @param {{id:string, baseURL:string, flavor?:string, pricing?:object}} entry
95
+ * @param {{timeoutMs?:number, bearer?:string}} [opts]
96
+ * @returns {Promise<Array<object>>}
97
+ */
98
+ async function listLocalModels(entry, opts = {}) {
99
+ const timeoutMs = opts.timeoutMs || 5000;
100
+ const probe = await probeLocalProvider(entry, { timeoutMs, bearer: opts.bearer });
101
+ if (probe.status !== 'ok') { return []; }
102
+ const pricing = entry.pricing || { prompt: 0, completion: 0 };
103
+ return probe.models.map((id) => ({
104
+ id, name: id.slice(entry.id.length + 1), contextLength: null, pricing,
105
+ authoritative: true, local: true,
106
+ }));
107
+ }
108
+
109
+ module.exports = { probeLocalProvider, listLocalModels };
@@ -0,0 +1,141 @@
1
+ // src/utils/local-providers.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module local-providers
6
+ * The merge layer beside the static provider-registry (v4.2 §4.1). Reads
7
+ * user-defined OpenAI-compatible providers from config.providers, validates
8
+ * and normalizes them. Forgiving: an invalid entry is skipped with a one-line
9
+ * stderr warning, never fatal (config.js posture). Leaf-ish: only lazy-requires
10
+ * ./config; NEVER requires provider-registry (no cycle, no static-table edits).
11
+ */
12
+
13
+ // Mirrors PROVIDERS ids in provider-registry.js; duplicated (not imported) per the leaf-module rule — update both together.
14
+ const RESERVED_IDS = Object.freeze(['openrouter', 'google', 'openai', 'anthropic', 'deepseek']);
15
+ const VALID_FLAVORS = Object.freeze(['ollama', 'lmstudio', 'vllm', 'generic']);
16
+ const ID_RE = /^[a-z][a-z0-9_-]{1,31}$/;
17
+
18
+ /**
19
+ * Preset id → partial entry (baseURL + flavor). ALL THREE carry a baseURL (D15):
20
+ * vLLM's own default `vllm serve` port is 8000, so `--preset vllm` works alone;
21
+ * `--url` overrides any preset for a non-default port or a remote host.
22
+ * 127.0.0.1 never `localhost` (IPv6-first `::1` gotcha, spec §4.1/§4.10).
23
+ */
24
+ const PRESETS = Object.freeze({
25
+ ollama: { baseURL: 'http://127.0.0.1:11434/v1', flavor: 'ollama' },
26
+ lmstudio: { baseURL: 'http://127.0.0.1:1234/v1', flavor: 'lmstudio' },
27
+ vllm: { baseURL: 'http://127.0.0.1:8000/v1', flavor: 'vllm' },
28
+ });
29
+
30
+ /** `vllm-lab` → `VLLM_LAB_API_KEY`. @param {string} id @returns {string} */
31
+ function deriveKeyEnv(id) {
32
+ return `${String(id).toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`;
33
+ }
34
+
35
+ /** Absolute http:/https: only. @param {string} u @returns {boolean} */
36
+ function isAllowedUrl(u) {
37
+ try {
38
+ const parsed = new URL(u);
39
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
40
+ } catch { return false; }
41
+ }
42
+
43
+ /**
44
+ * Validate + normalize a provider entry (id-agnostic — id is checked at the map level).
45
+ * @param {object} entry @returns {{ok:true, normalized:object}|{ok:false, error:string}}
46
+ */
47
+ function validateProviderEntry(entry) {
48
+ if (!entry || typeof entry !== 'object') { return { ok: false, error: 'entry must be an object' }; }
49
+ if (entry.type !== 'openai-compatible') { return { ok: false, error: `unsupported type '${entry.type}'` }; }
50
+ if (typeof entry.baseURL !== 'string' || !isAllowedUrl(entry.baseURL)) {
51
+ return { ok: false, error: `baseURL must be an absolute http:/https: URL (got '${entry.baseURL}')` };
52
+ }
53
+ const flavor = entry.flavor === undefined ? 'generic' : entry.flavor;
54
+ if (!VALID_FLAVORS.includes(flavor)) { return { ok: false, error: `invalid flavor '${entry.flavor}'` }; }
55
+ let pricing = { prompt: 0, completion: 0 };
56
+ if (entry.pricing !== undefined) {
57
+ const p = entry.pricing;
58
+ const prompt = Number(p && p.prompt);
59
+ const completion = Number(p && p.completion);
60
+ if (!Number.isFinite(prompt) || !Number.isFinite(completion) || prompt < 0 || completion < 0) {
61
+ return { ok: false, error: 'pricing.prompt/completion must be non-negative numbers' };
62
+ }
63
+ pricing = { prompt, completion };
64
+ }
65
+ const normalized = {
66
+ type: 'openai-compatible',
67
+ baseURL: entry.baseURL,
68
+ flavor,
69
+ name: typeof entry.name === 'string' && entry.name ? entry.name : undefined,
70
+ apiKeyEnv: typeof entry.apiKeyEnv === 'string' && entry.apiKeyEnv ? entry.apiKeyEnv : undefined,
71
+ pricing,
72
+ };
73
+ return { ok: true, normalized };
74
+ }
75
+
76
+ /**
77
+ * Validated, normalized local-provider map from config.providers.
78
+ * Invalid/reserved/malformed-id entries are skipped with a stderr warning.
79
+ * @returns {Object<string, object>} id → normalized entry (id stamped on; name defaulted to id)
80
+ */
81
+ function getLocalProviders() {
82
+ let config;
83
+ try { config = require('./config').loadConfig(); } catch { config = null; }
84
+ const raw = (config && config.providers && typeof config.providers === 'object') ? config.providers : {};
85
+ const out = {};
86
+ for (const [id, entry] of Object.entries(raw)) {
87
+ if (!ID_RE.test(id) || RESERVED_IDS.includes(id)) {
88
+ process.stderr.write(`Notice: skipping invalid provider id '${id}' in config.providers.\n`);
89
+ continue;
90
+ }
91
+ const v = validateProviderEntry(entry);
92
+ if (!v.ok) {
93
+ process.stderr.write(`Notice: skipping provider '${id}' — ${v.error}.\n`);
94
+ continue;
95
+ }
96
+ out[id] = { ...v.normalized, id, name: v.normalized.name || id };
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /** @param {string} id @returns {boolean} */
102
+ function isLocalProvider(id) {
103
+ return !!id && Object.prototype.hasOwnProperty.call(getLocalProviders(), id);
104
+ }
105
+
106
+ /**
107
+ * v4.2: assemble the router's local inputs for one descriptor.
108
+ * Lives here (not in route-launch.js) so route-launch stays under the 300-line gate.
109
+ * `providers` is a PARAMETER, not a bare same-module `getLocalProviders()` call, so
110
+ * callers/tests control the map (cf. B3: bare identifiers are unmockable via the exports object).
111
+ * @returns {Promise<{localProviders?: Object, localLive?: Object}>} — `{}` for a non-local vendor.
112
+ */
113
+ async function resolveLocalRouteInputs(descriptor, { validateModel, providers } = {}) {
114
+ const all = providers || getLocalProviders();
115
+ const vendor = descriptor && descriptor.vendor;
116
+ const entry = vendor && all && Object.prototype.hasOwnProperty.call(all, vendor) ? all[vendor] : undefined;
117
+ if (!entry) { return {}; }
118
+ let bearer;
119
+ if (entry.apiKeyEnv) {
120
+ // Sole source: env-loader.js's loadCredentials() already projects every
121
+ // configured apiKeyEnv from the .env into process.env at CLI bootstrap.
122
+ // (B1, whole-branch review: do NOT add a readApiKeyValues()[vendor]
123
+ // fallback here. That map is keyed only by the 5 static PROVIDER_ENV_MAP
124
+ // vendor ids, so for a real local id the lookup is always an own-property
125
+ // miss -- and for a local id colliding with an Object.prototype member
126
+ // (e.g. 'constructor') it walks the prototype chain to a truthy inherited
127
+ // value, fabricating a bearer and defeating gateway-router.js's
128
+ // no_local_key check. Never use a bare `map[vendor]`-shaped lookup here.)
129
+ bearer = process.env[entry.apiKeyEnv] || undefined;
130
+ }
131
+ const localProviders = { [vendor]: { ...entry, keyPresent: !!bearer } };
132
+ const localLive = validateModel === false
133
+ ? { status: 'skipped', models: [] }
134
+ : await require('./local-probe').probeLocalProvider(entry, { timeoutMs: 2000, bearer });
135
+ return { localProviders, localLive };
136
+ }
137
+
138
+ module.exports = {
139
+ getLocalProviders, isLocalProvider, deriveKeyEnv, validateProviderEntry, resolveLocalRouteInputs,
140
+ PRESETS, RESERVED_IDS, VALID_FLAVORS, ID_RE,
141
+ };
@@ -95,8 +95,12 @@ async function refreshCatalog() {
95
95
  // The anthropic rows are a hardcoded zero-network floor: a result containing
96
96
  // ONLY them means every network provider failed. Treat that as a failed
97
97
  // refresh — never clobber a previously-good cache with the floor (the
98
- // "stale cache stands" contract).
99
- const networkRows = (models || []).filter(m => m && typeof m.id === 'string' && !m.id.startsWith('anthropic/'));
98
+ // "stale cache stands" contract). v4.2 §4.4: local rows (local:true) are
99
+ // ALSO excluded here a localhost-only refresh (offline except loopback)
100
+ // must not be counted as a successful network refresh, or it would clobber
101
+ // a previously-good OpenRouter cache with a local-only catalog.
102
+ const networkRows = (models || []).filter(m =>
103
+ m && typeof m.id === 'string' && !m.id.startsWith('anthropic/') && m.local !== true);
100
104
  if (networkRows.length === 0) {
101
105
  const reason = (models || []).length > 0
102
106
  ? 'floor-only: all providers returned no network rows'
@@ -192,7 +192,17 @@ function providersToFetch(keys) {
192
192
  async function fetchAllModels(keys) {
193
193
  const providers = providersToFetch(keys);
194
194
  const results = await Promise.all(providers.map(p => fetchModelsFromProvider(p, keys[p] || '')));
195
- return results.flat();
195
+ const rows = results.flat();
196
+ // v4.2 §4.4: append local-provider rows via the scheme-aware probe (5s, [] on failure).
197
+ try {
198
+ const { getLocalProviders } = require('./local-providers');
199
+ const { listLocalModels } = require('./local-probe');
200
+ const localEntries = Object.values(getLocalProviders());
201
+ const localResults = await Promise.all(localEntries.map((e) =>
202
+ listLocalModels(e, { timeoutMs: 5000, bearer: e.apiKeyEnv ? process.env[e.apiKeyEnv] : undefined })));
203
+ for (const r of localResults) { rows.push(...r); }
204
+ } catch (_err) { /* local rows are best-effort — never break the cloud catalog */ }
205
+ return rows;
196
206
  }
197
207
 
198
208
  /**
@@ -39,14 +39,27 @@ function sumPerMessageUsage(map) {
39
39
  function lookupPricing(modelId) {
40
40
  if (!modelId) { return null; }
41
41
  let cache;
42
- try { cache = require('./model-catalog').readCache(); } catch { return null; }
43
- if (!cache || !Array.isArray(cache.models)) { return null; }
44
- const row = cache.models.find(m => m && m.id === modelId);
45
- if (!row || !row.pricing) { return null; }
46
- const prompt = Number(row.pricing.prompt);
47
- const completion = Number(row.pricing.completion);
48
- if (!Number.isFinite(prompt) || !Number.isFinite(completion) || prompt < 0 || completion < 0) { return null; }
49
- return { prompt, completion };
42
+ try { cache = require('./model-catalog').readCache(); } catch { cache = null; }
43
+ const rows = (cache && Array.isArray(cache.models)) ? cache.models : [];
44
+ const row = rows.find(m => m && m.id === modelId);
45
+ if (row && row.pricing) {
46
+ const prompt = Number(row.pricing.prompt);
47
+ const completion = Number(row.pricing.completion);
48
+ if (Number.isFinite(prompt) && Number.isFinite(completion) && prompt >= 0 && completion >= 0) {
49
+ return { prompt, completion };
50
+ }
51
+ }
52
+ // v4.2 §4.5: local vendor with no catalog row → the provider's configured pricing (default zeros).
53
+ try {
54
+ const vendor = modelId.split('/')[0];
55
+ const { isLocalProvider, getLocalProviders } = require('./local-providers');
56
+ if (isLocalProvider(vendor)) {
57
+ const entry = getLocalProviders()[vendor];
58
+ const p = (entry && entry.pricing) || { prompt: 0, completion: 0 };
59
+ return { prompt: Number(p.prompt) || 0, completion: Number(p.completion) || 0 };
60
+ }
61
+ } catch { /* fall through */ }
62
+ return null;
50
63
  }
51
64
 
52
65
  /** @returns {{amount:number|null, currency:'USD', source:'reported'|'estimated'|'unknown'}} */
@@ -56,7 +69,8 @@ function resolveLegCost({ reportedCost, tokens, pricing }) {
56
69
  }
57
70
  if (pricing && tokens) {
58
71
  const est = (tokens.input || 0) * pricing.prompt + (tokens.output || 0) * pricing.completion;
59
- if (est > 0) { return { amount: est, currency: 'USD', source: 'estimated' }; }
72
+ // v4.2 §4.5: a genuine $0 estimate is a REAL priced tier (not unknown/null).
73
+ if (est >= 0) { return { amount: est, currency: 'USD', source: 'estimated' }; }
60
74
  }
61
75
  return { amount: null, currency: 'USD', source: 'unknown' };
62
76
  }
@@ -18,6 +18,7 @@ const { resolveTier } = require('./model-tiers');
18
18
  const { getCostTier, loadConfig, saveConfig } = require('./config');
19
19
  const { pairAcrossGateways } = require('./gateway-route-catalog');
20
20
  const { toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
21
+ const { isLocalProvider } = require('./local-providers');
21
22
 
22
23
  /**
23
24
  * @param {{pricing?: {prompt?: string|number|null}|null}|null|undefined} orRow
@@ -97,6 +98,9 @@ function buildRows(catalog, vendor) {
97
98
  const catalogInfo = { models: catalog };
98
99
  const directPrefix = `${vendor}/`;
99
100
  const orPrefix = `openrouter/${vendor}/`;
101
+ // Hoisted: `vendor` is fixed for the whole call, so this is decided once
102
+ // rather than re-reading config.providers on every row.
103
+ const isLocal = isLocalProvider(vendor);
100
104
 
101
105
  const rows = [];
102
106
  const seenIds = new Set();
@@ -112,11 +116,22 @@ function buildRows(catalog, vendor) {
112
116
  const orRow = paired.openrouter ? byId.get(paired.openrouter) : null;
113
117
  const sourceRow = isDirect ? row : ((paired.direct && byId.get(paired.direct)) || orRow || row);
114
118
 
119
+ // A local vendor (v4.2 §4.5) has no OpenRouter twin BY CONSTRUCTION --
120
+ // OpenRouter cannot proxy a localhost model -- so `orRow` is always null
121
+ // here and `pricePerMInputFrom(orRow)` would always be null too, no
122
+ // matter that the local catalog row carries its own real
123
+ // `pricing: {prompt:0, completion:0}`. Price local rows from their OWN
124
+ // pricing (`sourceRow`, which for a local/direct row IS the catalog row
125
+ // itself) instead. Gated on `isLocal`, NOT on "no OpenRouter twin", so a
126
+ // direct (non-local) row with no twin still renders `pricePerMInput:
127
+ // null` (tests/provider-default-picker.test.js:59, pinned).
128
+ const localPrice = isLocal ? pricePerMInputFrom(sourceRow) : null;
129
+
115
130
  rows.push({
116
131
  id: chosenId,
117
132
  name: sourceRow.name,
118
133
  contextLength: (sourceRow.contextLength === undefined ? null : sourceRow.contextLength),
119
- pricePerMInput: pricePerMInputFrom(orRow),
134
+ pricePerMInput: localPrice !== null ? localPrice : pricePerMInputFrom(orRow),
120
135
  isPreselected: false,
121
136
  });
122
137
  }
@@ -231,4 +246,4 @@ function applyProviderDefault(vendor, chosenId, { seedDefaultIfAbsent = true } =
231
246
  return { alias: vendor, setAsDefault };
232
247
  }
233
248
 
234
- module.exports = { buildProviderDefaultChoices, applyProviderDefault };
249
+ module.exports = { buildProviderDefaultChoices, applyProviderDefault, pricePerMInputFrom };
@@ -17,6 +17,7 @@
17
17
 
18
18
  const { buildProviderDefaultChoices, applyProviderDefault } = require('./provider-default-picker');
19
19
  const { isDirectProvider } = require('./provider-registry');
20
+ const { isLocalProvider } = require('./local-providers');
20
21
 
21
22
  /** Format a $/M-input price for display; `null`/`undefined` -> 'n/a'. @param {number|null|undefined} pricePerMInput */
22
23
  function formatPrice(pricePerMInput) {
@@ -68,8 +69,10 @@ async function promptForChoice(ask, print, choices) {
68
69
  * match every OR-namespaced catalog row, "recommended" would be arbitrary,
69
70
  * and writing `aliases.openrouter = "<some vendor>/<model>"` would be
70
71
  * nonsensical. Per-provider defaults only make sense for DIRECT model
71
- * vendors (`provider-registry.isDirectProvider`) -- no choices are built, no
72
- * alias is written, and `config.default` is never seeded for a gateway.
72
+ * vendors (`provider-registry.isDirectProvider`) and local/OpenAI-compatible
73
+ * vendors (`local-providers.isLocalProvider`, v4.2 §4.5) -- for any other
74
+ * (gateway) provider, no choices are built, no alias is written, and
75
+ * `config.default` is never seeded.
73
76
  * @param {string} provider vendor name, e.g. 'anthropic'
74
77
  * @param {{interactive?: boolean, ask?: (prompt: string) => Promise<string>,
75
78
  * catalog?: Array<object>, print?: (line: string) => void}} [options]
@@ -80,11 +83,11 @@ async function runProviderDefaultFlow(provider, options = {}) {
80
83
  const catalog = Array.isArray(options.catalog) ? options.catalog : [];
81
84
  const print = typeof options.print === 'function' ? options.print : () => {};
82
85
 
83
- if (!isDirectProvider(provider)) {
86
+ if (!isDirectProvider(provider) && !isLocalProvider(provider)) {
84
87
  return {
85
88
  chosenId: null,
86
89
  setAsDefault: false,
87
- summaryLine: 'Per-provider defaults apply to direct provider keys (openai/anthropic/google/deepseek) -- ' +
90
+ summaryLine: 'Per-provider defaults apply to direct provider keys and local providers -- ' +
88
91
  'models routed via OpenRouter use your overall default.',
89
92
  };
90
93
  }