Spaces:
Paused
Paused
File size: 14,882 Bytes
a875dec b814c28 a875dec b814c28 a875dec b814c28 a875dec | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | /** Metered, provider-neutral completion transport. No environment loading or funding fallback. */
import fs from 'node:fs';
import path from 'node:path';
import {createHash, randomUUID} from 'node:crypto';
import type {ForecastCompletion} from './coherence.ts';
export type Rates = {input: number; output: number}; // USD per million tokens
export type CompletionTrace = {
id: string; model: string; stage: string; started_at: string; finished_at?: string;
status: 'reserved' | 'complete' | 'error'; input: {system: string; user: string};
max_tokens: number; reservation_usd: number; accounted_usd: number;
response?: string; error?: string; http_status?: number; finish_reason?: string;
usage?: {prompt_tokens: number; completion_tokens: number; cost?: number};
};
type Ledger = {schema_version: 1; spent_usd: number; calls: CompletionTrace[]};
export class RuntimeError extends Error {
readonly kind: 'budget' | 'transport' | 'provider' | 'output' | 'configuration';
constructor(message: string, kind: RuntimeError['kind']) {
super(message); this.name = 'RuntimeError'; this.kind = kind;
}
}
export const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex');
const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0;
export function atomicJson(filename: string, value: unknown): void {
fs.mkdirSync(path.dirname(path.resolve(filename)), {recursive: true});
const temp = `${filename}.${randomUUID()}.tmp`;
try { fs.writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', {mode: 0o600, flag: 'wx'}); fs.renameSync(temp, filename); }
finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
}
export type RuntimeOptions = {
model: string; apiKey: string; endpoint?: string; rates: Rates;
maxCost: number; ledger: string; timeoutMs?: number; idleTimeoutMs?: number;
reasoning?: boolean; maxOutputTokens?: number;
/** Dependency injection for offline tests or an explicitly configured local provider. */
fetch?: typeof fetch;
};
/** Read-only discovery. Price caps remain explicit; discovery is not spending authorization. */
export async function openRouterModel(model: string, request = fetch, options: {textOnly?: boolean} = {}): Promise<{rates: Rates; reasoning: boolean; context: number}> {
const response = await request('https://openrouter.ai/api/v1/models', {signal: AbortSignal.timeout(20000)});
if (!response.ok) throw new RuntimeError(`Model catalog HTTP ${response.status}`, 'configuration');
const data = await response.json() as {data?: Array<Record<string, any>>};
const item = data.data?.find(x => x.id === model);
if (!item) throw new RuntimeError(`Model is absent from the provider catalog: ${model}`, 'configuration');
const prices = item.pricing ?? {};
let input = Number(prices.prompt) * 1e6, output = Number(prices.completion) * 1e6;
if (!finite(input) || !finite(output)) throw new RuntimeError('Model prices are unavailable', 'configuration');
// Reserve the highest time/context tier, even when catalog discovery happens off-peak.
for (const tier of prices.overrides ?? []) {
const tierInput = Number(tier.prompt ?? prices.prompt) * 1e6;
const tierOutput = Number(tier.completion ?? prices.completion) * 1e6;
if (!finite(tierInput) || !finite(tierOutput)) throw new RuntimeError('Invalid tier prices', 'configuration');
input = Math.max(input, tierInput); output = Math.max(output, tierOutput);
}
const tokenFields = ['prompt', 'completion', 'input_cache_read', 'input_cache_write', 'input_cache_write_1h', 'discount', 'overrides'];
// These features are never sent by the text-only Space (no tools, images, audio or plugins).
const optionalFeatures = ['web_search', 'image', 'audio', 'input_audio_cache', 'image_output', 'audio_output'];
for (const [name, amount] of Object.entries(prices)) {
if (tokenFields.includes(name) || (options.textOnly && optionalFeatures.includes(name))) continue;
if (options.textOnly && name === 'internal_reasoning') {
const rate = Number(amount) * 1e6;
if (!finite(rate)) throw new RuntimeError('Invalid reasoning price', 'configuration');
output = Math.max(output, rate); continue;
}
if (Number(amount) > 0) throw new RuntimeError(`Unsupported additional model charge: ${name}`, 'configuration');
}
return {rates: {input, output}, reasoning: item.supported_parameters?.includes('reasoning') === true,
context: Number(item.context_length) || 0};
}
export class MeteredRuntime {
private readonly options: RuntimeOptions;
private ledger: Ledger;
private lock: number;
private closed = false;
private busy = false;
private stopped = false;
private readonly request: typeof fetch;
private readonly url: URL;
readonly traceStart: number;
constructor(options: RuntimeOptions) {
this.options = options;
if (!options.model.trim() || !options.apiKey.trim()) throw new RuntimeError('A model and personal API key are required', 'configuration');
if (!finite(options.maxCost) || options.maxCost <= 0 || !finite(options.rates.input) || !finite(options.rates.output))
throw new RuntimeError('Set a positive cumulative spending ceiling and nonnegative token rates', 'configuration');
this.url = new URL(options.endpoint ?? 'https://openrouter.ai/api/v1/chat/completions');
if (this.url.username || this.url.password || (this.url.protocol !== 'https:' && !(this.url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(this.url.hostname))))
throw new RuntimeError('Provider URL must use HTTPS (HTTP allowed only for an explicit local provider)', 'configuration');
this.request = options.fetch ?? fetch;
fs.mkdirSync(path.dirname(path.resolve(options.ledger)), {recursive: true});
this.lock = fs.openSync(options.ledger + '.lock', 'wx', 0o600);
try {
this.ledger = fs.existsSync(options.ledger) ? JSON.parse(fs.readFileSync(options.ledger, 'utf8')) : {schema_version: 1, spent_usd: 0, calls: []};
const l = this.ledger;
if (l.schema_version !== 1 || !finite(l.spent_usd) || !Array.isArray(l.calls) || l.calls.some(c => !finite(c.accounted_usd)) ||
Math.abs(l.calls.reduce((sum, c) => sum + c.accounted_usd, 0) - l.spent_usd) > 1e-8)
throw new RuntimeError('Invalid budget ledger; do not reset it to recover allowance', 'configuration');
this.traceStart = l.calls.length;
this.save();
} catch (error) { fs.closeSync(this.lock); fs.unlinkSync(options.ledger + '.lock'); throw error; }
}
private save() { atomicJson(this.options.ledger, this.ledger); }
get spent(): number { return this.ledger.spent_usd; }
get traces(): CompletionTrace[] { return structuredClone(this.ledger.calls.slice(this.traceStart)); }
get terminal(): boolean { return this.stopped; }
close(): void {
if (this.busy) throw new RuntimeError('Cannot close during an active completion', 'configuration');
if (!this.closed) { this.closed = true; fs.closeSync(this.lock); fs.unlinkSync(this.options.ledger + '.lock'); }
}
readonly complete: ForecastCompletion = async (system, user, stage, requested) => {
if (this.closed || this.busy || this.stopped) throw new RuntimeError('Runtime closed, busy, or stopped after an uncertain charge', 'configuration');
// Reasoning consumes the same output allowance; a tiny review cap can starve the answer.
const max = Math.min(this.options.reasoning ? Math.max(requested,8400) : requested, this.options.maxOutputTokens ?? 8400);
if (!Number.isInteger(max) || max <= 0) throw new RuntimeError('Invalid output-token ceiling', 'configuration');
const messages = [{role: 'system', content: system}, {role: 'user', content: user}];
const bytes = Buffer.byteLength(JSON.stringify(messages), 'utf8');
if (bytes > 300000) throw new RuntimeError('Input exceeds the executor message limit', 'configuration');
// A byte bound plus framing allowance deliberately over-reserves ordinary text tokenization.
const reserve = ((bytes + 4096) * this.options.rates.input + max * this.options.rates.output) / 1e6;
if (this.spent + reserve > this.options.maxCost + 1e-12) throw new RuntimeError('Cumulative spending ceiling reached before request', 'budget');
const trace: CompletionTrace = {id: randomUUID(), model: this.options.model, stage, started_at: new Date().toISOString(),
input: {system, user}, max_tokens: max, reservation_usd: reserve, accounted_usd: reserve, status: 'reserved'};
this.ledger.calls.push(trace); this.ledger.spent_usd += reserve; this.save();
this.busy = true;
const controller = new AbortController();
const totalTimer = setTimeout(() => controller.abort(new Error('Total completion timeout')), this.options.timeoutMs ?? 240000);
let idleTimer: ReturnType<typeof setTimeout>;
const touch = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => controller.abort(new Error('Completion stream idle timeout')), this.options.idleTimeoutMs ?? 60000); };
touch();
let bodyComplete = false;
let reportedUsage: any;
let output = '';
const consume = (data: any) => {
if (data.error) throw new RuntimeError(`Provider error: ${JSON.stringify(data.error).slice(0, 1600)}`, 'provider');
if (data.usage) reportedUsage = data.usage;
const choice = data.choices?.[0];
if (typeof choice?.delta?.content === 'string') output += choice.delta.content;
if (typeof choice?.message?.content === 'string') output = choice.message.content;
if (choice?.finish_reason) trace.finish_reason = choice.finish_reason;
if (output.length > 2_000_000) throw new RuntimeError('Completion exceeded response size limit', 'output');
};
try {
const body: Record<string, unknown> = {model: this.options.model, messages, max_tokens: max, stream: true, stream_options: {include_usage: true}};
if (this.url.hostname === 'openrouter.ai') {
body.provider = {allow_fallbacks: false, require_parameters: true,
max_price: {prompt: this.options.rates.input, completion: this.options.rates.output}};
// Never disable a model's required reasoning on contract/review stages.
if (this.options.reasoning) body.reasoning = {enabled: true};
}
const response = await this.request(this.url, {method: 'POST', signal: controller.signal,
headers: {Authorization: `Bearer ${this.options.apiKey}`, 'Content-Type': 'application/json'}, body: JSON.stringify(body)});
trace.http_status = response.status;
touch();
const readBounded=async()=>{
if(!response.body)throw new RuntimeError('Provider returned no body','transport');
const reader=response.body.getReader(),chunks:Uint8Array[]=[];let size=0;
try{for(;;){const {done,value}=await reader.read();if(done)break;touch();size+=value.length;if(size>2_000_000)throw new RuntimeError('Provider body too large','transport');chunks.push(value);}}
finally{await reader.cancel().catch(()=>{});reader.releaseLock();}
return Buffer.concat(chunks).toString('utf8');
};
if (!response.ok) {
const text = (await readBounded()).slice(0, 3000); bodyComplete = true;
throw new RuntimeError(`Provider HTTP ${response.status}: ${text}`, 'provider');
}
if (response.headers.get('content-type')?.includes('text/event-stream')) {
if (!response.body) throw new RuntimeError('Provider returned no response stream', 'transport');
const reader = response.body.getReader(), decoder = new TextDecoder();
let buffer = '', doneMarker = false;
const processEvent = (event: string) => {
const payload = event.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trim()).join('\n');
if (!payload) return;
if (payload === '[DONE]') { doneMarker = true; return; }
consume(JSON.parse(payload));
};
try {
for (;;) {
const {done, value} = await reader.read();
if (done) break;
touch(); buffer = (buffer + decoder.decode(value, {stream: true})).replace(/\r\n/g, '\n');
if (buffer.length > 2_000_000) throw new RuntimeError('Provider stream event too large', 'transport');
let boundary: number;
while ((boundary = buffer.indexOf('\n\n')) >= 0) { processEvent(buffer.slice(0, boundary)); buffer = buffer.slice(boundary + 2); }
}
buffer += decoder.decode(); if (buffer.trim()) processEvent(buffer.trim());
bodyComplete = doneMarker || Boolean(trace.finish_reason);
if (!bodyComplete) throw new RuntimeError('Provider stream ended before a completion marker', 'transport');
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
} else { consume(JSON.parse(await readBounded())); bodyComplete = true; }
trace.response = output;
if (trace.finish_reason === 'length' || trace.finish_reason === 'content_filter')
throw new RuntimeError(`Unusable completion: ${trace.finish_reason}`, 'output');
if (!output.trim()) throw new RuntimeError('Provider produced no answer text', 'output');
trace.status = 'complete';
return output.trim();
} catch (error) {
trace.status = 'error'; trace.response = output;
trace.error = String(error).split(this.options.apiKey).join('[REDACTED]');
// Unknown/partial charges must remain reserved; stop rather than multiply them.
if (!bodyComplete || [401, 402, 403, 429].includes(trace.http_status ?? 0)) this.stopped = true;
throw new RuntimeError(trace.error, error instanceof RuntimeError ? error.kind : 'transport');
} finally {
clearTimeout(totalTimer); clearTimeout(idleTimer!);
if (reportedUsage && finite(reportedUsage.prompt_tokens) && finite(reportedUsage.completion_tokens)) {
trace.usage = {prompt_tokens: reportedUsage.prompt_tokens, completion_tokens: reportedUsage.completion_tokens,
...(finite(reportedUsage.cost) ? {cost: reportedUsage.cost} : {})};
const accounted = Math.max((reportedUsage.prompt_tokens * this.options.rates.input + reportedUsage.completion_tokens * this.options.rates.output) / 1e6,
finite(reportedUsage.cost) ? reportedUsage.cost : 0);
// Only release a reservation after a complete response with actual usage.
if (bodyComplete) { this.ledger.spent_usd += accounted - trace.accounted_usd; trace.accounted_usd = accounted; }
else if (accounted > trace.accounted_usd) { this.ledger.spent_usd += accounted - trace.accounted_usd; trace.accounted_usd = accounted; }
if (accounted > reserve + 1e-9) this.stopped = true;
}
trace.finished_at = new Date().toISOString(); this.busy = false; this.save();
}
};
}
|