Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ greplica transcript bundle --platform codex|claude|copilot|opencode --file <path
- `greplica graph view` to visualize the current memory in a local HTML file, which opens in your default browser. Use `--out` to choose where the file is written; by default it goes to a temp path.
- `greplica transcript bundle` - converts one or more Codex, Claude Code, GitHub Copilot CLI, or OpenCode transcripts into a sanitized Markdown bundle for `greplica-fast-session-bootstrap`.
- `greplica embeddings prewarm` - downloads and initializes the local embedding model ahead of the first query when local embeddings are configured.
- `--embedding openai` reads `OPENAI_API_KEY` from the shell, target-root `.env.local`, or target-root `.env`, and calls `https://api.openai.com/v1` by default. Set `OPENAI_BASE_URL` in any of those places to use a different OpenAI-compatible embeddings endpoint - Azure OpenAI, ollama, vLLM, LM Studio, or a gateway - including whatever path prefix that endpoint serves, for example `http://localhost:11434/v1`. Set `embedding.model` and `embedding.dimensions` to values that endpoint actually serves; the `text-embedding-3-small` / 1536 default is OpenAI-specific, and `greplica doctor --check-embeddings` reports the endpoint it will call.
- `greplica session mark-memory-current` - marks a tracked agent session as already reflected in working memory.
- `greplica doctor` - verifies installation and diagnoses configuration failures. Not a required preflight before every command.
- `greplica install` prepares repo state, local storage, and agent integration; normal repo commands require install first. Local and managed mode are selected independently per repository.
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
ManagedProposal,
} from "../../libs/managed/protocol.js";
import { createEmbedder } from "../../libs/knowledge-graph/graph-context/embedder.js";
import { resolveOpenAIBaseUrl } from "../../libs/knowledge-graph/graph-context/openai-embedder.js";
import { renderGraphContextMarkdown } from "../../libs/knowledge-graph/graph-context/render.js";
import { buildGraphFolderExport } from "../../libs/knowledge-graph/folder-export.js";
import { buildTranscriptBundle } from "../../libs/session-transcript/bundle.js";
Expand Down Expand Up @@ -890,6 +891,7 @@ async function runDoctor(args: string[], getContext: CommandContextProvider): Pr
} else {
console.log(`OPENAI_API_KEY: found in ${source.path}`);
}
console.log(`Embeddings endpoint: ${resolveOpenAIBaseUrl()}/embeddings`);
}

if (installation.activeMode === "local" && (args.includes("--check-embeddings") || args.includes("--check-openai"))) {
Expand Down
2 changes: 1 addition & 1 deletion libs/env/load-local-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export type EnvVarSource =

export function loadRepoEnv(repoRoot: string): LoadedRepoEnv {
const initialEnvKeys = new Set(Object.keys(process.env).filter(hasEnvValue));
const repoEnvKeys = new Set(["OPENAI_API_KEY", "OPENAI_MODEL"]);
const repoEnvKeys = new Set(["OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_MODEL"]);
const files = [".env.local", ".env"]
.map((file) => loadEnvFile(resolve(repoRoot, file), repoEnvKeys))
.filter((file): file is LoadedEnvFile => file !== undefined);
Expand Down
17 changes: 16 additions & 1 deletion libs/knowledge-graph/graph-context/openai-embedder.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
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 };
}

export class OpenAIEmbedder {
private readonly apiKey: string;
private readonly baseUrl: string;

constructor(private readonly options: OpenAIEmbedderOptions) {
if (typeof options.batchSize !== "number" || options.batchSize < 1) {
Expand All @@ -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<number[]> {
Expand All @@ -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}`,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
59 changes: 59 additions & 0 deletions scripts/check-openai-embedder-base-url.js
Original file line number Diff line number Diff line change
@@ -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 <OPENAI_BASE_URL>/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.");