Skip to content

Commit d1a99c4

Browse files
committed
fix(flow): fail over on missing model errors (rebased on main)
1 parent 75c6c3f commit d1a99c4

12 files changed

Lines changed: 595 additions & 25 deletions

docs/CONFIGURATION.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ Use `flowModelConfigs` in your Pi settings to define tiered model strategies. Ea
3737

3838
Settings are merged: project `.pi/settings.json` overrides global `~/.pi/agent/settings.json`.
3939

40+
Models referenced in `flowModelConfigs` are checked against the local `models.json` registry (usually `~/.pi/agent/models.json`). If a model is known to be missing from the registry, a warning is logged, but the model is still tried so that stale or incomplete registries do not silently block valid provider-side models. If **every** configured model for a flow or trace is missing from the registry, the invocation fails fast with a clear `Bad settings` error.
41+
4042
## Persistent Flow Mode Switch
4143

4244
Switch the global active strategy quickly with `--flow-mode`:

src/config/config.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import * as path from "node:path";
1111
import { parseComplexity, type Complexity } from "../flow/complexity.js";
1212
import { type FlowTier } from "../flow/agents.js";
1313
import { logWarn } from "./log.js";
14-
import { resolveModelContextWindow as resolveModelContextWindowFromModels } from "./models.js";
14+
import { getModelsJsonPath, hasConfiguredModel, resolveModelContextWindow as resolveModelContextWindowFromModels } from "./models.js";
1515
import { getAgentDir, hasAgentDirOverride } from "./paths.js";
1616
import { atomicWriteFileSync, atomicWriteJsonAsync } from "../io/atomic-write.js";
1717

@@ -615,34 +615,43 @@ export function resolveFlowModelCandidates(opts: {
615615
cliTierOverride?: string;
616616
strategy: FlowModelStrategy;
617617
fallbackModel?: string;
618-
}): { primary: string | undefined; candidates: string[] } {
618+
}): { primary: string | undefined; candidates: string[]; invalidCandidates: string[] } {
619619
const unique = new Set<string>();
620620
const candidates: string[] = [];
621+
const invalidCandidates: string[] = [];
622+
// Read the registry once; models without a provider prefix or unknown providers
623+
// are treated as "cannot answer authoritatively" and are still tried.
624+
const registry = readSettingsJson(getModelsJsonPath());
621625

622626
const add = (value: string | undefined) => {
623627
if (!value) return;
624628
const normalized = value.trim();
625629
if (!normalized || unique.has(normalized)) return;
626630
unique.add(normalized);
631+
const configured = hasConfiguredModel(normalized, registry);
632+
if (configured === false) {
633+
invalidCandidates.push(normalized);
634+
logWarn(`[pi-agent-flow] Model "${normalized}" is not present in models.json; trying it anyway.`);
635+
}
627636
candidates.push(normalized);
628637
};
629638

630639
if (opts.flowModel) {
631640
add(opts.flowModel);
632-
return { primary: candidates[0], candidates };
641+
return { primary: candidates[0], candidates, invalidCandidates };
633642
}
634643

635644
if (opts.cliTierOverride) {
636645
add(opts.cliTierOverride);
637-
return { primary: candidates[0], candidates };
646+
return { primary: candidates[0], candidates, invalidCandidates };
638647
}
639648

640649
const tierConfig = opts.strategy[opts.tier];
641650
add(tierConfig?.primary);
642651
for (const model of tierConfig?.failover ?? []) add(model);
643652
add(opts.fallbackModel);
644653

645-
return { primary: candidates[0], candidates };
654+
return { primary: candidates[0], candidates, invalidCandidates };
646655
}
647656

