@askdb/studio 0.2.0-beta.36 → 0.2.0-beta.37

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/server.js CHANGED
@@ -5,21 +5,20 @@ import { extname, relative, join, resolve, dirname, sep } from "node:path";
5
5
  import { spawn } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { generateText as defaultGenerateText } from "ai";
8
- import { bootstrapAskDbEnv, getAskDbRuntimeConfig } from "@askdb/config";
8
+ import { ASKDB_AI_PROVIDERS, bootstrapAskDbEnv, getAskDbRuntimeConfig, } from "@askdb/config";
9
9
  import { createAiRegistry, resolveReasoningEffort, } from "@askdb/ai";
10
- import { anthropicProvider } from "@askdb/ai-anthropic";
11
- import { azureProvider } from "@askdb/ai-azure";
12
- import { googleProvider } from "@askdb/ai-google";
13
- import { openaiProvider } from "@askdb/ai-openai";
14
10
  import { ask, formatSensitiveReference, isBuiltInDialectId, loadSchema, schemaHasSensitiveIdentifiers, SqlValidationError, validateSensitiveReferences, suggestEnrichment, tenantScopeSchema, normalizeTenantPolicy, parseTenantPolicyMarkdown, SchemaParseError, writeTenantPolicyMarkdown, tenantPolicyFrontmatterSchema, } from "@askdb/core";
15
- import { buildSchemaIndex, chunkContentHash, chunkSchema, createAiSdkEmbedder, createFileStore, createMemoryStore, createPgvectorStore, createRetriever, loadChunkerSourcesFromDir, } from "@askdb/rag";
11
+ import { buildSchemaIndex, chunkContentHash, chunkSchema, aiSdkEmbedderId, createAiSdkEmbedder, createFileStore, createMemoryStore, createPgvectorStore, createRetriever, detectEmbeddingDimensions, loadChunkerSourcesFromDir, PgvectorDimensionMismatchError, } from "@askdb/rag";
16
12
  import { buildDefaultTableBody, buildFrontmatter, buildSuggestionContext, buildSuggestionTarget, buildTableDraft, loadWorkspace, replaceH2Section, replaceTableDescription, saveConcepts, saveTable, } from "@askdb/enrich";
17
13
  import { EXECUTE_DRIVER_REGISTRY, executeDialectFor, isDriverInstalled, isStudioExecuteProvider, validateExecuteSql, } from "./execute-registry.js";
18
14
  import { lockfilePackageManager, packageManagerAddArgs, packageManagerSpawnSpec, } from "./package-manager.js";
19
15
  import { resolveStudioIntrospectionPlan, runStudioIntrospection } from "./introspection.js";
20
16
  import { probeSetupState, writeSetupConfig, SetupError } from "./setup.js";
21
17
  import { checkApiRequest, checkHost, createStudioSessionToken, injectSessionToken, } from "./request-guard.js";
22
- const ai = createAiRegistry([openaiProvider, azureProvider, googleProvider, anthropicProvider]);
18
+ // Batteries-included surface: every built-in provider is registered, and each
19
+ // loads its @ai-sdk/* package only when first used, so env config alone
20
+ // selects the provider.
21
+ const ai = createAiRegistry();
23
22
  const DEFAULT_CLIENT_DIR = fileURLToPath(new URL("./client/", import.meta.url));
24
23
  let clientDirForTests;
25
24
  /** @internal Tests only — serve client assets from another directory. */
@@ -31,7 +30,6 @@ function clientDir() {
31
30
  }
32
31
  const STUDIO_RAG_MOCK_DIMENSIONS = 64;
33
32
  const STUDIO_RAG_MOCK_EMBEDDER_ID = `studio:mock-lexical-${STUDIO_RAG_MOCK_DIMENSIONS}`;
34
- const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
35
33
  let studioPgvectorStoreFactoryForTests;
