From 8eb66f8e2853b371a7ced32cbce6485b860dc5f3 Mon Sep 17 00:00:00 2001 From: HwangJohn Date: Tue, 14 Jul 2026 21:24:26 +0900 Subject: [PATCH] feat: support openrouter provider pinning --- README.md | 12 ++++++++++++ src/agent/index.ts | 4 ++++ src/constants.ts | 19 +++++++++++++++++++ src/env.ts | 3 +++ test/constants.test.ts | 28 ++++++++++++++++++++++++++++ 5 files changed, 66 insertions(+) diff --git a/README.md b/README.md index f0344609..a6d5b9d3 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,18 @@ refresh token like a password. 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 0f9455c4..deeb7da1 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -52,6 +52,7 @@ import { OPENWIKI_PROVIDER_RETRY_ATTEMPTS_ENV_KEY, providerRequiresBaseUrl, resolveConfiguredProvider, + resolveOpenRouterProviderOnly, resolveProviderBaseUrl, resolveProviderRetryAttempts, type OpenWikiProvider, @@ -499,10 +500,13 @@ 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 cb62841f..3abf927e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -17,6 +17,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 OPENWIKI_PROVIDER_ENV_KEY = "OPENWIKI_PROVIDER"; export const OPENWIKI_MODEL_ID_ENV_KEY = "OPENWIKI_MODEL_ID"; export const OPENWIKI_PROVIDER_RETRY_ATTEMPTS_ENV_KEY = @@ -365,6 +367,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 2db41781..0a568a6e 100644 --- a/src/env.ts +++ b/src/env.ts @@ -29,6 +29,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, @@ -86,6 +87,7 @@ export const MANAGED_ENV_KEYS = [ ANTHROPIC_API_KEY_ENV_KEY, ANTHROPIC_BASE_URL_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY, + OPENWIKI_OPENROUTER_PROVIDER_ONLY_ENV_KEY, OPENWIKI_PROVIDER_ENV_KEY, OPENWIKI_MODEL_ID_ENV_KEY, OPENWIKI_PROVIDER_RETRY_ATTEMPTS_ENV_KEY, @@ -265,6 +267,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 ); diff --git a/test/constants.test.ts b/test/constants.test.ts index bbcae399..1f87e0e9 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -11,6 +11,7 @@ import { normalizeModelId, normalizeProvider, resolveConfiguredProvider, + resolveOpenRouterProviderOnly, resolveProviderBaseUrl, resolveProviderRetryAttempts, } from "../src/constants.ts"; @@ -154,6 +155,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);