@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.
@@ -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 route
3
- * on `ctx.llm`, with connection facts resolved once per operation from the
4
- * plugin's `cordis.yml` mount config and the bearer token resolved per request
5
- * through the credential seam (`ctx.credentials`), falling back to the
6
- * process environment.
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/cordis` (plugin framework), and `eventsource-parser` (SSE
11
- * framing). There is no settings-section wiring and no schema library:
12
- * configuration is static from the mount, and validation is hand-rolled. The
13
- * route is `ollama-cloud-direct` (not `ollama-cloud`) so it can coexist with a
14
- * pi-ai-configured `ollama-cloud` route.
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 { credentialRef } from '@deepseek-ai/dsh-credentials'
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
- * Plugin config from the `cordis.yml` mount entry. Every field is optional: a
62
- * missing API key fails per request with `MISSING_CREDENTIAL`, omitted
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 Config {
67
- /** Environment-variable name resolved per request; defaults to `OLLAMA_CLOUD_API_KEY`. */
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 mount config to validated connection
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
- * @param config - raw plugin config.
140
- * @returns validated connection facts plus the credential reference.
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
- if (config.thinking === 'disabled'
144
- && config.reasoningEffort !== undefined
145
- && config.reasoningEffort !== 'off') {
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 (config.defaultContextWindow !== undefined
149
- && (!Number.isInteger(config.defaultContextWindow) || config.defaultContextWindow <= 0)) {
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 (config.maxTokens !== undefined
153
- && (!Number.isSafeInteger(config.maxTokens) || config.maxTokens <= 0)) {
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 = config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
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: config.apiKeyEnv ?? DEFAULT_API_KEY_ENV,
166
- baseURL: config.baseURL ?? PUBLIC_BASE_URL,
240
+ apiKeyEnv: credentialRef(profile?.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
241
+ baseURL: profile?.baseURL ?? PUBLIC_BASE_URL,
167
242
  defaults: {
168
- thinking: config.thinking,
169
- reasoningEffort: config.reasoningEffort,
243
+ thinking: profile?.thinking,
244
+ reasoningEffort: profile?.reasoningEffort,
170
245
  },
171
- maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
172
- defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
173
- models: resolveModels(config.models),
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(config.retryPolicy, 'llm-ollama-cloud: retryPolicy'),
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
- // Static composition: connection facts resolve once at load. A config
186
- // change in the mount requires a restart — there is no settings section.
187
- const options = (): OllamaConnectionOptions => resolveAdapterOptions(config)
188
- const connection = options()
189
- // Fail loud at load on a malformed credential reference name.
190
- credentialRef(connection.apiKeyEnv)
191
-
192
- const credentials = ctx.get('credentials') as CredentialsLike | undefined
193
- const adapter = new OllamaAdapter({
194
- options,
195
- resolveApiKey: async (connection) => {
196
- const ref = connection.apiKeyEnv
197
- if (credentials !== undefined) {
198
- const hit = await credentials.resolve(ref)
199
- if (hit !== undefined && hit.value.length > 0) {
200
- return assertUsableApiKey(hit.value, 'llm-ollama-cloud', ref)
201
- }
202
- }
203
- const value = process.env[ref]
204
- if (value !== undefined && value.length > 0) {
205
- return assertUsableApiKey(value, 'llm-ollama-cloud', ref)
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
- throw new LlmError(
208
- `llm-ollama-cloud: no API key for provider route "${PROVIDER}"; store ${ref} in the credentials file or export it in the launching environment`,
209
- 'MISSING_CREDENTIAL',
210
- )
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
  }