feat(coding-agent): allow configuring Anthropic fallback models

Allow models.json overrides to replace or disable server-side fallback metadata.

Fixes #9294
This commit is contained in:
David Brailovsky 2026-09-16 10:49:11 +02:00
parent 60e7e76bd7
commit b03a367a4f
4 changed files with 77 additions and 19 deletions

View file

@ -6,6 +6,7 @@
- Added `ctx.modelRegistry.stream()` and `streamSimple()` for extension model calls through configured providers with resolved authentication ([#8964](https://github.com/earendil-works/pi/issues/8964)).
- Added per-model `reserveTokens` and `keepRecentTokens` settings through `compaction.modelOverrides`, with ordinary compaction settings as fallback ([#8133](https://github.com/earendil-works/pi-mono/issues/8133)).
- Added `compat.allowedFallbackModels` configuration for overriding or disabling Anthropic server-side fallback models ([#9294](https://github.com/earendil-works/pi/issues/9294)).
### Changed

View file

@ -435,6 +435,7 @@ Built-in Anthropic models enable `supportsStrictTools` in their model metadata.
| `supportsMidConvoEffort` | Whether the exact Claude model transport supports per-turn effort system messages and thinking binding controls. Pi persists native effort levels and always sends `drop_block` when enabled. Default: `false`. |
| `allowEmptySignature` | Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: `false`. |
| `supportsStrictTools` | Whether the provider accepts strict JSON-schema tool definitions. Default: `false`; built-in Anthropic models enable it in generated metadata. |
| `allowedFallbackModels` | Up to three server-side fallback models, each with `provider`, `model`, and complete `cost` metadata. An empty array disables fallback. |
## OpenAI Compatibility

View file

@ -125,25 +125,6 @@ const OpenAIResponsesCompatSchema = Type.Object({
supportsMaxOutputTokens: Type.Optional(Type.Boolean()),
});
const AnthropicMessagesCompatSchema = Type.Object({
supportsEagerToolInputStreaming: Type.Optional(Type.Boolean()),
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
supportsCacheControlOnTools: Type.Optional(Type.Boolean()),
supportsTemperature: Type.Optional(Type.Boolean()),
forceAdaptiveThinking: Type.Optional(Type.Boolean()),
allowEmptySignature: Type.Optional(Type.Boolean()),
supportsStrictTools: Type.Optional(Type.Boolean()),
supportsMidConvoEffort: Type.Optional(Type.Boolean()),
supportsToolReferences: Type.Optional(Type.Boolean()),
});
const ProviderCompatSchema = Type.Union([
OpenAICompletionsCompatSchema,
OpenAIResponsesCompatSchema,
AnthropicMessagesCompatSchema,
]);
const ModelCostRatesSchema = {
input: Type.Number(),
output: Type.Number(),
@ -159,6 +140,35 @@ const ModelCostSchema = Type.Object({
tiers: Type.Optional(Type.Array(ModelCostTierSchema)),
});
const AnthropicMessagesCompatSchema = Type.Object({
supportsEagerToolInputStreaming: Type.Optional(Type.Boolean()),
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
supportsCacheControlOnTools: Type.Optional(Type.Boolean()),
supportsTemperature: Type.Optional(Type.Boolean()),
forceAdaptiveThinking: Type.Optional(Type.Boolean()),
allowEmptySignature: Type.Optional(Type.Boolean()),
supportsStrictTools: Type.Optional(Type.Boolean()),
supportsMidConvoEffort: Type.Optional(Type.Boolean()),
supportsToolReferences: Type.Optional(Type.Boolean()),
allowedFallbackModels: Type.Optional(
Type.Array(
Type.Object({
provider: Type.String({ minLength: 1 }),
model: Type.String({ minLength: 1 }),
cost: ModelCostSchema,
}),
{ maxItems: 3 },
),
),
});
const ProviderCompatSchema = Type.Union([
OpenAICompletionsCompatSchema,
OpenAIResponsesCompatSchema,
AnthropicMessagesCompatSchema,
]);
const ModelDefinitionSchema = Type.Object({
id: Type.String({ minLength: 1 }),
name: Type.Optional(Type.String({ minLength: 1 })),

View file

@ -714,6 +714,52 @@ describe("ModelRegistry", () => {
expect(opus?.name).not.toBe("Custom Sonnet Name");
});
test("Anthropic model override replaces allowed fallback metadata", async () => {
const allowedFallbackModels = [
{
provider: "anthropic",
model: "claude-opus-5",
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
},
{
provider: "anthropic",
model: "claude-opus-4-8",
cost: { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5 },
},
];
writeRawModelsJson({
anthropic: {
modelOverrides: {
"claude-fable-5": {
compat: { allowedFallbackModels },
},
},
},
});
const registry = await createModelRegistry(authStorage, modelsJsonPath);
const compat = registry.find("anthropic", "claude-fable-5")?.compat as AnthropicMessagesCompat | undefined;
expect(registry.getError()).toBeUndefined();
expect(compat?.allowedFallbackModels).toEqual(allowedFallbackModels);
});
test("empty allowed fallback model override disables server-side fallback", async () => {
writeRawModelsJson({
anthropic: {
modelOverrides: {
"claude-fable-5": { compat: { allowedFallbackModels: [] } },
},
},
});
const registry = await createModelRegistry(authStorage, modelsJsonPath);
const compat = registry.find("anthropic", "claude-fable-5")?.compat as AnthropicMessagesCompat | undefined;
expect(registry.getError()).toBeUndefined();
expect(compat?.allowedFallbackModels).toEqual([]);
});
test("custom model and model override carry sampling params", async () => {
writeRawModelsJson({
openrouter: {