diff --git a/README.md b/README.md index 74f70b81..e89d6873 100644 --- a/README.md +++ b/README.md @@ -392,6 +392,18 @@ environment. Base URLs (and all credentials) can be set in your environment or stored in `~/.openwiki/.env`. +### OpenRouter provider pinning + +When OpenRouter serves a model through multiple upstream providers, set +`OPENWIKI_OPENROUTER_PROVIDER_ONLY` to restrict routing to one provider or a +comma-separated provider allowlist: + +```bash +OPENWIKI_PROVIDER=openrouter +OPENROUTER_API_KEY=your-key +OPENWIKI_OPENROUTER_PROVIDER_ONLY=Novita +``` + ### Provider retry attempts OpenWiki uses LangChain's built-in retry handling for transient provider errors. diff --git a/src/agent/index.ts b/src/agent/index.ts index d9f33f7e..49f2cc70 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -78,6 +78,7 @@ import { providerRequiresRegion, providerRequiresSecretKey, resolveConfiguredProvider, + resolveOpenRouterProviderOnly, resolveProviderBaseUrl, resolveProviderLocation, resolveProviderRegion, @@ -672,10 +673,13 @@ export function createModel( } if (provider === "openrouter") { + const providerOnly = resolveOpenRouterProviderOnly(); + return new ChatOpenRouter({ apiKey: process.env[OPENROUTER_API_KEY_ENV_KEY], baseURL: OPENROUTER_BASE_URL, model: modelId, + provider: providerOnly ? { only: providerOnly } : undefined, siteName: "OpenWiki", ...retryOptions, }); diff --git a/src/constants.ts b/src/constants.ts index c810ac8b..2c47fb84 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -18,6 +18,8 @@ export const OPENAI_CHATGPT_PLAN_ENV_KEY = "OPENAI_CHATGPT_PLAN"; export const ANTHROPIC_API_KEY_ENV_KEY = "ANTHROPIC_API_KEY"; export const ANTHROPIC_BASE_URL_ENV_KEY = "ANTHROPIC_BASE_URL"; export const OPENROUTER_API_KEY_ENV_KEY = "OPENROUTER_API_KEY"; +export const OPENWIKI_OPENROUTER_PROVIDER_ONLY_ENV_KEY = + "OPENWIKI_OPENROUTER_PROVIDER_ONLY"; export const BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY = "BEDROCK_AWS_ACCESS_KEY_ID"; export const BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY = "BEDROCK_AWS_SECRET_ACCESS_KEY"; @@ -591,6 +593,23 @@ export function resolveProviderRetryAttempts( return parsedRetryAttempts; } +export function resolveOpenRouterProviderOnly( + env: NodeJS.ProcessEnv = process.env, +): string[] | undefined { + const rawProviderOnly = env[OPENWIKI_OPENROUTER_PROVIDER_ONLY_ENV_KEY]; + + if (rawProviderOnly === undefined) { + return undefined; + } + + const providers = rawProviderOnly + .split(",") + .map((provider) => provider.trim()) + .filter((provider) => provider.length > 0); + + return providers.length > 0 ? providers : undefined; +} + export function normalizeModelId(value: string): string { return value.trim(); } diff --git a/src/env.ts b/src/env.ts index 7166c4f3..4b50e0c9 100644 --- a/src/env.ts +++ b/src/env.ts @@ -37,6 +37,7 @@ import { OPENWIKI_NOTION_MCP_REFRESH_TOKEN_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY, OPENWIKI_NOTION_TOKEN_ENV_KEY, + OPENWIKI_OPENROUTER_PROVIDER_ONLY_ENV_KEY, OPENWIKI_SLACK_BOT_TOKEN_ENV_KEY, OPENWIKI_SLACK_CLIENT_ID_ENV_KEY, OPENWIKI_SLACK_CLIENT_SECRET_ENV_KEY, @@ -100,6 +101,7 @@ export const MANAGED_ENV_KEYS = [ GOOGLE_CLOUD_LOCATION_ENV_KEY, GOOGLE_APPLICATION_CREDENTIALS_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY, + OPENWIKI_OPENROUTER_PROVIDER_ONLY_ENV_KEY, BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY, BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY, BEDROCK_AWS_REGION_ENV_KEY, @@ -372,6 +374,7 @@ function isNonSecretDiagnosticKey(key: string): boolean { key === OPENWIKI_MODEL_ID_ENV_KEY || key === OPENWIKI_PROVIDER_ENV_KEY || key === OPENWIKI_PROVIDER_RETRY_ATTEMPTS_ENV_KEY || + key === OPENWIKI_OPENROUTER_PROVIDER_ONLY_ENV_KEY || key === ANTHROPIC_BASE_URL_ENV_KEY || key === OPENAI_COMPATIBLE_BASE_URL_ENV_KEY || key === BEDROCK_AWS_REGION_ENV_KEY || diff --git a/test/constants.test.ts b/test/constants.test.ts index 3656b02f..bdcee408 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -20,6 +20,7 @@ import { providerRequiresRegion, providerRequiresSecretKey, resolveConfiguredProvider, + resolveOpenRouterProviderOnly, resolveProviderBaseUrl, resolveProviderLocation, resolveProviderRegion, @@ -200,6 +201,33 @@ describe("resolveProviderRetryAttempts", () => { }); }); +describe("resolveOpenRouterProviderOnly", () => { + test("returns undefined when no provider pin is configured", () => { + expect(resolveOpenRouterProviderOnly({})).toBeUndefined(); + expect( + resolveOpenRouterProviderOnly({ + OPENWIKI_OPENROUTER_PROVIDER_ONLY: " ", + }), + ).toBeUndefined(); + }); + + test("normalizes a single provider name", () => { + expect( + resolveOpenRouterProviderOnly({ + OPENWIKI_OPENROUTER_PROVIDER_ONLY: " Novita ", + }), + ).toEqual(["Novita"]); + }); + + test("normalizes a comma-separated provider allowlist", () => { + expect( + resolveOpenRouterProviderOnly({ + OPENWIKI_OPENROUTER_PROVIDER_ONLY: "Novita, Fireworks,, Together", + }), + ).toEqual(["Novita", "Fireworks", "Together"]); + }); +}); + describe("isValidBaseUrl", () => { test("accepts http and https URLs", () => { expect(isValidBaseUrl("https://api.example.com/v1")).toBe(true);