diff --git a/apps/server/src/provider/Drivers/OpenRouterDriver.ts b/apps/server/src/provider/Drivers/OpenRouterDriver.ts index 9e2c3dd48697..0d5bb7d548e9 100644 --- a/apps/server/src/provider/Drivers/OpenRouterDriver.ts +++ b/apps/server/src/provider/Drivers/OpenRouterDriver.ts @@ -12,11 +12,13 @@ import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; @@ -49,6 +51,9 @@ import { import { buildOpenRouterProcessEnv, OPENROUTER_DRIVER_KIND, + openRouterApiKeySecretName, + resolveOpenRouterApiKey, + selectLiveOpenRouterConfig, toClaudeSettings, withOpenRouterAdapterIdentity, } from "../openrouter/OpenRouterRuntime.ts"; @@ -103,9 +108,51 @@ export const OpenRouterDriver: ProviderDriver + Effect.gen(function* () { + const stored = Option.isNone(secretStore) + ? Option.none() + : yield* secretStore.value + .get(secretName) + .pipe(Effect.catchCause(() => Effect.succeed(Option.none()))); + const storedApiKey = Option.isSome(stored) + ? new TextDecoder().decode(stored.value) + : undefined; + const apiKey = resolveOpenRouterApiKey({ + settingsApiKey: candidate.apiKey, + storedApiKey, + }); + + // A key typed into settings lands in settings.json as plain text. + // Copy it into the secret store so the credential has a home that + // is not part of the settings blob; settings keep working either way. + if ( + Option.isSome(secretStore) && + apiKey.length > 0 && + apiKey !== (storedApiKey ?? "").trim() + ) { + yield* secretStore.value + .set(secretName, new TextEncoder().encode(apiKey)) + .pipe(Effect.ignore); + } + + return { ...candidate, apiKey } satisfies OpenRouterSettings; + }); + + const effectiveConfig = yield* withStoredApiKey({ + ...config, + enabled, + } satisfies OpenRouterSettings); // Build OpenRouter-owned process env once; pass through to adapter + probes. const processEnv = buildOpenRouterProcessEnv(effectiveConfig, baseEnv); const claudeSettings = toClaudeSettings(effectiveConfig); @@ -133,7 +180,38 @@ export const OpenRouterDriver: ProviderDriver Effect.succeed(undefined)), + ); + if (settings === undefined) { + return effectiveConfig; + } + const raw = selectLiveOpenRouterConfig(settings, instanceId); + if (raw === undefined) { + return effectiveConfig; + } + const decoded = yield* Schema.decodeUnknownEffect(OpenRouterSettings)(raw).pipe( + Effect.catchCause(() => Effect.succeed(undefined)), + ); + if (decoded === undefined) { + return effectiveConfig; + } + // `enabled` stays owned by the registry envelope, not the config blob. + return yield* withStoredApiKey({ ...decoded, enabled }); + }); + + const checkProvider = readLiveConfig.pipe( + Effect.flatMap((liveConfig) => + checkOpenRouterProviderStatus(liveConfig, buildOpenRouterProcessEnv(liveConfig, baseEnv)), + ), Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/provider/openrouter/OpenRouterRuntime.ts b/apps/server/src/provider/openrouter/OpenRouterRuntime.ts index 7527c21cdd0a..f9bdccecc0b7 100644 --- a/apps/server/src/provider/openrouter/OpenRouterRuntime.ts +++ b/apps/server/src/provider/openrouter/OpenRouterRuntime.ts @@ -2,7 +2,9 @@ import { ClaudeSettings, type OpenRouterSettings, ProviderDriverKind, + type ProviderInstanceId, type ProviderSession, + type ServerSettings, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -33,6 +35,53 @@ const OPENROUTER_OWNED_ENV_KEYS = [ "OR_APP_NAME", ] as const; +/** + * Secret-store key holding an instance's OpenRouter API key. + * + * The key is a credential, so the server prefers the secret store (0600 files + * in the secrets dir) over `settings.json`, which is world-readable on most + * setups and travels with settings exports. + */ +export function openRouterApiKeySecretName(instanceId: ProviderInstanceId): string { + return `provider-${instanceId}-api-key`; +} + +/** + * Resolve the effective API key for an instance. + * + * A key typed into settings still wins, so an explicit edit takes effect + * immediately; otherwise the stored secret is used. Both are trimmed, and a + * blank value on either side falls through to the next source. + */ +export function resolveOpenRouterApiKey(input: { + readonly settingsApiKey: string; + readonly storedApiKey: string | undefined; +}): string { + const fromSettings = input.settingsApiKey.trim(); + if (fromSettings.length > 0) { + return fromSettings; + } + return (input.storedApiKey ?? "").trim(); +} + +/** + * Pick the raw OpenRouter config blob the server should treat as current. + * + * Mirrors `ProviderInstanceRegistryHydration`: an explicit `providerInstances` + * entry wins over the legacy `providers.openrouter` block. Returns `undefined` + * when neither is present so callers can keep their existing config. + */ +export function selectLiveOpenRouterConfig( + settings: Pick, + instanceId: ProviderInstanceId, +): unknown { + const explicit = settings.providerInstances[instanceId]?.config; + if (explicit !== undefined && explicit !== null) { + return explicit; + } + return settings.providers.openrouter; +} + export function normalizeOpenRouterBaseUrl(baseUrl: string): string { const trimmed = baseUrl.trim(); const normalized = (trimmed.length > 0 ? trimmed : DEFAULT_OPENROUTER_BASE_URL).replace( diff --git a/apps/server/src/provider/openrouter/OpenRouterSettingsPersistence.test.ts b/apps/server/src/provider/openrouter/OpenRouterSettingsPersistence.test.ts new file mode 100644 index 000000000000..2cebbfa50069 --- /dev/null +++ b/apps/server/src/provider/openrouter/OpenRouterSettingsPersistence.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + defaultInstanceIdForDriver, + OpenRouterSettings, + ProviderDriverKind, + ProviderInstanceId, + ServerSettings, + ServerSettingsPatch, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + openRouterApiKeySecretName, + resolveOpenRouterApiKey, + selectLiveOpenRouterConfig, +} from "./OpenRouterRuntime.ts"; + +const decodeSettings = Schema.decodeUnknownSync(ServerSettings); +const decodePatch = Schema.decodeUnknownSync(ServerSettingsPatch); +const decodeOpenRouter = Schema.decodeUnknownSync(OpenRouterSettings); +const KIND = ProviderDriverKind.make("openrouter"); + +describe("OpenRouter settings persistence", () => { + it("keeps the api key when saved as an explicit instance", () => { + const instanceId = defaultInstanceIdForDriver(KIND); + const patch = decodePatch({ + providerInstances: { + [instanceId]: { driver: KIND, config: { apiKey: "sk-or-secret", enabled: true } }, + }, + }); + + const saved = decodeSettings({ providerInstances: patch.providerInstances }); + const entry = saved.providerInstances[instanceId]; + expect(entry).toBeDefined(); + expect(entry?.driver).toBe(KIND); + // The envelope keeps the blob opaque; the driver schema decodes it. + expect(decodeOpenRouter(entry?.config).apiKey).toBe("sk-or-secret"); + }); + + it("keeps the api key when saved to the legacy providers block", () => { + const saved = decodeSettings({ + providers: { openrouter: { apiKey: "sk-or-legacy", enabled: true } }, + }); + expect(saved.providers.openrouter.apiKey).toBe("sk-or-legacy"); + }); + + it("survives a full settings encode/decode round trip", () => { + const instanceId = defaultInstanceIdForDriver(KIND); + const saved = decodeSettings({ + providerInstances: { + [instanceId]: { driver: KIND, config: { apiKey: "sk-or-roundtrip" } }, + }, + }); + const wire = JSON.parse(JSON.stringify(Schema.encodeSync(ServerSettings)(saved))); + const back = decodeSettings(wire); + expect(decodeOpenRouter(back.providerInstances[instanceId]?.config).apiKey).toBe( + "sk-or-roundtrip", + ); + }); + + it("does not drop the key when an unrelated setting is patched", () => { + const instanceId = defaultInstanceIdForDriver(KIND); + const existing = decodeSettings({ + providerInstances: { + [instanceId]: { driver: KIND, config: { apiKey: "sk-or-keep" } }, + }, + }); + // The UI resends the whole map; simulate it editing another driver. + const patch = decodePatch({ + providerInstances: { + ...existing.providerInstances, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { enabled: true }, + }, + }, + }); + const next = decodeSettings({ providerInstances: patch.providerInstances }); + expect(decodeOpenRouter(next.providerInstances[instanceId]?.config).apiKey).toBe("sk-or-keep"); + }); + + it("reads a key saved after boot: explicit instance wins over legacy", () => { + const instanceId = defaultInstanceIdForDriver(KIND); + const settings = decodeSettings({ + providers: { openrouter: { apiKey: "sk-or-legacy" } }, + providerInstances: { + [instanceId]: { driver: KIND, config: { apiKey: "sk-or-instance" } }, + }, + }); + + const live = selectLiveOpenRouterConfig(settings, instanceId); + expect(decodeOpenRouter(live).apiKey).toBe("sk-or-instance"); + }); + + it("falls back to the legacy block when no instance entry exists", () => { + const instanceId = defaultInstanceIdForDriver(KIND); + const settings = decodeSettings({ providers: { openrouter: { apiKey: "sk-or-legacy" } } }); + expect(decodeOpenRouter(selectLiveOpenRouterConfig(settings, instanceId)).apiKey).toBe( + "sk-or-legacy", + ); + }); + + it("reports no live config when the user has configured nothing", () => { + const instanceId = defaultInstanceIdForDriver(KIND); + const settings = decodeSettings({}); + const live = selectLiveOpenRouterConfig(settings, instanceId); + // Legacy defaults decode to an empty key, which is what the status check + // reports as "add an API key" rather than silently probing. + expect(decodeOpenRouter(live).apiKey).toBe(""); + }); + + it("prefers a key typed into settings, else the stored secret", () => { + expect(resolveOpenRouterApiKey({ settingsApiKey: "sk-typed", storedApiKey: "sk-stored" })).toBe( + "sk-typed", + ); + expect(resolveOpenRouterApiKey({ settingsApiKey: "", storedApiKey: "sk-stored" })).toBe( + "sk-stored", + ); + expect(resolveOpenRouterApiKey({ settingsApiKey: " ", storedApiKey: "sk-stored" })).toBe( + "sk-stored", + ); + // Trailing newline is easy to introduce when writing the secret file. + expect(resolveOpenRouterApiKey({ settingsApiKey: "", storedApiKey: "sk-stored\n" })).toBe( + "sk-stored", + ); + expect(resolveOpenRouterApiKey({ settingsApiKey: "", storedApiKey: undefined })).toBe(""); + }); + + it("names the secret per instance so multiple accounts do not collide", () => { + expect(openRouterApiKeySecretName(defaultInstanceIdForDriver(KIND))).toBe( + "provider-openrouter-api-key", + ); + expect(openRouterApiKeySecretName(ProviderInstanceId.make("openrouter_work"))).toBe( + "provider-openrouter_work-api-key", + ); + }); + + it.effect("reports the key as configured to the status check", () => + Effect.gen(function* () { + const settings = decodeOpenRouter({ apiKey: "sk-or-live", enabled: true }); + expect(settings.apiKey).toBe("sk-or-live"); + expect(settings.enabled).toBe(true); + }), + ); +});