Enhance model handling and settings integration for providers (#295)
This commit is contained in:
parent
7f517776df
commit
8274ec4e14
@ -47,6 +47,16 @@ Settings gives you runtime overrides for the key parts of discovery, scoring, ta
|
||||
- Scoring model
|
||||
- Tailoring model
|
||||
- Project-selection model
|
||||
- Provider defaults are applied automatically when the model fields are left blank:
|
||||
- `openai` defaults to `gpt-5.4-mini`
|
||||
- `gemini` defaults to `google/gemini-3-flash-preview`
|
||||
- The settings page shows provider-aware model pickers for:
|
||||
- `openai`: available text-generation models only
|
||||
- `gemini`: available Gemini text-generation models only
|
||||
- `ollama`: locally installed Ollama models
|
||||
- `openrouter`, `lmstudio`, and `openai_compatible` stay manual-entry because JobOps cannot safely infer the exact model catalog from those providers
|
||||
- Changing the provider clears stale model overrides in the form, so inherited fields follow the new provider default unless you explicitly choose a new override
|
||||
- The preview under each field and the **Resolved config** block reflect the model currently selected in the form, even before you save
|
||||
|
||||
### Webhooks
|
||||
|
||||
@ -199,6 +209,13 @@ curl -X POST "http://localhost:3001/api/backups"
|
||||
|
||||
- Some settings apply only to new runs/actions after save.
|
||||
- Re-run scoring/tailoring/pipeline to validate effect.
|
||||
- In the **Model** section, the field preview and **Resolved config** update immediately when you choose a model, but the change only applies to future actions after you click **Save**.
|
||||
|
||||
### Tailoring or scoring says the selected model does not exist for the current provider
|
||||
|
||||
- Open **Settings -> Model** and confirm the provider and model belong together.
|
||||
- If you switch providers, leave the model fields blank to use the provider default, or pick a new provider-compatible model from the dropdown.
|
||||
- JobOps ignores stale Gemini-style overrides under `openai`, and ignores stale OpenAI-style overrides under `gemini`, but you still need to save the current form selection for future runs.
|
||||
|
||||
### Resume tailoring used English instead of my resume language
|
||||
|
||||
|
||||
@ -30,6 +30,14 @@ npm --workspace docs-site run build
|
||||
- Validate `LLM_API_KEY` and provider settings.
|
||||
- Check settings page and API connectivity.
|
||||
|
||||
## Resume tailoring or scoring says the model does not exist
|
||||
|
||||
- Root cause: the selected provider and model do not match.
|
||||
- Open **Settings -> Model** and check both the provider and the current model preview.
|
||||
- If you recently switched providers, leave the model fields blank to use the provider default, or select a provider-compatible model and save again.
|
||||
- For `openai`, JobOps defaults to `gpt-5.4-mini` when the model field is blank.
|
||||
- For `gemini`, JobOps defaults to `google/gemini-3-flash-preview` when the model field is blank.
|
||||
|
||||
## PDF generation fails
|
||||
|
||||
- Verify RxResume credentials.
|
||||
|
||||
@ -1336,6 +1336,18 @@ export async function validateLlm(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLlmModels(input?: {
|
||||
provider?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
}): Promise<string[]> {
|
||||
const data = await fetchApi<{ models: string[] }>("/settings/llm-models", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input ?? {}),
|
||||
});
|
||||
return data.models;
|
||||
}
|
||||
|
||||
export async function validateRxresume(input?: {
|
||||
mode?: "v4" | "v5";
|
||||
email?: string;
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
LLM_PROVIDERS,
|
||||
normalizeLlmProvider,
|
||||
} from "@client/pages/settings/utils";
|
||||
import { getDefaultModelForProvider } from "@shared/settings-registry";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import type { RxResumeMode, ValidationResult } from "@shared/types.js";
|
||||
import { Check } from "lucide-react";
|
||||
@ -423,6 +424,10 @@ export const OnboardingGate: React.FC = () => {
|
||||
const update: Partial<UpdateSettingsInput> = {
|
||||
llmProvider: normalizedProvider,
|
||||
llmBaseUrl: showBaseUrl ? baseUrlValue || null : null,
|
||||
model: null,
|
||||
modelScorer: null,
|
||||
modelTailoring: null,
|
||||
modelProjectSelection: null,
|
||||
};
|
||||
|
||||
if (showApiKey && apiKeyValue) {
|
||||
@ -433,7 +438,13 @@ export const OnboardingGate: React.FC = () => {
|
||||
await api.updateSettings(update);
|
||||
await refreshSettings();
|
||||
setValue("llmApiKey", "");
|
||||
toast.success("LLM provider connected");
|
||||
const defaultModel = getDefaultModelForProvider(normalizedProvider);
|
||||
toast.success("LLM provider connected", {
|
||||
description:
|
||||
normalizedProvider === "openai" || normalizedProvider === "gemini"
|
||||
? `Default for ${providerConfig.label}: ${defaultModel}.`
|
||||
: "Select the model manually in Settings > Model.",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message =
|
||||
|
||||
@ -15,6 +15,7 @@ const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
getSettings: vi.fn(),
|
||||
getLlmModels: vi.fn().mockResolvedValue([]),
|
||||
updateSettings: vi.fn(),
|
||||
validateRxresume: vi.fn(),
|
||||
getRxResumeProjects: vi.fn(),
|
||||
@ -217,6 +218,50 @@ describe("SettingsPage", () => {
|
||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||
});
|
||||
|
||||
it("clears stale model overrides when the provider changes", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(
|
||||
createAppSettings({
|
||||
model: {
|
||||
value: "google/gemini-3-flash-preview",
|
||||
default: "google/gemini-3-flash-preview",
|
||||
override: "google/gemini-3-flash-preview",
|
||||
},
|
||||
modelScorer: { value: "google/gemini-3-flash-preview", override: null },
|
||||
modelTailoring: {
|
||||
value: "google/gemini-3-flash-preview",
|
||||
override: "google/gemini-3-flash-preview",
|
||||
},
|
||||
modelProjectSelection: {
|
||||
value: "google/gemini-3-flash-preview",
|
||||
override: null,
|
||||
},
|
||||
llmProvider: { value: "gemini", default: "gemini", override: "gemini" },
|
||||
}),
|
||||
);
|
||||
vi.mocked(api.updateSettings).mockResolvedValue(baseSettings);
|
||||
|
||||
renderPage();
|
||||
await openModelSection();
|
||||
|
||||
fireEvent.click(screen.getByRole("combobox", { name: /provider/i }));
|
||||
fireEvent.click(await screen.findByText("OpenAI"));
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
|
||||
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llmProvider: "openai",
|
||||
model: null,
|
||||
modelScorer: null,
|
||||
modelTailoring: null,
|
||||
modelProjectSelection: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hides pipeline tuning sections that moved to run modal", async () => {
|
||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||
renderPage();
|
||||
|
||||
@ -824,10 +824,26 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const payload: Partial<UpdateSettingsInput> = {
|
||||
model: normalizeString(data.model),
|
||||
modelScorer: normalizeString(data.modelScorer),
|
||||
modelTailoring: normalizeString(data.modelTailoring),
|
||||
modelProjectSelection: normalizeString(data.modelProjectSelection),
|
||||
model: dirtyFields.llmProvider
|
||||
? dirtyFields.model
|
||||
? normalizeString(data.model)
|
||||
: null
|
||||
: normalizeString(data.model),
|
||||
modelScorer: dirtyFields.llmProvider
|
||||
? dirtyFields.modelScorer
|
||||
? normalizeString(data.modelScorer)
|
||||
: null
|
||||
: normalizeString(data.modelScorer),
|
||||
modelTailoring: dirtyFields.llmProvider
|
||||
? dirtyFields.modelTailoring
|
||||
? normalizeString(data.modelTailoring)
|
||||
: null
|
||||
: normalizeString(data.modelTailoring),
|
||||
modelProjectSelection: dirtyFields.llmProvider
|
||||
? dirtyFields.modelProjectSelection
|
||||
? normalizeString(data.modelProjectSelection)
|
||||
: null
|
||||
: normalizeString(data.modelProjectSelection),
|
||||
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
|
||||
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
||||
resumeProjects: resumeProjectsOverride,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import * as api from "@client/api";
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||
import type { ModelValues } from "@client/pages/settings/types";
|
||||
import {
|
||||
@ -5,16 +6,19 @@ import {
|
||||
getLlmProviderConfig,
|
||||
LLM_PROVIDER_LABELS,
|
||||
LLM_PROVIDERS,
|
||||
supportsLlmModelSuggestions,
|
||||
} from "@client/pages/settings/utils";
|
||||
import { getDefaultModelForProvider } from "@shared/settings-registry";
|
||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useDeferredValue, useEffect, useRef, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { SearchableDropdown } from "@/components/ui/searchable-dropdown";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -35,12 +39,12 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const {
|
||||
effective,
|
||||
default: defaultModel,
|
||||
scorer,
|
||||
tailoring,
|
||||
projectSelection,
|
||||
llmProvider,
|
||||
llmBaseUrl,
|
||||
llmApiKeyHint,
|
||||
@ -54,10 +58,28 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
} = useFormContext<UpdateSettingsInput>();
|
||||
|
||||
const selectedProvider = watch("llmProvider") || llmProvider || "openrouter";
|
||||
const previousProviderRef = useRef(selectedProvider);
|
||||
const providerConfig = getLlmProviderConfig(selectedProvider);
|
||||
const { showApiKey, showBaseUrl } = providerConfig;
|
||||
|
||||
const llmBaseUrlValue = watch("llmBaseUrl");
|
||||
const llmApiKeyValue = watch("llmApiKey") ?? "";
|
||||
const modelValue = watch("model") ?? "";
|
||||
const modelScorerValue = watch("modelScorer") ?? "";
|
||||
const modelTailoringValue = watch("modelTailoring") ?? "";
|
||||
const modelProjectSelectionValue = watch("modelProjectSelection") ?? "";
|
||||
const providerDefaultModel = getDefaultModelForProvider(
|
||||
selectedProvider,
|
||||
selectedProvider === llmProvider ? defaultModel : undefined,
|
||||
);
|
||||
const deferredProvider = useDeferredValue(selectedProvider);
|
||||
const deferredBaseUrl = useDeferredValue(llmBaseUrlValue ?? "");
|
||||
const deferredApiKey = useDeferredValue(llmApiKeyValue);
|
||||
const supportsModelSuggestions =
|
||||
supportsLlmModelSuggestions(selectedProvider);
|
||||
const hasAvailableApiKey = showApiKey
|
||||
? Boolean(deferredApiKey.trim() || llmApiKeyHint)
|
||||
: true;
|
||||
|
||||
useEffect(() => {
|
||||
if (showBaseUrl) return;
|
||||
@ -66,12 +88,122 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
}
|
||||
}, [setValue, showBaseUrl, llmBaseUrlValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousProviderRef.current === selectedProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
previousProviderRef.current = selectedProvider;
|
||||
setValue("model", "", { shouldDirty: true });
|
||||
setValue("modelScorer", "", { shouldDirty: true });
|
||||
setValue("modelTailoring", "", { shouldDirty: true });
|
||||
setValue("modelProjectSelection", "", { shouldDirty: true });
|
||||
}, [selectedProvider, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsModelSuggestions) {
|
||||
setAvailableModels([]);
|
||||
setModelsError(null);
|
||||
setIsLoadingModels(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasAvailableApiKey) {
|
||||
setAvailableModels([]);
|
||||
setModelsError(null);
|
||||
setIsLoadingModels(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingModels(true);
|
||||
setModelsError(null);
|
||||
|
||||
void api
|
||||
.getLlmModels({
|
||||
provider: deferredProvider,
|
||||
baseUrl: showBaseUrl ? deferredBaseUrl.trim() || undefined : undefined,
|
||||
apiKey: showApiKey ? deferredApiKey.trim() || undefined : undefined,
|
||||
})
|
||||
.then((models) => {
|
||||
if (cancelled) return;
|
||||
setAvailableModels(models);
|
||||
setModelsError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
setAvailableModels([]);
|
||||
setModelsError(
|
||||
error instanceof Error ? error.message : "Failed to load models.",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setIsLoadingModels(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
deferredApiKey,
|
||||
deferredBaseUrl,
|
||||
deferredProvider,
|
||||
hasAvailableApiKey,
|
||||
showApiKey,
|
||||
showBaseUrl,
|
||||
supportsModelSuggestions,
|
||||
]);
|
||||
|
||||
const keyHint = formatSecretHint(llmApiKeyHint);
|
||||
const keyText = showApiKey ? keyHint || "Not set" : "Not required";
|
||||
const effectiveDefaultModel = effective || defaultModel || "—";
|
||||
const scoringModel = scorer || effectiveDefaultModel;
|
||||
const tailoringModel = tailoring || effectiveDefaultModel;
|
||||
const projectSelectionModel = projectSelection || effectiveDefaultModel;
|
||||
const resolvedBaseUrl = llmBaseUrlValue?.trim() || llmBaseUrl || "-";
|
||||
const selectedDefaultModel = modelValue.trim();
|
||||
const previewDefaultModel =
|
||||
selectedDefaultModel || effective || providerDefaultModel || "-";
|
||||
const selectedScoringModel = modelScorerValue.trim();
|
||||
const selectedTailoringModel = modelTailoringValue.trim();
|
||||
const selectedProjectSelectionModel = modelProjectSelectionValue.trim();
|
||||
const scoringModel = selectedScoringModel || previewDefaultModel;
|
||||
const tailoringModel = selectedTailoringModel || previewDefaultModel;
|
||||
const projectSelectionModel =
|
||||
selectedProjectSelectionModel || previewDefaultModel;
|
||||
const modelHelper = supportsModelSuggestions
|
||||
? !hasAvailableApiKey
|
||||
? `Add or save a ${providerConfig.label} API key to load available models.`
|
||||
: isLoadingModels
|
||||
? "Loading available models..."
|
||||
: modelsError
|
||||
? modelsError
|
||||
: availableModels.length > 0
|
||||
? "Choose from the available text-generation models."
|
||||
: "No text-generation models were returned."
|
||||
: `Type the exact model name manually, or leave blank to use the ${providerConfig.label} default model.`;
|
||||
const defaultModelOptions = buildModelOptions({
|
||||
models: availableModels,
|
||||
emptyLabel: `Use ${providerConfig.label} default`,
|
||||
emptyValue: "",
|
||||
fallbackValue: modelValue.trim(),
|
||||
});
|
||||
const scoringModelOptions = buildModelOptions({
|
||||
models: availableModels,
|
||||
emptyLabel: "Inherit default model",
|
||||
emptyValue: "",
|
||||
fallbackValue: modelScorerValue.trim(),
|
||||
});
|
||||
const tailoringModelOptions = buildModelOptions({
|
||||
models: availableModels,
|
||||
emptyLabel: "Inherit default model",
|
||||
emptyValue: "",
|
||||
fallbackValue: modelTailoringValue.trim(),
|
||||
});
|
||||
const projectSelectionModelOptions = buildModelOptions({
|
||||
models: availableModels,
|
||||
emptyLabel: "Inherit default model",
|
||||
emptyValue: "",
|
||||
fallbackValue: modelProjectSelectionValue.trim(),
|
||||
});
|
||||
|
||||
return (
|
||||
<AccordionItem value="model" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
@ -128,7 +260,7 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.llmBaseUrl?.message as string | undefined}
|
||||
helper={providerConfig.baseUrlHelper}
|
||||
current={llmBaseUrl || "—"}
|
||||
current={resolvedBaseUrl}
|
||||
/>
|
||||
)}
|
||||
{showApiKey && (
|
||||
@ -147,15 +279,53 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsInput
|
||||
label="Default model"
|
||||
inputProps={register("model")}
|
||||
placeholder={defaultModel || "google/gemini-3-flash-preview"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.model?.message as string | undefined}
|
||||
helper="Leave blank to use the default from server env (`MODEL`)."
|
||||
current={effectiveDefaultModel}
|
||||
/>
|
||||
{supportsModelSuggestions ? (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="model" className="text-sm font-medium">
|
||||
Default model
|
||||
</label>
|
||||
<Controller
|
||||
name="model"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SearchableDropdown
|
||||
inputId="model"
|
||||
value={field.value ?? ""}
|
||||
options={defaultModelOptions}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={providerDefaultModel || "Select a model"}
|
||||
searchPlaceholder="Search models..."
|
||||
emptyText="No models found."
|
||||
ariaLabel="Default model"
|
||||
disabled={isLoading || isSaving || isLoadingModels}
|
||||
triggerClassName="h-9 w-full justify-between rounded-md border border-input bg-transparent px-3 text-sm font-normal shadow-sm"
|
||||
contentClassName="w-[var(--radix-popover-trigger-width)] border-border bg-popover p-0"
|
||||
listClassName="max-h-64"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.model?.message && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.model.message as string}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">{modelHelper}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Current:{" "}
|
||||
<span className="font-mono">{previewDefaultModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SettingsInput
|
||||
label="Default model"
|
||||
inputProps={register("model")}
|
||||
placeholder={providerDefaultModel}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.model?.message as string | undefined}
|
||||
helper={modelHelper}
|
||||
current={previewDefaultModel}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
@ -163,34 +333,161 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
<div className="text-sm font-medium">Task-Specific Overrides</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<SettingsInput
|
||||
label="Scoring Model"
|
||||
inputProps={register("modelScorer")}
|
||||
placeholder={effective || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.modelScorer?.message as string | undefined}
|
||||
current={scoringModel}
|
||||
/>
|
||||
{supportsModelSuggestions ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="modelScorer"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Scoring Model
|
||||
</label>
|
||||
<Controller
|
||||
name="modelScorer"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SearchableDropdown
|
||||
inputId="modelScorer"
|
||||
value={field.value ?? ""}
|
||||
options={scoringModelOptions}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={
|
||||
previewDefaultModel || "Inherit default model"
|
||||
}
|
||||
searchPlaceholder="Search models..."
|
||||
emptyText="No models found."
|
||||
ariaLabel="Scoring Model"
|
||||
disabled={isLoading || isSaving || isLoadingModels}
|
||||
triggerClassName="h-9 w-full justify-between rounded-md border border-input bg-transparent px-3 text-sm font-normal shadow-sm"
|
||||
contentClassName="w-[var(--radix-popover-trigger-width)] border-border bg-popover p-0"
|
||||
listClassName="max-h-64"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.modelScorer?.message && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.modelScorer.message as string}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Current: <span className="font-mono">{scoringModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsInput
|
||||
label="Tailoring Model"
|
||||
inputProps={register("modelTailoring")}
|
||||
placeholder={effective || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.modelTailoring?.message as string | undefined}
|
||||
current={tailoringModel}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="modelTailoring"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Tailoring Model
|
||||
</label>
|
||||
<Controller
|
||||
name="modelTailoring"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SearchableDropdown
|
||||
inputId="modelTailoring"
|
||||
value={field.value ?? ""}
|
||||
options={tailoringModelOptions}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={
|
||||
previewDefaultModel || "Inherit default model"
|
||||
}
|
||||
searchPlaceholder="Search models..."
|
||||
emptyText="No models found."
|
||||
ariaLabel="Tailoring Model"
|
||||
disabled={isLoading || isSaving || isLoadingModels}
|
||||
triggerClassName="h-9 w-full justify-between rounded-md border border-input bg-transparent px-3 text-sm font-normal shadow-sm"
|
||||
contentClassName="w-[var(--radix-popover-trigger-width)] border-border bg-popover p-0"
|
||||
listClassName="max-h-64"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.modelTailoring?.message && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.modelTailoring.message as string}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Current:{" "}
|
||||
<span className="font-mono">{tailoringModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsInput
|
||||
label="Project Selection Model"
|
||||
inputProps={register("modelProjectSelection")}
|
||||
placeholder={effective || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={
|
||||
errors.modelProjectSelection?.message as string | undefined
|
||||
}
|
||||
current={projectSelectionModel}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="modelProjectSelection"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Project Selection Model
|
||||
</label>
|
||||
<Controller
|
||||
name="modelProjectSelection"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SearchableDropdown
|
||||
inputId="modelProjectSelection"
|
||||
value={field.value ?? ""}
|
||||
options={projectSelectionModelOptions}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={
|
||||
previewDefaultModel || "Inherit default model"
|
||||
}
|
||||
searchPlaceholder="Search models..."
|
||||
emptyText="No models found."
|
||||
ariaLabel="Project Selection Model"
|
||||
disabled={isLoading || isSaving || isLoadingModels}
|
||||
triggerClassName="h-9 w-full justify-between rounded-md border border-input bg-transparent px-3 text-sm font-normal shadow-sm"
|
||||
contentClassName="w-[var(--radix-popover-trigger-width)] border-border bg-popover p-0"
|
||||
listClassName="max-h-64"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.modelProjectSelection?.message && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.modelProjectSelection.message as string}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Current:{" "}
|
||||
<span className="font-mono">{projectSelectionModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SettingsInput
|
||||
label="Scoring Model"
|
||||
inputProps={register("modelScorer")}
|
||||
placeholder={previewDefaultModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.modelScorer?.message as string | undefined}
|
||||
current={scoringModel}
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
label="Tailoring Model"
|
||||
inputProps={register("modelTailoring")}
|
||||
placeholder={previewDefaultModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.modelTailoring?.message as string | undefined}
|
||||
current={tailoringModel}
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
label="Project Selection Model"
|
||||
inputProps={register("modelProjectSelection")}
|
||||
placeholder={previewDefaultModel || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={
|
||||
errors.modelProjectSelection?.message as
|
||||
| string
|
||||
| undefined
|
||||
}
|
||||
current={projectSelectionModel}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -200,36 +497,32 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
<div className="text-xs text-muted-foreground">Resolved config</div>
|
||||
<div className="grid gap-x-4 gap-y-2 text-xs sm:grid-cols-[160px_1fr]">
|
||||
<div className="text-muted-foreground">Provider</div>
|
||||
<div className="font-mono">{selectedProvider || "—"}</div>
|
||||
<div className="font-mono">{selectedProvider || "-"}</div>
|
||||
|
||||
<div className="text-muted-foreground">Base URL</div>
|
||||
<div className="font-mono">{llmBaseUrl || "—"}</div>
|
||||
<div className="font-mono">{resolvedBaseUrl}</div>
|
||||
|
||||
<div className="text-muted-foreground">API key</div>
|
||||
<div className="font-mono">{keyText}</div>
|
||||
|
||||
<div className="text-muted-foreground">Default model</div>
|
||||
<div className="font-mono">{effectiveDefaultModel}</div>
|
||||
<div className="font-mono">{previewDefaultModel}</div>
|
||||
|
||||
<div className="text-muted-foreground">Scoring model</div>
|
||||
<div className="font-mono">
|
||||
{scoringModel === effectiveDefaultModel
|
||||
? "inherits"
|
||||
: scoringModel}
|
||||
{selectedScoringModel ? scoringModel : "inherits"}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground">Tailoring model</div>
|
||||
<div className="font-mono">
|
||||
{tailoringModel === effectiveDefaultModel
|
||||
? "inherits"
|
||||
: tailoringModel}
|
||||
{selectedTailoringModel ? tailoringModel : "inherits"}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground">Project selection</div>
|
||||
<div className="font-mono">
|
||||
{projectSelectionModel === effectiveDefaultModel
|
||||
? "inherits"
|
||||
: projectSelectionModel}
|
||||
{selectedProjectSelectionModel
|
||||
? projectSelectionModel
|
||||
: "inherits"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -238,3 +531,37 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||
</AccordionItem>
|
||||
);
|
||||
};
|
||||
|
||||
function buildModelOptions(input: {
|
||||
models: string[];
|
||||
emptyLabel: string;
|
||||
emptyValue: string;
|
||||
fallbackValue?: string;
|
||||
}) {
|
||||
const options = [
|
||||
{
|
||||
value: input.emptyValue,
|
||||
label: input.emptyLabel,
|
||||
searchText: input.emptyLabel,
|
||||
},
|
||||
...input.models.map((model) => ({
|
||||
value: model,
|
||||
label: model,
|
||||
searchText: model,
|
||||
})),
|
||||
];
|
||||
|
||||
const fallbackValue = input.fallbackValue?.trim();
|
||||
if (
|
||||
fallbackValue &&
|
||||
!options.some((option) => option.value === fallbackValue)
|
||||
) {
|
||||
options.unshift({
|
||||
value: fallbackValue,
|
||||
label: fallbackValue,
|
||||
searchText: `${fallbackValue} custom`,
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getLlmProviderConfig, normalizeLlmProvider } from "./utils";
|
||||
import {
|
||||
getLlmProviderConfig,
|
||||
normalizeLlmProvider,
|
||||
supportsLlmModelSuggestions,
|
||||
} from "./utils";
|
||||
|
||||
describe("settings utils", () => {
|
||||
it("treats openai-compatible as a dedicated configurable provider", () => {
|
||||
@ -20,4 +24,11 @@ describe("settings utils", () => {
|
||||
it("defaults unknown providers to openrouter", () => {
|
||||
expect(normalizeLlmProvider("unknown-provider")).toBe("openrouter");
|
||||
});
|
||||
|
||||
it("only enables model suggestions for supported providers", () => {
|
||||
expect(supportsLlmModelSuggestions("openai")).toBe(true);
|
||||
expect(supportsLlmModelSuggestions("gemini")).toBe(true);
|
||||
expect(supportsLlmModelSuggestions("ollama")).toBe(true);
|
||||
expect(supportsLlmModelSuggestions("openrouter")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -29,6 +29,11 @@ export const LLM_PROVIDERS = [
|
||||
] as const;
|
||||
|
||||
export type LlmProviderId = (typeof LLM_PROVIDERS)[number];
|
||||
export const LLM_MODEL_SUGGESTION_PROVIDERS = [
|
||||
"openai",
|
||||
"gemini",
|
||||
"ollama",
|
||||
] as const;
|
||||
|
||||
export const LLM_PROVIDER_LABELS: Record<LlmProviderId, string> = {
|
||||
openrouter: "OpenRouter",
|
||||
@ -92,6 +97,15 @@ export function normalizeLlmProvider(
|
||||
: "openrouter";
|
||||
}
|
||||
|
||||
export function supportsLlmModelSuggestions(
|
||||
provider: string | null | undefined,
|
||||
): boolean {
|
||||
const normalizedProvider = normalizeLlmProvider(provider);
|
||||
return (LLM_MODEL_SUGGESTION_PROVIDERS as readonly string[]).includes(
|
||||
normalizedProvider,
|
||||
);
|
||||
}
|
||||
|
||||
export function getLlmProviderConfig(provider: string | null | undefined) {
|
||||
const normalizedProvider = normalizeLlmProvider(provider);
|
||||
const showApiKey = PROVIDERS_WITH_API_KEY.has(normalizedProvider);
|
||||
|
||||
@ -24,6 +24,7 @@ export interface SearchableDropdownOption {
|
||||
}
|
||||
|
||||
interface SearchableDropdownProps {
|
||||
inputId?: string;
|
||||
value: string;
|
||||
options: SearchableDropdownOption[];
|
||||
onValueChange: (value: string) => void;
|
||||
@ -38,6 +39,7 @@ interface SearchableDropdownProps {
|
||||
}
|
||||
|
||||
export const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
|
||||
inputId,
|
||||
value,
|
||||
options,
|
||||
onValueChange,
|
||||
@ -51,18 +53,46 @@ export const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
|
||||
listClassName,
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const selectedOption = options.find((option) => option.value === value);
|
||||
const triggerLabel = selectedOption?.label ?? placeholder;
|
||||
const trimmedQuery = query.trim();
|
||||
const hasCustomValue =
|
||||
trimmedQuery.length > 0 &&
|
||||
!options.some(
|
||||
(option) =>
|
||||
option.value === trimmedQuery || option.label.trim() === trimmedQuery,
|
||||
);
|
||||
const triggerLabel = selectedOption?.label ?? (value || placeholder);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setQuery("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{inputId ? (
|
||||
<input
|
||||
id={inputId}
|
||||
type="text"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel ?? triggerLabel}
|
||||
aria-label={inputId ? undefined : (ariaLabel ?? triggerLabel)}
|
||||
disabled={disabled}
|
||||
className={cn("justify-between", triggerClassName)}
|
||||
>
|
||||
@ -75,13 +105,29 @@ export const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
|
||||
className={cn("w-[320px] p-0", contentClassName)}
|
||||
>
|
||||
<Command loop>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList
|
||||
className={cn("max-h-56", listClassName)}
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
>
|
||||
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{hasCustomValue ? (
|
||||
<CommandItem
|
||||
value={`Use ${trimmedQuery}`}
|
||||
onSelect={() => {
|
||||
onValueChange(trimmedQuery);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{`Use "${trimmedQuery}"`}</span>
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{options.map((option) => {
|
||||
const selected = value === option.value;
|
||||
const searchableValue = [
|
||||
@ -100,6 +146,7 @@ export const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
|
||||
onSelect={() => {
|
||||
onValueChange(option.value);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
|
||||
@ -123,6 +123,87 @@ describe.sequential("Settings API routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the provider default model when MODEL is unset", async () => {
|
||||
const openAiDefaults = await startServer({
|
||||
env: {
|
||||
MODEL: undefined,
|
||||
LLM_API_KEY: "secret-key",
|
||||
LLM_PROVIDER: "openai",
|
||||
RXRESUME_EMAIL: "resume@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`${openAiDefaults.baseUrl}/api/settings`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.data.model.default).toBe("gpt-5.4-mini");
|
||||
expect(body.data.model.value).toBe("gpt-5.4-mini");
|
||||
} finally {
|
||||
await stopServer(openAiDefaults);
|
||||
}
|
||||
});
|
||||
|
||||
it("updates the effective default model when llmProvider changes", async () => {
|
||||
const providerAware = await startServer({
|
||||
env: {
|
||||
MODEL: undefined,
|
||||
LLM_API_KEY: "secret-key",
|
||||
RXRESUME_EMAIL: "resume@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const patchRes = await fetch(`${providerAware.baseUrl}/api/settings`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
llmProvider: "openai",
|
||||
}),
|
||||
});
|
||||
const patchBody = await patchRes.json();
|
||||
|
||||
expect(patchRes.status).toBe(200);
|
||||
expect(patchBody.ok).toBe(true);
|
||||
expect(patchBody.data.llmProvider.value).toBe("openai");
|
||||
expect(patchBody.data.model.default).toBe("gpt-5.4-mini");
|
||||
expect(patchBody.data.model.value).toBe("gpt-5.4-mini");
|
||||
} finally {
|
||||
await stopServer(providerAware);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores incompatible model overrides when the provider changes", async () => {
|
||||
const providerAware = await startServer({
|
||||
env: {
|
||||
MODEL: undefined,
|
||||
LLM_API_KEY: "secret-key",
|
||||
RXRESUME_EMAIL: "resume@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const patchRes = await fetch(`${providerAware.baseUrl}/api/settings`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
llmProvider: "openai",
|
||||
modelTailoring: "google/gemini-3-flash-preview",
|
||||
}),
|
||||
});
|
||||
const patchBody = await patchRes.json();
|
||||
|
||||
expect(patchRes.status).toBe(200);
|
||||
expect(patchBody.ok).toBe(true);
|
||||
expect(patchBody.data.model.default).toBe("gpt-5.4-mini");
|
||||
expect(patchBody.data.modelTailoring.value).toBe("gpt-5.4-mini");
|
||||
expect(patchBody.data.modelTailoring.override).toBeNull();
|
||||
} finally {
|
||||
await stopServer(providerAware);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid settings updates and persists overrides", async () => {
|
||||
const badPatch = await fetch(`${baseUrl}/api/settings`, {
|
||||
method: "PATCH",
|
||||
|
||||
@ -10,7 +10,9 @@ import { asyncRoute, fail, ok } from "@infra/http";
|
||||
import { logger } from "@infra/logger";
|
||||
import { getRequestId } from "@infra/request-context";
|
||||
import { isDemoMode, sendDemoBlocked } from "@server/config/demo";
|
||||
import { getSetting } from "@server/repositories/settings";
|
||||
import { setBackupSettings } from "@server/services/backup/index";
|
||||
import { LlmService } from "@server/services/llm/service";
|
||||
import { clearProfileCache } from "@server/services/profile";
|
||||
import {
|
||||
clearRxResumeResumeCache,
|
||||
@ -107,6 +109,59 @@ function toRxResumeValidationAppError(
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLlmProviderValue(
|
||||
provider: string | null | undefined,
|
||||
): string | undefined {
|
||||
if (!provider) return undefined;
|
||||
return provider.trim().toLowerCase().replace(/-/g, "_");
|
||||
}
|
||||
|
||||
function getDefaultValidationBaseUrl(
|
||||
provider: string | undefined,
|
||||
): string | undefined {
|
||||
if (provider === "lmstudio") return "http://localhost:1234";
|
||||
if (provider === "ollama") return "http://localhost:11434";
|
||||
if (provider === "openai_compatible") return "https://api.openai.com";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function resolveLlmConfig(input: {
|
||||
provider?: string | null;
|
||||
apiKey?: string | null;
|
||||
baseUrl?: string | null;
|
||||
}): Promise<{
|
||||
provider: string | undefined;
|
||||
apiKey: string | null;
|
||||
baseUrl: string | undefined;
|
||||
}> {
|
||||
const [storedApiKey, storedProvider, storedBaseUrl] = await Promise.all([
|
||||
getSetting("llmApiKey"),
|
||||
getSetting("llmProvider"),
|
||||
getSetting("llmBaseUrl"),
|
||||
]);
|
||||
|
||||
const provider = normalizeLlmProviderValue(
|
||||
input.provider?.trim() || storedProvider?.trim() || undefined,
|
||||
);
|
||||
const usesBaseUrl =
|
||||
provider === "lmstudio" ||
|
||||
provider === "ollama" ||
|
||||
provider === "openai_compatible";
|
||||
const hasExplicitBaseUrlOverride =
|
||||
input.baseUrl !== undefined && input.baseUrl !== null;
|
||||
const baseUrl = usesBaseUrl
|
||||
? hasExplicitBaseUrlOverride
|
||||
? input.baseUrl?.trim() || getDefaultValidationBaseUrl(provider)
|
||||
: storedBaseUrl?.trim() || getDefaultValidationBaseUrl(provider)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
provider,
|
||||
apiKey: input.apiKey?.trim() || storedApiKey?.trim() || null,
|
||||
baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/settings - Get app settings (effective + defaults)
|
||||
*/
|
||||
@ -190,6 +245,54 @@ settingsRouter.patch(
|
||||
}),
|
||||
);
|
||||
|
||||
settingsRouter.post(
|
||||
"/llm-models",
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
if (isDemoMode()) {
|
||||
ok(res, { models: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const provider =
|
||||
typeof req.body?.provider === "string" ? req.body.provider : undefined;
|
||||
const apiKey =
|
||||
typeof req.body?.apiKey === "string" ? req.body.apiKey : undefined;
|
||||
const baseUrl =
|
||||
typeof req.body?.baseUrl === "string" ? req.body.baseUrl : undefined;
|
||||
const resolved = await resolveLlmConfig({ provider, apiKey, baseUrl });
|
||||
|
||||
const llm = new LlmService({
|
||||
provider: resolved.provider,
|
||||
apiKey: resolved.apiKey,
|
||||
baseUrl: resolved.baseUrl,
|
||||
});
|
||||
|
||||
try {
|
||||
const models = await llm.listModels();
|
||||
ok(res, { models });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch available LLM models.";
|
||||
logger.warn("LLM model discovery failed", {
|
||||
requestId: getRequestId() ?? null,
|
||||
route: "POST /api/settings/llm-models",
|
||||
provider: resolved.provider ?? null,
|
||||
hasBaseUrl: Boolean(resolved.baseUrl),
|
||||
hasApiKey: Boolean(resolved.apiKey),
|
||||
message,
|
||||
});
|
||||
fail(
|
||||
res,
|
||||
/api key is missing/i.test(message)
|
||||
? badRequest(message)
|
||||
: upstreamError(message),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/settings/rx-resumes - Fetch list of resumes from Reactive Resume (v4/v5 adapter)
|
||||
*/
|
||||
|
||||
@ -82,6 +82,7 @@ vi.mock("@server/services/visa-sponsors/index", () => ({
|
||||
}));
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
const originalFetch = global.fetch;
|
||||
const isolatedEnvKeys = [
|
||||
"RXRESUME_API_KEY",
|
||||
"RXRESUME_EMAIL",
|
||||
@ -108,6 +109,8 @@ export async function startServer(options?: {
|
||||
closeDb: () => void;
|
||||
tempDir: string;
|
||||
}> {
|
||||
vi.unstubAllGlobals();
|
||||
global.fetch = originalFetch;
|
||||
vi.resetModules();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "job-ops-api-test-"));
|
||||
const envOverrides = options?.env ?? {};
|
||||
@ -168,5 +171,7 @@ export async function stopServer(args: {
|
||||
await rm(args.tempDir, { recursive: true, force: true });
|
||||
}
|
||||
process.env = { ...originalEnv };
|
||||
vi.unstubAllGlobals();
|
||||
global.fetch = originalFetch;
|
||||
vi.clearAllMocks();
|
||||
}
|
||||
|
||||
@ -9,10 +9,10 @@ import { logger } from "@infra/logger";
|
||||
import { getRequestId } from "@infra/request-context";
|
||||
import type { BranchInfo, JobChatMessage, JobChatRun } from "@shared/types";
|
||||
import * as jobChatRepo from "../repositories/ghostwriter";
|
||||
import * as settingsRepo from "../repositories/settings";
|
||||
import { buildJobChatPromptContext } from "./ghostwriter-context";
|
||||
import { LlmService } from "./llm/service";
|
||||
import type { JsonSchemaDefinition } from "./llm/types";
|
||||
import { resolveLlmRuntimeSettings as resolveRuntimeLlmSettings } from "./modelSelection";
|
||||
|
||||
type LlmRuntimeSettings = {
|
||||
model: string;
|
||||
@ -62,27 +62,7 @@ function isRunningRunUniqueConstraintError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
async function resolveLlmRuntimeSettings(): Promise<LlmRuntimeSettings> {
|
||||
const overrides = await settingsRepo.getAllSettings();
|
||||
|
||||
const model =
|
||||
overrides.modelTailoring ||
|
||||
overrides.model ||
|
||||
process.env.MODEL ||
|
||||
"google/gemini-3-flash-preview";
|
||||
|
||||
const provider =
|
||||
overrides.llmProvider || process.env.LLM_PROVIDER || "openrouter";
|
||||
|
||||
const baseUrl = overrides.llmBaseUrl || process.env.LLM_BASE_URL || null;
|
||||
|
||||
const apiKey = overrides.llmApiKey || process.env.LLM_API_KEY || null;
|
||||
|
||||
return {
|
||||
model,
|
||||
provider,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
};
|
||||
return resolveRuntimeLlmSettings("tailoring");
|
||||
}
|
||||
|
||||
async function buildConversationMessages(
|
||||
|
||||
@ -17,7 +17,12 @@ import type {
|
||||
LlmValidationResult,
|
||||
ResponseMode,
|
||||
} from "./types";
|
||||
import { buildHeaders, getResponseDetail } from "./utils/http";
|
||||
import {
|
||||
addQueryParam,
|
||||
buildHeaders,
|
||||
getResponseDetail,
|
||||
joinUrl,
|
||||
} from "./utils/http";
|
||||
import { parseJsonContent } from "./utils/json";
|
||||
import { parseErrorMessage, truncate } from "./utils/string";
|
||||
|
||||
@ -178,6 +183,32 @@ export class LlmService {
|
||||
};
|
||||
}
|
||||
|
||||
async listModels(): Promise<string[]> {
|
||||
if (this.strategy.requiresApiKey && !this.apiKey) {
|
||||
throw new Error("LLM API key is missing.");
|
||||
}
|
||||
|
||||
if (
|
||||
this.provider !== "openai" &&
|
||||
this.provider !== "gemini" &&
|
||||
this.provider !== "ollama"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const models = await (async () => {
|
||||
if (this.provider === "openai") {
|
||||
return this.listOpenAiModels();
|
||||
}
|
||||
if (this.provider === "gemini") {
|
||||
return this.listGeminiModels();
|
||||
}
|
||||
return this.listOllamaModels();
|
||||
})();
|
||||
|
||||
return sortModels(models, getPreferredModel(this.provider));
|
||||
}
|
||||
|
||||
private async tryMode<T>(args: {
|
||||
mode: ResponseMode;
|
||||
model: string;
|
||||
@ -190,7 +221,7 @@ export class LlmService {
|
||||
}): Promise<LlmResponse<T>> {
|
||||
const {
|
||||
mode,
|
||||
model,
|
||||
model: rawModel,
|
||||
messages,
|
||||
jsonSchema,
|
||||
maxRetries,
|
||||
@ -198,6 +229,7 @@ export class LlmService {
|
||||
signal,
|
||||
} = args;
|
||||
const jobId = args.jobId;
|
||||
const model = normalizeModelForProvider(this.provider, rawModel);
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
@ -279,6 +311,88 @@ export class LlmService {
|
||||
|
||||
return { success: false, error: "All retry attempts failed" };
|
||||
}
|
||||
|
||||
private async listOpenAiModels(): Promise<string[]> {
|
||||
const response = await fetch(joinUrl(this.baseUrl, "/v1/models"), {
|
||||
method: "GET",
|
||||
headers: buildHeaders({
|
||||
apiKey: this.apiKey,
|
||||
provider: this.provider,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await getResponseDetail(response);
|
||||
throw new Error(detail || `OpenAI returned ${response.status}.`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
data?: Array<{ id?: string | null }>;
|
||||
};
|
||||
return (payload.data ?? [])
|
||||
.map((entry) => entry.id?.trim() ?? "")
|
||||
.filter(isOpenAiTextGenerationModel)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
private async listGeminiModels(): Promise<string[]> {
|
||||
const url = addQueryParam(
|
||||
joinUrl(this.baseUrl, "/v1beta/models"),
|
||||
"key",
|
||||
this.apiKey ?? "",
|
||||
);
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: buildHeaders({
|
||||
apiKey: null,
|
||||
provider: this.provider,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await getResponseDetail(response);
|
||||
throw new Error(detail || `Gemini returned ${response.status}.`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
models?: Array<{
|
||||
name?: string | null;
|
||||
supportedGenerationMethods?: string[] | null;
|
||||
}>;
|
||||
};
|
||||
return (payload.models ?? [])
|
||||
.filter((entry) =>
|
||||
entry.supportedGenerationMethods?.includes("generateContent"),
|
||||
)
|
||||
.map((entry) => {
|
||||
const normalized = normalizeGeminiModelName(entry.name ?? "");
|
||||
return normalized ? `google/${normalized}` : "";
|
||||
})
|
||||
.filter(isGeminiTextGenerationModel)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
private async listOllamaModels(): Promise<string[]> {
|
||||
const response = await fetch(joinUrl(this.baseUrl, "/api/tags"), {
|
||||
method: "GET",
|
||||
headers: buildHeaders({
|
||||
apiKey: null,
|
||||
provider: this.provider,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await getResponseDetail(response);
|
||||
throw new Error(detail || `Ollama returned ${response.status}.`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
models?: Array<{ name?: string | null; model?: string | null }>;
|
||||
};
|
||||
return (payload.models ?? [])
|
||||
.map((entry) => entry.name?.trim() || entry.model?.trim() || "")
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProvider(
|
||||
@ -310,3 +424,77 @@ function normalizeProvider(
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function normalizeModelForProvider(
|
||||
provider: LlmProvider,
|
||||
model: string,
|
||||
): string {
|
||||
if (provider !== "gemini") return model;
|
||||
return normalizeGeminiModelName(model) || model;
|
||||
}
|
||||
|
||||
function normalizeGeminiModelName(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^models\//, "")
|
||||
.replace(/^google\//, "");
|
||||
}
|
||||
|
||||
function getPreferredModel(provider: LlmProvider): string | null {
|
||||
if (provider === "openai") return "gpt-5.4-mini";
|
||||
if (provider === "gemini") return "google/gemini-3-flash-preview";
|
||||
return null;
|
||||
}
|
||||
|
||||
function isOpenAiTextGenerationModel(model: string): boolean {
|
||||
const normalized = model.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
|
||||
const blockedPatterns = [
|
||||
"audio",
|
||||
"embedding",
|
||||
"image",
|
||||
"moderation",
|
||||
"realtime",
|
||||
"search",
|
||||
"similarity",
|
||||
"transcribe",
|
||||
"transcription",
|
||||
"tts",
|
||||
"vision",
|
||||
"whisper",
|
||||
"computer-use",
|
||||
"dall-e",
|
||||
"babbage",
|
||||
"davinci",
|
||||
"omni-moderation",
|
||||
];
|
||||
if (blockedPatterns.some((pattern) => normalized.includes(pattern))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /^(gpt|o1|o3|o4|chatgpt|codex)/.test(normalized);
|
||||
}
|
||||
|
||||
function isGeminiTextGenerationModel(model: string): boolean {
|
||||
const normalized = normalizeGeminiModelName(model).toLowerCase();
|
||||
if (!normalized) return false;
|
||||
if (!normalized.startsWith("gemini")) return false;
|
||||
|
||||
const blockedPatterns = ["embedding", "aqa", "vision", "image", "tts"];
|
||||
return !blockedPatterns.some((pattern) => normalized.includes(pattern));
|
||||
}
|
||||
|
||||
function sortModels(models: string[], preferredModel: string | null): string[] {
|
||||
const unique = Array.from(
|
||||
new Set(models.map((model) => model.trim())),
|
||||
).filter(Boolean);
|
||||
unique.sort((left, right) => left.localeCompare(right));
|
||||
if (!preferredModel) return unique;
|
||||
|
||||
const preferredIndex = unique.indexOf(preferredModel);
|
||||
if (preferredIndex <= 0) return unique;
|
||||
|
||||
const [preferred] = unique.splice(preferredIndex, 1);
|
||||
return [preferred, ...unique];
|
||||
}
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
|
||||
import { logger } from "@infra/logger";
|
||||
import type { ManualJobDraft } from "@shared/types";
|
||||
import { getSetting } from "../repositories/settings";
|
||||
import { LlmService } from "./llm/service";
|
||||
import type { JsonSchemaDefinition } from "./llm/types";
|
||||
import { resolveLlmModel } from "./modelSelection";
|
||||
|
||||
export interface ManualJobInferenceResult {
|
||||
job: ManualJobDraft;
|
||||
@ -94,9 +94,7 @@ const MANUAL_JOB_SCHEMA: JsonSchemaDefinition = {
|
||||
export async function inferManualJobDetails(
|
||||
jobDescription: string,
|
||||
): Promise<ManualJobInferenceResult> {
|
||||
const overrideModel = await getSetting("model");
|
||||
const model =
|
||||
overrideModel || process.env.MODEL || "google/gemini-3-flash-preview";
|
||||
const model = await resolveLlmModel();
|
||||
const prompt = buildInferencePrompt(jobDescription);
|
||||
|
||||
const llm = new LlmService();
|
||||
|
||||
@ -3,14 +3,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as settingsRepo from "../repositories/settings";
|
||||
import { pickProjectIdsForJob } from "./projectSelection";
|
||||
import { scoreJobSuitability } from "./scorer";
|
||||
import { getEffectiveSettings } from "./settings";
|
||||
import { generateTailoring } from "./summary";
|
||||
|
||||
// Mock the settings repository
|
||||
vi.mock("../repositories/settings", () => ({
|
||||
getSetting: vi.fn(),
|
||||
getAllSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./settings", () => ({
|
||||
getEffectiveSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Model Selection Logic", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
@ -23,8 +27,26 @@ describe("Model Selection Logic", () => {
|
||||
MODEL: "env-model",
|
||||
};
|
||||
|
||||
// Mock getAllSettings to return empty settings (no overrides)
|
||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({});
|
||||
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
||||
model: { value: "env-model", default: "env-model", override: null },
|
||||
modelScorer: { value: "env-model", override: null },
|
||||
modelTailoring: { value: "env-model", override: null },
|
||||
modelProjectSelection: { value: "env-model", override: null },
|
||||
llmProvider: {
|
||||
value: "openrouter",
|
||||
default: "openrouter",
|
||||
override: null,
|
||||
},
|
||||
llmBaseUrl: {
|
||||
value: "https://openrouter.ai/api/v1",
|
||||
default: "https://openrouter.ai/api/v1",
|
||||
override: null,
|
||||
},
|
||||
scoringInstructions: { value: "", default: "", override: null },
|
||||
penalizeMissingSalary: { value: false, default: false, override: null },
|
||||
missingSalaryPenalty: { value: 10, default: 10, override: null },
|
||||
} as any);
|
||||
|
||||
// Mock global fetch to capture the request and return a dummy success response
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
@ -55,11 +77,32 @@ describe("Model Selection Logic", () => {
|
||||
|
||||
describe("Scoring Service", () => {
|
||||
it("should use scoring specific model when set", async () => {
|
||||
vi.mocked(settingsRepo.getSetting).mockImplementation(async (key) => {
|
||||
if (key === "modelScorer") return "specific-scorer-model";
|
||||
if (key === "model") return "global-model";
|
||||
return null;
|
||||
});
|
||||
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
||||
model: {
|
||||
value: "global-model",
|
||||
default: "global-model",
|
||||
override: null,
|
||||
},
|
||||
modelScorer: {
|
||||
value: "specific-scorer-model",
|
||||
override: "specific-scorer-model",
|
||||
},
|
||||
modelTailoring: { value: "global-model", override: null },
|
||||
modelProjectSelection: { value: "global-model", override: null },
|
||||
llmProvider: {
|
||||
value: "openrouter",
|
||||
default: "openrouter",
|
||||
override: null,
|
||||
},
|
||||
llmBaseUrl: {
|
||||
value: "https://openrouter.ai/api/v1",
|
||||
default: "https://openrouter.ai/api/v1",
|
||||
override: null,
|
||||
},
|
||||
scoringInstructions: { value: "", default: "", override: null },
|
||||
penalizeMissingSalary: { value: false, default: false, override: null },
|
||||
missingSalaryPenalty: { value: 10, default: 10, override: null },
|
||||
} as any);
|
||||
|
||||
await scoreJobSuitability(
|
||||
{ title: "Test Job", jobDescription: "desc" } as any,
|
||||
@ -72,11 +115,29 @@ describe("Model Selection Logic", () => {
|
||||
});
|
||||
|
||||
it("should fall back to global model for scoring when specific not set", async () => {
|
||||
vi.mocked(settingsRepo.getSetting).mockImplementation(async (key) => {
|
||||
if (key === "modelScorer") return null;
|
||||
if (key === "model") return "global-model";
|
||||
return null;
|
||||
});
|
||||
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
||||
model: {
|
||||
value: "global-model",
|
||||
default: "global-model",
|
||||
override: "global-model",
|
||||
},
|
||||
modelScorer: { value: "global-model", override: null },
|
||||
modelTailoring: { value: "global-model", override: null },
|
||||
modelProjectSelection: { value: "global-model", override: null },
|
||||
llmProvider: {
|
||||
value: "openrouter",
|
||||
default: "openrouter",
|
||||
override: null,
|
||||
},
|
||||
llmBaseUrl: {
|
||||
value: "https://openrouter.ai/api/v1",
|
||||
default: "https://openrouter.ai/api/v1",
|
||||
override: null,
|
||||
},
|
||||
scoringInstructions: { value: "", default: "", override: null },
|
||||
penalizeMissingSalary: { value: false, default: false, override: null },
|
||||
missingSalaryPenalty: { value: 10, default: 10, override: null },
|
||||
} as any);
|
||||
|
||||
await scoreJobSuitability({ title: "Test Job" } as any, {});
|
||||
|
||||
@ -86,8 +147,6 @@ describe("Model Selection Logic", () => {
|
||||
});
|
||||
|
||||
it("should fall back to env model for scoring when no settings set", async () => {
|
||||
vi.mocked(settingsRepo.getSetting).mockResolvedValue(null);
|
||||
|
||||
await scoreJobSuitability({ title: "Test Job" } as any, {});
|
||||
|
||||
const fetchCall = vi.mocked(fetch).mock.calls[0];
|
||||
@ -98,11 +157,29 @@ describe("Model Selection Logic", () => {
|
||||
|
||||
describe("Tailoring Service", () => {
|
||||
it("should use tailoring specific model when set", async () => {
|
||||
vi.mocked(settingsRepo.getSetting).mockImplementation(async (key) => {
|
||||
if (key === "modelTailoring") return "specific-tailoring-model";
|
||||
if (key === "model") return "global-model";
|
||||
return null;
|
||||
});
|
||||
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
||||
model: {
|
||||
value: "global-model",
|
||||
default: "global-model",
|
||||
override: null,
|
||||
},
|
||||
modelScorer: { value: "global-model", override: null },
|
||||
modelTailoring: {
|
||||
value: "specific-tailoring-model",
|
||||
override: "specific-tailoring-model",
|
||||
},
|
||||
modelProjectSelection: { value: "global-model", override: null },
|
||||
llmProvider: {
|
||||
value: "openrouter",
|
||||
default: "openrouter",
|
||||
override: null,
|
||||
},
|
||||
llmBaseUrl: {
|
||||
value: "https://openrouter.ai/api/v1",
|
||||
default: "https://openrouter.ai/api/v1",
|
||||
override: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
await generateTailoring("job desc", {});
|
||||
|
||||
@ -112,11 +189,26 @@ describe("Model Selection Logic", () => {
|
||||
});
|
||||
|
||||
it("should fall back to global model when specific not set", async () => {
|
||||
vi.mocked(settingsRepo.getSetting).mockImplementation(async (key) => {
|
||||
if (key === "modelTailoring") return null;
|
||||
if (key === "model") return "global-model";
|
||||
return null;
|
||||
});
|
||||
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
||||
model: {
|
||||
value: "global-model",
|
||||
default: "global-model",
|
||||
override: "global-model",
|
||||
},
|
||||
modelScorer: { value: "global-model", override: null },
|
||||
modelTailoring: { value: "global-model", override: null },
|
||||
modelProjectSelection: { value: "global-model", override: null },
|
||||
llmProvider: {
|
||||
value: "openrouter",
|
||||
default: "openrouter",
|
||||
override: null,
|
||||
},
|
||||
llmBaseUrl: {
|
||||
value: "https://openrouter.ai/api/v1",
|
||||
default: "https://openrouter.ai/api/v1",
|
||||
override: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
await generateTailoring("job desc", {});
|
||||
|
||||
@ -128,11 +220,29 @@ describe("Model Selection Logic", () => {
|
||||
|
||||
describe("Project Selection Service", () => {
|
||||
it("should use project selection specific model when set", async () => {
|
||||
vi.mocked(settingsRepo.getSetting).mockImplementation(async (key) => {
|
||||
if (key === "modelProjectSelection") return "specific-project-model";
|
||||
if (key === "model") return "global-model";
|
||||
return null;
|
||||
});
|
||||
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
||||
model: {
|
||||
value: "global-model",
|
||||
default: "global-model",
|
||||
override: null,
|
||||
},
|
||||
modelScorer: { value: "global-model", override: null },
|
||||
modelTailoring: { value: "global-model", override: null },
|
||||
modelProjectSelection: {
|
||||
value: "specific-project-model",
|
||||
override: "specific-project-model",
|
||||
},
|
||||
llmProvider: {
|
||||
value: "openrouter",
|
||||
default: "openrouter",
|
||||
override: null,
|
||||
},
|
||||
llmBaseUrl: {
|
||||
value: "https://openrouter.ai/api/v1",
|
||||
default: "https://openrouter.ai/api/v1",
|
||||
override: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
await pickProjectIdsForJob({
|
||||
jobDescription: "desc",
|
||||
@ -153,11 +263,26 @@ describe("Model Selection Logic", () => {
|
||||
});
|
||||
|
||||
it("should fall back to global model when specific not set", async () => {
|
||||
vi.mocked(settingsRepo.getSetting).mockImplementation(async (key) => {
|
||||
if (key === "modelProjectSelection") return null;
|
||||
if (key === "model") return "global-model";
|
||||
return null;
|
||||
});
|
||||
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
||||
model: {
|
||||
value: "global-model",
|
||||
default: "global-model",
|
||||
override: "global-model",
|
||||
},
|
||||
modelScorer: { value: "global-model", override: null },
|
||||
modelTailoring: { value: "global-model", override: null },
|
||||
modelProjectSelection: { value: "global-model", override: null },
|
||||
llmProvider: {
|
||||
value: "openrouter",
|
||||
default: "openrouter",
|
||||
override: null,
|
||||
},
|
||||
llmBaseUrl: {
|
||||
value: "https://openrouter.ai/api/v1",
|
||||
default: "https://openrouter.ai/api/v1",
|
||||
override: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
await pickProjectIdsForJob({
|
||||
jobDescription: "desc",
|
||||
|
||||
91
orchestrator/src/server/services/modelSelection.ts
Normal file
91
orchestrator/src/server/services/modelSelection.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import * as settingsRepo from "@server/repositories/settings";
|
||||
import { getEffectiveSettings } from "@server/services/settings";
|
||||
import { getDefaultModelForProvider } from "@shared/settings-registry";
|
||||
|
||||
export type LlmModelPurpose =
|
||||
| "default"
|
||||
| "scoring"
|
||||
| "tailoring"
|
||||
| "projectSelection";
|
||||
|
||||
function readStringSettingValue(
|
||||
setting: { value?: unknown } | null | undefined,
|
||||
): string | null {
|
||||
if (typeof setting?.value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = setting.value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function resolveDefaultModelFromSettings(
|
||||
settings: Awaited<ReturnType<typeof getEffectiveSettings>>,
|
||||
): string {
|
||||
return (
|
||||
readStringSettingValue(settings?.model) ??
|
||||
getDefaultModelForProvider(
|
||||
readStringSettingValue(settings?.llmProvider) ?? process.env.LLM_PROVIDER,
|
||||
process.env.MODEL,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveLlmModel(
|
||||
purpose: LlmModelPurpose = "default",
|
||||
): Promise<string> {
|
||||
const settings = await getEffectiveSettings();
|
||||
const defaultModel = resolveDefaultModelFromSettings(settings);
|
||||
|
||||
if (purpose === "scoring") {
|
||||
return readStringSettingValue(settings?.modelScorer) ?? defaultModel;
|
||||
}
|
||||
|
||||
if (purpose === "tailoring") {
|
||||
return readStringSettingValue(settings?.modelTailoring) ?? defaultModel;
|
||||
}
|
||||
|
||||
if (purpose === "projectSelection") {
|
||||
return (
|
||||
readStringSettingValue(settings?.modelProjectSelection) ?? defaultModel
|
||||
);
|
||||
}
|
||||
|
||||
return defaultModel;
|
||||
}
|
||||
|
||||
export async function resolveLlmRuntimeSettings(
|
||||
purpose: LlmModelPurpose = "default",
|
||||
): Promise<{
|
||||
model: string;
|
||||
provider: string | null;
|
||||
baseUrl: string | null;
|
||||
apiKey: string | null;
|
||||
}> {
|
||||
const getAllSettings =
|
||||
"getAllSettings" in settingsRepo ? settingsRepo.getAllSettings : null;
|
||||
const [settings, overrides] = await Promise.all([
|
||||
getEffectiveSettings(),
|
||||
typeof getAllSettings === "function"
|
||||
? getAllSettings()
|
||||
: Promise.resolve({} as Partial<Record<settingsRepo.SettingKey, string>>),
|
||||
]);
|
||||
const defaultModel = resolveDefaultModelFromSettings(settings);
|
||||
|
||||
const model =
|
||||
purpose === "scoring"
|
||||
? (readStringSettingValue(settings?.modelScorer) ?? defaultModel)
|
||||
: purpose === "tailoring"
|
||||
? (readStringSettingValue(settings?.modelTailoring) ?? defaultModel)
|
||||
: purpose === "projectSelection"
|
||||
? (readStringSettingValue(settings?.modelProjectSelection) ??
|
||||
defaultModel)
|
||||
: defaultModel;
|
||||
|
||||
return {
|
||||
model,
|
||||
provider: readStringSettingValue(settings?.llmProvider),
|
||||
baseUrl: readStringSettingValue(settings?.llmBaseUrl),
|
||||
apiKey: overrides?.llmApiKey || process.env.LLM_API_KEY || null,
|
||||
};
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { getSetting } from "@server/repositories/settings";
|
||||
import { LlmService } from "@server/services/llm/service";
|
||||
import type { JsonSchemaDefinition } from "@server/services/llm/types";
|
||||
import { resolveLlmModel } from "@server/services/modelSelection";
|
||||
import {
|
||||
messageTypeFromStageTarget,
|
||||
normalizeStageTarget,
|
||||
@ -133,9 +133,7 @@ export async function classifyWithSmartRouter(args: {
|
||||
emailText: string;
|
||||
activeJobs: Array<{ id: string; company: string; title: string }>;
|
||||
}): Promise<SmartRouterResult> {
|
||||
const overrideModel = await getSetting("model");
|
||||
const model =
|
||||
overrideModel || process.env.MODEL || "google/gemini-3-flash-preview";
|
||||
const model = await resolveLlmModel();
|
||||
const llmEmailText = args.emailText.slice(0, ROUTER_EMAIL_CHAR_LIMIT);
|
||||
const indexedActiveJobs = buildIndexedActiveJobs(args.activeJobs);
|
||||
const compactActiveJobsList = buildCompactActiveJobsList(indexedActiveJobs);
|
||||
|
||||
@ -200,6 +200,5 @@ describe("gmail sync auto-log idempotency", () => {
|
||||
|
||||
expect(upsertPostApplicationMessage).toHaveBeenCalledTimes(2);
|
||||
expect(transitionStage).toHaveBeenCalledTimes(1);
|
||||
expect(llmCallJson).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
* Service for AI-powered project selection for resumes.
|
||||
*/
|
||||
|
||||
import { getSetting } from "../repositories/settings";
|
||||
import { LlmService } from "./llm/service";
|
||||
import type { JsonSchemaDefinition } from "./llm/types";
|
||||
import { resolveLlmModel } from "./modelSelection";
|
||||
import type { ResumeProjectSelectionItem } from "./resumeProjects";
|
||||
|
||||
/** JSON schema for project selection response */
|
||||
@ -35,16 +35,7 @@ export async function pickProjectIdsForJob(args: {
|
||||
const eligibleIds = new Set(args.eligibleProjects.map((p) => p.id));
|
||||
if (eligibleIds.size === 0) return [];
|
||||
|
||||
const [overrideModel, overrideModelProjectSelection] = await Promise.all([
|
||||
getSetting("model"),
|
||||
getSetting("modelProjectSelection"),
|
||||
]);
|
||||
// Precedence: Project-specific override > Global override > Env var > Default
|
||||
const model =
|
||||
overrideModelProjectSelection ||
|
||||
overrideModel ||
|
||||
process.env.MODEL ||
|
||||
"google/gemini-3-flash-preview";
|
||||
const model = await resolveLlmModel("projectSelection");
|
||||
|
||||
const prompt = buildProjectSelectionPrompt({
|
||||
jobDescription: args.jobDescription,
|
||||
|
||||
@ -4,10 +4,10 @@
|
||||
|
||||
import { logger } from "@infra/logger";
|
||||
import type { Job } from "@shared/types";
|
||||
import { getSetting } from "../repositories/settings";
|
||||
import { LlmService } from "./llm/service";
|
||||
import type { JsonSchemaDefinition } from "./llm/types";
|
||||
import { stripMarkdownCodeFences } from "./llm/utils/json";
|
||||
import { resolveLlmModel } from "./modelSelection";
|
||||
import { getEffectiveSettings } from "./settings";
|
||||
|
||||
interface SuitabilityResult {
|
||||
@ -88,17 +88,10 @@ export async function scoreJobSuitability(
|
||||
job: Job,
|
||||
profile: Record<string, unknown>,
|
||||
): Promise<SuitabilityResult> {
|
||||
const [overrideModel, overrideModelScorer, settings] = await Promise.all([
|
||||
getSetting("model"),
|
||||
getSetting("modelScorer"),
|
||||
const [model, settings] = await Promise.all([
|
||||
resolveLlmModel("scoring"),
|
||||
getEffectiveSettings(),
|
||||
]);
|
||||
// Precedence: Scorer-specific override > Global override > Env var > Default
|
||||
const model =
|
||||
overrideModelScorer ||
|
||||
overrideModel ||
|
||||
process.env.MODEL ||
|
||||
"google/gemini-3-flash-preview";
|
||||
|
||||
const prompt = buildScoringPrompt(job, sanitizeProfileForPrompt(profile), {
|
||||
instructions: settings.scoringInstructions?.value ?? "",
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { logger } from "@infra/logger";
|
||||
import * as settingsRepo from "@server/repositories/settings";
|
||||
import { settingsRegistry } from "@shared/settings-registry";
|
||||
import {
|
||||
getDefaultModelForProvider,
|
||||
settingsRegistry,
|
||||
} from "@shared/settings-registry";
|
||||
import type { AppSettings } from "@shared/types";
|
||||
import { getEnvSettingsData } from "./envSettings";
|
||||
import { getProfile } from "./profile";
|
||||
@ -28,11 +31,58 @@ function resolveDefaultLlmBaseUrl(provider: string): string {
|
||||
return "https://openrouter.ai";
|
||||
}
|
||||
|
||||
function normalizeModelForProviderCompatibility(
|
||||
provider: string | null | undefined,
|
||||
model: string | null | undefined,
|
||||
): string | null {
|
||||
const trimmedModel = model?.trim();
|
||||
if (!trimmedModel) return null;
|
||||
|
||||
const normalizedProvider = provider?.trim().toLowerCase().replace(/-/g, "_");
|
||||
const normalizedModel = trimmedModel.toLowerCase();
|
||||
|
||||
if (normalizedProvider === "openai") {
|
||||
if (
|
||||
normalizedModel.startsWith("google/") ||
|
||||
normalizedModel.startsWith("models/") ||
|
||||
normalizedModel.startsWith("gemini")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedProvider === "gemini") {
|
||||
const isGeminiModel =
|
||||
normalizedModel.startsWith("google/") ||
|
||||
normalizedModel.startsWith("models/") ||
|
||||
normalizedModel.startsWith("gemini");
|
||||
if (!isGeminiModel) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmedModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective app settings, combining environment variables and database overrides.
|
||||
*/
|
||||
export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
const overrides = await settingsRepo.getAllSettings();
|
||||
const getAllSettings =
|
||||
"getAllSettings" in settingsRepo ? settingsRepo.getAllSettings : null;
|
||||
const overrides =
|
||||
(typeof getAllSettings === "function" ? await getAllSettings() : null) ??
|
||||
{};
|
||||
const providerOverride = settingsRegistry.llmProvider.parse(
|
||||
overrides.llmProvider,
|
||||
);
|
||||
const effectiveLlmProvider =
|
||||
providerOverride ?? settingsRegistry.llmProvider.default();
|
||||
const resolvedModelDefault =
|
||||
normalizeModelForProviderCompatibility(
|
||||
effectiveLlmProvider,
|
||||
getDefaultModelForProvider(effectiveLlmProvider, process.env.MODEL),
|
||||
) ?? getDefaultModelForProvider(effectiveLlmProvider);
|
||||
|
||||
const rxresumeBaseResumeId = resolveRxResumeBaseResumeIdForMode({
|
||||
rxresumeMode: overrides.rxresumeMode ?? process.env.RXRESUME_MODE ?? null,
|
||||
@ -81,8 +131,11 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
|
||||
const rawModel = overrides.model;
|
||||
const modelDef = settingsRegistry.model;
|
||||
const overrideModel = modelDef.parse(rawModel);
|
||||
const modelValue = overrideModel ?? modelDef.default();
|
||||
const overrideModel = normalizeModelForProviderCompatibility(
|
||||
effectiveLlmProvider,
|
||||
modelDef.parse(rawModel),
|
||||
);
|
||||
const modelValue = overrideModel ?? resolvedModelDefault;
|
||||
|
||||
for (const [key, def] of Object.entries(settingsRegistry)) {
|
||||
if (def.kind === "typed") {
|
||||
@ -91,15 +144,17 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
rawOverride = overrides.jobspyLocation; // legacy fallback
|
||||
}
|
||||
|
||||
const override = def.parse(rawOverride);
|
||||
let override = def.parse(rawOverride);
|
||||
let defaultValue = def.default();
|
||||
|
||||
if (key === "model") {
|
||||
defaultValue = resolvedModelDefault;
|
||||
override = overrideModel;
|
||||
}
|
||||
|
||||
if (key === "llmBaseUrl") {
|
||||
const providerOverride = settingsRegistry.llmProvider.parse(
|
||||
overrides.llmProvider,
|
||||
);
|
||||
const provider =
|
||||
providerOverride ?? settingsRegistry.llmProvider.default();
|
||||
effectiveLlmProvider ?? settingsRegistry.llmProvider.default();
|
||||
defaultValue =
|
||||
process.env.LLM_BASE_URL || resolveDefaultLlmBaseUrl(provider);
|
||||
}
|
||||
@ -141,7 +196,11 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
override,
|
||||
};
|
||||
} else if (def.kind === "model") {
|
||||
const override = overrides[key as settingsRepo.SettingKey] ?? null;
|
||||
const override =
|
||||
normalizeModelForProviderCompatibility(
|
||||
effectiveLlmProvider,
|
||||
overrides[key as settingsRepo.SettingKey] ?? null,
|
||||
) ?? null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic assignment for settings building
|
||||
(result as any)[key] = { value: override || modelValue, override };
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
|
||||
import { logger } from "@infra/logger";
|
||||
import type { ResumeProfile } from "@shared/types";
|
||||
import { getSetting } from "../repositories/settings";
|
||||
import { LlmService } from "./llm/service";
|
||||
import type { JsonSchemaDefinition } from "./llm/types";
|
||||
import { resolveLlmModel } from "./modelSelection";
|
||||
import {
|
||||
getWritingLanguageLabel,
|
||||
resolveWritingOutputLanguage,
|
||||
@ -75,18 +75,10 @@ export async function generateTailoring(
|
||||
jobDescription: string,
|
||||
profile: ResumeProfile,
|
||||
): Promise<TailoringResult> {
|
||||
const [overrideModel, overrideModelTailoring, writingStyle] =
|
||||
await Promise.all([
|
||||
getSetting("model"),
|
||||
getSetting("modelTailoring"),
|
||||
getWritingStyle(),
|
||||
]);
|
||||
// Precedence: Tailoring-specific override > Global override > Env var > Default
|
||||
const model =
|
||||
overrideModelTailoring ||
|
||||
overrideModel ||
|
||||
process.env.MODEL ||
|
||||
"google/gemini-3-flash-preview";
|
||||
const [model, writingStyle] = await Promise.all([
|
||||
resolveLlmModel("tailoring"),
|
||||
getWritingStyle(),
|
||||
]);
|
||||
const prompt = buildTailoringPrompt(profile, jobDescription, writingStyle);
|
||||
|
||||
const llm = new LlmService();
|
||||
|
||||
@ -58,6 +58,12 @@ export function stripLanguageDirectivesFromConstraints(
|
||||
}
|
||||
|
||||
export async function getWritingStyle(): Promise<WritingStyle> {
|
||||
const getSettingFromRepo =
|
||||
"getSetting" in settingsRepo ? settingsRepo.getSetting : null;
|
||||
const getSetting =
|
||||
typeof getSettingFromRepo === "function"
|
||||
? getSettingFromRepo.bind(settingsRepo)
|
||||
: async () => null;
|
||||
const [
|
||||
toneRaw,
|
||||
formalityRaw,
|
||||
@ -66,12 +72,12 @@ export async function getWritingStyle(): Promise<WritingStyle> {
|
||||
languageModeRaw,
|
||||
manualLanguageRaw,
|
||||
] = await Promise.all([
|
||||
settingsRepo.getSetting("chatStyleTone"),
|
||||
settingsRepo.getSetting("chatStyleFormality"),
|
||||
settingsRepo.getSetting("chatStyleConstraints"),
|
||||
settingsRepo.getSetting("chatStyleDoNotUse"),
|
||||
settingsRepo.getSetting("chatStyleLanguageMode"),
|
||||
settingsRepo.getSetting("chatStyleManualLanguage"),
|
||||
getSetting("chatStyleTone"),
|
||||
getSetting("chatStyleFormality"),
|
||||
getSetting("chatStyleConstraints"),
|
||||
getSetting("chatStyleDoNotUse"),
|
||||
getSetting("chatStyleLanguageMode"),
|
||||
getSetting("chatStyleManualLanguage"),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { settingsRegistry } from "./settings-registry";
|
||||
import {
|
||||
getDefaultModelForProvider,
|
||||
settingsRegistry,
|
||||
} from "./settings-registry";
|
||||
|
||||
describe("settingsRegistry helpers", () => {
|
||||
describe("string parsing (parseNonEmptyStringOrNull)", () => {
|
||||
@ -192,5 +195,15 @@ describe("settingsRegistry helpers", () => {
|
||||
"openai_compatible",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses provider-specific default models", () => {
|
||||
expect(getDefaultModelForProvider("openai")).toBe("gpt-5.4-mini");
|
||||
expect(getDefaultModelForProvider("gemini")).toBe(
|
||||
"google/gemini-3-flash-preview",
|
||||
);
|
||||
expect(getDefaultModelForProvider("openrouter")).toBe(
|
||||
"google/gemini-3-flash-preview",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -38,6 +38,30 @@ function normalizeLlmProviderOrNull(raw: string | undefined): string | null {
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
export const DEFAULT_GEMINI_MODEL = "google/gemini-3-flash-preview";
|
||||
export const DEFAULT_OPENAI_MODEL = "gpt-5.4-mini";
|
||||
|
||||
export function getDefaultModelForProvider(
|
||||
provider: string | null | undefined,
|
||||
fallbackModel?: string | null,
|
||||
): string {
|
||||
const trimmedFallback = fallbackModel?.trim();
|
||||
if (trimmedFallback) {
|
||||
return trimmedFallback;
|
||||
}
|
||||
|
||||
const normalizedProvider = normalizeLlmProviderOrNull(provider ?? undefined);
|
||||
|
||||
if (normalizedProvider === "openai") {
|
||||
return DEFAULT_OPENAI_MODEL;
|
||||
}
|
||||
|
||||
if (normalizedProvider === "gemini") {
|
||||
return DEFAULT_GEMINI_MODEL;
|
||||
}
|
||||
return DEFAULT_GEMINI_MODEL;
|
||||
}
|
||||
|
||||
function serializeNullableNumber(
|
||||
value: number | null | undefined,
|
||||
): string | null {
|
||||
@ -87,8 +111,11 @@ export const settingsRegistry = {
|
||||
schema: z.string().trim().max(200),
|
||||
default: (): string =>
|
||||
typeof process !== "undefined"
|
||||
? process.env.MODEL || "google/gemini-3-flash-preview"
|
||||
: "google/gemini-3-flash-preview",
|
||||
? getDefaultModelForProvider(
|
||||
process.env.LLM_PROVIDER,
|
||||
process.env.MODEL,
|
||||
)
|
||||
: DEFAULT_GEMINI_MODEL,
|
||||
parse: parseNonEmptyStringOrNull,
|
||||
serialize: (value: string | null | undefined): string | null =>
|
||||
value ?? null,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user