Skip to content
Merged
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
13 changes: 13 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ const eslintConfig = [
"node_modules/**",
"examples/**",
"packages/**",
// Per-session agent worktrees are complete checkouts of this repo living
// inside it, so without this every file gets linted twice: once here and
// once per worktree. Measured 2026-08-27 — `npx eslint .` reported 641
// errors and ALL 641 were inside .claude/worktrees. The real tree had
// zero.
//
// That makes `npm run verify` permanently red on a clean repo, while CI —
// which checks out without the worktrees — stays green. A gate that fails
// on good code is worse than no gate: it is what teaches people to reach
// for --no-verify, and then the day it means something, nobody reads it.
//
// Ignored by FAMILY, not by instance, so a new worktree needs no new line.
".claude/worktrees/**",
],
},
...nextConfig,
Expand Down
116 changes: 116 additions & 0 deletions src/lib/ai/__tests__/no-retired-models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* No retired model id may survive anywhere in this repo's AI layers.
*
* This exists because evig has been repinned three times for the same reason,
* and the history is still legible in the comments: Llama-4-Scout was
* decommissioned, so the ids moved to `llama-3.3-70b-versatile`, and Groq then
* retired the entire llama-3.x family. At the low point FOUR ids were dead at
* once — the Groq default, the large-context fallback, the OpenRouter default
* and the OpenRouter vision model — and none of them failed anything until a
* user saw it.
*
* A repin cannot prevent the next retirement; only a check that runs without
* being asked can. Two now do:
*
* - dotfiles/scripts/ci/model-pin-audit.mjs asks Groq and OpenRouter DAILY
* whether these exact ids are still listed. That is the one that catches
* new rot, because it talks to the vendor.
* - this test, which catches the cheaper mistake: someone reintroducing an
* id already known to be dead, by copying an old line, reverting a file, or
* merging a stale branch.
*
* ── Why this reads source text, and why that is normally wrong ──────────────
*
* These ids are module-private constants. Nothing exports them, so there is no
* value to assert against, and the alternative — exporting internals purely to
* make them testable — would be a worse trade.
*
* Reading source has a specific failure mode that has already bitten this fleet
* once: a sibling repo's guard grepped a function body and failed on a COMMENT
* that named the old id, so it reported on prose rather than behaviour. The
* comments above these very constants name every retired id on purpose, because
* that history is the most useful thing on the page.
*
* So comments are stripped before anything is matched. The test then sees only
* code, which is the only thing that can call a vendor.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join } from 'node:path'

/** Source with `//` and block comments removed. Not a parser; sufficient here. */
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
}

function walk(dir: string): string[] {
const out: string[] = []
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
if (statSync(full).isDirectory()) {
if (entry === '__tests__' || entry === 'node_modules') continue
out.push(...walk(full))
} else if (/\.(ts|tsx)$/.test(entry)) {
out.push(full)
}
}
return out
}

/**
* Families, not individual ids.
*
* The failure here was never one model being deprecated — it was a whole
* lineage withdrawn at once, twice. Listing `llama-3.3-70b-versatile` alone
* would pass happily on `llama-3.1-8b-instant`, which died the same day.
*/
const RETIRED = [
{ pattern: /\bllama-3\.\d[\w.-]*/i, why: 'Groq retired the entire llama-3.x family' },
{ pattern: /\bllama-4-scout[\w.-]*/i, why: 'decommissioned; this repo was already repinned off it once' },
{ pattern: /\bopenai\/gpt-oss-20b:free\b/i, why: 'retired from OpenRouter’s catalogue' },
{ pattern: /\bgoogle\/gemini-2\.0-flash-001\b/i, why: 'retired from OpenRouter’s catalogue' },
]

const AI_DIRS = ['src/lib/ai', 'src/lib/hirn']

