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