@graphorin/provider 0.6.1 → 0.7.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/dist/adapters/llamacpp-server.d.ts +1 -1
  4. package/dist/adapters/llamacpp-server.js.map +1 -1
  5. package/dist/adapters/ollama.d.ts.map +1 -1
  6. package/dist/adapters/ollama.js +19 -6
  7. package/dist/adapters/ollama.js.map +1 -1
  8. package/dist/adapters/vercel.d.ts +1 -1
  9. package/dist/adapters/vercel.d.ts.map +1 -1
  10. package/dist/adapters/vercel.js +111 -33
  11. package/dist/adapters/vercel.js.map +1 -1
  12. package/dist/errors/errors.d.ts +1 -1
  13. package/dist/errors/errors.js +1 -1
  14. package/dist/errors/errors.js.map +1 -1
  15. package/dist/index.js +0 -6
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/http.js +111 -7
  18. package/dist/internal/http.js.map +1 -1
  19. package/dist/internal/openai-shaped.js +21 -4
  20. package/dist/internal/openai-shaped.js.map +1 -1
  21. package/dist/middleware/with-fallback.js +1 -1
  22. package/dist/middleware/with-rate-limit.d.ts +21 -8
  23. package/dist/middleware/with-rate-limit.d.ts.map +1 -1
  24. package/dist/middleware/with-rate-limit.js +65 -12
  25. package/dist/middleware/with-rate-limit.js.map +1 -1
  26. package/dist/middleware/with-retry.js +1 -1
  27. package/dist/package.js +1 -1
  28. package/dist/package.js.map +1 -1
  29. package/package.json +18 -16
  30. package/src/adapters/index.ts +14 -0
  31. package/src/adapters/llamacpp-server.ts +102 -0
  32. package/src/adapters/ollama.ts +382 -0
  33. package/src/adapters/openai-compatible.ts +95 -0
  34. package/src/adapters/vercel-messages.ts +308 -0
  35. package/src/adapters/vercel.ts +706 -0
  36. package/src/counters/anthropic-wire.ts +199 -0
  37. package/src/counters/anthropic.ts +114 -0
  38. package/src/counters/bedrock.ts +46 -0
  39. package/src/counters/dispatcher.ts +127 -0
  40. package/src/counters/global.ts +39 -0
  41. package/src/counters/google.ts +46 -0
  42. package/src/counters/heuristic.ts +107 -0
  43. package/src/counters/index.ts +35 -0
  44. package/src/counters/js-tiktoken.ts +135 -0
  45. package/src/counters/serialize.ts +85 -0
  46. package/src/errors/errors.ts +316 -0
  47. package/src/errors/index.ts +20 -0
  48. package/src/index.ts +42 -0
  49. package/src/internal/abort.ts +30 -0
  50. package/src/internal/http.ts +388 -0
  51. package/src/internal/openai-shaped.ts +555 -0
  52. package/src/internal/sse.ts +112 -0
  53. package/src/internal/url-utils.ts +20 -0
  54. package/src/middleware/compose.ts +213 -0
  55. package/src/middleware/index.ts +37 -0
  56. package/src/middleware/production-hook.ts +47 -0
  57. package/src/middleware/with-cost-limit.ts +131 -0
  58. package/src/middleware/with-cost-tracking.ts +216 -0
  59. package/src/middleware/with-fallback.ts +157 -0
  60. package/src/middleware/with-rate-limit.ts +306 -0
  61. package/src/middleware/with-redaction.ts +671 -0
  62. package/src/middleware/with-retry.ts +274 -0
  63. package/src/middleware/with-tracing.ts +117 -0
  64. package/src/model-tier/classify.ts +125 -0
  65. package/src/model-tier/index.ts +11 -0
  66. package/src/provider.ts +121 -0
  67. package/src/reasoning/apply-policy.ts +89 -0
  68. package/src/reasoning/classify-contract.ts +120 -0
  69. package/src/reasoning/index.ts +21 -0
  70. package/src/reasoning/retention.ts +64 -0
  71. package/src/tool-examples.ts +54 -0
  72. package/src/trust/classify-local-provider.ts +254 -0
  73. package/src/trust/index.ts +14 -0
  74. package/dist/adapters/index.js +0 -6
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Internal HTTP helpers shared across the local-LLM adapters.
3
+ *
4
+ * @internal
5
+ */
6
+
7
+ import type { ProviderEvent } from '@graphorin/core';
8
+
9
+ import { ProviderHttpError } from '../errors/errors.js';
10
+
11
+ /**
12
+ * W-095: conversion options for the local-adapter message converters.
13
+ * `multimodal` mirrors the adapter instance's effective
14
+ * `capabilities.multimodal`; `warn` receives ONE message per convert
15
+ * call when parts were dropped (adapters dedupe it to once per
16
+ * instance).
17
+ *
18
+ * @internal
19
+ */
20
+ export interface ChatMessageConversionOptions {
21
+ readonly multimodal: boolean;
22
+ readonly warn?: (message: string) => void;
23
+ }
24
+
25
+ const TEXT_ONLY: ChatMessageConversionOptions = { multimodal: false };
26
+
27
+ /**
28
+ * Convert a graphorin `Message` to the OpenAI-compatible chat-completion
29
+ * shape. The shape is the lingua-franca of the bundled local adapters
30
+ * (`llamaCppServerAdapter` and `openAICompatibleAdapter`); the
31
+ * native-Ollama path uses its own conversion.
32
+ *
33
+ * W-095: with `opts.multimodal === true` image parts are emitted as
34
+ * OpenAI `image_url` content parts (bytes as a data URI, `URL`s passed
35
+ * through as strings - the server dereferences; this adapter never
36
+ * fetches). Audio/file parts have no portable wire form on
37
+ * OpenAI-compatible servers and are dropped LOUDLY. With
38
+ * `multimodal: false` (the default) content flattens to a plain string
39
+ * exactly as before, and any dropped non-text part triggers the warn.
40
+ *
41
+ * @internal
42
+ */
43
+ export function toOpenAIChatMessages(
44
+ messages: ReadonlyArray<{
45
+ readonly role: 'system' | 'user' | 'assistant' | 'tool';
46
+ readonly content: string | ReadonlyArray<unknown>;
47
+ readonly toolCalls?: ReadonlyArray<{
48
+ readonly toolCallId: string;
49
+ readonly toolName: string;
50
+ readonly args: unknown;
51
+ }>;
52
+ readonly toolCallId?: string;
53
+ }>,
54
+ opts: ChatMessageConversionOptions = TEXT_ONLY,
55
+ ): ReadonlyArray<Record<string, unknown>> {
56
+ const dropped = new Set<string>();
57
+ const converted = messages.map((msg) => {
58
+ const out: Record<string, unknown> = {
59
+ role: msg.role,
60
+ content:
61
+ typeof msg.content === 'string'
62
+ ? msg.content
63
+ : opts.multimodal
64
+ ? toOpenAIParts(msg.content, dropped)
65
+ : flattenContent(msg.content, dropped),
66
+ };
67
+ if (msg.toolCalls !== undefined && msg.toolCalls.length > 0) {
68
+ out.tool_calls = msg.toolCalls.map((tc) => ({
69
+ id: tc.toolCallId,
70
+ type: 'function',
71
+ function: {
72
+ name: tc.toolName,
73
+ arguments: typeof tc.args === 'string' ? tc.args : JSON.stringify(tc.args),
74
+ },
75
+ }));
76
+ }
77
+ if (msg.role === 'tool' && msg.toolCallId !== undefined) {
78
+ out.tool_call_id = msg.toolCallId;
79
+ }
80
+ return out;
81
+ });
82
+ warnDropped(opts, dropped);
83
+ return converted;
84
+ }
85
+
86
+ /** W-095: build the OpenAI `content` parts array for a vision model. */
87
+ function toOpenAIParts(
88
+ parts: ReadonlyArray<unknown>,
89
+ dropped: Set<string>,
90
+ ): ReadonlyArray<Record<string, unknown>> {
91
+ const out: Record<string, unknown>[] = [];
92
+ for (const part of parts) {
93
+ if (typeof part === 'string') {
94
+ out.push({ type: 'text', text: part });
95
+ continue;
96
+ }
97
+ if (typeof part !== 'object' || part === null) continue;
98
+ const obj = part as { type?: string; text?: string; image?: unknown; mimeType?: string };
99
+ if (obj.type === 'text' && typeof obj.text === 'string') {
100
+ out.push({ type: 'text', text: obj.text });
101
+ continue;
102
+ }
103
+ if (obj.type === 'image') {
104
+ const url = imageToUrl(obj.image, obj.mimeType);
105
+ if (url !== undefined) {
106
+ out.push({ type: 'image_url', image_url: { url } });
107
+ continue;
108
+ }
109
+ }
110
+ if (typeof obj.type === 'string') dropped.add(obj.type);
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * W-095: bytes become a `data:` URI (default mime `image/png`); `URL`s
117
+ * pass through as strings - the SERVER dereferences, the adapter makes
118
+ * no network call of its own.
119
+ */
120
+ function imageToUrl(image: unknown, mimeType?: string): string | undefined {
121
+ if (image instanceof URL) return image.toString();
122
+ if (image instanceof Uint8Array) {
123
+ const mime = mimeType ?? 'image/png';
124
+ return `data:${mime};base64,${Buffer.from(image).toString('base64')}`;
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ /** W-095: one honest WARN per convert call when parts were dropped. */
130
+ function warnDropped(opts: ChatMessageConversionOptions, dropped: Set<string>): void {
131
+ if (dropped.size === 0 || opts.warn === undefined) return;
132
+ const kinds = [...dropped].sort().join(', ');
133
+ opts.warn(
134
+ opts.multimodal
135
+ ? `content parts of kind [${kinds}] have no wire mapping on this adapter and were dropped`
136
+ : `non-text content parts of kind [${kinds}] were dropped - this adapter instance has capabilities.multimodal=false; pass capabilities: { multimodal: true } if the model supports vision`,
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Convert a graphorin `Message` to Ollama's **native** `/api/chat` shape
142
+ * (PS-13). Unlike the OpenAI form, Ollama's Go server expects `tool_calls`
143
+ * with object `arguments` (a `map[string]any`, never a JSON string) and no
144
+ * `id` / `type` fields - sending those breaks its unmarshaller and any
145
+ * multi-turn replay of assistant tool calls.
146
+ *
147
+ * @internal
148
+ */
149
+ export function toOllamaChatMessages(
150
+ messages: ReadonlyArray<{
151
+ readonly role: 'system' | 'user' | 'assistant' | 'tool';
152
+ readonly content: string | ReadonlyArray<unknown>;
153
+ readonly toolCalls?: ReadonlyArray<{
154
+ readonly toolCallId: string;
155
+ readonly toolName: string;
156
+ readonly args: unknown;
157
+ }>;
158
+ readonly toolCallId?: string;
159
+ }>,
160
+ opts: ChatMessageConversionOptions = TEXT_ONLY,
161
+ ): ReadonlyArray<Record<string, unknown>> {
162
+ const dropped = new Set<string>();
163
+ const converted = messages.map((msg) => {
164
+ let content: string;
165
+ let images: string[] | undefined;
166
+ if (typeof msg.content === 'string') {
167
+ content = msg.content;
168
+ } else if (opts.multimodal) {
169
+ // W-095: Ollama's native API takes a per-message `images` array
170
+ // of RAW base64 strings (no data: prefix). URL images cannot be
171
+ // inlined on this path (the adapter never fetches) - dropped
172
+ // loudly.
173
+ const split = toOllamaContent(msg.content, dropped);
174
+ content = split.text;
175
+ images = split.images.length > 0 ? split.images : undefined;
176
+ } else {
177
+ content = flattenContent(msg.content, dropped);
178
+ }
179
+ const out: Record<string, unknown> = { role: msg.role, content };
180
+ if (images !== undefined) out.images = images;
181
+ if (msg.toolCalls !== undefined && msg.toolCalls.length > 0) {
182
+ out.tool_calls = msg.toolCalls.map((tc) => ({
183
+ function: {
184
+ name: tc.toolName,
185
+ arguments: toArgsObject(tc.args),
186
+ },
187
+ }));
188
+ }
189
+ return out;
190
+ });
191
+ warnDropped(opts, dropped);
192
+ return converted;
193
+ }
194
+
195
+ /** W-095: split parts into flattened text + raw-base64 image payloads. */
196
+ function toOllamaContent(
197
+ parts: ReadonlyArray<unknown>,
198
+ dropped: Set<string>,
199
+ ): { readonly text: string; readonly images: string[] } {
200
+ const buffer: string[] = [];
201
+ const images: string[] = [];
202
+ for (const part of parts) {
203
+ if (typeof part === 'string') {
204
+ buffer.push(part);
205
+ continue;
206
+ }
207
+ if (typeof part !== 'object' || part === null) continue;
208
+ const obj = part as { type?: string; text?: string; image?: unknown };
209
+ if (obj.type === 'text' && typeof obj.text === 'string') {
210
+ buffer.push(obj.text);
211
+ continue;
212
+ }
213
+ if (obj.type === 'image' && obj.image instanceof Uint8Array) {
214
+ images.push(Buffer.from(obj.image).toString('base64'));
215
+ continue;
216
+ }
217
+ if (obj.type === 'image') {
218
+ dropped.add('image (URL - the native Ollama API needs inline bytes)');
219
+ continue;
220
+ }
221
+ if (typeof obj.type === 'string') dropped.add(obj.type);
222
+ }
223
+ return { text: buffer.join(''), images };
224
+ }
225
+
226
+ /**
227
+ * Coerce a tool-call `args` value into the object map Ollama expects. Strings
228
+ * (e.g. an OpenAI-style JSON blob produced upstream) are parsed leniently;
229
+ * anything that isn't a JSON object becomes `{}`.
230
+ */
231
+ function toArgsObject(args: unknown): Record<string, unknown> {
232
+ if (typeof args === 'string') {
233
+ try {
234
+ const parsed: unknown = JSON.parse(args);
235
+ return typeof parsed === 'object' && parsed !== null
236
+ ? (parsed as Record<string, unknown>)
237
+ : {};
238
+ } catch {
239
+ return {};
240
+ }
241
+ }
242
+ return typeof args === 'object' && args !== null ? (args as Record<string, unknown>) : {};
243
+ }
244
+
245
+ function flattenContent(parts: ReadonlyArray<unknown>, dropped?: Set<string>): string {
246
+ const buffer: string[] = [];
247
+ for (const part of parts) {
248
+ if (typeof part === 'string') {
249
+ buffer.push(part);
250
+ continue;
251
+ }
252
+ if (typeof part === 'object' && part !== null) {
253
+ const obj = part as { type?: string; text?: string };
254
+ if (obj.type === 'text' && typeof obj.text === 'string') {
255
+ buffer.push(obj.text);
256
+ } else if (typeof obj.type === 'string') {
257
+ // W-095: silently vanishing multimodal content was the bug -
258
+ // collect the kind so the caller can WARN once.
259
+ dropped?.add(obj.type);
260
+ }
261
+ }
262
+ }
263
+ return buffer.join('');
264
+ }
265
+
266
+ /**
267
+ * Wrap a `fetch` call with HTTP error mapping. The helper does not
268
+ * assume any particular streaming format - callers receive the raw
269
+ * `Response` and dispatch on its body.
270
+ *
271
+ * @internal
272
+ */
273
+ /**
274
+ * Default per-request timeout for the baseUrl adapters (PS-24). Scoped
275
+ * to time-to-response (headers): the timer is cleared the moment the
276
+ * server answers, so long streaming bodies are never killed - only a
277
+ * hung server that never responds. Generous because a cold local
278
+ * llama-server can take tens of seconds to load a model.
279
+ *
280
+ * @stable
281
+ */
282
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
283
+
284
+ export async function callJsonHttp(args: {
285
+ readonly providerName: string;
286
+ readonly url: string;
287
+ readonly headers: Record<string, string>;
288
+ readonly body: unknown;
289
+ readonly signal?: AbortSignal;
290
+ readonly fetchImpl?: typeof fetch;
291
+ /**
292
+ * Time-to-response budget (PS-24). Default
293
+ * {@link DEFAULT_REQUEST_TIMEOUT_MS}; `0` disables.
294
+ */
295
+ readonly timeoutMs?: number;
296
+ }): Promise<Response> {
297
+ const fetchImpl = args.fetchImpl ?? globalThis.fetch.bind(globalThis);
298
+ const timeoutMs = args.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
299
+ const timeoutCtl = timeoutMs > 0 ? new AbortController() : undefined;
300
+ const timer =
301
+ timeoutCtl !== undefined ? setTimeout(() => timeoutCtl.abort(), timeoutMs) : undefined;
302
+ const signal =
303
+ args.signal !== undefined && timeoutCtl !== undefined
304
+ ? AbortSignal.any([args.signal, timeoutCtl.signal])
305
+ : (args.signal ?? timeoutCtl?.signal);
306
+ let resp: Response;
307
+ try {
308
+ resp = await fetchImpl(args.url, {
309
+ method: 'POST',
310
+ headers: args.headers,
311
+ body: JSON.stringify(args.body),
312
+ ...(signal !== undefined ? { signal } : {}),
313
+ });
314
+ } catch (cause) {
315
+ if (timeoutCtl?.signal.aborted === true && args.signal?.aborted !== true) {
316
+ throw new ProviderHttpError({
317
+ providerName: args.providerName,
318
+ status: 0,
319
+ message: `request timed out after ${timeoutMs}ms reaching ${args.url}`,
320
+ cause,
321
+ });
322
+ }
323
+ throw new ProviderHttpError({
324
+ providerName: args.providerName,
325
+ status: 0,
326
+ message: `network error reaching ${args.url}`,
327
+ cause,
328
+ });
329
+ } finally {
330
+ if (timer !== undefined) clearTimeout(timer);
331
+ }
332
+ if (!resp.ok) {
333
+ const detail = await safeReadText(resp);
334
+ const headers = pickBackoffHeaders(resp.headers);
335
+ throw new ProviderHttpError({
336
+ providerName: args.providerName,
337
+ status: resp.status,
338
+ message: detail.length > 0 ? detail : resp.statusText,
339
+ ...(headers !== undefined ? { headers } : {}),
340
+ });
341
+ }
342
+ return resp;
343
+ }
344
+
345
+ /**
346
+ * Capture the backoff-relevant response headers (`retry-after`,
347
+ * `x-ratelimit-*`) so `withRetry`'s Retry-After hint reader can honour
348
+ * server-provided delays. Returns `undefined` when none are present.
349
+ */
350
+ function pickBackoffHeaders(headers: Headers): Readonly<Record<string, string>> | undefined {
351
+ const picked: Record<string, string> = {};
352
+ headers.forEach((value, key) => {
353
+ const lower = key.toLowerCase();
354
+ if (lower === 'retry-after' || lower.startsWith('x-ratelimit-')) {
355
+ picked[lower] = value;
356
+ }
357
+ });
358
+ return Object.keys(picked).length > 0 ? picked : undefined;
359
+ }
360
+
361
+ async function safeReadText(resp: Response): Promise<string> {
362
+ try {
363
+ return await resp.text();
364
+ } catch {
365
+ return '';
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Yield a `stream-start` `ProviderEvent` for an HTTP adapter.
371
+ *
372
+ * @internal
373
+ */
374
+ export function makeStreamStartEvent(args: {
375
+ readonly providerName: string;
376
+ readonly modelId: string;
377
+ readonly responseId?: string;
378
+ }): ProviderEvent {
379
+ return {
380
+ type: 'stream-start',
381
+ metadata: {
382
+ providerName: args.providerName,
383
+ modelId: args.modelId,
384
+ ...(args.responseId !== undefined ? { responseId: args.responseId } : {}),
385
+ createdAt: new Date().toISOString(),
386
+ },
387
+ };
388
+ }