Repository navigation
Expand file tree
/
Copy pathmodel-provider.mjs
More file actions
262 lines (240 loc) · 15.3 KB
/
Copy pathmodel-provider.mjs
File metadata and controls
262 lines (240 loc) · 15.3 KB
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
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
// lib/model-provider.mjs — one chat interface over a local model server and, when the operator
// configures one, a hosted API. Local is the default and first in PROVIDER_ORDER; a hosted provider
// is used only when CW_MODEL_PROVIDER names it, and a failure there is reported, never retried
// against another provider. Every answer carries `answeredBy` so a recorded result is attributable.
//
// env, read at call time: CW_MODEL_PROVIDER (local, openai-compatible or anthropic; default local) ·
// CW_MODEL_BASE_URL (API root of a hosted provider) · CW_MODEL_NAME (model id; required for
// openai-compatible, default claude-opus-5-5 for anthropic) · CW_MODEL_API_KEY (key for a hosted
// provider, sent only to its host) · CW_MODEL_TIMEOUT_MS (bound on one call; default 600 s local,
// 300 s hosted) · ANTHROPIC_API_KEY is accepted only while the base is Anthropic's own host
import http from 'node:http';
import https from 'node:https';
import { reachOf } from '../monitor/llm-runtime.mjs';
export const PROVIDER_ORDER = Object.freeze(['local', 'openai-compatible', 'anthropic']);
export const ANTHROPIC_BASE_URL = 'https://api.anthropic.com';
export const ANTHROPIC_DEFAULT_MODEL = 'claude-opus-5-5';
export const ANTHROPIC_VERSION = '2023-06-01';
// The Messages API requires max_tokens; a caller that sets none locally gets this there.
export const ANTHROPIC_DEFAULT_MAX_TOKENS = 16_000;
const ALIASES = { '': 'local', local: 'local', openai: 'openai-compatible', 'openai-compatible': 'openai-compatible', anthropic: 'anthropic' };
const LOCAL_TIMEOUT_MS = 600_000;
const API_TIMEOUT_MS = 300_000;
const MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
const trimSlash = (s) => String(s).replace(/\/+$/, '');
const fail = (error) => ({ ok: false, error });
/**
* The caller's local server, whatever CW_MODEL_PROVIDER names: for a request that names a local
* engine, or material that must not leave the machine. `local` is { baseUrl (server root), engine,
* tokenEnv? } or { apiBase (an API root already ending where /chat/completions goes), ... };
* `tokenEnv` names the env var holding the server's bearer token, read at request time.
*/
export function localProvider(local, { model = null } = {}) {
if (!local || !(local.apiBase || local.baseUrl)) throw new TypeError('localProvider needs { baseUrl } or { apiBase }');
const base = local.apiBase ? trimSlash(local.apiBase) : `${trimSlash(local.baseUrl)}/v1`;
return Object.freeze({
id: 'local', engine: local.engine || 'local', wire: 'openai', baseUrl: base, model, reach: reachOf(base),
...(local.tokenEnv ? { tokenEnv: String(local.tokenEnv) } : {}),
});
}
/**
* Which provider answers. `local` is as for localProvider(). Returns { ok, provider } or
* { ok: false, error }; never a guess. The provider object holds no key: the key is read from env
* at request time and nowhere kept.
*/
export function resolveModelProvider({ local = null, env = process.env } = {}) {
const raw = String(env.CW_MODEL_PROVIDER || '').trim().toLowerCase();
const id = ALIASES[raw];
if (!id) return fail(`CW_MODEL_PROVIDER=${raw} is not a provider (${PROVIDER_ORDER.join(', ')})`);
const pinned = env.CW_MODEL_NAME || null;
if (id === 'local') {
// A base URL with no provider named is ambiguous: refusing it is what keeps a hosted URL from
// being used under the local label, or a local run from being mistaken for a hosted one.
if (env.CW_MODEL_BASE_URL) {
return fail('CW_MODEL_BASE_URL is set but CW_MODEL_PROVIDER is not an API provider — set ' +
'CW_MODEL_PROVIDER=openai-compatible|anthropic to use it, or point a local server with CW_LLM_URL_<HOST>');
}
if (!local || !(local.baseUrl || local.apiBase)) return fail('no local model server is configured for this caller');
return { ok: true, provider: localProvider(local, { model: pinned }) };
}
const base = id === 'anthropic' ? trimSlash(env.CW_MODEL_BASE_URL || ANTHROPIC_BASE_URL) : env.CW_MODEL_BASE_URL && trimSlash(env.CW_MODEL_BASE_URL);
if (!base) return fail(`CW_MODEL_PROVIDER=${id} needs CW_MODEL_BASE_URL (the API root that /chat/completions is under)`);
let url;
try { url = new URL(base); } catch { return fail(`CW_MODEL_BASE_URL is not a URL: ${base}`); }
if (url.protocol !== 'https:' && url.protocol !== 'http:') return fail(`CW_MODEL_BASE_URL must be http(s), not ${url.protocol}`);
if (url.username || url.password) return fail('CW_MODEL_BASE_URL must not carry credentials; use CW_MODEL_API_KEY');
const model = id === 'anthropic' ? pinned || ANTHROPIC_DEFAULT_MODEL : pinned;
if (!model) return fail(`CW_MODEL_PROVIDER=${id} needs CW_MODEL_NAME`);
const provider = Object.freeze({ id, engine: id, wire: id === 'anthropic' ? 'anthropic' : 'openai', baseUrl: base, model, reach: reachOf(base) });
const key = apiKeyFor(provider, env);
if (id === 'anthropic' && !key) {
return fail(base === ANTHROPIC_BASE_URL
? 'CW_MODEL_PROVIDER=anthropic needs ANTHROPIC_API_KEY or CW_MODEL_API_KEY'
: `CW_MODEL_PROVIDER=anthropic at ${url.host} needs CW_MODEL_API_KEY (ANTHROPIC_API_KEY is sent only to ${new URL(ANTHROPIC_BASE_URL).host})`);
}
if (key && url.protocol === 'http:' && provider.reach !== 'local') {
return fail(`refusing to send an API key in clear text to ${url.host}; use https`);
}
return { ok: true, provider };
}
// ANTHROPIC_API_KEY is honoured only for Anthropic's own host, so overriding the base URL can never
// carry that key to another server; anything else needs the explicit CW_MODEL_API_KEY.
function apiKeyFor(provider, env) {
if (provider.id === 'local') return provider.tokenEnv ? env[provider.tokenEnv] || null : null;
if (env.CW_MODEL_API_KEY) return env.CW_MODEL_API_KEY;
if (provider.id === 'anthropic' && provider.baseUrl === ANTHROPIC_BASE_URL) return env.ANTHROPIC_API_KEY || null;
return null;
}
/** "anthropic:claude-opus-5-5 @ api.anthropic.com" — for a progress line, never carries the key. */
export function describeProvider(p, model = p.model) {
let host = p.baseUrl;
try { host = new URL(p.baseUrl).host; } catch { /* the raw string is the honest fallback */ }
return `${p.engine}:${model || '(model unset)'} @ ${host}`;
}
// node:http, not fetch: fetch drops a response whose headers take over 300 s, and a local model
// sends none until a non-streaming completion finishes. No redirect is followed, so a request — and
// any key on it — reaches only the configured host.
function post(url, { headers, body, timeoutMs, signal }) {
return new Promise((resolve) => {
const ac = new AbortController();
let timedOut = false;
const t = setTimeout(() => { timedOut = true; ac.abort(); }, timeoutMs);
const onExt = () => ac.abort();
if (signal) { if (signal.aborted) ac.abort(); else signal.addEventListener('abort', onExt, { once: true }); }
const done = (r) => { clearTimeout(t); if (signal) signal.removeEventListener('abort', onExt); resolve(r); };
const u = new URL(url);
const payload = Buffer.from(JSON.stringify(body));
const req = (u.protocol === 'https:' ? https : http).request(u, {
method: 'POST', agent: false, signal: ac.signal,
headers: { 'content-type': 'application/json', 'content-length': payload.length, ...headers },
}, (res) => {
const chunks = [];
let size = 0;
res.on('data', (c) => {
size += c.length;
if (size > MAX_RESPONSE_BYTES) { req.destroy(); done({ error: `response exceeded ${MAX_RESPONSE_BYTES} bytes`, code: 'too-large' }); return; }
chunks.push(c);
});
res.on('end', () => done({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') }));
res.on('error', failed);
});
req.on('error', failed);
function failed(e) {
if (signal && signal.aborted) return done({ error: 'stopped by caller', stopped: true, code: 'stopped' });
done(timedOut ? { error: `no answer within ${Math.round(timeoutMs / 1000)}s`, code: 'timeout' } : { error: `unreachable: ${e.message}`, code: 'unreachable' });
}
req.end(payload);
});
}
const scrub = (s, key) => (key ? String(s).split(key).join('[redacted]') : String(s));
function errorDetail(text) {
try {
const j = JSON.parse(text);
const e = j && j.error;
const msg = e && (typeof e === 'string' ? e : e.message || e.type);
if (msg) return String(msg).slice(0, 200);
} catch { /* a non-JSON error body is described by its status alone */ }
return null;
}
function textOf(content) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) return content.filter((p) => p && p.type === 'text').map((p) => p.text || '').join('');
return '';
}
function readOpenAi(j) {
const choice = j && Array.isArray(j.choices) && j.choices[0];
if (!choice || !choice.message) return null;
const msg = choice.message;
return {
text: textOf(msg.content), thinking: msg.reasoning_content || msg.reasoning || null,
finishReason: choice.finish_reason || null, truncated: choice.finish_reason === 'length', model: j.model,
};
}
// Fable, Mythos, Opus 4.7+ and every 5.x model fix their own sampling: Opus answers a temperature
// with HTTP 400, Sonnet 5.5 any non-default one. Older models still take it.
export function anthropicTakesTemperature(model) {
return !/^claude-(fable|mythos)/.test(model) && !/^claude-opus-4-[78](-|$)/.test(model) && !/^claude-[a-z]+-([5-9]|\d{2,})(-|$)/.test(model);
}
export const MODEL_DEFAULT_SAMPLING = 'model-default';
/** The sampling basis a result's `temperature` records: `pinned-<t>` for a temperature sent,
* MODEL_DEFAULT_SAMPLING where none could be, null where the result reports neither. */
export function samplingOf(temperature) {
if (temperature === null) return MODEL_DEFAULT_SAMPLING;
return Number.isFinite(temperature) ? `pinned-${temperature}` : null;
}
function readAnthropic(j) {
if (!j || !Array.isArray(j.content)) return null;
const thinking = j.content.filter((b) => b && b.type === 'thinking').map((b) => b.thinking || '').join('\n\n');
return {
text: textOf(j.content), thinking: thinking || null,
finishReason: j.stop_reason || null, truncated: j.stop_reason === 'max_tokens', model: j.model,
refusal: j.stop_reason === 'refusal' ? { category: j.stop_details?.category ?? null } : null,
};
}
/**
* One chat completion. `messages` are {role: 'user'|'assistant', content}; `system` is separate
* because the two wires carry it differently. Resolves, never throws:
* { ok: true, text, thinking, truncated, finishReason, usage, temperature, answeredBy }
* { ok: false, error, status?, code?, stopped?, thinking?, finishReason?, temperature?, answeredBy }
* An empty answer is `code: 'empty-answer'`, not a success; its reasoning, if any, is kept, and so is
* `temperature`, because a caller may still read a verdict from that reasoning. A declined request
* is `code: 'refusal'`, even when partial text came back. A transport failure carries `code`
* timeout | unreachable | stopped | too-large. `temperature` is the value sent, or null where the
* model fixes its own sampling and a pinned temperature could not be honoured; samplingOf() names it.
*
* `maxTokens: null` sends no ceiling on the OpenAI wire (the server's own applies); the Messages API
* requires one, so it gets ANTHROPIC_DEFAULT_MAX_TOKENS. `jsonSchema: { name, schema }` constrains
* the reply on either wire. `extra` holds OpenAI-wire body fields with no Anthropic equivalent (a
* local server's reasoning_effort, a seed); a call that sets it on the Anthropic wire is refused,
* never sent without it.
*/
export async function chatComplete(provider, { messages, system = null, model = provider.model, maxTokens = 2048, temperature = 0, jsonSchema = null, extra = null, timeoutMs, signal = null, env = process.env } = {}) {
const answeredBy = { provider: provider.id, engine: provider.engine, model: model || null, host: hostOf(provider.baseUrl) };
if (!model) return { ok: false, error: 'no model selected', answeredBy };
const key = apiKeyFor(provider, env);
if (provider.wire === 'anthropic' && !key) return { ok: false, error: 'no API key for anthropic in the environment', answeredBy };
const limit = timeoutMs || Number(env.CW_MODEL_TIMEOUT_MS) || (provider.id === 'local' ? LOCAL_TIMEOUT_MS : API_TIMEOUT_MS);
const label = provider.id === 'local' ? `${provider.engine} at ${answeredBy.host}` : `${provider.engine} (${answeredBy.host})`;
const extraKeys = extra ? Object.keys(extra) : [];
if (extraKeys.length && provider.wire !== 'openai') return { ok: false, error: `${label} takes no ${extraKeys.join(', ')}; the request was not sent`, answeredBy };
const temp = Number.isFinite(temperature) ? temperature : 0;
const sentTemp = provider.wire === 'anthropic' && !anthropicTakesTemperature(model) ? null : temp;
let url, headers, body;
if (provider.wire === 'openai') {
url = `${provider.baseUrl}/chat/completions`;
headers = key ? { authorization: `Bearer ${key}` } : {};
body = {
...extra, model, temperature: temp, ...(maxTokens == null ? {} : { max_tokens: maxTokens }),
messages: system ? [{ role: 'system', content: system }, ...messages] : messages,
...(jsonSchema ? { response_format: { type: 'json_schema', json_schema: { name: jsonSchema.name, strict: true, schema: jsonSchema.schema } } } : {}),
};
} else {
url = `${provider.baseUrl}/v1/messages`;
headers = { 'x-api-key': key, 'anthropic-version': ANTHROPIC_VERSION };
body = {
model, max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, ...(sentTemp == null ? {} : { temperature: sentTemp }), messages, ...(system ? { system } : {}),
...(jsonSchema ? { output_config: { format: { type: 'json_schema', schema: jsonSchema.schema } } } : {}),
};
}
const r = await post(url, { headers, body, timeoutMs: limit, signal });
if (r.error) return { ok: false, error: scrub(`${label}: ${r.error}`, key), code: r.code, stopped: !!r.stopped, answeredBy };
if (r.status < 200 || r.status > 299) {
const detail = errorDetail(r.text);
return { ok: false, status: r.status, error: scrub(`${label} answered HTTP ${r.status}${detail ? `: ${detail}` : ''}`, key), answeredBy };
}
let j;
try { j = JSON.parse(r.text); } catch (e) { return { ok: false, status: r.status, error: scrub(`${label} answered HTTP ${r.status} with a body that is not JSON (${e.message})`, key), answeredBy }; }
const out = provider.wire === 'anthropic' ? readAnthropic(j) : readOpenAi(j);
if (!out) return { ok: false, status: r.status, error: `${label} answered HTTP ${r.status} without a completion in its body`, answeredBy };
if (typeof out.model === 'string' && out.model) answeredBy.model = out.model;
if (out.refusal) {
return { ok: false, status: r.status, code: 'refusal', error: `${label} declined the request${out.refusal.category ? ` (${out.refusal.category})` : ''}`, finishReason: out.finishReason, answeredBy };
}
if (!out.text.trim()) {
return { ok: false, status: r.status, code: 'empty-answer', error: `${label} returned no answer text${out.truncated ? ' (cut off at the token limit)' : ''}`, thinking: out.thinking, truncated: out.truncated, finishReason: out.finishReason, temperature: sentTemp, answeredBy };
}
return { ok: true, text: out.text, thinking: out.thinking, truncated: out.truncated, finishReason: out.finishReason, usage: j.usage ?? null, temperature: sentTemp, answeredBy };
}
function hostOf(base) {
try { return new URL(base).host; } catch { return String(base); }
}