648657
export function formatFlowModelStrategy(modeName: string, strategy: FlowModelStrategy): string {

src/config/models.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as path from "node:path";
33
import { logWarn } from "./log.js";
44
import { getAgentDir, hasAgentDirOverride } from "./paths.js";
55

6-
function getModelsJsonPath(): string {
6+
export function getModelsJsonPath(): string {
77
const agentDir = getAgentDir();
88
const defaultPath = path.join(agentDir, "models.json");
99
if (!hasAgentDirOverride() && !fs.existsSync(defaultPath)) {
@@ -56,3 +56,34 @@ export function resolveModelContextWindow(model: string): number | undefined {
5656

5757
return undefined;
5858
}
59+
60+
/**
61+
* Return whether a provider/model entry is known in models.json.
62+
*
63+
* `undefined` means the local model registry cannot answer authoritatively
64+
* (for example, no provider/model form or unreadable models.json).
65+
*/
66+
export function hasConfiguredModel(
67+
model: string,
68+
registry?: Record<string, unknown> | null,
69+
): boolean | undefined {
70+
const parts = model.split("/");
71+
if (parts.length < 2) return undefined;
72+
73+
const providerKey = parts[0];
74+
const modelId = parts.slice(1).join("/");
75+
76+
const raw = registry === undefined ? readSettingsJson(getModelsJsonPath()) : registry;
77+
if (!isPlainObject(raw)) return undefined;
78+
79+
const providers = raw.providers;
80+
if (!isPlainObject(providers)) return undefined;
81+
82+
const provider = providers[providerKey];
83+
if (!isPlainObject(provider)) return undefined;
84+
85+
const models = provider.models;
86+
if (!Array.isArray(models)) return undefined;
87+
88+
return models.some((m) => isPlainObject(m) && m.id === modelId);
89+
}

src/flow/cycle-guard.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ export function shouldFailover(result: SingleResult): boolean {
3333
if (result.stopReason === "aborted") return false;
3434
const text = `${result.errorMessage ?? ""}\n${result.stderr ?? ""}`.toLowerCase();
3535
if (!text.trim()) return false;
36-
if (text.includes("permission") || text.includes("invalid tool") || text.includes("bad settings")) {
36+
if (text.includes("permission") || text.includes("bad settings")) {
37+
return false;
38+
}
39+
// Generic "invalid tool" failures are not retryable, but "invalid tool_call_id"
40+
// errors are a specific provider-side rejection that failover can recover from.
41+
if (text.includes("invalid tool") && !text.includes("tool_call_id")) {
3742
return false;
3843
}
3944
if (result.exitCode > 0) return true;
@@ -47,6 +52,14 @@ export function shouldFailover(result: SingleResult): boolean {
4752
if (!isFlowComplete(result) && text.includes("400") && text.includes("tool_call_id")) {
4853
return true;
4954
}
55+
// Provider-side 404 / resource-not-found errors should fail over to the next
56+
// configured model even when the process exits 0.
57+
if (
58+
!isFlowComplete(result) &&
59+
(text.includes("resource_not_found_error") || text.includes("requested resource was not found"))
60+
) {
61+
return true;
62+
}
5063
return false;
5164
}
5265

src/flow/execute-single.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,56 @@ export async function executeSingleFlow(
6363

6464
const shouldInheritContext = targetFlow?.inheritContext !== false;
6565
const tier = targetFlow?.tier ?? "flash";
66-
const { candidates } = resolveFlowModelCandidates({
66+
const { candidates, invalidCandidates } = resolveFlowModelCandidates({
6767
tier,
6868
flowModel: targetFlow?.model,
6969
cliTierOverride: tierOverrideResolver(tier),
7070
strategy: selectedFlowModelConfig.strategy,
7171
fallbackModel,
7272
});
73-
const attemptModels = candidates.length > 0 ? candidates : [undefined];
7473
const attemptedModels: string[] = [];
7574
let result = allResults[resultIndex];
7675
const flowStart = Date.now();
7776

77+
// Fail fast when every configured model is known to be missing from models.json.
78+
if (invalidCandidates.length > 0 && candidates.length === invalidCandidates.length) {
79+
const badSettingsMessage = `Bad settings: all configured flow models are missing from models.json: ${invalidCandidates.join(", ")}`;
80+
result = {
81+
type: normalizedType,
82+
agentSource: targetFlow?.source ?? "unknown",
83+
intent: item.intent,
84+
aim: item.aim,
85+
exitCode: 1,
86+
messages: [],
87+
stderr: badSettingsMessage,
88+
usage: emptyFlowUsage(),
89+
model: invalidCandidates[0],
90+
stopReason: "error",
91+
errorMessage: badSettingsMessage,
92+
};
93+
const previous = allResults[resultIndex];
94+
allResults[resultIndex] = result;
95+
preserveMetadata(allResults[resultIndex], previous);
96+
emitProgress();
97+
if (onFlowMetrics) {
98+
onFlowMetrics({
99+
type: normalizedType,
100+
durationMs: Date.now() - flowStart,
101+
exitCode: result.exitCode,
102+
success: false,
103+
model: result.model,
104+
failoverCount: 0,
105+
connectionRetryCount: 0,
106+
usage: result.usage,
107+
source: result.agentSource,
108+
depth: currentDepth + 1,
109+
});
110+
}
111+
return result;
112+
}
113+
114+
const attemptModels = candidates.length > 0 ? candidates : [undefined];
115+
78116
const maxRetries = deps.subAgentMaxRetries ?? DEFAULT_SUB_AGENT_MAX_RETRIES;
79117
const baseDelayMs = deps.subAgentBaseDelayMs ?? DEFAULT_SUB_AGENT_BASE_DELAY_MS;
80118
let retryCount = 0;

src/tools/trace.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,8 @@ export interface TraceToolOptions {
211211
fallbackModel?: string;
212212
}
213213

214-
function resolveTraceRuntime(
214+
/** Exported for unit testing; not part of the public API. */
215+
export function resolveTraceRuntime(
215216
opts: TraceToolOptions,
216217
traceFlow: FlowConfig,
217218
ctx: ExtensionContext,
@@ -228,13 +229,18 @@ function resolveTraceRuntime(
228229
);
229230
selectedStrategy = selectedFlowModelConfig.strategy;
230231
}
231-
const { candidates } = resolveFlowModelCandidates({
232+
const { candidates, invalidCandidates } = resolveFlowModelCandidates({
232233
tier,
233234
flowModel: traceFlow.model,
234235
cliTierOverride: opts.tierOverrideResolver?.(tier),
235236
strategy: selectedStrategy ?? {},
236237
fallbackModel: opts.fallbackModel,
237238
});
239+
if (invalidCandidates.length > 0 && candidates.length === invalidCandidates.length) {
240+
throw new Error(
241+
`Bad settings: all configured trace models are missing from models.json: ${invalidCandidates.join(", ")}`,
242+
);
243+
}
238244
const resolvedModel = candidates[0];
239245
const maxContextTokens = resolveModelContextWindow(resolvedModel);
240246
// INTENTIONALLY OMIT: `tier` and `compressionProfile`.

src/types/flow.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -126,22 +126,31 @@ export function aggregateFlowUsage(results: SingleResult[]): UsageStats {
126126
let weightedTps = 0;
127127
let totalOutput = 0;
128128
for (const r of results) {
129-
total.input += r.usage.input;
130-
total.output += r.usage.output;
131-
total.cacheRead += r.usage.cacheRead;
132-
total.cacheWrite += r.usage.cacheWrite;
133-
total.cost += r.usage.cost;
134-
total.turns += r.usage.turns;
135-
total.toolCalls += r.usage.toolCalls;
136-
if ((r.usage.smoothedTps ?? 0) > 0) {
137-
weightedTps += (r.usage.smoothedTps ?? 0) * r.usage.output;
138-
totalOutput += r.usage.output;
129+
const usage = r.usage ?? emptyFlowUsage();
130+
const input = usage.input ?? 0;
131+
const output = usage.output ?? 0;
132+
const cacheRead = usage.cacheRead ?? 0;
133+
const cacheWrite = usage.cacheWrite ?? 0;
134+
const cost = usage.cost ?? 0;
135+
const turns = usage.turns ?? 0;
136+
const toolCalls = usage.toolCalls ?? 0;
137+
138+
total.input += input;
139+
total.output += output;
140+
total.cacheRead += cacheRead;
141+
total.cacheWrite += cacheWrite;
142+
total.cost += cost;
143+
total.turns += turns;
144+
total.toolCalls += toolCalls;
145+
if ((usage.smoothedTps ?? 0) > 0) {
146+
weightedTps += (usage.smoothedTps ?? 0) * output;
147+
totalOutput += output;
139148
}
140149
}
141150
if (totalOutput > 0) {
142151
total.smoothedTps = weightedTps / totalOutput;
143152
} else if (results.length > 0) {
144-
total.smoothedTps = Math.max(...results.map((r) => r.usage.smoothedTps ?? 0));
153+
total.smoothedTps = Math.max(...results.map((r) => r.usage?.smoothedTps ?? 0));
145154
}
146155
return total;
147156
}

0 commit comments

Comments
 (0)