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 { dirname, join } from "node:path";
|
||||
import { parseSearchTerms } from "job-ops-shared/utils/search-terms";
|
||||
import {
|
||||
toNumberOrNull,
|
||||
toStringOrNull,
|
||||
@ -7,6 +8,7 @@ import {
|
||||
|
||||
const API_BASE = "https://api.adzuna.com/v1/api";
|
||||
const JOBOPS_PROGRESS_PREFIX = "JOBOPS_PROGRESS ";
|
||||
const DEFAULT_SEARCH_TERM = "web developer";
|
||||
|
||||
type AdzunaCompany = { display_name?: unknown };
|
||||
type AdzunaLocation = { display_name?: unknown };
|
||||
@ -44,36 +46,6 @@ function parsePositiveInt(input: string | undefined, fallback: number): number {
|
||||
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 {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new Error(`Missing required environment variable: ${name}`);
|
||||
@ -167,7 +139,10 @@ async function run(): Promise<void> {
|
||||
50,
|
||||
);
|
||||
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 =
|
||||
process.env.ADZUNA_OUTPUT_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 { fileURLToPath } from "node:url";
|
||||
import { launchOptions } from "camoufox-js";
|
||||
import { parseSearchTerms } from "job-ops-shared/utils/search-terms";
|
||||
import {
|
||||
toNumberOrNull,
|
||||
toStringOrNull,
|
||||
@ -56,38 +57,6 @@ function parsePositiveInt(input: string | undefined, fallback: number): number {
|
||||
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 {
|
||||
const json = JSON.stringify(searchState);
|
||||
const urlEncodedJson = encodeURIComponent(json);
|
||||
@ -126,12 +95,7 @@ function formatCompensation(
|
||||
const frequency =
|
||||
toStringOrNull(processedJobData.listed_compensation_frequency) ?? "Yearly";
|
||||
|
||||
const amount =
|
||||
min !== null && max !== null
|
||||
? `${Math.round(min)}-${Math.round(max)}`
|
||||
: min !== null
|
||||
? `${Math.round(min)}+`
|
||||
: `${Math.round(max ?? 0)}`;
|
||||
const amount = formatCompensationAmount(min, max);
|
||||
|
||||
const parts = [currency, amount, frequency ? `/ ${frequency}` : ""]
|
||||
.filter(Boolean)
|
||||
@ -141,6 +105,17 @@ function formatCompensation(
|
||||
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 {
|
||||
const jobInformation = asRecord(raw.job_information);
|
||||
const processed = asRecord(raw.v5_processed_job_data);
|
||||
@ -277,7 +252,10 @@ async function callHiringCafeApi(
|
||||
}
|
||||
|
||||
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(
|
||||
process.env.HIRING_CAFE_COUNTRY ?? "united kingdom",
|
||||
);
|
||||
|
||||
@ -51,6 +51,18 @@ const emptyDraft: ManualJobDraftState = {
|
||||
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 = (
|
||||
draft?: ManualJobDraft | null,
|
||||
jd?: string,
|
||||
@ -128,8 +140,8 @@ export const ManualImportFlow: React.FC<ManualImportFlowProps> = ({
|
||||
setIsImporting(false);
|
||||
}, [active]);
|
||||
|
||||
const stepIndex = step === "paste" ? 0 : step === "loading" ? 1 : 2;
|
||||
const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex];
|
||||
const stepIndex = STEP_INDEX_BY_ID[step];
|
||||
const stepLabel = STEP_LABEL_BY_ID[step];
|
||||
|
||||
const canAnalyze = rawDescription.trim().length > 0 && step !== "loading";
|
||||
const canFetch =
|
||||
|
||||
@ -54,6 +54,22 @@ type OnboardingFormData = {
|
||||
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 = () => {
|
||||
const {
|
||||
settings,
|
||||
@ -107,13 +123,15 @@ export const OnboardingGate: React.FC = () => {
|
||||
values.llmProvider || settings?.llmProvider || "openrouter",
|
||||
);
|
||||
const providerConfig = getLlmProviderConfig(selectedProvider);
|
||||
const { requiresApiKey } = providerConfig;
|
||||
const { requiresApiKey, showBaseUrl } = providerConfig;
|
||||
|
||||
setIsValidatingLlm(true);
|
||||
try {
|
||||
const result = await api.validateLlm({
|
||||
provider: selectedProvider,
|
||||
baseUrl: values.llmBaseUrl.trim() || undefined,
|
||||
baseUrl: showBaseUrl
|
||||
? values.llmBaseUrl.trim() || undefined
|
||||
: undefined,
|
||||
apiKey: requiresApiKey
|
||||
? values.llmApiKey.trim() || undefined
|
||||
: undefined,
|
||||
@ -198,7 +216,6 @@ export const OnboardingGate: React.FC = () => {
|
||||
hasCheckedValidations &&
|
||||
!(llmValidated && rxresumeValidation.valid && baseResumeValidation.valid);
|
||||
|
||||
const llmKeyCurrent = llmKeyHint ? formatSecretHint(llmKeyHint) : undefined;
|
||||
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
|
||||
? settings.rxresumeEmail
|
||||
: undefined;
|
||||
@ -471,20 +488,12 @@ export const OnboardingGate: React.FC = () => {
|
||||
isValidatingRxresume ||
|
||||
isValidatingBaseResume;
|
||||
const canGoBack = stepIndex > 0;
|
||||
const primaryLabel =
|
||||
currentStep === "llm"
|
||||
? llmValidated
|
||||
? "Revalidate"
|
||||
: "Validate"
|
||||
: currentStep === "rxresume"
|
||||
? rxresumeValidation.valid
|
||||
? "Revalidate"
|
||||
: "Validate"
|
||||
: currentStep === "baseresume"
|
||||
? baseResumeValidation.valid
|
||||
? "Revalidate"
|
||||
: "Validate"
|
||||
: "Validate";
|
||||
const primaryLabel = getStepPrimaryLabel({
|
||||
currentStep,
|
||||
llmValidated,
|
||||
rxresumeValidated: rxresumeValidation.valid,
|
||||
baseResumeValidated: baseResumeValidation.valid,
|
||||
});
|
||||
|
||||
const handlePrimaryAction = async () => {
|
||||
if (!currentStep) return;
|
||||
@ -648,8 +657,11 @@ export const OnboardingGate: React.FC = () => {
|
||||
}}
|
||||
type="password"
|
||||
placeholder="Enter key"
|
||||
current={llmKeyCurrent}
|
||||
helper={providerConfig.keyHelper}
|
||||
helper={
|
||||
llmKeyHint
|
||||
? `${providerConfig.keyHelper}. Leave blank to use the saved key.`
|
||||
: providerConfig.keyHelper
|
||||
}
|
||||
disabled={isSavingEnv}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -72,6 +72,22 @@ const GLASSDOOR_COUNTRY_REASON =
|
||||
"Glassdoor is not available for the selected country.";
|
||||
const GLASSDOOR_LOCATION_REASON =
|
||||
"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) {
|
||||
const parsed = Number.parseInt(input, 10);
|
||||
@ -529,14 +545,10 @@ export const AutomaticRunTab: React.FC<AutomaticRunTabProps> = ({
|
||||
);
|
||||
const allowed = isSourceAvailableForRun(source);
|
||||
const selected = compatiblePipelineSources.includes(source);
|
||||
const disabledReason =
|
||||
source === "glassdoor"
|
||||
? 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 disabledReason = getSourceDisabledReason(
|
||||
source,
|
||||
countryAllowed,
|
||||
);
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
|
||||
@ -10,6 +10,12 @@ interface JobRowContentProps {
|
||||
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 = ({
|
||||
job,
|
||||
isSelected = false,
|
||||
@ -19,6 +25,7 @@ export const JobRowContent = ({
|
||||
}: JobRowContentProps) => {
|
||||
const hasScore = job.suitabilityScore != null;
|
||||
const statusToken = statusTokens[job.status] ?? defaultStatusToken;
|
||||
const suitabilityTone = getSuitabilityScoreTone(job.suitabilityScore ?? 0);
|
||||
|
||||
return (
|
||||
<div className={cn("flex min-w-0 flex-1 items-center gap-3", className)}>
|
||||
@ -57,16 +64,7 @@ export const JobRowContent = ({
|
||||
|
||||
{hasScore && (
|
||||
<div className="shrink-0 text-right">
|
||||
<span
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-xs tabular-nums", suitabilityTone)}>
|
||||
{job.suitabilityScore}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -24,6 +24,12 @@ const bulkActionLabel: Record<BulkJobAction, string> = {
|
||||
rescore: "Calculating match scores...",
|
||||
};
|
||||
|
||||
const bulkActionSuccessLabel: Record<BulkJobAction, string> = {
|
||||
move_to_ready: "jobs moved to Ready",
|
||||
skip: "jobs skipped",
|
||||
rescore: "matches recalculated",
|
||||
};
|
||||
|
||||
interface UseBulkJobSelectionArgs {
|
||||
activeJobs: JobListItem[];
|
||||
activeTab: FilterTab;
|
||||
@ -222,12 +228,7 @@ export function useBulkJobSelection({
|
||||
|
||||
const result = finalResult as BulkJobActionResponse;
|
||||
const failedIds = getFailedJobIds(result);
|
||||
const successLabel =
|
||||
action === "skip"
|
||||
? "jobs skipped"
|
||||
: action === "move_to_ready"
|
||||
? "jobs moved to Ready"
|
||||
: "matches recalculated";
|
||||
const successLabel = bulkActionSuccessLabel[action];
|
||||
|
||||
if (result.failed === 0) {
|
||||
toast.success(`${result.succeeded} ${successLabel}`);
|
||||
|
||||
@ -37,6 +37,39 @@ export const LLM_PROVIDER_LABELS: Record<LlmProviderId, string> = {
|
||||
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(
|
||||
value: string | null | undefined,
|
||||
): LlmProviderId {
|
||||
@ -49,34 +82,15 @@ export function normalizeLlmProvider(
|
||||
|
||||
export function getLlmProviderConfig(provider: string | null | undefined) {
|
||||
const normalizedProvider = normalizeLlmProvider(provider);
|
||||
const showApiKey = ["openrouter", "openai", "gemini"].includes(
|
||||
normalizedProvider,
|
||||
);
|
||||
const showBaseUrl = ["lmstudio", "ollama"].includes(normalizedProvider);
|
||||
const baseUrlPlaceholder =
|
||||
normalizedProvider === "ollama"
|
||||
? "http://localhost:11434"
|
||||
: "http://localhost:1234";
|
||||
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";
|
||||
const showApiKey = PROVIDERS_WITH_API_KEY.has(normalizedProvider);
|
||||
const showBaseUrl = PROVIDERS_WITH_BASE_URL.has(normalizedProvider);
|
||||
const baseUrlPlaceholder = showBaseUrl
|
||||
? PROVIDER_BASE_URLS[normalizedProvider as BaseUrlProviderId]
|
||||
: "";
|
||||
const baseUrlHelper = showBaseUrl ? `Default: ${baseUrlPlaceholder}` : "";
|
||||
const providerHint = PROVIDER_HINTS[normalizedProvider];
|
||||
const keyHelper = PROVIDER_KEY_HELPERS[normalizedProvider];
|
||||
|
||||
return {
|
||||
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", () => {
|
||||
it("returns invalid when no credentials are provided and none in env", async () => {
|
||||
const res = await fetch(`${baseUrl}/api/onboarding/validate/rxresume`, {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { okWithMeta } from "@infra/http";
|
||||
import { logger } from "@infra/logger";
|
||||
import { getSetting } from "@server/repositories/settings";
|
||||
import { LlmService } from "@server/services/llm-service";
|
||||
import { RxResumeClient } from "@server/services/rxresume-client";
|
||||
@ -22,10 +23,32 @@ async function validateLlm(options: {
|
||||
provider?: string | null;
|
||||
baseUrl?: string | null;
|
||||
}): 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({
|
||||
apiKey: options.apiKey,
|
||||
provider: options.provider ?? undefined,
|
||||
baseUrl: options.baseUrl ?? undefined,
|
||||
apiKey: resolvedApiKey,
|
||||
provider: normalizedProvider,
|
||||
baseUrl: resolvedBaseUrl,
|
||||
});
|
||||
return llm.validateCredentials();
|
||||
}
|
||||
|
||||
@ -239,21 +239,14 @@ export function updateStageEvent(
|
||||
|
||||
const metadata = parseMetadata(lastEvent.metadata);
|
||||
const lastStage = lastEvent.toStage as ApplicationStage;
|
||||
const storedOutcome = (lastEvent.outcome as JobOutcome | null) ?? null;
|
||||
const inferredOutcome = inferOutcome(lastStage, metadata);
|
||||
const closingStage = isClosingStage(lastStage);
|
||||
const outcome =
|
||||
storedOutcome ??
|
||||
inferredOutcome ??
|
||||
(closingStage ? ((job.outcome as JobOutcome | null) ?? null) : null);
|
||||
const closedAt =
|
||||
lastStage === "closed"
|
||||
? lastEvent.occurredAt
|
||||
: outcome
|
||||
? storedOutcome || inferredOutcome
|
||||
? lastEvent.occurredAt
|
||||
: (job.closedAt ?? null)
|
||||
: null;
|
||||
const { outcome, closedAt } = resolveOutcomeAndClosedAt({
|
||||
lastStage,
|
||||
lastEventOccurredAt: lastEvent.occurredAt,
|
||||
metadata,
|
||||
lastEventOutcome: (lastEvent.outcome as JobOutcome | null) ?? null,
|
||||
jobOutcome: (job.outcome as JobOutcome | null) ?? null,
|
||||
jobClosedAt: job.closedAt ?? null,
|
||||
});
|
||||
|
||||
tx.update(jobs)
|
||||
.set({
|
||||
@ -298,21 +291,14 @@ export function deleteStageEvent(eventId: string): void {
|
||||
|
||||
const metadata = parseMetadata(lastEvent.metadata);
|
||||
const lastStage = lastEvent.toStage as ApplicationStage;
|
||||
const storedOutcome = (lastEvent.outcome as JobOutcome | null) ?? null;
|
||||
const inferredOutcome = inferOutcome(lastStage, metadata);
|
||||
const closingStage = isClosingStage(lastStage);
|
||||
const outcome =
|
||||
storedOutcome ??
|
||||
inferredOutcome ??
|
||||
(closingStage ? ((job.outcome as JobOutcome | null) ?? null) : null);
|
||||
const closedAt =
|
||||
lastStage === "closed"
|
||||
? lastEvent.occurredAt
|
||||
: outcome
|
||||
? storedOutcome || inferredOutcome
|
||||
? lastEvent.occurredAt
|
||||
: (job.closedAt ?? null)
|
||||
: null;
|
||||
const { outcome, closedAt } = resolveOutcomeAndClosedAt({
|
||||
lastStage,
|
||||
lastEventOccurredAt: lastEvent.occurredAt,
|
||||
metadata,
|
||||
lastEventOutcome: (lastEvent.outcome as JobOutcome | null) ?? null,
|
||||
jobOutcome: (job.outcome as JobOutcome | null) ?? null,
|
||||
jobClosedAt: job.closedAt ?? null,
|
||||
});
|
||||
|
||||
tx.update(jobs)
|
||||
.set({
|
||||
@ -364,3 +350,30 @@ function inferOutcome(
|
||||
function isClosingStage(toStage: ApplicationStage): boolean {
|
||||
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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
@ -22,7 +22,7 @@ export const geminiStrategy = createProviderStrategy({
|
||||
if (mode === "json_schema") {
|
||||
body.generationConfig = {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: jsonSchema.schema,
|
||||
responseSchema: toGeminiResponseSchema(jsonSchema.schema),
|
||||
};
|
||||
} else if (mode === "json_object") {
|
||||
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"]): {
|
||||
systemInstruction: { parts: Array<{ text: string }> } | null;
|
||||
contents: Array<{ role: "user" | "model"; parts: Array<{ text: string }> }>;
|
||||
|
||||
@ -134,4 +134,49 @@ describe("provider adapters", () => {
|
||||
}),
|
||||
).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) {
|
||||
try {
|
||||
const validationApiKey =
|
||||
this.provider === "gemini" ? null : this.apiKey;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: buildHeaders({
|
||||
apiKey: this.apiKey,
|
||||
apiKey: validationApiKey,
|
||||
provider: this.provider,
|
||||
}),
|
||||
});
|
||||
@ -147,15 +149,24 @@ export class LlmService {
|
||||
}
|
||||
|
||||
const detail = await getResponseDetail(response);
|
||||
if (response.status === 401) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
valid: false,
|
||||
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}`;
|
||||
} catch (error) {
|
||||
logger.warn("LLM credential validation request errored", {
|
||||
provider: this.provider,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
lastMessage =
|
||||
error instanceof Error ? error.message : "LLM validation failed.";
|
||||
}
|
||||
|
||||
@ -127,6 +127,16 @@ type SmartRouterResult = {
|
||||
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 = {
|
||||
index: number;
|
||||
id: string;
|
||||
@ -868,11 +878,11 @@ export async function runGmailIngestionSync(args: {
|
||||
const isAutoLinked = routerResult.confidence >= 95 && matchedJobId;
|
||||
const isPendingMatch = routerResult.confidence >= 50;
|
||||
const isRelevantOrphan = routerResult.isRelevant;
|
||||
const processingStatus = isAutoLinked
|
||||
? "auto_linked"
|
||||
: isPendingMatch || isRelevantOrphan
|
||||
? "pending_user"
|
||||
: "ignored";
|
||||
const processingStatus = resolveProcessingStatus({
|
||||
isAutoLinked: Boolean(isAutoLinked),
|
||||
isPendingMatch,
|
||||
isRelevantOrphan,
|
||||
});
|
||||
|
||||
const { message: savedMessage, autoLinkTransitioned } =
|
||||
await upsertPostApplicationMessage({
|
||||
|
||||
@ -2,7 +2,10 @@ import { logger } from "@infra/logger";
|
||||
import type { PostApplicationProviderActionResponse } from "@shared/types";
|
||||
import { toProviderAppError } from "./errors";
|
||||
import { resolvePostApplicationProvider } from "./registry";
|
||||
import type { ExecutePostApplicationProviderActionInput } from "./types";
|
||||
import type {
|
||||
ExecutePostApplicationProviderActionInput,
|
||||
PostApplicationProviderActionResult,
|
||||
} from "./types";
|
||||
|
||||
export async function executePostApplicationProviderAction(
|
||||
input: ExecutePostApplicationProviderActionInput,
|
||||
@ -10,27 +13,34 @@ export async function executePostApplicationProviderAction(
|
||||
const provider = resolvePostApplicationProvider(input.provider);
|
||||
|
||||
try {
|
||||
const result =
|
||||
input.action === "connect"
|
||||
? await provider.connect({
|
||||
let result: PostApplicationProviderActionResult;
|
||||
switch (input.action) {
|
||||
case "connect":
|
||||
result = await provider.connect({
|
||||
accountKey: input.accountKey,
|
||||
initiatedBy: input.initiatedBy,
|
||||
payload: input.connectPayload,
|
||||
})
|
||||
: input.action === "status"
|
||||
? await provider.status({
|
||||
});
|
||||
break;
|
||||
case "status":
|
||||
result = await provider.status({
|
||||
accountKey: input.accountKey,
|
||||
})
|
||||
: input.action === "sync"
|
||||
? await provider.sync({
|
||||
});
|
||||
break;
|
||||
case "sync":
|
||||
result = await provider.sync({
|
||||
accountKey: input.accountKey,
|
||||
initiatedBy: input.initiatedBy,
|
||||
payload: input.syncPayload,
|
||||
})
|
||||
: await provider.disconnect({
|
||||
});
|
||||
break;
|
||||
case "disconnect":
|
||||
result = await provider.disconnect({
|
||||
accountKey: input.accountKey,
|
||||
initiatedBy: input.initiatedBy,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
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