@graphorin/provider 0.6.0 → 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 (76) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +8 -3
  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.d.ts +1 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +3 -8
  18. package/dist/index.js.map +1 -1
  19. package/dist/internal/http.js +111 -7
  20. package/dist/internal/http.js.map +1 -1
  21. package/dist/internal/openai-shaped.js +21 -4
  22. package/dist/internal/openai-shaped.js.map +1 -1
  23. package/dist/middleware/with-fallback.js +1 -1
  24. package/dist/middleware/with-rate-limit.d.ts +21 -8
  25. package/dist/middleware/with-rate-limit.d.ts.map +1 -1
  26. package/dist/middleware/with-rate-limit.js +65 -12
  27. package/dist/middleware/with-rate-limit.js.map +1 -1
  28. package/dist/middleware/with-retry.js +1 -1
  29. package/dist/package.js +6 -0
  30. package/dist/package.js.map +1 -0
  31. package/package.json +18 -16
  32. package/src/adapters/index.ts +14 -0
  33. package/src/adapters/llamacpp-server.ts +102 -0
  34. package/src/adapters/ollama.ts +382 -0
  35. package/src/adapters/openai-compatible.ts +95 -0
  36. package/src/adapters/vercel-messages.ts +308 -0
  37. package/src/adapters/vercel.ts +706 -0
  38. package/src/counters/anthropic-wire.ts +199 -0
  39. package/src/counters/anthropic.ts +114 -0
  40. package/src/counters/bedrock.ts +46 -0
  41. package/src/counters/dispatcher.ts +127 -0
  42. package/src/counters/global.ts +39 -0
  43. package/src/counters/google.ts +46 -0
  44. package/src/counters/heuristic.ts +107 -0
  45. package/src/counters/index.ts +35 -0
  46. package/src/counters/js-tiktoken.ts +135 -0
  47. package/src/counters/serialize.ts +85 -0
  48. package/src/errors/errors.ts +316 -0
  49. package/src/errors/index.ts +20 -0
  50. package/src/index.ts +42 -0
  51. package/src/internal/abort.ts +30 -0
  52. package/src/internal/http.ts +388 -0
  53. package/src/internal/openai-shaped.ts +555 -0
  54. package/src/internal/sse.ts +112 -0
  55. package/src/internal/url-utils.ts +20 -0
  56. package/src/middleware/compose.ts +213 -0
  57. package/src/middleware/index.ts +37 -0
  58. package/src/middleware/production-hook.ts +47 -0
  59. package/src/middleware/with-cost-limit.ts +131 -0
  60. package/src/middleware/with-cost-tracking.ts +216 -0
  61. package/src/middleware/with-fallback.ts +157 -0
  62. package/src/middleware/with-rate-limit.ts +306 -0
  63. package/src/middleware/with-redaction.ts +671 -0
  64. package/src/middleware/with-retry.ts +274 -0
  65. package/src/middleware/with-tracing.ts +117 -0
  66. package/src/model-tier/classify.ts +125 -0
  67. package/src/model-tier/index.ts +11 -0
  68. package/src/provider.ts +121 -0
  69. package/src/reasoning/apply-policy.ts +89 -0
  70. package/src/reasoning/classify-contract.ts +120 -0
  71. package/src/reasoning/index.ts +21 -0
  72. package/src/reasoning/retention.ts +64 -0
  73. package/src/tool-examples.ts +54 -0
  74. package/src/trust/classify-local-provider.ts +254 -0
  75. package/src/trust/index.ts +14 -0
  76. package/dist/adapters/index.js +0 -6
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Direct adapter for the upstream `llama-server` binary from the
3
+ * llama.cpp project. The binary speaks the OpenAI-compatible REST
4
+ * contract end-to-end (`POST /v1/chat/completions`, `POST /v1/completions`,
5
+ * `POST /v1/embeddings`); streaming is via `text/event-stream` chunks
6
+ * terminated by `data: [DONE]` exactly as the upstream OpenAI shape.
7
+ *
8
+ * The adapter shares a single `LocalProviderTrust` classifier with
9
+ * `ollamaAdapter` and `openAICompatibleAdapter` - one classifier, one
10
+ * policy table, one error type.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+
15
+ import type { Provider, Sensitivity } from '@graphorin/core';
16
+
17
+ import { buildOpenAIShapedProvider } from '../internal/openai-shaped.js';
18
+
19
+ /**
20
+ * Default port used by the upstream `llama-server` binary.
21
+ *
22
+ * @stable
23
+ */
24
+ export const DEFAULT_LLAMACPP_SERVER_BASE_URL = 'http://127.0.0.1:8080';
25
+
26
+ /**
27
+ * Options accepted by {@link llamaCppServerAdapter}.
28
+ *
29
+ * @stable
30
+ */
31
+ export interface LlamaCppServerAdapterOptions {
32
+ /** GGUF model identifier exposed by the running server (e.g. `'qwen2.5:7b-instruct-q4_k_m'`). */
33
+ readonly model: string;
34
+ /** Base URL of the running `llama-server` process. Defaults to `http://127.0.0.1:8080`. */
35
+ readonly baseUrl?: string;
36
+ /** Optional bearer-auth API key (`--api-key` flag on the server). */
37
+ readonly apiKey?: string;
38
+ /** Extra headers merged on top of `content-type` + `accept` defaults. */
39
+ readonly headers?: Readonly<Record<string, string>>;
40
+ /** Custom `fetch` implementation; useful for tests. */
41
+ readonly fetchImpl?: typeof fetch;
42
+ /**
43
+ * Time-to-response budget per request (PS-24). Default
44
+ * `DEFAULT_REQUEST_TIMEOUT_MS` (120s); `0` disables.
45
+ */
46
+ readonly timeoutMs?: number;
47
+ /** Capability overrides merged on top of the adapter defaults. */
48
+ readonly capabilities?: Partial<import('@graphorin/core').ProviderCapabilities>;
49
+ /**
50
+ * Acknowledge the risk of running over plaintext HTTP against a
51
+ * public host. Without this flag the adapter throws
52
+ * `LocalProviderInsecureTransportError`.
53
+ */
54
+ readonly allowInsecureTransport?: boolean;
55
+ /** Override for the default `acceptsSensitivity` value. */
56
+ readonly acceptsSensitivity?: ReadonlyArray<Sensitivity>;
57
+ /** Provider name attached to spans / log lines. */
58
+ readonly name?: string;
59
+ /** Optional log sink. Tests pass a fixture sink to silence the console. */
60
+ readonly logger?: (level: 'warn' | 'info', message: string, meta?: object) => void;
61
+ }
62
+
63
+ /**
64
+ * Build a Graphorin {@link Provider} backed by the upstream
65
+ * `llama-server` binary. The factory does not start the binary -
66
+ * operators launch it themselves with the desired model + GPU flags
67
+ * and pass the URL here.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * const local = createProvider(
72
+ * llamaCppServerAdapter({
73
+ * model: 'qwen2.5:7b-instruct-q4_k_m',
74
+ * baseUrl: 'http://127.0.0.1:8080',
75
+ * }),
76
+ * );
77
+ * ```
78
+ *
79
+ * @stable
80
+ */
81
+ export function llamaCppServerAdapter(options: LlamaCppServerAdapterOptions): Provider {
82
+ const baseUrl = options.baseUrl ?? DEFAULT_LLAMACPP_SERVER_BASE_URL;
83
+ const providerName = options.name ?? `llamacpp-server-${options.model}`;
84
+ const built = buildOpenAIShapedProvider({
85
+ providerName,
86
+ model: options.model,
87
+ baseUrl,
88
+ ...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}),
89
+ ...(options.headers !== undefined ? { headers: options.headers } : {}),
90
+ ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}),
91
+ ...(options.allowInsecureTransport !== undefined
92
+ ? { allowInsecureTransport: options.allowInsecureTransport }
93
+ : {}),
94
+ ...(options.acceptsSensitivity !== undefined
95
+ ? { acceptsSensitivity: options.acceptsSensitivity }
96
+ : {}),
97
+ ...(options.logger !== undefined ? { logger: options.logger } : {}),
98
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
99
+ capabilities: { multimodal: false, parallelToolCalls: false, ...options.capabilities },
100
+ });
101
+ return built.provider;
102
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Direct adapter for the Ollama HTTP API. The adapter speaks the
3
+ * native Ollama streaming JSON protocol (`POST /api/chat` returning
4
+ * newline-delimited JSON objects). For operators who prefer the
5
+ * OpenAI-compatible variant exposed by recent Ollama releases, the
6
+ * generic `openAICompatibleAdapter` is the better choice - both
7
+ * adapters share the same `LocalProviderTrust` classifier and
8
+ * {@link LocalProviderInsecureTransportError} startup behaviour.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ import { randomUUID } from 'node:crypto';
14
+ import type {
15
+ FinishReason,
16
+ Provider,
17
+ ProviderCapabilities,
18
+ ProviderEvent,
19
+ ProviderRequest,
20
+ ProviderResponse,
21
+ Sensitivity,
22
+ Usage,
23
+ } from '@graphorin/core';
24
+
25
+ import { LocalProviderInsecureTransportError, ProviderStreamParseError } from '../errors/errors.js';
26
+ import {
27
+ type ChatMessageConversionOptions,
28
+ callJsonHttp,
29
+ makeStreamStartEvent,
30
+ toOllamaChatMessages,
31
+ } from '../internal/http.js';
32
+ import { parseNdJsonStream } from '../internal/sse.js';
33
+ import { stripTrailingSlashes } from '../internal/url-utils.js';
34
+ import { applyReasoningPolicy } from '../reasoning/apply-policy.js';
35
+ import { resolveReasoningRetention } from '../reasoning/retention.js';
36
+ import { foldToolExamples } from '../tool-examples.js';
37
+ import {
38
+ classifyLocalProvider,
39
+ type LocalProviderClassification,
40
+ } from '../trust/classify-local-provider.js';
41
+
42
+ /**
43
+ * Default Ollama base URL.
44
+ *
45
+ * @stable
46
+ */
47
+ export const DEFAULT_OLLAMA_BASE_URL = 'http://127.0.0.1:11434';
48
+
49
+ /**
50
+ * Options accepted by {@link ollamaAdapter}.
51
+ *
52
+ * @stable
53
+ */
54
+ export interface OllamaAdapterOptions {
55
+ readonly model: string;
56
+ readonly baseUrl?: string;
57
+ readonly chatPath?: string;
58
+ readonly headers?: Readonly<Record<string, string>>;
59
+ readonly fetchImpl?: typeof fetch;
60
+ /**
61
+ * Time-to-response budget per request (PS-24). Default
62
+ * `DEFAULT_REQUEST_TIMEOUT_MS` (120s); `0` disables.
63
+ */
64
+ readonly timeoutMs?: number;
65
+ readonly allowInsecureTransport?: boolean;
66
+ readonly acceptsSensitivity?: ReadonlyArray<Sensitivity>;
67
+ readonly capabilities?: Partial<ProviderCapabilities>;
68
+ readonly name?: string;
69
+ readonly logger?: (level: 'warn' | 'info', message: string, meta?: object) => void;
70
+ }
71
+
72
+ const DEFAULT_CAPABILITIES: ProviderCapabilities = {
73
+ streaming: true,
74
+ toolCalling: true,
75
+ parallelToolCalls: false,
76
+ multimodal: false,
77
+ structuredOutput: true,
78
+ reasoning: false,
79
+ contextWindow: 8_192,
80
+ maxOutput: 4_096,
81
+ reasoningContract: 'optional',
82
+ };
83
+
84
+ /**
85
+ * Build a Graphorin {@link Provider} backed by Ollama's native HTTP
86
+ * API. The adapter is fail-safe by default: public-cleartext URLs
87
+ * refuse to start with `LocalProviderInsecureTransportError`.
88
+ *
89
+ * @stable
90
+ */
91
+ export function ollamaAdapter(options: OllamaAdapterOptions): Provider {
92
+ const baseUrl = options.baseUrl ?? DEFAULT_OLLAMA_BASE_URL;
93
+ const classification = classifyLocalProvider(baseUrl);
94
+ if (classification.trust === 'public-cleartext' && options.allowInsecureTransport !== true) {
95
+ throw new LocalProviderInsecureTransportError(baseUrl);
96
+ }
97
+ const log = options.logger ?? defaultLogger;
98
+ emitTrustWarning(log, classification, baseUrl);
99
+ const acceptsSensitivity = options.acceptsSensitivity ?? classification.acceptsSensitivity;
100
+ const providerName = options.name ?? `ollama-${options.model}`;
101
+ const capabilities: ProviderCapabilities = {
102
+ ...DEFAULT_CAPABILITIES,
103
+ ...options.capabilities,
104
+ };
105
+ const chatPath = options.chatPath ?? '/api/chat';
106
+ const url = `${stripTrailingSlashes(baseUrl)}${chatPath}`;
107
+ return {
108
+ name: providerName,
109
+ modelId: options.model,
110
+ capabilities,
111
+ acceptsSensitivity,
112
+ stream(req) {
113
+ return streamOllama(options, providerName, url, applyOllamaPreflight(req, capabilities));
114
+ },
115
+ async generate(req) {
116
+ return generateOllama(options, providerName, url, applyOllamaPreflight(req, capabilities));
117
+ },
118
+ };
119
+ }
120
+
121
+ function applyOllamaPreflight(
122
+ req: ProviderRequest,
123
+ capabilities: ProviderCapabilities,
124
+ ): ProviderRequest {
125
+ const retention = resolveReasoningRetention({
126
+ ...(req.reasoningRetention !== undefined ? { requested: req.reasoningRetention } : {}),
127
+ ...(capabilities.reasoningContract !== undefined
128
+ ? { contract: capabilities.reasoningContract }
129
+ : {}),
130
+ });
131
+ if (retention === 'pass-through-all') return req;
132
+ const filtered = applyReasoningPolicy({ messages: req.messages, retention });
133
+ if (filtered === req.messages) return req;
134
+ return { ...req, messages: filtered };
135
+ }
136
+
137
+ async function* streamOllama(
138
+ options: OllamaAdapterOptions,
139
+ providerName: string,
140
+ url: string,
141
+ req: ProviderRequest,
142
+ ): AsyncIterable<ProviderEvent> {
143
+ const body = buildBody(
144
+ options.model,
145
+ req,
146
+ true,
147
+ options.capabilities?.structuredOutput ?? true,
148
+ conversionOptionsFor(options),
149
+ );
150
+ const resp = await callJsonHttp({
151
+ providerName,
152
+ url,
153
+ headers: buildHeaders(options),
154
+ body,
155
+ ...(req.signal !== undefined ? { signal: req.signal } : {}),
156
+ ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}),
157
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
158
+ });
159
+ yield makeStreamStartEvent({ providerName, modelId: options.model });
160
+ let usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
161
+ let finishReason: FinishReason = 'stop';
162
+ for await (const line of parseNdJsonStream(
163
+ resp.body,
164
+ req.signal !== undefined ? { signal: req.signal } : {},
165
+ )) {
166
+ if (req.signal?.aborted) {
167
+ finishReason = 'aborted'; // PS-12: honest abort reason, not 'stop'
168
+ break;
169
+ }
170
+ let chunk: OllamaChatChunk;
171
+ try {
172
+ chunk = JSON.parse(line) as OllamaChatChunk;
173
+ } catch (cause) {
174
+ throw new ProviderStreamParseError(
175
+ providerName,
176
+ `failed to parse ndjson line: ${(cause as Error).message}`,
177
+ cause,
178
+ );
179
+ }
180
+ if (chunk.message?.content !== undefined && chunk.message.content.length > 0) {
181
+ yield { type: 'text-delta', delta: chunk.message.content };
182
+ }
183
+ if (Array.isArray(chunk.message?.tool_calls)) {
184
+ for (const tc of chunk.message.tool_calls) {
185
+ const toolCallId = tc.id ?? `call_${randomUUID()}`;
186
+ const toolName = tc.function?.name ?? '';
187
+ if (toolName.length === 0) continue;
188
+ yield { type: 'tool-call-start', toolCallId, toolName };
189
+ yield {
190
+ type: 'tool-call-end',
191
+ toolCallId,
192
+ finalArgs: tc.function?.arguments ?? {},
193
+ };
194
+ }
195
+ }
196
+ if (chunk.done === true) {
197
+ finishReason = mapFinishReason(chunk.done_reason);
198
+ usage = mapUsage(chunk);
199
+ break;
200
+ }
201
+ }
202
+ yield { type: 'finish', finishReason, usage };
203
+ }
204
+
205
+ async function generateOllama(
206
+ options: OllamaAdapterOptions,
207
+ providerName: string,
208
+ url: string,
209
+ req: ProviderRequest,
210
+ ): Promise<ProviderResponse> {
211
+ const body = buildBody(
212
+ options.model,
213
+ req,
214
+ false,
215
+ options.capabilities?.structuredOutput ?? true,
216
+ conversionOptionsFor(options),
217
+ );
218
+ const resp = await callJsonHttp({
219
+ providerName,
220
+ url,
221
+ headers: buildHeaders(options),
222
+ body,
223
+ ...(req.signal !== undefined ? { signal: req.signal } : {}),
224
+ ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}),
225
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
226
+ });
227
+ let json: OllamaChatChunk;
228
+ try {
229
+ json = (await resp.json()) as OllamaChatChunk;
230
+ } catch (cause) {
231
+ throw new ProviderStreamParseError(providerName, 'response body was not valid JSON', cause);
232
+ }
233
+ return {
234
+ usage: mapUsage(json),
235
+ finishReason: mapFinishReason(json.done_reason),
236
+ ...(typeof json.message?.content === 'string' && json.message.content.length > 0
237
+ ? { text: json.message.content }
238
+ : {}),
239
+ ...(Array.isArray(json.message?.tool_calls) && json.message.tool_calls.length > 0
240
+ ? {
241
+ toolCalls: json.message.tool_calls.map((tc) => ({
242
+ toolCallId: tc.id ?? `call_${randomUUID()}`,
243
+ toolName: tc.function?.name ?? '',
244
+ args: tc.function?.arguments ?? {},
245
+ })),
246
+ }
247
+ : {}),
248
+ };
249
+ }
250
+
251
+ function buildBody(
252
+ model: string,
253
+ req: ProviderRequest,
254
+ stream: boolean,
255
+ structuredOutput: boolean,
256
+ conversion: ChatMessageConversionOptions,
257
+ ): Record<string, unknown> {
258
+ const messages =
259
+ req.systemMessage !== undefined
260
+ ? [{ role: 'system' as const, content: req.systemMessage }, ...req.messages]
261
+ : req.messages;
262
+ const body: Record<string, unknown> = {
263
+ model,
264
+ messages: toOllamaChatMessages(messages, conversion),
265
+ stream,
266
+ };
267
+ if (req.temperature !== undefined || req.maxTokens !== undefined) {
268
+ const optionsBlock: Record<string, unknown> = {};
269
+ if (req.temperature !== undefined) optionsBlock.temperature = req.temperature;
270
+ if (req.maxTokens !== undefined) optionsBlock.num_predict = req.maxTokens;
271
+ body.options = optionsBlock;
272
+ }
273
+ if (req.tools !== undefined && req.tools.length > 0) {
274
+ // C2: fold worked examples in the adapter itself (idempotent when an
275
+ // upstream createProvider fold already ran).
276
+ body.tools = foldToolExamples(req.tools).map((t) => ({
277
+ type: 'function',
278
+ function: {
279
+ name: t.name,
280
+ ...(t.description !== undefined ? { description: t.description } : {}),
281
+ parameters: t.inputSchema,
282
+ },
283
+ }));
284
+ }
285
+ // PS-24: Ollama's native structured output - `format` takes a JSON
286
+ // schema object (or 'json' for schema-less JSON mode).
287
+ if (structuredOutput && req.outputType?.kind === 'structured') {
288
+ body.format = req.outputType.jsonSchema ?? 'json';
289
+ }
290
+ if (req.providerOptions !== undefined) {
291
+ Object.assign(body, req.providerOptions);
292
+ }
293
+ return body;
294
+ }
295
+
296
+ function buildHeaders(options: OllamaAdapterOptions): Record<string, string> {
297
+ return {
298
+ 'content-type': 'application/json',
299
+ accept: 'application/json',
300
+ ...options.headers,
301
+ };
302
+ }
303
+
304
+ function mapFinishReason(value: string | null | undefined): FinishReason {
305
+ switch (value) {
306
+ case 'stop':
307
+ case 'length':
308
+ return value;
309
+ case 'tool_calls':
310
+ return 'tool-calls';
311
+ default:
312
+ return 'stop';
313
+ }
314
+ }
315
+
316
+ function mapUsage(chunk: OllamaChatChunk): Usage {
317
+ const promptTokens = chunk.prompt_eval_count ?? 0;
318
+ const completionTokens = chunk.eval_count ?? 0;
319
+ return {
320
+ promptTokens,
321
+ completionTokens,
322
+ totalTokens: promptTokens + completionTokens,
323
+ };
324
+ }
325
+
326
+ function emitTrustWarning(
327
+ log: (level: 'warn' | 'info', message: string, meta?: object) => void,
328
+ classification: LocalProviderClassification,
329
+ baseUrl: string,
330
+ ): void {
331
+ if (classification.trust === 'public-cleartext') {
332
+ log('warn', `[ollama] allowInsecureTransport=true accepted for ${baseUrl}`, { baseUrl });
333
+ } else if (classification.trust === 'public-tls') {
334
+ log('warn', `[ollama] public-TLS endpoint; treating as cloud-tier`, { baseUrl });
335
+ } else if (classification.trust === 'private') {
336
+ log('warn', `[ollama] private-network endpoint detected (${classification.reason})`, {
337
+ baseUrl,
338
+ acceptsSensitivity: classification.acceptsSensitivity,
339
+ });
340
+ }
341
+ }
342
+
343
+ function defaultLogger(level: 'warn' | 'info', message: string, meta?: object): void {
344
+ const fn = level === 'warn' ? console.warn : console.info;
345
+ if (meta !== undefined) {
346
+ fn(`[graphorin/provider] ${message}`, meta);
347
+ } else {
348
+ fn(`[graphorin/provider] ${message}`);
349
+ }
350
+ }
351
+
352
+ /** W-095: one dropped-content WARN per adapter instance. */
353
+ const droppedContentWarned = new WeakSet<object>();
354
+
355
+ function conversionOptionsFor(options: OllamaAdapterOptions): ChatMessageConversionOptions {
356
+ const log = options.logger ?? defaultLogger;
357
+ return {
358
+ multimodal: options.capabilities?.multimodal ?? false,
359
+ warn: (message) => {
360
+ if (droppedContentWarned.has(options)) return;
361
+ droppedContentWarned.add(options);
362
+ log('warn', message);
363
+ },
364
+ };
365
+ }
366
+
367
+ interface OllamaChatChunk {
368
+ readonly model?: string;
369
+ readonly created_at?: string;
370
+ readonly message?: {
371
+ readonly role?: string;
372
+ readonly content?: string;
373
+ readonly tool_calls?: ReadonlyArray<{
374
+ readonly id?: string;
375
+ readonly function?: { readonly name?: string; readonly arguments?: unknown };
376
+ }>;
377
+ };
378
+ readonly done?: boolean;
379
+ readonly done_reason?: string;
380
+ readonly prompt_eval_count?: number;
381
+ readonly eval_count?: number;
382
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Generic OpenAI-compatible adapter - works against any HTTP server
3
+ * that speaks the `/v1/chat/completions` REST contract. Tested
4
+ * deployments include LMStudio (default port 1234), LocalAI (default
5
+ * port 8080), vLLM (`python -m vllm.entrypoints.openai.api_server`,
6
+ * default port 8000), Together-style self-host endpoints, and any
7
+ * other server in the OpenAI-compatible ecosystem.
8
+ *
9
+ * The adapter shares the same `LocalProviderTrust` classifier as
10
+ * `ollamaAdapter` and `llamaCppServerAdapter` - one classifier, one
11
+ * policy table, one error type.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+
16
+ import type { Provider, ProviderCapabilities, Sensitivity } from '@graphorin/core';
17
+
18
+ import { buildOpenAIShapedProvider } from '../internal/openai-shaped.js';
19
+
20
+ /**
21
+ * Options accepted by {@link openAICompatibleAdapter}.
22
+ *
23
+ * @stable
24
+ */
25
+ export interface OpenAICompatibleAdapterOptions {
26
+ /** Model identifier sent in the request body's `model` field. */
27
+ readonly model: string;
28
+ /**
29
+ * Base URL of the OpenAI-compatible server. The classifier inspects
30
+ * the protocol + host to assign a `LocalProviderTrust` value.
31
+ */
32
+ readonly baseUrl: string;
33
+ /** Optional REST path override. Defaults to `/v1/chat/completions`. */
34
+ readonly chatPath?: string;
35
+ /** Optional bearer-auth API key. */
36
+ readonly apiKey?: string;
37
+ /** Extra headers merged on top of `content-type` + `accept` defaults. */
38
+ readonly headers?: Readonly<Record<string, string>>;
39
+ /** Custom `fetch` implementation; useful for tests. */
40
+ readonly fetchImpl?: typeof fetch;
41
+ /**
42
+ * Acknowledge the risk of running over plaintext HTTP against a
43
+ * public host.
44
+ */
45
+ readonly allowInsecureTransport?: boolean;
46
+ /**
47
+ * Capability overrides merged on top of the adapter defaults
48
+ * (core-provider-10). Use them to widen `contextWindow` /
49
+ * `maxOutput` for large-context servers or to set
50
+ * `structuredOutput: false` for servers that reject
51
+ * `response_format`.
52
+ */
53
+ readonly capabilities?: Partial<ProviderCapabilities>;
54
+ /**
55
+ * Time-to-response budget per request (PS-24). Default 120s; `0`
56
+ * disables.
57
+ */
58
+ readonly timeoutMs?: number;
59
+ /** Override for the default `acceptsSensitivity` value. */
60
+ readonly acceptsSensitivity?: ReadonlyArray<Sensitivity>;
61
+ /** Provider name attached to spans / log lines. */
62
+ readonly name?: string;
63
+ /** Optional log sink. */
64
+ readonly logger?: (level: 'warn' | 'info', message: string, meta?: object) => void;
65
+ }
66
+
67
+ /**
68
+ * Build a Graphorin {@link Provider} backed by an OpenAI-compatible
69
+ * HTTP server. The same code path serves LMStudio, LocalAI, vLLM, and
70
+ * any other compatible self-host endpoint.
71
+ *
72
+ * @stable
73
+ */
74
+ export function openAICompatibleAdapter(options: OpenAICompatibleAdapterOptions): Provider {
75
+ const providerName = options.name ?? `openai-compatible-${options.model}`;
76
+ const built = buildOpenAIShapedProvider({
77
+ providerName,
78
+ model: options.model,
79
+ baseUrl: options.baseUrl,
80
+ ...(options.chatPath !== undefined ? { chatPath: options.chatPath } : {}),
81
+ ...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}),
82
+ ...(options.headers !== undefined ? { headers: options.headers } : {}),
83
+ ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}),
84
+ ...(options.allowInsecureTransport !== undefined
85
+ ? { allowInsecureTransport: options.allowInsecureTransport }
86
+ : {}),
87
+ ...(options.capabilities !== undefined ? { capabilities: options.capabilities } : {}),
88
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
89
+ ...(options.acceptsSensitivity !== undefined
90
+ ? { acceptsSensitivity: options.acceptsSensitivity }
91
+ : {}),
92
+ ...(options.logger !== undefined ? { logger: options.logger } : {}),
93
+ });
94
+ return built.provider;
95
+ }