diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 67b2a4a68..169e6e2e0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 3cf2ad3ea..4b99adb62 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -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 diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index bf5a8467c..977fcb3bb 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -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 })), diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 35dd8f954..a959fe6e7 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -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: {