Skip to content
Closed
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
12 changes: 12 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ R2_BUCKET_NAME=mike
GEMINI_API_KEY=your-gemini-key
ANTHROPIC_API_KEY=your-anthropic-key
OPENAI_API_KEY=your-openai-key
# Optional: OpenAI-compatible gateway endpoint, e.g. OpenRouter/Azure proxy/Ollama-compatible bridge.
# Must be https in production unless OPENAI_ALLOW_LOCAL_BASE_URL=true.
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_ALLOW_LOCAL_BASE_URL=false
# Local models via Ollama (opt-in, off by default). To enable: set ENABLE_OLLAMA=true
# and point the OpenAI-compatible client at your local Ollama server:
# ENABLE_OLLAMA=true
# OPENAI_BASE_URL=http://localhost:11434/v1
# OPENAI_ALLOW_LOCAL_BASE_URL=true
# OPENAI_API_KEY=ollama # Ollama accepts any non-empty string
# OLLAMA_MODELS=my-custom-model # optional; extra models beyond the defaults
ENABLE_OLLAMA=false
RESEND_API_KEY=your-resend-key
USER_API_KEYS_ENCRYPTION_SECRET=your-long-random-secret

Expand Down
66 changes: 66 additions & 0 deletions backend/src/core/apiKeyProviders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
export type ApiKeyProvider = string;
export type ApiKeySource = "user" | "env" | null;

type ProviderRecord = {
readonly envVars: readonly string[];
};

// Table-driven: env-var names live here, not in a switch statement.
// Adding a new provider is one registerApiKeyProvider() call — no edits here.
const _providerRegistry = new Map<string, ProviderRecord>([
["claude", { envVars: ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"] }],
["gemini", { envVars: ["GEMINI_API_KEY"] }],
["openai", { envVars: ["OPENAI_API_KEY"] }],
["openrouter", { envVars: ["OPENROUTER_API_KEY"] }],
["courtlistener", { envVars: ["COURTLISTENER_API_TOKEN"] }],
]);

/**
* Register a new API-key provider so that getUserApiKeyStatus() and
* getUserApiKeys() include it automatically.
*
* Call once from your provider setup file alongside registerProvider():
*
* registerApiKeyProvider("bedrock", ["AWS_ACCESS_KEY_ID"]);
* registerApiKeyProvider("ollama", []); // no key required
*/
export function registerApiKeyProvider(
provider: string,
envVars: readonly string[],
): void {
_providerRegistry.set(provider, { envVars });
}

/** Returns provider IDs in registration order. */
export function getRegisteredProviders(): readonly string[] {
return [..._providerRegistry.keys()];
}

export function isApiKeyProvider(value: string): boolean {
return _providerRegistry.has(value);
}

export function normalizeApiKeyProvider(value: string): string | null {
return _providerRegistry.has(value) ? value : null;
}

/**
* Returns the platform API key for provider from environment variables,
* or null when none of the provider's env vars are set.
*
* Table-driven: the env var names are declared in the provider registry above,
* not hard-coded per-provider in this function body.
*/
export function envApiKey(provider: string): string | null {
const record = _providerRegistry.get(provider);
if (!record) return null;
for (const varName of record.envVars) {
const val = process.env[varName]?.trim();
if (val) return val;
}
return null;
}

export function hasEnvApiKey(provider: string): boolean {
return !!envApiKey(provider);
}
58 changes: 58 additions & 0 deletions backend/src/lib/llm/__tests__/baseUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";

import { openAIResponsesUrl, resolveOpenAIBaseUrl } from "../baseUrl";

describe("resolveOpenAIBaseUrl", () => {
it("defaults to the official OpenAI v1 endpoint", () => {
expect(resolveOpenAIBaseUrl()).toBe("https://api.openai.com/v1");
expect(openAIResponsesUrl()).toBe("https://api.openai.com/v1/responses");
});

it("normalizes trailing slashes", () => {
expect(resolveOpenAIBaseUrl("https://gateway.example.com/v1///")).toBe(
"https://gateway.example.com/v1",
);
});

it("allows local http endpoints outside production", () => {
expect(resolveOpenAIBaseUrl("http://localhost:11434/v1", "development")).toBe(
"http://localhost:11434/v1",
);
});

it("rejects http endpoints in production", () => {
expect(() =>
resolveOpenAIBaseUrl("http://gateway.example.com/v1", "production"),
).toThrow(/https in production/);
});

it("rejects unsupported protocols", () => {
expect(() => resolveOpenAIBaseUrl("file:///tmp/openai")).toThrow(
/http or https/,
);
});

it("rejects private/reserved IP literals in production (SSRF)", () => {
for (const host of [
"https://10.0.1.50/v1",
"https://172.16.5.4/v1",
"https://192.168.1.10/v1",
"https://169.254.169.254/v1", // cloud metadata
"https://127.0.0.1/v1",
"https://[::1]/v1",
]) {
expect(() => resolveOpenAIBaseUrl(host, "production")).toThrow(
/private or reserved IP|localhost/,
);
}
});

it("allows a public IP / hostname in production", () => {
expect(resolveOpenAIBaseUrl("https://8.8.8.8/v1", "production")).toBe(
"https://8.8.8.8/v1",
);
expect(
resolveOpenAIBaseUrl("https://gateway.example.com/v1", "production"),
).toBe("https://gateway.example.com/v1");
});
});
97 changes: 97 additions & 0 deletions backend/src/lib/llm/__tests__/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
registerProvider,
getRegisteredProvider,
findProviderForModel,
registeredProviderIds,
allRegisteredModels,
_resetRegistryForTesting,
type LLMProviderAdapter,
} from "../registry";

function makeAdapter(id: string, prefixes: string[], models: string[] = []): LLMProviderAdapter {
return {
id,
matchesModel: (m) => prefixes.some((p) => m.startsWith(p)),
stream: async () => ({ fullText: "" }),
complete: async () => "",
models: { main: models, mid: [], low: [] },
};
}

beforeEach(() => {
_resetRegistryForTesting();
});

describe("registerProvider / getRegisteredProvider", () => {
it("stores and retrieves an adapter by id", () => {
const adapter = makeAdapter("test", ["test-"]);
registerProvider(adapter);
expect(getRegisteredProvider("test")).toBe(adapter);
});

it("returns undefined for an unknown id", () => {
expect(getRegisteredProvider("unknown")).toBeUndefined();
});

it("re-registration replaces the previous adapter", () => {
const first = makeAdapter("p", ["p-"]);
const second = makeAdapter("p", ["p-"]);
registerProvider(first);
registerProvider(second);
expect(getRegisteredProvider("p")).toBe(second);
});
});

describe("findProviderForModel", () => {
it("returns the first provider whose matchesModel is true", () => {
const a = makeAdapter("alpha", ["alpha-"]);
const b = makeAdapter("beta", ["beta-"]);
registerProvider(a);
registerProvider(b);
expect(findProviderForModel("alpha-turbo")).toBe(a);
expect(findProviderForModel("beta-fast")).toBe(b);
});

it("returns undefined when no provider matches", () => {
registerProvider(makeAdapter("x", ["x-"]));
expect(findProviderForModel("unknown-model")).toBeUndefined();
});

it("the first registered provider wins on overlap", () => {
const first = makeAdapter("first", ["shared-"]);
const second = makeAdapter("second", ["shared-"]);
registerProvider(first);
registerProvider(second);
expect(findProviderForModel("shared-model")).toBe(first);
});
});

describe("registeredProviderIds", () => {
it("returns ids in insertion order", () => {
registerProvider(makeAdapter("c", ["c-"]));
registerProvider(makeAdapter("a", ["a-"]));
registerProvider(makeAdapter("b", ["b-"]));
expect(registeredProviderIds()).toEqual(["c", "a", "b"]);
});

it("returns an empty array when no providers are registered", () => {
expect(registeredProviderIds()).toEqual([]);
});
});

describe("allRegisteredModels", () => {
it("returns the union of all provider model lists", () => {
registerProvider(makeAdapter("p1", ["m-"], ["m1", "m2"]));
registerProvider(makeAdapter("p2", ["n-"], ["m2", "n1"]));
const set = allRegisteredModels();
expect(set.has("m1")).toBe(true);
expect(set.has("m2")).toBe(true);
expect(set.has("n1")).toBe(true);
expect(set.size).toBe(3);
});

it("returns an empty set when no providers are registered", () => {
expect(allRegisteredModels().size).toBe(0);
});
});
50 changes: 50 additions & 0 deletions backend/src/lib/llm/baseUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import net from "net";
import { isBlockedIp } from "../privateIp";

const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";

function isLocalHostname(hostname: string): boolean {
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname.endsWith(".local")
);
}