describe('retired model ids', () => {
const files = AI_DIRS.flatMap((dir) => walk(join(process.cwd(), dir)))

it('finds AI source to check, so an empty sweep cannot pass vacuously', () => {
// Without this, a moved directory turns the whole file below into a
// guarantee about nothing — which reads identically to a clean result.
expect(files.length).toBeGreaterThan(5)
})

it.each(RETIRED)('carries no id matching $pattern ($why)', ({ pattern }) => {
const offenders: string[] = []

for (const file of files) {
const code = stripComments(readFileSync(file, 'utf8'))
// Only quoted strings can be sent to a vendor. This also keeps a bare
// word in an identifier or type name from tripping the check.
for (const match of code.matchAll(/['"`]([^'"`\n]+)['"`]/g)) {
if (pattern.test(match[1])) {
offenders.push(`${file.replace(process.cwd() + '/', '')}: ${match[1]}`)
}
}
}

// Named, not counted: the failure should say which file and which id.
expect(offenders).toEqual([])
})

it('still catches an id that is genuinely present', () => {
// Guards the stripper and the matcher together. If `stripComments` ever
// ate real code, or the quoted-string scan stopped finding anything, every
// assertion above would pass on an empty haystack.
const sample = `
// a comment naming 'llama-3.3-70b-versatile' must NOT count
const model = 'llama-3.1-8b-instant'
`
const code = stripComments(sample)
const found = [...code.matchAll(/['"`]([^'"`\n]+)['"`]/g)].map((m) => m[1])

expect(found).toContain('llama-3.1-8b-instant')
expect(found).not.toContain('llama-3.3-70b-versatile')
})
})
43 changes: 31 additions & 12 deletions src/lib/ai/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,41 @@ import { ORG } from '@/config/org'
// CONFIGURATION (SSOT - all AI provider settings in one place)
// =============================================================================

// ── Read the comments below before changing a model id ──────────────────────
//
// This block has now been repinned three times, and the first two repins are
// still visible in its history: Llama-4-Scout was decommissioned, so it moved
// to `llama-3.3-70b-versatile` — and Groq has since retired the entire
// llama-3.x family, so that id died too, along with every OpenRouter llama-3
// entry beside it. Four of the five ids here were dead simultaneously.
//
// Repinning is what this repo keeps doing and it is not what fixes it. What
// fixes it is that dotfiles/scripts/ci/model-pin-audit.mjs now asks both
// vendors DAILY whether these ids still exist, so the next retirement surfaces
// within a day instead of on a user's screen. Every id below was verified
// present in the live catalogue on 2026-08-27.
const GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions'
const GROQ_MODEL = 'llama-3.3-70b-versatile'
// Fallback for large prompts. Groq decommissioned Llama-4-Scout (404
// model_not_found), so fall back to the versatile 128k model — a dead model id
// here silently broke the large-prompt retry path.
const GROQ_LARGE_CONTEXT_MODEL = 'llama-3.3-70b-versatile'
const GROQ_MODEL = 'openai/gpt-oss-120b'
// Fallback for large prompts. Same 131k context as the default above, which is
// the whole Groq free lineup's window now — Groq's catalogue no longer has a
// larger free model to escalate to, so this is a retry rather than an upgrade.
const GROQ_LARGE_CONTEXT_MODEL = 'openai/gpt-oss-120b'

const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'
const OPENROUTER_MODEL = 'meta-llama/llama-3.3-70b-instruct:free'

// Vision-capable models (multimodal). Groq RETIRED Llama-4-Scout — its whole
// lineup lost vision except Qwen3 (verified: qwen/qwen3.6-27b accepts image_url);
// OpenRouter keeps a free vision model as fallback. Used by callVisionWithFallback
// so photo analysis works on prod (Ollama vision is local-dev only, not deployed).
const OPENROUTER_MODEL = 'nvidia/nemotron-3-super-120b-a12b:free'

// Vision-capable models (multimodal). Groq's lineup lost vision except Qwen3
// (verified: qwen/qwen3.6-27b accepts image_url, and it is still listed);
// OpenRouter keeps a free vision model as fallback. Used by
// callVisionWithFallback so photo analysis works on prod (Ollama vision is
// local-dev only, not deployed).
//
// The OpenRouter vision id was `meta-llama/llama-3.2-11b-vision-instruct:free`,
// retired with the rest of that family. Its replacement was chosen against the
// live catalogue on three properties, not on name: `:free` with
// `pricing.prompt = 0`, `image` in its input modalities, and tool support.
const GROQ_VISION_MODEL = 'qwen/qwen3.6-27b'
const OPENROUTER_VISION_MODEL = 'meta-llama/llama-3.2-11b-vision-instruct:free'
const OPENROUTER_VISION_MODEL = 'google/gemma-4-26b-a4b-it:free'

const DEFAULT_TIMEOUT_MS = 60000

Expand Down
5 changes: 4 additions & 1 deletion src/lib/hirn/providers/groq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import type {
} from './types'

const GROQ_API_URL = 'https://api.groq.com/openai/v1'
const DEFAULT_MODEL = 'llama-3.3-70b-versatile'
// Groq retired the whole llama-3.x family; this read
// `llama-3.3-70b-versatile`, so every Hirn chat answered by Groq returned 404
// with a valid key. Verified present in the live catalogue 2026-08-27.
const DEFAULT_MODEL = 'openai/gpt-oss-120b'
const REQUEST_TIMEOUT_MS = 30_000
const AVAILABILITY_TIMEOUT_MS = 5_000

Expand Down
7 changes: 6 additions & 1 deletion src/lib/hirn/providers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1'
* without a deploy — and when it rots, replace it with another `:free` id rather
* than dropping the suffix.
*/
const DEFAULT_MODEL = process.env.OPENROUTER_MODEL?.trim() || 'openai/gpt-oss-20b:free'
// `openai/gpt-oss-20b:free` has since been retired — the comment above already
// said this would happen and asked for another `:free` id rather than dropping
// the suffix, which is exactly what this is. Verified 2026-08-27: `:free`, and
// `pricing.prompt = 0` in OpenRouter's own catalogue.
const DEFAULT_MODEL =
process.env.OPENROUTER_MODEL?.trim() || 'nvidia/nemotron-3-super-120b-a12b:free'
const REQUEST_TIMEOUT_MS = 30_000
const AVAILABILITY_TIMEOUT_MS = 5_000

Expand Down
Loading