From 3662576235df3fe29203d94cfe1f9e6a4cedd233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Twardziak?= Date: Tue, 4 Aug 2026 23:58:20 +0200 Subject: [PATCH] feat(agent): support capping OpenRouter output tokens via OPENWIKI_OPENROUTER_MAX_TOKENS Without an explicit max_tokens, OpenRouter's credit pre-check budgets for the model's full advertised output ceiling and rejects requests with 402 when the balance cannot cover that worst case. Let users cap per-request output tokens with a validated env var, following the existing OPENWIKI_OPENROUTER_PROVIDER_ONLY pattern. Fixes #588 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012eivpomJ5GqQU6HwcYgb3b --- .changeset/spicy-llamas-shout.md | 5 ++++ README.md | 8 ++++++ src/agent/index.ts | 3 +++ src/constants.ts | 35 +++++++++++++++++++++++++ src/env.ts | 3 +++ test/constants.test.ts | 24 +++++++++++++++++ test/create-model.test.ts | 45 ++++++++++++++++++++++++++++++++ 7 files changed, 123 insertions(+) create mode 100644 .changeset/spicy-llamas-shout.md diff --git a/.changeset/spicy-llamas-shout.md b/.changeset/spicy-llamas-shout.md new file mode 100644 index 00000000..627ef885 --- /dev/null +++ b/.changeset/spicy-llamas-shout.md @@ -0,0 +1,5 @@ +--- +"openwiki": minor +--- + +feat: cap OpenRouter output tokens with OPENWIKI_OPENROUTER_MAX_TOKENS to avoid 402 errors on low credit balances diff --git a/README.md b/README.md index 9520a702..69842c11 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,14 @@ OPENROUTER_API_KEY=your-key OPENWIKI_OPENROUTER_PROVIDER_ONLY=Novita ``` +**OpenRouter output-token cap.** By default no `max_tokens` is sent, so OpenRouter's credit pre-check budgets for the model's full advertised output ceiling — on a low credit balance every request can fail with a 402 error. Cap the per-request output explicitly with: + +```bash +OPENWIKI_OPENROUTER_MAX_TOKENS=8192 +``` + +A cap trades those hard 402 failures for possible truncation when a long wiki generation genuinely needs more output tokens, so prefer the largest value your balance allows. + **Retry attempts.** OpenWiki uses LangChain's retry handling for transient provider errors. Override the retry count (default 3) with `OPENWIKI_PROVIDER_RETRY_ATTEMPTS=3` (a positive integer). diff --git a/src/agent/index.ts b/src/agent/index.ts index dd028088..94ecbc19 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -115,6 +115,7 @@ import { providerUsesExternalCliAuth, providerUsesResponsesApi, resolveConfiguredProvider, + resolveOpenRouterMaxTokens, resolveOpenRouterProviderOnly, resolveProviderBaseUrl, resolveProviderLocation, @@ -1104,11 +1105,13 @@ export function createModel( if (provider === "openrouter") { const providerOnly = resolveOpenRouterProviderOnly(); + const maxTokens = resolveOpenRouterMaxTokens(); return new ChatOpenRouter({ apiKey: process.env[OPENROUTER_API_KEY_ENV_KEY], baseURL: OPENROUTER_BASE_URL, model: modelId, + ...(maxTokens !== undefined ? { maxTokens } : {}), provider: providerOnly ? { only: providerOnly } : undefined, siteName: "OpenWiki", ...retryOptions, diff --git a/src/constants.ts b/src/constants.ts index d7475193..076e9ee5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -27,6 +27,8 @@ 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_OPENROUTER_MAX_TOKENS_ENV_KEY = + "OPENWIKI_OPENROUTER_MAX_TOKENS"; 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"; @@ -908,6 +910,39 @@ export function resolveOpenRouterProviderOnly( return providers.length > 0 ? providers : undefined; } +// Caps per-request output tokens for OpenRouter. Without a cap, OpenRouter's +// credit pre-check budgets for the model's full advertised output ceiling and +// rejects the request with 402 when the balance can't cover that worst case. +// Setting a cap trades those hard 402 failures for possible truncation +// (finish_reason "length") when a generation genuinely needs more tokens. +export function resolveOpenRouterMaxTokens( + env: NodeJS.ProcessEnv = process.env, +): number | undefined { + const rawMaxTokens = env[OPENWIKI_OPENROUTER_MAX_TOKENS_ENV_KEY]; + + if (rawMaxTokens === undefined) { + return undefined; + } + + const maxTokens = rawMaxTokens.trim(); + + if (!/^[1-9]\d*$/u.test(maxTokens)) { + throw new Error( + `Invalid ${OPENWIKI_OPENROUTER_MAX_TOKENS_ENV_KEY}. Expected a positive integer.`, + ); + } + + const parsedMaxTokens = Number(maxTokens); + + if (!Number.isSafeInteger(parsedMaxTokens)) { + throw new Error( + `Invalid ${OPENWIKI_OPENROUTER_MAX_TOKENS_ENV_KEY}. Expected a positive integer.`, + ); + } + + return parsedMaxTokens; +} + export function normalizeModelId(value: string): string { return value.trim(); } diff --git a/src/env.ts b/src/env.ts index 3da57f5c..b12b5c39 100644 --- a/src/env.ts +++ b/src/env.ts @@ -45,6 +45,7 @@ import { OPENWIKI_NOTION_MCP_REFRESH_TOKEN_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY, OPENWIKI_NOTION_TOKEN_ENV_KEY, + OPENWIKI_OPENROUTER_MAX_TOKENS_ENV_KEY, OPENWIKI_OPENROUTER_PROVIDER_ONLY_ENV_KEY, OPENWIKI_SLACK_BOT_TOKEN_ENV_KEY, OPENWIKI_SLACK_CLIENT_ID_ENV_KEY, @@ -115,6 +116,7 @@ export const MANAGED_ENV_KEYS = [ GOOGLE_CLOUD_LOCATION_ENV_KEY, GOOGLE_APPLICATION_CREDENTIALS_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY, + OPENWIKI_OPENROUTER_MAX_TOKENS_ENV_KEY, OPENWIKI_OPENROUTER_PROVIDER_ONLY_ENV_KEY, BEDROCK_AWS_ACCESS_KEY_ID_ENV_KEY, BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY, @@ -442,6 +444,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_MAX_TOKENS_ENV_KEY || key === OPENWIKI_OPENROUTER_PROVIDER_ONLY_ENV_KEY || key === ANTHROPIC_BASE_URL_ENV_KEY || key === BASETEN_BASE_URL_ENV_KEY || diff --git a/test/constants.test.ts b/test/constants.test.ts index a1f98a96..870c1fdd 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -29,6 +29,7 @@ import { providerRequiresSecretKey, providerUsesAwsSdkCredentials, resolveConfiguredProvider, + resolveOpenRouterMaxTokens, resolveOpenRouterProviderOnly, resolveProviderBaseUrl, resolveProviderLocation, @@ -311,6 +312,29 @@ describe("resolveOpenRouterProviderOnly", () => { }); }); +describe("resolveOpenRouterMaxTokens", () => { + test("returns undefined when no cap is configured", () => { + expect(resolveOpenRouterMaxTokens({})).toBeUndefined(); + }); + + test("parses a positive integer cap", () => { + expect( + resolveOpenRouterMaxTokens({ OPENWIKI_OPENROUTER_MAX_TOKENS: "4096" }), + ).toBe(4096); + expect( + resolveOpenRouterMaxTokens({ OPENWIKI_OPENROUTER_MAX_TOKENS: " 512 " }), + ).toBe(512); + }); + + test("rejects zero, negative, fractional, and non-numeric values", () => { + for (const value of ["0", "-1", "1.5", "abc", "", " ", "1e3", "0x10"]) { + expect(() => + resolveOpenRouterMaxTokens({ OPENWIKI_OPENROUTER_MAX_TOKENS: value }), + ).toThrow(/OPENWIKI_OPENROUTER_MAX_TOKENS/u); + } + }); +}); + describe("isValidBaseUrl", () => { test("accepts http and https URLs", () => { expect(isValidBaseUrl("https://api.example.com/v1")).toBe(true); diff --git a/test/create-model.test.ts b/test/create-model.test.ts index 50816767..fbbbfc49 100644 --- a/test/create-model.test.ts +++ b/test/create-model.test.ts @@ -156,6 +156,51 @@ describe("createModel OpenAI-compatible transport selection", () => { }); }); +describe("createModel openrouter output-token cap", () => { + const OPENROUTER_KEY = "OPENROUTER_API_KEY"; + const MAX_TOKENS_KEY = "OPENWIKI_OPENROUTER_MAX_TOKENS"; + let savedApiKey: string | undefined; + let savedMaxTokens: string | undefined; + + beforeEach(() => { + savedApiKey = process.env[OPENROUTER_KEY]; + savedMaxTokens = process.env[MAX_TOKENS_KEY]; + process.env[OPENROUTER_KEY] = "test-key"; + delete process.env[MAX_TOKENS_KEY]; + }); + + afterEach(() => { + restoreEnv(OPENROUTER_KEY, savedApiKey); + restoreEnv(MAX_TOKENS_KEY, savedMaxTokens); + }); + + test("leaves maxTokens unset by default", () => { + const model = createModel("openrouter", "z-ai/glm-4.7-flash", 0) as { + maxTokens?: number; + }; + + expect(model.maxTokens).toBeUndefined(); + }); + + test("passes the configured cap through to ChatOpenRouter", () => { + process.env[MAX_TOKENS_KEY] = "4096"; + + const model = createModel("openrouter", "z-ai/glm-4.7-flash", 0) as { + maxTokens?: number; + }; + + expect(model.maxTokens).toBe(4096); + }); + + test("rejects an invalid cap with a clear error", () => { + process.env[MAX_TOKENS_KEY] = "lots"; + + expect(() => createModel("openrouter", "z-ai/glm-4.7-flash", 0)).toThrow( + /OPENWIKI_OPENROUTER_MAX_TOKENS/u, + ); + }); +}); + function restoreEnv(key: string, value: string | undefined): void { if (value === undefined) { delete process.env[key];