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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +48 -0
- package/README.md +21 -2
- package/bin/amicus.js +10 -0
- package/electron/ipc-setup-local.js +109 -0
- package/electron/ipc-setup.js +14 -2
- package/electron/preload-setup.js +3 -1
- package/electron/setup-ui-local-script.js +114 -0
- package/electron/setup-ui-local.js +75 -0
- package/electron/setup-ui-styles.js +15 -1
- package/electron/setup-ui.js +6 -1
- package/package.json +1 -1
- package/scripts/postinstall.js +12 -211
- package/src/cli-handlers-doctor.js +12 -0
- package/src/cli-handlers-init.js +83 -0
- package/src/cli-handlers-key-local.js +144 -0
- package/src/cli-handlers-provider.js +212 -0
- package/src/cli-handlers.js +31 -3
- package/src/cli.js +21 -0
- package/src/sidecar/setup-local.js +75 -0
- package/src/sidecar/setup.js +99 -3
- package/src/utils/api-key-store.js +17 -64
- package/src/utils/claude-register.js +267 -0
- package/src/utils/config.js +53 -1
- package/src/utils/doctor-local-providers-check.js +62 -0
- package/src/utils/doctor-summary.js +33 -0
- package/src/utils/env-loader.js +12 -0
- package/src/utils/env-raw-store.js +120 -0
- package/src/utils/gateway-router.js +65 -2
- package/src/utils/lifecycle.js +2 -1
- package/src/utils/local-probe.js +109 -0
- package/src/utils/local-providers.js +141 -0
- package/src/utils/model-catalog.js +6 -2
- package/src/utils/model-fetcher.js +11 -1
- package/src/utils/pricing.js +23 -9
- package/src/utils/provider-default-picker.js +17 -2
- package/src/utils/provider-default-prompt.js +7 -4
- package/src/utils/route-error.js +10 -3
- package/src/utils/route-launch.js +8 -65
- 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') {
|
package/src/utils/lifecycle.js
CHANGED
|
@@ -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' /*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/utils/pricing.js
CHANGED
|
@@ -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 {
|
|
43
|
-
|
|
44
|
-
const row =
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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`)
|
|
72
|
-
*
|
|
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
|
|
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
|
}
|