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
package/lib/providers/grok.js
CHANGED
|
@@ -7,7 +7,7 @@ import { attributionHeaders, EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmErr
|
|
|
7
7
|
import { decodeJwtPayload } from '../auth/jwt.js';
|
|
8
8
|
import { resolveImages } from '../translate/resolved.js';
|
|
9
9
|
import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
|
|
10
|
-
import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
|
|
10
|
+
import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, parseProviderJson, oauthEndpointError, OAuthEndpointError, } from './common.js';
|
|
11
11
|
import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
|
|
12
12
|
import { hostFetch } from '../http.js';
|
|
13
13
|
import { DEFAULT_RATE_LIMIT_WAIT, DEFAULT_RETRY, jsonBody, resetFromFields, subscriptionRetryPolicy, } from './rate-limit.js';
|
|
@@ -33,7 +33,12 @@ const GROK_RESET_FIELDS = ['retry_after', 'retry_after_seconds', 'resets_at', 'r
|
|
|
33
33
|
* supplies the retry hint; diagnostics omit raw provider headers and body text.
|
|
34
34
|
*/
|
|
35
35
|
export const grokRateLimitReset = (_response, body, now) => resetFromFields(jsonBody(body), GROK_RESET_FIELDS, now);
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* A discovered URL must be https on x.ai or a subdomain; anything else is a
|
|
38
|
+
* hostile document. The rejection names the field and never the URL: the URL
|
|
39
|
+
* comes from the provider's discovery document, which is exactly the text a
|
|
40
|
+
* hostile document would choose.
|
|
41
|
+
*/
|
|
37
42
|
function assertXaiEndpoint(url, field) {
|
|
38
43
|
let parsed;
|
|
39
44
|
try {
|
|
@@ -44,7 +49,7 @@ function assertXaiEndpoint(url, field) {
|
|
|
44
49
|
}
|
|
45
50
|
if (parsed.protocol !== 'https:'
|
|
46
51
|
|| (parsed.hostname !== 'x.ai' && !parsed.hostname.endsWith('.x.ai'))) {
|
|
47
|
-
throw new Error(`grok OIDC discovery returned a non-x.ai ${field}
|
|
52
|
+
throw new Error(`grok OIDC discovery returned a non-x.ai ${field}`);
|
|
48
53
|
}
|
|
49
54
|
return url;
|
|
50
55
|
}
|
|
@@ -59,7 +64,7 @@ export async function grokDiscovery() {
|
|
|
59
64
|
const response = await hostFetch(GROK_DISCOVERY_URL);
|
|
60
65
|
if (!response.ok)
|
|
61
66
|
throw await oauthEndpointError(response, 'grok OIDC discovery');
|
|
62
|
-
const document = await response
|
|
67
|
+
const document = await parseProviderJson(response, 'grok OIDC discovery');
|
|
63
68
|
if (typeof document.authorization_endpoint !== 'string' || typeof document.token_endpoint !== 'string') {
|
|
64
69
|
throw new Error('grok OIDC discovery document is missing endpoints');
|
|
65
70
|
}
|
|
@@ -191,7 +196,7 @@ export async function exchangeGrokCode(code, verifier, redirectUri, challenge) {
|
|
|
191
196
|
}
|
|
192
197
|
if (!response.ok)
|
|
193
198
|
throw await oauthEndpointError(response, 'grok');
|
|
194
|
-
return grokSession(await response
|
|
199
|
+
return grokSession(await parseProviderJson(response, 'grok token exchange'), discovery.tokenEndpoint);
|
|
195
200
|
}
|
|
196
201
|
/**
|
|
197
202
|
* Refresh a grok session (form-encoded grant).
|
|
@@ -210,7 +215,7 @@ export async function refreshGrok(session) {
|
|
|
210
215
|
});
|
|
211
216
|
if (!response.ok)
|
|
212
217
|
throw await oauthEndpointError(response, 'grok');
|
|
213
|
-
const next = grokSession(await response
|
|
218
|
+
const next = grokSession(await parseProviderJson(response, 'grok token refresh'), session.tokenEndpoint, session.refreshToken);
|
|
214
219
|
return {
|
|
215
220
|
...next,
|
|
216
221
|
...session.account === undefined ? {} : { account: session.account },
|
|
@@ -261,7 +266,7 @@ export async function fetchGrokUsage(session, fetchFn = hostFetch, signal) {
|
|
|
261
266
|
});
|
|
262
267
|
if (!response.ok)
|
|
263
268
|
throw await oauthEndpointError(response, 'grok billing');
|
|
264
|
-
const payload = await response
|
|
269
|
+
const payload = await parseProviderJson(response, 'grok billing');
|
|
265
270
|
const config = typeof payload.config === 'object' && payload.config !== null ? payload.config : {};
|
|
266
271
|
const windows = [];
|
|
267
272
|
if (config.currentPeriod || (typeof config.creditUsagePercent === 'number' && Number.isFinite(config.creditUsagePercent))) {
|
|
@@ -357,7 +362,7 @@ export async function fetchGrokCliCatalog(session, fetchFn = hostFetch, signal)
|
|
|
357
362
|
});
|
|
358
363
|
if (!response.ok)
|
|
359
364
|
throw await oauthEndpointError(response, 'grok CLI catalog');
|
|
360
|
-
const payload = await response
|
|
365
|
+
const payload = await parseProviderJson(response, 'grok CLI catalog');
|
|
361
366
|
if (!Array.isArray(payload.data))
|
|
362
367
|
throw new Error('grok CLI catalog returned no data array');
|
|
363
368
|
const catalog = new Map();
|
|
@@ -442,7 +447,7 @@ export async function fetchGrokModels(session, fetchFn = hostFetch, onWarn, prev
|
|
|
442
447
|
]);
|
|
443
448
|
if (!response.ok)
|
|
444
449
|
throw await oauthEndpointError(response, 'grok models');
|
|
445
|
-
const payload = await response
|
|
450
|
+
const payload = await parseProviderJson(response, 'grok models');
|
|
446
451
|
if (!Array.isArray(payload.data))
|
|
447
452
|
throw new Error('grok models endpoint returned no data array');
|
|
448
453
|
const seen = new Set();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseProviderJson } from './common.js';
|
|
1
2
|
/**
|
|
2
3
|
* Used percentage from the legacy counts, read as the official CLI does when no
|
|
3
4
|
* explicit percentage exists: the count is the quota remaining. Undefined
|
|
@@ -40,7 +41,7 @@ export async function fetchMiniMaxUsage(key, region, http = fetch, signal) {
|
|
|
40
41
|
});
|
|
41
42
|
if (!response.ok)
|
|
42
43
|
throw new Error(`MiniMax usage endpoint returned HTTP ${response.status}`);
|
|
43
|
-
const body = await response
|
|
44
|
+
const body = await parseProviderJson(response, 'MiniMax usage');
|
|
44
45
|
if ((body?.base_resp?.status_code !== undefined && body.base_resp.status_code !== 0) || !Array.isArray(body?.model_remains)) {
|
|
45
46
|
throw new Error('MiniMax usage response failed or has no quota records');
|
|
46
47
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One model the Ollama Cloud route serves. The route ships no catalog of its
|
|
3
|
+
* own: this list is the catalog, in the order the picker shows it. Adoption
|
|
4
|
+
* fills the capability fields from the endpoint's `/api/tags` + `/api/show`
|
|
5
|
+
* reply; every field except `id` is optional so a hand-written row still
|
|
6
|
+
* works.
|
|
7
|
+
*/
|
|
8
|
+
export interface OllamaModelSetting {
|
|
9
|
+
/** Wire model id; must be non-empty and unique within the list. */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Selector label; defaults to the id. */
|
|
12
|
+
name?: string;
|
|
13
|
+
/** Known combined request/response context capacity in tokens. */
|
|
14
|
+
contextWindow?: number;
|
|
15
|
+
/**
|
|
16
|
+
* The user's local context-window choice. Unlike `contextWindow`, which
|
|
17
|
+
* snapshots what the endpoint advertised, this records only an explicit
|
|
18
|
+
* edit: absent follows the endpoint. The runtime caps it at the advertised
|
|
19
|
+
* window and it never widens the API's real capacity.
|
|
20
|
+
*/
|
|
21
|
+
contextOverride?: number;
|
|
22
|
+
/** Per-request output cap; defaults to the route's fallback. */
|
|
23
|
+
maxTokens?: number;
|
|
24
|
+
/** The endpoint advertised the `vision` capability. */
|
|
25
|
+
vision?: boolean;
|
|
26
|
+
/** The endpoint advertised the `thinking` capability. */
|
|
27
|
+
thinking?: boolean;
|
|
28
|
+
/** Advertised reasoning efforts, in picker order; empty means non-reasoning. */
|
|
29
|
+
efforts?: string[];
|
|
30
|
+
/** Effort preselected when the session picks none; must be one of `efforts`. */
|
|
31
|
+
defaultEffort?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Durable, non-secret Ollama Cloud configuration. Every field is optional and
|
|
35
|
+
* its absence means "no override": the effective value then falls back to the
|
|
36
|
+
* plugin config and, last, to the built-in default. The API key itself never
|
|
37
|
+
* appears here; `apiKeyRef` only names a DSH credential.
|
|
38
|
+
*/
|
|
39
|
+
export interface OllamaSettings {
|
|
40
|
+
/** The route's catalog, in picker order. `[]` hides every model. */
|
|
41
|
+
models?: OllamaModelSetting[];
|
|
42
|
+
/** Register the web search/fetch providers; the config value is the default. */
|
|
43
|
+
web?: boolean;
|
|
44
|
+
/** Native base URL; the config value is the default. */
|
|
45
|
+
baseURL?: string;
|
|
46
|
+
/** Credential reference naming the API key; the config value is the default. */
|
|
47
|
+
apiKeyRef?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Accept only an absolute http(s) base URL with no credentials, query, or
|
|
51
|
+
* fragment, and return its trimmed form. The route appends `/tags`, `/show`,
|
|
52
|
+
* and `/v1/chat/completions` to this value, so anything else would build a URL
|
|
53
|
+
* that cannot work — and a non-official host receives the referenced
|
|
54
|
+
* credential, which the dialog tells the user.
|
|
55
|
+
* @param value - the candidate URL.
|
|
56
|
+
* @returns the trimmed URL to store.
|
|
57
|
+
* @throws when the value is not a usable absolute http(s) URL.
|
|
58
|
+
*/
|
|
59
|
+
export declare function validateOllamaBaseURL(value: unknown): string;
|
|
60
|
+
/**
|
|
61
|
+
* Validate one settings document, dropping unknown keys and duplicate model
|
|
62
|
+
* ids (first wins, matching the Cursor store's visible-model handling).
|
|
63
|
+
* @param input - the parsed store document or an RPC payload.
|
|
64
|
+
* @throws when a known field has the wrong type or an unusable value.
|
|
65
|
+
*/
|
|
66
|
+
export declare function validateOllamaSettings(input: unknown): OllamaSettings;
|
|
67
|
+
/**
|
|
68
|
+
* Durable Ollama Cloud configuration under the DSH home directory. Writes are
|
|
69
|
+
* serialized and atomic (temp file with mode 0600, then rename), so a crash
|
|
70
|
+
* never leaves a half-written document and a concurrent writer cannot lose an
|
|
71
|
+
* earlier save.
|
|
72
|
+
*/
|
|
73
|
+
export declare class OllamaSettingsStore {
|
|
74
|
+
readonly path: string;
|
|
75
|
+
private readonly onCorrupt?;
|
|
76
|
+
private current;
|
|
77
|
+
private writes;
|
|
78
|
+
/** Set when the stored file existed but was unreadable or invalid. */
|
|
79
|
+
private corrupt;
|
|
80
|
+
constructor(path?: string, onCorrupt?: ((message: string) => void) | undefined);
|
|
81
|
+
/** Why the stored file is ignored, when it is. */
|
|
82
|
+
corruption(): string | undefined;
|
|
83
|
+
get(): OllamaSettings;
|
|
84
|
+
/** The stored catalog, or `undefined` when the store carries no override. */
|
|
85
|
+
models(): readonly OllamaModelSetting[] | undefined;
|
|
86
|
+
web(): boolean | undefined;
|
|
87
|
+
baseURL(): string | undefined;
|
|
88
|
+
apiKeyRef(): string | undefined;
|
|
89
|
+
set(input: unknown): Promise<void>;
|
|
90
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
5
|
+
function fail(message) {
|
|
6
|
+
throw new Error(message);
|
|
7
|
+
}
|
|
8
|
+
function readNonEmptyString(value, field) {
|
|
9
|
+
if (typeof value !== 'string' || value.trim().length === 0)
|
|
10
|
+
fail(`${field} must be a non-empty string`);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function readPositiveInteger(value, field) {
|
|
14
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
|
|
15
|
+
fail(`${field} must be a positive integer`);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function readBoolean(value, field) {
|
|
20
|
+
if (typeof value !== 'boolean')
|
|
21
|
+
fail(`${field} must be a boolean`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Accept only an absolute http(s) base URL with no credentials, query, or
|
|
26
|
+
* fragment, and return its trimmed form. The route appends `/tags`, `/show`,
|
|
27
|
+
* and `/v1/chat/completions` to this value, so anything else would build a URL
|
|
28
|
+
* that cannot work — and a non-official host receives the referenced
|
|
29
|
+
* credential, which the dialog tells the user.
|
|
30
|
+
* @param value - the candidate URL.
|
|
31
|
+
* @returns the trimmed URL to store.
|
|
32
|
+
* @throws when the value is not a usable absolute http(s) URL.
|
|
33
|
+
*/
|
|
34
|
+
export function validateOllamaBaseURL(value) {
|
|
35
|
+
const raw = readNonEmptyString(value, 'baseURL');
|
|
36
|
+
let url;
|
|
37
|
+
try {
|
|
38
|
+
url = new URL(raw);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
fail('baseURL must be an absolute URL');
|
|
42
|
+
}
|
|
43
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
44
|
+
fail('baseURL must use http or https');
|
|
45
|
+
if (url.username !== '' || url.password !== '')
|
|
46
|
+
fail('baseURL must not carry credentials');
|
|
47
|
+
if (url.search !== '' || url.hash !== '')
|
|
48
|
+
fail('baseURL must not carry a query or fragment');
|
|
49
|
+
return raw.trim();
|
|
50
|
+
}
|
|
51
|
+
/** Credential references are looked up by name, so whitespace is always a typo. */
|
|
52
|
+
function readApiKeyRef(value) {
|
|
53
|
+
const raw = readNonEmptyString(value, 'apiKeyRef');
|
|
54
|
+
if (/\s/.test(raw))
|
|
55
|
+
fail('apiKeyRef must not contain whitespace');
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
58
|
+
function readEfforts(value, field) {
|
|
59
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
60
|
+
fail(`${field} must be a non-empty array`);
|
|
61
|
+
const efforts = [];
|
|
62
|
+
for (const entry of value) {
|
|
63
|
+
const effort = readNonEmptyString(entry, `${field} entries`);
|
|
64
|
+
if (!efforts.includes(effort))
|
|
65
|
+
efforts.push(effort);
|
|
66
|
+
}
|
|
67
|
+
return efforts;
|
|
68
|
+
}
|
|
69
|
+
function readModel(entry, index) {
|
|
70
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
71
|
+
fail(`models[${index}] must be an object`);
|
|
72
|
+
}
|
|
73
|
+
const record = entry;
|
|
74
|
+
// The id is the wire id, so surrounding whitespace is always a paste
|
|
75
|
+
// artifact; trim it before the duplicate check sees it.
|
|
76
|
+
const model = { id: readNonEmptyString(record.id, `models[${index}].id`).trim() };
|
|
77
|
+
if (record.name !== undefined)
|
|
78
|
+
model.name = readNonEmptyString(record.name, `models[${index}].name`);
|
|
79
|
+
if (record.contextWindow !== undefined) {
|
|
80
|
+
model.contextWindow = readPositiveInteger(record.contextWindow, `models[${index}].contextWindow`);
|
|
81
|
+
}
|
|
82
|
+
if (record.contextOverride !== undefined) {
|
|
83
|
+
model.contextOverride = readPositiveInteger(record.contextOverride, `models[${index}].contextOverride`);
|
|
84
|
+
}
|
|
85
|
+
if (record.maxTokens !== undefined) {
|
|
86
|
+
model.maxTokens = readPositiveInteger(record.maxTokens, `models[${index}].maxTokens`);
|
|
87
|
+
}
|
|
88
|
+
if (record.vision !== undefined)
|
|
89
|
+
model.vision = readBoolean(record.vision, `models[${index}].vision`);
|
|
90
|
+
if (record.thinking !== undefined)
|
|
91
|
+
model.thinking = readBoolean(record.thinking, `models[${index}].thinking`);
|
|
92
|
+
if (record.efforts !== undefined)
|
|
93
|
+
model.efforts = readEfforts(record.efforts, `models[${index}].efforts`);
|
|
94
|
+
if (record.defaultEffort !== undefined) {
|
|
95
|
+
const effort = readNonEmptyString(record.defaultEffort, `models[${index}].defaultEffort`);
|
|
96
|
+
// The runtime rejects a default the model does not advertise, so a
|
|
97
|
+
// mismatch is a store bug rather than something to carry forward.
|
|
98
|
+
if (model.efforts !== undefined && !model.efforts.includes(effort)) {
|
|
99
|
+
fail(`models[${index}].defaultEffort must be one of its efforts`);
|
|
100
|
+
}
|
|
101
|
+
model.defaultEffort = effort;
|
|
102
|
+
}
|
|
103
|
+
return model;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Validate one settings document, dropping unknown keys and duplicate model
|
|
107
|
+
* ids (first wins, matching the Cursor store's visible-model handling).
|
|
108
|
+
* @param input - the parsed store document or an RPC payload.
|
|
109
|
+
* @throws when a known field has the wrong type or an unusable value.
|
|
110
|
+
*/
|
|
111
|
+
export function validateOllamaSettings(input) {
|
|
112
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
113
|
+
fail('Ollama settings must be an object');
|
|
114
|
+
}
|
|
115
|
+
const record = input;
|
|
116
|
+
const settings = {};
|
|
117
|
+
if (record.models !== undefined) {
|
|
118
|
+
if (!Array.isArray(record.models))
|
|
119
|
+
fail('models must be an array');
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
const models = [];
|
|
122
|
+
for (const [index, entry] of record.models.entries()) {
|
|
123
|
+
const model = readModel(entry, index);
|
|
124
|
+
if (seen.has(model.id))
|
|
125
|
+
continue;
|
|
126
|
+
seen.add(model.id);
|
|
127
|
+
models.push(model);
|
|
128
|
+
}
|
|
129
|
+
settings.models = models;
|
|
130
|
+
}
|
|
131
|
+
if (record.web !== undefined)
|
|
132
|
+
settings.web = readBoolean(record.web, 'web');
|
|
133
|
+
if (record.baseURL !== undefined)
|
|
134
|
+
settings.baseURL = validateOllamaBaseURL(record.baseURL);
|
|
135
|
+
if (record.apiKeyRef !== undefined)
|
|
136
|
+
settings.apiKeyRef = readApiKeyRef(record.apiKeyRef);
|
|
137
|
+
return settings;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Durable Ollama Cloud configuration under the DSH home directory. Writes are
|
|
141
|
+
* serialized and atomic (temp file with mode 0600, then rename), so a crash
|
|
142
|
+
* never leaves a half-written document and a concurrent writer cannot lose an
|
|
143
|
+
* earlier save.
|
|
144
|
+
*/
|
|
145
|
+
export class OllamaSettingsStore {
|
|
146
|
+
path;
|
|
147
|
+
onCorrupt;
|
|
148
|
+
current = {};
|
|
149
|
+
writes = Promise.resolve();
|
|
150
|
+
/** Set when the stored file existed but was unreadable or invalid. */
|
|
151
|
+
corrupt;
|
|
152
|
+
constructor(path = dshHomePath('plugins', 'subscriptions', 'ollama-settings.json'), onCorrupt) {
|
|
153
|
+
this.path = path;
|
|
154
|
+
this.onCorrupt = onCorrupt;
|
|
155
|
+
try {
|
|
156
|
+
this.current = validateOllamaSettings(JSON.parse(readFileSync(path, 'utf8')));
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (error.code !== 'ENOENT') {
|
|
160
|
+
// A broken file must not take the whole plugin down: degrade to
|
|
161
|
+
// defaults, keep the file untouched for manual recovery, and let
|
|
162
|
+
// the next save overwrite it.
|
|
163
|
+
this.corrupt = error instanceof Error ? error.message : String(error);
|
|
164
|
+
// The raw V8 message quotes the offending bytes; the log keeps a
|
|
165
|
+
// fixed diagnosis and corruption() carries the detail for callers
|
|
166
|
+
// that surface it outside log lines.
|
|
167
|
+
this.onCorrupt?.(`Ollama settings at ${path} are unreadable and ignored; defaults serve until the next save.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Why the stored file is ignored, when it is. */
|
|
172
|
+
corruption() { return this.corrupt; }
|
|
173
|
+
get() { return structuredClone(this.current); }
|
|
174
|
+
/** The stored catalog, or `undefined` when the store carries no override. */
|
|
175
|
+
models() { return this.current.models; }
|
|
176
|
+
web() { return this.current.web; }
|
|
177
|
+
baseURL() { return this.current.baseURL; }
|
|
178
|
+
apiKeyRef() { return this.current.apiKeyRef; }
|
|
179
|
+
set(input) {
|
|
180
|
+
const validated = validateOllamaSettings(input);
|
|
181
|
+
const run = this.writes.then(async () => {
|
|
182
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
183
|
+
const temporary = `${this.path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
184
|
+
try {
|
|
185
|
+
await writeFile(temporary, JSON.stringify(validated, null, 2), { mode: 0o600 });
|
|
186
|
+
await rename(temporary, this.path);
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
await rm(temporary, { force: true });
|
|
190
|
+
}
|
|
191
|
+
this.current = validated;
|
|
192
|
+
this.corrupt = undefined;
|
|
193
|
+
});
|
|
194
|
+
this.writes = run.catch(() => undefined);
|
|
195
|
+
return run;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama-native web capabilities: search and fetch ride the Cloud
|
|
3
|
+
* `/api/web_search` + `/api/web_fetch` endpoints independently of the
|
|
4
|
+
* selected chat model. Both providers share the `ollama-cloud` id because
|
|
5
|
+
* the seam addresses each capability kind separately.
|
|
6
|
+
*/
|
|
7
|
+
import type { WebFetchProvider, WebFetchRequest, WebFetchResult, WebSearchProvider, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web';
|
|
8
|
+
import type { FetchFn } from './common.js';
|
|
9
|
+
/** Shared provider id for both web capabilities. */
|
|
10
|
+
export declare const OLLAMA_WEB_PROVIDER_ID = "ollama-cloud";
|
|
11
|
+
/** Per-attempt budget; the seam owns overall cancellation. */
|
|
12
|
+
export declare const OLLAMA_WEB_TIMEOUT_MS = 15000;
|
|
13
|
+
/** Search result cap applied at the request layer. */
|
|
14
|
+
export declare const OLLAMA_WEB_MAX_RESULTS = 10;
|
|
15
|
+
/** Response body bound for web payloads. */
|
|
16
|
+
export declare const OLLAMA_WEB_MAX_BYTES: number;
|
|
17
|
+
export interface OllamaWebOptions {
|
|
18
|
+
/** Resolve the API key per call; `undefined` means unconfigured. */
|
|
19
|
+
apiKey: () => Promise<string | undefined>;
|
|
20
|
+
/**
|
|
21
|
+
* Native base URL; web endpoints ride it unchanged (no `/v1` mapping). A
|
|
22
|
+
* function form is read per call so a Settings save applies without a
|
|
23
|
+
* restart.
|
|
24
|
+
*/
|
|
25
|
+
baseURL: string | (() => string);
|
|
26
|
+
/** Local gate; absent counts as enabled. Read on every call. */
|
|
27
|
+
enabled?: () => boolean;
|
|
28
|
+
fetchFn?: FetchFn;
|
|
29
|
+
requestTimeoutMs?: number;
|
|
30
|
+
}
|
|
31
|
+
/** Search provider behind DSH's stock web_search tool. */
|
|
32
|
+
export declare class OllamaWebSearchProvider implements WebSearchProvider {
|
|
33
|
+
private readonly options;
|
|
34
|
+
readonly id = "ollama-cloud";
|
|
35
|
+
constructor(options: OllamaWebOptions);
|
|
36
|
+
available(): boolean;
|
|
37
|
+
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
|
|
38
|
+
}
|
|
39
|
+
/** Fetch provider behind DSH's stock web_fetch tool. */
|
|
40
|
+
export declare class OllamaWebFetchProvider implements WebFetchProvider {
|
|
41
|
+
private readonly options;
|
|
42
|
+
readonly id = "ollama-cloud";
|
|
43
|
+
constructor(options: OllamaWebOptions);
|
|
44
|
+
available(): boolean;
|
|
45
|
+
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama-native web capabilities: search and fetch ride the Cloud
|
|
3
|
+
* `/api/web_search` + `/api/web_fetch` endpoints independently of the
|
|
4
|
+
* selected chat model. Both providers share the `ollama-cloud` id because
|
|
5
|
+
* the seam addresses each capability kind separately.
|
|
6
|
+
*/
|
|
7
|
+
import { attributionHeaders } from '@deepseek-ai/dsh-llm';
|
|
8
|
+
import { WebError } from '@deepseek-ai/dsh-web';
|
|
9
|
+
import { readBoundedJson } from './common.js';
|
|
10
|
+
import { hostFetch } from '../http.js';
|
|
11
|
+
/** Shared provider id for both web capabilities. */
|
|
12
|
+
export const OLLAMA_WEB_PROVIDER_ID = 'ollama-cloud';
|
|
13
|
+
/** Per-attempt budget; the seam owns overall cancellation. */
|
|
14
|
+
export const OLLAMA_WEB_TIMEOUT_MS = 15_000;
|
|
15
|
+
/** Search result cap applied at the request layer. */
|
|
16
|
+
export const OLLAMA_WEB_MAX_RESULTS = 10;
|
|
17
|
+
/** Response body bound for web payloads. */
|
|
18
|
+
export const OLLAMA_WEB_MAX_BYTES = 2 * 1024 * 1024;
|
|
19
|
+
/** The effective base URL; both forms are read fresh so saves apply live. */
|
|
20
|
+
function baseURLOf(options) {
|
|
21
|
+
return typeof options.baseURL === 'function' ? options.baseURL() : options.baseURL;
|
|
22
|
+
}
|
|
23
|
+
function throwIfAborted(signal) {
|
|
24
|
+
signal?.throwIfAborted();
|
|
25
|
+
}
|
|
26
|
+
function isRedirectFailure(error) {
|
|
27
|
+
// Real fetch failures nest: undici reports `fetch failed` on top with the
|
|
28
|
+
// redirect rejection in `cause`. Walk the chain instead of reading only
|
|
29
|
+
// the outer message.
|
|
30
|
+
for (let current = error; current instanceof Error; current = current.cause) {
|
|
31
|
+
if (/redirect/i.test(current.message))
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
async function postJson(url, apiKey, payload, options, signal) {
|
|
37
|
+
const fetchFn = options.fetchFn ?? hostFetch;
|
|
38
|
+
const timeoutMs = options.requestTimeoutMs ?? OLLAMA_WEB_TIMEOUT_MS;
|
|
39
|
+
// Exactly two attempts; only a timeout or a pre-response transport
|
|
40
|
+
// failure retries. HTTP errors, malformed replies, missing credentials,
|
|
41
|
+
// redirects, and caller cancellation never retry.
|
|
42
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
43
|
+
throwIfAborted(signal);
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
let timedOut = false;
|
|
46
|
+
const timer = setTimeout(() => {
|
|
47
|
+
timedOut = true;
|
|
48
|
+
controller.abort(new Error('ollama web request timed out'));
|
|
49
|
+
}, timeoutMs);
|
|
50
|
+
if (typeof timer.unref === 'function') {
|
|
51
|
+
timer.unref();
|
|
52
|
+
}
|
|
53
|
+
const onCallerAbort = () => controller.abort(signal?.reason);
|
|
54
|
+
signal?.addEventListener('abort', onCallerAbort, { once: true });
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetchFn(url, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: {
|
|
59
|
+
'content-type': 'application/json',
|
|
60
|
+
'accept': 'application/json',
|
|
61
|
+
'authorization': `Bearer ${apiKey}`,
|
|
62
|
+
...attributionHeaders(),
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify(payload),
|
|
65
|
+
redirect: 'error',
|
|
66
|
+
signal: controller.signal,
|
|
67
|
+
});
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
throw new WebError(`ollama web request failed (HTTP ${String(response.status)})`, 'OLLAMA_WEB_BAD_RESPONSE');
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return await readBoundedJson(response, 'ollama web request', OLLAMA_WEB_MAX_BYTES);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (signal?.aborted)
|
|
76
|
+
throw error;
|
|
77
|
+
// A timeout can also land mid-body: same retry treatment as a
|
|
78
|
+
// connect timeout, under the same code.
|
|
79
|
+
if (timedOut) {
|
|
80
|
+
if (attempt >= 2)
|
|
81
|
+
throw new WebError('ollama web request timed out', 'OLLAMA_WEB_TIMEOUT');
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// The SyntaxError cause is dropped on purpose: V8 quotes the
|
|
85
|
+
// offending body bytes, which would carry provider text into
|
|
86
|
+
// logs through the chained message. An oversized body lands
|
|
87
|
+
// here too: loud and unretried.
|
|
88
|
+
throw new WebError('ollama web request returned a malformed reply', 'OLLAMA_WEB_BAD_REPLY');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (signal?.aborted)
|
|
93
|
+
throw error;
|
|
94
|
+
// Classified failures carry their own code and never retry.
|
|
95
|
+
if (error instanceof WebError)
|
|
96
|
+
throw error;
|
|
97
|
+
if (isRedirectFailure(error)) {
|
|
98
|
+
throw new WebError('ollama web request redirected; redirects are not followed', 'OLLAMA_WEB_REDIRECT');
|
|
99
|
+
}
|
|
100
|
+
if (timedOut) {
|
|
101
|
+
if (attempt >= 2)
|
|
102
|
+
throw new WebError('ollama web request timed out', 'OLLAMA_WEB_TIMEOUT', { cause: error });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (attempt >= 2) {
|
|
106
|
+
throw new WebError('ollama web request transport failed', 'OLLAMA_WEB_TRANSPORT', { cause: error });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
signal?.removeEventListener('abort', onCallerAbort);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
throw new WebError('ollama web request failed', 'OLLAMA_WEB_TRANSPORT');
|
|
115
|
+
}
|
|
116
|
+
function recordOf(value) {
|
|
117
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
118
|
+
? value
|
|
119
|
+
: undefined;
|
|
120
|
+
}
|
|
121
|
+
async function resolveWebKey(options, signal) {
|
|
122
|
+
throwIfAborted(signal);
|
|
123
|
+
const apiKey = await options.apiKey();
|
|
124
|
+
if (apiKey === undefined) {
|
|
125
|
+
throw new WebError('Ollama web search requires an Ollama Cloud API key; configure OLLAMA_API_KEY', 'OLLAMA_WEB_MISSING_CREDENTIAL');
|
|
126
|
+
}
|
|
127
|
+
return apiKey;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Refuse a call while the Settings switch is off. The host normally selects
|
|
131
|
+
* only available providers, but a pinned `searchProvider`/`fetchProvider`
|
|
132
|
+
* would otherwise keep routing through a provider the user turned off.
|
|
133
|
+
*/
|
|
134
|
+
function assertEnabled(options) {
|
|
135
|
+
if (options.enabled?.() === false) {
|
|
136
|
+
throw new WebError('Ollama web search/fetch is disabled in Settings', 'OLLAMA_WEB_DISABLED');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Search provider behind DSH's stock web_search tool. */
|
|
140
|
+
export class OllamaWebSearchProvider {
|
|
141
|
+
options;
|
|
142
|
+
id = OLLAMA_WEB_PROVIDER_ID;
|
|
143
|
+
constructor(options) {
|
|
144
|
+
this.options = options;
|
|
145
|
+
}
|
|
146
|
+
available() {
|
|
147
|
+
try {
|
|
148
|
+
new URL(baseURLOf(this.options));
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
return this.options.enabled?.() ?? true;
|
|
154
|
+
}
|
|
155
|
+
async search(request, signal) {
|
|
156
|
+
assertEnabled(this.options);
|
|
157
|
+
const apiKey = await resolveWebKey(this.options, signal);
|
|
158
|
+
const base = baseURLOf(this.options).replace(/\/+$/, '');
|
|
159
|
+
const payload = await postJson(`${base}/web_search`, apiKey, {
|
|
160
|
+
query: request.query,
|
|
161
|
+
...(request.maxResults === undefined ? {} : { max_results: Math.min(request.maxResults, OLLAMA_WEB_MAX_RESULTS) }),
|
|
162
|
+
}, this.options, signal);
|
|
163
|
+
const results = recordOf(payload)?.['results'];
|
|
164
|
+
if (!Array.isArray(results))
|
|
165
|
+
throw new WebError('ollama web search returned no results array', 'OLLAMA_WEB_BAD_REPLY');
|
|
166
|
+
// Official shape per docs: {title, url, content}. The snippet rides
|
|
167
|
+
// `content`, not `snippet`.
|
|
168
|
+
const sources = results.flatMap(entry => {
|
|
169
|
+
const record = recordOf(entry);
|
|
170
|
+
const url = record?.['url'];
|
|
171
|
+
if (typeof url !== 'string' || url.length === 0)
|
|
172
|
+
return [];
|
|
173
|
+
return [{
|
|
174
|
+
url,
|
|
175
|
+
...(typeof record?.['title'] === 'string' ? { title: record?.['title'] } : {}),
|
|
176
|
+
...(typeof record?.['content'] === 'string' ? { snippet: record?.['content'] } : {}),
|
|
177
|
+
}];
|
|
178
|
+
});
|
|
179
|
+
return { sources, truncated: false };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Fetch provider behind DSH's stock web_fetch tool. */
|
|
183
|
+
export class OllamaWebFetchProvider {
|
|
184
|
+
options;
|
|
185
|
+
id = OLLAMA_WEB_PROVIDER_ID;
|
|
186
|
+
constructor(options) {
|
|
187
|
+
this.options = options;
|
|
188
|
+
}
|
|
189
|
+
available() {
|
|
190
|
+
try {
|
|
191
|
+
new URL(baseURLOf(this.options));
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
return this.options.enabled?.() ?? true;
|
|
197
|
+
}
|
|
198
|
+
async fetch(request, signal) {
|
|
199
|
+
assertEnabled(this.options);
|
|
200
|
+
const apiKey = await resolveWebKey(this.options, signal);
|
|
201
|
+
const base = baseURLOf(this.options).replace(/\/+$/, '');
|
|
202
|
+
const payload = await postJson(`${base}/web_fetch`, apiKey, { url: request.url }, this.options, signal);
|
|
203
|
+
// Official shape per docs: {title, content, links}. The endpoint
|
|
204
|
+
// answers the retrieval itself, so the request URL is the result URL
|
|
205
|
+
// and a decoded body means success.
|
|
206
|
+
const record = recordOf(payload);
|
|
207
|
+
const content = record?.['content'];
|
|
208
|
+
if (typeof content !== 'string') {
|
|
209
|
+
throw new WebError('ollama web fetch returned a malformed reply', 'OLLAMA_WEB_BAD_REPLY');
|
|
210
|
+
}
|
|
211
|
+
return { url: request.url, statusCode: 200, body: { kind: 'text', content }, truncated: false };
|
|
212
|
+
}
|
|
213
|
+
}
|