From c6160e083a308fe5ffa170bc0c4746094ece5930 Mon Sep 17 00:00:00 2001 From: t3-turbo-bot Date: Tue, 18 Aug 2026 18:32:29 -0400 Subject: [PATCH 1/2] fix(openrouter): re-read live settings on every status check Provider snapshot settings sources capture the driver config at create time: `makeProviderSnapshotSettingsSource` closes over the config it was seeded with, so every later refresh re-probed with the config the instance was born with. That is harmless for drivers whose credentials live outside settings, but OpenRouter keeps its API key in that config, so a key saved after boot never reached the probe. The provider stayed on "add an API key", the model catalog never refreshed past the fallback list, and the snapshot looked permanently stale. The status check now resolves the current config on each run, preferring an explicit `providerInstances` entry over the legacy `providers.openrouter` block (matching ProviderInstanceRegistryHydration), and rebuilds the OpenRouter-owned process env from it. Falls back to the create-time config when settings are unreadable or fail to decode. Adds persistence tests covering the schema round trip, whole-map replacement, and the instance-over-legacy precedence. Co-Authored-By: Claude Fable 5 --- .../src/provider/Drivers/OpenRouterDriver.ts | 31 ++++- .../provider/openrouter/OpenRouterRuntime.ts | 20 +++ .../OpenRouterSettingsPersistence.test.ts | 116 ++++++++++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/provider/openrouter/OpenRouterSettingsPersistence.test.ts diff --git a/apps/server/src/provider/Drivers/OpenRouterDriver.ts b/apps/server/src/provider/Drivers/OpenRouterDriver.ts index 9e2c3dd48697..93ad82adc47b 100644 --- a/apps/server/src/provider/Drivers/OpenRouterDriver.ts +++ b/apps/server/src/provider/Drivers/OpenRouterDriver.ts @@ -49,6 +49,7 @@ import { import { buildOpenRouterProcessEnv, OPENROUTER_DRIVER_KIND, + selectLiveOpenRouterConfig, toClaudeSettings, withOpenRouterAdapterIdentity, } from "../openrouter/OpenRouterRuntime.ts"; @@ -133,7 +134,35 @@ 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)), + ); + // `enabled` stays owned by the registry envelope, not the config blob. + return decoded === undefined ? effectiveConfig : { ...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..721a9392056b 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,24 @@ const OPENROUTER_OWNED_ENV_KEYS = [ "OR_APP_NAME", ] as const; +/** + * 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..03a7240dc2c5 --- /dev/null +++ b/apps/server/src/provider/openrouter/OpenRouterSettingsPersistence.test.ts @@ -0,0 +1,116 @@ +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 { 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.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); + }), + ); +}); From e939e29db65fb436bddbaa1d2dff80758cefd909 Mon Sep 17 00:00:00 2001 From: t3-turbo-bot Date: Tue, 18 Aug 2026 18:51:14 -0400 Subject: [PATCH 2/2] feat(openrouter): keep the API key in the secret store The key was only readable from `settings.json`, which is plain text, travels with settings exports, and is the same blob the UI rewrites wholesale. It now resolves from the server secret store (0600 files under the secrets dir), keyed per instance as `provider--api-key`, so several OpenRouter instances can hold different accounts. Resolution order: a key typed into settings still wins, so an explicit edit applies immediately; otherwise the stored secret is used. When settings do carry a key it is copied into the secret store, so entering one in the UI gives the credential a home outside the settings blob. The secret store is taken as an optional service, so the driver still builds in contexts that do not wire one (tests, minimal embeddings) and simply falls back to the settings key. Co-Authored-By: Claude Fable 5 --- .../src/provider/Drivers/OpenRouterDriver.ts | 53 ++++++++++++++++++- .../provider/openrouter/OpenRouterRuntime.ts | 29 ++++++++++ .../OpenRouterSettingsPersistence.test.ts | 32 ++++++++++- 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Drivers/OpenRouterDriver.ts b/apps/server/src/provider/Drivers/OpenRouterDriver.ts index 93ad82adc47b..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,8 @@ import { import { buildOpenRouterProcessEnv, OPENROUTER_DRIVER_KIND, + openRouterApiKeySecretName, + resolveOpenRouterApiKey, selectLiveOpenRouterConfig, toClaudeSettings, withOpenRouterAdapterIdentity, @@ -104,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); @@ -155,8 +201,11 @@ export const OpenRouterDriver: ProviderDriver Effect.succeed(undefined)), ); + if (decoded === undefined) { + return effectiveConfig; + } // `enabled` stays owned by the registry envelope, not the config blob. - return decoded === undefined ? effectiveConfig : { ...decoded, enabled }; + return yield* withStoredApiKey({ ...decoded, enabled }); }); const checkProvider = readLiveConfig.pipe( diff --git a/apps/server/src/provider/openrouter/OpenRouterRuntime.ts b/apps/server/src/provider/openrouter/OpenRouterRuntime.ts index 721a9392056b..f9bdccecc0b7 100644 --- a/apps/server/src/provider/openrouter/OpenRouterRuntime.ts +++ b/apps/server/src/provider/openrouter/OpenRouterRuntime.ts @@ -35,6 +35,35 @@ 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. * diff --git a/apps/server/src/provider/openrouter/OpenRouterSettingsPersistence.test.ts b/apps/server/src/provider/openrouter/OpenRouterSettingsPersistence.test.ts index 03a7240dc2c5..2cebbfa50069 100644 --- a/apps/server/src/provider/openrouter/OpenRouterSettingsPersistence.test.ts +++ b/apps/server/src/provider/openrouter/OpenRouterSettingsPersistence.test.ts @@ -10,7 +10,11 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { selectLiveOpenRouterConfig } from "./OpenRouterRuntime.ts"; +import { + openRouterApiKeySecretName, + resolveOpenRouterApiKey, + selectLiveOpenRouterConfig, +} from "./OpenRouterRuntime.ts"; const decodeSettings = Schema.decodeUnknownSync(ServerSettings); const decodePatch = Schema.decodeUnknownSync(ServerSettingsPatch); @@ -106,6 +110,32 @@ describe("OpenRouter settings persistence", () => { 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 });