feat(llm): add Claude Fable 5 support (#482)

## Summary

Adds Anthropic Claude Fable 5 as a first-class Fabro model without
changing the default Anthropic model. The catalog now exposes
`claude-fable-5` with `fable` and `claude-fable` aliases, 1M context,
128k max output, effort levels, vision/tools, prompt caching, and the
documented pricing.

The Anthropic adapter now handles Fable's API behavior directly: it uses
the `claude-fable-5` API ID, omits the legacy 1M context beta header,
avoids injecting default `thinking`, preserves `output_config.effort`,
omits deprecated `temperature`/`top_p` sampling fields for Fable, and
rejects unsupported manual enabled/disabled thinking configs locally.

Fable refusals are converted into content-filter LLM errors with
`stop_details` preserved. Those refusal errors are fallback-eligible, so
existing `run.model.fallbacks` chains work for both prompt and agent
paths, while no-fallback refusals surface clearly as LLM errors.

## Live QA

Manually exercised the PR branch against a live Anthropic API key from
`~/.fabro.bak/.env.bak` using a temporary local harness that was removed
before commit. The run covered non-streaming completion via `fable`,
token counting via `claude-fable`, streaming completion, the deep
model-test path with tools/reasoning, local rejection of manual thinking
config, and a live refusal probe. The live run initially exposed
Anthropic's Fable rejection of `temperature`; this PR now strips
deprecated sampling fields for Fable and the live harness then passed
6/6 checks.

## Testing

- `cargo test -p fabro-llm --test live_fable_manual -- --nocapture
--test-threads=1` -> 6 passed against live Anthropic, temporary harness
removed afterward
- `cargo nextest run -p fabro-llm
encode_fable_uses_api_id_effort_and_omits_1m_beta`
- `cargo nextest run -p fabro-model -p fabro-llm -p fabro-workflow` ->
1808 passed, 41 skipped
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo insta pending-snapshots` -> no pending snapshots
- `git diff --check`

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-06-10 14:01:56 -04:00 committed by GitHub
parent 8ed47d31ba
commit a4e8987da8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1525 additions and 72 deletions

View file

@ -7590,10 +7590,15 @@ components:
example: 128000
ReasoningEffortFeature:
description: Whether the model endpoint supports a native reasoning-effort parameter.
description: >-
Whether the model endpoint supports a native reasoning-effort
parameter. `levels` accepts discrete effort levels; `always_adaptive`
accepts effort levels with natively always-on adaptive thinking;
`none` has no native effort parameter.
type: string
enum:
- levels
- always_adaptive
- none
ReasoningEffort:
@ -7615,6 +7620,7 @@ components:
- reasoning
- reasoning_effort
- prompt_cache
- sampling_params
properties:
tools:
type: boolean
@ -7630,6 +7636,9 @@ components:
prompt_cache:
type: boolean
description: Whether the model endpoint supports prompt caching.
sampling_params:
type: boolean
description: Whether the model accepts classic sampling parameters (temperature, top_p).
ModelCosts:
description: Pricing per million tokens in USD.

View file

@ -0,0 +1,24 @@
---
title: "Claude Fable 5 support"
date: "2026-06-10"
---
## Claude Fable 5 support
Fabro now includes Anthropic's `claude-fable-5` in the built-in model catalog, with `fable` and `claude-fable` aliases. It supports the model's 1M context window, 128K max output, vision, tools, prompt caching, and native `reasoning_effort` levels without changing the default Anthropic model from `claude-sonnet-4-6`.
Fable refusals are now handled through Fabro's provider-neutral fallback system. When Anthropic returns `stop_reason: "refusal"`, Fabro surfaces a content-filter LLM error with the refusal detail preserved and retries through `run.model.fallbacks` when configured.
## More
<Accordion title="Models">
- Added `claude-fable-5` to the Anthropic catalog at $10 / $50 per MTok input/output pricing
- Added aliases `fable` and `claude-fable`
- Kept `claude-sonnet-4-6` as the Anthropic default
- Omitted deprecated Anthropic sampling parameters for Fable requests
</Accordion>
<Accordion title="Fallbacks">
- Fable refusal responses now participate in configured model fallback chains
- Refusals without a configured fallback surface as clear LLM content-filter errors
</Accordion>

View file

@ -13,6 +13,7 @@ No single model is best at everything. Fabro lets you assign the right model to
| Model | Provider | Aliases | Context | Cost (in/out per Mtok) | Speed |
|---|---|---|---|---|---|
| `claude-fable-5` | anthropic | `fable`, `claude-fable` | 1M | $10.00 / $50.00 | n/a |
| `claude-opus-4-8` | anthropic | `opus`, `claude-opus` | 1M | $5.00 / $25.00 | 25 tok/s |
| `claude-opus-4-7` | anthropic | | 1M | $5.00 / $25.00 | 25 tok/s |
| `claude-opus-4-6` | anthropic | | 1M | $5.00 / $25.00 | 25 tok/s |
@ -36,6 +37,8 @@ No single model is best at everything. Fabro lets you assign the right model to
Each provider requires its own API key. Server-backed workflows read provider credentials from the server vault (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GEMINI_API_KEY` set with `fabro secret set` or `fabro provider login`). Standalone SDK/CLI flows can opt into env-backed credential sources explicitly. See the [Quick Start](/getting-started/quick-start) for setup.
Claude Fable 5 is available as an explicit model but is not the default Anthropic model. If Fable refuses a request, Fabro reports the refusal as a content-filter LLM error and applies the configured `run.model.fallbacks` chain when one is present.
## Configuring providers and models
Fabro's catalog starts with the built-in providers and models, then merges any `[llm]` entries from settings. Provider and model IDs are strings, so a server or project can add an OpenAI-compatible provider without a Fabro release.

View file

