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
|
- Scoring model
|
||||||
- Tailoring model
|
- Tailoring model
|
||||||
- Project-selection 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
|
### Webhooks
|
||||||
|
|
||||||
@ -199,6 +209,13 @@ curl -X POST "http://localhost:3001/api/backups"
|
|||||||
|
|
||||||
- Some settings apply only to new runs/actions after save.
|
- Some settings apply only to new runs/actions after save.
|
||||||
- Re-run scoring/tailoring/pipeline to validate effect.
|
- 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
|
### 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.
|
- Validate `LLM_API_KEY` and provider settings.
|
||||||
- Check settings page and API connectivity.
|
- 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
|
## PDF generation fails
|
||||||
|
|
||||||
- Verify RxResume credentials.
|
- 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?: {
|
export async function validateRxresume(input?: {
|
||||||
mode?: "v4" | "v5";
|
mode?: "v4" | "v5";
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
LLM_PROVIDERS,
|
LLM_PROVIDERS,
|
||||||
normalizeLlmProvider,
|
normalizeLlmProvider,
|
||||||
} from "@client/pages/settings/utils";
|
} from "@client/pages/settings/utils";
|
||||||
|
import { getDefaultModelForProvider } from "@shared/settings-registry";
|
||||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||||
import type { RxResumeMode, ValidationResult } from "@shared/types.js";
|
import type { RxResumeMode, ValidationResult } from "@shared/types.js";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
@ -423,6 +424,10 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
const update: Partial<UpdateSettingsInput> = {
|
const update: Partial<UpdateSettingsInput> = {
|
||||||
llmProvider: normalizedProvider,
|
llmProvider: normalizedProvider,
|
||||||
llmBaseUrl: showBaseUrl ? baseUrlValue || null : null,
|
llmBaseUrl: showBaseUrl ? baseUrlValue || null : null,
|
||||||
|
model: null,
|
||||||
|
modelScorer: null,
|
||||||
|
modelTailoring: null,
|
||||||
|
modelProjectSelection: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (showApiKey && apiKeyValue) {
|
if (showApiKey && apiKeyValue) {
|
||||||
@ -433,7 +438,13 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
await api.updateSettings(update);
|
await api.updateSettings(update);
|
||||||
await refreshSettings();
|
await refreshSettings();
|
||||||
setValue("llmApiKey", "");
|
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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
|
|||||||
@ -15,6 +15,7 @@ const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
|
|||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../api", () => ({
|
||||||
getSettings: vi.fn(),
|
getSettings: vi.fn(),
|
||||||
|
getLlmModels: vi.fn().mockResolvedValue([]),
|
||||||
updateSettings: vi.fn(),
|
updateSettings: vi.fn(),
|
||||||
validateRxresume: vi.fn(),
|
validateRxresume: vi.fn(),
|
||||||
getRxResumeProjects: vi.fn(),
|
getRxResumeProjects: vi.fn(),
|
||||||
@ -217,6 +218,50 @@ describe("SettingsPage", () => {
|
|||||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
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 () => {
|
it("hides pipeline tuning sections that moved to run modal", async () => {
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|||||||
@ -824,10 +824,26 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload: Partial<UpdateSettingsInput> = {
|
const payload: Partial<UpdateSettingsInput> = {
|
||||||
model: normalizeString(data.model),
|
model: dirtyFields.llmProvider
|
||||||
modelScorer: normalizeString(data.modelScorer),
|
? dirtyFields.model
|
||||||
modelTailoring: normalizeString(data.modelTailoring),
|
? normalizeString(data.model)
|
||||||
modelProjectSelection: normalizeString(data.modelProjectSelection),
|
: 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),
|
pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl),
|
||||||
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
||||||
resumeProjects: resumeProjectsOverride,
|
resumeProjects: resumeProjectsOverride,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import * as api from "@client/api";
|
||||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||||
import type { ModelValues } from "@client/pages/settings/types";
|
import type { ModelValues } from "@client/pages/settings/types";
|
||||||
import {
|
import {
|
||||||
@ -5,16 +6,19 @@ import {
|
|||||||
getLlmProviderConfig,
|
getLlmProviderConfig,
|
||||||
LLM_PROVIDER_LABELS,
|
LLM_PROVIDER_LABELS,
|
||||||
LLM_PROVIDERS,
|
LLM_PROVIDERS,
|
||||||
|
supportsLlmModelSuggestions,
|
||||||
} from "@client/pages/settings/utils";
|
} from "@client/pages/settings/utils";
|
||||||
|
import { getDefaultModelForProvider } from "@shared/settings-registry";
|
||||||
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
import type { UpdateSettingsInput } from "@shared/settings-schema.js";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect } from "react";
|
import { useDeferredValue, useEffect, useRef, useState } from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
|
import { SearchableDropdown } from "@/components/ui/searchable-dropdown";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -35,12 +39,12 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||||
|
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||||
|
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||||
const {
|
const {
|
||||||
effective,
|
effective,
|
||||||
default: defaultModel,
|
default: defaultModel,
|
||||||
scorer,
|
|
||||||
tailoring,
|
|
||||||
projectSelection,
|
|
||||||
llmProvider,
|
llmProvider,
|
||||||
llmBaseUrl,
|
llmBaseUrl,
|
||||||
llmApiKeyHint,
|
llmApiKeyHint,
|
||||||
@ -54,10 +58,28 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
} = useFormContext<UpdateSettingsInput>();
|
} = useFormContext<UpdateSettingsInput>();
|
||||||
|
|
||||||
const selectedProvider = watch("llmProvider") || llmProvider || "openrouter";
|
const selectedProvider = watch("llmProvider") || llmProvider || "openrouter";
|
||||||
|
const previousProviderRef = useRef(selectedProvider);
|
||||||
const providerConfig = getLlmProviderConfig(selectedProvider);
|
const providerConfig = getLlmProviderConfig(selectedProvider);
|
||||||
const { showApiKey, showBaseUrl } = providerConfig;
|
const { showApiKey, showBaseUrl } = providerConfig;
|
||||||
|
|
||||||
const llmBaseUrlValue = watch("llmBaseUrl");
|
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(() => {
|
useEffect(() => {
|
||||||
if (showBaseUrl) return;
|
if (showBaseUrl) return;
|
||||||
@ -66,12 +88,122 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
}
|
}
|
||||||
}, [setValue, showBaseUrl, llmBaseUrlValue]);
|
}, [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 keyHint = formatSecretHint(llmApiKeyHint);
|
||||||
const keyText = showApiKey ? keyHint || "Not set" : "Not required";
|
const keyText = showApiKey ? keyHint || "Not set" : "Not required";
|
||||||
const effectiveDefaultModel = effective || defaultModel || "—";
|
const resolvedBaseUrl = llmBaseUrlValue?.trim() || llmBaseUrl || "-";
|
||||||
const scoringModel = scorer || effectiveDefaultModel;
|
const selectedDefaultModel = modelValue.trim();
|
||||||
const tailoringModel = tailoring || effectiveDefaultModel;
|
const previewDefaultModel =
|
||||||
const projectSelectionModel = projectSelection || effectiveDefaultModel;
|
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 (
|
return (
|
||||||
<AccordionItem value="model" className="border rounded-lg px-4">
|
<AccordionItem value="model" className="border rounded-lg px-4">
|
||||||
<AccordionTrigger className="hover:no-underline py-4">
|
<AccordionTrigger className="hover:no-underline py-4">
|
||||||
@ -128,7 +260,7 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
error={errors.llmBaseUrl?.message as string | undefined}
|
error={errors.llmBaseUrl?.message as string | undefined}
|
||||||
helper={providerConfig.baseUrlHelper}
|
helper={providerConfig.baseUrlHelper}
|
||||||
current={llmBaseUrl || "—"}
|
current={resolvedBaseUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showApiKey && (
|
{showApiKey && (
|
||||||
@ -147,15 +279,53 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<SettingsInput
|
{supportsModelSuggestions ? (
|
||||||
label="Default model"
|
<div className="space-y-2">
|
||||||
inputProps={register("model")}
|
<label htmlFor="model" className="text-sm font-medium">
|
||||||
placeholder={defaultModel || "google/gemini-3-flash-preview"}
|
Default model
|
||||||
disabled={isLoading || isSaving}
|
</label>
|
||||||
error={errors.model?.message as string | undefined}
|
<Controller
|
||||||
helper="Leave blank to use the default from server env (`MODEL`)."
|
name="model"
|
||||||
current={effectiveDefaultModel}
|
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 />
|
<Separator />
|
||||||
|
|
||||||
@ -163,34 +333,161 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
<div className="text-sm font-medium">Task-Specific Overrides</div>
|
<div className="text-sm font-medium">Task-Specific Overrides</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<SettingsInput
|
{supportsModelSuggestions ? (
|
||||||
label="Scoring Model"
|
<>
|
||||||
inputProps={register("modelScorer")}
|
<div className="space-y-2">
|
||||||
placeholder={effective || "inherit"}
|
<label
|
||||||
disabled={isLoading || isSaving}
|
htmlFor="modelScorer"
|
||||||
error={errors.modelScorer?.message as string | undefined}
|
className="text-sm font-medium"
|
||||||
current={scoringModel}
|
>
|
||||||
/>
|
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
|
<div className="space-y-2">
|
||||||
label="Tailoring Model"
|
<label
|
||||||
inputProps={register("modelTailoring")}
|
htmlFor="modelTailoring"
|
||||||
placeholder={effective || "inherit"}
|
className="text-sm font-medium"
|
||||||
disabled={isLoading || isSaving}
|
>
|
||||||
error={errors.modelTailoring?.message as string | undefined}
|
Tailoring Model
|
||||||
current={tailoringModel}
|
</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
|
<div className="space-y-2">
|
||||||
label="Project Selection Model"
|
<label
|
||||||
inputProps={register("modelProjectSelection")}
|
htmlFor="modelProjectSelection"
|
||||||
placeholder={effective || "inherit"}
|
className="text-sm font-medium"
|
||||||
disabled={isLoading || isSaving}
|
>
|
||||||
error={
|
Project Selection Model
|
||||||
errors.modelProjectSelection?.message as string | undefined
|
</label>
|
||||||
}
|
<Controller
|
||||||
current={projectSelectionModel}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -200,36 +497,32 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
<div className="text-xs text-muted-foreground">Resolved config</div>
|
<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="grid gap-x-4 gap-y-2 text-xs sm:grid-cols-[160px_1fr]">
|
||||||
<div className="text-muted-foreground">Provider</div>
|
<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="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="text-muted-foreground">API key</div>
|
||||||
<div className="font-mono">{keyText}</div>
|
<div className="font-mono">{keyText}</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground">Default model</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="text-muted-foreground">Scoring model</div>
|
||||||
<div className="font-mono">
|
<div className="font-mono">
|
||||||
{scoringModel === effectiveDefaultModel
|
{selectedScoringModel ? scoringModel : "inherits"}
|
||||||
? "inherits"
|
|
||||||
: scoringModel}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground">Tailoring model</div>
|
<div className="text-muted-foreground">Tailoring model</div>
|
||||||
<div className="font-mono">
|
<div className="font-mono">
|
||||||
{tailoringModel === effectiveDefaultModel
|
{selectedTailoringModel ? tailoringModel : "inherits"}
|
||||||
? "inherits"
|
|
||||||
: tailoringModel}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground">Project selection</div>
|
<div className="text-muted-foreground">Project selection</div>
|
||||||
<div className="font-mono">
|
<div className="font-mono">
|
||||||
{projectSelectionModel === effectiveDefaultModel
|
{selectedProjectSelectionModel
|
||||||
? "inherits"
|
? projectSelectionModel
|
||||||
: projectSelectionModel}
|
: "inherits"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -238,3 +531,37 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
</AccordionItem>
|
</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 { describe, expect, it } from "vitest";
|
||||||
import { getLlmProviderConfig, normalizeLlmProvider } from "./utils";
|
import {
|
||||||
|
getLlmProviderConfig,
|
||||||
|
normalizeLlmProvider,
|
||||||
|
supportsLlmModelSuggestions,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
describe("settings utils", () => {
|
describe("settings utils", () => {
|
||||||
it("treats openai-compatible as a dedicated configurable provider", () => {
|
it("treats openai-compatible as a dedicated configurable provider", () => {
|
||||||
@ -20,4 +24,11 @@ describe("settings utils", () => {
|
|||||||
it("defaults unknown providers to openrouter", () => {
|
it("defaults unknown providers to openrouter", () => {
|
||||||
expect(normalizeLlmProvider("unknown-provider")).toBe("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;
|
] as const;
|
||||||
|
|
||||||
export type LlmProviderId = (typeof LLM_PROVIDERS)[number];
|
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> = {
|
export const LLM_PROVIDER_LABELS: Record<LlmProviderId, string> = {
|
||||||
openrouter: "OpenRouter",
|
openrouter: "OpenRouter",
|
||||||
@ -92,6 +97,15 @@ export function normalizeLlmProvider(
|
|||||||
: "openrouter";
|
: "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) {
|
export function getLlmProviderConfig(provider: string | null | undefined) {
|
||||||
const normalizedProvider = normalizeLlmProvider(provider);
|
const normalizedProvider = normalizeLlmProvider(provider);
|
||||||
const showApiKey = PROVIDERS_WITH_API_KEY.has(normalizedProvider);
|
const showApiKey = PROVIDERS_WITH_API_KEY.has(normalizedProvider);
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export interface SearchableDropdownOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SearchableDropdownProps {
|
interface SearchableDropdownProps {
|
||||||
|
inputId?: string;
|
||||||
value: string;
|
value: string;
|
||||||
options: SearchableDropdownOption[];
|
options: SearchableDropdownOption[];
|
||||||
onValueChange: (value: string) => void;
|
onValueChange: (value: string) => void;
|
||||||
@ -38,6 +39,7 @@ interface SearchableDropdownProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
|
export const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
|
||||||
|
inputId,
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
@ -51,18 +53,46 @@ export const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
|
|||||||
listClassName,
|
listClassName,
|
||||||
}) => {
|
}) => {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [query, setQuery] = React.useState("");
|
||||||
const selectedOption = options.find((option) => option.value === value);
|
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 (
|
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>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-label={ariaLabel ?? triggerLabel}
|
aria-label={inputId ? undefined : (ariaLabel ?? triggerLabel)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn("justify-between", triggerClassName)}
|
className={cn("justify-between", triggerClassName)}
|
||||||
>
|
>
|
||||||
@ -75,13 +105,29 @@ export const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
|
|||||||
className={cn("w-[320px] p-0", contentClassName)}
|
className={cn("w-[320px] p-0", contentClassName)}
|
||||||
>
|
>
|
||||||
<Command loop>
|
<Command loop>
|
||||||
<CommandInput placeholder={searchPlaceholder} />
|
<CommandInput
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
/>
|
||||||
<CommandList
|
<CommandList
|
||||||
className={cn("max-h-56", listClassName)}
|
className={cn("max-h-56", listClassName)}
|
||||||
onWheelCapture={(event) => event.stopPropagation()}
|
onWheelCapture={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<CommandEmpty>{emptyText}</CommandEmpty>
|
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
{hasCustomValue ? (
|
||||||
|
<CommandItem
|
||||||
|
value={`Use ${trimmedQuery}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onValueChange(trimmedQuery);
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="truncate">{`Use "${trimmedQuery}"`}</span>
|
||||||
|
</CommandItem>
|
||||||
|
) : null}
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const selected = value === option.value;
|
const selected = value === option.value;
|
||||||
const searchableValue = [
|
const searchableValue = [
|
||||||
@ -100,6 +146,7 @@ export const SearchableDropdown: React.FC<SearchableDropdownProps> = ({
|
|||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onValueChange(option.value);
|
onValueChange(option.value);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="truncate">{option.label}</span>
|
<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 () => {
|
it("rejects invalid settings updates and persists overrides", async () => {
|
||||||
const badPatch = await fetch(`${baseUrl}/api/settings`, {
|
const badPatch = await fetch(`${baseUrl}/api/settings`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
|||||||
@ -10,7 +10,9 @@ import { asyncRoute, fail, ok } from "@infra/http";
|
|||||||
import { logger } from "@infra/logger";
|
import { logger } from "@infra/logger";
|
||||||
import { getRequestId } from "@infra/request-context";
|
import { getRequestId } from "@infra/request-context";
|
||||||
import { isDemoMode, sendDemoBlocked } from "@server/config/demo";
|
import { isDemoMode, sendDemoBlocked } from "@server/config/demo";
|
||||||
|
import { getSetting } from "@server/repositories/settings";
|
||||||
import { setBackupSettings } from "@server/services/backup/index";
|
import { setBackupSettings } from "@server/services/backup/index";
|
||||||
|
import { LlmService } from "@server/services/llm/service";
|
||||||
import { clearProfileCache } from "@server/services/profile";
|
import { clearProfileCache } from "@server/services/profile";
|
||||||
import {
|
import {
|
||||||
clearRxResumeResumeCache,
|
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)
|
* 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)
|
* 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 originalEnv = { ...process.env };
|
||||||
|
const originalFetch = global.fetch;
|
||||||
const isolatedEnvKeys = [
|
const isolatedEnvKeys = [
|
||||||
"RXRESUME_API_KEY",
|
"RXRESUME_API_KEY",
|
||||||
"RXRESUME_EMAIL",
|
"RXRESUME_EMAIL",
|
||||||
@ -108,6 +109,8 @@ export async function startServer(options?: {
|
|||||||
closeDb: () => void;
|
closeDb: () => void;
|
||||||
tempDir: string;
|
tempDir: string;
|
||||||
}> {
|
}> {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
global.fetch = originalFetch;
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const tempDir = await mkdtemp(join(tmpdir(), "job-ops-api-test-"));
|
const tempDir = await mkdtemp(join(tmpdir(), "job-ops-api-test-"));
|
||||||
const envOverrides = options?.env ?? {};
|
const envOverrides = options?.env ?? {};
|
||||||
@ -168,5 +171,7 @@ export async function stopServer(args: {
|
|||||||
await rm(args.tempDir, { recursive: true, force: true });
|
await rm(args.tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
global.fetch = originalFetch;
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,10 +9,10 @@ import { logger } from "@infra/logger";
|
|||||||
import { getRequestId } from "@infra/request-context";
|
import { getRequestId } from "@infra/request-context";
|
||||||
import type { BranchInfo, JobChatMessage, JobChatRun } from "@shared/types";
|
import type { BranchInfo, JobChatMessage, JobChatRun } from "@shared/types";
|
||||||
import * as jobChatRepo from "../repositories/ghostwriter";
|
import * as jobChatRepo from "../repositories/ghostwriter";
|
||||||
import * as settingsRepo from "../repositories/settings";
|
|
||||||
import { buildJobChatPromptContext } from "./ghostwriter-context";
|
import { buildJobChatPromptContext } from "./ghostwriter-context";
|
||||||
import { LlmService } from "./llm/service";
|
import { LlmService } from "./llm/service";
|
||||||
import type { JsonSchemaDefinition } from "./llm/types";
|
import type { JsonSchemaDefinition } from "./llm/types";
|
||||||
|
import { resolveLlmRuntimeSettings as resolveRuntimeLlmSettings } from "./modelSelection";
|
||||||
|
|
||||||
type LlmRuntimeSettings = {
|
type LlmRuntimeSettings = {
|
||||||
model: string;
|
model: string;
|
||||||
@ -62,27 +62,7 @@ function isRunningRunUniqueConstraintError(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveLlmRuntimeSettings(): Promise<LlmRuntimeSettings> {
|
async function resolveLlmRuntimeSettings(): Promise<LlmRuntimeSettings> {
|
||||||
const overrides = await settingsRepo.getAllSettings();
|
return resolveRuntimeLlmSettings("tailoring");
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildConversationMessages(
|
async function buildConversationMessages(
|
||||||
|
|||||||
@ -17,7 +17,12 @@ import type {
|
|||||||
LlmValidationResult,
|
LlmValidationResult,
|
||||||
ResponseMode,
|
ResponseMode,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { buildHeaders, getResponseDetail } from "./utils/http";
|
import {
|
||||||
|
addQueryParam,
|
||||||
|
buildHeaders,
|
||||||
|
getResponseDetail,
|
||||||
|
joinUrl,
|
||||||
|
} from "./utils/http";
|
||||||
import { parseJsonContent } from "./utils/json";
|
import { parseJsonContent } from "./utils/json";
|
||||||
import { parseErrorMessage, truncate } from "./utils/string";
|
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: {
|
private async tryMode<T>(args: {
|
||||||
mode: ResponseMode;
|
mode: ResponseMode;
|
||||||
model: string;
|
model: string;
|
||||||
@ -190,7 +221,7 @@ export class LlmService {
|
|||||||
}): Promise<LlmResponse<T>> {
|
}): Promise<LlmResponse<T>> {
|
||||||
const {
|
const {
|
||||||
mode,
|
mode,
|
||||||
model,
|
model: rawModel,
|
||||||
messages,
|
messages,
|
||||||
jsonSchema,
|
jsonSchema,
|
||||||
maxRetries,
|
maxRetries,
|
||||||
@ -198,6 +229,7 @@ export class LlmService {
|
|||||||
signal,
|
signal,
|
||||||
} = args;
|
} = args;
|
||||||
const jobId = args.jobId;
|
const jobId = args.jobId;
|
||||||
|
const model = normalizeModelForProvider(this.provider, rawModel);
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
@ -279,6 +311,88 @@ export class LlmService {
|
|||||||
|
|
||||||
return { success: false, error: "All retry attempts failed" };
|
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(
|
function normalizeProvider(
|
||||||
@ -310,3 +424,77 @@ function normalizeProvider(
|
|||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
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 { logger } from "@infra/logger";
|
||||||
import type { ManualJobDraft } from "@shared/types";
|
import type { ManualJobDraft } from "@shared/types";
|
||||||
import { getSetting } from "../repositories/settings";
|
|
||||||
import { LlmService } from "./llm/service";
|
import { LlmService } from "./llm/service";
|
||||||
import type { JsonSchemaDefinition } from "./llm/types";
|
import type { JsonSchemaDefinition } from "./llm/types";
|
||||||
|
import { resolveLlmModel } from "./modelSelection";
|
||||||
|
|
||||||
export interface ManualJobInferenceResult {
|
export interface ManualJobInferenceResult {
|
||||||
job: ManualJobDraft;
|
job: ManualJobDraft;
|
||||||
@ -94,9 +94,7 @@ const MANUAL_JOB_SCHEMA: JsonSchemaDefinition = {
|
|||||||
export async function inferManualJobDetails(
|
export async function inferManualJobDetails(
|
||||||
jobDescription: string,
|
jobDescription: string,
|
||||||
): Promise<ManualJobInferenceResult> {
|
): Promise<ManualJobInferenceResult> {
|
||||||
const overrideModel = await getSetting("model");
|
const model = await resolveLlmModel();
|
||||||
const model =
|
|
||||||
overrideModel || process.env.MODEL || "google/gemini-3-flash-preview";
|
|
||||||
const prompt = buildInferencePrompt(jobDescription);
|
const prompt = buildInferencePrompt(jobDescription);
|
||||||
|
|
||||||
const llm = new LlmService();
|
const llm = new LlmService();
|
||||||
|
|||||||
@ -3,14 +3,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import * as settingsRepo from "../repositories/settings";
|
import * as settingsRepo from "../repositories/settings";
|
||||||
import { pickProjectIdsForJob } from "./projectSelection";
|
import { pickProjectIdsForJob } from "./projectSelection";
|
||||||
import { scoreJobSuitability } from "./scorer";
|
import { scoreJobSuitability } from "./scorer";
|
||||||
|
import { getEffectiveSettings } from "./settings";
|
||||||
import { generateTailoring } from "./summary";
|
import { generateTailoring } from "./summary";
|
||||||
|
|
||||||
// Mock the settings repository
|
// Mock the settings repository
|
||||||
vi.mock("../repositories/settings", () => ({
|
vi.mock("../repositories/settings", () => ({
|
||||||
getSetting: vi.fn(),
|
|
||||||
getAllSettings: vi.fn(),
|
getAllSettings: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./settings", () => ({
|
||||||
|
getEffectiveSettings: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("Model Selection Logic", () => {
|
describe("Model Selection Logic", () => {
|
||||||
const originalEnv = process.env;
|
const originalEnv = process.env;
|
||||||
|
|
||||||
@ -23,8 +27,26 @@ describe("Model Selection Logic", () => {
|
|||||||
MODEL: "env-model",
|
MODEL: "env-model",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock getAllSettings to return empty settings (no overrides)
|
|
||||||
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({});
|
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
|
// Mock global fetch to capture the request and return a dummy success response
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
@ -55,11 +77,32 @@ describe("Model Selection Logic", () => {
|
|||||||
|
|
||||||
describe("Scoring Service", () => {
|
describe("Scoring Service", () => {
|
||||||
it("should use scoring specific model when set", async () => {
|
it("should use scoring specific model when set", async () => {
|
||||||
vi.mocked(settingsRepo.getSetting).mockImplementation(async (key) => {
|
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
||||||
if (key === "modelScorer") return "specific-scorer-model";
|
model: {
|
||||||
if (key === "model") return "global-model";
|
value: "global-model",
|
||||||
return null;
|
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(
|
await scoreJobSuitability(
|
||||||
{ title: "Test Job", jobDescription: "desc" } as any,
|
{ 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 () => {
|
it("should fall back to global model for scoring when specific not set", async () => {
|
||||||
vi.mocked(settingsRepo.getSetting).mockImplementation(async (key) => {
|
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
||||||
if (key === "modelScorer") return null;
|
model: {
|
||||||
if (key === "model") return "global-model";
|
value: "global-model",
|
||||||
return null;
|
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, {});
|
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 () => {
|
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, {});
|
await scoreJobSuitability({ title: "Test Job" } as any, {});
|
||||||
|
|
||||||
const fetchCall = vi.mocked(fetch).mock.calls[0];
|
const fetchCall = vi.mocked(fetch).mock.calls[0];
|
||||||
@ -98,11 +157,29 @@ describe("Model Selection Logic", () => {
|
|||||||
|
|
||||||
describe("Tailoring Service", () => {
|
describe("Tailoring Service", () => {
|
||||||
it("should use tailoring specific model when set", async () => {
|
it("should use tailoring specific model when set", async () => {
|
||||||
vi.mocked(settingsRepo.getSetting).mockImplementation(async (key) => {
|
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
||||||
if (key === "modelTailoring") return "specific-tailoring-model";
|
model: {
|
||||||
if (key === "model") return "global-model";
|
value: "global-model",
|
||||||
return null;
|
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", {});
|
await generateTailoring("job desc", {});
|
||||||
|
|
||||||
@ -112,11 +189,26 @@ describe("Model Selection Logic", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should fall back to global model when specific not set", async () => {
|
it("should fall back to global model when specific not set", async () => {
|
||||||
vi.mocked(settingsRepo.getSetting).mockImplementation(async (key) => {
|
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
||||||
if (key === "modelTailoring") return null;
|
model: {
|
||||||
if (key === "model") return "global-model";
|
value: "global-model",
|
||||||
return null;
|
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", {});
|
await generateTailoring("job desc", {});
|
||||||
|
|
||||||
@ -128,11 +220,29 @@ describe("Model Selection Logic", () => {
|
|||||||
|
|
||||||
describe("Project Selection Service", () => {
|
describe("Project Selection Service", () => {
|
||||||
it("should use project selection specific model when set", async () => {
|
it("should use project selection specific model when set", async () => {
|
||||||
vi.mocked(settingsRepo.getSetting).mockImplementation(async (key) => {
|
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
||||||
if (key === "modelProjectSelection") return "specific-project-model";
|
model: {
|
||||||
if (key === "model") return "global-model";
|
value: "global-model",
|
||||||
return null;
|
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({
|
await pickProjectIdsForJob({
|
||||||
jobDescription: "desc",
|
jobDescription: "desc",
|
||||||
@ -153,11 +263,26 @@ describe("Model Selection Logic", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should fall back to global model when specific not set", async () => {
|
it("should fall back to global model when specific not set", async () => {
|
||||||
vi.mocked(settingsRepo.getSetting).mockImplementation(async (key) => {
|
vi.mocked(getEffectiveSettings).mockResolvedValue({
|
||||||
if (key === "modelProjectSelection") return null;
|
model: {
|
||||||
if (key === "model") return "global-model";
|
value: "global-model",
|
||||||
return null;
|
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({
|
await pickProjectIdsForJob({
|
||||||
jobDescription: "desc",
|
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 { LlmService } from "@server/services/llm/service";
|
||||||
import type { JsonSchemaDefinition } from "@server/services/llm/types";
|
import type { JsonSchemaDefinition } from "@server/services/llm/types";
|
||||||
|
import { resolveLlmModel } from "@server/services/modelSelection";
|
||||||
import {
|
import {
|
||||||
messageTypeFromStageTarget,
|
messageTypeFromStageTarget,
|
||||||
normalizeStageTarget,
|
normalizeStageTarget,
|
||||||
@ -133,9 +133,7 @@ export async function classifyWithSmartRouter(args: {
|
|||||||
emailText: string;
|
emailText: string;
|
||||||
activeJobs: Array<{ id: string; company: string; title: string }>;
|
activeJobs: Array<{ id: string; company: string; title: string }>;
|
||||||
}): Promise<SmartRouterResult> {
|
}): Promise<SmartRouterResult> {
|
||||||
const overrideModel = await getSetting("model");
|
const model = await resolveLlmModel();
|
||||||
const model =
|
|
||||||
overrideModel || process.env.MODEL || "google/gemini-3-flash-preview";
|
|
||||||
const llmEmailText = args.emailText.slice(0, ROUTER_EMAIL_CHAR_LIMIT);
|
const llmEmailText = args.emailText.slice(0, ROUTER_EMAIL_CHAR_LIMIT);
|
||||||
const indexedActiveJobs = buildIndexedActiveJobs(args.activeJobs);
|
const indexedActiveJobs = buildIndexedActiveJobs(args.activeJobs);
|
||||||
const compactActiveJobsList = buildCompactActiveJobsList(indexedActiveJobs);
|
const compactActiveJobsList = buildCompactActiveJobsList(indexedActiveJobs);
|
||||||
|
|||||||
@ -200,6 +200,5 @@ describe("gmail sync auto-log idempotency", () => {
|
|||||||
|
|
||||||
expect(upsertPostApplicationMessage).toHaveBeenCalledTimes(2);
|
expect(upsertPostApplicationMessage).toHaveBeenCalledTimes(2);
|
||||||
expect(transitionStage).toHaveBeenCalledTimes(1);
|
expect(transitionStage).toHaveBeenCalledTimes(1);
|
||||||
expect(llmCallJson).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
* Service for AI-powered project selection for resumes.
|
* Service for AI-powered project selection for resumes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getSetting } from "../repositories/settings";
|
|
||||||
import { LlmService } from "./llm/service";
|
import { LlmService } from "./llm/service";
|
||||||
import type { JsonSchemaDefinition } from "./llm/types";
|
import type { JsonSchemaDefinition } from "./llm/types";
|
||||||
|
import { resolveLlmModel } from "./modelSelection";
|
||||||
import type { ResumeProjectSelectionItem } from "./resumeProjects";
|
import type { ResumeProjectSelectionItem } from "./resumeProjects";
|
||||||
|
|
||||||
/** JSON schema for project selection response */
|
/** 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));
|
const eligibleIds = new Set(args.eligibleProjects.map((p) => p.id));
|
||||||
if (eligibleIds.size === 0) return [];
|
if (eligibleIds.size === 0) return [];
|
||||||
|
|
||||||
const [overrideModel, overrideModelProjectSelection] = await Promise.all([
|
const model = await resolveLlmModel("projectSelection");
|
||||||
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 prompt = buildProjectSelectionPrompt({
|
const prompt = buildProjectSelectionPrompt({
|
||||||
jobDescription: args.jobDescription,
|
jobDescription: args.jobDescription,
|
||||||
|
|||||||
@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
import { logger } from "@infra/logger";
|
import { logger } from "@infra/logger";
|
||||||
import type { Job } from "@shared/types";
|
import type { Job } from "@shared/types";
|
||||||
import { getSetting } from "../repositories/settings";
|
|
||||||
import { LlmService } from "./llm/service";
|
import { LlmService } from "./llm/service";
|
||||||
import type { JsonSchemaDefinition } from "./llm/types";
|
import type { JsonSchemaDefinition } from "./llm/types";
|
||||||
import { stripMarkdownCodeFences } from "./llm/utils/json";
|
import { stripMarkdownCodeFences } from "./llm/utils/json";
|
||||||
|
import { resolveLlmModel } from "./modelSelection";
|
||||||
import { getEffectiveSettings } from "./settings";
|
import { getEffectiveSettings } from "./settings";
|
||||||
|
|
||||||
interface SuitabilityResult {
|
interface SuitabilityResult {
|
||||||
@ -88,17 +88,10 @@ export async function scoreJobSuitability(
|
|||||||
job: Job,
|
job: Job,
|
||||||
profile: Record<string, unknown>,
|
profile: Record<string, unknown>,
|
||||||
): Promise<SuitabilityResult> {
|
): Promise<SuitabilityResult> {
|
||||||
const [overrideModel, overrideModelScorer, settings] = await Promise.all([
|
const [model, settings] = await Promise.all([
|
||||||
getSetting("model"),
|
resolveLlmModel("scoring"),
|
||||||
getSetting("modelScorer"),
|
|
||||||
getEffectiveSettings(),
|
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), {
|
const prompt = buildScoringPrompt(job, sanitizeProfileForPrompt(profile), {
|
||||||
instructions: settings.scoringInstructions?.value ?? "",
|
instructions: settings.scoringInstructions?.value ?? "",
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { logger } from "@infra/logger";
|
import { logger } from "@infra/logger";
|
||||||
import * as settingsRepo from "@server/repositories/settings";
|
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 type { AppSettings } from "@shared/types";
|
||||||
import { getEnvSettingsData } from "./envSettings";
|
import { getEnvSettingsData } from "./envSettings";
|
||||||
import { getProfile } from "./profile";
|
import { getProfile } from "./profile";
|
||||||
@ -28,11 +31,58 @@ function resolveDefaultLlmBaseUrl(provider: string): string {
|
|||||||
return "https://openrouter.ai";
|
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.
|
* Get the effective app settings, combining environment variables and database overrides.
|
||||||
*/
|
*/
|
||||||
export async function getEffectiveSettings(): Promise<AppSettings> {
|
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({
|
const rxresumeBaseResumeId = resolveRxResumeBaseResumeIdForMode({
|
||||||
rxresumeMode: overrides.rxresumeMode ?? process.env.RXRESUME_MODE ?? null,
|
rxresumeMode: overrides.rxresumeMode ?? process.env.RXRESUME_MODE ?? null,
|
||||||
@ -81,8 +131,11 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
|||||||
|
|
||||||
const rawModel = overrides.model;
|
const rawModel = overrides.model;
|
||||||
const modelDef = settingsRegistry.model;
|
const modelDef = settingsRegistry.model;
|
||||||
const overrideModel = modelDef.parse(rawModel);
|
const overrideModel = normalizeModelForProviderCompatibility(
|
||||||
const modelValue = overrideModel ?? modelDef.default();
|
effectiveLlmProvider,
|
||||||
|
modelDef.parse(rawModel),
|
||||||
|
);
|
||||||
|
const modelValue = overrideModel ?? resolvedModelDefault;
|
||||||
|
|
||||||
for (const [key, def] of Object.entries(settingsRegistry)) {
|
for (const [key, def] of Object.entries(settingsRegistry)) {
|
||||||
if (def.kind === "typed") {
|
if (def.kind === "typed") {
|
||||||
@ -91,15 +144,17 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
|||||||
rawOverride = overrides.jobspyLocation; // legacy fallback
|
rawOverride = overrides.jobspyLocation; // legacy fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
const override = def.parse(rawOverride);
|
let override = def.parse(rawOverride);
|
||||||
let defaultValue = def.default();
|
let defaultValue = def.default();
|
||||||
|
|
||||||
|
if (key === "model") {
|
||||||
|
defaultValue = resolvedModelDefault;
|
||||||
|
override = overrideModel;
|
||||||
|
}
|
||||||
|
|
||||||
if (key === "llmBaseUrl") {
|
if (key === "llmBaseUrl") {
|
||||||
const providerOverride = settingsRegistry.llmProvider.parse(
|
|
||||||
overrides.llmProvider,
|
|
||||||
);
|
|
||||||
const provider =
|
const provider =
|
||||||
providerOverride ?? settingsRegistry.llmProvider.default();
|
effectiveLlmProvider ?? settingsRegistry.llmProvider.default();
|
||||||
defaultValue =
|
defaultValue =
|
||||||
process.env.LLM_BASE_URL || resolveDefaultLlmBaseUrl(provider);
|
process.env.LLM_BASE_URL || resolveDefaultLlmBaseUrl(provider);
|
||||||
}
|
}
|
||||||
@ -141,7 +196,11 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
|||||||
override,
|
override,
|
||||||
};
|
};
|
||||||
} else if (def.kind === "model") {
|
} 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic assignment for settings building
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic assignment for settings building
|
||||||
(result as any)[key] = { value: override || modelValue, override };
|
(result as any)[key] = { value: override || modelValue, override };
|
||||||
|
|||||||
@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
import { logger } from "@infra/logger";
|
import { logger } from "@infra/logger";
|
||||||
import type { ResumeProfile } from "@shared/types";
|
import type { ResumeProfile } from "@shared/types";
|
||||||
import { getSetting } from "../repositories/settings";
|
|
||||||
import { LlmService } from "./llm/service";
|
import { LlmService } from "./llm/service";
|
||||||
import type { JsonSchemaDefinition } from "./llm/types";
|
import type { JsonSchemaDefinition } from "./llm/types";
|
||||||
|
import { resolveLlmModel } from "./modelSelection";
|
||||||
import {
|
import {
|
||||||
getWritingLanguageLabel,
|
getWritingLanguageLabel,
|
||||||
resolveWritingOutputLanguage,
|
resolveWritingOutputLanguage,
|
||||||
@ -75,18 +75,10 @@ export async function generateTailoring(
|
|||||||
jobDescription: string,
|
jobDescription: string,
|
||||||
profile: ResumeProfile,
|
profile: ResumeProfile,
|
||||||
): Promise<TailoringResult> {
|
): Promise<TailoringResult> {
|
||||||
const [overrideModel, overrideModelTailoring, writingStyle] =
|
const [model, writingStyle] = await Promise.all([
|
||||||
await Promise.all([
|
resolveLlmModel("tailoring"),
|
||||||
getSetting("model"),
|
getWritingStyle(),
|
||||||
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 prompt = buildTailoringPrompt(profile, jobDescription, writingStyle);
|
const prompt = buildTailoringPrompt(profile, jobDescription, writingStyle);
|
||||||
|
|
||||||
const llm = new LlmService();
|
const llm = new LlmService();
|
||||||
|
|||||||
@ -58,6 +58,12 @@ export function stripLanguageDirectivesFromConstraints(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getWritingStyle(): Promise<WritingStyle> {
|
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 [
|
const [
|
||||||
toneRaw,
|
toneRaw,
|
||||||
formalityRaw,
|
formalityRaw,
|
||||||
@ -66,12 +72,12 @@ export async function getWritingStyle(): Promise<WritingStyle> {
|
|||||||
languageModeRaw,
|
languageModeRaw,
|
||||||
manualLanguageRaw,
|
manualLanguageRaw,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
settingsRepo.getSetting("chatStyleTone"),
|
getSetting("chatStyleTone"),
|
||||||
settingsRepo.getSetting("chatStyleFormality"),
|
getSetting("chatStyleFormality"),
|
||||||
settingsRepo.getSetting("chatStyleConstraints"),
|
getSetting("chatStyleConstraints"),
|
||||||
settingsRepo.getSetting("chatStyleDoNotUse"),
|
getSetting("chatStyleDoNotUse"),
|
||||||
settingsRepo.getSetting("chatStyleLanguageMode"),
|
getSetting("chatStyleLanguageMode"),
|
||||||
settingsRepo.getSetting("chatStyleManualLanguage"),
|
getSetting("chatStyleManualLanguage"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { settingsRegistry } from "./settings-registry";
|
import {
|
||||||
|
getDefaultModelForProvider,
|
||||||
|
settingsRegistry,
|
||||||
|
} from "./settings-registry";
|
||||||
|
|
||||||
describe("settingsRegistry helpers", () => {
|
describe("settingsRegistry helpers", () => {
|
||||||
describe("string parsing (parseNonEmptyStringOrNull)", () => {
|
describe("string parsing (parseNonEmptyStringOrNull)", () => {
|
||||||
@ -192,5 +195,15 @@ describe("settingsRegistry helpers", () => {
|
|||||||
"openai_compatible",
|
"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;
|
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(
|
function serializeNullableNumber(
|
||||||
value: number | null | undefined,
|
value: number | null | undefined,
|
||||||
): string | null {
|
): string | null {
|
||||||
@ -87,8 +111,11 @@ export const settingsRegistry = {
|
|||||||
schema: z.string().trim().max(200),
|
schema: z.string().trim().max(200),
|
||||||
default: (): string =>
|
default: (): string =>
|
||||||
typeof process !== "undefined"
|
typeof process !== "undefined"
|
||||||
? process.env.MODEL || "google/gemini-3-flash-preview"
|
? getDefaultModelForProvider(
|
||||||
: "google/gemini-3-flash-preview",
|
process.env.LLM_PROVIDER,
|
||||||
|
process.env.MODEL,
|
||||||
|
)
|
||||||
|
: DEFAULT_GEMINI_MODEL,
|
||||||
parse: parseNonEmptyStringOrNull,
|
parse: parseNonEmptyStringOrNull,
|
||||||
serialize: (value: string | null | undefined): string | null =>
|
serialize: (value: string | null | undefined): string | null =>
|
||||||
value ?? null,
|
value ?? null,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user