Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 80 additions & 2 deletions apps/server/src/provider/Drivers/OpenRouterDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -49,6 +51,9 @@ import {
import {
buildOpenRouterProcessEnv,
OPENROUTER_DRIVER_KIND,
openRouterApiKeySecretName,
resolveOpenRouterApiKey,
selectLiveOpenRouterConfig,
toClaudeSettings,
withOpenRouterAdapterIdentity,
} from "../openrouter/OpenRouterRuntime.ts";
Expand Down Expand Up @@ -103,9 +108,51 @@ export const OpenRouterDriver: ProviderDriver<OpenRouterSettings, OpenRouterDriv
const path = yield* Path.Path;
const httpClient = yield* HttpClient.HttpClient;
const serverSettings = yield* ServerSettingsService;
// Optional so the driver still builds in contexts that do not wire a
// secret store (tests, minimal embeddings); those simply fall back to
// whatever key settings carry.
const secretStore = yield* Effect.serviceOption(ServerSecretStore);
const eventLoggers = yield* ProviderEventLoggers;
const baseEnv = mergeProviderInstanceEnvironment(environment);
const effectiveConfig = { ...config, enabled } satisfies OpenRouterSettings;

// The API key is a credential: prefer the secret store over settings.json.
// A key typed into settings still wins so an explicit edit applies at once.
const secretName = openRouterApiKeySecretName(instanceId);
const withStoredApiKey = (candidate: OpenRouterSettings) =>
Effect.gen(function* () {
const stored = Option.isNone(secretStore)
? Option.none<Uint8Array>()
: yield* secretStore.value
.get(secretName)
.pipe(Effect.catchCause(() => Effect.succeed(Option.none<Uint8Array>())));
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);
Expand Down Expand Up @@ -133,7 +180,38 @@ export const OpenRouterDriver: ProviderDriver<OpenRouterSettings, OpenRouterDriv
);
const textGeneration = yield* makeClaudeTextGeneration(claudeSettings, processEnv);

const checkProvider = checkOpenRouterProviderStatus(effectiveConfig, processEnv).pipe(
// Snapshot settings sources capture the driver config at create time, so
// every later status check would re-probe with the config this instance
// was born with. For OpenRouter the API key lives in that config, so a
// key saved after boot never reached the probe: the provider sat on
// "add an API key" and the model catalog never refreshed. Re-read the
// live config on each check instead, and rebuild the OpenRouter-owned
// env from it.
const readLiveConfig = Effect.gen(function* () {
const settings = yield* serverSettings.getSettings.pipe(
Effect.catchCause(() => 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),
Expand Down
49 changes: 49 additions & 0 deletions apps/server/src/provider/openrouter/OpenRouterRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ServerSettings, "providerInstances" | "providers">,
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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}),
);
});
Loading