Skip to content
This repository was archived by the owner on May 9, 2026. It is now read-only.
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
142 changes: 142 additions & 0 deletions scripts/smoke-normalize-model.mjs
Original file line number Diff line number Diff line change
@@ -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');
86 changes: 85 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Provider, string> = {
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-<family>-<major>-<minor>" (hyphen) →
// "anthropic/claude-<family>-<major>.<minor>" (dot). The regex only
// touches the single version pair to avoid clobbering slugs like
Expand All @@ -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.
Expand All @@ -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;
}

Expand Down Expand Up @@ -384,6 +462,12 @@ export function saveConfig(c: Partial<Config>): Config {
delete (current as Record<string, unknown>)[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.
Expand Down