Skip to content

Commit 734bb6b

Browse files
committed
feat: add models scan and fallbacks
1 parent a2ba7dd commit 734bb6b

22 files changed

Lines changed: 2058 additions & 187 deletions

docs/configuration.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,7 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
430430
`allowedModels` lets `/model` list/filter and enforce a per-session allowlist
431431
(omit to show the full catalog).
432432
`modelAliases` adds short names for `/model` (alias -> provider/model).
433+
`modelFallbacks` lists ordered fallback models to try when the default fails.
433434

434435
```json5
435436
{
@@ -443,6 +444,10 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
443444
Opus: "anthropic/claude-opus-4-5",
444445
Sonnet: "anthropic/claude-sonnet-4-1"
445446
},
447+
modelFallbacks: [
448+
"openrouter/deepseek/deepseek-r1:free",
449+
"openrouter/meta-llama/llama-3.3-70b-instruct:free"
450+
],
446451
thinkingDefault: "low",
447452
verboseDefault: "off",
448453
elevatedDefault: "on",

docs/models.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,18 @@ that prefers tool-call + image-capable models and maintains ordered fallbacks.
1212

1313
## Command tree (draft)
1414

15-
- `clawdis models list`
15+
- `clawdbot models list`
1616
- default: configured models only
1717
- flags: `--all` (full catalog), `--local`, `--provider <name>`, `--json`, `--plain`
18-
- `clawdis models status`
19-
- show default model + last used + aliases + fallbacks
20-
- `clawdis models set <modelOrAlias>`
18+
- `clawdbot models status`
19+
- show default model + aliases + fallbacks + allowlist
20+
- `clawdbot models set <modelOrAlias>`
2121
- writes `agent.model` in config
22-
- `clawdis models aliases list|add|remove`
22+
- `clawdbot models aliases list|add|remove`
2323
- writes `agent.modelAliases`
24-
- `clawdis models fallbacks list|add|remove|clear`
24+
- `clawdbot models fallbacks list|add|remove|clear`
2525
- writes `agent.modelFallbacks`
26-
- `clawdis models scan`
26+
- `clawdbot models scan`
2727
- OpenRouter :free scan; probe tool-call + image; interactive selection
2828

2929
## Config changes
@@ -38,7 +38,9 @@ that prefers tool-call + image-capable models and maintains ordered fallbacks.
3838

3939
Input
4040
- OpenRouter `/models` list (filter `:free`)
41+
- Requires `OPENROUTER_API_KEY` (or stored OpenRouter key in auth storage)
4142
- Optional filters: `--max-age-days`, `--min-params`, `--provider`, `--max-candidates`
43+
- Probe controls: `--timeout`, `--concurrency`
4244

4345
Probes (direct pi-ai complete)
4446
- Tool-call probe (required):
@@ -49,13 +51,13 @@ Probes (direct pi-ai complete)
4951
Scoring/selection
5052
- Prefer models passing tool + image.
5153
- Fallback to tool-only if no tool+image pass.
52-
- Rank by: tool+image first, then lower median latency, then larger context.
54+
- Rank by: image ok, then lower tool latency, then larger context, then params.
5355

5456
Interactive selection (TTY)
5557
- Multiselect list with per-model stats:
5658
- model id, tool ok, image ok, median latency, context, inferred params.
5759
- Pre-select top N (default 6).
58-
- Non-TTY: auto-select; require `--yes` or use defaults.
60+
- Non-TTY: auto-select; require `--yes`/`--no-input` to apply.
5961

6062
Output
6163
- Writes `agent.modelFallbacks` ordered.
@@ -64,6 +66,7 @@ Output
6466
## Runtime fallback
6567

6668
- On model failure: try `agent.modelFallbacks` in order.
69+
- Ignore fallback entries not in `agent.allowedModels` (if allowlist set).
6770
- Persist last successful provider/model to session entry.
6871
- `/status` shows last used model (not just default).
6972

src/agents/model-fallback.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type { ClawdbotConfig } from "../config/config.js";
2+
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
3+
import {
4+
buildModelAliasIndex,
5+
modelKey,
6+
parseModelRef,
7+
resolveModelRefFromString,
8+
} from "./model-selection.js";
9+
10+
type ModelCandidate = {
11+
provider: string;
12+
model: string;
13+
};
14+
15+
type FallbackAttempt = {
16+
provider: string;
17+
model: string;
18+
error: string;
19+
};
20+
21+
function isAbortError(err: unknown): boolean {
22+
if (!err || typeof err !== "object") return false;
23+
const name = "name" in err ? String(err.name) : "";
24+
if (name === "AbortError") return true;
25+
const message =
26+
"message" in err && typeof err.message === "string"
27+
? err.message.toLowerCase()
28+
: "";
29+
return message.includes("aborted");
30+
}
31+
32+
function buildAllowedModelKeys(
33+
cfg: ClawdbotConfig | undefined,
34+
defaultProvider: string,
35+
): Set<string> | null {
36+
const rawAllowlist = cfg?.agent?.allowedModels ?? [];
37+
if (rawAllowlist.length === 0) return null;
38+
const keys = new Set<string>();
39+
for (const raw of rawAllowlist) {
40+
const parsed = parseModelRef(String(raw ?? ""), defaultProvider);
41+
if (!parsed) continue;
42+
keys.add(modelKey(parsed.provider, parsed.model));
43+
}
44+
return keys.size > 0 ? keys : null;
45+
}
46+
47+
function resolveFallbackCandidates(params: {
48+
cfg: ClawdbotConfig | undefined;
49+
provider: string;
50+
model: string;
51+
}): ModelCandidate[] {
52+
const provider = params.provider.trim() || DEFAULT_PROVIDER;
53+
const model = params.model.trim() || DEFAULT_MODEL;
54+
const aliasIndex = buildModelAliasIndex({
55+
cfg: params.cfg ?? {},
56+
defaultProvider: DEFAULT_PROVIDER,
57+
});
58+
const allowlist = buildAllowedModelKeys(params.cfg, DEFAULT_PROVIDER);
59+
const seen = new Set<string>();
60+
const candidates: ModelCandidate[] = [];
61+
62+
const addCandidate = (candidate: ModelCandidate, enforceAllowlist: boolean) => {
63+
if (!candidate.provider || !candidate.model) return;
64+
const key = modelKey(candidate.provider, candidate.model);
65+
if (seen.has(key)) return;
66+
if (enforceAllowlist && allowlist && !allowlist.has(key)) return;
67+
seen.add(key);
68+
candidates.push(candidate);
69+
};
70+
71+
addCandidate({ provider, model }, false);
72+
73+
for (const raw of params.cfg?.agent?.modelFallbacks ?? []) {
74+
const resolved = resolveModelRefFromString({
75+
raw: String(raw ?? ""),
76+
defaultProvider: DEFAULT_PROVIDER,
77+
aliasIndex,
78+
});
79+
if (!resolved) continue;
80+
addCandidate(resolved.ref, true);
81+
}
82+
83+
return candidates;
84+
}
85+
86+
export async function runWithModelFallback<T>(params: {
87+
cfg: ClawdbotConfig | undefined;
88+
provider: string;
89+
model: string;
90+
run: (provider: string, model: string) => Promise<T>;
91+
onError?: (attempt: {
92+
provider: string;
93+
model: string;
94+
error: unknown;
95+
attempt: number;
96+
total: number;
97+
}) => void | Promise<void>;
98+
}): Promise<{
99+
result: T;
100+
provider: string;
101+
model: string;
102+
attempts: FallbackAttempt[];
103+
}> {
104+
const candidates = resolveFallbackCandidates(params);
105+
const attempts: FallbackAttempt[] = [];
106+
let lastError: unknown;
107+
108+
for (let i = 0; i < candidates.length; i += 1) {
109+
const candidate = candidates[i] as ModelCandidate;
110+
try {
111+
const result = await params.run(candidate.provider, candidate.model);
112+
return {
113+
result,
114+
provider: candidate.provider,
115+
model: candidate.model,
116+
attempts,
117+
};
118+
} catch (err) {
119+
if (isAbortError(err)) throw err;
120+
lastError = err;
121+
attempts.push({
122+
provider: candidate.provider,
123+
model: candidate.model,
124+
error: err instanceof Error ? err.message : String(err),
125+
});
126+
await params.onError?.({
127+
provider: candidate.provider,
128+
model: candidate.model,
129+
error: err,
130+
attempt: i + 1,
131+
total: candidates.length,
132+
});
133+
}
134+
}
135+
136+
if (attempts.length <= 1 && lastError) throw lastError;
137+
const summary =
138+
attempts.length > 0
139+
? attempts
140+
.map(
141+
(attempt) =>
142+
`${attempt.provider}/${attempt.model}: ${attempt.error}`,
143+
)
144+
.join(" | ")
145+
: "unknown";
146+
throw new Error(
147+
`All models failed (${attempts.length || candidates.length}): ${summary}`,
148+
{ cause: lastError instanceof Error ? lastError : undefined },
149+
);
150+
}

0 commit comments

Comments
 (0)