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:
Shaheer Sarfaraz 2026-02-20 00:01:34 +00:00 committed by GitHub
parent 3640abef2d
commit eed5c2adba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 532 additions and 206 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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", () => {

View File

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

View File

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

View File

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

View File

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

View File

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

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