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:
parent
6454efd9a2
commit
faea61a249
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
23
orchestrator/src/client/pages/settings/utils.test.ts
Normal file
23
orchestrator/src/client/pages/settings/utils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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];
|
||||
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
@ -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",
|
||||
|
||||
34
orchestrator/src/server/services/llm/service.test.ts
Normal file
34
orchestrator/src/server/services/llm/service.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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";
|
||||
|
||||
@ -3,6 +3,7 @@ export type LlmProvider =
|
||||
| "lmstudio"
|
||||
| "ollama"
|
||||
| "openai"
|
||||
| "openai_compatible"
|
||||
| "gemini";
|
||||
|
||||
export type ResponseMode = "json_schema" | "json_object" | "text" | "none";
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user