@ -251,6 +251,13 @@
"tab": "Changelog",
"icon": "clock-rotate-left",
"groups": [
{
"group": "June 2026",
"icon": "clock-rotate-left",
"pages": [
"changelog/2026-06-10"
]
},
{
"group": "May 2026",
"icon": "clock-rotate-left",

View file

@ -279,14 +279,15 @@ cache_input_cost_per_mtok = 0.60
| `tools` | boolean | `false` | Whether the model supports tool calls. |
| `vision` | boolean | `false` | Whether the model accepts image inputs. |
| `reasoning` | boolean | `false` | Whether the model has reasoning behavior. |
| `reasoning_effort` | `"levels"` \| `"none"` | `"none"` | Whether the model endpoint supports a native reasoning-effort parameter. |
| `reasoning_effort` | `"levels"` \| `"always_adaptive"` \| `"none"` | `"none"` | Whether the model endpoint supports a native reasoning-effort parameter. `levels` accepts discrete effort levels; `always_adaptive` accepts effort levels with natively always-on adaptive thinking; `none` has no native effort parameter. |
| `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. |
| `sampling_params` | boolean | `true` | Whether the model accepts classic sampling parameters (`temperature`, `top_p`). |
## `[llm.models.<id>.controls]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `reasoning_effort` | array<string> | all standard levels when feature is `"levels"` | User-facing reasoning effort values Fabro may send for this model. Can be set explicitly for reasoning models whose provider adapter maps effort to a non-native API shape. |
| `reasoning_effort` | array<string> | all standard levels when feature is `"levels"` or `"always_adaptive"` | User-facing reasoning effort values Fabro may send for this model. Can be set explicitly for reasoning models whose provider adapter maps effort to a non-native API shape. |
| `speed` | array<string> | `[]` | Additional speeds beyond implicit `standard`; do not list `standard`. |
## `[llm.models.<id>.costs]`

View file

@ -0,0 +1,622 @@
# Catalog-Driven Model Behaviors Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove all model-specific leakage (`CLAUDE_FABLE_5_MODEL`, `is_fable`, hard-coded `"Claude Fable 5"` strings) from production Rust code in the Anthropic adapter, expressing the underlying model capabilities as catalog TOML data instead.
**Architecture:** Two new catalog mechanisms replace the `is_fable` special cases: (1) a `ReasoningEffortFeature::Adaptive` variant ("effort levels supported; thinking is natively always-on; manual thinking toggle rejected"), (2) a neutral `ModelFeatures.sampling_params: bool` flag ("accepts `temperature`/`top_p`"). The third special case — the `context-1m-2025-08-07` beta header heuristic — is **deleted outright**: per current Anthropic docs (verified 2026-06-10 against platform.claude.com/docs/en/build-with-claude/context-windows), 1M context is GA on Opus 4.6/4.7/4.8 and Sonnet 4.6 with no beta header, and is Fable 5's default. No catalog model needs the header, so no replacement mechanism is built (request-level `provider_options.anthropic.beta_headers` remains as the escape hatch for custom needs). The refusal error message becomes model-derived instead of hard-coding "Claude Fable 5". Tasks 12 build pure mechanism (zero behavior change); Task 3 flips the TOML data and adapter code together, with the existing Fable wire tests as the oracle.
**Tech Stack:** Rust (serde, strum, insta snapshots, httpmock wire tests), OpenAPI spec (`docs/public/api-reference/fabro-api.yaml`) regenerated via progenitor (`cargo build -p fabro-api`), TypeScript client via openapi-generator.
**Branch:** work on `feature/claude-fable-5-support` (the Fable PR branch). New commits on top; never amend.
**Decisions confirmed with user (2026-06-10):** variant name `Adaptive`; feature name `sampling_params`; 1M beta header deleted (GA per docs — deliberate wire change: opus requests stop sending `context-1m-2025-08-07`, a no-op server-side); refusal message includes the model ID from the response.
---
## Background for the implementer
The PR `feat(llm): add Claude Fable 5 support` introduced these production-code leaks in `lib/crates/fabro-llm/src/providers/anthropic.rs`:
| Site | Behavior encoded | Replacement |
|---|---|---|
| `anthropic.rs:591` `const CLAUDE_FABLE_5_MODEL` | (lookup key) | deleted |
| `anthropic.rs:1360` `!is_fable` in auto-adaptive thinking injection | thinking is native/always-on | `ReasoningEffortFeature::Adaptive` |
| `anthropic.rs:1683` `validate_request` rejects manual thinking | same fact as above | `ReasoningEffortFeature::Adaptive` |
| `anthropic.rs:1379` temperature/top_p forced to `None` | model rejects sampling params | `features.sampling_params = false` |
| `anthropic.rs:1419,1465` `!is_fable` in `include_1m_context` | 1M is GA, not beta opt-in | heuristic + `CONTEXT_1M_BETA_HEADER` deleted (1M is GA on every catalog model that has it) |
| `anthropic.rs` `refusal_error()` message `"Claude Fable 5 refused..."` | (nothing — refusals are wire-protocol-generic) | message uses the response's model ID |
Key constraint: `Model` and `ModelFeatures` are reused by `fabro-api` via `with_replacement` (`lib/crates/fabro-api/build.rs`) and appear in the OpenAPI spec, with JSON-parity tests in `lib/crates/fabro-api/tests/model_features_round_trip.rs`. Any serialized-shape change to those types MUST update the spec in the same task.
---
### Task 1: Add `ReasoningEffortFeature::Adaptive`
**Files:**
- Modify: `lib/crates/fabro-model/src/types.rs` (enum at :22, `ModelFeatures` at :35, `Model::supports_reasoning_effort` at :117)
- Modify: `lib/crates/fabro-model/src/catalog.rs:1430` (validation), `:1499-1500` (controls)
- Modify: `docs/public/api-reference/fabro-api.yaml:7592` (enum schema)
- Test: `lib/crates/fabro-model/src/types.rs` tests, `lib/crates/fabro-model/src/catalog.rs` tests
- [ ] **Step 1: Write the failing tests**
In `lib/crates/fabro-model/src/types.rs` test module:
```rust
#[test]
fn reasoning_effort_feature_adaptive_round_trips() {
let parsed: ReasoningEffortFeature =
serde_json::from_value(serde_json::json!("adaptive")).unwrap();
assert_eq!(parsed, ReasoningEffortFeature::Adaptive);
assert_eq!(
serde_json::to_value(parsed).unwrap(),
serde_json::json!("adaptive")
);
assert_eq!(parsed.to_string(), "adaptive");
assert_eq!("adaptive".parse::<ReasoningEffortFeature>().unwrap(), parsed);
}
```
(Check `fabro-model/Cargo.toml` for a `serde_json` dev-dependency first; mirror existing test patterns if a different parse path is conventional.)
In `lib/crates/fabro-model/src/catalog.rs` test module (pattern: copy `catalog_from_settings_accepts_reasoning_effort_feature_levels` at :3404):
```rust
#[test]
fn catalog_from_settings_accepts_reasoning_effort_feature_adaptive() {
let settings = minimal_settings(
r#"
[providers.test]
display_name = "Test"
adapter = "openai"
agent_profile = "openai"
[models.model]
provider = "test"
display_name = "Model"
family = "test"
default = true
[models.model.limits]
context_window = 1000
[models.model.features]
tools = true
vision = false
reasoning = true
reasoning_effort = "adaptive"
prompt_cache = true
"#,
);
let catalog = Catalog::from_settings(&settings).unwrap();
let model = catalog.get("model").unwrap();
assert_eq!(
model.features.reasoning_effort,
crate::ReasoningEffortFeature::Adaptive
);
assert!(model.supports_reasoning_effort());
// Adaptive models get the full default effort controls, same as Levels.
assert_eq!(
catalog
.model_settings("model")
.unwrap()
.controls
.reasoning_effort,
ReasoningEffort::VARIANTS.to_vec()
);
}
#[test]
fn catalog_from_settings_rejects_adaptive_effort_without_reasoning() {
let settings = minimal_settings(
r#"
[providers.test]
display_name = "Test"
adapter = "openai"
agent_profile = "openai"
[models.model]
provider = "test"
display_name = "Model"
family = "test"
default = true
[models.model.limits]
context_window = 1000
[models.model.features]
tools = true
vision = false
reasoning = false
reasoning_effort = "adaptive"
"#,
);
assert!(matches!(
Catalog::from_settings(&settings),
Err(CatalogBuildError::ReasoningEffortWithoutReasoning { .. })
));
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo nextest run -p fabro-model`
Expected: FAIL — `adaptive` does not parse (unknown variant).
- [ ] **Step 3: Implement**
`lib/crates/fabro-model/src/types.rs` — add variant (keep `None` as `#[default]`):
```rust
pub enum ReasoningEffortFeature {
Levels,
/// Effort levels are supported and thinking is natively always-on /
/// adaptive; a manual thinking on/off toggle is not accepted.
Adaptive,
#[default]
None,
}
```
Add a method on `ModelFeatures` (single source of truth for "has a native effort param"):
```rust
impl ModelFeatures {
/// Whether the model endpoint accepts a native reasoning-effort level.
#[must_use]
pub fn supports_reasoning_effort(&self) -> bool {
matches!(
self.reasoning_effort,
ReasoningEffortFeature::Levels | ReasoningEffortFeature::Adaptive
)
}
}
```
Change `Model::supports_reasoning_effort` (types.rs:116-118) to delegate:
```rust
pub fn supports_reasoning_effort(&self) -> bool {
self.features.supports_reasoning_effort()
}
```
`lib/crates/fabro-model/src/catalog.rs:1430` — any effort feature requires `reasoning`:
```rust
if !reasoning && reasoning_effort != ReasoningEffortFeature::None {
```
`lib/crates/fabro-model/src/catalog.rs:1499-1500`:
```rust
let supports_native_reasoning_effort = features.supports_reasoning_effort();
```
`docs/public/api-reference/fabro-api.yaml:7592` — update the enum schema:
```yaml
ReasoningEffortFeature:
description: >-
Whether the model endpoint supports a native reasoning-effort
parameter. `levels` accepts discrete effort levels; `adaptive`
accepts effort levels with natively always-on adaptive thinking;
`none` has no native effort parameter.
type: string
enum:
- levels
- adaptive
- none
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo nextest run -p fabro-model && cargo build -p fabro-api && cargo nextest run -p fabro-api`
Expected: PASS (fabro-api build regenerates types from the spec; parity tests still pass since the enum reuses the canonical Rust type).
- [ ] **Step 5: Verify zero behavior change**
Run: `cargo nextest run -p fabro-llm -p fabro-config`
Expected: PASS — nothing references `Adaptive` yet; no TOML uses it.
- [ ] **Step 6: Commit**
```bash
git add lib/crates/fabro-model docs/public/api-reference/fabro-api.yaml lib/crates/fabro-api
git commit -m "feat(model): add adaptive reasoning-effort feature variant"
```
---
### Task 2: Add `ModelFeatures.sampling_params`
**Files:**
- Modify: `lib/crates/fabro-model/src/types.rs` (`ModelFeatures` :35, test fixture :179)
- Modify: `lib/crates/fabro-model/src/catalog.rs` (`SettingsModelFeatures` :115, `merge_model_features_settings` :1182, `build_model_features` :1419)
- Modify: `lib/crates/fabro-config/src/layers/llm.rs` (layer `ModelFeatures`, ~:152)
- Modify: `lib/crates/fabro-config/src/builders.rs:393` (`model_features_to_catalog`)
- Modify: `docs/public/api-reference/fabro-api.yaml` (`ModelFeatures` schema, ~:7609)
- Modify: `lib/crates/fabro-api/tests/model_features_round_trip.rs`
- Modify (test fixtures that construct `ModelFeatures` literally — run `rg -n 'ModelFeatures \{' lib/crates` to confirm the full list): `lib/crates/fabro-cli/src/commands/model.rs:495,528`, `lib/crates/fabro-llm/src/model_test.rs:227`
- [ ] **Step 1: Write the failing test**
In `lib/crates/fabro-model/src/catalog.rs` test module:
```rust
#[test]
fn catalog_from_settings_sampling_params_defaults_true_and_accepts_false() {
let settings = minimal_settings(
r#"
[providers.test]
display_name = "Test"
adapter = "openai"
agent_profile = "openai"
[models.with-sampling]
provider = "test"
display_name = "With"
family = "test"
default = true
[models.with-sampling.limits]
context_window = 1000
[models.with-sampling.features]
tools = true
vision = false
reasoning = false
[models.no-sampling]
provider = "test"
display_name = "Without"
family = "test"
[models.no-sampling.limits]
context_window = 1000
[models.no-sampling.features]
tools = true
vision = false
reasoning = false
sampling_params = false
"#,
);
let catalog = Catalog::from_settings(&settings).unwrap();
assert!(catalog.get("with-sampling").unwrap().features.sampling_params);
assert!(!catalog.get("no-sampling").unwrap().features.sampling_params);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo nextest run -p fabro-model catalog_from_settings_sampling_params`
Expected: FAIL to compile (no `sampling_params` field) — compile error is the failing state.
- [ ] **Step 3: Implement**
`lib/crates/fabro-model/src/types.rs` — add field to `ModelFeatures` (last position) and a serde default helper:
```rust
fn default_true() -> bool {
true
}
pub struct ModelFeatures {
// ... existing fields unchanged ...
/// Whether the model endpoint accepts classic sampling parameters
/// (`temperature`, `top_p`). Models with always-on adaptive behavior
/// reject them.
#[serde(default = "default_true")]
pub sampling_params: bool,
}
```
Add a `Model` accessor next to `supports_prompt_cache` (types.rs:120):
```rust
pub fn supports_sampling_params(&self) -> bool {
self.features.sampling_params
}
```
`lib/crates/fabro-model/src/catalog.rs`:
```rust
// SettingsModelFeatures (:115) — add:
#[serde(default)]
pub sampling_params: Option<bool>,
// merge_model_features_settings (:1186) — add:
sampling_params: higher.sampling_params.or(fallback.sampling_params),
// build_model_features Ok(ModelFeatures { ... }) (:1436) — add:
sampling_params: features.sampling_params.unwrap_or(true),
```
`lib/crates/fabro-config/src/layers/llm.rs` — add to the layer `ModelFeatures` struct:
```rust
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sampling_params: Option<bool>,
```
(`Option<bool>` already has a `Combine` impl via `impl_combine_or_option!` in `layers/combine.rs` — no macro change needed.)
`lib/crates/fabro-config/src/builders.rs:393` `model_features_to_catalog` — add:
```rust
sampling_params: features.sampling_params,
```
`docs/public/api-reference/fabro-api.yaml` `ModelFeatures` schema — add to `required` and `properties`:
```yaml
required:
- tools
- vision
- reasoning
- reasoning_effort
- prompt_cache
- sampling_params
properties:
# ... existing ...
sampling_params:
type: boolean
description: Whether the model accepts classic sampling parameters (temperature, top_p).
```
`lib/crates/fabro-api/tests/model_features_round_trip.rs` — add `sampling_params: true` to the fixture and `assert_eq!(json["sampling_params"], true);`.
Fix all remaining `ModelFeatures { ... }` struct literals the compiler flags (test fixtures in `fabro-cli/src/commands/model.rs`, `fabro-llm/src/model_test.rs`, `fabro-model/src/types.rs` test) by adding `sampling_params: true`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo build --workspace && cargo nextest run -p fabro-model -p fabro-config -p fabro-api -p fabro-cli -p fabro-llm`
Expected: PASS. (fabro-api regenerates from the spec during build; parity test passes with the new field.)
- [ ] **Step 5: Commit**
```bash
git add lib/crates docs/public/api-reference/fabro-api.yaml
git commit -m "feat(model): add sampling_params model feature flag"
```
---
### Task 3: Refactor the Anthropic adapter onto catalog data
This is the behavior task: the TOML flip and the adapter changes must land together (e.g. once Fable's TOML says `adaptive`, the old `supports_effort` check `== Levels` would wrongly convert effort to `budget_tokens` — so the existing Fable wire tests only pass with both halves in place).
**Deliberate wire change:** Opus requests stop sending the `context-1m-2025-08-07` beta header. Per Anthropic's context-windows doc (verified 2026-06-10), 1M context is GA on Opus 4.6/4.7/4.8 (and Sonnet 4.6) on the Claude API — the header is a no-op. Users who genuinely need a beta header on a custom setup can still pass request-level `provider_options.anthropic.beta_headers`.
**Files:**
- Modify: `lib/crates/fabro-model/src/catalog/providers/anthropic.toml` (Fable features only)
- Modify: `lib/crates/fabro-llm/src/providers/anthropic.rs`
- Modify: `lib/crates/fabro-llm/tests/it/wire/anthropic.rs` (one new test)
- Modify: `lib/crates/fabro-workflow/src/handler/llm/api.rs` (test fixture message only)
- [ ] **Step 1: Write the failing wire test (opus no longer gets the 1M beta header)**
In `lib/crates/fabro-llm/tests/it/wire/anthropic.rs`, next to `encode_fable_uses_api_id_effort_and_omits_1m_beta`:
```rust
#[tokio::test]
async fn encode_opus_omits_1m_beta_header() {
let capture = encode_capture(
adapter().with_catalog(builtin_catalog()),
&base_request("claude-opus-4-8"),
)
.await;
assert!(
!header_value(&capture, "anthropic-beta")
.unwrap_or("")
.contains("context-1m-2025-08-07"),
"1M context is GA on opus; the legacy beta opt-in must not be sent"
);
}
```
- [ ] **Step 2: Run the new test to verify it fails**
Run: `cargo nextest run -p fabro-llm encode_opus_omits_1m_beta_header`
Expected: FAIL — the current heuristic still adds the header for 1M-context catalog models.
- [ ] **Step 3: Flip the TOML data**
`lib/crates/fabro-model/src/catalog/providers/anthropic.toml` — Fable features block (lines 23-28) becomes:
```toml
[models."claude-fable-5".features]
tools = true
vision = true
reasoning = true
reasoning_effort = "adaptive"
prompt_cache = true
sampling_params = false
```
No changes to the opus entries.
- [ ] **Step 4: Refactor `lib/crates/fabro-llm/src/providers/anthropic.rs`**
Delete the model constant (line 591):
```rust
// DELETE: const CLAUDE_FABLE_5_MODEL: &str = "claude-fable-5";
```
Delete the 1M beta constant and heuristic:
- Remove `const CONTEXT_1M_BETA_HEADER` (line 688).
- Remove the `include_1m_context: bool` parameter from `build_beta_header` (line 690) and the block at :723 that appends the header.
- At both call sites (`build_api_request` :1419-1426 and `count_input_tokens` :1465-1481): delete the `include_1m_context` computation and the argument.
- In the test module, drop the final `false` argument from every `build_beta_header(...)` call (lines 2040, 2046, 2057, 2073, 2085, 2631, 2645, 3025).
In `build_api_request` (around :1289):
```rust
let model_info = common::catalog_model(adapter.catalog.as_deref(), &request.model);
let api_model = common::api_model_id(adapter.catalog.as_deref(), &request.model);
// DELETE: let is_fable = api_model == CLAUDE_FABLE_5_MODEL;
```
`supports_effort` (:1327) — Adaptive also takes the effort parameter:
```rust
let supports_effort = model_info.is_none_or(Model::supports_reasoning_effort);
```
(Import: change `use fabro_model::{Catalog, ReasoningEffortFeature};` to also bring in `Model`.)
Auto-adaptive thinking injection (:1359) — `Levels` only; `Adaptive` models are natively adaptive and must not receive a `thinking` param:
```rust
let thinking = explicit_thinking.or_else(|| {
if model_info
.is_some_and(|m| m.features.reasoning_effort == ReasoningEffortFeature::Levels)
{
Some(serde_json::json!({"type": "adaptive"}))
} else {
None
}
});
```
Sampling gate (:1378):
```rust
// Models with always-on adaptive behavior reject classic sampling knobs.
let (temperature, top_p) = if model_info.is_none_or(|m| m.features.sampling_params) {
(request.temperature, request.top_p)
} else {
(None, None)
};
```
`validate_request` (:1676):
```rust
fn validate_request(&self, request: &Request) -> Result<(), Error> {
if let Some(tool_choice) = &request.tool_choice {
crate::provider::validate_tool_choice(self, tool_choice)?;
}
let model_info = common::catalog_model(self.catalog.as_deref(), &request.model);
if let Some(model) = model_info
.filter(|m| m.features.reasoning_effort == ReasoningEffortFeature::Adaptive)
{
if let Some(kind @ ("enabled" | "disabled")) =
anthropic_thinking_type(request.provider_options.as_ref())
{
return Err(Error::Configuration {
message: format!(
"{} uses always-on adaptive thinking; provider_options.anthropic.thinking.type = \"{kind}\" is not supported. Omit thinking or set only display options.",
model.display_name()
),
source: None,
});
}
}
Ok(())
}
}
```
`refusal_error` — take the model ID and use it in the message (the response model in `complete`, the accumulator model in streaming):
```rust
fn refusal_error(
provider_name: &str,
model: &str,
raw: serde_json::Value,
stop_details: Option<&serde_json::Value>,
) -> Error {
let model_label = if model.is_empty() { "model" } else { model };
let message = stop_details
.and_then(|details| details.get("explanation"))
.and_then(serde_json::Value::as_str)
.map_or_else(
|| format!("{model_label} refused the request"),
|explanation| format!("{model_label} refused the request: {explanation}"),
);
// ... Error::Provider construction unchanged ...
}
```
Call sites: in `complete()` pass `&api_resp.model`; in `process_sse_event_for_provider` pass `&acc.model` (populated by `message_start`, empty-string fallback handled above). `process_sse_event_for_provider` already receives `acc`.
- [ ] **Step 5: Update the workflow test fixture message**
`lib/crates/fabro-workflow/src/handler/llm/api.rs` (test module): in `refusal_llm_error()` change `message: "Claude Fable 5 refused the request".into()` to `message: "claude-fable-5 refused the request".into()`. In `classify_refusal_llm_returns_terminal_when_not_allowed` change `assert!(llm_err.to_string().contains("Claude Fable 5 refused"))` to `assert!(llm_err.to_string().contains("refused the request"))`.
Also update the wire test `decode_refusal_returns_failover_eligible_content_filter_error` in `lib/crates/fabro-llm/tests/it/wire/anthropic.rs` if needed — its `detail.message.contains("declined")` assertion still passes (the explanation is embedded); optionally strengthen with `detail.message.contains("claude-fable-5")` since the canned body's `model` is `claude-fable-5`.
- [ ] **Step 6: Run the full affected test suites**
Run: `cargo nextest run -p fabro-llm -p fabro-model -p fabro-workflow`
Expected: PASS, including all pre-existing Fable wire tests unchanged (`encode_fable_uses_api_id_effort_and_omits_1m_beta`, `encode_fable_without_effort_omits_default_thinking`, `fable_rejects_manual_enabled_or_disabled_thinking` — its `contains("Claude Fable 5")` assertion still passes because the message now uses the catalog `display_name`, which is "Claude Fable 5" — and the refusal decode/stream tests) plus the new opus test.
- [ ] **Step 7: Verify no snapshot drift and no remaining leaks**
Run: `cargo insta pending-snapshots`
Expected: empty output (zero snapshot changes).
Run: `rg -n "is_fable|CLAUDE_FABLE|claude-fable|Claude Fable|context-1m" lib/crates/fabro-llm/src lib/crates/fabro-workflow/src --type rust` — confirm every remaining hit is inside a `#[cfg(test)]` module or gone. Expected: no production-code hits. (`"Claude Fable 5"` remains only as catalog TOML data.)
- [ ] **Step 8: Commit**
```bash
git add lib/crates/fabro-model/src/catalog/providers/anthropic.toml lib/crates/fabro-llm lib/crates/fabro-workflow
git commit -m "refactor(llm): drive Fable request behaviors from catalog data"
```
---
### Task 4: Regenerate TypeScript client; full verification
**Files:**
- Regenerate: `lib/packages/fabro-api-client` (generated)
- Possibly modify: `apps/fabro-web` (only if typecheck flags exhaustive switches on `ReasoningEffortFeature`)
- [ ] **Step 1: Regenerate the TS client**
Run: `cd lib/packages/fabro-api-client && bun run generate`
Expected: regenerated types include `"adaptive"` in the reasoning-effort-feature union and `sampling_params` on model features.
- [ ] **Step 2: Web typecheck and tests**
Run: `cd apps/fabro-web && bun run typecheck && bun test`
Expected: PASS. If an exhaustive switch over the feature enum breaks, handle `"adaptive"` the same way as `"levels"`.
- [ ] **Step 3: Full workspace verification**
Run:
```bash
cargo build --workspace
cargo nextest run --workspace
cargo +nightly-2026-04-14 fmt --all
cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings
```
Expected: all green. If fmt changes files, include them in the commit.
- [ ] **Step 4: Commit**
```bash
git add lib/packages/fabro-api-client apps/fabro-web
git commit -m "chore(api): regenerate TS client for adaptive effort and sampling_params"
```
---
## Self-review notes
- Tasks 12 are pure mechanism: no TOML uses the new fields until Task 3, so behavior and snapshots are provably unchanged at each commit.
- Task 3's oracle: the existing Fable wire tests pass unchanged, the new opus test pins the deliberate header removal, and `cargo insta pending-snapshots` is empty.
- The original plan had a per-model `provider_options.beta_headers` catalog mechanism; it was cut after verifying no catalog model needs the 1M beta header (GA per Anthropic docs). If a future model needs a per-model wire opt-in, resurrect that design from git history of this plan.
- The `1c8fe39ab` changelog/docs files (`docs/public/changelog/2026-06-10.mdx`, `models.mdx`) describe user-visible behavior only and need no edits for this refactor.

View file

@ -1073,6 +1073,7 @@ mod tests {
reasoning: Some(false),
reasoning_effort: None,
prompt_cache: None,
sampling_params: None,
}),
..ModelCatalogSettings::default()
});
@ -1157,6 +1158,7 @@ mod tests {
reasoning: Some(false),
reasoning_effort: None,
prompt_cache: None,
sampling_params: None,
}),
..ModelCatalogSettings::default()
});