export function resolveOpenAIBaseUrl(
raw = process.env.OPENAI_BASE_URL ?? DEFAULT_OPENAI_BASE_URL,
nodeEnv = process.env.NODE_ENV,
): string {
const parsed = new URL(raw);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new Error("OPENAI_BASE_URL must use http or https");
}
if (nodeEnv === "production" && parsed.protocol !== "https:") {
throw new Error("OPENAI_BASE_URL must use https in production");
}
if (nodeEnv === "production" && process.env.OPENAI_ALLOW_LOCAL_BASE_URL !== "true") {
// URL hostnames wrap IPv6 in brackets ([::1]); strip them for net.isIP.
const host = parsed.hostname.replace(/^\[|\]$/g, "");
if (isLocalHostname(parsed.hostname)) {
throw new Error(
"OPENAI_BASE_URL cannot point at localhost in production unless OPENAI_ALLOW_LOCAL_BASE_URL=true",
);
}
// Reject IP literals in private/reserved ranges (SSRF) — parity with the
// MCP egress guard. DNS hostnames are operator config, not resolved here.
if (net.isIP(host) !== 0 && isBlockedIp(host)) {
throw new Error(
"OPENAI_BASE_URL cannot point at a private or reserved IP in production unless OPENAI_ALLOW_LOCAL_BASE_URL=true",
);
}
}
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
parsed.search = "";
parsed.hash = "";
return parsed.toString().replace(/\/$/, "");
}

export function openAIResponsesUrl(baseUrl = resolveOpenAIBaseUrl()): string {
return `${baseUrl}/responses`;
}
Loading
Loading