@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.
- package/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/dist/adapters/llamacpp-server.d.ts +1 -1
- package/dist/adapters/llamacpp-server.js.map +1 -1
- package/dist/adapters/ollama.d.ts.map +1 -1
- package/dist/adapters/ollama.js +19 -6
- package/dist/adapters/ollama.js.map +1 -1
- package/dist/adapters/vercel.d.ts +1 -1
- package/dist/adapters/vercel.d.ts.map +1 -1
- package/dist/adapters/vercel.js +111 -33
- package/dist/adapters/vercel.js.map +1 -1
- package/dist/errors/errors.d.ts +1 -1
- package/dist/errors/errors.js +1 -1
- package/dist/errors/errors.js.map +1 -1
- package/dist/index.js +0 -6
- package/dist/index.js.map +1 -1
- package/dist/internal/http.js +111 -7
- package/dist/internal/http.js.map +1 -1
- package/dist/internal/openai-shaped.js +21 -4
- package/dist/internal/openai-shaped.js.map +1 -1
- package/dist/middleware/with-fallback.js +1 -1
- package/dist/middleware/with-rate-limit.d.ts +21 -8
- package/dist/middleware/with-rate-limit.d.ts.map +1 -1
- package/dist/middleware/with-rate-limit.js +65 -12
- package/dist/middleware/with-rate-limit.js.map +1 -1
- package/dist/middleware/with-retry.js +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/package.json +18 -16
- package/src/adapters/index.ts +14 -0
- package/src/adapters/llamacpp-server.ts +102 -0
- package/src/adapters/ollama.ts +382 -0
- package/src/adapters/openai-compatible.ts +95 -0
- package/src/adapters/vercel-messages.ts +308 -0
- package/src/adapters/vercel.ts +706 -0
- package/src/counters/anthropic-wire.ts +199 -0
- package/src/counters/anthropic.ts +114 -0
- package/src/counters/bedrock.ts +46 -0
- package/src/counters/dispatcher.ts +127 -0
- package/src/counters/global.ts +39 -0
- package/src/counters/google.ts +46 -0
- package/src/counters/heuristic.ts +107 -0
- package/src/counters/index.ts +35 -0
- package/src/counters/js-tiktoken.ts +135 -0
- package/src/counters/serialize.ts +85 -0
- package/src/errors/errors.ts +316 -0
- package/src/errors/index.ts +20 -0
- package/src/index.ts +42 -0
- package/src/internal/abort.ts +30 -0
- package/src/internal/http.ts +388 -0
- package/src/internal/openai-shaped.ts +555 -0
- package/src/internal/sse.ts +112 -0
- package/src/internal/url-utils.ts +20 -0
- package/src/middleware/compose.ts +213 -0
- package/src/middleware/index.ts +37 -0
- package/src/middleware/production-hook.ts +47 -0
- package/src/middleware/with-cost-limit.ts +131 -0
- package/src/middleware/with-cost-tracking.ts +216 -0
- package/src/middleware/with-fallback.ts +157 -0
- package/src/middleware/with-rate-limit.ts +306 -0
- package/src/middleware/with-redaction.ts +671 -0
- package/src/middleware/with-retry.ts +274 -0
- package/src/middleware/with-tracing.ts +117 -0
- package/src/model-tier/classify.ts +125 -0
- package/src/model-tier/index.ts +11 -0
- package/src/provider.ts +121 -0
- package/src/reasoning/apply-policy.ts +89 -0
- package/src/reasoning/classify-contract.ts +120 -0
- package/src/reasoning/index.ts +21 -0
- package/src/reasoning/retention.ts +64 -0
- package/src/tool-examples.ts +54 -0
- package/src/trust/classify-local-provider.ts +254 -0
- package/src/trust/index.ts +14 -0
- 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
|
+
}
|