Enhance model handling and settings integration for providers (#295)

This commit is contained in:
Ammad Ali 2026-03-21 17:34:47 +00:00 committed by GitHub
parent 7f517776df
commit 8274ec4e14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1343 additions and 186 deletions

View File

@ -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

View File

@ -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.

View File

@ -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;

View File

@ -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 =

View File

@ -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();

View File

@ -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,

View File

@ -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;
}

View File

@ -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);
});
});

View File

@ -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);

View File

@ -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>

View File

@ -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",

View File

@ -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)
*/

View File

@ -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();
}

View File

@ -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(

View File

@ -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];
}

View File

@ -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();

View File

@ -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",

View 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,
};
}

View File

@ -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);

View File

@ -200,6 +200,5 @@ describe("gmail sync auto-log idempotency", () => {
expect(upsertPostApplicationMessage).toHaveBeenCalledTimes(2);
expect(transitionStage).toHaveBeenCalledTimes(1);
expect(llmCallJson).toHaveBeenCalledTimes(1);
});
});

View File

@ -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,

View File

@ -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 ?? "",

View File

@ -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 };

View File

@ -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();

View File

@ -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 {

View File

@ -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",
);
});
});
});

View File

@ -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,