Customise llm base url (#68)

* backend initial commit

* frontend initial commit

* better copy

* make lmstudio work

* enum of providers

* better error messages

* llm model settings stay in one place

* llm settings should be under the model accordion

* skip llm key step in onboarding if provider is set to local

* onboarding now factors in new llm provider flow

* fix tests

* fix typecheck
This commit is contained in:
Shaheer Sarfaraz 2026-01-29 16:20:12 +00:00 committed by GitHub
parent 6e771ce728
commit b4641ad9cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1629 additions and 472 deletions

View File

@ -323,12 +323,14 @@ export async function refreshProfile(): Promise<ResumeProfile> {
});
}
export async function validateOpenrouter(
apiKey?: string,
): Promise<ValidationResult> {
return fetchApi<ValidationResult>("/onboarding/validate/openrouter", {
export async function validateLlm(input: {
provider?: string;
baseUrl?: string;
apiKey?: string;
}): Promise<ValidationResult> {
return fetchApi<ValidationResult>("/onboarding/validate/llm", {
method: "POST",
body: JSON.stringify({ apiKey }),
body: JSON.stringify(input),
});
}
@ -351,6 +353,9 @@ export async function updateSettings(update: {
modelScorer?: string | null;
modelTailoring?: string | null;
modelProjectSelection?: string | null;
llmProvider?: string | null;
llmBaseUrl?: string | null;
llmApiKey?: string | null;
pipelineWebhookUrl?: string | null;
jobCompleteWebhookUrl?: string | null;
resumeProjects?: ResumeProjectsSettings | null;

View File

@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { OnboardingGate } from "./OnboardingGate";
vi.mock("@client/api", () => ({
validateOpenrouter: vi.fn(),
validateLlm: vi.fn(),
validateRxresume: vi.fn(),
validateResumeConfig: vi.fn(),
updateSettings: vi.fn(),
@ -55,6 +55,24 @@ vi.mock("@/components/ui/tabs", () => ({
),
}));
vi.mock("@/components/ui/select", () => ({
Select: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
SelectValue: ({ children }: { children: React.ReactNode }) => (
<span>{children}</span>
),
}));
vi.mock("@/components/ui/progress", () => ({
Progress: () => <div>Progress</div>,
}));
@ -69,6 +87,8 @@ vi.mock("sonner", () => ({
const settingsResponse = {
settings: {
llmProvider: "openrouter",
llmApiKeyHint: null,
openrouterApiKeyHint: null,
rxresumeEmail: "",
rxresumePasswordHint: null,
@ -85,7 +105,7 @@ describe("OnboardingGate", () => {
});
it("renders the gate once validations complete and any fail", async () => {
vi.mocked(api.validateOpenrouter).mockResolvedValue({
vi.mocked(api.validateLlm).mockResolvedValue({
valid: false,
message: "Invalid",
});
@ -100,12 +120,12 @@ describe("OnboardingGate", () => {
render(<OnboardingGate />);
await waitFor(() => expect(api.validateOpenrouter).toHaveBeenCalled());
await waitFor(() => expect(api.validateLlm).toHaveBeenCalled());
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
});
it("hides the gate when all validations succeed", async () => {
vi.mocked(api.validateOpenrouter).mockResolvedValue({
vi.mocked(api.validateLlm).mockResolvedValue({
valid: true,
message: null,
});
@ -120,7 +140,32 @@ describe("OnboardingGate", () => {
render(<OnboardingGate />);
await waitFor(() => expect(api.validateOpenrouter).toHaveBeenCalled());
await waitFor(() => expect(api.validateLlm).toHaveBeenCalled());
expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
});
it("skips LLM key validation for providers without API keys", async () => {
vi.mocked(useSettings).mockReturnValue({
...settingsResponse,
settings: {
...settingsResponse.settings,
llmProvider: "ollama",
},
} as any);
vi.mocked(api.validateRxresume).mockResolvedValue({
valid: false,
message: "Missing",
});
vi.mocked(api.validateResumeConfig).mockResolvedValue({
valid: true,
message: null,
});
render(<OnboardingGate />);
await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled());
expect(api.validateLlm).not.toHaveBeenCalled();
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
expect(screen.queryByText("LLM API key")).not.toBeInTheDocument();
});
});

View File

@ -2,7 +2,13 @@ import * as api from "@client/api";
import { useSettings } from "@client/hooks/useSettings";
import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection";
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import { formatSecretHint } from "@client/pages/settings/utils";
import {
formatSecretHint,
getLlmProviderConfig,
LLM_PROVIDER_LABELS,
LLM_PROVIDERS,
normalizeLlmProvider,
} from "@client/pages/settings/utils";
import type { ValidationResult } from "@shared/types";
import { Check } from "lucide-react";
import type React from "react";
@ -24,6 +30,13 @@ import {
FieldTitle,
} from "@/components/ui/field";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
@ -36,15 +49,14 @@ export const OnboardingGate: React.FC = () => {
refreshSettings,
} = useSettings();
const [isSavingEnv, setIsSavingEnv] = useState(false);
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false);
const [isValidatingLlm, setIsValidatingLlm] = useState(false);
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false);
const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false);
const [openrouterValidation, setOpenrouterValidation] =
useState<ValidationState>({
valid: false,
message: null,
checked: false,
});
const [llmValidation, setLlmValidation] = useState<ValidationState>({
valid: false,
message: null,
checked: false,
});
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>(
{
valid: false,
@ -60,29 +72,34 @@ export const OnboardingGate: React.FC = () => {
});
const [currentStep, setCurrentStep] = useState<string | null>(null);
const [openrouterApiKey, setOpenrouterApiKey] = useState("");
const [llmProvider, setLlmProvider] = useState("");
const [llmBaseUrl, setLlmBaseUrl] = useState("");
const [llmApiKey, setLlmApiKey] = useState("");
const [rxresumeEmail, setRxresumeEmail] = useState("");
const [rxresumePassword, setRxresumePassword] = useState("");
const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState<
string | null
>(null);
const validateOpenrouter = useCallback(async (apiKey?: string) => {
setIsValidatingOpenrouter(true);
try {
const result = await api.validateOpenrouter(apiKey);
setOpenrouterValidation({ ...result, checked: true });
return result;
} catch (error) {
const message =
error instanceof Error ? error.message : "OpenRouter validation failed";
const result = { valid: false, message };
setOpenrouterValidation({ ...result, checked: true });
return result;
} finally {
setIsValidatingOpenrouter(false);
}
}, []);
const validateLlm = useCallback(
async (input: { provider?: string; baseUrl?: string; apiKey?: string }) => {
setIsValidatingLlm(true);
try {
const result = await api.validateLlm(input);
setLlmValidation({ ...result, checked: true });
return result;
} catch (error) {
const message =
error instanceof Error ? error.message : "LLM validation failed";
const result = { valid: false, message };
setLlmValidation({ ...result, checked: true });
return result;
} finally {
setIsValidatingLlm(false);
}
},
[],
);
const validateRxresume = useCallback(
async (email?: string, password?: string) => {
@ -123,25 +140,32 @@ export const OnboardingGate: React.FC = () => {
}
}, []);
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint);
const selectedProvider = normalizeLlmProvider(
llmProvider || settings?.llmProvider || "openrouter",
);
const providerConfig = getLlmProviderConfig(selectedProvider);
const {
normalizedProvider,
showApiKey,
showBaseUrl,
requiresApiKey: requiresLlmKey,
} = providerConfig;
const llmKeyHint =
settings?.llmApiKeyHint ?? settings?.openrouterApiKeyHint ?? null;
const hasLlmKey = Boolean(llmKeyHint);
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim());
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
const hasCheckedValidations =
openrouterValidation.checked &&
(requiresLlmKey ? llmValidation.checked : true) &&
rxresumeValidation.checked &&
baseResumeValidation.checked;
const llmValidated = requiresLlmKey ? llmValidation.valid : true;
const shouldOpen =
Boolean(settings && !settingsLoading) &&
hasCheckedValidations &&
!(
openrouterValidation.valid &&
rxresumeValidation.valid &&
baseResumeValidation.valid
);
!(llmValidated && rxresumeValidation.valid && baseResumeValidation.valid);
const openrouterCurrent = settings?.openrouterApiKeyHint
? formatSecretHint(settings.openrouterApiKeyHint)
: undefined;
const llmKeyCurrent = llmKeyHint ? formatSecretHint(llmKeyHint) : undefined;
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
? settings.rxresumeEmail
: undefined;
@ -152,16 +176,32 @@ export const OnboardingGate: React.FC = () => {
useEffect(() => {
if (settings) {
setRxresumeBaseResumeId(settings.rxresumeBaseResumeId || null);
if (!llmProvider && settings.llmProvider) {
setLlmProvider(settings.llmProvider);
}
if (!llmBaseUrl && settings.llmBaseUrl) {
setLlmBaseUrl(settings.llmBaseUrl);
}
}
}, [settings]);
}, [llmBaseUrl, llmProvider, settings]);
useEffect(() => {
if (showBaseUrl) return;
if (llmBaseUrl) setLlmBaseUrl("");
}, [llmBaseUrl, showBaseUrl]);
useEffect(() => {
if (!selectedProvider) return;
setLlmValidation({ valid: false, message: null, checked: false });
}, [selectedProvider]);
const steps = useMemo(
() => [
{
id: "openrouter",
label: "Connect AI",
subtitle: "OpenRouter key",
complete: openrouterValidation.valid,
id: "llm",
label: "LLM Provider",
subtitle: "Provider + credentials",
complete: llmValidated,
disabled: false,
},
{
@ -179,11 +219,7 @@ export const OnboardingGate: React.FC = () => {
disabled: !rxresumeValidation.valid,
},
],
[
openrouterValidation.valid,
rxresumeValidation.valid,
baseResumeValidation.valid,
],
[llmValidated, rxresumeValidation.valid, baseResumeValidation.valid],
);
const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id;
@ -197,11 +233,21 @@ export const OnboardingGate: React.FC = () => {
const runAllValidations = useCallback(async () => {
if (!settings) return;
const results = await Promise.allSettled([
validateOpenrouter(),
validateRxresume(),
validateBaseResume(),
]);
const validations: Promise<ValidationResult>[] = [];
if (requiresLlmKey) {
validations.push(
validateLlm({
provider: normalizedProvider,
baseUrl: llmBaseUrl.trim() || undefined,
apiKey: llmApiKey.trim() || undefined,
}),
);
} else {
setLlmValidation({ valid: true, message: null, checked: true });
}
validations.push(validateRxresume(), validateBaseResume());
const results = await Promise.allSettled(validations);
const failed = results.find((result) => result.status === "rejected");
if (failed) {
@ -210,21 +256,30 @@ export const OnboardingGate: React.FC = () => {
reason instanceof Error ? reason.message : "Validation checks failed";
toast.error(message);
}
}, [settings, validateOpenrouter, validateRxresume, validateBaseResume]);
}, [
settings,
requiresLlmKey,
validateLlm,
validateRxresume,
validateBaseResume,
normalizedProvider,
llmBaseUrl,
llmApiKey,
]);
useEffect(() => {
if (!settings || settingsLoading) return;
if (
openrouterValidation.checked ||
rxresumeValidation.checked ||
baseResumeValidation.checked
)
return;
const needsValidation =
(requiresLlmKey ? !llmValidation.checked : false) ||
!rxresumeValidation.checked ||
!baseResumeValidation.checked;
if (!needsValidation) return;
void runAllValidations();
}, [
settings,
settingsLoading,
openrouterValidation.checked,
requiresLlmKey,
llmValidation.checked,
rxresumeValidation.checked,
baseResumeValidation.checked,
runAllValidations,
@ -244,34 +299,51 @@ export const OnboardingGate: React.FC = () => {
}
};
const handleSaveOpenrouter = async (): Promise<boolean> => {
const openrouterValue = openrouterApiKey.trim();
if (!openrouterValue && !hasOpenrouterKey) {
toast.info("Add your OpenRouter API key to continue");
const handleSaveLlm = async (): Promise<boolean> => {
const apiKeyValue = llmApiKey.trim();
const baseUrlValue = llmBaseUrl.trim();
if (requiresLlmKey && !apiKeyValue && !hasLlmKey) {
toast.info("Add your LLM API key to continue");
return false;
}
try {
const validation = await validateOpenrouter(openrouterValue || undefined);
const validation = requiresLlmKey
? await validateLlm({
provider: normalizedProvider,
baseUrl: baseUrlValue || undefined,
apiKey: apiKeyValue || undefined,
})
: { valid: true, message: null };
if (!validation.valid) {
toast.error(validation.message || "OpenRouter validation failed");
toast.error(validation.message || "LLM validation failed");
return false;
}
if (openrouterValue) {
setIsSavingEnv(true);
await api.updateSettings({ openrouterApiKey: openrouterValue });
await refreshSettings();
setOpenrouterApiKey("");
const update: {
llmProvider?: string;
llmBaseUrl?: string | null;
llmApiKey?: string;
} = {
llmProvider: normalizedProvider,
llmBaseUrl: showBaseUrl ? baseUrlValue || null : null,
};
if (showApiKey && apiKeyValue) {
update.llmApiKey = apiKeyValue;
}
toast.success("OpenRouter connected");
setIsSavingEnv(true);
await api.updateSettings(update);
await refreshSettings();
setLlmApiKey("");
toast.success("LLM provider connected");
return true;
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to save OpenRouter key";
error instanceof Error ? error.message : "Failed to save LLM settings";
toast.error(message);
return false;
} finally {
@ -368,13 +440,13 @@ export const OnboardingGate: React.FC = () => {
const isBusy =
isSavingEnv ||
settingsLoading ||
isValidatingOpenrouter ||
isValidatingLlm ||
isValidatingRxresume ||
isValidatingBaseResume;
const canGoBack = stepIndex > 0;
const primaryLabel =
currentStep === "openrouter"
? openrouterValidation.valid
currentStep === "llm"
? llmValidated
? "Revalidate"
: "Validate"
: currentStep === "rxresume"
@ -389,8 +461,8 @@ export const OnboardingGate: React.FC = () => {
const handlePrimaryAction = async () => {
if (!currentStep) return;
if (currentStep === "openrouter") {
await handleSaveOpenrouter();
if (currentStep === "llm") {
await handleSaveLlm();
return;
}
if (currentStep === "rxresume") {
@ -475,26 +547,68 @@ export const OnboardingGate: React.FC = () => {
})}
</TabsList>
<TabsContent value="openrouter" className="space-y-4 pt-6">
<TabsContent value="llm" className="space-y-4 pt-6">
<div>
<p className="text-sm font-semibold">Connect OpenRouter</p>
<p className="text-sm font-semibold">Connect LLM provider</p>
<p className="text-xs text-muted-foreground">
Used for job scoring, summaries, and tailoring.
</p>
</div>
<SettingsInput
label="OpenRouter API key"
inputProps={{
name: "openrouterApiKey",
value: openrouterApiKey,
onChange: (event) => setOpenrouterApiKey(event.target.value),
}}
type="password"
placeholder="sk-or-v1..."
current={openrouterCurrent}
helper="Create a key at openrouter.ai"
disabled={isSavingEnv}
/>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label htmlFor="llmProvider" className="text-sm font-medium">
Provider
</label>
<Select
value={selectedProvider}
onValueChange={(value) => setLlmProvider(value)}
disabled={isSavingEnv}
>
<SelectTrigger id="llmProvider">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{LLM_PROVIDERS.map((provider) => (
<SelectItem key={provider} value={provider}>
{LLM_PROVIDER_LABELS[provider]}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{providerConfig.providerHint}
</p>
</div>
{showBaseUrl && (
<SettingsInput
label="LLM base URL"
inputProps={{
name: "llmBaseUrl",
value: llmBaseUrl,
onChange: (event) => setLlmBaseUrl(event.target.value),
}}
placeholder={providerConfig.baseUrlPlaceholder}
helper={providerConfig.baseUrlHelper}
current={settings?.llmBaseUrl || "—"}
disabled={isSavingEnv}
/>
)}
{showApiKey && (
<SettingsInput
label="LLM API key"
inputProps={{
name: "llmApiKey",
value: llmApiKey,
onChange: (event) => setLlmApiKey(event.target.value),
}}
type="password"
placeholder="Enter key"
current={llmKeyCurrent}
helper={providerConfig.keyHelper}
disabled={isSavingEnv}
/>
)}
</div>
</TabsContent>
<TabsContent value="rxresume" className="space-y-4 pt-6">

View File

@ -136,8 +136,10 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
}
toast.success("AI Summary & Projects generated");
await onUpdate();
} catch (_error) {
toast.error("AI summarization failed");
} catch (error) {
const message =
error instanceof Error ? error.message : "AI summarization failed";
toast.error(message);
} finally {
setIsSummarizing(false);
}

View File

@ -141,8 +141,10 @@ export const TailorMode: React.FC<TailorModeProps> = ({
toast.success("Draft generated with AI", {
description: "Review and edit before finalizing.",
});
} catch {
toast.error("Failed to generate AI draft");
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to generate AI draft";
toast.error(message);
} finally {
setIsGenerating(false);
}

View File

@ -1,11 +1,5 @@
import type { AppSettings } from "@shared/types";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -37,6 +31,12 @@ const baseSettings: AppSettings = {
overrideModelTailoring: null,
modelProjectSelection: "google/gemini-3-flash-preview",
overrideModelProjectSelection: null,
llmProvider: "openrouter",
defaultLlmProvider: "openrouter",
overrideLlmProvider: null,
llmBaseUrl: "https://openrouter.ai",
defaultLlmBaseUrl: "https://openrouter.ai",
overrideLlmBaseUrl: null,
pipelineWebhookUrl: "",
defaultPipelineWebhookUrl: "",
overridePipelineWebhookUrl: null,
@ -100,6 +100,7 @@ const baseSettings: AppSettings = {
showSponsorInfo: true,
defaultShowSponsorInfo: true,
overrideShowSponsorInfo: null,
llmApiKeyHint: null,
openrouterApiKeyHint: null,
rxresumeEmail: "",
rxresumePasswordHint: null,
@ -138,10 +139,7 @@ describe("SettingsPage", () => {
const modelTrigger = await screen.findByRole("button", { name: /model/i });
fireEvent.click(modelTrigger);
const modelField =
screen.getByText("Override model").parentElement ??
screen.getByRole("main");
const modelInput = within(modelField).getByRole("textbox");
const modelInput = screen.getByLabelText(/default model/i);
fireEvent.change(modelInput, { target: { value: " gpt-4 " } });
const saveButton = screen.getByRole("button", { name: /^save$/i });
@ -166,10 +164,7 @@ describe("SettingsPage", () => {
const modelTrigger = await screen.findByRole("button", { name: /model/i });
fireEvent.click(modelTrigger);
const modelField =
screen.getByText("Override model").parentElement ??
screen.getByRole("main");
const modelInput = within(modelField).getByRole("textbox");
const modelInput = screen.getByLabelText(/default model/i);
// Change to > 200 chars
fireEvent.change(modelInput, { target: { value: "a".repeat(201) } });
@ -229,7 +224,7 @@ describe("SettingsPage", () => {
const modelTrigger = await screen.findByRole("button", { name: /model/i });
fireEvent.click(modelTrigger);
const modelInput = screen.getByLabelText(/override model/i);
const modelInput = screen.getByLabelText(/default model/i);
fireEvent.change(modelInput, { target: { value: "new-model" } });
expect(saveButton).toBeEnabled();
});

View File

@ -10,7 +10,11 @@ import { ReactiveResumeSection } from "@client/pages/settings/components/Reactiv
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection";
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection";
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection";
import { resumeProjectsEqual } from "@client/pages/settings/utils";
import {
type LlmProviderId,
normalizeLlmProvider,
resumeProjectsEqual,
} from "@client/pages/settings/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import {
type UpdateSettingsInput,
@ -25,7 +29,7 @@ import type {
import { Settings } from "lucide-react";
import type React from "react";
import { useEffect, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { FormProvider, type Resolver, useForm } from "react-hook-form";
import { toast } from "sonner";
import { Accordion } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
@ -36,6 +40,9 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
modelScorer: "",
modelTailoring: "",
modelProjectSelection: "",
llmProvider: null,
llmBaseUrl: "",
llmApiKey: "",
pipelineWebhookUrl: "",
jobCompleteWebhookUrl: "",
resumeProjects: null,
@ -61,11 +68,20 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
enableBasicAuth: false,
};
type LlmProviderValue = LlmProviderId | null;
const normalizeLlmProviderValue = (
value: string | null | undefined,
): LlmProviderValue => (value ? normalizeLlmProvider(value) : null);
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
model: null,
modelScorer: null,
modelTailoring: null,
modelProjectSelection: null,
llmProvider: null,
llmBaseUrl: null,
llmApiKey: null,
pipelineWebhookUrl: null,
jobCompleteWebhookUrl: null,
resumeProjects: null,
@ -96,6 +112,9 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
modelScorer: data.overrideModelScorer ?? "",
modelTailoring: data.overrideModelTailoring ?? "",
modelProjectSelection: data.overrideModelProjectSelection ?? "",
llmProvider: normalizeLlmProviderValue(data.overrideLlmProvider),
llmBaseUrl: data.overrideLlmBaseUrl ?? "",
llmApiKey: "",
pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "",
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "",
resumeProjects: data.resumeProjects,
@ -207,6 +226,10 @@ const getDerivedSettings = (settings: AppSettings | null) => {
scorer: settings?.modelScorer ?? "",
tailoring: settings?.modelTailoring ?? "",
projectSelection: settings?.modelProjectSelection ?? "",
llmProvider: settings?.llmProvider ?? "",
llmBaseUrl: settings?.llmBaseUrl ?? "",
llmApiKeyHint:
settings?.llmApiKeyHint ?? settings?.openrouterApiKeyHint ?? null,
},
pipelineWebhook: {
effective: settings?.pipelineWebhookUrl ?? "",
@ -297,7 +320,9 @@ export const SettingsPage: React.FC = () => {
useState(false);
const methods = useForm<UpdateSettingsInput>({
resolver: zodResolver(updateSettingsSchema),
resolver: zodResolver(
updateSettingsSchema,
) as Resolver<UpdateSettingsInput>,
mode: "onChange",
defaultValues: DEFAULT_FORM_VALUES,
});
@ -482,6 +507,19 @@ export const SettingsPage: React.FC = () => {
if (value !== undefined) envPayload.openrouterApiKey = value;
}
if (dirtyFields.llmProvider) {
envPayload.llmProvider = data.llmProvider ?? null;
}
if (dirtyFields.llmBaseUrl) {
envPayload.llmBaseUrl = normalizeString(data.llmBaseUrl);
}
if (dirtyFields.llmApiKey) {
const value = normalizePrivateInput(data.llmApiKey);
if (value !== undefined) envPayload.llmApiKey = value;
}
if (dirtyFields.rxresumePassword) {
const value = normalizePrivateInput(data.rxresumePassword);
if (value !== undefined) envPayload.rxresumePassword = value;

View File

@ -53,7 +53,6 @@ describe("EnvironmentSettingsSection", () => {
expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument();
expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument();
expect(screen.getByText(/sk-1\*{8}/)).toBeInTheDocument();
expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument();
expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument();
expect(screen.getByText("Not set")).toBeInTheDocument();
@ -63,7 +62,6 @@ describe("EnvironmentSettingsSection", () => {
expect(screen.getByDisplayValue("admin")).toBeInTheDocument();
// Sections
expect(screen.getByText("External Services")).toBeInTheDocument();
expect(screen.getByText("Service Accounts")).toBeInTheDocument();
expect(screen.getByText("Security")).toBeInTheDocument();
});

View File

@ -38,26 +38,6 @@ export const EnvironmentSettingsSection: React.FC<
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-8">
{/* External Services */}
<div className="space-y-4">
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">
External Services
</div>
<div className="grid gap-4 md:grid-cols-2">
<SettingsInput
label="OpenRouter API key"
inputProps={register("openrouterApiKey")}
type="password"
placeholder="Enter new key"
disabled={isLoading || isSaving}
error={errors.openrouterApiKey?.message as string | undefined}
current={formatSecretHint(privateValues.openrouterApiKeyHint)}
/>
</div>
</div>
<Separator />
{/* Service Accounts */}
<div className="space-y-6">
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">

View File

@ -1,13 +1,25 @@
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
import type { ModelValues } from "@client/pages/settings/types";
import {
formatSecretHint,
getLlmProviderConfig,
} from "@client/pages/settings/utils";
import type { UpdateSettingsInput } from "@shared/settings-schema";
import type React from "react";
import { useFormContext } from "react-hook-form";
import { useEffect } from "react";
import { Controller, useFormContext } from "react-hook-form";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
type ModelSettingsSectionProps = {
@ -27,12 +39,37 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
scorer,
tailoring,
projectSelection,
llmProvider,
llmBaseUrl,
llmApiKeyHint,
} = values;
const {
register,
control,
watch,
setValue,
formState: { errors },
} = useFormContext<UpdateSettingsInput>();
const selectedProvider = watch("llmProvider") || llmProvider || "openrouter";
const providerConfig = getLlmProviderConfig(selectedProvider);
const { showApiKey, showBaseUrl } = providerConfig;
const llmBaseUrlValue = watch("llmBaseUrl");
useEffect(() => {
if (showBaseUrl) return;
if (llmBaseUrlValue) {
setValue("llmBaseUrl", "", { shouldDirty: true });
}
}, [setValue, showBaseUrl, llmBaseUrlValue]);
const keyHint = formatSecretHint(llmApiKeyHint);
const keyText = showApiKey ? keyHint || "Not set" : "Not required";
const effectiveDefaultModel = effective || defaultModel || "—";
const scoringModel = scorer || effectiveDefaultModel;
const tailoringModel = tailoring || effectiveDefaultModel;
const projectSelectionModel = projectSelection || effectiveDefaultModel;
return (
<AccordionItem value="model" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
@ -40,14 +77,82 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-4">
<div className="text-sm font-medium">LLM Provider</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label htmlFor="llmProvider" className="text-sm font-medium">
Provider
</label>
<Controller
name="llmProvider"
control={control}
render={({ field }) => (
<Select
value={field.value ?? ""}
onValueChange={(value) => field.onChange(value)}
disabled={isLoading || isSaving}
>
<SelectTrigger id="llmProvider">
<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>
</SelectContent>
</Select>
)}
/>
{errors.llmProvider?.message && (
<p className="text-xs text-destructive">
{errors.llmProvider.message as string}
</p>
)}
<p className="text-xs text-muted-foreground">
Used for scoring, tailoring, and extraction.
</p>
<p className="text-xs text-muted-foreground">
{providerConfig.providerHint}
</p>
</div>
{showBaseUrl && (
<SettingsInput
label="LLM base URL"
inputProps={register("llmBaseUrl")}
placeholder={providerConfig.baseUrlPlaceholder}
disabled={isLoading || isSaving}
error={errors.llmBaseUrl?.message as string | undefined}
helper={providerConfig.baseUrlHelper}
current={llmBaseUrl || "—"}
/>
)}
{showApiKey && (
<SettingsInput
label="LLM API key"
inputProps={register("llmApiKey")}
type="password"
placeholder="Enter new key"
disabled={isLoading || isSaving}
error={errors.llmApiKey?.message as string | undefined}
current={keyHint}
/>
)}
</div>
</div>
<Separator />
<SettingsInput
label="Override model"
label="Default model"
inputProps={register("model")}
placeholder={defaultModel || "google/gemini-3-flash-preview"}
disabled={isLoading || isSaving}
error={errors.model?.message as string | undefined}
helper="Leave blank to use the default from server env (`MODEL`)."
current={effective || "—"}
current={effectiveDefaultModel}
/>
<Separator />
@ -62,7 +167,7 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
error={errors.modelScorer?.message as string | undefined}
current={scorer || effective || "—"}
current={scoringModel}
/>
<SettingsInput
@ -71,7 +176,7 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
error={errors.modelTailoring?.message as string | undefined}
current={tailoring || effective || "—"}
current={tailoringModel}
/>
<SettingsInput
@ -82,26 +187,47 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
error={
errors.modelProjectSelection?.message as string | undefined
}
current={projectSelection || effective || "—"}
current={projectSelectionModel}
/>
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">
Global Effective
<div className="space-y-3 text-sm">
<div className="text-xs text-muted-foreground">Resolved config</div>
<div className="grid gap-x-4 gap-y-2 text-xs sm:grid-cols-[160px_1fr]">
<div className="text-muted-foreground">Provider</div>
<div className="font-mono">{selectedProvider || "—"}</div>
<div className="text-muted-foreground">Base URL</div>
<div className="font-mono">{llmBaseUrl || "—"}</div>
<div className="text-muted-foreground">API key</div>
<div className="font-mono">{keyText}</div>
<div className="text-muted-foreground">Default model</div>
<div className="font-mono">{effectiveDefaultModel}</div>
<div className="text-muted-foreground">Scoring model</div>
<div className="font-mono">
{scoringModel === effectiveDefaultModel
? "inherits"
: scoringModel}
</div>
<div className="break-words font-mono text-xs">
{effective || "—"}
<div className="text-muted-foreground">Tailoring model</div>
<div className="font-mono">
{tailoringModel === effectiveDefaultModel
? "inherits"
: tailoringModel}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">
{defaultModel || "—"}
<div className="text-muted-foreground">Project selection</div>
<div className="font-mono">
{projectSelectionModel === effectiveDefaultModel
? "inherits"
: projectSelectionModel}
</div>
</div>
</div>

View File

@ -7,6 +7,9 @@ export type ModelValues = EffectiveDefault<string> & {
scorer: string;
tailoring: string;
projectSelection: string;
llmProvider: string;
llmBaseUrl: string;
llmApiKeyHint: string | null;
};
export type WebhookValues = EffectiveDefault<string>;

View File

@ -18,3 +18,74 @@ export function resumeProjectsEqual(
export const formatSecretHint = (hint: string | null) =>
hint ? `${hint}********` : "Not set";
export const LLM_PROVIDERS = [
"openrouter",
"lmstudio",
"ollama",
"openai",
"gemini",
] as const;
export type LlmProviderId = (typeof LLM_PROVIDERS)[number];
export const LLM_PROVIDER_LABELS: Record<LlmProviderId, string> = {
openrouter: "OpenRouter",
lmstudio: "LM Studio",
ollama: "Ollama",
openai: "OpenAI",
gemini: "Gemini",
};
export function normalizeLlmProvider(
value: string | null | undefined,
): LlmProviderId {
const normalized = value?.trim().toLowerCase();
if (!normalized) return "openrouter";
return (LLM_PROVIDERS as readonly string[]).includes(normalized)
? (normalized as LlmProviderId)
: "openrouter";
}
export function getLlmProviderConfig(provider: string | null | undefined) {
const normalizedProvider = normalizeLlmProvider(provider);
const showApiKey = ["openrouter", "openai", "gemini"].includes(
normalizedProvider,
);
const showBaseUrl = ["lmstudio", "ollama"].includes(normalizedProvider);
const baseUrlPlaceholder =
normalizedProvider === "ollama"
? "http://localhost:11434"
: "http://localhost:1234";
const baseUrlHelper =
normalizedProvider === "ollama"
? "Default: http://localhost:11434"
: "Default: http://localhost:1234";
const providerHint =
normalizedProvider === "ollama"
? "Ollama typically runs locally and does not require an API key."
: normalizedProvider === "lmstudio"
? "LM Studio runs locally via its OpenAI-compatible server."
: normalizedProvider === "openai"
? "OpenAI uses the Responses API with structured outputs."
: normalizedProvider === "gemini"
? "Gemini uses the native AI Studio API and requires a key."
: "OpenRouter uses your API key and supports model routing across providers.";
const keyHelper =
normalizedProvider === "openai"
? "Create a key at platform.openai.com"
: normalizedProvider === "gemini"
? "Create a key at ai.google.dev"
: "Create a key at openrouter.ai";
return {
normalizedProvider,
label: LLM_PROVIDER_LABELS[normalizedProvider],
showApiKey,
showBaseUrl,
requiresApiKey: showApiKey,
baseUrlPlaceholder,
baseUrlHelper,
providerHint,
keyHelper,
};
}

View File

@ -1,4 +1,5 @@
import { getSetting } from "@server/repositories/settings.js";
import { LlmService } from "@server/services/llm-service.js";
import { RxResumeClient } from "@server/services/rxresume-client.js";
import {
getResume,
@ -14,56 +15,17 @@ type ValidationResponse = {
message: string | null;
};
async function validateOpenrouter(
apiKey?: string | null,
): Promise<ValidationResponse> {
const key = apiKey?.trim() || process.env.OPENROUTER_API_KEY || "";
if (!key) {
return { valid: false, message: "OpenRouter API key is missing." };
}
try {
const response = await fetch("https://openrouter.ai/api/v1/key", {
method: "GET",
headers: {
Authorization: `Bearer ${key}`,
},
});
if (!response.ok) {
let detail = "";
try {
const payload = await response.json();
if (payload && typeof payload === "object" && "error" in payload) {
const errorObj = payload.error as {
message?: string;
code?: number | string;
};
const message = errorObj?.message || "";
const code = errorObj?.code ? ` (${errorObj.code})` : "";
detail = `${message}${code}`.trim();
}
} catch {
// ignore JSON parse errors
}
if (response.status === 401) {
return {
valid: false,
message: "Invalid OpenRouter API key. Check the key and try again.",
};
}
const fallback = `OpenRouter returned ${response.status}`;
return { valid: false, message: detail || fallback };
}
return { valid: true, message: null };
} catch (error) {
const message =
error instanceof Error ? error.message : "OpenRouter validation failed.";
return { valid: false, message };
}
async function validateLlm(options: {
apiKey?: string | null;
provider?: string | null;
baseUrl?: string | null;
}): Promise<ValidationResponse> {
const llm = new LlmService({
apiKey: options.apiKey,
provider: options.provider ?? undefined,
baseUrl: options.baseUrl ?? undefined,
});
return llm.validateCredentials();
}
/**
@ -164,11 +126,22 @@ onboardingRouter.post(
async (req: Request, res: Response) => {
const apiKey =
typeof req.body?.apiKey === "string" ? req.body.apiKey : undefined;
const result = await validateOpenrouter(apiKey);
const result = await validateLlm({ apiKey, provider: "openrouter" });
res.json({ success: true, data: result });
},
);
onboardingRouter.post("/validate/llm", async (req: Request, res: Response) => {
const apiKey =
typeof req.body?.apiKey === "string" ? req.body.apiKey : undefined;
const provider =
typeof req.body?.provider === "string" ? req.body.provider : undefined;
const baseUrl =
typeof req.body?.baseUrl === "string" ? req.body.baseUrl : undefined;
const result = await validateLlm({ apiKey, provider, baseUrl });
res.json({ success: true, data: result });
});
onboardingRouter.post(
"/validate/rxresume",
async (req: Request, res: Response) => {

View File

@ -63,6 +63,24 @@ settingsRouter.patch("/", async (req: Request, res: Response) => {
);
}
if ("llmProvider" in input) {
const value = normalizeEnvInput(input.llmProvider);
promises.push(
settingsRepo.setSetting("llmProvider", value).then(() => {
applyEnvValue("LLM_PROVIDER", value);
}),
);
}
if ("llmBaseUrl" in input) {
const value = normalizeEnvInput(input.llmBaseUrl);
promises.push(
settingsRepo.setSetting("llmBaseUrl", value).then(() => {
applyEnvValue("LLM_BASE_URL", value);
}),
);
}
if ("pipelineWebhookUrl" in input) {
promises.push(
settingsRepo.setSetting(
@ -219,6 +237,15 @@ settingsRouter.patch("/", async (req: Request, res: Response) => {
);
}
if ("llmApiKey" in input) {
const value = normalizeEnvInput(input.llmApiKey);
promises.push(
settingsRepo.setSetting("llmApiKey", value).then(() => {
applyEnvValue("LLM_API_KEY", value);
}),
);
}
if ("rxresumeEmail" in input) {
const value = normalizeEnvInput(input.rxresumeEmail);
promises.push(

View File

@ -506,6 +506,11 @@ export async function summarizeJob(
tailoredSummary = tailoringResult.data.summary;
tailoredHeadline = tailoringResult.data.headline;
tailoredSkills = JSON.stringify(tailoringResult.data.skills);
} else if (options?.force || !tailoredSummary || !tailoredHeadline) {
return {
success: false,
error: `Tailoring failed: ${tailoringResult.error || "unknown error"}`,
};
}
}

View File

@ -12,6 +12,9 @@ export type SettingKey =
| "modelScorer"
| "modelTailoring"
| "modelProjectSelection"
| "llmProvider"
| "llmBaseUrl"
| "llmApiKey"
| "pipelineWebhookUrl"
| "jobCompleteWebhookUrl"
| "resumeProjects"

View File

@ -4,6 +4,8 @@ import * as settingsRepo from "@server/repositories/settings.js";
const envDefaults: Record<string, string | undefined> = { ...process.env };
const readableStringConfig: { settingKey: SettingKey; envKey: string }[] = [
{ settingKey: "llmProvider", envKey: "LLM_PROVIDER" },
{ settingKey: "llmBaseUrl", envKey: "LLM_BASE_URL" },
{ settingKey: "rxresumeEmail", envKey: "RXRESUME_EMAIL" },
{ settingKey: "ukvisajobsEmail", envKey: "UKVISAJOBS_EMAIL" },
{ settingKey: "basicAuthUser", envKey: "BASIC_AUTH_USER" },
@ -20,6 +22,11 @@ const privateStringConfig: {
envKey: string;
hintKey: string;
}[] = [
{
settingKey: "llmApiKey",
envKey: "LLM_API_KEY",
hintKey: "llmApiKeyHint",
},
{
settingKey: "openrouterApiKey",
envKey: "OPENROUTER_API_KEY",

View File

@ -1,15 +1,14 @@
/**
* Tests for the shared OpenRouter API helper.
* Tests for the shared LLM service helper.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
callOpenRouter,
type JsonSchemaDefinition,
LlmService,
parseJsonContent,
} from "./openrouter.js";
} from "./llm-service.js";
// Mock fetch globally
const originalFetch = global.fetch;
const testSchema: JsonSchemaDefinition = {
@ -25,22 +24,27 @@ const testSchema: JsonSchemaDefinition = {
},
};
describe("callOpenRouter", () => {
describe("LlmService", () => {
beforeEach(() => {
process.env.LLM_PROVIDER = "openrouter";
process.env.OPENROUTER_API_KEY = "test-api-key";
delete process.env.LLM_API_KEY;
global.fetch = vi.fn();
});
afterEach(() => {
delete process.env.LLM_PROVIDER;
delete process.env.OPENROUTER_API_KEY;
delete process.env.LLM_API_KEY;
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("should return error when API key is not set", async () => {
it("returns error when API key is missing", async () => {
delete process.env.OPENROUTER_API_KEY;
const result = await callOpenRouter({
const llm = new LlmService();
const result = await llm.callJson({
model: "test-model",
messages: [{ role: "user", content: "test" }],
jsonSchema: testSchema,
@ -48,11 +52,11 @@ describe("callOpenRouter", () => {
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain("API_KEY");
expect(result.error).toContain("API key");
}
});
it("should return parsed data on successful response", async () => {
it("returns parsed data on successful response", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
@ -64,7 +68,8 @@ describe("callOpenRouter", () => {
}),
} as Response);
const result = await callOpenRouter<{ value: string; count: number }>({
const llm = new LlmService();
const result = await llm.callJson<{ value: string; count: number }>({
model: "test-model",
messages: [{ role: "user", content: "test" }],
jsonSchema: testSchema,
@ -77,14 +82,15 @@ describe("callOpenRouter", () => {
}
});
it("should handle API errors gracefully", async () => {
it("handles API errors gracefully", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
status: 500,
text: async () => "Internal Server Error",
} as Response);
const result = await callOpenRouter({
const llm = new LlmService();
const result = await llm.callJson({
model: "test-model",
messages: [{ role: "user", content: "test" }],
jsonSchema: testSchema,
@ -96,7 +102,7 @@ describe("callOpenRouter", () => {
}
});
it("should handle empty response content", async () => {
it("handles empty response content", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
@ -104,7 +110,8 @@ describe("callOpenRouter", () => {
}),
} as Response);
const result = await callOpenRouter({
const llm = new LlmService();
const result = await llm.callJson({
model: "test-model",
messages: [{ role: "user", content: "test" }],
jsonSchema: testSchema,
@ -116,7 +123,7 @@ describe("callOpenRouter", () => {
}
});
it("should include json_schema in request body", async () => {
it("includes json_schema and OpenRouter plugins in request body", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
@ -124,7 +131,8 @@ describe("callOpenRouter", () => {
}),
} as Response);
await callOpenRouter({
const llm = new LlmService();
await llm.callJson({
model: "test-model",
messages: [{ role: "user", content: "test prompt" }],
jsonSchema: testSchema,
@ -136,9 +144,33 @@ describe("callOpenRouter", () => {
expect(body.response_format.type).toBe("json_schema");
expect(body.response_format.json_schema.name).toBe("test_schema");
expect(body.response_format.json_schema.strict).toBe(true);
expect(body.plugins[0].id).toBe("response-healing");
});
it("should retry on parsing failures when maxRetries is set", async () => {
it("adds OpenRouter headers", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
choices: [{ message: { content: '{"value": "test", "count": 1}' } }],
}),
} as Response);
const llm = new LlmService();
await llm.callJson({
model: "test-model",
messages: [{ role: "user", content: "test prompt" }],
jsonSchema: testSchema,
});
const fetchCall = vi.mocked(global.fetch).mock.calls[0];
const headers = fetchCall[1]?.headers as Record<string, string>;
expect(headers.Authorization).toContain("Bearer");
expect(headers["HTTP-Referer"]).toBe("JobOps");
expect(headers["X-Title"]).toBe("JobOpsOrchestrator");
});
it("retries on parsing failures when maxRetries is set", async () => {
let callCount = 0;
vi.mocked(global.fetch).mockImplementation(async () => {
callCount++;
@ -160,17 +192,17 @@ describe("callOpenRouter", () => {
} as Response;
});
// Suppress console output during test
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
const result = await callOpenRouter<{ value: string; count: number }>({
const llm = new LlmService();
const result = await llm.callJson<{ value: string; count: number }>({
model: "test-model",
messages: [{ role: "user", content: "test" }],
jsonSchema: testSchema,
maxRetries: 2,
retryDelayMs: 10, // Fast retries for tests
retryDelayMs: 10,
});
expect(result.success).toBe(true);
@ -179,36 +211,90 @@ describe("callOpenRouter", () => {
}
expect(callCount).toBe(3);
});
it("falls back to a looser mode when schema is rejected", async () => {
process.env.LLM_PROVIDER = "lmstudio";
delete process.env.OPENROUTER_API_KEY;
vi.mocked(global.fetch).mockImplementation(async (_input, init) => {
const body = JSON.parse(init?.body as string);
if (body.response_format?.type === "json_schema") {
return {
ok: false,
status: 400,
text: async () =>
JSON.stringify({
error: "'response_format.type' must be 'json_schema' or 'text'",
}),
} as Response;
}
if (body.response_format?.type === "text") {
return {
ok: true,
json: async () => ({
choices: [
{
message: { content: '{"value": "ok", "count": 1}' },
},
],
}),
} as Response;
}
return {
ok: true,
json: async () => ({
choices: [
{
message: { content: '{"value": "fallback", "count": 2}' },
},
],
}),
} as Response;
});
const llm = new LlmService();
const result = await llm.callJson<{ value: string; count: number }>({
model: "test-model",
messages: [{ role: "user", content: "test" }],
jsonSchema: testSchema,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.value).toBe("ok");
}
expect(vi.mocked(global.fetch).mock.calls.length).toBe(2);
});
});
describe("parseJsonContent", () => {
it("should parse clean JSON", () => {
it("parses clean JSON", () => {
const result = parseJsonContent<{ foo: string }>('{"foo": "bar"}');
expect(result.foo).toBe("bar");
});
it("should handle markdown code fences", () => {
it("handles markdown code fences", () => {
const result = parseJsonContent<{ foo: string }>(
'```json\n{"foo": "bar"}\n```',
);
expect(result.foo).toBe("bar");
});
it("should handle json without language specifier", () => {
it("handles json without language specifier", () => {
const result = parseJsonContent<{ foo: string }>(
'```\n{"foo": "bar"}\n```',
);
expect(result.foo).toBe("bar");
});
it("should extract JSON from surrounding text", () => {
it("extracts JSON from surrounding text", () => {
const result = parseJsonContent<{ foo: string }>(
'Here is the result: {"foo": "bar"} as requested.',
);
expect(result.foo).toBe("bar");
});
it("should throw on completely invalid content", () => {
it("throws on completely invalid content", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => parseJsonContent("not json at all")).toThrow();
});

View File

@ -0,0 +1,823 @@
/**
* LLM service with provider-specific strategies and strict-first fallback.
*/
export type LlmProvider =
| "openrouter"
| "lmstudio"
| "ollama"
| "openai"
| "gemini";
type ResponseMode = "json_schema" | "json_object" | "text" | "none";
export interface JsonSchemaDefinition {
name: string;
schema: {
type: "object";
properties: Record<string, unknown>;
required: string[];
additionalProperties: boolean;
};
}
export interface LlmRequestOptions<_T> {
/** The model to use (e.g., 'google/gemini-3-flash-preview') */
model: string;
/** The prompt messages to send */
messages: Array<{ role: "user" | "system" | "assistant"; content: string }>;
/** JSON schema for structured output */
jsonSchema: JsonSchemaDefinition;
/** Number of retries on parsing failures (default: 0) */
maxRetries?: number;
/** Delay between retries in ms (default: 500) */
retryDelayMs?: number;
/** Job ID for logging purposes */
jobId?: string;
}
export interface LlmResult<T> {
success: true;
data: T;
}
export interface LlmError {
success: false;
error: string;
}
export type LlmResponse<T> = LlmResult<T> | LlmError;
export type LlmValidationResult = {
valid: boolean;
message: string | null;
};
type LlmServiceOptions = {
provider?: string | null;
baseUrl?: string | null;
apiKey?: string | null;
};
type ProviderStrategy = {
provider: LlmProvider;
defaultBaseUrl: string;
requiresApiKey: boolean;
modes: ResponseMode[];
validationPaths: string[];
buildRequest: (args: {
mode: ResponseMode;
baseUrl: string;
apiKey: string | null;
model: string;
messages: LlmRequestOptions<unknown>["messages"];
jsonSchema: JsonSchemaDefinition;
}) => { url: string; headers: Record<string, string>; body: unknown };
extractText: (response: unknown) => string | null;
isCapabilityError: (args: {
mode: ResponseMode;
status?: number;
body?: string;
}) => boolean;
getValidationUrls: (args: {
baseUrl: string;
apiKey: string | null;
}) => string[];
};
interface LlmApiError extends Error {
status?: number;
body?: string;
}
const modeCache = new Map<string, ResponseMode>();
const openRouterStrategy: ProviderStrategy = {
provider: "openrouter",
defaultBaseUrl: "https://openrouter.ai",
requiresApiKey: true,
modes: ["json_schema", "none"],
validationPaths: ["/api/v1/key"],
buildRequest: ({ mode, baseUrl, apiKey, model, messages, jsonSchema }) => {
const body: Record<string, unknown> = {
model,
messages,
stream: false,
plugins: [{ id: "response-healing" }],
};
if (mode === "json_schema") {
body.response_format = {
type: "json_schema",
json_schema: {
name: jsonSchema.name,
strict: true,
schema: jsonSchema.schema,
},
};
}
return {
url: joinUrl(baseUrl, "/api/v1/chat/completions"),
headers: buildHeaders({ apiKey, provider: "openrouter" }),
body,
};
},
extractText: (response) => {
const content = getNestedValue(response, [
"choices",
0,
"message",
"content",
]);
return typeof content === "string" ? content : null;
},
isCapabilityError: ({ mode, status, body }) =>
isCapabilityError({ mode, status, body }),
getValidationUrls: ({ baseUrl }) => [joinUrl(baseUrl, "/api/v1/key")],
};
const lmStudioStrategy: ProviderStrategy = {
provider: "lmstudio",
defaultBaseUrl: "http://localhost:1234",
requiresApiKey: false,
modes: ["json_schema", "text", "none"],
validationPaths: ["/v1/models"],
buildRequest: ({ mode, baseUrl, model, messages, jsonSchema }) => {
const body: Record<string, unknown> = {
model,
messages,
stream: false,
};
if (mode === "json_schema") {
body.response_format = {
type: "json_schema",
json_schema: {
name: jsonSchema.name,
strict: true,
schema: jsonSchema.schema,
},
};
} else if (mode === "text") {
body.response_format = { type: "text" };
}
return {
url: joinUrl(baseUrl, "/v1/chat/completions"),
headers: buildHeaders({ apiKey: null, provider: "lmstudio" }),
body,
};
},
extractText: (response) => {
const content = getNestedValue(response, [
"choices",
0,
"message",
"content",
]);
return typeof content === "string" ? content : null;
},
isCapabilityError: ({ mode, status, body }) =>
isCapabilityError({ mode, status, body }),
getValidationUrls: ({ baseUrl }) => [joinUrl(baseUrl, "/v1/models")],
};
const ollamaStrategy: ProviderStrategy = {
provider: "ollama",
defaultBaseUrl: "http://localhost:11434",
requiresApiKey: false,
modes: ["json_schema", "text", "none"],
validationPaths: ["/v1/models", "/api/tags"],
buildRequest: ({ mode, baseUrl, model, messages, jsonSchema }) => {
const body: Record<string, unknown> = {
model,
messages,
stream: false,
};
if (mode === "json_schema") {
body.response_format = {
type: "json_schema",
json_schema: {
name: jsonSchema.name,
strict: true,
schema: jsonSchema.schema,
},
};
} else if (mode === "text") {
body.response_format = { type: "text" };
}
return {
url: joinUrl(baseUrl, "/v1/chat/completions"),
headers: buildHeaders({ apiKey: null, provider: "ollama" }),
body,
};
},
extractText: (response) => {
const content = getNestedValue(response, [
"choices",
0,
"message",
"content",
]);
return typeof content === "string" ? content : null;
},
isCapabilityError: ({ mode, status, body }) =>
isCapabilityError({ mode, status, body }),
getValidationUrls: ({ baseUrl }) => [
joinUrl(baseUrl, "/v1/models"),
joinUrl(baseUrl, "/api/tags"),
],
};
const openAiStrategy: ProviderStrategy = {
provider: "openai",
defaultBaseUrl: "https://api.openai.com",
requiresApiKey: true,
modes: ["json_schema", "json_object", "none"],
validationPaths: ["/v1/models"],
buildRequest: ({ mode, baseUrl, apiKey, model, messages, jsonSchema }) => {
const input = ensureJsonInstructionIfNeeded(messages, mode);
const body: Record<string, unknown> = {
model,
input,
};
if (mode === "json_schema") {
body.text = {
format: {
type: "json_schema",
name: jsonSchema.name,
strict: true,
schema: jsonSchema.schema,
},
};
} else if (mode === "json_object") {
body.text = { format: { type: "json_object" } };
}
return {
url: joinUrl(baseUrl, "/v1/responses"),
headers: buildHeaders({ apiKey, provider: "openai" }),
body,
};
},
extractText: (response) => {
const direct = getNestedValue(response, ["output_text"]);
if (typeof direct === "string" && direct.trim()) return direct;
const output = getNestedValue(response, ["output"]);
if (!Array.isArray(output)) return null;
for (const item of output) {
const content = getNestedValue(item, ["content"]);
if (!Array.isArray(content)) continue;
for (const part of content) {
const type = getNestedValue(part, ["type"]);
const text = getNestedValue(part, ["text"]);
if (type === "output_text" && typeof text === "string") {
return text;
}
}
}
return null;
},
isCapabilityError: ({ mode, status, body }) =>
isCapabilityError({ mode, status, body }),
getValidationUrls: ({ baseUrl }) => [joinUrl(baseUrl, "/v1/models")],
};
const geminiStrategy: ProviderStrategy = {
provider: "gemini",
defaultBaseUrl: "https://generativelanguage.googleapis.com",
requiresApiKey: true,
modes: ["json_schema", "json_object", "none"],
validationPaths: ["/v1beta/models"],
buildRequest: ({ mode, baseUrl, apiKey, model, messages, jsonSchema }) => {
const { systemInstruction, contents } = toGeminiContents(messages);
const body: Record<string, unknown> = {
contents,
};
if (systemInstruction) {
body.systemInstruction = systemInstruction;
}
if (mode === "json_schema") {
body.generationConfig = {
responseMimeType: "application/json",
responseSchema: jsonSchema.schema,
};
} else if (mode === "json_object") {
body.generationConfig = {
responseMimeType: "application/json",
};
}
const url = joinUrl(
baseUrl,
`/v1beta/models/${encodeURIComponent(model)}:generateContent`,
);
const urlWithKey = addQueryParam(url, "key", apiKey ?? "");
return {
url: urlWithKey,
headers: buildHeaders({ apiKey: null, provider: "gemini" }),
body,
};
},
extractText: (response) => {
const parts = getNestedValue(response, [
"candidates",
0,
"content",
"parts",
]);
if (!Array.isArray(parts)) return null;
const text = parts
.map((part) => getNestedValue(part, ["text"]))
.filter((part) => typeof part === "string")
.join("");
return text || null;
},
isCapabilityError: ({ mode, status, body }) =>
isCapabilityError({ mode, status, body }),
getValidationUrls: ({ baseUrl, apiKey }) => {
const url = joinUrl(baseUrl, "/v1beta/models");
return [addQueryParam(url, "key", apiKey ?? "")];
},
};
const strategies: Record<LlmProvider, ProviderStrategy> = {
openrouter: openRouterStrategy,
lmstudio: lmStudioStrategy,
ollama: ollamaStrategy,
openai: openAiStrategy,
gemini: geminiStrategy,
};
export class LlmService {
private readonly provider: LlmProvider;
private readonly baseUrl: string;
private readonly apiKey: string | null;
private readonly strategy: ProviderStrategy;
constructor(options: LlmServiceOptions = {}) {
const normalizedBaseUrl =
normalizeEnvInput(options.baseUrl) ||
normalizeEnvInput(process.env.LLM_BASE_URL) ||
null;
const resolvedProvider = normalizeProvider(
options.provider ?? process.env.LLM_PROVIDER ?? null,
normalizedBaseUrl,
);
const strategy = strategies[resolvedProvider];
const baseUrl = normalizedBaseUrl || strategy.defaultBaseUrl;
const apiKey =
normalizeEnvInput(options.apiKey) ||
normalizeEnvInput(process.env.LLM_API_KEY) ||
(resolvedProvider === "openrouter"
? normalizeEnvInput(process.env.OPENROUTER_API_KEY)
: null);
this.provider = resolvedProvider;
this.baseUrl = baseUrl;
this.apiKey = apiKey;
this.strategy = strategy;
}
async callJson<T>(options: LlmRequestOptions<T>): Promise<LlmResponse<T>> {
if (this.strategy.requiresApiKey && !this.apiKey) {
return { success: false, error: "LLM API key not configured" };
}
const {
model,
messages,
jsonSchema,
maxRetries = 0,
retryDelayMs = 500,
} = options;
const jobId = options.jobId;
const cacheKey = `${this.provider}:${this.baseUrl}`;
const cachedMode = modeCache.get(cacheKey);
const modes = cachedMode
? [cachedMode, ...this.strategy.modes.filter((m) => m !== cachedMode)]
: this.strategy.modes;
for (const mode of modes) {
const result = await this.tryMode<T>({
mode,
model,
messages,
jsonSchema,
maxRetries,
retryDelayMs,
jobId,
});
if (result.success) {
modeCache.set(cacheKey, mode);
return result;
}
if (!result.success && result.error.startsWith("CAPABILITY:")) {
continue;
}
return result;
}
return { success: false, error: "All provider modes failed" };
}
getProvider(): LlmProvider {
return this.provider;
}
getBaseUrl(): string {
return this.baseUrl;
}
async validateCredentials(): Promise<LlmValidationResult> {
if (this.strategy.requiresApiKey && !this.apiKey) {
return { valid: false, message: "LLM API key is missing." };
}
const urls = this.strategy.getValidationUrls({
baseUrl: this.baseUrl,
apiKey: this.apiKey,
});
let lastMessage: string | null = null;
for (const url of urls) {
try {
const response = await fetch(url, {
method: "GET",
headers: buildHeaders({
apiKey: this.apiKey,
provider: this.provider,
}),
});
if (response.ok) {
return { valid: true, message: null };
}
const detail = await getResponseDetail(response);
if (response.status === 401) {
return {
valid: false,
message: "Invalid LLM API key. Check the key and try again.",
};
}
lastMessage = detail || `LLM provider returned ${response.status}`;
} catch (error) {
lastMessage =
error instanceof Error ? error.message : "LLM validation failed.";
}
}
return {
valid: false,
message: lastMessage || "LLM provider validation failed.",
};
}
private async tryMode<T>(args: {
mode: ResponseMode;
model: string;
messages: LlmRequestOptions<T>["messages"];
jsonSchema: JsonSchemaDefinition;
maxRetries: number;
retryDelayMs: number;
jobId?: string;
}): Promise<LlmResponse<T>> {
const { mode, model, messages, jsonSchema, maxRetries, retryDelayMs } =
args;
const jobId = args.jobId;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
console.log(
`🔄 [${jobId ?? "unknown"}] Retry attempt ${attempt}/${maxRetries}...`,
);
await sleep(retryDelayMs * attempt);
}
const { url, headers, body } = this.strategy.buildRequest({
mode,
baseUrl: this.baseUrl,
apiKey: this.apiKey,
model,
messages,
jsonSchema,
});
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
const errorBody = await response.text().catch(() => "No error body");
const parsedError = parseErrorMessage(errorBody);
const detail = parsedError ? ` - ${truncate(parsedError, 400)}` : "";
const err = new Error(
`LLM API error: ${response.status}${detail}`,
) as LlmApiError;
err.status = response.status;
err.body = errorBody;
throw err;
}
const data = await response.json();
const content = this.strategy.extractText(data);
if (!content) {
throw new Error("No content in response");
}
const parsed = parseJsonContent<T>(content, jobId);
return { success: true, data: parsed };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const status = (error as LlmApiError).status;
const body = (error as LlmApiError).body;
if (
this.strategy.isCapabilityError({
mode,
status,
body,
})
) {
return { success: false, error: `CAPABILITY:${message}` };
}
const shouldRetry =
message.includes("parse") ||
status === 429 ||
(status !== undefined && status >= 500 && status <= 599) ||
message.toLowerCase().includes("timeout") ||
message.toLowerCase().includes("fetch failed");
if (attempt < maxRetries && shouldRetry) {
console.warn(
`⚠️ [${jobId ?? "unknown"}] Attempt ${attempt + 1} failed (${status ?? "no-status"}): ${message}. Retrying...`,
);
continue;
}
return { success: false, error: message };
}
}
return { success: false, error: "All retry attempts failed" };
}
}
export function parseJsonContent<T>(content: string, jobId?: string): T {
let candidate = content.trim();
candidate = candidate
.replace(/```(?:json|JSON)?\s*/g, "")
.replace(/```/g, "")
.trim();
const firstBrace = candidate.indexOf("{");
const lastBrace = candidate.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
candidate = candidate.substring(firstBrace, lastBrace + 1);
}
try {
return JSON.parse(candidate) as T;
} catch (error) {
console.error(
`❌ [${jobId ?? "unknown"}] Failed to parse JSON:`,
candidate.substring(0, 200),
);
throw new Error(
`Failed to parse JSON response: ${error instanceof Error ? error.message : "unknown"}`,
);
}
}
function normalizeProvider(
raw: string | null,
baseUrl: string | null,
): LlmProvider {
const normalized = raw?.trim().toLowerCase();
if (normalized === "openai_compatible") {
if (
baseUrl?.includes("localhost:1234") ||
baseUrl?.includes("127.0.0.1:1234")
) {
return "lmstudio";
}
return "openai";
}
if (normalized === "openai") return "openai";
if (normalized === "gemini") return "gemini";
if (normalized === "lmstudio") return "lmstudio";
if (normalized === "ollama") return "ollama";
if (normalized && normalized !== "openrouter") {
console.warn(
`⚠️ Unknown LLM provider "${normalized}", defaulting to openrouter`,
);
}
return "openrouter";
}
function normalizeEnvInput(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function buildHeaders(args: {
apiKey: string | null;
provider: LlmProvider;
}): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (args.apiKey) {
headers.Authorization = `Bearer ${args.apiKey}`;
}
if (args.provider === "openrouter") {
headers["HTTP-Referer"] = "JobOps";
headers["X-Title"] = "JobOpsOrchestrator";
}
return headers;
}
function ensureJsonInstructionIfNeeded(
messages: LlmRequestOptions<unknown>["messages"],
mode: ResponseMode,
) {
if (mode !== "json_object") return messages;
const hasJson = messages.some((message) =>
message.content.toLowerCase().includes("json"),
);
if (hasJson) return messages;
return [
{
role: "system" as const,
content: "Respond with valid JSON.",
},
...messages,
];
}
function toGeminiContents(messages: LlmRequestOptions<unknown>["messages"]): {
systemInstruction: { parts: Array<{ text: string }> } | null;
contents: Array<{ role: "user" | "model"; parts: Array<{ text: string }> }>;
} {
const systemParts: string[] = [];
const contents = messages
.filter((message) => {
if (message.role === "system") {
systemParts.push(message.content);
return false;
}
return true;
})
.map((message) => {
const role: "user" | "model" =
message.role === "assistant" ? "model" : "user";
return { role, parts: [{ text: message.content }] };
});
const systemInstruction = systemParts.length
? { parts: [{ text: systemParts.join("\n") }] }
: null;
return { systemInstruction, contents };
}
async function getResponseDetail(response: Response): Promise<string> {
try {
const payload = await response.json();
if (payload && typeof payload === "object" && "error" in payload) {
const errorObj = payload.error as {
message?: string;
code?: number | string;
};
const message = errorObj?.message || "";
const code = errorObj?.code ? ` (${errorObj.code})` : "";
return `${message}${code}`.trim();
}
} catch {
// ignore JSON parse errors
}
return response.text().catch(() => "");
}
function isCapabilityError(args: {
mode: ResponseMode;
status?: number;
body?: string;
}): boolean {
if (args.mode === "none") return false;
if (args.status !== 400) return false;
const body = (args.body || "").toLowerCase();
if (body.includes("model") && body.includes("not")) return false;
if (body.includes("unknown model")) return false;
return (
body.includes("response_format") ||
body.includes("json_schema") ||
body.includes("json_object") ||
body.includes("text.format") ||
body.includes("response schema") ||
body.includes("responseschema") ||
body.includes("responsemime") ||
body.includes("response_mime")
);
}
function joinUrl(baseUrl: string, path: string): string {
const base = baseUrl.replace(/\/+$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${base}${suffix}`;
}
function addQueryParam(url: string, key: string, value: string): string {
const connector = url.includes("?") ? "&" : "?";
return `${url}${connector}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}
type PathSegment = string | number;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getNestedValue(value: unknown, path: PathSegment[]): unknown {
let current: unknown = value;
for (const segment of path) {
if (typeof segment === "number") {
if (!Array.isArray(current)) return undefined;
current = current[segment];
continue;
}
if (!isRecord(current)) return undefined;
current = current[segment];
}
return current;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function truncate(value: string, maxLength: number): string {
if (value.length <= maxLength) return value;
return `${value.slice(0, maxLength - 1)}`;
}
function parseErrorMessage(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "";
try {
const payload = JSON.parse(trimmed) as unknown;
const candidates: Array<unknown> = [
getNestedValue(payload, ["error", "message"]),
getNestedValue(payload, ["error", "error", "message"]),
getNestedValue(payload, ["error"]),
getNestedValue(payload, ["message"]),
getNestedValue(payload, ["detail"]),
getNestedValue(payload, ["msg"]),
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
}
if (typeof payload === "string" && payload.trim()) {
return payload.trim();
}
} catch {
// Not JSON
}
return trimmed;
}

View File

@ -30,7 +30,7 @@ describe("manual job inference", () => {
const result = await inferManualJobDetails("JD text");
expect(result.job).toEqual({});
expect(result.warning).toContain("OPENROUTER_API_KEY not set");
expect(result.warning).toContain("LLM API key not set");
expect(global.fetch).not.toHaveBeenCalled();
});

View File

@ -4,7 +4,7 @@
import type { ManualJobDraft } from "../../shared/types.js";
import { getSetting } from "../repositories/settings.js";
import { callOpenRouter, type JsonSchemaDefinition } from "./openrouter.js";
import { type JsonSchemaDefinition, LlmService } from "./llm-service.js";
export interface ManualJobInferenceResult {
job: ManualJobDraft;
@ -92,25 +92,25 @@ const MANUAL_JOB_SCHEMA: JsonSchemaDefinition = {
export async function inferManualJobDetails(
jobDescription: string,
): Promise<ManualJobInferenceResult> {
if (!process.env.OPENROUTER_API_KEY) {
return {
job: {},
warning: "OPENROUTER_API_KEY not set. Fill details manually.",
};
}
const overrideModel = await getSetting("model");
const model =
overrideModel || process.env.MODEL || "google/gemini-3-flash-preview";
const prompt = buildInferencePrompt(jobDescription);
const result = await callOpenRouter<ManualJobApiResponse>({
const llm = new LlmService();
const result = await llm.callJson<ManualJobApiResponse>({
model,
messages: [{ role: "user", content: prompt }],
jsonSchema: MANUAL_JOB_SCHEMA,
});
if (!result.success) {
if (result.error.toLowerCase().includes("api key")) {
return {
job: {},
warning: "LLM API key not set. Fill details manually.",
};
}
console.warn("Manual job inference failed:", result.error);
return {
job: {},

View File

@ -1,194 +0,0 @@
/**
* Shared OpenRouter API helper for structured JSON responses.
*/
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions";
export interface JsonSchemaDefinition {
name: string;
schema: {
type: "object";
properties: Record<string, unknown>;
required: string[];
additionalProperties: boolean;
};
}
export interface OpenRouterRequestOptions<_T> {
/** The model to use (e.g., 'google/gemini-3-flash-preview') */
model: string;
/** The prompt messages to send */
messages: Array<{ role: "user" | "system" | "assistant"; content: string }>;
/** JSON schema for structured output */
jsonSchema: JsonSchemaDefinition;
/** Number of retries on parsing failures (default: 0) */
maxRetries?: number;
/** Delay between retries in ms (default: 500) */
retryDelayMs?: number;
/** Job ID for logging purposes */
jobId?: string;
}
export interface OpenRouterResult<T> {
success: true;
data: T;
}
export interface OpenRouterError {
success: false;
error: string;
}
export type OpenRouterResponse<T> = OpenRouterResult<T> | OpenRouterError;
interface OpenRouterApiError extends Error {
status?: number;
body?: string;
}
/**
* Call OpenRouter API with structured JSON output.
*
* @returns Parsed JSON response matching the schema, or an error object
*/
export async function callOpenRouter<T>(
options: OpenRouterRequestOptions<T>,
): Promise<OpenRouterResponse<T>> {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return { success: false, error: "OPENROUTER_API_KEY not configured" };
}
const {
model,
messages,
jsonSchema,
maxRetries = 0,
retryDelayMs = 500,
jobId,
} = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
console.log(
`🔄 [${jobId ?? "unknown"}] Retry attempt ${attempt}/${maxRetries}...`,
);
await sleep(retryDelayMs * attempt);
}
const response = await fetch(OPENROUTER_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "JobOps",
"X-Title": "JobOpsOrchestrator",
},
body: JSON.stringify({
model,
messages,
stream: false,
response_format: {
type: "json_schema",
json_schema: {
name: jsonSchema.name,
strict: true,
schema: jsonSchema.schema,
},
},
plugins: [{ id: "response-healing" }],
}),
});
if (!response.ok) {
// Throw error with status to allow specific retries
const errorBody = await response.text().catch(() => "No error body");
const err = new Error(
`OpenRouter API error: ${response.status}`,
) as OpenRouterApiError;
err.status = response.status;
err.body = errorBody;
throw err;
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error("No content in response");
}
// Parse JSON - structured outputs should always return valid JSON
const parsed = parseJsonContent<T>(content, jobId);
return { success: true, data: parsed };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const status = (error as OpenRouterApiError).status;
// Retry on:
// 1. Parsing errors (AI returned malformed JSON)
// 2. Rate limits (429)
// 3. Server errors (5xx)
// 4. Timeouts/Network issues
const shouldRetry =
message.includes("parse") ||
status === 429 ||
(status !== undefined && status >= 500 && status <= 599) ||
message.toLowerCase().includes("timeout") ||
message.toLowerCase().includes("fetch failed");
if (attempt < maxRetries && shouldRetry) {
console.warn(
`⚠️ [${jobId ?? "unknown"}] Attempt ${attempt + 1} failed (${status ?? "no-status"}): ${message}. Retrying...`,
);
continue;
}
return { success: false, error: message };
}
}
return { success: false, error: "All retry attempts failed" };
}
/**
* Parse JSON content from OpenRouter response.
* Handles common AI quirks like markdown code fences.
*/
export function parseJsonContent<T>(content: string, jobId?: string): T {
let candidate = content.trim();
// Remove markdown code fences if present
candidate = candidate
.replace(/```(?:json|JSON)?\s*/g, "")
.replace(/```/g, "")
.trim();
// Try to extract JSON object if there's surrounding text
// Use non-greedy match and find the outermost braces
const firstBrace = candidate.indexOf("{");
const lastBrace = candidate.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
candidate = candidate.substring(firstBrace, lastBrace + 1);
}
try {
return JSON.parse(candidate) as T;
} catch (error) {
console.error(
`❌ [${jobId ?? "unknown"}] Failed to parse JSON:`,
candidate.substring(0, 200),
);
throw new Error(
`Failed to parse JSON response: ${error instanceof Error ? error.message : "unknown"}`,
);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@ -3,7 +3,7 @@
*/
import { getSetting } from "../repositories/settings.js";
import { callOpenRouter, type JsonSchemaDefinition } from "./openrouter.js";
import { type JsonSchemaDefinition, LlmService } from "./llm-service.js";
import type { ResumeProjectSelectionItem } from "./resumeProjects.js";
/** JSON schema for project selection response */
@ -34,14 +34,6 @@ export async function pickProjectIdsForJob(args: {
const eligibleIds = new Set(args.eligibleProjects.map((p) => p.id));
if (eligibleIds.size === 0) return [];
if (!process.env.OPENROUTER_API_KEY) {
return fallbackPickProjectIds(
args.jobDescription,
args.eligibleProjects,
desiredCount,
);
}
const [overrideModel, overrideModelProjectSelection] = await Promise.all([
getSetting("model"),
getSetting("modelProjectSelection"),
@ -59,7 +51,8 @@ export async function pickProjectIdsForJob(args: {
desiredCount,
});
const result = await callOpenRouter<{ selectedProjectIds: string[] }>({
const llm = new LlmService();
const result = await llm.callJson<{ selectedProjectIds: string[] }>({
model,
messages: [{ role: "user", content: prompt }],
jsonSchema: PROJECT_SELECTION_SCHEMA,

View File

@ -4,7 +4,7 @@
import type { Job } from "../../shared/types.js";
import { getSetting } from "../repositories/settings.js";
import { callOpenRouter, type JsonSchemaDefinition } from "./openrouter.js";
import { type JsonSchemaDefinition, LlmService } from "./llm-service.js";
interface SuitabilityResult {
score: number; // 0-100
@ -39,11 +39,6 @@ export async function scoreJobSuitability(
job: Job,
profile: Record<string, unknown>,
): Promise<SuitabilityResult> {
if (!process.env.OPENROUTER_API_KEY) {
console.warn("⚠️ OPENROUTER_API_KEY not set, using mock scoring");
return mockScore(job);
}
const [overrideModel, overrideModelScorer] = await Promise.all([
getSetting("model"),
getSetting("modelScorer"),
@ -57,7 +52,8 @@ export async function scoreJobSuitability(
const prompt = buildScoringPrompt(job, profile);
const result = await callOpenRouter<{ score: number; reason: string }>({
const llm = new LlmService();
const result = await llm.callJson<{ score: number; reason: string }>({
model,
messages: [{ role: "user", content: prompt }],
jsonSchema: SCORING_SCHEMA,
@ -66,6 +62,9 @@ export async function scoreJobSuitability(
});
if (!result.success) {
if (result.error.toLowerCase().includes("api key")) {
console.warn("⚠️ LLM API key not set, using mock scoring");
}
console.error(
`❌ [Job ${job.id}] Scoring failed: ${result.error}, using mock scoring`,
);
@ -92,7 +91,7 @@ export async function scoreJobSuitability(
* Robustly parse JSON from AI-generated content.
* Handles common AI quirks: markdown fences, extra text, trailing commas, etc.
*
* @deprecated Use callOpenRouter with structured outputs instead. Kept for backwards compatibility with tests.
* @deprecated Use LlmService with structured outputs instead. Kept for backwards compatibility with tests.
*/
export function parseJsonFromContent(
content: string,

View File

@ -59,6 +59,15 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
const overrideModelProjectSelection = overrides.modelProjectSelection ?? null;
const modelProjectSelection = overrideModelProjectSelection || model;
const defaultLlmProvider = process.env.LLM_PROVIDER || "openrouter";
const overrideLlmProvider = overrides.llmProvider ?? null;
const llmProvider = overrideLlmProvider || defaultLlmProvider;
const defaultLlmBaseUrl =
process.env.LLM_BASE_URL || resolveDefaultLlmBaseUrl(llmProvider);
const overrideLlmBaseUrl = overrides.llmBaseUrl ?? null;
const llmBaseUrl = overrideLlmBaseUrl || defaultLlmBaseUrl;
const defaultPipelineWebhookUrl =
process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || "";
const overridePipelineWebhookUrl = overrides.pipelineWebhookUrl ?? null;
@ -169,6 +178,7 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo;
return {
...envSettings,
model,
defaultModel,
overrideModel,
@ -178,6 +188,12 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
overrideModelTailoring,
modelProjectSelection,
overrideModelProjectSelection,
llmProvider,
defaultLlmProvider,
overrideLlmProvider,
llmBaseUrl,
defaultLlmBaseUrl,
overrideLlmBaseUrl,
pipelineWebhookUrl,
defaultPipelineWebhookUrl,
overridePipelineWebhookUrl,
@ -216,6 +232,18 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
showSponsorInfo,
defaultShowSponsorInfo,
overrideShowSponsorInfo,
...envSettings,
} as AppSettings;
}
function resolveDefaultLlmBaseUrl(provider: string): string {
const normalized = provider.trim().toLowerCase();
if (normalized === "ollama") return "http://localhost:11434";
if (normalized === "lmstudio") return "http://localhost:1234";
if (normalized === "openai") {
return "https://api.openai.com";
}
if (normalized === "gemini") {
return "https://generativelanguage.googleapis.com";
}
return "https://openrouter.ai";
}

View File

@ -4,7 +4,7 @@
import type { ResumeProfile } from "../../shared/types.js";
import { getSetting } from "../repositories/settings.js";
import { callOpenRouter, type JsonSchemaDefinition } from "./openrouter.js";
import { type JsonSchemaDefinition, LlmService } from "./llm-service.js";
export interface TailoredData {
summary: string;
@ -65,11 +65,6 @@ export async function generateTailoring(
jobDescription: string,
profile: ResumeProfile,
): Promise<TailoringResult> {
if (!process.env.OPENROUTER_API_KEY) {
console.warn("⚠️ OPENROUTER_API_KEY not set, cannot generate tailoring");
return { success: false, error: "API key not configured" };
}
const [overrideModel, overrideModelTailoring] = await Promise.all([
getSetting("model"),
getSetting("modelTailoring"),
@ -82,14 +77,24 @@ export async function generateTailoring(
"google/gemini-3-flash-preview";
const prompt = buildTailoringPrompt(profile, jobDescription);
const result = await callOpenRouter<TailoredData>({
const llm = new LlmService();
const result = await llm.callJson<TailoredData>({
model,
messages: [{ role: "user", content: prompt }],
jsonSchema: TAILORING_SCHEMA,
});
if (!result.success) {
return { success: false, error: result.error };
const context = `provider=${llm.getProvider()} baseUrl=${llm.getBaseUrl()}`;
if (result.error.toLowerCase().includes("api key")) {
const message = `LLM API key not set, cannot generate tailoring. (${context})`;
console.warn(`⚠️ ${message}`);
return { success: false, error: message };
}
return {
success: false,
error: `${result.error} (${context})`,
};
}
const { summary, headline, skills } = result.data;

View File

@ -12,6 +12,21 @@ export const updateSettingsSchema = z
modelScorer: z.string().trim().max(200).nullable().optional(),
modelTailoring: z.string().trim().max(200).nullable().optional(),
modelProjectSelection: z.string().trim().max(200).nullable().optional(),
llmProvider: z
.preprocess(
(value) => (value === "" ? null : value),
z
.enum(["openrouter", "lmstudio", "ollama", "openai", "gemini"])
.nullable(),
)
.optional(),
llmBaseUrl: z
.preprocess(
(value) => (value === "" ? null : value),
z.string().trim().url().max(2000).nullable(),
)
.optional(),
llmApiKey: z.string().trim().max(2000).nullable().optional(),
pipelineWebhookUrl: z.string().trim().max(2000).nullable().optional(),
jobCompleteWebhookUrl: z.string().trim().max(2000).nullable().optional(),
resumeProjects: resumeProjectsSchema.nullable().optional(),

View File

@ -483,6 +483,13 @@ export interface AppSettings {
modelProjectSelection: string; // resolved
overrideModelProjectSelection: string | null;
llmProvider: string;
defaultLlmProvider: string;
overrideLlmProvider: string | null;
llmBaseUrl: string;
defaultLlmBaseUrl: string;
overrideLlmBaseUrl: string | null;
pipelineWebhookUrl: string;
defaultPipelineWebhookUrl: string;
overridePipelineWebhookUrl: string | null;
@ -524,6 +531,7 @@ export interface AppSettings {
showSponsorInfo: boolean;
defaultShowSponsorInfo: boolean;
overrideShowSponsorInfo: boolean | null;
llmApiKeyHint: string | null;
openrouterApiKeyHint: string | null;
rxresumeEmail: string | null;
rxresumePasswordHint: string | null;