Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/spicy-llamas-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openwiki": minor
---

feat: cap OpenRouter output tokens with OPENWIKI_OPENROUTER_MAX_TOKENS to avoid 402 errors on low credit balances
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

</details>
Expand Down
3 changes: 3 additions & 0 deletions src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ import {
providerUsesExternalCliAuth,
providerUsesResponsesApi,
resolveConfiguredProvider,
resolveOpenRouterMaxTokens,
resolveOpenRouterProviderOnly,
resolveProviderBaseUrl,
resolveProviderLocation,
Expand Down Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
}
Expand Down
3 changes: 3 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ||
Expand Down
24 changes: 24 additions & 0 deletions test/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
providerRequiresSecretKey,
providerUsesAwsSdkCredentials,
resolveConfiguredProvider,
resolveOpenRouterMaxTokens,
resolveOpenRouterProviderOnly,
resolveProviderBaseUrl,
resolveProviderLocation,
Expand Down Expand Up @@ -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);
Expand Down
45 changes: 45 additions & 0 deletions test/create-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down