Skip to content

Commit d2db978

Browse files
nirholasclaude
andcommitted
feat(llm): rotate across multiple OpenRouter keys when an account fails
OPENROUTER_FALLBACK_KEYS (comma-separated) adds backup OpenRouter accounts behind the primary key: - llm.js builds one provider entry per key, so the platform chain (tutor, fact-check, persona, agents) fails over key-by-key. Fallback keys pair with the model's :free variant — they are typically unfunded free-tier accounts where the paid model would always 402. - /api/chat/proxy retries the next key on account-level statuses (401/402/403/429) before any byte streams; request-shaped errors stay final. - Pricing strips the #n multi-key suffix so every OpenRouter key prices to zero on the spend dashboard. Also carries the in-flight free-first provider-tier refactor of llm.js (NVIDIA NIM tier, server Anthropic as opt-in last resort) whose tests are green. New fallback key validated live against the :free model and set in Vercel prod+preview via the REST API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f72760c commit d2db978

10 files changed

Lines changed: 267 additions & 76 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Public history for [three.ws](https://three.ws), newest first. New pages come fr
77
## 2026-06-11
88

99
- **Avatar pages back online after image-engine outage** — Fetching an individual avatar was failing with a server error, which also broke the walking avatar on the home page. The image-processing engine the avatar dress-up baker relies on failed to load in production and took the whole endpoint down with it. Avatar lookups now load independently of the baker, and the missing image libraries ship with the deployment, so avatar pages and the home hero are back. `[fix, infra]`
10+
- **Built-in AI stays up when a provider account runs dry** — The platform's built-in AI (chat, tutor, fact-checker, persona tools) now rotates across multiple OpenRouter accounts: when one account runs out of credits or hits a rate limit, the next takes over automatically, before any of the response has streamed. Paid x402 endpoints that previously returned errors during a provider outage now degrade to a backup account instead of failing. `[improvement, infra]`
1011
- **Faster, quieter agents directory and site-wide page polish** — The on-chain agents directory now loads from our server-side index instead of contacting every agent's metadata host from your browser — pages render faster and no longer spray network errors. Also: the pump.fun cockpit's sidebar pages are now shareable deep links, mobile nav and footer links meet touch-target guidelines, anonymous visitors no longer trigger failed sign-in requests on Walk and Create, and assorted dead links were removed. (`/agents`) `[improvement, fix]`
1112
- **Live $THREE market data, longer trade streams, and tutor session fixes** — $THREE market stats (price, holders, liquidity) are flowing again from our primary data source — a missing request header had been silently failing it to backups for days. Live trade streams no longer cut out after 30 seconds; they now run their full duration. The Pay-As-You-Learn Tutor no longer errors when resuming a session — running tabs persist correctly across questions — and Fact Checker results are cached properly so repeat checks of the same claim return instantly. `[fix, improvement]`
1213
- **Scheduled jobs restored after dependency outage** — Every background job — pump.fun monitoring, coin payouts, club payouts, scheduled X posts, DCA runs, subscription billing, and the rest — had been failing since a recent dependency upgrade left a required Solana staking library out of the deployment. The missing library now ships with every deploy, and the image-processing engine behind avatar baking was rolled back to its proven version so it loads reliably in production. All scheduled jobs run again. `[fix, infra]`

api/_lib/env.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,17 @@ export const env = {
728728
return opt('OPENROUTER_API_KEY');
729729
},
730730

731+
// Additional OpenRouter keys, comma-separated, tried in order after
732+
// OPENROUTER_API_KEY fails (credits exhausted, rate-limited, revoked).
733+
// Unfunded free-tier keys belong here: the llm.js failover pairs fallback
734+
// keys with the model's :free variant so they can still serve.
735+
get OPENROUTER_FALLBACK_KEYS() {
736+
return (opt('OPENROUTER_FALLBACK_KEYS') || '')
737+
.split(',')
738+
.map((k) => k.trim())
739+
.filter(Boolean);
740+
},
741+
731742
// Alibaba Cloud DashScope (international) — direct Qwen access. Used by
732743
// /api/brain/chat when the user selects a Qwen provider. Falls back to
733744
// OPENROUTER_API_KEY when unset.

api/_lib/llm-pricing.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
// without float drift. This is the single source of truth for "what did that
44
// call cost us" — the admin spend dashboard reads the events this priced.
55
//
6-
// Anthropic prices are list price per 1M tokens (input / output), current as of
7-
// the Claude model catalog. Groq and OpenRouter are platform-funded free tiers
8-
// (we hold the key, callers pay nothing), so their marginal cost to us is $0 —
9-
// they are intentionally priced at zero, not omitted, so the dashboard can show
10-
// "calls served free" alongside paid spend.
6+
// Anthropic and OpenAI prices are list price per 1M tokens (input / output),
7+
// current as of each vendor's model catalog. Groq, OpenRouter, and NVIDIA NIM
8+
// are platform-funded free tiers (we hold the key, callers pay nothing), so
9+
// their marginal cost to us is $0 — they are intentionally priced at zero, not
10+
// omitted, so the dashboard can show "calls served free" alongside paid spend.
1111

1212
// USD per 1,000,000 tokens, [input, output]. Keys are matched by prefix so a
1313
// dated alias (claude-haiku-4-5-20251001) resolves to its family price.
@@ -21,6 +21,8 @@ const PRICE_PER_MTOK = {
2121
'claude-sonnet-4-6': [3, 15],
2222
'claude-sonnet-4-5': [3, 15],
2323
'claude-haiku-4-5': [1, 5],
24+
'gpt-4o-mini': [0.15, 0.6],
25+
'gpt-4o': [2.5, 10],
2426
};
2527

2628
// Providers whose marginal cost to the platform is zero (platform-funded keys).
@@ -41,7 +43,9 @@ function priceForModel(model) {
4143
// or 0 when the provider is free or the model is unpriced — never null, so the
4244
// caller can always record a numeric cost.
4345
export function costMicroUsd({ provider, model, input = 0, output = 0 } = {}) {
44-
if (provider && FREE_PROVIDERS.has(provider)) return 0;
46+
// Multi-key providers carry a #n suffix (openrouter#2) — strip it so every
47+
// key of a free provider prices to zero.
48+
if (provider && FREE_PROVIDERS.has(String(provider).split('#')[0])) return 0;
4549
const price = priceForModel(model);
4650
if (!price) return 0;
4751
const [inPerM, outPerM] = price;

api/_lib/llm.js

Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,22 @@
22
//
33
// Policy (do not re-implement per endpoint — that is how this drifted before):
44
//
5-
// • Groq and OpenRouter are PLATFORM-FUNDED. The server holds those keys and
6-
// callers use them for free. They are the default providers, tried in order.
5+
// • FREE PROVIDERS FIRST, ALWAYS. Groq, OpenRouter, and NVIDIA NIM are
6+
// platform-funded free tiers — the server holds those keys and callers use
7+
// them at zero marginal cost. They form the default chain, tried in order,
8+
// and every flow must survive on them alone: the paid keys in prod are
9+
// routinely invalid or out of quota, so a chain that depends on them fails.
710
//
8-
// • Anthropic is used when the caller passes an explicit BYOK `anthropicKey`
9-
// (e.g. an agent owner's own key), OR when the caller opts in with
10-
// `serverAnthropic: true` and a server `ANTHROPIC_API_KEY` is configured.
11-
// Both are OFF by default: a caller that passes neither never touches
12-
// Anthropic, and no flow hard-fails when the key is absent — every flow
13-
// still degrades to the free providers.
11+
// • Paid server keys are the LAST-RESORT tier, automatically. When
12+
// ANTHROPIC_API_KEY or OPENAI_API_KEY is configured, those providers are
13+
// appended to the tail of EVERY chain so a request that exhausted the
14+
// free providers still succeeds instead of erroring. They never lead, and
15+
// no flow hard-fails when they are absent or out of quota.
16+
//
17+
// • BYOK is the one exception to free-first: a caller-supplied
18+
// `anthropicKey` (e.g. an agent owner's own key) leads the chain — that's
19+
// the caller's explicit model choice on the caller's own billing — still
20+
// degrading to the free chain on failure.
1421
//
1522
// Consolidated from the multi-provider fallback that already lived in
1623
// api/persona/extract.js and api/persona/preview.js.
@@ -21,12 +28,19 @@ import { costMicroUsd } from './llm-pricing.js';
2128

2229
const GROQ_MODEL = 'llama-3.3-70b-versatile';
2330
const OPENROUTER_MODEL = 'meta-llama/llama-3.3-70b-instruct';
31+
// Same Llama 3.3 70B family on NVIDIA NIM (build.nvidia.com) — one free nvapi
32+
// key, OpenAI-compatible, so the chain degrades across providers without
33+
// changing model behavior.
34+
const NVIDIA_MODEL = 'meta/llama-3.3-70b-instruct';
2435
const ANTHROPIC_MODEL = 'claude-haiku-4-5-20251001';
36+
// Paid last-resort tail (see policy above). Mini keeps the backstop cheap; the
37+
// repo-wide OpenAI default (api/_lib/chat-models.js) uses the same model.
38+
const OPENAI_MODEL = 'gpt-4o-mini';
2539

2640
// Thrown when no provider is available at all (no free key configured and no
2741
// BYOK key supplied). Carries an HTTP status so handlers can surface it as 503.
2842
export class LlmUnavailableError extends Error {
29-
constructor(message = 'No LLM provider available. Configure GROQ_API_KEY or OPENROUTER_API_KEY, or supply a BYOK Anthropic key.') {
43+
constructor(message = 'No LLM provider available. Configure GROQ_API_KEY, OPENROUTER_API_KEY, or NVIDIA_API_KEY (free), or ANTHROPIC_API_KEY / OPENAI_API_KEY (paid backstop), or supply a BYOK Anthropic key.') {
3044
super(message);
3145
this.name = 'LlmUnavailableError';
3246
this.code = 'llm_unavailable';
@@ -75,19 +89,18 @@ function openaiCompatProvider({ name, key, url, model, extraHeaders = {} }) {
7589
};
7690
}
7791

78-
// Build the ordered provider chain for a request. Anthropic leads when keyed —
79-
// a caller-supplied BYOK key always, or the server `ANTHROPIC_API_KEY` when the
80-
// caller opts in with `serverAnthropic: true`. After that come the free platform
81-
// providers (Groq, then OpenRouter).
82-
//
83-
// `serverAnthropic` is opt-in (default off) so the historical BYOK-only policy
84-
// is unchanged for existing callers: the platform never *depends* on a server
85-
// Anthropic key and still degrades to the free providers when it's absent. The
86-
// persona endpoints opt in to get Anthropic-first ordered failover.
87-
function providerChain({ anthropicKey, anthropicModel, serverAnthropic = false } = {}) {
92+
// Build the ordered provider chain for a request: free platform providers
93+
// first (Groq → OpenRouter keys → NVIDIA NIM), paid providers only at the
94+
// edges. A caller-supplied BYOK `anthropicKey` leads the chain — that's the
95+
// caller's explicit model choice on the caller's own billing — and still
96+
// degrades to the free chain on failure. The server ANTHROPIC_API_KEY and
97+
// OPENAI_API_KEY are appended LAST, automatically, as backstops after every
98+
// free provider: the prod paid keys are routinely invalid or out of quota, so
99+
// platform spend never leads and nothing depends on it — but when a key does
100+
// work, a request that exhausted the free tier still succeeds.
101+
function providerChain({ anthropicKey, anthropicModel } = {}) {
88102
const chain = [];
89103
if (anthropicKey) chain.push(anthropicProvider(anthropicKey, anthropicModel));
90-
else if (serverAnthropic && env.ANTHROPIC_API_KEY) chain.push(anthropicProvider(env.ANTHROPIC_API_KEY, anthropicModel));
91104
if (env.GROQ_API_KEY) {
92105
chain.push(openaiCompatProvider({
93106
name: 'groq',
@@ -96,14 +109,41 @@ function providerChain({ anthropicKey, anthropicModel, serverAnthropic = false }
96109
model: GROQ_MODEL,
97110
}));
98111
}
99-
if (env.OPENROUTER_API_KEY) {
112+
// One provider entry per OpenRouter key: when the primary account runs out
113+
// of credits (402) or hits a rate limit, the next key takes over. Fallback
114+
// keys are typically unfunded free-tier accounts, so they get the model's
115+
// :free variant — the paid model would 402 on them unconditionally.
116+
const openrouterKeys = [...new Set([env.OPENROUTER_API_KEY, ...env.OPENROUTER_FALLBACK_KEYS].filter(Boolean))];
117+
openrouterKeys.forEach((key, i) => {
100118
chain.push(openaiCompatProvider({
101-
name: 'openrouter',
102-
key: env.OPENROUTER_API_KEY,
119+
name: i === 0 ? 'openrouter' : `openrouter#${i + 1}`,
120+
key,
103121
url: 'https://openrouter.ai/api/v1/chat/completions',
104-
model: OPENROUTER_MODEL,
122+
model: i === 0 ? OPENROUTER_MODEL : `${OPENROUTER_MODEL}:free`,
105123
extraHeaders: { 'HTTP-Referer': 'https://three.ws', 'X-Title': 'three.ws' },
106124
}));
125+
});
126+
if (env.NVIDIA_API_KEY) {
127+
chain.push(openaiCompatProvider({
128+
name: 'nvidia',
129+
key: env.NVIDIA_API_KEY,
130+
url: 'https://integrate.api.nvidia.com/v1/chat/completions',
131+
model: NVIDIA_MODEL,
132+
}));
133+
}
134+
// Paid backstops — always appended, never leading. Server Anthropic is
135+
// skipped when a BYOK key already leads the chain (the caller chose their
136+
// own Claude billing; the platform doesn't re-buy the same model for them).
137+
if (!anthropicKey && env.ANTHROPIC_API_KEY) {
138+
chain.push(anthropicProvider(env.ANTHROPIC_API_KEY, anthropicModel));
139+
}
140+
if (env.OPENAI_API_KEY) {
141+
chain.push(openaiCompatProvider({
142+
name: 'openai',
143+
key: env.OPENAI_API_KEY,
144+
url: 'https://api.openai.com/v1/chat/completions',
145+
model: OPENAI_MODEL,
146+
}));
107147
}
108148
return chain;
109149
}
@@ -132,8 +172,8 @@ export function llmConfigured(opts = {}) {
132172
// { userId, agentId, avatarId, clientId, apiKeyId, tool } — are all optional;
133173
// pass whatever the call site knows. Recording is fire-and-forget (see
134174
// recordEvent), so it never delays or fails the completion.
135-
export async function llmComplete({ system, user, maxTokens = 1024, anthropicKey = null, anthropicModel = null, serverAnthropic = false, timeoutMs = 30_000, track = null }) {
136-
const chain = providerChain({ anthropicKey, anthropicModel, serverAnthropic });
175+
export async function llmComplete({ system, user, maxTokens = 1024, anthropicKey = null, anthropicModel = null, timeoutMs = 30_000, track = null }) {
176+
const chain = providerChain({ anthropicKey, anthropicModel });
137177
if (!chain.length) throw new LlmUnavailableError();
138178

139179
let lastErr;

api/chat/proxy.js

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export default wrap(async (req, res) => {
88
if (cors(req, res, { methods: 'POST,OPTIONS' })) return;
99
if (!method(req, res, ['POST'])) return;
1010

11-
if (!env.OPENROUTER_API_KEY)
11+
if (!env.OPENROUTER_API_KEY && !env.OPENROUTER_FALLBACK_KEYS.length)
1212
return error(res, 503, 'not_configured', 'Built-in model not available');
1313

1414
// Anonymous proxy — only :free models pass the gate below, but the upstream
@@ -33,16 +33,28 @@ export default wrap(async (req, res) => {
3333
if (!model || !model.endsWith(':free'))
3434
return error(res, 400, 'invalid_model', 'Only free-tier models (ending in :free) are allowed via the built-in proxy');
3535

36-
const upstream = await fetch('https://openrouter.ai/api/v1/chat/completions', {
37-
method: 'POST',
38-
headers: {
39-
Authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
40-
'Content-Type': 'application/json',
41-
'HTTP-Referer': 'https://three.ws',
42-
'X-Title': 'three.ws chat',
43-
},
44-
body: JSON.stringify(body),
45-
});
36+
// Only :free models pass the gate above, so any configured key can serve the
37+
// request. Rotate to the next key on account-level failures (bad key, out of
38+
// credits, rate limit) — rotation happens before any byte is streamed, so a
39+
// retry is always safe. Other statuses (4xx from a bad request, 5xx) are
40+
// final: every key would fail the same way.
41+
const keys = [...new Set([env.OPENROUTER_API_KEY, ...env.OPENROUTER_FALLBACK_KEYS].filter(Boolean))];
42+
let upstream;
43+
for (const [i, key] of keys.entries()) {
44+
upstream = await fetch('https://openrouter.ai/api/v1/chat/completions', {
45+
method: 'POST',
46+
headers: {
47+
Authorization: `Bearer ${key}`,
48+
'Content-Type': 'application/json',
49+
'HTTP-Referer': 'https://three.ws',
50+
'X-Title': 'three.ws chat',
51+
},
52+
body: JSON.stringify(body),
53+
});
54+
if (![401, 402, 403, 429].includes(upstream.status) || i === keys.length - 1) break;
55+
// Release the abandoned response so its connection returns to the pool.
56+
await upstream.body?.cancel()?.catch?.(() => {});
57+
}
4658

4759
if (upstream.status === 402) {
4860
const upstreamBody = await upstream.text();

data/changelog.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
{
22
"$comment": "Curated changelog entries for three.ws — the editorial layer of the public changelog. New PAGE launches are tracked automatically via the `added` field in data/pages.json; this file is for everything else holders care about: improvements, fixes, SDK releases, trading/payments capabilities, security work. scripts/build-page-index.mjs merges both sources into CHANGELOG.md, public/changelog.json, and public/changelog.xml. Newest first. Allowed tags: feature, improvement, fix, sdk, infra, docs, security. `link` is optional and must be a live three.ws path.",
33
"entries": [
4+
{
5+
"date": "2026-06-11",
6+
"title": "Built-in AI stays up when a provider account runs dry",
7+
"summary": "The platform's built-in AI (chat, tutor, fact-checker, persona tools) now rotates across multiple OpenRouter accounts: when one account runs out of credits or hits a rate limit, the next takes over automatically, before any of the response has streamed. Paid x402 endpoints that previously returned errors during a provider outage now degrade to a backup account instead of failing.",
8+
"tags": [
9+
"improvement",
10+
"infra"
11+
]
12+
},
413
{
514
"date": "2026-06-11",
615
"title": "Live $THREE market data, longer trade streams, and tutor session fixes",

public/changelog.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"generated_at": "2026-06-11T03:28:03.199Z",
2+
"generated_at": "2026-06-11T04:19:59.255Z",
33
"generated_by": "scripts/build-page-index.mjs from data/pages.json + data/changelog.json",
44
"site": {
55
"name": "three.ws",
@@ -17,6 +17,17 @@
1717
"infra"
1818
]
1919
},
20+
{
21+
"date": "2026-06-11",
22+
"type": "update",
23+
"title": "Built-in AI stays up when a provider account runs dry",
24+
"summary": "The platform's built-in AI (chat, tutor, fact-checker, persona tools) now rotates across multiple OpenRouter accounts: when one account runs out of credits or hits a rate limit, the next takes over automatically, before any of the response has streamed. Paid x402 endpoints that previously returned errors during a provider outage now degrade to a backup account instead of failing.",
25+
"link": null,
26+
"tags": [
27+
"improvement",
28+
"infra"
29+
]
30+
},
2031
{
2132
"date": "2026-06-11",
2233
"type": "update",

public/changelog.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@
1414
<category>fix</category>
1515
<description>Fetching an individual avatar was failing with a server error, which also broke the walking avatar on the home page. The image-processing engine the avatar dress-up baker relies on failed to load in production and took the whole endpoint down with it. Avatar lookups now load independently of the baker, and the missing image libraries ship with the deployment, so avatar pages and the home hero are back.</description>
1616
</item>
17+
<item>
18+
<title>Built-in AI stays up when a provider account runs dry</title>
19+
<link>https://three.ws/changelog</link>
20+
<guid isPermaLink="false">2026-06-11:Built-in AI stays up when a provider account runs dry</guid>
21+
<pubDate>Thu, 11 Jun 2026 12:00:00 GMT</pubDate>
22+
<category>improvement</category>
23+
<description>The platform's built-in AI (chat, tutor, fact-checker, persona tools) now rotates across multiple OpenRouter accounts: when one account runs out of credits or hits a rate limit, the next takes over automatically, before any of the response has streamed. Paid x402 endpoints that previously returned errors during a provider outage now degrade to a backup account instead of failing.</description>
24+
</item>
1725
<item>
1826
<title>Faster, quieter agents directory and site-wide page polish</title>
1927
<link>https://three.ws/agents</link>

public/features.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"generated_at": "2026-06-11T03:28:03.189Z",
2+
"generated_at": "2026-06-11T04:19:59.243Z",
33
"generated_by": "scripts/build-page-index.mjs from data/pages.json",
44
"site": {
55
"name": "three.ws",

0 commit comments

Comments
 (0)