@mushinjs/plugin-providers 0.0.0-stage → 0.2.0-next.20261112165327

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/dist/index.js ADDED
@@ -0,0 +1,751 @@
1
+ import { MushinError, Service, isMasked, mask, randomId, toJsonSchema, usage, z } from "@mushinjs/core";
2
+ // src/index.ts
3
+ const STORAGE_KEY = "providers";
4
+ /** A chat request that shows the model an image is counted as reading it. */
5
+ function chatKind(request) {
6
+ return request.messages.some((message) => message.role === "user" && Array.isArray(message.content) && message.content.some((part) => part.type === "image")) ? "vision" : "chat";
7
+ }
8
+ /** Most texts sent in one embedding request. */
9
+ const EMBED_BATCH = 64;
10
+ const providerInput = z.object({
11
+ name: z.string().min(1).describe("Display name, such as \"DeepSeek\""),
12
+ type: z.string().describe("Provider type id from providers.types, such as \"openai\" or \"anthropic\""),
13
+ config: z.record(z.string(), z.any()).describe("Config matching the type's schema from providers.types"),
14
+ models: z.array(z.string()).optional().describe("Models offered by this provider"),
15
+ defaultModel: z.string().optional().describe("Model used when a request names none; any name the provider accepts, listed or not. Empty clears it"),
16
+ isDefault: z.boolean().optional().describe("Make this the default provider")
17
+ });
18
+ const profileInput = z.object({
19
+ name: z.string().trim().min(1).describe("Display name, such as \"Chat with fallbacks\""),
20
+ models: z.array(z.object({
21
+ provider: z.string().describe("Provider id from providers.list"),
22
+ model: z.string().trim().min(1).describe("Model name; any name the provider accepts, listed or not")
23
+ })).min(1).describe("The first model answers; each later one is tried when the one before it fails")
24
+ });
25
+ const decisionText = z.union([
26
+ z.string(),
27
+ z.record(z.string(), z.unknown()),
28
+ z.array(z.unknown()),
29
+ z.null()
30
+ ]);
31
+ const questionInput = z.discriminatedUnion("type", [
32
+ z.object({
33
+ type: z.literal("noul"),
34
+ instructions: decisionText.optional(),
35
+ criteria: z.object({
36
+ true: decisionText.optional(),
37
+ false: decisionText.optional()
38
+ }).optional().describe("What true and false mean")
39
+ }),
40
+ z.object({
41
+ type: z.literal("choice"),
42
+ instructions: decisionText.optional(),
43
+ criteria: z.record(z.string(), decisionText).refine((value) => {
44
+ const count = Object.keys(value).length;
45
+ return count >= 1 && count <= 255;
46
+ }, "Give 1 to 255 options").describe("Option name to description (1 to 255 options; null to go by the name alone)")
47
+ }),
48
+ z.object({
49
+ type: z.literal("score"),
50
+ instructions: decisionText.optional(),
51
+ criteria: z.array(decisionText).min(1).max(10).describe("1 to 10 ordered levels; the index is the score")
52
+ }),
53
+ z.object({
54
+ type: z.literal("box"),
55
+ instructions: decisionText.optional()
56
+ })
57
+ ]);
58
+ const decideInput = z.object({
59
+ provider: z.string().optional().describe("Provider id of a decision provider; the default decision provider when omitted"),
60
+ model: z.string().optional().describe("Model name; the provider default model when omitted"),
61
+ state: z.union([
62
+ z.string(),
63
+ z.record(z.string(), z.unknown()),
64
+ z.array(z.unknown())
65
+ ]).describe("What to decide about: text or any JSON value"),
66
+ questions: z.record(z.string(), questionInput).describe("Questions by name; each answer comes back under the same name"),
67
+ assets: z.record(z.string(), z.object({
68
+ type: z.literal("image"),
69
+ format: z.enum([
70
+ "png",
71
+ "jpg",
72
+ "jpeg"
73
+ ]),
74
+ data: z.string().describe("Base64 image data")
75
+ })).optional().describe("Images the state refers to by placeholder name")
76
+ });
77
+ /** The question a decision-only provider answers in its connection test. */
78
+ const TEST_DECISION = {
79
+ state: "The sky on a clear day is blue.",
80
+ questions: { ok: {
81
+ type: "noul",
82
+ instructions: "Is the statement true?"
83
+ } }
84
+ };
85
+ /**
86
+ * Owns provider records and exposes one `chat` entry point.
87
+ * Provider types (OpenAI-compatible, Anthropic, ...) are registered by other plugins.
88
+ */
89
+ var LlmService = class extends Service {
90
+ static inject = ["storage", "actions"];
91
+ home;
92
+ typeStore = /* @__PURE__ */ new Map();
93
+ records = [];
94
+ profileRecords = [];
95
+ clients = /* @__PURE__ */ new Map();
96
+ constructor(ctx) {
97
+ super(ctx, "llm");
98
+ this.home = ctx;
99
+ }
100
+ /** Provider lifecycle lines, tagged `provider` in the dashboard. Never includes config values. */
101
+ get logger() {
102
+ return this.home.logger("provider");
103
+ }
104
+ async *[Service.init]() {
105
+ const stored = await this.home.storage.get(STORAGE_KEY);
106
+ this.records = stored?.providers ?? [];
107
+ this.profileRecords = stored?.profiles ?? [];
108
+ this.logger.info("loaded %d provider(s)", this.records.length);
109
+ yield* this.defineActions();
110
+ }
111
+ /** Registers a provider type. It is removed when the calling plugin unloads. */
112
+ defineType(type) {
113
+ const ctx = this.ctx;
114
+ return ctx.effect(() => {
115
+ if (this.typeStore.has(type.id)) throw new Error(`provider type "${type.id}" is already defined`);
116
+ this.typeStore.set(type.id, type);
117
+ this.logger.info("provider type %s is available", type.id);
118
+ ctx.emit("providers/changed");
119
+ return () => {
120
+ this.typeStore.delete(type.id);
121
+ this.logger.info("provider type %s was removed", type.id);
122
+ for (const record of this.records) if (record.type === type.id) this.clients.delete(record.id);
123
+ ctx.emit("providers/changed");
124
+ };
125
+ }, `llm.defineType(${type.id})`);
126
+ }
127
+ types() {
128
+ return [...this.typeStore.values()].map((type) => ({
129
+ id: type.id,
130
+ displayName: type.displayName,
131
+ configSchema: toJsonSchema(type.config),
132
+ secrets: type.secrets ?? [],
133
+ presets: type.presets ?? [],
134
+ capabilities: type.capabilities ?? ["chat"]
135
+ }));
136
+ }
137
+ list() {
138
+ return this.records.map((record) => this.redact(record));
139
+ }
140
+ get(id) {
141
+ const record = this.records.find((item) => item.id === id);
142
+ if (!record) throw new MushinError("not_found", `No provider with id "${id}"`);
143
+ return record;
144
+ }
145
+ /** What a saved provider can do. A record whose type is not loaded counts as a chat provider. */
146
+ capabilities(record) {
147
+ return this.typeStore.get(record.type)?.capabilities ?? ["chat"];
148
+ }
149
+ supports(record, capability) {
150
+ return this.capabilities(record).includes(capability);
151
+ }
152
+ /** The default provider that can do `capability`, or the first such one if none is marked default. */
153
+ defaultProvider(capability = "chat") {
154
+ const able = this.records.filter((record) => this.supports(record, capability));
155
+ return able.find((record) => record.isDefault) ?? able[0];
156
+ }
157
+ /** True when at least one chat provider has passed its connection test. */
158
+ isReady() {
159
+ return this.records.some((record) => record.lastTest?.ok && this.supports(record, "chat"));
160
+ }
161
+ redact(record) {
162
+ const secrets = this.typeStore.get(record.type)?.secrets ?? [];
163
+ const config = { ...record.config };
164
+ for (const key of secrets) if (typeof config[key] === "string") config[key] = mask(config[key]);
165
+ return {
166
+ ...record,
167
+ config,
168
+ typeAvailable: this.typeStore.has(record.type),
169
+ capabilities: this.capabilities(record)
170
+ };
171
+ }
172
+ requireType(id) {
173
+ const type = this.typeStore.get(id);
174
+ if (!type) {
175
+ const known = [...this.typeStore.keys()].join(", ") || "none";
176
+ throw new MushinError("invalid_input", `Unknown provider type "${id}". Available types: ${known}`);
177
+ }
178
+ return type;
179
+ }
180
+ validateConfig(type, config) {
181
+ const result = type.config.safeParse(config);
182
+ if (!result.success) throw new MushinError("invalid_input", z.prettifyError(result.error), result.error.issues);
183
+ return result.data;
184
+ }
185
+ async save() {
186
+ await this.home.storage.set(STORAGE_KEY, {
187
+ providers: this.records,
188
+ profiles: this.profileRecords
189
+ });
190
+ this.home.emit("providers/changed");
191
+ }
192
+ async create(input) {
193
+ const type = this.requireType(input.type);
194
+ const record = {
195
+ id: randomId(),
196
+ name: input.name,
197
+ type: input.type,
198
+ config: this.validateConfig(type, input.config),
199
+ models: input.models ?? [],
200
+ defaultModel: input.defaultModel || input.models?.[0],
201
+ createdAt: Date.now()
202
+ };
203
+ const chat = type.capabilities?.includes("chat") ?? true;
204
+ if (input.isDefault || chat && !this.defaultProvider("chat")) this.markDefault(record);
205
+ this.records.push(record);
206
+ await this.save();
207
+ this.logger.info("created provider %s", this.label(record));
208
+ return this.redact(record);
209
+ }
210
+ async update(id, patch) {
211
+ const record = this.get(id);
212
+ const type = this.requireType(patch.type ?? record.type);
213
+ if (patch.config) {
214
+ const merged = { ...patch.config };
215
+ for (const key of type.secrets ?? []) if (isMasked(merged[key])) merged[key] = record.config[key];
216
+ record.config = this.validateConfig(type, merged);
217
+ }
218
+ if (patch.name !== void 0) record.name = patch.name;
219
+ if (patch.type !== void 0) record.type = patch.type;
220
+ if (patch.models !== void 0) record.models = patch.models;
221
+ if (patch.defaultModel !== void 0) record.defaultModel = patch.defaultModel || void 0;
222
+ if (patch.isDefault) this.markDefault(record);
223
+ record.lastTest = void 0;
224
+ this.clients.delete(id);
225
+ await this.save();
226
+ this.logger.info("updated provider %s", this.label(record));
227
+ return this.redact(record);
228
+ }
229
+ async delete(id) {
230
+ const record = this.get(id);
231
+ this.records = this.records.filter((item) => item !== record);
232
+ const next = this.defaultProvider("chat") ?? this.records[0];
233
+ if (record.isDefault && next) this.markDefault(next);
234
+ this.clients.delete(id);
235
+ for (const profile of this.profileRecords) profile.models = profile.models.filter((item) => item.provider !== id);
236
+ await this.save();
237
+ this.logger.info("deleted provider %s", this.label(record));
238
+ return { deleted: id };
239
+ }
240
+ markDefault(record) {
241
+ for (const item of this.records) item.isDefault = false;
242
+ record.isDefault = true;
243
+ }
244
+ /** Log lines name a provider by its id; the dashboard links it to the provider. */
245
+ label(record) {
246
+ return record.id;
247
+ }
248
+ async setDefault(id) {
249
+ this.markDefault(this.get(id));
250
+ await this.save();
251
+ this.logger.info("default provider is now %s", this.label(this.get(id)));
252
+ return this.redact(this.get(id));
253
+ }
254
+ profiles() {
255
+ return this.profileRecords;
256
+ }
257
+ getProfile(id) {
258
+ const profile = this.profileRecords.find((item) => item.id === id);
259
+ if (!profile) throw new MushinError("not_found", `No profile with id "${id}"`);
260
+ return profile;
261
+ }
262
+ isProfile(id) {
263
+ return this.profileRecords.some((item) => item.id === id);
264
+ }
265
+ checkProfileModels(models) {
266
+ for (const item of models) {
267
+ const record = this.get(item.provider);
268
+ if (!this.supports(record, "chat")) throw new MushinError("invalid_input", `Provider "${record.name}" does not chat, so it cannot be in a profile`);
269
+ }
270
+ return models.map((item) => ({
271
+ provider: item.provider,
272
+ model: item.model.trim()
273
+ }));
274
+ }
275
+ async createProfile(input) {
276
+ const profile = {
277
+ id: randomId(),
278
+ name: input.name,
279
+ models: this.checkProfileModels(input.models),
280
+ createdAt: Date.now()
281
+ };
282
+ this.profileRecords.push(profile);
283
+ await this.save();
284
+ this.logger.info("created profile %s with %d model(s)", profile.id, profile.models.length);
285
+ return profile;
286
+ }
287
+ async updateProfile(id, patch) {
288
+ const profile = this.getProfile(id);
289
+ if (patch.name !== void 0) profile.name = patch.name;
290
+ if (patch.models !== void 0) profile.models = this.checkProfileModels(patch.models);
291
+ await this.save();
292
+ this.logger.info("updated profile %s", profile.id);
293
+ return profile;
294
+ }
295
+ async deleteProfile(id) {
296
+ const profile = this.getProfile(id);
297
+ this.profileRecords = this.profileRecords.filter((item) => item !== profile);
298
+ await this.save();
299
+ this.logger.info("deleted profile %s", profile.id);
300
+ return { deleted: id };
301
+ }
302
+ /** Providers and profiles as `{ value, label }`, for fields that take either. */
303
+ choices() {
304
+ return [...this.records.filter((record) => this.supports(record, "chat")).map((record) => ({
305
+ value: record.id,
306
+ label: record.name,
307
+ group: "providers"
308
+ })), ...this.profileRecords.map((profile) => ({
309
+ value: profile.id,
310
+ label: profile.name,
311
+ group: "profiles"
312
+ }))];
313
+ }
314
+ client(record) {
315
+ const type = this.typeStore.get(record.type);
316
+ if (!type) throw new MushinError("unavailable", `Provider type "${record.type}" is not loaded; enable its plugin`);
317
+ const key = JSON.stringify(record.config);
318
+ const cached = this.clients.get(record.id);
319
+ if (cached?.key === key) return cached.client;
320
+ const client = type.create(record.config);
321
+ this.logger.debug("created a client for provider %s", this.label(record));
322
+ this.clients.set(record.id, {
323
+ key,
324
+ client
325
+ });
326
+ return client;
327
+ }
328
+ resolve(options, capability = "chat") {
329
+ const record = options.provider ? this.get(options.provider) : this.defaultProvider(capability);
330
+ if (!record) throw new MushinError("unavailable", capability === "chat" ? "No LLM provider is configured. Add one in Providers first." : "No decision provider is configured. Add one in Providers first.");
331
+ if (!this.supports(record, capability)) throw new MushinError("invalid_input", capability === "chat" ? `Provider "${record.name}" only answers decisions; use providers.decide` : `Provider "${record.name}" cannot answer decisions`);
332
+ const model = options.model ?? record.defaultModel ?? record.models[0];
333
+ if (!model) throw new MushinError("invalid_input", `Provider "${record.name}" has no model set`);
334
+ return {
335
+ record,
336
+ model,
337
+ client: this.client(record)
338
+ };
339
+ }
340
+ /** Where a request goes: one provider and model, or a profile's models in order. */
341
+ targets(options) {
342
+ const profile = options.provider ? this.profileRecords.find((item) => item.id === options.provider) : void 0;
343
+ if (!profile) {
344
+ const { record, model } = this.resolve(options);
345
+ return [{
346
+ record,
347
+ model
348
+ }];
349
+ }
350
+ const targets = profile.models.flatMap((item) => {
351
+ const record = this.records.find((provider) => provider.id === item.provider);
352
+ return record ? [{
353
+ record,
354
+ model: item.model
355
+ }] : [];
356
+ });
357
+ if (!targets.length) throw new MushinError("invalid_input", `Profile "${profile.name}" has no models whose provider still exists`);
358
+ return targets;
359
+ }
360
+ /** Logs a failed model of a profile and says whether to try the next one. */
361
+ fallBack(targets, index, error, signal) {
362
+ if (signal?.aborted || index === targets.length - 1) return false;
363
+ const { record, model } = targets[index];
364
+ const next = targets[index + 1];
365
+ this.logger.warn("falling back from %s (%s) to %s (%s): %s", record.id, model, next.record.id, next.model, error instanceof Error ? error.message : error);
366
+ return true;
367
+ }
368
+ /** Counts a request against the message being handled, if any; see `usage` in core. */
369
+ report(kind, record, model, latencyMs, used, ok = true) {
370
+ usage.report({
371
+ kind,
372
+ plugin: this.ctx.fiber.name,
373
+ provider: record.name,
374
+ model,
375
+ latencyMs,
376
+ ok,
377
+ ...used?.inputTokens !== void 0 ? { inputTokens: used.inputTokens } : {},
378
+ ...used?.outputTokens !== void 0 ? { outputTokens: used.outputTokens } : {}
379
+ });
380
+ }
381
+ result(record, model, start, response, request) {
382
+ const latencyMs = Date.now() - start;
383
+ const tokens = response.usage ? response.usage.inputTokens + response.usage.outputTokens : void 0;
384
+ this.report(chatKind(request), record, model, latencyMs, response.usage);
385
+ this.home.emit("llm/called", {
386
+ provider: record.id,
387
+ providerName: record.name,
388
+ model,
389
+ ok: true,
390
+ inputTokens: response.usage?.inputTokens,
391
+ outputTokens: response.usage?.outputTokens,
392
+ latencyMs
393
+ });
394
+ return {
395
+ ...response,
396
+ provider: record.id,
397
+ providerName: record.name,
398
+ model,
399
+ latencyMs,
400
+ source: {
401
+ kind: "llm",
402
+ provider: record.name,
403
+ model,
404
+ latencyMs,
405
+ tokens
406
+ }
407
+ };
408
+ }
409
+ /** Runs one chat completion through the chosen (or default) provider. */
410
+ async chat(options) {
411
+ const targets = this.targets(options);
412
+ for (let index = 0;; index++) {
413
+ const { record, model } = targets[index];
414
+ const start = Date.now();
415
+ try {
416
+ const response = await this.client(record).chat({
417
+ ...options,
418
+ model
419
+ });
420
+ return this.result(record, model, start, response, options);
421
+ } catch (error) {
422
+ this.logRequestError(record, model, error, start, chatKind(options));
423
+ if (!this.fallBack(targets, index, error, options.signal)) throw error;
424
+ }
425
+ }
426
+ }
427
+ /** Streams text deltas, then yields the final result. Falls back to `chat` for clients without streaming. */
428
+ async *stream(options) {
429
+ const targets = this.targets(options);
430
+ for (let index = 0;; index++) {
431
+ const { record, model } = targets[index];
432
+ const start = Date.now();
433
+ const request = {
434
+ ...options,
435
+ model
436
+ };
437
+ let streamed = false;
438
+ try {
439
+ const client = this.client(record);
440
+ if (!client.stream) {
441
+ const response = await client.chat(request);
442
+ if (response.text) yield {
443
+ type: "delta",
444
+ text: response.text
445
+ };
446
+ yield {
447
+ type: "result",
448
+ result: this.result(record, model, start, response, request)
449
+ };
450
+ return;
451
+ }
452
+ for await (const event of client.stream(request)) if (event.type === "delta") {
453
+ streamed = true;
454
+ yield event;
455
+ } else yield {
456
+ type: "result",
457
+ result: this.result(record, model, start, event.response, request)
458
+ };
459
+ return;
460
+ } catch (error) {
461
+ this.logRequestError(record, model, error, start, chatKind(request));
462
+ if (streamed || !this.fallBack(targets, index, error, options.signal)) throw error;
463
+ }
464
+ }
465
+ }
466
+ /** Whether a provider can embed text, for pickers. */
467
+ canEmbed(id) {
468
+ try {
469
+ return !!this.client(this.get(id)).embed;
470
+ } catch {
471
+ return false;
472
+ }
473
+ }
474
+ /** Embeds texts through a provider, `EMBED_BATCH` at a time; each batch is retried once. */
475
+ async embed(options) {
476
+ const record = options.provider ? this.get(options.provider) : this.defaultProvider("chat");
477
+ if (!record) throw new MushinError("unavailable", "No LLM provider is configured. Add one in Providers first.");
478
+ const client = this.client(record);
479
+ if (!client.embed) throw new MushinError("unavailable", `Provider "${record.name}" cannot embed text`);
480
+ const vectors = [];
481
+ let tokens = 0;
482
+ const start = Date.now();
483
+ for (let index = 0; index < options.texts.length; index += 64) {
484
+ const input = options.texts.slice(index, index + 64);
485
+ let response;
486
+ try {
487
+ response = await client.embed({
488
+ model: options.model,
489
+ input,
490
+ signal: options.signal
491
+ }).catch((error) => {
492
+ if (options.signal?.aborted) throw error;
493
+ return client.embed({
494
+ model: options.model,
495
+ input,
496
+ signal: options.signal
497
+ });
498
+ });
499
+ } catch (error) {
500
+ this.logRequestError(record, options.model, error, start, "embed");
501
+ throw error;
502
+ }
503
+ if (response.vectors.length !== input.length) throw new MushinError("unavailable", `Provider "${record.name}" returned ${response.vectors.length} vectors for ${input.length} texts`);
504
+ vectors.push(...response.vectors);
505
+ tokens += response.usage?.inputTokens ?? 0;
506
+ }
507
+ this.home.emit("llm/called", {
508
+ provider: record.id,
509
+ providerName: record.name,
510
+ model: options.model,
511
+ ok: true,
512
+ inputTokens: tokens || void 0,
513
+ latencyMs: Date.now() - start
514
+ });
515
+ this.report("embed", record, options.model, Date.now() - start, tokens ? { inputTokens: tokens } : void 0);
516
+ return {
517
+ vectors,
518
+ provider: record.id,
519
+ model: options.model,
520
+ dimension: vectors[0]?.length ?? 0,
521
+ ...tokens ? { tokens } : {}
522
+ };
523
+ }
524
+ /** Asks a decision provider structured questions about a state. */
525
+ async decide(options) {
526
+ const { record, model, client } = this.resolve(options, "decision");
527
+ if (!client.decide) throw new MushinError("unavailable", `Provider "${record.name}" cannot answer decisions`);
528
+ const start = Date.now();
529
+ let response;
530
+ try {
531
+ response = await client.decide({
532
+ model,
533
+ state: options.state,
534
+ questions: options.questions,
535
+ assets: options.assets,
536
+ signal: options.signal
537
+ });
538
+ } catch (error) {
539
+ this.logRequestError(record, model, error, start, "decision");
540
+ throw error;
541
+ }
542
+ const latencyMs = Date.now() - start;
543
+ this.report("decision", record, model, latencyMs, response.usage);
544
+ this.home.emit("llm/called", {
545
+ provider: record.id,
546
+ providerName: record.name,
547
+ model,
548
+ ok: true,
549
+ inputTokens: response.usage?.inputTokens,
550
+ outputTokens: response.usage?.outputTokens,
551
+ latencyMs
552
+ });
553
+ return {
554
+ ...response,
555
+ provider: record.id,
556
+ providerName: record.name,
557
+ model,
558
+ latencyMs
559
+ };
560
+ }
561
+ logRequestError(record, model, error, start, kind) {
562
+ if (error instanceof Error && error.name === "AbortError") return;
563
+ this.report(kind, record, model, Date.now() - start, void 0, false);
564
+ this.home.emit("llm/called", {
565
+ provider: record.id,
566
+ providerName: record.name,
567
+ model,
568
+ ok: false,
569
+ latencyMs: Date.now() - start
570
+ });
571
+ this.logger.warn("request to %s (%s) failed: %s", this.label(record), model, error instanceof Error ? error.message : error);
572
+ }
573
+ async test(id) {
574
+ const record = this.get(id);
575
+ const start = Date.now();
576
+ let test;
577
+ try {
578
+ const capability = this.supports(record, "chat") ? "chat" : "decision";
579
+ const { model, client } = this.resolve({ provider: id }, capability);
580
+ const signal = AbortSignal.timeout(6e4);
581
+ if (capability === "chat") await client.chat({
582
+ model,
583
+ messages: [{
584
+ role: "user",
585
+ content: "Reply with OK."
586
+ }],
587
+ maxTokens: 256,
588
+ signal
589
+ });
590
+ else {
591
+ if (!client.decide) throw new MushinError("unavailable", `Provider "${record.name}" cannot answer decisions`);
592
+ if ((await client.decide({
593
+ model,
594
+ ...TEST_DECISION,
595
+ signal
596
+ })).answers.ok?.type !== "noul") throw new MushinError("unavailable", "The provider did not answer the test question");
597
+ }
598
+ test = {
599
+ ok: true,
600
+ at: Date.now(),
601
+ latencyMs: Date.now() - start,
602
+ model
603
+ };
604
+ } catch (error) {
605
+ test = {
606
+ ok: false,
607
+ at: Date.now(),
608
+ error: error instanceof Error ? error.message : String(error)
609
+ };
610
+ }
611
+ record.lastTest = test;
612
+ await this.save();
613
+ if (test.ok) this.logger.info("provider %s passed its test in %d ms", this.label(record), test.latencyMs);
614
+ else this.logger.warn("provider %s failed its test: %s", this.label(record), test.error);
615
+ return test;
616
+ }
617
+ async models(input) {
618
+ let client;
619
+ if (input.provider) client = this.client(this.get(input.provider));
620
+ else if (input.type && input.config) client = this.requireType(input.type).create(input.config);
621
+ else throw new MushinError("invalid_input", "Pass a provider id, or a type with its config");
622
+ if (!client.listModels) throw new MushinError("unavailable", "This provider type cannot list models");
623
+ return client.listModels(AbortSignal.timeout(15e3));
624
+ }
625
+ *defineActions() {
626
+ const actions = this.home.actions;
627
+ const idInput = z.object({ id: z.string().describe("Provider id from providers.list") });
628
+ yield actions.define({
629
+ name: "providers.types",
630
+ description: "List provider types with their config JSON Schemas and presets (base URLs for DeepSeek, Moonshot, Qwen and others).",
631
+ risk: "read",
632
+ input: z.object({}),
633
+ handler: () => this.types()
634
+ });
635
+ yield actions.define({
636
+ name: "providers.list",
637
+ description: "List configured LLM providers and model profiles. Secrets are masked. Also reports whether the Agent is ready (a provider passed its test).",
638
+ risk: "read",
639
+ input: z.object({}),
640
+ handler: () => ({
641
+ providers: this.list(),
642
+ profiles: this.profiles(),
643
+ ready: this.isReady()
644
+ })
645
+ });
646
+ yield actions.define({
647
+ name: "providers.choices",
648
+ description: "Providers and model profiles as ids and names, for fields that take a provider id. A profile id works wherever a provider id does.",
649
+ risk: "read",
650
+ input: z.object({}),
651
+ handler: () => this.choices()
652
+ });
653
+ yield actions.define({
654
+ name: "providers.createProfile",
655
+ description: "Add a model profile: a primary model and fallbacks, each a provider id and a model name. Requests through the profile try them in order until one answers. Use the profile id wherever a provider id is taken.",
656
+ risk: "write",
657
+ input: profileInput,
658
+ handler: (input) => this.createProfile(input)
659
+ });
660
+ yield actions.define({
661
+ name: "providers.updateProfile",
662
+ description: "Rename a model profile or replace its ordered model list.",
663
+ risk: "write",
664
+ input: z.object({ id: z.string().describe("Profile id from providers.list") }).extend(profileInput.partial().shape),
665
+ handler: ({ id, ...patch }) => this.updateProfile(id, patch)
666
+ });
667
+ yield actions.define({
668
+ name: "providers.deleteProfile",
669
+ description: "Delete a model profile. Places that used it fall back to the default provider.",
670
+ risk: "dangerous",
671
+ input: z.object({ id: z.string().describe("Profile id from providers.list") }),
672
+ handler: ({ id }) => this.deleteProfile(id)
673
+ });
674
+ yield actions.define({
675
+ name: "providers.create",
676
+ description: "Add an LLM provider. Run providers.test afterwards to check it works.",
677
+ risk: "write",
678
+ input: providerInput,
679
+ handler: (input) => this.create(input)
680
+ });
681
+ yield actions.define({
682
+ name: "providers.update",
683
+ description: "Change a provider. Masked secret values are kept as they are.",
684
+ risk: "write",
685
+ input: idInput.extend(providerInput.partial().shape),
686
+ handler: ({ id, ...patch }) => this.update(id, patch)
687
+ });
688
+ yield actions.define({
689
+ name: "providers.delete",
690
+ description: "Delete a provider.",
691
+ risk: "dangerous",
692
+ input: idInput,
693
+ handler: ({ id }) => this.delete(id)
694
+ });
695
+ yield actions.define({
696
+ name: "providers.test",
697
+ description: "Send a tiny request through a provider and report latency or the error.",
698
+ risk: "write",
699
+ input: idInput,
700
+ handler: ({ id }) => this.test(id)
701
+ });
702
+ yield actions.define({
703
+ name: "providers.setDefault",
704
+ description: "Make a provider the default for the Agent and bot plugins.",
705
+ risk: "write",
706
+ input: idInput,
707
+ handler: ({ id }) => this.setDefault(id)
708
+ });
709
+ yield actions.define({
710
+ name: "providers.decide",
711
+ description: "Ask a decision provider (such as TypeSafe Jev) structured questions about a state: noul (probability a statement holds), choice (pick a named option), score (grade on an ordered scale) or box (locate something in an image asset; edges come back as 0-1 fractions of the image). Instructions and criteria may be text or JSON. Returns one answer per question name.",
712
+ risk: "read",
713
+ input: decideInput,
714
+ handler: (input) => this.decide(input)
715
+ });
716
+ yield actions.define({
717
+ name: "providers.decideChoices",
718
+ description: "Providers that answer decisions, as ids and names.",
719
+ risk: "read",
720
+ input: z.object({}),
721
+ handler: () => this.records.filter((record) => this.supports(record, "decision")).map((record) => ({
722
+ value: record.id,
723
+ label: record.name
724
+ }))
725
+ });
726
+ yield actions.define({
727
+ name: "providers.embedChoices",
728
+ description: "Providers that can embed text (for knowledge bases), as ids and names.",
729
+ risk: "read",
730
+ input: z.object({}),
731
+ handler: () => this.records.filter((record) => this.canEmbed(record.id)).map((record) => ({
732
+ value: record.id,
733
+ label: record.name
734
+ }))
735
+ });
736
+ yield actions.define({
737
+ name: "providers.models",
738
+ description: "Fetch the live model list from a saved provider, or from an unsaved type and config.",
739
+ risk: "read",
740
+ input: z.object({
741
+ provider: z.string().optional(),
742
+ type: z.string().optional(),
743
+ config: z.record(z.string(), z.any()).optional()
744
+ }),
745
+ handler: (input) => this.models(input)
746
+ });
747
+ }
748
+ };
749
+ export { EMBED_BATCH, LlmService, LlmService as default };
750
+
751
+ //# sourceMappingURL=index.js.map