Files
stack/packages/model-proxy/src/client.mjs
T

151 lines
8.9 KiB
JavaScript

// Bus-only in-memory management client. Values never enter receipts or errors.
import http from 'node:http';
import { ProxyError, refuse, sha256, regularBytes, validManagementKey } from './common.mjs';
import { lstatSync } from 'node:fs';
import { join } from 'node:path';
const KEY_PATH = '/v8/management/config/access/api-keys';
const validKeys = value => Array.isArray(value) && value.length <= 100000 && value.every(k => typeof k === 'string' && k.length > 0 && k.length <= 4096 && !/[\r\n\x00]/.test(k));
export class ManagementClient {
#base; #key; #receipt; #timeout;
constructor({ baseUrl, key, receipt = () => {}, timeout = 5000 }) {
let u; try { u = new URL(baseUrl); } catch { refuse('proxy-client-invalid'); }
if (u.protocol !== 'http:' || u.hostname !== '127.0.0.1' || !u.port || Number(u.port) < 1024 || u.port === '8317'
|| u.username || u.password || u.pathname !== '/' || u.search || u.hash
|| !validManagementKey(key)
|| typeof receipt !== 'function' || !Number.isSafeInteger(timeout) || timeout <= 0) refuse('proxy-client-invalid');
this.#base = u.origin; this.#key = key; this.#receipt = receipt; this.#timeout = timeout;
}
#request(method, endpoint, payload, onStatus) {
// node:http addresses the fixed numeric loopback directly. An ambient fetch
// dispatcher or HTTP_PROXY cannot send the Authorization header elsewhere.
let deadline;
return new Promise((resolve, reject) => {
let received = false;
const req = http.request(this.#base + endpoint, {
method, headers: { Authorization: `Bearer ${this.#key}`, ...(payload !== undefined ? { 'Content-Type': 'application/json' } : {}) },
}, response => {
received = true; onStatus(response.statusCode);
const error = response.statusCode >= 300 && response.statusCode < 400 ? 'proxy-client-redirect'
: response.statusCode < 200 || response.statusCode >= 300 ? 'proxy-client-refused' : null;
if (error) { response.destroy(); reject(new ProxyError(error)); return; }
let size = 0; const chunks = [];
response.on('data', chunk => {
size += chunk.length;
if (size > 8 * 1024 * 1024) { response.destroy(); reject(new ProxyError('proxy-client-response')); }
else chunks.push(chunk);
});
response.on('aborted', () => reject(new ProxyError('proxy-client-response')));
response.on('error', () => reject(new ProxyError('proxy-client-response')));
response.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks))); }
catch { reject(new ProxyError('proxy-client-response')); }
});
});
req.on('error', () => reject(new ProxyError(received ? 'proxy-client-response' : 'proxy-client-down')));
deadline = setTimeout(() => { req.destroy(); reject(new ProxyError('proxy-client-down')); }, this.#timeout);
req.end(payload === undefined ? undefined : JSON.stringify(payload));
}).finally(() => clearTimeout(deadline));
}
async #call(method, keys) {
let status = 0, ids = [];
try {
const body = await this.#request(method, KEY_PATH, keys, value => { status = value; });
if (method === 'GET') {
if (!validKeys(body)) refuse('proxy-client-response');
ids = body.map(sha256).map(h => h.slice(0, 12));
return body;
}
if (!body || body.status !== 'ok') refuse('proxy-client-response');
ids = keys.map(sha256).map(h => h.slice(0, 12));
return { ok: true };
} finally { this.#receipt({ call: `${method} ${KEY_PATH}`, status, keyIds: ids }); }
}
getKeys() { return this.#call('GET'); }
putKeys(keys) { if (!validKeys(keys)) refuse('proxy-client-invalid'); return this.#call('PUT', keys); }
// Fixed management operations for the bus's native page. Queries contain login
// states; neither queries nor request/response bodies belong in a receipt.
async #page(method, endpoint, payload, expectedStatus = null) {
let status = 0;
try {
const answer = await this.#request(method, endpoint, payload, value => { status = value; });
if (expectedStatus !== null && status !== expectedStatus) refuse('proxy-client-refused');
return answer;
} catch (error) {
if (method === 'GET' && endpoint === '/v8/management/config/api-keys/claude' && status === 404) return [];
throw error;
}
finally { this.#receipt({ call: `${method} ${endpoint.split('?')[0]}`, status, keyIds: [] }); }
}
// The queue drains at most 100 events. Credential-bearing strings exist only
// inside this bounded read, never in a plug-in result, receipt, store or view.
async readUsageQueue() {
const events = await this.#page('GET', '/v8/management/observability/usage/queue?count=100');
if (!Array.isArray(events) || events.length > 100) refuse('proxy-client-response');
const string = v => typeof v === 'string' && v.length > 0 && v.length <= 4096 && !/[\r\n\x00]/.test(v);
const amount = v => v === undefined ? 0 : Number.isSafeInteger(v) && v >= 0 ? v : refuse('proxy-client-response');
return events.map(event => {
if (!event || !string(event.api_key) || !string(event.source) || typeof event.failed !== 'boolean'
|| !event.tokens || typeof event.tokens !== 'object' || Array.isArray(event.tokens)) refuse('proxy-client-response');
const statusCode = event.fail?.status_code ?? null;
if (statusCode !== null && (!Number.isSafeInteger(statusCode) || statusCode < 0 || statusCode > 599)) refuse('proxy-client-response');
return { keyId: sha256(event.api_key).slice(0, 12), accountId: sha256(event.source).slice(0, 12),
managed: event.api_key.startsWith('msk-'), failed: event.failed, statusCode,
input: amount(event.tokens.input_tokens), output: amount(event.tokens.output_tokens),
cacheWrite: amount(event.tokens.cache_creation_tokens), cacheRead: amount(event.tokens.cache_read_tokens) };
});
}
getCredentials() { return this.#page('GET', '/v8/management/credentials'); }
getCredentialModels(name) {
if (typeof name !== 'string' || !name || name.length > 512 || /[\r\n\x00]/.test(name)) refuse('proxy-client-invalid');
return this.#page('GET', `/v8/management/credentials/models?name=${encodeURIComponent(name)}`);
}
getUpstreamGroups() { return this.#page('GET', '/v8/management/config/api-keys/claude'); }
putUpstreamGroups(groups) {
// auth_index is derived response metadata, not writable provider config.
const payload = groups.map(group => ({ ...group, keys: group.keys.map(({ auth_index, ...key }) => key) }));
return this.#page('PUT', '/v8/management/config/api-keys/claude', payload, 200);
}
patchCredential(name, priority) { return this.#page('PATCH', '/v8/management/credentials/fields', { name, priority }, 200); }
oauthStart(provider) {
if (!['claude', 'codex'].includes(provider)) refuse('proxy-client-invalid');
return this.#page('GET', `/v8/management/oauth/auth-url?provider=${provider}`);
}
oauthCallback(redirect_url) { return this.#page('POST', '/v8/management/oauth/callback', { redirect_url }); }
oauthStatus(state) { return this.#page('GET', `/v8/management/oauth/status?state=${encodeURIComponent(state)}`); }
oauthCancel(state) { return this.#page('DELETE', `/v8/management/oauth/session?state=${encodeURIComponent(state)}`); }
// Client-key authentication only. Never send the management key to the model route,
// follow a Location, consume/log the response body or trust an ambient dispatcher.
async probeKey(key, { timeout = this.#timeout } = {}) {
if (!validManagementKey(key) || !Number.isFinite(timeout) || timeout <= 0) refuse('proxy-client-invalid');
let timer, status = 0;
try {
return await new Promise((resolve, reject) => {
const req = http.request(this.#base + '/v1/models', { method: 'GET', headers: { Authorization: `Bearer ${key}` } }, response => {
status = response.statusCode;
response.destroy();
resolve(status);
});
req.on('error', () => reject(new ProxyError('proxy-down')));
timer = setTimeout(() => { req.destroy(); reject(new ProxyError('proxy-down')); }, timeout);
req.end();
});
} finally {
clearTimeout(timer);
this.#receipt({ call: 'GET /v1/models', status, keyIds: [sha256(key).slice(0, 12)] });
}
}
}
// Trusted bus boot only: no CLI/Console/session exports this value or client.
export async function loadManagementClient({ configDir, proxy, receipt }) {
const file = join(configDir, 'secrets', 'proxy-management.key');
let key;
try {
const stat = lstatSync(file);
if ((stat.mode & 0o777) !== 0o600 || !stat.isFile() || stat.isSymbolicLink()) refuse('proxy-management-key-invalid');
key = regularBytes(file, 'proxy-management-key-invalid').toString('utf8');
if (!validManagementKey(key)) refuse('proxy-management-key-invalid');
} catch { refuse('proxy-management-key-invalid'); }
return new ManagementClient({ baseUrl: `http://127.0.0.1:${proxy.port}`, key, receipt });
}