Gemini api key issue (#204)
* uggo ternary fix * fix ai studio url * service returns a 403 if unauthed * pass validation correctly * fix response format * Update orchestrator/src/client/pages/settings/utils.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix nested ternaries client * server fix * Address PR #204 review feedback and stabilize CI --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
3640abef2d
commit
eed5c2adba
@ -1,5 +1,6 @@
|
|||||||
import { mkdir, writeFile } from "node:fs/promises";
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
import { parseSearchTerms } from "job-ops-shared/utils/search-terms";
|
||||||
import {
|
import {
|
||||||
toNumberOrNull,
|
toNumberOrNull,
|
||||||
toStringOrNull,
|
toStringOrNull,
|
||||||
@ -7,6 +8,7 @@ import {
|
|||||||
|
|
||||||
const API_BASE = "https://api.adzuna.com/v1/api";
|
const API_BASE = "https://api.adzuna.com/v1/api";
|
||||||
const JOBOPS_PROGRESS_PREFIX = "JOBOPS_PROGRESS ";
|
const JOBOPS_PROGRESS_PREFIX = "JOBOPS_PROGRESS ";
|
||||||
|
const DEFAULT_SEARCH_TERM = "web developer";
|
||||||
|
|
||||||
type AdzunaCompany = { display_name?: unknown };
|
type AdzunaCompany = { display_name?: unknown };
|
||||||
type AdzunaLocation = { display_name?: unknown };
|
type AdzunaLocation = { display_name?: unknown };
|
||||||
@ -44,36 +46,6 @@ function parsePositiveInt(input: string | undefined, fallback: number): number {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSearchTerms(raw: string | undefined): string[] {
|
|
||||||
if (!raw || raw.trim().length === 0) return ["web developer"];
|
|
||||||
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (trimmed.startsWith("[")) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed) as unknown;
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
const terms = parsed
|
|
||||||
.map((value) => toStringOrNull(value))
|
|
||||||
.filter((value): value is string => value !== null);
|
|
||||||
if (terms.length > 0) return terms;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to delimiter parsing.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const delimiter = trimmed.includes("|")
|
|
||||||
? "|"
|
|
||||||
: trimmed.includes("\n")
|
|
||||||
? "\n"
|
|
||||||
: ",";
|
|
||||||
const terms = trimmed
|
|
||||||
.split(delimiter)
|
|
||||||
.map((value) => value.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
return terms.length > 0 ? terms : ["web developer"];
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireEnv(name: string): string {
|
function requireEnv(name: string): string {
|
||||||
const value = process.env[name]?.trim();
|
const value = process.env[name]?.trim();
|
||||||
if (!value) throw new Error(`Missing required environment variable: ${name}`);
|
if (!value) throw new Error(`Missing required environment variable: ${name}`);
|
||||||
@ -167,7 +139,10 @@ async function run(): Promise<void> {
|
|||||||
50,
|
50,
|
||||||
);
|
);
|
||||||
const resultsPerPage = Math.min(50, configuredResultsPerPage);
|
const resultsPerPage = Math.min(50, configuredResultsPerPage);
|
||||||
const searchTerms = parseSearchTerms(process.env.ADZUNA_SEARCH_TERMS);
|
const searchTerms = parseSearchTerms(
|
||||||
|
process.env.ADZUNA_SEARCH_TERMS,
|
||||||
|
DEFAULT_SEARCH_TERM,
|
||||||
|
);
|
||||||
const outputJson =
|
const outputJson =
|
||||||
process.env.ADZUNA_OUTPUT_JSON ||
|
process.env.ADZUNA_OUTPUT_JSON ||
|
||||||
join(process.cwd(), "storage/datasets/default/jobs.json");
|
join(process.cwd(), "storage/datasets/default/jobs.json");
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { launchOptions } from "camoufox-js";
|
import { launchOptions } from "camoufox-js";
|
||||||
|
import { parseSearchTerms } from "job-ops-shared/utils/search-terms";
|
||||||
import {
|
import {
|
||||||
toNumberOrNull,
|
toNumberOrNull,
|
||||||
toStringOrNull,
|
toStringOrNull,
|
||||||
@ -56,38 +57,6 @@ function parsePositiveInt(input: string | undefined, fallback: number): number {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSearchTerms(raw: string | undefined): string[] {
|
|
||||||
if (!raw || raw.trim().length === 0) return [DEFAULT_SEARCH_TERM];
|
|
||||||
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (trimmed.startsWith("[")) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed) as unknown;
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
const terms = parsed
|
|
||||||
.map((value) => toStringOrNull(value))
|
|
||||||
.filter((value): value is string => Boolean(value));
|
|
||||||
if (terms.length > 0) return terms;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to delimiter parsing.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const delimiter = trimmed.includes("|")
|
|
||||||
? "|"
|
|
||||||
: trimmed.includes("\n")
|
|
||||||
? "\n"
|
|
||||||
: ",";
|
|
||||||
|
|
||||||
const terms = trimmed
|
|
||||||
.split(delimiter)
|
|
||||||
.map((value) => value.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return terms.length > 0 ? terms : [DEFAULT_SEARCH_TERM];
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeSearchState(searchState: unknown): string {
|
function encodeSearchState(searchState: unknown): string {
|
||||||
const json = JSON.stringify(searchState);
|
const json = JSON.stringify(searchState);
|
||||||
const urlEncodedJson = encodeURIComponent(json);
|
const urlEncodedJson = encodeURIComponent(json);
|
||||||
@ -126,12 +95,7 @@ function formatCompensation(
|
|||||||
const frequency =
|
const frequency =
|
||||||
toStringOrNull(processedJobData.listed_compensation_frequency) ?? "Yearly";
|
toStringOrNull(processedJobData.listed_compensation_frequency) ?? "Yearly";
|
||||||
|
|
||||||
const amount =
|
const amount = formatCompensationAmount(min, max);
|
||||||
min !== null && max !== null
|
|
||||||
? `${Math.round(min)}-${Math.round(max)}`
|
|
||||||
: min !== null
|
|
||||||
? `${Math.round(min)}+`
|
|
||||||
: `${Math.round(max ?? 0)}`;
|
|
||||||
|
|
||||||
const parts = [currency, amount, frequency ? `/ ${frequency}` : ""]
|
const parts = [currency, amount, frequency ? `/ ${frequency}` : ""]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@ -141,6 +105,17 @@ function formatCompensation(
|
|||||||
return parts || undefined;
|
return parts || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCompensationAmount(
|
||||||
|
min: number | null,
|
||||||
|
max: number | null,
|
||||||
|
): string {
|
||||||
|
if (min !== null && max !== null) {
|
||||||
|
return `${Math.round(min)}-${Math.round(max)}`;
|
||||||
|
}
|
||||||
|
if (min !== null) return `${Math.round(min)}+`;
|
||||||
|
return `${Math.round(max ?? 0)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function mapHiringCafeJob(raw: RawHiringCafeJob): ExtractedJob | null {
|
function mapHiringCafeJob(raw: RawHiringCafeJob): ExtractedJob | null {
|
||||||
const jobInformation = asRecord(raw.job_information);
|
const jobInformation = asRecord(raw.job_information);
|
||||||
const processed = asRecord(raw.v5_processed_job_data);
|
const processed = asRecord(raw.v5_processed_job_data);
|
||||||
@ -277,7 +252,10 @@ async function callHiringCafeApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
const searchTerms = parseSearchTerms(process.env.HIRING_CAFE_SEARCH_TERMS);
|
const searchTerms = parseSearchTerms(
|
||||||
|
process.env.HIRING_CAFE_SEARCH_TERMS,
|
||||||
|
DEFAULT_SEARCH_TERM,
|
||||||
|
);
|
||||||
const country = normalizeCountryKey(
|
const country = normalizeCountryKey(
|
||||||
process.env.HIRING_CAFE_COUNTRY ?? "united kingdom",
|
process.env.HIRING_CAFE_COUNTRY ?? "united kingdom",
|
||||||
);
|
);
|
||||||
|
|||||||
@ -51,6 +51,18 @@ const emptyDraft: ManualJobDraftState = {
|
|||||||
starting: "",
|
starting: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STEP_INDEX_BY_ID: Record<ManualImportStep, number> = {
|
||||||
|
paste: 0,
|
||||||
|
loading: 1,
|
||||||
|
review: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEP_LABEL_BY_ID: Record<ManualImportStep, string> = {
|
||||||
|
paste: "Paste JD",
|
||||||
|
loading: "Infer details",
|
||||||
|
review: "Review & import",
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeDraft = (
|
const normalizeDraft = (
|
||||||
draft?: ManualJobDraft | null,
|
draft?: ManualJobDraft | null,
|
||||||
jd?: string,
|
jd?: string,
|
||||||
@ -128,8 +140,8 @@ export const ManualImportFlow: React.FC<ManualImportFlowProps> = ({
|
|||||||
setIsImporting(false);
|
setIsImporting(false);
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
const stepIndex = step === "paste" ? 0 : step === "loading" ? 1 : 2;
|
const stepIndex = STEP_INDEX_BY_ID[step];
|
||||||
const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex];
|
const stepLabel = STEP_LABEL_BY_ID[step];
|
||||||
|
|
||||||
const canAnalyze = rawDescription.trim().length > 0 && step !== "loading";
|
const canAnalyze = rawDescription.trim().length > 0 && step !== "loading";
|
||||||
const canFetch =
|
const canFetch =
|
||||||
|
|||||||
@ -54,6 +54,22 @@ type OnboardingFormData = {
|
|||||||
rxresumeBaseResumeId: string | null;
|
rxresumeBaseResumeId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getStepPrimaryLabel(input: {
|
||||||
|
currentStep: string | null;
|
||||||
|
llmValidated: boolean;
|
||||||
|
rxresumeValidated: boolean;
|
||||||
|
baseResumeValidated: boolean;
|
||||||
|
}): string {
|
||||||
|
const toLabel = (isValidated: boolean): string =>
|
||||||
|
isValidated ? "Revalidate" : "Validate";
|
||||||
|
|
||||||
|
if (input.currentStep === "llm") return toLabel(input.llmValidated);
|
||||||
|
if (input.currentStep === "rxresume") return toLabel(input.rxresumeValidated);
|
||||||
|
if (input.currentStep === "baseresume")
|
||||||
|
return toLabel(input.baseResumeValidated);
|
||||||
|
return "Validate";
|
||||||
|
}
|
||||||
|
|
||||||
export const OnboardingGate: React.FC = () => {
|
export const OnboardingGate: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
settings,
|
settings,
|
||||||
@ -107,13 +123,15 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
values.llmProvider || settings?.llmProvider || "openrouter",
|
values.llmProvider || settings?.llmProvider || "openrouter",
|
||||||
);
|
);
|
||||||
const providerConfig = getLlmProviderConfig(selectedProvider);
|
const providerConfig = getLlmProviderConfig(selectedProvider);
|
||||||
const { requiresApiKey } = providerConfig;
|
const { requiresApiKey, showBaseUrl } = providerConfig;
|
||||||
|
|
||||||
setIsValidatingLlm(true);
|
setIsValidatingLlm(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.validateLlm({
|
const result = await api.validateLlm({
|
||||||
provider: selectedProvider,
|
provider: selectedProvider,
|
||||||
baseUrl: values.llmBaseUrl.trim() || undefined,
|
baseUrl: showBaseUrl
|
||||||
|
? values.llmBaseUrl.trim() || undefined
|
||||||
|
: undefined,
|
||||||
apiKey: requiresApiKey
|
apiKey: requiresApiKey
|
||||||
? values.llmApiKey.trim() || undefined
|
? values.llmApiKey.trim() || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
@ -198,7 +216,6 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
hasCheckedValidations &&
|
hasCheckedValidations &&
|
||||||
!(llmValidated && rxresumeValidation.valid && baseResumeValidation.valid);
|
!(llmValidated && rxresumeValidation.valid && baseResumeValidation.valid);
|
||||||
|
|
||||||
const llmKeyCurrent = llmKeyHint ? formatSecretHint(llmKeyHint) : undefined;
|
|
||||||
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
|
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
|
||||||
? settings.rxresumeEmail
|
? settings.rxresumeEmail
|
||||||
: undefined;
|
: undefined;
|
||||||
@ -471,20 +488,12 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
isValidatingRxresume ||
|
isValidatingRxresume ||
|
||||||
isValidatingBaseResume;
|
isValidatingBaseResume;
|
||||||
const canGoBack = stepIndex > 0;
|
const canGoBack = stepIndex > 0;
|
||||||
const primaryLabel =
|
const primaryLabel = getStepPrimaryLabel({
|
||||||
currentStep === "llm"
|
currentStep,
|
||||||
? llmValidated
|
llmValidated,
|
||||||
? "Revalidate"
|
rxresumeValidated: rxresumeValidation.valid,
|
||||||
: "Validate"
|
baseResumeValidated: baseResumeValidation.valid,
|
||||||
: currentStep === "rxresume"
|
});
|
||||||
? rxresumeValidation.valid
|
|
||||||
? "Revalidate"
|
|
||||||
: "Validate"
|
|
||||||
: currentStep === "baseresume"
|
|
||||||
? baseResumeValidation.valid
|
|
||||||
? "Revalidate"
|
|
||||||
: "Validate"
|
|
||||||
: "Validate";
|
|
||||||
|
|
||||||
const handlePrimaryAction = async () => {
|
const handlePrimaryAction = async () => {
|
||||||
if (!currentStep) return;
|
if (!currentStep) return;
|
||||||
@ -648,8 +657,11 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter key"
|
placeholder="Enter key"
|
||||||
current={llmKeyCurrent}
|
helper={
|
||||||
helper={providerConfig.keyHelper}
|
llmKeyHint
|
||||||
|
? `${providerConfig.keyHelper}. Leave blank to use the saved key.`
|
||||||
|
: providerConfig.keyHelper
|
||||||
|
}
|
||||||
disabled={isSavingEnv}
|
disabled={isSavingEnv}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -72,6 +72,22 @@ const GLASSDOOR_COUNTRY_REASON =
|
|||||||
"Glassdoor is not available for the selected country.";
|
"Glassdoor is not available for the selected country.";
|
||||||
const GLASSDOOR_LOCATION_REASON =
|
const GLASSDOOR_LOCATION_REASON =
|
||||||
"Set a Glassdoor city in Advanced settings to enable Glassdoor.";
|
"Set a Glassdoor city in Advanced settings to enable Glassdoor.";
|
||||||
|
const UK_ONLY_SOURCES = new Set<JobSource>(["gradcracker", "ukvisajobs"]);
|
||||||
|
|
||||||
|
function getSourceDisabledReason(
|
||||||
|
source: JobSource,
|
||||||
|
countryAllowed: boolean,
|
||||||
|
): string {
|
||||||
|
if (source === "glassdoor") {
|
||||||
|
return countryAllowed
|
||||||
|
? GLASSDOOR_LOCATION_REASON
|
||||||
|
: GLASSDOOR_COUNTRY_REASON;
|
||||||
|
}
|
||||||
|
if (UK_ONLY_SOURCES.has(source)) {
|
||||||
|
return `${sourceLabel[source]} is available only when country is United Kingdom.`;
|
||||||
|
}
|
||||||
|
return `${sourceLabel[source]} is not available for the selected country.`;
|
||||||
|
}
|
||||||
|
|
||||||
function toNumber(input: string, min: number, max: number, fallback: number) {
|
function toNumber(input: string, min: number, max: number, fallback: number) {
|
||||||
const parsed = Number.parseInt(input, 10);
|
const parsed = Number.parseInt(input, 10);
|
||||||
@ -529,14 +545,10 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
|||||||
);
|
);
|
||||||
const allowed = isSourceAvailableForRun(source);
|
const allowed = isSourceAvailableForRun(source);
|
||||||
const selected = compatiblePipelineSources.includes(source);
|
const selected = compatiblePipelineSources.includes(source);
|
||||||
const disabledReason =
|
const disabledReason = getSourceDisabledReason(
|
||||||
source === "glassdoor"
|
source,
|
||||||
? countryAllowed
|
countryAllowed,
|
||||||
? GLASSDOOR_LOCATION_REASON
|
);
|
||||||
: GLASSDOOR_COUNTRY_REASON
|
|
||||||
: source === "gradcracker" || source === "ukvisajobs"
|
|
||||||
? `${sourceLabel[source]} is available only when country is United Kingdom.`
|
|
||||||
: `${sourceLabel[source]} is not available for the selected country.`;
|
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -10,6 +10,12 @@ interface JobRowContentProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSuitabilityScoreTone(score: number): string {
|
||||||
|
if (score >= 70) return "text-emerald-400/90";
|
||||||
|
if (score >= 50) return "text-foreground/60";
|
||||||
|
return "text-muted-foreground/60";
|
||||||
|
}
|
||||||
|
|
||||||
export const JobRowContent = ({
|
export const JobRowContent = ({
|
||||||
job,
|
job,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
@ -19,6 +25,7 @@ export const JobRowContent = ({
|
|||||||
}: JobRowContentProps) => {
|
}: JobRowContentProps) => {
|
||||||
const hasScore = job.suitabilityScore != null;
|
const hasScore = job.suitabilityScore != null;
|
||||||
const statusToken = statusTokens[job.status] ?? defaultStatusToken;
|
const statusToken = statusTokens[job.status] ?? defaultStatusToken;
|
||||||
|
const suitabilityTone = getSuitabilityScoreTone(job.suitabilityScore ?? 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex min-w-0 flex-1 items-center gap-3", className)}>
|
<div className={cn("flex min-w-0 flex-1 items-center gap-3", className)}>
|
||||||
@ -57,16 +64,7 @@ export const JobRowContent = ({
|
|||||||
|
|
||||||
{hasScore && (
|
{hasScore && (
|
||||||
<div className="shrink-0 text-right">
|
<div className="shrink-0 text-right">
|
||||||
<span
|
<span className={cn("text-xs tabular-nums", suitabilityTone)}>
|
||||||
className={cn(
|
|
||||||
"text-xs tabular-nums",
|
|
||||||
(job.suitabilityScore ?? 0) >= 70
|
|
||||||
? "text-emerald-400/90"
|
|
||||||
: (job.suitabilityScore ?? 0) >= 50
|
|
||||||
? "text-foreground/60"
|
|
||||||
: "text-muted-foreground/60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{job.suitabilityScore}
|
{job.suitabilityScore}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -24,6 +24,12 @@ const bulkActionLabel: Record<BulkJobAction, string> = {
|
|||||||
rescore: "Calculating match scores...",
|
rescore: "Calculating match scores...",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bulkActionSuccessLabel: Record<BulkJobAction, string> = {
|
||||||
|
move_to_ready: "jobs moved to Ready",
|
||||||
|
skip: "jobs skipped",
|
||||||
|
rescore: "matches recalculated",
|
||||||
|
};
|
||||||
|
|
||||||
interface UseBulkJobSelectionArgs {
|
interface UseBulkJobSelectionArgs {
|
||||||
activeJobs: JobListItem[];
|
activeJobs: JobListItem[];
|
||||||
activeTab: FilterTab;
|
activeTab: FilterTab;
|
||||||
@ -222,12 +228,7 @@ export function useBulkJobSelection({
|
|||||||
|
|
||||||
const result = finalResult as BulkJobActionResponse;
|
const result = finalResult as BulkJobActionResponse;
|
||||||
const failedIds = getFailedJobIds(result);
|
const failedIds = getFailedJobIds(result);
|
||||||
const successLabel =
|
const successLabel = bulkActionSuccessLabel[action];
|
||||||
action === "skip"
|
|
||||||
? "jobs skipped"
|
|
||||||
: action === "move_to_ready"
|
|
||||||
? "jobs moved to Ready"
|
|
||||||
: "matches recalculated";
|
|
||||||
|
|
||||||
if (result.failed === 0) {
|
if (result.failed === 0) {
|
||||||
toast.success(`${result.succeeded} ${successLabel}`);
|
toast.success(`${result.succeeded} ${successLabel}`);
|
||||||
|
|||||||
@ -37,6 +37,39 @@ export const LLM_PROVIDER_LABELS: Record<LlmProviderId, string> = {
|
|||||||
gemini: "Gemini",
|
gemini: "Gemini",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PROVIDERS_WITH_API_KEY = new Set<LlmProviderId>([
|
||||||
|
"openrouter",
|
||||||
|
"openai",
|
||||||
|
"gemini",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PROVIDERS_WITH_BASE_URL = new Set<LlmProviderId>(["lmstudio", "ollama"]);
|
||||||
|
|
||||||
|
const PROVIDER_HINTS: Record<LlmProviderId, string> = {
|
||||||
|
openrouter:
|
||||||
|
"OpenRouter uses your API key and supports model routing across providers.",
|
||||||
|
lmstudio: "LM Studio runs locally via its OpenAI-compatible server.",
|
||||||
|
ollama: "Ollama typically runs locally and does not require an API key.",
|
||||||
|
openai: "OpenAI uses the Responses API with structured outputs.",
|
||||||
|
gemini: "Gemini uses the native AI Studio API and requires a key.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_KEY_HELPERS: Record<LlmProviderId, string> = {
|
||||||
|
openrouter: "Create a key at openrouter.ai",
|
||||||
|
lmstudio: "No API key required for LM Studio",
|
||||||
|
ollama: "No API key required for Ollama",
|
||||||
|
openai: "Create a key at platform.openai.com",
|
||||||
|
gemini: "Create a key at aistudio.google.com/api-keys",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_URL_PROVIDERS = ["lmstudio", "ollama"] as const;
|
||||||
|
type BaseUrlProviderId = (typeof BASE_URL_PROVIDERS)[number];
|
||||||
|
|
||||||
|
const PROVIDER_BASE_URLS: Record<BaseUrlProviderId, string> = {
|
||||||
|
lmstudio: "http://localhost:1234",
|
||||||
|
ollama: "http://localhost:11434",
|
||||||
|
};
|
||||||
|
|
||||||
export function normalizeLlmProvider(
|
export function normalizeLlmProvider(
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
): LlmProviderId {
|
): LlmProviderId {
|
||||||
@ -49,34 +82,15 @@ export function normalizeLlmProvider(
|
|||||||
|
|
||||||
export function getLlmProviderConfig(provider: string | null | undefined) {
|
export function getLlmProviderConfig(provider: string | null | undefined) {
|
||||||
const normalizedProvider = normalizeLlmProvider(provider);
|
const normalizedProvider = normalizeLlmProvider(provider);
|
||||||
const showApiKey = ["openrouter", "openai", "gemini"].includes(
|
const showApiKey = PROVIDERS_WITH_API_KEY.has(normalizedProvider);
|
||||||
normalizedProvider,
|
const showBaseUrl = PROVIDERS_WITH_BASE_URL.has(normalizedProvider);
|
||||||
);
|
const baseUrlPlaceholder = showBaseUrl
|
||||||
const showBaseUrl = ["lmstudio", "ollama"].includes(normalizedProvider);
|
? PROVIDER_BASE_URLS[normalizedProvider as BaseUrlProviderId]
|
||||||
const baseUrlPlaceholder =
|
: "";
|
||||||
normalizedProvider === "ollama"
|
const baseUrlHelper = showBaseUrl ? `Default: ${baseUrlPlaceholder}` : "";
|
||||||
? "http://localhost:11434"
|
const providerHint = PROVIDER_HINTS[normalizedProvider];
|
||||||
: "http://localhost:1234";
|
const keyHelper = PROVIDER_KEY_HELPERS[normalizedProvider];
|
||||||
const baseUrlHelper =
|
|
||||||
normalizedProvider === "ollama"
|
|
||||||
? "Default: http://localhost:11434"
|
|
||||||
: "Default: http://localhost:1234";
|
|
||||||
const providerHint =
|
|
||||||
normalizedProvider === "ollama"
|
|
||||||
? "Ollama typically runs locally and does not require an API key."
|
|
||||||
: normalizedProvider === "lmstudio"
|
|
||||||
? "LM Studio runs locally via its OpenAI-compatible server."
|
|
||||||
: normalizedProvider === "openai"
|
|
||||||
? "OpenAI uses the Responses API with structured outputs."
|
|
||||||
: normalizedProvider === "gemini"
|
|
||||||
? "Gemini uses the native AI Studio API and requires a key."
|
|
||||||
: "OpenRouter uses your API key and supports model routing across providers.";
|
|
||||||
const keyHelper =
|
|
||||||
normalizedProvider === "openai"
|
|
||||||
? "Create a key at platform.openai.com"
|
|
||||||
: normalizedProvider === "gemini"
|
|
||||||
? "Create a key at ai.google.dev"
|
|
||||||
: "Create a key at openrouter.ai";
|
|
||||||
return {
|
return {
|
||||||
normalizedProvider,
|
normalizedProvider,
|
||||||
label: LLM_PROVIDER_LABELS[normalizedProvider],
|
label: LLM_PROVIDER_LABELS[normalizedProvider],
|
||||||
|
|||||||
@ -74,6 +74,144 @@ describe.sequential("Onboarding API routes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("POST /api/onboarding/validate/llm", () => {
|
||||||
|
it("maps Gemini 403 key validation failures to an invalid-key message", async () => {
|
||||||
|
global.fetch = vi.fn((input, init) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
if (
|
||||||
|
url.startsWith(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models?",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
json: async () => ({
|
||||||
|
error: {
|
||||||
|
code: 403,
|
||||||
|
message:
|
||||||
|
"Method doesn't allow unregistered callers. Please use API key.",
|
||||||
|
status: "PERMISSION_DENIED",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/onboarding/validate/llm`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: "gemini",
|
||||||
|
apiKey: "invalid-gemini-key",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.data.valid).toBe(false);
|
||||||
|
expect(body.data.message).toBe(
|
||||||
|
"Invalid LLM API key. Check the key and try again.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores baseUrl for Gemini and validates against the Gemini API", async () => {
|
||||||
|
global.fetch = vi.fn((input, init) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
if (
|
||||||
|
url.startsWith(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models?",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ models: [] }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.startsWith("http://localhost:1234")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
json: async () => ({ error: { message: "bad local auth" } }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/onboarding/validate/llm`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: "gemini",
|
||||||
|
apiKey: "valid-gemini-key",
|
||||||
|
baseUrl: "http://localhost:1234",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.data.valid).toBe(true);
|
||||||
|
expect(body.data.message).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to stored settings when request omits apiKey", async () => {
|
||||||
|
await fetch(`${baseUrl}/api/settings`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
llmProvider: "gemini",
|
||||||
|
llmApiKey: "db-gemini-key",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
delete process.env.LLM_API_KEY;
|
||||||
|
|
||||||
|
global.fetch = vi.fn((input, init) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
if (
|
||||||
|
url.startsWith(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models?",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ models: [] }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/onboarding/validate/llm`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ provider: "gemini" }),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.data.valid).toBe(true);
|
||||||
|
expect(body.data.message).toBeNull();
|
||||||
|
const fetchCalls = vi.mocked(global.fetch).mock.calls.map((call) => {
|
||||||
|
const requestInput = call[0];
|
||||||
|
if (typeof requestInput === "string") return requestInput;
|
||||||
|
if (requestInput instanceof URL) return requestInput.href;
|
||||||
|
return requestInput.url;
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
fetchCalls.some((url) =>
|
||||||
|
url.includes(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models?key=db-gemini-key",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("POST /api/onboarding/validate/rxresume", () => {
|
describe("POST /api/onboarding/validate/rxresume", () => {
|
||||||
it("returns invalid when no credentials are provided and none in env", async () => {
|
it("returns invalid when no credentials are provided and none in env", async () => {
|
||||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
|
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { okWithMeta } from "@infra/http";
|
import { okWithMeta } from "@infra/http";
|
||||||
|
import { logger } from "@infra/logger";
|
||||||
import { getSetting } from "@server/repositories/settings";
|
import { getSetting } from "@server/repositories/settings";
|
||||||
import { LlmService } from "@server/services/llm-service";
|
import { LlmService } from "@server/services/llm-service";
|
||||||
import { RxResumeClient } from "@server/services/rxresume-client";
|
import { RxResumeClient } from "@server/services/rxresume-client";
|
||||||
@ -22,10 +23,32 @@ async function validateLlm(options: {
|
|||||||
provider?: string | null;
|
provider?: string | null;
|
||||||
baseUrl?: string | null;
|
baseUrl?: string | null;
|
||||||
}): Promise<ValidationResponse> {
|
}): Promise<ValidationResponse> {
|
||||||
|
const [storedApiKey, storedProvider, storedBaseUrl] = await Promise.all([
|
||||||
|
getSetting("llmApiKey"),
|
||||||
|
getSetting("llmProvider"),
|
||||||
|
getSetting("llmBaseUrl"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const normalizedProvider =
|
||||||
|
options.provider?.trim() || storedProvider?.trim() || undefined;
|
||||||
|
const shouldUseBaseUrl =
|
||||||
|
normalizedProvider === "lmstudio" || normalizedProvider === "ollama";
|
||||||
|
const resolvedBaseUrl = shouldUseBaseUrl
|
||||||
|
? options.baseUrl?.trim() || storedBaseUrl?.trim() || undefined
|
||||||
|
: undefined;
|
||||||
|
const resolvedApiKey = options.apiKey?.trim() || storedApiKey?.trim() || null;
|
||||||
|
|
||||||
|
logger.debug("LLM onboarding validation resolved config", {
|
||||||
|
provider: normalizedProvider ?? null,
|
||||||
|
usesBaseUrl: shouldUseBaseUrl,
|
||||||
|
hasBaseUrl: Boolean(resolvedBaseUrl),
|
||||||
|
hasApiKey: Boolean(resolvedApiKey),
|
||||||
|
});
|
||||||
|
|
||||||
const llm = new LlmService({
|
const llm = new LlmService({
|
||||||
apiKey: options.apiKey,
|
apiKey: resolvedApiKey,
|
||||||
provider: options.provider ?? undefined,
|
provider: normalizedProvider,
|
||||||
baseUrl: options.baseUrl ?? undefined,
|
baseUrl: resolvedBaseUrl,
|
||||||
});
|
});
|
||||||
return llm.validateCredentials();
|
return llm.validateCredentials();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -239,21 +239,14 @@ export function updateStageEvent(
|
|||||||
|
|
||||||
const metadata = parseMetadata(lastEvent.metadata);
|
const metadata = parseMetadata(lastEvent.metadata);
|
||||||
const lastStage = lastEvent.toStage as ApplicationStage;
|
const lastStage = lastEvent.toStage as ApplicationStage;
|
||||||
const storedOutcome = (lastEvent.outcome as JobOutcome | null) ?? null;
|
const { outcome, closedAt } = resolveOutcomeAndClosedAt({
|
||||||
const inferredOutcome = inferOutcome(lastStage, metadata);
|
lastStage,
|
||||||
const closingStage = isClosingStage(lastStage);
|
lastEventOccurredAt: lastEvent.occurredAt,
|
||||||
const outcome =
|
metadata,
|
||||||
storedOutcome ??
|
lastEventOutcome: (lastEvent.outcome as JobOutcome | null) ?? null,
|
||||||
inferredOutcome ??
|
jobOutcome: (job.outcome as JobOutcome | null) ?? null,
|
||||||
(closingStage ? ((job.outcome as JobOutcome | null) ?? null) : null);
|
jobClosedAt: job.closedAt ?? null,
|
||||||
const closedAt =
|
});
|
||||||
lastStage === "closed"
|
|
||||||
? lastEvent.occurredAt
|
|
||||||
: outcome
|
|
||||||
? storedOutcome || inferredOutcome
|
|
||||||
? lastEvent.occurredAt
|
|
||||||
: (job.closedAt ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
tx.update(jobs)
|
tx.update(jobs)
|
||||||
.set({
|
.set({
|
||||||
@ -298,21 +291,14 @@ export function deleteStageEvent(eventId: string): void {
|
|||||||
|
|
||||||
const metadata = parseMetadata(lastEvent.metadata);
|
const metadata = parseMetadata(lastEvent.metadata);
|
||||||
const lastStage = lastEvent.toStage as ApplicationStage;
|
const lastStage = lastEvent.toStage as ApplicationStage;
|
||||||
const storedOutcome = (lastEvent.outcome as JobOutcome | null) ?? null;
|
const { outcome, closedAt } = resolveOutcomeAndClosedAt({
|
||||||
const inferredOutcome = inferOutcome(lastStage, metadata);
|
lastStage,
|
||||||
const closingStage = isClosingStage(lastStage);
|
lastEventOccurredAt: lastEvent.occurredAt,
|
||||||
const outcome =
|
metadata,
|
||||||
storedOutcome ??
|
lastEventOutcome: (lastEvent.outcome as JobOutcome | null) ?? null,
|
||||||
inferredOutcome ??
|
jobOutcome: (job.outcome as JobOutcome | null) ?? null,
|
||||||
(closingStage ? ((job.outcome as JobOutcome | null) ?? null) : null);
|
jobClosedAt: job.closedAt ?? null,
|
||||||
const closedAt =
|
});
|
||||||
lastStage === "closed"
|
|
||||||
? lastEvent.occurredAt
|
|
||||||
: outcome
|
|
||||||
? storedOutcome || inferredOutcome
|
|
||||||
? lastEvent.occurredAt
|
|
||||||
: (job.closedAt ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
tx.update(jobs)
|
tx.update(jobs)
|
||||||
.set({
|
.set({
|
||||||
@ -364,3 +350,30 @@ function inferOutcome(
|
|||||||
function isClosingStage(toStage: ApplicationStage): boolean {
|
function isClosingStage(toStage: ApplicationStage): boolean {
|
||||||
return toStage === "closed" || toStage === "offer";
|
return toStage === "closed" || toStage === "offer";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveOutcomeAndClosedAt(input: {
|
||||||
|
lastStage: ApplicationStage;
|
||||||
|
lastEventOccurredAt: number;
|
||||||
|
metadata: StageEventMetadata | null;
|
||||||
|
lastEventOutcome: JobOutcome | null;
|
||||||
|
jobOutcome: JobOutcome | null;
|
||||||
|
jobClosedAt: number | null;
|
||||||
|
}): { outcome: JobOutcome | null; closedAt: number | null } {
|
||||||
|
const inferredOutcome = inferOutcome(input.lastStage, input.metadata);
|
||||||
|
const closingStage = isClosingStage(input.lastStage);
|
||||||
|
const outcome =
|
||||||
|
input.lastEventOutcome ??
|
||||||
|
inferredOutcome ??
|
||||||
|
(closingStage ? input.jobOutcome : null);
|
||||||
|
|
||||||
|
if (input.lastStage === "closed") {
|
||||||
|
return { outcome, closedAt: input.lastEventOccurredAt };
|
||||||
|
}
|
||||||
|
if (!outcome) {
|
||||||
|
return { outcome, closedAt: null };
|
||||||
|
}
|
||||||
|
if (input.lastEventOutcome || inferredOutcome) {
|
||||||
|
return { outcome, closedAt: input.lastEventOccurredAt };
|
||||||
|
}
|
||||||
|
return { outcome, closedAt: input.jobClosedAt };
|
||||||
|
}
|
||||||
|
|||||||
@ -269,6 +269,26 @@ describe("LlmService", () => {
|
|||||||
}
|
}
|
||||||
expect(vi.mocked(global.fetch).mock.calls.length).toBe(2);
|
expect(vi.mocked(global.fetch).mock.calls.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not send Authorization header for Gemini key validation", async () => {
|
||||||
|
process.env.LLM_PROVIDER = "gemini";
|
||||||
|
process.env.LLM_API_KEY = "AIza-valid-gemini-key";
|
||||||
|
delete process.env.OPENROUTER_API_KEY;
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ models: [] }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const llm = new LlmService();
|
||||||
|
const result = await llm.validateCredentials();
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
const fetchCall = vi.mocked(global.fetch).mock.calls[0];
|
||||||
|
const headers = fetchCall?.[1]?.headers as Record<string, string>;
|
||||||
|
expect(headers.Authorization).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseJsonContent", () => {
|
describe("parseJsonContent", () => {
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export const geminiStrategy = createProviderStrategy({
|
|||||||
if (mode === "json_schema") {
|
if (mode === "json_schema") {
|
||||||
body.generationConfig = {
|
body.generationConfig = {
|
||||||
responseMimeType: "application/json",
|
responseMimeType: "application/json",
|
||||||
responseSchema: jsonSchema.schema,
|
responseSchema: toGeminiResponseSchema(jsonSchema.schema),
|
||||||
};
|
};
|
||||||
} else if (mode === "json_object") {
|
} else if (mode === "json_object") {
|
||||||
body.generationConfig = {
|
body.generationConfig = {
|
||||||
@ -62,6 +62,24 @@ export const geminiStrategy = createProviderStrategy({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function toGeminiResponseSchema(schema: unknown): unknown {
|
||||||
|
if (Array.isArray(schema)) {
|
||||||
|
return schema.map((item) => toGeminiResponseSchema(item));
|
||||||
|
}
|
||||||
|
if (!schema || typeof schema !== "object") {
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(schema)) {
|
||||||
|
// Gemini's responseSchema rejects JSON Schema's additionalProperties.
|
||||||
|
// Fix as part of #202.
|
||||||
|
if (key === "additionalProperties") continue;
|
||||||
|
out[key] = toGeminiResponseSchema(value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function toGeminiContents(messages: LlmRequestOptions<unknown>["messages"]): {
|
function toGeminiContents(messages: LlmRequestOptions<unknown>["messages"]): {
|
||||||
systemInstruction: { parts: Array<{ text: string }> } | null;
|
systemInstruction: { parts: Array<{ text: string }> } | null;
|
||||||
contents: Array<{ role: "user" | "model"; parts: Array<{ text: string }> }>;
|
contents: Array<{ role: "user" | "model"; parts: Array<{ text: string }> }>;
|
||||||
|
|||||||
@ -134,4 +134,49 @@ describe("provider adapters", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe("gemini");
|
).toBe("gemini");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips unsupported additionalProperties keys from Gemini responseSchema", () => {
|
||||||
|
const request = geminiStrategy.buildRequest({
|
||||||
|
mode: "json_schema",
|
||||||
|
baseUrl: "https://generativelanguage.googleapis.com",
|
||||||
|
apiKey: "x",
|
||||||
|
model: "gemini-2.5-flash",
|
||||||
|
messages,
|
||||||
|
jsonSchema: {
|
||||||
|
name: "resume_tailoring",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
skills: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
keywords: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
required: ["name", "keywords"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["skills"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const generationConfig = (request.body as Record<string, unknown>)
|
||||||
|
.generationConfig as Record<string, unknown>;
|
||||||
|
const responseSchema = generationConfig.responseSchema as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const skills = (responseSchema.properties as Record<string, unknown>)
|
||||||
|
.skills as Record<string, unknown>;
|
||||||
|
const itemSchema = skills.items as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(responseSchema.additionalProperties).toBeUndefined();
|
||||||
|
expect(itemSchema.additionalProperties).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -134,10 +134,12 @@ export class LlmService {
|
|||||||
|
|
||||||
for (const url of urls) {
|
for (const url of urls) {
|
||||||
try {
|
try {
|
||||||
|
const validationApiKey =
|
||||||
|
this.provider === "gemini" ? null : this.apiKey;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: buildHeaders({
|
headers: buildHeaders({
|
||||||
apiKey: this.apiKey,
|
apiKey: validationApiKey,
|
||||||
provider: this.provider,
|
provider: this.provider,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -147,15 +149,24 @@ export class LlmService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detail = await getResponseDetail(response);
|
const detail = await getResponseDetail(response);
|
||||||
if (response.status === 401) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
message: "Invalid LLM API key. Check the key and try again.",
|
message: "Invalid LLM API key. Check the key and try again.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
logger.warn("LLM credential validation request failed", {
|
||||||
|
provider: this.provider,
|
||||||
|
status: response.status,
|
||||||
|
detail: detail || null,
|
||||||
|
});
|
||||||
|
|
||||||
lastMessage = detail || `LLM provider returned ${response.status}`;
|
lastMessage = detail || `LLM provider returned ${response.status}`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.warn("LLM credential validation request errored", {
|
||||||
|
provider: this.provider,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
lastMessage =
|
lastMessage =
|
||||||
error instanceof Error ? error.message : "LLM validation failed.";
|
error instanceof Error ? error.message : "LLM validation failed.";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -127,6 +127,16 @@ type SmartRouterResult = {
|
|||||||
reason: string;
|
reason: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function resolveProcessingStatus(input: {
|
||||||
|
isAutoLinked: boolean;
|
||||||
|
isPendingMatch: boolean;
|
||||||
|
isRelevantOrphan: boolean;
|
||||||
|
}): "auto_linked" | "pending_user" | "ignored" {
|
||||||
|
if (input.isAutoLinked) return "auto_linked";
|
||||||
|
if (input.isPendingMatch || input.isRelevantOrphan) return "pending_user";
|
||||||
|
return "ignored";
|
||||||
|
}
|
||||||
|
|
||||||
type IndexedActiveJob = {
|
type IndexedActiveJob = {
|
||||||
index: number;
|
index: number;
|
||||||
id: string;
|
id: string;
|
||||||
@ -868,11 +878,11 @@ export async function runGmailIngestionSync(args: {
|
|||||||
const isAutoLinked = routerResult.confidence >= 95 && matchedJobId;
|
const isAutoLinked = routerResult.confidence >= 95 && matchedJobId;
|
||||||
const isPendingMatch = routerResult.confidence >= 50;
|
const isPendingMatch = routerResult.confidence >= 50;
|
||||||
const isRelevantOrphan = routerResult.isRelevant;
|
const isRelevantOrphan = routerResult.isRelevant;
|
||||||
const processingStatus = isAutoLinked
|
const processingStatus = resolveProcessingStatus({
|
||||||
? "auto_linked"
|
isAutoLinked: Boolean(isAutoLinked),
|
||||||
: isPendingMatch || isRelevantOrphan
|
isPendingMatch,
|
||||||
? "pending_user"
|
isRelevantOrphan,
|
||||||
: "ignored";
|
});
|
||||||
|
|
||||||
const { message: savedMessage, autoLinkTransitioned } =
|
const { message: savedMessage, autoLinkTransitioned } =
|
||||||
await upsertPostApplicationMessage({
|
await upsertPostApplicationMessage({
|
||||||
|
|||||||
@ -2,7 +2,10 @@ import { logger } from "@infra/logger";
|
|||||||
import type { PostApplicationProviderActionResponse } from "@shared/types";
|
import type { PostApplicationProviderActionResponse } from "@shared/types";
|
||||||
import { toProviderAppError } from "./errors";
|
import { toProviderAppError } from "./errors";
|
||||||
import { resolvePostApplicationProvider } from "./registry";
|
import { resolvePostApplicationProvider } from "./registry";
|
||||||
import type { ExecutePostApplicationProviderActionInput } from "./types";
|
import type {
|
||||||
|
ExecutePostApplicationProviderActionInput,
|
||||||
|
PostApplicationProviderActionResult,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export async function executePostApplicationProviderAction(
|
export async function executePostApplicationProviderAction(
|
||||||
input: ExecutePostApplicationProviderActionInput,
|
input: ExecutePostApplicationProviderActionInput,
|
||||||
@ -10,27 +13,34 @@ export async function executePostApplicationProviderAction(
|
|||||||
const provider = resolvePostApplicationProvider(input.provider);
|
const provider = resolvePostApplicationProvider(input.provider);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result =
|
let result: PostApplicationProviderActionResult;
|
||||||
input.action === "connect"
|
switch (input.action) {
|
||||||
? await provider.connect({
|
case "connect":
|
||||||
accountKey: input.accountKey,
|
result = await provider.connect({
|
||||||
initiatedBy: input.initiatedBy,
|
accountKey: input.accountKey,
|
||||||
payload: input.connectPayload,
|
initiatedBy: input.initiatedBy,
|
||||||
})
|
payload: input.connectPayload,
|
||||||
: input.action === "status"
|
});
|
||||||
? await provider.status({
|
break;
|
||||||
accountKey: input.accountKey,
|
case "status":
|
||||||
})
|
result = await provider.status({
|
||||||
: input.action === "sync"
|
accountKey: input.accountKey,
|
||||||
? await provider.sync({
|
});
|
||||||
accountKey: input.accountKey,
|
break;
|
||||||
initiatedBy: input.initiatedBy,
|
case "sync":
|
||||||
payload: input.syncPayload,
|
result = await provider.sync({
|
||||||
})
|
accountKey: input.accountKey,
|
||||||
: await provider.disconnect({
|
initiatedBy: input.initiatedBy,
|
||||||
accountKey: input.accountKey,
|
payload: input.syncPayload,
|
||||||
initiatedBy: input.initiatedBy,
|
});
|
||||||
});
|
break;
|
||||||
|
case "disconnect":
|
||||||
|
result = await provider.disconnect({
|
||||||
|
accountKey: input.accountKey,
|
||||||
|
initiatedBy: input.initiatedBy,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: provider.key,
|
provider: provider.key,
|
||||||
|
|||||||
36
shared/src/utils/search-terms.ts
Normal file
36
shared/src/utils/search-terms.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { toStringOrNull } from "./type-conversion.js";
|
||||||
|
|
||||||
|
export function detectSearchTermDelimiter(value: string): string {
|
||||||
|
if (value.includes("|")) return "|";
|
||||||
|
if (value.includes("\n")) return "\n";
|
||||||
|
return ",";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSearchTerms(
|
||||||
|
raw: string | undefined,
|
||||||
|
fallbackTerm: string,
|
||||||
|
): string[] {
|
||||||
|
if (!raw || raw.trim().length === 0) return [fallbackTerm];
|
||||||
|
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as unknown;
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const terms = parsed
|
||||||
|
.map((value) => toStringOrNull(value))
|
||||||
|
.filter((value): value is string => value !== null);
|
||||||
|
if (terms.length > 0) return terms;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to delimiter parsing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const delimiter = detectSearchTermDelimiter(trimmed);
|
||||||
|
const terms = trimmed
|
||||||
|
.split(delimiter)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return terms.length > 0 ? terms : [fallbackTerm];
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user