@lihuu/dsh-ollama-cloud 0.1.2 → 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 +56 -8
- package/dist/index.js +254 -37
- package/dist/index.js.map +7 -0
- package/lib/discovery.d.ts +36 -0
- package/lib/index.d.ts +61 -17
- package/package.json +6 -2
- package/src/adapter.ts +3 -1
- package/src/discovery.ts +252 -0
- package/src/index.ts +200 -62
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answering "which models can this draft serve?" for the Models settings
|
|
3
|
+
* page's fetch action.
|
|
4
|
+
*
|
|
5
|
+
* A draft naming this plugin's route is answered **from the adapter's own
|
|
6
|
+
* catalog**, with no network call: the resolved section is the authoritative
|
|
7
|
+
* list for the route, and it carries the cloud-suffixed ids requests actually
|
|
8
|
+
* use. Only a draft carrying an endpoint — a gateway or OpenAI-compatible
|
|
9
|
+
* mirror the catalog says nothing about — is interrogated over the wire at
|
|
10
|
+
* `GET {baseURL}/models`, the one listing shape such endpoints agree on.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here is stored: the request carries a draft the user is still
|
|
13
|
+
* editing, and the reply is candidate metadata the surface offers for
|
|
14
|
+
* adoption. The section remains the only thing that decides what the route
|
|
15
|
+
* serves.
|
|
16
|
+
*
|
|
17
|
+
* @module llm-ollama-cloud/discovery
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { INVALID_CREDENTIAL_CODE, LlmError, normalizeApiKey } from '@deepseek-ai/dsh-llm'
|
|
21
|
+
import type { LlmDiscoveredModel, LlmModelDiscoveryOperation } from '@deepseek-ai/dsh-llm'
|
|
22
|
+
import { attributionHeaders } from '@deepseek-ai/dsh-llm'
|
|
23
|
+
import type { OllamaCatalogModel } from './adapter.ts'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Endpoint replies larger than this are refused. The endpoint is whatever URL
|
|
27
|
+
* the user typed, so the ceiling holds on the bytes actually read rather than
|
|
28
|
+
* on the length the server claims.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
/** One entry of an OpenAI-compatible `GET /models` reply. */
|
|
33
|
+
interface ListingEntry {
|
|
34
|
+
id?: unknown
|
|
35
|
+
/** Common gateway extensions; absent from the official listing. */
|
|
36
|
+
name?: unknown
|
|
37
|
+
display_name?: unknown
|
|
38
|
+
context_window?: unknown
|
|
39
|
+
context_length?: unknown
|
|
40
|
+
max_tokens?: unknown
|
|
41
|
+
max_output_tokens?: unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A positive integer field of a listing entry, or `undefined` when absent or unusable. */
|
|
45
|
+
function capacity(...candidates: readonly unknown[]): number | undefined {
|
|
46
|
+
for (const candidate of candidates) {
|
|
47
|
+
if (typeof candidate === 'number' && Number.isInteger(candidate) && candidate > 0) return candidate
|
|
48
|
+
}
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A non-empty string field of a listing entry, or `undefined`. */
|
|
53
|
+
function label(...candidates: readonly unknown[]): string | undefined {
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
if (typeof candidate === 'string' && candidate.length > 0) return candidate
|
|
56
|
+
}
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Join the endpoint base with the listing path. The base is treated as a
|
|
62
|
+
* prefix rather than a URL to resolve against, so a deployment path such as
|
|
63
|
+
* `https://gateway.example/openai/v1` keeps its segments instead of losing
|
|
64
|
+
* them to `URL` resolution.
|
|
65
|
+
*/
|
|
66
|
+
function listingUrl(baseURL: string): string {
|
|
67
|
+
return `${baseURL.replace(/\/+$/, '')}/models`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Accept one probe key, or refuse it before the header is built. Without this
|
|
72
|
+
* the `fetch` below would throw a ByteString `TypeError` that the transport
|
|
73
|
+
* catch reports as `could not reach <url>` — blaming the network for a local,
|
|
74
|
+
* deterministic fault.
|
|
75
|
+
* @param raw - the key typed into the form or read from storage.
|
|
76
|
+
* @returns the trimmed, usable key.
|
|
77
|
+
*/
|
|
78
|
+
function usableProbeKey(raw: string): string {
|
|
79
|
+
const checked = normalizeApiKey(raw)
|
|
80
|
+
if (checked.ok) return checked.value
|
|
81
|
+
throw new LlmError(
|
|
82
|
+
checked.reason === 'empty'
|
|
83
|
+
? 'this provider\'s API key is blank; enter it on the Models page, or clear it to probe unauthenticated'
|
|
84
|
+
: 'this provider\'s API key contains characters no HTTP header can carry; paste the raw key only',
|
|
85
|
+
INVALID_CREDENTIAL_CODE,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read a reply body, refusing one that outgrows the ceiling. A declared length
|
|
91
|
+
* is checked first so an honest server is turned away without transferring
|
|
92
|
+
* anything; the accumulated total is what actually enforces the bound, because
|
|
93
|
+
* a server that under-declares (or streams) tells us nothing up front.
|
|
94
|
+
*/
|
|
95
|
+
async function readBounded(response: Response, url: string): Promise<string> {
|
|
96
|
+
const oversized = (): LlmError =>
|
|
97
|
+
new LlmError(`${url} answered with more than ${MAX_RESPONSE_BYTES} bytes`, 'DISCOVERY_FAILED')
|
|
98
|
+
const declared = Number(response.headers.get('content-length') ?? Number.NaN)
|
|
99
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
100
|
+
await response.body?.cancel()
|
|
101
|
+
throw oversized()
|
|
102
|
+
}
|
|
103
|
+
if (response.body === null) return ''
|
|
104
|
+
const reader = response.body.getReader()
|
|
105
|
+
const chunks: Uint8Array[] = []
|
|
106
|
+
let total = 0
|
|
107
|
+
try {
|
|
108
|
+
for (;;) {
|
|
109
|
+
const { done, value } = await reader.read()
|
|
110
|
+
if (done) break
|
|
111
|
+
total += value.byteLength
|
|
112
|
+
if (total > MAX_RESPONSE_BYTES) throw oversized()
|
|
113
|
+
chunks.push(value)
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
/* v8 ignore next 4 -- cancel() after a completed or abandoned read settles without rejecting; unobserved best-effort cleanup. */
|
|
117
|
+
await reader.cancel().catch(() => {
|
|
118
|
+
// Cancel after a drained read, or after this function walked away from
|
|
119
|
+
// an oversized one, is cleanup; the reply is already decided either way.
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
const body = new Uint8Array(total)
|
|
123
|
+
let offset = 0
|
|
124
|
+
for (const chunk of chunks) {
|
|
125
|
+
body.set(chunk, offset)
|
|
126
|
+
offset += chunk.byteLength
|
|
127
|
+
}
|
|
128
|
+
return new TextDecoder().decode(body)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Read one OpenAI-compatible listing reply. Entries without a usable id are
|
|
133
|
+
* skipped rather than failing the whole interrogation: a single malformed row
|
|
134
|
+
* should not deny the user the rest of a working endpoint's catalog.
|
|
135
|
+
*/
|
|
136
|
+
function readListing(body: unknown): LlmDiscoveredModel[] {
|
|
137
|
+
const data = (body as { data?: unknown } | null)?.data
|
|
138
|
+
if (!Array.isArray(data)) {
|
|
139
|
+
throw new LlmError(
|
|
140
|
+
'the endpoint\'s model listing has no "data" array; enter this provider\'s models by hand',
|
|
141
|
+
'DISCOVERY_FAILED',
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
const models: LlmDiscoveredModel[] = []
|
|
145
|
+
for (const raw of data) {
|
|
146
|
+
const entry = raw as ListingEntry | null
|
|
147
|
+
const id = label(entry?.id)
|
|
148
|
+
if (id === undefined) continue
|
|
149
|
+
const name = label(entry?.name, entry?.display_name)
|
|
150
|
+
const contextWindow = capacity(entry?.context_window, entry?.context_length)
|
|
151
|
+
const maxTokens = capacity(entry?.max_output_tokens, entry?.max_tokens)
|
|
152
|
+
models.push({
|
|
153
|
+
id,
|
|
154
|
+
...name === undefined ? {} : { name },
|
|
155
|
+
...contextWindow === undefined ? {} : { contextWindow },
|
|
156
|
+
...maxTokens === undefined ? {} : { maxTokens },
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
return models
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Interrogate one draft provider for the models it advertises.
|
|
164
|
+
* @param request - the endpoint and one-shot credential to use.
|
|
165
|
+
* @param installed - the route's own catalog as currently resolved; the
|
|
166
|
+
* answer for a draft naming the route.
|
|
167
|
+
* @param storedApiKey - the credential the stored section resolves, asked for
|
|
168
|
+
* only when the draft carries none and only on the path that reaches the
|
|
169
|
+
* network. A configuration surface never holds a stored secret — it edits a
|
|
170
|
+
* redacted descriptor — so without this an already-configured route would be
|
|
171
|
+
* interrogated unauthenticated and answer 401.
|
|
172
|
+
* @returns the advertised models in endpoint order.
|
|
173
|
+
* @throws LlmError when the draft names neither a catalog route nor an
|
|
174
|
+
* endpoint, the endpoint refuses or fails the request, or the reply is not
|
|
175
|
+
* a model listing.
|
|
176
|
+
*/
|
|
177
|
+
export async function discoverModels(
|
|
178
|
+
request: LlmModelDiscoveryOperation,
|
|
179
|
+
installed: readonly OllamaCatalogModel[],
|
|
180
|
+
storedApiKey?: () => Promise<string | undefined>,
|
|
181
|
+
): Promise<readonly LlmDiscoveredModel[]> {
|
|
182
|
+
// A named route already has its answer, and a better one: the installed
|
|
183
|
+
// entries carry the cloud-suffixed ids and capacities no listing endpoint
|
|
184
|
+
// reports, and they are already normalized through the same step the
|
|
185
|
+
// adapter's own requests go through.
|
|
186
|
+
if (request.provider !== undefined && installed.length > 0) {
|
|
187
|
+
return installed.map(model => ({
|
|
188
|
+
id: model.id,
|
|
189
|
+
name: model.name ?? model.id,
|
|
190
|
+
...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
|
|
191
|
+
...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
|
|
192
|
+
}))
|
|
193
|
+
}
|
|
194
|
+
if (request.baseURL === undefined || request.baseURL.length === 0) {
|
|
195
|
+
throw new LlmError(
|
|
196
|
+
'model discovery needs a baseURL to interrogate; set one, or enter this provider\'s models by hand',
|
|
197
|
+
'DISCOVERY_FAILED',
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
const url = listingUrl(request.baseURL)
|
|
201
|
+
// A key typed into the form wins: it is the one the user is testing, and it
|
|
202
|
+
// may be the replacement for exactly the stored key that is failing. The
|
|
203
|
+
// stored one is only asked for here, past the catalog short-circuit, so a
|
|
204
|
+
// route answered from the registry costs no credential lookup — and no
|
|
205
|
+
// diagnostic about a credential it never needed. A probe carrying no key
|
|
206
|
+
// stays unauthenticated, which is how an auth-free gateway is meant to be
|
|
207
|
+
// asked.
|
|
208
|
+
const supplied = request.apiKey ?? await storedApiKey?.()
|
|
209
|
+
const apiKey = supplied === undefined ? undefined : usableProbeKey(supplied)
|
|
210
|
+
let response: Response
|
|
211
|
+
try {
|
|
212
|
+
response = await fetch(url, {
|
|
213
|
+
method: 'GET',
|
|
214
|
+
headers: {
|
|
215
|
+
accept: 'application/json',
|
|
216
|
+
...apiKey === undefined ? {} : { authorization: `Bearer ${apiKey}` },
|
|
217
|
+
...attributionHeaders(),
|
|
218
|
+
},
|
|
219
|
+
...request.signal === undefined ? {} : { signal: request.signal },
|
|
220
|
+
})
|
|
221
|
+
} catch (error: unknown) {
|
|
222
|
+
if (request.signal?.aborted) {
|
|
223
|
+
throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error })
|
|
224
|
+
}
|
|
225
|
+
throw new LlmError(`could not reach ${url}`, 'DISCOVERY_FAILED', { cause: error })
|
|
226
|
+
}
|
|
227
|
+
if (!response.ok) {
|
|
228
|
+
throw new LlmError(
|
|
229
|
+
`${url} answered ${response.status}${response.status === 401 || response.status === 403 ? '; check the API key' : ''}`,
|
|
230
|
+
'DISCOVERY_FAILED',
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
let text: string
|
|
234
|
+
try {
|
|
235
|
+
text = await readBounded(response, url)
|
|
236
|
+
} catch (error: unknown) {
|
|
237
|
+
// Cancellation during the body read rejects with the abort reason, which
|
|
238
|
+
// may be any value; the caller gets the same coded failure it would have
|
|
239
|
+
// for a cancellation before the request went out.
|
|
240
|
+
if (request.signal?.aborted) {
|
|
241
|
+
throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error })
|
|
242
|
+
}
|
|
243
|
+
throw error
|
|
244
|
+
}
|
|
245
|
+
let body: unknown
|
|
246
|
+
try {
|
|
247
|
+
body = JSON.parse(text)
|
|
248
|
+
} catch (error: unknown) {
|
|
249
|
+
throw new LlmError(`${url} did not answer with JSON`, 'DISCOVERY_FAILED', { cause: error })
|
|
250
|
+
}
|
|
251
|
+
return readListing(body)
|
|
252
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,25 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Register an {@link OllamaAdapter} for the `ollama-cloud-direct` provider
|
|
3
|
-
* on `ctx.llm`, with connection facts resolved
|
|
4
|
-
* plugin
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Register an {@link OllamaAdapter} for the `ollama-cloud-direct` provider
|
|
3
|
+
* route on `ctx.llm`, with connection facts resolved per request instead of
|
|
4
|
+
* frozen at load: the plugin layers its `cordis.yml` entry config under the
|
|
5
|
+
* optional `llm-ollama-cloud` user-settings section (`ctx.settings`) and
|
|
6
|
+
* resolves the bearer token through the credential seam (`ctx.credentials`),
|
|
7
|
+
* falling back to the process environment, so a changed base URL, catalog, or
|
|
8
|
+
* key reaches the very next request without restarting anything, while an
|
|
9
|
+
* in-flight stream keeps the facts it started with. The one
|
|
10
|
+
* registration-captured fact — the retry policy — re-registers the route in
|
|
11
|
+
* place when it changes.
|
|
12
|
+
*
|
|
13
|
+
* The route is configured pi-ai-style, as a per-route profile under
|
|
14
|
+
* `providers.ollama-cloud-direct`: with no stored profile the route is
|
|
15
|
+
* **dormant on configuration surfaces** — declared in the configurable-provider
|
|
16
|
+
* directory so the Models settings page lists it in the add-provider select —
|
|
17
|
+
* while the adapter itself serves the resolved defaults (schema defaults plus
|
|
18
|
+
* this module's fallbacks) the moment the plugin mounts, so an ambient
|
|
19
|
+
* `OLLAMA_CLOUD_API_KEY` keeps working before the page ever writes a profile.
|
|
20
|
+
* A model-discovery registration answers the page's fetch action from the
|
|
21
|
+
* resolved catalog, or interrogates a drafted endpoint.
|
|
7
22
|
*
|
|
8
23
|
* Dependencies are intentionally minimal — `@deepseek-ai/dsh-llm` (the harness
|
|
9
24
|
* LLM seam contract), `@deepseek-ai/dsh-credentials` (the credential seam),
|
|
10
|
-
* `@deepseek-ai/
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* route is `ollama-cloud-direct` (not `ollama-cloud`) so it
|
|
14
|
-
* pi-ai-configured `ollama-cloud` route.
|
|
25
|
+
* `@deepseek-ai/dsh-settings` (the settings-section install), `@deepseek-ai/
|
|
26
|
+
* schemastery` (the section schema), `@deepseek-ai/cordis` (plugin framework),
|
|
27
|
+
* and `eventsource-parser` (SSE framing); validation beyond the schema is
|
|
28
|
+
* hand-rolled. The route is `ollama-cloud-direct` (not `ollama-cloud`) so it
|
|
29
|
+
* can coexist with a pi-ai-configured `ollama-cloud` route.
|
|
15
30
|
*
|
|
16
31
|
* @module llm-ollama-cloud
|
|
17
32
|
*/
|
|
18
33
|
|
|
19
34
|
import type { Context } from '@deepseek-ai/cordis'
|
|
20
|
-
import
|
|
21
|
-
import { assertUsableApiKey, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
|
|
35
|
+
import z from '@deepseek-ai/schemastery'
|
|
36
|
+
import { assertUsableApiKey, LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
|
|
22
37
|
import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
|
|
38
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
39
|
+
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
23
40
|
import {
|
|
24
41
|
DEFAULT_CONTEXT_WINDOW,
|
|
25
42
|
DEFAULT_MAX_TOKENS,
|
|
@@ -29,6 +46,7 @@ import {
|
|
|
29
46
|
OllamaAdapter,
|
|
30
47
|
} from './adapter.ts'
|
|
31
48
|
import type { OllamaCatalogModel, OllamaConnectionOptions } from './adapter.ts'
|
|
49
|
+
import { discoverModels } from './discovery.ts'
|
|
32
50
|
|
|
33
51
|
export {
|
|
34
52
|
DEFAULT_CONTEXT_WINDOW,
|
|
@@ -39,15 +57,17 @@ export {
|
|
|
39
57
|
OllamaAdapter,
|
|
40
58
|
} from './adapter.ts'
|
|
41
59
|
export type { OllamaAdapterOptions, OllamaCatalogModel, OllamaConnectionOptions } from './adapter.ts'
|
|
60
|
+
export { discoverModels } from './discovery.ts'
|
|
42
61
|
export type { RequestDefaults } from './serialize.ts'
|
|
43
62
|
export type * from './types.ts'
|
|
44
63
|
|
|
45
64
|
export const name = 'llm-ollama-cloud'
|
|
46
65
|
export const inject = ['llm']
|
|
47
66
|
|
|
67
|
+
const NS = settingsNamespace('llm-ollama-cloud')
|
|
48
68
|
const DEFAULT_API_KEY_ENV = 'OLLAMA_CLOUD_API_KEY'
|
|
49
69
|
/** The single provider route this plugin owns. */
|
|
50
|
-
const PROVIDER = 'ollama-cloud-direct'
|
|
70
|
+
export const PROVIDER = 'ollama-cloud-direct'
|
|
51
71
|
|
|
52
72
|
const DEFAULT_MODELS: OllamaCatalogModel[] = [
|
|
53
73
|
{ id: 'deepseek-v4-flash:cloud', name: 'DeepSeek-V4-Flash (cloud)', contextWindow: DEFAULT_CONTEXT_WINDOW },
|
|
@@ -58,13 +78,15 @@ const DEFAULT_MODELS: OllamaCatalogModel[] = [
|
|
|
58
78
|
const MODEL_MODALITIES = ['text', 'image'] as const satisfies readonly ModelModality[]
|
|
59
79
|
|
|
60
80
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
81
|
+
* One route's stored profile — the plugin config's per-route entry and the
|
|
82
|
+
* shape the Models page writes under `providers.<route>`. Every field is
|
|
83
|
+
* optional: a profile naming no reference resolves key material through
|
|
84
|
+
* {@link OllamaProviderProfile.apiKeyEnv}'s default at each request, omitted
|
|
63
85
|
* thinking mode uses the provider default, and omitted reasoning effort lets
|
|
64
86
|
* the server auto-enable thinking at its default.
|
|
65
87
|
*/
|
|
66
|
-
export interface
|
|
67
|
-
/**
|
|
88
|
+
export interface OllamaProviderProfile {
|
|
89
|
+
/** Credential reference (environment-variable name) resolved per request; defaults to `OLLAMA_CLOUD_API_KEY`. */
|
|
68
90
|
apiKeyEnv?: string
|
|
69
91
|
/** Endpoint base; defaults to the Ollama cloud API. */
|
|
70
92
|
baseURL?: string
|
|
@@ -84,6 +106,47 @@ export interface Config {
|
|
|
84
106
|
retryPolicy?: RetryPolicyConfig
|
|
85
107
|
}
|
|
86
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Plugin config from the `cordis.yml` mount entry, validated by the
|
|
111
|
+
* same-named schemastery schema and doubling as the `llm-ollama-cloud`
|
|
112
|
+
* settings-section shape. Profiles are keyed by provider route id; the route
|
|
113
|
+
* this plugin serves is {@link PROVIDER}. A mount that pins the profile
|
|
114
|
+
* presents the route as configured; a bare mount leaves it dormant in the
|
|
115
|
+
* add-provider select until the page (or `settings.yaml`) writes one.
|
|
116
|
+
*/
|
|
117
|
+
export interface Config {
|
|
118
|
+
/** Per-route profiles keyed by provider route id. */
|
|
119
|
+
providers?: Record<string, OllamaProviderProfile>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The catalog-model entry schema (one profile's `models` row). */
|
|
123
|
+
const catalogModel: z<OllamaCatalogModel> = z.object({
|
|
124
|
+
id: z.string().required(),
|
|
125
|
+
name: z.string(),
|
|
126
|
+
description: z.string(),
|
|
127
|
+
contextWindow: z.number().step(1).min(1),
|
|
128
|
+
maxTokens: z.number().step(1).min(1),
|
|
129
|
+
inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(['text']),
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
/** One stored route profile; its defaults apply only once the profile exists. */
|
|
133
|
+
const profileSchema: z<OllamaProviderProfile> = z.object({
|
|
134
|
+
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
|
|
135
|
+
baseURL: z.string(),
|
|
136
|
+
thinking: z.union(['enabled', 'disabled']),
|
|
137
|
+
reasoningEffort: z.union(['off', 'low', 'high', 'max']),
|
|
138
|
+
maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_TOKENS),
|
|
139
|
+
defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
|
|
140
|
+
models: z.array(catalogModel).default(DEFAULT_MODELS),
|
|
141
|
+
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
|
142
|
+
retryPolicy: RetryPolicySchema,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
/** The `llm-ollama-cloud` settings-section schema; `Config` is its static type. */
|
|
146
|
+
export const Config: z<Config> = z.object({
|
|
147
|
+
providers: z.dict(profileSchema).default({}),
|
|
148
|
+
})
|
|
149
|
+
|
|
87
150
|
/** The public Ollama cloud API base. */
|
|
88
151
|
export const PUBLIC_BASE_URL = 'https://ollama.com/v1'
|
|
89
152
|
|
|
@@ -134,26 +197,38 @@ function resolveModels(models: readonly OllamaCatalogModel[] | undefined): Ollam
|
|
|
134
197
|
}
|
|
135
198
|
|
|
136
199
|
/**
|
|
137
|
-
* The one explicit resolve step from raw
|
|
200
|
+
* The one explicit resolve step from raw config to validated connection
|
|
138
201
|
* facts, with every default and bound re-judged here (fail loud at load).
|
|
139
|
-
*
|
|
140
|
-
*
|
|
202
|
+
* Programmatic construction may bypass Schemastery normalization, so this
|
|
203
|
+
* also re-judges each settings snapshot at its first use.
|
|
204
|
+
* @param config - raw plugin config or resolved settings snapshot.
|
|
205
|
+
* @returns validated connection facts for {@link PROVIDER}.
|
|
141
206
|
*/
|
|
142
207
|
export function resolveAdapterOptions(config: Config): OllamaConnectionOptions {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
208
|
+
return resolveProfileOptions(config.providers?.[PROVIDER])
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolve one raw profile into validated connection facts. A missing profile
|
|
213
|
+
* resolves the defaults, which is the dormant route's serving posture.
|
|
214
|
+
* @param profile - raw profile fields, or `undefined` when none is stored.
|
|
215
|
+
* @returns validated connection facts plus the credential reference.
|
|
216
|
+
*/
|
|
217
|
+
export function resolveProfileOptions(profile: OllamaProviderProfile | undefined): OllamaConnectionOptions {
|
|
218
|
+
if (profile?.thinking === 'disabled'
|
|
219
|
+
&& profile.reasoningEffort !== undefined
|
|
220
|
+
&& profile.reasoningEffort !== 'off') {
|
|
146
221
|
throw new Error('llm-ollama-cloud: only reasoningEffort "off" can be configured when thinking is disabled')
|
|
147
222
|
}
|
|
148
|
-
if (
|
|
149
|
-
&& (!Number.isInteger(
|
|
223
|
+
if (profile?.defaultContextWindow !== undefined
|
|
224
|
+
&& (!Number.isInteger(profile.defaultContextWindow) || profile.defaultContextWindow <= 0)) {
|
|
150
225
|
throw new Error('llm-ollama-cloud: defaultContextWindow must be a positive integer')
|
|
151
226
|
}
|
|
152
|
-
if (
|
|
153
|
-
&& (!Number.isSafeInteger(
|
|
227
|
+
if (profile?.maxTokens !== undefined
|
|
228
|
+
&& (!Number.isSafeInteger(profile.maxTokens) || profile.maxTokens <= 0)) {
|
|
154
229
|
throw new Error('llm-ollama-cloud: maxTokens must be a positive safe integer')
|
|
155
230
|
}
|
|
156
|
-
const streamIdleTimeoutMs =
|
|
231
|
+
const streamIdleTimeoutMs = profile?.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
|
|
157
232
|
if (!Number.isFinite(streamIdleTimeoutMs)
|
|
158
233
|
|| streamIdleTimeoutMs <= 0
|
|
159
234
|
|| streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
|
|
@@ -162,17 +237,17 @@ export function resolveAdapterOptions(config: Config): OllamaConnectionOptions {
|
|
|
162
237
|
)
|
|
163
238
|
}
|
|
164
239
|
return {
|
|
165
|
-
apiKeyEnv:
|
|
166
|
-
baseURL:
|
|
240
|
+
apiKeyEnv: credentialRef(profile?.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
|
|
241
|
+
baseURL: profile?.baseURL ?? PUBLIC_BASE_URL,
|
|
167
242
|
defaults: {
|
|
168
|
-
thinking:
|
|
169
|
-
reasoningEffort:
|
|
243
|
+
thinking: profile?.thinking,
|
|
244
|
+
reasoningEffort: profile?.reasoningEffort,
|
|
170
245
|
},
|
|
171
|
-
maxTokens:
|
|
172
|
-
defaultContextWindow:
|
|
173
|
-
models: resolveModels(
|
|
246
|
+
maxTokens: profile?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
247
|
+
defaultContextWindow: profile?.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
248
|
+
models: resolveModels(profile?.models),
|
|
174
249
|
streamIdleTimeoutMs,
|
|
175
|
-
retryPolicy: resolveRetryPolicy(
|
|
250
|
+
retryPolicy: resolveRetryPolicy(profile?.retryPolicy, 'llm-ollama-cloud: retryPolicy'),
|
|
176
251
|
}
|
|
177
252
|
}
|
|
178
253
|
|
|
@@ -182,33 +257,96 @@ interface CredentialsLike {
|
|
|
182
257
|
}
|
|
183
258
|
|
|
184
259
|
export function apply(ctx: Context, config: Config = {}): void {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
260
|
+
let current: () => Config = () => config
|
|
261
|
+
let lastRaw: Config | undefined
|
|
262
|
+
let lastGood: OllamaConnectionOptions | undefined
|
|
263
|
+
const options = (): OllamaConnectionOptions => {
|
|
264
|
+
const raw = current()
|
|
265
|
+
if (raw === lastRaw && lastGood !== undefined) return lastGood
|
|
266
|
+
try {
|
|
267
|
+
const next = resolveAdapterOptions(raw)
|
|
268
|
+
lastRaw = raw
|
|
269
|
+
lastGood = next
|
|
270
|
+
return next
|
|
271
|
+
} catch (error) {
|
|
272
|
+
// Static composition resolves before anything registers, so this branch
|
|
273
|
+
// only sees a live settings snapshot failing a beyond-schema bound:
|
|
274
|
+
// keep serving the last good facts and say so once per bad snapshot.
|
|
275
|
+
if (lastGood === undefined) throw error
|
|
276
|
+
lastRaw = raw
|
|
277
|
+
ctx.logger.error('llm-ollama-cloud: keeping the last good configuration after an invalid settings section')
|
|
278
|
+
ctx.logger.error(error)
|
|
279
|
+
return lastGood
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
options()
|
|
283
|
+
|
|
284
|
+
const resolveApiKey = async (connection: OllamaConnectionOptions): Promise<string> => {
|
|
285
|
+
// Every credential fact comes from the caller's snapshot, so a rejected
|
|
286
|
+
// settings generation cannot leak its key onto the previous endpoint.
|
|
287
|
+
const ref = connection.apiKeyEnv
|
|
288
|
+
const credentials = ctx.get('credentials') as CredentialsLike | undefined
|
|
289
|
+
if (credentials !== undefined) {
|
|
290
|
+
const hit = await credentials.resolve(ref)
|
|
291
|
+
if (hit !== undefined && hit.value.length > 0) {
|
|
292
|
+
return assertUsableApiKey(hit.value, 'llm-ollama-cloud', ref)
|
|
206
293
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
)
|
|
294
|
+
}
|
|
295
|
+
const ambient = process.env[ref]
|
|
296
|
+
if (ambient !== undefined && ambient.length > 0) {
|
|
297
|
+
return assertUsableApiKey(ambient, 'llm-ollama-cloud', ref)
|
|
298
|
+
}
|
|
299
|
+
throw new LlmError(
|
|
300
|
+
`llm-ollama-cloud: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials`
|
|
301
|
+
+ ` service (the web Models page writes it), or export ${ref} in the launching environment`,
|
|
302
|
+
'MISSING_CREDENTIAL',
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The stored credential, for a probe whose draft carries none. Missing is
|
|
307
|
+
* an answer here (`undefined`, probe unauthenticated), not a failure — the
|
|
308
|
+
* request path owns the loud MISSING_CREDENTIAL refusal.
|
|
309
|
+
*/
|
|
310
|
+
const storedApiKey = async (): Promise<string | undefined> => {
|
|
311
|
+
const ref = options().apiKeyEnv
|
|
312
|
+
const credentials = ctx.get('credentials') as CredentialsLike | undefined
|
|
313
|
+
const hit = credentials !== undefined ? (await credentials.resolve(ref))?.value : undefined
|
|
314
|
+
const value = hit !== undefined && hit.length > 0 ? hit : process.env[ref]
|
|
315
|
+
return value !== undefined && value.length > 0 ? value : undefined
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const adapter = new OllamaAdapter({ options, resolveApiKey })
|
|
319
|
+
// Declared even while dormant, so configuration surfaces list the route in
|
|
320
|
+
// the add-provider select before any profile exists.
|
|
321
|
+
ctx.llm.registerConfigurableProviders([
|
|
322
|
+
{ provider: PROVIDER, displayName: 'Ollama Cloud', settingsNs: NS, settingsPath: ['providers', PROVIDER] },
|
|
323
|
+
])
|
|
324
|
+
// Route effects bind to this apply fiber via the stable `ctx` reference,
|
|
325
|
+
// even when a swap runs inside the scoped settings callback below.
|
|
326
|
+
const registration = ctx.llm.registerAdapter([PROVIDER], adapter)
|
|
327
|
+
let registeredPolicy = options().retryPolicy
|
|
328
|
+
const ensureRegistrationFacts = (): void => {
|
|
329
|
+
const policy = options().retryPolicy
|
|
330
|
+
if (deepEqualJson(policy, registeredPolicy)) return
|
|
331
|
+
// The registry captures the retry policy at registration, so it is the one
|
|
332
|
+
// fact per-request resolution cannot refresh. `replace` re-reads it in one
|
|
333
|
+
// synchronous registry section: disposing and re-registering instead would
|
|
334
|
+
// publish an empty route set between the two, and an observer that reacted
|
|
335
|
+
// to it would see this provider disappear and come back.
|
|
336
|
+
registration.replace([PROVIDER])
|
|
337
|
+
registeredPolicy = policy
|
|
338
|
+
}
|
|
339
|
+
// The Models page's fetch action: a draft naming this route answers from the
|
|
340
|
+
// resolved catalog; anything else is interrogated at the endpoint it shows.
|
|
341
|
+
ctx.llm.registerModelDiscovery(NS, (request, signal) => discoverModels(
|
|
342
|
+
{ ...request, ...signal === undefined ? {} : { signal } },
|
|
343
|
+
options().models,
|
|
344
|
+
storedApiKey,
|
|
345
|
+
))
|
|
346
|
+
installSettingsSection(ctx, NS, Config, config, {
|
|
347
|
+
setSource: (source) => {
|
|
348
|
+
current = source
|
|
211
349
|
},
|
|
350
|
+
onChange: ensureRegistrationFacts,
|
|
212
351
|
})
|
|
213
|
-
ctx.llm.registerAdapter([PROVIDER], adapter)
|
|
214
352
|
}
|