Add support for generic OpenAI-compatible endpoint LLM provider (#253)

* initial

* fix regressions

* Fix OpenAI-compatible provider aliases

* Normalize OpenAI-compatible settings aliases

* Update shared/src/settings-registry.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Shaheer Sarfaraz 2026-03-11 02:33:20 +00:00 committed by GitHub
parent 6454efd9a2
commit faea61a249
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 331 additions and 21 deletions

View File

@ -69,7 +69,7 @@ docker compose up -d
## Why JobOps?
* **Universal Scraping**: Supports **LinkedIn, Indeed, Glassdoor, Adzuna, Hiring Café, Gradcracker, UK Visa Jobs**.
* **AI Scoring**: Ranks jobs by fit against *your* profile using your preferred LLM (OpenRouter/OpenAI/Gemini).
* **AI Scoring**: Ranks jobs by fit against *your* profile using your preferred LLM (OpenAI, OpenRouter, `openai-compatible` endpoints such as LM Studio/Ollama, Gemini).
* **Auto-Tailoring**: Generates custom resumes (PDFs) for every application using RxResume v4.
* **Email Tracking**: Connect Gmail to auto-detect interviews, offers, and rejections.
* **Self-Hosted**: Your data stays with you. SQLite database. No SaaS fees.

View File

@ -40,9 +40,11 @@ orchestrator/
`v5` (API key) is recommended for self-hosted/latest Reactive Resume. Use `v4` when connecting to the legacy email/password flow.
OpenRouter is the default LLM provider, but LM Studio, Ollama, OpenAI, and Gemini are also supported.
OpenRouter is the default LLM provider, but OpenAI, LM Studio, Ollama, `openai-compatible` endpoints, and Gemini are also supported.
Use `LLM_API_KEY` / `llmApiKey` to configure providers that require an API key.
To use the native OpenAI integration, set `LLM_PROVIDER=openai`.
For third-party services that expose an OpenAI-style API but are not OpenAI itself, use `LLM_PROVIDER=openai-compatible`.
3. **Initialize database:**
```bash
@ -143,6 +145,6 @@ npm start
- **Backend:** Express, TypeScript, Drizzle ORM, SQLite
- **Frontend:** React, Vite, CSS (custom design system)
- **AI:** Configurable LLM provider (OpenRouter default; also supports OpenAI/Gemini/LM Studio/Ollama)
- **AI:** Configurable LLM provider (OpenRouter default; also supports OpenAI via the dedicated `openai` provider, `openai-compatible` endpoints, Gemini, LM Studio, and Ollama)
- **PDF Generation:** Reactive Resume v4/v5 API export (configured via Settings)
- **Job Crawling:** Wraps existing TypeScript Crawlee crawler

View File

@ -3,6 +3,8 @@ import type { ModelValues } from "@client/pages/settings/types";
import {
formatSecretHint,
getLlmProviderConfig,
LLM_PROVIDER_LABELS,
LLM_PROVIDERS,
} from "@client/pages/settings/utils";
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
import type React from "react";
@ -97,11 +99,11 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="openrouter">OpenRouter</SelectItem>
<SelectItem value="lmstudio">LM Studio</SelectItem>
<SelectItem value="ollama">Ollama</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
{LLM_PROVIDERS.map((provider) => (
<SelectItem key={provider} value={provider}>
{LLM_PROVIDER_LABELS[provider]}
</SelectItem>
))}
</SelectContent>
</Select>
)}

View File

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { getLlmProviderConfig, normalizeLlmProvider } from "./utils";
describe("settings utils", () => {
it("treats openai-compatible as a dedicated configurable provider", () => {
const config = getLlmProviderConfig("openai_compatible");
expect(config.label).toBe("OpenAI-compatible");
expect(config.showApiKey).toBe(true);
expect(config.showBaseUrl).toBe(true);
expect(config.baseUrlPlaceholder).toBe(
"https://api.example.com/v1/chat/completions",
);
});
it("normalizes the hyphenated openai-compatible alias", () => {
expect(normalizeLlmProvider("openai-compatible")).toBe("openai_compatible");
});
it("defaults unknown providers to openrouter", () => {
expect(normalizeLlmProvider("unknown-provider")).toBe("openrouter");
});
});

View File

@ -24,6 +24,7 @@ export const LLM_PROVIDERS = [
"lmstudio",
"ollama",
"openai",
"openai_compatible",
"gemini",
] as const;
@ -34,16 +35,22 @@ export const LLM_PROVIDER_LABELS: Record<LlmProviderId, string> = {
lmstudio: "LM Studio",
ollama: "Ollama",
openai: "OpenAI",
openai_compatible: "OpenAI-compatible",
gemini: "Gemini",
};
const PROVIDERS_WITH_API_KEY = new Set<LlmProviderId>([
"openrouter",
"openai",
"openai_compatible",
"gemini",
]);
const PROVIDERS_WITH_BASE_URL = new Set<LlmProviderId>(["lmstudio", "ollama"]);
const PROVIDERS_WITH_BASE_URL = new Set<LlmProviderId>([
"lmstudio",
"ollama",
"openai_compatible",
]);
const PROVIDER_HINTS: Record<LlmProviderId, string> = {
openrouter:
@ -51,6 +58,8 @@ const PROVIDER_HINTS: Record<LlmProviderId, string> = {
lmstudio: "LM Studio runs locally via its OpenAI-compatible server.",
ollama: "Ollama typically runs locally and does not require an API key.",
openai: "OpenAI uses the Responses API with structured outputs.",
openai_compatible:
"Use a bearer token with any chat-completions-compatible endpoint.",
gemini: "Gemini uses the native AI Studio API and requires a key.",
};
@ -59,15 +68,17 @@ const PROVIDER_KEY_HELPERS: Record<LlmProviderId, string> = {
lmstudio: "No API key required for LM Studio",
ollama: "No API key required for Ollama",
openai: "Create a key at platform.openai.com",
openai_compatible: "Use the bearer token issued by your compatible provider",
gemini: "Create a key at aistudio.google.com/api-keys",
};
const BASE_URL_PROVIDERS = ["lmstudio", "ollama"] as const;
const BASE_URL_PROVIDERS = ["lmstudio", "ollama", "openai_compatible"] as const;
type BaseUrlProviderId = (typeof BASE_URL_PROVIDERS)[number];
const PROVIDER_BASE_URLS: Record<BaseUrlProviderId, string> = {
lmstudio: "http://localhost:1234",
ollama: "http://localhost:11434",
openai_compatible: "https://api.example.com/v1/chat/completions",
};
export function normalizeLlmProvider(
@ -75,6 +86,7 @@ export function normalizeLlmProvider(
): LlmProviderId {
const normalized = value?.trim().toLowerCase();
if (!normalized) return "openrouter";
if (normalized === "openai-compatible") return "openai_compatible";
return (LLM_PROVIDERS as readonly string[]).includes(normalized)
? (normalized as LlmProviderId)
: "openrouter";
@ -87,7 +99,11 @@ export function getLlmProviderConfig(provider: string | null | undefined) {
const baseUrlPlaceholder = showBaseUrl
? PROVIDER_BASE_URLS[normalizedProvider as BaseUrlProviderId]
: "";
const baseUrlHelper = showBaseUrl ? `Default: ${baseUrlPlaceholder}` : "";
const baseUrlHelper = showBaseUrl
? normalizedProvider === "openai_compatible"
? "Enter a base URL or a full /v1/chat/completions endpoint."
: `Default: ${baseUrlPlaceholder}`
: "";
const providerHint = PROVIDER_HINTS[normalizedProvider];
const keyHelper = PROVIDER_KEY_HELPERS[normalizedProvider];

View File

@ -210,6 +210,59 @@ describe.sequential("Onboarding API routes", () => {
),
).toBe(true);
});
it("uses the provided baseUrl for the hyphenated OpenAI-compatible alias", async () => {
global.fetch = vi.fn((input, init) => {
const url = typeof input === "string" ? input : input.url;
if (url.startsWith("https://llm.example.com/v1/models")) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ data: [] }),
} as Response);
}
if (url.startsWith("https://api.openai.com/v1/models")) {
return Promise.resolve({
ok: false,
status: 500,
json: async () => ({ error: { message: "wrong endpoint used" } }),
} as Response);
}
return originalFetch(input, init);
});
const res = await fetch(`${baseUrl}/api/onboarding/validate/llm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: "openai-compatible",
apiKey: "test-compatible-key",
baseUrl: "https://llm.example.com/v1/",
}),
});
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(true);
expect(body.data.message).toBeNull();
const fetchCalls = vi.mocked(global.fetch).mock.calls.map((call) => {
const requestInput = call[0];
if (typeof requestInput === "string") return requestInput;
if (requestInput instanceof URL) return requestInput.href;
return requestInput.url;
});
expect(
fetchCalls.some((url) =>
url.startsWith("https://llm.example.com/v1/models"),
),
).toBe(true);
expect(
fetchCalls.some((url) =>
url.startsWith("https://api.openai.com/v1/models"),
),
).toBe(false);
});
});
describe("POST /api/onboarding/validate/rxresume", () => {

View File

@ -30,10 +30,13 @@ async function validateLlm(options: {
getSetting("llmBaseUrl"),
]);
const normalizedProvider =
options.provider?.trim() || storedProvider?.trim() || undefined;
const normalizedProvider = normalizeLlmProviderValue(
options.provider?.trim() || storedProvider?.trim() || undefined,
);
const shouldUseBaseUrl =
normalizedProvider === "lmstudio" || normalizedProvider === "ollama";
normalizedProvider === "lmstudio" ||
normalizedProvider === "ollama" ||
normalizedProvider === "openai_compatible";
const resolvedBaseUrl = shouldUseBaseUrl
? options.baseUrl?.trim() || storedBaseUrl?.trim() || undefined
: undefined;
@ -54,6 +57,13 @@ async function validateLlm(options: {
return llm.validateCredentials();
}
function normalizeLlmProviderValue(
provider: string | undefined,
): string | undefined {
if (!provider) return undefined;
return provider.toLowerCase().replace(/-/g, "_");
}
/**
* Validate that a base resume is configured and accessible via Reactive Resume.
*/

View File

@ -88,6 +88,28 @@ describe.sequential("Settings API routes", () => {
expect(body.data.basicAuthActive).toBe(false);
});
it("normalizes hyphenated openai-compatible env defaults", async () => {
const hyphenated = await startServer({
env: {
LLM_API_KEY: "secret-key",
LLM_PROVIDER: "openai-compatible",
RXRESUME_EMAIL: "resume@example.com",
},
});
try {
const res = await fetch(`${hyphenated.baseUrl}/api/settings`);
const body = await res.json();
expect(body.ok).toBe(true);
expect(body.data.llmProvider.default).toBe("openai_compatible");
expect(body.data.llmProvider.value).toBe("openai_compatible");
expect(body.data.llmBaseUrl.default).toBe("https://api.openai.com");
} finally {
await stopServer(hyphenated);
}
});
it("rejects invalid settings updates and persists overrides", async () => {
const badPatch = await fetch(`${baseUrl}/api/settings`, {
method: "PATCH",

View File

@ -3,6 +3,7 @@ import { geminiStrategy } from "./gemini";
import { lmStudioStrategy } from "./lmstudio";
import { ollamaStrategy } from "./ollama";
import { openAiStrategy } from "./openai";
import { openAiCompatibleStrategy } from "./openai-compatible";
import { openRouterStrategy } from "./openrouter";
export const strategies: Record<LlmProvider, ProviderStrategy> = {
@ -10,5 +11,6 @@ export const strategies: Record<LlmProvider, ProviderStrategy> = {
lmstudio: lmStudioStrategy,
ollama: ollamaStrategy,
openai: openAiStrategy,
openai_compatible: openAiCompatibleStrategy,
gemini: geminiStrategy,
};

View File

@ -0,0 +1,60 @@
import { buildHeaders, joinUrl } from "../utils/http";
import {
buildChatCompletionsBody,
createProviderStrategy,
extractChatCompletionsText,
} from "./factory";
const CHAT_COMPLETIONS_SUFFIX = "/v1/chat/completions";
const MODELS_SUFFIX = "/v1/models";
const API_VERSION_SUFFIX = "/v1";
function normalizeBaseUrlOrEndpoint(baseUrlOrEndpoint: string): string {
return baseUrlOrEndpoint.trim().replace(/\/+$/, "");
}
function appendVersionedPath(baseUrl: string, path: string): string {
if (baseUrl.endsWith(API_VERSION_SUFFIX)) {
return joinUrl(baseUrl.slice(0, -API_VERSION_SUFFIX.length), path);
}
return joinUrl(baseUrl, path);
}
function resolveChatCompletionsUrl(baseUrlOrEndpoint: string): string {
const normalized = normalizeBaseUrlOrEndpoint(baseUrlOrEndpoint);
if (
normalized.endsWith(CHAT_COMPLETIONS_SUFFIX) ||
normalized.endsWith("/chat/completions")
) {
return normalized;
}
return appendVersionedPath(normalized, CHAT_COMPLETIONS_SUFFIX);
}
function resolveModelsUrl(baseUrlOrEndpoint: string): string {
const normalized = normalizeBaseUrlOrEndpoint(baseUrlOrEndpoint);
if (normalized.endsWith(CHAT_COMPLETIONS_SUFFIX)) {
return `${normalized.slice(0, -"/chat/completions".length)}/models`;
}
if (normalized.endsWith("/chat/completions")) {
return normalized.replace(/\/chat\/completions$/, "/models");
}
return appendVersionedPath(normalized, MODELS_SUFFIX);
}
export const openAiCompatibleStrategy = createProviderStrategy({
provider: "openai_compatible",
defaultBaseUrl: "https://api.openai.com",
requiresApiKey: true,
modes: ["json_schema", "json_object", "text", "none"],
validationPaths: ["/v1/models"],
getValidationUrls: ({ baseUrl }) => [resolveModelsUrl(baseUrl)],
buildRequest: ({ mode, baseUrl, apiKey, model, messages, jsonSchema }) => {
return {
url: resolveChatCompletionsUrl(baseUrl),
headers: buildHeaders({ apiKey, provider: "openai_compatible" }),
body: buildChatCompletionsBody({ mode, model, messages, jsonSchema }),
};
},
extractText: extractChatCompletionsText,
});

View File

@ -3,6 +3,7 @@ import { geminiStrategy } from "./gemini";
import { lmStudioStrategy } from "./lmstudio";
import { ollamaStrategy } from "./ollama";
import { openAiStrategy } from "./openai";
import { openAiCompatibleStrategy } from "./openai-compatible";
import { openRouterStrategy } from "./openrouter";
const schema = {
@ -43,6 +44,42 @@ describe("provider adapters", () => {
},
expectedUrl: "https://api.openai.com/v1/responses",
},
{
name: "openai-compatible-json_object",
strategy: openAiCompatibleStrategy,
args: {
mode: "json_object" as const,
baseUrl: "https://llm.example.com/v1/chat/completions",
apiKey: "x",
model: "model-a",
},
expectedUrl: "https://llm.example.com/v1/chat/completions",
expectedResponseFormat: "json_object",
},
{
name: "openai-compatible-json_object-base-root",
strategy: openAiCompatibleStrategy,
args: {
mode: "json_object" as const,
baseUrl: "https://llm.example.com",
apiKey: "x",
model: "model-a",
},
expectedUrl: "https://llm.example.com/v1/chat/completions",
expectedResponseFormat: "json_object",
},
{
name: "openai-compatible-json_object-base-v1",
strategy: openAiCompatibleStrategy,
args: {
mode: "json_object" as const,
baseUrl: "https://llm.example.com/v1/",
apiKey: "x",
model: "model-a",
},
expectedUrl: "https://llm.example.com/v1/chat/completions",
expectedResponseFormat: "json_object",
},
{
name: "gemini-json_schema",
strategy: geminiStrategy,
@ -114,6 +151,27 @@ describe("provider adapters", () => {
expect(ollamaStrategy.extractText(response)).toBe("ok");
});
it("builds validation URLs for OpenAI-compatible base URLs and endpoints", () => {
expect(
openAiCompatibleStrategy.getValidationUrls({
baseUrl: "https://llm.example.com",
apiKey: "x",
}),
).toEqual(["https://llm.example.com/v1/models"]);
expect(
openAiCompatibleStrategy.getValidationUrls({
baseUrl: "https://llm.example.com/v1/",
apiKey: "x",
}),
).toEqual(["https://llm.example.com/v1/models"]);
expect(
openAiCompatibleStrategy.getValidationUrls({
baseUrl: "https://llm.example.com/v1/chat/completions",
apiKey: "x",
}),
).toEqual(["https://llm.example.com/v1/models"]);
});
it("extracts text for openai and gemini variants", () => {
expect(openAiStrategy.extractText({ output_text: "openai-direct" })).toBe(
"openai-direct",

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { LlmService } from "./service";
describe("LlmService provider normalization", () => {
it("keeps legacy localhost openai_compatible configs on LM Studio", () => {
const llm = new LlmService({
provider: "openai_compatible",
baseUrl: "http://localhost:1234",
});
expect(llm.getProvider()).toBe("lmstudio");
expect(llm.getBaseUrl()).toBe("http://localhost:1234");
});
it("uses the dedicated provider for non-local OpenAI-compatible endpoints", () => {
const llm = new LlmService({
provider: "openai_compatible",
baseUrl: "https://llm.example.com",
});
expect(llm.getProvider()).toBe("openai_compatible");
expect(llm.getBaseUrl()).toBe("https://llm.example.com");
});
it("normalizes the hyphenated openai-compatible alias", () => {
const llm = new LlmService({
provider: "openai-compatible",
baseUrl: "https://llm.example.com",
});
expect(llm.getProvider()).toBe("openai_compatible");
expect(llm.getBaseUrl()).toBe("https://llm.example.com");
});
});

View File

@ -285,7 +285,7 @@ function normalizeProvider(
raw: string | null,
baseUrl: string | null,
): LlmProvider {
const normalized = raw?.trim().toLowerCase();
const normalized = raw?.trim().toLowerCase().replace(/-/g, "_");
if (normalized === "openai_compatible") {
if (
baseUrl?.includes("localhost:1234") ||
@ -293,7 +293,7 @@ function normalizeProvider(
) {
return "lmstudio";
}
return "openai";
return "openai_compatible";
}
if (normalized === "openai") return "openai";
if (normalized === "gemini") return "gemini";

View File

@ -3,6 +3,7 @@ export type LlmProvider =
| "lmstudio"
| "ollama"
| "openai"
| "openai_compatible"
| "gemini";
export type ResponseMode = "json_schema" | "json_object" | "text" | "none";

View File

@ -13,12 +13,15 @@ import {
import { resolveRxResumeBaseResumeIdForMode } from "./rxresume/baseResumeId";
function resolveDefaultLlmBaseUrl(provider: string): string {
const normalized = provider.trim().toLowerCase();
const normalized = provider.trim().toLowerCase().replace(/-/g, "_");
if (normalized === "ollama") return "http://localhost:11434";
if (normalized === "lmstudio") return "http://localhost:1234";
if (normalized === "openai") {
return "https://api.openai.com";
}
if (normalized === "openai_compatible") {
return "https://api.openai.com";
}
if (normalized === "gemini") {
return "https://generativelanguage.googleapis.com";
}

View File

@ -117,4 +117,15 @@ describe("settingsRegistry helpers", () => {
expect(settingsRegistry.rxresumeApiKey.envKey).toBe("RXRESUME_API_KEY");
});
});
describe("LLM provider parsing", () => {
it("normalizes the documented openai-compatible alias", () => {
expect(settingsRegistry.llmProvider.parse("openai-compatible")).toBe(
"openai_compatible",
);
expect(settingsRegistry.llmProvider.parse("OPENAI-COMPATIBLE")).toBe(
"openai_compatible",
);
});
});
});

View File

@ -26,6 +26,12 @@ function parseBitBoolOrNull(raw: string | undefined): boolean | null {
return raw === "true" || raw === "1";
}
function normalizeLlmProviderOrNull(raw: string | undefined): string | null {
if (raw === undefined) return null;
const normalized = raw.trim().toLowerCase().replace(/-/g, "_");
return normalized ? normalized : null;
}
function serializeNullableNumber(
value: number | null | undefined,
): string | null {
@ -66,16 +72,23 @@ export const settingsRegistry = {
kind: "typed" as const,
envKey: "LLM_PROVIDER",
schema: z.preprocess(
(v) => (v === "" ? null : v),
(v) => (typeof v === "string" ? normalizeLlmProviderOrNull(v) : v),
z
.enum(["openrouter", "lmstudio", "ollama", "openai", "gemini"])
.enum([
"openrouter",
"lmstudio",
"ollama",
"openai",
"openai_compatible",
"gemini",
])
.nullable(),
),
default: (): string =>
typeof process !== "undefined"
? process.env.LLM_PROVIDER || "openrouter"
? normalizeLlmProviderOrNull(process.env.LLM_PROVIDER) || "openrouter"
: "openrouter",
parse: parseNonEmptyStringOrNull,
parse: normalizeLlmProviderOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},