Refactor LLM service into modular adapters and policies (#83)
* llm migration * orchestrator runer * Decompose runPipeline steps * dedupe * refactor(settings): unify settings conversion metadata and round-trip tests * refactor(llm): extract shared provider strategy factory * refactor(settings-ui): add reusable numeric setting section * test(orchestrator): stabilize usePipelineSources localStorage setup * comments
This commit is contained in:
parent
b94f85b149
commit
82b261c7bc
@ -4,13 +4,58 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PIPELINE_SOURCES_STORAGE_KEY } from "./constants";
|
||||
import { usePipelineSources } from "./usePipelineSources";
|
||||
|
||||
function ensureStorage(): Storage {
|
||||
const existing = globalThis.localStorage as Partial<Storage> | undefined;
|
||||
const hasStorageShape =
|
||||
existing &&
|
||||
typeof existing.getItem === "function" &&
|
||||
typeof existing.setItem === "function" &&
|
||||
typeof existing.removeItem === "function" &&
|
||||
typeof existing.clear === "function";
|
||||
|
||||
if (hasStorageShape) {
|
||||
return existing as Storage;
|
||||
}
|
||||
|
||||
const store = new Map<string, string>();
|
||||
const storage: Storage = {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(key: string) {
|
||||
const value = store.get(key);
|
||||
return value ?? null;
|
||||
},
|
||||
key(index: number) {
|
||||
return Array.from(store.keys())[index] ?? null;
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, value);
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: storage,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
return storage;
|
||||
}
|
||||
|
||||
describe("usePipelineSources", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
ensureStorage().clear();
|
||||
});
|
||||
|
||||
it("filters stored sources to enabled sources", () => {
|
||||
localStorage.setItem(
|
||||
ensureStorage().setItem(
|
||||
PIPELINE_SOURCES_STORAGE_KEY,
|
||||
JSON.stringify(["gradcracker", "ukvisajobs"]),
|
||||
);
|
||||
@ -23,7 +68,7 @@ describe("usePipelineSources", () => {
|
||||
});
|
||||
|
||||
it("falls back to the first enabled source", () => {
|
||||
localStorage.setItem(
|
||||
ensureStorage().setItem(
|
||||
PIPELINE_SOURCES_STORAGE_KEY,
|
||||
JSON.stringify(["ukvisajobs"]),
|
||||
);
|
||||
@ -36,7 +81,7 @@ describe("usePipelineSources", () => {
|
||||
});
|
||||
|
||||
it("ignores toggles for disabled sources", () => {
|
||||
localStorage.setItem(
|
||||
ensureStorage().setItem(
|
||||
PIPELINE_SOURCES_STORAGE_KEY,
|
||||
JSON.stringify(["gradcracker"]),
|
||||
);
|
||||
|
||||
@ -1,13 +1,6 @@
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||
import type { NumericSettingValues } from "@client/pages/settings/types";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import type React from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { NumericSettingSection } from "./NumericSettingSection";
|
||||
|
||||
type GradcrackerSectionProps = {
|
||||
values: NumericSettingValues;
|
||||
@ -20,57 +13,18 @@ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const {
|
||||
effective: effectiveGradcrackerMaxJobsPerTerm,
|
||||
default: defaultGradcrackerMaxJobsPerTerm,
|
||||
} = values;
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<UpdateSettingsInput>();
|
||||
|
||||
return (
|
||||
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Gradcracker Extractor</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<Controller
|
||||
name="gradcrackerMaxJobsPerTerm"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Max jobs per search term"
|
||||
type="number"
|
||||
inputProps={{
|
||||
...field,
|
||||
inputMode: "numeric",
|
||||
min: 1,
|
||||
max: 1000,
|
||||
value: field.value ?? defaultGradcrackerMaxJobsPerTerm,
|
||||
onChange: (event) => {
|
||||
const value = parseInt(event.target.value, 10);
|
||||
if (Number.isNaN(value)) {
|
||||
field.onChange(null);
|
||||
} else {
|
||||
field.onChange(Math.min(1000, Math.max(1, value)));
|
||||
}
|
||||
},
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
error={
|
||||
errors.gradcrackerMaxJobsPerTerm?.message as
|
||||
| string
|
||||
| undefined
|
||||
}
|
||||
helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${defaultGradcrackerMaxJobsPerTerm}. Range: 1-1000.`}
|
||||
current={String(effectiveGradcrackerMaxJobsPerTerm)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<NumericSettingSection
|
||||
accordionValue="gradcracker"
|
||||
title="Gradcracker Extractor"
|
||||
fieldName="gradcrackerMaxJobsPerTerm"
|
||||
label="Max jobs per search term"
|
||||
helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${values.default}. Range: 1-1000.`}
|
||||
values={values}
|
||||
min={1}
|
||||
max={1000}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Accordion } from "@/components/ui/accordion";
|
||||
import { NumericSettingSection } from "./NumericSettingSection";
|
||||
|
||||
const Harness = () => {
|
||||
const methods = useForm<UpdateSettingsInput>({
|
||||
defaultValues: {
|
||||
ukvisajobsMaxJobs: 50,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Accordion type="multiple" defaultValue={["ukvisajobs"]}>
|
||||
<NumericSettingSection
|
||||
accordionValue="ukvisajobs"
|
||||
title="UKVisaJobs Extractor"
|
||||
fieldName="ukvisajobsMaxJobs"
|
||||
label="Max jobs to fetch"
|
||||
helper="Maximum jobs per run."
|
||||
values={{ default: 50, effective: 50 }}
|
||||
min={1}
|
||||
max={1000}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe("NumericSettingSection", () => {
|
||||
it("clamps out-of-range values and clears invalid number input", () => {
|
||||
render(<Harness />);
|
||||
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "1001" } });
|
||||
expect(input).toHaveValue(1000);
|
||||
|
||||
fireEvent.change(input, { target: { value: "0" } });
|
||||
expect(input).toHaveValue(1);
|
||||
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
expect(input).toHaveValue(50);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,91 @@
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||
import type { NumericSettingValues } from "@client/pages/settings/types";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import type React from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
|
||||
type NumericFieldName =
|
||||
| "ukvisajobsMaxJobs"
|
||||
| "gradcrackerMaxJobsPerTerm"
|
||||
| "jobspyResultsWanted"
|
||||
| "jobspyHoursOld"
|
||||
| "backupHour"
|
||||
| "backupMaxCount";
|
||||
|
||||
type NumericSettingSectionProps = {
|
||||
accordionValue: string;
|
||||
title: string;
|
||||
fieldName: NumericFieldName;
|
||||
label: string;
|
||||
helper: string;
|
||||
values: NumericSettingValues;
|
||||
min: number;
|
||||
max: number;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
};
|
||||
|
||||
export const NumericSettingSection: React.FC<NumericSettingSectionProps> = ({
|
||||
accordionValue,
|
||||
title,
|
||||
fieldName,
|
||||
label,
|
||||
helper,
|
||||
values,
|
||||
min,
|
||||
max,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { effective, default: defaultValue } = values;
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<UpdateSettingsInput>();
|
||||
|
||||
return (
|
||||
<AccordionItem value={accordionValue} className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">{title}</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<Controller
|
||||
name={fieldName}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label={label}
|
||||
type="number"
|
||||
inputProps={{
|
||||
...field,
|
||||
inputMode: "numeric",
|
||||
min,
|
||||
max,
|
||||
value: field.value ?? defaultValue,
|
||||
onChange: (event) => {
|
||||
const parsed = parseInt(event.target.value, 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
field.onChange(Math.min(max, Math.max(min, parsed)));
|
||||
},
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors[fieldName]?.message as string | undefined}
|
||||
helper={helper}
|
||||
current={String(effective)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
};
|
||||
@ -1,13 +1,6 @@
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||
import type { NumericSettingValues } from "@client/pages/settings/types";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import type React from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { NumericSettingSection } from "./NumericSettingSection";
|
||||
|
||||
type UkvisajobsSectionProps = {
|
||||
values: NumericSettingValues;
|
||||
@ -20,53 +13,18 @@ export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const {
|
||||
effective: effectiveUkvisajobsMaxJobs,
|
||||
default: defaultUkvisajobsMaxJobs,
|
||||
} = values;
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<UpdateSettingsInput>();
|
||||
|
||||
return (
|
||||
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">UKVisaJobs Extractor</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<Controller
|
||||
name="ukvisajobsMaxJobs"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Max jobs to fetch"
|
||||
type="number"
|
||||
inputProps={{
|
||||
...field,
|
||||
inputMode: "numeric",
|
||||
min: 1,
|
||||
max: 1000,
|
||||
value: field.value ?? defaultUkvisajobsMaxJobs,
|
||||
onChange: (event) => {
|
||||
const value = parseInt(event.target.value, 10);
|
||||
if (Number.isNaN(value)) {
|
||||
field.onChange(null);
|
||||
} else {
|
||||
field.onChange(Math.min(1000, Math.max(1, value)));
|
||||
}
|
||||
},
|
||||
}}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.ukvisajobsMaxJobs?.message as string | undefined}
|
||||
helper={`Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Default: ${defaultUkvisajobsMaxJobs}. Range: 1-1000.`}
|
||||
current={String(effectiveUkvisajobsMaxJobs)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<NumericSettingSection
|
||||
accordionValue="ukvisajobs"
|
||||
title="UKVisaJobs Extractor"
|
||||
fieldName="ukvisajobsMaxJobs"
|
||||
label="Max jobs to fetch"
|
||||
helper={`Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Default: ${values.default}. Range: 1-1000.`}
|
||||
values={values}
|
||||
min={1}
|
||||
max={1000}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,17 +1,12 @@
|
||||
import * as settingsRepo from "@server/repositories/settings";
|
||||
import { setBackupSettings } from "@server/services/backup/index";
|
||||
import { applyEnvValue, normalizeEnvInput } from "@server/services/envSettings";
|
||||
import { getProfile } from "@server/services/profile";
|
||||
import {
|
||||
extractProjectsFromProfile,
|
||||
normalizeResumeProjectsSettings,
|
||||
} from "@server/services/resumeProjects";
|
||||
import { extractProjectsFromProfile } from "@server/services/resumeProjects";
|
||||
import {
|
||||
getResume,
|
||||
listResumes,
|
||||
RxResumeCredentialsError,
|
||||
} from "@server/services/rxresume-v4";
|
||||
import { getEffectiveSettings } from "@server/services/settings";
|
||||
import { applySettingsUpdates } from "@server/services/settings-update";
|
||||
import { updateSettingsSchema } from "@shared/settings-schema";
|
||||
import { type Request, type Response, Router } from "express";
|
||||
|
||||
@ -36,337 +31,11 @@ settingsRouter.get("/", async (_req: Request, res: Response) => {
|
||||
settingsRouter.patch("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const input = updateSettingsSchema.parse(req.body);
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
if ("model" in input) {
|
||||
promises.push(settingsRepo.setSetting("model", input.model ?? null));
|
||||
}
|
||||
|
||||
if ("modelScorer" in input) {
|
||||
promises.push(
|
||||
settingsRepo.setSetting("modelScorer", input.modelScorer ?? null),
|
||||
);
|
||||
}
|
||||
if ("modelTailoring" in input) {
|
||||
promises.push(
|
||||
settingsRepo.setSetting("modelTailoring", input.modelTailoring ?? null),
|
||||
);
|
||||
}
|
||||
if ("modelProjectSelection" in input) {
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"modelProjectSelection",
|
||||
input.modelProjectSelection ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("llmProvider" in input) {
|
||||
const value = normalizeEnvInput(input.llmProvider);
|
||||
promises.push(
|
||||
settingsRepo.setSetting("llmProvider", value).then(() => {
|
||||
applyEnvValue("LLM_PROVIDER", value);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if ("llmBaseUrl" in input) {
|
||||
const value = normalizeEnvInput(input.llmBaseUrl);
|
||||
promises.push(
|
||||
settingsRepo.setSetting("llmBaseUrl", value).then(() => {
|
||||
applyEnvValue("LLM_BASE_URL", value);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if ("pipelineWebhookUrl" in input) {
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"pipelineWebhookUrl",
|
||||
input.pipelineWebhookUrl ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("jobCompleteWebhookUrl" in input) {
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"jobCompleteWebhookUrl",
|
||||
input.jobCompleteWebhookUrl ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("rxresumeBaseResumeId" in input) {
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"rxresumeBaseResumeId",
|
||||
normalizeEnvInput(input.rxresumeBaseResumeId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("resumeProjects" in input) {
|
||||
const resumeProjects = input.resumeProjects ?? null;
|
||||
|
||||
if (resumeProjects === null) {
|
||||
promises.push(settingsRepo.setSetting("resumeProjects", null));
|
||||
} else {
|
||||
promises.push(
|
||||
(async () => {
|
||||
// getProfile() will fetch from RxResume v4 API using rxresumeBaseResumeId
|
||||
const profile = await getProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const allowed = new Set(catalog.map((p) => p.id));
|
||||
const normalized = normalizeResumeProjectsSettings(
|
||||
resumeProjects,
|
||||
allowed,
|
||||
);
|
||||
await settingsRepo.setSetting(
|
||||
"resumeProjects",
|
||||
JSON.stringify(normalized),
|
||||
);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ("ukvisajobsMaxJobs" in input) {
|
||||
const val = input.ukvisajobsMaxJobs ?? null;
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"ukvisajobsMaxJobs",
|
||||
val !== null ? String(val) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("gradcrackerMaxJobsPerTerm" in input) {
|
||||
const val = input.gradcrackerMaxJobsPerTerm ?? null;
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"gradcrackerMaxJobsPerTerm",
|
||||
val !== null ? String(val) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("searchTerms" in input) {
|
||||
const val = input.searchTerms ?? null;
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"searchTerms",
|
||||
val !== null ? JSON.stringify(val) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("jobspyLocation" in input) {
|
||||
promises.push(
|
||||
settingsRepo.setSetting("jobspyLocation", input.jobspyLocation ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
if ("jobspyResultsWanted" in input) {
|
||||
const val = input.jobspyResultsWanted ?? null;
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"jobspyResultsWanted",
|
||||
val !== null ? String(val) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("jobspyHoursOld" in input) {
|
||||
const val = input.jobspyHoursOld ?? null;
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"jobspyHoursOld",
|
||||
val !== null ? String(val) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("jobspyCountryIndeed" in input) {
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"jobspyCountryIndeed",
|
||||
input.jobspyCountryIndeed ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("jobspySites" in input) {
|
||||
const val = input.jobspySites ?? null;
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"jobspySites",
|
||||
val !== null ? JSON.stringify(val) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("jobspyLinkedinFetchDescription" in input) {
|
||||
const val = input.jobspyLinkedinFetchDescription ?? null;
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"jobspyLinkedinFetchDescription",
|
||||
val !== null ? (val ? "1" : "0") : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("jobspyIsRemote" in input) {
|
||||
const val = input.jobspyIsRemote ?? null;
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"jobspyIsRemote",
|
||||
val !== null ? (val ? "1" : "0") : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("showSponsorInfo" in input) {
|
||||
const val = input.showSponsorInfo ?? null;
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"showSponsorInfo",
|
||||
val !== null ? (val ? "1" : "0") : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("openrouterApiKey" in input) {
|
||||
// @deprecated Use llmApiKey. Keep accepting this field for backwards compatibility.
|
||||
console.warn(
|
||||
"[DEPRECATED] Received openrouterApiKey update. Storing as llmApiKey and clearing legacy openrouterApiKey.",
|
||||
);
|
||||
const value = normalizeEnvInput(input.openrouterApiKey);
|
||||
promises.push(
|
||||
settingsRepo.setSetting("llmApiKey", value).then(() => {
|
||||
applyEnvValue("LLM_API_KEY", value);
|
||||
}),
|
||||
);
|
||||
promises.push(
|
||||
settingsRepo.setSetting("openrouterApiKey", null).then(() => {
|
||||
applyEnvValue("OPENROUTER_API_KEY", null);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if ("llmApiKey" in input) {
|
||||
const value = normalizeEnvInput(input.llmApiKey);
|
||||
promises.push(
|
||||
settingsRepo.setSetting("llmApiKey", value).then(() => {
|
||||
applyEnvValue("LLM_API_KEY", value);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if ("rxresumeEmail" in input) {
|
||||
const value = normalizeEnvInput(input.rxresumeEmail);
|
||||
promises.push(
|
||||
settingsRepo.setSetting("rxresumeEmail", value).then(() => {
|
||||
applyEnvValue("RXRESUME_EMAIL", value);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if ("rxresumePassword" in input) {
|
||||
const value = normalizeEnvInput(input.rxresumePassword);
|
||||
promises.push(
|
||||
settingsRepo.setSetting("rxresumePassword", value).then(() => {
|
||||
applyEnvValue("RXRESUME_PASSWORD", value);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if ("basicAuthUser" in input) {
|
||||
const value = normalizeEnvInput(input.basicAuthUser);
|
||||
promises.push(
|
||||
settingsRepo.setSetting("basicAuthUser", value).then(() => {
|
||||
applyEnvValue("BASIC_AUTH_USER", value);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if ("basicAuthPassword" in input) {
|
||||
const value = normalizeEnvInput(input.basicAuthPassword);
|
||||
promises.push(
|
||||
settingsRepo.setSetting("basicAuthPassword", value).then(() => {
|
||||
applyEnvValue("BASIC_AUTH_PASSWORD", value);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if ("ukvisajobsEmail" in input) {
|
||||
const value = normalizeEnvInput(input.ukvisajobsEmail);
|
||||
promises.push(
|
||||
settingsRepo.setSetting("ukvisajobsEmail", value).then(() => {
|
||||
applyEnvValue("UKVISAJOBS_EMAIL", value);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if ("ukvisajobsPassword" in input) {
|
||||
const value = normalizeEnvInput(input.ukvisajobsPassword);
|
||||
promises.push(
|
||||
settingsRepo.setSetting("ukvisajobsPassword", value).then(() => {
|
||||
applyEnvValue("UKVISAJOBS_PASSWORD", value);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if ("webhookSecret" in input) {
|
||||
const value = normalizeEnvInput(input.webhookSecret);
|
||||
promises.push(
|
||||
settingsRepo.setSetting("webhookSecret", value).then(() => {
|
||||
applyEnvValue("WEBHOOK_SECRET", value);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Backup settings
|
||||
if ("backupEnabled" in input) {
|
||||
const val = input.backupEnabled ?? null;
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"backupEnabled",
|
||||
val !== null ? (val ? "1" : "0") : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("backupHour" in input) {
|
||||
const val = input.backupHour ?? null;
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"backupHour",
|
||||
val !== null ? String(val) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ("backupMaxCount" in input) {
|
||||
const val = input.backupMaxCount ?? null;
|
||||
promises.push(
|
||||
settingsRepo.setSetting(
|
||||
"backupMaxCount",
|
||||
val !== null ? String(val) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
const plan = await applySettingsUpdates(input);
|
||||
|
||||
const data = await getEffectiveSettings();
|
||||
|
||||
// Update backup scheduler if backup settings changed
|
||||
if (
|
||||
"backupEnabled" in input ||
|
||||
"backupHour" in input ||
|
||||
"backupMaxCount" in input
|
||||
) {
|
||||
if (plan.shouldRefreshBackupScheduler) {
|
||||
setBackupSettings({
|
||||
enabled: data.backupEnabled,
|
||||
hour: data.backupHour,
|
||||
|
||||
@ -8,14 +8,11 @@
|
||||
*/
|
||||
|
||||
import { join } from "node:path";
|
||||
import type { CreateJobInput, Job, PipelineConfig } from "@shared/types";
|
||||
import type { PipelineConfig } from "@shared/types";
|
||||
import { getDataDir } from "../config/dataDir";
|
||||
import * as jobsRepo from "../repositories/jobs";
|
||||
import * as pipelineRepo from "../repositories/pipeline";
|
||||
import * as settingsRepo from "../repositories/settings";
|
||||
import { getSetting } from "../repositories/settings";
|
||||
import { runCrawler } from "../services/crawler";
|
||||
import { runJobSpy } from "../services/jobspy";
|
||||
import { generatePdf } from "../services/pdf";
|
||||
import { getProfile } from "../services/profile";
|
||||
import { pickProjectIdsForJob } from "../services/projectSelection";
|
||||
@ -23,11 +20,17 @@ import {
|
||||
extractProjectsFromProfile,
|
||||
resolveResumeProjectsSettings,
|
||||
} from "../services/resumeProjects";
|
||||
import { scoreJobSuitability } from "../services/scorer";
|
||||
import { generateTailoring } from "../services/summary";
|
||||
import { runUkVisaJobs } from "../services/ukvisajobs";
|
||||
import * as visaSponsors from "../services/visa-sponsors/index";
|
||||
import { progressHelpers, resetProgress, updateProgress } from "./progress";
|
||||
import { progressHelpers, resetProgress } from "./progress";
|
||||
import {
|
||||
discoverJobsStep,
|
||||
importJobsStep,
|
||||
loadProfileStep,
|
||||
notifyPipelineWebhookStep,
|
||||
processJobsStep,
|
||||
scoreJobsStep,
|
||||
selectJobsStep,
|
||||
} from "./steps";
|
||||
|
||||
const DEFAULT_CONFIG: PipelineConfig = {
|
||||
topN: 10,
|
||||
@ -43,47 +46,6 @@ const DEFAULT_CONFIG: PipelineConfig = {
|
||||
// Track if pipeline is currently running
|
||||
let isPipelineRunning = false;
|
||||
|
||||
async function notifyPipelineWebhook(
|
||||
event: "pipeline.completed" | "pipeline.failed",
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
const overridePipelineWebhookUrl =
|
||||
await settingsRepo.getSetting("pipelineWebhookUrl");
|
||||
const pipelineWebhookUrl = (
|
||||
overridePipelineWebhookUrl ||
|
||||
process.env.PIPELINE_WEBHOOK_URL ||
|
||||
process.env.WEBHOOK_URL ||
|
||||
""
|
||||
).trim();
|
||||
if (!pipelineWebhookUrl) return;
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const secret = process.env.WEBHOOK_SECRET;
|
||||
if (secret) headers.Authorization = `Bearer ${secret}`;
|
||||
|
||||
const response = await fetch(pipelineWebhookUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
event,
|
||||
sentAt: new Date().toISOString(),
|
||||
...payload,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`⚠️ Pipeline webhook POST failed (${response.status}): ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Pipeline webhook POST failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full job discovery and processing pipeline.
|
||||
*/
|
||||
@ -108,7 +70,6 @@ export async function runPipeline(
|
||||
resetProgress();
|
||||
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
// Create pipeline run record
|
||||
const pipelineRun = await pipelineRepo.createPipelineRun();
|
||||
|
||||
console.log("🚀 Starting job pipeline...");
|
||||
@ -117,306 +78,33 @@ export async function runPipeline(
|
||||
);
|
||||
|
||||
try {
|
||||
// Step 1: Load profile
|
||||
console.log("\n📋 Loading profile...");
|
||||
const profile = await getProfile().catch((error) => {
|
||||
console.warn(
|
||||
"⚠️ Failed to load profile for scoring, using empty profile:",
|
||||
error,
|
||||
);
|
||||
return {} as Record<string, unknown>;
|
||||
});
|
||||
const profile = await loadProfileStep();
|
||||
|
||||
// Step 2: Run crawler
|
||||
console.log("\n🕷️ Running crawler...");
|
||||
progressHelpers.startCrawling();
|
||||
const discoveredJobs: CreateJobInput[] = [];
|
||||
const sourceErrors: string[] = [];
|
||||
const { discoveredJobs } = await discoverJobsStep({ mergedConfig });
|
||||
|
||||
// Read all settings at once to avoid sequential DB calls
|
||||
const settings = await settingsRepo.getAllSettings();
|
||||
|
||||
// Read search terms setting
|
||||
const searchTermsSetting = settings.searchTerms;
|
||||
let searchTerms: string[] = [];
|
||||
|
||||
if (searchTermsSetting) {
|
||||
searchTerms = JSON.parse(searchTermsSetting) as string[];
|
||||
} else {
|
||||
// Default from env var
|
||||
const defaultSearchTermsEnv =
|
||||
process.env.JOBSPY_SEARCH_TERMS || "web developer";
|
||||
searchTerms = defaultSearchTermsEnv
|
||||
.split("|")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Run JobSpy (Indeed/LinkedIn) if selected
|
||||
let jobSpySites = mergedConfig.sources.filter(
|
||||
(s): s is "indeed" | "linkedin" => s === "indeed" || s === "linkedin",
|
||||
);
|
||||
|
||||
// Apply setting override for JobSpy sites
|
||||
const jobspySitesSettingRaw = settings.jobspySites;
|
||||
if (jobspySitesSettingRaw) {
|
||||
try {
|
||||
const allowed = JSON.parse(jobspySitesSettingRaw);
|
||||
if (Array.isArray(allowed)) {
|
||||
jobSpySites = jobSpySites.filter((s) => allowed.includes(s));
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse error
|
||||
}
|
||||
}
|
||||
|
||||
if (jobSpySites.length > 0) {
|
||||
updateProgress({
|
||||
step: "crawling",
|
||||
detail: `JobSpy: scraping ${jobSpySites.join(", ")}...`,
|
||||
});
|
||||
|
||||
const jobspyLocationSetting = settings.jobspyLocation;
|
||||
const jobspyResultsWantedSetting = settings.jobspyResultsWanted;
|
||||
const jobspyHoursOldSetting = settings.jobspyHoursOld;
|
||||
const jobspyCountryIndeedSetting = settings.jobspyCountryIndeed;
|
||||
const jobspyLinkedinFetchDescriptionSetting =
|
||||
settings.jobspyLinkedinFetchDescription;
|
||||
const jobspyIsRemoteSetting = settings.jobspyIsRemote;
|
||||
|
||||
const jobSpyResult = await runJobSpy({
|
||||
sites: jobSpySites,
|
||||
searchTerms,
|
||||
location: jobspyLocationSetting ?? undefined,
|
||||
resultsWanted: jobspyResultsWantedSetting
|
||||
? parseInt(jobspyResultsWantedSetting, 10)
|
||||
: undefined,
|
||||
hoursOld: jobspyHoursOldSetting
|
||||
? parseInt(jobspyHoursOldSetting, 10)
|
||||
: undefined,
|
||||
countryIndeed: jobspyCountryIndeedSetting ?? undefined,
|
||||
linkedinFetchDescription:
|
||||
jobspyLinkedinFetchDescriptionSetting !== null &&
|
||||
jobspyLinkedinFetchDescriptionSetting !== undefined
|
||||
? jobspyLinkedinFetchDescriptionSetting === "1"
|
||||
: undefined,
|
||||
isRemote:
|
||||
jobspyIsRemoteSetting !== null && jobspyIsRemoteSetting !== undefined
|
||||
? jobspyIsRemoteSetting === "1"
|
||||
: undefined,
|
||||
});
|
||||
if (!jobSpyResult.success) {
|
||||
sourceErrors.push(`jobspy: ${jobSpyResult.error ?? "unknown error"}`);
|
||||
} else {
|
||||
discoveredJobs.push(...jobSpyResult.jobs);
|
||||
}
|
||||
}
|
||||
|
||||
// Run Gradcracker crawler if selected
|
||||
if (mergedConfig.sources.includes("gradcracker")) {
|
||||
updateProgress({
|
||||
step: "crawling",
|
||||
detail: "Gradcracker: scraping...",
|
||||
});
|
||||
|
||||
// Pass existing URLs to avoid clicking "Apply" on jobs we already have
|
||||
const existingJobUrls = await jobsRepo.getAllJobUrls();
|
||||
|
||||
const gradcrackerMaxJobsSetting = settings.gradcrackerMaxJobsPerTerm;
|
||||
const gradcrackerMaxJobs = gradcrackerMaxJobsSetting
|
||||
? parseInt(gradcrackerMaxJobsSetting, 10)
|
||||
: 50;
|
||||
|
||||
const crawlerResult = await runCrawler({
|
||||
existingJobUrls,
|
||||
searchTerms,
|
||||
maxJobsPerTerm: gradcrackerMaxJobs,
|
||||
onProgress: (progress) => {
|
||||
// Calculate overall progress based on list pages processed vs total
|
||||
// This is rough but better than nothing
|
||||
if (progress.listPagesTotal && progress.listPagesTotal > 0) {
|
||||
const percent = Math.round(
|
||||
((progress.listPagesProcessed ?? 0) / progress.listPagesTotal) *
|
||||
100,
|
||||
);
|
||||
updateProgress({
|
||||
step: "crawling",
|
||||
detail: `Gradcracker: ${percent}% (scan ${progress.listPagesProcessed}/${progress.listPagesTotal}, found ${progress.jobCardsFound})`,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!crawlerResult.success) {
|
||||
sourceErrors.push(
|
||||
`gradcracker: ${crawlerResult.error ?? "unknown error"}`,
|
||||
);
|
||||
} else {
|
||||
discoveredJobs.push(...crawlerResult.jobs);
|
||||
}
|
||||
}
|
||||
|
||||
// Run UKVisaJobs extractor if selected
|
||||
if (mergedConfig.sources.includes("ukvisajobs")) {
|
||||
updateProgress({
|
||||
step: "crawling",
|
||||
detail: "UKVisaJobs: scraping visa-sponsoring jobs...",
|
||||
});
|
||||
|
||||
// Read max jobs setting from database (default to 50 if not set)
|
||||
const ukvisajobsMaxJobsSetting = settings.ukvisajobsMaxJobs;
|
||||
const ukvisajobsMaxJobs = ukvisajobsMaxJobsSetting
|
||||
? parseInt(ukvisajobsMaxJobsSetting, 10)
|
||||
: 50;
|
||||
|
||||
const ukVisaResult = await runUkVisaJobs({
|
||||
maxJobs: ukvisajobsMaxJobs,
|
||||
searchTerms,
|
||||
});
|
||||
if (!ukVisaResult.success) {
|
||||
sourceErrors.push(
|
||||
`ukvisajobs: ${ukVisaResult.error ?? "unknown error"}`,
|
||||
);
|
||||
} else {
|
||||
discoveredJobs.push(...ukVisaResult.jobs);
|
||||
}
|
||||
}
|
||||
|
||||
if (discoveredJobs.length === 0 && sourceErrors.length > 0) {
|
||||
throw new Error(`All sources failed: ${sourceErrors.join("; ")}`);
|
||||
}
|
||||
|
||||
if (sourceErrors.length > 0) {
|
||||
console.warn(`ƒsÿ‹,? Some sources failed: ${sourceErrors.join("; ")}`);
|
||||
}
|
||||
|
||||
progressHelpers.crawlingComplete(discoveredJobs.length);
|
||||
|
||||
// Step 3: Import discovered jobs
|
||||
console.log("\n💾 Importing jobs to database...");
|
||||
const { created, skipped } = await jobsRepo.bulkCreateJobs(discoveredJobs);
|
||||
console.log(` Created: ${created}, Skipped (duplicates): ${skipped}`);
|
||||
|
||||
progressHelpers.importComplete(created, skipped);
|
||||
const { created } = await importJobsStep({ discoveredJobs });
|
||||
|
||||
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
|
||||
jobsDiscovered: created,
|
||||
});
|
||||
|
||||
// Step 4: Score all discovered jobs missing a score
|
||||
console.log("\n🎯 Scoring jobs for suitability...");
|
||||
const unprocessedJobs = await jobsRepo.getUnscoredDiscoveredJobs();
|
||||
const { unprocessedJobs, scoredJobs } = await scoreJobsStep({ profile });
|
||||
|
||||
updateProgress({
|
||||
step: "scoring",
|
||||
jobsDiscovered: unprocessedJobs.length,
|
||||
jobsScored: 0,
|
||||
jobsProcessed: 0,
|
||||
totalToProcess: 0,
|
||||
currentJob: undefined,
|
||||
const jobsToProcess = selectJobsStep({
|
||||
scoredJobs,
|
||||
mergedConfig,
|
||||
});
|
||||
|
||||
// Score jobs with progress updates
|
||||
const scoredJobs: Array<
|
||||
Job & { suitabilityScore: number; suitabilityReason: string }
|
||||
> = [];
|
||||
for (let i = 0; i < unprocessedJobs.length; i++) {
|
||||
const job = unprocessedJobs[i];
|
||||
const hasCachedScore =
|
||||
typeof job.suitabilityScore === "number" &&
|
||||
!Number.isNaN(job.suitabilityScore);
|
||||
progressHelpers.scoringJob(
|
||||
i + 1,
|
||||
unprocessedJobs.length,
|
||||
hasCachedScore ? `${job.title} (cached)` : job.title,
|
||||
);
|
||||
|
||||
if (hasCachedScore) {
|
||||
scoredJobs.push({
|
||||
...job,
|
||||
suitabilityScore: job.suitabilityScore as number,
|
||||
suitabilityReason: job.suitabilityReason ?? "",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const { score, reason } = await scoreJobSuitability(job, profile);
|
||||
scoredJobs.push({
|
||||
...job,
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
});
|
||||
|
||||
// Calculate sponsor match score using fuzzy search
|
||||
let sponsorMatchScore = 0;
|
||||
let sponsorMatchNames: string | undefined;
|
||||
|
||||
if (job.employer) {
|
||||
const sponsorResults = visaSponsors.searchSponsors(job.employer, {
|
||||
limit: 10,
|
||||
minScore: 50,
|
||||
});
|
||||
|
||||
const summary =
|
||||
visaSponsors.calculateSponsorMatchSummary(sponsorResults);
|
||||
sponsorMatchScore = summary.sponsorMatchScore;
|
||||
sponsorMatchNames = summary.sponsorMatchNames ?? undefined;
|
||||
}
|
||||
|
||||
// Update score and sponsor match in database
|
||||
await jobsRepo.updateJob(job.id, {
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
sponsorMatchScore,
|
||||
sponsorMatchNames,
|
||||
});
|
||||
}
|
||||
|
||||
progressHelpers.scoringComplete(scoredJobs.length);
|
||||
console.log(`\n📊 Scored ${scoredJobs.length} jobs.`);
|
||||
|
||||
// Step 5: Auto-process top jobs
|
||||
console.log("\n🏭 Auto-processing top jobs...");
|
||||
|
||||
const jobsToProcess = scoredJobs
|
||||
.filter(
|
||||
(j) => (j.suitabilityScore ?? 0) >= mergedConfig.minSuitabilityScore,
|
||||
)
|
||||
.sort((a, b) => (b.suitabilityScore ?? 0) - (a.suitabilityScore ?? 0))
|
||||
.slice(0, mergedConfig.topN);
|
||||
|
||||
console.log(
|
||||
` Found ${jobsToProcess.length} candidates (score >= ${mergedConfig.minSuitabilityScore}, top ${mergedConfig.topN})`,
|
||||
);
|
||||
|
||||
let processedCount = 0;
|
||||
const { processedCount } = await processJobsStep({
|
||||
jobsToProcess,
|
||||
processJob,
|
||||
});
|
||||
|
||||
if (jobsToProcess.length > 0) {
|
||||
updateProgress({
|
||||
step: "processing",
|
||||
jobsProcessed: 0,
|
||||
totalToProcess: jobsToProcess.length,
|
||||
});
|
||||
|
||||
for (let i = 0; i < jobsToProcess.length; i++) {
|
||||
const job = jobsToProcess[i];
|
||||
progressHelpers.processingJob(i + 1, jobsToProcess.length, job);
|
||||
|
||||
// Process job (Generate Summary + PDF)
|
||||
// We catch errors here to ensure one failure doesn't stop the whole batch
|
||||
const result = await processJob(job.id, { force: false });
|
||||
|
||||
if (result.success) {
|
||||
processedCount++;
|
||||
} else {
|
||||
console.warn(` ⚠️ Failed to process job ${job.id}: ${result.error}`);
|
||||
}
|
||||
|
||||
progressHelpers.jobComplete(i + 1, jobsToProcess.length);
|
||||
}
|
||||
}
|
||||
|
||||
// Update pipeline run as completed
|
||||
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
|
||||
status: "completed",
|
||||
completedAt: new Date().toISOString(),
|
||||
@ -429,7 +117,7 @@ export async function runPipeline(
|
||||
|
||||
progressHelpers.complete(created, processedCount);
|
||||
|
||||
await notifyPipelineWebhook("pipeline.completed", {
|
||||
await notifyPipelineWebhookStep("pipeline.completed", {
|
||||
pipelineRunId: pipelineRun.id,
|
||||
jobsDiscovered: created,
|
||||
jobsScored: unprocessedJobs.length,
|
||||
@ -453,7 +141,7 @@ export async function runPipeline(
|
||||
|
||||
progressHelpers.failed(message);
|
||||
|
||||
await notifyPipelineWebhook("pipeline.failed", {
|
||||
await notifyPipelineWebhookStep("pipeline.failed", {
|
||||
pipelineRunId: pipelineRun.id,
|
||||
error: message,
|
||||
});
|
||||
|
||||
99
orchestrator/src/server/pipeline/steps/discover-jobs.test.ts
Normal file
99
orchestrator/src/server/pipeline/steps/discover-jobs.test.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import type { PipelineConfig } from "@shared/types";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { discoverJobsStep } from "./discover-jobs";
|
||||
|
||||
vi.mock("../../repositories/jobs", () => ({
|
||||
getAllJobUrls: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("../../repositories/settings", () => ({
|
||||
getAllSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../services/jobspy", () => ({
|
||||
runJobSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../services/crawler", () => ({
|
||||
runCrawler: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../services/ukvisajobs", () => ({
|
||||
runUkVisaJobs: vi.fn(),
|
||||
}));
|
||||
|
||||
const config: PipelineConfig = {
|
||||
topN: 10,
|
||||
minSuitabilityScore: 50,
|
||||
sources: ["indeed", "linkedin", "ukvisajobs"],
|
||||
outputDir: "./tmp",
|
||||
enableCrawling: true,
|
||||
enableScoring: true,
|
||||
enableImporting: true,
|
||||
enableAutoTailoring: true,
|
||||
};
|
||||
|
||||
describe("discoverJobsStep", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("applies jobspySites setting and aggregates source errors", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const jobSpy = await import("../../services/jobspy");
|
||||
const ukVisa = await import("../../services/ukvisajobs");
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
jobspySites: JSON.stringify(["linkedin"]),
|
||||
} as any);
|
||||
|
||||
vi.mocked(jobSpy.runJobSpy).mockResolvedValue({
|
||||
success: true,
|
||||
jobs: [
|
||||
{
|
||||
source: "linkedin",
|
||||
title: "Engineer",
|
||||
employer: "ACME",
|
||||
jobUrl: "https://example.com/job",
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
vi.mocked(ukVisa.runUkVisaJobs).mockResolvedValue({
|
||||
success: false,
|
||||
error: "login failed",
|
||||
} as any);
|
||||
|
||||
const result = await discoverJobsStep({ mergedConfig: config });
|
||||
|
||||
expect(result.discoveredJobs).toHaveLength(1);
|
||||
expect(result.sourceErrors).toEqual(["ukvisajobs: login failed"]);
|
||||
expect(vi.mocked(jobSpy.runJobSpy)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sites: ["linkedin"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when all enabled sources fail", async () => {
|
||||
const settingsRepo = await import("../../repositories/settings");
|
||||
const ukVisa = await import("../../services/ukvisajobs");
|
||||
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
|
||||
searchTerms: JSON.stringify(["engineer"]),
|
||||
} as any);
|
||||
|
||||
vi.mocked(ukVisa.runUkVisaJobs).mockResolvedValue({
|
||||
success: false,
|
||||
error: "boom",
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
discoverJobsStep({
|
||||
mergedConfig: {
|
||||
...config,
|
||||
sources: ["ukvisajobs"],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("All sources failed: ukvisajobs: boom");
|
||||
});
|
||||
});
|
||||
158
orchestrator/src/server/pipeline/steps/discover-jobs.ts
Normal file
158
orchestrator/src/server/pipeline/steps/discover-jobs.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import type { CreateJobInput, PipelineConfig } from "@shared/types";
|
||||
import * as jobsRepo from "../../repositories/jobs";
|
||||
import * as settingsRepo from "../../repositories/settings";
|
||||
import { runCrawler } from "../../services/crawler";
|
||||
import { runJobSpy } from "../../services/jobspy";
|
||||
import { runUkVisaJobs } from "../../services/ukvisajobs";
|
||||
import { progressHelpers, updateProgress } from "../progress";
|
||||
|
||||
export async function discoverJobsStep(args: {
|
||||
mergedConfig: PipelineConfig;
|
||||
}): Promise<{
|
||||
discoveredJobs: CreateJobInput[];
|
||||
sourceErrors: string[];
|
||||
}> {
|
||||
console.log("\n🕷️ Running crawler...");
|
||||
progressHelpers.startCrawling();
|
||||
|
||||
const discoveredJobs: CreateJobInput[] = [];
|
||||
const sourceErrors: string[] = [];
|
||||
|
||||
const settings = await settingsRepo.getAllSettings();
|
||||
|
||||
const searchTermsSetting = settings.searchTerms;
|
||||
let searchTerms: string[] = [];
|
||||
|
||||
if (searchTermsSetting) {
|
||||
searchTerms = JSON.parse(searchTermsSetting) as string[];
|
||||
} else {
|
||||
const defaultSearchTermsEnv =
|
||||
process.env.JOBSPY_SEARCH_TERMS || "web developer";
|
||||
searchTerms = defaultSearchTermsEnv
|
||||
.split("|")
|
||||
.map((term) => term.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
let jobSpySites = args.mergedConfig.sources.filter(
|
||||
(source): source is "indeed" | "linkedin" =>
|
||||
source === "indeed" || source === "linkedin",
|
||||
);
|
||||
|
||||
const jobspySitesSettingRaw = settings.jobspySites;
|
||||
if (jobspySitesSettingRaw) {
|
||||
try {
|
||||
const allowed = JSON.parse(jobspySitesSettingRaw);
|
||||
if (Array.isArray(allowed)) {
|
||||
jobSpySites = jobSpySites.filter((site) => allowed.includes(site));
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse error
|
||||
}
|
||||
}
|
||||
|
||||
if (jobSpySites.length > 0) {
|
||||
updateProgress({
|
||||
step: "crawling",
|
||||
detail: `JobSpy: scraping ${jobSpySites.join(", ")}...`,
|
||||
});
|
||||
|
||||
const jobSpyResult = await runJobSpy({
|
||||
sites: jobSpySites,
|
||||
searchTerms,
|
||||
location: settings.jobspyLocation ?? undefined,
|
||||
resultsWanted: settings.jobspyResultsWanted
|
||||
? parseInt(settings.jobspyResultsWanted, 10)
|
||||
: undefined,
|
||||
hoursOld: settings.jobspyHoursOld
|
||||
? parseInt(settings.jobspyHoursOld, 10)
|
||||
: undefined,
|
||||
countryIndeed: settings.jobspyCountryIndeed ?? undefined,
|
||||
linkedinFetchDescription:
|
||||
settings.jobspyLinkedinFetchDescription !== null &&
|
||||
settings.jobspyLinkedinFetchDescription !== undefined
|
||||
? settings.jobspyLinkedinFetchDescription === "1"
|
||||
: undefined,
|
||||
isRemote:
|
||||
settings.jobspyIsRemote !== null &&
|
||||
settings.jobspyIsRemote !== undefined
|
||||
? settings.jobspyIsRemote === "1"
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!jobSpyResult.success) {
|
||||
sourceErrors.push(`jobspy: ${jobSpyResult.error ?? "unknown error"}`);
|
||||
} else {
|
||||
discoveredJobs.push(...jobSpyResult.jobs);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.mergedConfig.sources.includes("gradcracker")) {
|
||||
updateProgress({ step: "crawling", detail: "Gradcracker: scraping..." });
|
||||
|
||||
const existingJobUrls = await jobsRepo.getAllJobUrls();
|
||||
const gradcrackerMaxJobs = settings.gradcrackerMaxJobsPerTerm
|
||||
? parseInt(settings.gradcrackerMaxJobsPerTerm, 10)
|
||||
: 50;
|
||||
|
||||
const crawlerResult = await runCrawler({
|
||||
existingJobUrls,
|
||||
searchTerms,
|
||||
maxJobsPerTerm: gradcrackerMaxJobs,
|
||||
onProgress: (progress) => {
|
||||
if (progress.listPagesTotal && progress.listPagesTotal > 0) {
|
||||
const percent = Math.round(
|
||||
((progress.listPagesProcessed ?? 0) / progress.listPagesTotal) *
|
||||
100,
|
||||
);
|
||||
updateProgress({
|
||||
step: "crawling",
|
||||
detail: `Gradcracker: ${percent}% (scan ${progress.listPagesProcessed}/${progress.listPagesTotal}, found ${progress.jobCardsFound})`,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!crawlerResult.success) {
|
||||
sourceErrors.push(
|
||||
`gradcracker: ${crawlerResult.error ?? "unknown error"}`,
|
||||
);
|
||||
} else {
|
||||
discoveredJobs.push(...crawlerResult.jobs);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.mergedConfig.sources.includes("ukvisajobs")) {
|
||||
updateProgress({
|
||||
step: "crawling",
|
||||
detail: "UKVisaJobs: scraping visa-sponsoring jobs...",
|
||||
});
|
||||
|
||||
const ukvisajobsMaxJobs = settings.ukvisajobsMaxJobs
|
||||
? parseInt(settings.ukvisajobsMaxJobs, 10)
|
||||
: 50;
|
||||
|
||||
const ukVisaResult = await runUkVisaJobs({
|
||||
maxJobs: ukvisajobsMaxJobs,
|
||||
searchTerms,
|
||||
});
|
||||
|
||||
if (!ukVisaResult.success) {
|
||||
sourceErrors.push(`ukvisajobs: ${ukVisaResult.error ?? "unknown error"}`);
|
||||
} else {
|
||||
discoveredJobs.push(...ukVisaResult.jobs);
|
||||
}
|
||||
}
|
||||
|
||||
if (discoveredJobs.length === 0 && sourceErrors.length > 0) {
|
||||
throw new Error(`All sources failed: ${sourceErrors.join("; ")}`);
|
||||
}
|
||||
|
||||
if (sourceErrors.length > 0) {
|
||||
console.warn(`⚠️ Some sources failed: ${sourceErrors.join("; ")}`);
|
||||
}
|
||||
|
||||
progressHelpers.crawlingComplete(discoveredJobs.length);
|
||||
|
||||
return { discoveredJobs, sourceErrors };
|
||||
}
|
||||
17
orchestrator/src/server/pipeline/steps/import-jobs.ts
Normal file
17
orchestrator/src/server/pipeline/steps/import-jobs.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { CreateJobInput } from "@shared/types";
|
||||
import * as jobsRepo from "../../repositories/jobs";
|
||||
import { progressHelpers } from "../progress";
|
||||
|
||||
export async function importJobsStep(args: {
|
||||
discoveredJobs: CreateJobInput[];
|
||||
}): Promise<{ created: number; skipped: number }> {
|
||||
console.log("\n💾 Importing jobs to database...");
|
||||
const { created, skipped } = await jobsRepo.bulkCreateJobs(
|
||||
args.discoveredJobs,
|
||||
);
|
||||
console.log(` Created: ${created}, Skipped (duplicates): ${skipped}`);
|
||||
|
||||
progressHelpers.importComplete(created, skipped);
|
||||
|
||||
return { created, skipped };
|
||||
}
|
||||
8
orchestrator/src/server/pipeline/steps/index.ts
Normal file
8
orchestrator/src/server/pipeline/steps/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export { discoverJobsStep } from "./discover-jobs";
|
||||
export { importJobsStep } from "./import-jobs";
|
||||
export { loadProfileStep } from "./load-profile";
|
||||
export { notifyPipelineWebhookStep } from "./notify-webhook";
|
||||
export { processJobsStep } from "./process-jobs";
|
||||
export { scoreJobsStep } from "./score-jobs";
|
||||
export { selectJobsStep } from "./select-jobs";
|
||||
export type { RunPipelineContext, ScoredJob } from "./types";
|
||||
12
orchestrator/src/server/pipeline/steps/load-profile.ts
Normal file
12
orchestrator/src/server/pipeline/steps/load-profile.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { getProfile } from "../../services/profile";
|
||||
|
||||
export async function loadProfileStep(): Promise<Record<string, unknown>> {
|
||||
console.log("\n📋 Loading profile...");
|
||||
return getProfile().catch((error) => {
|
||||
console.warn(
|
||||
"⚠️ Failed to load profile for scoring, using empty profile:",
|
||||
error,
|
||||
);
|
||||
return {} as Record<string, unknown>;
|
||||
});
|
||||
}
|
||||
43
orchestrator/src/server/pipeline/steps/notify-webhook.ts
Normal file
43
orchestrator/src/server/pipeline/steps/notify-webhook.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import * as settingsRepo from "../../repositories/settings";
|
||||
|
||||
export async function notifyPipelineWebhookStep(
|
||||
event: "pipeline.completed" | "pipeline.failed",
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const overridePipelineWebhookUrl =
|
||||
await settingsRepo.getSetting("pipelineWebhookUrl");
|
||||
const pipelineWebhookUrl = (
|
||||
overridePipelineWebhookUrl ||
|
||||
process.env.PIPELINE_WEBHOOK_URL ||
|
||||
process.env.WEBHOOK_URL ||
|
||||
""
|
||||
).trim();
|
||||
|
||||
if (!pipelineWebhookUrl) return;
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const secret = process.env.WEBHOOK_SECRET;
|
||||
if (secret) headers.Authorization = `Bearer ${secret}`;
|
||||
|
||||
const response = await fetch(pipelineWebhookUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
event,
|
||||
sentAt: new Date().toISOString(),
|
||||
...payload,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`⚠️ Pipeline webhook POST failed (${response.status}): ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Pipeline webhook POST failed:", error);
|
||||
}
|
||||
}
|
||||
39
orchestrator/src/server/pipeline/steps/process-jobs.ts
Normal file
39
orchestrator/src/server/pipeline/steps/process-jobs.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { progressHelpers, updateProgress } from "../progress";
|
||||
import type { ScoredJob } from "./types";
|
||||
|
||||
type ProcessJobFn = (
|
||||
jobId: string,
|
||||
options?: { force?: boolean },
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
export async function processJobsStep(args: {
|
||||
jobsToProcess: ScoredJob[];
|
||||
processJob: ProcessJobFn;
|
||||
}): Promise<{ processedCount: number }> {
|
||||
let processedCount = 0;
|
||||
|
||||
if (args.jobsToProcess.length > 0) {
|
||||
updateProgress({
|
||||
step: "processing",
|
||||
jobsProcessed: 0,
|
||||
totalToProcess: args.jobsToProcess.length,
|
||||
});
|
||||
|
||||
for (let i = 0; i < args.jobsToProcess.length; i++) {
|
||||
const job = args.jobsToProcess[i];
|
||||
progressHelpers.processingJob(i + 1, args.jobsToProcess.length, job);
|
||||
|
||||
const result = await args.processJob(job.id, { force: false });
|
||||
|
||||
if (result.success) {
|
||||
processedCount++;
|
||||
} else {
|
||||
console.warn(` ⚠️ Failed to process job ${job.id}: ${result.error}`);
|
||||
}
|
||||
|
||||
progressHelpers.jobComplete(i + 1, args.jobsToProcess.length);
|
||||
}
|
||||
}
|
||||
|
||||
return { processedCount };
|
||||
}
|
||||
79
orchestrator/src/server/pipeline/steps/score-jobs.ts
Normal file
79
orchestrator/src/server/pipeline/steps/score-jobs.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import type { Job } from "@shared/types";
|
||||
import * as jobsRepo from "../../repositories/jobs";
|
||||
import { scoreJobSuitability } from "../../services/scorer";
|
||||
import * as visaSponsors from "../../services/visa-sponsors/index";
|
||||
import { progressHelpers, updateProgress } from "../progress";
|
||||
import type { ScoredJob } from "./types";
|
||||
|
||||
export async function scoreJobsStep(args: {
|
||||
profile: Record<string, unknown>;
|
||||
}): Promise<{ unprocessedJobs: Job[]; scoredJobs: ScoredJob[] }> {
|
||||
console.log("\n🎯 Scoring jobs for suitability...");
|
||||
const unprocessedJobs = await jobsRepo.getUnscoredDiscoveredJobs();
|
||||
|
||||
updateProgress({
|
||||
step: "scoring",
|
||||
jobsDiscovered: unprocessedJobs.length,
|
||||
jobsScored: 0,
|
||||
jobsProcessed: 0,
|
||||
totalToProcess: 0,
|
||||
currentJob: undefined,
|
||||
});
|
||||
|
||||
const scoredJobs: ScoredJob[] = [];
|
||||
|
||||
for (let i = 0; i < unprocessedJobs.length; i++) {
|
||||
const job = unprocessedJobs[i];
|
||||
const hasCachedScore =
|
||||
typeof job.suitabilityScore === "number" &&
|
||||
!Number.isNaN(job.suitabilityScore);
|
||||
|
||||
progressHelpers.scoringJob(
|
||||
i + 1,
|
||||
unprocessedJobs.length,
|
||||
hasCachedScore ? `${job.title} (cached)` : job.title,
|
||||
);
|
||||
|
||||
if (hasCachedScore) {
|
||||
scoredJobs.push({
|
||||
...job,
|
||||
suitabilityScore: job.suitabilityScore as number,
|
||||
suitabilityReason: job.suitabilityReason ?? "",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const { score, reason } = await scoreJobSuitability(job, args.profile);
|
||||
scoredJobs.push({
|
||||
...job,
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
});
|
||||
|
||||
let sponsorMatchScore = 0;
|
||||
let sponsorMatchNames: string | undefined;
|
||||
|
||||
if (job.employer) {
|
||||
const sponsorResults = visaSponsors.searchSponsors(job.employer, {
|
||||
limit: 10,
|
||||
minScore: 50,
|
||||
});
|
||||
|
||||
const summary = visaSponsors.calculateSponsorMatchSummary(sponsorResults);
|
||||
sponsorMatchScore = summary.sponsorMatchScore;
|
||||
sponsorMatchNames = summary.sponsorMatchNames ?? undefined;
|
||||
}
|
||||
|
||||
await jobsRepo.updateJob(job.id, {
|
||||
suitabilityScore: score,
|
||||
suitabilityReason: reason,
|
||||
sponsorMatchScore,
|
||||
sponsorMatchNames,
|
||||
});
|
||||
}
|
||||
|
||||
progressHelpers.scoringComplete(scoredJobs.length);
|
||||
console.log(`\n📊 Scored ${scoredJobs.length} jobs.`);
|
||||
|
||||
return { unprocessedJobs, scoredJobs };
|
||||
}
|
||||
32
orchestrator/src/server/pipeline/steps/select-jobs.test.ts
Normal file
32
orchestrator/src/server/pipeline/steps/select-jobs.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { PipelineConfig } from "@shared/types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectJobsStep } from "./select-jobs";
|
||||
|
||||
const baseConfig: PipelineConfig = {
|
||||
topN: 2,
|
||||
minSuitabilityScore: 50,
|
||||
sources: ["gradcracker"],
|
||||
outputDir: "./tmp",
|
||||
enableCrawling: true,
|
||||
enableScoring: true,
|
||||
enableImporting: true,
|
||||
enableAutoTailoring: true,
|
||||
};
|
||||
|
||||
describe("selectJobsStep", () => {
|
||||
it("filters by min score, sorts descending, and limits topN", () => {
|
||||
const jobs = [
|
||||
{ id: "a", suitabilityScore: 90, suitabilityReason: "high" },
|
||||
{ id: "b", suitabilityScore: 45, suitabilityReason: "low" },
|
||||
{ id: "c", suitabilityScore: 80, suitabilityReason: "med" },
|
||||
{ id: "d", suitabilityScore: 70, suitabilityReason: "ok" },
|
||||
] as any;
|
||||
|
||||
const selected = selectJobsStep({
|
||||
scoredJobs: jobs,
|
||||
mergedConfig: baseConfig,
|
||||
});
|
||||
|
||||
expect(selected.map((job) => job.id)).toEqual(["a", "c"]);
|
||||
});
|
||||
});
|
||||
18
orchestrator/src/server/pipeline/steps/select-jobs.ts
Normal file
18
orchestrator/src/server/pipeline/steps/select-jobs.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { PipelineConfig } from "@shared/types";
|
||||
import type { ScoredJob } from "./types";
|
||||
|
||||
export function selectJobsStep(args: {
|
||||
scoredJobs: ScoredJob[];
|
||||
mergedConfig: PipelineConfig;
|
||||
}): ScoredJob[] {
|
||||
return args.scoredJobs
|
||||
.filter(
|
||||
(job) =>
|
||||
(job.suitabilityScore ?? 0) >= args.mergedConfig.minSuitabilityScore,
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(right.suitabilityScore ?? 0) - (left.suitabilityScore ?? 0),
|
||||
)
|
||||
.slice(0, args.mergedConfig.topN);
|
||||
}
|
||||
19
orchestrator/src/server/pipeline/steps/types.ts
Normal file
19
orchestrator/src/server/pipeline/steps/types.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { CreateJobInput, Job, PipelineConfig } from "@shared/types";
|
||||
|
||||
export type ScoredJob = Job & {
|
||||
suitabilityScore: number;
|
||||
suitabilityReason: string;
|
||||
};
|
||||
|
||||
export type RunPipelineContext = {
|
||||
mergedConfig: PipelineConfig;
|
||||
profile: Record<string, unknown>;
|
||||
discoveredJobs: CreateJobInput[];
|
||||
sourceErrors: string[];
|
||||
created: number;
|
||||
skipped: number;
|
||||
unprocessedJobs: Job[];
|
||||
scoredJobs: ScoredJob[];
|
||||
jobsToProcess: ScoredJob[];
|
||||
processedCount: number;
|
||||
};
|
||||
@ -1,838 +1,14 @@
|
||||
/**
|
||||
* LLM service with provider-specific strategies and strict-first fallback.
|
||||
* Compatibility facade for legacy imports.
|
||||
* New implementation lives under ./llm/*
|
||||
*/
|
||||
|
||||
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;
|
||||
|
||||
let apiKey =
|
||||
normalizeEnvInput(options.apiKey) ||
|
||||
normalizeEnvInput(process.env.LLM_API_KEY) ||
|
||||
null;
|
||||
|
||||
// Backwards-compat migration: OPENROUTER_API_KEY -> LLM_API_KEY.
|
||||
// This prevents users from losing access when upgrading (keys are often only shown once).
|
||||
if (
|
||||
!apiKey &&
|
||||
resolvedProvider === "openrouter" &&
|
||||
normalizeEnvInput(process.env.OPENROUTER_API_KEY)
|
||||
) {
|
||||
console.warn(
|
||||
"[DEPRECATED] OPENROUTER_API_KEY is deprecated. Copying to LLM_API_KEY; please update your environment.",
|
||||
);
|
||||
const migrated = normalizeEnvInput(process.env.OPENROUTER_API_KEY);
|
||||
if (migrated) {
|
||||
process.env.LLM_API_KEY = migrated;
|
||||
apiKey = migrated;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
export { LlmService } from "./llm/service";
|
||||
export type {
|
||||
JsonSchemaDefinition,
|
||||
LlmProvider,
|
||||
LlmRequestOptions,
|
||||
LlmResponse,
|
||||
LlmValidationResult,
|
||||
} from "./llm/types";
|
||||
export { parseJsonContent } from "./llm/utils/json";
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isCapabilityError } from "./capability-fallback";
|
||||
|
||||
describe("capability fallback policy", () => {
|
||||
it("detects structured output capability errors", () => {
|
||||
expect(
|
||||
isCapabilityError({
|
||||
mode: "json_schema",
|
||||
status: 400,
|
||||
body: "response_format json_schema is unsupported",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes model-not-found errors", () => {
|
||||
expect(
|
||||
isCapabilityError({
|
||||
mode: "json_schema",
|
||||
status: 400,
|
||||
body: "unknown model: test-model",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never treats none mode as capability fallback", () => {
|
||||
expect(
|
||||
isCapabilityError({
|
||||
mode: "none",
|
||||
status: 400,
|
||||
body: "response_format json_schema is unsupported",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,25 @@
|
||||
import type { ResponseMode } from "../types";
|
||||
|
||||
export 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")
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildModeCacheKey,
|
||||
clearModeCache,
|
||||
getOrderedModes,
|
||||
rememberSuccessfulMode,
|
||||
} from "./mode-selection";
|
||||
|
||||
describe("mode-selection policy", () => {
|
||||
it("returns provider modes in declared order when cache is empty", () => {
|
||||
clearModeCache();
|
||||
const key = buildModeCacheKey("openrouter", "https://openrouter.ai");
|
||||
expect(getOrderedModes(key, ["json_schema", "none"])).toEqual([
|
||||
"json_schema",
|
||||
"none",
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers cached mode first", () => {
|
||||
clearModeCache();
|
||||
const key = buildModeCacheKey("lmstudio", "http://localhost:1234");
|
||||
rememberSuccessfulMode(key, "text");
|
||||
|
||||
expect(getOrderedModes(key, ["json_schema", "text", "none"])).toEqual([
|
||||
"text",
|
||||
"json_schema",
|
||||
"none",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps cache scoped by provider+baseUrl", () => {
|
||||
clearModeCache();
|
||||
const keyA = buildModeCacheKey("lmstudio", "http://localhost:1234");
|
||||
const keyB = buildModeCacheKey("lmstudio", "http://localhost:1235");
|
||||
rememberSuccessfulMode(keyA, "text");
|
||||
|
||||
expect(getOrderedModes(keyA, ["json_schema", "text", "none"])).toEqual([
|
||||
"text",
|
||||
"json_schema",
|
||||
"none",
|
||||
]);
|
||||
expect(getOrderedModes(keyB, ["json_schema", "text", "none"])).toEqual([
|
||||
"json_schema",
|
||||
"text",
|
||||
"none",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,28 @@
|
||||
import type { ResponseMode } from "../types";
|
||||
|
||||
const modeCache = new Map<string, ResponseMode>();
|
||||
|
||||
export function buildModeCacheKey(provider: string, baseUrl: string): string {
|
||||
return `${provider}:${baseUrl}`;
|
||||
}
|
||||
|
||||
export function getOrderedModes(
|
||||
cacheKey: string,
|
||||
modes: ResponseMode[],
|
||||
): ResponseMode[] {
|
||||
const cachedMode = modeCache.get(cacheKey);
|
||||
return cachedMode
|
||||
? [cachedMode, ...modes.filter((mode) => mode !== cachedMode)]
|
||||
: modes;
|
||||
}
|
||||
|
||||
export function rememberSuccessfulMode(
|
||||
cacheKey: string,
|
||||
mode: ResponseMode,
|
||||
): void {
|
||||
modeCache.set(cacheKey, mode);
|
||||
}
|
||||
|
||||
export function clearModeCache(): void {
|
||||
modeCache.clear();
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getRetryDelayMs, shouldRetryAttempt } from "./retry-policy";
|
||||
|
||||
describe("retry-policy", () => {
|
||||
it("retries parse errors", () => {
|
||||
expect(
|
||||
shouldRetryAttempt({ message: "Failed to parse JSON", status: 200 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("retries on 429 and 5xx", () => {
|
||||
expect(shouldRetryAttempt({ message: "rate limited", status: 429 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldRetryAttempt({ message: "server error", status: 503 })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("retries timeout and fetch failures", () => {
|
||||
expect(
|
||||
shouldRetryAttempt({ message: "Request timeout occurred", status: 0 }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRetryAttempt({ message: "TypeError: fetch failed", status: 0 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not retry non-retryable 4xx", () => {
|
||||
expect(
|
||||
shouldRetryAttempt({
|
||||
message: "LLM API error: 400 bad request",
|
||||
status: 400,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves backoff multiplier behavior", () => {
|
||||
expect(getRetryDelayMs(500, 1)).toBe(500);
|
||||
expect(getRetryDelayMs(500, 2)).toBe(1000);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,16 @@
|
||||
export function shouldRetryAttempt(args: {
|
||||
message: string;
|
||||
status?: number;
|
||||
}): boolean {
|
||||
return (
|
||||
args.message.includes("parse") ||
|
||||
args.status === 429 ||
|
||||
(args.status !== undefined && args.status >= 500 && args.status <= 599) ||
|
||||
args.message.toLowerCase().includes("timeout") ||
|
||||
args.message.toLowerCase().includes("fetch failed")
|
||||
);
|
||||
}
|
||||
|
||||
export function getRetryDelayMs(baseDelayMs: number, attempt: number): number {
|
||||
return baseDelayMs * attempt;
|
||||
}
|
||||
72
orchestrator/src/server/services/llm/providers/factory.ts
Normal file
72
orchestrator/src/server/services/llm/providers/factory.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { isCapabilityError } from "../policies/capability-fallback";
|
||||
import type {
|
||||
JsonSchemaDefinition,
|
||||
LlmRequestOptions,
|
||||
ProviderStrategy,
|
||||
ResponseMode,
|
||||
} from "../types";
|
||||
import { joinUrl } from "../utils/http";
|
||||
import { getNestedValue } from "../utils/object";
|
||||
|
||||
type ProviderStrategyArgs = Omit<
|
||||
ProviderStrategy,
|
||||
"isCapabilityError" | "getValidationUrls"
|
||||
> & {
|
||||
getValidationUrls?: ProviderStrategy["getValidationUrls"];
|
||||
};
|
||||
|
||||
export function createProviderStrategy(
|
||||
args: ProviderStrategyArgs,
|
||||
): ProviderStrategy {
|
||||
return {
|
||||
...args,
|
||||
isCapabilityError: ({ mode, status, body }) =>
|
||||
isCapabilityError({ mode, status, body }),
|
||||
getValidationUrls:
|
||||
args.getValidationUrls ??
|
||||
(({ baseUrl }) =>
|
||||
args.validationPaths.map((path) => joinUrl(baseUrl, path))),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChatCompletionsBody(args: {
|
||||
mode: ResponseMode;
|
||||
model: string;
|
||||
messages: LlmRequestOptions<unknown>["messages"];
|
||||
jsonSchema: JsonSchemaDefinition;
|
||||
extra?: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
model: args.model,
|
||||
messages: args.messages,
|
||||
stream: false,
|
||||
...(args.extra ?? {}),
|
||||
};
|
||||
|
||||
if (args.mode === "json_schema") {
|
||||
body.response_format = {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: args.jsonSchema.name,
|
||||
strict: true,
|
||||
schema: args.jsonSchema.schema,
|
||||
},
|
||||
};
|
||||
} else if (args.mode === "json_object") {
|
||||
body.response_format = { type: "json_object" };
|
||||
} else if (args.mode === "text") {
|
||||
body.response_format = { type: "text" };
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export function extractChatCompletionsText(response: unknown): string | null {
|
||||
const content = getNestedValue(response, [
|
||||
"choices",
|
||||
0,
|
||||
"message",
|
||||
"content",
|
||||
]);
|
||||
return typeof content === "string" ? content : null;
|
||||
}
|
||||
89
orchestrator/src/server/services/llm/providers/gemini.ts
Normal file
89
orchestrator/src/server/services/llm/providers/gemini.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import type { LlmRequestOptions } from "../types";
|
||||
import { addQueryParam, buildHeaders, joinUrl } from "../utils/http";
|
||||
import { getNestedValue } from "../utils/object";
|
||||
import { createProviderStrategy } from "./factory";
|
||||
|
||||
export const geminiStrategy = createProviderStrategy({
|
||||
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;
|
||||
},
|
||||
getValidationUrls: ({ baseUrl, apiKey }) => {
|
||||
const url = joinUrl(baseUrl, "/v1beta/models");
|
||||
return [addQueryParam(url, "key", apiKey ?? "")];
|
||||
},
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
14
orchestrator/src/server/services/llm/providers/index.ts
Normal file
14
orchestrator/src/server/services/llm/providers/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { LlmProvider, ProviderStrategy } from "../types";
|
||||
import { geminiStrategy } from "./gemini";
|
||||
import { lmStudioStrategy } from "./lmstudio";
|
||||
import { ollamaStrategy } from "./ollama";
|
||||
import { openAiStrategy } from "./openai";
|
||||
import { openRouterStrategy } from "./openrouter";
|
||||
|
||||
export const strategies: Record<LlmProvider, ProviderStrategy> = {
|
||||
openrouter: openRouterStrategy,
|
||||
lmstudio: lmStudioStrategy,
|
||||
ollama: ollamaStrategy,
|
||||
openai: openAiStrategy,
|
||||
gemini: geminiStrategy,
|
||||
};
|
||||
22
orchestrator/src/server/services/llm/providers/lmstudio.ts
Normal file
22
orchestrator/src/server/services/llm/providers/lmstudio.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { buildHeaders, joinUrl } from "../utils/http";
|
||||
import {
|
||||
buildChatCompletionsBody,
|
||||
createProviderStrategy,
|
||||
extractChatCompletionsText,
|
||||
} from "./factory";
|
||||
|
||||
export const lmStudioStrategy = createProviderStrategy({
|
||||
provider: "lmstudio",
|
||||
defaultBaseUrl: "http://localhost:1234",
|
||||
requiresApiKey: false,
|
||||
modes: ["json_schema", "text", "none"],
|
||||
validationPaths: ["/v1/models"],
|
||||
buildRequest: ({ mode, baseUrl, model, messages, jsonSchema }) => {
|
||||
return {
|
||||
url: joinUrl(baseUrl, "/v1/chat/completions"),
|
||||
headers: buildHeaders({ apiKey: null, provider: "lmstudio" }),
|
||||
body: buildChatCompletionsBody({ mode, model, messages, jsonSchema }),
|
||||
};
|
||||
},
|
||||
extractText: extractChatCompletionsText,
|
||||
});
|
||||
22
orchestrator/src/server/services/llm/providers/ollama.ts
Normal file
22
orchestrator/src/server/services/llm/providers/ollama.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { buildHeaders, joinUrl } from "../utils/http";
|
||||
import {
|
||||
buildChatCompletionsBody,
|
||||
createProviderStrategy,
|
||||
extractChatCompletionsText,
|
||||
} from "./factory";
|
||||
|
||||
export const ollamaStrategy = createProviderStrategy({
|
||||
provider: "ollama",
|
||||
defaultBaseUrl: "http://localhost:11434",
|
||||
requiresApiKey: false,
|
||||
modes: ["json_schema", "text", "none"],
|
||||
validationPaths: ["/v1/models", "/api/tags"],
|
||||
buildRequest: ({ mode, baseUrl, model, messages, jsonSchema }) => {
|
||||
return {
|
||||
url: joinUrl(baseUrl, "/v1/chat/completions"),
|
||||
headers: buildHeaders({ apiKey: null, provider: "ollama" }),
|
||||
body: buildChatCompletionsBody({ mode, model, messages, jsonSchema }),
|
||||
};
|
||||
},
|
||||
extractText: extractChatCompletionsText,
|
||||
});
|
||||
76
orchestrator/src/server/services/llm/providers/openai.ts
Normal file
76
orchestrator/src/server/services/llm/providers/openai.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { LlmRequestOptions, ResponseMode } from "../types";
|
||||
import { buildHeaders, joinUrl } from "../utils/http";
|
||||
import { getNestedValue } from "../utils/object";
|
||||
import { createProviderStrategy } from "./factory";
|
||||
|
||||
export const openAiStrategy = createProviderStrategy({
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
];
|
||||
}
|
||||
28
orchestrator/src/server/services/llm/providers/openrouter.ts
Normal file
28
orchestrator/src/server/services/llm/providers/openrouter.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { buildHeaders, joinUrl } from "../utils/http";
|
||||
import {
|
||||
buildChatCompletionsBody,
|
||||
createProviderStrategy,
|
||||
extractChatCompletionsText,
|
||||
} from "./factory";
|
||||
|
||||
export const openRouterStrategy = createProviderStrategy({
|
||||
provider: "openrouter",
|
||||
defaultBaseUrl: "https://openrouter.ai",
|
||||
requiresApiKey: true,
|
||||
modes: ["json_schema", "none"],
|
||||
validationPaths: ["/api/v1/key"],
|
||||
buildRequest: ({ mode, baseUrl, apiKey, model, messages, jsonSchema }) => {
|
||||
return {
|
||||
url: joinUrl(baseUrl, "/api/v1/chat/completions"),
|
||||
headers: buildHeaders({ apiKey, provider: "openrouter" }),
|
||||
body: buildChatCompletionsBody({
|
||||
mode,
|
||||
model,
|
||||
messages,
|
||||
jsonSchema,
|
||||
extra: { plugins: [{ id: "response-healing" }] },
|
||||
}),
|
||||
};
|
||||
},
|
||||
extractText: extractChatCompletionsText,
|
||||
});
|
||||
137
orchestrator/src/server/services/llm/providers/providers.test.ts
Normal file
137
orchestrator/src/server/services/llm/providers/providers.test.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { geminiStrategy } from "./gemini";
|
||||
import { lmStudioStrategy } from "./lmstudio";
|
||||
import { ollamaStrategy } from "./ollama";
|
||||
import { openAiStrategy } from "./openai";
|
||||
import { openRouterStrategy } from "./openrouter";
|
||||
|
||||
const schema = {
|
||||
name: "test_schema",
|
||||
schema: {
|
||||
type: "object" as const,
|
||||
properties: { value: { type: "string" } },
|
||||
required: ["value"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
|
||||
const messages = [{ role: "user" as const, content: "hello" }];
|
||||
|
||||
describe("provider adapters", () => {
|
||||
it("builds requests for each provider/mode path", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "openrouter-json_schema",
|
||||
strategy: openRouterStrategy,
|
||||
args: {
|
||||
mode: "json_schema" as const,
|
||||
baseUrl: "https://openrouter.ai",
|
||||
apiKey: "x",
|
||||
model: "model-a",
|
||||
},
|
||||
expectedUrl: "https://openrouter.ai/api/v1/chat/completions",
|
||||
expectedResponseFormat: "json_schema",
|
||||
},
|
||||
{
|
||||
name: "openai-json_object",
|
||||
strategy: openAiStrategy,
|
||||
args: {
|
||||
mode: "json_object" as const,
|
||||
baseUrl: "https://api.openai.com",
|
||||
apiKey: "x",
|
||||
model: "model-a",
|
||||
},
|
||||
expectedUrl: "https://api.openai.com/v1/responses",
|
||||
},
|
||||
{
|
||||
name: "gemini-json_schema",
|
||||
strategy: geminiStrategy,
|
||||
args: {
|
||||
mode: "json_schema" as const,
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
apiKey: "x",
|
||||
model: "gemini-1.5-flash",
|
||||
},
|
||||
expectedUrlContains: [":generateContent", "key=x"],
|
||||
},
|
||||
{
|
||||
name: "lmstudio-text",
|
||||
strategy: lmStudioStrategy,
|
||||
args: {
|
||||
mode: "text" as const,
|
||||
baseUrl: "http://localhost:1234",
|
||||
apiKey: null,
|
||||
model: "local",
|
||||
},
|
||||
expectedUrl: "http://localhost:1234/v1/chat/completions",
|
||||
expectedResponseFormat: "text",
|
||||
},
|
||||
{
|
||||
name: "ollama-none",
|
||||
strategy: ollamaStrategy,
|
||||
args: {
|
||||
mode: "none" as const,
|
||||
baseUrl: "http://localhost:11434",
|
||||
apiKey: null,
|
||||
model: "local",
|
||||
},
|
||||
expectedUrl: "http://localhost:11434/v1/chat/completions",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const request = testCase.strategy.buildRequest({
|
||||
...testCase.args,
|
||||
messages,
|
||||
jsonSchema: schema,
|
||||
});
|
||||
|
||||
if (testCase.expectedUrl) {
|
||||
expect(request.url, testCase.name).toBe(testCase.expectedUrl);
|
||||
}
|
||||
if (testCase.expectedUrlContains) {
|
||||
for (const expectedPart of testCase.expectedUrlContains) {
|
||||
expect(request.url, testCase.name).toContain(expectedPart);
|
||||
}
|
||||
}
|
||||
|
||||
if (testCase.expectedResponseFormat) {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
expect(
|
||||
(body.response_format as Record<string, unknown>).type,
|
||||
testCase.name,
|
||||
).toBe(testCase.expectedResponseFormat);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("extracts text consistently for chat-completions providers", () => {
|
||||
const response = {
|
||||
choices: [{ message: { content: "ok" } }],
|
||||
};
|
||||
expect(openRouterStrategy.extractText(response)).toBe("ok");
|
||||
expect(lmStudioStrategy.extractText(response)).toBe("ok");
|
||||
expect(ollamaStrategy.extractText(response)).toBe("ok");
|
||||
});
|
||||
|
||||
it("extracts text for openai and gemini variants", () => {
|
||||
expect(openAiStrategy.extractText({ output_text: "openai-direct" })).toBe(
|
||||
"openai-direct",
|
||||
);
|
||||
expect(
|
||||
openAiStrategy.extractText({
|
||||
output: [
|
||||
{
|
||||
content: [{ type: "output_text", text: "openai-nested" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe("openai-nested");
|
||||
|
||||
expect(
|
||||
geminiStrategy.extractText({
|
||||
candidates: [{ content: { parts: [{ text: "gemini" }] } }],
|
||||
}),
|
||||
).toBe("gemini");
|
||||
});
|
||||
});
|
||||
283
orchestrator/src/server/services/llm/service.ts
Normal file
283
orchestrator/src/server/services/llm/service.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import { toStringOrNull } from "@shared/utils/type-conversion";
|
||||
import {
|
||||
buildModeCacheKey,
|
||||
getOrderedModes,
|
||||
rememberSuccessfulMode,
|
||||
} from "./policies/mode-selection";
|
||||
import { getRetryDelayMs, shouldRetryAttempt } from "./policies/retry-policy";
|
||||
import { strategies } from "./providers";
|
||||
import type {
|
||||
JsonSchemaDefinition,
|
||||
LlmApiError,
|
||||
LlmProvider,
|
||||
LlmRequestOptions,
|
||||
LlmResponse,
|
||||
LlmServiceOptions,
|
||||
LlmValidationResult,
|
||||
ResponseMode,
|
||||
} from "./types";
|
||||
import { buildHeaders, getResponseDetail } from "./utils/http";
|
||||
import { parseJsonContent } from "./utils/json";
|
||||
import { parseErrorMessage, truncate } from "./utils/string";
|
||||
|
||||
export class LlmService {
|
||||
private readonly provider: LlmProvider;
|
||||
private readonly baseUrl: string;
|
||||
private readonly apiKey: string | null;
|
||||
private readonly strategy: (typeof strategies)[LlmProvider];
|
||||
|
||||
constructor(options: LlmServiceOptions = {}) {
|
||||
const normalizedBaseUrl =
|
||||
toStringOrNull(options.baseUrl) ||
|
||||
toStringOrNull(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;
|
||||
|
||||
let apiKey =
|
||||
toStringOrNull(options.apiKey) ||
|
||||
toStringOrNull(process.env.LLM_API_KEY) ||
|
||||
null;
|
||||
|
||||
// Backwards-compat migration: OPENROUTER_API_KEY -> LLM_API_KEY.
|
||||
// This prevents users from losing access when upgrading (keys are often only shown once).
|
||||
if (
|
||||
!apiKey &&
|
||||
resolvedProvider === "openrouter" &&
|
||||
toStringOrNull(process.env.OPENROUTER_API_KEY)
|
||||
) {
|
||||
console.warn(
|
||||
"[DEPRECATED] OPENROUTER_API_KEY is deprecated. Copying to LLM_API_KEY; please update your environment.",
|
||||
);
|
||||
const migrated = toStringOrNull(process.env.OPENROUTER_API_KEY);
|
||||
if (migrated) {
|
||||
process.env.LLM_API_KEY = migrated;
|
||||
apiKey = migrated;
|
||||
}
|
||||
}
|
||||
|
||||
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 = buildModeCacheKey(this.provider, this.baseUrl);
|
||||
const modes = getOrderedModes(cacheKey, this.strategy.modes);
|
||||
|
||||
for (const mode of modes) {
|
||||
const result = await this.tryMode<T>({
|
||||
mode,
|
||||
model,
|
||||
messages,
|
||||
jsonSchema,
|
||||
maxRetries,
|
||||
retryDelayMs,
|
||||
jobId,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
rememberSuccessfulMode(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(getRetryDelayMs(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}` };
|
||||
}
|
||||
|
||||
if (attempt < maxRetries && shouldRetryAttempt({ message, status })) {
|
||||
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" };
|
||||
}
|
||||
}
|
||||
|
||||
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 sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
87
orchestrator/src/server/services/llm/types.ts
Normal file
87
orchestrator/src/server/services/llm/types.ts
Normal file
@ -0,0 +1,87 @@
|
||||
export type LlmProvider =
|
||||
| "openrouter"
|
||||
| "lmstudio"
|
||||
| "ollama"
|
||||
| "openai"
|
||||
| "gemini";
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export type LlmServiceOptions = {
|
||||
provider?: string | null;
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string | null;
|
||||
};
|
||||
|
||||
export 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[];
|
||||
};
|
||||
|
||||
export interface LlmApiError extends Error {
|
||||
status?: number;
|
||||
body?: string;
|
||||
}
|
||||
51
orchestrator/src/server/services/llm/utils/http.ts
Normal file
51
orchestrator/src/server/services/llm/utils/http.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import type { LlmProvider } from "../types";
|
||||
|
||||
export function joinUrl(baseUrl: string, path: string): string {
|
||||
const base = baseUrl.replace(/\/+$/, "");
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${base}${suffix}`;
|
||||
}
|
||||
|
||||
export function addQueryParam(url: string, key: string, value: string): string {
|
||||
const connector = url.includes("?") ? "&" : "?";
|
||||
return `${url}${connector}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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(() => "");
|
||||
}
|
||||
27
orchestrator/src/server/services/llm/utils/json.ts
Normal file
27
orchestrator/src/server/services/llm/utils/json.ts
Normal file
@ -0,0 +1,27 @@
|
||||
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"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
19
orchestrator/src/server/services/llm/utils/object.ts
Normal file
19
orchestrator/src/server/services/llm/utils/object.ts
Normal file
@ -0,0 +1,19 @@
|
||||
type PathSegment = string | number;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
37
orchestrator/src/server/services/llm/utils/string.ts
Normal file
37
orchestrator/src/server/services/llm/utils/string.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { getNestedValue } from "./object";
|
||||
|
||||
export function truncate(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
return `${value.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
81
orchestrator/src/server/services/settings-conversion.test.ts
Normal file
81
orchestrator/src/server/services/settings-conversion.test.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveSettingValue,
|
||||
serializeSettingValue,
|
||||
} from "./settings-conversion";
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
describe("settings-conversion", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("round-trips numeric settings", () => {
|
||||
const serialized = serializeSettingValue("ukvisajobsMaxJobs", 42);
|
||||
expect(serialized).toBe("42");
|
||||
|
||||
const resolved = resolveSettingValue(
|
||||
"ukvisajobsMaxJobs",
|
||||
serialized ?? undefined,
|
||||
);
|
||||
expect(resolved.overrideValue).toBe(42);
|
||||
expect(resolved.value).toBe(42);
|
||||
expect(resolved.defaultValue).toBe(50);
|
||||
});
|
||||
|
||||
it("round-trips boolean bit settings", () => {
|
||||
expect(serializeSettingValue("jobspyIsRemote", true)).toBe("1");
|
||||
expect(serializeSettingValue("jobspyIsRemote", false)).toBe("0");
|
||||
|
||||
expect(resolveSettingValue("jobspyIsRemote", "1").value).toBe(true);
|
||||
expect(resolveSettingValue("jobspyIsRemote", "0").value).toBe(false);
|
||||
expect(resolveSettingValue("jobspyIsRemote", "true").value).toBe(true);
|
||||
expect(resolveSettingValue("jobspyIsRemote", "false").value).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips JSON array settings", () => {
|
||||
const serialized = serializeSettingValue("searchTerms", [
|
||||
"backend",
|
||||
"platform",
|
||||
]);
|
||||
expect(serialized).toBe('["backend","platform"]');
|
||||
|
||||
const resolved = resolveSettingValue(
|
||||
"searchTerms",
|
||||
serialized ?? undefined,
|
||||
);
|
||||
expect(resolved.overrideValue).toEqual(["backend", "platform"]);
|
||||
expect(resolved.value).toEqual(["backend", "platform"]);
|
||||
});
|
||||
|
||||
it("uses string defaults when override is empty", () => {
|
||||
process.env.JOBSPY_LOCATION = "Remote";
|
||||
const resolved = resolveSettingValue("jobspyLocation", "");
|
||||
expect(resolved.defaultValue).toBe("Remote");
|
||||
expect(resolved.overrideValue).toBe("");
|
||||
expect(resolved.value).toBe("Remote");
|
||||
});
|
||||
|
||||
it("applies clamped backup value parsing", () => {
|
||||
expect(resolveSettingValue("backupHour", "26").value).toBe(23);
|
||||
expect(resolveSettingValue("backupMaxCount", "0").value).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to default for invalid numeric overrides", () => {
|
||||
const resolved = resolveSettingValue("ukvisajobsMaxJobs", "not-a-number");
|
||||
expect(resolved.overrideValue).toBeNull();
|
||||
expect(resolved.value).toBe(50);
|
||||
});
|
||||
|
||||
it("falls back to default for invalid JSON array overrides", () => {
|
||||
const objectOverride = resolveSettingValue("searchTerms", '{"term":"x"}');
|
||||
expect(objectOverride.overrideValue).toBeNull();
|
||||
expect(objectOverride.value).toEqual(["web developer"]);
|
||||
|
||||
const malformedOverride = resolveSettingValue("searchTerms", "[oops");
|
||||
expect(malformedOverride.overrideValue).toBeNull();
|
||||
expect(malformedOverride.value).toEqual(["web developer"]);
|
||||
});
|
||||
});
|
||||
223
orchestrator/src/server/services/settings-conversion.ts
Normal file
223
orchestrator/src/server/services/settings-conversion.ts
Normal file
@ -0,0 +1,223 @@
|
||||
type SettingMetadata<T, Input = T | null | undefined> = {
|
||||
defaultValue: () => T;
|
||||
parseOverride: (raw: string | undefined) => T | null;
|
||||
serialize: (value: Input) => string | null;
|
||||
resolve: (args: { defaultValue: T; overrideValue: T | null }) => T;
|
||||
};
|
||||
|
||||
type SettingsConversionValueMap = {
|
||||
ukvisajobsMaxJobs: number;
|
||||
gradcrackerMaxJobsPerTerm: number;
|
||||
searchTerms: string[];
|
||||
jobspyLocation: string;
|
||||
jobspyResultsWanted: number;
|
||||
jobspyHoursOld: number;
|
||||
jobspyCountryIndeed: string;
|
||||
jobspySites: string[];
|
||||
jobspyLinkedinFetchDescription: boolean;
|
||||
jobspyIsRemote: boolean;
|
||||
showSponsorInfo: boolean;
|
||||
backupEnabled: boolean;
|
||||
backupHour: number;
|
||||
backupMaxCount: number;
|
||||
};
|
||||
|
||||
type SettingsConversionInputMap = {
|
||||
[K in keyof SettingsConversionValueMap]:
|
||||
| SettingsConversionValueMap[K]
|
||||
| null
|
||||
| undefined;
|
||||
};
|
||||
|
||||
type SettingsConversionMetadata = {
|
||||
[K in keyof SettingsConversionValueMap]: SettingMetadata<
|
||||
SettingsConversionValueMap[K],
|
||||
SettingsConversionInputMap[K]
|
||||
>;
|
||||
};
|
||||
|
||||
export type SettingsConversionKey = keyof SettingsConversionValueMap;
|
||||
|
||||
function parseIntOrNull(raw: string | undefined): number | null {
|
||||
if (!raw) return null;
|
||||
const parsed = parseInt(raw, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
function parseJsonArrayOrNull(raw: string | undefined): string[] | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as string[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseBitBoolOrNull(raw: string | undefined): boolean | null {
|
||||
if (!raw) return null;
|
||||
return raw === "true" || raw === "1";
|
||||
}
|
||||
|
||||
function serializeNullableNumber(
|
||||
value: number | null | undefined,
|
||||
): string | null {
|
||||
return value !== null && value !== undefined ? String(value) : null;
|
||||
}
|
||||
|
||||
function serializeNullableJsonArray(
|
||||
value: string[] | null | undefined,
|
||||
): string | null {
|
||||
return value !== null && value !== undefined ? JSON.stringify(value) : null;
|
||||
}
|
||||
|
||||
function serializeBitBool(value: boolean | null | undefined): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
return value ? "1" : "0";
|
||||
}
|
||||
|
||||
function resolveWithNullishFallback<T>(args: {
|
||||
defaultValue: T;
|
||||
overrideValue: T | null;
|
||||
}): T {
|
||||
return args.overrideValue ?? args.defaultValue;
|
||||
}
|
||||
|
||||
function resolveWithEmptyStringFallback(args: {
|
||||
defaultValue: string;
|
||||
overrideValue: string | null;
|
||||
}): string {
|
||||
return args.overrideValue || args.defaultValue;
|
||||
}
|
||||
|
||||
export const settingsConversionMetadata: SettingsConversionMetadata = {
|
||||
ukvisajobsMaxJobs: {
|
||||
defaultValue: () => 50,
|
||||
parseOverride: parseIntOrNull,
|
||||
serialize: serializeNullableNumber,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
gradcrackerMaxJobsPerTerm: {
|
||||
defaultValue: () => 50,
|
||||
parseOverride: parseIntOrNull,
|
||||
serialize: serializeNullableNumber,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
searchTerms: {
|
||||
defaultValue: () =>
|
||||
(process.env.JOBSPY_SEARCH_TERMS || "web developer")
|
||||
.split("|")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
parseOverride: parseJsonArrayOrNull,
|
||||
serialize: serializeNullableJsonArray,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
jobspyLocation: {
|
||||
defaultValue: () => process.env.JOBSPY_LOCATION || "UK",
|
||||
parseOverride: (raw) => raw ?? null,
|
||||
serialize: (value) => value ?? null,
|
||||
resolve: resolveWithEmptyStringFallback,
|
||||
},
|
||||
jobspyResultsWanted: {
|
||||
defaultValue: () =>
|
||||
parseInt(process.env.JOBSPY_RESULTS_WANTED || "200", 10),
|
||||
parseOverride: parseIntOrNull,
|
||||
serialize: serializeNullableNumber,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
jobspyHoursOld: {
|
||||
defaultValue: () => parseInt(process.env.JOBSPY_HOURS_OLD || "72", 10),
|
||||
parseOverride: parseIntOrNull,
|
||||
serialize: serializeNullableNumber,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
jobspyCountryIndeed: {
|
||||
defaultValue: () => process.env.JOBSPY_COUNTRY_INDEED || "UK",
|
||||
parseOverride: (raw) => raw ?? null,
|
||||
serialize: (value) => value ?? null,
|
||||
resolve: resolveWithEmptyStringFallback,
|
||||
},
|
||||
jobspySites: {
|
||||
defaultValue: () =>
|
||||
(process.env.JOBSPY_SITES || "indeed,linkedin")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
parseOverride: parseJsonArrayOrNull,
|
||||
serialize: serializeNullableJsonArray,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
jobspyLinkedinFetchDescription: {
|
||||
defaultValue: () =>
|
||||
(process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || "1") === "1",
|
||||
parseOverride: parseBitBoolOrNull,
|
||||
serialize: serializeBitBool,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
jobspyIsRemote: {
|
||||
defaultValue: () => (process.env.JOBSPY_IS_REMOTE || "0") === "1",
|
||||
parseOverride: parseBitBoolOrNull,
|
||||
serialize: serializeBitBool,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
showSponsorInfo: {
|
||||
defaultValue: () => true,
|
||||
parseOverride: parseBitBoolOrNull,
|
||||
serialize: serializeBitBool,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
backupEnabled: {
|
||||
defaultValue: () => false,
|
||||
parseOverride: parseBitBoolOrNull,
|
||||
serialize: serializeBitBool,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
backupHour: {
|
||||
defaultValue: () => 2,
|
||||
parseOverride: (raw) => {
|
||||
const parsed = raw ? parseInt(raw, 10) : NaN;
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return Math.min(23, Math.max(0, parsed));
|
||||
},
|
||||
serialize: serializeNullableNumber,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
backupMaxCount: {
|
||||
defaultValue: () => 5,
|
||||
parseOverride: (raw) => {
|
||||
const parsed = raw ? parseInt(raw, 10) : NaN;
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return Math.min(5, Math.max(1, parsed));
|
||||
},
|
||||
serialize: serializeNullableNumber,
|
||||
resolve: resolveWithNullishFallback,
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveSettingValue<K extends SettingsConversionKey>(
|
||||
key: K,
|
||||
raw: string | undefined,
|
||||
): {
|
||||
defaultValue: SettingsConversionValueMap[K];
|
||||
overrideValue: SettingsConversionValueMap[K] | null;
|
||||
value: SettingsConversionValueMap[K];
|
||||
} {
|
||||
const metadata = settingsConversionMetadata[key];
|
||||
const defaultValue = metadata.defaultValue();
|
||||
const overrideValue = metadata.parseOverride(raw);
|
||||
const value = metadata.resolve({
|
||||
defaultValue,
|
||||
overrideValue,
|
||||
});
|
||||
|
||||
return { defaultValue, overrideValue, value };
|
||||
}
|
||||
|
||||
export function serializeSettingValue<K extends SettingsConversionKey>(
|
||||
key: K,
|
||||
value: SettingsConversionInputMap[K],
|
||||
): string | null {
|
||||
const metadata = settingsConversionMetadata[key];
|
||||
return metadata.serialize(value);
|
||||
}
|
||||
@ -0,0 +1,149 @@
|
||||
import type { ResumeProjectsSettingsInput } from "@shared/settings-schema";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { applySettingsUpdates } from "./apply-updates";
|
||||
|
||||
vi.mock("@server/repositories/settings", () => ({
|
||||
setSetting: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@server/services/envSettings", () => ({
|
||||
applyEnvValue: vi.fn(),
|
||||
normalizeEnvInput: (value: string | null | undefined) => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@server/services/profile", () => ({
|
||||
getProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@server/services/resumeProjects", () => ({
|
||||
extractProjectsFromProfile: vi.fn(),
|
||||
normalizeResumeProjectsSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("applySettingsUpdates", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("applies representative handlers and env side effects", async () => {
|
||||
const settingsRepo = await import("@server/repositories/settings");
|
||||
const envSettings = await import("@server/services/envSettings");
|
||||
|
||||
const plan = await applySettingsUpdates({
|
||||
model: "gpt-4o-mini",
|
||||
ukvisajobsMaxJobs: 42,
|
||||
searchTerms: ["backend", "platform"],
|
||||
jobspyIsRemote: true,
|
||||
llmProvider: "openai",
|
||||
});
|
||||
|
||||
expect(settingsRepo.setSetting).toHaveBeenCalledTimes(5);
|
||||
expect(vi.mocked(settingsRepo.setSetting).mock.calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
["model", "gpt-4o-mini"],
|
||||
["ukvisajobsMaxJobs", "42"],
|
||||
["searchTerms", '["backend","platform"]'],
|
||||
["jobspyIsRemote", "1"],
|
||||
["llmProvider", "openai"],
|
||||
]),
|
||||
);
|
||||
expect(envSettings.applyEnvValue).toHaveBeenCalledWith(
|
||||
"LLM_PROVIDER",
|
||||
"openai",
|
||||
);
|
||||
expect(plan.shouldRefreshBackupScheduler).toBe(false);
|
||||
});
|
||||
|
||||
it("handles deprecated openrouterApiKey migration path", async () => {
|
||||
const settingsRepo = await import("@server/repositories/settings");
|
||||
const envSettings = await import("@server/services/envSettings");
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
await applySettingsUpdates({
|
||||
openrouterApiKey: " legacy-key ",
|
||||
});
|
||||
|
||||
expect(vi.mocked(settingsRepo.setSetting).mock.calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
["llmApiKey", "legacy-key"],
|
||||
["openrouterApiKey", null],
|
||||
]),
|
||||
);
|
||||
expect(envSettings.applyEnvValue).toHaveBeenCalledWith(
|
||||
"LLM_API_KEY",
|
||||
"legacy-key",
|
||||
);
|
||||
expect(envSettings.applyEnvValue).toHaveBeenCalledWith(
|
||||
"OPENROUTER_API_KEY",
|
||||
null,
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledOnce();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("marks backup scheduler refresh when backup settings are changed", async () => {
|
||||
const settingsRepo = await import("@server/repositories/settings");
|
||||
|
||||
const plan = await applySettingsUpdates({
|
||||
backupEnabled: false,
|
||||
backupHour: 4,
|
||||
});
|
||||
|
||||
expect(vi.mocked(settingsRepo.setSetting).mock.calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
["backupEnabled", "0"],
|
||||
["backupHour", "4"],
|
||||
]),
|
||||
);
|
||||
expect(plan.shouldRefreshBackupScheduler).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves and persists normalized resumeProjects", async () => {
|
||||
const settingsRepo = await import("@server/repositories/settings");
|
||||
const profileService = await import("@server/services/profile");
|
||||
const resumeProjectsService = await import(
|
||||
"@server/services/resumeProjects"
|
||||
);
|
||||
|
||||
const input: ResumeProjectsSettingsInput = {
|
||||
maxProjects: 2,
|
||||
lockedProjectIds: ["proj-1"],
|
||||
aiSelectableProjectIds: ["proj-2"],
|
||||
};
|
||||
const normalized: ResumeProjectsSettingsInput = {
|
||||
maxProjects: 1,
|
||||
lockedProjectIds: ["proj-1"],
|
||||
aiSelectableProjectIds: [],
|
||||
};
|
||||
|
||||
vi.mocked(profileService.getProfile).mockResolvedValue({} as any);
|
||||
vi.mocked(resumeProjectsService.extractProjectsFromProfile).mockReturnValue(
|
||||
{
|
||||
catalog: [{ id: "proj-1" }, { id: "proj-2" }] as any,
|
||||
selectionItems: [],
|
||||
},
|
||||
);
|
||||
vi.mocked(
|
||||
resumeProjectsService.normalizeResumeProjectsSettings,
|
||||
).mockReturnValue(normalized as any);
|
||||
|
||||
await applySettingsUpdates({ resumeProjects: input });
|
||||
|
||||
expect(profileService.getProfile).toHaveBeenCalledOnce();
|
||||
const allowedSet = vi.mocked(
|
||||
resumeProjectsService.normalizeResumeProjectsSettings,
|
||||
).mock.calls[0]?.[1];
|
||||
expect(allowedSet).toBeInstanceOf(Set);
|
||||
expect(Array.from(allowedSet as Set<string>).sort()).toEqual([
|
||||
"proj-1",
|
||||
"proj-2",
|
||||
]);
|
||||
expect(vi.mocked(settingsRepo.setSetting)).toHaveBeenCalledWith(
|
||||
"resumeProjects",
|
||||
JSON.stringify(normalized),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,46 @@
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||
import type {
|
||||
DeferredSideEffect,
|
||||
SettingsUpdateAction,
|
||||
SettingsUpdateContext,
|
||||
SettingsUpdatePlan,
|
||||
SettingUpdateHandler,
|
||||
} from "./registry";
|
||||
import { settingsUpdateRegistry } from "./registry";
|
||||
|
||||
async function runAction(action: SettingsUpdateAction): Promise<void> {
|
||||
await action.persist();
|
||||
if (action.sideEffect) {
|
||||
await action.sideEffect();
|
||||
}
|
||||
}
|
||||
|
||||
export async function applySettingsUpdates(
|
||||
input: UpdateSettingsInput,
|
||||
): Promise<SettingsUpdatePlan> {
|
||||
const context: SettingsUpdateContext = { input };
|
||||
const actions: SettingsUpdateAction[] = [];
|
||||
const deferredSideEffects = new Set<DeferredSideEffect>();
|
||||
|
||||
const keys = Object.keys(input) as Array<keyof UpdateSettingsInput>;
|
||||
for (const key of keys) {
|
||||
const handler = settingsUpdateRegistry[key] as
|
||||
| SettingUpdateHandler<typeof key>
|
||||
| undefined;
|
||||
if (!handler) continue;
|
||||
|
||||
const result = await handler({ key, value: input[key], context });
|
||||
actions.push(...result.actions);
|
||||
for (const deferred of result.deferredSideEffects) {
|
||||
deferredSideEffects.add(deferred);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(actions.map(runAction));
|
||||
|
||||
return {
|
||||
shouldRefreshBackupScheduler: deferredSideEffects.has(
|
||||
"refreshBackupScheduler",
|
||||
),
|
||||
};
|
||||
}
|
||||
16
orchestrator/src/server/services/settings-update/index.ts
Normal file
16
orchestrator/src/server/services/settings-update/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export { applySettingsUpdates } from "./apply-updates";
|
||||
export type {
|
||||
DeferredSideEffect,
|
||||
SettingsUpdateAction,
|
||||
SettingsUpdateContext,
|
||||
SettingsUpdatePlan,
|
||||
SettingsUpdateResult,
|
||||
SettingUpdateHandler,
|
||||
} from "./registry";
|
||||
export {
|
||||
settingsUpdateRegistry,
|
||||
toBitBoolOrNull,
|
||||
toJsonOrNull,
|
||||
toNormalizedStringOrNull,
|
||||
toNumberStringOrNull,
|
||||
} from "./registry";
|
||||
329
orchestrator/src/server/services/settings-update/registry.ts
Normal file
329
orchestrator/src/server/services/settings-update/registry.ts
Normal file
@ -0,0 +1,329 @@
|
||||
import type { SettingKey } from "@server/repositories/settings";
|
||||
import * as settingsRepo from "@server/repositories/settings";
|
||||
import { applyEnvValue, normalizeEnvInput } from "@server/services/envSettings";
|
||||
import { getProfile } from "@server/services/profile";
|
||||
import {
|
||||
extractProjectsFromProfile,
|
||||
normalizeResumeProjectsSettings,
|
||||
} from "@server/services/resumeProjects";
|
||||
import {
|
||||
type SettingsConversionKey,
|
||||
serializeSettingValue,
|
||||
} from "@server/services/settings-conversion";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||
|
||||
export type DeferredSideEffect = "refreshBackupScheduler";
|
||||
|
||||
export type SettingsUpdateAction = {
|
||||
settingKey: SettingKey;
|
||||
persist: () => Promise<void>;
|
||||
sideEffect?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type SettingsUpdateResult = {
|
||||
actions: SettingsUpdateAction[];
|
||||
deferredSideEffects: Set<DeferredSideEffect>;
|
||||
};
|
||||
|
||||
export type SettingsUpdateContext = {
|
||||
input: UpdateSettingsInput;
|
||||
};
|
||||
|
||||
export type SettingUpdateHandler<K extends keyof UpdateSettingsInput> = (args: {
|
||||
key: K;
|
||||
value: UpdateSettingsInput[K];
|
||||
context: SettingsUpdateContext;
|
||||
}) => Promise<SettingsUpdateResult> | SettingsUpdateResult;
|
||||
|
||||
export type SettingsUpdatePlan = {
|
||||
shouldRefreshBackupScheduler: boolean;
|
||||
};
|
||||
|
||||
export function toNormalizedStringOrNull(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
return normalizeEnvInput(value);
|
||||
}
|
||||
|
||||
export function toNumberStringOrNull(
|
||||
value: number | null | undefined,
|
||||
): string | null {
|
||||
return serializeSettingValue("ukvisajobsMaxJobs", value);
|
||||
}
|
||||
|
||||
export function toJsonOrNull<T>(value: T | null | undefined): string | null {
|
||||
return value !== null && value !== undefined ? JSON.stringify(value) : null;
|
||||
}
|
||||
|
||||
export function toBitBoolOrNull(
|
||||
value: boolean | null | undefined,
|
||||
): string | null {
|
||||
return serializeSettingValue("jobspyIsRemote", value);
|
||||
}
|
||||
|
||||
function result(
|
||||
args: {
|
||||
actions?: SettingsUpdateAction[];
|
||||
deferred?: DeferredSideEffect[];
|
||||
} = {},
|
||||
): SettingsUpdateResult {
|
||||
return {
|
||||
actions: args.actions ?? [],
|
||||
deferredSideEffects: new Set(args.deferred ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function persistAction(
|
||||
settingKey: Parameters<typeof settingsRepo.setSetting>[0],
|
||||
value: string | null,
|
||||
sideEffect?: () => void | Promise<void>,
|
||||
): SettingsUpdateAction {
|
||||
return {
|
||||
settingKey,
|
||||
persist: () => settingsRepo.setSetting(settingKey, value),
|
||||
sideEffect,
|
||||
};
|
||||
}
|
||||
|
||||
function singleAction<K extends keyof UpdateSettingsInput>(
|
||||
fn: SettingUpdateHandler<K>,
|
||||
): SettingUpdateHandler<K> {
|
||||
return fn;
|
||||
}
|
||||
|
||||
function metadataPersistAction(
|
||||
key: SettingsConversionKey,
|
||||
value: unknown,
|
||||
): SettingsUpdateAction {
|
||||
return persistAction(key, serializeSettingValue(key, value as never));
|
||||
}
|
||||
|
||||
export const settingsUpdateRegistry: Partial<{
|
||||
[K in keyof UpdateSettingsInput]: SettingUpdateHandler<K>;
|
||||
}> = {
|
||||
model: singleAction(({ value }) =>
|
||||
result({ actions: [persistAction("model", value ?? null)] }),
|
||||
),
|
||||
modelScorer: singleAction(({ value }) =>
|
||||
result({ actions: [persistAction("modelScorer", value ?? null)] }),
|
||||
),
|
||||
modelTailoring: singleAction(({ value }) =>
|
||||
result({ actions: [persistAction("modelTailoring", value ?? null)] }),
|
||||
),
|
||||
modelProjectSelection: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [persistAction("modelProjectSelection", value ?? null)],
|
||||
}),
|
||||
),
|
||||
llmProvider: singleAction(({ value }) => {
|
||||
const normalized = toNormalizedStringOrNull(value);
|
||||
return result({
|
||||
actions: [
|
||||
persistAction("llmProvider", normalized, () => {
|
||||
applyEnvValue("LLM_PROVIDER", normalized);
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
llmBaseUrl: singleAction(({ value }) => {
|
||||
const normalized = toNormalizedStringOrNull(value);
|
||||
return result({
|
||||
actions: [
|
||||
persistAction("llmBaseUrl", normalized, () => {
|
||||
applyEnvValue("LLM_BASE_URL", normalized);
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
pipelineWebhookUrl: singleAction(({ value }) =>
|
||||
result({ actions: [persistAction("pipelineWebhookUrl", value ?? null)] }),
|
||||
),
|
||||
jobCompleteWebhookUrl: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [persistAction("jobCompleteWebhookUrl", value ?? null)],
|
||||
}),
|
||||
),
|
||||
rxresumeBaseResumeId: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [
|
||||
persistAction("rxresumeBaseResumeId", toNormalizedStringOrNull(value)),
|
||||
],
|
||||
}),
|
||||
),
|
||||
resumeProjects: singleAction(async ({ value }) => {
|
||||
const resumeProjects = value ?? null;
|
||||
if (resumeProjects === null) {
|
||||
return result({ actions: [persistAction("resumeProjects", null)] });
|
||||
}
|
||||
|
||||
const profile = await getProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const allowed = new Set(catalog.map((project) => project.id));
|
||||
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
||||
|
||||
return result({
|
||||
actions: [persistAction("resumeProjects", JSON.stringify(normalized))],
|
||||
});
|
||||
}),
|
||||
ukvisajobsMaxJobs: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [metadataPersistAction("ukvisajobsMaxJobs", value)],
|
||||
}),
|
||||
),
|
||||
gradcrackerMaxJobsPerTerm: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [metadataPersistAction("gradcrackerMaxJobsPerTerm", value)],
|
||||
}),
|
||||
),
|
||||
searchTerms: singleAction(({ value }) =>
|
||||
result({ actions: [metadataPersistAction("searchTerms", value)] }),
|
||||
),
|
||||
jobspyLocation: singleAction(({ value }) =>
|
||||
result({ actions: [metadataPersistAction("jobspyLocation", value)] }),
|
||||
),
|
||||
jobspyResultsWanted: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [metadataPersistAction("jobspyResultsWanted", value)],
|
||||
}),
|
||||
),
|
||||
jobspyHoursOld: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [metadataPersistAction("jobspyHoursOld", value)],
|
||||
}),
|
||||
),
|
||||
jobspyCountryIndeed: singleAction(({ value }) =>
|
||||
result({ actions: [metadataPersistAction("jobspyCountryIndeed", value)] }),
|
||||
),
|
||||
jobspySites: singleAction(({ value }) =>
|
||||
result({ actions: [metadataPersistAction("jobspySites", value)] }),
|
||||
),
|
||||
jobspyLinkedinFetchDescription: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [metadataPersistAction("jobspyLinkedinFetchDescription", value)],
|
||||
}),
|
||||
),
|
||||
jobspyIsRemote: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [metadataPersistAction("jobspyIsRemote", value)],
|
||||
}),
|
||||
),
|
||||
showSponsorInfo: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [metadataPersistAction("showSponsorInfo", value)],
|
||||
}),
|
||||
),
|
||||
openrouterApiKey: singleAction(({ value }) => {
|
||||
console.warn(
|
||||
"[DEPRECATED] Received openrouterApiKey update. Storing as llmApiKey and clearing legacy openrouterApiKey.",
|
||||
);
|
||||
const normalized = toNormalizedStringOrNull(value);
|
||||
return result({
|
||||
actions: [
|
||||
persistAction("llmApiKey", normalized, () => {
|
||||
applyEnvValue("LLM_API_KEY", normalized);
|
||||
}),
|
||||
persistAction("openrouterApiKey", null, () => {
|
||||
applyEnvValue("OPENROUTER_API_KEY", null);
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
llmApiKey: singleAction(({ value }) => {
|
||||
const normalized = toNormalizedStringOrNull(value);
|
||||
return result({
|
||||
actions: [
|
||||
persistAction("llmApiKey", normalized, () => {
|
||||
applyEnvValue("LLM_API_KEY", normalized);
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
rxresumeEmail: singleAction(({ value }) => {
|
||||
const normalized = toNormalizedStringOrNull(value);
|
||||
return result({
|
||||
actions: [
|
||||
persistAction("rxresumeEmail", normalized, () => {
|
||||
applyEnvValue("RXRESUME_EMAIL", normalized);
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
rxresumePassword: singleAction(({ value }) => {
|
||||
const normalized = toNormalizedStringOrNull(value);
|
||||
return result({
|
||||
actions: [
|
||||
persistAction("rxresumePassword", normalized, () => {
|
||||
applyEnvValue("RXRESUME_PASSWORD", normalized);
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
basicAuthUser: singleAction(({ value }) => {
|
||||
const normalized = toNormalizedStringOrNull(value);
|
||||
return result({
|
||||
actions: [
|
||||
persistAction("basicAuthUser", normalized, () => {
|
||||
applyEnvValue("BASIC_AUTH_USER", normalized);
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
basicAuthPassword: singleAction(({ value }) => {
|
||||
const normalized = toNormalizedStringOrNull(value);
|
||||
return result({
|
||||
actions: [
|
||||
persistAction("basicAuthPassword", normalized, () => {
|
||||
applyEnvValue("BASIC_AUTH_PASSWORD", normalized);
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
ukvisajobsEmail: singleAction(({ value }) => {
|
||||
const normalized = toNormalizedStringOrNull(value);
|
||||
return result({
|
||||
actions: [
|
||||
persistAction("ukvisajobsEmail", normalized, () => {
|
||||
applyEnvValue("UKVISAJOBS_EMAIL", normalized);
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
ukvisajobsPassword: singleAction(({ value }) => {
|
||||
const normalized = toNormalizedStringOrNull(value);
|
||||
return result({
|
||||
actions: [
|
||||
persistAction("ukvisajobsPassword", normalized, () => {
|
||||
applyEnvValue("UKVISAJOBS_PASSWORD", normalized);
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
webhookSecret: singleAction(({ value }) => {
|
||||
const normalized = toNormalizedStringOrNull(value);
|
||||
return result({
|
||||
actions: [
|
||||
persistAction("webhookSecret", normalized, () => {
|
||||
applyEnvValue("WEBHOOK_SECRET", normalized);
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
backupEnabled: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [metadataPersistAction("backupEnabled", value)],
|
||||
deferred: ["refreshBackupScheduler"],
|
||||
}),
|
||||
),
|
||||
backupHour: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [metadataPersistAction("backupHour", value)],
|
||||
deferred: ["refreshBackupScheduler"],
|
||||
}),
|
||||
),
|
||||
backupMaxCount: singleAction(({ value }) =>
|
||||
result({
|
||||
actions: [metadataPersistAction("backupMaxCount", value)],
|
||||
deferred: ["refreshBackupScheduler"],
|
||||
}),
|
||||
),
|
||||
};
|
||||
@ -7,6 +7,7 @@ import {
|
||||
resolveResumeProjectsSettings,
|
||||
} from "./resumeProjects";
|
||||
import { getResume, RxResumeCredentialsError } from "./rxresume-v4";
|
||||
import { resolveSettingValue } from "./settings-conversion";
|
||||
|
||||
/**
|
||||
* Get the effective app settings, combining environment variables and database overrides.
|
||||
@ -87,130 +88,122 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
overrideRaw: overrideResumeProjectsRaw,
|
||||
});
|
||||
|
||||
const defaultUkvisajobsMaxJobs = 50;
|
||||
const overrideUkvisajobsMaxJobsRaw = overrides.ukvisajobsMaxJobs;
|
||||
const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw
|
||||
? parseInt(overrideUkvisajobsMaxJobsRaw, 10)
|
||||
: null;
|
||||
const ukvisajobsMaxJobs =
|
||||
overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs;
|
||||
|
||||
const defaultGradcrackerMaxJobsPerTerm = 50;
|
||||
const overrideGradcrackerMaxJobsPerTermRaw =
|
||||
overrides.gradcrackerMaxJobsPerTerm;
|
||||
const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw
|
||||
? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10)
|
||||
: null;
|
||||
const gradcrackerMaxJobsPerTerm =
|
||||
overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm;
|
||||
|
||||
const defaultSearchTermsEnv =
|
||||
process.env.JOBSPY_SEARCH_TERMS || "web developer";
|
||||
const defaultSearchTerms = defaultSearchTermsEnv
|
||||
.split("|")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const overrideSearchTermsRaw = overrides.searchTerms;
|
||||
const overrideSearchTerms = overrideSearchTermsRaw
|
||||
? (JSON.parse(overrideSearchTermsRaw) as string[])
|
||||
: null;
|
||||
const searchTerms = overrideSearchTerms ?? defaultSearchTerms;
|
||||
|
||||
const defaultJobspyLocation = process.env.JOBSPY_LOCATION || "UK";
|
||||
const overrideJobspyLocation = overrides.jobspyLocation ?? null;
|
||||
const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation;
|
||||
|
||||
const defaultJobspyResultsWanted = parseInt(
|
||||
process.env.JOBSPY_RESULTS_WANTED || "200",
|
||||
10,
|
||||
const ukvisajobsMaxJobsSetting = resolveSettingValue(
|
||||
"ukvisajobsMaxJobs",
|
||||
overrides.ukvisajobsMaxJobs,
|
||||
);
|
||||
const overrideJobspyResultsWantedRaw = overrides.jobspyResultsWanted;
|
||||
const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw
|
||||
? parseInt(overrideJobspyResultsWantedRaw, 10)
|
||||
: null;
|
||||
const jobspyResultsWanted =
|
||||
overrideJobspyResultsWanted ?? defaultJobspyResultsWanted;
|
||||
const defaultUkvisajobsMaxJobs = ukvisajobsMaxJobsSetting.defaultValue;
|
||||
const overrideUkvisajobsMaxJobs = ukvisajobsMaxJobsSetting.overrideValue;
|
||||
const ukvisajobsMaxJobs = ukvisajobsMaxJobsSetting.value;
|
||||
|
||||
const defaultJobspyHoursOld = parseInt(
|
||||
process.env.JOBSPY_HOURS_OLD || "72",
|
||||
10,
|
||||
const gradcrackerMaxJobsPerTermSetting = resolveSettingValue(
|
||||
"gradcrackerMaxJobsPerTerm",
|
||||
overrides.gradcrackerMaxJobsPerTerm,
|
||||
);
|
||||
const overrideJobspyHoursOldRaw = overrides.jobspyHoursOld;
|
||||
const overrideJobspyHoursOld = overrideJobspyHoursOldRaw
|
||||
? parseInt(overrideJobspyHoursOldRaw, 10)
|
||||
: null;
|
||||
const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld;
|
||||
const defaultGradcrackerMaxJobsPerTerm =
|
||||
gradcrackerMaxJobsPerTermSetting.defaultValue;
|
||||
const overrideGradcrackerMaxJobsPerTerm =
|
||||
gradcrackerMaxJobsPerTermSetting.overrideValue;
|
||||
const gradcrackerMaxJobsPerTerm = gradcrackerMaxJobsPerTermSetting.value;
|
||||
|
||||
const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || "UK";
|
||||
const overrideJobspyCountryIndeed = overrides.jobspyCountryIndeed ?? null;
|
||||
const jobspyCountryIndeed =
|
||||
overrideJobspyCountryIndeed || defaultJobspyCountryIndeed;
|
||||
const searchTermsSetting = resolveSettingValue(
|
||||
"searchTerms",
|
||||
overrides.searchTerms,
|
||||
);
|
||||
const defaultSearchTerms = searchTermsSetting.defaultValue;
|
||||
const overrideSearchTerms = searchTermsSetting.overrideValue;
|
||||
const searchTerms = searchTermsSetting.value;
|
||||
|
||||
const defaultJobspySites = (process.env.JOBSPY_SITES || "indeed,linkedin")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const overrideJobspySitesRaw = overrides.jobspySites;
|
||||
const overrideJobspySites = overrideJobspySitesRaw
|
||||
? (JSON.parse(overrideJobspySitesRaw) as string[])
|
||||
: null;
|
||||
const jobspySites = overrideJobspySites ?? defaultJobspySites;
|
||||
const jobspyLocationSetting = resolveSettingValue(
|
||||
"jobspyLocation",
|
||||
overrides.jobspyLocation,
|
||||
);
|
||||
const defaultJobspyLocation = jobspyLocationSetting.defaultValue;
|
||||
const overrideJobspyLocation = jobspyLocationSetting.overrideValue;
|
||||
const jobspyLocation = jobspyLocationSetting.value;
|
||||
|
||||
const jobspyResultsWantedSetting = resolveSettingValue(
|
||||
"jobspyResultsWanted",
|
||||
overrides.jobspyResultsWanted,
|
||||
);
|
||||
const defaultJobspyResultsWanted = jobspyResultsWantedSetting.defaultValue;
|
||||
const overrideJobspyResultsWanted = jobspyResultsWantedSetting.overrideValue;
|
||||
const jobspyResultsWanted = jobspyResultsWantedSetting.value;
|
||||
|
||||
const jobspyHoursOldSetting = resolveSettingValue(
|
||||
"jobspyHoursOld",
|
||||
overrides.jobspyHoursOld,
|
||||
);
|
||||
const defaultJobspyHoursOld = jobspyHoursOldSetting.defaultValue;
|
||||
const overrideJobspyHoursOld = jobspyHoursOldSetting.overrideValue;
|
||||
const jobspyHoursOld = jobspyHoursOldSetting.value;
|
||||
|
||||
const jobspyCountryIndeedSetting = resolveSettingValue(
|
||||
"jobspyCountryIndeed",
|
||||
overrides.jobspyCountryIndeed,
|
||||
);
|
||||
const defaultJobspyCountryIndeed = jobspyCountryIndeedSetting.defaultValue;
|
||||
const overrideJobspyCountryIndeed = jobspyCountryIndeedSetting.overrideValue;
|
||||
const jobspyCountryIndeed = jobspyCountryIndeedSetting.value;
|
||||
|
||||
const jobspySitesSetting = resolveSettingValue(
|
||||
"jobspySites",
|
||||
overrides.jobspySites,
|
||||
);
|
||||
const defaultJobspySites = jobspySitesSetting.defaultValue;
|
||||
const overrideJobspySites = jobspySitesSetting.overrideValue;
|
||||
const jobspySites = jobspySitesSetting.value;
|
||||
|
||||
const jobspyLinkedinFetchDescriptionSetting = resolveSettingValue(
|
||||
"jobspyLinkedinFetchDescription",
|
||||
overrides.jobspyLinkedinFetchDescription,
|
||||
);
|
||||
const defaultJobspyLinkedinFetchDescription =
|
||||
(process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || "1") === "1";
|
||||
const overrideJobspyLinkedinFetchDescriptionRaw =
|
||||
overrides.jobspyLinkedinFetchDescription;
|
||||
jobspyLinkedinFetchDescriptionSetting.defaultValue;
|
||||
const overrideJobspyLinkedinFetchDescription =
|
||||
overrideJobspyLinkedinFetchDescriptionRaw
|
||||
? overrideJobspyLinkedinFetchDescriptionRaw === "true" ||
|
||||
overrideJobspyLinkedinFetchDescriptionRaw === "1"
|
||||
: null;
|
||||
jobspyLinkedinFetchDescriptionSetting.overrideValue;
|
||||
const jobspyLinkedinFetchDescription =
|
||||
overrideJobspyLinkedinFetchDescription ??
|
||||
defaultJobspyLinkedinFetchDescription;
|
||||
jobspyLinkedinFetchDescriptionSetting.value;
|
||||
|
||||
const defaultJobspyIsRemote = (process.env.JOBSPY_IS_REMOTE || "0") === "1";
|
||||
const overrideJobspyIsRemoteRaw = overrides.jobspyIsRemote;
|
||||
const overrideJobspyIsRemote = overrideJobspyIsRemoteRaw
|
||||
? overrideJobspyIsRemoteRaw === "true" || overrideJobspyIsRemoteRaw === "1"
|
||||
: null;
|
||||
const jobspyIsRemote = overrideJobspyIsRemote ?? defaultJobspyIsRemote;
|
||||
const jobspyIsRemoteSetting = resolveSettingValue(
|
||||
"jobspyIsRemote",
|
||||
overrides.jobspyIsRemote,
|
||||
);
|
||||
const defaultJobspyIsRemote = jobspyIsRemoteSetting.defaultValue;
|
||||
const overrideJobspyIsRemote = jobspyIsRemoteSetting.overrideValue;
|
||||
const jobspyIsRemote = jobspyIsRemoteSetting.value;
|
||||
|
||||
const defaultShowSponsorInfo = true;
|
||||
const overrideShowSponsorInfoRaw = overrides.showSponsorInfo;
|
||||
const overrideShowSponsorInfo = overrideShowSponsorInfoRaw
|
||||
? overrideShowSponsorInfoRaw === "true" ||
|
||||
overrideShowSponsorInfoRaw === "1"
|
||||
: null;
|
||||
const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo;
|
||||
const showSponsorInfoSetting = resolveSettingValue(
|
||||
"showSponsorInfo",
|
||||
overrides.showSponsorInfo,
|
||||
);
|
||||
const defaultShowSponsorInfo = showSponsorInfoSetting.defaultValue;
|
||||
const overrideShowSponsorInfo = showSponsorInfoSetting.overrideValue;
|
||||
const showSponsorInfo = showSponsorInfoSetting.value;
|
||||
|
||||
// Backup settings
|
||||
const defaultBackupEnabled = false;
|
||||
const overrideBackupEnabledRaw = overrides.backupEnabled;
|
||||
const overrideBackupEnabled = overrideBackupEnabledRaw
|
||||
? overrideBackupEnabledRaw === "true" || overrideBackupEnabledRaw === "1"
|
||||
: null;
|
||||
const backupEnabled = overrideBackupEnabled ?? defaultBackupEnabled;
|
||||
const backupEnabledSetting = resolveSettingValue(
|
||||
"backupEnabled",
|
||||
overrides.backupEnabled,
|
||||
);
|
||||
const defaultBackupEnabled = backupEnabledSetting.defaultValue;
|
||||
const overrideBackupEnabled = backupEnabledSetting.overrideValue;
|
||||
const backupEnabled = backupEnabledSetting.value;
|
||||
|
||||
const defaultBackupHour = 2;
|
||||
const overrideBackupHourRaw = overrides.backupHour;
|
||||
const parsedBackupHour = overrideBackupHourRaw
|
||||
? parseInt(overrideBackupHourRaw, 10)
|
||||
: NaN;
|
||||
const overrideBackupHour = Number.isNaN(parsedBackupHour)
|
||||
? null
|
||||
: Math.min(23, Math.max(0, parsedBackupHour));
|
||||
const backupHour = overrideBackupHour ?? defaultBackupHour;
|
||||
const backupHourSetting = resolveSettingValue(
|
||||
"backupHour",
|
||||
overrides.backupHour,
|
||||
);
|
||||
const defaultBackupHour = backupHourSetting.defaultValue;
|
||||
const overrideBackupHour = backupHourSetting.overrideValue;
|
||||
const backupHour = backupHourSetting.value;
|
||||
|
||||
const defaultBackupMaxCount = 5;
|
||||
const overrideBackupMaxCountRaw = overrides.backupMaxCount;
|
||||
const parsedBackupMaxCount = overrideBackupMaxCountRaw
|
||||
? parseInt(overrideBackupMaxCountRaw, 10)
|
||||
: NaN;
|
||||
const overrideBackupMaxCount = Number.isNaN(parsedBackupMaxCount)
|
||||
? null
|
||||
: Math.min(5, Math.max(1, parsedBackupMaxCount));
|
||||
const backupMaxCount = overrideBackupMaxCount ?? defaultBackupMaxCount;
|
||||
const backupMaxCountSetting = resolveSettingValue(
|
||||
"backupMaxCount",
|
||||
overrides.backupMaxCount,
|
||||
);
|
||||
const defaultBackupMaxCount = backupMaxCountSetting.defaultValue;
|
||||
const overrideBackupMaxCount = backupMaxCountSetting.overrideValue;
|
||||
const backupMaxCount = backupMaxCountSetting.value;
|
||||
|
||||
return {
|
||||
...envSettings,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user