View file

@ -16,6 +16,7 @@ fn model_features_json_matches_openapi_shape() {
reasoning: true,
reasoning_effort: ReasoningEffortFeature::Levels,
prompt_cache: false,
sampling_params: true,
};
let json = serde_json::to_value(&features).unwrap();
@ -24,6 +25,7 @@ fn model_features_json_matches_openapi_shape() {
assert_eq!(json["reasoning"], true);
assert_eq!(json["reasoning_effort"], "levels");
assert_eq!(json["prompt_cache"], false);
assert_eq!(json["sampling_params"], true);
let round_trip: ApiModelFeatures = serde_json::from_value(json).unwrap();
assert_eq!(round_trip, features);

View file

@ -29,6 +29,7 @@ fn model_json_matches_openapi_shape() {
reasoning: true,
reasoning_effort: ReasoningEffortFeature::Levels,
prompt_cache: true,
sampling_params: true,
},
costs: ModelCosts {
input_cost_per_mtok: Some(5.0),

View file

@ -39,6 +39,7 @@ fn provider_id_json_matches_openapi_shape_through_model() {
reasoning: false,
reasoning_effort: ReasoningEffortFeature::None,
prompt_cache: false,
sampling_params: true,
},
costs: ModelCosts {
input_cost_per_mtok: None,

View file

@ -494,6 +494,7 @@ mod tests {
reasoning: false,
reasoning_effort: ReasoningEffortFeature::None,
prompt_cache: false,
sampling_params: true,
},
costs: ModelCosts {
input_cost_per_mtok: Some(1.0),
@ -527,6 +528,7 @@ mod tests {
reasoning: false,
reasoning_effort: ReasoningEffortFeature::None,
prompt_cache: false,
sampling_params: true,
},
costs: ModelCosts {
input_cost_per_mtok: Some(1.0),

View file

@ -397,6 +397,7 @@ fn model_features_to_catalog(features: &LlmModelFeatures) -> model_catalog::Sett
reasoning: features.reasoning,
reasoning_effort: features.reasoning_effort,
prompt_cache: features.prompt_cache,
sampling_params: features.sampling_params,
}
}

View file

@ -161,6 +161,8 @@ pub struct ModelFeatures {
pub reasoning_effort: Option<ReasoningEffortFeature>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_cache: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sampling_params: Option<bool>,
}
/// User-facing allow-list for native control values Fabro accepts on this

View file

@ -325,14 +325,15 @@ cache_input_cost_per_mtok = 0.60
| `tools` | boolean | `false` | Whether the model supports tool calls. |
| `vision` | boolean | `false` | Whether the model accepts image inputs. |
| `reasoning` | boolean | `false` | Whether the model has reasoning behavior. |
| `reasoning_effort` | `"levels"` \| `"none"` | `"none"` | Whether the model endpoint supports a native reasoning-effort parameter. |
| `reasoning_effort` | `"levels"` \| `"always_adaptive"` \| `"none"` | `"none"` | Whether the model endpoint supports a native reasoning-effort parameter. `levels` accepts discrete effort levels; `always_adaptive` accepts effort levels with natively always-on adaptive thinking; `none` has no native effort parameter. |
| `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. |
| `sampling_params` | boolean | `true` | Whether the model accepts classic sampling parameters (`temperature`, `top_p`). |
## `[llm.models.<id>.controls]`
| Key | Type / values | Default | Description |
|---|---|---|---|
| `reasoning_effort` | array<string> | all standard levels when feature is `"levels"` | User-facing reasoning effort values Fabro may send for this model. Can be set explicitly for reasoning models whose provider adapter maps effort to a non-native API shape. |
| `reasoning_effort` | array<string> | all standard levels when feature is `"levels"` or `"always_adaptive"` | User-facing reasoning effort values Fabro may send for this model. Can be set explicitly for reasoning models whose provider adapter maps effort to a non-native API shape. |
| `speed` | array<string> | `[]` | Additional speeds beyond implicit `standard`; do not list `standard`. |
## `[llm.models.<id>.costs]`

View file

@ -209,7 +209,7 @@ impl Error {
/// same-provider retry) plus `QuotaExceeded` — a different provider won't
/// share the same quota.
#[must_use]
pub const fn failover_eligible(&self) -> bool {
pub fn failover_eligible(&self) -> bool {
if self.retryable() {
return true;
}
@ -219,6 +219,16 @@ impl Error {
kind: ProviderErrorKind::QuotaExceeded,
..
} | Self::RequestTimeout { .. }
) || self.refusal_content_filter()
}
fn refusal_content_filter(&self) -> bool {
matches!(
self,
Self::Provider {
kind: ProviderErrorKind::ContentFilter,
detail,
} if detail.error_code.as_deref() == Some("refusal")
)
}
@ -926,6 +936,35 @@ mod tests {
);
}
#[test]
fn failover_eligible_for_refusal_content_filter_only() {
assert!(
Error::Provider {
kind: ProviderErrorKind::ContentFilter,
detail: Box::new(ProviderErrorDetail {
error_code: Some("refusal".to_string()),
raw: Some(serde_json::json!({
"stop_reason": "refusal",
"stop_details": {"type": "refusal", "category": "cyber"}
})),
..ProviderErrorDetail::new("declined", "anthropic")
}),
}
.failover_eligible()
);
assert!(
!Error::Provider {
kind: ProviderErrorKind::ContentFilter,
detail: Box::new(ProviderErrorDetail {
error_code: Some("safety".to_string()),
..ProviderErrorDetail::new("blocked", "anthropic")
}),
}
.failover_eligible()
);
}
#[test]
fn failover_not_eligible_non_provider_errors() {
assert!(

View file

@ -226,6 +226,7 @@ mod tests {
reasoning: true,
reasoning_effort: ReasoningEffortFeature::Levels,
prompt_cache: false,
sampling_params: true,
});
let outcome = run_model_test(&info, ModelTestMode::Deep, empty_test_client()).await;

View file

@ -2,11 +2,11 @@ use std::sync::Arc;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use fabro_model::{Catalog, ReasoningEffortFeature};
use fabro_model::{Catalog, Model, ReasoningEffortFeature};
use futures::stream;
use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind, error_from_status_code};
use crate::provider::{ProviderAdapter, StreamEventStream};
use crate::provider::{ProviderAdapter, StreamEventStream, validate_tool_choice};
use crate::providers::common::{
self as common, extract_system_prompt, parse_error_body, parse_rate_limit_headers,
parse_retry_after, send_and_read_response,
@ -205,11 +205,13 @@ impl CacheControl {
#[derive(serde::Deserialize)]
struct ApiResponse {
id: String,
model: String,
content: Vec<serde_json::Value>,
stop_reason: Option<String>,
usage: ApiUsage,
id: String,
model: String,
content: Vec<serde_json::Value>,
stop_reason: Option<String>,
#[serde(default)]
stop_details: Option<serde_json::Value>,
usage: ApiUsage,
}
#[derive(serde::Deserialize)]
@ -625,6 +627,14 @@ fn is_auto_cache_enabled(provider_options: Option<&serde_json::Value>) -> bool {
.unwrap_or(true)
}
fn anthropic_thinking_type(provider_options: Option<&serde_json::Value>) -> Option<&str> {
provider_options
.and_then(|opts| opts.get("anthropic"))
.and_then(|anthropic| anthropic.get("thinking"))
.and_then(|thinking| thinking.get("type"))
.and_then(serde_json::Value::as_str)
}
/// Wrap a system prompt string as an array of content blocks with
/// `cache_control` on the last block.
fn system_with_cache_control(system: &str) -> serde_json::Value {
@ -674,13 +684,10 @@ fn apply_cache_control_to_conversation_prefix(messages: &mut [ApiMessage]) {
/// Collect beta headers from `provider_options` and merge with the caching
/// header when auto-caching is active.
const CONTEXT_1M_BETA_HEADER: &str = "context-1m-2025-08-07";
fn build_beta_header(
provider_options: Option<&serde_json::Value>,
include_cache_header: bool,
include_fast_mode_header: bool,
include_1m_context: bool,
) -> Option<String> {
let mut headers: Vec<String> = Vec::new();
@ -708,11 +715,6 @@ fn build_beta_header(
headers.push(FAST_MODE_BETA_HEADER.to_string());
}
// Add 1M context header for models with >= 1M context window
if include_1m_context && !headers.iter().any(|h| h == CONTEXT_1M_BETA_HEADER) {
headers.push(CONTEXT_1M_BETA_HEADER.to_string());
}
if headers.is_empty() {
None
} else {
@ -1043,11 +1045,67 @@ fn process_sse_event_for_provider(
) -> Result<Vec<StreamEvent>, Error> {
if event_type == "error" {
Err(stream_error_event_to_provider_error(data, provider_name))
} else if event_type == "message_delta"
&& data
.get("delta")
.and_then(|delta| delta.get("stop_reason"))
.and_then(serde_json::Value::as_str)
== Some("refusal")
{
let stop_details = data
.get("delta")
.and_then(|delta| delta.get("stop_details"));
Err(refusal_error(
provider_name,
&acc.model,
refusal_stream_raw(data),
stop_details,
))
} else {
Ok(process_sse_event(event_type, data, acc))
}
}
fn refusal_stream_raw(data: &serde_json::Value) -> serde_json::Value {
serde_json::json!({
"stop_reason": "refusal",
"stop_details": data
.get("delta")
.and_then(|delta| delta.get("stop_details"))
.cloned()
.unwrap_or(serde_json::Value::Null),
"stream_event": data,
})
}
fn refusal_error(
provider_name: &str,
model: &str,
raw: serde_json::Value,
stop_details: Option<&serde_json::Value>,
) -> Error {
let model_label = if model.is_empty() { "The model" } else { model };
let message = stop_details
.and_then(|details| details.get("explanation"))
.and_then(serde_json::Value::as_str)
.map_or_else(
|| format!("{model_label} refused the request"),
|explanation| format!("{model_label} refused the request: {explanation}"),
);
Error::Provider {
kind: ProviderErrorKind::ContentFilter,
detail: Box::new(ProviderErrorDetail {
message,
provider: provider_name.to_string(),
status_code: None,
error_code: Some("refusal".to_string()),
retry_after: None,
raw: Some(raw),
}),
}
}
fn stream_error_event_to_provider_error(data: &serde_json::Value, provider_name: &str) -> Error {
let error = data.get("error").unwrap_or(data);
let message = error
@ -1223,6 +1281,7 @@ async fn build_api_request(
};
let model_info = common::catalog_model(adapter.catalog.as_deref(), &request.model);
let api_model = common::api_model_id(adapter.catalog.as_deref(), &request.model);
let supports_prompt_cache = model_info.is_some_and(|m| m.features.prompt_cache);
let auto_cache =
supports_prompt_cache && is_auto_cache_enabled(request.provider_options.as_ref());
@ -1258,18 +1317,30 @@ async fn build_api_request(
// Check whether this model supports the `output_config.effort` parameter.
// Older reasoning models (e.g. claude-sonnet-4-5) need `thinking` with
// `budget_tokens` instead.
let supports_effort =
model_info.is_none_or(|m| m.features.reasoning_effort == ReasoningEffortFeature::Levels);
let supports_effort = model_info.is_none_or(Model::supports_reasoning_effort);
let mut resolved_max_tokens = request
.max_tokens
.or_else(|| model_info.and_then(|m| m.limits.max_output))
.unwrap_or(65536);
// Default thinking when none is configured explicitly: adaptive for
// `levels` models, with or without an effort level — effort is guidance
// for thinking allocation, not a replacement for it. Natively adaptive
// models don't need one injected (and reject a manual on/off toggle).
let default_thinking = || {
if model_info.is_some_and(|m| m.features.reasoning_effort == ReasoningEffortFeature::Levels)
{
Some(serde_json::json!({"type": "adaptive"}))
} else {
None
}
};
let (mut thinking, mut output_config) = if let Some(effort) = &request.reasoning_effort {
if supports_effort {
(
explicit_thinking,
explicit_thinking.or_else(default_thinking),
Some(serde_json::json!({"effort": <&'static str>::from(*effort)})),
)
} else if explicit_thinking.is_none() {
@ -1288,18 +1359,7 @@ async fn build_api_request(
(explicit_thinking, None)
}
} else {
// Auto-set adaptive thinking for known effort-capable models when no
// explicit thinking config or reasoning_effort is provided.
let thinking = explicit_thinking.or_else(|| {
if model_info
.is_some_and(|m| m.features.reasoning_effort == ReasoningEffortFeature::Levels)
{
Some(serde_json::json!({"type": "adaptive"}))
} else {
None
}
});
(thinking, None)
(explicit_thinking.or_else(default_thinking), None)
};
if tool_choice_forces_tool_use(tool_choice_json.as_ref()) {
@ -1308,14 +1368,23 @@ async fn build_api_request(
}
let is_fast = request.speed == Some(Speed::Fast);
// Models with `sampling_params = false` reject classic sampling knobs.
// This gate covers only the typed request fields; values injected through
// `provider_options.anthropic` (e.g. `top_k`) are a raw escape hatch and
// pass through unfiltered.
let (temperature, top_p) = if model_info.is_none_or(Model::supports_sampling_params) {
(request.temperature, request.top_p)
} else {
(None, None)
};
let api_request = ApiRequest {
model: common::api_model_id(adapter.catalog.as_deref(), &request.model),
model: api_model,
messages: api_messages,
max_tokens: resolved_max_tokens,
system: system_value,
temperature: request.temperature,
top_p: request.top_p,
temperature,
top_p,
stop_sequences: Some(request.stop_sequences.clone().unwrap_or_default()),
tools: api_tools,
tool_choice: tool_choice_json,
@ -1343,13 +1412,9 @@ async fn build_api_request(
}
req_builder = req_builder.header("anthropic-version", "2023-06-01");
let include_1m_context = model_info.is_some_and(|m| m.context_window() >= 1_000_000);
if let Some(beta_str) = build_beta_header(
request.provider_options.as_ref(),
auto_cache,
is_fast,
include_1m_context,
) {
if let Some(beta_str) =
build_beta_header(request.provider_options.as_ref(), auto_cache, is_fast)
{
req_builder = req_builder.header("anthropic-beta", beta_str);
}
} else if let Some(api_key) = &adapter.http.api_key {
@ -1386,7 +1451,6 @@ impl ProviderAdapter for Adapter {
let auto_cache =
supports_prompt_cache && is_auto_cache_enabled(request.provider_options.as_ref());
let is_fast = request.speed == Some(Speed::Fast);
let include_1m_context = model_info.is_some_and(|m| m.context_window() >= 1_000_000);
let url = self.count_tokens_url();
let mut req = self.http.client.post(&url);
@ -1397,12 +1461,9 @@ impl ProviderAdapter for Adapter {
req = req.header("x-api-key", api_key);
}
req = req.header("anthropic-version", "2023-06-01");
if let Some(beta_str) = build_beta_header(
request.provider_options.as_ref(),
auto_cache,
is_fast,
include_1m_context,
) {
if let Some(beta_str) =
build_beta_header(request.provider_options.as_ref(), auto_cache, is_fast)
{
req = req.header("anthropic-beta", beta_str);
}
@ -1447,12 +1508,27 @@ impl ProviderAdapter for Adapter {
}
let (body, headers) = send_and_read_response(req, &self.provider_name, "type").await?;
let api_resp: ApiResponse = serde_json::from_str(&body).map_err(|e| {
let raw: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
Error::network(
format!("failed to parse {} response: {e}", self.provider_name),
e,
)
})?;
let api_resp: ApiResponse = serde_json::from_value(raw.clone()).map_err(|e| {
Error::network(
format!("failed to parse {} response: {e}", self.provider_name),
e,
)
})?;
if api_resp.stop_reason.as_deref() == Some("refusal") {
return Err(refusal_error(
&self.provider_name,
&api_resp.model,
raw,
api_resp.stop_details.as_ref(),
));
}
let content_parts: Vec<ContentPart> = api_resp
.content
@ -1486,7 +1562,7 @@ impl ProviderAdapter for Adapter {
},
finish_reason,
usage: token_counts_from_api_usage(&api_resp.usage),
raw: serde_json::from_str(&body).ok(),
raw: Some(raw),
warnings: vec![],
rate_limit: parse_rate_limit_headers(&headers),
})
@ -1582,6 +1658,31 @@ impl ProviderAdapter for Adapter {
fn supports_tool_choice(&self, mode: &str) -> bool {
matches!(mode, "auto" | "none" | "required" | "named")
}
fn validate_request(&self, request: &Request) -> Result<(), Error> {
if let Some(tool_choice) = &request.tool_choice {
validate_tool_choice(self, tool_choice)?;
}
let model_info = common::catalog_model(self.catalog.as_deref(), &request.model);
if let Some(model) = model_info
.filter(|m| m.features.reasoning_effort == ReasoningEffortFeature::AlwaysAdaptive)
{
if let Some(kind @ ("enabled" | "disabled")) =
anthropic_thinking_type(request.provider_options.as_ref())
{
return Err(Error::Configuration {
message: format!(
"{} uses always-on adaptive thinking; provider_options.anthropic.thinking.type = \"{kind}\" is not supported. Omit thinking or set only display options.",
model.display_name()
),
source: None,
});
}
}
Ok(())
}
}
#[cfg(test)]
@ -1924,13 +2025,13 @@ mod tests {
#[test]
fn beta_header_includes_cache_header() {
let result = build_beta_header(None, true, false, false);
let result = build_beta_header(None, true, false);
assert_eq!(result, Some(CACHE_BETA_HEADER.to_string()));
}
#[test]
fn beta_header_no_cache_no_user_headers() {
let result = build_beta_header(None, false, false, false);
let result = build_beta_header(None, false, false);
assert_eq!(result, None);
}
@ -1941,7 +2042,7 @@ mod tests {
"beta_headers": ["interleaved-thinking-2025-05-14"]
}
});
let result = build_beta_header(Some(&opts), true, false, false);
let result = build_beta_header(Some(&opts), true, false);
assert_eq!(
result,
Some(format!(
@ -1957,7 +2058,7 @@ mod tests {
"beta_headers": [CACHE_BETA_HEADER]
}
});
let result = build_beta_header(Some(&opts), true, false, false);
let result = build_beta_header(Some(&opts), true, false);
// Should not duplicate the header
assert_eq!(result, Some(CACHE_BETA_HEADER.to_string()));
}
@ -1969,10 +2070,25 @@ mod tests {
"beta_headers": ["interleaved-thinking-2025-05-14"]
}
});
let result = build_beta_header(Some(&opts), false, false, false);
let result = build_beta_header(Some(&opts), false, false);
assert_eq!(result, Some("interleaved-thinking-2025-05-14".to_string()));
}
#[test]
fn refusal_error_falls_back_to_generic_label_when_model_is_empty() {
let err = refusal_error("anthropic", "", serde_json::json!({}), None);
match err {
Error::Provider { detail, .. } => {
assert!(
detail.message.starts_with("The model refused"),
"unexpected message: {}",
detail.message
);
}
other => panic!("expected provider error, got {other:?}"),
}
}
#[test]
fn tool_serialization_includes_cache_control() {
let tool = ApiToolDef {
@ -2515,7 +2631,7 @@ reasoning = true
];
// No user headers — only cache header should appear
let header = build_beta_header(None, true, false, false).unwrap_or_default();
let header = build_beta_header(None, true, false).unwrap_or_default();
for dep in &deprecated {
assert!(
!header.contains(dep),
@ -2529,7 +2645,7 @@ reasoning = true
"beta_headers": ["interleaved-thinking-2025-05-14"]
}
});
let header = build_beta_header(Some(&opts), true, false, false).unwrap_or_default();
let header = build_beta_header(Some(&opts), true, false).unwrap_or_default();
for dep in &deprecated {
assert!(
!header.contains(dep),
@ -2909,7 +3025,7 @@ reasoning_effort = "levels"
}
#[test]
fn beta_header_includes_both_cache_and_fast_mode() {
let result = build_beta_header(None, true, true, false);
let result = build_beta_header(None, true, true);
let header = result.expect("should produce a header");
assert!(
header.contains(CACHE_BETA_HEADER),

View file

@ -1,10 +1,16 @@
//! Wire snapshots for the Anthropic Messages dialect.
use std::sync::Arc;
use fabro_llm::provider::ProviderAdapter;
use fabro_llm::providers::AnthropicAdapter;
use fabro_llm::types::{
Message, Request, ResponseFormat, ResponseFormatType, ToolChoice, ToolDefinition,
Message, ReasoningEffort, Request, ResponseFormat, ResponseFormatType, StreamEvent, ToolChoice,
ToolDefinition,
};
use fabro_llm::{Error, ProviderErrorKind};
use fabro_model::Catalog;
use futures::StreamExt;
use httpmock::prelude::*;
use crate::support::{
@ -64,6 +70,18 @@ fn adapter() -> AnthropicAdapter {
AnthropicAdapter::new("test-key")
}
fn builtin_catalog() -> Arc<Catalog> {
Arc::new(Catalog::from_builtin().expect("built-in catalog should build"))
}
fn header_value<'a>(capture: &'a WireCapture, name: &str) -> Option<&'a str> {
capture
.headers
.iter()
.find(|(header, _)| header == name)
.map(|(_, value)| value.as_str())
}
// ---------------------------------------------------------------------------
// Round trip (encode + decode)
// ---------------------------------------------------------------------------
@ -271,6 +289,131 @@ prompt_cache = false
fabro_test::fabro_json_snapshot!(capture.body);
}
#[tokio::test]
async fn encode_fable_uses_api_id_effort_and_omits_1m_beta() {
let request = Request {
reasoning_effort: Some(ReasoningEffort::XHigh),
temperature: Some(0.0),
top_p: Some(0.5),
..base_request("fable")
};
let capture = encode_capture(adapter().with_catalog(builtin_catalog()), &request).await;
assert_eq!(capture.body["model"], "claude-fable-5");
assert_eq!(capture.body["output_config"]["effort"], "xhigh");
assert!(capture.body.get("thinking").is_none());
assert!(capture.body.get("temperature").is_none());
assert!(capture.body.get("top_p").is_none());
assert!(
!header_value(&capture, "anthropic-beta")
.unwrap_or("")
.contains("context-1m-2025-08-07"),
"Fable has 1M context by default and must not receive the legacy beta header"
);
}
#[tokio::test]
async fn encode_opus_omits_1m_beta_header() {
let capture = encode_capture(
adapter().with_catalog(builtin_catalog()),
&base_request("claude-opus-4-8"),
)
.await;
assert!(
!header_value(&capture, "anthropic-beta")
.unwrap_or("")
.contains("context-1m-2025-08-07"),
"1M context is GA on opus; the legacy beta opt-in must not be sent"
);
}
#[tokio::test]
async fn encode_opus_drops_sampling_params() {
let request = Request {
temperature: Some(0.0),
top_p: Some(0.5),
..base_request("claude-opus-4-8")
};
let capture = encode_capture(adapter().with_catalog(builtin_catalog()), &request).await;
assert!(
capture.body.get("temperature").is_none(),
"Opus 4.7/4.8 reject temperature; it must not be sent"
);
assert!(
capture.body.get("top_p").is_none(),
"Opus 4.7/4.8 reject top_p; it must not be sent"
);
}
#[tokio::test]
async fn encode_opus_effort_keeps_adaptive_thinking() {
let request = Request {
reasoning_effort: Some(ReasoningEffort::High),
..base_request("claude-opus-4-8")
};
let capture = encode_capture(adapter().with_catalog(builtin_catalog()), &request).await;
assert_eq!(capture.body["output_config"]["effort"], "high");
assert_eq!(
capture.body["thinking"]["type"], "adaptive",
"asking for effort must not turn thinking off; Opus 4.7/4.8 run without thinking unless adaptive is sent"
);
}
#[tokio::test]
async fn encode_opus_without_effort_injects_adaptive_thinking() {
let capture = encode_capture(
adapter().with_catalog(builtin_catalog()),
&base_request("claude-opus-4-8"),
)
.await;
assert_eq!(capture.body["thinking"]["type"], "adaptive");
}
#[tokio::test]
async fn encode_fable_without_effort_omits_default_thinking() {
let capture = encode_capture(
adapter().with_catalog(builtin_catalog()),
&base_request("claude-fable-5"),
)
.await;
assert_eq!(capture.body["model"], "claude-fable-5");
assert!(capture.body.get("thinking").is_none());
}
#[test]
fn fable_rejects_manual_enabled_or_disabled_thinking() {
let adapter = adapter().with_catalog(builtin_catalog());
for kind in ["enabled", "disabled"] {
let request = Request {
provider_options: Some(serde_json::json!({
"anthropic": {
"thinking": {"type": kind, "budget_tokens": 1024}
}
})),
..base_request("claude-fable-5")
};
let err = adapter
.validate_request(&request)
.expect_err("manual Fable thinking mode should be rejected locally");
assert!(
err.to_string().contains("Claude Fable 5")
&& err.to_string().contains("thinking")
&& err.to_string().contains(kind),
"unexpected error: {err}"
);
}
}
#[tokio::test]
async fn encode_prompt_cache_with_catalog() {
let catalog = support::catalog_from_toml(
@ -351,6 +494,16 @@ async fn decode_response(body: serde_json::Value) -> fabro_llm::types::Response
response
}
/// Runs `complete()` against a canned body and returns the adapter result.
async fn complete_result(body: serde_json::Value) -> Result<fabro_llm::types::Response, Error> {
let server = MockServer::start();
let (mock, _slot) = mount_capture(&server, "/messages", body);
let adapter = adapter().with_base_url(server.base_url());
let result = adapter.complete(&base_request(MODEL)).await;
mock.assert();
result
}
#[tokio::test]
async fn decode_tool_use_stop_reason() {
let response = decode_response(serde_json::json!({
@ -411,6 +564,42 @@ async fn decode_max_tokens_stop_reason() {
fabro_test::fabro_json_snapshot!(response);
}
#[tokio::test]
async fn decode_refusal_returns_failover_eligible_content_filter_error() {
let err = complete_result(serde_json::json!({
"id": "msg_refusal",
"type": "message",
"role": "assistant",
"model": "claude-fable-5",
"content": [],
"stop_reason": "refusal",
"stop_details": {
"type": "refusal",
"category": "cyber",
"explanation": "This request was declined because it could enable cyber harm."
},
"usage": {"input_tokens": 412, "output_tokens": 0}
}))
.await
.expect_err("refusal should be returned as an LLM error");
assert!(err.failover_eligible());
match &err {
Error::Provider { kind, detail } => {
assert_eq!(*kind, ProviderErrorKind::ContentFilter);
assert_eq!(detail.provider, "anthropic");
assert_eq!(detail.error_code.as_deref(), Some("refusal"));
assert!(detail.message.contains("claude-fable-5"));
assert!(detail.message.contains("declined"));
assert_eq!(
detail.raw.as_ref().unwrap()["stop_details"]["category"],
"cyber"
);
}
other => panic!("expected provider content-filter error, got {other:?}"),
}
}
// ---------------------------------------------------------------------------
// Stream
// ---------------------------------------------------------------------------
@ -561,6 +750,58 @@ async fn stream_error_event_mid_stream() {
fabro_test::fabro_json_snapshot!(events);
}
#[tokio::test]
async fn stream_refusal_returns_error_without_final_response() {
let sse = support::sse_transcript(&[
(
"message_start",
r#"{"type":"message_start","message":{"id":"msg_stream_refusal","type":"message","role":"assistant","model":"claude-fable-5","content":[],"usage":{"input_tokens":412,"output_tokens":0}}}"#,
),
(
"message_delta",
r#"{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null,"stop_details":{"type":"refusal","category":"cyber","explanation":"This request was declined."}},"usage":{"output_tokens":0}}"#,
),
("message_stop", r#"{"type":"message_stop"}"#),
]);
let server = MockServer::start();
let (mock, _slot) = mount_capture_sse(&server, "/messages", &sse);
let adapter = adapter().with_base_url(server.base_url());
let mut stream = adapter
.stream(&base_request("claude-fable-5"))
.await
.expect("stream should start");
let mut saw_finish = false;
let mut refusal = None;
while let Some(item) = stream.next().await {
match item {
Ok(StreamEvent::Finish { .. }) => saw_finish = true,
Ok(_) => {}
Err(err) => {
refusal = Some(err);
break;
}
}
}
mock.assert();
assert!(!saw_finish, "refusal stream must not emit a final response");
let err = refusal.expect("stream should yield a refusal error");
assert!(err.failover_eligible());
match &err {
Error::Provider { kind, detail } => {
assert_eq!(*kind, ProviderErrorKind::ContentFilter);
assert_eq!(detail.error_code.as_deref(), Some("refusal"));
assert!(detail.message.contains("claude-fable-5"));
assert_eq!(
detail.raw.as_ref().unwrap()["stop_details"]["category"],
"cyber"
);
}
other => panic!("expected provider content-filter error, got {other:?}"),
}
}
/// The Anthropic decoder never synthesizes a `Finish` on byte-stream end:
/// `message_stop` is the only finisher. A transcript that ends without it
/// must produce no `Finish` event.

View file

@ -17,6 +17,9 @@ expression: rendered
],
"max_tokens": 128,
"stop_sequences": [],
"thinking": {
"type": "adaptive"
},
"output_config": {
"effort": "high"
}

View file

@ -123,6 +123,8 @@ pub struct SettingsModelFeatures {
pub reasoning_effort: Option<ReasoningEffortFeature>,
#[serde(default)]
pub prompt_cache: Option<bool>,
#[serde(default)]
pub sampling_params: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
@ -599,7 +601,7 @@ pub enum CatalogBuildError {
#[error("model '{model}' declares reasoning_effort feature but features.reasoning is false")]
ReasoningEffortWithoutReasoning { model: String },
#[error(
"model '{model}' must declare at least one reasoning_effort when features.reasoning_effort is levels"
"model '{model}' must declare at least one reasoning_effort when features.reasoning_effort is levels or always_adaptive"
)]
EmptyReasoningEffortControls { model: String },
#[error("model '{model}' has invalid speed '{value}'")]
@ -1189,6 +1191,7 @@ fn merge_model_features_settings(
reasoning: higher.reasoning.or(fallback.reasoning),
reasoning_effort: higher.reasoning_effort.or(fallback.reasoning_effort),
prompt_cache: higher.prompt_cache.or(fallback.prompt_cache),
sampling_params: higher.sampling_params.or(fallback.sampling_params),
}
}
@ -1427,7 +1430,7 @@ fn build_model_features(
field: "features.reasoning",
})?;
let reasoning_effort = features.reasoning_effort.unwrap_or_default();
if !reasoning && reasoning_effort == ReasoningEffortFeature::Levels {
if !reasoning && reasoning_effort != ReasoningEffortFeature::None {
return Err(CatalogBuildError::ReasoningEffortWithoutReasoning {
model: model_id.to_string(),
});
@ -1449,6 +1452,7 @@ fn build_model_features(
reasoning,
reasoning_effort,
prompt_cache: features.prompt_cache.unwrap_or_default(),
sampling_params: features.sampling_params.unwrap_or(true),
})
}
@ -1496,8 +1500,7 @@ fn build_model_controls(
features: &ModelFeatures,
settings: &ModelCatalogSettings,
) -> Result<CatalogModelControls, CatalogBuildError> {
let supports_native_reasoning_effort =
features.reasoning_effort == ReasoningEffortFeature::Levels;
let supports_native_reasoning_effort = features.supports_reasoning_effort();
let reasoning_effort = match settings
.controls
.as_ref()
@ -3448,6 +3451,52 @@ reasoning_effort = ["low", "medium"]
);
}
#[test]
fn catalog_from_settings_accepts_reasoning_effort_feature_always_adaptive() {
let settings = minimal_settings(
r#"
[providers.test]
display_name = "Test"
adapter = "openai"
agent_profile = "openai"
[models.model]
provider = "test"
display_name = "Model"
family = "test"
default = true
[models.model.limits]
context_window = 1000
[models.model.features]
tools = true
vision = false
reasoning = true
reasoning_effort = "always_adaptive"
prompt_cache = true
"#,
);
let catalog = Catalog::from_settings(&settings).unwrap();
let model = catalog.get("model").unwrap();
assert_eq!(
model.features.reasoning_effort,
crate::ReasoningEffortFeature::AlwaysAdaptive
);
assert!(model.supports_reasoning_effort());
// Always-adaptive models get the full default effort controls, same as
// Levels.
assert_eq!(
catalog
.model_settings("model")
.unwrap()
.controls
.reasoning_effort,
ReasoningEffort::VARIANTS.to_vec()
);
}
#[test]
fn catalog_from_settings_accepts_reasoning_effort_controls_without_native_effort_feature() {
let settings = minimal_settings(
@ -3560,6 +3609,89 @@ reasoning_effort = "levels"
));
}
#[test]
fn catalog_from_settings_rejects_always_adaptive_effort_without_reasoning() {
let settings = minimal_settings(
r#"
[providers.test]
display_name = "Test"
adapter = "openai"
agent_profile = "openai"
[models.model]
provider = "test"
display_name = "Model"
family = "test"
default = true
[models.model.limits]
context_window = 1000
[models.model.features]
tools = true
vision = false
reasoning = false
reasoning_effort = "always_adaptive"
"#,
);
assert!(matches!(
Catalog::from_settings(&settings).unwrap_err(),
CatalogBuildError::ReasoningEffortWithoutReasoning { model }
if model == "model"
));
}
#[test]
fn catalog_from_settings_sampling_params_defaults_true_and_accepts_false() {
let settings = minimal_settings(
r#"
[providers.test]
display_name = "Test"
adapter = "openai"
agent_profile = "openai"
[models.with-sampling]
provider = "test"
display_name = "With"
family = "test"
default = true
[models.with-sampling.limits]
context_window = 1000
[models.with-sampling.features]
tools = true
vision = false
reasoning = false
[models.no-sampling]
provider = "test"
display_name = "Without"
family = "test"
[models.no-sampling.limits]
context_window = 1000
[models.no-sampling.features]
tools = true
vision = false
reasoning = false
sampling_params = false
"#,
);
let catalog = Catalog::from_settings(&settings).unwrap();
assert!(
catalog
.get("with-sampling")
.unwrap()
.features
.sampling_params
);
assert!(!catalog.get("no-sampling").unwrap().features.sampling_params);
}
// ---- Provider / catalog data integrity tests ----
#[test]
@ -3637,6 +3769,7 @@ reasoning_effort = "levels"
reasoning: true,
reasoning_effort: Levels,
prompt_cache: true,
sampling_params: true,
},
costs: ModelCosts {
input_cost_per_mtok: Some(
@ -3692,6 +3825,7 @@ reasoning_effort = "levels"
reasoning: false,
reasoning_effort: None,
prompt_cache: false,
sampling_params: true,
},
costs: ModelCosts {
input_cost_per_mtok: Some(
@ -3755,6 +3889,7 @@ reasoning_effort = "levels"
reasoning: true,
reasoning_effort: Levels,
prompt_cache: false,
sampling_params: true,
},
costs: ModelCosts {
input_cost_per_mtok: Some(
@ -3810,6 +3945,7 @@ reasoning_effort = "levels"
reasoning: true,
reasoning_effort: Levels,
prompt_cache: false,
sampling_params: true,
},
costs: ModelCosts {
input_cost_per_mtok: Some(

View file

@ -9,6 +9,30 @@ priority = 100
credentials = ["env:ANTHROPIC_API_KEY", "vault:ANTHROPIC_API_KEY"]
header = { custom = "x-api-key" }
[models."claude-fable-5"]
provider = "anthropic"
api_id = "claude-fable-5"
display_name = "Claude Fable 5"
family = "claude-5"
aliases = ["fable", "claude-fable"]
[models."claude-fable-5".limits]
context_window = 1000000
max_output = 128000
[models."claude-fable-5".features]
tools = true
vision = true
reasoning = true
reasoning_effort = "always_adaptive"
prompt_cache = true
sampling_params = false
[models."claude-fable-5".costs]
input_cost_per_mtok = 10.0
output_cost_per_mtok = 50.0
cache_input_cost_per_mtok = 1.0
[models."claude-opus-4-8"]
provider = "anthropic"
api_id = "claude-opus-4-8"
@ -29,6 +53,7 @@ vision = true
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
sampling_params = false
[models."claude-opus-4-8".controls]
speed = ["fast"]
@ -62,6 +87,7 @@ vision = true
reasoning = true
reasoning_effort = "levels"
prompt_cache = true
sampling_params = false
[models."claude-opus-4-7".controls]
speed = ["fast"]

View file

@ -17,10 +17,14 @@ use crate::ids::ProviderId;
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum ReasoningEffortFeature {
Levels,
/// Effort levels are supported, and thinking is natively always-on
/// adaptive at the endpoint; a manual thinking on/off toggle is not
/// accepted.
AlwaysAdaptive,
#[default]
None,
}
@ -31,6 +35,10 @@ pub struct ModelLimits {
pub max_output: Option<i64>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelFeatures {
pub tools: bool,
@ -43,6 +51,22 @@ pub struct ModelFeatures {
/// Whether this model endpoint supports prompt caching annotations.
#[serde(default)]
pub prompt_cache: bool,
/// Whether the model endpoint accepts classic sampling parameters
/// (`temperature`, `top_p`). Models with always-on adaptive behavior
/// reject them.
#[serde(default = "default_true")]
pub sampling_params: bool,
}
impl ModelFeatures {
/// Whether the model endpoint accepts a native reasoning-effort level.
#[must_use]
pub fn supports_reasoning_effort(&self) -> bool {
matches!(
self.reasoning_effort,
ReasoningEffortFeature::Levels | ReasoningEffortFeature::AlwaysAdaptive
)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -114,13 +138,17 @@ impl Model {
}
pub fn supports_reasoning_effort(&self) -> bool {
self.features.reasoning_effort == ReasoningEffortFeature::Levels
self.features.supports_reasoning_effort()
}
pub fn supports_prompt_cache(&self) -> bool {
self.features.prompt_cache
}
pub fn supports_sampling_params(&self) -> bool {
self.features.sampling_params
}
pub fn training(&self) -> Option<&str> {
self.training.as_deref()
}
@ -163,6 +191,22 @@ mod tests {
use super::*;
use crate::ids::ProviderId;
#[test]
fn reasoning_effort_feature_always_adaptive_round_trips() {
let parsed: ReasoningEffortFeature =
serde_json::from_value(serde_json::json!("always_adaptive")).unwrap();
assert_eq!(parsed, ReasoningEffortFeature::AlwaysAdaptive);
assert_eq!(
serde_json::to_value(parsed).unwrap(),
serde_json::json!("always_adaptive")
);
assert_eq!(parsed.to_string(), "always_adaptive");
assert_eq!(
"always_adaptive".parse::<ReasoningEffortFeature>().unwrap(),
parsed
);
}
#[test]
fn inherent_methods_return_correct_values() {
let info = Model {
@ -182,6 +226,7 @@ mod tests {
reasoning: true,
reasoning_effort: ReasoningEffortFeature::Levels,
prompt_cache: true,
sampling_params: true,
},
costs: ModelCosts {
input_cost_per_mtok: Some(1.0),
@ -206,6 +251,7 @@ mod tests {
assert!(info.supports_reasoning());
assert!(info.supports_reasoning_effort());
assert!(info.supports_prompt_cache());
assert!(info.supports_sampling_params());
assert_eq!(info.training(), Some("training"));
assert_eq!(info.knowledge_cutoff(), Some("knowledge-cutoff"));
assert_eq!(info.input_cost_per_mtok(), Some(1.0));

View file

@ -1677,6 +1677,63 @@ mod tests {
}
}
struct RefusalTestProvider;
#[async_trait]
impl ProviderAdapter for RefusalTestProvider {
fn name(&self) -> &str {
"anthropic"
}
async fn complete(
&self,
_request: &Request,
) -> Result<fabro_llm::types::Response, LlmError> {
Err(refusal_llm_error())
}
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, LlmError> {
Ok(Box::pin(stream::empty()))
}
}
struct TextTestProvider {
provider: &'static str,
text: &'static str,
}
#[async_trait]
impl ProviderAdapter for TextTestProvider {
fn name(&self) -> &str {
self.provider
}
async fn complete(
&self,
request: &Request,
) -> Result<fabro_llm::types::Response, LlmError> {
Ok(fabro_llm::types::Response {
id: "msg_fallback".to_string(),
model: request.model.clone(),
provider: self.provider.to_string(),
message: Message::assistant(self.text),
finish_reason: fabro_llm::types::FinishReason::Stop,
usage: TokenCounts {
input_tokens: 3,
output_tokens: 2,
..TokenCounts::default()
},
raw: None,
warnings: vec![],
rate_limit: None,
})
}
async fn stream(&self, _request: &Request) -> Result<StreamEventStream, LlmError> {
Ok(Box::pin(stream::empty()))
}
}
fn mock_llm_catalog(server: &MockServer) -> Arc<Catalog> {
let settings: LlmCatalogSettings = toml::from_str(&format!(
r#"
@ -2644,6 +2701,70 @@ reasoning = false
assert_eq!(client.provider_names(), vec!["anthropic"]);
}
#[tokio::test]
async fn one_shot_falls_back_after_refusal_error() {
let fallback_chain = vec![FallbackTarget {
provider: "openai".to_string(),
model: "gpt-5.5".to_string(),
}];
let backend = AgentApiBackend::new_from_env(
"claude-fable-5".to_string(),
ProviderId::anthropic(),
fallback_chain.clone(),
SteeringHub::for_tests(),
);
let mut providers = HashMap::new();
providers.insert(
"anthropic".to_string(),
Arc::new(RefusalTestProvider) as Arc<dyn ProviderAdapter>,
);
providers.insert(
"openai".to_string(),
Arc::new(TextTestProvider {
provider: "openai",
text: "fallback ok",
}) as Arc<dyn ProviderAdapter>,
);
let client = Client::new(providers, Some("anthropic".to_string()), Vec::new());
let node = Node::new("ask");
let context = Context::new();
let stage_scope = StageScope::for_handler(&context, &node.id);
let emitter = Arc::new(Emitter::new(fabro_types::RunId::new()));
let request = Request {
model: "claude-fable-5".to_string(),
messages: vec![Message::user("Hello")],
provider: Some("anthropic".to_string()),
tools: None,
tool_choice: None,
response_format: None,
temperature: None,
top_p: None,
max_tokens: Some(128),
stop_sequences: None,
reasoning_effort: None,
speed: None,
metadata: None,
provider_options: None,
};
let completion = backend
.complete_one_shot_request(
&client,
&node,
&emitter,
&stage_scope,
&request,
EffectiveRequestControls::default(),
&fallback_chain,
)
.await
.unwrap();
assert_eq!(completion.response.text(), "fallback ok");
assert_eq!(completion.model.provider, ProviderId::openai());
assert_eq!(completion.model.model_id, "gpt-5.5");
}
#[tokio::test]
async fn one_shot_repairs_custom_output_schema_with_previous_assistant_message() {
let server = MockServer::start();
@ -2838,6 +2959,27 @@ reasoning = false
}
}
fn refusal_llm_error() -> LlmError {
LlmError::Provider {
kind: ProviderErrorKind::ContentFilter,
detail: Box::new(ProviderErrorDetail {
message: "claude-fable-5 refused the request".into(),
provider: "anthropic".into(),
status_code: None,
error_code: Some("refusal".into()),
retry_after: None,
raw: Some(serde_json::json!({
"stop_reason": "refusal",
"stop_details": {
"type": "refusal",
"category": "cyber",
"explanation": "This request was declined."
}
})),
}),
}
}
#[tokio::test]
async fn spawn_bridge_task_sets_cancelled_and_cancels_session_token() {
let run_token = CancellationToken::new();
@ -3016,6 +3158,26 @@ reasoning = false
}
}
#[test]
fn classify_refusal_llm_returns_failover_when_allowed() {
let err = fabro_agent::Error::Llm(refusal_llm_error());
assert!(matches!(
classify_agent_error(err, true),
AgentApiErrorDisposition::FailoverEligible(_)
));
}
#[test]
fn classify_refusal_llm_returns_terminal_when_not_allowed() {
let err = fabro_agent::Error::Llm(refusal_llm_error());
match classify_agent_error(err, false) {
AgentApiErrorDisposition::Terminal(Error::Llm(llm_err)) => {
assert!(llm_err.to_string().contains("claude-fable-5 refused"));
}
_ => panic!("expected Terminal(Error::Llm) when refusal failover is disallowed"),
}
}
#[test]
fn classify_session_closed_is_terminal_precondition() {
let err = fabro_agent::Error::SessionClosed;

View file

@ -38,4 +38,8 @@ export interface ModelFeatures {
* Whether the model endpoint supports prompt caching.
*/
'prompt_cache': boolean;
/**
* Whether the model accepts classic sampling parameters (temperature, top_p).
*/
'sampling_params': boolean;
}

View file

@ -15,11 +15,12 @@
/**
* Whether the model endpoint supports a native reasoning-effort parameter.
* Whether the model endpoint supports a native reasoning-effort parameter. `levels` accepts discrete effort levels; `always_adaptive` accepts effort levels with natively always-on adaptive thinking; `none` has no native effort parameter.
*/
export const ReasoningEffortFeature = {
LEVELS: 'levels',
ALWAYS_ADAPTIVE: 'always_adaptive',
NONE: 'none'
} as const;