diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 1ef8e19..9368e91 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -323,12 +323,14 @@ export async function refreshProfile(): Promise { }); } -export async function validateOpenrouter( - apiKey?: string, -): Promise { - return fetchApi("/onboarding/validate/openrouter", { +export async function validateLlm(input: { + provider?: string; + baseUrl?: string; + apiKey?: string; +}): Promise { + return fetchApi("/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; diff --git a/orchestrator/src/client/components/OnboardingGate.test.tsx b/orchestrator/src/client/components/OnboardingGate.test.tsx index 389deb8..9d3d638 100644 --- a/orchestrator/src/client/components/OnboardingGate.test.tsx +++ b/orchestrator/src/client/components/OnboardingGate.test.tsx @@ -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 }) => ( +
{children}
+ ), + SelectContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SelectItem: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SelectTrigger: ({ children }: { children: React.ReactNode }) => ( + + ), + SelectValue: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})); + vi.mock("@/components/ui/progress", () => ({ Progress: () =>
Progress
, })); @@ -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(); - 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(); - 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(); + + 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(); + }); }); diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx index 7359da2..98c6747 100644 --- a/orchestrator/src/client/components/OnboardingGate.tsx +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -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({ - valid: false, - message: null, - checked: false, - }); + const [llmValidation, setLlmValidation] = useState({ + valid: false, + message: null, + checked: false, + }); const [rxresumeValidation, setRxresumeValidation] = useState( { valid: false, @@ -60,29 +72,34 @@ export const OnboardingGate: React.FC = () => { }); const [currentStep, setCurrentStep] = useState(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[] = []; + 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 => { - const openrouterValue = openrouterApiKey.trim(); - if (!openrouterValue && !hasOpenrouterKey) { - toast.info("Add your OpenRouter API key to continue"); + const handleSaveLlm = async (): Promise => { + 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 = () => { })} - +
-

Connect OpenRouter

+

Connect LLM provider

Used for job scoring, summaries, and tailoring.

- setOpenrouterApiKey(event.target.value), - }} - type="password" - placeholder="sk-or-v1..." - current={openrouterCurrent} - helper="Create a key at openrouter.ai" - disabled={isSavingEnv} - /> +
+
+ + +

+ {providerConfig.providerHint} +

+
+ {showBaseUrl && ( + setLlmBaseUrl(event.target.value), + }} + placeholder={providerConfig.baseUrlPlaceholder} + helper={providerConfig.baseUrlHelper} + current={settings?.llmBaseUrl || "—"} + disabled={isSavingEnv} + /> + )} + {showApiKey && ( + setLlmApiKey(event.target.value), + }} + type="password" + placeholder="Enter key" + current={llmKeyCurrent} + helper={providerConfig.keyHelper} + disabled={isSavingEnv} + /> + )} +
diff --git a/orchestrator/src/client/components/TailoringEditor.tsx b/orchestrator/src/client/components/TailoringEditor.tsx index 6c80c19..a341423 100644 --- a/orchestrator/src/client/components/TailoringEditor.tsx +++ b/orchestrator/src/client/components/TailoringEditor.tsx @@ -136,8 +136,10 @@ export const TailoringEditor: React.FC = ({ } 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); } diff --git a/orchestrator/src/client/components/discovered-panel/TailorMode.tsx b/orchestrator/src/client/components/discovered-panel/TailorMode.tsx index 4c9d916..bb5dc1f 100644 --- a/orchestrator/src/client/components/discovered-panel/TailorMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/TailorMode.tsx @@ -141,8 +141,10 @@ export const TailorMode: React.FC = ({ 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); } diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index 154a395..408700e 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -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(); }); diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 54fd962..d199fa0 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -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({ - resolver: zodResolver(updateSettingsSchema), + resolver: zodResolver( + updateSettingsSchema, + ) as Resolver, 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; diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx index 783b9af..3954488 100644 --- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.test.tsx @@ -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(); }); diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx index 205d23f..7d441be 100644 --- a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx @@ -38,26 +38,6 @@ export const EnvironmentSettingsSection: React.FC<
- {/* External Services */} -
-
- External Services -
-
- -
-
- - - {/* Service Accounts */}
diff --git a/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx index 22667e2..1f6c49f 100644 --- a/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx @@ -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 = ({ scorer, tailoring, projectSelection, + llmProvider, + llmBaseUrl, + llmApiKeyHint, } = values; const { register, + control, + watch, + setValue, formState: { errors }, } = useFormContext(); + 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 ( @@ -40,14 +77,82 @@ export const ModelSettingsSection: React.FC = ({
+
+
LLM Provider
+
+
+ + ( + + )} + /> + {errors.llmProvider?.message && ( +

+ {errors.llmProvider.message as string} +

+ )} +

+ Used for scoring, tailoring, and extraction. +

+

+ {providerConfig.providerHint} +

+
+ {showBaseUrl && ( + + )} + {showApiKey && ( + + )} +
+
+ + + @@ -62,7 +167,7 @@ export const ModelSettingsSection: React.FC = ({ placeholder={effective || "inherit"} disabled={isLoading || isSaving} error={errors.modelScorer?.message as string | undefined} - current={scorer || effective || "—"} + current={scoringModel} /> = ({ placeholder={effective || "inherit"} disabled={isLoading || isSaving} error={errors.modelTailoring?.message as string | undefined} - current={tailoring || effective || "—"} + current={tailoringModel} /> = ({ error={ errors.modelProjectSelection?.message as string | undefined } - current={projectSelection || effective || "—"} + current={projectSelectionModel} />
-
-
-
- Global Effective +
+
Resolved config
+
+
Provider
+
{selectedProvider || "—"}
+ +
Base URL
+
{llmBaseUrl || "—"}
+ +
API key
+
{keyText}
+ +
Default model
+
{effectiveDefaultModel}
+ +
Scoring model
+
+ {scoringModel === effectiveDefaultModel + ? "inherits" + : scoringModel}
-
- {effective || "—"} + +
Tailoring model
+
+ {tailoringModel === effectiveDefaultModel + ? "inherits" + : tailoringModel}
-
-
-
Default (env)
-
- {defaultModel || "—"} + +
Project selection
+
+ {projectSelectionModel === effectiveDefaultModel + ? "inherits" + : projectSelectionModel}
diff --git a/orchestrator/src/client/pages/settings/types.ts b/orchestrator/src/client/pages/settings/types.ts index 6f04b92..6df50ef 100644 --- a/orchestrator/src/client/pages/settings/types.ts +++ b/orchestrator/src/client/pages/settings/types.ts @@ -7,6 +7,9 @@ export type ModelValues = EffectiveDefault & { scorer: string; tailoring: string; projectSelection: string; + llmProvider: string; + llmBaseUrl: string; + llmApiKeyHint: string | null; }; export type WebhookValues = EffectiveDefault; diff --git a/orchestrator/src/client/pages/settings/utils.ts b/orchestrator/src/client/pages/settings/utils.ts index 784dade..6507cf8 100644 --- a/orchestrator/src/client/pages/settings/utils.ts +++ b/orchestrator/src/client/pages/settings/utils.ts @@ -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 = { + 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, + }; +} diff --git a/orchestrator/src/server/api/routes/onboarding.ts b/orchestrator/src/server/api/routes/onboarding.ts index 8421162..c1f300a 100644 --- a/orchestrator/src/server/api/routes/onboarding.ts +++ b/orchestrator/src/server/api/routes/onboarding.ts @@ -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 { - 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 { + 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) => { diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index e74deb2..452dcdb 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -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( diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts index 2f85196..40f2a00 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -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"}`, + }; } } diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts index f4fb328..bdf94df 100644 --- a/orchestrator/src/server/repositories/settings.ts +++ b/orchestrator/src/server/repositories/settings.ts @@ -12,6 +12,9 @@ export type SettingKey = | "modelScorer" | "modelTailoring" | "modelProjectSelection" + | "llmProvider" + | "llmBaseUrl" + | "llmApiKey" | "pipelineWebhookUrl" | "jobCompleteWebhookUrl" | "resumeProjects" diff --git a/orchestrator/src/server/services/envSettings.ts b/orchestrator/src/server/services/envSettings.ts index 7e65491..4d09264 100644 --- a/orchestrator/src/server/services/envSettings.ts +++ b/orchestrator/src/server/services/envSettings.ts @@ -4,6 +4,8 @@ import * as settingsRepo from "@server/repositories/settings.js"; const envDefaults: Record = { ...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", diff --git a/orchestrator/src/server/services/openrouter.test.ts b/orchestrator/src/server/services/llm-service.test.ts similarity index 55% rename from orchestrator/src/server/services/openrouter.test.ts rename to orchestrator/src/server/services/llm-service.test.ts index 3ddd4f8..75f2282 100644 --- a/orchestrator/src/server/services/openrouter.test.ts +++ b/orchestrator/src/server/services/llm-service.test.ts @@ -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; + + 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(); }); diff --git a/orchestrator/src/server/services/llm-service.ts b/orchestrator/src/server/services/llm-service.ts new file mode 100644 index 0000000..c8722de --- /dev/null +++ b/orchestrator/src/server/services/llm-service.ts @@ -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; + 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 { + success: true; + data: T; +} + +export interface LlmError { + success: false; + error: string; +} + +export type LlmResponse = LlmResult | 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["messages"]; + jsonSchema: JsonSchemaDefinition; + }) => { url: string; headers: Record; 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(); + +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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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(options: LlmRequestOptions): Promise> { + 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({ + 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 { + 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(args: { + mode: ResponseMode; + model: string; + messages: LlmRequestOptions["messages"]; + jsonSchema: JsonSchemaDefinition; + maxRetries: number; + retryDelayMs: number; + jobId?: string; + }): Promise> { + 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(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(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 { + const headers: Record = { + "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["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["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 { + 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 { + 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 { + 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 = [ + 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; +} diff --git a/orchestrator/src/server/services/manualJob.test.ts b/orchestrator/src/server/services/manualJob.test.ts index 217ba90..e3b8439 100644 --- a/orchestrator/src/server/services/manualJob.test.ts +++ b/orchestrator/src/server/services/manualJob.test.ts @@ -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(); }); diff --git a/orchestrator/src/server/services/manualJob.ts b/orchestrator/src/server/services/manualJob.ts index 7f5d31e..8f6005d 100644 --- a/orchestrator/src/server/services/manualJob.ts +++ b/orchestrator/src/server/services/manualJob.ts @@ -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 { - 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({ + const llm = new LlmService(); + const result = await llm.callJson({ 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: {}, diff --git a/orchestrator/src/server/services/openrouter.ts b/orchestrator/src/server/services/openrouter.ts deleted file mode 100644 index d834439..0000000 --- a/orchestrator/src/server/services/openrouter.ts +++ /dev/null @@ -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; - 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 { - success: true; - data: T; -} - -export interface OpenRouterError { - success: false; - error: string; -} - -export type OpenRouterResponse = OpenRouterResult | 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( - options: OpenRouterRequestOptions, -): Promise> { - 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(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(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 { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/orchestrator/src/server/services/projectSelection.ts b/orchestrator/src/server/services/projectSelection.ts index 80cd25a..9d7894c 100644 --- a/orchestrator/src/server/services/projectSelection.ts +++ b/orchestrator/src/server/services/projectSelection.ts @@ -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, diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index 1bef8e7..eed2a2c 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -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, ): Promise { - 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, diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts index 2931963..419a41a 100644 --- a/orchestrator/src/server/services/settings.ts +++ b/orchestrator/src/server/services/settings.ts @@ -59,6 +59,15 @@ export async function getEffectiveSettings(): Promise { 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 { const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo; return { + ...envSettings, model, defaultModel, overrideModel, @@ -178,6 +188,12 @@ export async function getEffectiveSettings(): Promise { overrideModelTailoring, modelProjectSelection, overrideModelProjectSelection, + llmProvider, + defaultLlmProvider, + overrideLlmProvider, + llmBaseUrl, + defaultLlmBaseUrl, + overrideLlmBaseUrl, pipelineWebhookUrl, defaultPipelineWebhookUrl, overridePipelineWebhookUrl, @@ -216,6 +232,18 @@ export async function getEffectiveSettings(): Promise { 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"; +} diff --git a/orchestrator/src/server/services/summary.ts b/orchestrator/src/server/services/summary.ts index bd031a4..167742e 100644 --- a/orchestrator/src/server/services/summary.ts +++ b/orchestrator/src/server/services/summary.ts @@ -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 { - 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({ + const llm = new LlmService(); + const result = await llm.callJson({ 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; diff --git a/orchestrator/src/shared/settings-schema.ts b/orchestrator/src/shared/settings-schema.ts index 124d07a..932a8b5 100644 --- a/orchestrator/src/shared/settings-schema.ts +++ b/orchestrator/src/shared/settings-schema.ts @@ -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(), diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index 5ed311c..fa94cb1 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -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;