auxilo-mcp 0.9.12 → 0.9.13
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/README.md +8 -8
- package/bin/auxilo-cli.js +356 -14
- package/lib/installer.js +40 -0
- package/mcp-server.js +8 -8
- package/package.json +2 -1
- package/scripts/extract-local.js +107 -201
- package/scripts/providers/byo-key.js +631 -0
- package/scripts/providers/claude-code.js +477 -0
- package/scripts/providers/codex-cli.js +358 -0
- package/scripts/providers/index.js +316 -0
- package/scripts/providers/provider.interface.js +98 -0
- package/scripts/providers/schemas/extraction-envelope.schema.json +42 -0
- package/scripts/providers/schemas/judge-decisions.schema.json +20 -0
- package/scripts/runner.js +32 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* scripts/providers/byo-key.js — BYO (bring-your-own) provider-key adapter
|
|
4
|
+
* (EXTRACT-PER-CLIENT W1 PART C).
|
|
5
|
+
*
|
|
6
|
+
* The builder's OWN key against an OpenAI-compatible chat-completions
|
|
7
|
+
* endpoint, or one of two thin payload variants (Anthropic Messages, Gemini
|
|
8
|
+
* generateContent) — selected by the `provider` field stored in
|
|
9
|
+
* ~/.auxilo/providers.json's `byo` object (the SAME file
|
|
10
|
+
* scripts/providers/index.js persists auto-detected `selected` into — one
|
|
11
|
+
* file, not two). Auxilo never sees this key: it is read from disk, used for
|
|
12
|
+
* exactly one outbound HTTP call, and never logged.
|
|
13
|
+
*
|
|
14
|
+
* providers.json shape (0600):
|
|
15
|
+
* {
|
|
16
|
+
* "selected": "byo-key", // written by index.js, untouched here
|
|
17
|
+
* "byo": {
|
|
18
|
+
* "provider": "openai"|"anthropic"|"gemini"|<custom>,
|
|
19
|
+
* "base_url": "https://...", // optional; vendor default if absent
|
|
20
|
+
* "model": "gpt-4o-mini",
|
|
21
|
+
* "api_key": "sk-..."
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
*
|
|
25
|
+
* Routing: provider 'anthropic' -> Anthropic Messages; provider 'gemini' ->
|
|
26
|
+
* Gemini generateContent; anything else (including 'openai' and any custom
|
|
27
|
+
* label with a custom base_url) -> the OpenAI-compatible chat-completions
|
|
28
|
+
* shape. `vendor` in the returned identity is always one of
|
|
29
|
+
* 'openai-compatible'|'anthropic'|'gemini' (the wire shape actually used),
|
|
30
|
+
* distinct from the free-form stored `provider` label.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
const os = require('os');
|
|
36
|
+
|
|
37
|
+
/** Same literal value as scripts/providers/index.js's PROVIDERS_STATE_PATH —
|
|
38
|
+
* duplicated (not imported) to avoid a circular require (index.js loads this
|
|
39
|
+
* module dynamically via loadOptionalProvider). test/byo-key-provider.test.js
|
|
40
|
+
* pins the two paths equal. */
|
|
41
|
+
const DEFAULT_PROVIDERS_STATE_PATH = path.join(os.homedir(), '.auxilo', 'providers.json');
|
|
42
|
+
|
|
43
|
+
const VENDOR_DEFAULT_BASE_URL = Object.freeze({
|
|
44
|
+
anthropic: 'https://api.anthropic.com/v1',
|
|
45
|
+
gemini: 'https://generativelanguage.googleapis.com/v1beta',
|
|
46
|
+
'openai-compatible': 'https://api.openai.com/v1',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const ANTHROPIC_API_VERSION = '2023-06-01';
|
|
50
|
+
const DEFAULT_MAX_TOKENS = 4096;
|
|
51
|
+
|
|
52
|
+
function statePath(opts = {}) {
|
|
53
|
+
return opts.providersStatePath || DEFAULT_PROVIDERS_STATE_PATH;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* true iff `target` did not resolve to an absolute path (EXTRACT-PER-CLIENT
|
|
58
|
+
* W1 FIX GOV-3 item 13). `os.homedir()` can — rarely — return `''` (HOME/
|
|
59
|
+
* USERPROFILE unset and the OS lookup fails); DEFAULT_PROVIDERS_STATE_PATH
|
|
60
|
+
* is a plain `path.join(os.homedir(), ...)` module constant, so that failure
|
|
61
|
+
* mode silently turns it into a RELATIVE path under the process's cwd — in
|
|
62
|
+
* this repo, a public git tree. Every entry point that touches
|
|
63
|
+
* providers.json (read, write, or clear) checks this first and refuses
|
|
64
|
+
* rather than guess a location, with reasonCode 'provider-home-unresolved'.
|
|
65
|
+
* A caller-supplied `opts.providersStatePath` (every test in this repo, and
|
|
66
|
+
* any future explicit override) is never subject to this — the caller
|
|
67
|
+
* chose that path deliberately.
|
|
68
|
+
*/
|
|
69
|
+
function isHomeUnresolved(target) {
|
|
70
|
+
return typeof target !== 'string' || !target || !path.isAbsolute(target);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* ONE writer for every providers.json write (EXTRACT-PER-CLIENT W1 FIX
|
|
75
|
+
* GOV-3 item 1 + should-fix item 9) — writeByoConfig, clearProvidersFile,
|
|
76
|
+
* AND scripts/providers/index.js's persistSelected all route through this,
|
|
77
|
+
* so there is exactly one place that gets the tmp+rename+chmod discipline
|
|
78
|
+
* right instead of three independent (and, before this fix, drifted) copies
|
|
79
|
+
* of it. Discipline, in order:
|
|
80
|
+
* 1. mkdir the parent dir 0700 (idempotent).
|
|
81
|
+
* 2. If a file already sits at `target`, chmod it 0600 BEFORE the
|
|
82
|
+
* rename lands (belt-and-suspenders — the post-rename chmod below is
|
|
83
|
+
* the one that actually matters for a stale `.tmp`).
|
|
84
|
+
* 3. Unlink any leftover `${target}.tmp` first (a crashed prior run, or a
|
|
85
|
+
* planted symlink), THEN create it fresh with `flag:'wx'` (O_EXCL) —
|
|
86
|
+
* refuses to silently reuse or follow anything already at that path.
|
|
87
|
+
* 4. Atomic rename tmp -> target.
|
|
88
|
+
* 5. chmodSync(target, 0o600) AFTER the rename. This is the literal fix
|
|
89
|
+
* for GOV-3 finding 1: writeFileSync's `mode` option only applies on
|
|
90
|
+
* CREATION, so a stale `.tmp` that survived a crash at 0644 would
|
|
91
|
+
* rename onto `target` and KEEP 0644, silently falsifying the
|
|
92
|
+
* "readable only by your user account" consent promise. The old
|
|
93
|
+
* `persistSelected` (scripts/providers/index.js) had this exact gap;
|
|
94
|
+
* this writer closes it everywhere at once.
|
|
95
|
+
* Throws (does not swallow) when the home directory could not be resolved
|
|
96
|
+
* (isHomeUnresolved) — callers decide how to surface that; see
|
|
97
|
+
* writeByoConfig/clearProvidersFile below and cmdProvider in
|
|
98
|
+
* bin/auxilo-cli.js, which catches this rather than let a raw stack out
|
|
99
|
+
* (should-fix item 10).
|
|
100
|
+
*/
|
|
101
|
+
function writeProvidersStateAtomic(state, opts = {}) {
|
|
102
|
+
const target = statePath(opts);
|
|
103
|
+
if (isHomeUnresolved(target)) {
|
|
104
|
+
const err = new Error('cannot resolve ~/.auxilo/providers.json — the home directory did not resolve to an absolute path');
|
|
105
|
+
err.reasonCode = 'provider-home-unresolved';
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
109
|
+
if (fs.existsSync(target)) fs.chmodSync(target, 0o600);
|
|
110
|
+
const tmp = `${target}.tmp`;
|
|
111
|
+
try { fs.unlinkSync(tmp); } catch { /* nothing there, or already gone — fine either way */ }
|
|
112
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600, flag: 'wx' });
|
|
113
|
+
fs.renameSync(tmp, target);
|
|
114
|
+
fs.chmodSync(target, 0o600); // the fix: applies even when the rename source was a stale, wider-mode tmp.
|
|
115
|
+
return target;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Read ~/.auxilo/providers.json. Never throws; missing/malformed -> {}. */
|
|
119
|
+
function readProvidersState(opts = {}) {
|
|
120
|
+
try {
|
|
121
|
+
const raw = fs.readFileSync(statePath(opts), 'utf8');
|
|
122
|
+
const parsed = JSON.parse(raw);
|
|
123
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
124
|
+
} catch {
|
|
125
|
+
return {};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The `byo` object, or null when absent/malformed/incomplete. */
|
|
130
|
+
function readByoConfig(opts = {}) {
|
|
131
|
+
const state = readProvidersState(opts);
|
|
132
|
+
const byo = state.byo;
|
|
133
|
+
if (!byo || typeof byo !== 'object' || Array.isArray(byo)) return null;
|
|
134
|
+
if (typeof byo.provider !== 'string' || !byo.provider) return null;
|
|
135
|
+
if (typeof byo.model !== 'string' || !byo.model) return null;
|
|
136
|
+
if (typeof byo.api_key !== 'string' || !byo.api_key) return null;
|
|
137
|
+
return {
|
|
138
|
+
provider: byo.provider,
|
|
139
|
+
base_url: typeof byo.base_url === 'string' && byo.base_url ? byo.base_url : null,
|
|
140
|
+
model: byo.model,
|
|
141
|
+
api_key: byo.api_key,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Write the `byo` object into providers.json, preserving every other
|
|
147
|
+
* top-level key (notably `selected`, written by index.js). 0600 tmp+rename —
|
|
148
|
+
* mirrors lib/installer.js's writeCredentials discipline (that module is not
|
|
149
|
+
* imported here to keep this file's dependency surface HTTP/fs-only, per
|
|
150
|
+
* source discipline; the discipline itself — chmod-then-tmp-write-then-
|
|
151
|
+
* rename, both 0600 — is copied, not the code).
|
|
152
|
+
*
|
|
153
|
+
* @param {{provider:string, base_url?:string|null, model:string, api_key:string}} byoConfig
|
|
154
|
+
*/
|
|
155
|
+
function writeByoConfig(byoConfig, opts = {}) {
|
|
156
|
+
const state = readProvidersState(opts);
|
|
157
|
+
state.byo = {
|
|
158
|
+
provider: byoConfig.provider,
|
|
159
|
+
...(byoConfig.base_url && { base_url: byoConfig.base_url }),
|
|
160
|
+
model: byoConfig.model,
|
|
161
|
+
api_key: byoConfig.api_key,
|
|
162
|
+
};
|
|
163
|
+
return writeProvidersStateAtomic(state, opts);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* `clear` removes only the BYO credentials (the `byo` object) from
|
|
168
|
+
* providers.json — per spec, the `selected` field (written by
|
|
169
|
+
* scripts/providers/index.js's auto-detect path) is preserved, so clearing
|
|
170
|
+
* a BYO key never silently reverts or loses the account's auto-detected
|
|
171
|
+
* provider choice. (W1 integration: this replaces the narrower "delete the
|
|
172
|
+
* whole file" behavior PART C shipped as a flagged deviation.)
|
|
173
|
+
*
|
|
174
|
+
* If nothing is left after `byo` is removed (an empty object — no
|
|
175
|
+
* `selected` and nothing else was ever stored there), the file itself is
|
|
176
|
+
* removed rather than leaving an empty `{}` on disk. Otherwise the file is
|
|
177
|
+
* rewritten with `byo` gone via the same 0600 tmp+rename discipline as
|
|
178
|
+
* writeByoConfig. Never throws.
|
|
179
|
+
*
|
|
180
|
+
* `target` is checked for home-dir resolution before anything else
|
|
181
|
+
* (GOV-3 item 13) — an unresolved home directory returns 'unresolved'
|
|
182
|
+
* rather than guess a location to read/write. A stat/read failure other
|
|
183
|
+
* than ENOENT (e.g. EACCES) returns 'unreadable' rather than rethrow — this
|
|
184
|
+
* function's contract really is "never throws" now (should-fix item 10; the
|
|
185
|
+
* old rethrow on non-ENOENT contradicted this same docblock).
|
|
186
|
+
*
|
|
187
|
+
* @returns {'removed-file'|'removed-byo'|'noop'|'unreadable'|'unresolved'}
|
|
188
|
+
* 'noop' covers both "no file" and "a file with no `byo` key to clear".
|
|
189
|
+
*/
|
|
190
|
+
function clearProvidersFile(opts = {}) {
|
|
191
|
+
const target = statePath(opts);
|
|
192
|
+
if (isHomeUnresolved(target)) return 'unresolved';
|
|
193
|
+
const readFileSyncImpl = typeof opts.readFileSyncImpl === 'function' ? opts.readFileSyncImpl : fs.readFileSync;
|
|
194
|
+
let raw;
|
|
195
|
+
try {
|
|
196
|
+
raw = readFileSyncImpl(target, 'utf8');
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (err && err.code === 'ENOENT') return 'noop';
|
|
199
|
+
return 'unreadable'; // e.g. EACCES — cannot verify contents; never throws (contract)
|
|
200
|
+
}
|
|
201
|
+
let state;
|
|
202
|
+
try {
|
|
203
|
+
state = JSON.parse(raw);
|
|
204
|
+
} catch {
|
|
205
|
+
return 'noop'; // malformed JSON — nothing safely parseable to preserve or clear
|
|
206
|
+
}
|
|
207
|
+
if (!state || typeof state !== 'object' || Array.isArray(state) || !('byo' in state)) {
|
|
208
|
+
return 'noop';
|
|
209
|
+
}
|
|
210
|
+
delete state.byo;
|
|
211
|
+
if (Object.keys(state).length === 0) {
|
|
212
|
+
fs.unlinkSync(target);
|
|
213
|
+
return 'removed-file';
|
|
214
|
+
}
|
|
215
|
+
writeProvidersStateAtomic(state, opts);
|
|
216
|
+
return 'removed-byo';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Routing: which wire vendor a stored `provider` label maps to. */
|
|
220
|
+
function resolveVendor(providerLabel) {
|
|
221
|
+
if (providerLabel === 'anthropic') return 'anthropic';
|
|
222
|
+
if (providerLabel === 'gemini') return 'gemini';
|
|
223
|
+
return 'openai-compatible';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function baseUrlFor(vendor, configured) {
|
|
227
|
+
if (configured) return configured.replace(/\/+$/, '');
|
|
228
|
+
return VENDOR_DEFAULT_BASE_URL[vendor];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* "Owner-read-only" predicate (EXTRACT-PER-CLIENT W1 FIX, PUNCH-LIST P1) —
|
|
233
|
+
* the runtime twin of bin/auxilo-cli.js's `providersFileModeUnsafe`, which
|
|
234
|
+
* only ever ran once, interactively, before `provider set` first wrote the
|
|
235
|
+
* file. That left a gap: if providers.json widens (a stray `chmod`, a
|
|
236
|
+
* umask surprise, manual editing) AFTER setup, detect()/runModel() would
|
|
237
|
+
* still call it "installed" and hand the builder's key to a spawn/fetch
|
|
238
|
+
* under insecure permissions. Duplicated (not imported) for the same reason
|
|
239
|
+
* DEFAULT_PROVIDERS_STATE_PATH above is duplicated — avoids a circular
|
|
240
|
+
* require, since bin/auxilo-cli.js requires this module, not the reverse.
|
|
241
|
+
* No file yet is not unsafe (writeByoConfig always writes 0600 itself); any
|
|
242
|
+
* OTHER stat failure (e.g. EACCES) fails CLOSED (treated as unsafe) rather
|
|
243
|
+
* than silently trusting a permission state it could not verify. Never
|
|
244
|
+
* throws.
|
|
245
|
+
*/
|
|
246
|
+
function isProvidersFileModeUnsafe(opts = {}) {
|
|
247
|
+
const target = statePath(opts);
|
|
248
|
+
if (isHomeUnresolved(target)) return true; // can't even name the file — fail closed
|
|
249
|
+
const statSyncImpl = typeof opts.statSyncImpl === 'function' ? opts.statSyncImpl : fs.statSync;
|
|
250
|
+
let stat;
|
|
251
|
+
try {
|
|
252
|
+
stat = statSyncImpl(target);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
if (err && err.code === 'ENOENT') return false;
|
|
255
|
+
return true; // cannot verify permissions — fail closed, not open
|
|
256
|
+
}
|
|
257
|
+
return (stat.mode & 0o077) !== 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** true iff the configured base_url is present and is NOT https:// (GOV-3
|
|
261
|
+
* item 3 — a plaintext endpoint would send the transcript, and for two of
|
|
262
|
+
* the three vendors the key itself, in cleartext). An absent base_url is
|
|
263
|
+
* fine — the vendor default is always https (VENDOR_DEFAULT_BASE_URL). */
|
|
264
|
+
function isBaseUrlInsecure(baseUrl) {
|
|
265
|
+
if (!baseUrl) return false;
|
|
266
|
+
try {
|
|
267
|
+
return new URL(baseUrl).protocol !== 'https:';
|
|
268
|
+
} catch {
|
|
269
|
+
return true; // unparseable — treat as insecure, not as "no opinion"
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** detect(): true iff a complete BYO config is on disk, its file is
|
|
274
|
+
* actually owner-read-only, the home directory resolved, and any configured
|
|
275
|
+
* base_url is https:// — "usable now", not merely "configured once". */
|
|
276
|
+
function detect(opts = {}) {
|
|
277
|
+
if (isHomeUnresolved(statePath(opts))) return false;
|
|
278
|
+
if (isProvidersFileModeUnsafe(opts)) return false;
|
|
279
|
+
const config = readByoConfig(opts);
|
|
280
|
+
if (!config) return false;
|
|
281
|
+
return !isBaseUrlInsecure(config.base_url);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** BYO has no meaningful local-auth concept — the key IS the auth. */
|
|
285
|
+
function checkAuthStatus() {
|
|
286
|
+
return 'unknown';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function buildRequest(vendor, config, text, opts) {
|
|
290
|
+
const baseUrl = baseUrlFor(vendor, config.base_url);
|
|
291
|
+
if (vendor === 'anthropic') {
|
|
292
|
+
return {
|
|
293
|
+
url: `${baseUrl}/messages`,
|
|
294
|
+
headers: {
|
|
295
|
+
'Content-Type': 'application/json',
|
|
296
|
+
'x-api-key': config.api_key,
|
|
297
|
+
'anthropic-version': ANTHROPIC_API_VERSION,
|
|
298
|
+
},
|
|
299
|
+
body: JSON.stringify({
|
|
300
|
+
model: config.model,
|
|
301
|
+
max_tokens: opts.maxTokens || DEFAULT_MAX_TOKENS,
|
|
302
|
+
messages: [{ role: 'user', content: text }],
|
|
303
|
+
}),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (vendor === 'gemini') {
|
|
307
|
+
// GOV-3 item 5: the key rides the `x-goog-api-key` header, never the URL
|
|
308
|
+
// query string — credentials in URLs land in proxy logs, server access
|
|
309
|
+
// logs, and any future error path that echoes `request.url`.
|
|
310
|
+
return {
|
|
311
|
+
url: `${baseUrl}/models/${encodeURIComponent(config.model)}:generateContent`,
|
|
312
|
+
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': config.api_key },
|
|
313
|
+
body: JSON.stringify({
|
|
314
|
+
contents: [{ role: 'user', parts: [{ text }] }],
|
|
315
|
+
}),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
// openai-compatible (default): OpenAI itself, or any OpenAI-compatible
|
|
319
|
+
// chat-completions endpoint (custom base_url).
|
|
320
|
+
return {
|
|
321
|
+
url: `${baseUrl}/chat/completions`,
|
|
322
|
+
headers: {
|
|
323
|
+
'Content-Type': 'application/json',
|
|
324
|
+
Authorization: `Bearer ${config.api_key}`,
|
|
325
|
+
},
|
|
326
|
+
body: JSON.stringify({
|
|
327
|
+
model: config.model,
|
|
328
|
+
messages: [{ role: 'user', content: text }],
|
|
329
|
+
}),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Extract the model's reply text from each vendor's distinct response shape. */
|
|
334
|
+
function extractText(vendor, data) {
|
|
335
|
+
if (vendor === 'anthropic') {
|
|
336
|
+
const block = Array.isArray(data && data.content) ? data.content.find((b) => b && b.type === 'text') : null;
|
|
337
|
+
return block && typeof block.text === 'string' ? block.text : '';
|
|
338
|
+
}
|
|
339
|
+
if (vendor === 'gemini') {
|
|
340
|
+
const candidate = data && Array.isArray(data.candidates) ? data.candidates[0] : null;
|
|
341
|
+
const parts = candidate && candidate.content && Array.isArray(candidate.content.parts)
|
|
342
|
+
? candidate.content.parts
|
|
343
|
+
: [];
|
|
344
|
+
return parts.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join('');
|
|
345
|
+
}
|
|
346
|
+
const choice = data && Array.isArray(data.choices) ? data.choices[0] : null;
|
|
347
|
+
const content = choice && choice.message && typeof choice.message.content === 'string'
|
|
348
|
+
? choice.message.content
|
|
349
|
+
: '';
|
|
350
|
+
return content;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Extract usage where the vendor reports it; null otherwise (caller falls
|
|
354
|
+
* back to its own text-length estimate — same degrade-not-break contract
|
|
355
|
+
* every other provider follows, per provider.interface.js). */
|
|
356
|
+
function extractUsage(vendor, data) {
|
|
357
|
+
if (!data || typeof data !== 'object') return null;
|
|
358
|
+
if (vendor === 'anthropic' && data.usage) {
|
|
359
|
+
const inputTokens = Number(data.usage.input_tokens) || 0;
|
|
360
|
+
const outputTokens = Number(data.usage.output_tokens) || 0;
|
|
361
|
+
if (!inputTokens && !outputTokens) return null;
|
|
362
|
+
return { input_tokens: inputTokens, output_tokens: outputTokens };
|
|
363
|
+
}
|
|
364
|
+
if (vendor === 'openai-compatible' && data.usage) {
|
|
365
|
+
const inputTokens = Number(data.usage.prompt_tokens) || 0;
|
|
366
|
+
const outputTokens = Number(data.usage.completion_tokens) || 0;
|
|
367
|
+
if (!inputTokens && !outputTokens) return null;
|
|
368
|
+
return { input_tokens: inputTokens, output_tokens: outputTokens };
|
|
369
|
+
}
|
|
370
|
+
// Gemini's usageMetadata uses yet another field naming; folded in defensively.
|
|
371
|
+
if (vendor === 'gemini' && data.usageMetadata) {
|
|
372
|
+
const inputTokens = Number(data.usageMetadata.promptTokenCount) || 0;
|
|
373
|
+
const outputTokens = Number(data.usageMetadata.candidatesTokenCount) || 0;
|
|
374
|
+
if (!inputTokens && !outputTokens) return null;
|
|
375
|
+
return { input_tokens: inputTokens, output_tokens: outputTokens };
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// GOV-3 should-fix item 8: cap the response body at 2MB, in both directions
|
|
381
|
+
// — a Content-Length that already exceeds the cap is rejected without
|
|
382
|
+
// reading a byte, and a body with no (or a lying) Content-Length is read in
|
|
383
|
+
// chunks with a running byte count, aborted the moment it crosses the cap.
|
|
384
|
+
// Without this, a stalling or oversized endpoint hangs or OOMs an
|
|
385
|
+
// unattended session-end hook.
|
|
386
|
+
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
387
|
+
const RESPONSE_TOO_LARGE = Symbol('provider-response-too-large');
|
|
388
|
+
|
|
389
|
+
function headerContentLength(res) {
|
|
390
|
+
try {
|
|
391
|
+
if (res && res.headers && typeof res.headers.get === 'function') {
|
|
392
|
+
const raw = res.headers.get('content-length');
|
|
393
|
+
const n = Number(raw);
|
|
394
|
+
return Number.isFinite(n) ? n : null;
|
|
395
|
+
}
|
|
396
|
+
} catch { /* no headers on this response — fall through to the byte-counted read */ }
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Read `res` as JSON with the 2MB cap enforced. Prefers a byte-counted
|
|
402
|
+
* streaming read (undici's real fetch Response always exposes `res.body` as
|
|
403
|
+
* a ReadableStream) so a body with no/lying Content-Length is still capped;
|
|
404
|
+
* falls back to `res.json()` when the response has no stream body at all —
|
|
405
|
+
* covers this repo's test fixtures, which hand back a plain
|
|
406
|
+
* `{ok,status,json}` object, not a real Response.
|
|
407
|
+
*/
|
|
408
|
+
async function readBoundedJson(res) {
|
|
409
|
+
const declaredLength = headerContentLength(res);
|
|
410
|
+
if (declaredLength !== null && declaredLength > MAX_RESPONSE_BYTES) {
|
|
411
|
+
const err = new Error(`response Content-Length ${declaredLength} exceeds the ${MAX_RESPONSE_BYTES}-byte cap`);
|
|
412
|
+
err[RESPONSE_TOO_LARGE] = true;
|
|
413
|
+
throw err;
|
|
414
|
+
}
|
|
415
|
+
if (!res || !res.body || typeof res.body.getReader !== 'function') {
|
|
416
|
+
return res.json();
|
|
417
|
+
}
|
|
418
|
+
const reader = res.body.getReader();
|
|
419
|
+
let total = 0;
|
|
420
|
+
const chunks = [];
|
|
421
|
+
for (;;) {
|
|
422
|
+
// eslint-disable-next-line no-await-in-loop
|
|
423
|
+
const { done, value } = await reader.read();
|
|
424
|
+
if (done) break;
|
|
425
|
+
total += value ? value.byteLength : 0;
|
|
426
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
427
|
+
try { await reader.cancel(); } catch { /* best-effort */ }
|
|
428
|
+
const err = new Error(`response body exceeded the ${MAX_RESPONSE_BYTES}-byte cap (byte-counted read)`);
|
|
429
|
+
err[RESPONSE_TOO_LARGE] = true;
|
|
430
|
+
throw err;
|
|
431
|
+
}
|
|
432
|
+
if (value) chunks.push(value);
|
|
433
|
+
}
|
|
434
|
+
const text = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
|
|
435
|
+
return JSON.parse(text);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* runModel(opts) — the provider.interface.js contract. `prompt`/`input`
|
|
440
|
+
* combine exactly like every other provider (`prompt + (input || '')`, the
|
|
441
|
+
* full instruction). No retries — a 429 or 5xx returns immediately with a
|
|
442
|
+
* matching reasonCode; the caller (extract-local.js) treats a failed
|
|
443
|
+
* provider as a skip, never as something to hammer.
|
|
444
|
+
*
|
|
445
|
+
* The key is READ from disk and used in exactly two places: the
|
|
446
|
+
* Authorization/x-api-key/x-goog-api-key header — Gemini moved off the URL
|
|
447
|
+
* query string, GOV-3 item 5, so ALL three vendors now carry the key in a
|
|
448
|
+
* header only. It is never interpolated into any log/console call in this
|
|
449
|
+
* module — a build-time grep test (test/byo-key-provider.test.js) asserts
|
|
450
|
+
* that no console or log call in this file's source can carry it.
|
|
451
|
+
*
|
|
452
|
+
* The abort timer is kept alive through the ENTIRE call, including the body
|
|
453
|
+
* read — GOV-3 should-fix item 8's other half: the old code cleared it in
|
|
454
|
+
* the `finally` of the fetch await, which resolves at headers, leaving
|
|
455
|
+
* `res.json()` unbounded in time. `clearTimer()` below runs on every exit
|
|
456
|
+
* path via the outer try/finally.
|
|
457
|
+
*/
|
|
458
|
+
async function runModel(opts = {}) {
|
|
459
|
+
const target = statePath(opts);
|
|
460
|
+
if (isHomeUnresolved(target)) {
|
|
461
|
+
return {
|
|
462
|
+
ok: false,
|
|
463
|
+
text: '',
|
|
464
|
+
usage: null,
|
|
465
|
+
reason: 'cannot resolve ~/.auxilo/providers.json — the home directory did not resolve to an absolute path',
|
|
466
|
+
reasonCode: 'provider-home-unresolved',
|
|
467
|
+
authStatus: 'unknown',
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
// Checked BEFORE config completeness — a widened-permissions file is
|
|
471
|
+
// refused even if it happens to hold a complete config; defense in depth
|
|
472
|
+
// alongside detect()'s own gate above (this module's runModel may also be
|
|
473
|
+
// reached directly via an AUXILO_EXTRACTION_PROVIDER override, which never
|
|
474
|
+
// calls detect() at all).
|
|
475
|
+
if (isProvidersFileModeUnsafe(opts)) {
|
|
476
|
+
return {
|
|
477
|
+
ok: false,
|
|
478
|
+
text: '',
|
|
479
|
+
usage: null,
|
|
480
|
+
reason: '~/.auxilo/providers.json is not owner-read-only; refusing to use the stored key until its permissions are fixed (chmod 600 ~/.auxilo/providers.json) or the file is removed (`auxilo provider clear`)',
|
|
481
|
+
reasonCode: 'providers-file-mode-unsafe',
|
|
482
|
+
authStatus: 'unknown',
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
const config = readByoConfig(opts);
|
|
486
|
+
if (!config) {
|
|
487
|
+
return {
|
|
488
|
+
ok: false,
|
|
489
|
+
text: '',
|
|
490
|
+
usage: null,
|
|
491
|
+
reason: 'no BYO provider key configured — run `auxilo provider set`',
|
|
492
|
+
reasonCode: 'provider-not-configured',
|
|
493
|
+
authStatus: 'unknown',
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
// GOV-3 item 3: same override the CLI enforces at `set` time, re-checked
|
|
497
|
+
// here at read time — this module's runModel can be reached directly
|
|
498
|
+
// (AUXILO_EXTRACTION_PROVIDER override) without ever going through
|
|
499
|
+
// detect(), and a config could have been hand-edited on disk since `set`.
|
|
500
|
+
if (isBaseUrlInsecure(config.base_url)) {
|
|
501
|
+
return {
|
|
502
|
+
ok: false,
|
|
503
|
+
text: '',
|
|
504
|
+
usage: null,
|
|
505
|
+
reason: `configured base_url "${config.base_url}" is not https:// — refusing to send the transcript or the key over an insecure connection`,
|
|
506
|
+
reasonCode: 'provider-base-url-insecure',
|
|
507
|
+
authStatus: 'unknown',
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const vendor = resolveVendor(config.provider);
|
|
511
|
+
const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
|
|
512
|
+
const text = prompt + String(opts.input || '');
|
|
513
|
+
const request = buildRequest(vendor, config, text, opts);
|
|
514
|
+
const fetchImpl = typeof opts.fetchImpl === 'function' ? opts.fetchImpl : fetch;
|
|
515
|
+
const timeoutMs = opts.timeoutMs || 120000;
|
|
516
|
+
|
|
517
|
+
const identity = { provider: 'byo-key', model: config.model, version: null, vendor };
|
|
518
|
+
|
|
519
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
520
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
521
|
+
const clearTimer = () => { if (timer) clearTimeout(timer); };
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
let res;
|
|
525
|
+
try {
|
|
526
|
+
res = await fetchImpl(request.url, {
|
|
527
|
+
method: 'POST',
|
|
528
|
+
headers: request.headers,
|
|
529
|
+
body: request.body,
|
|
530
|
+
// GOV-3 item 4: a cross-host redirect would re-send the transcript
|
|
531
|
+
// (and, for two of three vendors, the key) to whatever the redirect
|
|
532
|
+
// target is, and accept its reply as the model's answer. One line.
|
|
533
|
+
redirect: 'error',
|
|
534
|
+
...(controller && { signal: controller.signal }),
|
|
535
|
+
});
|
|
536
|
+
} catch (error) {
|
|
537
|
+
return {
|
|
538
|
+
ok: false,
|
|
539
|
+
text: '',
|
|
540
|
+
usage: null,
|
|
541
|
+
reason: `BYO provider request failed: ${error.message}`,
|
|
542
|
+
reasonCode: 'provider-error',
|
|
543
|
+
authStatus: 'unknown',
|
|
544
|
+
identity,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (res.status === 429) {
|
|
549
|
+
return {
|
|
550
|
+
ok: false,
|
|
551
|
+
text: '',
|
|
552
|
+
usage: null,
|
|
553
|
+
reason: 'BYO provider rate-limited the request (HTTP 429)',
|
|
554
|
+
reasonCode: 'provider-rate-limited',
|
|
555
|
+
authStatus: 'unknown',
|
|
556
|
+
identity,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
if (!res.ok) {
|
|
560
|
+
return {
|
|
561
|
+
ok: false,
|
|
562
|
+
text: '',
|
|
563
|
+
usage: null,
|
|
564
|
+
reason: `BYO provider returned HTTP ${res.status}`,
|
|
565
|
+
reasonCode: 'provider-error',
|
|
566
|
+
authStatus: 'unknown',
|
|
567
|
+
identity,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
let data;
|
|
572
|
+
try {
|
|
573
|
+
data = await readBoundedJson(res);
|
|
574
|
+
} catch (error) {
|
|
575
|
+
if (error && error[RESPONSE_TOO_LARGE]) {
|
|
576
|
+
return {
|
|
577
|
+
ok: false,
|
|
578
|
+
text: '',
|
|
579
|
+
usage: null,
|
|
580
|
+
reason: `BYO provider response exceeded the ${MAX_RESPONSE_BYTES}-byte cap`,
|
|
581
|
+
reasonCode: 'provider-response-too-large',
|
|
582
|
+
authStatus: 'unknown',
|
|
583
|
+
identity,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
ok: false,
|
|
588
|
+
text: '',
|
|
589
|
+
usage: null,
|
|
590
|
+
reason: `BYO provider returned a non-JSON body: ${error.message}`,
|
|
591
|
+
reasonCode: 'provider-error',
|
|
592
|
+
authStatus: 'unknown',
|
|
593
|
+
identity,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return {
|
|
598
|
+
ok: true,
|
|
599
|
+
text: extractText(vendor, data),
|
|
600
|
+
usage: extractUsage(vendor, data),
|
|
601
|
+
reason: null,
|
|
602
|
+
authStatus: 'unknown',
|
|
603
|
+
identity,
|
|
604
|
+
};
|
|
605
|
+
} finally {
|
|
606
|
+
clearTimer();
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
module.exports = {
|
|
611
|
+
runModel,
|
|
612
|
+
detect,
|
|
613
|
+
checkAuthStatus,
|
|
614
|
+
readProvidersState,
|
|
615
|
+
readByoConfig,
|
|
616
|
+
writeByoConfig,
|
|
617
|
+
clearProvidersFile,
|
|
618
|
+
resolveVendor,
|
|
619
|
+
DEFAULT_PROVIDERS_STATE_PATH,
|
|
620
|
+
// Exported for direct unit coverage (test/byo-key-provider.test.js,
|
|
621
|
+
// test/extract-w1-fix2.test.js) AND for scripts/providers/index.js, which
|
|
622
|
+
// routes persistSelected's write through writeProvidersStateAtomic (ONE
|
|
623
|
+
// writer for every providers.json write, GOV-3 item 1) and checks
|
|
624
|
+
// isProvidersFileModeUnsafe before trusting a persisted `selected` value
|
|
625
|
+
// it reads (GOV-3 item 2, "read too, not just write").
|
|
626
|
+
isProvidersFileModeUnsafe,
|
|
627
|
+
isHomeUnresolved,
|
|
628
|
+
isBaseUrlInsecure,
|
|
629
|
+
writeProvidersStateAtomic,
|
|
630
|
+
MAX_RESPONSE_BYTES,
|
|
631
|
+
};
|