dsh-subscriptions 0.1.5 → 0.2.2

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 (110) hide show
  1. package/README.md +212 -154
  2. package/lib/auth/claude-code-creds.js +31 -9
  3. package/lib/auth/device-flow.js +16 -9
  4. package/lib/auth/oauth-flow.js +13 -10
  5. package/lib/auth/rpc.d.ts +73 -2
  6. package/lib/auth/rpc.js +124 -23
  7. package/lib/auth/store.js +80 -31
  8. package/lib/client/CursorCard.d.ts +1 -1
  9. package/lib/client/CursorCard.js +50 -16
  10. package/lib/client/ExternalUsageCards.d.ts +1 -1
  11. package/lib/client/ExternalUsageCards.js +37 -16
  12. package/lib/client/ImageGallery.d.ts +2 -2
  13. package/lib/client/ImageGallery.js +92 -33
  14. package/lib/client/ImageGenerateToolview.js +50 -14
  15. package/lib/client/OllamaCard.d.ts +13 -0
  16. package/lib/client/OllamaCard.js +94 -0
  17. package/lib/client/OllamaSettingsManager.d.ts +24 -0
  18. package/lib/client/OllamaSettingsManager.js +329 -0
  19. package/lib/client/ProviderAccountManager.d.ts +1 -1
  20. package/lib/client/ProviderAccountManager.js +164 -49
  21. package/lib/client/ProviderModelEditor.js +109 -26
  22. package/lib/client/SpeedSelect.d.ts +62 -14
  23. package/lib/client/SpeedSelect.js +257 -46
  24. package/lib/client/SubscriptionUsageBadge.d.ts +5 -11
  25. package/lib/client/SubscriptionUsageBadge.js +301 -112
  26. package/lib/client/SubscriptionsSection.d.ts +2 -0
  27. package/lib/client/SubscriptionsSection.js +259 -85
  28. package/lib/client/UsageBadgeDisplaySetting.js +35 -12
  29. package/lib/client/UsageMeter.d.ts +1 -1
  30. package/lib/client/UsageMeter.js +69 -27
  31. package/lib/client/VideoGenerateToolview.js +69 -17
  32. package/lib/client/account-preferences.d.ts +8 -0
  33. package/lib/client/account-preferences.js +20 -5
  34. package/lib/client/host-contract.d.ts +11 -2
  35. package/lib/client/host-contract.js +20 -3
  36. package/lib/client/index.d.ts +5 -5
  37. package/lib/client/index.js +34 -12
  38. package/lib/client/locales.d.ts +78 -0
  39. package/lib/client/locales.js +121 -34
  40. package/lib/client/ollama-draft.d.ts +229 -0
  41. package/lib/client/ollama-draft.js +255 -0
  42. package/lib/client/reset-action.js +6 -4
  43. package/lib/client/reset-credits-view.d.ts +1 -1
  44. package/lib/client/reset-credits-view.js +66 -17
  45. package/lib/client/subscription-card-styles.js +94 -24
  46. package/lib/client/usage-color-preferences.js +4 -2
  47. package/lib/client/usage-pace.d.ts +7 -0
  48. package/lib/client/usage-pace.js +43 -10
  49. package/lib/client.js +1647 -266
  50. package/lib/client.js.map +1 -1
  51. package/lib/http.js +1 -1
  52. package/lib/index.d.ts +28 -3
  53. package/lib/index.js +9329 -8217
  54. package/lib/model-defaults.js +4 -2
  55. package/lib/provider-settings.d.ts +14 -1
  56. package/lib/provider-settings.js +64 -20
  57. package/lib/providers/account-preferences.js +51 -22
  58. package/lib/providers/accounts.js +15 -9
  59. package/lib/providers/antigravity-oauth-client.js +2 -2
  60. package/lib/providers/antigravity.js +141 -68
  61. package/lib/providers/catalog-store.js +120 -41
  62. package/lib/providers/claude-cli-version.js +3 -3
  63. package/lib/providers/claude.d.ts +23 -3
  64. package/lib/providers/claude.js +167 -92
  65. package/lib/providers/codex-client-version.js +3 -3
  66. package/lib/providers/codex-search.js +40 -13
  67. package/lib/providers/codex.js +196 -112
  68. package/lib/providers/common.d.ts +14 -0
  69. package/lib/providers/common.js +118 -38
  70. package/lib/providers/copilot.js +143 -82
  71. package/lib/providers/cursor-adapter.js +42 -18
  72. package/lib/providers/cursor-auth.d.ts +1 -1
  73. package/lib/providers/cursor-auth.js +69 -24
  74. package/lib/providers/cursor-model-settings.js +14 -5
  75. package/lib/providers/cursor-usage.js +27 -13
  76. package/lib/providers/external-usage-controller.d.ts +9 -2
  77. package/lib/providers/external-usage-controller.js +30 -7
  78. package/lib/providers/external-usage.d.ts +8 -0
  79. package/lib/providers/external-usage.js +140 -20
  80. package/lib/providers/grok.js +152 -89
  81. package/lib/providers/image-pool.js +29 -11
  82. package/lib/providers/minimax-usage.js +31 -11
  83. package/lib/providers/npm-cli-version.js +10 -3
  84. package/lib/providers/ollama-settings.d.ts +90 -0
  85. package/lib/providers/ollama-settings.js +213 -0
  86. package/lib/providers/ollama-web.d.ts +46 -0
  87. package/lib/providers/ollama-web.js +231 -0
  88. package/lib/providers/ollama.d.ts +107 -0
  89. package/lib/providers/ollama.js +608 -0
  90. package/lib/providers/pool-family.js +11 -3
  91. package/lib/providers/pool-health.js +28 -8
  92. package/lib/providers/pool-usage.js +16 -9
  93. package/lib/providers/pool.d.ts +8 -0
  94. package/lib/providers/pool.js +81 -42
  95. package/lib/providers/rate-limit.js +8 -2
  96. package/lib/providers/replay.js +40 -17
  97. package/lib/providers/reset-redemption.js +18 -6
  98. package/lib/tools/image-generate.js +123 -65
  99. package/lib/tools/registration.js +5 -2
  100. package/lib/tools/video-generate.js +51 -31
  101. package/lib/tools/x-search.js +50 -22
  102. package/lib/translate/anthropic.js +85 -37
  103. package/lib/translate/antigravity-schema.js +71 -28
  104. package/lib/translate/antigravity-thinking.js +22 -9
  105. package/lib/translate/antigravity.js +141 -50
  106. package/lib/translate/chat-completions.js +67 -23
  107. package/lib/translate/resolved.js +55 -21
  108. package/lib/translate/responses.js +69 -29
  109. package/lib/translate/sse.js +4 -1
  110. package/package.json +26 -4
