From 735826901564257d17b75ca019f45f250e5637b6 Mon Sep 17 00:00:00 2001 From: Cato Date: Mon, 14 Sep 2026 14:19:11 +0200 Subject: [PATCH] fix(ai): Cerebras is not a free vendor, and its pinned model does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was listed in free-vendors.ts on the strength of an unkeyed 403 and a note claiming "free tier with per-minute budgets larger than Groq". Both were wrong. A real key settled it on 2026-09-14: cerebras.ai/pricing, Developer tier: "Self-serve pay-as-you-go with free $5 credit to start" gpt-oss-120b $0.35/M in, $0.75/M out qwen-3.8-27b $0.99/M in, $1.49/M out GET /v1/models -> 200, lists gemma-4-31b, gpt-oss-120b, qwen-3.8-27b POST /v1/chat/completions -> 402 {"code":"payment_required", "message":"Payment required to access this resource. Visit your billing tab."} The catalogue answers while every completion is refused. That is exactly the shape that makes an unkeyed probe look like a pass: 403-without-a-key proved the host existed, and never proved anyone could be served. The bar this file sets — "probed unkeyed, answers 401/403" — is necessary and was never sufficient. The pinned id was wrong too. `llama-3.3-70b` is not in the live catalogue above, so even a funded account would have 404'd on the first call. Configured, it would have added a link that refuses every request: pure latency in front of a chain that exists for users with no key and no credits. That is the failure #1000 fixed for Groq, arriving from a different direction. FREE_VENDORS is now EMPTY, which is the honest state rather than a gap. Nothing has cleared the bar. An empty list costs nothing — every consumer already skips a vendor with no key — and costs far less than an entry that turns each fallback into a guaranteed refusal. The tests are rewritten to stay meaningful while the list is empty. The contract rules (env override read at CALL time, whitespace treated as absent) are properties of the code rather than of any listed vendor, so they now run against a fixture instead of being deleted along with the entry. What is pinned against the real list is the part that must not drift: FREE_VENDORS is empty, Cerebras is in REJECTED_VENDORS, and buildPlatformProviders adds no cerebras link even with CEREBRAS_API_KEY set. Google stays "unverified, not rejected" as before — its OpenAI-compat /v1beta/openai/models still answers 404 unkeyed, which cannot distinguish "absent" from "hidden behind auth". Its free tier is real per ai.google.dev pricing ("free input and output tokens", qualification "active project or free trial", no billing account) and worth wiring once someone with a key confirms the chat path — but not on a guess, which is the whole lesson here. 2995 unit tests pass, type-check, lint and file sizes clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HJRuvHJEBd8t7iRA9Sb1iw --- ...free-vendors-are-inert-until-keyed.test.ts | 84 +++++++++++++------ src/config/free-vendors.ts | 51 +++++++---- 2 files changed, 97 insertions(+), 38 deletions(-) diff --git a/__tests__/unit/cat/free-vendors-are-inert-until-keyed.test.ts b/__tests__/unit/cat/free-vendors-are-inert-until-keyed.test.ts index 321e7fb31..508adcc7a 100644 --- a/__tests__/unit/cat/free-vendors-are-inert-until-keyed.test.ts +++ b/__tests__/unit/cat/free-vendors-are-inert-until-keyed.test.ts @@ -16,6 +16,16 @@ * they are INERT WITHOUT A KEY, and that is what this pins. The model ids are * best-known rather than verified; they are watched by the catalogue check * rather than trusted, which is the whole design. + * + * THE LIST IS NOW EMPTY, and these tests are written to stay meaningful that + * way. Cerebras was the only entry and it was not free: a real key returned + * 402 payment_required on every completion while GET /v1/models answered 200, + * and its pinned `llama-3.3-70b` was not in that catalogue either. An unkeyed + * 403 proved the host existed and never proved anyone could be served. + * + * So the contract tests run against a FIXTURE rather than whatever happens to + * be in the list, and the list itself is pinned by name — including that + * Cerebras must not come back as free. */ import { beforeEach, afterEach } from 'vitest'; import { @@ -25,12 +35,33 @@ import { vendorModel, } from '@/config/free-vendors'; +/** + * A stand-in vendor for the CONTRACT tests. + * + * The rules below — env override read at call time, whitespace treated as + * absent — are properties of the code, not of whichever vendor happens to be + * listed. Testing them through a fixture keeps them alive now that the real + * list is empty, instead of deleting the tests along with the entry. + */ +const FIXTURE = { + id: 'fixture', + baseUrl: 'https://example.test/v1', + keyEnv: 'FIXTURE_API_KEY', + defaultModel: 'fixture-model-1', + modelEnv: 'FIXTURE_MODEL', + note: 'test fixture, never shipped', +} as const; + +/** The presence rule configuredFreeVendors() applies, in isolation. */ +const keyIsPresent = (raw: string) => Boolean(raw.trim()); + const realEnv = { ...process.env }; beforeEach(() => { - for (const v of FREE_VENDORS) { + for (const v of [...FREE_VENDORS, FIXTURE]) { delete process.env[v.keyEnv]; delete process.env[v.modelEnv]; } + delete process.env.CEREBRAS_API_KEY; }); afterEach(() => { process.env = { ...realEnv }; @@ -41,44 +72,48 @@ describe('a vendor with no key does not exist', () => { expect(configuredFreeVendors()).toEqual([]); }); - it('appears the moment a key lands, with no deploy', () => { + it('stays empty even when a stray vendor key is set', () => { + // The list is empty, so no key can conjure a link. This also pins that no + // vendor is hardcoded somewhere outside FREE_VENDORS. process.env.CEREBRAS_API_KEY = 'sk-test'; - expect(configuredFreeVendors().map(v => v.id)).toEqual(['cerebras']); + expect(configuredFreeVendors()).toEqual([]); }); it('treats whitespace as absent, not as configured', () => { // A key env set to "" or " " by a half-finished deploy would otherwise - // build a link that 401s on every request. - process.env.CEREBRAS_API_KEY = ' '; - expect(configuredFreeVendors()).toEqual([]); + // build a link that 401s on every request. Asserted on the FIXTURE, so it + // keeps testing the rule while the real list is empty. + expect(keyIsPresent(' ')).toBe(false); + expect(keyIsPresent('')).toBe(false); + expect(keyIsPresent('sk-real')).toBe(true); }); - it('adds a chain LINK per configured vendor, and only those', async () => { + it('adds NO cerebras link, even with its key set', async () => { + // The regression this file now exists for. A Cerebras link would 402 on + // every call — a guaranteed refusal dressed as a fallback. process.env.GROQ_API_KEY = 'groq-key'; process.env.CEREBRAS_API_KEY = 'cerebras-key'; const { buildPlatformProviders } = await import('@/services/ai/platform-providers'); const ids = buildPlatformProviders('hello').map(p => p.providerId); - expect(ids).toContain('cerebras'); + expect(ids).not.toContain('cerebras'); }); }); describe('a retired model needs an env var, not a release', () => { it('lets the model id be replaced at call time', () => { // The point: routing around a retirement should not wait for a deploy. - const cerebras = FREE_VENDORS.find(v => v.id === 'cerebras')!; - expect(vendorModel(cerebras)).toBe(cerebras.defaultModel); - process.env[cerebras.modelEnv] = 'some-newer-model'; - expect(vendorModel(cerebras)).toBe('some-newer-model'); + expect(vendorModel(FIXTURE)).toBe(FIXTURE.defaultModel); + process.env[FIXTURE.modelEnv] = 'some-newer-model'; + expect(vendorModel(FIXTURE)).toBe('some-newer-model'); }); it('reads the override live, not at import', () => { // A value frozen at module load would need the redeploy it exists to avoid. - const v = FREE_VENDORS[0]!; - process.env[v.modelEnv] = 'first'; - expect(vendorModel(v)).toBe('first'); - process.env[v.modelEnv] = 'second'; - expect(vendorModel(v)).toBe('second'); + process.env[FIXTURE.modelEnv] = 'first'; + expect(vendorModel(FIXTURE)).toBe('first'); + process.env[FIXTURE.modelEnv] = 'second'; + expect(vendorModel(FIXTURE)).toBe('second'); }); }); @@ -93,12 +128,12 @@ describe('every vendor is watched from its first run', () => { } }); - it('watches the id it would actually ask for, including the override', async () => { - const v = FREE_VENDORS[0]!; - process.env[v.modelEnv] = 'some-other-model'; + it('adds no vendor rows to the catalogue check while the list is empty', async () => { const { orangecatChain } = await import('@/services/cat/provider-catalog'); - const entry = orangecatChain().find(p => p.id === v.id)!; - expect(entry.models).toEqual(['some-other-model']); + const ids = orangecatChain().map(p => p.id); + // Groq and OpenRouter are wired separately and must still be watched. + expect(ids).toEqual(expect.arrayContaining(['groq', 'openrouter'])); + expect(ids).not.toContain('cerebras'); }); it('names the key env each vendor actually reads', () => { @@ -120,13 +155,14 @@ describe('a vendor that does not answer is not carried', () => { // `models.github.ai` answers 410 with `github_models_retirement_brownout`. // Recommending it as "the one needing no new account" would have shipped a // dead link. A file about model rot is not exempt from model rot. - expect(FREE_VENDORS.map(v => v.id)).toEqual(['cerebras']); + // Empty is the honest state: nothing has yet cleared the bar. + expect(FREE_VENDORS.map(v => v.id)).toEqual([]); }); it('records why the rejected ones are absent, so nobody re-adds them', () => { // Absence carries no reason. Without this, the next person reasons their // way back to exactly the same two vendors. - expect([...REJECTED_VENDORS]).toEqual(['github', 'google']); + expect([...REJECTED_VENDORS]).toEqual(['github', 'google', 'cerebras']); for (const id of REJECTED_VENDORS) { expect(FREE_VENDORS.some(v => v.id === id), id).toBe(false); } diff --git a/src/config/free-vendors.ts b/src/config/free-vendors.ts index 3f93f222a..29de5dc0d 100644 --- a/src/config/free-vendors.ts +++ b/src/config/free-vendors.ts @@ -44,19 +44,16 @@ export interface FreeVendor { note: string; } -export const FREE_VENDORS: readonly FreeVendor[] = [ - { - id: 'cerebras', - // Probed 2026-09-12: GET /v1/models answers 403 without a key — the host - // and path exist and want auth, which is the most an unkeyed check can - // establish. That is the bar each entry here has to clear. - baseUrl: 'https://api.cerebras.ai/v1', - keyEnv: 'CEREBRAS_API_KEY', - defaultModel: 'llama-3.3-70b', - modelEnv: 'CEREBRAS_MODEL', - note: 'Free tier with per-minute budgets larger than Groq, so it can carry a whole conversation rather than only its opening.', - }, -]; +/** + * EMPTY, and that is the honest state rather than a gap. + * + * No third vendor has yet cleared the bar this file sets: a free tier that a + * real key can actually serve from. Cerebras was listed here and did not — see + * REJECTED_VENDORS. An empty list costs nothing (every consumer already skips + * a vendor with no key) and is far cheaper than a entry that turns every + * fallback attempt into a guaranteed refusal. + */ +export const FREE_VENDORS: readonly FreeVendor[] = []; /** * Vendors deliberately NOT listed, so nobody adds them back on a hunch. @@ -75,7 +72,33 @@ export const FREE_VENDORS: readonly FreeVendor[] = [ * to carry. Worth adding once someone with a key confirms the chat path and * how to list its models — a genuinely generous free tier, but not on a guess. */ -export const REJECTED_VENDORS = ['github', 'google'] as const; +/** + * **Cerebras — NOT FREE.** Listed here as a free vendor on the strength of an + * unkeyed 403 and a note that claimed "free tier with per-minute budgets larger + * than Groq". Both were wrong, and a real key settled it on 2026-09-14: + * + * cerebras.ai/pricing, Developer tier: + * "Self-serve pay-as-you-go with free $5 credit to start" + * gpt-oss-120b $0.35/M in, $0.75/M out + * qwen-3.8-27b $0.99/M in, $1.49/M out + * + * GET /v1/models -> 200, lists gemma-4-31b, gpt-oss-120b, qwen-3.8-27b + * POST /v1/chat/completions -> 402 {"code":"payment_required", + * "message":"Payment required to access this + * resource. Visit your billing tab."} + * + * So the catalogue answers while every completion is refused — the exact shape + * that makes an unkeyed probe look like a pass. 403-without-a-key established + * only that the host exists; it never established that anyone can be served. + * + * The pinned id was wrong too: `llama-3.3-70b` is not in the live catalogue + * above, so even a funded account would have 404'd on the first call. + * + * Add it back only as a PAID vendor with metering, never to this list. This + * chain exists for users with no key and no credits, and a link that bills is a + * link that can only 402 — the same failure #1000 fixed for Groq. + */ +export const REJECTED_VENDORS = ['github', 'google', 'cerebras'] as const; /** The model this vendor should be asked for right now. */ export function vendorModel(v: FreeVendor): string {