36
34
  export function setStudioPgvectorStoreFactoryForTests(factory) {
37
35
  studioPgvectorStoreFactoryForTests = factory;
@@ -224,8 +222,8 @@ export function serializeWorkspace(workspace) {
224
222
  dialect,
225
223
  warnings: workspace.warnings,
226
224
  aiConfigured: Boolean(aiConfig),
227
- model: aiConfig?.model ?? "gpt-4o-mini",
228
- aiProvider: aiConfig?.provider ?? "openai",
225
+ model: aiConfig?.model ?? rt.ai.language.model ?? "provider default",
226
+ aiProvider: aiConfig?.provider ?? rt.ai.language.provider,
229
227
  tables: workspace.tables.map((table) => {
230
228
  const draft = buildTableDraft(table.physical, table.parsed);
231
229
  return {
@@ -373,7 +371,8 @@ function parseSetupConfigBody(body) {
373
371
  throw new StudioHttpError(400, "Request body must be a JSON object.");
374
372
  }
375
373
  const databases = ["postgres", "mysql", "sqlite", "sqlserver", "prisma"];
376
- const aiProviders = ["openai", "anthropic", "google", "azure", "foundry"];
374
+ // Every provider with an askdb.config.* branch; @askdb/client's provider-config-drift test asserts this matches @askdb/ai's built-in table.
375
+ const aiProviders = ASKDB_AI_PROVIDERS;
377
376
  const ragStores = ["file", "memory", "pgvector"];
378
377
  const executeProviders = ["postgres", "mysql", "sqlite", "sqlserver"];
379
378
  if (typeof body.database !== "string" || !databases.includes(body.database)) {
@@ -659,14 +658,17 @@ async function getRagStatus(state) {
659
658
  const sources = loadChunkerSourcesFromDir(state.schemaDir);
660
659
  const chunkResult = chunkSchema(sources);
661
660
  const lockPath = join(state.schemaDir, "schema.lock.json");
662
- const lock = readOptionalJson(lockPath);
661
+ const lock = resolveStudioRagStoreConfig(state).kind === "memory"
662
+ ? state.ragMemoryIndex
663
+ : readOptionalJson(lockPath);
663
664
  const currentHashes = Object.fromEntries(chunkResult.chunks.map((chunk) => [chunk.id, chunkContentHash(chunk.text)]));
664
665
  const lockHashes = lock?.hashes ?? {};
665
666
  const hashIds = Object.keys(currentHashes);
666
- const store = await openStudioRagStore(state, config.dimensions);
667
+ const store = await openStudioRagStore(state, { dimensions: config.dimensions });
667
668
  try {
668
669
  const chunksIndexed = await countStudioRagStoreChunks(store, sources.schema.schemaId);
669
670
  const stale = !lock ||
671
+ lock.incomplete === true ||
670
672
  lock.embedderId !== config.embedderId ||
671
673
  Object.keys(lockHashes).length !== hashIds.length ||
672
674
  hashIds.some((id) => lockHashes[id] !== currentHashes[id]) ||
@@ -707,8 +709,9 @@ async function getRagStatus(state) {
707
709
  updatedAt: lock?.updatedAt ?? null,
708
710
  chunksTotal: chunkResult.chunks.length,
709
711
  chunksIndexed,
710
- dimensions: config.dimensions,
711
- expectedDimensions: config.dimensions,
712
+ // The width the last index build learned from the model, else the one configured.
713
+ dimensions: lock?.dimensions ?? config.dimensions ?? null,
714
+ expectedDimensions: config.dimensions ?? null,
712
715
  sensitiveExcluded: chunkResult.stats.sensitiveExcluded,
713
716
  sensitiveIncluded: chunkResult.stats.sensitiveIncluded,
714
717
  files: fileArtifacts,
@@ -721,27 +724,40 @@ async function getRagStatus(state) {
721
724
  async function indexRag(state) {
722
725
  const config = resolveStudioRagEmbedderConfig();
723
726
  if (!config.configured) {
724
- throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
727
+ throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage(config));
725
728
  }
726
729
  const usage = createRequestUsageCollector();
727
730
  clearIncompatibleRagStore(state, config);
728
731
  const sources = loadChunkerSourcesFromDir(state.schemaDir);
729
- const store = await openStudioRagStore(state, config.dimensions);
732
+ const embedder = await createStudioRagEmbedder(config, usage);
730
733
  let result;
734
+ let store;
731
735
  try {
736
+ // A new pgvector table needs its width up front: the configured one, else the model's own.
737
+ const dimensions = config.dimensions ??
738
+ (resolveStudioRagStoreConfig(state).kind === "pgvector" ? await detectEmbeddingDimensions(embedder) : undefined);
739
+ store = await openStudioRagStore(state, { dimensions, provision: true });
732
740
  result = await buildSchemaIndex({
733
741
  schema: sources,
734
- embedder: await createStudioRagEmbedder(config, usage),
742
+ embedder,
735
743
  store: store.store,
736
744
  embedderId: config.embedderId,
737
745
  lockFilePath: join(state.schemaDir, "schema.lock.json"),
738
746
  });
747
+ if (store.kind === "memory") {
748
+ state.ragMemoryIndex = {
749
+ embedderId: config.embedderId,
750
+ updatedAt: new Date().toISOString(),
751
+ dimensions: store.store.describe?.().dimensions,
752
+ hashes: Object.fromEntries(result.chunks.map((chunk) => [chunk.id, chunkContentHash(chunk.text)])),
753
+ };
754
+ }
739
755
  }
740
756
  catch (error) {
741
757
  throw formatStudioRagOperationError(error, config);
742
758
  }
743
759
  finally {
744
- await store.dispose();
760
+ await store?.dispose();
745
761
  }
746
762
  return {
747
763
  status: await getRagStatus(state),
@@ -779,7 +795,7 @@ async function queryRag(state, query) {
779
795
  async function createCurrentStudioRagIndex(state, action, usage) {
780
796
  const config = resolveStudioRagEmbedderConfig();
781
797
  if (!config.configured) {
782
- throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
798
+ throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage(config));
783
799
  }
784
800
  const status = (await getRagStatus(state));
785
801
  if (!status.hasIndex) {
@@ -791,7 +807,7 @@ async function createCurrentStudioRagIndex(state, action, usage) {
791
807
  if (!status.schemaId || typeof status.chunksTotal !== "number") {
792
808
  throw new StudioHttpError(500, "Studio RAG status is missing schema metadata.");
793
809
  }
794
- const store = await openStudioRagStore(state, config.dimensions);
810
+ const store = await openStudioRagStore(state, { dimensions: config.dimensions });
795
811
  return {
796
812
  config,
797
813
  status: {
@@ -809,22 +825,26 @@ async function createCurrentStudioRagIndex(state, action, usage) {
809
825
  }
810
826
  function resolveStudioRagStoreConfig(state) {
811
827
  const rt = getAskDbRuntimeConfig();
812
- const kind = rt.structured.rag.store;
828
+ const kind = rt.rag.store;
813
829
  if (kind === "memory")
814
830
  return { kind };
815
831
  if (kind === "file") {
816
- const basePath = rt.structured.rag.storeConfig.file?.basePath?.trim();
832
+ const basePath = rt.rag.storeConfig.file?.basePath?.trim();
817
833
  return { kind, basePath: basePath ? resolve(basePath) : join(state.schemaDir, "schema") };
818
834
  }
819
835
  const connectionString = pickFlat(rt.flat, "ASKDB_PGVECTOR_URL");
820
836
  return {
821
837
  kind,
822
838
  connectionString,
823
- table: rt.structured.rag.storeConfig.pgvector?.table?.trim() || undefined,
839
+ table: rt.rag.storeConfig.pgvector?.table?.trim() || undefined,
824
840
  indexStrategy: pickFlat(rt.flat, "ASKDB_PGVECTOR_INDEX_STRATEGY"),
825
841
  };
826
842
  }
827
- async function openStudioRagStore(state, dimensions) {
843
+ /**
844
+ * Opens the configured store. `provision` creates the pgvector table when it's missing, at
845
+ * `dimensions`; without it, a pgvector store only reads and writes an existing table.
846
+ */
847
+ async function openStudioRagStore(state, { dimensions, provision = false }) {
828
848
  const config = resolveStudioRagStoreConfig(state);
829
849
  if (config.kind === "memory") {
830
850
  state.ragMemoryStore ??= createMemoryStore();
@@ -852,10 +872,19 @@ async function openStudioRagStore(state, dimensions) {
852
872
  const store = pgvectorFactory({
853
873
  connectionString: config.connectionString,
854
874
  table: config.table,
855
- dimensions,
875
+ ...(dimensions !== undefined ? { dimensions } : {}),
856
876
  ...(config.indexStrategy ? { indexStrategy: config.indexStrategy } : {}),
857
877
  });
858
- await store.ensureSchema();
878
+ if (provision) {
879
+ try {
880
+ await store.ensureSchema();
881
+ }
882
+ catch (error) {
883
+ // The caller never receives this store, so close its pool here (a width refusal has opened it).
884
+ await store.close().catch(() => { });
885
+ throw error;
886
+ }
887
+ }
859
888
  return {
860
889
  kind: "pgvector",
861
890
  store,
@@ -867,6 +896,10 @@ async function openStudioRagStore(state, dimensions) {
867
896
  };
868
897
  }
869
898
  async function countStudioRagStoreChunks(store, schemaId) {
899
+ // A pgvector table that doesn't exist yet holds nothing; the first index build creates it.
900
+ if (typeof store.store.tableDimensions === "function" && (await store.store.tableDimensions()) === undefined) {
901
+ return 0;
902
+ }
870
903
  if (typeof store.store.count === "function") {
871
904
  return store.store.count({ schemaId });
872
905
  }
@@ -879,33 +912,9 @@ function pickFlat(flat, key) {
879
912
  const value = flat[key];
880
913
  return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
881
914
  }
882
- function pickEnv(env, key) {
883
- const v = env[key];
884
- return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
885
- }
886
915
  function resolveStudioRagEmbedderConfig() {
887
- const rt = getAskDbRuntimeConfig();
888
- const base = rt.ai.aiEnv;
889
- const explicitKind = pickEnv(base, "ASKDB_RAG_EMBEDDER");
890
- const kind = explicitKind?.toLowerCase();
891
- if (kind === "mock") {
892
- return {
893
- kind: "mock",
894
- embedderId: STUDIO_RAG_MOCK_EMBEDDER_ID,
895
- dimensions: STUDIO_RAG_MOCK_DIMENSIONS,
896
- configured: true,
897
- label: "Mock lexical",
898
- };
899
- }
900
- if (kind !== undefined && kind !== "ai-sdk" && kind !== "openai") {
901
- throw new StudioHttpError(400, `Unsupported Studio RAG embedder: ${kind}`);
902
- }
903
- const env = buildStudioRagEmbeddingEnv(kind, base);
904
- const aiConfig = ai.resolveEmbeddingConfig(env, {
905
- modelEnvVar: "ASKDB_RAG_EMBEDDER_MODEL",
906
- modelDefault: DEFAULT_EMBEDDING_MODEL,
907
- });
908
- if (!aiConfig && kind === undefined) {
916
+ const embedding = getAskDbRuntimeConfig().ai.embedding;
917
+ if (!embedding) {
909
918
  return {
910
919
  kind: "mock",
911
920
  embedderId: STUDIO_RAG_MOCK_EMBEDDER_ID,
@@ -914,50 +923,38 @@ function resolveStudioRagEmbedderConfig() {
914
923
  label: "Mock lexical",
915
924
  };
916
925
  }
917
- const provider = aiConfig?.provider ?? fallbackStudioRagProvider(kind, base);
918
- const model = aiConfig?.model ?? DEFAULT_EMBEDDING_MODEL;
919
- const dimensionOverride = readPositiveIntegerEnv(pickEnv(base, "ASKDB_RAG_EMBEDDER_DIMENSIONS"));
920
- const dimensions = dimensionOverride ?? defaultEmbeddingDimensions(model);
926
+ // The env holds the embedding connection only, so no other provider's key can reach it (#345).
927
+ const aiConfig = ai.resolveEmbeddingConfig(embedding.env);
928
+ // The adapter's canonical name (`foundry` resolves to `azure`) keeps existing index ids stable.
929
+ const provider = aiConfig?.provider ?? embedding.provider;
930
+ const model = embedding.model;
931
+ const dimensions = embedding.dimensions;
921
932
  return {
922
933
  kind: "ai-sdk",
923
934
  provider,
924
- embedderId: `ai-sdk:${provider}:${model}:${dimensions}`,
935
+ configuredProvider: embedding.provider,
936
+ connection: embedding.connection,
937
+ embedderId: aiSdkEmbedderId({ provider, model, dimensions }),
925
938
  dimensions,
926
939
  configured: Boolean(aiConfig),
927
940
  label: `AI SDK (${provider})`,
928
941
  model,
929
942
  baseUrl: aiConfig?.baseURL,
930
943
  aiConfig,
931
- requestDimensions: dimensionOverride,
944
+ // Only an explicit ai.embedding.dimensions is sent; otherwise the model's own width applies.
945
+ requestDimensions: embedding.dimensions,
932
946
  };
933
947
  }
934
- function buildStudioRagEmbeddingEnv(kind, base) {
935
- const apiKeyOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_API_KEY");
936
- const baseUrlOverride = pickEnv(base, "ASKDB_RAG_EMBEDDER_BASE_URL");
937
- return {
938
- ...base,
939
- ...(kind === "openai" ? { ASKDB_AI_PROVIDER: "openai" } : {}),
940
- ...(apiKeyOverride ? { ASKDB_AI_API_KEY: apiKeyOverride } : {}),
941
- ...(baseUrlOverride ? { ASKDB_AI_BASE_URL: baseUrlOverride } : {}),
942
- };
943
- }
944
- function fallbackStudioRagProvider(kind, base) {
945
- if (kind === "openai")
946
- return "openai";
947
- const raw = (pickEnv(base, "ASKDB_AI_PROVIDER") ?? "").toLowerCase();
948
- return raw === "azure" || raw === "azure-openai" || raw === "foundry"
949
- ? "azure"
950
- : "openai";
951
- }
952
- function studioRagAiSdkKeyMissingMessage() {
953
- return ("Studio RAG AI SDK embeddings require a configured AI provider key. " +
954
- "Set ASKDB_AI_API_KEY or the provider-native key, or set ASKDB_RAG_EMBEDDER=mock for the local lexical embedder.");
948
+ function studioRagAiSdkKeyMissingMessage(config) {
949
+ return (`Studio RAG embeddings need an API key on the ai.embedding connection ` +
950
+ `("${config.connection}" in ai.providerConfig.${config.configuredProvider}). ` +
951
+ 'Set it in askdb.config.*, or set rag.embedder: "mock" for the local lexical embedder.');
955
952
  }
956
953
  async function createStudioRagEmbedder(config, usage) {
957
954
  if (config.kind === "mock")
958
955
  return createStudioMockEmbedder(config.dimensions);
959
956
  if (!config.aiConfig) {
960
- throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage());
957
+ throw new StudioHttpError(400, studioRagAiSdkKeyMissingMessage(config));
961
958
  }
962
959
  const model = await ai.createEmbeddingModel(config.aiConfig, {
963
960
  dimensions: config.requestDimensions,
@@ -977,6 +974,9 @@ async function createStudioRagEmbedder(config, usage) {
977
974
  function formatStudioRagOperationError(error, config) {
978
975
  if (error instanceof StudioHttpError)
979
976
  return error;
977
+ if (error instanceof PgvectorDimensionMismatchError) {
978
+ return new StudioHttpError(409, studioRagWidthMismatchMessage(error, config));
979
+ }
980
980
  if (config.kind === "mock") {
981
981
  return new StudioHttpError(500, error instanceof Error ? error.message : String(error));
982
982
  }
@@ -985,7 +985,7 @@ function formatStudioRagOperationError(error, config) {
985
985
  return new StudioHttpError(500, error instanceof Error ? error.message : String(error));
986
986
  }
987
987
  const parts = [
988
- `Studio RAG embedding request failed for provider ${config.provider}, model ${config.model}.`,
988
+ `Studio RAG embedding request failed for ai.embedding (provider ${config.configuredProvider}, connection ${config.connection}, model ${config.model}).`,
989
989
  ];
990
990
  if (config.baseUrl)
991
991
  parts.push(`Base URL: ${config.baseUrl}.`);
@@ -998,6 +998,20 @@ function formatStudioRagOperationError(error, config) {
998
998
  parts.push(`Error: ${error.message}`);
999
999
  return new StudioHttpError(502, parts.join(" "));
1000
1000
  }
1001
+ /** An existing pgvector table of another width: say where the new width comes from and how to resolve it, in config terms. */
1002
+ function studioRagWidthMismatchMessage(error, config) {
1003
+ const mismatch = `pgvector table "${error.table}" holds ${error.tableDimensions}-dimension vectors, but`;
1004
+ const rebuild = `Drop the table (DROP TABLE "${error.table}";) and build the index again`;
1005
+ const newTable = "point rag.storeConfig.pgvector.table at a new table";
1006
+ if (config.kind === "mock") {
1007
+ return `${mismatch} the mock embedder writes ${error.dimensions}. ${rebuild}, or ${newTable}.`;
1008
+ }
1009
+ const source = config.dimensions !== undefined
1010
+ ? `ai.embedding.dimensions asks for ${error.dimensions}`
1011
+ : `embedding model ${config.model} returns ${error.dimensions}`;
1012
+ return (`${mismatch} ${source}. ${rebuild}, ${newTable}, ` +
1013
+ `or set ai.embedding.dimensions: ${error.tableDimensions} if model ${config.model} supports that width.`);
1014
+ }
1001
1015
  function findApiCallError(error) {
1002
1016
  const queue = [error];
1003
1017
  for (let i = 0; i < queue.length; i++) {
@@ -1032,30 +1046,20 @@ function clearIncompatibleRagStore(state, config) {
1032
1046
  return;
1033
1047
  const embeddingsJsonPath = `${store.basePath}.embeddings.json`;
1034
1048
  const embeddings = readOptionalJson(embeddingsJsonPath);
1035
- if (embeddings?.dimensions === undefined || embeddings.dimensions === config.dimensions)
1049
+ if (embeddings?.dimensions === undefined)
1050
+ return;
1051
+ // The file store refuses mixed widths. When the width isn't known up front, vectors from a
1052
+ // different embedder may not match it, so they go too.
1053
+ const lockPath = join(state.schemaDir, "schema.lock.json");
1054
+ const compatible = config.dimensions !== undefined
1055
+ ? embeddings.dimensions === config.dimensions
1056
+ : readOptionalJson(lockPath)?.embedderId === config.embedderId;
1057
+ if (compatible)
1036
1058
  return;
1037
- for (const path of [
1038
- `${store.basePath}.embeddings.json`,
1039
- `${store.basePath}.embeddings.bin`,
1040
- join(state.schemaDir, "schema.lock.json"),
1041
- ]) {
1059
+ for (const path of [`${store.basePath}.embeddings.json`, `${store.basePath}.embeddings.bin`, lockPath]) {
1042
1060
  rmSync(path, { force: true });
1043
1061
  }
1044
1062
  }
1045
- function defaultEmbeddingDimensions(model) {
1046
- if (model === "text-embedding-3-large")
1047
- return 3072;
1048
- return 1536;
1049
- }
1050
- function readPositiveIntegerEnv(value) {
1051
- if (value === undefined || value.trim() === "")
1052
- return undefined;
1053
- const parsed = Number(value);
1054
- if (!Number.isInteger(parsed) || parsed <= 0) {
1055
- throw new StudioHttpError(400, `Invalid Studio RAG embedding dimensions: ${value}`);
1056
- }
1057
- return parsed;
1058
- }
1059
1063
  function serializeRagResult(result) {
1060
1064
  return {
1061
1065
  id: result.id,