@lihuu/dsh-ollama-cloud 0.1.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/README.md +70 -0
- package/cordis.patch.yml +8 -0
- package/dist/index.js +837 -0
- package/lib/adapter.d.ts +111 -0
- package/lib/index.d.ts +62 -0
- package/lib/serialize.d.ts +44 -0
- package/lib/sse.d.ts +23 -0
- package/lib/translate.d.ts +32 -0
- package/lib/types.d.ts +143 -0
- package/package.json +52 -0
- package/src/adapter.ts +423 -0
- package/src/index.ts +214 -0
- package/src/serialize.ts +215 -0
- package/src/sse.ts +40 -0
- package/src/translate.ts +182 -0
- package/src/types.ts +148 -0
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OllamaAdapter`: fetch + SSE against an Ollama (OpenAI-compatible)
|
|
3
|
+
* chat-completions endpoint, emitting harness StreamChunks. Transport-only:
|
|
4
|
+
* connection facts arrive through a thunk resolved once per operation and the
|
|
5
|
+
* bearer token through a per-request resolver.
|
|
6
|
+
*
|
|
7
|
+
* Model ids are normalized to Ollama's `:cloud` naming on every operation: a
|
|
8
|
+
* request for `deepseek-v4-flash` is sent as `deepseek-v4-flash:cloud`, and an
|
|
9
|
+
* already-suffixed id is forwarded unchanged.
|
|
10
|
+
*
|
|
11
|
+
* Dependencies are intentionally minimal: `@deepseek-ai/dsh-llm` (the harness
|
|
12
|
+
* LLM seam contract), `@deepseek-ai/cordis` (plugin framework), and
|
|
13
|
+
* `eventsource-parser` (SSE framing). Everything else is hand-rolled here.
|
|
14
|
+
*
|
|
15
|
+
* @module llm-ollama-cloud/adapter
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
attributionHeaders,
|
|
20
|
+
contentHasImage,
|
|
21
|
+
CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
22
|
+
isContextWindowExceededError,
|
|
23
|
+
isQuotaExceededError,
|
|
24
|
+
LlmAdapter,
|
|
25
|
+
LlmError,
|
|
26
|
+
ProviderRequestId,
|
|
27
|
+
QUOTA_EXCEEDED_CODE,
|
|
28
|
+
ReasoningEffortId,
|
|
29
|
+
} from '@deepseek-ai/dsh-llm'
|
|
30
|
+
import type {
|
|
31
|
+
GenerateOptions,
|
|
32
|
+
LlmModelInfo,
|
|
33
|
+
LlmProviderInfo,
|
|
34
|
+
LlmResolvedModelInfo,
|
|
35
|
+
ModelModality,
|
|
36
|
+
ResolvedRetryPolicy,
|
|
37
|
+
StreamChunk,
|
|
38
|
+
} from '@deepseek-ai/dsh-llm'
|
|
39
|
+
import { serializeRequest } from './serialize.ts'
|
|
40
|
+
import type { RequestDefaults } from './serialize.ts'
|
|
41
|
+
import { parseSse } from './sse.ts'
|
|
42
|
+
import { translate } from './translate.ts'
|
|
43
|
+
import type { WireError } from './types.ts'
|
|
44
|
+
|
|
45
|
+
/** One optional model entry advertised by the direct-fetch adapter. */
|
|
46
|
+
export interface OllamaCatalogModel {
|
|
47
|
+
/** Wire model id accepted by the configured endpoint; a missing `:cloud` suffix is appended. */
|
|
48
|
+
id: string
|
|
49
|
+
/** Selector label; defaults to {@link id}. */
|
|
50
|
+
name?: string
|
|
51
|
+
/** Optional selector detail for deployments with similar model variants. */
|
|
52
|
+
description?: string
|
|
53
|
+
/** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */
|
|
54
|
+
contextWindow?: number
|
|
55
|
+
/** Per-request output cap for this model; omission falls back to the profile's {@link OllamaConnectionOptions.maxTokens}. */
|
|
56
|
+
maxTokens?: number
|
|
57
|
+
/** Accepted request modalities; omission is text-only. */
|
|
58
|
+
inputModalities?: ModelModality[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validated connection facts for one operation. The plugin's
|
|
63
|
+
* `resolveAdapterOptions` is the one explicit resolve step producing this
|
|
64
|
+
* shape; the adapter trusts it and re-reads it per operation.
|
|
65
|
+
*/
|
|
66
|
+
export interface OllamaConnectionOptions {
|
|
67
|
+
/** Endpoint base; `/chat/completions` is appended. */
|
|
68
|
+
baseURL: string
|
|
69
|
+
/** Environment-variable name holding the bearer token, resolved per request. */
|
|
70
|
+
apiKeyEnv: string
|
|
71
|
+
/** Request defaults applied to every call (thinking mode, effort). */
|
|
72
|
+
defaults: RequestDefaults
|
|
73
|
+
/** Default per-request output cap; explicit request values win. */
|
|
74
|
+
maxTokens: number
|
|
75
|
+
/** Positive context capacity used when the selected model has no exact value. */
|
|
76
|
+
defaultContextWindow: number
|
|
77
|
+
/** Advisory models exposed to discovery consumers; requests remain unrestricted. */
|
|
78
|
+
models: readonly OllamaCatalogModel[]
|
|
79
|
+
/** Maximum provider idle time while one stream read is outstanding. */
|
|
80
|
+
streamIdleTimeoutMs: number
|
|
81
|
+
/** Provider-owned model-request retry policy, already resolved. */
|
|
82
|
+
retryPolicy: ResolvedRetryPolicy
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Constructor options for {@link OllamaAdapter}: the operation-local resolution hooks the plugin owns. */
|
|
86
|
+
export interface OllamaAdapterOptions {
|
|
87
|
+
/** Current validated connection facts; called once per operation. */
|
|
88
|
+
options: () => OllamaConnectionOptions
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the bearer token for the connection facts of one request. The
|
|
91
|
+
* snapshot is passed in — never re-read — so the key can only ever come
|
|
92
|
+
* from the same resolution as the endpoint it is sent to.
|
|
93
|
+
*/
|
|
94
|
+
resolveApiKey: (connection: OllamaConnectionOptions) => Promise<string>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
|
98
|
+
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
|
|
99
|
+
/** Default combined request/response context capacity. */
|
|
100
|
+
export const DEFAULT_CONTEXT_WINDOW = 1_000_000
|
|
101
|
+
/** Default per-request output-token cap. */
|
|
102
|
+
export const DEFAULT_MAX_TOKENS = 65_536
|
|
103
|
+
/** The Ollama cloud model-name suffix this adapter appends when missing. */
|
|
104
|
+
export const CLOUD_SUFFIX = ':cloud'
|
|
105
|
+
/** Largest value `setTimeout` accepts (2^31 - 1 ms). */
|
|
106
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
107
|
+
const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT'
|
|
108
|
+
const OFF_REASONING_EFFORT = ReasoningEffortId('off')
|
|
109
|
+
const LOW_REASONING_EFFORT = ReasoningEffortId('low')
|
|
110
|
+
const HIGH_REASONING_EFFORT = ReasoningEffortId('high')
|
|
111
|
+
const MAX_REASONING_EFFORT = ReasoningEffortId('max')
|
|
112
|
+
const REASONING_EFFORTS = [
|
|
113
|
+
{ id: OFF_REASONING_EFFORT, name: 'Off' },
|
|
114
|
+
{ id: LOW_REASONING_EFFORT, name: 'Low' },
|
|
115
|
+
{ id: HIGH_REASONING_EFFORT, name: 'High' },
|
|
116
|
+
{ id: MAX_REASONING_EFFORT, name: 'Max' },
|
|
117
|
+
] as const
|
|
118
|
+
const OFF_ONLY_REASONING_EFFORTS = [
|
|
119
|
+
{ id: OFF_REASONING_EFFORT, name: 'Off' },
|
|
120
|
+
] as const
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Normalize a model id to Ollama's cloud naming. An id already carrying the
|
|
124
|
+
* `:cloud` suffix is returned unchanged; any other id gets it appended. This
|
|
125
|
+
* is the one place a bare harness model name becomes a wire model name.
|
|
126
|
+
* @param model - the requested model id.
|
|
127
|
+
* @returns the id with a `:cloud` suffix.
|
|
128
|
+
*/
|
|
129
|
+
export function normalizeCloud(model: string): string {
|
|
130
|
+
return model.endsWith(CLOUD_SUFFIX) ? model : `${model}${CLOUD_SUFFIX}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Minimal idle watchdog: arms a timer on construction and after every read,
|
|
135
|
+
* and aborts its signal when the idle budget elapses without a pulse. The
|
|
136
|
+
* {@link OllamaAdapter} maps the expired flag to `TIMEOUT` and the caller's
|
|
137
|
+
* own abort to `ABORTED`.
|
|
138
|
+
*/
|
|
139
|
+
class IdleWatchdog {
|
|
140
|
+
private readonly controller = new AbortController()
|
|
141
|
+
private timer: ReturnType<typeof setTimeout> | undefined
|
|
142
|
+
private expired = false
|
|
143
|
+
/** Combined caller + watchdog signal; aborts when either fires. */
|
|
144
|
+
readonly signal: AbortSignal
|
|
145
|
+
|
|
146
|
+
constructor(upstream: AbortSignal, private readonly timeoutMs: number) {
|
|
147
|
+
this.signal = upstream.aborted
|
|
148
|
+
? upstream
|
|
149
|
+
: AbortSignal.any([upstream, this.controller.signal])
|
|
150
|
+
if (!upstream.aborted) {
|
|
151
|
+
upstream.addEventListener('abort', () => this.stop(), { once: true })
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
get didExpire(): boolean {
|
|
156
|
+
return this.expired
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private arm(): void {
|
|
160
|
+
this.stop()
|
|
161
|
+
this.timer = setTimeout(() => {
|
|
162
|
+
this.expired = true
|
|
163
|
+
this.controller.abort(new Error(STREAM_IDLE_TIMEOUT_CODE))
|
|
164
|
+
}, this.timeoutMs)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Rearm the idle window; called after each provider read. */
|
|
168
|
+
pulse(): void {
|
|
169
|
+
this.arm()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
stop(): void {
|
|
173
|
+
if (this.timer !== undefined) {
|
|
174
|
+
clearTimeout(this.timer)
|
|
175
|
+
this.timer = undefined
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function modelInfo(provider: string, model: OllamaCatalogModel): LlmModelInfo {
|
|
181
|
+
return {
|
|
182
|
+
provider,
|
|
183
|
+
id: model.id,
|
|
184
|
+
name: model.name ?? model.id,
|
|
185
|
+
...model.description === undefined ? {} : { description: model.description },
|
|
186
|
+
inputModalities: model.inputModalities ?? ['text'],
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function providerRetryAfterMs(value: string | null): number | undefined {
|
|
191
|
+
if (value === null) return undefined
|
|
192
|
+
if (/^\d+$/.test(value)) {
|
|
193
|
+
const delay = Number(value) * 1_000
|
|
194
|
+
return Number.isFinite(delay) && delay > 0 ? delay : undefined
|
|
195
|
+
}
|
|
196
|
+
const delay = Date.parse(value) - Date.now()
|
|
197
|
+
return Number.isFinite(delay) && delay > 0 ? delay : undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function requestId(headers: Headers): ReturnType<typeof ProviderRequestId> | undefined {
|
|
201
|
+
const value = headers.get('x-request-id') ?? headers.get('x-ollama-request-id')
|
|
202
|
+
return value === null || value.length === 0 ? undefined : ProviderRequestId(value)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Map an HTTP status to a stable LlmError code.
|
|
207
|
+
* @param status - status of a non-2xx provider response.
|
|
208
|
+
* @param error - parsed provider error body, when available.
|
|
209
|
+
* @returns the normalized harness error code.
|
|
210
|
+
*/
|
|
211
|
+
export function httpErrorCode(status: number, error?: WireError['error']): string {
|
|
212
|
+
if (status === 401 || status === 403) return 'AUTH'
|
|
213
|
+
if (status === 413) return 'INVALID_REQUEST'
|
|
214
|
+
const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ')
|
|
215
|
+
if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE
|
|
216
|
+
if (status === 429) return 'RATE_LIMIT'
|
|
217
|
+
if (status === 400) {
|
|
218
|
+
if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE
|
|
219
|
+
return 'INVALID_REQUEST'
|
|
220
|
+
}
|
|
221
|
+
if (status >= 500) return 'SERVER'
|
|
222
|
+
return `HTTP_${status}`
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* One instance serves every model name it was registered under. The harness
|
|
227
|
+
* model name is normalized to its cloud form and IS the wire model name.
|
|
228
|
+
*
|
|
229
|
+
* One stable signal reaches both initial fetch and body reads. Caller aborts
|
|
230
|
+
* map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`.
|
|
231
|
+
*/
|
|
232
|
+
export class OllamaAdapter extends LlmAdapter {
|
|
233
|
+
constructor(private readonly config: OllamaAdapterOptions) {
|
|
234
|
+
super()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
override providerInfo(provider: string): LlmProviderInfo {
|
|
238
|
+
return { id: provider, name: 'Ollama' }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
override providerRetryPolicy(_provider: string): ResolvedRetryPolicy {
|
|
242
|
+
return this.config.options().retryPolicy
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
|
246
|
+
return Promise.resolve(this.config.options().models.map(model => modelInfo(provider, model)))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
override resolveModel(
|
|
250
|
+
provider: string,
|
|
251
|
+
model: string,
|
|
252
|
+
_signal?: AbortSignal,
|
|
253
|
+
): Promise<LlmResolvedModelInfo> {
|
|
254
|
+
const connection = this.config.options()
|
|
255
|
+
// Resolve against the wire (cloud-suffixed) id so an unsuffixed request
|
|
256
|
+
// still matches its catalog entry and reports the cloud id onward.
|
|
257
|
+
const wireModel = normalizeCloud(model)
|
|
258
|
+
const configured = connection.models.find(entry => entry.id === wireModel)
|
|
259
|
+
const contextWindow = configured?.contextWindow
|
|
260
|
+
?? connection.defaultContextWindow
|
|
261
|
+
return Promise.resolve({
|
|
262
|
+
// An uncatalogued endpoint is safely treated as text-only.
|
|
263
|
+
...configured === undefined
|
|
264
|
+
? { provider, id: wireModel, name: wireModel, inputModalities: ['text' as const] }
|
|
265
|
+
: modelInfo(provider, configured),
|
|
266
|
+
context: { contextWindow },
|
|
267
|
+
defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens,
|
|
268
|
+
...connection.defaults.thinking === 'disabled'
|
|
269
|
+
? {
|
|
270
|
+
reasoning: {
|
|
271
|
+
efforts: OFF_ONLY_REASONING_EFFORTS,
|
|
272
|
+
defaultEffort: OFF_REASONING_EFFORT,
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
: {
|
|
276
|
+
reasoning: {
|
|
277
|
+
efforts: REASONING_EFFORTS,
|
|
278
|
+
defaultEffort: connection.defaults.reasoningEffort === 'off'
|
|
279
|
+
? OFF_REASONING_EFFORT
|
|
280
|
+
: connection.defaults.reasoningEffort === 'low'
|
|
281
|
+
? LOW_REASONING_EFFORT
|
|
282
|
+
: connection.defaults.reasoningEffort === 'max'
|
|
283
|
+
? MAX_REASONING_EFFORT
|
|
284
|
+
: HIGH_REASONING_EFFORT,
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
291
|
+
// One resolution per stream call: connection facts and the credential
|
|
292
|
+
// freeze here and hold for this whole request.
|
|
293
|
+
const connection = this.config.options()
|
|
294
|
+
if (options.messages.some(message => contentHasImage(message.content))) {
|
|
295
|
+
throw new LlmError(
|
|
296
|
+
'Ollama image input is not supported yet.',
|
|
297
|
+
'UNSUPPORTED_CONTENT',
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
const apiKey = await this.config.resolveApiKey(connection)
|
|
301
|
+
const consumer = new AbortController()
|
|
302
|
+
const upstream = options.signal === undefined
|
|
303
|
+
? consumer.signal
|
|
304
|
+
: AbortSignal.any([options.signal, consumer.signal])
|
|
305
|
+
const watchdog = new IdleWatchdog(upstream, connection.streamIdleTimeoutMs)
|
|
306
|
+
const iterator = this.request(
|
|
307
|
+
options,
|
|
308
|
+
watchdog.signal,
|
|
309
|
+
connection,
|
|
310
|
+
apiKey,
|
|
311
|
+
() => watchdog.pulse(),
|
|
312
|
+
)[Symbol.asyncIterator]()
|
|
313
|
+
let exhausted = false
|
|
314
|
+
try {
|
|
315
|
+
while (true) {
|
|
316
|
+
watchdog.pulse()
|
|
317
|
+
const result = await iterator.next()
|
|
318
|
+
if (result.done) {
|
|
319
|
+
exhausted = true
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
yield result.value
|
|
323
|
+
}
|
|
324
|
+
} catch (error: unknown) {
|
|
325
|
+
if (watchdog.didExpire) {
|
|
326
|
+
throw new LlmError(
|
|
327
|
+
`Ollama stream idle timeout after ${connection.streamIdleTimeoutMs}ms`,
|
|
328
|
+
'TIMEOUT',
|
|
329
|
+
{ cause: error },
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
if (options.signal?.aborted) {
|
|
333
|
+
throw new LlmError('Ollama request aborted by caller', 'ABORTED', { cause: error })
|
|
334
|
+
}
|
|
335
|
+
if (error instanceof LlmError) throw error
|
|
336
|
+
throw new LlmError(`Ollama API stream from ${connection.baseURL} failed`, 'TRANSPORT', { cause: error })
|
|
337
|
+
} finally {
|
|
338
|
+
watchdog.stop()
|
|
339
|
+
consumer.abort('Ollama stream consumer stopped')
|
|
340
|
+
if (!exhausted && iterator.return !== undefined) {
|
|
341
|
+
try {
|
|
342
|
+
await iterator.return()
|
|
343
|
+
} catch (_abortedTransportTeardown) {
|
|
344
|
+
// The consumer controller already owns termination; a return-time abort cannot add a second outcome.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private async * request(
|
|
351
|
+
options: GenerateOptions,
|
|
352
|
+
signal: AbortSignal,
|
|
353
|
+
connection: OllamaConnectionOptions,
|
|
354
|
+
apiKey: string,
|
|
355
|
+
onComment: () => void,
|
|
356
|
+
): AsyncIterable<StreamChunk> {
|
|
357
|
+
const body = serializeRequest(
|
|
358
|
+
{ ...options, model: normalizeCloud(options.model) },
|
|
359
|
+
connection.defaults,
|
|
360
|
+
)
|
|
361
|
+
// Prepared outside the try so the TRANSPORT label below covers exactly the
|
|
362
|
+
// transport boundary, never a serialization failure.
|
|
363
|
+
const payload = JSON.stringify(body)
|
|
364
|
+
const headers = {
|
|
365
|
+
'authorization': `Bearer ${apiKey}`,
|
|
366
|
+
'content-type': 'application/json',
|
|
367
|
+
'accept': 'text/event-stream',
|
|
368
|
+
...attributionHeaders(),
|
|
369
|
+
...options.sessionId !== undefined
|
|
370
|
+
? { 'x-deepseek-harness-session-id': String(options.sessionId) }
|
|
371
|
+
: {},
|
|
372
|
+
...options.purpose === 'compaction'
|
|
373
|
+
? { 'x-deepseek-harness-compact': '1' }
|
|
374
|
+
: {},
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let response: Response
|
|
378
|
+
try {
|
|
379
|
+
response = await fetch(`${connection.baseURL}/chat/completions`, {
|
|
380
|
+
method: 'POST',
|
|
381
|
+
headers,
|
|
382
|
+
body: payload,
|
|
383
|
+
signal,
|
|
384
|
+
})
|
|
385
|
+
} catch (error: unknown) {
|
|
386
|
+
// The outer stream distinguishes caller cancellation and watchdog expiry.
|
|
387
|
+
if (signal.aborted) throw error
|
|
388
|
+
// fetch wraps every transport failure (DNS, refused connection, TLS,
|
|
389
|
+
// proxy) in a bare `TypeError: fetch failed` whose actionable detail
|
|
390
|
+
// lives on `cause`.
|
|
391
|
+
throw new LlmError(
|
|
392
|
+
`Ollama API request to ${connection.baseURL} failed`,
|
|
393
|
+
'TRANSPORT',
|
|
394
|
+
{ cause: error },
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!response.ok) {
|
|
399
|
+
let message = `Ollama API error (HTTP ${response.status})`
|
|
400
|
+
let providerError: WireError['error']
|
|
401
|
+
try {
|
|
402
|
+
const parsed = await response.json() as WireError
|
|
403
|
+
providerError = parsed.error
|
|
404
|
+
if (providerError?.message) message = providerError.message
|
|
405
|
+
} catch {
|
|
406
|
+
// Only swallow error-body parsing: the HTTP status still identifies the
|
|
407
|
+
// failure, so malformed gateway JSON must not mask it.
|
|
408
|
+
}
|
|
409
|
+
const delay = providerRetryAfterMs(response.headers.get('retry-after'))
|
|
410
|
+
const id = requestId(response.headers)
|
|
411
|
+
throw new LlmError(message, httpErrorCode(response.status, providerError), {
|
|
412
|
+
status: response.status,
|
|
413
|
+
...delay === undefined ? {} : { providerRetryAfterMs: delay },
|
|
414
|
+
...id === undefined ? {} : { requestId: id },
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
if (!response.body) {
|
|
418
|
+
throw new LlmError('Ollama API returned no response body', 'EMPTY_RESPONSE')
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
yield* translate(parseSse(response.body, onComment))
|
|
422
|
+
}
|
|
423
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register an {@link OllamaAdapter} for the `ollama-cloud-direct` provider route
|
|
3
|
+
* on `ctx.llm`, with connection facts resolved once per operation from the
|
|
4
|
+
* plugin's `cordis.yml` mount config and the bearer token resolved per request
|
|
5
|
+
* through the credential seam (`ctx.credentials`), falling back to the
|
|
6
|
+
* process environment.
|
|
7
|
+
*
|
|
8
|
+
* Dependencies are intentionally minimal — `@deepseek-ai/dsh-llm` (the harness
|
|
9
|
+
* LLM seam contract), `@deepseek-ai/dsh-credentials` (the credential seam),
|
|
10
|
+
* `@deepseek-ai/cordis` (plugin framework), and `eventsource-parser` (SSE
|
|
11
|
+
* framing). There is no settings-section wiring and no schema library:
|
|
12
|
+
* configuration is static from the mount, and validation is hand-rolled. The
|
|
13
|
+
* route is `ollama-cloud-direct` (not `ollama-cloud`) so it can coexist with a
|
|
14
|
+
* pi-ai-configured `ollama-cloud` route.
|
|
15
|
+
*
|
|
16
|
+
* @module llm-ollama-cloud
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
20
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
21
|
+
import { assertUsableApiKey, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
|
|
22
|
+
import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_CONTEXT_WINDOW,
|
|
25
|
+
DEFAULT_MAX_TOKENS,
|
|
26
|
+
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
|
27
|
+
MAX_TIMER_DELAY_MS,
|
|
28
|
+
normalizeCloud,
|
|
29
|
+
OllamaAdapter,
|
|
30
|
+
} from './adapter.ts'
|
|
31
|
+
import type { OllamaCatalogModel, OllamaConnectionOptions } from './adapter.ts'
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
DEFAULT_CONTEXT_WINDOW,
|
|
35
|
+
DEFAULT_MAX_TOKENS,
|
|
36
|
+
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
|
37
|
+
MAX_TIMER_DELAY_MS,
|
|
38
|
+
normalizeCloud,
|
|
39
|
+
OllamaAdapter,
|
|
40
|
+
} from './adapter.ts'
|
|
41
|
+
export type { OllamaAdapterOptions, OllamaCatalogModel, OllamaConnectionOptions } from './adapter.ts'
|
|
42
|
+
export type { RequestDefaults } from './serialize.ts'
|
|
43
|
+
export type * from './types.ts'
|
|
44
|
+
|
|
45
|
+
export const name = 'llm-ollama-cloud'
|
|
46
|
+
export const inject = ['llm']
|
|
47
|
+
|
|
48
|
+
const DEFAULT_API_KEY_ENV = 'OLLAMA_CLOUD_API_KEY'
|
|
49
|
+
/** The single provider route this plugin owns. */
|
|
50
|
+
const PROVIDER = 'ollama-cloud-direct'
|
|
51
|
+
|
|
52
|
+
const DEFAULT_MODELS: OllamaCatalogModel[] = [
|
|
53
|
+
{ id: 'deepseek-v4-flash:cloud', name: 'DeepSeek-V4-Flash (cloud)', contextWindow: DEFAULT_CONTEXT_WINDOW },
|
|
54
|
+
{ id: 'deepseek-v4-pro:cloud', name: 'DeepSeek-V4-Pro (cloud)', contextWindow: DEFAULT_CONTEXT_WINDOW },
|
|
55
|
+
{ id: 'glm-5.2:cloud', name: 'GLM-5.2 (cloud)', contextWindow: DEFAULT_CONTEXT_WINDOW },
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
const MODEL_MODALITIES = ['text', 'image'] as const satisfies readonly ModelModality[]
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Plugin config from the `cordis.yml` mount entry. Every field is optional: a
|
|
62
|
+
* missing API key fails per request with `MISSING_CREDENTIAL`, omitted
|
|
63
|
+
* thinking mode uses the provider default, and omitted reasoning effort lets
|
|
64
|
+
* the server auto-enable thinking at its default.
|
|
65
|
+
*/
|
|
66
|
+
export interface Config {
|
|
67
|
+
/** Environment-variable name resolved per request; defaults to `OLLAMA_CLOUD_API_KEY`. */
|
|
68
|
+
apiKeyEnv?: string
|
|
69
|
+
/** Endpoint base; defaults to the Ollama cloud API. */
|
|
70
|
+
baseURL?: string
|
|
71
|
+
/** Deployment thinking policy; `disabled` limits every conversation request to `none` effort. */
|
|
72
|
+
thinking?: 'enabled' | 'disabled'
|
|
73
|
+
/** Default thinking effort (default unset, so the server picks); `off` maps to wire `none`. */
|
|
74
|
+
reasoningEffort?: 'off' | 'low' | 'high' | 'max'
|
|
75
|
+
/** Default per-request output cap (default 65,536); a model's own cap and explicit request values win. */
|
|
76
|
+
maxTokens?: number
|
|
77
|
+
/** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
|
|
78
|
+
defaultContextWindow?: number
|
|
79
|
+
/** Advisory models shown by discovery consumers; a missing `:cloud` suffix is appended. */
|
|
80
|
+
models?: OllamaCatalogModel[]
|
|
81
|
+
/** Maximum provider idle time while one stream read is outstanding (default five minutes). */
|
|
82
|
+
streamIdleTimeoutMs?: number
|
|
83
|
+
/** Provider-owned model-request retry policy; omission uses normal mode with five retries. */
|
|
84
|
+
retryPolicy?: RetryPolicyConfig
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The public Ollama cloud API base. */
|
|
88
|
+
export const PUBLIC_BASE_URL = 'https://ollama.com/v1'
|
|
89
|
+
|
|
90
|
+
/** Resolve, validate, and detach the advisory model catalog, normalizing every id to cloud naming. */
|
|
91
|
+
function resolveModels(models: readonly OllamaCatalogModel[] | undefined): OllamaCatalogModel[] {
|
|
92
|
+
const seen = new Set<string>()
|
|
93
|
+
return (models ?? DEFAULT_MODELS).map((model) => {
|
|
94
|
+
if (model.id.length === 0) throw new Error('llm-ollama-cloud: catalog model ids must be non-empty')
|
|
95
|
+
const id = normalizeCloud(model.id)
|
|
96
|
+
if (model.name !== undefined && model.name.length === 0) {
|
|
97
|
+
throw new Error(`llm-ollama-cloud: catalog model "${id}" has an empty name`)
|
|
98
|
+
}
|
|
99
|
+
if (model.contextWindow !== undefined
|
|
100
|
+
&& (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`llm-ollama-cloud: catalog model "${id}" contextWindow must be a positive integer`,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
if (model.maxTokens !== undefined
|
|
106
|
+
&& (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`llm-ollama-cloud: catalog model "${id}" maxTokens must be a positive integer`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
const inputModalities = model.inputModalities ?? ['text']
|
|
112
|
+
if (inputModalities.length === 0) {
|
|
113
|
+
throw new Error(`llm-ollama-cloud: catalog model "${id}" inputModalities must not be empty`)
|
|
114
|
+
}
|
|
115
|
+
if (inputModalities.some(modality => !MODEL_MODALITIES.includes(modality))) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`llm-ollama-cloud: catalog model "${id}" inputModalities must contain only "text" and "image"`,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
if (new Set(inputModalities).size !== inputModalities.length) {
|
|
121
|
+
throw new Error(`llm-ollama-cloud: catalog model "${id}" inputModalities must not contain duplicates`)
|
|
122
|
+
}
|
|
123
|
+
if (seen.has(id)) throw new Error(`llm-ollama-cloud: duplicate catalog model "${id}"`)
|
|
124
|
+
seen.add(id)
|
|
125
|
+
return {
|
|
126
|
+
id,
|
|
127
|
+
...model.name === undefined ? {} : { name: model.name },
|
|
128
|
+
...model.description === undefined ? {} : { description: model.description },
|
|
129
|
+
...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
|
|
130
|
+
...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
|
|
131
|
+
inputModalities: [...inputModalities],
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The one explicit resolve step from raw mount config to validated connection
|
|
138
|
+
* facts, with every default and bound re-judged here (fail loud at load).
|
|
139
|
+
* @param config - raw plugin config.
|
|
140
|
+
* @returns validated connection facts plus the credential reference.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveAdapterOptions(config: Config): OllamaConnectionOptions {
|
|
143
|
+
if (config.thinking === 'disabled'
|
|
144
|
+
&& config.reasoningEffort !== undefined
|
|
145
|
+
&& config.reasoningEffort !== 'off') {
|
|
146
|
+
throw new Error('llm-ollama-cloud: only reasoningEffort "off" can be configured when thinking is disabled')
|
|
147
|
+
}
|
|
148
|
+
if (config.defaultContextWindow !== undefined
|
|
149
|
+
&& (!Number.isInteger(config.defaultContextWindow) || config.defaultContextWindow <= 0)) {
|
|
150
|
+
throw new Error('llm-ollama-cloud: defaultContextWindow must be a positive integer')
|
|
151
|
+
}
|
|
152
|
+
if (config.maxTokens !== undefined
|
|
153
|
+
&& (!Number.isSafeInteger(config.maxTokens) || config.maxTokens <= 0)) {
|
|
154
|
+
throw new Error('llm-ollama-cloud: maxTokens must be a positive safe integer')
|
|
155
|
+
}
|
|
156
|
+
const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
|
|
157
|
+
if (!Number.isFinite(streamIdleTimeoutMs)
|
|
158
|
+
|| streamIdleTimeoutMs <= 0
|
|
159
|
+
|| streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`llm-ollama-cloud: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
apiKeyEnv: config.apiKeyEnv ?? DEFAULT_API_KEY_ENV,
|
|
166
|
+
baseURL: config.baseURL ?? PUBLIC_BASE_URL,
|
|
167
|
+
defaults: {
|
|
168
|
+
thinking: config.thinking,
|
|
169
|
+
reasoningEffort: config.reasoningEffort,
|
|
170
|
+
},
|
|
171
|
+
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
172
|
+
defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
173
|
+
models: resolveModels(config.models),
|
|
174
|
+
streamIdleTimeoutMs,
|
|
175
|
+
retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-ollama-cloud: retryPolicy'),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The `ctx.credentials` service surface this plugin uses (dsh-credentials). */
|
|
180
|
+
interface CredentialsLike {
|
|
181
|
+
resolve(ref: string): Promise<{ value: string; source: string } | undefined>
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function apply(ctx: Context, config: Config): void {
|
|
185
|
+
// Static composition: connection facts resolve once at load. A config
|
|
186
|
+
// change in the mount requires a restart — there is no settings section.
|
|
187
|
+
const options = (): OllamaConnectionOptions => resolveAdapterOptions(config)
|
|
188
|
+
const connection = options()
|
|
189
|
+
// Fail loud at load on a malformed credential reference name.
|
|
190
|
+
credentialRef(connection.apiKeyEnv)
|
|
191
|
+
|
|
192
|
+
const credentials = ctx.get('credentials') as CredentialsLike | undefined
|
|
193
|
+
const adapter = new OllamaAdapter({
|
|
194
|
+
options,
|
|
195
|
+
resolveApiKey: async (connection) => {
|
|
196
|
+
const ref = connection.apiKeyEnv
|
|
197
|
+
if (credentials !== undefined) {
|
|
198
|
+
const hit = await credentials.resolve(ref)
|
|
199
|
+
if (hit !== undefined && hit.value.length > 0) {
|
|
200
|
+
return assertUsableApiKey(hit.value, 'llm-ollama-cloud', ref)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const value = process.env[ref]
|
|
204
|
+
if (value !== undefined && value.length > 0) {
|
|
205
|
+
return assertUsableApiKey(value, 'llm-ollama-cloud', ref)
|
|
206
|
+
}
|
|
207
|
+
throw new LlmError(
|
|
208
|
+
`llm-ollama-cloud: no API key for provider route "${PROVIDER}"; store ${ref} in the credentials file or export it in the launching environment`,
|
|
209
|
+
'MISSING_CREDENTIAL',
|
|
210
|
+
)
|
|
211
|
+
},
|
|
212
|
+
})
|
|
213
|
+
ctx.llm.registerAdapter([PROVIDER], adapter)
|
|
214
|
+
}
|