diff --git a/orchestrator/src/server/api/routes/settings.test.ts b/orchestrator/src/server/api/routes/settings.test.ts index 6c9ee60..1713938 100644 --- a/orchestrator/src/server/api/routes/settings.test.ts +++ b/orchestrator/src/server/api/routes/settings.test.ts @@ -28,6 +28,7 @@ describe.sequential("Settings API routes", () => { expect(body.data.defaultModel).toBe("test-model"); expect(Array.isArray(body.data.searchTerms)).toBe(true); expect(body.data.rxresumeEmail).toBe("resume@example.com"); + expect(body.data.llmApiKeyHint).toBe("secr"); expect(body.data.openrouterApiKeyHint).toBe("secr"); expect(body.data.basicAuthActive).toBe(false); }); @@ -54,6 +55,7 @@ describe.sequential("Settings API routes", () => { expect(patchBody.data.searchTerms).toEqual(["engineer"]); expect(patchBody.data.overrideSearchTerms).toEqual(["engineer"]); expect(patchBody.data.rxresumeEmail).toBe("updated@example.com"); + expect(patchBody.data.llmApiKeyHint).toBe("upda"); expect(patchBody.data.openrouterApiKeyHint).toBe("upda"); }); diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index 452dcdb..09760a1 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -229,10 +229,19 @@ settingsRouter.patch("/", async (req: Request, res: Response) => { } if ("openrouterApiKey" in input) { + // @deprecated Use llmApiKey. Keep accepting this field for backwards compatibility. + console.warn( + "[DEPRECATED] Received openrouterApiKey update. Storing as llmApiKey and clearing legacy openrouterApiKey.", + ); const value = normalizeEnvInput(input.openrouterApiKey); promises.push( - settingsRepo.setSetting("openrouterApiKey", value).then(() => { - applyEnvValue("OPENROUTER_API_KEY", value); + settingsRepo.setSetting("llmApiKey", value).then(() => { + applyEnvValue("LLM_API_KEY", value); + }), + ); + promises.push( + settingsRepo.setSetting("openrouterApiKey", null).then(() => { + applyEnvValue("OPENROUTER_API_KEY", null); }), ); } diff --git a/orchestrator/src/server/services/ai-resilience.test.ts b/orchestrator/src/server/services/ai-resilience.test.ts index 4283780..e090377 100644 --- a/orchestrator/src/server/services/ai-resilience.test.ts +++ b/orchestrator/src/server/services/ai-resilience.test.ts @@ -82,11 +82,13 @@ const mockProfile = { name: "Test User" }; describe("AI Service Resilience", () => { beforeEach(() => { global.fetch = vi.fn(); + delete process.env.LLM_API_KEY; process.env.OPENROUTER_API_KEY = "mock-key"; // Ensure logic tries to call API }); afterEach(() => { global.fetch = globalFetch; + delete process.env.LLM_API_KEY; delete process.env.OPENROUTER_API_KEY; vi.restoreAllMocks(); }); diff --git a/orchestrator/src/server/services/envSettings.migration.test.ts b/orchestrator/src/server/services/envSettings.migration.test.ts new file mode 100644 index 0000000..4fc9ff7 --- /dev/null +++ b/orchestrator/src/server/services/envSettings.migration.test.ts @@ -0,0 +1,63 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const originalEnv = { ...process.env }; + +describe.sequential("envSettings migration", () => { + let tempDir: string; + let closeDb: (() => void) | null = null; + + beforeEach(async () => { + vi.resetModules(); + tempDir = await mkdtemp(join(tmpdir(), "job-ops-env-migration-test-")); + process.env = { + ...originalEnv, + DATA_DIR: tempDir, + NODE_ENV: "test", + MODEL: "test-model", + }; + + await import("../db/migrate.js"); + const dbMod = await import("../db/index.js"); + closeDb = dbMod.closeDb; + }); + + afterEach(async () => { + if (closeDb) closeDb(); + await rm(tempDir, { recursive: true, force: true }); + process.env = { ...originalEnv }; + }); + + it("migrates stored openrouterApiKey -> llmApiKey for openrouter provider", async () => { + const settingsRepo = await import("../repositories/settings.js"); + const { applyStoredEnvOverrides } = await import("./envSettings.js"); + + await settingsRepo.setSetting("llmProvider", "openrouter"); + await settingsRepo.setSetting("openrouterApiKey", "sk-or-legacy"); + await settingsRepo.setSetting("llmApiKey", null); + + await applyStoredEnvOverrides(); + + expect(await settingsRepo.getSetting("llmApiKey")).toBe("sk-or-legacy"); + expect(await settingsRepo.getSetting("openrouterApiKey")).toBe(null); + expect(process.env.LLM_API_KEY).toBe("sk-or-legacy"); + }); + + it("does not migrate openrouterApiKey when provider is not openrouter", async () => { + const settingsRepo = await import("../repositories/settings.js"); + const { applyStoredEnvOverrides } = await import("./envSettings.js"); + + await settingsRepo.setSetting("llmProvider", "openai"); + await settingsRepo.setSetting("openrouterApiKey", "sk-or-legacy"); + await settingsRepo.setSetting("llmApiKey", null); + + await applyStoredEnvOverrides(); + + expect(await settingsRepo.getSetting("llmApiKey")).toBe(null); + expect(await settingsRepo.getSetting("openrouterApiKey")).toBe( + "sk-or-legacy", + ); + }); +}); diff --git a/orchestrator/src/server/services/envSettings.ts b/orchestrator/src/server/services/envSettings.ts index 4d09264..611cb19 100644 --- a/orchestrator/src/server/services/envSettings.ts +++ b/orchestrator/src/server/services/envSettings.ts @@ -90,22 +90,63 @@ export function serializeEnvBoolean(value: boolean | null): string | null { } export async function applyStoredEnvOverrides(): Promise { + const overrides = await settingsRepo.getAllSettings(); + + // Migration: move legacy OpenRouter key to the unified LLM key. + // + // Users only see their API keys once. If we simply switch to LLM_API_KEY without + // copying, they may be unable to recover their existing key. + const effectiveProvider = (overrides.llmProvider ?? process.env.LLM_PROVIDER) + ?.trim() + .toLowerCase(); + const legacyOpenrouterKey = normalizeEnvInput(overrides.openrouterApiKey); + const unifiedKey = normalizeEnvInput(overrides.llmApiKey); + + if ( + (effectiveProvider ?? "openrouter") === "openrouter" && + legacyOpenrouterKey && + !unifiedKey + ) { + console.warn( + "[DEPRECATED] Detected stored OpenRouter API key. Migrating to LLM_API_KEY and clearing legacy storage.", + ); + await settingsRepo.setSetting("llmApiKey", legacyOpenrouterKey); + await settingsRepo.setSetting("openrouterApiKey", null); + overrides.llmApiKey = legacyOpenrouterKey; + delete overrides.openrouterApiKey; + } + + // Migration helper for env-based users: copy OPENROUTER_API_KEY -> LLM_API_KEY + // at runtime so the app keeps working after removing fallback logic. + if ( + (effectiveProvider ?? "openrouter") === "openrouter" && + !normalizeEnvInput(process.env.LLM_API_KEY) && + normalizeEnvInput(process.env.OPENROUTER_API_KEY) + ) { + console.warn( + "[DEPRECATED] OPENROUTER_API_KEY is deprecated. Copying to LLM_API_KEY for compatibility.", + ); + process.env.LLM_API_KEY = normalizeEnvInput( + process.env.OPENROUTER_API_KEY, + )!; + } + await Promise.all([ ...readableStringConfig.map(async ({ settingKey, envKey }) => { - const override = await settingsRepo.getSetting(settingKey); + const override = overrides[settingKey] ?? null; if (override === null) return; applyEnvValue(envKey, normalizeEnvInput(override)); }), ...readableBooleanConfig.map( async ({ settingKey, envKey, defaultValue }) => { - const override = await settingsRepo.getSetting(settingKey); + const override = overrides[settingKey] ?? null; if (override === null) return; const parsed = parseEnvBoolean(override, defaultValue); applyEnvValue(envKey, serializeEnvBoolean(parsed)); }, ), ...privateStringConfig.map(async ({ settingKey, envKey }) => { - const override = await settingsRepo.getSetting(settingKey); + const override = overrides[settingKey] ?? null; if (override === null) return; applyEnvValue(envKey, normalizeEnvInput(override)); }), @@ -144,6 +185,12 @@ export async function getEnvSettingsData( privateValues[hintKey] = rawValue.slice(0, hintLength); } + // Backwards-compat: old clients still expect openrouterApiKeyHint. + // Always prefer the unified LLM key hint when present. + if (privateValues.llmApiKeyHint) { + privateValues.openrouterApiKeyHint = privateValues.llmApiKeyHint; + } + const basicAuthUser = activeOverrides.basicAuthUser ?? process.env.BASIC_AUTH_USER; const basicAuthPassword = diff --git a/orchestrator/src/server/services/llm-service.test.ts b/orchestrator/src/server/services/llm-service.test.ts index 75f2282..681a722 100644 --- a/orchestrator/src/server/services/llm-service.test.ts +++ b/orchestrator/src/server/services/llm-service.test.ts @@ -69,6 +69,10 @@ describe("LlmService", () => { } as Response); const llm = new LlmService(); + + // Backwards-compat: OPENROUTER_API_KEY should be copied to LLM_API_KEY. + expect(process.env.LLM_API_KEY).toBe("test-api-key"); + const result = await llm.callJson<{ value: string; count: number }>({ model: "test-model", messages: [{ role: "user", content: "test" }], diff --git a/orchestrator/src/server/services/llm-service.ts b/orchestrator/src/server/services/llm-service.ts index c8722de..850cc2e 100644 --- a/orchestrator/src/server/services/llm-service.ts +++ b/orchestrator/src/server/services/llm-service.ts @@ -377,12 +377,27 @@ export class LlmService { const strategy = strategies[resolvedProvider]; const baseUrl = normalizedBaseUrl || strategy.defaultBaseUrl; - const apiKey = + let apiKey = normalizeEnvInput(options.apiKey) || normalizeEnvInput(process.env.LLM_API_KEY) || - (resolvedProvider === "openrouter" - ? normalizeEnvInput(process.env.OPENROUTER_API_KEY) - : null); + null; + + // Backwards-compat migration: OPENROUTER_API_KEY -> LLM_API_KEY. + // This prevents users from losing access when upgrading (keys are often only shown once). + if ( + !apiKey && + resolvedProvider === "openrouter" && + normalizeEnvInput(process.env.OPENROUTER_API_KEY) + ) { + console.warn( + "[DEPRECATED] OPENROUTER_API_KEY is deprecated. Copying to LLM_API_KEY; please update your environment.", + ); + const migrated = normalizeEnvInput(process.env.OPENROUTER_API_KEY); + if (migrated) { + process.env.LLM_API_KEY = migrated; + apiKey = migrated; + } + } this.provider = resolvedProvider; this.baseUrl = baseUrl; diff --git a/orchestrator/src/shared/settings-schema.ts b/orchestrator/src/shared/settings-schema.ts index 932a8b5..e631e13 100644 --- a/orchestrator/src/shared/settings-schema.ts +++ b/orchestrator/src/shared/settings-schema.ts @@ -61,6 +61,7 @@ export const updateSettingsSchema = z .optional(), jobspyLinkedinFetchDescription: z.boolean().nullable().optional(), showSponsorInfo: z.boolean().nullable().optional(), + /** @deprecated Use llmApiKey instead. */ openrouterApiKey: z.string().trim().max(2000).nullable().optional(), rxresumeEmail: z.string().trim().max(200).nullable().optional(), rxresumePassword: z.string().trim().max(2000).nullable().optional(), diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index fa94cb1..e872848 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -532,6 +532,7 @@ export interface AppSettings { defaultShowSponsorInfo: boolean; overrideShowSponsorInfo: boolean | null; llmApiKeyHint: string | null; + /** @deprecated Use llmApiKeyHint instead. */ openrouterApiKeyHint: string | null; rxresumeEmail: string | null; rxresumePasswordHint: string | null;