@latimer-woods-tech/llm 0.5.0 → 0.6.0
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/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/dist/index.d.mts +104 -14
- package/dist/index.mjs +299 -36
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
package/dist/index.mjs
CHANGED
|
@@ -1,12 +1,143 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import {
|
|
3
|
-
InternalError,
|
|
3
|
+
InternalError as InternalError3,
|
|
4
4
|
RateLimitError,
|
|
5
|
-
ValidationError,
|
|
5
|
+
ValidationError as ValidationError2,
|
|
6
6
|
toErrorResponse
|
|
7
7
|
} from "@latimer-woods-tech/errors";
|
|
8
8
|
|
|
9
|
+
// src/gcp-token.ts
|
|
10
|
+
import { InternalError, ValidationError } from "@latimer-woods-tech/errors";
|
|
11
|
+
var EXPIRY_SKEW_MS = 3e5;
|
|
12
|
+
var tokenCache = /* @__PURE__ */ new Map();
|
|
13
|
+
function parseServiceAccountKey(gcpSaKey) {
|
|
14
|
+
const text = gcpSaKey.trimStart().startsWith("{") ? gcpSaKey : atob(gcpSaKey);
|
|
15
|
+
const key = JSON.parse(text);
|
|
16
|
+
if (!key.client_email || !key.private_key || !key.token_uri) {
|
|
17
|
+
throw new ValidationError("GCP_SA_KEY is missing client_email, private_key, or token_uri");
|
|
18
|
+
}
|
|
19
|
+
return key;
|
|
20
|
+
}
|
|
21
|
+
function base64UrlEncode(bytes) {
|
|
22
|
+
let binary = "";
|
|
23
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
24
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
25
|
+
}
|
|
26
|
+
async function importPrivateKey(pem) {
|
|
27
|
+
const body = pem.split("\n").filter((line) => !line.startsWith("-----")).join("");
|
|
28
|
+
const binary = atob(body);
|
|
29
|
+
const bytes = new Uint8Array(binary.length);
|
|
30
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
31
|
+
return crypto.subtle.importKey(
|
|
32
|
+
"pkcs8",
|
|
33
|
+
bytes.buffer,
|
|
34
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
35
|
+
false,
|
|
36
|
+
["sign"]
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
async function createAssertion(key, nowSeconds) {
|
|
40
|
+
const encoder = new TextEncoder();
|
|
41
|
+
const header = base64UrlEncode(encoder.encode(JSON.stringify({ alg: "RS256", typ: "JWT" })));
|
|
42
|
+
const payload = base64UrlEncode(
|
|
43
|
+
encoder.encode(
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
iss: key.client_email,
|
|
46
|
+
scope: "https://www.googleapis.com/auth/cloud-platform",
|
|
47
|
+
aud: key.token_uri,
|
|
48
|
+
exp: nowSeconds + 3600,
|
|
49
|
+
iat: nowSeconds
|
|
50
|
+
})
|
|
51
|
+
)
|
|
52
|
+
);
|
|
53
|
+
const signingInput = `${header}.${payload}`;
|
|
54
|
+
const cryptoKey = await importPrivateKey(key.private_key);
|
|
55
|
+
const signature = await crypto.subtle.sign(
|
|
56
|
+
"RSASSA-PKCS1-v1_5",
|
|
57
|
+
cryptoKey,
|
|
58
|
+
encoder.encode(signingInput).buffer
|
|
59
|
+
);
|
|
60
|
+
return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`;
|
|
61
|
+
}
|
|
62
|
+
async function mintGcpAccessToken(gcpSaKey, fetchImpl) {
|
|
63
|
+
const key = parseServiceAccountKey(gcpSaKey);
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
const cached = tokenCache.get(key.client_email);
|
|
66
|
+
if (cached && cached.expiresAt - EXPIRY_SKEW_MS > now) return cached.token;
|
|
67
|
+
const assertion = await createAssertion(key, Math.floor(now / 1e3));
|
|
68
|
+
const response = await fetchImpl(key.token_uri, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
71
|
+
body: new URLSearchParams({
|
|
72
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
73
|
+
assertion
|
|
74
|
+
}).toString()
|
|
75
|
+
}).catch((cause) => {
|
|
76
|
+
throw new InternalError(`GCP token exchange failed: ${String(cause)}`);
|
|
77
|
+
});
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new InternalError(`GCP token exchange returned ${response.status}`);
|
|
80
|
+
}
|
|
81
|
+
const data = await response.json();
|
|
82
|
+
if (!data.access_token) {
|
|
83
|
+
throw new InternalError("GCP token exchange returned no access_token");
|
|
84
|
+
}
|
|
85
|
+
const ttlMs = (data.expires_in > 0 ? data.expires_in : 3600) * 1e3;
|
|
86
|
+
tokenCache.set(key.client_email, { token: data.access_token, expiresAt: now + ttlMs });
|
|
87
|
+
return data.access_token;
|
|
88
|
+
}
|
|
89
|
+
var METADATA_BASE = "http://metadata.google.internal/computeMetadata/v1";
|
|
90
|
+
var METADATA_HEADER = { "Metadata-Flavor": "Google" };
|
|
91
|
+
var METADATA_TIMEOUT_MS = 2e3;
|
|
92
|
+
var adcCache;
|
|
93
|
+
async function fetchAdcAccessToken(fetchImpl) {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
if (adcCache && adcCache.expiresAt - EXPIRY_SKEW_MS > now) {
|
|
96
|
+
return { token: adcCache.token, project: adcCache.project };
|
|
97
|
+
}
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const timer = setTimeout(() => controller.abort(), METADATA_TIMEOUT_MS);
|
|
100
|
+
try {
|
|
101
|
+
const tokenRes = await fetchImpl(
|
|
102
|
+
`${METADATA_BASE}/instance/service-accounts/default/token`,
|
|
103
|
+
{ headers: METADATA_HEADER, signal: controller.signal }
|
|
104
|
+
);
|
|
105
|
+
if (!tokenRes.ok) {
|
|
106
|
+
throw new InternalError(`GCP metadata token endpoint returned ${tokenRes.status}`);
|
|
107
|
+
}
|
|
108
|
+
const data = await tokenRes.json();
|
|
109
|
+
if (!data.access_token) {
|
|
110
|
+
throw new InternalError("GCP metadata token endpoint returned no access_token");
|
|
111
|
+
}
|
|
112
|
+
let project;
|
|
113
|
+
try {
|
|
114
|
+
const projRes = await fetchImpl(`${METADATA_BASE}/project/project-id`, {
|
|
115
|
+
headers: METADATA_HEADER,
|
|
116
|
+
signal: controller.signal
|
|
117
|
+
});
|
|
118
|
+
if (projRes.ok) project = (await projRes.text()).trim() || void 0;
|
|
119
|
+
} catch {
|
|
120
|
+
}
|
|
121
|
+
const ttlMs = (data.expires_in > 0 ? data.expires_in : 3600) * 1e3;
|
|
122
|
+
adcCache = { token: data.access_token, project, expiresAt: now + ttlMs };
|
|
123
|
+
return { token: data.access_token, project };
|
|
124
|
+
} catch (cause) {
|
|
125
|
+
if (cause instanceof InternalError) throw cause;
|
|
126
|
+
throw new InternalError(`GCP metadata credential unavailable: ${String(cause)}`);
|
|
127
|
+
} finally {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function clearGcpTokenCache() {
|
|
132
|
+
tokenCache.clear();
|
|
133
|
+
adcCache = void 0;
|
|
134
|
+
}
|
|
135
|
+
function serviceAccountProjectId(gcpSaKey) {
|
|
136
|
+
return parseServiceAccountKey(gcpSaKey).project_id;
|
|
137
|
+
}
|
|
138
|
+
|
|
9
139
|
// src/embed.ts
|
|
140
|
+
import { InternalError as InternalError2 } from "@latimer-woods-tech/errors";
|
|
10
141
|
var DEFAULT_EMBEDDING_MODEL = "@cf/baai/bge-base-en-v1.5";
|
|
11
142
|
async function embed(ai, input, opts) {
|
|
12
143
|
const model = opts?.model ?? DEFAULT_EMBEDDING_MODEL;
|
|
@@ -22,6 +153,41 @@ async function embed(ai, input, opts) {
|
|
|
22
153
|
dims: vectors[0].length
|
|
23
154
|
};
|
|
24
155
|
}
|
|
156
|
+
var LOCAL_EMBEDDING_MODEL = "nomic-embed-text";
|
|
157
|
+
async function embedLocal(env, input) {
|
|
158
|
+
const texts = Array.isArray(input) ? input : [input];
|
|
159
|
+
const res = await fetch(
|
|
160
|
+
`${env.AI_GATEWAY_BASE_URL}/custom-local-gpu/v1/embeddings`,
|
|
161
|
+
{
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: {
|
|
164
|
+
"Content-Type": "application/json",
|
|
165
|
+
// CF Access service-token headers only sent when present (bearer-only rails still work).
|
|
166
|
+
...env.GPU_LLM_API_TOKEN ? { Authorization: `Bearer ${env.GPU_LLM_API_TOKEN}` } : {},
|
|
167
|
+
...env.GPU_LLM_ACCESS_CLIENT_ID ? { "CF-Access-Client-Id": env.GPU_LLM_ACCESS_CLIENT_ID } : {},
|
|
168
|
+
...env.GPU_LLM_ACCESS_CLIENT_SECRET ? { "CF-Access-Client-Secret": env.GPU_LLM_ACCESS_CLIENT_SECRET } : {}
|
|
169
|
+
},
|
|
170
|
+
body: JSON.stringify({ model: LOCAL_EMBEDDING_MODEL, input: texts })
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
throw new InternalError2(
|
|
175
|
+
`embedLocal(): rail ${res.status}: ${(await res.text()).slice(0, 160)}`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
const json = await res.json();
|
|
179
|
+
const vectors = (json.data ?? []).map((d) => d.embedding);
|
|
180
|
+
if (vectors.length === 0) {
|
|
181
|
+
throw new InternalError2(
|
|
182
|
+
`embedLocal(): rail returned no vectors for model ${LOCAL_EMBEDDING_MODEL}`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
vectors,
|
|
187
|
+
model: LOCAL_EMBEDDING_MODEL,
|
|
188
|
+
dims: vectors[0].length
|
|
189
|
+
};
|
|
190
|
+
}
|
|
25
191
|
|
|
26
192
|
// src/index.ts
|
|
27
193
|
function contentToText(content) {
|
|
@@ -35,25 +201,51 @@ function systemText(opts, messages) {
|
|
|
35
201
|
}
|
|
36
202
|
var MODELS = {
|
|
37
203
|
anthropic: {
|
|
38
|
-
|
|
204
|
+
// `claude-haiku-4-20250514` was never a model Anthropic served — it 404s
|
|
205
|
+
// (verified live against /v1/messages). Date suffixes are never appended to
|
|
206
|
+
// an alias; the alias is `claude-haiku-4-5`, which resolves server-side to
|
|
207
|
+
// `claude-haiku-4-5-20251001`. This is the `fast` tier's FALLBACK, so the
|
|
208
|
+
// 404 stayed invisible for as long as the Grok primary held.
|
|
209
|
+
fast: "claude-haiku-4-5",
|
|
39
210
|
balanced: "claude-sonnet-4-6",
|
|
40
211
|
smart: "claude-opus-4-7"
|
|
41
212
|
},
|
|
42
213
|
gemini: {
|
|
43
|
-
|
|
214
|
+
// `gemini-2.5-flash`, not `-pro`: the leg's job here is a fast, reliable,
|
|
215
|
+
// JSON-returning fallback. Gemini 2.5 *Pro* mandates a thinking budget of
|
|
216
|
+
// ≥128 tokens that is drawn from `maxOutputTokens` and CANNOT be disabled
|
|
217
|
+
// (thinkingBudget=0 is rejected). On the render-runner's large judge /
|
|
218
|
+
// generation prompts — and any low-`maxTokens` call (headline uses 40) —
|
|
219
|
+
// the thinking phase exhausts the whole budget and Vertex returns 200 with
|
|
220
|
+
// an empty candidate (finishReason MAX_TOKENS, no text), which the router
|
|
221
|
+
// treats as a failed leg. Flash supports `thinkingBudget: 0` (set in
|
|
222
|
+
// buildGeminiRequest), so text is always emitted. Both are Vertex-served.
|
|
223
|
+
smart: "gemini-2.5-flash"
|
|
44
224
|
},
|
|
45
225
|
groq: {
|
|
46
|
-
|
|
226
|
+
// `llama-4-maverick` was NOT a model Groq serves — every `verifier` call
|
|
227
|
+
// 404'd (the tier has no fallback, so it was hard-broken), and it was also
|
|
228
|
+
// the `workbench` fallback. Groq's llama-4 offering is `scout`, which is
|
|
229
|
+
// blocked at our org level; `llama-3.3-70b-versatile` is served and
|
|
230
|
+
// unblocked (verified against /v1/models and a live completion).
|
|
231
|
+
verifier: "llama-3.3-70b-versatile"
|
|
47
232
|
},
|
|
48
233
|
grok: {
|
|
49
234
|
fast: "grok-4.3"
|
|
50
235
|
},
|
|
51
236
|
deepseek: {
|
|
52
237
|
workbench: "deepseek-chat"
|
|
238
|
+
},
|
|
239
|
+
local: {
|
|
240
|
+
// Self-hosted qwen3:8b on the GPU box, reached via the `custom-local-gpu`
|
|
241
|
+
// AI Gateway provider. The `fast` tier's optional zero-cost primary.
|
|
242
|
+
fast: "qwen3:8b",
|
|
243
|
+
workbench: "qwen3.6:27b"
|
|
53
244
|
}
|
|
54
245
|
};
|
|
55
246
|
var DEFAULT_MAX_TOKENS = 1024;
|
|
56
247
|
var DEFAULT_TEMPERATURE = 0.7;
|
|
248
|
+
var DEFAULT_VERTEX_LOCATION = "us-central1";
|
|
57
249
|
var DEFAULT_LONG_CONTEXT_THRESHOLD = 15e4;
|
|
58
250
|
var BACKOFF_BASE_MS = 500;
|
|
59
251
|
var BACKOFF_CAP_MS = 8e3;
|
|
@@ -88,8 +280,9 @@ async function recordOrgCostUsage(kv, todayKey, monthKey, costUsd, opts) {
|
|
|
88
280
|
}
|
|
89
281
|
}
|
|
90
282
|
var MODEL_PRICE_PER_1M = {
|
|
91
|
-
// Anthropic Haiku 4
|
|
92
|
-
|
|
283
|
+
// Anthropic Haiku 4.5 — `claude-haiku-4-5` is the routed alias; the dated id
|
|
284
|
+
// is what the API echoes back in `response.model`, so both must price.
|
|
285
|
+
"claude-haiku-4-5": { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
|
|
93
286
|
"claude-haiku-4-5-20251001": { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
|
|
94
287
|
// Anthropic Sonnet 4
|
|
95
288
|
"claude-sonnet-4-20250514": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
@@ -97,10 +290,13 @@ var MODEL_PRICE_PER_1M = {
|
|
|
97
290
|
// Anthropic Opus 4
|
|
98
291
|
"claude-opus-4-20250514": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
99
292
|
"claude-opus-4-7": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
100
|
-
// Gemini 2.5
|
|
293
|
+
// Gemini 2.5 Flash — the routed `smart`/long-context model (JSON fallback leg).
|
|
294
|
+
"gemini-2.5-flash": { input: 0.3, output: 2.5, cacheRead: 0.075, cacheWrite: 0.3 },
|
|
295
|
+
// Gemini 2.5 Pro — retained for historical ledger rows (was the routed model).
|
|
101
296
|
"gemini-2.5-pro": { input: 1.25, output: 10, cacheRead: 0.31, cacheWrite: 4.5 },
|
|
102
|
-
// Groq Llama
|
|
103
|
-
|
|
297
|
+
// Groq Llama 3.3 70B Versatile (`verifier` tier). Groq has no prompt caching,
|
|
298
|
+
// so cache rates are 0.00 — same convention as grok-4.3 below.
|
|
299
|
+
"llama-3.3-70b-versatile": { input: 0.59, output: 0.79, cacheRead: 0, cacheWrite: 0 },
|
|
104
300
|
// Grok 4.3
|
|
105
301
|
"grok-4.3": { input: 1.25, output: 2.5, cacheRead: 0, cacheWrite: 0 },
|
|
106
302
|
// DeepSeek API pricing as of 2026-05: cache-write conservatively uses cache-miss input pricing.
|
|
@@ -108,7 +304,10 @@ var MODEL_PRICE_PER_1M = {
|
|
|
108
304
|
"deepseek-reasoner": { input: 0.55, output: 2.19, cacheRead: 0.14, cacheWrite: 0.55 },
|
|
109
305
|
// Deprecated aliases retained for historical ledger rows.
|
|
110
306
|
"grok-4-fast": { input: 1.25, output: 2.5, cacheRead: 0, cacheWrite: 0 },
|
|
111
|
-
"grok-3-mini-latest": { input: 1.25, output: 2.5, cacheRead: 0, cacheWrite: 0 }
|
|
307
|
+
"grok-3-mini-latest": { input: 1.25, output: 2.5, cacheRead: 0, cacheWrite: 0 },
|
|
308
|
+
// Self-hosted qwen3 on the GPU box — zero marginal cost (electricity aside).
|
|
309
|
+
"qwen3:8b": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
310
|
+
"qwen3.6:27b": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
112
311
|
};
|
|
113
312
|
var PRICE_FALLBACK = MODEL_PRICE_PER_1M["claude-opus-4-7"];
|
|
114
313
|
function estimateCostUsd(tokens, model) {
|
|
@@ -186,7 +385,18 @@ function buildAnthropicRequest(model, messages, opts, env, streaming = false) {
|
|
|
186
385
|
body: JSON.stringify(body)
|
|
187
386
|
};
|
|
188
387
|
}
|
|
189
|
-
function
|
|
388
|
+
async function resolveVertexAuth(env, fetchImpl) {
|
|
389
|
+
if (env.GCP_SA_KEY) {
|
|
390
|
+
try {
|
|
391
|
+
return { token: await mintGcpAccessToken(env.GCP_SA_KEY, fetchImpl) };
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (!env.VERTEX_ACCESS_TOKEN) throw error;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (env.VERTEX_ACCESS_TOKEN) return { token: env.VERTEX_ACCESS_TOKEN };
|
|
397
|
+
return fetchAdcAccessToken(fetchImpl);
|
|
398
|
+
}
|
|
399
|
+
function buildGeminiRequest(model, messages, opts, env, accessToken, adcProject) {
|
|
190
400
|
const sys = systemText(opts, messages);
|
|
191
401
|
const contents = messages.filter((m) => m.role !== "system").map((m) => ({
|
|
192
402
|
role: m.role === "assistant" ? "model" : "user",
|
|
@@ -196,18 +406,26 @@ function buildGeminiRequest(model, messages, opts, env) {
|
|
|
196
406
|
contents,
|
|
197
407
|
generationConfig: {
|
|
198
408
|
maxOutputTokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
199
|
-
temperature: opts.temperature ?? DEFAULT_TEMPERATURE
|
|
409
|
+
temperature: opts.temperature ?? DEFAULT_TEMPERATURE,
|
|
410
|
+
// Disable "thinking": Gemini 2.5 draws thinking tokens from
|
|
411
|
+
// maxOutputTokens, so on a large prompt (or a small token budget) the
|
|
412
|
+
// model can spend the entire budget thinking and return an empty
|
|
413
|
+
// candidate (finishReason MAX_TOKENS). This leg wants deterministic text
|
|
414
|
+
// out, so thinking is turned off (supported by gemini-2.5-flash).
|
|
415
|
+
thinkingConfig: { thinkingBudget: 0 }
|
|
200
416
|
}
|
|
201
417
|
};
|
|
202
418
|
if (sys) {
|
|
203
419
|
body.systemInstruction = { parts: [{ text: sys }] };
|
|
204
420
|
}
|
|
205
|
-
const
|
|
421
|
+
const project = env.VERTEX_PROJECT || (env.GCP_SA_KEY ? serviceAccountProjectId(env.GCP_SA_KEY) : adcProject ?? "");
|
|
422
|
+
const location = env.VERTEX_LOCATION || DEFAULT_VERTEX_LOCATION;
|
|
423
|
+
const path = `v1/projects/${project}/locations/${location}/publishers/google/models/${model}:generateContent`;
|
|
206
424
|
return {
|
|
207
425
|
url: `${env.AI_GATEWAY_BASE_URL}/google-vertex-ai/${path}`,
|
|
208
426
|
headers: {
|
|
209
427
|
"content-type": "application/json",
|
|
210
|
-
authorization: `Bearer ${
|
|
428
|
+
authorization: `Bearer ${accessToken}`
|
|
211
429
|
},
|
|
212
430
|
body: JSON.stringify(body)
|
|
213
431
|
};
|
|
@@ -274,7 +492,7 @@ function buildGroqRequest(model, messages, opts, env) {
|
|
|
274
492
|
}
|
|
275
493
|
function buildGrokRequest(model, messages, opts, env) {
|
|
276
494
|
if (!env.GROK_API_KEY) {
|
|
277
|
-
throw new
|
|
495
|
+
throw new ValidationError2("GROK_API_KEY required for grok-* model override");
|
|
278
496
|
}
|
|
279
497
|
const sys = systemText(opts, messages);
|
|
280
498
|
const body = {
|
|
@@ -303,7 +521,7 @@ function buildGrokRequest(model, messages, opts, env) {
|
|
|
303
521
|
}
|
|
304
522
|
function buildDeepSeekRequest(model, messages, opts, env) {
|
|
305
523
|
if (!env.DEEPSEEK_API_KEY) {
|
|
306
|
-
throw new
|
|
524
|
+
throw new ValidationError2("DEEPSEEK_API_KEY required for workbench tier or deepseek-* model override");
|
|
307
525
|
}
|
|
308
526
|
const sys = systemText(opts, messages);
|
|
309
527
|
const body = {
|
|
@@ -327,6 +545,32 @@ function buildDeepSeekRequest(model, messages, opts, env) {
|
|
|
327
545
|
body: JSON.stringify(body)
|
|
328
546
|
};
|
|
329
547
|
}
|
|
548
|
+
function buildLocalRequest(model, messages, opts, env) {
|
|
549
|
+
if (!env.GPU_LLM_API_TOKEN) {
|
|
550
|
+
throw new ValidationError2("GPU_LLM_API_TOKEN required for the local provider");
|
|
551
|
+
}
|
|
552
|
+
const sys = systemText(opts, messages);
|
|
553
|
+
const fastMode = model === MODELS.local.fast;
|
|
554
|
+
const augmented = fastMode ? sys ? `${sys} /no_think` : "/no_think" : sys;
|
|
555
|
+
const headers = {
|
|
556
|
+
"content-type": "application/json",
|
|
557
|
+
authorization: `Bearer ${env.GPU_LLM_API_TOKEN}`
|
|
558
|
+
};
|
|
559
|
+
if (env.GPU_LLM_ACCESS_CLIENT_ID) headers["CF-Access-Client-Id"] = env.GPU_LLM_ACCESS_CLIENT_ID;
|
|
560
|
+
if (env.GPU_LLM_ACCESS_CLIENT_SECRET) headers["CF-Access-Client-Secret"] = env.GPU_LLM_ACCESS_CLIENT_SECRET;
|
|
561
|
+
const tools = openAiTools(opts);
|
|
562
|
+
return {
|
|
563
|
+
url: `${env.AI_GATEWAY_BASE_URL}/custom-local-gpu/v1/chat/completions`,
|
|
564
|
+
headers,
|
|
565
|
+
body: JSON.stringify({
|
|
566
|
+
model,
|
|
567
|
+
max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
568
|
+
temperature: opts.temperature ?? DEFAULT_TEMPERATURE,
|
|
569
|
+
messages: toOpenAiMessages(messages, augmented),
|
|
570
|
+
...tools ? { tools, tool_choice: openAiToolChoice(opts.toolChoice ?? "auto") } : {}
|
|
571
|
+
})
|
|
572
|
+
};
|
|
573
|
+
}
|
|
330
574
|
function normalizeAnthropicStop(reason) {
|
|
331
575
|
switch (reason) {
|
|
332
576
|
case "end_turn":
|
|
@@ -474,7 +718,7 @@ async function callWithBackoff(provider, request, fetchImpl, signal, logger, now
|
|
|
474
718
|
function isProviderError(err) {
|
|
475
719
|
return typeof err === "object" && err !== null && typeof err.status === "number" && typeof err.message === "string" && typeof err.provider === "string";
|
|
476
720
|
}
|
|
477
|
-
var TOOL_CAPABLE_PROVIDERS = /* @__PURE__ */ new Set(["anthropic", "grok", "deepseek"]);
|
|
721
|
+
var TOOL_CAPABLE_PROVIDERS = /* @__PURE__ */ new Set(["anthropic", "grok", "deepseek", "local"]);
|
|
478
722
|
function plan(tier, opts, tokenEstimate) {
|
|
479
723
|
if (opts.model) {
|
|
480
724
|
const m = opts.model;
|
|
@@ -482,6 +726,7 @@ function plan(tier, opts, tokenEstimate) {
|
|
|
482
726
|
if (m.startsWith("gemini")) return { primary: { provider: "gemini", model: m } };
|
|
483
727
|
if (m.startsWith("grok")) return { primary: { provider: "grok", model: m } };
|
|
484
728
|
if (m.startsWith("deepseek")) return { primary: { provider: "deepseek", model: m } };
|
|
729
|
+
if (m.startsWith("qwen")) return { primary: { provider: "local", model: m } };
|
|
485
730
|
return { primary: { provider: "groq", model: m } };
|
|
486
731
|
}
|
|
487
732
|
const longContext = tokenEstimate >= (opts.longContextThreshold ?? DEFAULT_LONG_CONTEXT_THRESHOLD);
|
|
@@ -531,9 +776,11 @@ async function callOne(leg, messages, opts, env, fetchImpl, logger, nowFn) {
|
|
|
531
776
|
case "anthropic":
|
|
532
777
|
req = buildAnthropicRequest(leg.model, messages, opts, env);
|
|
533
778
|
break;
|
|
534
|
-
case "gemini":
|
|
535
|
-
|
|
779
|
+
case "gemini": {
|
|
780
|
+
const vertexAuth = await resolveVertexAuth(env, fetchImpl);
|
|
781
|
+
req = buildGeminiRequest(leg.model, messages, opts, env, vertexAuth.token, vertexAuth.project);
|
|
536
782
|
break;
|
|
783
|
+
}
|
|
537
784
|
case "groq":
|
|
538
785
|
req = buildGroqRequest(leg.model, messages, opts, env);
|
|
539
786
|
break;
|
|
@@ -543,6 +790,9 @@ async function callOne(leg, messages, opts, env, fetchImpl, logger, nowFn) {
|
|
|
543
790
|
case "deepseek":
|
|
544
791
|
req = buildDeepSeekRequest(leg.model, messages, opts, env);
|
|
545
792
|
break;
|
|
793
|
+
case "local":
|
|
794
|
+
req = buildLocalRequest(leg.model, messages, opts, env);
|
|
795
|
+
break;
|
|
546
796
|
}
|
|
547
797
|
const aigMetadata = buildAigMetadata(opts);
|
|
548
798
|
if (aigMetadata) req.headers["cf-aig-metadata"] = aigMetadata;
|
|
@@ -565,14 +815,16 @@ async function callOne(leg, messages, opts, env, fetchImpl, logger, nowFn) {
|
|
|
565
815
|
return { parsed: parseOpenAi(json), gatewayRequestId, attempts };
|
|
566
816
|
case "deepseek":
|
|
567
817
|
return { parsed: parseOpenAi(json), gatewayRequestId, attempts };
|
|
818
|
+
case "local":
|
|
819
|
+
return { parsed: parseOpenAi(json), gatewayRequestId, attempts };
|
|
568
820
|
}
|
|
569
821
|
}
|
|
570
822
|
async function complete(messages, env, opts = {}, deps = {}) {
|
|
571
823
|
if (messages.length === 0) {
|
|
572
|
-
throw new
|
|
824
|
+
throw new ValidationError2("messages must not be empty");
|
|
573
825
|
}
|
|
574
826
|
if (!env.AI_GATEWAY_BASE_URL) {
|
|
575
|
-
throw new
|
|
827
|
+
throw new ValidationError2("AI_GATEWAY_BASE_URL is required in 0.3.0");
|
|
576
828
|
}
|
|
577
829
|
const fetchImpl = deps.fetch ?? fetch;
|
|
578
830
|
const now = deps.now ?? (() => Date.now());
|
|
@@ -581,7 +833,13 @@ async function complete(messages, env, opts = {}, deps = {}) {
|
|
|
581
833
|
const tier = opts.tier ?? "balanced";
|
|
582
834
|
const system = systemText(opts, messages);
|
|
583
835
|
const tokenEstimate = estimateTokens(messages, system);
|
|
584
|
-
|
|
836
|
+
let route = plan(tier, opts, tokenEstimate);
|
|
837
|
+
if (env.LLM_LOCAL_FIRST && tier === "fast" && !opts.model && env.GPU_LLM_API_TOKEN) {
|
|
838
|
+
route = { primary: { provider: "local", model: MODELS.local.fast }, fallback: route.primary };
|
|
839
|
+
}
|
|
840
|
+
if (env.LLM_LOCAL_WORKBENCH && tier === "workbench" && !opts.model && env.GPU_LLM_API_TOKEN) {
|
|
841
|
+
route = { primary: { provider: "local", model: MODELS.local.workbench }, fallback: route.primary };
|
|
842
|
+
}
|
|
585
843
|
const kv = env.LLM_COST_KV;
|
|
586
844
|
const todayKey = `llm:daily-cost:${isoDate(now())}`;
|
|
587
845
|
const monthKey = `llm:monthly-cost:${isoMonth(now())}`;
|
|
@@ -616,7 +874,7 @@ async function complete(messages, env, opts = {}, deps = {}) {
|
|
|
616
874
|
if (opts.tools && opts.tools.length > 0) {
|
|
617
875
|
routeLegs = routeLegs.filter((l) => TOOL_CAPABLE_PROVIDERS.has(l.provider));
|
|
618
876
|
if (routeLegs.length === 0) {
|
|
619
|
-
throw new
|
|
877
|
+
throw new ValidationError2(
|
|
620
878
|
`tool-calling requires a tool-capable provider (${[...TOOL_CAPABLE_PROVIDERS].join(", ")}); tier '${tier}' has none \u2014 use tier fast/balanced/smart or a claude-* model override`
|
|
621
879
|
);
|
|
622
880
|
}
|
|
@@ -629,7 +887,7 @@ async function complete(messages, env, opts = {}, deps = {}) {
|
|
|
629
887
|
}
|
|
630
888
|
if (opts.signal?.aborted) {
|
|
631
889
|
return toErrorResponse(
|
|
632
|
-
new
|
|
890
|
+
new InternalError3("llm call aborted", { provider: leg.provider, model: leg.model })
|
|
633
891
|
);
|
|
634
892
|
}
|
|
635
893
|
try {
|
|
@@ -704,7 +962,7 @@ async function complete(messages, env, opts = {}, deps = {}) {
|
|
|
704
962
|
} catch (e) {
|
|
705
963
|
if (e instanceof DOMException && e.name === "AbortError") {
|
|
706
964
|
return toErrorResponse(
|
|
707
|
-
new
|
|
965
|
+
new InternalError3("llm call aborted", { provider: leg.provider, model: leg.model })
|
|
708
966
|
);
|
|
709
967
|
}
|
|
710
968
|
if (isProviderError(e)) {
|
|
@@ -721,15 +979,15 @@ async function complete(messages, env, opts = {}, deps = {}) {
|
|
|
721
979
|
}
|
|
722
980
|
}
|
|
723
981
|
return toErrorResponse(
|
|
724
|
-
new
|
|
982
|
+
new InternalError3("LLM_ALL_PROVIDERS_FAILED", { attempts: attemptLog, tier, tokenEstimate })
|
|
725
983
|
);
|
|
726
984
|
}
|
|
727
985
|
async function* completionStream(messages, env, opts = {}) {
|
|
728
986
|
if (messages.length === 0) {
|
|
729
|
-
throw new
|
|
987
|
+
throw new ValidationError2("messages must not be empty");
|
|
730
988
|
}
|
|
731
989
|
if (!env.AI_GATEWAY_BASE_URL) {
|
|
732
|
-
throw new
|
|
990
|
+
throw new ValidationError2("AI_GATEWAY_BASE_URL is required in 0.3.0");
|
|
733
991
|
}
|
|
734
992
|
const deps = opts.deps ?? {};
|
|
735
993
|
const fetchImpl = deps.fetch ?? fetch;
|
|
@@ -744,7 +1002,7 @@ async function* completionStream(messages, env, opts = {}) {
|
|
|
744
1002
|
if (streamLeg.provider !== "anthropic") {
|
|
745
1003
|
const result = await complete(messages, env, opts, deps);
|
|
746
1004
|
if (result.error !== null || result.data === null) {
|
|
747
|
-
throw new
|
|
1005
|
+
throw new InternalError3("LLM_ALL_PROVIDERS_FAILED", { error: result.error });
|
|
748
1006
|
}
|
|
749
1007
|
yield result.data.content;
|
|
750
1008
|
return result.data;
|
|
@@ -753,7 +1011,7 @@ async function* completionStream(messages, env, opts = {}) {
|
|
|
753
1011
|
logger?.warn?.("llm.provider.coolingDown", { provider: streamLeg.provider });
|
|
754
1012
|
const result = await complete(messages, env, opts, deps);
|
|
755
1013
|
if (result.error !== null || result.data === null) {
|
|
756
|
-
throw new
|
|
1014
|
+
throw new InternalError3("LLM_ALL_PROVIDERS_FAILED", { error: result.error });
|
|
757
1015
|
}
|
|
758
1016
|
yield result.data.content;
|
|
759
1017
|
return result.data;
|
|
@@ -773,12 +1031,12 @@ async function* completionStream(messages, env, opts = {}) {
|
|
|
773
1031
|
});
|
|
774
1032
|
} catch (e) {
|
|
775
1033
|
if (e instanceof DOMException && e.name === "AbortError") {
|
|
776
|
-
throw new
|
|
1034
|
+
throw new InternalError3("llm call aborted", {
|
|
777
1035
|
provider: streamLeg.provider,
|
|
778
1036
|
model: streamLeg.model
|
|
779
1037
|
});
|
|
780
1038
|
}
|
|
781
|
-
throw new
|
|
1039
|
+
throw new InternalError3("llm stream fetch failed", {
|
|
782
1040
|
message: e instanceof Error ? e.message : String(e)
|
|
783
1041
|
});
|
|
784
1042
|
}
|
|
@@ -790,7 +1048,7 @@ async function* completionStream(messages, env, opts = {}) {
|
|
|
790
1048
|
}
|
|
791
1049
|
const result = await complete(messages, env, opts, deps);
|
|
792
1050
|
if (result.error !== null || result.data === null) {
|
|
793
|
-
throw new
|
|
1051
|
+
throw new InternalError3("LLM_ALL_PROVIDERS_FAILED", {
|
|
794
1052
|
streamError: `${streamLeg.provider} ${String(response.status)}: ${text.slice(0, 300)}`,
|
|
795
1053
|
error: result.error
|
|
796
1054
|
});
|
|
@@ -799,7 +1057,7 @@ async function* completionStream(messages, env, opts = {}) {
|
|
|
799
1057
|
return result.data;
|
|
800
1058
|
}
|
|
801
1059
|
if (!response.body) {
|
|
802
|
-
throw new
|
|
1060
|
+
throw new InternalError3("llm stream response body is null", {
|
|
803
1061
|
provider: streamLeg.provider
|
|
804
1062
|
});
|
|
805
1063
|
}
|
|
@@ -917,15 +1175,20 @@ function assertGrounding(response, sources) {
|
|
|
917
1175
|
export {
|
|
918
1176
|
BASE_BACKOFF_MS,
|
|
919
1177
|
DEFAULT_EMBEDDING_MODEL,
|
|
1178
|
+
LOCAL_EMBEDDING_MODEL,
|
|
920
1179
|
MODELS,
|
|
921
1180
|
MODEL_PRICE_PER_1M,
|
|
922
1181
|
PROVIDER_COOLDOWN_MS,
|
|
923
1182
|
assertGrounding,
|
|
1183
|
+
clearGcpTokenCache,
|
|
924
1184
|
clearProviderCooldown,
|
|
925
1185
|
complete,
|
|
926
1186
|
completionStream,
|
|
927
1187
|
embed,
|
|
1188
|
+
embedLocal,
|
|
928
1189
|
isProviderCoolingDown,
|
|
929
|
-
markProviderCoolingDown
|
|
1190
|
+
markProviderCoolingDown,
|
|
1191
|
+
mintGcpAccessToken,
|
|
1192
|
+
serviceAccountProjectId
|
|
930
1193
|
};
|
|
931
1194
|
//# sourceMappingURL=index.mjs.map
|