@objectstack/service-ai 6.5.1 → 6.7.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/dist/index.d.cts CHANGED
@@ -293,7 +293,7 @@ interface AIServiceConfig {
293
293
  * the {@link AIServicePlugin}.
294
294
  */
295
295
  declare class AIService implements IAIService {
296
- private readonly adapter;
296
+ private adapter;
297
297
  private readonly logger;
298
298
  readonly toolRegistry: ToolRegistry;
299
299
  readonly conversationService: IAIConversationService;
@@ -311,6 +311,13 @@ declare class AIService implements IAIService {
311
311
  constructor(config?: AIServiceConfig);
312
312
  /** The name of the active LLM adapter. */
313
313
  get adapterName(): string;
314
+ /**
315
+ * Hot-swap the LLM adapter. Used by AIServicePlugin when the `ai`
316
+ * settings namespace changes (provider/key/model edited via Setup UI).
317
+ * In-flight requests bound to the previous adapter complete normally;
318
+ * subsequent calls go through the new adapter.
319
+ */
320
+ setAdapter(next: LLMAdapter): void;
314
321
  /**
315
322
  * Best-effort persistence of a single chat message to the conversation
316
323
  * store. Failures are logged at warn level and swallowed — chat requests
@@ -451,6 +458,14 @@ interface AIServicePluginOptions {
451
458
  * until an operator explicitly enables this routing).
452
459
  */
453
460
  enableActionApproval?: boolean;
461
+ /**
462
+ * Bind to the `ai` settings namespace and rebuild the LLM adapter on
463
+ * every `settings:changed` event. When enabled (default), operators
464
+ * can edit provider/credentials/model via the Setup app and the
465
+ * change applies live without restart. Disable to lock the adapter
466
+ * to whatever was resolved at boot (constructor option or env var).
467
+ */
468
+ bindToSettings?: boolean;
454
469
  }
455
470
  /**
456
471
  * AIServicePlugin — Kernel plugin for the unified AI capability service.
@@ -483,6 +498,25 @@ declare class AIServicePlugin implements Plugin {
483
498
  private service?;
484
499
  private readonly options;
485
500
  constructor(options?: AIServicePluginOptions);
501
+ /**
502
+ * Build an LLM adapter from a provider/key/model triple. Used both
503
+ * by the boot-time auto-detect path and by the live `settings:changed`
504
+ * rebuild path. Returns `null` if the requested provider cannot be
505
+ * loaded or required credentials are missing.
506
+ */
507
+ private buildAdapterFromValues;
508
+ /**
509
+ * Build an `IEmbedder` instance from embedder settings values
510
+ * (`embedder_provider`, `embedder_api_key`, …) by dynamically
511
+ * importing `@objectstack/embedder-openai`. Returns `null` for
512
+ * `none` (embedder disabled) or when required credentials are
513
+ * missing / the package isn't installed.
514
+ *
515
+ * The OpenAI-compatible plugin covers OpenAI, Azure, 阿里通义,
516
+ * 智谱, 硅基流动, 火山 Doubao, MiniMax, Ollama, and any custom
517
+ * OpenAI-shape endpoint via `embedder_base_url`.
518
+ */
519
+ private buildEmbedderFromValues;
486
520
  /**
487
521
  * Auto-detect LLM provider from environment variables.
488
522
  *
@@ -498,6 +532,12 @@ declare class AIServicePlugin implements Plugin {
498
532
  private detectAdapter;
499
533
  init(ctx: PluginContext): Promise<void>;
500
534
  start(ctx: PluginContext): Promise<void>;
535
+ /**
536
+ * Resolve the `settings` service, apply any persisted `ai` values,
537
+ * subscribe to changes, and register the live `ai/test` action
538
+ * (overrides the manifest's fallback stub).
539
+ */
540
+ private bindSettings;
501
541
  destroy(): Promise<void>;
502
542
  }
503
543
 
package/dist/index.d.ts CHANGED
@@ -293,7 +293,7 @@ interface AIServiceConfig {
293
293
  * the {@link AIServicePlugin}.
294
294
  */
295
295
  declare class AIService implements IAIService {
296
- private readonly adapter;
296
+ private adapter;
297
297
  private readonly logger;
298
298
  readonly toolRegistry: ToolRegistry;
299
299
  readonly conversationService: IAIConversationService;
@@ -311,6 +311,13 @@ declare class AIService implements IAIService {
311
311
  constructor(config?: AIServiceConfig);
312
312
  /** The name of the active LLM adapter. */
313
313
  get adapterName(): string;
314
+ /**
315
+ * Hot-swap the LLM adapter. Used by AIServicePlugin when the `ai`
316
+ * settings namespace changes (provider/key/model edited via Setup UI).
317
+ * In-flight requests bound to the previous adapter complete normally;
318
+ * subsequent calls go through the new adapter.
319
+ */
320
+ setAdapter(next: LLMAdapter): void;
314
321
  /**
315
322
  * Best-effort persistence of a single chat message to the conversation
316
323
  * store. Failures are logged at warn level and swallowed — chat requests
@@ -451,6 +458,14 @@ interface AIServicePluginOptions {
451
458
  * until an operator explicitly enables this routing).
452
459
  */
453
460
  enableActionApproval?: boolean;
461
+ /**
462
+ * Bind to the `ai` settings namespace and rebuild the LLM adapter on
463
+ * every `settings:changed` event. When enabled (default), operators
464
+ * can edit provider/credentials/model via the Setup app and the
465
+ * change applies live without restart. Disable to lock the adapter
466
+ * to whatever was resolved at boot (constructor option or env var).
467
+ */
468
+ bindToSettings?: boolean;
454
469
  }
455
470
  /**
456
471
  * AIServicePlugin — Kernel plugin for the unified AI capability service.
@@ -483,6 +498,25 @@ declare class AIServicePlugin implements Plugin {
483
498
  private service?;
484
499
  private readonly options;
485
500
  constructor(options?: AIServicePluginOptions);
501
+ /**
502
+ * Build an LLM adapter from a provider/key/model triple. Used both
503
+ * by the boot-time auto-detect path and by the live `settings:changed`
504
+ * rebuild path. Returns `null` if the requested provider cannot be
505
+ * loaded or required credentials are missing.
506
+ */
507
+ private buildAdapterFromValues;
508
+ /**
509
+ * Build an `IEmbedder` instance from embedder settings values
510
+ * (`embedder_provider`, `embedder_api_key`, …) by dynamically
511
+ * importing `@objectstack/embedder-openai`. Returns `null` for
512
+ * `none` (embedder disabled) or when required credentials are
513
+ * missing / the package isn't installed.
514
+ *
515
+ * The OpenAI-compatible plugin covers OpenAI, Azure, 阿里通义,
516
+ * 智谱, 硅基流动, 火山 Doubao, MiniMax, Ollama, and any custom
517
+ * OpenAI-shape endpoint via `embedder_base_url`.
518
+ */
519
+ private buildEmbedderFromValues;
486
520
  /**
487
521
  * Auto-detect LLM provider from environment variables.
488
522
  *
@@ -498,6 +532,12 @@ declare class AIServicePlugin implements Plugin {
498
532
  private detectAdapter;
499
533
  init(ctx: PluginContext): Promise<void>;
500
534
  start(ctx: PluginContext): Promise<void>;
535
+ /**
536
+ * Resolve the `settings` service, apply any persisted `ai` values,
537
+ * subscribe to changes, and register the live `ai/test` action
538
+ * (overrides the manifest's fallback stub).
539
+ */
540
+ private bindSettings;
501
541
  destroy(): Promise<void>;
502
542
  }
503
543
 
package/dist/index.js CHANGED
@@ -1443,6 +1443,19 @@ var _AIService = class _AIService {
1443
1443
  get adapterName() {
1444
1444
  return this.adapter.name;
1445
1445
  }
1446
+ /**
1447
+ * Hot-swap the LLM adapter. Used by AIServicePlugin when the `ai`
1448
+ * settings namespace changes (provider/key/model edited via Setup UI).
1449
+ * In-flight requests bound to the previous adapter complete normally;
1450
+ * subsequent calls go through the new adapter.
1451
+ */
1452
+ setAdapter(next) {
1453
+ const prev = this.adapter.name;
1454
+ this.adapter = next;
1455
+ if (prev !== next.name) {
1456
+ this.logger.info(`[AI] LLM adapter swapped: ${prev} \u2192 ${next.name}`);
1457
+ }
1458
+ }
1446
1459
  /**
1447
1460
  * Best-effort persistence of a single chat message to the conversation
1448
1461
  * store. Failures are logged at warn level and swallowed — chat requests
@@ -1934,6 +1947,9 @@ function cryptoRandomId() {
1934
1947
  return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
1935
1948
  }
1936
1949
 
1950
+ // src/plugin.ts
1951
+ import { EMBEDDER_SERVICE } from "@objectstack/spec/contracts";
1952
+
1937
1953
  // src/stream/vercel-stream-encoder.ts
1938
1954
  function sse(data) {
1939
1955
  return `data: ${JSON.stringify(data)}
@@ -5349,6 +5365,136 @@ var AIServicePlugin = class {
5349
5365
  this.dependencies = ["com.objectstack.engine.objectql"];
5350
5366
  this.options = options;
5351
5367
  }
5368
+ /**
5369
+ * Build an LLM adapter from a provider/key/model triple. Used both
5370
+ * by the boot-time auto-detect path and by the live `settings:changed`
5371
+ * rebuild path. Returns `null` if the requested provider cannot be
5372
+ * loaded or required credentials are missing.
5373
+ */
5374
+ async buildAdapterFromValues(ctx, values) {
5375
+ const provider = String(values.provider ?? "memory");
5376
+ if (provider === "memory") {
5377
+ return { adapter: new MemoryLLMAdapter(), description: "MemoryLLMAdapter (echo mode)" };
5378
+ }
5379
+ if (provider === "gateway") {
5380
+ const gatewayModel = String(values.gateway_model ?? "").trim();
5381
+ if (!gatewayModel) return null;
5382
+ try {
5383
+ const gatewayPkg = "@ai-sdk/gateway";
5384
+ const { gateway } = await import(
5385
+ /* webpackIgnore: true */
5386
+ gatewayPkg
5387
+ );
5388
+ return {
5389
+ adapter: new VercelLLMAdapter({ model: gateway(gatewayModel) }),
5390
+ description: `Vercel AI Gateway (model: ${gatewayModel})`
5391
+ };
5392
+ } catch (err) {
5393
+ ctx.logger.warn(
5394
+ `[AI] Failed to load @ai-sdk/gateway for provider=gateway`,
5395
+ err instanceof Error ? { error: err.message } : void 0
5396
+ );
5397
+ return null;
5398
+ }
5399
+ }
5400
+ const providerSpecs = {
5401
+ openai: { pkg: "@ai-sdk/openai", factory: "openai", defaultModel: "gpt-4o", displayName: "OpenAI" },
5402
+ anthropic: { pkg: "@ai-sdk/anthropic", factory: "anthropic", defaultModel: "claude-sonnet-4-20250514", displayName: "Anthropic" },
5403
+ google: { pkg: "@ai-sdk/google", factory: "google", defaultModel: "gemini-2.0-flash", displayName: "Google" }
5404
+ };
5405
+ const spec = providerSpecs[provider];
5406
+ if (!spec) return null;
5407
+ const apiKey = String(values[`${provider}_api_key`] ?? "").trim();
5408
+ if (!apiKey) return null;
5409
+ const envKey = provider === "openai" ? "OPENAI_API_KEY" : provider === "anthropic" ? "ANTHROPIC_API_KEY" : "GOOGLE_GENERATIVE_AI_API_KEY";
5410
+ process.env[envKey] = apiKey;
5411
+ try {
5412
+ const mod = await import(
5413
+ /* webpackIgnore: true */
5414
+ spec.pkg
5415
+ );
5416
+ const factory = mod[spec.factory] ?? mod.default;
5417
+ if (typeof factory !== "function") return null;
5418
+ const modelId = String(values[`${provider}_model`] ?? "").trim() || spec.defaultModel;
5419
+ const useChatApi = provider === "openai" && typeof factory.chat === "function";
5420
+ const model = useChatApi ? factory.chat(modelId) : factory(modelId);
5421
+ const apiSuffix = useChatApi ? " [chat-completions]" : "";
5422
+ return {
5423
+ adapter: new VercelLLMAdapter({ model }),
5424
+ description: `${spec.displayName} (model: ${modelId})${apiSuffix}`
5425
+ };
5426
+ } catch (err) {
5427
+ ctx.logger.warn(
5428
+ `[AI] Failed to load ${spec.pkg} for provider=${provider}`,
5429
+ err instanceof Error ? { error: err.message } : void 0
5430
+ );
5431
+ return null;
5432
+ }
5433
+ }
5434
+ /**
5435
+ * Build an `IEmbedder` instance from embedder settings values
5436
+ * (`embedder_provider`, `embedder_api_key`, …) by dynamically
5437
+ * importing `@objectstack/embedder-openai`. Returns `null` for
5438
+ * `none` (embedder disabled) or when required credentials are
5439
+ * missing / the package isn't installed.
5440
+ *
5441
+ * The OpenAI-compatible plugin covers OpenAI, Azure, 阿里通义,
5442
+ * 智谱, 硅基流动, 火山 Doubao, MiniMax, Ollama, and any custom
5443
+ * OpenAI-shape endpoint via `embedder_base_url`.
5444
+ */
5445
+ async buildEmbedderFromValues(ctx, values) {
5446
+ const provider = String(values.embedder_provider ?? "none").trim();
5447
+ if (!provider || provider === "none") return null;
5448
+ const apiKey = String(values.embedder_api_key ?? "").trim();
5449
+ const model = String(values.embedder_model ?? "").trim() || void 0;
5450
+ const baseUrlOverride = String(values.embedder_base_url ?? "").trim() || void 0;
5451
+ const dimensions = values.embedder_dimensions != null && values.embedder_dimensions !== "" ? Number(values.embedder_dimensions) : void 0;
5452
+ if (!apiKey && provider !== "ollama") {
5453
+ ctx.logger.warn(
5454
+ `[AI] Embedder provider=${provider} requires embedder_api_key \u2014 embedder unchanged.`
5455
+ );
5456
+ return null;
5457
+ }
5458
+ if ((provider === "custom" || provider === "azure") && !baseUrlOverride) {
5459
+ ctx.logger.warn(
5460
+ `[AI] Embedder provider=${provider} requires embedder_base_url \u2014 embedder unchanged.`
5461
+ );
5462
+ return null;
5463
+ }
5464
+ try {
5465
+ const pkg = "@objectstack/embedder-openai";
5466
+ const mod = await import(
5467
+ /* webpackIgnore: true */
5468
+ pkg
5469
+ );
5470
+ const create = mod.createOpenAIEmbedder ?? mod.default?.createOpenAIEmbedder;
5471
+ if (typeof create !== "function") {
5472
+ ctx.logger.warn(
5473
+ `[AI] ${pkg} did not export createOpenAIEmbedder \u2014 embedder unchanged.`
5474
+ );
5475
+ return null;
5476
+ }
5477
+ const embedder = create({
5478
+ preset: provider === "custom" ? void 0 : provider,
5479
+ baseUrl: baseUrlOverride,
5480
+ apiKey: apiKey || "ollama",
5481
+ model,
5482
+ dimensions: Number.isFinite(dimensions) ? dimensions : void 0,
5483
+ id: provider
5484
+ });
5485
+ const dimsLabel = embedder.dimensions ? `dims=${embedder.dimensions}` : "dims=?";
5486
+ return {
5487
+ embedder,
5488
+ description: `OpenAI-compatible embedder (provider=${provider}${model ? `, model=${model}` : ""}, ${dimsLabel})`
5489
+ };
5490
+ } catch (err) {
5491
+ ctx.logger.warn(
5492
+ `[AI] Failed to load @objectstack/embedder-openai for embedder provider=${provider}`,
5493
+ err instanceof Error ? { error: err.message } : void 0
5494
+ );
5495
+ return null;
5496
+ }
5497
+ }
5352
5498
  /**
5353
5499
  * Auto-detect LLM provider from environment variables.
5354
5500
  *
@@ -5768,6 +5914,179 @@ var AIServicePlugin = class {
5768
5914
  ctx.logger.info(
5769
5915
  `[AI] Service started \u2014 adapter="${this.service.adapterName}", tools=${this.service.toolRegistry.size}, routes=${routes.length}`
5770
5916
  );
5917
+ if (this.options.bindToSettings !== false) {
5918
+ ctx.hook("kernel:ready", async () => {
5919
+ await this.bindSettings(ctx);
5920
+ });
5921
+ }
5922
+ }
5923
+ /**
5924
+ * Resolve the `settings` service, apply any persisted `ai` values,
5925
+ * subscribe to changes, and register the live `ai/test` action
5926
+ * (overrides the manifest's fallback stub).
5927
+ */
5928
+ async bindSettings(ctx) {
5929
+ if (!this.service) return;
5930
+ let settings;
5931
+ try {
5932
+ settings = ctx.getService("settings");
5933
+ } catch {
5934
+ return;
5935
+ }
5936
+ if (!settings || typeof settings.getNamespace !== "function") return;
5937
+ const applySettings = async () => {
5938
+ if (!this.service) return;
5939
+ try {
5940
+ const payload = await settings.getNamespace("ai");
5941
+ const values = {};
5942
+ for (const [k, v] of Object.entries(payload.values)) {
5943
+ values[k] = v?.value;
5944
+ }
5945
+ const provider = String(values.provider ?? "memory");
5946
+ if (provider === "memory") return;
5947
+ const built = await this.buildAdapterFromValues(ctx, values);
5948
+ if (!built) {
5949
+ ctx.logger.warn(
5950
+ `[AI] Settings provider=${provider} could not be applied (missing credentials or package). Adapter unchanged (current="${this.service.adapterName}").`
5951
+ );
5952
+ return;
5953
+ }
5954
+ this.service.setAdapter(built.adapter);
5955
+ ctx.logger.info(`[AI] Adapter rebuilt from settings: ${built.description}`);
5956
+ } catch (err) {
5957
+ ctx.logger.warn("[AI] Failed to apply ai settings: " + (err?.message ?? err));
5958
+ }
5959
+ };
5960
+ await applySettings();
5961
+ if (typeof settings.subscribe === "function") {
5962
+ settings.subscribe("ai", () => {
5963
+ void applySettings();
5964
+ });
5965
+ ctx.logger.info("[AI] Bound to settings:changed for namespace=ai");
5966
+ }
5967
+ let currentEmbedderId = null;
5968
+ const applyEmbedder = async () => {
5969
+ try {
5970
+ const payload = await settings.getNamespace("ai");
5971
+ const values = {};
5972
+ for (const [k, v] of Object.entries(payload.values)) {
5973
+ values[k] = v?.value;
5974
+ }
5975
+ const built = await this.buildEmbedderFromValues(ctx, values);
5976
+ if (!built) {
5977
+ if (currentEmbedderId !== null) {
5978
+ ctx.logger.info("[AI] Embedder disabled by settings; kernel embedder service unset.");
5979
+ currentEmbedderId = null;
5980
+ }
5981
+ return;
5982
+ }
5983
+ const replace = ctx.replaceService ?? ctx.registerService;
5984
+ replace.call(ctx, EMBEDDER_SERVICE, built.embedder);
5985
+ currentEmbedderId = built.embedder.id;
5986
+ ctx.logger.info(`[AI] Embedder registered from settings: ${built.description}`);
5987
+ } catch (err) {
5988
+ ctx.logger.warn("[AI] Failed to apply embedder settings: " + (err?.message ?? err));
5989
+ }
5990
+ };
5991
+ await applyEmbedder();
5992
+ if (typeof settings.subscribe === "function") {
5993
+ settings.subscribe("ai", () => {
5994
+ void applyEmbedder();
5995
+ });
5996
+ }
5997
+ if (typeof settings.registerAction === "function") {
5998
+ settings.registerAction("ai", "test_embedder", async ({ values, payload }) => {
5999
+ const overrides = payload && typeof payload === "object" && payload !== null && "values" in payload ? payload.values ?? {} : {};
6000
+ const merged = { ...values ?? {}, ...overrides };
6001
+ const provider = String(merged.embedder_provider ?? "none");
6002
+ if (provider === "none") {
6003
+ return {
6004
+ ok: false,
6005
+ severity: "warning",
6006
+ message: "Embedder disabled (provider=none). Select a provider to enable knowledge search."
6007
+ };
6008
+ }
6009
+ let built;
6010
+ try {
6011
+ built = await this.buildEmbedderFromValues(ctx, merged);
6012
+ } catch (err) {
6013
+ return { ok: false, severity: "error", message: err?.message ?? String(err) };
6014
+ }
6015
+ if (!built) {
6016
+ return {
6017
+ ok: false,
6018
+ severity: "error",
6019
+ message: `Could not build embedder for provider=${provider}. Check api key, base URL, and that @objectstack/embedder-openai is installed.`
6020
+ };
6021
+ }
6022
+ const started = Date.now();
6023
+ try {
6024
+ const vectors = await built.embedder.embed(["ping"]);
6025
+ const latency = Date.now() - started;
6026
+ const dim = vectors[0]?.length ?? 0;
6027
+ return {
6028
+ ok: true,
6029
+ severity: "info",
6030
+ message: `${built.description} responded in ${latency}ms (vector dims=${dim}).`
6031
+ };
6032
+ } catch (err) {
6033
+ return {
6034
+ ok: false,
6035
+ severity: "error",
6036
+ message: `${built.description} request failed: ${err?.message ?? String(err)}`
6037
+ };
6038
+ }
6039
+ });
6040
+ ctx.logger.info("[AI] Registered live settings action ai/test_embedder");
6041
+ }
6042
+ if (typeof settings.registerAction === "function") {
6043
+ settings.registerAction("ai", "test", async ({ values, payload }) => {
6044
+ const overrides = payload && typeof payload === "object" && payload !== null && "values" in payload ? payload.values ?? {} : {};
6045
+ const merged = { ...values ?? {}, ...overrides };
6046
+ const provider = String(merged.provider ?? "memory");
6047
+ if (provider === "memory") {
6048
+ return {
6049
+ ok: true,
6050
+ severity: "warning",
6051
+ message: "Memory provider is an echo stub \u2014 no external call to validate. Switch to a real provider for production."
6052
+ };
6053
+ }
6054
+ let built;
6055
+ try {
6056
+ built = await this.buildAdapterFromValues(ctx, merged);
6057
+ } catch (err) {
6058
+ return { ok: false, severity: "error", message: err?.message ?? String(err) };
6059
+ }
6060
+ if (!built) {
6061
+ return {
6062
+ ok: false,
6063
+ severity: "error",
6064
+ message: `Could not build adapter for provider=${provider}. Check API key and that the provider SDK package is installed.`
6065
+ };
6066
+ }
6067
+ const started = Date.now();
6068
+ try {
6069
+ const result = await built.adapter.chat(
6070
+ [{ role: "user", content: "ping" }],
6071
+ { maxTokens: 8 }
6072
+ );
6073
+ const latency = Date.now() - started;
6074
+ const preview = String(result?.text ?? "").slice(0, 60);
6075
+ return {
6076
+ ok: true,
6077
+ severity: "info",
6078
+ message: `${built.description} responded in ${latency}ms${preview ? ` \u2014 "${preview}"` : ""}.`
6079
+ };
6080
+ } catch (err) {
6081
+ return {
6082
+ ok: false,
6083
+ severity: "error",
6084
+ message: `${built.description} request failed: ${err?.message ?? String(err)}`
6085
+ };
6086
+ }
6087
+ });
6088
+ ctx.logger.info("[AI] Registered live settings action ai/test");
6089
+ }
5771
6090
  }
5772
6091
  async destroy() {
5773
6092
  this.service = void 0;