embedding-factory.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  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("native GEMINI_API_KEY with no endpoint uses Gemini OpenAI-compat", () => {
  107. const p = createEmbeddingProvider({
  108. env: { GEMINI_API_KEY: "AIzaSy-test-key" },
  109. configPath,
  110. }) as OpenAIEmbeddingsProvider & { endpoint: string; apiKey: string };
  111. expect(p).toBeInstanceOf(OpenAIEmbeddingsProvider);
  112. expect(p["endpoint"]).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
  113. expect(p["apiKey"]).toBe("AIzaSy-test-key");
  114. expect(p.getModelId()).toBe("gemini-embedding-001");
  115. });
  116. test("GEMINI_API_KEY is not sent to a non-Gemini endpoint", () => {
  117. const p = createEmbeddingProvider({
  118. env: { QMD_EMBED_ENDPOINT: "https://ai.example.com", GEMINI_API_KEY: "AIzaSy-test-key" },
  119. configPath,
  120. }) as OpenAIEmbeddingsProvider & { endpoint: string; apiKey: string };
  121. expect(p["endpoint"]).toBe("https://ai.example.com");
  122. expect(p["apiKey"]).toBeUndefined();
  123. expect(p.getModelId()).toBe("embeddinggemma");
  124. });
  125. test("openai kind w/ endpoint env → OpenAIEmbeddingsProvider", () => {
  126. const p = createEmbeddingProvider({
  127. env: { QMD_EMBED_ENDPOINT: "https://ai.example.com" },
  128. configPath,
  129. });
  130. expect(p).toBeInstanceOf(OpenAIEmbeddingsProvider);
  131. expect(p.kind).toBe("openai");
  132. expect(p.getModelId()).toBe("embeddinggemma");
  133. });
  134. test("openai kind w/ explicit options merges over env", () => {
  135. const p = createEmbeddingProvider({
  136. env: { QMD_EMBED_ENDPOINT: "https://env.example.com", QMD_EMBED_API_KEY: "env-key" },
  137. configPath,
  138. openai: { endpoint: "https://override.example.com" },
  139. });
  140. // Cast to access internal properties for verification
  141. const inner = p as OpenAIEmbeddingsProvider & { endpoint: string; apiKey: string };
  142. expect(inner["endpoint"]).toBe("https://override.example.com");
  143. // apiKey should still come from env since we didn't override it
  144. expect(inner["apiKey"]).toBe("env-key");
  145. });
  146. test("openai kind reads modelId from env", () => {
  147. const p = createEmbeddingProvider({
  148. env: {
  149. QMD_EMBED_ENDPOINT: "https://ai.example.com",
  150. QMD_EMBED_MODEL_ID: "custom-model",
  151. },
  152. configPath,
  153. });
  154. expect(p.getModelId()).toBe("custom-model");
  155. });
  156. test("openai kind reads upstream model from env", () => {
  157. const p = createEmbeddingProvider({
  158. env: {
  159. QMD_EMBED_ENDPOINT: "https://ai.example.com",
  160. QMD_EMBED_UPSTREAM_MODEL: "embeddinggemma:300m",
  161. },
  162. configPath,
  163. }) as OpenAIEmbeddingsProvider & { upstreamModel: string };
  164. expect(p["upstreamModel"]).toBe("embeddinggemma:300m");
  165. });
  166. test("openai kind reads batch size and timeout from env", () => {
  167. const p = createEmbeddingProvider({
  168. env: {
  169. QMD_EMBED_ENDPOINT: "https://ai.example.com",
  170. QMD_EMBED_BATCH_SIZE: "32",
  171. QMD_EMBED_TIMEOUT_MS: "5000",
  172. },
  173. configPath,
  174. }) as OpenAIEmbeddingsProvider & { batchSize: number; timeoutMs: number };
  175. expect(p["batchSize"]).toBe(32);
  176. expect(p["timeoutMs"]).toBe(5000);
  177. });
  178. test("openai kind merges config file values", () => {
  179. writeConfig({
  180. embedProvider: {
  181. kind: "openai",
  182. endpoint: "https://config.example.com",
  183. apiKey: "config-key",
  184. modelId: "config-model",
  185. batchSize: 16,
  186. },
  187. });
  188. const p = createEmbeddingProvider({
  189. env: EMPTY_ENV,
  190. configPath,
  191. }) as OpenAIEmbeddingsProvider & {
  192. endpoint: string;
  193. apiKey: string;
  194. batchSize: number;
  195. };
  196. expect(p["endpoint"]).toBe("https://config.example.com");
  197. expect(p["apiKey"]).toBe("config-key");
  198. expect(p.getModelId()).toBe("config-model");
  199. expect(p["batchSize"]).toBe(16);
  200. });
  201. test("env wins over config file", () => {
  202. writeConfig({
  203. embedProvider: {
  204. endpoint: "https://config.example.com",
  205. },
  206. });
  207. const p = createEmbeddingProvider({
  208. env: { QMD_EMBED_ENDPOINT: "https://env.example.com" },
  209. configPath,
  210. }) as OpenAIEmbeddingsProvider & { endpoint: string };
  211. expect(p["endpoint"]).toBe("https://env.example.com");
  212. });
  213. test("openai kind without endpoint throws", () => {
  214. expect(() =>
  215. createEmbeddingProvider({ kind: "openai", env: EMPTY_ENV, configPath }),
  216. ).toThrow(/endpoint/);
  217. });
  218. test("local kind explicitly requested → typed HOLD", () => {
  219. expect(() => createEmbeddingProvider({
  220. kind: "local" as never,
  221. env: EMPTY_ENV,
  222. configPath,
  223. })).toThrow(CommercialApiHoldError);
  224. });
  225. test("missing endpoint → typed HOLD", () => {
  226. expect(() => createEmbeddingProvider({ env: EMPTY_ENV, configPath }))
  227. .toThrow(CommercialApiHoldError);
  228. });
  229. test("legacy auto-fallback request → typed HOLD", () => {
  230. expect(() => createEmbeddingProvider({
  231. env: {
  232. QMD_EMBED_ENDPOINT: "https://commercial.example.com",
  233. QMD_EMBED_AUTO_FALLBACK: "1",
  234. },
  235. configPath,
  236. })).toThrow(CommercialApiHoldError);
  237. });
  238. test("self-hosted and local endpoints → typed HOLD", () => {
  239. for (const endpoint of [
  240. "http://models:8082",
  241. "http://127.0.0.1:8082",
  242. "https://10.0.2.162/v1",
  243. "https://localhost/v1",
  244. ]) {
  245. expect(() => assertCommercialEndpoint(endpoint)).toThrow(CommercialApiHoldError);
  246. }
  247. });
  248. test("commercial HTTPS endpoint is accepted", () => {
  249. expect(() => assertCommercialEndpoint("https://generativelanguage.googleapis.com/v1beta/openai"))
  250. .not.toThrow();
  251. });
  252. });
  253. // ─────────────────────────── loadConfigFile ──────────────────────────────────
  254. describe("loadConfigFile", () => {
  255. test("missing file → empty object", () => {
  256. expect(loadConfigFile(join(workDir, "missing.json"))).toEqual({});
  257. });
  258. test("invalid JSON → empty object (no throw)", () => {
  259. writeFileSync(configPath, "not json");
  260. expect(loadConfigFile(configPath)).toEqual({});
  261. });
  262. test("valid JSON parsed", () => {
  263. writeConfig({ embedProvider: { kind: "openai" } });
  264. expect(loadConfigFile(configPath)).toEqual({
  265. embedProvider: { kind: "openai" },
  266. });
  267. });
  268. });