@frockbot/plugin-provider-ollama-cloud 0.0.0 → 0.1.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 +115 -1
- package/frockbot.json +79 -0
- package/package.json +39 -6
- package/src/client.test.ts +140 -0
- package/src/client.ts +357 -0
- package/src/endpoint.test.ts +412 -0
- package/src/index.ts +2 -0
- package/src/manifest.ts +3 -0
- package/src/runtime.test.ts +518 -0
- package/src/runtime.ts +193 -0
- package/src/user.test.ts +2230 -0
- package/src/user.ts +2444 -0
- package/src/web-search.test.ts +383 -0
- package/src/web-search.ts +299 -0
- package/tsconfig.json +15 -0
package/src/user.ts
ADDED
|
@@ -0,0 +1,2444 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_CONNECTION_SETTINGS_V1,
|
|
3
|
+
decodeConnectionCommandReceiptV1,
|
|
4
|
+
decodeConnectionCommandV1,
|
|
5
|
+
decodeConnectionModelCatalogV1,
|
|
6
|
+
type ConnectionCommandReceiptV1,
|
|
7
|
+
type ConnectionCommandV1,
|
|
8
|
+
type ConnectionModelCatalogV1,
|
|
9
|
+
type ConnectionModelV1,
|
|
10
|
+
type ConnectionSettingsV1,
|
|
11
|
+
type CredentialLeaseV1,
|
|
12
|
+
} from "@frockbot/connection-core";
|
|
13
|
+
import type { ConnectionView } from "@frockbot/configuration-core";
|
|
14
|
+
import type {
|
|
15
|
+
CredentialStorage,
|
|
16
|
+
CredentialTransaction,
|
|
17
|
+
CredentialUserBackendContribution,
|
|
18
|
+
} from "@frockbot/plugin-credentials/user";
|
|
19
|
+
import type {
|
|
20
|
+
UserSettingsBackendContribution,
|
|
21
|
+
UserSettingsStorage,
|
|
22
|
+
UserSettingsTransaction,
|
|
23
|
+
} from "@frockbot/plugin-settings/user";
|
|
24
|
+
import type { Plugin } from "cordis";
|
|
25
|
+
import {
|
|
26
|
+
decodeOllamaApiBaseUrl,
|
|
27
|
+
OllamaCloudClient,
|
|
28
|
+
type OllamaCloudClientConfig,
|
|
29
|
+
} from "./client.js";
|
|
30
|
+
import { OLLAMA_CLOUD_PROVIDER } from "./runtime.js";
|
|
31
|
+
|
|
32
|
+
const PACKAGE_ID = "provider-ollama-cloud";
|
|
33
|
+
const CONNECTION_TYPE_ID = "ollama-cloud-account";
|
|
34
|
+
const COMMAND_PREFIX = "ollama-connection-command:";
|
|
35
|
+
const PENDING_KEY = "ollama-pending-connection-commands";
|
|
36
|
+
const RECEIPT_INDEX_KEY = "ollama-connection-receipt-index";
|
|
37
|
+
const COMMAND_TOMBSTONES_KEY = "ollama-connection-command-tombstones";
|
|
38
|
+
const MAX_MANUAL_RECEIPTS = 256;
|
|
39
|
+
const MAX_COMMAND_TOMBSTONES = 128;
|
|
40
|
+
const MAX_MANUAL_COMMANDS = MAX_MANUAL_RECEIPTS + MAX_COMMAND_TOMBSTONES;
|
|
41
|
+
const MAX_PENDING_COMMANDS = 64;
|
|
42
|
+
const MAX_PENDING_RECOVERIES_PER_ALARM = 1;
|
|
43
|
+
const MAX_CATALOG_REFRESHES_PER_ALARM = 1;
|
|
44
|
+
const ACCOUNT_KEY = "ollama-connection-account";
|
|
45
|
+
const AUTOMATIC_REFRESH_RECEIPT_PREFIX = "ollama-refresh-receipt:";
|
|
46
|
+
|
|
47
|
+
const AUTOMATIC_DEFAULT_MODEL_PREFERENCES = [
|
|
48
|
+
"gpt-oss:20b",
|
|
49
|
+
"glm-5.3-flash:cloud",
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
/** Choose the safest useful default from the catalog a new Connection proved. */
|
|
53
|
+
export function selectAutomaticOllamaModelV1(
|
|
54
|
+
catalog: ConnectionModelCatalogV1,
|
|
55
|
+
): ConnectionModelV1 | undefined {
|
|
56
|
+
for (const providerModelId of AUTOMATIC_DEFAULT_MODEL_PREFERENCES) {
|
|
57
|
+
const preferred = catalog.models.find(
|
|
58
|
+
(model) => model.providerModelId === providerModelId,
|
|
59
|
+
);
|
|
60
|
+
if (preferred) return preferred;
|
|
61
|
+
}
|
|
62
|
+
return catalog.models[0];
|
|
63
|
+
}
|
|
64
|
+
const MUTATION_SEQUENCE_PREFIX = "ollama-mutation-sequence:";
|
|
65
|
+
const MODEL_RESOLUTION_PREFIX = "ollama-model-resolution:";
|
|
66
|
+
const REFRESH_INTERVAL_MS = 60 * 60 * 1_000;
|
|
67
|
+
const RECOVERY_DELAY_MS = 60_000;
|
|
68
|
+
const MODEL_LEASE_MS = 30 * 60 * 1_000;
|
|
69
|
+
const MAX_CONNECTION_MODELS = 100;
|
|
70
|
+
const MAX_DISCOVERED_MODELS = 90;
|
|
71
|
+
|
|
72
|
+
interface StoredCommand {
|
|
73
|
+
schemaVersion: 1;
|
|
74
|
+
commandId: string;
|
|
75
|
+
fingerprint: string;
|
|
76
|
+
accountId: string;
|
|
77
|
+
connectionId: string;
|
|
78
|
+
credentialGeneration?: string;
|
|
79
|
+
expectedGeneration?: string;
|
|
80
|
+
/**
|
|
81
|
+
* `connection/create` is not among them: an Ollama Cloud account is always
|
|
82
|
+
* a keyed Connection, so the keyless create command is refused on arrival
|
|
83
|
+
* and never reaches a durable record.
|
|
84
|
+
*/
|
|
85
|
+
operation: Exclude<ConnectionCommandV1["type"], "connection/create">;
|
|
86
|
+
label?: string;
|
|
87
|
+
settings?: Record<string, string>;
|
|
88
|
+
enabled?: boolean;
|
|
89
|
+
revokeUpstream?: boolean;
|
|
90
|
+
receipt?: ConnectionCommandReceiptV1;
|
|
91
|
+
completedAt?: number;
|
|
92
|
+
automaticRefresh?: boolean;
|
|
93
|
+
mutationSequence?: number;
|
|
94
|
+
settlementEffectId?: string;
|
|
95
|
+
settlementStatus?: "applied" | "failed";
|
|
96
|
+
validationCatalog?: ConnectionModelCatalogV1;
|
|
97
|
+
validationFailure?: string;
|
|
98
|
+
validationStatus?: "applied" | "failed";
|
|
99
|
+
providerRetryPolicy?: "safe-metadata-read";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface StoredModelResolution {
|
|
103
|
+
schemaVersion: 1;
|
|
104
|
+
effectId: string;
|
|
105
|
+
accountId: string;
|
|
106
|
+
connectionId: string;
|
|
107
|
+
connectionGeneration: string;
|
|
108
|
+
providerModelId: string;
|
|
109
|
+
retryPolicy: "safe-metadata-read";
|
|
110
|
+
status: "pending" | "applied" | "failed";
|
|
111
|
+
model?: ConnectionModelCatalogV1["models"][number];
|
|
112
|
+
failure?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type StoredCommandTombstone = StoredCommand & {
|
|
116
|
+
receipt: ConnectionCommandReceiptV1;
|
|
117
|
+
completedAt: number;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
type OllamaCredentialContribution = Omit<
|
|
121
|
+
CredentialUserBackendContribution,
|
|
122
|
+
"discardPending" | "lease" | "replayLease" | "settle"
|
|
123
|
+
> & {
|
|
124
|
+
discardPending(
|
|
125
|
+
connectionId: string,
|
|
126
|
+
generation: string,
|
|
127
|
+
storage?: CredentialTransaction,
|
|
128
|
+
): Promise<void>;
|
|
129
|
+
replayLease(input: {
|
|
130
|
+
accountId: string;
|
|
131
|
+
connectionId: string;
|
|
132
|
+
packageId: string;
|
|
133
|
+
effectId: string;
|
|
134
|
+
}): Promise<CredentialLeaseV1 | undefined>;
|
|
135
|
+
lease(
|
|
136
|
+
input: {
|
|
137
|
+
accountId: string;
|
|
138
|
+
connectionId: string;
|
|
139
|
+
packageId: string;
|
|
140
|
+
effectId: string;
|
|
141
|
+
expiresAt: string;
|
|
142
|
+
expectedGeneration: string;
|
|
143
|
+
credentialState?: "active" | "pending";
|
|
144
|
+
},
|
|
145
|
+
storage?: CredentialTransaction,
|
|
146
|
+
): Promise<CredentialLeaseV1>;
|
|
147
|
+
settle(input: {
|
|
148
|
+
accountId: string;
|
|
149
|
+
connectionId: string;
|
|
150
|
+
packageId: string;
|
|
151
|
+
effectId: string;
|
|
152
|
+
}): Promise<void>;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Ollama Cloud names its models bare (`gpt-oss:20b`, `glm-5.1`), with no
|
|
156
|
+
// `:cloud` suffix. `gpt-oss:20b` is the smallest model that is routinely
|
|
157
|
+
// present, so a probe against it costs the least; otherwise the first
|
|
158
|
+
// discovered model has to do.
|
|
159
|
+
const PREFERRED_PROBE_MODEL_ID = "gpt-oss:20b";
|
|
160
|
+
|
|
161
|
+
function probeModelId(models: readonly ConnectionModelV1[]): string {
|
|
162
|
+
const preferred = models.find(
|
|
163
|
+
(model) => model.providerModelId === PREFERRED_PROBE_MODEL_ID,
|
|
164
|
+
);
|
|
165
|
+
const chosen = preferred ?? models[0];
|
|
166
|
+
if (!chosen) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
"Ollama Cloud exposed no model to validate the key against",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return chosen.providerModelId;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The only setting this Package's Connection Type declares (manifest v4). The
|
|
175
|
+
// Connection settings bag carries it under the same id, which the shared
|
|
176
|
+
// decoder requires to be lower-case kebab.
|
|
177
|
+
const API_BASE_URL_SETTING = "api-base-url";
|
|
178
|
+
|
|
179
|
+
/** The per-value bound the shared Connection settings decoder enforces. */
|
|
180
|
+
const MAX_CONNECTION_SETTING_VALUE = 2_048;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Decode the Connection settings a `connection/create-api-key` command carried.
|
|
184
|
+
*
|
|
185
|
+
* An unknown key or an unusable endpoint is a User-visible refusal, not a
|
|
186
|
+
* silently ignored field.
|
|
187
|
+
*/
|
|
188
|
+
function decodeOllamaConnectionSettings(
|
|
189
|
+
input: ConnectionSettingsV1 | undefined,
|
|
190
|
+
): Record<string, string> {
|
|
191
|
+
if (input === undefined) return {};
|
|
192
|
+
const entries = Object.entries(input);
|
|
193
|
+
if (entries.length > MAX_CONNECTION_SETTINGS_V1) {
|
|
194
|
+
throw new Error("Ollama Cloud Connection settings are too many");
|
|
195
|
+
}
|
|
196
|
+
const accepted: Record<string, string> = {};
|
|
197
|
+
for (const [key, value] of entries) {
|
|
198
|
+
if (key !== API_BASE_URL_SETTING) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Ollama Cloud Connection setting "${key}" is not supported`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
accepted[key] = decodeOllamaApiBaseUrl(value);
|
|
204
|
+
}
|
|
205
|
+
return accepted;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function decodeStoredConnectionSettings(
|
|
209
|
+
input: unknown,
|
|
210
|
+
): Record<string, string> {
|
|
211
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
212
|
+
throw new Error("Stored Ollama Connection settings are invalid");
|
|
213
|
+
}
|
|
214
|
+
const entries = Object.entries(input as Record<string, unknown>);
|
|
215
|
+
if (entries.length > MAX_CONNECTION_SETTINGS_V1) {
|
|
216
|
+
throw new Error("Stored Ollama Connection settings are invalid");
|
|
217
|
+
}
|
|
218
|
+
const raw: Record<string, string> = {};
|
|
219
|
+
for (const [key, value] of entries) {
|
|
220
|
+
raw[key] = storedText(
|
|
221
|
+
value,
|
|
222
|
+
`settings.${key}`,
|
|
223
|
+
MAX_CONNECTION_SETTING_VALUE,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
return decodeOllamaConnectionSettings(raw);
|
|
228
|
+
} catch {
|
|
229
|
+
throw new Error("Stored Ollama Connection settings are invalid");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Read a Connection's endpoint root off its durable projection. */
|
|
234
|
+
function connectionApiBaseUrl(
|
|
235
|
+
connection: ConnectionView | undefined,
|
|
236
|
+
): string | undefined {
|
|
237
|
+
const candidate = connection?.settings?.[API_BASE_URL_SETTING];
|
|
238
|
+
return candidate === undefined
|
|
239
|
+
? undefined
|
|
240
|
+
: decodeOllamaApiBaseUrl(candidate);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface OllamaUserBackendHost {
|
|
244
|
+
storage: UserSettingsStorage &
|
|
245
|
+
CredentialStorage & {
|
|
246
|
+
getAlarm?(): Promise<number | null>;
|
|
247
|
+
setAlarm(scheduledTime: number | Date): Promise<void>;
|
|
248
|
+
};
|
|
249
|
+
settings: UserSettingsBackendContribution;
|
|
250
|
+
credentials: OllamaCredentialContribution;
|
|
251
|
+
/** A client the host supplies for every Connection; wins when given. */
|
|
252
|
+
client?: OllamaCloudClient;
|
|
253
|
+
/** Build a client for one Connection's endpoint. */
|
|
254
|
+
createClient?(config: OllamaCloudClientConfig): OllamaCloudClient;
|
|
255
|
+
now?: () => number;
|
|
256
|
+
randomId?: () => string;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function commandKey(commandId: string): string {
|
|
260
|
+
return `${COMMAND_PREFIX}${commandId}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function modelResolutionKey(effectId: string): string {
|
|
264
|
+
return `${MODEL_RESOLUTION_PREFIX}${effectId}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function mutationSequenceKey(
|
|
268
|
+
connectionId: string,
|
|
269
|
+
operation: StoredCommand["operation"],
|
|
270
|
+
): string {
|
|
271
|
+
return `${MUTATION_SEQUENCE_PREFIX}${connectionId}:${operation}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function fingerprint(value: unknown): Promise<string> {
|
|
275
|
+
const digest = await crypto.subtle.digest(
|
|
276
|
+
"SHA-256",
|
|
277
|
+
new TextEncoder().encode(JSON.stringify(value)),
|
|
278
|
+
);
|
|
279
|
+
return Array.from(new Uint8Array(digest), (byte) =>
|
|
280
|
+
byte.toString(16).padStart(2, "0"),
|
|
281
|
+
).join("");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function receipt(
|
|
285
|
+
record: StoredCommand,
|
|
286
|
+
status: ConnectionCommandReceiptV1["status"],
|
|
287
|
+
): ConnectionCommandReceiptV1 {
|
|
288
|
+
return {
|
|
289
|
+
schemaVersion: 1,
|
|
290
|
+
commandId: record.commandId,
|
|
291
|
+
connectionId: record.connectionId,
|
|
292
|
+
status,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function storedRecord(
|
|
297
|
+
input: unknown,
|
|
298
|
+
label: string,
|
|
299
|
+
required: readonly string[],
|
|
300
|
+
optional: readonly string[] = [],
|
|
301
|
+
): Record<string, unknown> {
|
|
302
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
303
|
+
throw new Error(`${label} is invalid`);
|
|
304
|
+
}
|
|
305
|
+
const value = input as Record<string, unknown>;
|
|
306
|
+
const allowed = new Set([...required, ...optional]);
|
|
307
|
+
if (
|
|
308
|
+
!required.every((key) => Object.hasOwn(value, key)) ||
|
|
309
|
+
Object.keys(value).some((key) => !allowed.has(key))
|
|
310
|
+
) {
|
|
311
|
+
throw new Error(`${label} is invalid`);
|
|
312
|
+
}
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function storedText(value: unknown, label: string, maximum: number): string {
|
|
317
|
+
if (
|
|
318
|
+
typeof value !== "string" ||
|
|
319
|
+
value.length === 0 ||
|
|
320
|
+
value.length > maximum
|
|
321
|
+
) {
|
|
322
|
+
throw new Error(`${label} is invalid`);
|
|
323
|
+
}
|
|
324
|
+
return value;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function decodeStoredAccount(input: unknown): string {
|
|
328
|
+
return storedText(input, "Stored Ollama account", 256);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function decodeCommandIdList(input: unknown, label: string): string[] {
|
|
332
|
+
if (!Array.isArray(input)) {
|
|
333
|
+
throw new Error(`Stored Ollama ${label} is invalid`);
|
|
334
|
+
}
|
|
335
|
+
return [
|
|
336
|
+
...new Set(input.map((value) => storedText(value, "commandId", 128))),
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function decodePendingCommands(input: unknown): string[] {
|
|
341
|
+
const pending = decodeCommandIdList(input, "pending commands");
|
|
342
|
+
if (pending.length > MAX_PENDING_COMMANDS) {
|
|
343
|
+
throw new Error("Stored Ollama pending commands are invalid");
|
|
344
|
+
}
|
|
345
|
+
return pending;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function decodeReceiptIndex(input: unknown): string[] {
|
|
349
|
+
const index = decodeCommandIdList(input, "receipt index");
|
|
350
|
+
if (index.length > MAX_MANUAL_RECEIPTS) {
|
|
351
|
+
throw new Error("Stored Ollama receipt index is invalid");
|
|
352
|
+
}
|
|
353
|
+
return index;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function decodeCommandTombstones(input: unknown): StoredCommandTombstone[] {
|
|
357
|
+
if (!Array.isArray(input) || input.length > MAX_COMMAND_TOMBSTONES) {
|
|
358
|
+
throw new Error("Stored Ollama command tombstones are invalid");
|
|
359
|
+
}
|
|
360
|
+
return input.map((value) => {
|
|
361
|
+
const command = decodeStoredCommand(value);
|
|
362
|
+
if (!command.receipt || command.completedAt === undefined) {
|
|
363
|
+
throw new Error("Stored Ollama command tombstone is invalid");
|
|
364
|
+
}
|
|
365
|
+
return command as StoredCommandTombstone;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function decodeStoredCommand(input: unknown): StoredCommand {
|
|
370
|
+
const value = storedRecord(
|
|
371
|
+
input,
|
|
372
|
+
"Stored Ollama command",
|
|
373
|
+
[
|
|
374
|
+
"schemaVersion",
|
|
375
|
+
"commandId",
|
|
376
|
+
"fingerprint",
|
|
377
|
+
"accountId",
|
|
378
|
+
"connectionId",
|
|
379
|
+
"operation",
|
|
380
|
+
],
|
|
381
|
+
[
|
|
382
|
+
"credentialGeneration",
|
|
383
|
+
"expectedGeneration",
|
|
384
|
+
"label",
|
|
385
|
+
"settings",
|
|
386
|
+
"enabled",
|
|
387
|
+
"revokeUpstream",
|
|
388
|
+
"receipt",
|
|
389
|
+
"completedAt",
|
|
390
|
+
"automaticRefresh",
|
|
391
|
+
"mutationSequence",
|
|
392
|
+
"settlementEffectId",
|
|
393
|
+
"settlementStatus",
|
|
394
|
+
"validationCatalog",
|
|
395
|
+
"validationFailure",
|
|
396
|
+
"validationStatus",
|
|
397
|
+
"providerRetryPolicy",
|
|
398
|
+
],
|
|
399
|
+
);
|
|
400
|
+
const operations: StoredCommand["operation"][] = [
|
|
401
|
+
"connection/create-api-key",
|
|
402
|
+
"connection/rotate-api-key",
|
|
403
|
+
"connection/update-label",
|
|
404
|
+
"connection/refresh-models",
|
|
405
|
+
"connection/set-enabled",
|
|
406
|
+
"connection/disconnect",
|
|
407
|
+
];
|
|
408
|
+
if (
|
|
409
|
+
value.schemaVersion !== 1 ||
|
|
410
|
+
!operations.includes(value.operation as never) ||
|
|
411
|
+
(value.enabled !== undefined && typeof value.enabled !== "boolean") ||
|
|
412
|
+
(value.revokeUpstream !== undefined &&
|
|
413
|
+
typeof value.revokeUpstream !== "boolean") ||
|
|
414
|
+
(value.completedAt !== undefined &&
|
|
415
|
+
(typeof value.completedAt !== "number" ||
|
|
416
|
+
!Number.isSafeInteger(value.completedAt) ||
|
|
417
|
+
value.completedAt < 0)) ||
|
|
418
|
+
(value.automaticRefresh !== undefined &&
|
|
419
|
+
typeof value.automaticRefresh !== "boolean") ||
|
|
420
|
+
(value.mutationSequence !== undefined &&
|
|
421
|
+
(typeof value.mutationSequence !== "number" ||
|
|
422
|
+
!Number.isSafeInteger(value.mutationSequence) ||
|
|
423
|
+
value.mutationSequence <= 0)) ||
|
|
424
|
+
(value.settlementStatus !== undefined &&
|
|
425
|
+
value.settlementStatus !== "applied" &&
|
|
426
|
+
value.settlementStatus !== "failed") ||
|
|
427
|
+
Boolean(value.settlementEffectId) !== Boolean(value.settlementStatus) ||
|
|
428
|
+
(value.validationStatus !== undefined &&
|
|
429
|
+
value.validationStatus !== "applied" &&
|
|
430
|
+
value.validationStatus !== "failed") ||
|
|
431
|
+
(value.validationStatus === "applied") !==
|
|
432
|
+
(value.validationCatalog !== undefined) ||
|
|
433
|
+
(value.validationStatus === "failed") !==
|
|
434
|
+
(value.validationFailure !== undefined) ||
|
|
435
|
+
(value.providerRetryPolicy !== undefined &&
|
|
436
|
+
value.providerRetryPolicy !== "safe-metadata-read")
|
|
437
|
+
) {
|
|
438
|
+
throw new Error("Stored Ollama command is invalid");
|
|
439
|
+
}
|
|
440
|
+
const commandId = storedText(value.commandId, "commandId", 128);
|
|
441
|
+
const connectionId = storedText(value.connectionId, "connectionId", 128);
|
|
442
|
+
const decodedReceipt =
|
|
443
|
+
value.receipt === undefined
|
|
444
|
+
? undefined
|
|
445
|
+
: decodeConnectionCommandReceiptV1(value.receipt);
|
|
446
|
+
const validationCatalog =
|
|
447
|
+
value.validationCatalog === undefined
|
|
448
|
+
? undefined
|
|
449
|
+
: decodeConnectionModelCatalogV1(value.validationCatalog);
|
|
450
|
+
if (
|
|
451
|
+
decodedReceipt &&
|
|
452
|
+
(decodedReceipt.commandId !== commandId ||
|
|
453
|
+
decodedReceipt.connectionId !== connectionId)
|
|
454
|
+
) {
|
|
455
|
+
throw new Error("Stored Ollama command receipt is invalid");
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
schemaVersion: 1,
|
|
459
|
+
commandId,
|
|
460
|
+
fingerprint: storedText(value.fingerprint, "fingerprint", 64),
|
|
461
|
+
accountId: storedText(value.accountId, "accountId", 256),
|
|
462
|
+
connectionId,
|
|
463
|
+
operation: value.operation as StoredCommand["operation"],
|
|
464
|
+
...(value.credentialGeneration === undefined
|
|
465
|
+
? {}
|
|
466
|
+
: {
|
|
467
|
+
credentialGeneration: storedText(
|
|
468
|
+
value.credentialGeneration,
|
|
469
|
+
"credentialGeneration",
|
|
470
|
+
128,
|
|
471
|
+
),
|
|
472
|
+
}),
|
|
473
|
+
...(value.expectedGeneration === undefined
|
|
474
|
+
? {}
|
|
475
|
+
: {
|
|
476
|
+
expectedGeneration: storedText(
|
|
477
|
+
value.expectedGeneration,
|
|
478
|
+
"expectedGeneration",
|
|
479
|
+
128,
|
|
480
|
+
),
|
|
481
|
+
}),
|
|
482
|
+
...(value.label === undefined
|
|
483
|
+
? {}
|
|
484
|
+
: { label: storedText(value.label, "label", 120) }),
|
|
485
|
+
...(value.settings === undefined
|
|
486
|
+
? {}
|
|
487
|
+
: { settings: decodeStoredConnectionSettings(value.settings) }),
|
|
488
|
+
...(value.enabled === undefined
|
|
489
|
+
? {}
|
|
490
|
+
: { enabled: value.enabled as boolean }),
|
|
491
|
+
...(value.revokeUpstream === undefined
|
|
492
|
+
? {}
|
|
493
|
+
: { revokeUpstream: value.revokeUpstream as boolean }),
|
|
494
|
+
...(decodedReceipt === undefined ? {} : { receipt: decodedReceipt }),
|
|
495
|
+
...(value.completedAt === undefined
|
|
496
|
+
? {}
|
|
497
|
+
: { completedAt: value.completedAt as number }),
|
|
498
|
+
...(value.automaticRefresh === undefined
|
|
499
|
+
? {}
|
|
500
|
+
: { automaticRefresh: value.automaticRefresh as boolean }),
|
|
501
|
+
...(value.mutationSequence === undefined
|
|
502
|
+
? {}
|
|
503
|
+
: { mutationSequence: value.mutationSequence as number }),
|
|
504
|
+
...(value.settlementEffectId === undefined
|
|
505
|
+
? {}
|
|
506
|
+
: {
|
|
507
|
+
settlementEffectId: storedText(
|
|
508
|
+
value.settlementEffectId,
|
|
509
|
+
"settlementEffectId",
|
|
510
|
+
256,
|
|
511
|
+
),
|
|
512
|
+
settlementStatus: value.settlementStatus as "applied" | "failed",
|
|
513
|
+
}),
|
|
514
|
+
...(value.validationStatus === undefined
|
|
515
|
+
? {}
|
|
516
|
+
: {
|
|
517
|
+
validationStatus: value.validationStatus as "applied" | "failed",
|
|
518
|
+
...(validationCatalog ? { validationCatalog } : {}),
|
|
519
|
+
...(value.validationFailure === undefined
|
|
520
|
+
? {}
|
|
521
|
+
: {
|
|
522
|
+
validationFailure: storedText(
|
|
523
|
+
value.validationFailure,
|
|
524
|
+
"validationFailure",
|
|
525
|
+
500,
|
|
526
|
+
),
|
|
527
|
+
}),
|
|
528
|
+
}),
|
|
529
|
+
...(value.providerRetryPolicy === undefined
|
|
530
|
+
? {}
|
|
531
|
+
: { providerRetryPolicy: "safe-metadata-read" as const }),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function decodeStoredModelResolution(input: unknown): StoredModelResolution {
|
|
536
|
+
const value = storedRecord(
|
|
537
|
+
input,
|
|
538
|
+
"Stored Ollama model resolution",
|
|
539
|
+
[
|
|
540
|
+
"schemaVersion",
|
|
541
|
+
"effectId",
|
|
542
|
+
"accountId",
|
|
543
|
+
"connectionId",
|
|
544
|
+
"connectionGeneration",
|
|
545
|
+
"providerModelId",
|
|
546
|
+
"retryPolicy",
|
|
547
|
+
"status",
|
|
548
|
+
],
|
|
549
|
+
["model", "failure"],
|
|
550
|
+
);
|
|
551
|
+
const status = value.status;
|
|
552
|
+
if (
|
|
553
|
+
value.schemaVersion !== 1 ||
|
|
554
|
+
value.retryPolicy !== "safe-metadata-read" ||
|
|
555
|
+
(status !== "pending" && status !== "applied" && status !== "failed") ||
|
|
556
|
+
(status === "applied") !== (value.model !== undefined) ||
|
|
557
|
+
(status === "failed") !== (value.failure !== undefined)
|
|
558
|
+
) {
|
|
559
|
+
throw new Error("Stored Ollama model resolution is invalid");
|
|
560
|
+
}
|
|
561
|
+
const model =
|
|
562
|
+
value.model === undefined
|
|
563
|
+
? undefined
|
|
564
|
+
: decodeConnectionModelCatalogV1({
|
|
565
|
+
schemaVersion: 1,
|
|
566
|
+
generation: "resolution-journal",
|
|
567
|
+
state: "fresh",
|
|
568
|
+
models: [value.model],
|
|
569
|
+
}).models[0];
|
|
570
|
+
return {
|
|
571
|
+
schemaVersion: 1,
|
|
572
|
+
effectId: storedText(value.effectId, "effectId", 256),
|
|
573
|
+
accountId: storedText(value.accountId, "accountId", 256),
|
|
574
|
+
connectionId: storedText(value.connectionId, "connectionId", 128),
|
|
575
|
+
connectionGeneration: storedText(
|
|
576
|
+
value.connectionGeneration,
|
|
577
|
+
"connectionGeneration",
|
|
578
|
+
128,
|
|
579
|
+
),
|
|
580
|
+
providerModelId: storedText(value.providerModelId, "providerModelId", 256),
|
|
581
|
+
retryPolicy: "safe-metadata-read",
|
|
582
|
+
status,
|
|
583
|
+
...(model ? { model } : {}),
|
|
584
|
+
...(value.failure === undefined
|
|
585
|
+
? {}
|
|
586
|
+
: { failure: storedText(value.failure, "failure", 500) }),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function decodeMutationSequence(input: unknown): {
|
|
591
|
+
next: number;
|
|
592
|
+
applied: number;
|
|
593
|
+
} {
|
|
594
|
+
const value = storedRecord(input, "Stored Ollama mutation sequence", [
|
|
595
|
+
"schemaVersion",
|
|
596
|
+
"next",
|
|
597
|
+
"applied",
|
|
598
|
+
]);
|
|
599
|
+
if (
|
|
600
|
+
value.schemaVersion !== 1 ||
|
|
601
|
+
typeof value.next !== "number" ||
|
|
602
|
+
!Number.isSafeInteger(value.next) ||
|
|
603
|
+
value.next < 0 ||
|
|
604
|
+
typeof value.applied !== "number" ||
|
|
605
|
+
!Number.isSafeInteger(value.applied) ||
|
|
606
|
+
value.applied < 0 ||
|
|
607
|
+
value.applied > value.next
|
|
608
|
+
) {
|
|
609
|
+
throw new Error("Stored Ollama mutation sequence is invalid");
|
|
610
|
+
}
|
|
611
|
+
return { next: value.next, applied: value.applied };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function compactCompletedCommand(
|
|
615
|
+
record: StoredCommand,
|
|
616
|
+
): StoredCommandTombstone {
|
|
617
|
+
if (!record.receipt || record.completedAt === undefined) {
|
|
618
|
+
throw new Error("Completed Ollama command receipt is unavailable");
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
schemaVersion: 1,
|
|
622
|
+
commandId: record.commandId,
|
|
623
|
+
fingerprint: record.fingerprint,
|
|
624
|
+
accountId: record.accountId,
|
|
625
|
+
connectionId: record.connectionId,
|
|
626
|
+
operation: record.operation,
|
|
627
|
+
receipt: record.receipt,
|
|
628
|
+
completedAt: record.completedAt,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function isSequencedMutation(operation: StoredCommand["operation"]): boolean {
|
|
633
|
+
return (
|
|
634
|
+
operation === "connection/update-label" ||
|
|
635
|
+
operation === "connection/set-enabled" ||
|
|
636
|
+
operation === "connection/refresh-models"
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function retainResolvedModel(
|
|
641
|
+
catalog: ConnectionModelCatalogV1,
|
|
642
|
+
resolved: ConnectionModelCatalogV1["models"][number],
|
|
643
|
+
): ConnectionModelCatalogV1["models"] {
|
|
644
|
+
const discovered = catalog.models
|
|
645
|
+
.filter((model) => model.source === "discovered")
|
|
646
|
+
.slice(0, MAX_DISCOVERED_MODELS);
|
|
647
|
+
const available = MAX_CONNECTION_MODELS - discovered.length;
|
|
648
|
+
if (available === 0) return discovered;
|
|
649
|
+
const exact = [
|
|
650
|
+
...catalog.models.filter(
|
|
651
|
+
(model) =>
|
|
652
|
+
model.source === "exact-resolution" &&
|
|
653
|
+
model.providerModelId !== resolved.providerModelId,
|
|
654
|
+
),
|
|
655
|
+
resolved,
|
|
656
|
+
];
|
|
657
|
+
return [...discovered, ...exact.slice(-available)];
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export class OllamaCloudUserBackendContribution {
|
|
661
|
+
readonly packageId = PACKAGE_ID;
|
|
662
|
+
private readonly now: () => number;
|
|
663
|
+
private readonly randomId: () => string;
|
|
664
|
+
private readonly resumptions = new Map<
|
|
665
|
+
string,
|
|
666
|
+
{ fingerprint: string; promise: Promise<ConnectionCommandReceiptV1> }
|
|
667
|
+
>();
|
|
668
|
+
|
|
669
|
+
constructor(private readonly host: OllamaUserBackendHost) {
|
|
670
|
+
this.now = host.now ?? Date.now;
|
|
671
|
+
this.randomId = host.randomId ?? crypto.randomUUID.bind(crypto);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async executeConnection(
|
|
675
|
+
accountId: string,
|
|
676
|
+
input: unknown,
|
|
677
|
+
): Promise<ConnectionCommandReceiptV1> {
|
|
678
|
+
return this.executeCommand(
|
|
679
|
+
accountId,
|
|
680
|
+
decodeConnectionCommandV1(input),
|
|
681
|
+
false,
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async lookupConnectionCommand(
|
|
686
|
+
accountId: string,
|
|
687
|
+
commandId: string,
|
|
688
|
+
): Promise<ConnectionCommandReceiptV1 | undefined> {
|
|
689
|
+
await this.host.settings.read(accountId);
|
|
690
|
+
const value = await this.host.storage.get<unknown>(commandKey(commandId));
|
|
691
|
+
const tombstonesValue = await this.host.storage.get<unknown>(
|
|
692
|
+
COMMAND_TOMBSTONES_KEY,
|
|
693
|
+
);
|
|
694
|
+
const record =
|
|
695
|
+
value === undefined
|
|
696
|
+
? (tombstonesValue === undefined
|
|
697
|
+
? []
|
|
698
|
+
: decodeCommandTombstones(tombstonesValue)
|
|
699
|
+
).find((candidate) => candidate.commandId === commandId)
|
|
700
|
+
: decodeStoredCommand(value);
|
|
701
|
+
if (!record) return undefined;
|
|
702
|
+
if (record.accountId !== accountId) {
|
|
703
|
+
throw new Error("Connection command authority does not match");
|
|
704
|
+
}
|
|
705
|
+
return record.receipt;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private async executeCommand(
|
|
709
|
+
accountId: string,
|
|
710
|
+
command: ConnectionCommandV1,
|
|
711
|
+
automaticRefresh: boolean,
|
|
712
|
+
): Promise<ConnectionCommandReceiptV1> {
|
|
713
|
+
const storedAccountValue =
|
|
714
|
+
await this.host.storage.get<unknown>(ACCOUNT_KEY);
|
|
715
|
+
const storedAccount =
|
|
716
|
+
storedAccountValue === undefined
|
|
717
|
+
? undefined
|
|
718
|
+
: decodeStoredAccount(storedAccountValue);
|
|
719
|
+
if (storedAccount && storedAccount !== accountId) {
|
|
720
|
+
throw new Error("Ollama Connection authority does not match");
|
|
721
|
+
}
|
|
722
|
+
if (!storedAccount) await this.host.storage.put(ACCOUNT_KEY, accountId);
|
|
723
|
+
const commandFingerprint = await fingerprint(command);
|
|
724
|
+
const existingValue = await this.host.storage.get<unknown>(
|
|
725
|
+
commandKey(command.commandId),
|
|
726
|
+
);
|
|
727
|
+
const existing =
|
|
728
|
+
existingValue === undefined
|
|
729
|
+
? undefined
|
|
730
|
+
: decodeStoredCommand(existingValue);
|
|
731
|
+
if (existing) {
|
|
732
|
+
if (
|
|
733
|
+
existing.accountId !== accountId ||
|
|
734
|
+
existing.fingerprint !== commandFingerprint
|
|
735
|
+
) {
|
|
736
|
+
throw new Error("Connection command idempotency key was reused");
|
|
737
|
+
}
|
|
738
|
+
if (existing.receipt) return existing.receipt;
|
|
739
|
+
return this.resumeOnce(
|
|
740
|
+
existing,
|
|
741
|
+
command.type === "connection/create-api-key" ||
|
|
742
|
+
command.type === "connection/rotate-api-key"
|
|
743
|
+
? command.apiKey
|
|
744
|
+
: undefined,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
const tombstonesValue = await this.host.storage.get<unknown>(
|
|
748
|
+
COMMAND_TOMBSTONES_KEY,
|
|
749
|
+
);
|
|
750
|
+
const tombstone = (
|
|
751
|
+
tombstonesValue === undefined
|
|
752
|
+
? []
|
|
753
|
+
: decodeCommandTombstones(tombstonesValue)
|
|
754
|
+
).find((candidate) => candidate.commandId === command.commandId);
|
|
755
|
+
if (tombstone) {
|
|
756
|
+
if (
|
|
757
|
+
tombstone.accountId !== accountId ||
|
|
758
|
+
tombstone.fingerprint !== commandFingerprint
|
|
759
|
+
) {
|
|
760
|
+
throw new Error("Connection command idempotency key was reused");
|
|
761
|
+
}
|
|
762
|
+
return tombstone.receipt;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const record = await this.admit(
|
|
766
|
+
accountId,
|
|
767
|
+
command,
|
|
768
|
+
commandFingerprint,
|
|
769
|
+
automaticRefresh,
|
|
770
|
+
);
|
|
771
|
+
return record.receipt ?? this.resumeOnce(record);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
private async admit(
|
|
775
|
+
accountId: string,
|
|
776
|
+
command: ConnectionCommandV1,
|
|
777
|
+
commandFingerprint: string,
|
|
778
|
+
automaticRefresh: boolean,
|
|
779
|
+
): Promise<StoredCommand> {
|
|
780
|
+
if (command.type === "connection/create-api-key") {
|
|
781
|
+
if (
|
|
782
|
+
command.packageId !== PACKAGE_ID ||
|
|
783
|
+
command.connectionTypeId !== CONNECTION_TYPE_ID
|
|
784
|
+
) {
|
|
785
|
+
throw new Error("Ollama Cloud Connection type is invalid");
|
|
786
|
+
}
|
|
787
|
+
if (
|
|
788
|
+
!(await this.host.settings.isPackageInstalled(accountId, PACKAGE_ID))
|
|
789
|
+
) {
|
|
790
|
+
throw new Error("Ollama Cloud Package is not installed and enabled");
|
|
791
|
+
}
|
|
792
|
+
const connectionId = `connection-${this.randomId()}`;
|
|
793
|
+
const generation = this.randomId();
|
|
794
|
+
// An unusable endpoint is admitted and then refused, so the User sees a
|
|
795
|
+
// failed receipt and a failed Connection carrying the reason rather than
|
|
796
|
+
// an unhandled throw. No provider request is made for it.
|
|
797
|
+
let accepted: Record<string, string> = {};
|
|
798
|
+
let settingsFailure: string | undefined;
|
|
799
|
+
try {
|
|
800
|
+
accepted = decodeOllamaConnectionSettings(command.settings);
|
|
801
|
+
} catch (error) {
|
|
802
|
+
settingsFailure = (
|
|
803
|
+
error instanceof Error && error.message
|
|
804
|
+
? error.message
|
|
805
|
+
: "Ollama Cloud Connection settings are invalid"
|
|
806
|
+
).slice(0, 500);
|
|
807
|
+
}
|
|
808
|
+
const record: StoredCommand = {
|
|
809
|
+
schemaVersion: 1,
|
|
810
|
+
commandId: command.commandId,
|
|
811
|
+
fingerprint: commandFingerprint,
|
|
812
|
+
accountId,
|
|
813
|
+
connectionId,
|
|
814
|
+
credentialGeneration: generation,
|
|
815
|
+
operation: command.type,
|
|
816
|
+
label: command.label,
|
|
817
|
+
...(Object.keys(accepted).length > 0 ? { settings: accepted } : {}),
|
|
818
|
+
providerRetryPolicy: "safe-metadata-read",
|
|
819
|
+
...(settingsFailure === undefined
|
|
820
|
+
? {}
|
|
821
|
+
: {
|
|
822
|
+
validationFailure: settingsFailure,
|
|
823
|
+
validationStatus: "failed" as const,
|
|
824
|
+
}),
|
|
825
|
+
};
|
|
826
|
+
const prepared = await this.host.credentials.prepareApiKey({
|
|
827
|
+
accountId,
|
|
828
|
+
connectionId,
|
|
829
|
+
packageId: PACKAGE_ID,
|
|
830
|
+
generation,
|
|
831
|
+
apiKey: command.apiKey,
|
|
832
|
+
});
|
|
833
|
+
const admitted = await this.host.storage.transaction(
|
|
834
|
+
async (storage: UserSettingsTransaction & CredentialTransaction) => {
|
|
835
|
+
const admission = await this.admitRecord(record, storage);
|
|
836
|
+
if (!admission.created) return admission.record;
|
|
837
|
+
const settings = await this.host.settings.readSnapshot(storage);
|
|
838
|
+
if (
|
|
839
|
+
!settings.packages.some(
|
|
840
|
+
(pkg) =>
|
|
841
|
+
pkg.packageId === PACKAGE_ID && pkg.state === "installed",
|
|
842
|
+
)
|
|
843
|
+
) {
|
|
844
|
+
throw new Error(
|
|
845
|
+
"Ollama Cloud Package is not installed and enabled",
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
await this.host.settings.createConnection(
|
|
849
|
+
accountId,
|
|
850
|
+
this.authorizingConnection(record, command.label),
|
|
851
|
+
storage,
|
|
852
|
+
);
|
|
853
|
+
await this.host.credentials.stagePreparedApiKey(prepared, storage);
|
|
854
|
+
return record;
|
|
855
|
+
},
|
|
856
|
+
);
|
|
857
|
+
await this.host.storage.setAlarm(this.now() + RECOVERY_DELAY_MS);
|
|
858
|
+
return admitted;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (command.type === "connection/create") {
|
|
862
|
+
throw new Error("Ollama Cloud Connections require an API key");
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const connection = await this.requireConnection(
|
|
866
|
+
accountId,
|
|
867
|
+
command.connectionId,
|
|
868
|
+
);
|
|
869
|
+
if (command.type === "connection/rotate-api-key") {
|
|
870
|
+
if (connection.state !== "ready" && connection.state !== "disabled") {
|
|
871
|
+
throw new Error(`Connection is ${connection.state}`);
|
|
872
|
+
}
|
|
873
|
+
const generation = this.randomId();
|
|
874
|
+
const record: StoredCommand = {
|
|
875
|
+
schemaVersion: 1,
|
|
876
|
+
commandId: command.commandId,
|
|
877
|
+
fingerprint: commandFingerprint,
|
|
878
|
+
accountId,
|
|
879
|
+
connectionId: connection.connectionId,
|
|
880
|
+
credentialGeneration: generation,
|
|
881
|
+
expectedGeneration: connection.generation,
|
|
882
|
+
operation: command.type,
|
|
883
|
+
providerRetryPolicy: "safe-metadata-read",
|
|
884
|
+
};
|
|
885
|
+
const prepared = await this.host.credentials.prepareApiKey({
|
|
886
|
+
accountId,
|
|
887
|
+
connectionId: connection.connectionId,
|
|
888
|
+
packageId: PACKAGE_ID,
|
|
889
|
+
generation,
|
|
890
|
+
apiKey: command.apiKey,
|
|
891
|
+
});
|
|
892
|
+
const admitted = await this.host.storage.transaction(
|
|
893
|
+
async (storage: UserSettingsTransaction & CredentialTransaction) => {
|
|
894
|
+
const admission = await this.admitRecord(record, storage);
|
|
895
|
+
if (!admission.created) return admission.record;
|
|
896
|
+
const settings = await this.host.settings.readSnapshot(storage);
|
|
897
|
+
const current = settings.connections.find(
|
|
898
|
+
(candidate) => candidate.connectionId === connection.connectionId,
|
|
899
|
+
);
|
|
900
|
+
if (
|
|
901
|
+
!settings.packages.some(
|
|
902
|
+
(pkg) =>
|
|
903
|
+
pkg.packageId === PACKAGE_ID && pkg.state === "installed",
|
|
904
|
+
) ||
|
|
905
|
+
!current ||
|
|
906
|
+
current.generation !== connection.generation ||
|
|
907
|
+
(current.state !== "ready" && current.state !== "disabled")
|
|
908
|
+
) {
|
|
909
|
+
throw new Error("Connection changed before credential rotation");
|
|
910
|
+
}
|
|
911
|
+
await this.host.credentials.stagePreparedApiKey(prepared, storage);
|
|
912
|
+
return record;
|
|
913
|
+
},
|
|
914
|
+
);
|
|
915
|
+
await this.host.storage.setAlarm(this.now() + RECOVERY_DELAY_MS);
|
|
916
|
+
return admitted;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const record: StoredCommand = {
|
|
920
|
+
schemaVersion: 1,
|
|
921
|
+
commandId: command.commandId,
|
|
922
|
+
fingerprint: commandFingerprint,
|
|
923
|
+
accountId,
|
|
924
|
+
connectionId: connection.connectionId,
|
|
925
|
+
expectedGeneration: connection.generation,
|
|
926
|
+
operation: command.type,
|
|
927
|
+
...(command.type === "connection/refresh-models"
|
|
928
|
+
? { providerRetryPolicy: "safe-metadata-read" as const }
|
|
929
|
+
: {}),
|
|
930
|
+
...(command.type === "connection/update-label"
|
|
931
|
+
? { label: command.label }
|
|
932
|
+
: {}),
|
|
933
|
+
...(command.type === "connection/set-enabled"
|
|
934
|
+
? { enabled: command.enabled }
|
|
935
|
+
: {}),
|
|
936
|
+
...(command.type === "connection/disconnect"
|
|
937
|
+
? { revokeUpstream: command.revokeUpstream }
|
|
938
|
+
: {}),
|
|
939
|
+
...(automaticRefresh ? { automaticRefresh: true } : {}),
|
|
940
|
+
};
|
|
941
|
+
const admitted = await this.admitRecord(record);
|
|
942
|
+
await this.host.storage.setAlarm(this.now() + RECOVERY_DELAY_MS);
|
|
943
|
+
return admitted.record;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
private async ensureAdmittedState(
|
|
947
|
+
record: StoredCommand,
|
|
948
|
+
apiKey?: string,
|
|
949
|
+
): Promise<void> {
|
|
950
|
+
if (record.operation === "connection/create-api-key") {
|
|
951
|
+
const current = await this.host.settings.getConnection(
|
|
952
|
+
record.accountId,
|
|
953
|
+
record.connectionId,
|
|
954
|
+
);
|
|
955
|
+
if (!current) {
|
|
956
|
+
await this.host.settings.createConnection(
|
|
957
|
+
record.accountId,
|
|
958
|
+
this.authorizingConnection(record, record.label ?? "Ollama Cloud"),
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (apiKey && record.credentialGeneration) {
|
|
963
|
+
await this.host.credentials.stageApiKey({
|
|
964
|
+
accountId: record.accountId,
|
|
965
|
+
connectionId: record.connectionId,
|
|
966
|
+
packageId: PACKAGE_ID,
|
|
967
|
+
generation: record.credentialGeneration,
|
|
968
|
+
apiKey,
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
private async admitRecord(
|
|
974
|
+
record: StoredCommand,
|
|
975
|
+
storage?: CredentialTransaction,
|
|
976
|
+
): Promise<{ record: StoredCommand; created: boolean }> {
|
|
977
|
+
const admit = async (transaction: CredentialTransaction) => {
|
|
978
|
+
const existingValue = await transaction.get<unknown>(
|
|
979
|
+
commandKey(record.commandId),
|
|
980
|
+
);
|
|
981
|
+
const tombstonesValue = await transaction.get<unknown>(
|
|
982
|
+
COMMAND_TOMBSTONES_KEY,
|
|
983
|
+
);
|
|
984
|
+
const tombstones =
|
|
985
|
+
tombstonesValue === undefined
|
|
986
|
+
? []
|
|
987
|
+
: decodeCommandTombstones(tombstonesValue);
|
|
988
|
+
const tombstone = tombstones.find(
|
|
989
|
+
(candidate) => candidate.commandId === record.commandId,
|
|
990
|
+
);
|
|
991
|
+
if (tombstone) {
|
|
992
|
+
if (
|
|
993
|
+
tombstone.accountId !== record.accountId ||
|
|
994
|
+
tombstone.fingerprint !== record.fingerprint
|
|
995
|
+
) {
|
|
996
|
+
throw new Error("Connection command idempotency key was reused");
|
|
997
|
+
}
|
|
998
|
+
return { record: tombstone, created: false };
|
|
999
|
+
}
|
|
1000
|
+
const existing =
|
|
1001
|
+
existingValue === undefined
|
|
1002
|
+
? undefined
|
|
1003
|
+
: decodeStoredCommand(existingValue);
|
|
1004
|
+
if (existing) {
|
|
1005
|
+
if (
|
|
1006
|
+
existing.accountId !== record.accountId ||
|
|
1007
|
+
existing.fingerprint !== record.fingerprint
|
|
1008
|
+
) {
|
|
1009
|
+
throw new Error("Connection command idempotency key was reused");
|
|
1010
|
+
}
|
|
1011
|
+
return { record: existing, created: false };
|
|
1012
|
+
}
|
|
1013
|
+
const pendingValue = await transaction.get<unknown>(PENDING_KEY);
|
|
1014
|
+
const pending = (
|
|
1015
|
+
pendingValue === undefined ? [] : decodePendingCommands(pendingValue)
|
|
1016
|
+
).filter((id) => id !== record.commandId);
|
|
1017
|
+
if (pending.length >= MAX_PENDING_COMMANDS) {
|
|
1018
|
+
throw new Error("Ollama Connection command capacity reached");
|
|
1019
|
+
}
|
|
1020
|
+
if (!record.automaticRefresh) {
|
|
1021
|
+
const receiptIndexValue =
|
|
1022
|
+
await transaction.get<unknown>(RECEIPT_INDEX_KEY);
|
|
1023
|
+
const receiptIndex =
|
|
1024
|
+
receiptIndexValue === undefined
|
|
1025
|
+
? []
|
|
1026
|
+
: decodeReceiptIndex(receiptIndexValue);
|
|
1027
|
+
if (
|
|
1028
|
+
receiptIndex.length + tombstones.length + pending.length >=
|
|
1029
|
+
MAX_MANUAL_COMMANDS
|
|
1030
|
+
) {
|
|
1031
|
+
throw new Error("Ollama Connection command history capacity reached");
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
let admitted = record;
|
|
1035
|
+
let sequenceEntry: Record<string, unknown> = {};
|
|
1036
|
+
if (isSequencedMutation(record.operation)) {
|
|
1037
|
+
const sequenceValue = await transaction.get<unknown>(
|
|
1038
|
+
mutationSequenceKey(record.connectionId, record.operation),
|
|
1039
|
+
);
|
|
1040
|
+
const sequence =
|
|
1041
|
+
sequenceValue === undefined
|
|
1042
|
+
? { next: 0, applied: 0 }
|
|
1043
|
+
: decodeMutationSequence(sequenceValue);
|
|
1044
|
+
const next = sequence.next + 1;
|
|
1045
|
+
admitted = { ...record, mutationSequence: next };
|
|
1046
|
+
sequenceEntry = {
|
|
1047
|
+
[mutationSequenceKey(record.connectionId, record.operation)]: {
|
|
1048
|
+
schemaVersion: 1,
|
|
1049
|
+
next,
|
|
1050
|
+
applied: sequence.applied,
|
|
1051
|
+
},
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
await transaction.put({
|
|
1055
|
+
[commandKey(record.commandId)]: admitted,
|
|
1056
|
+
[PENDING_KEY]: [...pending, record.commandId],
|
|
1057
|
+
...sequenceEntry,
|
|
1058
|
+
});
|
|
1059
|
+
await transaction.setAlarm?.(this.now() + RECOVERY_DELAY_MS);
|
|
1060
|
+
return { record: admitted, created: true };
|
|
1061
|
+
};
|
|
1062
|
+
return storage ? admit(storage) : this.host.storage.transaction(admit);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
private authorizingConnection(
|
|
1066
|
+
record: StoredCommand,
|
|
1067
|
+
displayName: string,
|
|
1068
|
+
): ConnectionView {
|
|
1069
|
+
const updatedAt = new Date(this.now()).toISOString();
|
|
1070
|
+
return {
|
|
1071
|
+
connectionId: record.connectionId,
|
|
1072
|
+
packageId: PACKAGE_ID,
|
|
1073
|
+
connectionTypeId: CONNECTION_TYPE_ID,
|
|
1074
|
+
displayName,
|
|
1075
|
+
state: "authorizing",
|
|
1076
|
+
generation: record.credentialGeneration,
|
|
1077
|
+
providerType: OLLAMA_CLOUD_PROVIDER,
|
|
1078
|
+
authorization: {
|
|
1079
|
+
schemaVersion: 1,
|
|
1080
|
+
kind: "api-key",
|
|
1081
|
+
credential: {
|
|
1082
|
+
schemaVersion: 1,
|
|
1083
|
+
configured: true,
|
|
1084
|
+
source: "api-key",
|
|
1085
|
+
writable: true,
|
|
1086
|
+
generation: record.credentialGeneration,
|
|
1087
|
+
updatedAt,
|
|
1088
|
+
},
|
|
1089
|
+
},
|
|
1090
|
+
settings: { ...(record.settings ?? {}) },
|
|
1091
|
+
safeMetadata: { creationCommandId: record.commandId },
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
private async resumeOnce(
|
|
1096
|
+
record: StoredCommand,
|
|
1097
|
+
apiKey?: string,
|
|
1098
|
+
): Promise<ConnectionCommandReceiptV1> {
|
|
1099
|
+
const active = this.resumptions.get(record.commandId);
|
|
1100
|
+
if (active) {
|
|
1101
|
+
if (active.fingerprint !== record.fingerprint) {
|
|
1102
|
+
throw new Error("Connection command idempotency key was reused");
|
|
1103
|
+
}
|
|
1104
|
+
return active.promise;
|
|
1105
|
+
}
|
|
1106
|
+
const promise = (async () => {
|
|
1107
|
+
try {
|
|
1108
|
+
const sequenced = await this.ensureMutationSequence(record);
|
|
1109
|
+
await this.ensureAdmittedState(sequenced, apiKey);
|
|
1110
|
+
return await this.resume(sequenced);
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
await this.host.storage.setAlarm(this.now() + RECOVERY_DELAY_MS);
|
|
1113
|
+
throw error;
|
|
1114
|
+
}
|
|
1115
|
+
})();
|
|
1116
|
+
this.resumptions.set(record.commandId, {
|
|
1117
|
+
fingerprint: record.fingerprint,
|
|
1118
|
+
promise,
|
|
1119
|
+
});
|
|
1120
|
+
const release = () => {
|
|
1121
|
+
if (this.resumptions.get(record.commandId)?.promise === promise) {
|
|
1122
|
+
this.resumptions.delete(record.commandId);
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
void promise.then(release, release);
|
|
1126
|
+
return promise;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
private async ensureMutationSequence(
|
|
1130
|
+
record: StoredCommand,
|
|
1131
|
+
): Promise<StoredCommand> {
|
|
1132
|
+
if (!isSequencedMutation(record.operation) || record.mutationSequence) {
|
|
1133
|
+
return record;
|
|
1134
|
+
}
|
|
1135
|
+
return this.host.storage.transaction(
|
|
1136
|
+
async (storage: UserSettingsTransaction & CredentialTransaction) => {
|
|
1137
|
+
const storedValue = await storage.get<unknown>(
|
|
1138
|
+
commandKey(record.commandId),
|
|
1139
|
+
);
|
|
1140
|
+
if (storedValue === undefined) return record;
|
|
1141
|
+
const stored = decodeStoredCommand(storedValue);
|
|
1142
|
+
if (stored.mutationSequence) return stored;
|
|
1143
|
+
const sequenceValue = await storage.get<unknown>(
|
|
1144
|
+
mutationSequenceKey(record.connectionId, record.operation),
|
|
1145
|
+
);
|
|
1146
|
+
const sequence =
|
|
1147
|
+
sequenceValue === undefined
|
|
1148
|
+
? { next: 0, applied: 0 }
|
|
1149
|
+
: decodeMutationSequence(sequenceValue);
|
|
1150
|
+
const next = sequence.next + 1;
|
|
1151
|
+
const sequenced = { ...stored, mutationSequence: next };
|
|
1152
|
+
await storage.put({
|
|
1153
|
+
[commandKey(record.commandId)]: sequenced,
|
|
1154
|
+
[mutationSequenceKey(record.connectionId, record.operation)]: {
|
|
1155
|
+
schemaVersion: 1,
|
|
1156
|
+
next,
|
|
1157
|
+
applied: sequence.applied,
|
|
1158
|
+
},
|
|
1159
|
+
});
|
|
1160
|
+
return sequenced;
|
|
1161
|
+
},
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
private async resume(
|
|
1166
|
+
record: StoredCommand,
|
|
1167
|
+
): Promise<ConnectionCommandReceiptV1> {
|
|
1168
|
+
switch (record.operation) {
|
|
1169
|
+
case "connection/create-api-key":
|
|
1170
|
+
case "connection/rotate-api-key":
|
|
1171
|
+
return this.validateAndActivate(record);
|
|
1172
|
+
case "connection/update-label":
|
|
1173
|
+
return this.updateLabel(record);
|
|
1174
|
+
case "connection/refresh-models":
|
|
1175
|
+
return this.refreshCatalog(record);
|
|
1176
|
+
case "connection/set-enabled":
|
|
1177
|
+
return this.setEnabled(record);
|
|
1178
|
+
case "connection/disconnect":
|
|
1179
|
+
return this.disconnect(record);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
private catalog(
|
|
1184
|
+
models: ConnectionModelCatalogV1["models"],
|
|
1185
|
+
): ConnectionModelCatalogV1 {
|
|
1186
|
+
const now = this.now();
|
|
1187
|
+
return {
|
|
1188
|
+
schemaVersion: 1,
|
|
1189
|
+
generation: this.randomId(),
|
|
1190
|
+
state: "fresh",
|
|
1191
|
+
models: models.slice(0, MAX_DISCOVERED_MODELS),
|
|
1192
|
+
refreshedAt: new Date(now).toISOString(),
|
|
1193
|
+
refreshAfter: new Date(now + REFRESH_INTERVAL_MS).toISOString(),
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
private async validateAndActivate(
|
|
1198
|
+
record: StoredCommand,
|
|
1199
|
+
): Promise<ConnectionCommandReceiptV1> {
|
|
1200
|
+
const generation = record.credentialGeneration;
|
|
1201
|
+
if (!generation) throw new Error("Credential generation is unavailable");
|
|
1202
|
+
const effectId = `validation:${record.commandId}`;
|
|
1203
|
+
let outcome = record;
|
|
1204
|
+
const existingProjection = await this.requireConnection(
|
|
1205
|
+
record.accountId,
|
|
1206
|
+
record.connectionId,
|
|
1207
|
+
);
|
|
1208
|
+
if (
|
|
1209
|
+
!outcome.validationStatus &&
|
|
1210
|
+
existingProjection.generation === generation &&
|
|
1211
|
+
(existingProjection.state === "ready" ||
|
|
1212
|
+
existingProjection.state === "disabled")
|
|
1213
|
+
) {
|
|
1214
|
+
return this.finishRecord(record, "applied");
|
|
1215
|
+
}
|
|
1216
|
+
if (!outcome.validationStatus) {
|
|
1217
|
+
try {
|
|
1218
|
+
if (outcome.providerRetryPolicy !== "safe-metadata-read") {
|
|
1219
|
+
throw new Error("Ollama validation retry policy is unavailable");
|
|
1220
|
+
}
|
|
1221
|
+
const lease = await this.host.credentials.lease({
|
|
1222
|
+
accountId: record.accountId,
|
|
1223
|
+
connectionId: record.connectionId,
|
|
1224
|
+
packageId: PACKAGE_ID,
|
|
1225
|
+
effectId,
|
|
1226
|
+
expiresAt: new Date(this.now() + MODEL_LEASE_MS).toISOString(),
|
|
1227
|
+
expectedGeneration: generation,
|
|
1228
|
+
credentialState: "pending",
|
|
1229
|
+
});
|
|
1230
|
+
const apiKey = await this.host.credentials.openLease({
|
|
1231
|
+
accountId: record.accountId,
|
|
1232
|
+
packageId: PACKAGE_ID,
|
|
1233
|
+
lease,
|
|
1234
|
+
});
|
|
1235
|
+
// A catalog read is not validation: measured against https://ollama.com
|
|
1236
|
+
// on 2026-08-31, `GET /api/tags`, `GET /v1/models`, and `POST
|
|
1237
|
+
// /api/show` all answer 200 for a valid key, a garbage key, and no key
|
|
1238
|
+
// at all (docs/research/ollama-cloud-auth.md), so `listModels` alone
|
|
1239
|
+
// promotes a bad key to `ready` and the User only learns it is bad when
|
|
1240
|
+
// a Turn ends `model-error`. `POST /api/chat` authenticates, so a
|
|
1241
|
+
// one-token completion is what proves the key.
|
|
1242
|
+
const client = this.clientFor(
|
|
1243
|
+
record.settings?.[API_BASE_URL_SETTING] ??
|
|
1244
|
+
connectionApiBaseUrl(existingProjection),
|
|
1245
|
+
);
|
|
1246
|
+
const models = await client.listModels(apiKey);
|
|
1247
|
+
await client.probeInference(apiKey, probeModelId(models));
|
|
1248
|
+
outcome = {
|
|
1249
|
+
...record,
|
|
1250
|
+
validationCatalog: this.catalog(models),
|
|
1251
|
+
validationStatus: "applied",
|
|
1252
|
+
};
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
outcome = {
|
|
1255
|
+
...record,
|
|
1256
|
+
validationFailure: (error instanceof Error && error.message
|
|
1257
|
+
? error.message
|
|
1258
|
+
: "Ollama Cloud validation failed"
|
|
1259
|
+
).slice(0, 500),
|
|
1260
|
+
validationStatus: "failed",
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
await this.host.storage.transaction(async (storage) => {
|
|
1264
|
+
const storedValue = await storage.get<unknown>(
|
|
1265
|
+
commandKey(record.commandId),
|
|
1266
|
+
);
|
|
1267
|
+
if (storedValue === undefined) {
|
|
1268
|
+
throw new Error("Ollama Connection command is unavailable");
|
|
1269
|
+
}
|
|
1270
|
+
const stored = decodeStoredCommand(storedValue);
|
|
1271
|
+
if (!stored.receipt) {
|
|
1272
|
+
await storage.put(commandKey(record.commandId), outcome);
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
const settleValidation = () =>
|
|
1277
|
+
this.host.credentials.settle({
|
|
1278
|
+
accountId: record.accountId,
|
|
1279
|
+
connectionId: record.connectionId,
|
|
1280
|
+
packageId: PACKAGE_ID,
|
|
1281
|
+
effectId,
|
|
1282
|
+
});
|
|
1283
|
+
let projected: ConnectionView;
|
|
1284
|
+
try {
|
|
1285
|
+
projected = await this.requireConnection(
|
|
1286
|
+
record.accountId,
|
|
1287
|
+
record.connectionId,
|
|
1288
|
+
);
|
|
1289
|
+
} catch (error) {
|
|
1290
|
+
await settleValidation();
|
|
1291
|
+
await this.host.credentials.discardPending(
|
|
1292
|
+
record.connectionId,
|
|
1293
|
+
generation,
|
|
1294
|
+
);
|
|
1295
|
+
const settledValue = await this.host.storage.get<unknown>(
|
|
1296
|
+
commandKey(record.commandId),
|
|
1297
|
+
);
|
|
1298
|
+
const settled =
|
|
1299
|
+
settledValue === undefined
|
|
1300
|
+
? undefined
|
|
1301
|
+
: decodeStoredCommand(settledValue).receipt;
|
|
1302
|
+
if (settled) return settled;
|
|
1303
|
+
throw error;
|
|
1304
|
+
}
|
|
1305
|
+
if (
|
|
1306
|
+
projected.generation === generation &&
|
|
1307
|
+
(projected.state === "ready" || projected.state === "disabled")
|
|
1308
|
+
) {
|
|
1309
|
+
await settleValidation();
|
|
1310
|
+
return this.finishRecord(outcome, "applied");
|
|
1311
|
+
}
|
|
1312
|
+
if (outcome.validationStatus === "failed") {
|
|
1313
|
+
await settleValidation();
|
|
1314
|
+
await this.host.credentials.discardPending(
|
|
1315
|
+
record.connectionId,
|
|
1316
|
+
generation,
|
|
1317
|
+
);
|
|
1318
|
+
if (
|
|
1319
|
+
record.operation === "connection/create-api-key" &&
|
|
1320
|
+
projected.state === "authorizing" &&
|
|
1321
|
+
projected.generation === generation
|
|
1322
|
+
) {
|
|
1323
|
+
await this.host.settings.replaceConnection(
|
|
1324
|
+
record.accountId,
|
|
1325
|
+
record.connectionId,
|
|
1326
|
+
projected.generation,
|
|
1327
|
+
{
|
|
1328
|
+
...projected,
|
|
1329
|
+
state: "failed",
|
|
1330
|
+
authorization: {
|
|
1331
|
+
schemaVersion: 1,
|
|
1332
|
+
kind: "api-key",
|
|
1333
|
+
credential: {
|
|
1334
|
+
schemaVersion: 1,
|
|
1335
|
+
configured: false,
|
|
1336
|
+
source: "api-key",
|
|
1337
|
+
writable: true,
|
|
1338
|
+
},
|
|
1339
|
+
},
|
|
1340
|
+
failure: outcome.validationFailure,
|
|
1341
|
+
},
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
return this.finishRecord(outcome, "failed");
|
|
1345
|
+
}
|
|
1346
|
+
try {
|
|
1347
|
+
await this.host.storage.transaction(
|
|
1348
|
+
async (storage: UserSettingsTransaction & CredentialTransaction) => {
|
|
1349
|
+
const current = await this.host.settings.getConnection(
|
|
1350
|
+
record.accountId,
|
|
1351
|
+
record.connectionId,
|
|
1352
|
+
storage,
|
|
1353
|
+
);
|
|
1354
|
+
if (!current || current.packageId !== PACKAGE_ID) {
|
|
1355
|
+
throw new Error("Ollama Cloud Connection is unavailable");
|
|
1356
|
+
}
|
|
1357
|
+
if (
|
|
1358
|
+
record.operation === "connection/create-api-key" &&
|
|
1359
|
+
(current.state !== "authorizing" ||
|
|
1360
|
+
current.generation !== generation)
|
|
1361
|
+
) {
|
|
1362
|
+
throw new Error(`Connection is ${current.state}`);
|
|
1363
|
+
}
|
|
1364
|
+
if (
|
|
1365
|
+
record.operation === "connection/rotate-api-key" &&
|
|
1366
|
+
current.state !== "ready" &&
|
|
1367
|
+
current.state !== "disabled"
|
|
1368
|
+
) {
|
|
1369
|
+
throw new Error(`Connection is ${current.state}`);
|
|
1370
|
+
}
|
|
1371
|
+
await this.host.credentials.activate(
|
|
1372
|
+
{
|
|
1373
|
+
accountId: record.accountId,
|
|
1374
|
+
connectionId: record.connectionId,
|
|
1375
|
+
packageId: PACKAGE_ID,
|
|
1376
|
+
generation,
|
|
1377
|
+
},
|
|
1378
|
+
storage,
|
|
1379
|
+
);
|
|
1380
|
+
await this.host.settings.replaceConnection(
|
|
1381
|
+
record.accountId,
|
|
1382
|
+
record.connectionId,
|
|
1383
|
+
record.expectedGeneration ?? current.generation,
|
|
1384
|
+
{
|
|
1385
|
+
...current,
|
|
1386
|
+
state: current.state === "disabled" ? "disabled" : "ready",
|
|
1387
|
+
generation,
|
|
1388
|
+
modelCatalog: outcome.validationCatalog,
|
|
1389
|
+
authorization: {
|
|
1390
|
+
schemaVersion: 1,
|
|
1391
|
+
kind: "api-key",
|
|
1392
|
+
credential: {
|
|
1393
|
+
schemaVersion: 1,
|
|
1394
|
+
configured: true,
|
|
1395
|
+
source: "api-key",
|
|
1396
|
+
writable: true,
|
|
1397
|
+
generation,
|
|
1398
|
+
updatedAt: new Date(this.now()).toISOString(),
|
|
1399
|
+
},
|
|
1400
|
+
},
|
|
1401
|
+
failure: undefined,
|
|
1402
|
+
},
|
|
1403
|
+
storage,
|
|
1404
|
+
);
|
|
1405
|
+
if (
|
|
1406
|
+
record.operation === "connection/create-api-key" &&
|
|
1407
|
+
outcome.validationCatalog
|
|
1408
|
+
) {
|
|
1409
|
+
const user = await this.host.settings.read(
|
|
1410
|
+
record.accountId,
|
|
1411
|
+
storage,
|
|
1412
|
+
);
|
|
1413
|
+
const selected = selectAutomaticOllamaModelV1(
|
|
1414
|
+
outcome.validationCatalog,
|
|
1415
|
+
);
|
|
1416
|
+
if (selected && user.newBotModelTemplateSource !== "user") {
|
|
1417
|
+
await this.host.settings.executeConfigurationCommand(
|
|
1418
|
+
record.accountId,
|
|
1419
|
+
{
|
|
1420
|
+
schemaVersion: 1,
|
|
1421
|
+
type: "user/set-new-bot-model",
|
|
1422
|
+
commandId: record.commandId,
|
|
1423
|
+
expectedRevision: user.revision,
|
|
1424
|
+
model: {
|
|
1425
|
+
connectionId: record.connectionId,
|
|
1426
|
+
providerModelId: selected.providerModelId,
|
|
1427
|
+
},
|
|
1428
|
+
source: "auto",
|
|
1429
|
+
},
|
|
1430
|
+
storage,
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
},
|
|
1435
|
+
);
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
await settleValidation();
|
|
1438
|
+
await this.host.credentials.discardPending(
|
|
1439
|
+
record.connectionId,
|
|
1440
|
+
generation,
|
|
1441
|
+
);
|
|
1442
|
+
const settledValue = await this.host.storage.get<unknown>(
|
|
1443
|
+
commandKey(record.commandId),
|
|
1444
|
+
);
|
|
1445
|
+
const settled =
|
|
1446
|
+
settledValue === undefined
|
|
1447
|
+
? undefined
|
|
1448
|
+
: decodeStoredCommand(settledValue).receipt;
|
|
1449
|
+
if (settled) return settled;
|
|
1450
|
+
if (record.operation === "connection/create-api-key") {
|
|
1451
|
+
const current = await this.requireConnection(
|
|
1452
|
+
record.accountId,
|
|
1453
|
+
record.connectionId,
|
|
1454
|
+
);
|
|
1455
|
+
if (
|
|
1456
|
+
current.state === "authorizing" &&
|
|
1457
|
+
current.generation === generation
|
|
1458
|
+
) {
|
|
1459
|
+
await this.host.settings.replaceConnection(
|
|
1460
|
+
record.accountId,
|
|
1461
|
+
record.connectionId,
|
|
1462
|
+
current.generation,
|
|
1463
|
+
{
|
|
1464
|
+
...current,
|
|
1465
|
+
state: "failed",
|
|
1466
|
+
authorization: {
|
|
1467
|
+
schemaVersion: 1,
|
|
1468
|
+
kind: "api-key",
|
|
1469
|
+
credential: {
|
|
1470
|
+
schemaVersion: 1,
|
|
1471
|
+
configured: false,
|
|
1472
|
+
source: "api-key",
|
|
1473
|
+
writable: true,
|
|
1474
|
+
},
|
|
1475
|
+
},
|
|
1476
|
+
failure:
|
|
1477
|
+
error instanceof Error
|
|
1478
|
+
? error.message
|
|
1479
|
+
: "Ollama Cloud validation failed",
|
|
1480
|
+
},
|
|
1481
|
+
);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return this.finishRecord(outcome, "failed");
|
|
1485
|
+
}
|
|
1486
|
+
await settleValidation();
|
|
1487
|
+
return this.finishRecord(outcome, "applied");
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
private async updateLabel(
|
|
1491
|
+
record: StoredCommand,
|
|
1492
|
+
): Promise<ConnectionCommandReceiptV1> {
|
|
1493
|
+
const label = record.label;
|
|
1494
|
+
if (!label) return this.finishRecord(record, "failed");
|
|
1495
|
+
const applied = await this.applySequencedMutation(record, (current) => ({
|
|
1496
|
+
...current,
|
|
1497
|
+
displayName: label,
|
|
1498
|
+
}));
|
|
1499
|
+
return this.finishRecord(record, applied ? "applied" : "failed");
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
private async refreshCatalog(
|
|
1503
|
+
record: StoredCommand,
|
|
1504
|
+
): Promise<ConnectionCommandReceiptV1> {
|
|
1505
|
+
const expectedGeneration = record.expectedGeneration;
|
|
1506
|
+
if (!expectedGeneration) return this.finishRecord(record, "failed");
|
|
1507
|
+
if (record.settlementEffectId && record.settlementStatus) {
|
|
1508
|
+
await this.host.credentials.settle({
|
|
1509
|
+
accountId: record.accountId,
|
|
1510
|
+
connectionId: record.connectionId,
|
|
1511
|
+
packageId: PACKAGE_ID,
|
|
1512
|
+
effectId: record.settlementEffectId,
|
|
1513
|
+
});
|
|
1514
|
+
return this.finishRecord(record, record.settlementStatus);
|
|
1515
|
+
}
|
|
1516
|
+
if (record.providerRetryPolicy !== "safe-metadata-read") {
|
|
1517
|
+
return this.finishRecord(record, "failed");
|
|
1518
|
+
}
|
|
1519
|
+
const effectId = `catalog:${record.commandId}`;
|
|
1520
|
+
let lease: CredentialLeaseV1 | undefined;
|
|
1521
|
+
let models: ConnectionModelCatalogV1["models"] | undefined;
|
|
1522
|
+
let failure: unknown;
|
|
1523
|
+
let refreshEndpoint: string | undefined;
|
|
1524
|
+
try {
|
|
1525
|
+
lease = await this.host.storage.transaction(
|
|
1526
|
+
async (storage: UserSettingsTransaction & CredentialTransaction) => {
|
|
1527
|
+
const authority = await this.requireModelAuthority(
|
|
1528
|
+
{
|
|
1529
|
+
accountId: record.accountId,
|
|
1530
|
+
connectionId: record.connectionId,
|
|
1531
|
+
connectionGeneration: expectedGeneration,
|
|
1532
|
+
},
|
|
1533
|
+
storage,
|
|
1534
|
+
);
|
|
1535
|
+
refreshEndpoint = connectionApiBaseUrl(authority);
|
|
1536
|
+
return this.host.credentials.lease(
|
|
1537
|
+
{
|
|
1538
|
+
accountId: record.accountId,
|
|
1539
|
+
connectionId: record.connectionId,
|
|
1540
|
+
packageId: PACKAGE_ID,
|
|
1541
|
+
effectId,
|
|
1542
|
+
expiresAt: new Date(this.now() + MODEL_LEASE_MS).toISOString(),
|
|
1543
|
+
expectedGeneration,
|
|
1544
|
+
},
|
|
1545
|
+
storage,
|
|
1546
|
+
);
|
|
1547
|
+
},
|
|
1548
|
+
);
|
|
1549
|
+
const apiKey = await this.host.credentials.openLease({
|
|
1550
|
+
accountId: record.accountId,
|
|
1551
|
+
packageId: PACKAGE_ID,
|
|
1552
|
+
lease,
|
|
1553
|
+
});
|
|
1554
|
+
models = await this.clientFor(refreshEndpoint).listModels(apiKey);
|
|
1555
|
+
} catch (error) {
|
|
1556
|
+
failure = error;
|
|
1557
|
+
}
|
|
1558
|
+
const outcome = await this.host.storage.transaction(
|
|
1559
|
+
async (storage: UserSettingsTransaction & CredentialTransaction) => {
|
|
1560
|
+
let outcomeModels = models;
|
|
1561
|
+
let outcomeFailure = failure;
|
|
1562
|
+
let authorized: ConnectionView | undefined;
|
|
1563
|
+
const mutationSequenceValue = record.mutationSequence
|
|
1564
|
+
? await storage.get<unknown>(
|
|
1565
|
+
mutationSequenceKey(record.connectionId, record.operation),
|
|
1566
|
+
)
|
|
1567
|
+
: undefined;
|
|
1568
|
+
const mutationSequence =
|
|
1569
|
+
mutationSequenceValue === undefined
|
|
1570
|
+
? undefined
|
|
1571
|
+
: decodeMutationSequence(mutationSequenceValue);
|
|
1572
|
+
const appliesProjection =
|
|
1573
|
+
!record.mutationSequence ||
|
|
1574
|
+
!mutationSequence ||
|
|
1575
|
+
record.mutationSequence === mutationSequence.next;
|
|
1576
|
+
if (outcomeModels) {
|
|
1577
|
+
try {
|
|
1578
|
+
authorized = await this.requireModelAuthority(
|
|
1579
|
+
{
|
|
1580
|
+
accountId: record.accountId,
|
|
1581
|
+
connectionId: record.connectionId,
|
|
1582
|
+
connectionGeneration: expectedGeneration,
|
|
1583
|
+
},
|
|
1584
|
+
storage,
|
|
1585
|
+
);
|
|
1586
|
+
} catch (error) {
|
|
1587
|
+
outcomeModels = undefined;
|
|
1588
|
+
outcomeFailure = error;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
const status: ConnectionCommandReceiptV1["status"] =
|
|
1592
|
+
appliesProjection && outcomeFailure === undefined
|
|
1593
|
+
? "applied"
|
|
1594
|
+
: "failed";
|
|
1595
|
+
const pendingSettlement: StoredCommand | undefined = lease
|
|
1596
|
+
? {
|
|
1597
|
+
...record,
|
|
1598
|
+
settlementEffectId: effectId,
|
|
1599
|
+
settlementStatus: status,
|
|
1600
|
+
}
|
|
1601
|
+
: undefined;
|
|
1602
|
+
if (appliesProjection && outcomeModels && authorized) {
|
|
1603
|
+
await this.host.settings.replaceConnection(
|
|
1604
|
+
record.accountId,
|
|
1605
|
+
record.connectionId,
|
|
1606
|
+
expectedGeneration,
|
|
1607
|
+
{
|
|
1608
|
+
...authorized,
|
|
1609
|
+
modelCatalog: this.catalog(outcomeModels),
|
|
1610
|
+
failure: undefined,
|
|
1611
|
+
},
|
|
1612
|
+
storage,
|
|
1613
|
+
);
|
|
1614
|
+
} else if (appliesProjection) {
|
|
1615
|
+
const settings = await this.host.settings.readSnapshot(storage);
|
|
1616
|
+
const current = settings.connections.find(
|
|
1617
|
+
(connection) => connection.connectionId === record.connectionId,
|
|
1618
|
+
);
|
|
1619
|
+
if (
|
|
1620
|
+
settings.packages.some(
|
|
1621
|
+
(pkg) =>
|
|
1622
|
+
pkg.packageId === PACKAGE_ID && pkg.state === "installed",
|
|
1623
|
+
) &&
|
|
1624
|
+
current?.packageId === PACKAGE_ID &&
|
|
1625
|
+
current.generation === expectedGeneration &&
|
|
1626
|
+
current.state === "ready" &&
|
|
1627
|
+
current.modelCatalog
|
|
1628
|
+
) {
|
|
1629
|
+
await this.host.settings.replaceConnection(
|
|
1630
|
+
record.accountId,
|
|
1631
|
+
record.connectionId,
|
|
1632
|
+
expectedGeneration,
|
|
1633
|
+
{
|
|
1634
|
+
...current,
|
|
1635
|
+
modelCatalog: {
|
|
1636
|
+
...current.modelCatalog,
|
|
1637
|
+
state: "stale",
|
|
1638
|
+
refreshAfter: new Date(
|
|
1639
|
+
this.now() + REFRESH_INTERVAL_MS,
|
|
1640
|
+
).toISOString(),
|
|
1641
|
+
failure:
|
|
1642
|
+
outcomeFailure instanceof Error
|
|
1643
|
+
? outcomeFailure.message
|
|
1644
|
+
: "Ollama Cloud catalog refresh failed",
|
|
1645
|
+
},
|
|
1646
|
+
},
|
|
1647
|
+
storage,
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
if (appliesProjection && record.mutationSequence && mutationSequence) {
|
|
1652
|
+
await storage.put(
|
|
1653
|
+
mutationSequenceKey(record.connectionId, record.operation),
|
|
1654
|
+
{
|
|
1655
|
+
schemaVersion: 1,
|
|
1656
|
+
next: mutationSequence.next,
|
|
1657
|
+
applied: Math.max(
|
|
1658
|
+
mutationSequence.applied,
|
|
1659
|
+
record.mutationSequence,
|
|
1660
|
+
),
|
|
1661
|
+
},
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
if (pendingSettlement) {
|
|
1665
|
+
const storedValue = await storage.get<unknown>(
|
|
1666
|
+
commandKey(record.commandId),
|
|
1667
|
+
);
|
|
1668
|
+
if (storedValue === undefined) {
|
|
1669
|
+
throw new Error("Ollama Connection command is unavailable");
|
|
1670
|
+
}
|
|
1671
|
+
const stored = decodeStoredCommand(storedValue);
|
|
1672
|
+
if (!stored.receipt) {
|
|
1673
|
+
await storage.put(commandKey(record.commandId), pendingSettlement);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
return { status, pendingSettlement };
|
|
1677
|
+
},
|
|
1678
|
+
);
|
|
1679
|
+
if (!outcome.pendingSettlement) {
|
|
1680
|
+
return this.finishRecord(record, outcome.status);
|
|
1681
|
+
}
|
|
1682
|
+
await this.host.credentials.settle({
|
|
1683
|
+
accountId: record.accountId,
|
|
1684
|
+
connectionId: record.connectionId,
|
|
1685
|
+
packageId: PACKAGE_ID,
|
|
1686
|
+
effectId,
|
|
1687
|
+
});
|
|
1688
|
+
return this.finishRecord(outcome.pendingSettlement, outcome.status);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
private async setEnabled(
|
|
1692
|
+
record: StoredCommand,
|
|
1693
|
+
): Promise<ConnectionCommandReceiptV1> {
|
|
1694
|
+
const applied = await this.applySequencedMutation(record, (current) => {
|
|
1695
|
+
if (current.state !== "ready" && current.state !== "disabled") {
|
|
1696
|
+
return undefined;
|
|
1697
|
+
}
|
|
1698
|
+
return { ...current, state: record.enabled ? "ready" : "disabled" };
|
|
1699
|
+
});
|
|
1700
|
+
return this.finishRecord(record, applied ? "applied" : "failed");
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
private async applySequencedMutation(
|
|
1704
|
+
record: StoredCommand,
|
|
1705
|
+
update: (current: ConnectionView) => ConnectionView | undefined,
|
|
1706
|
+
): Promise<boolean> {
|
|
1707
|
+
const mutationSequence = record.mutationSequence;
|
|
1708
|
+
if (!mutationSequence) return false;
|
|
1709
|
+
return this.host.storage.transaction(
|
|
1710
|
+
async (storage: UserSettingsTransaction & CredentialTransaction) => {
|
|
1711
|
+
const sequenceValue = await storage.get<unknown>(
|
|
1712
|
+
mutationSequenceKey(record.connectionId, record.operation),
|
|
1713
|
+
);
|
|
1714
|
+
const sequence =
|
|
1715
|
+
sequenceValue === undefined
|
|
1716
|
+
? { next: mutationSequence, applied: 0 }
|
|
1717
|
+
: decodeMutationSequence(sequenceValue);
|
|
1718
|
+
if (mutationSequence < sequence.applied) return false;
|
|
1719
|
+
if (mutationSequence === sequence.applied) return true;
|
|
1720
|
+
const current = await this.host.settings.getConnection(
|
|
1721
|
+
record.accountId,
|
|
1722
|
+
record.connectionId,
|
|
1723
|
+
storage,
|
|
1724
|
+
);
|
|
1725
|
+
if (
|
|
1726
|
+
!current ||
|
|
1727
|
+
current.packageId !== PACKAGE_ID ||
|
|
1728
|
+
current.generation !== record.expectedGeneration
|
|
1729
|
+
) {
|
|
1730
|
+
return false;
|
|
1731
|
+
}
|
|
1732
|
+
const updated = update(current);
|
|
1733
|
+
if (!updated) return false;
|
|
1734
|
+
await this.host.settings.replaceConnection(
|
|
1735
|
+
record.accountId,
|
|
1736
|
+
record.connectionId,
|
|
1737
|
+
current.generation,
|
|
1738
|
+
updated,
|
|
1739
|
+
storage,
|
|
1740
|
+
);
|
|
1741
|
+
await storage.put(
|
|
1742
|
+
mutationSequenceKey(record.connectionId, record.operation),
|
|
1743
|
+
{
|
|
1744
|
+
schemaVersion: 1,
|
|
1745
|
+
next: Math.max(sequence.next, mutationSequence),
|
|
1746
|
+
applied: mutationSequence,
|
|
1747
|
+
},
|
|
1748
|
+
);
|
|
1749
|
+
return true;
|
|
1750
|
+
},
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
private async cancelPendingCredentialMutations(
|
|
1755
|
+
record: StoredCommand,
|
|
1756
|
+
storage: UserSettingsTransaction & CredentialTransaction,
|
|
1757
|
+
): Promise<void> {
|
|
1758
|
+
const pendingValue = await storage.get<unknown>(PENDING_KEY);
|
|
1759
|
+
const pending =
|
|
1760
|
+
pendingValue === undefined ? [] : decodePendingCommands(pendingValue);
|
|
1761
|
+
const retained: string[] = [];
|
|
1762
|
+
const completedIds: string[] = [];
|
|
1763
|
+
const completed: Record<string, unknown> = {};
|
|
1764
|
+
for (const commandId of pending) {
|
|
1765
|
+
const value = await storage.get<unknown>(commandKey(commandId));
|
|
1766
|
+
if (value === undefined) continue;
|
|
1767
|
+
const candidate = decodeStoredCommand(value);
|
|
1768
|
+
if (
|
|
1769
|
+
(candidate.operation === "connection/create-api-key" ||
|
|
1770
|
+
candidate.operation === "connection/rotate-api-key") &&
|
|
1771
|
+
candidate.connectionId === record.connectionId &&
|
|
1772
|
+
!candidate.receipt
|
|
1773
|
+
) {
|
|
1774
|
+
completed[commandKey(commandId)] = compactCompletedCommand({
|
|
1775
|
+
...candidate,
|
|
1776
|
+
receipt: receipt(candidate, "failed"),
|
|
1777
|
+
completedAt: this.now(),
|
|
1778
|
+
});
|
|
1779
|
+
completedIds.push(commandId);
|
|
1780
|
+
if (candidate.credentialGeneration) {
|
|
1781
|
+
await this.host.credentials.discardPending(
|
|
1782
|
+
candidate.connectionId,
|
|
1783
|
+
candidate.credentialGeneration,
|
|
1784
|
+
storage,
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
} else {
|
|
1788
|
+
retained.push(commandId);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
const receiptIndexValue = await storage.get<unknown>(RECEIPT_INDEX_KEY);
|
|
1792
|
+
const receiptIndex =
|
|
1793
|
+
receiptIndexValue === undefined
|
|
1794
|
+
? []
|
|
1795
|
+
: decodeReceiptIndex(receiptIndexValue);
|
|
1796
|
+
const ordered = [
|
|
1797
|
+
...receiptIndex.filter((commandId) => !completedIds.includes(commandId)),
|
|
1798
|
+
...completedIds,
|
|
1799
|
+
];
|
|
1800
|
+
const tombstonesValue = await storage.get<unknown>(COMMAND_TOMBSTONES_KEY);
|
|
1801
|
+
let tombstones =
|
|
1802
|
+
tombstonesValue === undefined
|
|
1803
|
+
? []
|
|
1804
|
+
: decodeCommandTombstones(tombstonesValue);
|
|
1805
|
+
for (const commandId of ordered.slice(0, -MAX_MANUAL_RECEIPTS)) {
|
|
1806
|
+
const key = commandKey(commandId);
|
|
1807
|
+
const value = completed[key] ?? (await storage.get<unknown>(key));
|
|
1808
|
+
if (value === undefined) continue;
|
|
1809
|
+
const compacted = compactCompletedCommand(decodeStoredCommand(value));
|
|
1810
|
+
tombstones = [
|
|
1811
|
+
...tombstones.filter((candidate) => candidate.commandId !== commandId),
|
|
1812
|
+
compacted,
|
|
1813
|
+
];
|
|
1814
|
+
delete completed[key];
|
|
1815
|
+
await storage.delete(key);
|
|
1816
|
+
}
|
|
1817
|
+
if (tombstones.length > MAX_COMMAND_TOMBSTONES) {
|
|
1818
|
+
throw new Error("Ollama Connection command history capacity reached");
|
|
1819
|
+
}
|
|
1820
|
+
await storage.put({
|
|
1821
|
+
...completed,
|
|
1822
|
+
[COMMAND_TOMBSTONES_KEY]: tombstones,
|
|
1823
|
+
[PENDING_KEY]: retained,
|
|
1824
|
+
[RECEIPT_INDEX_KEY]: ordered.slice(-MAX_MANUAL_RECEIPTS),
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
private async disconnect(
|
|
1829
|
+
record: StoredCommand,
|
|
1830
|
+
): Promise<ConnectionCommandReceiptV1> {
|
|
1831
|
+
const expectedGeneration = record.expectedGeneration;
|
|
1832
|
+
if (!expectedGeneration) return this.finishRecord(record, "failed");
|
|
1833
|
+
const transition = await this.host.storage.transaction(
|
|
1834
|
+
async (storage: UserSettingsTransaction & CredentialTransaction) => {
|
|
1835
|
+
const current = await this.host.settings.getConnection(
|
|
1836
|
+
record.accountId,
|
|
1837
|
+
record.connectionId,
|
|
1838
|
+
storage,
|
|
1839
|
+
);
|
|
1840
|
+
if (!current || current.packageId !== PACKAGE_ID) return "stale";
|
|
1841
|
+
if (current.generation !== expectedGeneration) return "stale";
|
|
1842
|
+
if (current.state === "revoked") {
|
|
1843
|
+
if (!record.revokeUpstream) return "revoked";
|
|
1844
|
+
await this.host.settings.replaceConnection(
|
|
1845
|
+
record.accountId,
|
|
1846
|
+
record.connectionId,
|
|
1847
|
+
expectedGeneration,
|
|
1848
|
+
{
|
|
1849
|
+
...current,
|
|
1850
|
+
state: "reconciliation-required",
|
|
1851
|
+
failure:
|
|
1852
|
+
"Ollama Cloud does not expose upstream API-key revocation",
|
|
1853
|
+
},
|
|
1854
|
+
storage,
|
|
1855
|
+
);
|
|
1856
|
+
return "reconciliation-required";
|
|
1857
|
+
}
|
|
1858
|
+
if (current.state === "reconciliation-required") {
|
|
1859
|
+
return "reconciliation-required";
|
|
1860
|
+
}
|
|
1861
|
+
if (
|
|
1862
|
+
Array.isArray(current.safeMetadata.dependentAssignments) &&
|
|
1863
|
+
current.safeMetadata.dependentAssignments.length > 0
|
|
1864
|
+
) {
|
|
1865
|
+
return "dependent";
|
|
1866
|
+
}
|
|
1867
|
+
await this.cancelPendingCredentialMutations(record, storage);
|
|
1868
|
+
if (current.state !== "revoking") {
|
|
1869
|
+
await this.host.settings.replaceConnection(
|
|
1870
|
+
record.accountId,
|
|
1871
|
+
record.connectionId,
|
|
1872
|
+
expectedGeneration,
|
|
1873
|
+
{ ...current, state: "revoking" },
|
|
1874
|
+
storage,
|
|
1875
|
+
);
|
|
1876
|
+
}
|
|
1877
|
+
return "revoking";
|
|
1878
|
+
},
|
|
1879
|
+
);
|
|
1880
|
+
if (transition === "stale" || transition === "dependent") {
|
|
1881
|
+
return this.finishRecord(record, "failed");
|
|
1882
|
+
}
|
|
1883
|
+
if (transition === "revoked") return this.finishRecord(record, "applied");
|
|
1884
|
+
if (transition === "reconciliation-required") {
|
|
1885
|
+
return this.finishRecord(record, "reconciliation-required");
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
await this.host.credentials.disconnect(record.connectionId);
|
|
1889
|
+
const terminalStatus = await this.host.storage.transaction(
|
|
1890
|
+
async (storage: UserSettingsTransaction & CredentialTransaction) => {
|
|
1891
|
+
const current = await this.host.settings.getConnection(
|
|
1892
|
+
record.accountId,
|
|
1893
|
+
record.connectionId,
|
|
1894
|
+
storage,
|
|
1895
|
+
);
|
|
1896
|
+
if (
|
|
1897
|
+
!current ||
|
|
1898
|
+
current.packageId !== PACKAGE_ID ||
|
|
1899
|
+
current.generation !== expectedGeneration
|
|
1900
|
+
) {
|
|
1901
|
+
return "failed" as const;
|
|
1902
|
+
}
|
|
1903
|
+
if (current.state === "reconciliation-required") {
|
|
1904
|
+
return "reconciliation-required" as const;
|
|
1905
|
+
}
|
|
1906
|
+
const unsupportedUpstreamRevoke = record.revokeUpstream === true;
|
|
1907
|
+
if (current.state === "revoked" && !unsupportedUpstreamRevoke) {
|
|
1908
|
+
return "applied" as const;
|
|
1909
|
+
}
|
|
1910
|
+
if (current.state !== "revoking" && current.state !== "revoked") {
|
|
1911
|
+
return "failed" as const;
|
|
1912
|
+
}
|
|
1913
|
+
await this.host.settings.replaceConnection(
|
|
1914
|
+
record.accountId,
|
|
1915
|
+
record.connectionId,
|
|
1916
|
+
expectedGeneration,
|
|
1917
|
+
{
|
|
1918
|
+
...current,
|
|
1919
|
+
state: unsupportedUpstreamRevoke
|
|
1920
|
+
? "reconciliation-required"
|
|
1921
|
+
: "revoked",
|
|
1922
|
+
authorization: {
|
|
1923
|
+
schemaVersion: 1,
|
|
1924
|
+
kind: "api-key",
|
|
1925
|
+
credential: {
|
|
1926
|
+
schemaVersion: 1,
|
|
1927
|
+
configured: false,
|
|
1928
|
+
source: "api-key",
|
|
1929
|
+
writable: true,
|
|
1930
|
+
},
|
|
1931
|
+
},
|
|
1932
|
+
...(unsupportedUpstreamRevoke
|
|
1933
|
+
? {
|
|
1934
|
+
failure:
|
|
1935
|
+
"Ollama Cloud does not expose upstream API-key revocation",
|
|
1936
|
+
}
|
|
1937
|
+
: {}),
|
|
1938
|
+
},
|
|
1939
|
+
storage,
|
|
1940
|
+
);
|
|
1941
|
+
return unsupportedUpstreamRevoke
|
|
1942
|
+
? ("reconciliation-required" as const)
|
|
1943
|
+
: ("applied" as const);
|
|
1944
|
+
},
|
|
1945
|
+
);
|
|
1946
|
+
return this.finishRecord(record, terminalStatus);
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
private async finishRecord(
|
|
1950
|
+
record: StoredCommand,
|
|
1951
|
+
status: ConnectionCommandReceiptV1["status"],
|
|
1952
|
+
): Promise<ConnectionCommandReceiptV1> {
|
|
1953
|
+
const proposed = receipt(record, status);
|
|
1954
|
+
const result = await this.host.storage.transaction(
|
|
1955
|
+
async (storage: UserSettingsTransaction & CredentialTransaction) => {
|
|
1956
|
+
const storedValue = await storage.get<unknown>(
|
|
1957
|
+
commandKey(record.commandId),
|
|
1958
|
+
);
|
|
1959
|
+
const stored =
|
|
1960
|
+
storedValue === undefined
|
|
1961
|
+
? undefined
|
|
1962
|
+
: decodeStoredCommand(storedValue);
|
|
1963
|
+
if (stored?.receipt) return stored.receipt;
|
|
1964
|
+
const pendingValue = await storage.get<unknown>(PENDING_KEY);
|
|
1965
|
+
const pending = (
|
|
1966
|
+
pendingValue === undefined ? [] : decodePendingCommands(pendingValue)
|
|
1967
|
+
).filter((id) => id !== record.commandId);
|
|
1968
|
+
if (record.automaticRefresh) {
|
|
1969
|
+
await storage.delete(commandKey(record.commandId));
|
|
1970
|
+
await storage.put({
|
|
1971
|
+
[`${AUTOMATIC_REFRESH_RECEIPT_PREFIX}${record.connectionId}`]: {
|
|
1972
|
+
schemaVersion: 1,
|
|
1973
|
+
commandId: record.commandId,
|
|
1974
|
+
connectionId: record.connectionId,
|
|
1975
|
+
status: proposed.status,
|
|
1976
|
+
completedAt: new Date(this.now()).toISOString(),
|
|
1977
|
+
},
|
|
1978
|
+
[PENDING_KEY]: pending,
|
|
1979
|
+
});
|
|
1980
|
+
} else {
|
|
1981
|
+
const receiptIndexValue =
|
|
1982
|
+
await storage.get<unknown>(RECEIPT_INDEX_KEY);
|
|
1983
|
+
const receiptIndex =
|
|
1984
|
+
receiptIndexValue === undefined
|
|
1985
|
+
? []
|
|
1986
|
+
: decodeReceiptIndex(receiptIndexValue);
|
|
1987
|
+
const ordered = [
|
|
1988
|
+
...receiptIndex.filter(
|
|
1989
|
+
(commandId) => commandId !== record.commandId,
|
|
1990
|
+
),
|
|
1991
|
+
record.commandId,
|
|
1992
|
+
];
|
|
1993
|
+
const retained = ordered.slice(-MAX_MANUAL_RECEIPTS);
|
|
1994
|
+
const {
|
|
1995
|
+
validationCatalog: _validationCatalog,
|
|
1996
|
+
validationFailure: _validationFailure,
|
|
1997
|
+
validationStatus: _validationStatus,
|
|
1998
|
+
...durableRecord
|
|
1999
|
+
} = stored ?? record;
|
|
2000
|
+
const completed = {
|
|
2001
|
+
...durableRecord,
|
|
2002
|
+
receipt: proposed,
|
|
2003
|
+
completedAt: this.now(),
|
|
2004
|
+
};
|
|
2005
|
+
const tombstonesValue = await storage.get<unknown>(
|
|
2006
|
+
COMMAND_TOMBSTONES_KEY,
|
|
2007
|
+
);
|
|
2008
|
+
let tombstones =
|
|
2009
|
+
tombstonesValue === undefined
|
|
2010
|
+
? []
|
|
2011
|
+
: decodeCommandTombstones(tombstonesValue);
|
|
2012
|
+
for (const commandId of ordered.slice(0, -MAX_MANUAL_RECEIPTS)) {
|
|
2013
|
+
const value = await storage.get<unknown>(commandKey(commandId));
|
|
2014
|
+
if (value === undefined) continue;
|
|
2015
|
+
const compacted = compactCompletedCommand(
|
|
2016
|
+
decodeStoredCommand(value),
|
|
2017
|
+
);
|
|
2018
|
+
tombstones = [
|
|
2019
|
+
...tombstones.filter(
|
|
2020
|
+
(candidate) => candidate.commandId !== commandId,
|
|
2021
|
+
),
|
|
2022
|
+
compacted,
|
|
2023
|
+
];
|
|
2024
|
+
await storage.delete(commandKey(commandId));
|
|
2025
|
+
}
|
|
2026
|
+
if (tombstones.length > MAX_COMMAND_TOMBSTONES) {
|
|
2027
|
+
throw new Error(
|
|
2028
|
+
"Ollama Connection command history capacity reached",
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
2031
|
+
await storage.put({
|
|
2032
|
+
[commandKey(record.commandId)]: completed,
|
|
2033
|
+
[COMMAND_TOMBSTONES_KEY]: tombstones,
|
|
2034
|
+
[RECEIPT_INDEX_KEY]: retained,
|
|
2035
|
+
[PENDING_KEY]: pending,
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
return proposed;
|
|
2039
|
+
},
|
|
2040
|
+
);
|
|
2041
|
+
await this.scheduleNextAlarm(record.accountId);
|
|
2042
|
+
return result;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
private async scheduleNextAlarm(accountId: string): Promise<void> {
|
|
2046
|
+
const pendingValue = await this.host.storage.get<unknown>(PENDING_KEY);
|
|
2047
|
+
const pending =
|
|
2048
|
+
pendingValue === undefined ? [] : decodePendingCommands(pendingValue);
|
|
2049
|
+
const settings = await this.host.settings.read(accountId);
|
|
2050
|
+
const packageInstalled = settings.packages.some(
|
|
2051
|
+
(pkg) => pkg.packageId === PACKAGE_ID && pkg.state === "installed",
|
|
2052
|
+
);
|
|
2053
|
+
const catalogDeadlines = packageInstalled
|
|
2054
|
+
? settings.connections.flatMap((connection) => {
|
|
2055
|
+
const refreshAfter = connection.modelCatalog?.refreshAfter;
|
|
2056
|
+
if (
|
|
2057
|
+
connection.packageId !== PACKAGE_ID ||
|
|
2058
|
+
connection.state !== "ready" ||
|
|
2059
|
+
!refreshAfter
|
|
2060
|
+
) {
|
|
2061
|
+
return [];
|
|
2062
|
+
}
|
|
2063
|
+
const deadline = Date.parse(refreshAfter);
|
|
2064
|
+
return Number.isFinite(deadline)
|
|
2065
|
+
? [Math.max(deadline, this.now() + RECOVERY_DELAY_MS)]
|
|
2066
|
+
: [];
|
|
2067
|
+
})
|
|
2068
|
+
: [];
|
|
2069
|
+
const credentialExpiry = await this.host.credentials.nextLeaseExpiry();
|
|
2070
|
+
const deadlines = [
|
|
2071
|
+
...(pending.length > 0 ? [this.now() + RECOVERY_DELAY_MS] : []),
|
|
2072
|
+
...catalogDeadlines,
|
|
2073
|
+
...(credentialExpiry === undefined ? [] : [credentialExpiry]),
|
|
2074
|
+
];
|
|
2075
|
+
if (deadlines.length > 0) {
|
|
2076
|
+
await this.host.storage.setAlarm(Math.min(...deadlines));
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
async leaseModelCredential(input: {
|
|
2081
|
+
accountId: string;
|
|
2082
|
+
connectionId: string;
|
|
2083
|
+
providerModelId: string;
|
|
2084
|
+
effectId: string;
|
|
2085
|
+
connectionGeneration: string;
|
|
2086
|
+
}): Promise<CredentialLeaseV1> {
|
|
2087
|
+
await this.host.credentials.expireLeases();
|
|
2088
|
+
const replay = await this.host.credentials.replayLease({
|
|
2089
|
+
accountId: input.accountId,
|
|
2090
|
+
connectionId: input.connectionId,
|
|
2091
|
+
packageId: PACKAGE_ID,
|
|
2092
|
+
effectId: input.effectId,
|
|
2093
|
+
});
|
|
2094
|
+
if (replay) return replay;
|
|
2095
|
+
|
|
2096
|
+
const admittedGeneration = input.connectionGeneration;
|
|
2097
|
+
const connection = await this.host.storage.transaction(
|
|
2098
|
+
async (storage: UserSettingsTransaction & CredentialTransaction) => {
|
|
2099
|
+
return this.requireModelAuthority(input, storage);
|
|
2100
|
+
},
|
|
2101
|
+
);
|
|
2102
|
+
const known = connection.modelCatalog?.models.some(
|
|
2103
|
+
(model) => model.providerModelId === input.providerModelId,
|
|
2104
|
+
);
|
|
2105
|
+
let resolvedModel: ConnectionModelCatalogV1["models"][number] | undefined;
|
|
2106
|
+
const resolutionKey = modelResolutionKey(input.effectId);
|
|
2107
|
+
if (!known) {
|
|
2108
|
+
const discoveryEffectId = `resolve:${input.effectId}`;
|
|
2109
|
+
const resolution = await this.host.storage.transaction(
|
|
2110
|
+
async (storage: UserSettingsTransaction & CredentialTransaction) => {
|
|
2111
|
+
await this.requireModelAuthority(input, storage);
|
|
2112
|
+
const storedValue = await storage.get<unknown>(resolutionKey);
|
|
2113
|
+
const stored =
|
|
2114
|
+
storedValue === undefined
|
|
2115
|
+
? undefined
|
|
2116
|
+
: decodeStoredModelResolution(storedValue);
|
|
2117
|
+
if (
|
|
2118
|
+
stored &&
|
|
2119
|
+
(stored.effectId !== input.effectId ||
|
|
2120
|
+
stored.accountId !== input.accountId ||
|
|
2121
|
+
stored.connectionId !== input.connectionId ||
|
|
2122
|
+
stored.connectionGeneration !== admittedGeneration ||
|
|
2123
|
+
stored.providerModelId !== input.providerModelId)
|
|
2124
|
+
) {
|
|
2125
|
+
throw new Error("Stored Ollama model resolution authority changed");
|
|
2126
|
+
}
|
|
2127
|
+
if (stored?.status === "applied" || stored?.status === "failed") {
|
|
2128
|
+
return { journal: stored };
|
|
2129
|
+
}
|
|
2130
|
+
const lease = await this.host.credentials.lease(
|
|
2131
|
+
{
|
|
2132
|
+
accountId: input.accountId,
|
|
2133
|
+
connectionId: input.connectionId,
|
|
2134
|
+
packageId: PACKAGE_ID,
|
|
2135
|
+
effectId: discoveryEffectId,
|
|
2136
|
+
expiresAt: new Date(this.now() + MODEL_LEASE_MS).toISOString(),
|
|
2137
|
+
expectedGeneration: admittedGeneration,
|
|
2138
|
+
},
|
|
2139
|
+
storage,
|
|
2140
|
+
);
|
|
2141
|
+
const journal: StoredModelResolution = stored ?? {
|
|
2142
|
+
schemaVersion: 1,
|
|
2143
|
+
effectId: input.effectId,
|
|
2144
|
+
accountId: input.accountId,
|
|
2145
|
+
connectionId: input.connectionId,
|
|
2146
|
+
connectionGeneration: admittedGeneration,
|
|
2147
|
+
providerModelId: input.providerModelId,
|
|
2148
|
+
retryPolicy: "safe-metadata-read",
|
|
2149
|
+
status: "pending",
|
|
2150
|
+
};
|
|
2151
|
+
if (!stored) await storage.put(resolutionKey, journal);
|
|
2152
|
+
return { journal, lease };
|
|
2153
|
+
},
|
|
2154
|
+
);
|
|
2155
|
+
let journal = resolution.journal;
|
|
2156
|
+
if (journal.status === "pending") {
|
|
2157
|
+
if (!resolution.lease) {
|
|
2158
|
+
throw new Error("Ollama model resolution lease is unavailable");
|
|
2159
|
+
}
|
|
2160
|
+
let outcome: StoredModelResolution;
|
|
2161
|
+
try {
|
|
2162
|
+
const apiKey = await this.host.credentials.openLease({
|
|
2163
|
+
accountId: input.accountId,
|
|
2164
|
+
packageId: PACKAGE_ID,
|
|
2165
|
+
lease: resolution.lease,
|
|
2166
|
+
});
|
|
2167
|
+
const model = await this.clientFor(
|
|
2168
|
+
connectionApiBaseUrl(connection),
|
|
2169
|
+
).resolveModel(apiKey, input.providerModelId);
|
|
2170
|
+
outcome = { ...journal, status: "applied", model };
|
|
2171
|
+
} catch (error) {
|
|
2172
|
+
outcome = {
|
|
2173
|
+
...journal,
|
|
2174
|
+
status: "failed",
|
|
2175
|
+
failure: (error instanceof Error && error.message
|
|
2176
|
+
? error.message
|
|
2177
|
+
: "Ollama Cloud model resolution failed"
|
|
2178
|
+
).slice(0, 500),
|
|
2179
|
+
};
|
|
2180
|
+
}
|
|
2181
|
+
journal = await this.host.storage.transaction(async (storage) => {
|
|
2182
|
+
const storedValue = await storage.get<unknown>(resolutionKey);
|
|
2183
|
+
if (storedValue === undefined) {
|
|
2184
|
+
throw new Error("Ollama model resolution journal is unavailable");
|
|
2185
|
+
}
|
|
2186
|
+
const stored = decodeStoredModelResolution(storedValue);
|
|
2187
|
+
if (stored.status === "pending") {
|
|
2188
|
+
await storage.put(resolutionKey, outcome);
|
|
2189
|
+
return outcome;
|
|
2190
|
+
}
|
|
2191
|
+
return stored;
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2194
|
+
await this.host.credentials.settle({
|
|
2195
|
+
accountId: input.accountId,
|
|
2196
|
+
connectionId: input.connectionId,
|
|
2197
|
+
packageId: PACKAGE_ID,
|
|
2198
|
+
effectId: discoveryEffectId,
|
|
2199
|
+
});
|
|
2200
|
+
if (journal.status === "failed") {
|
|
2201
|
+
throw new Error(journal.failure);
|
|
2202
|
+
}
|
|
2203
|
+
resolvedModel = journal.model;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
return this.host.storage.transaction(
|
|
2207
|
+
async (storage: UserSettingsTransaction & CredentialTransaction) => {
|
|
2208
|
+
const current = await this.requireModelAuthority(input, storage);
|
|
2209
|
+
if (
|
|
2210
|
+
resolvedModel &&
|
|
2211
|
+
!current.modelCatalog?.models.some(
|
|
2212
|
+
(model) => model.providerModelId === input.providerModelId,
|
|
2213
|
+
)
|
|
2214
|
+
) {
|
|
2215
|
+
const catalog = current.modelCatalog ?? this.catalog([]);
|
|
2216
|
+
await this.host.settings.replaceConnection(
|
|
2217
|
+
input.accountId,
|
|
2218
|
+
input.connectionId,
|
|
2219
|
+
admittedGeneration,
|
|
2220
|
+
{
|
|
2221
|
+
...current,
|
|
2222
|
+
modelCatalog: {
|
|
2223
|
+
...catalog,
|
|
2224
|
+
generation: this.randomId(),
|
|
2225
|
+
models: retainResolvedModel(catalog, resolvedModel),
|
|
2226
|
+
},
|
|
2227
|
+
},
|
|
2228
|
+
storage,
|
|
2229
|
+
);
|
|
2230
|
+
}
|
|
2231
|
+
const lease = await this.host.credentials.lease(
|
|
2232
|
+
{
|
|
2233
|
+
accountId: input.accountId,
|
|
2234
|
+
connectionId: input.connectionId,
|
|
2235
|
+
packageId: PACKAGE_ID,
|
|
2236
|
+
effectId: input.effectId,
|
|
2237
|
+
expiresAt: new Date(this.now() + MODEL_LEASE_MS).toISOString(),
|
|
2238
|
+
expectedGeneration: admittedGeneration,
|
|
2239
|
+
},
|
|
2240
|
+
storage,
|
|
2241
|
+
);
|
|
2242
|
+
if (!known) await storage.delete(resolutionKey);
|
|
2243
|
+
return lease;
|
|
2244
|
+
},
|
|
2245
|
+
);
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
/**
|
|
2249
|
+
* Lease this Connection's key for a *tool* effect — today the
|
|
2250
|
+
* `ollama-cloud-web-search` Capability. It is the model lease minus the
|
|
2251
|
+
* model: `/api/web_search` resolves nothing and bills no inference, so there
|
|
2252
|
+
* is no catalog to reconcile and no discovery lease to settle.
|
|
2253
|
+
*
|
|
2254
|
+
* The authority check is the same one the model path runs, and the lease is
|
|
2255
|
+
* keyed by the tool call's durable `effectId`, so a Turn resumed after
|
|
2256
|
+
* eviction replays the same lease rather than minting a second one.
|
|
2257
|
+
*/
|
|
2258
|
+
async leaseToolCredential(input: {
|
|
2259
|
+
accountId: string;
|
|
2260
|
+
connectionId: string;
|
|
2261
|
+
effectId: string;
|
|
2262
|
+
connectionGeneration: string;
|
|
2263
|
+
}): Promise<CredentialLeaseV1> {
|
|
2264
|
+
await this.host.credentials.expireLeases();
|
|
2265
|
+
const replay = await this.host.credentials.replayLease({
|
|
2266
|
+
accountId: input.accountId,
|
|
2267
|
+
connectionId: input.connectionId,
|
|
2268
|
+
packageId: PACKAGE_ID,
|
|
2269
|
+
effectId: input.effectId,
|
|
2270
|
+
});
|
|
2271
|
+
if (replay) return replay;
|
|
2272
|
+
return this.host.storage.transaction(
|
|
2273
|
+
async (storage: UserSettingsTransaction & CredentialTransaction) => {
|
|
2274
|
+
await this.requireModelAuthority(input, storage);
|
|
2275
|
+
return this.host.credentials.lease(
|
|
2276
|
+
{
|
|
2277
|
+
accountId: input.accountId,
|
|
2278
|
+
connectionId: input.connectionId,
|
|
2279
|
+
packageId: PACKAGE_ID,
|
|
2280
|
+
effectId: input.effectId,
|
|
2281
|
+
expiresAt: new Date(this.now() + MODEL_LEASE_MS).toISOString(),
|
|
2282
|
+
expectedGeneration: input.connectionGeneration,
|
|
2283
|
+
},
|
|
2284
|
+
storage,
|
|
2285
|
+
);
|
|
2286
|
+
},
|
|
2287
|
+
);
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
private async requireModelAuthority(
|
|
2291
|
+
input: {
|
|
2292
|
+
accountId: string;
|
|
2293
|
+
connectionId: string;
|
|
2294
|
+
connectionGeneration: string;
|
|
2295
|
+
},
|
|
2296
|
+
storage: UserSettingsTransaction,
|
|
2297
|
+
): Promise<ConnectionView> {
|
|
2298
|
+
const settings = await this.host.settings.readSnapshot(storage);
|
|
2299
|
+
if (
|
|
2300
|
+
!settings.packages.some(
|
|
2301
|
+
(pkg) => pkg.packageId === PACKAGE_ID && pkg.state === "installed",
|
|
2302
|
+
)
|
|
2303
|
+
) {
|
|
2304
|
+
throw new Error("Ollama Cloud Package is not installed and enabled");
|
|
2305
|
+
}
|
|
2306
|
+
const connection = settings.connections.find(
|
|
2307
|
+
(candidate) => candidate.connectionId === input.connectionId,
|
|
2308
|
+
);
|
|
2309
|
+
if (
|
|
2310
|
+
!connection ||
|
|
2311
|
+
connection.packageId !== PACKAGE_ID ||
|
|
2312
|
+
connection.state !== "ready" ||
|
|
2313
|
+
connection.generation !== input.connectionGeneration
|
|
2314
|
+
) {
|
|
2315
|
+
throw new Error("Connection changed before model authorization");
|
|
2316
|
+
}
|
|
2317
|
+
return connection;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
/**
|
|
2321
|
+
* Settle a tool effect's lease. Unlike the model path there is no `resolve:`
|
|
2322
|
+
* companion lease and no stored model resolution, because a tool effect
|
|
2323
|
+
* resolves no model.
|
|
2324
|
+
*/
|
|
2325
|
+
async settleToolCredential(input: {
|
|
2326
|
+
accountId: string;
|
|
2327
|
+
connectionId: string;
|
|
2328
|
+
effectId: string;
|
|
2329
|
+
}): Promise<void> {
|
|
2330
|
+
await this.host.credentials.settle({ ...input, packageId: PACKAGE_ID });
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
async settleModelCredential(input: {
|
|
2334
|
+
accountId: string;
|
|
2335
|
+
connectionId: string;
|
|
2336
|
+
effectId: string;
|
|
2337
|
+
}): Promise<void> {
|
|
2338
|
+
for (const effectId of [input.effectId, `resolve:${input.effectId}`]) {
|
|
2339
|
+
await this.host.credentials.settle({
|
|
2340
|
+
...input,
|
|
2341
|
+
packageId: PACKAGE_ID,
|
|
2342
|
+
effectId,
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
await this.host.storage.delete(modelResolutionKey(input.effectId));
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
async alarm(): Promise<void> {
|
|
2349
|
+
const pendingValue = await this.host.storage.get<unknown>(PENDING_KEY);
|
|
2350
|
+
const pending =
|
|
2351
|
+
pendingValue === undefined ? [] : decodePendingCommands(pendingValue);
|
|
2352
|
+
for (const commandId of pending.slice(
|
|
2353
|
+
0,
|
|
2354
|
+
MAX_PENDING_RECOVERIES_PER_ALARM,
|
|
2355
|
+
)) {
|
|
2356
|
+
const recordValue = await this.host.storage.get<unknown>(
|
|
2357
|
+
commandKey(commandId),
|
|
2358
|
+
);
|
|
2359
|
+
if (recordValue === undefined) continue;
|
|
2360
|
+
const record = decodeStoredCommand(recordValue);
|
|
2361
|
+
if (!record.receipt) await this.resumeOnce(record);
|
|
2362
|
+
}
|
|
2363
|
+
const accountValue = await this.host.storage.get<unknown>(ACCOUNT_KEY);
|
|
2364
|
+
if (accountValue === undefined) return;
|
|
2365
|
+
const accountId = decodeStoredAccount(accountValue);
|
|
2366
|
+
const settings = await this.host.settings.read(accountId);
|
|
2367
|
+
const packageInstalled = settings.packages.some(
|
|
2368
|
+
(pkg) => pkg.packageId === PACKAGE_ID && pkg.state === "installed",
|
|
2369
|
+
);
|
|
2370
|
+
if (!packageInstalled) {
|
|
2371
|
+
await this.scheduleNextAlarm(accountId);
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
const dueConnections = settings.connections
|
|
2375
|
+
.filter((connection) => {
|
|
2376
|
+
const refreshAfter = connection.modelCatalog?.refreshAfter;
|
|
2377
|
+
return (
|
|
2378
|
+
connection.packageId === PACKAGE_ID &&
|
|
2379
|
+
connection.state === "ready" &&
|
|
2380
|
+
refreshAfter !== undefined &&
|
|
2381
|
+
Date.parse(refreshAfter) <= this.now()
|
|
2382
|
+
);
|
|
2383
|
+
})
|
|
2384
|
+
.slice(0, MAX_CATALOG_REFRESHES_PER_ALARM);
|
|
2385
|
+
for (const connection of dueConnections) {
|
|
2386
|
+
const refreshAfter = connection.modelCatalog?.refreshAfter;
|
|
2387
|
+
if (!refreshAfter) continue;
|
|
2388
|
+
await this.executeCommand(
|
|
2389
|
+
accountId,
|
|
2390
|
+
decodeConnectionCommandV1({
|
|
2391
|
+
schemaVersion: 1,
|
|
2392
|
+
type: "connection/refresh-models",
|
|
2393
|
+
commandId: `refresh-${connection.connectionId}-${Date.parse(refreshAfter)}`,
|
|
2394
|
+
connectionId: connection.connectionId,
|
|
2395
|
+
}),
|
|
2396
|
+
true,
|
|
2397
|
+
);
|
|
2398
|
+
}
|
|
2399
|
+
await this.scheduleNextAlarm(accountId);
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
/**
|
|
2403
|
+
* The provider client for one Connection's endpoint.
|
|
2404
|
+
*
|
|
2405
|
+
* A host-supplied client wins, so a test or an embedding host can serve every
|
|
2406
|
+
* Connection from one stub; otherwise the host's factory, or the Package
|
|
2407
|
+
* default pointing at https://ollama.com, builds one per endpoint.
|
|
2408
|
+
*/
|
|
2409
|
+
private clientFor(apiBaseUrl?: string): OllamaCloudClient {
|
|
2410
|
+
if (this.host.client) return this.host.client;
|
|
2411
|
+
const config: OllamaCloudClientConfig =
|
|
2412
|
+
apiBaseUrl === undefined ? {} : { apiBaseUrl };
|
|
2413
|
+
return this.host.createClient
|
|
2414
|
+
? this.host.createClient(config)
|
|
2415
|
+
: new OllamaCloudClient(config);
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
private async requireConnection(
|
|
2419
|
+
accountId: string,
|
|
2420
|
+
connectionId: string,
|
|
2421
|
+
): Promise<ConnectionView> {
|
|
2422
|
+
const connection = await this.host.settings.getConnection(
|
|
2423
|
+
accountId,
|
|
2424
|
+
connectionId,
|
|
2425
|
+
);
|
|
2426
|
+
if (!connection || connection.packageId !== PACKAGE_ID) {
|
|
2427
|
+
throw new Error("Ollama Cloud Connection is unavailable");
|
|
2428
|
+
}
|
|
2429
|
+
return connection;
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
export function createOllamaCloudUserBackendContribution(
|
|
2434
|
+
host: OllamaUserBackendHost,
|
|
2435
|
+
): OllamaCloudUserBackendContribution {
|
|
2436
|
+
return new OllamaCloudUserBackendContribution(host);
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
export function createOllamaCloudUserBackendPlugin(
|
|
2440
|
+
host: OllamaUserBackendHost,
|
|
2441
|
+
lifecycle: { mount(value: OllamaCloudUserBackendContribution): () => void },
|
|
2442
|
+
): Plugin {
|
|
2443
|
+
return () => lifecycle.mount(createOllamaCloudUserBackendContribution(host));
|
|
2444
|
+
}
|