@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/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
+ }