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:
parent
6e771ce728
commit
b4641ad9cb
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,9 @@ export type SettingKey =
|
||||
| "modelScorer"
|
||||
| "modelTailoring"
|
||||
| "modelProjectSelection"
|
||||
| "llmProvider"
|
||||
| "llmBaseUrl"
|
||||
| "llmApiKey"
|
||||
| "pipelineWebhookUrl"
|
||||
| "jobCompleteWebhookUrl"
|
||||
| "resumeProjects"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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();
|
||||
});
|
||||
823
orchestrator/src/server/services/llm-service.ts
Normal file
823
orchestrator/src/server/services/llm-service.ts
Normal 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;
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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: {},
|
||||
|
||||
@ -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));
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user