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:
@@ -323,12 +323,14 @@ export async function refreshProfile(): Promise<ResumeProfile> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateOpenrouter(
|
export async function validateLlm(input: {
|
||||||
apiKey?: string,
|
provider?: string;
|
||||||
): Promise<ValidationResult> {
|
baseUrl?: string;
|
||||||
return fetchApi<ValidationResult>("/onboarding/validate/openrouter", {
|
apiKey?: string;
|
||||||
|
}): Promise<ValidationResult> {
|
||||||
|
return fetchApi<ValidationResult>("/onboarding/validate/llm", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ apiKey }),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +353,9 @@ export async function updateSettings(update: {
|
|||||||
modelScorer?: string | null;
|
modelScorer?: string | null;
|
||||||
modelTailoring?: string | null;
|
modelTailoring?: string | null;
|
||||||
modelProjectSelection?: string | null;
|
modelProjectSelection?: string | null;
|
||||||
|
llmProvider?: string | null;
|
||||||
|
llmBaseUrl?: string | null;
|
||||||
|
llmApiKey?: string | null;
|
||||||
pipelineWebhookUrl?: string | null;
|
pipelineWebhookUrl?: string | null;
|
||||||
jobCompleteWebhookUrl?: string | null;
|
jobCompleteWebhookUrl?: string | null;
|
||||||
resumeProjects?: ResumeProjectsSettings | null;
|
resumeProjects?: ResumeProjectsSettings | null;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { OnboardingGate } from "./OnboardingGate";
|
import { OnboardingGate } from "./OnboardingGate";
|
||||||
|
|
||||||
vi.mock("@client/api", () => ({
|
vi.mock("@client/api", () => ({
|
||||||
validateOpenrouter: vi.fn(),
|
validateLlm: vi.fn(),
|
||||||
validateRxresume: vi.fn(),
|
validateRxresume: vi.fn(),
|
||||||
validateResumeConfig: vi.fn(),
|
validateResumeConfig: vi.fn(),
|
||||||
updateSettings: 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", () => ({
|
vi.mock("@/components/ui/progress", () => ({
|
||||||
Progress: () => <div>Progress</div>,
|
Progress: () => <div>Progress</div>,
|
||||||
}));
|
}));
|
||||||
@@ -69,6 +87,8 @@ vi.mock("sonner", () => ({
|
|||||||
|
|
||||||
const settingsResponse = {
|
const settingsResponse = {
|
||||||
settings: {
|
settings: {
|
||||||
|
llmProvider: "openrouter",
|
||||||
|
llmApiKeyHint: null,
|
||||||
openrouterApiKeyHint: null,
|
openrouterApiKeyHint: null,
|
||||||
rxresumeEmail: "",
|
rxresumeEmail: "",
|
||||||
rxresumePasswordHint: null,
|
rxresumePasswordHint: null,
|
||||||
@@ -85,7 +105,7 @@ describe("OnboardingGate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders the gate once validations complete and any fail", async () => {
|
it("renders the gate once validations complete and any fail", async () => {
|
||||||
vi.mocked(api.validateOpenrouter).mockResolvedValue({
|
vi.mocked(api.validateLlm).mockResolvedValue({
|
||||||
valid: false,
|
valid: false,
|
||||||
message: "Invalid",
|
message: "Invalid",
|
||||||
});
|
});
|
||||||
@@ -100,12 +120,12 @@ describe("OnboardingGate", () => {
|
|||||||
|
|
||||||
render(<OnboardingGate />);
|
render(<OnboardingGate />);
|
||||||
|
|
||||||
await waitFor(() => expect(api.validateOpenrouter).toHaveBeenCalled());
|
await waitFor(() => expect(api.validateLlm).toHaveBeenCalled());
|
||||||
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
|
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides the gate when all validations succeed", async () => {
|
it("hides the gate when all validations succeed", async () => {
|
||||||
vi.mocked(api.validateOpenrouter).mockResolvedValue({
|
vi.mocked(api.validateLlm).mockResolvedValue({
|
||||||
valid: true,
|
valid: true,
|
||||||
message: null,
|
message: null,
|
||||||
});
|
});
|
||||||
@@ -120,7 +140,32 @@ describe("OnboardingGate", () => {
|
|||||||
|
|
||||||
render(<OnboardingGate />);
|
render(<OnboardingGate />);
|
||||||
|
|
||||||
await waitFor(() => expect(api.validateOpenrouter).toHaveBeenCalled());
|
await waitFor(() => expect(api.validateLlm).toHaveBeenCalled());
|
||||||
expect(screen.queryByText("Welcome to Job Ops")).not.toBeInTheDocument();
|
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 { useSettings } from "@client/hooks/useSettings";
|
||||||
import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection";
|
import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection";
|
||||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
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 type { ValidationResult } from "@shared/types";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
@@ -24,6 +30,13 @@ import {
|
|||||||
FieldTitle,
|
FieldTitle,
|
||||||
} from "@/components/ui/field";
|
} from "@/components/ui/field";
|
||||||
import { Progress } from "@/components/ui/progress";
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -36,11 +49,10 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
refreshSettings,
|
refreshSettings,
|
||||||
} = useSettings();
|
} = useSettings();
|
||||||
const [isSavingEnv, setIsSavingEnv] = useState(false);
|
const [isSavingEnv, setIsSavingEnv] = useState(false);
|
||||||
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false);
|
const [isValidatingLlm, setIsValidatingLlm] = useState(false);
|
||||||
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false);
|
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false);
|
||||||
const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false);
|
const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false);
|
||||||
const [openrouterValidation, setOpenrouterValidation] =
|
const [llmValidation, setLlmValidation] = useState<ValidationState>({
|
||||||
useState<ValidationState>({
|
|
||||||
valid: false,
|
valid: false,
|
||||||
message: null,
|
message: null,
|
||||||
checked: false,
|
checked: false,
|
||||||
@@ -60,29 +72,34 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
});
|
});
|
||||||
const [currentStep, setCurrentStep] = useState<string | null>(null);
|
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 [rxresumeEmail, setRxresumeEmail] = useState("");
|
||||||
const [rxresumePassword, setRxresumePassword] = useState("");
|
const [rxresumePassword, setRxresumePassword] = useState("");
|
||||||
const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState<
|
const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const validateOpenrouter = useCallback(async (apiKey?: string) => {
|
const validateLlm = useCallback(
|
||||||
setIsValidatingOpenrouter(true);
|
async (input: { provider?: string; baseUrl?: string; apiKey?: string }) => {
|
||||||
|
setIsValidatingLlm(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.validateOpenrouter(apiKey);
|
const result = await api.validateLlm(input);
|
||||||
setOpenrouterValidation({ ...result, checked: true });
|
setLlmValidation({ ...result, checked: true });
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "OpenRouter validation failed";
|
error instanceof Error ? error.message : "LLM validation failed";
|
||||||
const result = { valid: false, message };
|
const result = { valid: false, message };
|
||||||
setOpenrouterValidation({ ...result, checked: true });
|
setLlmValidation({ ...result, checked: true });
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
setIsValidatingOpenrouter(false);
|
setIsValidatingLlm(false);
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const validateRxresume = useCallback(
|
const validateRxresume = useCallback(
|
||||||
async (email?: string, password?: string) => {
|
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 hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim());
|
||||||
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
|
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
|
||||||
const hasCheckedValidations =
|
const hasCheckedValidations =
|
||||||
openrouterValidation.checked &&
|
(requiresLlmKey ? llmValidation.checked : true) &&
|
||||||
rxresumeValidation.checked &&
|
rxresumeValidation.checked &&
|
||||||
baseResumeValidation.checked;
|
baseResumeValidation.checked;
|
||||||
|
const llmValidated = requiresLlmKey ? llmValidation.valid : true;
|
||||||
const shouldOpen =
|
const shouldOpen =
|
||||||
Boolean(settings && !settingsLoading) &&
|
Boolean(settings && !settingsLoading) &&
|
||||||
hasCheckedValidations &&
|
hasCheckedValidations &&
|
||||||
!(
|
!(llmValidated && rxresumeValidation.valid && baseResumeValidation.valid);
|
||||||
openrouterValidation.valid &&
|
|
||||||
rxresumeValidation.valid &&
|
|
||||||
baseResumeValidation.valid
|
|
||||||
);
|
|
||||||
|
|
||||||
const openrouterCurrent = settings?.openrouterApiKeyHint
|
const llmKeyCurrent = llmKeyHint ? formatSecretHint(llmKeyHint) : undefined;
|
||||||
? formatSecretHint(settings.openrouterApiKeyHint)
|
|
||||||
: undefined;
|
|
||||||
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
|
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
|
||||||
? settings.rxresumeEmail
|
? settings.rxresumeEmail
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -152,16 +176,32 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
setRxresumeBaseResumeId(settings.rxresumeBaseResumeId || null);
|
setRxresumeBaseResumeId(settings.rxresumeBaseResumeId || null);
|
||||||
|
if (!llmProvider && settings.llmProvider) {
|
||||||
|
setLlmProvider(settings.llmProvider);
|
||||||
}
|
}
|
||||||
}, [settings]);
|
if (!llmBaseUrl && settings.llmBaseUrl) {
|
||||||
|
setLlmBaseUrl(settings.llmBaseUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [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(
|
const steps = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: "openrouter",
|
id: "llm",
|
||||||
label: "Connect AI",
|
label: "LLM Provider",
|
||||||
subtitle: "OpenRouter key",
|
subtitle: "Provider + credentials",
|
||||||
complete: openrouterValidation.valid,
|
complete: llmValidated,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -179,11 +219,7 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
disabled: !rxresumeValidation.valid,
|
disabled: !rxresumeValidation.valid,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[llmValidated, rxresumeValidation.valid, baseResumeValidation.valid],
|
||||||
openrouterValidation.valid,
|
|
||||||
rxresumeValidation.valid,
|
|
||||||
baseResumeValidation.valid,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id;
|
const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id;
|
||||||
@@ -197,11 +233,21 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
|
|
||||||
const runAllValidations = useCallback(async () => {
|
const runAllValidations = useCallback(async () => {
|
||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
const results = await Promise.allSettled([
|
const validations: Promise<ValidationResult>[] = [];
|
||||||
validateOpenrouter(),
|
if (requiresLlmKey) {
|
||||||
validateRxresume(),
|
validations.push(
|
||||||
validateBaseResume(),
|
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");
|
const failed = results.find((result) => result.status === "rejected");
|
||||||
if (failed) {
|
if (failed) {
|
||||||
@@ -210,21 +256,30 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
reason instanceof Error ? reason.message : "Validation checks failed";
|
reason instanceof Error ? reason.message : "Validation checks failed";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
}, [settings, validateOpenrouter, validateRxresume, validateBaseResume]);
|
}, [
|
||||||
|
settings,
|
||||||
|
requiresLlmKey,
|
||||||
|
validateLlm,
|
||||||
|
validateRxresume,
|
||||||
|
validateBaseResume,
|
||||||
|
normalizedProvider,
|
||||||
|
llmBaseUrl,
|
||||||
|
llmApiKey,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settings || settingsLoading) return;
|
if (!settings || settingsLoading) return;
|
||||||
if (
|
const needsValidation =
|
||||||
openrouterValidation.checked ||
|
(requiresLlmKey ? !llmValidation.checked : false) ||
|
||||||
rxresumeValidation.checked ||
|
!rxresumeValidation.checked ||
|
||||||
baseResumeValidation.checked
|
!baseResumeValidation.checked;
|
||||||
)
|
if (!needsValidation) return;
|
||||||
return;
|
|
||||||
void runAllValidations();
|
void runAllValidations();
|
||||||
}, [
|
}, [
|
||||||
settings,
|
settings,
|
||||||
settingsLoading,
|
settingsLoading,
|
||||||
openrouterValidation.checked,
|
requiresLlmKey,
|
||||||
|
llmValidation.checked,
|
||||||
rxresumeValidation.checked,
|
rxresumeValidation.checked,
|
||||||
baseResumeValidation.checked,
|
baseResumeValidation.checked,
|
||||||
runAllValidations,
|
runAllValidations,
|
||||||
@@ -244,34 +299,51 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveOpenrouter = async (): Promise<boolean> => {
|
const handleSaveLlm = async (): Promise<boolean> => {
|
||||||
const openrouterValue = openrouterApiKey.trim();
|
const apiKeyValue = llmApiKey.trim();
|
||||||
if (!openrouterValue && !hasOpenrouterKey) {
|
const baseUrlValue = llmBaseUrl.trim();
|
||||||
toast.info("Add your OpenRouter API key to continue");
|
|
||||||
|
if (requiresLlmKey && !apiKeyValue && !hasLlmKey) {
|
||||||
|
toast.info("Add your LLM API key to continue");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
if (!validation.valid) {
|
||||||
toast.error(validation.message || "OpenRouter validation failed");
|
toast.error(validation.message || "LLM validation failed");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (openrouterValue) {
|
const update: {
|
||||||
setIsSavingEnv(true);
|
llmProvider?: string;
|
||||||
await api.updateSettings({ openrouterApiKey: openrouterValue });
|
llmBaseUrl?: string | null;
|
||||||
await refreshSettings();
|
llmApiKey?: string;
|
||||||
setOpenrouterApiKey("");
|
} = {
|
||||||
|
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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error ? error.message : "Failed to save LLM settings";
|
||||||
? error.message
|
|
||||||
: "Failed to save OpenRouter key";
|
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -368,13 +440,13 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
const isBusy =
|
const isBusy =
|
||||||
isSavingEnv ||
|
isSavingEnv ||
|
||||||
settingsLoading ||
|
settingsLoading ||
|
||||||
isValidatingOpenrouter ||
|
isValidatingLlm ||
|
||||||
isValidatingRxresume ||
|
isValidatingRxresume ||
|
||||||
isValidatingBaseResume;
|
isValidatingBaseResume;
|
||||||
const canGoBack = stepIndex > 0;
|
const canGoBack = stepIndex > 0;
|
||||||
const primaryLabel =
|
const primaryLabel =
|
||||||
currentStep === "openrouter"
|
currentStep === "llm"
|
||||||
? openrouterValidation.valid
|
? llmValidated
|
||||||
? "Revalidate"
|
? "Revalidate"
|
||||||
: "Validate"
|
: "Validate"
|
||||||
: currentStep === "rxresume"
|
: currentStep === "rxresume"
|
||||||
@@ -389,8 +461,8 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
|
|
||||||
const handlePrimaryAction = async () => {
|
const handlePrimaryAction = async () => {
|
||||||
if (!currentStep) return;
|
if (!currentStep) return;
|
||||||
if (currentStep === "openrouter") {
|
if (currentStep === "llm") {
|
||||||
await handleSaveOpenrouter();
|
await handleSaveLlm();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentStep === "rxresume") {
|
if (currentStep === "rxresume") {
|
||||||
@@ -475,26 +547,68 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="openrouter" className="space-y-4 pt-6">
|
<TabsContent value="llm" className="space-y-4 pt-6">
|
||||||
<div>
|
<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">
|
<p className="text-xs text-muted-foreground">
|
||||||
Used for job scoring, summaries, and tailoring.
|
Used for job scoring, summaries, and tailoring.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
<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
|
<SettingsInput
|
||||||
label="OpenRouter API key"
|
label="LLM base URL"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
name: "openrouterApiKey",
|
name: "llmBaseUrl",
|
||||||
value: openrouterApiKey,
|
value: llmBaseUrl,
|
||||||
onChange: (event) => setOpenrouterApiKey(event.target.value),
|
onChange: (event) => setLlmBaseUrl(event.target.value),
|
||||||
}}
|
}}
|
||||||
type="password"
|
placeholder={providerConfig.baseUrlPlaceholder}
|
||||||
placeholder="sk-or-v1..."
|
helper={providerConfig.baseUrlHelper}
|
||||||
current={openrouterCurrent}
|
current={settings?.llmBaseUrl || "—"}
|
||||||
helper="Create a key at openrouter.ai"
|
|
||||||
disabled={isSavingEnv}
|
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>
|
||||||
|
|
||||||
<TabsContent value="rxresume" className="space-y-4 pt-6">
|
<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");
|
toast.success("AI Summary & Projects generated");
|
||||||
await onUpdate();
|
await onUpdate();
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
toast.error("AI summarization failed");
|
const message =
|
||||||
|
error instanceof Error ? error.message : "AI summarization failed";
|
||||||
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSummarizing(false);
|
setIsSummarizing(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,8 +141,10 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
|||||||
toast.success("Draft generated with AI", {
|
toast.success("Draft generated with AI", {
|
||||||
description: "Review and edit before finalizing.",
|
description: "Review and edit before finalizing.",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error("Failed to generate AI draft");
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to generate AI draft";
|
||||||
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import type { AppSettings } from "@shared/types";
|
import type { AppSettings } from "@shared/types";
|
||||||
import {
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
fireEvent,
|
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
waitFor,
|
|
||||||
within,
|
|
||||||
} from "@testing-library/react";
|
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -37,6 +31,12 @@ const baseSettings: AppSettings = {
|
|||||||
overrideModelTailoring: null,
|
overrideModelTailoring: null,
|
||||||
modelProjectSelection: "google/gemini-3-flash-preview",
|
modelProjectSelection: "google/gemini-3-flash-preview",
|
||||||
overrideModelProjectSelection: null,
|
overrideModelProjectSelection: null,
|
||||||
|
llmProvider: "openrouter",
|
||||||
|
defaultLlmProvider: "openrouter",
|
||||||
|
overrideLlmProvider: null,
|
||||||
|
llmBaseUrl: "https://openrouter.ai",
|
||||||
|
defaultLlmBaseUrl: "https://openrouter.ai",
|
||||||
|
overrideLlmBaseUrl: null,
|
||||||
pipelineWebhookUrl: "",
|
pipelineWebhookUrl: "",
|
||||||
defaultPipelineWebhookUrl: "",
|
defaultPipelineWebhookUrl: "",
|
||||||
overridePipelineWebhookUrl: null,
|
overridePipelineWebhookUrl: null,
|
||||||
@@ -100,6 +100,7 @@ const baseSettings: AppSettings = {
|
|||||||
showSponsorInfo: true,
|
showSponsorInfo: true,
|
||||||
defaultShowSponsorInfo: true,
|
defaultShowSponsorInfo: true,
|
||||||
overrideShowSponsorInfo: null,
|
overrideShowSponsorInfo: null,
|
||||||
|
llmApiKeyHint: null,
|
||||||
openrouterApiKeyHint: null,
|
openrouterApiKeyHint: null,
|
||||||
rxresumeEmail: "",
|
rxresumeEmail: "",
|
||||||
rxresumePasswordHint: null,
|
rxresumePasswordHint: null,
|
||||||
@@ -138,10 +139,7 @@ describe("SettingsPage", () => {
|
|||||||
const modelTrigger = await screen.findByRole("button", { name: /model/i });
|
const modelTrigger = await screen.findByRole("button", { name: /model/i });
|
||||||
fireEvent.click(modelTrigger);
|
fireEvent.click(modelTrigger);
|
||||||
|
|
||||||
const modelField =
|
const modelInput = screen.getByLabelText(/default model/i);
|
||||||
screen.getByText("Override model").parentElement ??
|
|
||||||
screen.getByRole("main");
|
|
||||||
const modelInput = within(modelField).getByRole("textbox");
|
|
||||||
fireEvent.change(modelInput, { target: { value: " gpt-4 " } });
|
fireEvent.change(modelInput, { target: { value: " gpt-4 " } });
|
||||||
|
|
||||||
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||||
@@ -166,10 +164,7 @@ describe("SettingsPage", () => {
|
|||||||
const modelTrigger = await screen.findByRole("button", { name: /model/i });
|
const modelTrigger = await screen.findByRole("button", { name: /model/i });
|
||||||
fireEvent.click(modelTrigger);
|
fireEvent.click(modelTrigger);
|
||||||
|
|
||||||
const modelField =
|
const modelInput = screen.getByLabelText(/default model/i);
|
||||||
screen.getByText("Override model").parentElement ??
|
|
||||||
screen.getByRole("main");
|
|
||||||
const modelInput = within(modelField).getByRole("textbox");
|
|
||||||
|
|
||||||
// Change to > 200 chars
|
// Change to > 200 chars
|
||||||
fireEvent.change(modelInput, { target: { value: "a".repeat(201) } });
|
fireEvent.change(modelInput, { target: { value: "a".repeat(201) } });
|
||||||
@@ -229,7 +224,7 @@ describe("SettingsPage", () => {
|
|||||||
|
|
||||||
const modelTrigger = await screen.findByRole("button", { name: /model/i });
|
const modelTrigger = await screen.findByRole("button", { name: /model/i });
|
||||||
fireEvent.click(modelTrigger);
|
fireEvent.click(modelTrigger);
|
||||||
const modelInput = screen.getByLabelText(/override model/i);
|
const modelInput = screen.getByLabelText(/default model/i);
|
||||||
fireEvent.change(modelInput, { target: { value: "new-model" } });
|
fireEvent.change(modelInput, { target: { value: "new-model" } });
|
||||||
expect(saveButton).toBeEnabled();
|
expect(saveButton).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import { ReactiveResumeSection } from "@client/pages/settings/components/Reactiv
|
|||||||
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection";
|
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection";
|
||||||
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection";
|
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection";
|
||||||
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection";
|
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 { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
type UpdateSettingsInput,
|
type UpdateSettingsInput,
|
||||||
@@ -25,7 +29,7 @@ import type {
|
|||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useState } 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 { toast } from "sonner";
|
||||||
import { Accordion } from "@/components/ui/accordion";
|
import { Accordion } from "@/components/ui/accordion";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -36,6 +40,9 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
|||||||
modelScorer: "",
|
modelScorer: "",
|
||||||
modelTailoring: "",
|
modelTailoring: "",
|
||||||
modelProjectSelection: "",
|
modelProjectSelection: "",
|
||||||
|
llmProvider: null,
|
||||||
|
llmBaseUrl: "",
|
||||||
|
llmApiKey: "",
|
||||||
pipelineWebhookUrl: "",
|
pipelineWebhookUrl: "",
|
||||||
jobCompleteWebhookUrl: "",
|
jobCompleteWebhookUrl: "",
|
||||||
resumeProjects: null,
|
resumeProjects: null,
|
||||||
@@ -61,11 +68,20 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
|||||||
enableBasicAuth: false,
|
enableBasicAuth: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LlmProviderValue = LlmProviderId | null;
|
||||||
|
|
||||||
|
const normalizeLlmProviderValue = (
|
||||||
|
value: string | null | undefined,
|
||||||
|
): LlmProviderValue => (value ? normalizeLlmProvider(value) : null);
|
||||||
|
|
||||||
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||||
model: null,
|
model: null,
|
||||||
modelScorer: null,
|
modelScorer: null,
|
||||||
modelTailoring: null,
|
modelTailoring: null,
|
||||||
modelProjectSelection: null,
|
modelProjectSelection: null,
|
||||||
|
llmProvider: null,
|
||||||
|
llmBaseUrl: null,
|
||||||
|
llmApiKey: null,
|
||||||
pipelineWebhookUrl: null,
|
pipelineWebhookUrl: null,
|
||||||
jobCompleteWebhookUrl: null,
|
jobCompleteWebhookUrl: null,
|
||||||
resumeProjects: null,
|
resumeProjects: null,
|
||||||
@@ -96,6 +112,9 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
|||||||
modelScorer: data.overrideModelScorer ?? "",
|
modelScorer: data.overrideModelScorer ?? "",
|
||||||
modelTailoring: data.overrideModelTailoring ?? "",
|
modelTailoring: data.overrideModelTailoring ?? "",
|
||||||
modelProjectSelection: data.overrideModelProjectSelection ?? "",
|
modelProjectSelection: data.overrideModelProjectSelection ?? "",
|
||||||
|
llmProvider: normalizeLlmProviderValue(data.overrideLlmProvider),
|
||||||
|
llmBaseUrl: data.overrideLlmBaseUrl ?? "",
|
||||||
|
llmApiKey: "",
|
||||||
pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "",
|
pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "",
|
||||||
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "",
|
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "",
|
||||||
resumeProjects: data.resumeProjects,
|
resumeProjects: data.resumeProjects,
|
||||||
@@ -207,6 +226,10 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
|||||||
scorer: settings?.modelScorer ?? "",
|
scorer: settings?.modelScorer ?? "",
|
||||||
tailoring: settings?.modelTailoring ?? "",
|
tailoring: settings?.modelTailoring ?? "",
|
||||||
projectSelection: settings?.modelProjectSelection ?? "",
|
projectSelection: settings?.modelProjectSelection ?? "",
|
||||||
|
llmProvider: settings?.llmProvider ?? "",
|
||||||
|
llmBaseUrl: settings?.llmBaseUrl ?? "",
|
||||||
|
llmApiKeyHint:
|
||||||
|
settings?.llmApiKeyHint ?? settings?.openrouterApiKeyHint ?? null,
|
||||||
},
|
},
|
||||||
pipelineWebhook: {
|
pipelineWebhook: {
|
||||||
effective: settings?.pipelineWebhookUrl ?? "",
|
effective: settings?.pipelineWebhookUrl ?? "",
|
||||||
@@ -297,7 +320,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
const methods = useForm<UpdateSettingsInput>({
|
const methods = useForm<UpdateSettingsInput>({
|
||||||
resolver: zodResolver(updateSettingsSchema),
|
resolver: zodResolver(
|
||||||
|
updateSettingsSchema,
|
||||||
|
) as Resolver<UpdateSettingsInput>,
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
defaultValues: DEFAULT_FORM_VALUES,
|
defaultValues: DEFAULT_FORM_VALUES,
|
||||||
});
|
});
|
||||||
@@ -482,6 +507,19 @@ export const SettingsPage: React.FC = () => {
|
|||||||
if (value !== undefined) envPayload.openrouterApiKey = value;
|
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) {
|
if (dirtyFields.rxresumePassword) {
|
||||||
const value = normalizePrivateInput(data.rxresumePassword);
|
const value = normalizePrivateInput(data.rxresumePassword);
|
||||||
if (value !== undefined) envPayload.rxresumePassword = value;
|
if (value !== undefined) envPayload.rxresumePassword = value;
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ describe("EnvironmentSettingsSection", () => {
|
|||||||
expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument();
|
||||||
expect(screen.getByDisplayValue("visa@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(/pass\*{8}/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument();
|
expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument();
|
||||||
expect(screen.getByText("Not set")).toBeInTheDocument();
|
expect(screen.getByText("Not set")).toBeInTheDocument();
|
||||||
@@ -63,7 +62,6 @@ describe("EnvironmentSettingsSection", () => {
|
|||||||
expect(screen.getByDisplayValue("admin")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("admin")).toBeInTheDocument();
|
||||||
|
|
||||||
// Sections
|
// Sections
|
||||||
expect(screen.getByText("External Services")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Service Accounts")).toBeInTheDocument();
|
expect(screen.getByText("Service Accounts")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Security")).toBeInTheDocument();
|
expect(screen.getByText("Security")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,26 +38,6 @@ export const EnvironmentSettingsSection: React.FC<
|
|||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="pb-4">
|
<AccordionContent className="pb-4">
|
||||||
<div className="space-y-8">
|
<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 */}
|
{/* Service Accounts */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">
|
<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 { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||||
import type { ModelValues } from "@client/pages/settings/types";
|
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 { UpdateSettingsInput } from "@shared/settings-schema";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
type ModelSettingsSectionProps = {
|
type ModelSettingsSectionProps = {
|
||||||
@@ -27,12 +39,37 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
scorer,
|
scorer,
|
||||||
tailoring,
|
tailoring,
|
||||||
projectSelection,
|
projectSelection,
|
||||||
|
llmProvider,
|
||||||
|
llmBaseUrl,
|
||||||
|
llmApiKeyHint,
|
||||||
} = values;
|
} = values;
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useFormContext<UpdateSettingsInput>();
|
} = 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 (
|
return (
|
||||||
<AccordionItem value="model" className="border rounded-lg px-4">
|
<AccordionItem value="model" className="border rounded-lg px-4">
|
||||||
<AccordionTrigger className="hover:no-underline py-4">
|
<AccordionTrigger className="hover:no-underline py-4">
|
||||||
@@ -40,14 +77,82 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="pb-4">
|
<AccordionContent className="pb-4">
|
||||||
<div className="space-y-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
|
<SettingsInput
|
||||||
label="Override model"
|
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="Default model"
|
||||||
inputProps={register("model")}
|
inputProps={register("model")}
|
||||||
placeholder={defaultModel || "google/gemini-3-flash-preview"}
|
placeholder={defaultModel || "google/gemini-3-flash-preview"}
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
error={errors.model?.message as string | undefined}
|
error={errors.model?.message as string | undefined}
|
||||||
helper="Leave blank to use the default from server env (`MODEL`)."
|
helper="Leave blank to use the default from server env (`MODEL`)."
|
||||||
current={effective || "—"}
|
current={effectiveDefaultModel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -62,7 +167,7 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
placeholder={effective || "inherit"}
|
placeholder={effective || "inherit"}
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
error={errors.modelScorer?.message as string | undefined}
|
error={errors.modelScorer?.message as string | undefined}
|
||||||
current={scorer || effective || "—"}
|
current={scoringModel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
@@ -71,7 +176,7 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
placeholder={effective || "inherit"}
|
placeholder={effective || "inherit"}
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
error={errors.modelTailoring?.message as string | undefined}
|
error={errors.modelTailoring?.message as string | undefined}
|
||||||
current={tailoring || effective || "—"}
|
current={tailoringModel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
@@ -82,26 +187,47 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
error={
|
error={
|
||||||
errors.modelProjectSelection?.message as string | undefined
|
errors.modelProjectSelection?.message as string | undefined
|
||||||
}
|
}
|
||||||
current={projectSelection || effective || "—"}
|
current={projectSelectionModel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
<div className="space-y-3 text-sm">
|
||||||
<div>
|
<div className="text-xs text-muted-foreground">Resolved config</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="grid gap-x-4 gap-y-2 text-xs sm:grid-cols-[160px_1fr]">
|
||||||
Global Effective
|
<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>
|
||||||
<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>
|
<div className="text-muted-foreground">Project selection</div>
|
||||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
<div className="font-mono">
|
||||||
<div className="break-words font-mono text-xs">
|
{projectSelectionModel === effectiveDefaultModel
|
||||||
{defaultModel || "—"}
|
? "inherits"
|
||||||
|
: projectSelectionModel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export type ModelValues = EffectiveDefault<string> & {
|
|||||||
scorer: string;
|
scorer: string;
|
||||||
tailoring: string;
|
tailoring: string;
|
||||||
projectSelection: string;
|
projectSelection: string;
|
||||||
|
llmProvider: string;
|
||||||
|
llmBaseUrl: string;
|
||||||
|
llmApiKeyHint: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WebhookValues = EffectiveDefault<string>;
|
export type WebhookValues = EffectiveDefault<string>;
|
||||||
|
|||||||
@@ -18,3 +18,74 @@ export function resumeProjectsEqual(
|
|||||||
|
|
||||||
export const formatSecretHint = (hint: string | null) =>
|
export const formatSecretHint = (hint: string | null) =>
|
||||||
hint ? `${hint}********` : "Not set";
|
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 { getSetting } from "@server/repositories/settings.js";
|
||||||
|
import { LlmService } from "@server/services/llm-service.js";
|
||||||
import { RxResumeClient } from "@server/services/rxresume-client.js";
|
import { RxResumeClient } from "@server/services/rxresume-client.js";
|
||||||
import {
|
import {
|
||||||
getResume,
|
getResume,
|
||||||
@@ -14,56 +15,17 @@ type ValidationResponse = {
|
|||||||
message: string | null;
|
message: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function validateOpenrouter(
|
async function validateLlm(options: {
|
||||||
apiKey?: string | null,
|
apiKey?: string | null;
|
||||||
): Promise<ValidationResponse> {
|
provider?: string | null;
|
||||||
const key = apiKey?.trim() || process.env.OPENROUTER_API_KEY || "";
|
baseUrl?: string | null;
|
||||||
if (!key) {
|
}): Promise<ValidationResponse> {
|
||||||
return { valid: false, message: "OpenRouter API key is missing." };
|
const llm = new LlmService({
|
||||||
}
|
apiKey: options.apiKey,
|
||||||
|
provider: options.provider ?? undefined,
|
||||||
try {
|
baseUrl: options.baseUrl ?? undefined,
|
||||||
const response = await fetch("https://openrouter.ai/api/v1/key", {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${key}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
return llm.validateCredentials();
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,11 +126,22 @@ onboardingRouter.post(
|
|||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const apiKey =
|
const apiKey =
|
||||||
typeof req.body?.apiKey === "string" ? req.body.apiKey : undefined;
|
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 });
|
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(
|
onboardingRouter.post(
|
||||||
"/validate/rxresume",
|
"/validate/rxresume",
|
||||||
async (req: Request, res: Response) => {
|
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) {
|
if ("pipelineWebhookUrl" in input) {
|
||||||
promises.push(
|
promises.push(
|
||||||
settingsRepo.setSetting(
|
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) {
|
if ("rxresumeEmail" in input) {
|
||||||
const value = normalizeEnvInput(input.rxresumeEmail);
|
const value = normalizeEnvInput(input.rxresumeEmail);
|
||||||
promises.push(
|
promises.push(
|
||||||
|
|||||||
@@ -506,6 +506,11 @@ export async function summarizeJob(
|
|||||||
tailoredSummary = tailoringResult.data.summary;
|
tailoredSummary = tailoringResult.data.summary;
|
||||||
tailoredHeadline = tailoringResult.data.headline;
|
tailoredHeadline = tailoringResult.data.headline;
|
||||||
tailoredSkills = JSON.stringify(tailoringResult.data.skills);
|
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"
|
| "modelScorer"
|
||||||
| "modelTailoring"
|
| "modelTailoring"
|
||||||
| "modelProjectSelection"
|
| "modelProjectSelection"
|
||||||
|
| "llmProvider"
|
||||||
|
| "llmBaseUrl"
|
||||||
|
| "llmApiKey"
|
||||||
| "pipelineWebhookUrl"
|
| "pipelineWebhookUrl"
|
||||||
| "jobCompleteWebhookUrl"
|
| "jobCompleteWebhookUrl"
|
||||||
| "resumeProjects"
|
| "resumeProjects"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import * as settingsRepo from "@server/repositories/settings.js";
|
|||||||
const envDefaults: Record<string, string | undefined> = { ...process.env };
|
const envDefaults: Record<string, string | undefined> = { ...process.env };
|
||||||
|
|
||||||
const readableStringConfig: { settingKey: SettingKey; envKey: string }[] = [
|
const readableStringConfig: { settingKey: SettingKey; envKey: string }[] = [
|
||||||
|
{ settingKey: "llmProvider", envKey: "LLM_PROVIDER" },
|
||||||
|
{ settingKey: "llmBaseUrl", envKey: "LLM_BASE_URL" },
|
||||||
{ settingKey: "rxresumeEmail", envKey: "RXRESUME_EMAIL" },
|
{ settingKey: "rxresumeEmail", envKey: "RXRESUME_EMAIL" },
|
||||||
{ settingKey: "ukvisajobsEmail", envKey: "UKVISAJOBS_EMAIL" },
|
{ settingKey: "ukvisajobsEmail", envKey: "UKVISAJOBS_EMAIL" },
|
||||||
{ settingKey: "basicAuthUser", envKey: "BASIC_AUTH_USER" },
|
{ settingKey: "basicAuthUser", envKey: "BASIC_AUTH_USER" },
|
||||||
@@ -20,6 +22,11 @@ const privateStringConfig: {
|
|||||||
envKey: string;
|
envKey: string;
|
||||||
hintKey: string;
|
hintKey: string;
|
||||||
}[] = [
|
}[] = [
|
||||||
|
{
|
||||||
|
settingKey: "llmApiKey",
|
||||||
|
envKey: "LLM_API_KEY",
|
||||||
|
hintKey: "llmApiKeyHint",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
settingKey: "openrouterApiKey",
|
settingKey: "openrouterApiKey",
|
||||||
envKey: "OPENROUTER_API_KEY",
|
envKey: "OPENROUTER_API_KEY",
|
||||||
|
|||||||
+111
-25
@@ -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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
callOpenRouter,
|
|
||||||
type JsonSchemaDefinition,
|
type JsonSchemaDefinition,
|
||||||
|
LlmService,
|
||||||
parseJsonContent,
|
parseJsonContent,
|
||||||
} from "./openrouter.js";
|
} from "./llm-service.js";
|
||||||
|
|
||||||
// Mock fetch globally
|
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
const testSchema: JsonSchemaDefinition = {
|
const testSchema: JsonSchemaDefinition = {
|
||||||
@@ -25,22 +24,27 @@ const testSchema: JsonSchemaDefinition = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("callOpenRouter", () => {
|
describe("LlmService", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
process.env.LLM_PROVIDER = "openrouter";
|
||||||
process.env.OPENROUTER_API_KEY = "test-api-key";
|
process.env.OPENROUTER_API_KEY = "test-api-key";
|
||||||
|
delete process.env.LLM_API_KEY;
|
||||||
global.fetch = vi.fn();
|
global.fetch = vi.fn();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
delete process.env.LLM_PROVIDER;
|
||||||
delete process.env.OPENROUTER_API_KEY;
|
delete process.env.OPENROUTER_API_KEY;
|
||||||
|
delete process.env.LLM_API_KEY;
|
||||||
global.fetch = originalFetch;
|
global.fetch = originalFetch;
|
||||||
vi.restoreAllMocks();
|
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;
|
delete process.env.OPENROUTER_API_KEY;
|
||||||
|
|
||||||
const result = await callOpenRouter({
|
const llm = new LlmService();
|
||||||
|
const result = await llm.callJson({
|
||||||
model: "test-model",
|
model: "test-model",
|
||||||
messages: [{ role: "user", content: "test" }],
|
messages: [{ role: "user", content: "test" }],
|
||||||
jsonSchema: testSchema,
|
jsonSchema: testSchema,
|
||||||
@@ -48,11 +52,11 @@ describe("callOpenRouter", () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (!result.success) {
|
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({
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -64,7 +68,8 @@ describe("callOpenRouter", () => {
|
|||||||
}),
|
}),
|
||||||
} as Response);
|
} 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",
|
model: "test-model",
|
||||||
messages: [{ role: "user", content: "test" }],
|
messages: [{ role: "user", content: "test" }],
|
||||||
jsonSchema: testSchema,
|
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({
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 500,
|
status: 500,
|
||||||
text: async () => "Internal Server Error",
|
text: async () => "Internal Server Error",
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const result = await callOpenRouter({
|
const llm = new LlmService();
|
||||||
|
const result = await llm.callJson({
|
||||||
model: "test-model",
|
model: "test-model",
|
||||||
messages: [{ role: "user", content: "test" }],
|
messages: [{ role: "user", content: "test" }],
|
||||||
jsonSchema: testSchema,
|
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({
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -104,7 +110,8 @@ describe("callOpenRouter", () => {
|
|||||||
}),
|
}),
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const result = await callOpenRouter({
|
const llm = new LlmService();
|
||||||
|
const result = await llm.callJson({
|
||||||
model: "test-model",
|
model: "test-model",
|
||||||
messages: [{ role: "user", content: "test" }],
|
messages: [{ role: "user", content: "test" }],
|
||||||
jsonSchema: testSchema,
|
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({
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -124,7 +131,8 @@ describe("callOpenRouter", () => {
|
|||||||
}),
|
}),
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
await callOpenRouter({
|
const llm = new LlmService();
|
||||||
|
await llm.callJson({
|
||||||
model: "test-model",
|
model: "test-model",
|
||||||
messages: [{ role: "user", content: "test prompt" }],
|
messages: [{ role: "user", content: "test prompt" }],
|
||||||
jsonSchema: testSchema,
|
jsonSchema: testSchema,
|
||||||
@@ -136,9 +144,33 @@ describe("callOpenRouter", () => {
|
|||||||
expect(body.response_format.type).toBe("json_schema");
|
expect(body.response_format.type).toBe("json_schema");
|
||||||
expect(body.response_format.json_schema.name).toBe("test_schema");
|
expect(body.response_format.json_schema.name).toBe("test_schema");
|
||||||
expect(body.response_format.json_schema.strict).toBe(true);
|
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;
|
let callCount = 0;
|
||||||
vi.mocked(global.fetch).mockImplementation(async () => {
|
vi.mocked(global.fetch).mockImplementation(async () => {
|
||||||
callCount++;
|
callCount++;
|
||||||
@@ -160,17 +192,17 @@ describe("callOpenRouter", () => {
|
|||||||
} as Response;
|
} as Response;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Suppress console output during test
|
|
||||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
vi.spyOn(console, "error").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",
|
model: "test-model",
|
||||||
messages: [{ role: "user", content: "test" }],
|
messages: [{ role: "user", content: "test" }],
|
||||||
jsonSchema: testSchema,
|
jsonSchema: testSchema,
|
||||||
maxRetries: 2,
|
maxRetries: 2,
|
||||||
retryDelayMs: 10, // Fast retries for tests
|
retryDelayMs: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
@@ -179,36 +211,90 @@ describe("callOpenRouter", () => {
|
|||||||
}
|
}
|
||||||
expect(callCount).toBe(3);
|
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", () => {
|
describe("parseJsonContent", () => {
|
||||||
it("should parse clean JSON", () => {
|
it("parses clean JSON", () => {
|
||||||
const result = parseJsonContent<{ foo: string }>('{"foo": "bar"}');
|
const result = parseJsonContent<{ foo: string }>('{"foo": "bar"}');
|
||||||
expect(result.foo).toBe("bar");
|
expect(result.foo).toBe("bar");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle markdown code fences", () => {
|
it("handles markdown code fences", () => {
|
||||||
const result = parseJsonContent<{ foo: string }>(
|
const result = parseJsonContent<{ foo: string }>(
|
||||||
'```json\n{"foo": "bar"}\n```',
|
'```json\n{"foo": "bar"}\n```',
|
||||||
);
|
);
|
||||||
expect(result.foo).toBe("bar");
|
expect(result.foo).toBe("bar");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle json without language specifier", () => {
|
it("handles json without language specifier", () => {
|
||||||
const result = parseJsonContent<{ foo: string }>(
|
const result = parseJsonContent<{ foo: string }>(
|
||||||
'```\n{"foo": "bar"}\n```',
|
'```\n{"foo": "bar"}\n```',
|
||||||
);
|
);
|
||||||
expect(result.foo).toBe("bar");
|
expect(result.foo).toBe("bar");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should extract JSON from surrounding text", () => {
|
it("extracts JSON from surrounding text", () => {
|
||||||
const result = parseJsonContent<{ foo: string }>(
|
const result = parseJsonContent<{ foo: string }>(
|
||||||
'Here is the result: {"foo": "bar"} as requested.',
|
'Here is the result: {"foo": "bar"} as requested.',
|
||||||
);
|
);
|
||||||
expect(result.foo).toBe("bar");
|
expect(result.foo).toBe("bar");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw on completely invalid content", () => {
|
it("throws on completely invalid content", () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
expect(() => parseJsonContent("not json at all")).toThrow();
|
expect(() => parseJsonContent("not json at all")).toThrow();
|
||||||
});
|
});
|
||||||
@@ -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");
|
const result = await inferManualJobDetails("JD text");
|
||||||
|
|
||||||
expect(result.job).toEqual({});
|
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();
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import type { ManualJobDraft } from "../../shared/types.js";
|
import type { ManualJobDraft } from "../../shared/types.js";
|
||||||
import { getSetting } from "../repositories/settings.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 {
|
export interface ManualJobInferenceResult {
|
||||||
job: ManualJobDraft;
|
job: ManualJobDraft;
|
||||||
@@ -92,25 +92,25 @@ const MANUAL_JOB_SCHEMA: JsonSchemaDefinition = {
|
|||||||
export async function inferManualJobDetails(
|
export async function inferManualJobDetails(
|
||||||
jobDescription: string,
|
jobDescription: string,
|
||||||
): Promise<ManualJobInferenceResult> {
|
): 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 overrideModel = await getSetting("model");
|
||||||
const model =
|
const model =
|
||||||
overrideModel || process.env.MODEL || "google/gemini-3-flash-preview";
|
overrideModel || process.env.MODEL || "google/gemini-3-flash-preview";
|
||||||
const prompt = buildInferencePrompt(jobDescription);
|
const prompt = buildInferencePrompt(jobDescription);
|
||||||
|
|
||||||
const result = await callOpenRouter<ManualJobApiResponse>({
|
const llm = new LlmService();
|
||||||
|
const result = await llm.callJson<ManualJobApiResponse>({
|
||||||
model,
|
model,
|
||||||
messages: [{ role: "user", content: prompt }],
|
messages: [{ role: "user", content: prompt }],
|
||||||
jsonSchema: MANUAL_JOB_SCHEMA,
|
jsonSchema: MANUAL_JOB_SCHEMA,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
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);
|
console.warn("Manual job inference failed:", result.error);
|
||||||
return {
|
return {
|
||||||
job: {},
|
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 { 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";
|
import type { ResumeProjectSelectionItem } from "./resumeProjects.js";
|
||||||
|
|
||||||
/** JSON schema for project selection response */
|
/** 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));
|
const eligibleIds = new Set(args.eligibleProjects.map((p) => p.id));
|
||||||
if (eligibleIds.size === 0) return [];
|
if (eligibleIds.size === 0) return [];
|
||||||
|
|
||||||
if (!process.env.OPENROUTER_API_KEY) {
|
|
||||||
return fallbackPickProjectIds(
|
|
||||||
args.jobDescription,
|
|
||||||
args.eligibleProjects,
|
|
||||||
desiredCount,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [overrideModel, overrideModelProjectSelection] = await Promise.all([
|
const [overrideModel, overrideModelProjectSelection] = await Promise.all([
|
||||||
getSetting("model"),
|
getSetting("model"),
|
||||||
getSetting("modelProjectSelection"),
|
getSetting("modelProjectSelection"),
|
||||||
@@ -59,7 +51,8 @@ export async function pickProjectIdsForJob(args: {
|
|||||||
desiredCount,
|
desiredCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await callOpenRouter<{ selectedProjectIds: string[] }>({
|
const llm = new LlmService();
|
||||||
|
const result = await llm.callJson<{ selectedProjectIds: string[] }>({
|
||||||
model,
|
model,
|
||||||
messages: [{ role: "user", content: prompt }],
|
messages: [{ role: "user", content: prompt }],
|
||||||
jsonSchema: PROJECT_SELECTION_SCHEMA,
|
jsonSchema: PROJECT_SELECTION_SCHEMA,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import type { Job } from "../../shared/types.js";
|
import type { Job } from "../../shared/types.js";
|
||||||
import { getSetting } from "../repositories/settings.js";
|
import { getSetting } from "../repositories/settings.js";
|
||||||
import { callOpenRouter, type JsonSchemaDefinition } from "./openrouter.js";
|
import { type JsonSchemaDefinition, LlmService } from "./llm-service.js";
|
||||||
|
|
||||||
interface SuitabilityResult {
|
interface SuitabilityResult {
|
||||||
score: number; // 0-100
|
score: number; // 0-100
|
||||||
@@ -39,11 +39,6 @@ export async function scoreJobSuitability(
|
|||||||
job: Job,
|
job: Job,
|
||||||
profile: Record<string, unknown>,
|
profile: Record<string, unknown>,
|
||||||
): Promise<SuitabilityResult> {
|
): 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([
|
const [overrideModel, overrideModelScorer] = await Promise.all([
|
||||||
getSetting("model"),
|
getSetting("model"),
|
||||||
getSetting("modelScorer"),
|
getSetting("modelScorer"),
|
||||||
@@ -57,7 +52,8 @@ export async function scoreJobSuitability(
|
|||||||
|
|
||||||
const prompt = buildScoringPrompt(job, profile);
|
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,
|
model,
|
||||||
messages: [{ role: "user", content: prompt }],
|
messages: [{ role: "user", content: prompt }],
|
||||||
jsonSchema: SCORING_SCHEMA,
|
jsonSchema: SCORING_SCHEMA,
|
||||||
@@ -66,6 +62,9 @@ export async function scoreJobSuitability(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
if (result.error.toLowerCase().includes("api key")) {
|
||||||
|
console.warn("⚠️ LLM API key not set, using mock scoring");
|
||||||
|
}
|
||||||
console.error(
|
console.error(
|
||||||
`❌ [Job ${job.id}] Scoring failed: ${result.error}, using mock scoring`,
|
`❌ [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.
|
* Robustly parse JSON from AI-generated content.
|
||||||
* Handles common AI quirks: markdown fences, extra text, trailing commas, etc.
|
* 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(
|
export function parseJsonFromContent(
|
||||||
content: string,
|
content: string,
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
|||||||
const overrideModelProjectSelection = overrides.modelProjectSelection ?? null;
|
const overrideModelProjectSelection = overrides.modelProjectSelection ?? null;
|
||||||
const modelProjectSelection = overrideModelProjectSelection || model;
|
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 =
|
const defaultPipelineWebhookUrl =
|
||||||
process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || "";
|
process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || "";
|
||||||
const overridePipelineWebhookUrl = overrides.pipelineWebhookUrl ?? null;
|
const overridePipelineWebhookUrl = overrides.pipelineWebhookUrl ?? null;
|
||||||
@@ -169,6 +178,7 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
|||||||
const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo;
|
const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...envSettings,
|
||||||
model,
|
model,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
overrideModel,
|
overrideModel,
|
||||||
@@ -178,6 +188,12 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
|||||||
overrideModelTailoring,
|
overrideModelTailoring,
|
||||||
modelProjectSelection,
|
modelProjectSelection,
|
||||||
overrideModelProjectSelection,
|
overrideModelProjectSelection,
|
||||||
|
llmProvider,
|
||||||
|
defaultLlmProvider,
|
||||||
|
overrideLlmProvider,
|
||||||
|
llmBaseUrl,
|
||||||
|
defaultLlmBaseUrl,
|
||||||
|
overrideLlmBaseUrl,
|
||||||
pipelineWebhookUrl,
|
pipelineWebhookUrl,
|
||||||
defaultPipelineWebhookUrl,
|
defaultPipelineWebhookUrl,
|
||||||
overridePipelineWebhookUrl,
|
overridePipelineWebhookUrl,
|
||||||
@@ -216,6 +232,18 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
|||||||
showSponsorInfo,
|
showSponsorInfo,
|
||||||
defaultShowSponsorInfo,
|
defaultShowSponsorInfo,
|
||||||
overrideShowSponsorInfo,
|
overrideShowSponsorInfo,
|
||||||
...envSettings,
|
|
||||||
} as AppSettings;
|
} 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 type { ResumeProfile } from "../../shared/types.js";
|
||||||
import { getSetting } from "../repositories/settings.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 {
|
export interface TailoredData {
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -65,11 +65,6 @@ export async function generateTailoring(
|
|||||||
jobDescription: string,
|
jobDescription: string,
|
||||||
profile: ResumeProfile,
|
profile: ResumeProfile,
|
||||||
): Promise<TailoringResult> {
|
): 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([
|
const [overrideModel, overrideModelTailoring] = await Promise.all([
|
||||||
getSetting("model"),
|
getSetting("model"),
|
||||||
getSetting("modelTailoring"),
|
getSetting("modelTailoring"),
|
||||||
@@ -82,14 +77,24 @@ export async function generateTailoring(
|
|||||||
"google/gemini-3-flash-preview";
|
"google/gemini-3-flash-preview";
|
||||||
const prompt = buildTailoringPrompt(profile, jobDescription);
|
const prompt = buildTailoringPrompt(profile, jobDescription);
|
||||||
|
|
||||||
const result = await callOpenRouter<TailoredData>({
|
const llm = new LlmService();
|
||||||
|
const result = await llm.callJson<TailoredData>({
|
||||||
model,
|
model,
|
||||||
messages: [{ role: "user", content: prompt }],
|
messages: [{ role: "user", content: prompt }],
|
||||||
jsonSchema: TAILORING_SCHEMA,
|
jsonSchema: TAILORING_SCHEMA,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
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;
|
const { summary, headline, skills } = result.data;
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ export const updateSettingsSchema = z
|
|||||||
modelScorer: z.string().trim().max(200).nullable().optional(),
|
modelScorer: z.string().trim().max(200).nullable().optional(),
|
||||||
modelTailoring: z.string().trim().max(200).nullable().optional(),
|
modelTailoring: z.string().trim().max(200).nullable().optional(),
|
||||||
modelProjectSelection: 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(),
|
pipelineWebhookUrl: z.string().trim().max(2000).nullable().optional(),
|
||||||
jobCompleteWebhookUrl: z.string().trim().max(2000).nullable().optional(),
|
jobCompleteWebhookUrl: z.string().trim().max(2000).nullable().optional(),
|
||||||
resumeProjects: resumeProjectsSchema.nullable().optional(),
|
resumeProjects: resumeProjectsSchema.nullable().optional(),
|
||||||
|
|||||||
@@ -483,6 +483,13 @@ export interface AppSettings {
|
|||||||
modelProjectSelection: string; // resolved
|
modelProjectSelection: string; // resolved
|
||||||
overrideModelProjectSelection: string | null;
|
overrideModelProjectSelection: string | null;
|
||||||
|
|
||||||
|
llmProvider: string;
|
||||||
|
defaultLlmProvider: string;
|
||||||
|
overrideLlmProvider: string | null;
|
||||||
|
llmBaseUrl: string;
|
||||||
|
defaultLlmBaseUrl: string;
|
||||||
|
overrideLlmBaseUrl: string | null;
|
||||||
|
|
||||||
pipelineWebhookUrl: string;
|
pipelineWebhookUrl: string;
|
||||||
defaultPipelineWebhookUrl: string;
|
defaultPipelineWebhookUrl: string;
|
||||||
overridePipelineWebhookUrl: string | null;
|
overridePipelineWebhookUrl: string | null;
|
||||||
@@ -524,6 +531,7 @@ export interface AppSettings {
|
|||||||
showSponsorInfo: boolean;
|
showSponsorInfo: boolean;
|
||||||
defaultShowSponsorInfo: boolean;
|
defaultShowSponsorInfo: boolean;
|
||||||
overrideShowSponsorInfo: boolean | null;
|
overrideShowSponsorInfo: boolean | null;
|
||||||
|
llmApiKeyHint: string | null;
|
||||||
openrouterApiKeyHint: string | null;
|
openrouterApiKeyHint: string | null;
|
||||||
rxresumeEmail: string | null;
|
rxresumeEmail: string | null;
|
||||||
rxresumePasswordHint: string | null;
|
rxresumePasswordHint: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user