From 93ebd3a1f917b1b0d2ac669584244089666da09e Mon Sep 17 00:00:00 2001 From: protosphinx <133899485+protosphinx@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:06:11 -0700 Subject: [PATCH] feat: agent 0.12.11 (auto-heal cross-provider model orphans) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flipping `provider` from one service to another (e.g. anthropic → ollama) used to leave the previous provider's model slug in place, so the StatusLine would read `ollama/anthropic/claude-opus-4.7` and every cycle would explode trying to ask ollama to serve an openrouter-shaped slug. `normalizeModelForProvider()` now recognises known openrouter vendor prefixes plus bare claude-/gpt-/gemini-/o1- families, and when the slug clearly belongs to a different provider than the one now selected it resets to that provider's default. `saveConfig()` also runs this normalisation at write time so the on-disk file never sits in a broken cross-provider state. New scripts/smoke-normalize-model.mjs round-trips the fix against a scratch HOME and sweeps the pure-function cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 10 +++ package.json | 2 +- scripts/smoke-normalize-model.mjs | 142 ++++++++++++++++++++++++++++++ src/config.ts | 86 +++++++++++++++++- 4 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 scripts/smoke-normalize-model.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b398f5c..05bba43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to `@krawlerhq/agent` land here. Format follows [Keep a Chan Nothing queued yet. +## [0.12.11] - 2026-04-21 + +### Fixed + +- **Cross-provider model-slug orphans now self-heal.** Flipping `provider` from one service to another (e.g. `anthropic` → `ollama`) used to leave the previous provider's model slug in place, so the StatusLine would read `ollama/anthropic/claude-opus-4.7` and every heartbeat would explode trying to ask ollama to serve an openrouter-shaped slug. `normalizeModelForProvider()` in `src/config.ts` now recognises known openrouter vendor prefixes (`anthropic/`, `openai/`, `google/`, `meta-llama/`, `moonshotai/`, `deepseek/`, `mistralai/`, `minimax/`, `cohere/`, `microsoft/`, `perplexity/`, `x-ai/`, `qwen/`) plus bare `claude-` / `gpt-` / `gemini-` / `o1-` families, and when the slug clearly belongs to a different provider than the one now selected it resets to that provider's default (`claude-opus-4-7` / `gpt-4o` / `gemini-2.5-pro` / `anthropic/claude-opus-4.7` / `llama3.3`). Users can still pick a specific model from the `/keys` dropdown afterward. `saveConfig()` also runs this normalisation at write time so the on-disk file never sits in a broken cross-provider state — previously the repair only fired on the next `loadConfig`. + +### Under the hood + +- New `scripts/smoke-normalize-model.mjs` round-trips the fix against a scratch `$HOME`: primes `provider=openrouter, model=anthropic/claude-opus-4.7`, calls `saveConfig({ provider: 'ollama' })`, then reads `config.json` off disk and asserts the model auto-reset to `llama3.3`. Also sweeps the pure-function cases for each provider. Run via `node scripts/smoke-normalize-model.mjs` after `pnpm run build`. + ## [0.12.3] - 2026-04-21 ### Fixed diff --git a/package.json b/package.json index a7a764f..6121ba4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@krawlerhq/agent", - "version": "0.12.10", + "version": "0.12.11", "description": "Your personal AI agent, living locally, with a public identity on Krawler. Chat with it in the terminal; it posts, follows, endorses, remembers, and learns. Bring your own model (Anthropic, OpenAI, Google, OpenRouter, Ollama).", "keywords": [ "krawler", diff --git a/scripts/smoke-normalize-model.mjs b/scripts/smoke-normalize-model.mjs new file mode 100644 index 0000000..d59558d --- /dev/null +++ b/scripts/smoke-normalize-model.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +// Smoke test for cross-provider model slug normalization (0.12.9). +// +// Covers two layers: +// 1. normalizeModelForProvider() is a pure function — assert every +// (provider, stale-slug) pair repairs to a sane slug. +// 2. saveConfig({ provider: 'ollama' }) on a profile whose stored +// model is anthropic/claude-opus-4.7 auto-resets the model so the +// on-disk config never sits in a broken cross-provider state. +// +// Run: node scripts/smoke-normalize-model.mjs +// Expects dist/ to be built (`pnpm run build` first) or falls back to +// tsx-compiled source via `node --import tsx`. + +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// Redirect $HOME to a throwaway tmpdir so loadConfig/saveConfig touch +// only our scratch profile, never the user's real ~/.config. +const scratchHome = mkdtempSync(join(tmpdir(), 'krawler-smoke-')); +process.env.HOME = scratchHome; + +const { normalizeModelForProvider, loadConfig, saveConfig } = await import('../dist/config.js'); + +let failures = 0; +function assert(label, actual, expected) { + const ok = actual === expected; + if (!ok) { + failures++; + console.error(`FAIL ${label}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`); + } else { + console.log(`ok ${label}`); + } +} + +// --- pure-function cases --- +// Ollama eating a leftover openrouter-style claude slug (the bug). +assert( + 'ollama + anthropic/claude-opus-4.7 → llama3.3', + normalizeModelForProvider('ollama', 'anthropic/claude-opus-4.7'), + 'llama3.3', +); +// Ollama eating a bare anthropic slug. +assert( + 'ollama + claude-opus-4-7 → llama3.3', + normalizeModelForProvider('ollama', 'claude-opus-4-7'), + 'llama3.3', +); +// Ollama eating a bare openai slug. +assert( + 'ollama + gpt-4o → llama3.3', + normalizeModelForProvider('ollama', 'gpt-4o'), + 'llama3.3', +); +// Legit ollama tags left alone. +assert('ollama + llama3.3 unchanged', normalizeModelForProvider('ollama', 'llama3.3'), 'llama3.3'); +assert('ollama + qwen2.5:14b unchanged', normalizeModelForProvider('ollama', 'qwen2.5:14b'), 'qwen2.5:14b'); +assert('ollama + mistral:latest unchanged', normalizeModelForProvider('ollama', 'mistral:latest'), 'mistral:latest'); + +// Anthropic eating a foreign openrouter slug. +assert( + 'anthropic + openai/gpt-4o → claude-opus-4-7', + normalizeModelForProvider('anthropic', 'openai/gpt-4o'), + 'claude-opus-4-7', +); +// Anthropic eating a bare gemini slug. +assert( + 'anthropic + gemini-2.5-pro → claude-opus-4-7', + normalizeModelForProvider('anthropic', 'gemini-2.5-pro'), + 'claude-opus-4-7', +); +// Anthropic repairing a dotted version (pre-existing behavior). +assert( + 'anthropic + claude-opus-4.7 → claude-opus-4-7', + normalizeModelForProvider('anthropic', 'claude-opus-4.7'), + 'claude-opus-4-7', +); + +// OpenAI eating a foreign slug. +assert( + 'openai + anthropic/claude-opus-4.7 → gpt-4o', + normalizeModelForProvider('openai', 'anthropic/claude-opus-4.7'), + 'gpt-4o', +); +assert('openai + gpt-4o unchanged', normalizeModelForProvider('openai', 'gpt-4o'), 'gpt-4o'); +assert('openai + o1-mini unchanged', normalizeModelForProvider('openai', 'o1-mini'), 'o1-mini'); + +// Google eating a foreign slug. +assert( + 'google + claude-opus-4-7 → gemini-2.5-pro', + normalizeModelForProvider('google', 'claude-opus-4-7'), + 'gemini-2.5-pro', +); +assert('google + gemini-2.5-flash unchanged', normalizeModelForProvider('google', 'gemini-2.5-flash'), 'gemini-2.5-flash'); + +// Openrouter repairs (pre-existing + new bare openai/google handling). +assert( + 'openrouter + claude-opus-4-7 → anthropic/claude-opus-4.7', + normalizeModelForProvider('openrouter', 'claude-opus-4-7'), + 'anthropic/claude-opus-4.7', +); +assert( + 'openrouter + gpt-4o → openai/gpt-4o', + normalizeModelForProvider('openrouter', 'gpt-4o'), + 'openai/gpt-4o', +); +assert( + 'openrouter + gemini-2.5-pro → google/gemini-2.5-pro', + normalizeModelForProvider('openrouter', 'gemini-2.5-pro'), + 'google/gemini-2.5-pro', +); + +// --- round-trip saveConfig --- +// 1. Prime the profile as anthropic/claude-opus-4.7 (i.e. openrouter-style). +saveConfig({ provider: 'openrouter', model: 'anthropic/claude-opus-4.7' }); +const afterPrime = loadConfig(); +assert('prime: provider=openrouter', afterPrime.provider, 'openrouter'); +assert('prime: model=anthropic/claude-opus-4.7', afterPrime.model, 'anthropic/claude-opus-4.7'); + +// 2. Flip provider to ollama without touching model — this is the exact +// broken state the user observed. saveConfig must reset the slug. +saveConfig({ provider: 'ollama' }); +const afterFlip = loadConfig(); +assert('flip: provider=ollama', afterFlip.provider, 'ollama'); +assert('flip: model no longer cross-provider orphan', afterFlip.model, 'llama3.3'); + +// 3. Read config.json off disk to confirm the repair was persisted, not +// only rewritten in memory by loadConfig. +const configPath = join(scratchHome, '.config', 'krawler-agent', 'config.json'); +const onDisk = JSON.parse(readFileSync(configPath, 'utf8')); +assert('on-disk: provider=ollama', onDisk.provider, 'ollama'); +assert('on-disk: model=llama3.3', onDisk.model, 'llama3.3'); + +// --- cleanup --- +rmSync(scratchHome, { recursive: true, force: true }); + +if (failures > 0) { + console.error(`\n${failures} assertion(s) failed`); + process.exit(1); +} +console.log('\nall checks passed'); diff --git a/src/config.ts b/src/config.ts index bae11e5..72153cd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -281,19 +281,66 @@ function migrateProviderKeysToShared(): void { } catch { /* non-fatal */ } } +// Sensible default model when the on-disk slug clearly belongs to a +// different provider (e.g. provider was flipped to ollama but the +// anthropic/claude-opus-4.7 slug was left behind). Kept in sync with +// MODEL_SUGGESTIONS in src/model.ts but duplicated here to avoid an +// import cycle. +const DEFAULT_MODEL_BY_PROVIDER: Record = { + anthropic: 'claude-opus-4-7', + openai: 'gpt-4o', + google: 'gemini-2.5-pro', + openrouter: 'anthropic/claude-opus-4.7', + ollama: 'llama3.3', +}; + +// Known openrouter-style vendor prefixes. A slug carrying one of these +// belongs to openrouter, not to a bare Anthropic/OpenAI/Google/Ollama +// model catalogue. +const OPENROUTER_VENDOR_PREFIXES = [ + 'anthropic/', + 'openai/', + 'google/', + 'meta-llama/', + 'moonshotai/', + 'deepseek/', + 'mistralai/', + 'minimax/', + 'cohere/', + 'microsoft/', + 'perplexity/', + 'x-ai/', + 'qwen/', +]; + +function hasOpenrouterVendorPrefix(model: string): boolean { + return OPENROUTER_VENDOR_PREFIXES.some((p) => model.startsWith(p)); +} + // Rewrite model slugs so they match what the selected provider actually // serves. The direct Anthropic API uses hyphen-separated versions // (claude-opus-4-7); openrouter uses dot-separated versions with a // vendor prefix (anthropic/claude-opus-4.7). Mismatched slugs 404 // silently on openrouter as "Provider returned error" — 0.5.x–0.7.1 // shipped with broken default suggestions that produced exactly this. -// This runs on every loadConfig, so upgrading to 0.7.2 repairs in place. +// This runs on every loadConfig, so upgrading self-repairs in place. +// +// Beyond the anthropic↔openrouter repair, this also catches +// cross-provider orphans: if the stored slug obviously belongs to a +// DIFFERENT provider than the one now selected (e.g. provider=ollama +// paired with anthropic/claude-opus-4.7 left over from an anthropic +// install), the slug is reset to that provider's default rather than +// left to 404 or 500 on every request. export function normalizeModelForProvider(provider: Provider, model: string): string { if (!model) return model; if (provider === 'openrouter') { let slug = model; // Bare Anthropic slug (no vendor prefix) on openrouter: add the prefix. if (/^claude-/.test(slug)) slug = `anthropic/${slug}`; + // Bare OpenAI slug: add openai/ prefix. + else if (/^(gpt-|o[13](-|$)|chatgpt-)/i.test(slug)) slug = `openai/${slug}`; + // Bare Google slug: add google/ prefix. + else if (/^gemini-/i.test(slug)) slug = `google/${slug}`; // Convert "anthropic/claude---" (hyphen) → // "anthropic/claude--." (dot). The regex only // touches the single version pair to avoid clobbering slugs like @@ -305,6 +352,11 @@ export function normalizeModelForProvider(provider: Provider, model: string): st return slug; } if (provider === 'anthropic') { + // A non-anthropic openrouter vendor prefix (openai/..., google/...) + // belongs to openrouter, not the direct Anthropic API — reset. + if (hasOpenrouterVendorPrefix(model) && !model.startsWith('anthropic/')) { + return DEFAULT_MODEL_BY_PROVIDER.anthropic; + } // Direct Anthropic API doesn't want the vendor prefix and wants // hyphens. Only convert dot → hyphen on the known version pair // so custom dated slugs (claude-opus-4-5-20250929) stay intact. @@ -313,8 +365,34 @@ export function normalizeModelForProvider(provider: Provider, model: string): st /^(claude-(?:opus|sonnet|haiku))-(\d+)\.(\d+)(?=$|-)/, '$1-$2-$3', ); + // After stripping, the slug must look like a claude-* model. Anything + // else (gpt-, gemini-, llama3) is a cross-provider orphan. + if (!/^claude-/.test(slug)) return DEFAULT_MODEL_BY_PROVIDER.anthropic; return slug; } + if (provider === 'openai') { + if (hasOpenrouterVendorPrefix(model)) return DEFAULT_MODEL_BY_PROVIDER.openai; + if (!/^(gpt-|o[13](-|$)|chatgpt-|text-|davinci|babbage|ada)/i.test(model)) { + return DEFAULT_MODEL_BY_PROVIDER.openai; + } + return model; + } + if (provider === 'google') { + if (hasOpenrouterVendorPrefix(model)) return DEFAULT_MODEL_BY_PROVIDER.google; + if (!/^gemini-/i.test(model)) return DEFAULT_MODEL_BY_PROVIDER.google; + return model; + } + if (provider === 'ollama') { + // Ollama tags look like `llama3.3`, `qwen2.5:14b`, `mistral:latest`, + // or (rarely) `user/custom-model`. They never carry a known + // openrouter vendor prefix, and they don't use the bare + // claude-/gpt-/gemini- families from the hosted APIs. + if (hasOpenrouterVendorPrefix(model)) return DEFAULT_MODEL_BY_PROVIDER.ollama; + if (/^(claude-|gpt-|gemini-|o[13](-|$)|chatgpt-)/i.test(model)) { + return DEFAULT_MODEL_BY_PROVIDER.ollama; + } + return model; + } return model; } @@ -384,6 +462,12 @@ export function saveConfig(c: Partial): Config { delete (current as Record)[f]; } const mergedProfile = configSchema.parse({ ...current, ...profilePartial, ...shared }); + // Cross-provider orphan guard. If the resulting (provider, model) + // pair is a mismatch — e.g. saveConfig({ provider: 'ollama' }) on a + // profile whose model is still anthropic/claude-opus-4.7 — repair + // at write time so the on-disk file is never in a broken state, + // instead of relying on the next loadConfig to notice. + mergedProfile.model = normalizeModelForProvider(mergedProfile.provider, mergedProfile.model); // Write out the per-profile slice (without the shared keys) so the // on-disk file stays minimal. We still return the fully-merged view // for the caller.