embedding-factory.test.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. /**
  2. * embedding-factory.test.ts - Tests for createEmbeddingProvider factory.
  3. *
  4. * Verifies the resolution precedence:
  5. * 1. explicit `kind` argument
  6. * 2. QMD_EMBED_PROVIDER env
  7. * 3. QMD_EMBED_ENDPOINT env (forces openai)
  8. * 4. config file `embedProvider.kind` / `embedProvider.endpoint`
  9. * 5. missing commercial configuration: typed HOLD
  10. */
  11. import { describe, test, expect, beforeEach, afterEach } from "vitest";
  12. import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
  13. import { tmpdir } from "node:os";
  14. import { join } from "node:path";
  15. import {
  16. resolveProviderKind,
  17. createEmbeddingProvider,
  18. loadConfigFile,
  19. assertCommercialEndpoint,
  20. } from "../src/embedding/factory.js";
  21. import { OpenAIEmbeddingsProvider } from "../src/embedding/openai.js";
  22. import { CommercialApiHoldError } from "../src/model-policy.js";
  23. let workDir: string;
  24. let configPath: string;
  25. beforeEach(() => {
  26. workDir = mkdtempSync(join(tmpdir(), "qmd-factory-test-"));
  27. mkdirSync(join(workDir, "qmd"), { recursive: true });
  28. configPath = join(workDir, "qmd", "config.json");
  29. });
  30. afterEach(() => {
  31. rmSync(workDir, { recursive: true, force: true });
  32. });
  33. // ─────────────────────────── Helpers ─────────────────────────────────────────
  34. function writeConfig(obj: Record<string, unknown>) {
  35. writeFileSync(configPath, JSON.stringify(obj));
  36. }
  37. const EMPTY_ENV: Record<string, string | undefined> = {};
  38. // ─────────────────────────── resolveProviderKind ─────────────────────────────
  39. describe("resolveProviderKind", () => {
  40. test("explicit local kind is rejected", () => {
  41. expect(() => resolveProviderKind({
  42. kind: "local" as never,
  43. env: { QMD_EMBED_ENDPOINT: "https://x" },
  44. configPath,
  45. })).toThrow(CommercialApiHoldError);
  46. expect(
  47. resolveProviderKind({
  48. kind: "openai",
  49. env: EMPTY_ENV,
  50. configPath,
  51. }),
  52. ).toBe("openai");
  53. });
  54. test("QMD_EMBED_PROVIDER=local is rejected even with an endpoint", () => {
  55. expect(() => resolveProviderKind({
  56. env: { QMD_EMBED_PROVIDER: "local", QMD_EMBED_ENDPOINT: "https://x" },
  57. configPath,
  58. })).toThrow(CommercialApiHoldError);
  59. });
  60. test("QMD_EMBED_ENDPOINT presence → openai", () => {
  61. expect(
  62. resolveProviderKind({
  63. env: { QMD_EMBED_ENDPOINT: "https://ai.example.com" },
  64. configPath,
  65. }),
  66. ).toBe("openai");
  67. });
  68. test("QMD_EMBED_ENDPOINT empty string resolves commercial kind then holds at construction", () => {
  69. expect(
  70. resolveProviderKind({
  71. env: { QMD_EMBED_ENDPOINT: "" },
  72. configPath,
  73. }),
  74. ).toBe("openai");
  75. });
  76. test("config file embedProvider.kind respected", () => {
  77. writeConfig({ embedProvider: { kind: "openai", endpoint: "https://ai.example.com" } });
  78. expect(resolveProviderKind({ env: EMPTY_ENV, configPath })).toBe("openai");
  79. });
  80. test("config file embedProvider.endpoint alone → openai", () => {
  81. writeConfig({ embedProvider: { endpoint: "https://ai.example.com" } });
  82. expect(resolveProviderKind({ env: EMPTY_ENV, configPath })).toBe("openai");
  83. });
  84. test("no signal anywhere keeps commercial-only kind", () => {
  85. expect(resolveProviderKind({ env: EMPTY_ENV, configPath })).toBe("openai");
  86. });
  87. test("unsupported env provider returns typed HOLD", () => {
  88. expect(() =>
  89. resolveProviderKind({
  90. env: { QMD_EMBED_PROVIDER: "garbage" },
  91. configPath,
  92. }),
  93. ).toThrow(CommercialApiHoldError);
  94. });
  95. test("uppercase env QMD_EMBED_PROVIDER normalized", () => {
  96. expect(
  97. resolveProviderKind({
  98. env: { QMD_EMBED_PROVIDER: "OPENAI", QMD_EMBED_ENDPOINT: "https://x" },
  99. configPath,
  100. }),
  101. ).toBe("openai");
  102. });
  103. });
  104. // ─────────────────────────── createEmbeddingProvider ─────────────────────────
  105. describe("createEmbeddingProvider", () => {
  106. test("openai kind w/ endpoint env → OpenAIEmbeddingsProvider", () => {
  107. const p = createEmbeddingProvider({
  108. env: { QMD_EMBED_ENDPOINT: "https://ai.example.com" },
  109. configPath,
  110. });
  111. expect(p).toBeInstanceOf(OpenAIEmbeddingsProvider);
  112. expect(p.kind).toBe("openai");
  113. expect(p.getModelId()).toBe("embeddinggemma");
  114. });
  115. test("openai kind w/ explicit options merges over env", () => {
  116. const p = createEmbeddingProvider({
  117. env: { QMD_EMBED_ENDPOINT: "https://env.example.com", QMD_EMBED_API_KEY: "env-key" },
  118. configPath,
  119. openai: { endpoint: "https://override.example.com" },
  120. });
  121. // Cast to access internal properties for verification
  122. const inner = p as OpenAIEmbeddingsProvider & { endpoint: string; apiKey: string };
  123. expect(inner["endpoint"]).toBe("https://override.example.com");
  124. // apiKey should still come from env since we didn't override it
  125. expect(inner["apiKey"]).toBe("env-key");
  126. });
  127. test("openai kind reads modelId from env", () => {
  128. const p = createEmbeddingProvider({
  129. env: {
  130. QMD_EMBED_ENDPOINT: "https://ai.example.com",
  131. QMD_EMBED_MODEL_ID: "custom-model",
  132. },
  133. configPath,
  134. });
  135. expect(p.getModelId()).toBe("custom-model");
  136. });
  137. test("openai kind reads upstream model from env", () => {
  138. const p = createEmbeddingProvider({
  139. env: {
  140. QMD_EMBED_ENDPOINT: "https://ai.example.com",
  141. QMD_EMBED_UPSTREAM_MODEL: "embeddinggemma:300m",
  142. },
  143. configPath,
  144. }) as OpenAIEmbeddingsProvider & { upstreamModel: string };
  145. expect(p["upstreamModel"]).toBe("embeddinggemma:300m");
  146. });
  147. test("openai kind reads batch size and timeout from env", () => {
  148. const p = createEmbeddingProvider({
  149. env: {
  150. QMD_EMBED_ENDPOINT: "https://ai.example.com",
  151. QMD_EMBED_BATCH_SIZE: "32",
  152. QMD_EMBED_TIMEOUT_MS: "5000",
  153. },
  154. configPath,
  155. }) as OpenAIEmbeddingsProvider & { batchSize: number; timeoutMs: number };
  156. expect(p["batchSize"]).toBe(32);
  157. expect(p["timeoutMs"]).toBe(5000);
  158. });
  159. test("openai kind merges config file values", () => {
  160. writeConfig({
  161. embedProvider: {
  162. kind: "openai",
  163. endpoint: "https://config.example.com",
  164. apiKey: "config-key",
  165. modelId: "config-model",
  166. batchSize: 16,
  167. },
  168. });
  169. const p = createEmbeddingProvider({
  170. env: EMPTY_ENV,
  171. configPath,
  172. }) as OpenAIEmbeddingsProvider & {
  173. endpoint: string;
  174. apiKey: string;
  175. batchSize: number;
  176. };
  177. expect(p["endpoint"]).toBe("https://config.example.com");
  178. expect(p["apiKey"]).toBe("config-key");
  179. expect(p.getModelId()).toBe("config-model");
  180. expect(p["batchSize"]).toBe(16);
  181. });
  182. test("env wins over config file", () => {
  183. writeConfig({
  184. embedProvider: {
  185. endpoint: "https://config.example.com",
  186. },
  187. });
  188. const p = createEmbeddingProvider({
  189. env: { QMD_EMBED_ENDPOINT: "https://env.example.com" },
  190. configPath,
  191. }) as OpenAIEmbeddingsProvider & { endpoint: string };
  192. expect(p["endpoint"]).toBe("https://env.example.com");
  193. });
  194. test("openai kind without endpoint throws", () => {
  195. expect(() =>
  196. createEmbeddingProvider({ kind: "openai", env: EMPTY_ENV, configPath }),
  197. ).toThrow(/endpoint/);
  198. });
  199. test("local kind explicitly requested → typed HOLD", () => {
  200. expect(() => createEmbeddingProvider({
  201. kind: "local" as never,
  202. env: EMPTY_ENV,
  203. configPath,
  204. })).toThrow(CommercialApiHoldError);
  205. });
  206. test("missing endpoint → typed HOLD", () => {
  207. expect(() => createEmbeddingProvider({ env: EMPTY_ENV, configPath }))
  208. .toThrow(CommercialApiHoldError);
  209. });
  210. test("legacy auto-fallback request → typed HOLD", () => {
  211. expect(() => createEmbeddingProvider({
  212. env: {
  213. QMD_EMBED_ENDPOINT: "https://commercial.example.com",
  214. QMD_EMBED_AUTO_FALLBACK: "1",
  215. },
  216. configPath,
  217. })).toThrow(CommercialApiHoldError);
  218. });
  219. test("self-hosted and local endpoints → typed HOLD", () => {
  220. for (const endpoint of [
  221. "http://models:8082",
  222. "http://127.0.0.1:8082",
  223. "https://10.0.2.162/v1",
  224. "https://localhost/v1",
  225. ]) {
  226. expect(() => assertCommercialEndpoint(endpoint)).toThrow(CommercialApiHoldError);
  227. }
  228. });
  229. test("commercial HTTPS endpoint is accepted", () => {
  230. expect(() => assertCommercialEndpoint("https://generativelanguage.googleapis.com/v1beta/openai"))
  231. .not.toThrow();
  232. });
  233. });
  234. // ─────────────────────────── loadConfigFile ──────────────────────────────────
  235. describe("loadConfigFile", () => {
  236. test("missing file → empty object", () => {
  237. expect(loadConfigFile(join(workDir, "missing.json"))).toEqual({});
  238. });
  239. test("invalid JSON → empty object (no throw)", () => {
  240. writeFileSync(configPath, "not json");
  241. expect(loadConfigFile(configPath)).toEqual({});
  242. });
  243. test("valid JSON parsed", () => {
  244. writeConfig({ embedProvider: { kind: "openai" } });
  245. expect(loadConfigFile(configPath)).toEqual({
  246. embedProvider: { kind: "openai" },
  247. });
  248. });
  249. });