@@ -0,0 +1,608 @@
1
+ /**
2
+ * Ollama Cloud provider: a key-based OpenAI-completions route with no OAuth
3
+ * session. The credential arrives per request as a bearer key; chat posts to
4
+ * the configured native baseURL's `/v1` sibling. The route ships no catalog:
5
+ * only models the user stored on the Settings card are served, so an empty
6
+ * store hides the picker entry and rejects every chat model.
7
+ */
8
+ import { attributionHeaders, EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
9
+ import { DISCOVERY_TIMEOUT_MS, effortDisplayName, httpLlmError, idleWatchdog, isDiscoveryAborted, isMissingOrInvalidCredential, mapFetchFailure, ModelCatalogCache, readBoundedJson, withTimeout, } from './common.js';
10
+ import { hostFetch } from '../http.js';
11
+ import { resolveImages } from '../translate/resolved.js';
12
+ import { streamChatCompletions, toChatMessages, toChatTools, } from '../translate/chat-completions.js';
13
+ /** Route id owned by this adapter. Not a member of the OAuth `PROVIDER_IDS`. */
14
+ export const OLLAMA_CLOUD_ROUTE = 'ollama-cloud';
15
+ /** Default credential reference naming the Ollama Cloud API key. */
16
+ export const DEFAULT_OLLAMA_API_KEY_REF = 'OLLAMA_API_KEY';
17
+ /** Default native base URL; chat maps it to its `/v1` sibling. */
18
+ export const DEFAULT_OLLAMA_BASE_URL = 'https://ollama.com/api';
19
+ /** Fallback per-request output cap; Ollama publishes no per-model limit. */
20
+ export const OLLAMA_DEFAULT_MAX_TOKENS = 32_768;
21
+ /** Fallback context window for a row the endpoint never described. */
22
+ export const OLLAMA_DEFAULT_CONTEXT_WINDOW = 262_144;
23
+ /**
24
+ * Budget for the Settings card's explicit cloud listing. Longer than the
25
+ * picker's discovery bound because a user waits on this one and `/api/show`
26
+ * runs once per model.
27
+ */
28
+ export const OLLAMA_PULL_TIMEOUT_MS = 20_000;
29
+ /** Concurrency bound for `/api/show` enrichment. */
30
+ const SHOW_CONCURRENCY = 6;
31
+ /** Response body bound for discovery payloads. */
32
+ const MAX_DISCOVERY_BYTES = 4 * 1024 * 1024;
33
+ /**
34
+ * Fallback reasoning levels for known Cloud model families. `/api/show`
35
+ * publishes each thinking model's accepted levels under `thinking.values`
36
+ * with a `thinking.default`, so the vendor is the primary source; this table
37
+ * only answers a model whose `thinking` capability is reported without a
38
+ * level list. It is capability knowledge, not a catalog: a stored row's own
39
+ * `efforts` always win, and a row outside these families with no stored
40
+ * levels carries no reasoning block.
41
+ */
42
+ const OLLAMA_REASONING_FAMILIES = [
43
+ // Recorded before the endpoint's level list was read: the thinking values
44
+ // ride as `off`/`low`/`high`/`max` (`off` maps to the wire spelling
45
+ // `none`), and the vendor publishes no `medium` level.
46
+ {
47
+ prefix: 'deepseek-v4.1-flash',
48
+ efforts: ['off', 'low', 'high', 'max'],
49
+ defaultEffort: 'high',
50
+ },
51
+ { prefix: 'glm-5.3', efforts: ['low', 'high', 'max'], defaultEffort: 'max' },
52
+ ];
53
+ function reasoningFamily(id) {
54
+ return OLLAMA_REASONING_FAMILIES.find((family) => id.startsWith(family.prefix));
55
+ }
56
+ /**
57
+ * The window the session plans against: the user's override, capped at what
58
+ * the endpoint advertises (or, without discovery, the stored snapshot). The
59
+ * cap keeps a stale or mistyped override from planning past the model.
60
+ */
61
+ export function effectiveContextWindow(row, discovered) {
62
+ const advertised = discovered ?? row.contextWindow;
63
+ return Math.min(row.contextOverride ?? advertised, advertised);
64
+ }
65
+ /** Map a native base URL to its OpenAI-compatible chat sibling. */
66
+ export function ollamaChatBaseURL(baseURL) {
67
+ const trimmed = baseURL.replace(/\/+$/, '');
68
+ if (trimmed.endsWith('/v1'))
69
+ return trimmed;
70
+ if (trimmed.endsWith('/api'))
71
+ return `${trimmed.slice(0, -'/api'.length)}/v1`;
72
+ return `${trimmed}/v1`;
73
+ }
74
+ /**
75
+ * Normalize one listing id to the wire id the chat endpoint accepts. The
76
+ * Cloud listing advertises bare names (`glm-5.3`) while its chat wire wants
77
+ * the `:cloud` tag; a local daemon reached through a custom base URL takes
78
+ * the bare name, so only the official host gets the tag. An id that already
79
+ * carries a tag is returned unchanged.
80
+ * @param id - the id as listed by `/api/tags`.
81
+ * @param baseURL - the native base URL the listing came from.
82
+ * @returns the id to store and send on the chat wire.
83
+ */
84
+ export function normalizeOllamaModelId(id, baseURL) {
85
+ if (id.includes(':'))
86
+ return id;
87
+ let host;
88
+ try {
89
+ host = new URL(baseURL).hostname;
90
+ }
91
+ catch {
92
+ return id;
93
+ }
94
+ return /(^|\.)ollama\.com$/.test(host) ? `${id}:cloud` : id;
95
+ }
96
+ /** Whether the failure is a rejected key. Only a 401 hides the route: a
97
+ * 403 may be a transient scope denial, so it falls back to the stored
98
+ * catalog like any other discovery miss. */
99
+ function isUnauthorized(error) {
100
+ return (error instanceof LlmError &&
101
+ error.code === 'AUTH' &&
102
+ error.failure?.status === 401);
103
+ }
104
+ /**
105
+ * Find a discovered row for a catalog id, tolerating a listing endpoint that
106
+ * still reports bare names (`glm-5.3` for catalogued `glm-5.3:cloud`).
107
+ */
108
+ function discoveredFor(catalogId, discovered) {
109
+ return (discovered?.find((row) => row.id === catalogId) ??
110
+ discovered?.find((row) => row.id === catalogId.replace(/:cloud$/, '')));
111
+ }
112
+ function reasoningFor(row, configuredDefault) {
113
+ const family = reasoningFamily(row.id);
114
+ const familyDefault = row.efforts.includes(family?.defaultEffort ?? '')
115
+ ? family?.defaultEffort
116
+ : undefined;
117
+ if (row.efforts.length === 0)
118
+ return undefined;
119
+ // Every level here comes from the row or a family that named it; a
120
+ // stored default the row does not list cannot ride the wire.
121
+ const efforts = row.efforts.map((effort) => ({
122
+ id: ReasoningEffortId(effort),
123
+ name: effortDisplayName(effort),
124
+ }));
125
+ const storedDefault = row.efforts.includes(row.defaultEffort ?? '')
126
+ ? row.defaultEffort
127
+ : undefined;
128
+ const priority = [
129
+ configuredDefault,
130
+ // The family default is the last resort, mirroring the discovery
131
+ // path's priority; the stored row no longer bakes it in.
132
+ familyDefault,
133
+ storedDefault,
134
+ ];
135
+ const defaultEffort = priority.find((entry) => entry !== undefined && row.efforts.includes(entry));
136
+ return {
137
+ efforts,
138
+ ...(defaultEffort === undefined
139
+ ? {}
140
+ : { defaultEffort: ReasoningEffortId(defaultEffort) }),
141
+ };
142
+ }
143
+ /**
144
+ * Merge a discovered reasoning block into the stored row. The endpoint is
145
+ * the truth about the live model: a published level set is closed (a level
146
+ * the gateway stopped offering would only fail the request), and the
147
+ * default priority is the user's stored choice, then the endpoint's, then
148
+ * the family table — always within the offered set. Without a published
149
+ * set, the same priority chain runs against the stored row's levels.
150
+ * @param row - the stored catalog row, with the family fallback already applied.
151
+ * @param discovered - the endpoint's reasoning block.
152
+ * @param configuredDefault - the per-model configured default, when set.
153
+ * @returns the merged block, with a default that is always one of its levels.
154
+ */
155
+ function mergeDiscoveredReasoning(row, discovered, configuredDefault) {
156
+ const vendorLevels = discovered.efforts.map((entry) => String(entry.id));
157
+ // A published vendor set is closed: the stored row and the family table
158
+ // must not widen it. When the endpoint names no levels, the stored row
159
+ // supplies them and only a still-empty list falls to the family table.
160
+ const efforts = vendorLevels.length > 0
161
+ ? vendorLevels
162
+ : [
163
+ ...row.efforts,
164
+ ...(row.efforts.length > 0
165
+ ? []
166
+ : (reasoningFamily(row.id)?.efforts ?? [])),
167
+ ];
168
+ const vendorDefault = discovered.defaultEffort === undefined
169
+ ? undefined
170
+ : String(discovered.defaultEffort);
171
+ // The user's explicit choice wins only within the levels the model still
172
+ // offers: a stored default outside a retracted vendor set must not ride
173
+ // the wire, so it falls through to the vendor or stored fallback.
174
+ const priority = [
175
+ configuredDefault,
176
+ vendorDefault,
177
+ // The family default is the last resort, not a user preference.
178
+ ...(configuredDefault !== undefined || vendorDefault !== undefined
179
+ ? [undefined, reasoningFamily(row.id)?.defaultEffort]
180
+ : [row.defaultEffort, reasoningFamily(row.id)?.defaultEffort]),
181
+ ];
182
+ const defaultEffort = priority.find((entry) => entry !== undefined && efforts.includes(entry));
183
+ return {
184
+ efforts: efforts.map((effort) => ({
185
+ id: ReasoningEffortId(effort),
186
+ name: effortDisplayName(effort),
187
+ })),
188
+ ...(defaultEffort === undefined
189
+ ? {}
190
+ : { defaultEffort: ReasoningEffortId(defaultEffort) }),
191
+ };
192
+ }
193
+ /** Strip image blocks for models that cannot accept vision input. Runs before
194
+ * image resolution so a text-only model never requires the attachment store. */
195
+ function withoutImages(messages) {
196
+ return messages.map((message) => ({
197
+ ...message,
198
+ content: message.content.filter((block) => block.type !== 'image'),
199
+ }));
200
+ }
201
+ function discoveryHeaders(apiKey) {
202
+ return {
203
+ accept: 'application/json',
204
+ ...(apiKey === undefined ? {} : { authorization: `Bearer ${apiKey}` }),
205
+ ...attributionHeaders(),
206
+ };
207
+ }
208
+ function recordOf(value) {
209
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
210
+ ? value
211
+ : undefined;
212
+ }
213
+ function parseNumCtx(parameters) {
214
+ if (typeof parameters !== 'string')
215
+ return undefined;
216
+ const match = /(?:^|\s)num_ctx\s+(\d+)/.exec(parameters);
217
+ if (match === null)
218
+ return undefined;
219
+ const value = Number(match[1]);
220
+ return Number.isSafeInteger(value) && value > 0 ? value : undefined;
221
+ }
222
+ /** Prefer an explicit `num_ctx`, else any `*.context_length` in model info. */
223
+ function extractContextWindow(show) {
224
+ const numCtx = parseNumCtx(show['parameters']);
225
+ if (numCtx !== undefined)
226
+ return numCtx;
227
+ const info = recordOf(show['model_info']);
228
+ if (info !== undefined) {
229
+ for (const [key, value] of Object.entries(info)) {
230
+ if (key.endsWith('context_length') &&
231
+ typeof value === 'number' &&
232
+ Number.isSafeInteger(value) &&
233
+ value > 0)
234
+ return value;
235
+ }
236
+ }
237
+ return undefined;
238
+ }
239
+ /**
240
+ * Read the endpoint's own reasoning levels. `/api/show` reports them as
241
+ * `thinking: { values: [...], default: '...' }`; a `false` value is the
242
+ * vendor's spelling of the `off` level, so it maps to the effort id `off`.
243
+ * @param show - the `/api/show` payload.
244
+ * @returns the level list and default, or `undefined` when the payload
245
+ * carries no usable `thinking` object.
246
+ */
247
+ function extractThinking(show) {
248
+ const thinking = recordOf(show['thinking']);
249
+ if (thinking === undefined)
250
+ return undefined;
251
+ const rawValues = thinking['values'];
252
+ const values = [];
253
+ if (Array.isArray(rawValues)) {
254
+ for (const entry of rawValues) {
255
+ const effort = entry === false
256
+ ? 'off'
257
+ : typeof entry === 'string' && entry.length > 0
258
+ ? entry
259
+ : undefined;
260
+ if (effort !== undefined && !values.includes(effort))
261
+ values.push(effort);
262
+ }
263
+ }
264
+ const rawDefault = thinking['default'];
265
+ return {
266
+ values,
267
+ ...(typeof rawDefault === 'string' && rawDefault.length > 0
268
+ ? { default: rawDefault }
269
+ : {}),
270
+ };
271
+ }
272
+ function discoveredFromShow(id, rawId, show) {
273
+ const rawCapabilities = show['capabilities'];
274
+ const capabilities = new Set(Array.isArray(rawCapabilities)
275
+ ? rawCapabilities.filter((entry) => typeof entry === 'string')
276
+ : []);
277
+ const vision = capabilities.has('vision');
278
+ const thinking = capabilities.has('thinking');
279
+ const vendor = thinking ? extractThinking(show) : undefined;
280
+ // The vendor's level list is the truth about the live model; the family
281
+ // table only answers a model whose thinking capability came without one.
282
+ const family = thinking && (vendor === undefined || vendor.values.length === 0)
283
+ ? reasoningFamily(id)
284
+ : undefined;
285
+ const efforts = vendor !== undefined && vendor.values.length > 0
286
+ ? vendor.values
287
+ : (family?.efforts ?? []);
288
+ const defaultEffort = vendor?.default ?? family?.defaultEffort;
289
+ const contextWindow = extractContextWindow(show);
290
+ return {
291
+ id,
292
+ rawId,
293
+ name: id,
294
+ thinking,
295
+ ...(contextWindow === undefined ? {} : { contextWindow }),
296
+ inputModalities: vision ? ['text', 'image'] : ['text'],
297
+ ...(efforts.length === 0
298
+ ? {}
299
+ : {
300
+ reasoning: {
301
+ efforts: efforts.map((effort) => ({
302
+ id: ReasoningEffortId(effort),
303
+ name: effortDisplayName(effort),
304
+ })),
305
+ ...(defaultEffort === undefined
306
+ ? {}
307
+ : { defaultEffort: ReasoningEffortId(defaultEffort) }),
308
+ },
309
+ }),
310
+ };
311
+ }
312
+ /**
313
+ * List Cloud models via `/api/tags`, enriching through `/api/show`. A failed
314
+ * enrichment degrades to an id-only row; a failed listing fails. Listing ids
315
+ * are normalized to their wire form ({@link normalizeOllamaModelId}) while
316
+ * `/api/show` still receives the id the endpoint published.
317
+ */
318
+ export async function fetchOllamaModels(options) {
319
+ const fetchFn = options.fetchFn ?? hostFetch;
320
+ const base = options.baseURL.replace(/\/+$/, '');
321
+ let tagsResponse;
322
+ let transportError;
323
+ // The listing gets two attempts; only transport failures retry.
324
+ for (let attempt = 0; attempt < 2; attempt += 1) {
325
+ try {
326
+ tagsResponse = await fetchFn(`${base}/tags`, {
327
+ headers: discoveryHeaders(options.apiKey),
328
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
329
+ });
330
+ transportError = undefined;
331
+ break;
332
+ }
333
+ catch (error) {
334
+ transportError = error;
335
+ }
336
+ }
337
+ if (tagsResponse === undefined)
338
+ throw transportError;
339
+ if (!tagsResponse.ok)
340
+ throw await httpLlmError(tagsResponse, 'ollama models');
341
+ const tags = recordOf(await readBoundedJson(tagsResponse, 'ollama models', MAX_DISCOVERY_BYTES));
342
+ const listed = tags?.['models'];
343
+ if (!Array.isArray(listed))
344
+ throw new Error('ollama models endpoint returned no models array');
345
+ const seen = new Set();
346
+ const entries = [];
347
+ for (const entry of listed) {
348
+ const record = recordOf(entry);
349
+ const raw = record === undefined
350
+ ? undefined
351
+ : typeof record['model'] === 'string'
352
+ ? record['model']
353
+ : typeof record['name'] === 'string'
354
+ ? record['name']
355
+ : undefined;
356
+ if (raw === undefined || raw.length === 0)
357
+ continue;
358
+ const wire = normalizeOllamaModelId(raw, options.baseURL);
359
+ if (seen.has(wire))
360
+ continue;
361
+ seen.add(wire);
362
+ entries.push({ raw, wire });
363
+ }
364
+ if (entries.length === 0)
365
+ throw new LlmError('ollama models endpoint returned an empty catalog', 'DISCOVERY_FAILED');
366
+ const out = new Array(entries.length);
367
+ let cursor = 0;
368
+ const enrich = async () => {
369
+ for (;;) {
370
+ const index = cursor;
371
+ cursor += 1;
372
+ if (index >= entries.length)
373
+ return;
374
+ const { raw, wire } = entries[index];
375
+ try {
376
+ const showResponse = await fetchFn(`${base}/show`, {
377
+ method: 'POST',
378
+ headers: {
379
+ 'content-type': 'application/json',
380
+ ...discoveryHeaders(options.apiKey),
381
+ },
382
+ body: JSON.stringify({ model: raw }),
383
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
384
+ });
385
+ if (!showResponse.ok)
386
+ throw await httpLlmError(showResponse, 'ollama model details');
387
+ const show = recordOf(await readBoundedJson(showResponse, 'ollama model details', MAX_DISCOVERY_BYTES));
388
+ out[index] =
389
+ show === undefined
390
+ ? { id: wire, rawId: raw, name: wire, thinking: false }
391
+ : discoveredFromShow(wire, raw, show);
392
+ }
393
+ catch {
394
+ // One model's enrichment never sinks the listing.
395
+ out[index] = { id: wire, rawId: raw, name: wire, thinking: false };
396
+ }
397
+ }
398
+ };
399
+ await Promise.all(Array.from({ length: Math.min(SHOW_CONCURRENCY, entries.length) }, enrich));
400
+ return out;
401
+ }
402
+ export function ollamaChatRequestBody(options, messages, maxTokens) {
403
+ return {
404
+ model: options.model,
405
+ messages,
406
+ ...(options.tools !== undefined && options.tools.length > 0
407
+ ? { tools: toChatTools(options.tools), tool_choice: 'auto' }
408
+ : {}),
409
+ // Ollama takes `max_tokens`. The catalog row supplies the default
410
+ // (32,768, matching upstream); a row without one rides without it.
411
+ ...(maxTokens !== undefined ? { max_tokens: maxTokens } : {}),
412
+ // `off` rides as `none` (upstream parity); any other effort rides
413
+ // verbatim. Only advertised efforts ever reach this branch.
414
+ ...(options.reasoningEffort !== undefined
415
+ ? {
416
+ reasoning_effort: String(options.reasoningEffort) === 'off'
417
+ ? 'none'
418
+ : String(options.reasoningEffort),
419
+ }
420
+ : {}),
421
+ stream: true,
422
+ stream_options: { include_usage: true },
423
+ };
424
+ }
425
+ export class OllamaAdapter extends LlmAdapter {
426
+ options;
427
+ catalogCache = new ModelCatalogCache();
428
+ constructor(options) {
429
+ super();
430
+ this.options = options;
431
+ }
432
+ /** Drop the discovery snapshot so the next picker pass re-reads the endpoint. */
433
+ invalidateModels() {
434
+ this.catalogCache.invalidate();
435
+ }
436
+ rows() {
437
+ return this.options.catalog().map((entry) => {
438
+ const family = reasoningFamily(entry.id);
439
+ return {
440
+ id: entry.id,
441
+ name: entry.name ?? entry.id,
442
+ contextWindow: entry.contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW,
443
+ contextOverride: entry.contextOverride,
444
+ maxTokens: entry.maxTokens ?? OLLAMA_DEFAULT_MAX_TOKENS,
445
+ vision: entry.vision === true,
446
+ efforts: entry.efforts !== undefined && entry.efforts.length > 0
447
+ ? [...entry.efforts]
448
+ : [...(family?.efforts ?? [])],
449
+ // The family default stays out of the stored row: the resolver's
450
+ // priority keeps it last, so a vendor change is not held back by
451
+ // the fallback baked in during construction.
452
+ defaultEffort: entry.defaultEffort,
453
+ };
454
+ });
455
+ }
456
+ providerInfo(provider) {
457
+ return { id: provider, name: 'Ollama Cloud' };
458
+ }
459
+ async listModels(provider) {
460
+ const catalog = this.rows();
461
+ // No stored model means no catalog: the route ships no built-in rows, so
462
+ // an empty store hides the picker entry instead of advertising models
463
+ // the user never adopted.
464
+ if (catalog.length === 0)
465
+ return [];
466
+ // No key means logged out: hide the route instead of advertising
467
+ // models every request would fail for.
468
+ const apiKey = await this.options.apiKey();
469
+ if (apiKey === undefined)
470
+ return [];
471
+ if (this.options.discovery === false)
472
+ return this.staticList(provider, catalog);
473
+ try {
474
+ const discovered = await withTimeout((signal) => this.catalogCache.get(() => fetchOllamaModels({
475
+ baseURL: this.options.baseURL(),
476
+ apiKey,
477
+ fetchFn: this.options.fetchFn,
478
+ signal,
479
+ })), DISCOVERY_TIMEOUT_MS);
480
+ // A timeout resolves undefined: treat it like any other discovery
481
+ // miss and serve the stored catalog.
482
+ if (discovered === undefined)
483
+ return this.staticList(provider, catalog);
484
+ // Discovery enriches the stored allowlist but never extends it: the
485
+ // user adopted an exact catalog, so unmatched discoveries stay out of
486
+ // the picker.
487
+ return catalog.map((entry) => {
488
+ const match = discoveredFor(entry.id, discovered);
489
+ if (match === undefined) {
490
+ return {
491
+ provider,
492
+ id: entry.id,
493
+ name: entry.name,
494
+ inputModalities: entry.vision ? ['text', 'image'] : ['text'],
495
+ };
496
+ }
497
+ // The picker keeps the stored friendly name: discovered rows carry
498
+ // listing ids that would read as a rename every refresh. Discovery
499
+ // enriches capabilities only.
500
+ return {
501
+ provider,
502
+ id: entry.id,
503
+ name: entry.name,
504
+ inputModalities: match.inputModalities ??
505
+ (entry.vision ? ['text', 'image'] : ['text']),
506
+ };
507
+ });
508
+ }
509
+ catch (error) {
510
+ if (isDiscoveryAborted(error))
511
+ throw error;
512
+ // Keys are stateless, so any other failure falls back to the stored
513
+ // catalog; a 401 instead hides the route like a logout.
514
+ if (isMissingOrInvalidCredential(error) || isUnauthorized(error))
515
+ return [];
516
+ this.options.onWarn?.(`ollama-cloud model discovery failed; using the stored catalog (${errorChain(error)})`);
517
+ return this.staticList(provider, catalog);
518
+ }
519
+ }
520
+ staticList(provider, catalog) {
521
+ return catalog.map((entry) => ({
522
+ provider,
523
+ id: entry.id,
524
+ name: entry.name,
525
+ inputModalities: entry.vision ? ['text', 'image'] : ['text'],
526
+ }));
527
+ }
528
+ async resolveModel(provider, model) {
529
+ const entry = this.rows().find((row) => row.id === model);
530
+ // The pass-through native behavior is deliberately absent: an
531
+ // uncatalogued id fails here instead of 404ing at the gateway.
532
+ if (entry === undefined)
533
+ throw new LlmError(`ollama-cloud has no configured model "${model}"`, 'UNKNOWN_MODEL');
534
+ // Discovery carries the key when one exists; keyless endpoints still
535
+ // list, and no secret rides a metadata fetch it cannot use. The fetch
536
+ // shares the picker bound so a hung listing cannot stall resolution.
537
+ const discoveryKey = this.options.discovery === false ? undefined : await this.options.apiKey();
538
+ const discovered = this.options.discovery === false
539
+ ? undefined
540
+ : await withTimeout((signal) => this.catalogCache.resolve(() => fetchOllamaModels({
541
+ baseURL: this.options.baseURL(),
542
+ apiKey: discoveryKey,
543
+ fetchFn: this.options.fetchFn,
544
+ signal,
545
+ })), DISCOVERY_TIMEOUT_MS).then((models) => discoveredFor(model, models ?? undefined), () => undefined);
546
+ const modalities = discovered?.inputModalities ??
547
+ (entry.vision ? ['text', 'image'] : ['text']);
548
+ const configuredDefault = this.options.defaultEffortOf?.(model) ?? entry.defaultEffort;
549
+ const reasoning = discovered?.reasoning === undefined
550
+ ? reasoningFor(entry, configuredDefault)
551
+ : mergeDiscoveredReasoning(entry, discovered.reasoning, configuredDefault);
552
+ return {
553
+ provider,
554
+ id: model,
555
+ name: entry.name,
556
+ inputModalities: [...modalities],
557
+ context: {
558
+ contextWindow: effectiveContextWindow(entry, discovered?.contextWindow),
559
+ },
560
+ defaultMaxTokens: entry.maxTokens,
561
+ ...(reasoning === undefined ? {} : { reasoning }),
562
+ };
563
+ }
564
+ async *stream(options) {
565
+ const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs ?? 300_000);
566
+ try {
567
+ const apiKey = await this.options.apiKey();
568
+ if (apiKey === undefined)
569
+ throw new LlmError('ollama-cloud API key is not configured', 'MISSING_CREDENTIAL');
570
+ const entry = this.rows().find((row) => row.id === options.model);
571
+ if (entry === undefined)
572
+ throw new LlmError(`ollama-cloud has no configured model "${options.model}"`, 'UNKNOWN_MODEL');
573
+ const requestMessages = entry.vision
574
+ ? options.messages
575
+ : withoutImages(options.messages);
576
+ const resolved = await resolveImages(requestMessages, this.options.resolveAttachments?.(), watchdog.signal);
577
+ const body = ollamaChatRequestBody(options, toChatMessages(resolved, options.system), options.maxTokens ?? entry.maxTokens);
578
+ const chatBase = ollamaChatBaseURL(this.options.baseURL());
579
+ const fetchFn = this.options.fetchFn ?? hostFetch;
580
+ const response = await fetchFn(`${chatBase}/chat/completions`, {
581
+ method: 'POST',
582
+ headers: {
583
+ authorization: `Bearer ${apiKey}`,
584
+ accept: 'text/event-stream',
585
+ 'content-type': 'application/json',
586
+ ...attributionHeaders(),
587
+ },
588
+ body: JSON.stringify(body),
589
+ signal: watchdog.signal,
590
+ });
591
+ if (!response.ok)
592
+ throw await httpLlmError(response, 'ollama-cloud API');
593
+ if (response.body === null) {
594
+ throw new LlmError('ollama-cloud API returned no response body', EMPTY_RESPONSE_CODE);
595
+ }
596
+ const pulse = () => {
597
+ watchdog.pulse();
598
+ };
599
+ yield* streamChatCompletions(response.body, pulse);
600
+ }
601
+ catch (error) {
602
+ throw mapFetchFailure('ollama-cloud API', error, watchdog, options.signal);
603
+ }
604
+ finally {
605
+ watchdog.stop();
606
+ }
607
+ }
608
+ }
@@ -30,14 +30,22 @@ export function buildAccountPools(sources) {
30
30
  entry = { members: [], info: model };
31
31
  byModel.set(model.id, entry);
32
32
  }
33
- entry.members.push({ provider, account: catalog.account, model: model.id });
33
+ entry.members.push({
34
+ provider,
35
+ account: catalog.account,
36
+ model: model.id,
37
+ });
34
38
  }
35
39
  }
36
40
  for (const [id, { members, info }] of byModel) {
37
41
  pools.set(poolKey(provider, id), {
38
42
  members,
39
- ...info.name === undefined || info.name === id ? {} : { name: info.name },
40
- ...info.description === undefined ? {} : { description: info.description },
43
+ ...(info.name === undefined || info.name === id
44
+ ? {}
45
+ : { name: info.name }),
46
+ ...(info.description === undefined
47
+ ? {}
48
+ : { description: info.description }),
41
49
  });
42
50
  }
43
51
  }