From 269eca719fd2c379a247bb737ebb5587d65d54de Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Wed, 10 Jun 2026 17:01:03 -0400 Subject: [PATCH] refactor(llm): extract codec/anthropic_messages behind the Codec trait (#488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Dialect extraction in the gateway refactor series (after #481 / #485, sibling of #487): the Anthropic Messages wire translation moves out of `providers/anthropic.rs` into `codec/anthropic_messages/`, behind the `Codec` / `StreamDecoder` traits. The adapter becomes a thin transport shell owning auth, base URL, the streaming byte loop, and route config; all translation is in the codec. Three commits, each independently green: 1. **Add the codec** (`wire`/`encode`/`decode`/`stream`/`mod`) — compiling but unused behind a scoped `dead_code` allow. 2. **Rewire the adapter** to it and migrate the ~70 unit tests into the codec submodules they now cover. 3. **Port #482's Claude Fable 5 handling into the codec layout** (see below). Key moves: - **Route config replaces the request-time `provider_name == "anthropic"` branches**: auth scheme (x-api-key vs bearer), version/beta headers, the count-tokens availability gate, and Kimi-over-anthropic forced streaming resolve once per call into a `RouteConfig`. Dialect headers ride on `CodecParams` (`AnthropicVersion::Header("2023-06-01")` + beta-header emission for the direct route; inert defaults for Kimi). - **`build_api_request`'s `(ApiRequest, RequestBuilder)` dual-return dies**: codec `encode` produces body + headers as data (`EncodedRequest`); the transport applies them. This also kills the duplicated header rebuild in `count_input_tokens`. - **Encode goes sync**: file-backed Image/Document attachments resolve to inline data via the shared `attachments::resolve` (#485) in the adapter before encode (drop-on-error preserved; audio stays a text placeholder in the codec). - The SSE state machine becomes `SseAccumulator` behind `StreamDecoder`: the transport owns byte reading + `\n\n` framing; the decoder is fed framed `RawEvent`s. `finish()` returns nothing — `message_stop` is the only finisher, matching today's no-synthesis contract. - json_schema synthetic-tool machinery (encode injection, decode extraction, stream rewrite) moves intact around the shared `SYNTHETIC_TOOL_NAME`. ### The #482 (Claude Fable 5) port #482 modifies the old-layout `anthropic.rs` directly, so this branch re-homes its behavior into the codec structure (commit 3): `stop_details` on the wire type, the Fable encode gates keyed off the deployment id (no default adaptive `thinking`, no `temperature`/`top_p`, no legacy 1M-context beta header — which now lands **once** instead of twice, since both routes share `build_headers`), refusal → failover-eligible content-filter errors in decode and stream, and the `validate_request` rejection of manual thinking configs. The port is inert until the Fable catalog entry lands. Validated by merging #482's head into this branch on a scratch branch: the only conflict is `anthropic.rs` (resolved as this branch's version), and **all of #482's Fable/refusal tests pass against the codec implementation** (521 fabro-llm tests + fabro-model/fabro-workflow 1286 green on the merged tree). If #482 merges first, this PR's rebase resolves the same single-file conflict the same way. Coordination note: this PR makes the same unit→fielded `CodecParams` change as #487 (each adds only its own fields) — whichever lands second resolves a trivial field-union conflict in `codec/mod.rs`. ## Behavior preservation No behavior change. The anthropic wire snapshots from #471 (direct route, Kimi-over-anthropic bearer/no-version pin, prompt-cache with catalog, json_schema, count-tokens wire, streaming happy path / tool deltas / error events / no-message_stop-no-Finish) pass unmodified, and the full fabro-llm suite is back to count (515). ## Testing - `cargo nextest run -p fabro-llm` — 515 passed (126 wire snapshots included) - Scratch-merge validation against #482's head — 521 passed incl. its 6 Fable/refusal tests; `cargo nextest run -p fabro-model -p fabro-workflow` — 1286 passed - `cargo build --workspace` - `cargo +nightly-2026-04-14 clippy -p fabro-llm --all-targets -- -D warnings` - `cargo +nightly-2026-04-14 fmt --check` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- lib/crates/fabro-llm/src/attachments.rs | 43 +- .../src/codec/anthropic_messages/decode.rs | 263 ++ .../src/codec/anthropic_messages/encode.rs | 1637 +++++++++ .../src/codec/anthropic_messages/mod.rs | 63 + .../src/codec/anthropic_messages/stream.rs | 654 ++++ .../src/codec/anthropic_messages/wire.rs | 154 + lib/crates/fabro-llm/src/codec/mod.rs | 46 +- .../src/codec/openai_compatible/request.rs | 4 +- .../src/codec/openai_compatible/stream.rs | 2 +- .../fabro-llm/src/providers/anthropic.rs | 3062 ++--------------- .../src/providers/openai_compatible.rs | 4 +- 11 files changed, 3076 insertions(+), 2856 deletions(-) create mode 100644 lib/crates/fabro-llm/src/codec/anthropic_messages/decode.rs create mode 100644 lib/crates/fabro-llm/src/codec/anthropic_messages/encode.rs create mode 100644 lib/crates/fabro-llm/src/codec/anthropic_messages/mod.rs create mode 100644 lib/crates/fabro-llm/src/codec/anthropic_messages/stream.rs create mode 100644 lib/crates/fabro-llm/src/codec/anthropic_messages/wire.rs diff --git a/lib/crates/fabro-llm/src/attachments.rs b/lib/crates/fabro-llm/src/attachments.rs index 0d019051f..c650d3ddd 100644 --- a/lib/crates/fabro-llm/src/attachments.rs +++ b/lib/crates/fabro-llm/src/attachments.rs @@ -6,18 +6,11 @@ //! Loads that fail drop the part silently — the long-standing contract — and //! non-file URLs and already-inline data pass through untouched. //! -//! Shared infra introduced ahead of its consumers: the per-dialect codecs -//! (anthropic/openai_responses/gemini) each construct their own -//! [`AttachmentPolicy`] and call [`resolve`] from their adapter shells when -//! they are wired. Until the first of those lands, nothing here is reachable. +//! Shared infra for the per-dialect codecs (anthropic/openai_responses/gemini): +//! each constructs its own [`AttachmentPolicy`] and calls [`resolve`] from its +//! adapter shell. -// Each dialect adapter that wires this in removes the allow as part of its -// rewire; harmless if it lingers when several land in parallel. -#![allow( - dead_code, - reason = "Attachment-resolution infra added ahead of the dialect codecs (PRs 3-5) that \ - construct an AttachmentPolicy and call resolve from their adapter shells." -)] +use std::borrow::Cow; use crate::providers::common; use crate::types::{AudioData, ContentPart, DocumentData, ImageData, Request}; @@ -32,9 +25,15 @@ pub(crate) struct AttachmentPolicy { pub audio: bool, } -/// Return a copy of `request` with file-path attachments (per `policy`) -/// resolved to inline data. Parts whose file fails to load are dropped. -pub(crate) async fn resolve(request: &Request, policy: AttachmentPolicy) -> Request { +/// Resolve file-path attachments (per `policy`) to inline data. Parts whose +/// file fails to load are dropped. Borrows the request untouched in the common +/// case where nothing needs loading; only requests with policy-matching +/// local-file parts pay for a copy. +pub(crate) async fn resolve(request: &Request, policy: AttachmentPolicy) -> Cow<'_, Request> { + if !needs_resolution(request, policy) { + return Cow::Borrowed(request); + } + let mut resolved = request.clone(); for message in &mut resolved.messages { let mut new_content = Vec::with_capacity(message.content.len()); @@ -45,7 +44,21 @@ pub(crate) async fn resolve(request: &Request, policy: AttachmentPolicy) -> Requ } message.content = new_content; } - resolved + Cow::Owned(resolved) +} + +/// Whether any part is a policy-matching local-file attachment. +fn needs_resolution(request: &Request, policy: AttachmentPolicy) -> bool { + request + .messages + .iter() + .flat_map(|message| &message.content) + .any(|part| match part { + ContentPart::Image(img) => policy.images && is_local_file(img.url.as_deref()), + ContentPart::Document(doc) => policy.documents && is_local_file(doc.url.as_deref()), + ContentPart::Audio(audio) => policy.audio && is_local_file(audio.url.as_deref()), + _ => false, + }) } /// Resolve a single part. `None` means the part was dropped (load error). diff --git a/lib/crates/fabro-llm/src/codec/anthropic_messages/decode.rs b/lib/crates/fabro-llm/src/codec/anthropic_messages/decode.rs new file mode 100644 index 000000000..1270ef26e --- /dev/null +++ b/lib/crates/fabro-llm/src/codec/anthropic_messages/decode.rs @@ -0,0 +1,263 @@ +//! Response decoding: Anthropic Messages body → canonical `Response`. + +use serde::Deserialize; + +use super::SYNTHETIC_TOOL_NAME; +use super::wire::{ApiResponse, ApiUsage, CountTokensResponse}; +use crate::codec::CodecCtx; +use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind}; +use crate::types::{ + ContentPart, FinishReason, Message, RateLimitInfo, Request, Response, ResponseFormatType, Role, + ThinkingData, TokenCounts, ToolCall, +}; + +pub(super) fn token_counts_from_api_usage(usage: &ApiUsage) -> TokenCounts { + // Anthropic does not expose a separate billed thinking/reasoning token + // count. Thinking tokens are billed as part of `output_tokens`. When + // Anthropic adds a real thinking token field, wire it through and subtract + // it here. + TokenCounts { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + reasoning_tokens: 0, + cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0), + cache_write_tokens: usage.cache_creation_input_tokens.unwrap_or(0), + } +} + +pub(super) fn map_finish_reason(stop_reason: Option<&str>) -> FinishReason { + match stop_reason { + Some("end_turn" | "stop_sequence") | None => FinishReason::Stop, + Some("max_tokens") => FinishReason::Length, + Some("tool_use") => FinishReason::ToolCalls, + Some(other) => FinishReason::Other(other.to_string()), + } +} + +pub(super) fn parse_content_block(block: &serde_json::Value) -> Option { + match block.get("type")?.as_str()? { + "text" => Some(ContentPart::text(block.get("text")?.as_str()?)), + "tool_use" => Some(ContentPart::ToolCall(ToolCall::new( + block.get("id")?.as_str()?, + block.get("name")?.as_str()?, + block.get("input")?.clone(), + ))), + "thinking" => Some(ContentPart::Thinking(ThinkingData { + text: block.get("thinking")?.as_str()?.to_string(), + signature: block + .get("signature") + .and_then(serde_json::Value::as_str) + .map(String::from), + redacted: false, + })), + "redacted_thinking" => Some(ContentPart::Thinking(ThinkingData { + text: block + .get("data") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(), + signature: None, + redacted: true, + })), + _ => None, + } +} + +/// Convert synthetic `tool_use` content blocks back to text content parts. +/// +/// When `response_format` uses `JsonSchema` mode, the model responds with a +/// `tool_use` block for our synthetic tool. We extract its arguments as a JSON +/// text string. +pub(super) fn convert_synthetic_tool_to_text(content_parts: Vec) -> Vec { + content_parts + .into_iter() + .map(|part| match &part { + ContentPart::ToolCall(tc) if tc.name == SYNTHETIC_TOOL_NAME => { + ContentPart::text(tc.arguments.to_string()) + } + _ => part, + }) + .collect() +} + +/// Check if the request uses `JsonSchema` `response_format`. +pub(super) fn uses_json_schema_format(request: &Request) -> bool { + request + .response_format + .as_ref() + .is_some_and(|f| matches!(f.kind, ResponseFormatType::JsonSchema)) +} + +/// Map a refusal stop reason (Claude Fable 5) to a content-filter provider +/// error. Shared by the response decoder and the stream decoder; the +/// `error_code = "refusal"` marker is what makes it failover-eligible. +pub(super) 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), + }), + } +} + +pub(super) fn decode_response( + body: &str, + ctx: &CodecCtx<'_>, + rate_limit: Option, +) -> Result { + let raw: serde_json::Value = serde_json::from_str(body).map_err(|e| { + Error::network( + format!("failed to parse {} response: {e}", ctx.provider_name), + e, + ) + })?; + let api_resp = ApiResponse::deserialize(&raw).map_err(|e| { + Error::network( + format!("failed to parse {} response: {e}", ctx.provider_name), + e, + ) + })?; + + if api_resp.stop_reason.as_deref() == Some("refusal") { + return Err(refusal_error( + ctx.provider_name, + &api_resp.model, + raw, + api_resp.stop_details.as_ref(), + )); + } + + let content_parts: Vec = api_resp + .content + .iter() + .filter_map(parse_content_block) + .collect(); + + // If we used JsonSchema mode, convert the synthetic tool call back to text. + let json_schema_mode = uses_json_schema_format(ctx.request); + let content_parts = if json_schema_mode { + convert_synthetic_tool_to_text(content_parts) + } else { + content_parts + }; + + let finish_reason = if json_schema_mode { + // The model was forced to call a tool, so stop_reason is "tool_use", + // but from the caller's perspective, the request completed normally. + FinishReason::Stop + } else { + map_finish_reason(api_resp.stop_reason.as_deref()) + }; + + Ok(Response { + id: api_resp.id, + model: api_resp.model, + provider: ctx.provider_name.to_string(), + message: Message { + role: Role::Assistant, + content: content_parts, + name: None, + tool_call_id: None, + }, + finish_reason, + usage: token_counts_from_api_usage(&api_resp.usage), + raw: Some(raw), + warnings: vec![], + rate_limit, + }) +} + +pub(super) fn decode_count_tokens(body: &str) -> Result { + let response: CountTokensResponse = + serde_json::from_str(body).map_err(|e| Error::Configuration { + message: format!("failed to parse token count response: {e}"), + source: None, + })?; + Ok(response.input_tokens) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn api_token_counts_leaves_reasoning_zero_and_output_full() { + let body = serde_json::json!({ + "id": "msg_test", + "model": "claude-sonnet-4-5", + "content": [ + { "type": "thinking", "thinking": "summary text", "signature": "" }, + { "type": "text", "text": "answer" } + ], + "stop_reason": "end_turn", + "usage": { + "input_tokens": 50, + "output_tokens": 1200, + "cache_read_input_tokens": 9000, + "cache_creation_input_tokens": 1000 + } + }); + let api: ApiResponse = serde_json::from_value(body).unwrap(); + let usage = token_counts_from_api_usage(&api.usage); + + assert_eq!(usage.input_tokens, 50); + assert_eq!(usage.cache_read_tokens, 9000); + assert_eq!(usage.cache_write_tokens, 1000); + assert_eq!(usage.output_tokens, 1200); + assert_eq!(usage.reasoning_tokens, 0); + assert_eq!(usage.total_tokens(), 11_250); + } + + #[test] + fn convert_synthetic_tool_to_text_replaces_synthetic_tool() { + let parts = vec![ContentPart::ToolCall(ToolCall::new( + "id1", + SYNTHETIC_TOOL_NAME, + serde_json::json!({"name": "Alice"}), + ))]; + let result = convert_synthetic_tool_to_text(parts); + assert_eq!(result.len(), 1); + match &result[0] { + ContentPart::Text(text) => { + assert!(text.contains("Alice")); + } + other => panic!("expected Text, got {other:?}"), + } + } + + #[test] + fn convert_synthetic_tool_to_text_preserves_other_tool_calls() { + let parts = vec![ContentPart::ToolCall(ToolCall::new( + "id1", + "real_tool", + serde_json::json!({"key": "value"}), + ))]; + let result = convert_synthetic_tool_to_text(parts); + assert_eq!(result.len(), 1); + match &result[0] { + ContentPart::ToolCall(tc) => { + assert_eq!(tc.name, "real_tool"); + } + other => panic!("expected ToolCall, got {other:?}"), + } + } +} diff --git a/lib/crates/fabro-llm/src/codec/anthropic_messages/encode.rs b/lib/crates/fabro-llm/src/codec/anthropic_messages/encode.rs new file mode 100644 index 000000000..d59bd8c3d --- /dev/null +++ b/lib/crates/fabro-llm/src/codec/anthropic_messages/encode.rs @@ -0,0 +1,1637 @@ +//! Request encoding: canonical request → Anthropic Messages body + headers. +//! +//! Pure and sync. File-backed attachments are resolved to inline data by +//! `attachments::resolve` in the adapter *before* encode runs, so the content +//! translation here never touches the filesystem. + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + +use super::SYNTHETIC_TOOL_NAME; +use super::wire::{ApiMessage, ApiRequest, ApiToolDef, CacheControl, CountTokensRequest}; +use crate::codec::{AnthropicVersion, CodecCtx, EncodedRequest}; +use crate::providers::common; +use crate::types::{ + ContentPart, Message, ReasoningEffort, ReasoningEffortFeature, Request, ResponseFormatType, + Role, Speed, ThinkingData, ToolChoice, ToolDefinition, +}; + +const CACHE_BETA_HEADER: &str = "prompt-caching-2024-07-31"; +const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; + +/// Known `provider_options.anthropic` keys handled directly by the codec; not +/// re-merged into the body. +const KNOWN_ANTHROPIC_OPTION_KEYS: &[&str] = &["thinking", "auto_cache", "beta_headers"]; + +// --- Public entry points ----------------------------------------------------- + +pub(super) fn encode(ctx: &CodecCtx<'_>, stream: bool) -> EncodedRequest { + let request = build_request(ctx, stream); + let body = merge_provider_options(&request, ctx.request.provider_options.as_ref()); + EncodedRequest { + body, + endpoint: "/messages".to_string(), + headers: build_headers(ctx), + } +} + +pub(super) fn encode_count_tokens(ctx: &CodecCtx<'_>) -> EncodedRequest { + let count_request = CountTokensRequest::from(build_request(ctx, false)); + let body = serde_json::to_value(&count_request).unwrap_or_else(|_| serde_json::json!({})); + EncodedRequest { + body, + endpoint: "/messages/count_tokens".to_string(), + headers: build_headers(ctx), + } +} + +/// Whether auto prompt-caching applies: the model supports it and the request +/// hasn't opted out. +fn auto_cache(ctx: &CodecCtx<'_>) -> bool { + ctx.model.is_some_and(|m| m.features.prompt_cache) + && is_auto_cache_enabled(ctx.request.provider_options.as_ref()) +} + +fn build_headers(ctx: &CodecCtx<'_>) -> Vec<(String, String)> { + let mut headers = Vec::new(); + if let AnthropicVersion::Header(version) = ctx.params.anthropic_version { + headers.push(("anthropic-version".to_string(), version.to_string())); + } + if ctx.params.anthropic_beta { + if let Some(beta) = build_beta_header( + ctx.request.provider_options.as_ref(), + auto_cache(ctx), + ctx.request.speed == Some(Speed::Fast), + ) { + headers.push(("anthropic-beta".to_string(), beta)); + } + } + headers +} + +fn build_request(ctx: &CodecCtx<'_>, stream: bool) -> ApiRequest { + let request = ctx.request; + let (system, other_messages) = common::extract_system_prompt(&request.messages); + let mut api_messages = translate_messages(&other_messages); + + // `ToolChoice::None` omits the tools entirely instead of sending a choice. + let omit_tools = matches!(request.tool_choice, Some(ToolChoice::None)); + let mut tool_choice_json = if omit_tools { + None + } else { + request.tool_choice.as_ref().and_then(translate_tool_choice) + }; + + let mut api_tools = if omit_tools { + None + } else { + request.tools.as_ref().map(|t| translate_tools(t)) + }; + + let model_info = ctx.model; + let auto_cache = auto_cache(ctx); + + let mut system_value = system.and_then(|s| { + if s.trim().is_empty() { + None + } else if auto_cache { + Some(system_with_cache_control(&s)) + } else { + Some(serde_json::Value::String(s)) + } + }); + + // Apply response_format (may inject synthetic tool or system prompt suffix). + apply_response_format( + request, + &mut api_tools, + &mut tool_choice_json, + &mut system_value, + ); + + if auto_cache { + if let Some(ref mut tools) = api_tools { + apply_cache_control_to_last_tool(tools); + } + apply_cache_control_to_conversation_prefix(&mut api_messages); + } + + let explicit_thinking = extract_thinking_config(request.provider_options.as_ref()); + + // Older reasoning models (e.g. claude-sonnet-4-5) need `thinking` with + // `budget_tokens` instead of `output_config.effort`. + let supports_effort = model_info.is_none_or(fabro_model::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.or_else(default_thinking), + Some(serde_json::json!({"effort": <&'static str>::from(*effort)})), + ) + } else if explicit_thinking.is_none() { + let budget = effort_to_budget_tokens(*effort, resolved_max_tokens); + if resolved_max_tokens <= budget { + resolved_max_tokens = budget + 1024; + } + ( + Some(serde_json::json!({"type": "enabled", "budget_tokens": budget})), + None, + ) + } else { + (explicit_thinking, None) + } + } else { + (explicit_thinking.or_else(default_thinking), None) + }; + + if tool_choice_forces_tool_use(tool_choice_json.as_ref()) { + thinking = None; + output_config = None; + } + + // 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(fabro_model::Model::supports_sampling_params) { + (request.temperature, request.top_p) + } else { + (None, None) + }; + + ApiRequest { + model: ctx.deployment_id.to_string(), + messages: api_messages, + max_tokens: resolved_max_tokens, + system: system_value, + temperature, + top_p, + stop_sequences: request.stop_sequences.clone().unwrap_or_default(), + tools: api_tools, + tool_choice: tool_choice_json, + thinking, + output_config, + speed: request + .speed + .filter(|speed| *speed != Speed::Standard) + .map(<&'static str>::from) + .map(str::to_string), + metadata: request.metadata.clone(), + stream, + } +} + +// --- Content / message / tool translation ------------------------------------ + +/// Translate a unified `ContentPart` to an Anthropic content block. Sync: +/// file-backed attachments are already resolved to inline data upstream. +fn content_part_to_api(part: &ContentPart) -> Option { + match part { + ContentPart::Text(text) => Some(serde_json::json!({"type": "text", "text": text})), + ContentPart::ToolCall(tc) => Some(serde_json::json!({ + "type": "tool_use", + "id": tc.id, + "name": tc.name, + "input": tc.arguments, + })), + ContentPart::ToolResult(tr) => { + let content = tr + .content + .as_str() + .map_or_else(|| tr.content.to_string(), str::to_string); + Some(serde_json::json!({ + "type": "tool_result", + "tool_use_id": tr.tool_call_id, + "content": content, + "is_error": tr.is_error, + })) + } + ContentPart::Thinking(td) if td.redacted => Some(serde_json::json!({ + "type": "redacted_thinking", + "data": td.text, + })), + ContentPart::Thinking(ThinkingData { + text, signature, .. + }) => { + let mut block = serde_json::json!({ "type": "thinking", "thinking": text }); + if let Some(sig) = signature { + block["signature"] = serde_json::Value::String(sig.clone()); + } + Some(block) + } + ContentPart::Image(img) => media_block( + "image", + img.url.as_deref(), + img.data.as_deref(), + img.media_type.as_deref().unwrap_or("image/png"), + ), + ContentPart::Document(doc) => media_block( + "document", + doc.url.as_deref(), + doc.data.as_deref(), + doc.media_type.as_deref().unwrap_or("application/pdf"), + ), + ContentPart::Audio(_) => Some( + serde_json::json!({"type": "text", "text": "[Audio content not supported by this provider]"}), + ), + ContentPart::Other { .. } => None, + } +} + +/// An `image`/`document` content block: URL source when present, otherwise +/// base64-encoded inline data. +fn media_block( + kind: &str, + url: Option<&str>, + data: Option<&[u8]>, + mime: &str, +) -> Option { + if let Some(url) = url { + Some(serde_json::json!({"type": kind, "source": {"type": "url", "url": url}})) + } else { + data.map(|data| { + let b64 = BASE64_STANDARD.encode(data); + serde_json::json!({"type": kind, "source": {"type": "base64", "media_type": mime, "data": b64}}) + }) + } +} + +/// Convert unified messages to Anthropic API messages (role mapping, strict +/// alternation, tool results folded into user turns). +fn translate_messages(messages: &[&Message]) -> Vec { + let mut api_messages: Vec = Vec::new(); + + for msg in messages { + let role = match msg.role { + Role::Assistant => "assistant", + Role::User | Role::Tool => "user", + Role::System | Role::Developer => continue, + }; + + let mut content = Vec::new(); + for part in &msg.content { + if let Some(block) = content_part_to_api(part) { + content.push(block); + } + } + + if content.is_empty() { + continue; + } + + if let Some(last) = api_messages.last_mut() { + if last.role == role { + last.content.extend(content); + continue; + } + } + + api_messages.push(ApiMessage { + role: role.to_string(), + content, + }); + } + + api_messages +} + +fn translate_tools(tools: &[ToolDefinition]) -> Vec { + tools + .iter() + .map(|t| ApiToolDef { + name: t.name.clone(), + description: t.description.clone(), + input_schema: t.parameters.clone(), + cache_control: None, + }) + .collect() +} + +fn translate_tool_choice(choice: &ToolChoice) -> Option { + match choice { + ToolChoice::Auto => Some(serde_json::json!({"type": "auto"})), + // Anthropic does not support tool_choice none with tools present; the + // caller omits tools instead. + ToolChoice::None => None, + ToolChoice::Required => Some(serde_json::json!({"type": "any"})), + ToolChoice::Named { tool_name } => { + Some(serde_json::json!({"type": "tool", "name": tool_name})) + } + } +} + +fn tool_choice_forces_tool_use(tool_choice: Option<&serde_json::Value>) -> bool { + matches!( + tool_choice + .and_then(|value| value.get("type")) + .and_then(serde_json::Value::as_str), + Some("any" | "tool") + ) +} + +// --- Structured output (response_format) ------------------------------------- + +fn apply_response_format( + request: &Request, + api_tools: &mut Option>, + tool_choice: &mut Option, + system: &mut Option, +) { + let Some(format) = &request.response_format else { + return; + }; + + match format.kind { + ResponseFormatType::JsonSchema => { + let schema = format + .json_schema + .clone() + .unwrap_or_else(|| serde_json::json!({"type": "object"})); + let synthetic_tool = ApiToolDef { + name: SYNTHETIC_TOOL_NAME.to_string(), + description: "Output the requested structured data".to_string(), + input_schema: schema, + cache_control: None, + }; + match api_tools { + Some(tools) => tools.push(synthetic_tool), + None => *api_tools = Some(vec![synthetic_tool]), + } + *tool_choice = Some(serde_json::json!({"type": "tool", "name": SYNTHETIC_TOOL_NAME})); + } + ResponseFormatType::JsonObject => { + let json_instruction = "\n\nYou must respond with valid JSON only, no other text."; + match system { + Some(serde_json::Value::Array(blocks)) => { + if let Some(last) = blocks.last_mut() { + if let Some(text) = last.get("text").and_then(serde_json::Value::as_str) { + let mut new_text = text.to_string(); + new_text.push_str(json_instruction); + last["text"] = serde_json::Value::String(new_text); + } + } else { + blocks.push( + serde_json::json!({"type": "text", "text": json_instruction.trim()}), + ); + } + } + Some(serde_json::Value::String(s)) => { + s.push_str(json_instruction); + } + None => { + *system = Some(serde_json::Value::String( + json_instruction.trim().to_string(), + )); + } + _ => {} + } + } + ResponseFormatType::Text => {} + } +} + +// --- Prompt caching / thinking / beta headers -------------------------------- + +/// The `provider_options.anthropic` namespace object, if any. +fn anthropic_options(provider_options: Option<&serde_json::Value>) -> Option<&serde_json::Value> { + provider_options.and_then(|opts| opts.get("anthropic")) +} + +/// A single `provider_options.anthropic.` value, if any. `pub(crate)` so +/// the adapter's `validate_request` reads the same namespace the same way. +pub(crate) fn anthropic_option<'a>( + provider_options: Option<&'a serde_json::Value>, + key: &str, +) -> Option<&'a serde_json::Value> { + anthropic_options(provider_options).and_then(|anthropic| anthropic.get(key)) +} + +fn extract_thinking_config( + provider_options: Option<&serde_json::Value>, +) -> Option { + anthropic_option(provider_options, "thinking").cloned() +} + +fn effort_to_budget_tokens(effort: ReasoningEffort, max_tokens: i64) -> i64 { + let budget = match effort { + ReasoningEffort::Low => max_tokens / 4, + ReasoningEffort::Medium => max_tokens / 2, + ReasoningEffort::High => max_tokens * 3 / 4, + ReasoningEffort::XHigh => max_tokens * 7 / 8, + ReasoningEffort::Max => max_tokens, + }; + budget.max(1024) +} + +fn is_auto_cache_enabled(provider_options: Option<&serde_json::Value>) -> bool { + anthropic_option(provider_options, "auto_cache") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true) +} + +fn system_with_cache_control(system: &str) -> serde_json::Value { + serde_json::json!([{ + "type": "text", + "text": system, + "cache_control": {"type": "ephemeral"} + }]) +} + +fn apply_cache_control_to_last_tool(tools: &mut [ApiToolDef]) { + if let Some(last) = tools.last_mut() { + last.cache_control = Some(CacheControl::ephemeral()); + } +} + +fn apply_cache_control_to_conversation_prefix(messages: &mut [ApiMessage]) { + let user_indices: Vec = messages + .iter() + .enumerate() + .filter(|(_, m)| m.role == "user") + .map(|(i, _)| i) + .collect(); + + if user_indices.len() < 2 { + return; + } + + let target_idx = user_indices[user_indices.len() - 2]; + if let Some(serde_json::Value::Object(map)) = messages[target_idx].content.last_mut() { + map.insert( + "cache_control".to_string(), + serde_json::json!({"type": "ephemeral"}), + ); + } +} + +fn build_beta_header( + provider_options: Option<&serde_json::Value>, + include_cache_header: bool, + include_fast_mode_header: bool, +) -> Option { + let mut headers: Vec = Vec::new(); + + if let Some(beta_array) = + anthropic_option(provider_options, "beta_headers").and_then(serde_json::Value::as_array) + { + headers.extend( + beta_array + .iter() + .filter_map(serde_json::Value::as_str) + .map(String::from), + ); + } + + if include_cache_header && !headers.iter().any(|h| h == CACHE_BETA_HEADER) { + headers.push(CACHE_BETA_HEADER.to_string()); + } + + if include_fast_mode_header && !headers.iter().any(|h| h == FAST_MODE_BETA_HEADER) { + headers.push(FAST_MODE_BETA_HEADER.to_string()); + } + + if headers.is_empty() { + None + } else { + Some(headers.join(",")) + } +} + +/// Serialize the API request and merge any unknown `provider_options.anthropic` +/// keys into the body. +fn merge_provider_options( + api_request: &ApiRequest, + provider_options: Option<&serde_json::Value>, +) -> serde_json::Value { + let mut body = serde_json::to_value(api_request).unwrap_or_else(|_| serde_json::json!({})); + + if let Some(anthropic_opts) = anthropic_options(provider_options) { + if let (Some(base), Some(overrides)) = (body.as_object_mut(), anthropic_opts.as_object()) { + for (key, value) in overrides { + if !KNOWN_ANTHROPIC_OPTION_KEYS.contains(&key.as_str()) { + base.insert(key.clone(), value.clone()); + } + } + } + } + + body +} + +#[cfg(test)] +mod tests { + use fabro_model::Catalog; + use fabro_model::catalog::LlmCatalogSettings; + + use super::*; + use crate::codec::CodecParams; + use crate::types::{AudioData, DocumentData, ResponseFormat}; + + // --- Test helpers -------------------------------------------------------- + + fn make_base_request() -> Request { + Request { + model: "claude-sonnet-4-20250514".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, + } + } + + fn make_request_with_format(format: ResponseFormat) -> Request { + Request { + provider: None, + response_format: Some(format), + max_tokens: None, + ..make_base_request() + } + } + + fn catalog_with_anthropic_model(features: &str) -> Catalog { + let settings: LlmCatalogSettings = toml::from_str(&format!( + r#" +[providers.anthropic] +display_name = "Anthropic" +adapter = "anthropic" +agent_profile = "anthropic" + +[models."test-claude"] +provider = "anthropic" +display_name = "Test Claude" +family = "claude" +default = true + +[models."test-claude".limits] +context_window = 200000 +max_output = 4096 + +[models."test-claude".features] +tools = true +vision = true +reasoning = true +{features} +"# + )) + .unwrap(); + Catalog::from_settings(&settings).unwrap() + } + + /// Direct-Anthropic route params (version header + beta headers enabled), + /// matching what the adapter's `route_config()` resolves for "anthropic". + fn direct_params() -> CodecParams { + CodecParams { + anthropic_version: AnthropicVersion::Header("2023-06-01"), + anthropic_beta: true, + } + } + + /// Encode `request` on the direct-Anthropic route, optionally with a + /// catalog (for capability-driven behavior like prompt-cache/effort). + fn encode_direct(request: &Request, catalog: Option<&Catalog>, stream: bool) -> EncodedRequest { + let deployment_id = common::api_model_id(catalog, &request.model); + let params = direct_params(); + let ctx = CodecCtx { + request, + provider_name: "anthropic", + deployment_id: &deployment_id, + model: common::catalog_model(catalog, &request.model), + params: ¶ms, + }; + encode(&ctx, stream) + } + + fn encode_count_direct(request: &Request, catalog: Option<&Catalog>) -> EncodedRequest { + let deployment_id = common::api_model_id(catalog, &request.model); + let params = direct_params(); + let ctx = CodecCtx { + request, + provider_name: "anthropic", + deployment_id: &deployment_id, + model: common::catalog_model(catalog, &request.model), + params: ¶ms, + }; + encode_count_tokens(&ctx) + } + + fn header_value<'a>(encoded: &'a EncodedRequest, name: &str) -> Option<&'a str> { + encoded + .headers + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) + } + + // --- auto_cache ---------------------------------------------------------- + + #[test] + fn auto_cache_enabled_by_default() { + assert!(is_auto_cache_enabled(None)); + } + + #[test] + fn auto_cache_enabled_when_true() { + let opts = serde_json::json!({"anthropic": {"auto_cache": true}}); + assert!(is_auto_cache_enabled(Some(&opts))); + } + + #[test] + fn auto_cache_disabled_when_false() { + let opts = serde_json::json!({"anthropic": {"auto_cache": false}}); + assert!(!is_auto_cache_enabled(Some(&opts))); + } + + #[test] + fn auto_cache_enabled_when_key_missing() { + let opts = serde_json::json!({"anthropic": {}}); + assert!(is_auto_cache_enabled(Some(&opts))); + } + + #[test] + fn auto_cache_enabled_when_anthropic_missing() { + let opts = serde_json::json!({"openai": {}}); + assert!(is_auto_cache_enabled(Some(&opts))); + } + + // --- prompt-cache helpers ------------------------------------------------ + + #[test] + fn system_prompt_cache_control_wraps_as_array() { + let result = system_with_cache_control("You are helpful."); + let arr = result.as_array().expect("should be an array"); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["type"], "text"); + assert_eq!(arr[0]["text"], "You are helpful."); + assert_eq!(arr[0]["cache_control"]["type"], "ephemeral"); + } + + #[test] + fn tool_cache_control_applied_to_last_tool() { + let mut tools = vec![ + ApiToolDef { + name: "tool_a".to_string(), + description: "first".to_string(), + input_schema: serde_json::json!({}), + cache_control: None, + }, + ApiToolDef { + name: "tool_b".to_string(), + description: "second".to_string(), + input_schema: serde_json::json!({}), + cache_control: None, + }, + ]; + apply_cache_control_to_last_tool(&mut tools); + + assert!(tools[0].cache_control.is_none()); + assert!(tools[1].cache_control.is_some()); + assert_eq!(tools[1].cache_control.as_ref().unwrap().kind, "ephemeral"); + } + + #[test] + fn tool_cache_control_empty_slice() { + let mut tools: Vec = vec![]; + apply_cache_control_to_last_tool(&mut tools); + assert!(tools.is_empty()); + } + + #[test] + fn tool_cache_control_single_tool() { + let mut tools = vec![ApiToolDef { + name: "only_tool".to_string(), + description: "the one".to_string(), + input_schema: serde_json::json!({}), + cache_control: None, + }]; + apply_cache_control_to_last_tool(&mut tools); + assert!(tools[0].cache_control.is_some()); + } + + #[test] + fn conversation_prefix_cache_control_with_two_user_messages() { + let mut messages = vec![ + ApiMessage { + role: "user".to_string(), + content: vec![serde_json::json!({"type": "text", "text": "Hello"})], + }, + ApiMessage { + role: "assistant".to_string(), + content: vec![serde_json::json!({"type": "text", "text": "Hi there"})], + }, + ApiMessage { + role: "user".to_string(), + content: vec![serde_json::json!({"type": "text", "text": "How are you?"})], + }, + ]; + + apply_cache_control_to_conversation_prefix(&mut messages); + + // First user message should have cache_control + assert_eq!(messages[0].content[0]["cache_control"]["type"], "ephemeral"); + // Last user message should NOT have cache_control + assert!(messages[2].content[0].get("cache_control").is_none()); + // Assistant message should NOT have cache_control + assert!(messages[1].content[0].get("cache_control").is_none()); + } + + #[test] + fn conversation_prefix_cache_control_with_multiple_content_blocks() { + let mut messages = vec![ + ApiMessage { + role: "user".to_string(), + content: vec![ + serde_json::json!({"type": "text", "text": "Part 1"}), + serde_json::json!({"type": "text", "text": "Part 2"}), + ], + }, + ApiMessage { + role: "assistant".to_string(), + content: vec![serde_json::json!({"type": "text", "text": "Reply"})], + }, + ApiMessage { + role: "user".to_string(), + content: vec![serde_json::json!({"type": "text", "text": "Follow up"})], + }, + ]; + + apply_cache_control_to_conversation_prefix(&mut messages); + + // Only the LAST content block of the first user message should have + // cache_control + assert!(messages[0].content[0].get("cache_control").is_none()); + assert_eq!(messages[0].content[1]["cache_control"]["type"], "ephemeral"); + } + + #[test] + fn conversation_prefix_cache_control_single_user_message() { + let mut messages = vec![ApiMessage { + role: "user".to_string(), + content: vec![serde_json::json!({"type": "text", "text": "Hello"})], + }]; + + apply_cache_control_to_conversation_prefix(&mut messages); + + // With only one user message, no cache_control should be added + assert!(messages[0].content[0].get("cache_control").is_none()); + } + + #[test] + fn conversation_prefix_cache_control_no_user_messages() { + let mut messages: Vec = vec![]; + // Should not panic on empty messages + apply_cache_control_to_conversation_prefix(&mut messages); + } + + #[test] + fn conversation_prefix_cache_control_three_user_messages() { + let mut messages = vec![ + ApiMessage { + role: "user".to_string(), + content: vec![serde_json::json!({"type": "text", "text": "First"})], + }, + ApiMessage { + role: "assistant".to_string(), + content: vec![serde_json::json!({"type": "text", "text": "Reply 1"})], + }, + ApiMessage { + role: "user".to_string(), + content: vec![serde_json::json!({"type": "text", "text": "Second"})], + }, + ApiMessage { + role: "assistant".to_string(), + content: vec![serde_json::json!({"type": "text", "text": "Reply 2"})], + }, + ApiMessage { + role: "user".to_string(), + content: vec![serde_json::json!({"type": "text", "text": "Third"})], + }, + ]; + + apply_cache_control_to_conversation_prefix(&mut messages); + + // Only the second-to-last user message (index 2) should get cache_control + assert!(messages[0].content[0].get("cache_control").is_none()); + assert_eq!(messages[2].content[0]["cache_control"]["type"], "ephemeral"); + assert!(messages[4].content[0].get("cache_control").is_none()); + } + + // --- beta headers -------------------------------------------------------- + + #[test] + fn beta_header_includes_cache_header() { + 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); + assert_eq!(result, None); + } + + #[test] + fn beta_header_merges_user_headers_with_cache() { + let opts = serde_json::json!({ + "anthropic": { + "beta_headers": ["interleaved-thinking-2025-05-14"] + } + }); + let result = build_beta_header(Some(&opts), true, false); + assert_eq!( + result, + Some(format!( + "interleaved-thinking-2025-05-14,{CACHE_BETA_HEADER}" + )) + ); + } + + #[test] + fn beta_header_no_duplicate_cache_header() { + let opts = serde_json::json!({ + "anthropic": { + "beta_headers": [CACHE_BETA_HEADER] + } + }); + let result = build_beta_header(Some(&opts), true, false); + // Should not duplicate the header + assert_eq!(result, Some(CACHE_BETA_HEADER.to_string())); + } + + #[test] + fn beta_header_user_headers_only_when_cache_disabled() { + let opts = serde_json::json!({ + "anthropic": { + "beta_headers": ["interleaved-thinking-2025-05-14"] + } + }); + let result = build_beta_header(Some(&opts), false, false); + assert_eq!(result, Some("interleaved-thinking-2025-05-14".to_string())); + } + + /// Regression test: deprecated beta header values must not be sent. + /// The Anthropic API rejects requests containing these old headers. + #[test] + fn beta_header_rejects_deprecated_values() { + let deprecated = [ + "extended-thinking-2025-04-14", + "max-tokens-3-5-sonnet-2025-04-14", + ]; + + // No user headers — only cache header should appear + let header = build_beta_header(None, true, false).unwrap_or_default(); + for dep in &deprecated { + assert!( + !header.contains(dep), + "default header must not contain deprecated value {dep}" + ); + } + + // With a valid user header + let opts = serde_json::json!({ + "anthropic": { + "beta_headers": ["interleaved-thinking-2025-05-14"] + } + }); + let header = build_beta_header(Some(&opts), true, false).unwrap_or_default(); + for dep in &deprecated { + assert!( + !header.contains(dep), + "header with user values must not contain deprecated value {dep}" + ); + } + } + + #[test] + fn beta_header_includes_both_cache_and_fast_mode() { + let result = build_beta_header(None, true, true); + let header = result.expect("should produce a header"); + assert!( + header.contains(CACHE_BETA_HEADER), + "should contain cache header" + ); + assert!( + header.contains(FAST_MODE_BETA_HEADER), + "should contain fast-mode header" + ); + } + + // --- effort → thinking budget -------------------------------------------- + + #[test] + fn effort_to_budget_tokens_xhigh_maps_to_seven_eighths() { + assert_eq!( + effort_to_budget_tokens(ReasoningEffort::XHigh, 16_000), + 14_000 + ); + } + + #[test] + fn effort_to_budget_tokens_max_maps_to_full_budget() { + assert_eq!( + effort_to_budget_tokens(ReasoningEffort::Max, 16_000), + 16_000 + ); + } + + // --- system prompt serialization ----------------------------------------- + + #[test] + fn system_prompt_as_string_when_cache_disabled() { + let system = "You are helpful.".to_string(); + let value = serde_json::Value::String(system); + assert_eq!(value.as_str(), Some("You are helpful.")); + } + + #[test] + fn api_request_serialization_with_cached_system() { + let api_request = ApiRequest { + model: "claude-sonnet-4-20250514".to_string(), + messages: vec![ApiMessage { + role: "user".to_string(), + content: vec![serde_json::json!({"type": "text", "text": "Hello"})], + }], + max_tokens: 4096, + system: Some(system_with_cache_control("You are helpful.")), + temperature: None, + top_p: None, + stop_sequences: Vec::new(), + tools: None, + tool_choice: None, + thinking: None, + output_config: None, + speed: None, + metadata: None, + stream: false, + }; + + let json = serde_json::to_value(&api_request).expect("should serialize"); + let system = json.get("system").expect("system should be present"); + let arr = system.as_array().expect("system should be an array"); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["cache_control"]["type"], "ephemeral"); + } + + // --- response_format ------------------------------------------------------ + + #[test] + fn response_format_json_schema_injects_synthetic_tool() { + let schema = serde_json::json!({ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"] + }); + let request = make_request_with_format(ResponseFormat { + kind: ResponseFormatType::JsonSchema, + json_schema: Some(schema.clone()), + strict: false, + }); + + let mut tools: Option> = None; + let mut tool_choice: Option = None; + let mut system: Option = None; + + apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); + + let tools = tools.expect("tools should be set"); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name, SYNTHETIC_TOOL_NAME); + assert_eq!(tools[0].input_schema, schema); + + let tc = tool_choice.expect("tool_choice should be set"); + assert_eq!(tc["type"], "tool"); + assert_eq!(tc["name"], SYNTHETIC_TOOL_NAME); + + // System should not be modified + assert!(system.is_none()); + } + + #[test] + fn tool_choice_forces_tool_use_detects_forced_modes() { + assert!(tool_choice_forces_tool_use(Some( + &serde_json::json!({"type": "any"}) + ))); + assert!(tool_choice_forces_tool_use(Some( + &serde_json::json!({"type": "tool", "name": "json_output"}) + ))); + + assert!(!tool_choice_forces_tool_use(Some( + &serde_json::json!({"type": "auto"}) + ))); + assert!(!tool_choice_forces_tool_use(Some( + &serde_json::json!({"type": "none"}) + ))); + assert!(!tool_choice_forces_tool_use(None)); + } + + #[test] + fn response_format_json_schema_appends_to_existing_tools() { + let schema = serde_json::json!({"type": "object"}); + let mut request = make_request_with_format(ResponseFormat { + kind: ResponseFormatType::JsonSchema, + json_schema: Some(schema), + strict: false, + }); + request.tools = Some(vec![ToolDefinition { + name: "existing_tool".to_string(), + description: "An existing tool".to_string(), + parameters: serde_json::json!({}), + }]); + + let mut tools: Option> = + Some(translate_tools(request.tools.as_ref().unwrap())); + let mut tool_choice: Option = None; + let mut system: Option = None; + + apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); + + let tools = tools.expect("tools should be set"); + assert_eq!(tools.len(), 2); + assert_eq!(tools[0].name, "existing_tool"); + assert_eq!(tools[1].name, SYNTHETIC_TOOL_NAME); + } + + #[test] + fn response_format_json_object_appends_to_string_system() { + let request = make_request_with_format(ResponseFormat { + kind: ResponseFormatType::JsonObject, + json_schema: None, + strict: false, + }); + + let mut tools: Option> = None; + let mut tool_choice: Option = None; + let mut system = Some(serde_json::Value::String("You are helpful.".to_string())); + + apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); + + let sys = system.expect("system should be set"); + let text = sys.as_str().expect("should be a string"); + assert!(text.contains("You are helpful.")); + assert!(text.contains("valid JSON")); + + // Tools should not be modified + assert!(tools.is_none()); + assert!(tool_choice.is_none()); + } + + #[test] + fn response_format_json_object_sets_system_when_none() { + let request = make_request_with_format(ResponseFormat { + kind: ResponseFormatType::JsonObject, + json_schema: None, + strict: false, + }); + + let mut tools: Option> = None; + let mut tool_choice: Option = None; + let mut system: Option = None; + + apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); + + let sys = system.expect("system should be set"); + let text = sys.as_str().expect("should be a string"); + assert!(text.contains("valid JSON")); + } + + #[test] + fn response_format_json_object_appends_to_array_system() { + let request = make_request_with_format(ResponseFormat { + kind: ResponseFormatType::JsonObject, + json_schema: None, + strict: false, + }); + + let mut tools: Option> = None; + let mut tool_choice: Option = None; + let mut system = Some(system_with_cache_control("You are helpful.")); + + apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); + + let sys = system.expect("system should be set"); + let arr = sys.as_array().expect("should be an array"); + let text = arr[0]["text"].as_str().expect("should have text"); + assert!(text.contains("You are helpful.")); + assert!(text.contains("valid JSON")); + } + + #[test] + fn response_format_text_is_noop() { + let request = make_request_with_format(ResponseFormat { + kind: ResponseFormatType::Text, + json_schema: None, + strict: false, + }); + + let mut tools: Option> = None; + let mut tool_choice: Option = None; + let mut system: Option = None; + + apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); + + assert!(tools.is_none()); + assert!(tool_choice.is_none()); + assert!(system.is_none()); + } + + // --- merge_provider_options ---------------------------------------------- + + #[test] + fn merge_provider_options_passes_through_unknown_keys() { + let api_request = ApiRequest { + model: "claude-sonnet-4-20250514".to_string(), + messages: vec![ApiMessage { + role: "user".to_string(), + content: vec![serde_json::json!({"type": "text", "text": "Hello"})], + }], + max_tokens: 4096, + system: None, + temperature: None, + top_p: None, + stop_sequences: Vec::new(), + tools: None, + tool_choice: None, + thinking: None, + output_config: None, + speed: None, + metadata: None, + stream: false, + }; + + let opts = serde_json::json!({ + "anthropic": { + "top_k": 40, + "custom_field": "value" + } + }); + let body = merge_provider_options(&api_request, Some(&opts)); + assert_eq!(body["top_k"], 40); + assert_eq!(body["custom_field"], "value"); + } + + #[test] + fn merge_provider_options_skips_known_keys() { + let api_request = ApiRequest { + model: "claude-sonnet-4-20250514".to_string(), + messages: vec![ApiMessage { + role: "user".to_string(), + content: vec![serde_json::json!({"type": "text", "text": "Hello"})], + }], + max_tokens: 4096, + system: None, + temperature: None, + top_p: None, + stop_sequences: Vec::new(), + tools: None, + tool_choice: None, + thinking: None, + output_config: None, + speed: None, + metadata: None, + stream: false, + }; + + let opts = serde_json::json!({ + "anthropic": { + "thinking": {"type": "enabled", "budget_tokens": 10000}, + "auto_cache": false, + "beta_headers": ["some-header"], + "top_k": 40 + } + }); + let body = merge_provider_options(&api_request, Some(&opts)); + // Known keys should not be merged (they are handled separately) + assert!(body.get("auto_cache").is_none()); + assert!(body.get("beta_headers").is_none()); + // thinking is handled by the ApiRequest struct directly, should not be + // double-merged + assert!(body["thinking"].is_null()); + // Unknown keys should be merged + assert_eq!(body["top_k"], 40); + } + + // --- content_part_to_api (documents / audio) ----------------------------- + + #[test] + fn document_url_translates_to_url_source() { + let part = ContentPart::Document(DocumentData { + url: Some("https://example.com/doc.pdf".to_string()), + data: None, + media_type: None, + file_name: None, + }); + let result = content_part_to_api(&part).expect("should produce JSON"); + assert_eq!(result["type"], "document"); + assert_eq!(result["source"]["type"], "url"); + assert_eq!(result["source"]["url"], "https://example.com/doc.pdf"); + } + + #[test] + fn document_base64_data_translates_to_base64_source() { + let part = ContentPart::Document(DocumentData { + url: None, + data: Some(vec![0x25, 0x50, 0x44, 0x46]), + media_type: Some("application/pdf".to_string()), + file_name: Some("test.pdf".to_string()), + }); + let result = content_part_to_api(&part).expect("should produce JSON"); + assert_eq!(result["type"], "document"); + assert_eq!(result["source"]["type"], "base64"); + assert_eq!(result["source"]["media_type"], "application/pdf"); + assert!(result["source"]["data"].as_str().is_some()); + } + + #[test] + fn document_base64_defaults_to_pdf_mime() { + let part = ContentPart::Document(DocumentData { + url: None, + data: Some(vec![1, 2, 3]), + media_type: None, + file_name: None, + }); + let result = content_part_to_api(&part).expect("should produce JSON"); + assert_eq!(result["source"]["media_type"], "application/pdf"); + } + + #[test] + fn audio_produces_text_fallback() { + let part = ContentPart::Audio(AudioData { + url: Some("https://example.com/audio.wav".to_string()), + data: None, + media_type: None, + }); + let result = content_part_to_api(&part).expect("should produce JSON"); + assert_eq!(result["type"], "text"); + assert_eq!( + result["text"], + "[Audio content not supported by this provider]" + ); + } + + // --- end-to-end encode (formerly build_api_request) ---------------------- + + #[test] + fn build_request_omits_whitespace_only_system_prompt() { + let request = Request { + messages: vec![Message::system(" \n\t"), Message::user("Hello")], + ..make_base_request() + }; + + let encoded = encode_direct(&request, None, false); + assert!( + encoded.body.get("system").is_none(), + "whitespace-only system prompts should be omitted" + ); + } + + #[test] + fn build_request_maps_reasoning_effort_to_output_config() { + let request = Request { + reasoning_effort: Some(ReasoningEffort::Medium), + ..make_base_request() + }; + + let encoded = encode_direct(&request, None, false); + assert_eq!( + encoded.body["output_config"], + serde_json::json!({"effort": "medium"}) + ); + } + + #[test] + fn build_request_disables_prompt_cache_when_model_feature_is_false() { + let catalog = catalog_with_anthropic_model( + r#" +reasoning_effort = "levels" +prompt_cache = false +"#, + ); + let request = Request { + model: "test-claude".to_string(), + messages: vec![ + Message::system("Use the cache if supported."), + Message::user("Hello"), + ], + provider_options: Some(serde_json::json!({ + "anthropic": {"auto_cache": true} + })), + ..make_base_request() + }; + + let encoded = encode_direct(&request, Some(&catalog), false); + assert_eq!( + encoded.body["system"], + serde_json::json!("Use the cache if supported.") + ); + let beta = header_value(&encoded, "anthropic-beta"); + assert!( + beta.is_none_or(|value| !value.contains(CACHE_BETA_HEADER)), + "cache beta header must not be sent when the model disables prompt cache" + ); + } + + #[test] + fn build_request_without_injected_catalog_does_not_use_builtin_model_metadata() { + let request = Request { + model: "claude-sonnet-4-5".to_string(), + messages: vec![ + Message::system("Do not infer cache support from built-ins."), + Message::user("Hello"), + ], + provider_options: Some(serde_json::json!({ + "anthropic": {"auto_cache": true} + })), + ..make_base_request() + }; + + let encoded = encode_direct(&request, None, false); + assert_eq!( + encoded.body["system"], + serde_json::json!("Do not infer cache support from built-ins.") + ); + let beta = header_value(&encoded, "anthropic-beta"); + assert!( + beta.is_none_or(|value| !value.contains(CACHE_BETA_HEADER)), + "cache beta header must require injected model metadata" + ); + } + + #[test] + fn build_request_enables_prompt_cache_when_model_feature_is_true() { + let catalog = catalog_with_anthropic_model( + r#" +reasoning_effort = "levels" +prompt_cache = true +"#, + ); + let request = Request { + model: "test-claude".to_string(), + messages: vec![ + Message::system("Use the cache if supported."), + Message::user("Hello"), + ], + ..make_base_request() + }; + + let encoded = encode_direct(&request, Some(&catalog), false); + assert_eq!( + encoded.body["system"][0]["cache_control"]["type"], + "ephemeral" + ); + let beta = + header_value(&encoded, "anthropic-beta").expect("cache beta header should be present"); + assert!(beta.contains(CACHE_BETA_HEADER)); + } + + #[test] + fn build_request_uses_adaptive_thinking_for_injected_effort_model_without_forced_tools() { + let catalog = catalog_with_anthropic_model( + r#" +reasoning_effort = "levels" +"#, + ); + let request = Request { + model: "test-claude".to_string(), + ..make_base_request() + }; + + let encoded = encode_direct(&request, Some(&catalog), false); + assert_eq!( + encoded.body["thinking"], + serde_json::json!({"type": "adaptive"}) + ); + } + + #[test] + fn build_request_omits_thinking_for_opus_4_7_json_schema() { + let request = Request { + model: "claude-opus-4-7".to_string(), + response_format: Some(ResponseFormat { + kind: ResponseFormatType::JsonSchema, + json_schema: Some(serde_json::json!({ + "type": "object", + "properties": {"title": {"type": "string"}}, + "required": ["title"] + })), + strict: true, + }), + ..make_base_request() + }; + + let encoded = encode_direct(&request, None, false); + let tool_choice = encoded + .body + .get("tool_choice") + .expect("json schema response format should force synthetic tool"); + assert_eq!(tool_choice["type"], "tool"); + assert_eq!(tool_choice["name"], SYNTHETIC_TOOL_NAME); + assert!( + encoded.body.get("thinking").is_none(), + "forced tool calls must omit thinking" + ); + assert!( + encoded.body.get("output_config").is_none(), + "forced tool calls must omit output_config effort" + ); + } + + #[test] + fn build_request_omits_thinking_for_explicit_named_tool_choice() { + let request = Request { + tools: Some(vec![ToolDefinition { + name: "json_output".to_string(), + description: "Output JSON".to_string(), + parameters: serde_json::json!({"type": "object"}), + }]), + tool_choice: Some(ToolChoice::Named { + tool_name: "json_output".to_string(), + }), + provider_options: Some(serde_json::json!({ + "anthropic": { + "thinking": {"type": "adaptive"} + } + })), + ..make_base_request() + }; + + let encoded = encode_direct(&request, None, false); + let tool_choice = encoded + .body + .get("tool_choice") + .expect("named tool choice should be translated"); + assert_eq!(tool_choice["type"], "tool"); + assert_eq!(tool_choice["name"], "json_output"); + assert!( + encoded.body.get("thinking").is_none(), + "forced named tool choice must omit explicit thinking" + ); + } + + #[test] + fn build_request_omits_effort_for_required_tool_choice() { + let request = Request { + model: "claude-opus-4-7".to_string(), + tools: Some(vec![ToolDefinition { + name: "json_output".to_string(), + description: "Output JSON".to_string(), + parameters: serde_json::json!({"type": "object"}), + }]), + tool_choice: Some(ToolChoice::Required), + reasoning_effort: Some(ReasoningEffort::Medium), + ..make_base_request() + }; + + let encoded = encode_direct(&request, None, false); + let tool_choice = encoded + .body + .get("tool_choice") + .expect("required tool choice should be translated"); + assert_eq!(tool_choice["type"], "any"); + assert!( + encoded.body.get("output_config").is_none(), + "required tool choice must omit output_config effort" + ); + } + + #[test] + fn build_request_omits_output_config_when_no_reasoning_effort() { + let request = make_base_request(); + let encoded = encode_direct(&request, None, false); + assert!(encoded.body.get("output_config").is_none()); + } + + #[test] + fn build_request_sets_speed() { + let request = Request { + speed: Some(Speed::Fast), + ..make_base_request() + }; + + let encoded = encode_direct(&request, None, false); + assert_eq!(encoded.body["speed"], "fast"); + } + + #[test] + fn build_request_serializes_absent_stop_sequences_as_empty_array() { + let request = make_base_request(); + let encoded = encode_direct(&request, None, false); + assert_eq!(encoded.body["stop_sequences"], serde_json::json!([])); + } + + #[test] + fn build_request_injects_fast_mode_beta_header() { + let request = Request { + speed: Some(Speed::Fast), + ..make_base_request() + }; + + let encoded = encode_direct(&request, None, false); + let beta = header_value(&encoded, "anthropic-beta") + .expect("anthropic-beta header should be present"); + assert!( + beta.contains(FAST_MODE_BETA_HEADER), + "beta header should contain fast-mode header, got: {beta}" + ); + } + + #[test] + fn build_request_falls_back_to_thinking_budget_for_non_effort_model() { + let catalog = catalog_with_anthropic_model(""); + let request = Request { + model: "test-claude".to_string(), + max_tokens: Some(16_000), + reasoning_effort: Some(ReasoningEffort::XHigh), + ..make_base_request() + }; + + let encoded = encode_direct(&request, Some(&catalog), false); + assert!( + encoded.body.get("output_config").is_none(), + "non-effort models must not receive output_config" + ); + let thinking = encoded + .body + .get("thinking") + .expect("thinking must be set for fallback path"); + assert_eq!(thinking["type"], "enabled"); + assert_eq!(thinking["budget_tokens"], 14_000); + } + + // --- count_tokens encoding ----------------------------------------------- + + #[test] + fn count_request_omits_generation_only_fields_for_reasoning_effort() { + let catalog = catalog_with_anthropic_model( + r#" +reasoning_effort = "levels" +"#, + ); + let request = Request { + model: "test-claude".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + temperature: Some(0.2), + top_p: Some(0.9), + metadata: Some(std::collections::HashMap::from([( + "trace".to_string(), + "abc".to_string(), + )])), + ..make_base_request() + }; + + // The full request carries generation-only fields... + let full = encode_direct(&request, Some(&catalog), false); + assert!(full.body.get("output_config").is_some()); + + // ...but the count request strips them. + let count = encode_count_direct(&request, Some(&catalog)); + assert!(count.body.get("output_config").is_none()); + assert!(count.body.get("max_tokens").is_none()); + assert!(count.body.get("temperature").is_none()); + assert!(count.body.get("top_p").is_none()); + assert!(count.body.get("metadata").is_none()); + assert!(count.body.get("stream").is_none()); + } + + #[test] + fn count_request_includes_explicit_thinking_when_translated_request_has_it() { + let request = Request { + provider_options: Some(serde_json::json!({ + "anthropic": { + "thinking": {"type": "enabled", "budget_tokens": 1024} + } + })), + ..make_base_request() + }; + + let count = encode_count_direct(&request, None); + assert_eq!(count.body["thinking"]["type"], "enabled"); + assert_eq!(count.body["thinking"]["budget_tokens"], 1024); + } +} diff --git a/lib/crates/fabro-llm/src/codec/anthropic_messages/mod.rs b/lib/crates/fabro-llm/src/codec/anthropic_messages/mod.rs new file mode 100644 index 000000000..9cd0e8fa0 --- /dev/null +++ b/lib/crates/fabro-llm/src/codec/anthropic_messages/mod.rs @@ -0,0 +1,63 @@ +//! The Anthropic Messages (`/messages`) codec. +//! +//! Serves Anthropic direct today, and (via route config + `CodecParams`) +//! Kimi-over-anthropic; the Bedrock and OpenRouter-skin routes pair the same +//! codec with different transports later. Pure translation: no HTTP, auth, or +//! base URL — the adapter shell owns those. +//! +//! HTTP error bodies use the shared `decode_error` default (anthropic uses the +//! standard `error_from_status_code` + `parse_error_body` path); streaming +//! `error` events are mapped inside the decoder (`on_event` → `Err`). + +mod decode; +mod encode; +mod stream; +mod wire; + +pub(crate) use encode::anthropic_option; + +use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder}; +use crate::error::Error; +use crate::types::{RateLimitInfo, Response}; + +/// Synthetic tool injected to coerce structured (`JsonSchema`) output. Shared +/// across encode (injection), decode (extraction), and stream (rewrite). +pub(super) const SYNTHETIC_TOOL_NAME: &str = "json_output"; + +/// Codec for the Anthropic Messages wire dialect. +pub(crate) struct AnthropicMessages; + +impl Codec for AnthropicMessages { + fn encode(&self, ctx: &CodecCtx<'_>, stream: bool) -> Result { + Ok(encode::encode(ctx, stream)) + } + + fn decode_response( + &self, + body: &str, + ctx: &CodecCtx<'_>, + rate_limit: Option, + ) -> Result { + decode::decode_response(body, ctx, rate_limit) + } + + fn stream_decoder( + &self, + ctx: &CodecCtx<'_>, + rate_limit: Option, + ) -> Box { + Box::new(stream::SseAccumulator::new( + ctx.provider_name, + decode::uses_json_schema_format(ctx.request), + rate_limit, + )) + } + + fn encode_count_tokens(&self, ctx: &CodecCtx<'_>) -> Option> { + Some(Ok(encode::encode_count_tokens(ctx))) + } + + fn decode_count_tokens(&self, body: &str) -> Result { + decode::decode_count_tokens(body) + } +} diff --git a/lib/crates/fabro-llm/src/codec/anthropic_messages/stream.rs b/lib/crates/fabro-llm/src/codec/anthropic_messages/stream.rs new file mode 100644 index 000000000..56bad4609 --- /dev/null +++ b/lib/crates/fabro-llm/src/codec/anthropic_messages/stream.rs @@ -0,0 +1,654 @@ +//! Streaming decoder: Anthropic SSE events → canonical `StreamEvent`s. +//! +//! Byte reading and SSE block framing live in the transport; this decoder is +//! fed framed `RawEvent`s (`event:` type + `data:` JSON). Anthropic never +//! synthesizes a finish on byte-stream end — `message_stop` is the finisher — +//! so `finish()` returns nothing. + +use super::SYNTHETIC_TOOL_NAME; +use super::decode::{convert_synthetic_tool_to_text, map_finish_reason, refusal_error}; +use crate::codec::{RawEvent, StreamDecoder}; +use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind}; +use crate::types::{ + ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, ThinkingData, + TokenCounts, ToolCall, +}; + +/// The type of the current content block being streamed. +#[derive(Clone)] +enum ContentBlockKind { + Text, + ToolUse { id: String, name: String }, + Thinking { signature: Option }, +} + +/// Accumulated state across SSE events during streaming. +pub(super) struct SseAccumulator { + id: String, + model: String, + /// Configured provider name stamped into the final `Response.provider`. + provider: String, + /// When true, synthetic-tool events are rewritten to text events. + json_schema_mode: bool, + content_parts: Vec, + usage: TokenCounts, + finish_reason: FinishReason, + current_block: Option, + current_text: String, + current_thinking: String, + current_tool_args: String, + rate_limit: Option, +} + +impl SseAccumulator { + pub(super) fn new( + provider: &str, + json_schema_mode: bool, + rate_limit: Option, + ) -> Self { + Self { + id: String::new(), + model: String::new(), + provider: provider.to_string(), + json_schema_mode, + content_parts: Vec::new(), + usage: TokenCounts::default(), + finish_reason: FinishReason::Stop, + current_block: None, + current_text: String::new(), + current_thinking: String::new(), + current_tool_args: String::new(), + rate_limit, + } + } + + fn take_response(&mut self) -> Response { + Response { + id: std::mem::take(&mut self.id), + model: std::mem::take(&mut self.model), + provider: self.provider.clone(), + message: Message { + role: Role::Assistant, + content: std::mem::take(&mut self.content_parts), + name: None, + tool_call_id: None, + }, + finish_reason: std::mem::replace(&mut self.finish_reason, FinishReason::Stop), + usage: std::mem::take(&mut self.usage), + raw: None, + warnings: vec![], + rate_limit: self.rate_limit.take(), + } + } + + fn process_event(&mut self, event_type: &str, data: &serde_json::Value) -> Vec { + match event_type { + "message_start" => self.handle_message_start(data), + "content_block_start" => self.handle_content_block_start(data), + "content_block_delta" => self.handle_content_block_delta(data), + "content_block_stop" => self.handle_content_block_stop(data), + "message_delta" => { + self.handle_message_delta(data); + vec![] + } + "message_stop" => self.handle_message_stop(), + _ => vec![], + } + } + + fn handle_message_start(&mut self, data: &serde_json::Value) -> Vec { + if let Some(message) = data.get("message") { + if let Some(id) = message.get("id").and_then(serde_json::Value::as_str) { + self.id = id.to_string(); + } + if let Some(model) = message.get("model").and_then(serde_json::Value::as_str) { + self.model = model.to_string(); + } + if let Some(usage) = message.get("usage") { + self.usage.input_tokens = usage + .get("input_tokens") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0); + self.usage.cache_read_tokens = usage + .get("cache_read_input_tokens") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0); + self.usage.cache_write_tokens = usage + .get("cache_creation_input_tokens") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0); + } + } + vec![StreamEvent::StreamStart] + } + + fn handle_content_block_start(&mut self, data: &serde_json::Value) -> Vec { + let block_type = data + .get("content_block") + .and_then(|b| b.get("type")) + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + + let text_id = Some(block_text_id(data)); + + match block_type { + "text" => { + self.current_block = Some(ContentBlockKind::Text); + self.current_text.clear(); + vec![StreamEvent::TextStart { text_id }] + } + "tool_use" => { + let content_block = data.get("content_block"); + let id = content_block + .and_then(|b| b.get("id")) + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(); + let name = content_block + .and_then(|b| b.get("name")) + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(); + self.current_block = Some(ContentBlockKind::ToolUse { + id: id.clone(), + name: name.clone(), + }); + self.current_tool_args.clear(); + vec![StreamEvent::ToolCallStart { + tool_call: ToolCall::new(id, name, serde_json::json!({})), + }] + } + "thinking" => { + let signature = data + .get("content_block") + .and_then(|b| b.get("signature")) + .and_then(serde_json::Value::as_str) + .map(String::from); + self.current_block = Some(ContentBlockKind::Thinking { signature }); + self.current_thinking.clear(); + vec![StreamEvent::ReasoningStart] + } + _ => vec![], + } + } + + fn handle_content_block_delta(&mut self, data: &serde_json::Value) -> Vec { + let delta = data.get("delta"); + let delta_type = delta + .and_then(|d| d.get("type")) + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + + match delta_type { + "text_delta" => { + let text = delta + .and_then(|d| d.get("text")) + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + self.current_text.push_str(text); + + vec![StreamEvent::TextDelta { + delta: text.to_string(), + text_id: Some(block_text_id(data)), + }] + } + "input_json_delta" => { + let partial_json = delta + .and_then(|d| d.get("partial_json")) + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + self.current_tool_args.push_str(partial_json); + + if let Some(ContentBlockKind::ToolUse { id, name }) = &self.current_block { + vec![StreamEvent::ToolCallDelta { + tool_call: ToolCall::new( + id.clone(), + name.clone(), + serde_json::json!(partial_json), + ), + }] + } else { + vec![] + } + } + "thinking_delta" => { + let thinking = delta + .and_then(|d| d.get("thinking")) + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + self.current_thinking.push_str(thinking); + vec![StreamEvent::ReasoningDelta { + delta: thinking.to_string(), + }] + } + "signature_delta" => { + let signature = delta + .and_then(|d| d.get("signature")) + .and_then(serde_json::Value::as_str) + .map(String::from); + if let Some(ContentBlockKind::Thinking { + signature: ref mut sig, + }) = self.current_block + { + *sig = signature; + } + vec![] + } + _ => vec![], + } + } + + fn handle_content_block_stop(&mut self, data: &serde_json::Value) -> Vec { + let current_block = self.current_block.take(); + match current_block { + Some(ContentBlockKind::Text) => { + let text = std::mem::take(&mut self.current_text); + self.content_parts.push(ContentPart::text(text)); + + vec![StreamEvent::TextEnd { + text_id: Some(block_text_id(data)), + }] + } + Some(ContentBlockKind::ToolUse { id, name }) => { + let raw_args = std::mem::take(&mut self.current_tool_args); + let arguments = + serde_json::from_str(&raw_args).unwrap_or_else(|_| serde_json::json!({})); + let mut tool_call = ToolCall::new(id, name, arguments); + tool_call.raw_arguments = Some(raw_args); + self.content_parts + .push(ContentPart::ToolCall(tool_call.clone())); + vec![StreamEvent::ToolCallEnd { tool_call }] + } + Some(ContentBlockKind::Thinking { signature }) => { + let thinking_text = std::mem::take(&mut self.current_thinking); + // Prefer signature from content_block_stop if available, fall + // back to one captured at content_block_start. + let stop_signature = data + .get("content_block") + .and_then(|b| b.get("signature")) + .and_then(serde_json::Value::as_str) + .map(String::from); + self.content_parts.push(ContentPart::Thinking(ThinkingData { + text: thinking_text, + signature: stop_signature.or(signature), + redacted: false, + })); + vec![StreamEvent::ReasoningEnd] + } + None => vec![], + } + } + + fn handle_message_delta(&mut self, data: &serde_json::Value) { + if let Some(delta) = data.get("delta") { + let stop_reason = delta.get("stop_reason").and_then(serde_json::Value::as_str); + self.finish_reason = map_finish_reason(stop_reason); + } + if let Some(usage) = data.get("usage") { + self.usage.output_tokens = usage + .get("output_tokens") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0); + } + } + + fn handle_message_stop(&mut self) -> Vec { + let response = self.take_response(); + vec![StreamEvent::Finish { + finish_reason: response.finish_reason.clone(), + usage: response.usage.clone(), + response: Box::new(response), + }] + } +} + +/// The `text_id` for a content-block event: `block_`. +fn block_text_id(data: &serde_json::Value) -> String { + let index = data + .get("index") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + format!("block_{index}") +} + +/// Extract the `stop_details` from a refusal `message_delta`, if present. +fn refusal_stop_details(data: &serde_json::Value) -> Option<&serde_json::Value> { + data.get("delta") + .and_then(|delta| delta.get("stop_details")) +} + +/// Whether a `message_delta` event carries a refusal stop reason. +fn is_refusal_message_delta(event_type: &str, data: &serde_json::Value) -> bool { + event_type == "message_delta" + && data + .get("delta") + .and_then(|delta| delta.get("stop_reason")) + .and_then(serde_json::Value::as_str) + == Some("refusal") +} + +/// Wrap a refusal stream event in the same raw shape the non-streaming +/// refusal error carries (`stop_reason` + `stop_details` + the event). +fn refusal_stream_raw(data: &serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "stop_reason": "refusal", + "stop_details": refusal_stop_details(data) + .cloned() + .unwrap_or(serde_json::Value::Null), + "stream_event": data, + }) +} + +/// Map an Anthropic `error` stream event to a provider error. +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 + .get("message") + .and_then(serde_json::Value::as_str) + .or_else(|| data.get("message").and_then(serde_json::Value::as_str)) + .unwrap_or("Unknown Anthropic stream error") + .to_string(); + let error_code = error + .get("type") + .and_then(serde_json::Value::as_str) + .map(String::from); + + let kind = match error_code.as_deref() { + Some("rate_limit_error") => ProviderErrorKind::RateLimit, + Some("authentication_error") => ProviderErrorKind::Authentication, + Some("permission_error") => ProviderErrorKind::AccessDenied, + Some("not_found_error") => ProviderErrorKind::NotFound, + Some("invalid_request_error") => ProviderErrorKind::InvalidRequest, + Some("request_too_large") => ProviderErrorKind::ContextLength, + // overloaded_error, api_error, and unknown stream errors are transient. + _ => ProviderErrorKind::Server, + }; + + Error::Provider { + kind, + detail: Box::new(ProviderErrorDetail { + message, + provider: provider_name.to_string(), + status_code: None, + error_code, + retry_after: None, + raw: Some(data.clone()), + }), + } +} + +/// Rewrite a streaming event for `JsonSchema` mode: synthetic-tool events +/// become text events, and the Finish event's content + finish_reason are +/// adjusted. +fn convert_stream_event_for_json_schema(event: StreamEvent) -> StreamEvent { + match event { + StreamEvent::ToolCallStart { tool_call } if tool_call.name == SYNTHETIC_TOOL_NAME => { + StreamEvent::TextStart { text_id: None } + } + StreamEvent::ToolCallDelta { tool_call } if tool_call.name == SYNTHETIC_TOOL_NAME => { + let delta = match tool_call.arguments { + serde_json::Value::String(s) => s, + other => other.to_string(), + }; + StreamEvent::TextDelta { + delta, + text_id: None, + } + } + StreamEvent::ToolCallEnd { tool_call } if tool_call.name == SYNTHETIC_TOOL_NAME => { + StreamEvent::TextEnd { text_id: None } + } + StreamEvent::Finish { + mut response, + usage, + .. + } => { + response.message.content = + convert_synthetic_tool_to_text(std::mem::take(&mut response.message.content)); + response.finish_reason = FinishReason::Stop; + StreamEvent::Finish { + finish_reason: FinishReason::Stop, + usage, + response, + } + } + other => other, + } +} + +impl StreamDecoder for SseAccumulator { + fn on_event(&mut self, ev: RawEvent<'_>) -> Result, Error> { + let event_type = ev.event.unwrap_or(""); + let data: serde_json::Value = serde_json::from_str(ev.data) + .map_err(|e| Error::stream_error(format!("failed to parse SSE data: {e}"), e))?; + + if event_type == "error" { + return Err(stream_error_event_to_provider_error(&data, &self.provider)); + } + + // A refusal (Claude Fable 5) arrives as a `message_delta` stop reason; + // surface it as an error instead of letting `message_stop` emit a + // normal Finish. + if is_refusal_message_delta(event_type, &data) { + return Err(refusal_error( + &self.provider, + &self.model, + refusal_stream_raw(&data), + refusal_stop_details(&data), + )); + } + + let events = self.process_event(event_type, &data); + if self.json_schema_mode { + Ok(events + .into_iter() + .map(convert_stream_event_for_json_schema) + .collect()) + } else { + Ok(events) + } + } + + fn finish(&mut self) -> Vec { + // Anthropic relies on `message_stop` to finish; nothing to synthesize. + Vec::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn new_accumulator(provider: &str, json_schema_mode: bool) -> SseAccumulator { + SseAccumulator::new(provider, json_schema_mode, None) + } + + #[test] + fn stream_token_counts_leaves_reasoning_zero_and_output_full() { + let mut acc = new_accumulator("anthropic", false); + acc.content_parts.push(ContentPart::Thinking(ThinkingData { + text: "summary text".to_string(), + signature: Some(String::new()), + redacted: false, + })); + acc.content_parts.push(ContentPart::text("answer")); + acc.usage = TokenCounts { + input_tokens: 50, + output_tokens: 1200, + reasoning_tokens: 0, + cache_read_tokens: 9000, + cache_write_tokens: 1000, + }; + + let events = acc.handle_message_stop(); + let StreamEvent::Finish { + usage, response, .. + } = &events[0] + else { + panic!("expected finish event"); + }; + + assert_eq!(usage.input_tokens, 50); + assert_eq!(usage.cache_read_tokens, 9000); + assert_eq!(usage.cache_write_tokens, 1000); + assert_eq!(usage.output_tokens, 1200); + assert_eq!(usage.reasoning_tokens, 0); + assert_eq!(usage.total_tokens(), 11_250); + assert_eq!(response.usage, *usage); + } + + #[test] + fn stream_error_event_overloaded_becomes_retryable_server_error() { + let mut acc = new_accumulator("anthropic", false); + let data = serde_json::json!({ + "type": "error", + "error": { + "type": "overloaded_error", + "message": "Overloaded" + } + }); + let raw = data.to_string(); + + let err = acc + .on_event(RawEvent { + event: Some("error"), + data: &raw, + }) + .unwrap_err(); + + assert!(err.retryable()); + match err { + Error::Provider { kind, detail } => { + assert_eq!(kind, ProviderErrorKind::Server); + assert_eq!(detail.provider, "anthropic"); + assert_eq!(detail.message, "Overloaded"); + assert_eq!(detail.error_code.as_deref(), Some("overloaded_error")); + assert_eq!(detail.raw.as_ref(), Some(&data)); + } + other => panic!("expected provider error, got {other:?}"), + } + } + + #[test] + fn stream_error_event_invalid_request_remains_non_retryable() { + let mut acc = new_accumulator("anthropic", false); + let data = serde_json::json!({ + "type": "error", + "error": { + "type": "invalid_request_error", + "message": "max_tokens is required" + } + }); + let raw = data.to_string(); + + let err = acc + .on_event(RawEvent { + event: Some("error"), + data: &raw, + }) + .unwrap_err(); + + assert!(!err.retryable()); + match err { + Error::Provider { kind, detail } => { + assert_eq!(kind, ProviderErrorKind::InvalidRequest); + assert_eq!(detail.error_code.as_deref(), Some("invalid_request_error")); + } + other => panic!("expected provider error, got {other:?}"), + } + } + + #[test] + fn unknown_sse_events_remain_ignored() { + let mut acc = new_accumulator("anthropic", false); + let data = serde_json::json!({ + "type": "content_block_delta", + "delta": { "type": "text_delta", "text": "ignored" } + }); + let raw = data.to_string(); + + let events = acc + .on_event(RawEvent { + event: Some("some_future_event"), + data: &raw, + }) + .unwrap(); + + assert!(events.is_empty()); + } + + #[test] + fn convert_stream_event_converts_tool_start_for_synthetic() { + let event = StreamEvent::ToolCallStart { + tool_call: ToolCall::new("id1", SYNTHETIC_TOOL_NAME, serde_json::json!({})), + }; + let result = convert_stream_event_for_json_schema(event); + assert!(matches!(result, StreamEvent::TextStart { .. })); + } + + #[test] + fn convert_stream_event_preserves_real_tool_start() { + let event = StreamEvent::ToolCallStart { + tool_call: ToolCall::new("id1", "real_tool", serde_json::json!({})), + }; + let result = convert_stream_event_for_json_schema(event); + assert!(matches!(result, StreamEvent::ToolCallStart { .. })); + } + + #[test] + fn convert_stream_event_converts_tool_delta_for_synthetic() { + let event = StreamEvent::ToolCallDelta { + tool_call: ToolCall::new("id1", SYNTHETIC_TOOL_NAME, serde_json::json!("{\"name\"")), + }; + let result = convert_stream_event_for_json_schema(event); + match result { + StreamEvent::TextDelta { delta, .. } => { + assert_eq!(delta, "{\"name\""); + } + other => panic!("expected TextDelta, got {other:?}"), + } + } + + #[test] + fn convert_stream_event_converts_finish_reason() { + let response = Box::new(Response { + id: "test".to_string(), + model: "claude".to_string(), + provider: "anthropic".to_string(), + message: Message { + role: Role::Assistant, + content: vec![ContentPart::ToolCall(ToolCall::new( + "id1", + SYNTHETIC_TOOL_NAME, + serde_json::json!({"data": "value"}), + ))], + name: None, + tool_call_id: None, + }, + finish_reason: FinishReason::ToolCalls, + usage: TokenCounts::default(), + raw: None, + warnings: vec![], + rate_limit: None, + }); + let event = StreamEvent::Finish { + finish_reason: FinishReason::ToolCalls, + usage: TokenCounts::default(), + response, + }; + let result = convert_stream_event_for_json_schema(event); + match result { + StreamEvent::Finish { + finish_reason, + response, + .. + } => { + assert_eq!(finish_reason, FinishReason::Stop); + assert_eq!(response.finish_reason, FinishReason::Stop); + // Content should be converted from tool call to text + assert!(matches!(&response.message.content[0], ContentPart::Text(_))); + } + other => panic!("expected Finish, got {other:?}"), + } + } +} diff --git a/lib/crates/fabro-llm/src/codec/anthropic_messages/wire.rs b/lib/crates/fabro-llm/src/codec/anthropic_messages/wire.rs new file mode 100644 index 000000000..80458dea4 --- /dev/null +++ b/lib/crates/fabro-llm/src/codec/anthropic_messages/wire.rs @@ -0,0 +1,154 @@ +//! Serde types mirroring the Anthropic Messages wire shapes. + +#[derive(serde::Serialize)] +pub(super) struct ApiRequest { + pub model: String, + pub messages: Vec, + pub max_tokens: i64, + /// System prompt: either a plain string or an array of content blocks + /// (with optional `cache_control` annotations for prompt caching). + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + /// Always serialized, even when empty (pinned by wire tests). + pub stop_sequences: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + /// Extended thinking configuration (e.g. `{"type": "enabled", + /// "budget_tokens": 10000}`). + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub speed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub stream: bool, +} + +#[derive(serde::Serialize)] +pub(super) struct CountTokensRequest { + pub model: String, + pub messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking: Option, +} + +impl From for CountTokensRequest { + fn from(request: ApiRequest) -> Self { + Self { + model: request.model, + messages: request.messages, + system: request.system, + tools: request.tools, + tool_choice: request.tool_choice, + thinking: request.thinking, + } + } +} + +/// Anthropic messages use structured content blocks, not plain strings. +#[derive(serde::Serialize)] +pub(super) struct ApiMessage { + pub role: String, + pub content: Vec, +} + +/// Anthropic tool definition format. +#[derive(serde::Serialize)] +pub(super) struct ApiToolDef { + pub name: String, + pub description: String, + pub input_schema: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_control: Option, +} + +/// Anthropic `cache_control` annotation. +#[derive(serde::Serialize, Clone)] +pub(super) struct CacheControl { + #[serde(rename = "type")] + pub kind: String, +} + +impl CacheControl { + pub(super) fn ephemeral() -> Self { + Self { + kind: "ephemeral".to_string(), + } + } +} + +// --- Response types --- + +#[derive(serde::Deserialize)] +pub(super) struct ApiResponse { + pub id: String, + pub model: String, + pub content: Vec, + pub stop_reason: Option, + #[serde(default)] + pub stop_details: Option, + pub usage: ApiUsage, +} + +#[derive(serde::Deserialize)] +#[allow( + clippy::struct_field_names, + reason = "Field names mirror the provider API payload." +)] +pub(super) struct ApiUsage { + pub input_tokens: i64, + pub output_tokens: i64, + #[serde(default)] + pub cache_read_input_tokens: Option, + #[serde(default)] + pub cache_creation_input_tokens: Option, +} + +#[derive(serde::Deserialize)] +pub(super) struct CountTokensResponse { + pub input_tokens: i64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_serialization_includes_cache_control() { + let tool = ApiToolDef { + name: "test_tool".to_string(), + description: "A test tool".to_string(), + input_schema: serde_json::json!({"type": "object"}), + cache_control: Some(CacheControl::ephemeral()), + }; + let json = serde_json::to_value(&tool).expect("should serialize"); + assert_eq!(json["cache_control"]["type"], "ephemeral"); + } + + #[test] + fn tool_serialization_omits_cache_control_when_none() { + let tool = ApiToolDef { + name: "test_tool".to_string(), + description: "A test tool".to_string(), + input_schema: serde_json::json!({"type": "object"}), + cache_control: None, + }; + let json = serde_json::to_value(&tool).expect("should serialize"); + assert!(json.get("cache_control").is_none()); + } +} diff --git a/lib/crates/fabro-llm/src/codec/mod.rs b/lib/crates/fabro-llm/src/codec/mod.rs index b8b1c2a94..4e3959311 100644 --- a/lib/crates/fabro-llm/src/codec/mod.rs +++ b/lib/crates/fabro-llm/src/codec/mod.rs @@ -10,18 +10,7 @@ //! defaults) so the per-dialect codecs that follow only ever *override* //! methods, never extend the contract. -// The contract is defined in full now, but `openai_compatible` (the first -// codec) is the simplest dialect and does not exercise every seam: the -// `model`/`params` context, `RawEvent.event`, and the count-tokens methods -// are consumed by the anthropic/openai/gemini codecs and the transport -// consolidation in later PRs of this series. Scoped to this trait-definition -// file; the codec impls below are fully used. -#![allow( - dead_code, - reason = "Codec contract is defined in full ahead of the dialects (PRs 3-6) that exercise \ - the capability context, SSE event type, and count-tokens routes." -)] - +pub(crate) mod anthropic_messages; pub(crate) mod openai_compatible; use fabro_model::Model; @@ -51,11 +40,30 @@ pub(crate) struct CodecCtx<'a> { } /// Per-route dialect knobs, expressed as data so one codec can serve several -/// routes. Starts empty; grows by adding `#[serde(default)]` fields (a -/// non-breaking change) — e.g. PR 3 adds version-placement, #459 adds -/// model-placement for Bedrock. +/// routes. The default is inert ("nothing special"); a route that needs a +/// dialect quirk sets the relevant field. Grows as codecs need it — #459 adds +/// `ModelPlacement` for Bedrock. #[derive(Debug, Default, Clone)] -pub(crate) struct CodecParams; +pub(crate) struct CodecParams { + /// Where/whether to place the Anthropic API version. Direct Anthropic uses + /// `Header("2023-06-01")`; Kimi-over-anthropic uses `None`; the Bedrock + /// redo will add a body-field variant. Inert for non-anthropic codecs. + pub anthropic_version: AnthropicVersion, + /// Whether to emit Anthropic beta headers (prompt-caching / fast-mode / + /// 1M-context). True on the direct route, false for Kimi-over-anthropic. + pub anthropic_beta: bool, +} + +/// Placement of the Anthropic API version on the wire. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) enum AnthropicVersion { + /// No version sent (Kimi-over-anthropic; also the inert default). + #[default] + None, + /// `anthropic-version` request header (direct Anthropic). + Header(&'static str), + // BodyField(&'static str) arrives with the Bedrock redo (#459). +} /// What [`Codec::encode`] produces. The transport applies `endpoint` + /// `headers` on top of the route's base URL and auth; the codec never touches @@ -131,9 +139,9 @@ pub(crate) trait Codec: Send + Sync { /// Map a non-2xx response to an `Error`. `retry_after` is the /// transport-parsed `retry-after` header value in seconds (header parsing /// is the transport's job, like `rate_limit` on the decode methods). - /// Default = shared HTTP-status mapping (what openai_compatible uses); - /// anthropic/openai/gemini override to fold in dialect error bodies - /// (error.type, gRPC status, …). + /// Default = shared HTTP-status mapping, which openai_compatible and + /// anthropic use as-is; a codec overrides when its dialect's error bodies + /// need more (e.g. gemini's gRPC status). fn decode_error( &self, status: u16, diff --git a/lib/crates/fabro-llm/src/codec/openai_compatible/request.rs b/lib/crates/fabro-llm/src/codec/openai_compatible/request.rs index 6d360cd1c..35a32415d 100644 --- a/lib/crates/fabro-llm/src/codec/openai_compatible/request.rs +++ b/lib/crates/fabro-llm/src/codec/openai_compatible/request.rs @@ -107,7 +107,7 @@ mod tests { /// Encode `request` through the codec with `deployment_id == request.model` /// (the no-catalog case) and return the body. fn encode_body(request: &Request, provider_name: &str, stream: bool) -> serde_json::Value { - let params = CodecParams; + let params = CodecParams::default(); let deployment_id = request.model.clone(); let ctx = CodecCtx { request, @@ -155,7 +155,7 @@ mod tests { #[test] fn encode_uses_deployment_id_as_model() { let request = minimal_request(); - let params = CodecParams; + let params = CodecParams::default(); let deployment_id = "acme/model-large".to_string(); let ctx = CodecCtx { request: &request, diff --git a/lib/crates/fabro-llm/src/codec/openai_compatible/stream.rs b/lib/crates/fabro-llm/src/codec/openai_compatible/stream.rs index f99f74b4f..37081c90d 100644 --- a/lib/crates/fabro-llm/src/codec/openai_compatible/stream.rs +++ b/lib/crates/fabro-llm/src/codec/openai_compatible/stream.rs @@ -284,7 +284,7 @@ mod tests { metadata: None, provider_options: None, }; - let params = CodecParams; + let params = CodecParams::default(); let ctx = CodecCtx { request: &request, provider_name: provider, diff --git a/lib/crates/fabro-llm/src/providers/anthropic.rs b/lib/crates/fabro-llm/src/providers/anthropic.rs index 9191fb0ec..f5d4d79a1 100644 --- a/lib/crates/fabro-llm/src/providers/anthropic.rs +++ b/lib/crates/fabro-llm/src/providers/anthropic.rs @@ -1,24 +1,29 @@ use std::sync::Arc; -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use fabro_model::{Catalog, Model, ReasoningEffortFeature}; +use fabro_model::{Catalog, ReasoningEffortFeature}; use futures::stream; -use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind, error_from_status_code}; -use crate::provider::{ProviderAdapter, StreamEventStream, validate_tool_choice}; +use crate::attachments::{self, AttachmentPolicy}; +use crate::codec::anthropic_messages::{AnthropicMessages, anthropic_option}; +use crate::codec::{ + AnthropicVersion, Codec, CodecCtx, CodecParams, EncodedRequest, RawEvent, StreamDecoder, +}; +use crate::error::Error; +use crate::provider::{self, ProviderAdapter, StreamEventStream}; use crate::providers::common::{ - self as common, extract_system_prompt, parse_error_body, parse_rate_limit_headers, - parse_retry_after, send_and_read_response, + self as common, parse_rate_limit_headers, parse_retry_after, send_and_read_response, }; use crate::token_count::{InputTokenCount, InputTokenCountMethod}; -use crate::types::{ - AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, ReasoningEffort, Request, - Response, ResponseFormatType, Role, Speed, StreamEvent, ThinkingData, TokenCounts, ToolCall, - ToolChoice, ToolDefinition, -}; +use crate::types::{AdapterTimeout, Request, Response, StreamEvent}; + +const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1"; /// Provider adapter for the Anthropic Messages API. +/// +/// A thin transport shell over the `anthropic_messages` codec: it owns auth, +/// base URL, the streaming byte loop, and the route configuration that selects +/// between the direct-Anthropic and Kimi-over-anthropic behaviors. All wire +/// translation lives in the codec. pub struct Adapter { pub(crate) http: super::http_api::HttpApi, provider_name: String, @@ -74,12 +79,95 @@ impl Adapter { } } - fn messages_url(&self) -> String { - format!("{}/messages", self.http.base_url) + /// Resolve the route configuration for this adapter. + /// + /// The direct-Anthropic route (`provider_name == "anthropic"`) + /// authenticates with `x-api-key`, emits the version + beta headers, + /// and supports the count-tokens endpoint. Every other name (e.g. + /// Kimi-over-anthropic) is a bearer-auth route with no anthropic + /// headers, no count-tokens route, and blocking requests served via + /// streaming. Resolved once here instead of string-comparing + /// `provider_name` at each request-time decision. + fn route_config(&self) -> RouteConfig { + if self.provider_name == "anthropic" { + RouteConfig { + auth: AuthScheme::ApiKey, + codec_params: CodecParams { + anthropic_version: AnthropicVersion::Header("2023-06-01"), + anthropic_beta: true, + }, + supports_count_tokens: true, + force_streaming: false, + } + } else { + RouteConfig { + auth: AuthScheme::Bearer, + codec_params: CodecParams::default(), + supports_count_tokens: false, + force_streaming: true, + } + } } - fn count_tokens_url(&self) -> String { - format!("{}/messages/count_tokens", self.http.base_url) + /// Build the borrowed codec context. `deployment_id` and `params` are + /// created by the caller so their borrows outlive the context. + fn codec_ctx<'a>( + &'a self, + request: &'a Request, + deployment_id: &'a str, + params: &'a CodecParams, + ) -> CodecCtx<'a> { + CodecCtx { + request, + provider_name: &self.provider_name, + deployment_id, + model: common::catalog_model(self.catalog.as_deref(), &request.model), + params, + } + } + + /// Build the canonical request for the codec, resolving file-backed + /// attachments to inline data first. Borrowed when nothing needs loading. + async fn resolve_request<'a>(&self, request: &'a Request) -> std::borrow::Cow<'a, Request> { + // Anthropic loads images and documents inline; audio falls back to a + // text placeholder in the codec, so it is not loaded here. + let policy = AttachmentPolicy { + images: true, + documents: true, + audio: false, + }; + attachments::resolve(request, policy).await + } + + /// Apply the route base URL, auth, and codec-emitted dialect headers to an + /// encoded request. + fn build_http_request( + &self, + encoded: &EncodedRequest, + route: &RouteConfig, + ) -> fabro_http::RequestBuilder { + let url = format!("{}{}", self.http.base_url, encoded.endpoint); + let mut req = self.http.client.post(&url); + // default_headers first so codec/auth headers can override. + for (key, value) in &self.http.default_headers { + req = req.header(key, value); + } + match route.auth { + AuthScheme::ApiKey => { + if let Some(api_key) = &self.http.api_key { + req = req.header("x-api-key", api_key); + } + } + AuthScheme::Bearer => { + if let Some(api_key) = &self.http.api_key { + req = req.bearer_auth(api_key); + } + } + } + for (key, value) in &encoded.headers { + req = req.header(key, value); + } + req.json(&encoded.body) } /// Collect a streaming response into a single [`Response`]. @@ -104,1328 +192,63 @@ impl Adapter { } } -const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1"; - -// --- Request types --- - -#[derive(serde::Serialize)] -struct ApiRequest { - model: String, - messages: Vec, - max_tokens: i64, - /// System prompt: either a plain string or an array of content blocks - /// (with optional `cache_control` annotations for prompt caching). - #[serde(skip_serializing_if = "Option::is_none")] - system: Option, - #[serde(skip_serializing_if = "Option::is_none")] - temperature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - top_p: Option, - #[serde(skip_serializing_if = "Option::is_none")] - stop_sequences: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - tool_choice: Option, - /// Extended thinking configuration (e.g. `{"type": "enabled", - /// "budget_tokens": 10000}`). Passed through from - /// `provider_options.anthropic.thinking`. - #[serde(skip_serializing_if = "Option::is_none")] - thinking: Option, - #[serde(skip_serializing_if = "Option::is_none")] - output_config: Option, - #[serde(skip_serializing_if = "Option::is_none")] - speed: Option, - #[serde(skip_serializing_if = "Option::is_none")] - metadata: Option>, - #[serde(skip_serializing_if = "std::ops::Not::not")] - stream: bool, +/// Resolved per-request routing decisions (auth, dialect headers, optional +/// routes) that used to be inline `provider_name == "anthropic"` branches. +struct RouteConfig { + auth: AuthScheme, + codec_params: CodecParams, + supports_count_tokens: bool, + force_streaming: bool, } -#[derive(serde::Serialize)] -struct CountTokensRequest { - model: String, - messages: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - system: Option, - #[serde(skip_serializing_if = "Option::is_none")] - tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - tool_choice: Option, - #[serde(skip_serializing_if = "Option::is_none")] - thinking: Option, +enum AuthScheme { + ApiKey, + Bearer, } -impl From for CountTokensRequest { - fn from(request: ApiRequest) -> Self { - Self { - model: request.model, - messages: request.messages, - system: request.system, - tools: request.tools, - tool_choice: request.tool_choice, - thinking: request.thinking, - } - } -} - -/// Anthropic messages use structured content blocks, not plain strings. -#[derive(serde::Serialize)] -struct ApiMessage { - role: String, - content: Vec, -} - -/// Anthropic tool definition format. -#[derive(serde::Serialize)] -struct ApiToolDef { - name: String, - description: String, - input_schema: serde_json::Value, - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, -} - -/// Anthropic `cache_control` annotation. -#[derive(serde::Serialize, Clone)] -struct CacheControl { - #[serde(rename = "type")] - kind: String, -} - -impl CacheControl { - fn ephemeral() -> Self { - Self { - kind: "ephemeral".to_string(), - } - } -} - -// --- Response types --- - -#[derive(serde::Deserialize)] -struct ApiResponse { - id: String, - model: String, - content: Vec, - stop_reason: Option, - #[serde(default)] - stop_details: Option, - usage: ApiUsage, -} - -#[derive(serde::Deserialize)] -#[allow( - clippy::struct_field_names, - reason = "Field names mirror the provider API payload." -)] -struct ApiUsage { - input_tokens: i64, - output_tokens: i64, - #[serde(default)] - cache_read_input_tokens: Option, - #[serde(default)] - cache_creation_input_tokens: Option, -} - -#[derive(serde::Deserialize)] -struct CountTokensResponse { - input_tokens: i64, -} - -fn token_counts_from_api_usage(usage: &ApiUsage) -> TokenCounts { - // Anthropic does not expose a separate billed thinking/reasoning token - // count. Thinking tokens are billed as part of `output_tokens`. When - // Anthropic adds a real thinking token field, wire it through and subtract - // it here. - TokenCounts { - input_tokens: usage.input_tokens, - output_tokens: usage.output_tokens, - reasoning_tokens: 0, - cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0), - cache_write_tokens: usage.cache_creation_input_tokens.unwrap_or(0), - } -} - -fn map_finish_reason(stop_reason: Option<&str>) -> FinishReason { - match stop_reason { - Some("end_turn" | "stop_sequence") | None => FinishReason::Stop, - Some("max_tokens") => FinishReason::Length, - Some("tool_use") => FinishReason::ToolCalls, - Some(other) => FinishReason::Other(other.to_string()), - } -} - -fn parse_content_block(block: &serde_json::Value) -> Option { - match block.get("type")?.as_str()? { - "text" => Some(ContentPart::text(block.get("text")?.as_str()?)), - "tool_use" => Some(ContentPart::ToolCall(ToolCall::new( - block.get("id")?.as_str()?, - block.get("name")?.as_str()?, - block.get("input")?.clone(), - ))), - "thinking" => Some(ContentPart::Thinking(ThinkingData { - text: block.get("thinking")?.as_str()?.to_string(), - signature: block - .get("signature") - .and_then(serde_json::Value::as_str) - .map(String::from), - redacted: false, - })), - "redacted_thinking" => Some(ContentPart::Thinking(ThinkingData { - text: block - .get("data") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(), - signature: None, - redacted: true, - })), - _ => None, - } -} - -/// Translate a unified `ContentPart` to an Anthropic content block JSON value. -async fn content_part_to_api(part: &ContentPart) -> Option { - match part { - ContentPart::Text(text) => Some(serde_json::json!({"type": "text", "text": text})), - ContentPart::ToolCall(tc) => Some(serde_json::json!({ - "type": "tool_use", - "id": tc.id, - "name": tc.name, - "input": tc.arguments, - })), - ContentPart::ToolResult(tr) => { - let content = tr - .content - .as_str() - .map_or_else(|| tr.content.to_string(), str::to_string); - Some(serde_json::json!({ - "type": "tool_result", - "tool_use_id": tr.tool_call_id, - "content": content, - "is_error": tr.is_error, - })) - } - ContentPart::Thinking(td) if td.redacted => Some(serde_json::json!({ - "type": "redacted_thinking", - "data": td.text, - })), - ContentPart::Thinking(td) => { - let mut block = serde_json::json!({ - "type": "thinking", - "thinking": td.text, - }); - if let Some(sig) = &td.signature { - block["signature"] = serde_json::Value::String(sig.clone()); - } - Some(block) - } - ContentPart::Image(img) => { - if let Some(url) = &img.url { - if common::is_file_path(url) { - return match common::load_file_as_base64(url).await { - Ok((b64, mime)) => Some(serde_json::json!({ - "type": "image", - "source": {"type": "base64", "media_type": mime, "data": b64} - })), - Err(_) => None, - }; - } - Some(serde_json::json!({"type": "image", "source": {"type": "url", "url": url}})) - } else { - img.data.as_ref().map(|data| { - let mime = img.media_type.as_deref().unwrap_or("image/png"); - let b64 = BASE64_STANDARD.encode(data); - serde_json::json!({"type": "image", "source": {"type": "base64", "media_type": mime, "data": b64}}) - }) - } - } - ContentPart::Document(doc) => { - if let Some(url) = &doc.url { - if common::is_file_path(url) { - return match common::load_file_as_base64(url).await { - Ok((b64, mime)) => Some(serde_json::json!({ - "type": "document", - "source": {"type": "base64", "media_type": mime, "data": b64} - })), - Err(_) => None, - }; - } - Some(serde_json::json!({"type": "document", "source": {"type": "url", "url": url}})) - } else { - doc.data.as_ref().map(|data| { - let mime = doc.media_type.as_deref().unwrap_or("application/pdf"); - let b64 = BASE64_STANDARD.encode(data); - serde_json::json!({"type": "document", "source": {"type": "base64", "media_type": mime, "data": b64}}) - }) - } - } - ContentPart::Audio(_) => Some( - serde_json::json!({"type": "text", "text": "[Audio content not supported by this provider]"}), - ), - ContentPart::Other { .. } => None, - } -} - -/// Convert unified messages to Anthropic API messages. -/// -/// Handles: role mapping, content block translation, strict alternation -/// (merging consecutive same-role messages), and tool results in user messages. -async fn translate_messages(messages: &[&Message]) -> Vec { - let mut api_messages: Vec = Vec::new(); - - for msg in messages { - let role = match msg.role { - Role::Assistant => "assistant", - // Tool results go in user messages for Anthropic - Role::User | Role::Tool => "user", - // System and Developer are extracted separately - Role::System | Role::Developer => continue, - }; - - let mut content = Vec::new(); - for part in &msg.content { - if let Some(block) = content_part_to_api(part).await { - content.push(block); - } - } - - if content.is_empty() { - continue; - } - - // Enforce strict user/assistant alternation by merging consecutive same-role - // messages - if let Some(last) = api_messages.last_mut() { - if last.role == role { - last.content.extend(content); - continue; - } - } - - api_messages.push(ApiMessage { - role: role.to_string(), - content, - }); - } - - api_messages -} - -/// Translate unified `ToolDefinition` to Anthropic format. -fn translate_tools(tools: &[ToolDefinition]) -> Vec { - tools - .iter() - .map(|t| ApiToolDef { - name: t.name.clone(), - description: t.description.clone(), - input_schema: t.parameters.clone(), - cache_control: None, - }) - .collect() -} - -/// Translate unified `ToolChoice` to Anthropic's `tool_choice` JSON. -fn translate_tool_choice(choice: &ToolChoice) -> Option { - match choice { - ToolChoice::Auto => Some(serde_json::json!({"type": "auto"})), - // Anthropic does not support tool_choice none with tools present. - // The caller should omit tools from the request instead. - ToolChoice::None => None, - ToolChoice::Required => Some(serde_json::json!({"type": "any"})), - ToolChoice::Named { tool_name } => { - Some(serde_json::json!({"type": "tool", "name": tool_name})) - } - } -} - -fn tool_choice_forces_tool_use(tool_choice: Option<&serde_json::Value>) -> bool { - matches!( - tool_choice - .and_then(|value| value.get("type")) - .and_then(serde_json::Value::as_str), - Some("any" | "tool") - ) -} - -// --- Structured output (response_format) helpers --- - -const SYNTHETIC_TOOL_NAME: &str = "json_output"; - -/// Apply `response_format` to the Anthropic API request by mutating tools, -/// `tool_choice`, and system. -/// -/// For `JsonSchema`: injects a synthetic tool with the given schema and forces -/// the model to call it. For `JsonObject`: appends a JSON instruction to the -/// system prompt. For `Text`: no-op. -fn apply_response_format( - request: &Request, - api_tools: &mut Option>, - tool_choice: &mut Option, - system: &mut Option, -) { - let Some(format) = &request.response_format else { - return; - }; - - match format.kind { - ResponseFormatType::JsonSchema => { - let schema = format - .json_schema - .clone() - .unwrap_or_else(|| serde_json::json!({"type": "object"})); - let synthetic_tool = ApiToolDef { - name: SYNTHETIC_TOOL_NAME.to_string(), - description: "Output the requested structured data".to_string(), - input_schema: schema, - cache_control: None, - }; - match api_tools { - Some(tools) => tools.push(synthetic_tool), - None => *api_tools = Some(vec![synthetic_tool]), - } - *tool_choice = Some(serde_json::json!({"type": "tool", "name": SYNTHETIC_TOOL_NAME})); - } - ResponseFormatType::JsonObject => { - let json_instruction = "\n\nYou must respond with valid JSON only, no other text."; - match system { - Some(serde_json::Value::Array(blocks)) => { - // Append to the last text block's text - if let Some(last) = blocks.last_mut() { - if let Some(text) = last.get("text").and_then(serde_json::Value::as_str) { - let mut new_text = text.to_string(); - new_text.push_str(json_instruction); - last["text"] = serde_json::Value::String(new_text); - } - } else { - blocks.push( - serde_json::json!({"type": "text", "text": json_instruction.trim()}), - ); - } - } - Some(serde_json::Value::String(s)) => { - s.push_str(json_instruction); - } - None => { - *system = Some(serde_json::Value::String( - json_instruction.trim().to_string(), - )); - } - _ => {} - } - } - ResponseFormatType::Text => {} - } -} - -/// Convert synthetic `tool_use` content blocks back to text content parts. -/// -/// When `response_format` uses `JsonSchema` mode, the model responds with a -/// `tool_use` block for our synthetic tool. We extract its arguments as a JSON -/// text string. -fn convert_synthetic_tool_to_text(content_parts: Vec) -> Vec { - content_parts - .into_iter() - .map(|part| match &part { - ContentPart::ToolCall(tc) if tc.name == SYNTHETIC_TOOL_NAME => { - ContentPart::text(tc.arguments.to_string()) - } - _ => part, - }) - .collect() -} - -/// Check if the request uses `JsonSchema` `response_format`. -fn uses_json_schema_format(request: &Request) -> bool { - request - .response_format - .as_ref() - .is_some_and(|f| matches!(f.kind, ResponseFormatType::JsonSchema)) -} - -/// Convert a streaming event for `JsonSchema` mode: `tool_use` events for the -/// synthetic tool become text events, and the Finish event gets its content -/// parts and `finish_reason` adjusted. -fn convert_stream_event_for_json_schema(event: StreamEvent) -> StreamEvent { - match event { - StreamEvent::ToolCallStart { tool_call } if tool_call.name == SYNTHETIC_TOOL_NAME => { - StreamEvent::TextStart { text_id: None } - } - StreamEvent::ToolCallDelta { tool_call } if tool_call.name == SYNTHETIC_TOOL_NAME => { - // The delta's arguments field contains the partial JSON string - let delta = match &tool_call.arguments { - serde_json::Value::String(s) => s.clone(), - other => other.to_string(), - }; - StreamEvent::TextDelta { - delta, - text_id: None, - } - } - StreamEvent::ToolCallEnd { tool_call } if tool_call.name == SYNTHETIC_TOOL_NAME => { - StreamEvent::TextEnd { text_id: None } - } - StreamEvent::Finish { - mut response, - usage, - .. - } => { - response.message.content = - convert_synthetic_tool_to_text(std::mem::take(&mut response.message.content)); - response.finish_reason = FinishReason::Stop; - StreamEvent::Finish { - finish_reason: FinishReason::Stop, - usage, - response, - } - } - other => other, - } -} - -// --- Prompt caching helpers --- - -const CACHE_BETA_HEADER: &str = "prompt-caching-2024-07-31"; -const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; - -/// Check whether auto-caching is disabled via `provider_options`. -/// -/// Returns `true` if caching should be applied (the default). -/// Only returns `false` if `provider_options.anthropic.auto_cache` is -/// explicitly `false`. Extract the `thinking` configuration from -/// `provider_options.anthropic.thinking`. -fn extract_thinking_config( - provider_options: Option<&serde_json::Value>, -) -> Option { - provider_options - .and_then(|opts| opts.get("anthropic")) - .and_then(|anthropic| anthropic.get("thinking")) - .cloned() -} - -/// Map a reasoning effort level to a thinking `budget_tokens` value for models -/// that don't support the `output_config.effort` parameter (e.g. -/// claude-sonnet-4-5). -fn effort_to_budget_tokens(effort: ReasoningEffort, max_tokens: i64) -> i64 { - let budget = match effort { - ReasoningEffort::Low => max_tokens / 4, - ReasoningEffort::Medium => max_tokens / 2, - ReasoningEffort::High => max_tokens * 3 / 4, - ReasoningEffort::XHigh => max_tokens * 7 / 8, - ReasoningEffort::Max => max_tokens, - }; - // Anthropic requires budget_tokens >= 1024 - budget.max(1024) -} - -fn is_auto_cache_enabled(provider_options: Option<&serde_json::Value>) -> bool { - provider_options - .and_then(|opts| opts.get("anthropic")) - .and_then(|anthropic| anthropic.get("auto_cache")) - .and_then(serde_json::Value::as_bool) - .unwrap_or(true) +/// State driving the streaming byte loop: the codec's decoder plus the line +/// reader, with a buffer that flattens batched events into individual items. +struct StreamLoop { + decoder: Box, + line_reader: super::common::LineReader, + pending: std::collections::VecDeque, + done: bool, + finished_emitted: bool, } +/// The `provider_options.anthropic.thinking.type` value, if any. 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")) + anthropic_option(provider_options, "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 { - serde_json::json!([{ - "type": "text", - "text": system, - "cache_control": {"type": "ephemeral"} - }]) -} +/// Parse an SSE event block (lines separated within a `\n\n`-delimited chunk) +/// into `(event_type, data)`. Returns `None` for blocks with no `data:` lines +/// (e.g. heartbeat comments). Borrows from the block — Anthropic events carry +/// a single `data:` line, so the hot path allocates nothing. +fn parse_sse_block(event_block: &str) -> Option<(&str, std::borrow::Cow<'_, str>)> { + let mut event_type = ""; + let mut data: Option> = None; -/// Add `cache_control` to the last tool definition. -fn apply_cache_control_to_last_tool(tools: &mut [ApiToolDef]) { - if let Some(last) = tools.last_mut() { - last.cache_control = Some(CacheControl::ephemeral()); - } -} - -/// Add `cache_control` to the last content block of the second-to-last user -/// message. -/// -/// In a multi-turn conversation, the conversation prefix (everything before the -/// latest user turn) is stable and benefits from caching. We find the last user -/// message before the final one and annotate its last content block. -fn apply_cache_control_to_conversation_prefix(messages: &mut [ApiMessage]) { - // Find all user message indices - let user_indices: Vec = messages - .iter() - .enumerate() - .filter(|(_, m)| m.role == "user") - .map(|(i, _)| i) - .collect(); - - // We need at least 2 user messages to have a "prefix" user message - if user_indices.len() < 2 { - return; - } - - // The second-to-last user message is the one to cache - let target_idx = user_indices[user_indices.len() - 2]; - if let Some(serde_json::Value::Object(map)) = messages[target_idx].content.last_mut() { - map.insert( - "cache_control".to_string(), - serde_json::json!({"type": "ephemeral"}), - ); - } -} - -/// Collect beta headers from `provider_options` and merge with the caching -/// header when auto-caching is active. -fn build_beta_header( - provider_options: Option<&serde_json::Value>, - include_cache_header: bool, - include_fast_mode_header: bool, -) -> Option { - let mut headers: Vec = Vec::new(); - - // Add user-provided beta headers - if let Some(beta_array) = provider_options - .and_then(|opts| opts.get("anthropic")) - .and_then(|anthropic| anthropic.get("beta_headers")) - .and_then(serde_json::Value::as_array) - { - headers.extend( - beta_array - .iter() - .filter_map(serde_json::Value::as_str) - .map(String::from), - ); - } - - // Add prompt-caching header if caching is active and not already present - if include_cache_header && !headers.iter().any(|h| h == CACHE_BETA_HEADER) { - headers.push(CACHE_BETA_HEADER.to_string()); - } - - // Add fast-mode header if speed=fast and not already present - if include_fast_mode_header && !headers.iter().any(|h| h == FAST_MODE_BETA_HEADER) { - headers.push(FAST_MODE_BETA_HEADER.to_string()); - } - - if headers.is_empty() { - None - } else { - Some(headers.join(",")) - } -} - -// --- Streaming types and helpers --- - -/// The type of the current content block being streamed. -#[derive(Clone)] -enum ContentBlockKind { - Text, - ToolUse { id: String, name: String }, - Thinking { signature: Option }, -} - -/// Accumulated state across SSE events during streaming. -struct StreamAccumulator { - id: String, - model: String, - /// Configured provider name stamped into the final `Response.provider`. - provider: String, - content_parts: Vec, - usage: TokenCounts, - finish_reason: FinishReason, - /// The kind of the current content block, set by `content_block_start`. - current_block: Option, - /// Accumulated text for the current text block. - current_text: String, - /// Accumulated thinking text for the current thinking block. - current_thinking: String, - /// Accumulated raw JSON arguments for the current `tool_use` block. - current_tool_args: String, - /// Rate limit info parsed from the initial HTTP response headers. - rate_limit: Option, -} - -impl StreamAccumulator { - fn new(rate_limit: Option, provider: String) -> Self { - Self { - id: String::new(), - model: String::new(), - provider, - content_parts: Vec::new(), - usage: TokenCounts::default(), - finish_reason: FinishReason::Stop, - current_block: None, - current_text: String::new(), - current_thinking: String::new(), - current_tool_args: String::new(), - rate_limit, - } - } - - /// Build the final `Response` from accumulated state, consuming content - /// parts. - fn take_response(&mut self) -> Response { - let content_parts = std::mem::take(&mut self.content_parts); - Response { - id: self.id.clone(), - model: self.model.clone(), - provider: self.provider.clone(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason: self.finish_reason.clone(), - usage: self.usage.clone(), - raw: None, - warnings: vec![], - rate_limit: self.rate_limit.clone(), - } - } -} - -impl StreamAccumulator { - fn handle_message_start(&mut self, data: &serde_json::Value) -> Vec { - if let Some(message) = data.get("message") { - if let Some(id) = message.get("id").and_then(serde_json::Value::as_str) { - self.id = id.to_string(); - } - if let Some(model) = message.get("model").and_then(serde_json::Value::as_str) { - self.model = model.to_string(); - } - if let Some(usage) = message.get("usage") { - self.usage.input_tokens = usage - .get("input_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - self.usage.cache_read_tokens = usage - .get("cache_read_input_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - self.usage.cache_write_tokens = usage - .get("cache_creation_input_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - } - } - vec![StreamEvent::StreamStart] - } - - fn handle_content_block_start(&mut self, data: &serde_json::Value) -> Vec { - let block_type = data - .get("content_block") - .and_then(|b| b.get("type")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - - let index = data - .get("index") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let text_id = Some(format!("block_{index}")); - - match block_type { - "text" => { - self.current_block = Some(ContentBlockKind::Text); - self.current_text.clear(); - vec![StreamEvent::TextStart { text_id }] - } - "tool_use" => { - let content_block = data.get("content_block"); - let id = content_block - .and_then(|b| b.get("id")) - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(); - let name = content_block - .and_then(|b| b.get("name")) - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(); - self.current_block = Some(ContentBlockKind::ToolUse { - id: id.clone(), - name: name.clone(), - }); - self.current_tool_args.clear(); - vec![StreamEvent::ToolCallStart { - tool_call: ToolCall::new(id, name, serde_json::json!({})), - }] - } - "thinking" => { - let signature = data - .get("content_block") - .and_then(|b| b.get("signature")) - .and_then(serde_json::Value::as_str) - .map(String::from); - self.current_block = Some(ContentBlockKind::Thinking { signature }); - self.current_thinking.clear(); - vec![StreamEvent::ReasoningStart] - } - _ => vec![], - } - } - - fn handle_content_block_delta(&mut self, data: &serde_json::Value) -> Vec { - let delta = data.get("delta"); - let delta_type = delta - .and_then(|d| d.get("type")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - - match delta_type { - "text_delta" => { - let text = delta - .and_then(|d| d.get("text")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - self.current_text.push_str(text); - - let index = data - .get("index") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - - vec![StreamEvent::TextDelta { - delta: text.to_string(), - text_id: Some(format!("block_{index}")), - }] - } - "input_json_delta" => { - let partial_json = delta - .and_then(|d| d.get("partial_json")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - self.current_tool_args.push_str(partial_json); - - if let Some(ContentBlockKind::ToolUse { id, name }) = &self.current_block { - vec![StreamEvent::ToolCallDelta { - tool_call: ToolCall::new( - id.clone(), - name.clone(), - serde_json::json!(partial_json), - ), - }] - } else { - vec![] + for line in event_block.lines() { + if let Some(rest) = line.strip_prefix("event:") { + event_type = rest.trim(); + } else if let Some(rest) = line.strip_prefix("data:") { + let rest = rest.trim(); + data = Some(match data { + None => std::borrow::Cow::Borrowed(rest), + Some(prev) => { + let mut joined = prev.into_owned(); + joined.push('\n'); + joined.push_str(rest); + std::borrow::Cow::Owned(joined) } - } - "thinking_delta" => { - let thinking = delta - .and_then(|d| d.get("thinking")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - self.current_thinking.push_str(thinking); - vec![StreamEvent::ReasoningDelta { - delta: thinking.to_string(), - }] - } - "signature_delta" => { - let signature = delta - .and_then(|d| d.get("signature")) - .and_then(serde_json::Value::as_str) - .map(String::from); - if let Some(ContentBlockKind::Thinking { - signature: ref mut sig, - }) = self.current_block - { - *sig = signature; - } - vec![] - } - _ => vec![], + }); } } - fn handle_content_block_stop(&mut self, data: &serde_json::Value) -> Vec { - let current_block = self.current_block.take(); - match current_block { - Some(ContentBlockKind::Text) => { - let text = std::mem::take(&mut self.current_text); - self.content_parts.push(ContentPart::text(&text)); - - let index = data - .get("index") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - - vec![StreamEvent::TextEnd { - text_id: Some(format!("block_{index}")), - }] - } - Some(ContentBlockKind::ToolUse { id, name }) => { - let raw_args = std::mem::take(&mut self.current_tool_args); - let arguments = - serde_json::from_str(&raw_args).unwrap_or_else(|_| serde_json::json!({})); - let mut tool_call = ToolCall::new(id, name, arguments); - tool_call.raw_arguments = Some(raw_args); - self.content_parts - .push(ContentPart::ToolCall(tool_call.clone())); - vec![StreamEvent::ToolCallEnd { tool_call }] - } - Some(ContentBlockKind::Thinking { signature }) => { - let thinking_text = std::mem::take(&mut self.current_thinking); - // Prefer signature from content_block_stop if available, - // fall back to one captured at content_block_start. - let stop_signature = data - .get("content_block") - .and_then(|b| b.get("signature")) - .and_then(serde_json::Value::as_str) - .map(String::from); - self.content_parts.push(ContentPart::Thinking(ThinkingData { - text: thinking_text, - signature: stop_signature.or(signature), - redacted: false, - })); - vec![StreamEvent::ReasoningEnd] - } - None => vec![], - } - } - - fn handle_message_delta(&mut self, data: &serde_json::Value) { - if let Some(delta) = data.get("delta") { - let stop_reason = delta.get("stop_reason").and_then(serde_json::Value::as_str); - self.finish_reason = map_finish_reason(stop_reason); - } - if let Some(usage) = data.get("usage") { - self.usage.output_tokens = usage - .get("output_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - } - } - - fn handle_message_stop(&mut self) -> Vec { - // Anthropic does not expose a separate billed thinking/reasoning token - // count. Streaming usage reports the full billed output count, so keep - // reasoning_tokens at 0 and leave output_tokens unchanged. - let response = self.take_response(); - vec![StreamEvent::Finish { - finish_reason: response.finish_reason.clone(), - usage: response.usage.clone(), - response: Box::new(response), - }] - } -} - -/// Process a single SSE event and return zero or more `StreamEvent`s. -fn process_sse_event( - event_type: &str, - data: &serde_json::Value, - acc: &mut StreamAccumulator, -) -> Vec { - match event_type { - "message_start" => acc.handle_message_start(data), - "content_block_start" => acc.handle_content_block_start(data), - "content_block_delta" => acc.handle_content_block_delta(data), - "content_block_stop" => acc.handle_content_block_stop(data), - "message_delta" => { - acc.handle_message_delta(data); - vec![] - } - "message_stop" => acc.handle_message_stop(), - _ => vec![], - } -} - -fn process_sse_event_for_provider( - event_type: &str, - data: &serde_json::Value, - acc: &mut StreamAccumulator, - provider_name: &str, -) -> Result, 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 - .get("message") - .and_then(serde_json::Value::as_str) - .or_else(|| data.get("message").and_then(serde_json::Value::as_str)) - .unwrap_or("Unknown Anthropic stream error") - .to_string(); - let error_code = error - .get("type") - .and_then(serde_json::Value::as_str) - .map(String::from); - - let kind = match error_code.as_deref() { - Some("rate_limit_error") => ProviderErrorKind::RateLimit, - Some("authentication_error") => ProviderErrorKind::Authentication, - Some("permission_error") => ProviderErrorKind::AccessDenied, - Some("not_found_error") => ProviderErrorKind::NotFound, - Some("invalid_request_error") => ProviderErrorKind::InvalidRequest, - Some("request_too_large") => ProviderErrorKind::ContextLength, - // `overloaded_error`, `api_error`, and unknown stream errors are - // transient provider-side failures. - _ => ProviderErrorKind::Server, - }; - - Error::Provider { - kind, - detail: Box::new(ProviderErrorDetail { - message, - provider: provider_name.to_string(), - status_code: None, - error_code, - retry_after: None, - raw: Some(data.clone()), - }), - } -} - -// --- SSE reader --- - -enum SseResult { - Event { - event_type: String, - data: String, - }, - Done, - Error(Error), -} - -struct SseReaderState { - line_reader: super::common::LineReader, - accumulator: StreamAccumulator, - pending_events: std::collections::VecDeque, - /// When true, `tool_use` events for the synthetic tool are converted to - /// text events. - json_schema_mode: bool, - provider_name: String, -} - -impl SseReaderState { - fn new( - http_resp: fabro_http::Response, - rate_limit: Option, - json_schema_mode: bool, - stream_read_timeout: Option, - provider_name: String, - ) -> Self { - Self { - line_reader: super::common::LineReader::new(http_resp, stream_read_timeout), - accumulator: StreamAccumulator::new(rate_limit, provider_name.clone()), - pending_events: std::collections::VecDeque::new(), - json_schema_mode, - provider_name, - } - } - - /// Read the next complete SSE event from the byte stream. - /// - /// SSE events are separated by double newlines. Each event has optional - /// `event:` and `data:` lines. - async fn next_sse_event(&mut self) -> SseResult { - loop { - match self.line_reader.read_next_chunk("\n\n").await { - Ok(Some(event_block)) => { - if let Some(result) = Self::parse_event_block(&event_block) { - return result; - } - // No data in this block (e.g. heartbeat comment); keep - // reading. - } - Ok(None) => return SseResult::Done, - Err(e) => return SseResult::Error(e), - } - } - } - - /// Parse an SSE event block into an `SseResult`. - /// - /// Returns `None` for blocks with no `data:` lines (e.g. heartbeat - /// comments). - fn parse_event_block(event_block: &str) -> Option { - let mut event_type = String::new(); - let mut data_parts: Vec = Vec::new(); - - for line in event_block.lines() { - if let Some(rest) = line.strip_prefix("event:") { - event_type = rest.trim().to_string(); - } else if let Some(rest) = line.strip_prefix("data:") { - data_parts.push(rest.trim().to_string()); - } - // Ignore other SSE fields (id:, retry:, comments starting with :) - } - - // Skip events with no data (e.g. heartbeat comments). - if data_parts.is_empty() { - return None; - } - - let data = data_parts.join("\n"); - Some(SseResult::Event { event_type, data }) - } -} - -/// Known `provider_options.anthropic` keys that are already handled by the -/// adapter and should not be merged into the request body a second time. -const KNOWN_ANTHROPIC_OPTION_KEYS: &[&str] = &["thinking", "auto_cache", "beta_headers"]; - -/// Serialize the API request and merge any unknown `provider_options.anthropic` -/// keys. -fn merge_provider_options( - api_request: &ApiRequest, - provider_options: Option<&serde_json::Value>, -) -> serde_json::Value { - let mut body = serde_json::to_value(api_request).unwrap_or_else(|_| serde_json::json!({})); - - if let Some(anthropic_opts) = provider_options.and_then(|opts| opts.get("anthropic")) { - if let (Some(base), Some(overrides)) = (body.as_object_mut(), anthropic_opts.as_object()) { - for (key, value) in overrides { - if !KNOWN_ANTHROPIC_OPTION_KEYS.contains(&key.as_str()) { - base.insert(key.clone(), value.clone()); - } - } - } - } - - body -} - -/// Build an Anthropic API request and HTTP request builder for the given -/// unified request. -async fn build_api_request( - adapter: &Adapter, - request: &Request, - stream: bool, -) -> (ApiRequest, fabro_http::RequestBuilder) { - let (system, other_messages) = extract_system_prompt(&request.messages); - let mut api_messages = translate_messages(&other_messages).await; - - let mut omit_tools = false; - let tool_choice_json = request.tool_choice.as_ref().and_then(|tc| { - if matches!(tc, ToolChoice::None) { - omit_tools = true; - None - } else { - translate_tool_choice(tc) - } - }); - - let mut api_tools = if omit_tools { - None - } else { - request.tools.as_ref().map(|t| translate_tools(t)) - }; - - 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()); - - let mut system_value = system.and_then(|s| { - if s.trim().is_empty() { - None - } else if auto_cache { - Some(system_with_cache_control(&s)) - } else { - Some(serde_json::Value::String(s)) - } - }); - - // Apply response_format (may inject synthetic tool or system prompt suffix) - let mut tool_choice_json = tool_choice_json; - apply_response_format( - request, - &mut api_tools, - &mut tool_choice_json, - &mut system_value, - ); - - if auto_cache { - if let Some(ref mut tools) = api_tools { - apply_cache_control_to_last_tool(tools); - } - apply_cache_control_to_conversation_prefix(&mut api_messages); - } - - let explicit_thinking = extract_thinking_config(request.provider_options.as_ref()); - - // 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(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.or_else(default_thinking), - Some(serde_json::json!({"effort": <&'static str>::from(*effort)})), - ) - } else if explicit_thinking.is_none() { - // Convert effort level to a thinking budget for models that don't - // support the effort parameter (e.g. claude-sonnet-4-5). - let budget = effort_to_budget_tokens(*effort, resolved_max_tokens); - if resolved_max_tokens <= budget { - resolved_max_tokens = budget + 1024; - } - ( - Some(serde_json::json!({"type": "enabled", "budget_tokens": budget})), - None, - ) - } else { - // thinking already configured via provider_options; skip output_config - (explicit_thinking, None) - } - } else { - (explicit_thinking.or_else(default_thinking), None) - }; - - if tool_choice_forces_tool_use(tool_choice_json.as_ref()) { - thinking = None; - output_config = None; - } - - 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: api_model, - messages: api_messages, - max_tokens: resolved_max_tokens, - system: system_value, - temperature, - top_p, - stop_sequences: Some(request.stop_sequences.clone().unwrap_or_default()), - tools: api_tools, - tool_choice: tool_choice_json, - thinking, - output_config, - speed: request - .speed - .filter(|speed| *speed != Speed::Standard) - .map(<&'static str>::from) - .map(str::to_string), - metadata: request.metadata.clone(), - stream, - }; - - let url = adapter.messages_url(); - let mut req_builder = adapter.http.client.post(&url); - // Apply default_headers first so adapter-specific headers can override - for (key, value) in &adapter.http.default_headers { - req_builder = req_builder.header(key, value); - } - - if adapter.provider_name == "anthropic" { - if let Some(api_key) = &adapter.http.api_key { - req_builder = req_builder.header("x-api-key", api_key); - } - req_builder = req_builder.header("anthropic-version", "2023-06-01"); - - 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 { - req_builder = req_builder.bearer_auth(api_key); - } - - let req_builder = req_builder.json(&merge_provider_options( - &api_request, - request.provider_options.as_ref(), - )); - (api_request, req_builder) + data.map(|data| (event_type, data)) } #[async_trait::async_trait] @@ -1438,141 +261,74 @@ impl ProviderAdapter for Adapter { &self, request: &Request, ) -> Result, Error> { - if self.provider_name != "anthropic" { + let route = self.route_config(); + if !route.supports_count_tokens { return Ok(None); } self.validate_request(request)?; - let (api_request, _req_builder) = build_api_request(self, request, false).await; - let count_request = CountTokensRequest::from(api_request); + let resolved = self.resolve_request(request).await; + let codec = AnthropicMessages; + let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model); + let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params); - let model_info = common::catalog_model(self.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()); - let is_fast = request.speed == Some(Speed::Fast); + let Some(encoded) = codec.encode_count_tokens(&ctx).transpose()? else { + return Ok(None); + }; - let url = self.count_tokens_url(); - let mut req = self.http.client.post(&url); - for (key, value) in &self.http.default_headers { - req = req.header(key, value); - } - if let Some(api_key) = &self.http.api_key { - 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) - { - req = req.header("anthropic-beta", beta_str); - } - - let mut req = req.json(&count_request); + let mut req = self.build_http_request(&encoded, &route); if let Some(t) = self.http.request_timeout { req = req.timeout(t); } - let (body, _headers) = send_and_read_response(req, &self.provider_name, "type").await?; - let response: CountTokensResponse = - serde_json::from_str(&body).map_err(|e| Error::Configuration { - message: format!( - "failed to parse {} token count response: {e}", - self.provider_name - ), - source: None, - })?; + let input_tokens = codec.decode_count_tokens(&body)?; Ok(Some(InputTokenCount { - input_tokens: response.input_tokens, - method: InputTokenCountMethod::ProviderApi, - provider: self.provider_name.clone(), - model: request.model.clone(), - warnings: vec![], + input_tokens, + method: InputTokenCountMethod::ProviderApi, + provider: self.provider_name.clone(), + model: request.model.clone(), + warnings: vec![], })) } async fn complete(&self, request: &Request) -> Result { self.validate_request(request)?; + let route = self.route_config(); // Non-Anthropic providers (e.g. Kimi) require stream=true even for - // blocking calls. Collect the stream into a single Response. - if self.provider_name != "anthropic" { + // blocking calls. Collect the stream into a single Response. + if route.force_streaming { return self.complete_via_stream(request).await; } - let (_api_request, req_builder) = build_api_request(self, request, false).await; + let resolved = self.resolve_request(request).await; + let codec = AnthropicMessages; + let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model); + let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params); - let mut req = req_builder; + let encoded = codec.encode(&ctx, false)?; + let mut req = self.build_http_request(&encoded, &route); if let Some(t) = self.http.request_timeout { req = req.timeout(t); } let (body, headers) = send_and_read_response(req, &self.provider_name, "type").await?; - - 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 = api_resp - .content - .iter() - .filter_map(parse_content_block) - .collect(); - - // If we used JsonSchema mode, convert the synthetic tool call back to text - let content_parts = if uses_json_schema_format(request) { - convert_synthetic_tool_to_text(content_parts) - } else { - content_parts - }; - - let finish_reason = if uses_json_schema_format(request) { - // The model was forced to call a tool, so stop_reason is "tool_use", - // but from the caller's perspective, the request completed normally. - FinishReason::Stop - } else { - map_finish_reason(api_resp.stop_reason.as_deref()) - }; - Ok(Response { - id: api_resp.id, - model: api_resp.model, - provider: self.provider_name.clone(), - message: Message { - role: Role::Assistant, - content: content_parts, - name: None, - tool_call_id: None, - }, - finish_reason, - usage: token_counts_from_api_usage(&api_resp.usage), - raw: Some(raw), - warnings: vec![], - rate_limit: parse_rate_limit_headers(&headers), - }) + let rate_limit = parse_rate_limit_headers(&headers); + codec.decode_response(&body, &ctx, rate_limit) } async fn stream(&self, request: &Request) -> Result { self.validate_request(request)?; - let (_api_request, req_builder) = build_api_request(self, request, true).await; - let http_resp = req_builder + let route = self.route_config(); + let resolved = self.resolve_request(request).await; + let codec = AnthropicMessages; + let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model); + let ctx = self.codec_ctx(&resolved, &deployment_id, &route.codec_params); + + let encoded = codec.encode(&ctx, true)?; + let http_resp = self + .build_http_request(&encoded, &route) .send() .await .map_err(|e| Error::network(e.to_string(), e))?; @@ -1584,75 +340,61 @@ impl ProviderAdapter for Adapter { .text() .await .map_err(|e| Error::network(e.to_string(), e))?; - let (msg, code, raw) = parse_error_body(&body, "type"); - return Err(error_from_status_code( - status.as_u16(), - msg, - self.provider_name.clone(), - code, - raw, - retry_after, - )); + return Err(codec.decode_error(status.as_u16(), &body, &ctx, retry_after)); } let rate_limit = parse_rate_limit_headers(http_resp.headers()); - let json_schema_mode = uses_json_schema_format(request); let stream_read_timeout = self.http.stream_read_timeout; + let decoder = codec.stream_decoder(&ctx, rate_limit); - let stream = stream::unfold( - SseReaderState::new( - http_resp, - rate_limit, - json_schema_mode, - stream_read_timeout, - self.provider_name.clone(), - ), + let out = stream::unfold( + StreamLoop { + decoder, + line_reader: super::common::LineReader::new(http_resp, stream_read_timeout), + pending: std::collections::VecDeque::new(), + done: false, + finished_emitted: false, + }, |mut state| async move { loop { - // Drain any buffered events first. - if let Some(event) = state.pending_events.pop_front() { - let event = if state.json_schema_mode { - convert_stream_event_for_json_schema(event) - } else { - event - }; + if let Some(event) = state.pending.pop_front() { return Some((Ok(event), state)); } - // Read more SSE data from the byte stream. - match state.next_sse_event().await { - SseResult::Event { event_type, data } => { - let parsed: serde_json::Value = match serde_json::from_str(&data) { - Ok(v) => v, - Err(e) => { - return Some(( - Err(Error::stream_error( - format!("failed to parse SSE data: {e}"), - e, - )), - state, - )); - } - }; - match process_sse_event_for_provider( - &event_type, - &parsed, - &mut state.accumulator, - &state.provider_name, - ) { - Ok(events) => state.pending_events.extend(events), - Err(err) => return Some((Err(err), state)), - } - // Loop to drain from pending_events. + if state.done { + if state.finished_emitted { + return None; } - SseResult::Done => return None, - SseResult::Error(err) => return Some((Err(err), state)), + state.finished_emitted = true; + let events = state.decoder.finish(); + if events.is_empty() { + return None; + } + state.pending.extend(events); + continue; + } + + match state.line_reader.read_next_chunk("\n\n").await { + Ok(Some(block)) => { + let Some((event_type, data)) = parse_sse_block(&block) else { + continue; + }; + match state.decoder.on_event(RawEvent { + event: Some(event_type), + data: &data, + }) { + Ok(events) => state.pending.extend(events), + Err(e) => return Some((Err(e), state)), + } + } + Ok(None) => state.done = true, + Err(e) => return Some((Err(e), state)), } } }, ); - Ok(Box::pin(stream)) + Ok(Box::pin(out)) } fn supports_tool_choice(&self, mode: &str) -> bool { @@ -1661,9 +403,12 @@ impl ProviderAdapter for Adapter { fn validate_request(&self, request: &Request) -> Result<(), Error> { if let Some(tool_choice) = &request.tool_choice { - validate_tool_choice(self, tool_choice)?; + provider::validate_tool_choice(self, tool_choice)?; } + // Always-adaptive models reject manual enabled/disabled thinking + // configs at the API, so fail them locally with a clear message + // instead. 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) @@ -1687,12 +432,30 @@ impl ProviderAdapter for Adapter { #[cfg(test)] mod tests { - use fabro_model::catalog::LlmCatalogSettings; use httpmock::prelude::*; use super::*; - use crate::error::ProviderErrorKind; - use crate::types::{AudioData, DocumentData, ReasoningEffort, ResponseFormat}; + use crate::token_count::InputTokenCountMethod; + use crate::types::{Message, ToolDefinition}; + + fn make_base_request() -> Request { + Request { + model: "claude-sonnet-4-20250514".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, + } + } #[test] fn adapter_with_name() { @@ -1706,464 +469,6 @@ mod tests { assert_eq!(adapter.name(), "anthropic"); } - #[test] - fn auto_cache_enabled_by_default() { - assert!(is_auto_cache_enabled(None)); - } - - #[test] - fn auto_cache_enabled_when_true() { - let opts = serde_json::json!({"anthropic": {"auto_cache": true}}); - assert!(is_auto_cache_enabled(Some(&opts))); - } - - #[test] - fn auto_cache_disabled_when_false() { - let opts = serde_json::json!({"anthropic": {"auto_cache": false}}); - assert!(!is_auto_cache_enabled(Some(&opts))); - } - - #[test] - fn auto_cache_enabled_when_key_missing() { - let opts = serde_json::json!({"anthropic": {}}); - assert!(is_auto_cache_enabled(Some(&opts))); - } - - #[test] - fn auto_cache_enabled_when_anthropic_missing() { - let opts = serde_json::json!({"openai": {}}); - assert!(is_auto_cache_enabled(Some(&opts))); - } - - #[test] - fn system_prompt_cache_control_wraps_as_array() { - let result = system_with_cache_control("You are helpful."); - let arr = result.as_array().expect("should be an array"); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0]["type"], "text"); - assert_eq!(arr[0]["text"], "You are helpful."); - assert_eq!(arr[0]["cache_control"]["type"], "ephemeral"); - } - - #[test] - fn tool_cache_control_applied_to_last_tool() { - let mut tools = vec![ - ApiToolDef { - name: "tool_a".to_string(), - description: "first".to_string(), - input_schema: serde_json::json!({}), - cache_control: None, - }, - ApiToolDef { - name: "tool_b".to_string(), - description: "second".to_string(), - input_schema: serde_json::json!({}), - cache_control: None, - }, - ]; - apply_cache_control_to_last_tool(&mut tools); - - assert!(tools[0].cache_control.is_none()); - assert!(tools[1].cache_control.is_some()); - assert_eq!(tools[1].cache_control.as_ref().unwrap().kind, "ephemeral"); - } - - #[test] - fn tool_cache_control_empty_slice() { - let mut tools: Vec = vec![]; - apply_cache_control_to_last_tool(&mut tools); - assert!(tools.is_empty()); - } - - #[test] - fn tool_cache_control_single_tool() { - let mut tools = vec![ApiToolDef { - name: "only_tool".to_string(), - description: "the one".to_string(), - input_schema: serde_json::json!({}), - cache_control: None, - }]; - apply_cache_control_to_last_tool(&mut tools); - assert!(tools[0].cache_control.is_some()); - } - - #[test] - fn api_token_counts_leaves_reasoning_zero_and_output_full() { - let body = serde_json::json!({ - "id": "msg_test", - "model": "claude-sonnet-4-5", - "content": [ - { "type": "thinking", "thinking": "summary text", "signature": "" }, - { "type": "text", "text": "answer" } - ], - "stop_reason": "end_turn", - "usage": { - "input_tokens": 50, - "output_tokens": 1200, - "cache_read_input_tokens": 9000, - "cache_creation_input_tokens": 1000 - } - }); - let api: ApiResponse = serde_json::from_value(body).unwrap(); - let usage = token_counts_from_api_usage(&api.usage); - - assert_eq!(usage.input_tokens, 50); - assert_eq!(usage.cache_read_tokens, 9000); - assert_eq!(usage.cache_write_tokens, 1000); - assert_eq!(usage.output_tokens, 1200); - assert_eq!(usage.reasoning_tokens, 0); - assert_eq!(usage.total_tokens(), 11_250); - } - - #[test] - fn stream_token_counts_leaves_reasoning_zero_and_output_full() { - let mut acc = StreamAccumulator::new(None, "anthropic".to_string()); - acc.content_parts.push(ContentPart::Thinking(ThinkingData { - text: "summary text".to_string(), - signature: Some(String::new()), - redacted: false, - })); - acc.content_parts.push(ContentPart::text("answer")); - acc.usage = TokenCounts { - input_tokens: 50, - output_tokens: 1200, - reasoning_tokens: 0, - cache_read_tokens: 9000, - cache_write_tokens: 1000, - }; - - let events = acc.handle_message_stop(); - let StreamEvent::Finish { - usage, response, .. - } = &events[0] - else { - panic!("expected finish event"); - }; - - assert_eq!(usage.input_tokens, 50); - assert_eq!(usage.cache_read_tokens, 9000); - assert_eq!(usage.cache_write_tokens, 1000); - assert_eq!(usage.output_tokens, 1200); - assert_eq!(usage.reasoning_tokens, 0); - assert_eq!(usage.total_tokens(), 11_250); - assert_eq!(response.usage, *usage); - } - - #[test] - fn stream_error_event_overloaded_becomes_retryable_server_error() { - let mut acc = StreamAccumulator::new(None, "anthropic".to_string()); - let data = serde_json::json!({ - "type": "error", - "error": { - "type": "overloaded_error", - "message": "Overloaded" - } - }); - - let err = - process_sse_event_for_provider("error", &data, &mut acc, "anthropic").unwrap_err(); - - assert!(err.retryable()); - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::Server); - assert_eq!(detail.provider, "anthropic"); - assert_eq!(detail.message, "Overloaded"); - assert_eq!(detail.error_code.as_deref(), Some("overloaded_error")); - assert_eq!(detail.raw.as_ref(), Some(&data)); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn stream_error_event_invalid_request_remains_non_retryable() { - let mut acc = StreamAccumulator::new(None, "anthropic".to_string()); - let data = serde_json::json!({ - "type": "error", - "error": { - "type": "invalid_request_error", - "message": "max_tokens is required" - } - }); - - let err = - process_sse_event_for_provider("error", &data, &mut acc, "anthropic").unwrap_err(); - - assert!(!err.retryable()); - match err { - Error::Provider { kind, detail } => { - assert_eq!(kind, ProviderErrorKind::InvalidRequest); - assert_eq!(detail.error_code.as_deref(), Some("invalid_request_error")); - } - other => panic!("expected provider error, got {other:?}"), - } - } - - #[test] - fn unknown_sse_events_remain_ignored() { - let mut acc = StreamAccumulator::new(None, "anthropic".to_string()); - let data = serde_json::json!({ - "type": "content_block_delta", - "delta": { "type": "text_delta", "text": "ignored" } - }); - - let events = - process_sse_event_for_provider("some_future_event", &data, &mut acc, "anthropic") - .unwrap(); - - assert!(events.is_empty()); - } - - #[test] - fn conversation_prefix_cache_control_with_two_user_messages() { - let mut messages = vec![ - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hello"})], - }, - ApiMessage { - role: "assistant".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hi there"})], - }, - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "How are you?"})], - }, - ]; - - apply_cache_control_to_conversation_prefix(&mut messages); - - // First user message should have cache_control - assert_eq!(messages[0].content[0]["cache_control"]["type"], "ephemeral"); - // Last user message should NOT have cache_control - assert!(messages[2].content[0].get("cache_control").is_none()); - // Assistant message should NOT have cache_control - assert!(messages[1].content[0].get("cache_control").is_none()); - } - - #[test] - fn conversation_prefix_cache_control_with_multiple_content_blocks() { - let mut messages = vec![ - ApiMessage { - role: "user".to_string(), - content: vec![ - serde_json::json!({"type": "text", "text": "Part 1"}), - serde_json::json!({"type": "text", "text": "Part 2"}), - ], - }, - ApiMessage { - role: "assistant".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Reply"})], - }, - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Follow up"})], - }, - ]; - - apply_cache_control_to_conversation_prefix(&mut messages); - - // Only the LAST content block of the first user message should have - // cache_control - assert!(messages[0].content[0].get("cache_control").is_none()); - assert_eq!(messages[0].content[1]["cache_control"]["type"], "ephemeral"); - } - - #[test] - fn conversation_prefix_cache_control_single_user_message() { - let mut messages = vec![ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hello"})], - }]; - - apply_cache_control_to_conversation_prefix(&mut messages); - - // With only one user message, no cache_control should be added - assert!(messages[0].content[0].get("cache_control").is_none()); - } - - #[test] - fn conversation_prefix_cache_control_no_user_messages() { - let mut messages: Vec = vec![]; - // Should not panic on empty messages - apply_cache_control_to_conversation_prefix(&mut messages); - } - - #[test] - fn conversation_prefix_cache_control_three_user_messages() { - let mut messages = vec![ - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "First"})], - }, - ApiMessage { - role: "assistant".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Reply 1"})], - }, - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Second"})], - }, - ApiMessage { - role: "assistant".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Reply 2"})], - }, - ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Third"})], - }, - ]; - - apply_cache_control_to_conversation_prefix(&mut messages); - - // Only the second-to-last user message (index 2) should get cache_control - assert!(messages[0].content[0].get("cache_control").is_none()); - assert_eq!(messages[2].content[0]["cache_control"]["type"], "ephemeral"); - assert!(messages[4].content[0].get("cache_control").is_none()); - } - - #[test] - fn beta_header_includes_cache_header() { - 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); - assert_eq!(result, None); - } - - #[test] - fn beta_header_merges_user_headers_with_cache() { - let opts = serde_json::json!({ - "anthropic": { - "beta_headers": ["interleaved-thinking-2025-05-14"] - } - }); - let result = build_beta_header(Some(&opts), true, false); - assert_eq!( - result, - Some(format!( - "interleaved-thinking-2025-05-14,{CACHE_BETA_HEADER}" - )) - ); - } - - #[test] - fn beta_header_no_duplicate_cache_header() { - let opts = serde_json::json!({ - "anthropic": { - "beta_headers": [CACHE_BETA_HEADER] - } - }); - let result = build_beta_header(Some(&opts), true, false); - // Should not duplicate the header - assert_eq!(result, Some(CACHE_BETA_HEADER.to_string())); - } - - #[test] - fn beta_header_user_headers_only_when_cache_disabled() { - let opts = serde_json::json!({ - "anthropic": { - "beta_headers": ["interleaved-thinking-2025-05-14"] - } - }); - 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 { - name: "test_tool".to_string(), - description: "A test tool".to_string(), - input_schema: serde_json::json!({"type": "object"}), - cache_control: Some(CacheControl::ephemeral()), - }; - let json = serde_json::to_value(&tool).expect("should serialize"); - assert_eq!(json["cache_control"]["type"], "ephemeral"); - } - - #[test] - fn tool_serialization_omits_cache_control_when_none() { - let tool = ApiToolDef { - name: "test_tool".to_string(), - description: "A test tool".to_string(), - input_schema: serde_json::json!({"type": "object"}), - cache_control: None, - }; - let json = serde_json::to_value(&tool).expect("should serialize"); - assert!(json.get("cache_control").is_none()); - } - - #[test] - fn system_prompt_as_string_when_cache_disabled() { - let system = "You are helpful.".to_string(); - let value = serde_json::Value::String(system); - assert_eq!(value.as_str(), Some("You are helpful.")); - } - - #[test] - fn api_request_serialization_with_cached_system() { - let api_request = ApiRequest { - model: "claude-sonnet-4-20250514".to_string(), - messages: vec![ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hello"})], - }], - max_tokens: 4096, - system: Some(system_with_cache_control("You are helpful.")), - temperature: None, - top_p: None, - stop_sequences: None, - tools: None, - tool_choice: None, - thinking: None, - output_config: None, - speed: None, - metadata: None, - stream: false, - }; - - let json = serde_json::to_value(&api_request).expect("should serialize"); - let system = json.get("system").expect("system should be present"); - let arr = system.as_array().expect("system should be an array"); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0]["cache_control"]["type"], "ephemeral"); - } - - #[tokio::test] - async fn build_api_request_omits_whitespace_only_system_prompt() { - let adapter = Adapter::new("test-key"); - let request = Request { - messages: vec![Message::system(" \n\t"), Message::user("Hello")], - ..make_base_request() - }; - - let (api_request, _req_builder) = build_api_request(&adapter, &request, false).await; - assert!( - api_request.system.is_none(), - "whitespace-only system prompts should be omitted" - ); - } - #[tokio::test] async fn count_input_tokens_posts_count_request_and_parses_response() { let server = MockServer::start(); @@ -2197,881 +502,4 @@ mod tests { assert_eq!(count.input_tokens, 123); assert_eq!(count.method, InputTokenCountMethod::ProviderApi); } - - #[tokio::test] - async fn count_request_omits_generation_only_fields_for_reasoning_effort() { - let adapter = Adapter::new("test-key").with_catalog(catalog_with_anthropic_model( - r#" -reasoning_effort = "levels" -"#, - )); - let request = Request { - model: "test-claude".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - temperature: Some(0.2), - top_p: Some(0.9), - metadata: Some(std::collections::HashMap::from([( - "trace".to_string(), - "abc".to_string(), - )])), - ..make_base_request() - }; - - let (api_request, _req_builder) = build_api_request(&adapter, &request, false).await; - assert!(api_request.output_config.is_some()); - let body = serde_json::to_value(CountTokensRequest::from(api_request)).unwrap(); - - assert!(body.get("output_config").is_none()); - assert!(body.get("max_tokens").is_none()); - assert!(body.get("temperature").is_none()); - assert!(body.get("top_p").is_none()); - assert!(body.get("metadata").is_none()); - assert!(body.get("stream").is_none()); - } - - #[tokio::test] - async fn count_request_includes_explicit_thinking_when_translated_request_has_it() { - let adapter = Adapter::new("test-key"); - let request = Request { - provider_options: Some(serde_json::json!({ - "anthropic": { - "thinking": {"type": "enabled", "budget_tokens": 1024} - } - })), - ..make_base_request() - }; - - let (api_request, _req_builder) = build_api_request(&adapter, &request, false).await; - let body = serde_json::to_value(CountTokensRequest::from(api_request)).unwrap(); - - assert_eq!(body["thinking"]["type"], "enabled"); - assert_eq!(body["thinking"]["budget_tokens"], 1024); - } - - fn make_base_request() -> Request { - Request { - model: "claude-sonnet-4-20250514".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, - } - } - - fn catalog_with_anthropic_model(features: &str) -> Arc { - let settings: LlmCatalogSettings = toml::from_str(&format!( - r#" -[providers.anthropic] -display_name = "Anthropic" -adapter = "anthropic" -agent_profile = "anthropic" - -[models."test-claude"] -provider = "anthropic" -display_name = "Test Claude" -family = "claude" -default = true - -[models."test-claude".limits] -context_window = 200000 -max_output = 4096 - -[models."test-claude".features] -tools = true -vision = true -reasoning = true -{features} -"# - )) - .unwrap(); - Arc::new(Catalog::from_settings(&settings).unwrap()) - } - - fn make_request_with_format(format: ResponseFormat) -> Request { - Request { - provider: None, - response_format: Some(format), - max_tokens: None, - ..make_base_request() - } - } - - #[test] - fn response_format_json_schema_injects_synthetic_tool() { - let schema = serde_json::json!({ - "type": "object", - "properties": {"name": {"type": "string"}}, - "required": ["name"] - }); - let request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(schema.clone()), - strict: false, - }); - - let mut tools: Option> = None; - let mut tool_choice: Option = None; - let mut system: Option = None; - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - let tools = tools.expect("tools should be set"); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].name, SYNTHETIC_TOOL_NAME); - assert_eq!(tools[0].input_schema, schema); - - let tc = tool_choice.expect("tool_choice should be set"); - assert_eq!(tc["type"], "tool"); - assert_eq!(tc["name"], SYNTHETIC_TOOL_NAME); - - // System should not be modified - assert!(system.is_none()); - } - - #[test] - fn tool_choice_forces_tool_use_detects_forced_modes() { - assert!(tool_choice_forces_tool_use(Some( - &serde_json::json!({"type": "any"}) - ))); - assert!(tool_choice_forces_tool_use(Some( - &serde_json::json!({"type": "tool", "name": "json_output"}) - ))); - - assert!(!tool_choice_forces_tool_use(Some( - &serde_json::json!({"type": "auto"}) - ))); - assert!(!tool_choice_forces_tool_use(Some( - &serde_json::json!({"type": "none"}) - ))); - assert!(!tool_choice_forces_tool_use(None)); - } - - #[test] - fn response_format_json_schema_appends_to_existing_tools() { - let schema = serde_json::json!({"type": "object"}); - let mut request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(schema), - strict: false, - }); - request.tools = Some(vec![ToolDefinition { - name: "existing_tool".to_string(), - description: "An existing tool".to_string(), - parameters: serde_json::json!({}), - }]); - - let mut tools: Option> = - Some(translate_tools(request.tools.as_ref().unwrap())); - let mut tool_choice: Option = None; - let mut system: Option = None; - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - let tools = tools.expect("tools should be set"); - assert_eq!(tools.len(), 2); - assert_eq!(tools[0].name, "existing_tool"); - assert_eq!(tools[1].name, SYNTHETIC_TOOL_NAME); - } - - #[test] - fn response_format_json_object_appends_to_string_system() { - let request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }); - - let mut tools: Option> = None; - let mut tool_choice: Option = None; - let mut system = Some(serde_json::Value::String("You are helpful.".to_string())); - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - let sys = system.expect("system should be set"); - let text = sys.as_str().expect("should be a string"); - assert!(text.contains("You are helpful.")); - assert!(text.contains("valid JSON")); - - // Tools should not be modified - assert!(tools.is_none()); - assert!(tool_choice.is_none()); - } - - #[test] - fn response_format_json_object_sets_system_when_none() { - let request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }); - - let mut tools: Option> = None; - let mut tool_choice: Option = None; - let mut system: Option = None; - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - let sys = system.expect("system should be set"); - let text = sys.as_str().expect("should be a string"); - assert!(text.contains("valid JSON")); - } - - #[test] - fn response_format_json_object_appends_to_array_system() { - let request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::JsonObject, - json_schema: None, - strict: false, - }); - - let mut tools: Option> = None; - let mut tool_choice: Option = None; - let mut system = Some(system_with_cache_control("You are helpful.")); - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - let sys = system.expect("system should be set"); - let arr = sys.as_array().expect("should be an array"); - let text = arr[0]["text"].as_str().expect("should have text"); - assert!(text.contains("You are helpful.")); - assert!(text.contains("valid JSON")); - } - - #[test] - fn response_format_text_is_noop() { - let request = make_request_with_format(ResponseFormat { - kind: ResponseFormatType::Text, - json_schema: None, - strict: false, - }); - - let mut tools: Option> = None; - let mut tool_choice: Option = None; - let mut system: Option = None; - - apply_response_format(&request, &mut tools, &mut tool_choice, &mut system); - - assert!(tools.is_none()); - assert!(tool_choice.is_none()); - assert!(system.is_none()); - } - - #[test] - fn convert_synthetic_tool_to_text_replaces_synthetic_tool() { - let parts = vec![ContentPart::ToolCall(ToolCall::new( - "id1", - SYNTHETIC_TOOL_NAME, - serde_json::json!({"name": "Alice"}), - ))]; - let result = convert_synthetic_tool_to_text(parts); - assert_eq!(result.len(), 1); - match &result[0] { - ContentPart::Text(text) => { - assert!(text.contains("Alice")); - } - _ => panic!("expected Text, got {:?}", result[0]), - } - } - - #[test] - fn convert_synthetic_tool_to_text_preserves_other_tool_calls() { - let parts = vec![ContentPart::ToolCall(ToolCall::new( - "id1", - "real_tool", - serde_json::json!({"key": "value"}), - ))]; - let result = convert_synthetic_tool_to_text(parts); - assert_eq!(result.len(), 1); - match &result[0] { - ContentPart::ToolCall(tc) => { - assert_eq!(tc.name, "real_tool"); - } - _ => panic!("expected ToolCall"), - } - } - - #[test] - fn convert_stream_event_converts_tool_start_for_synthetic() { - let event = StreamEvent::ToolCallStart { - tool_call: ToolCall::new("id1", SYNTHETIC_TOOL_NAME, serde_json::json!({})), - }; - let result = convert_stream_event_for_json_schema(event); - assert!(matches!(result, StreamEvent::TextStart { .. })); - } - - #[test] - fn convert_stream_event_preserves_real_tool_start() { - let event = StreamEvent::ToolCallStart { - tool_call: ToolCall::new("id1", "real_tool", serde_json::json!({})), - }; - let result = convert_stream_event_for_json_schema(event); - assert!(matches!(result, StreamEvent::ToolCallStart { .. })); - } - - #[test] - fn convert_stream_event_converts_tool_delta_for_synthetic() { - let event = StreamEvent::ToolCallDelta { - tool_call: ToolCall::new("id1", SYNTHETIC_TOOL_NAME, serde_json::json!("{\"name\"")), - }; - let result = convert_stream_event_for_json_schema(event); - match result { - StreamEvent::TextDelta { delta, .. } => { - assert_eq!(delta, "{\"name\""); - } - _ => panic!("expected TextDelta"), - } - } - - #[test] - fn convert_stream_event_converts_finish_reason() { - let response = Box::new(Response { - id: "test".to_string(), - model: "claude".to_string(), - provider: "anthropic".to_string(), - message: Message { - role: Role::Assistant, - content: vec![ContentPart::ToolCall(ToolCall::new( - "id1", - SYNTHETIC_TOOL_NAME, - serde_json::json!({"data": "value"}), - ))], - name: None, - tool_call_id: None, - }, - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts::default(), - raw: None, - warnings: vec![], - rate_limit: None, - }); - let event = StreamEvent::Finish { - finish_reason: FinishReason::ToolCalls, - usage: TokenCounts::default(), - response, - }; - let result = convert_stream_event_for_json_schema(event); - match result { - StreamEvent::Finish { - finish_reason, - response, - .. - } => { - assert_eq!(finish_reason, FinishReason::Stop); - assert_eq!(response.finish_reason, FinishReason::Stop); - // Content should be converted from tool call to text - assert!(matches!(&response.message.content[0], ContentPart::Text(_))); - } - _ => panic!("expected Finish"), - } - } - - #[tokio::test] - async fn document_url_translates_to_url_source() { - let part = ContentPart::Document(DocumentData { - url: Some("https://example.com/doc.pdf".to_string()), - data: None, - media_type: None, - file_name: None, - }); - let result = content_part_to_api(&part) - .await - .expect("should produce JSON"); - assert_eq!(result["type"], "document"); - assert_eq!(result["source"]["type"], "url"); - assert_eq!(result["source"]["url"], "https://example.com/doc.pdf"); - } - - #[tokio::test] - async fn document_base64_data_translates_to_base64_source() { - let part = ContentPart::Document(DocumentData { - url: None, - data: Some(vec![0x25, 0x50, 0x44, 0x46]), - media_type: Some("application/pdf".to_string()), - file_name: Some("test.pdf".to_string()), - }); - let result = content_part_to_api(&part) - .await - .expect("should produce JSON"); - assert_eq!(result["type"], "document"); - assert_eq!(result["source"]["type"], "base64"); - assert_eq!(result["source"]["media_type"], "application/pdf"); - assert!(result["source"]["data"].as_str().is_some()); - } - - #[tokio::test] - async fn document_base64_defaults_to_pdf_mime() { - let part = ContentPart::Document(DocumentData { - url: None, - data: Some(vec![1, 2, 3]), - media_type: None, - file_name: None, - }); - let result = content_part_to_api(&part) - .await - .expect("should produce JSON"); - assert_eq!(result["source"]["media_type"], "application/pdf"); - } - - /// Regression test: deprecated beta header values must not be sent. - /// The Anthropic API rejects requests containing these old headers. - #[test] - fn beta_header_rejects_deprecated_values() { - let deprecated = [ - "extended-thinking-2025-04-14", - "max-tokens-3-5-sonnet-2025-04-14", - ]; - - // No user headers — only cache header should appear - let header = build_beta_header(None, true, false).unwrap_or_default(); - for dep in &deprecated { - assert!( - !header.contains(dep), - "default header must not contain deprecated value {dep}" - ); - } - - // With a valid user header - let opts = serde_json::json!({ - "anthropic": { - "beta_headers": ["interleaved-thinking-2025-05-14"] - } - }); - let header = build_beta_header(Some(&opts), true, false).unwrap_or_default(); - for dep in &deprecated { - assert!( - !header.contains(dep), - "header with user values must not contain deprecated value {dep}" - ); - } - } - - #[test] - fn merge_provider_options_passes_through_unknown_keys() { - let api_request = ApiRequest { - model: "claude-sonnet-4-20250514".to_string(), - messages: vec![ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hello"})], - }], - max_tokens: 4096, - system: None, - temperature: None, - top_p: None, - stop_sequences: None, - tools: None, - tool_choice: None, - thinking: None, - output_config: None, - speed: None, - metadata: None, - stream: false, - }; - - let opts = serde_json::json!({ - "anthropic": { - "top_k": 40, - "custom_field": "value" - } - }); - let body = merge_provider_options(&api_request, Some(&opts)); - assert_eq!(body["top_k"], 40); - assert_eq!(body["custom_field"], "value"); - } - - #[test] - fn merge_provider_options_skips_known_keys() { - let api_request = ApiRequest { - model: "claude-sonnet-4-20250514".to_string(), - messages: vec![ApiMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type": "text", "text": "Hello"})], - }], - max_tokens: 4096, - system: None, - temperature: None, - top_p: None, - stop_sequences: None, - tools: None, - tool_choice: None, - thinking: None, - output_config: None, - speed: None, - metadata: None, - stream: false, - }; - - let opts = serde_json::json!({ - "anthropic": { - "thinking": {"type": "enabled", "budget_tokens": 10000}, - "auto_cache": false, - "beta_headers": ["some-header"], - "top_k": 40 - } - }); - let body = merge_provider_options(&api_request, Some(&opts)); - // Known keys should not be merged (they are handled separately) - assert!(body.get("auto_cache").is_none()); - assert!(body.get("beta_headers").is_none()); - // thinking is handled by the ApiRequest struct directly, should not be - // double-merged - assert!(body["thinking"].is_null()); - // Unknown keys should be merged - assert_eq!(body["top_k"], 40); - } - - #[tokio::test] - async fn audio_produces_text_fallback() { - let part = ContentPart::Audio(AudioData { - url: Some("https://example.com/audio.wav".to_string()), - data: None, - media_type: None, - }); - let result = content_part_to_api(&part) - .await - .expect("should produce JSON"); - assert_eq!(result["type"], "text"); - assert_eq!( - result["text"], - "[Audio content not supported by this provider]" - ); - } - - #[tokio::test] - async fn build_api_request_maps_reasoning_effort_to_output_config() { - let adapter = Adapter::new("test-key"); - let request = Request { - reasoning_effort: Some(ReasoningEffort::Medium), - ..make_base_request() - }; - - let (api_request, _req_builder) = build_api_request(&adapter, &request, false).await; - assert_eq!( - api_request.output_config, - Some(serde_json::json!({"effort": "medium"})) - ); - } - - #[tokio::test] - async fn build_api_request_disables_prompt_cache_when_model_feature_is_false() { - let adapter = Adapter::new("test-key").with_catalog(catalog_with_anthropic_model( - r#" -reasoning_effort = "levels" -prompt_cache = false -"#, - )); - let request = Request { - model: "test-claude".to_string(), - messages: vec![ - Message::system("Use the cache if supported."), - Message::user("Hello"), - ], - provider_options: Some(serde_json::json!({ - "anthropic": {"auto_cache": true} - })), - ..make_base_request() - }; - - let (api_request, req_builder) = build_api_request(&adapter, &request, false).await; - assert_eq!( - api_request.system, - Some(serde_json::Value::String( - "Use the cache if supported.".to_string() - )) - ); - let built = req_builder.build().expect("should build request"); - let beta = built.headers().get("anthropic-beta"); - assert!( - beta.is_none_or(|value| !value.to_str().unwrap().contains(CACHE_BETA_HEADER)), - "cache beta header must not be sent when the model disables prompt cache" - ); - } - - #[tokio::test] - async fn build_api_request_without_injected_catalog_does_not_use_builtin_model_metadata() { - let adapter = Adapter::new("test-key"); - let request = Request { - model: "claude-sonnet-4-5".to_string(), - messages: vec![ - Message::system("Do not infer cache support from built-ins."), - Message::user("Hello"), - ], - provider_options: Some(serde_json::json!({ - "anthropic": {"auto_cache": true} - })), - ..make_base_request() - }; - - let (api_request, req_builder) = build_api_request(&adapter, &request, false).await; - assert_eq!( - api_request.system, - Some(serde_json::Value::String( - "Do not infer cache support from built-ins.".to_string() - )) - ); - let built = req_builder.build().expect("should build request"); - let beta = built.headers().get("anthropic-beta"); - assert!( - beta.is_none_or(|value| !value.to_str().unwrap().contains(CACHE_BETA_HEADER)), - "cache beta header must require injected model metadata" - ); - } - - #[tokio::test] - async fn build_api_request_enables_prompt_cache_when_model_feature_is_true() { - let adapter = Adapter::new("test-key").with_catalog(catalog_with_anthropic_model( - r#" -reasoning_effort = "levels" -prompt_cache = true -"#, - )); - let request = Request { - model: "test-claude".to_string(), - messages: vec![ - Message::system("Use the cache if supported."), - Message::user("Hello"), - ], - ..make_base_request() - }; - - let (api_request, req_builder) = build_api_request(&adapter, &request, false).await; - assert_eq!( - api_request.system.unwrap()[0]["cache_control"]["type"], - "ephemeral" - ); - let built = req_builder.build().expect("should build request"); - let beta = built - .headers() - .get("anthropic-beta") - .expect("cache beta header should be present") - .to_str() - .unwrap(); - assert!(beta.contains(CACHE_BETA_HEADER)); - } - - #[tokio::test] - async fn build_api_request_uses_adaptive_thinking_for_injected_effort_model_without_forced_tools() - { - let adapter = Adapter::new("test-key").with_catalog(catalog_with_anthropic_model( - r#" -reasoning_effort = "levels" -"#, - )); - let request = Request { - model: "test-claude".to_string(), - ..make_base_request() - }; - - let (api_request, _req_builder) = build_api_request(&adapter, &request, false).await; - assert_eq!( - api_request.thinking, - Some(serde_json::json!({"type": "adaptive"})) - ); - } - - #[tokio::test] - async fn build_api_request_omits_thinking_for_opus_4_7_json_schema() { - let adapter = Adapter::new("test-key"); - let request = Request { - model: "claude-opus-4-7".to_string(), - response_format: Some(ResponseFormat { - kind: ResponseFormatType::JsonSchema, - json_schema: Some(serde_json::json!({ - "type": "object", - "properties": {"title": {"type": "string"}}, - "required": ["title"] - })), - strict: true, - }), - ..make_base_request() - }; - - let (api_request, _req_builder) = build_api_request(&adapter, &request, false).await; - let tool_choice = api_request - .tool_choice - .as_ref() - .expect("json schema response format should force synthetic tool"); - assert_eq!(tool_choice["type"], "tool"); - assert_eq!(tool_choice["name"], SYNTHETIC_TOOL_NAME); - assert!( - api_request.thinking.is_none(), - "forced tool calls must omit thinking" - ); - assert!( - api_request.output_config.is_none(), - "forced tool calls must omit output_config effort" - ); - } - - #[tokio::test] - async fn build_api_request_omits_thinking_for_explicit_named_tool_choice() { - let adapter = Adapter::new("test-key"); - let request = Request { - tools: Some(vec![ToolDefinition { - name: "json_output".to_string(), - description: "Output JSON".to_string(), - parameters: serde_json::json!({"type": "object"}), - }]), - tool_choice: Some(ToolChoice::Named { - tool_name: "json_output".to_string(), - }), - provider_options: Some(serde_json::json!({ - "anthropic": { - "thinking": {"type": "adaptive"} - } - })), - ..make_base_request() - }; - - let (api_request, _req_builder) = build_api_request(&adapter, &request, false).await; - let tool_choice = api_request - .tool_choice - .as_ref() - .expect("named tool choice should be translated"); - assert_eq!(tool_choice["type"], "tool"); - assert_eq!(tool_choice["name"], "json_output"); - assert!( - api_request.thinking.is_none(), - "forced named tool choice must omit explicit thinking" - ); - } - - #[tokio::test] - async fn build_api_request_omits_effort_for_required_tool_choice() { - let adapter = Adapter::new("test-key"); - let request = Request { - model: "claude-opus-4-7".to_string(), - tools: Some(vec![ToolDefinition { - name: "json_output".to_string(), - description: "Output JSON".to_string(), - parameters: serde_json::json!({"type": "object"}), - }]), - tool_choice: Some(ToolChoice::Required), - reasoning_effort: Some(ReasoningEffort::Medium), - ..make_base_request() - }; - - let (api_request, _req_builder) = build_api_request(&adapter, &request, false).await; - let tool_choice = api_request - .tool_choice - .as_ref() - .expect("required tool choice should be translated"); - assert_eq!(tool_choice["type"], "any"); - assert!( - api_request.output_config.is_none(), - "required tool choice must omit output_config effort" - ); - } - - #[tokio::test] - async fn build_api_request_omits_output_config_when_no_reasoning_effort() { - let adapter = Adapter::new("test-key"); - let request = make_base_request(); - - let (api_request, _req_builder) = build_api_request(&adapter, &request, false).await; - assert!(api_request.output_config.is_none()); - } - - #[tokio::test] - async fn build_api_request_sets_speed() { - let adapter = Adapter::new("test-key"); - let request = Request { - speed: Some(Speed::Fast), - ..make_base_request() - }; - - let (api_request, _req_builder) = build_api_request(&adapter, &request, false).await; - assert_eq!(api_request.speed, Some("fast".to_string())); - } - - #[tokio::test] - async fn build_api_request_serializes_absent_stop_sequences_as_empty_array() { - let adapter = Adapter::new("test-key"); - let request = make_base_request(); - - let (api_request, _req_builder) = build_api_request(&adapter, &request, false).await; - - assert_eq!(api_request.stop_sequences, Some(Vec::new())); - } - - #[tokio::test] - async fn build_api_request_injects_fast_mode_beta_header() { - let adapter = Adapter::new("test-key"); - let request = Request { - speed: Some(Speed::Fast), - ..make_base_request() - }; - - let (_api_request, req_builder) = build_api_request(&adapter, &request, false).await; - let built = req_builder.build().expect("should build request"); - let beta = built - .headers() - .get("anthropic-beta") - .expect("anthropic-beta header should be present") - .to_str() - .unwrap(); - assert!( - beta.contains(FAST_MODE_BETA_HEADER), - "beta header should contain fast-mode header, got: {beta}" - ); - } - #[test] - fn beta_header_includes_both_cache_and_fast_mode() { - let result = build_beta_header(None, true, true); - let header = result.expect("should produce a header"); - assert!( - header.contains(CACHE_BETA_HEADER), - "should contain cache header" - ); - assert!( - header.contains(FAST_MODE_BETA_HEADER), - "should contain fast-mode header" - ); - } - - #[test] - fn effort_to_budget_tokens_xhigh_maps_to_seven_eighths() { - assert_eq!( - effort_to_budget_tokens(ReasoningEffort::XHigh, 16_000), - 14_000 - ); - } - - #[test] - fn effort_to_budget_tokens_max_maps_to_full_budget() { - assert_eq!( - effort_to_budget_tokens(ReasoningEffort::Max, 16_000), - 16_000 - ); - } - - #[tokio::test] - async fn build_api_request_falls_back_to_thinking_budget_for_non_effort_model() { - let adapter = Adapter::new("test-key").with_catalog(catalog_with_anthropic_model("")); - let request = Request { - model: "test-claude".to_string(), - max_tokens: Some(16_000), - reasoning_effort: Some(ReasoningEffort::XHigh), - ..make_base_request() - }; - - let (api_request, _req_builder) = build_api_request(&adapter, &request, false).await; - assert!( - api_request.output_config.is_none(), - "non-effort models must not receive output_config" - ); - let thinking = api_request - .thinking - .expect("thinking must be set for fallback path"); - assert_eq!(thinking["type"], "enabled"); - assert_eq!(thinking["budget_tokens"], 14_000); - } } diff --git a/lib/crates/fabro-llm/src/providers/openai_compatible.rs b/lib/crates/fabro-llm/src/providers/openai_compatible.rs index 3ba873e11..801ad4669 100644 --- a/lib/crates/fabro-llm/src/providers/openai_compatible.rs +++ b/lib/crates/fabro-llm/src/providers/openai_compatible.rs @@ -163,7 +163,7 @@ impl ProviderAdapter for Adapter { let codec = OpenAiCompatible; let deployment_id = self.deployment_id(request); - let params = CodecParams; + let params = CodecParams::default(); let ctx = self.codec_ctx(request, &deployment_id, ¶ms); let mut req = self.encoded_request(&codec, &ctx, false)?; @@ -181,7 +181,7 @@ impl ProviderAdapter for Adapter { let codec = OpenAiCompatible; let deployment_id = self.deployment_id(request); - let params = CodecParams; + let params = CodecParams::default(); let ctx = self.codec_ctx(request, &deployment_id, ¶ms); let req = self.encoded_request(&codec, &ctx, true)?;