diff --git a/README.md b/README.md index 3deff9e..b1e7fdb 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,7 @@ greplica transcript bundle --platform codex|claude|copilot|opencode --file loadEnvFile(resolve(repoRoot, file), repoEnvKeys)) .filter((file): file is LoadedEnvFile => file !== undefined); diff --git a/libs/knowledge-graph/graph-context/openai-embedder.ts b/libs/knowledge-graph/graph-context/openai-embedder.ts index 11e3852..e4b9069 100644 --- a/libs/knowledge-graph/graph-context/openai-embedder.ts +++ b/libs/knowledge-graph/graph-context/openai-embedder.ts @@ -1,12 +1,25 @@ import { HttpRequestError, withRetry } from "../../utils/retry.js"; +export const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"; + export interface OpenAIEmbedderOptions { apiKey?: string; + baseUrl?: string; model: string; dimensions: number; batchSize: number; } +/** + * Resolves the OpenAI-compatible API root used for embeddings. + * Falls back to OPENAI_BASE_URL, then to OpenAI itself, mirroring how the API key is resolved. + */ +export function resolveOpenAIBaseUrl(baseUrl?: string): string { + const configured = (baseUrl ?? process.env.OPENAI_BASE_URL)?.trim(); + if (!configured) return DEFAULT_OPENAI_BASE_URL; + return configured.replace(/\/+$/, ""); +} + interface OpenAIEmbeddingResponse { data?: Array<{ index: number; embedding: number[] }>; error?: { message?: string }; @@ -14,6 +27,7 @@ interface OpenAIEmbeddingResponse { export class OpenAIEmbedder { private readonly apiKey: string; + private readonly baseUrl: string; constructor(private readonly options: OpenAIEmbedderOptions) { if (typeof options.batchSize !== "number" || options.batchSize < 1) { @@ -27,6 +41,7 @@ export class OpenAIEmbedder { throw new Error("OPENAI_API_KEY is required for graph context embeddings. Set it in the environment, target-root .env.local, or target-root .env."); } this.apiKey = apiKey; + this.baseUrl = resolveOpenAIBaseUrl(options.baseUrl); } async embed(text: string): Promise { @@ -48,7 +63,7 @@ export class OpenAIEmbedder { return withRetry( async () => { - const response = await fetch("https://api.openai.com/v1/embeddings", { + const response = await fetch(`${this.baseUrl}/embeddings`, { method: "POST", headers: { Authorization: `Bearer ${this.apiKey}`, diff --git a/package.json b/package.json index d6dd889..71d3cae 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs", "smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs", "smoke:cursor": "npm run build && node scripts/smoke-cursor-install.mjs", - "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-agent-runner-spawn-error.js", + "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-agent-runner-spawn-error.js && node scripts/check-openai-embedder-base-url.js", "test:managed-collaboration": "npm run build && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js", "test:reconciliation-code-evidence": "npm run build && node scripts/check-reconciliation-code-evidence.js", "test:repo-installations": "npm run build && node scripts/check-repo-installations.js", diff --git a/scripts/check-openai-embedder-base-url.js b/scripts/check-openai-embedder-base-url.js new file mode 100644 index 0000000..fe95e42 --- /dev/null +++ b/scripts/check-openai-embedder-base-url.js @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { once } from "node:events"; + +const root = new URL("..", import.meta.url); +const { OpenAIEmbedder, DEFAULT_OPENAI_BASE_URL, resolveOpenAIBaseUrl } = await import( + new URL("dist/libs/knowledge-graph/graph-context/openai-embedder.js", root) +); + +delete process.env.OPENAI_BASE_URL; +assert.equal(resolveOpenAIBaseUrl(), DEFAULT_OPENAI_BASE_URL, "unset OPENAI_BASE_URL should keep the OpenAI default"); +assert.equal(resolveOpenAIBaseUrl("https://example.test/v1/"), "https://example.test/v1", "trailing slashes should be trimmed"); + +process.env.OPENAI_BASE_URL = "https://env.example.test/v1"; +assert.equal(resolveOpenAIBaseUrl(), "https://env.example.test/v1", "OPENAI_BASE_URL should override the default"); +assert.equal( + resolveOpenAIBaseUrl("https://option.example.test/v1"), + "https://option.example.test/v1", + "an explicit baseUrl option should win over the environment" +); + +const requests = []; +const server = createServer((req, res) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + requests.push({ url: req.url, authorization: req.headers.authorization, body }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ data: body.input.map((_, index) => ({ index, embedding: [index, 1, 2, 3] })) })); + }); +}); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); + +process.env.OPENAI_API_KEY = "test-key"; +process.env.OPENAI_BASE_URL = `http://127.0.0.1:${server.address().port}/v1/`; + +try { + const embedder = new OpenAIEmbedder({ model: "test-embedding", dimensions: 4, batchSize: 2 }); + const embeddings = await embedder.embedBatch(["alpha", "beta", "gamma"]); + + assert.equal(requests.length, 2, "batchSize 2 should split three inputs across two requests"); + assert.deepEqual( + requests.map((request) => request.url), + ["/v1/embeddings", "/v1/embeddings"], + "requests should go to /embeddings" + ); + assert.equal(requests[0].authorization, "Bearer test-key", "the API key should still be sent as a bearer token"); + assert.deepEqual(requests[0].body.input, ["alpha", "beta"], "the first request should carry the first batch"); + assert.equal(requests[0].body.model, "test-embedding", "the configured model should be forwarded"); + assert.equal(embeddings.length, 3, "every input should produce an embedding"); + assert.deepEqual(embeddings[0], [0, 1, 2, 3], "embeddings should be returned in input order"); +} finally { + server.close(); + await once(server, "close"); +} + +console.log("OpenAI embedder base URL checks passed.");