diff --git a/README.md b/README.md index f7b04fc..99e5ed2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/orchestrator/README.md b/orchestrator/README.md index 97fa40b..7691ae6 100644 --- a/orchestrator/README.md +++ b/orchestrator/README.md @@ -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 diff --git a/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx index 4c1c64a..d2012e2 100644 --- a/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx @@ -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 = ({ - OpenRouter - LM Studio - Ollama - OpenAI - Gemini + {LLM_PROVIDERS.map((provider) => ( + + {LLM_PROVIDER_LABELS[provider]} + + ))} )} diff --git a/orchestrator/src/client/pages/settings/utils.test.ts b/orchestrator/src/client/pages/settings/utils.test.ts new file mode 100644 index 0000000..97a7e80 --- /dev/null +++ b/orchestrator/src/client/pages/settings/utils.test.ts @@ -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"); + }); +}); diff --git a/orchestrator/src/client/pages/settings/utils.ts b/orchestrator/src/client/pages/settings/utils.ts index 819e538..4ef9895 100644 --- a/orchestrator/src/client/pages/settings/utils.ts +++ b/orchestrator/src/client/pages/settings/utils.ts @@ -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 = { lmstudio: "LM Studio", ollama: "Ollama", openai: "OpenAI", + openai_compatible: "OpenAI-compatible", gemini: "Gemini", }; const PROVIDERS_WITH_API_KEY = new Set([ "openrouter", "openai", + "openai_compatible", "gemini", ]); -const PROVIDERS_WITH_BASE_URL = new Set(["lmstudio", "ollama"]); +const PROVIDERS_WITH_BASE_URL = new Set([ + "lmstudio", + "ollama", + "openai_compatible", +]); const PROVIDER_HINTS: Record = { openrouter: @@ -51,6 +58,8 @@ const PROVIDER_HINTS: Record = { 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 = { 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 = { 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]; diff --git a/orchestrator/src/server/api/routes/onboarding.test.ts b/orchestrator/src/server/api/routes/onboarding.test.ts index 83f2409..a7f890a 100644 --- a/orchestrator/src/server/api/routes/onboarding.test.ts +++ b/orchestrator/src/server/api/routes/onboarding.test.ts @@ -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", () => { diff --git a/orchestrator/src/server/api/routes/onboarding.ts b/orchestrator/src/server/api/routes/onboarding.ts index 4dcb957..8f4f34c 100644 --- a/orchestrator/src/server/api/routes/onboarding.ts +++ b/orchestrator/src/server/api/routes/onboarding.ts @@ -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. */ diff --git a/orchestrator/src/server/api/routes/settings.test.ts b/orchestrator/src/server/api/routes/settings.test.ts index 76feaaf..f1a0d88 100644 --- a/orchestrator/src/server/api/routes/settings.test.ts +++ b/orchestrator/src/server/api/routes/settings.test.ts @@ -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", diff --git a/orchestrator/src/server/services/llm/providers/index.ts b/orchestrator/src/server/services/llm/providers/index.ts index 034f176..617b56f 100644 --- a/orchestrator/src/server/services/llm/providers/index.ts +++ b/orchestrator/src/server/services/llm/providers/index.ts @@ -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 = { @@ -10,5 +11,6 @@ export const strategies: Record = { lmstudio: lmStudioStrategy, ollama: ollamaStrategy, openai: openAiStrategy, + openai_compatible: openAiCompatibleStrategy, gemini: geminiStrategy, }; diff --git a/orchestrator/src/server/services/llm/providers/openai-compatible.ts b/orchestrator/src/server/services/llm/providers/openai-compatible.ts new file mode 100644 index 0000000..f74e71f --- /dev/null +++ b/orchestrator/src/server/services/llm/providers/openai-compatible.ts @@ -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, +}); diff --git a/orchestrator/src/server/services/llm/providers/providers.test.ts b/orchestrator/src/server/services/llm/providers/providers.test.ts index f510511..05fe84e 100644 --- a/orchestrator/src/server/services/llm/providers/providers.test.ts +++ b/orchestrator/src/server/services/llm/providers/providers.test.ts @@ -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", diff --git a/orchestrator/src/server/services/llm/service.test.ts b/orchestrator/src/server/services/llm/service.test.ts new file mode 100644 index 0000000..364a0f7 --- /dev/null +++ b/orchestrator/src/server/services/llm/service.test.ts @@ -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"); + }); +}); diff --git a/orchestrator/src/server/services/llm/service.ts b/orchestrator/src/server/services/llm/service.ts index 4ba837d..1a0313c 100644 --- a/orchestrator/src/server/services/llm/service.ts +++ b/orchestrator/src/server/services/llm/service.ts @@ -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"; diff --git a/orchestrator/src/server/services/llm/types.ts b/orchestrator/src/server/services/llm/types.ts index f39bc3b..8b7daeb 100644 --- a/orchestrator/src/server/services/llm/types.ts +++ b/orchestrator/src/server/services/llm/types.ts @@ -3,6 +3,7 @@ export type LlmProvider = | "lmstudio" | "ollama" | "openai" + | "openai_compatible" | "gemini"; export type ResponseMode = "json_schema" | "json_object" | "text" | "none"; diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts index 74cd3f5..f2b5022 100644 --- a/orchestrator/src/server/services/settings.ts +++ b/orchestrator/src/server/services/settings.ts @@ -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"; } diff --git a/shared/src/settings-registry.test.ts b/shared/src/settings-registry.test.ts index 641a2f9..f750828 100644 --- a/shared/src/settings-registry.test.ts +++ b/shared/src/settings-registry.test.ts @@ -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", + ); + }); + }); }); diff --git a/shared/src/settings-registry.ts b/shared/src/settings-registry.ts index ab5dad9..a363b62 100644 --- a/shared/src/settings-registry.ts +++ b/shared/src/settings-registry.ts @@ -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, },