cratefield_adapter_anthropic/lib.rs
1//! `cratefield-adapter-anthropic`: the minimal [`TextModel`] port over the
2//! Anthropic Messages API (issue #430). Uses the runtime's [`HttpClient`]
3//! port — no `reqwest`, no vendor SDK — so the same adapter runs on
4//! Workers and natively, and inherits that port's destination vetting,
5//! deadline and response-size caps.
6//!
7//! **Degraded mode.** When the API key is absent the adapter answers
8//! [`TextModelError::NotConfigured`] without any network call, so a
9//! pipeline stage can degrade instead of breaking.
10//!
11//! **Tools** (issue #665): [`Prompt::tools`] travel as the Messages API's
12//! `tools` array, [`Prompt::tool_choice`] as its `tool_choice`, and the
13//! answer's `tool_use` blocks come back as [`Completion::tool_calls`]. A
14//! turn built with [`Turn::assistant_tool_calls`] or
15//! [`Turn::tool_results`] is sent as the content-block array the API
16//! requires; every other turn keeps the bare-string content it always sent.
17//! [`Anthropic::supports`] reports [`Capability::Tools`]. A prompt that
18//! carries both [`Prompt::json_schema`] and its own tools is refused with
19//! [`TextModelError::Rejected`] before any request.
20//!
21//! **Streaming** (issue #859): [`Anthropic::stream`] sends the same
22//! request with `"stream": true` and answers the port's [`TextDelta`]s
23//! straight off the Messages event stream — text and reasoning as they
24//! are produced, each tool call from its start through its argument
25//! chunks to the finished call, running usage, and one `Finish` naming
26//! the wire's `stop_reason`. A finished call is the same [`ToolCall`]
27//! `complete` builds — one shared conversion — and the stream owns the
28//! response body, so dropping it cancels the upstream exchange. A prompt
29//! carrying [`Prompt::json_schema`] still completes and decomposes:
30//! [`Completion::json`] is a buffered feature.
31//!
32//! **Images** (issue #628): a turn built with [`Turn::user_parts`] is sent
33//! as the content-block array the Messages API requires — each [`Part`] as
34//! a `text` block or an inline base64 `image` block, in order — and
35//! [`Anthropic::supports`] reports [`Capability::Images`], because every
36//! current Claude model accepts image input. An over-limit prompt is
37//! refused by [`Prompt::check_images`] before any network call.
38
39#![doc = include_str!("../README.md")]
40#![forbid(unsafe_code)]
41
42use async_trait::async_trait;
43use bytes::Bytes;
44use cratefield_core::sse::{SseEvent, sse_events};
45use cratefield_core::{
46 BoxStream, ByteStream, Capability, Clock, Completion, FinishReason, HttpClient, HttpError,
47 HttpPolicy, MAX_RESPONSE_BYTES, MAX_RESPONSE_TIMEOUT, ModelTier, Part, Prompt, Role, TextDelta,
48 TextModel, TextModelError, ToolCall, ToolChoice, Turn, completion_deltas, encode_image,
49 retry_after,
50};
51use futures_util::StreamExt;
52use http::header::CONTENT_TYPE;
53use http::{Request, StatusCode};
54use std::sync::Arc;
55use std::time::Duration;
56
57const MESSAGES_ENDPOINT: &str = "https://api.anthropic.com/v1/messages";
58
59/// The `anthropic-version` header value the Messages API requires on every
60/// call. `2023-06-01` is the GA version date the API documents as the
61/// required value; features arrive behind request fields, not versions.
62const ANTHROPIC_VERSION: &str = "2023-06-01";
63
64/// The model [`Anthropic::from_env`] uses when `ANTHROPIC_MODEL` is unset:
65/// the current most-capable Claude 5 model id. The Messages API takes
66/// exact ids — no date suffixes. Overriding it has a catch: the JSON path
67/// forces `tool_choice: {"type":"tool"}`, and a model that rejects a
68/// forced tool choice makes every `json_schema` call come back a 400
69/// [`TextModelError::Rejected`].
70pub const DEFAULT_MODEL: &str = "claude-opus-5";
71
72/// The single tool the JSON path declares; its input schema is the
73/// caller's [`Prompt::json_schema`], and forcing it is how a completion
74/// comes back as JSON without the adapter parsing it out of prose.
75const JSON_TOOL_NAME: &str = "respond";
76const JSON_TOOL_DESCRIPTION: &str =
77 "Return the completion as JSON matching the provided schema exactly.";
78
79/// The Anthropic `TextModel` over `POST https://api.anthropic.com/v1/messages`.
80pub struct Anthropic {
81 http: Arc<dyn HttpClient>,
82 /// Needed only to read the HTTP-date form of `Retry-After` (issue #278).
83 /// A constructor argument rather than a builder default so a deployment
84 /// that forgets it fails to compile instead of silently retrying a
85 /// date-form 429 immediately.
86 clock: Arc<dyn Clock>,
87 api_key: Option<String>,
88 model: String,
89}
90
91impl Anthropic {
92 /// `api_key: None` => every call is [`TextModelError::NotConfigured`]
93 /// (no network).
94 pub fn new(
95 http: Arc<dyn HttpClient>,
96 clock: Arc<dyn Clock>,
97 api_key: Option<String>,
98 model: impl Into<String>,
99 ) -> Self {
100 Self {
101 http,
102 clock,
103 api_key,
104 model: model.into(),
105 }
106 }
107
108 /// Reads `ANTHROPIC_API_KEY` and `ANTHROPIC_MODEL` (default
109 /// [`DEFAULT_MODEL`]) from the process environment. On Workers the
110 /// venture should read the secrets from its `Env` and use
111 /// [`Anthropic::new`] instead (`std::env` has no Workers vars).
112 pub fn from_env(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
113 Self::new(
114 http,
115 clock,
116 std::env::var("ANTHROPIC_API_KEY").ok(),
117 std::env::var("ANTHROPIC_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_owned()),
118 )
119 }
120
121 /// Status → port error, the one place the provider's taxonomy meets
122 /// ours. The provider's error envelope
123 /// (`{"type":"error","error":{"type":…,"message":…}}`) is parsed once
124 /// for its message; the raw body is the fallback, so a proxy's HTML
125 /// error page still leaves the caller something to log.
126 fn map_status(status: StatusCode, body: &str, retry_after: Option<Duration>) -> TextModelError {
127 let detail = provider_message(body);
128 match status {
129 // "Later", in both shapes the provider says it: an explicit
130 // rate limit (its `Retry-After` honoured in both RFC 9110
131 // forms, through core's parser) and a server on its side.
132 StatusCode::TOO_MANY_REQUESTS => TextModelError::Transient { retry_after },
133 status if status.is_server_error() => TextModelError::Transient { retry_after: None },
134 // The provider refused the request itself: 400 a malformed or
135 // over-limit prompt, 422 content it will not process. Neither
136 // is worth a retry unchanged.
137 StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => {
138 TextModelError::Rejected(detail)
139 }
140 // A bad, revoked or unauthorised key is a configuration
141 // mistake, not a condition time fixes: retrying with the same
142 // key burns the rate budget and fails the same way.
143 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => TextModelError::Rejected(detail),
144 // Any other 4xx is still the request's fault (404: the model
145 // is not available to this org; 413: the prompt is too large).
146 status if status.is_client_error() => TextModelError::Rejected(detail),
147 // Redirects and anything unmodelled: the call did not complete.
148 _ => TextModelError::Transport(format!("unexpected status {status}: {detail}")),
149 }
150 }
151
152 /// Opens the streaming exchange (issue #859): the request `complete`
153 /// sends with `stream: true` added, the head under the same
154 /// [`HttpPolicy`] deadline, the body under the port's
155 /// [`StreamPolicy`](cratefield_core::StreamPolicy) defaults. Every
156 /// refusal `complete` raises before any body arrives is raised here
157 /// too — an unwired key, an over-limit prompt, a non-2xx head, mapped
158 /// by [`Anthropic::map_status`] off a bounded read of the error body
159 /// — and success is the [`TextDelta`] stream the events fold into.
160 ///
161 /// # Errors
162 ///
163 /// Everything `complete` refuses before a body would arrive:
164 /// [`TextModelError::NotConfigured`], a local
165 /// [`TextModelError::Rejected`] (`wire_request`'s refusal, an
166 /// over-limit image prompt) and the non-2xx mapping.
167 async fn open_stream(
168 &self,
169 prompt: &Prompt,
170 ) -> Result<BoxStream<'static, Result<TextDelta, TextModelError>>, TextModelError> {
171 // An over-limit image prompt is refused before anything is built
172 // or sent, exactly as `complete` refuses it: the limits are about
173 // the request a provider would be handed (issue #628).
174 prompt.check_images()?;
175
176 let Some(api_key) = &self.api_key else {
177 tracing::info!(
178 provider = "anthropic",
179 outcome = "not_configured",
180 model = %self.model,
181 "text model outcome"
182 );
183 return Err(TextModelError::NotConfigured);
184 };
185
186 let mut payload = wire_request(&self.model, prompt)?;
187 payload.stream = true;
188 let request = messages_request(api_key, &payload)?;
189
190 let response = self
191 .http
192 .send_streaming(request)
193 .await
194 .map_err(|err: HttpError| TextModelError::Transport(err.to_string()))?;
195
196 let status = response.status();
197 if !status.is_success() {
198 // A non-2xx head is an ordinary error answer, buffered or not:
199 // map it exactly as `complete` maps one.
200 let retry_after = retry_after(response.headers(), self.clock.as_ref());
201 let body = collect_bounded(response.into_body()).await;
202 let error = Self::map_status(status, &body, retry_after);
203 tracing::warn!(
204 provider = "anthropic",
205 code = status.as_u16(),
206 outcome = "failed",
207 model = %self.model,
208 error = %error,
209 "text model outcome"
210 );
211 return Err(error);
212 }
213
214 Ok(fold_stream(
215 sse_events(response.into_body()),
216 StreamParser::new(&self.model),
217 ))
218 }
219}
220
221const fn wire_role(role: Role) -> &'static str {
222 match role {
223 Role::User => "user",
224 Role::Assistant => "assistant",
225 }
226}
227
228#[derive(serde::Serialize)]
229struct MessagesRequest<'a> {
230 model: &'a str,
231 max_tokens: u32,
232 #[serde(skip_serializing_if = "Option::is_none")]
233 system: Option<&'a str>,
234 messages: Vec<WireTurn<'a>>,
235 #[serde(skip_serializing_if = "Vec::is_empty")]
236 tools: Vec<WireTool<'a>>,
237 #[serde(skip_serializing_if = "Option::is_none")]
238 tool_choice: Option<WireToolChoice<'a>>,
239 /// The streaming switch (issue #859): `true` on the stream path only,
240 /// so `complete`'s request body stays byte-for-byte what it always
241 /// was.
242 #[serde(skip_serializing_if = "is_false")]
243 stream: bool,
244}
245
246/// One message on the wire. `content` is the bare string the adapter has
247/// always sent for a turn that carries neither tool calls nor tool results,
248/// and the content-block array the Messages API needs for the tool turns
249/// (issue #665).
250#[derive(serde::Serialize)]
251struct WireTurn<'a> {
252 role: &'static str,
253 content: WireContent<'a>,
254}
255
256/// A turn's content: a bare string, or the block array a tool turn needs.
257/// `untagged` so the plain form stays exactly the string it was before
258/// tools existed — no existing request shape changes.
259#[derive(serde::Serialize)]
260#[serde(untagged)]
261enum WireContent<'a> {
262 Text(&'a str),
263 Blocks(Vec<WireBlock<'a>>),
264}
265
266/// One content block of a tool or image turn.
267#[derive(serde::Serialize)]
268#[serde(tag = "type", rename_all = "snake_case")]
269enum WireBlock<'a> {
270 /// The model's own words alongside (or answering) tool calls, or one
271 /// text [`Part`] of a parts-bearing turn (issue #628).
272 Text { text: &'a str },
273 /// An inline image [`Part`] (issue #628): the base64 source the
274 /// Messages API expects.
275 Image { source: WireImageSource },
276 /// A call the assistant asked for, read back as a previous turn.
277 ToolUse {
278 id: &'a str,
279 name: &'a str,
280 input: &'a serde_json::Value,
281 },
282 /// The result a user turn carries back for one call.
283 ToolResult {
284 tool_use_id: &'a str,
285 content: &'a str,
286 // A successful result omits `is_error`, matching the documented
287 // shape; only a tool-level failure carries it.
288 #[serde(skip_serializing_if = "is_false")]
289 is_error: bool,
290 },
291}
292
293/// The `source` of an inline image block (issue #628): the base64 payload
294/// and media type the Messages API documents for an inline image. `data` is
295/// owned because the encoding is built here from the part's raw bytes.
296#[derive(serde::Serialize)]
297struct WireImageSource {
298 #[serde(rename = "type")]
299 kind: &'static str,
300 media_type: &'static str,
301 data: String,
302}
303
304/// `skip_serializing_if` for the `is_error` flag. Serde passes the field by
305/// reference, so the signature is fixed even though a `bool` is `Copy`.
306#[allow(clippy::trivially_copy_pass_by_ref)]
307fn is_false(value: &bool) -> bool {
308 !*value
309}
310
311#[derive(serde::Serialize)]
312struct WireTool<'a> {
313 name: &'a str,
314 description: &'a str,
315 input_schema: &'a serde_json::Value,
316}
317
318/// The `tool_choice` wire shape: a `type` always, a `name` only for the
319/// `tool` form that pins one tool.
320#[derive(serde::Serialize)]
321struct WireToolChoice<'a> {
322 #[serde(rename = "type")]
323 kind: &'static str,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 name: Option<&'a str>,
326}
327
328/// Maps the port's [`ToolChoice`] onto the Messages wire. The port's enum
329/// is `#[non_exhaustive]`; a choice this adapter does not know falls back
330/// to the provider's own default, `auto`.
331fn wire_tool_choice(choice: &ToolChoice) -> WireToolChoice<'_> {
332 let (kind, name) = match choice {
333 ToolChoice::None => ("none", None),
334 ToolChoice::Required => ("any", None),
335 ToolChoice::Tool(name) => ("tool", Some(name.as_str())),
336 // `Auto`, and any future variant this adapter does not know: the
337 // provider's own default.
338 _ => ("auto", None),
339 };
340 WireToolChoice { kind, name }
341}
342
343/// Serialises one turn. A turn carrying no parts, no tool calls and no tool
344/// results keeps its old shape exactly — `content` as a bare string. Every
345/// other turn becomes the content-block array the Messages API requires:
346/// the turn's [`Part`]s in order (issue #628), then, for a tool turn, the
347/// text block (only when non-empty) and the `tool_use`/`tool_result` blocks.
348///
349/// # Errors
350///
351/// [`TextModelError::Rejected`] when a part is a variant this adapter was
352/// never taught to send: [`Part`] is `#[non_exhaustive]`, and a prompt is
353/// refused rather than sent with a part silently dropped.
354fn wire_turn(turn: &Turn) -> Result<WireTurn<'_>, TextModelError> {
355 let role = wire_role(turn.role);
356 if turn.parts.is_empty() && turn.tool_calls.is_empty() && turn.tool_results.is_empty() {
357 return Ok(WireTurn {
358 role,
359 content: WireContent::Text(turn.content.as_str()),
360 });
361 }
362
363 let mut blocks =
364 Vec::with_capacity(turn.parts.len() + turn.tool_calls.len() + turn.tool_results.len() + 1);
365 // The parts a caller built with `Turn::user_parts` travel in order —
366 // text and image interleaved exactly as given.
367 for part in &turn.parts {
368 match part {
369 Part::Text(text) => blocks.push(WireBlock::Text {
370 text: text.as_str(),
371 }),
372 Part::Image { media_type, bytes } => blocks.push(WireBlock::Image {
373 source: WireImageSource {
374 kind: "base64",
375 media_type: media_type.as_str(),
376 data: encode_image(bytes),
377 },
378 }),
379 // `Part` is `#[non_exhaustive]`: a variant this adapter has not
380 // been taught to serialise is refused rather than dropped, the
381 // same "never silently drop" rule. Not `Unsupported`: no
382 // capability names an unknown content kind.
383 _ => {
384 return Err(TextModelError::Rejected(
385 "the prompt carries a content part this adapter does not know how to send"
386 .to_owned(),
387 ));
388 }
389 }
390 }
391 if !turn.tool_calls.is_empty() && !turn.content.is_empty() {
392 blocks.push(WireBlock::Text {
393 text: turn.content.as_str(),
394 });
395 }
396 for call in &turn.tool_calls {
397 blocks.push(WireBlock::ToolUse {
398 id: call.id.as_str(),
399 name: call.name.as_str(),
400 input: &call.arguments,
401 });
402 }
403 for result in &turn.tool_results {
404 blocks.push(WireBlock::ToolResult {
405 tool_use_id: result.tool_call_id.as_str(),
406 content: result.content.as_str(),
407 is_error: result.is_error,
408 });
409 }
410 if !turn.tool_results.is_empty() && !turn.content.is_empty() {
411 blocks.push(WireBlock::Text {
412 text: turn.content.as_str(),
413 });
414 }
415
416 Ok(WireTurn {
417 role,
418 content: WireContent::Blocks(blocks),
419 })
420}
421
422/// Builds the wire body: a single forced tool — input schema = the caller's
423/// — when a schema was asked for; the caller's own `tools` and `tool_choice`
424/// when tools were offered; plain text otherwise. Hand-rolling this JSON
425/// would be a second escaping bug waiting to happen; the prompt is user
426/// content.
427///
428/// A prompt that carries both a [`Prompt::json_schema`] and its own
429/// [`Prompt::tools`] is [`TextModelError::Rejected`]: the schema path
430/// already declares a synthetic forced tool of its own, so the two would
431/// collide on the same request (issue #665). A turn carrying a [`Part`]
432/// this adapter cannot serialise is refused the same way, by
433/// [`wire_turn`].
434fn wire_request<'a>(
435 model: &'a str,
436 prompt: &'a Prompt,
437) -> Result<MessagesRequest<'a>, TextModelError> {
438 if prompt.json_schema.is_some() && !prompt.tools.is_empty() {
439 return Err(TextModelError::Rejected(
440 "a prompt cannot carry both json_schema and tools: the json_schema path already \
441 declares a forced tool of its own, so the two would collide"
442 .to_owned(),
443 ));
444 }
445
446 let (tools, tool_choice): (Vec<WireTool<'a>>, Option<WireToolChoice<'a>>) =
447 match prompt.json_schema.as_ref() {
448 Some(schema) => (
449 vec![WireTool {
450 name: JSON_TOOL_NAME,
451 description: JSON_TOOL_DESCRIPTION,
452 input_schema: schema,
453 }],
454 Some(WireToolChoice {
455 kind: "tool",
456 name: Some(JSON_TOOL_NAME),
457 }),
458 ),
459 None => (
460 prompt
461 .tools
462 .iter()
463 .map(|tool| WireTool {
464 name: tool.name.as_str(),
465 description: tool.description.as_str(),
466 input_schema: &tool.parameters,
467 })
468 .collect(),
469 prompt.tool_choice.as_ref().map(wire_tool_choice),
470 ),
471 };
472
473 Ok(MessagesRequest {
474 model,
475 max_tokens: prompt.max_tokens,
476 system: prompt.system.as_deref(),
477 messages: prompt
478 .messages
479 .iter()
480 .map(wire_turn)
481 .collect::<Result<Vec<_>, _>>()?,
482 tools,
483 tool_choice,
484 stream: false,
485 })
486}
487
488/// The Messages POST both paths send — `complete` buffered, `stream` with
489/// `"stream": true` added by [`Anthropic::open_stream`]: the same headers,
490/// the same serialisation of [`MessagesRequest`], the same [`HttpPolicy`]
491/// bounds on the head. The caps are the design (issue #430). The port's
492/// default 10 s deadline is a model call's normal case, not its tail, so
493/// the adapter asks for the port ceiling — 30 s, the most a Worker
494/// request can responsibly spend on one upstream — and keeps the port's
495/// body cap. `HttpPolicy::clamped` can only tighten, so this is also the
496/// widest request any caller can get through the adapter. The
497/// consequences are deliberate: keep `max_tokens` modest (a body over the
498/// cap is refused, not truncated), one model call per pipeline stage, and
499/// a timeout surfaces as `Transport` for the caller to judge — no retry
500/// happens in here. A streamed body is bounded by its own
501/// [`StreamPolicy`](cratefield_core::StreamPolicy) instead, and the
502/// defaults are the right ones for a model call (between-token pauses are
503/// quiet time, not dead time), so none is attached. Resend attaches no
504/// policy; this one does, on purpose.
505fn messages_request(
506 api_key: &str,
507 payload: &MessagesRequest<'_>,
508) -> Result<Request<Bytes>, TextModelError> {
509 // Hand-rolling this JSON would be a second escaping bug waiting to
510 // happen; the prompt is user content.
511 let body =
512 serde_json::to_vec(payload).map_err(|err| TextModelError::Transport(err.to_string()))?;
513 let mut request = Request::builder()
514 .method(http::Method::POST)
515 .uri(MESSAGES_ENDPOINT)
516 .header(CONTENT_TYPE, "application/json")
517 .header("x-api-key", api_key)
518 .header("anthropic-version", ANTHROPIC_VERSION)
519 .body(Bytes::from(body))
520 .map_err(|err| TextModelError::Transport(err.to_string()))?;
521 request.extensions_mut().insert(HttpPolicy {
522 timeout: MAX_RESPONSE_TIMEOUT,
523 max_response_bytes: MAX_RESPONSE_BYTES,
524 });
525 Ok(request)
526}
527
528#[derive(serde::Deserialize)]
529struct MessagesResponse {
530 #[serde(default)]
531 model: String,
532 #[serde(default)]
533 content: Vec<ContentBlock>,
534 #[serde(default)]
535 stop_reason: Option<String>,
536 #[serde(default)]
537 usage: Option<WireUsage>,
538}
539
540/// The content blocks the port has a use for. `thinking`,
541/// `redacted_thinking`, server-tool results and whatever ships next parse
542/// as [`ContentBlock::Other`] rather than refusing the whole response —
543/// the adapter was never asked to produce them.
544#[derive(serde::Deserialize)]
545#[serde(tag = "type", rename_all = "snake_case")]
546enum ContentBlock {
547 Text {
548 text: String,
549 },
550 ToolUse {
551 id: String,
552 name: String,
553 input: serde_json::Value,
554 },
555 #[serde(other)]
556 Other,
557}
558
559// The field names are the provider's wire names, kept verbatim so the
560// struct reads against the Messages API reference; the shared `_tokens`
561// postfix is theirs, not ours.
562#[allow(clippy::struct_field_names)]
563#[derive(serde::Deserialize, Default, Clone, Copy)]
564struct WireUsage {
565 #[serde(default)]
566 input_tokens: u32,
567 #[serde(default)]
568 output_tokens: u32,
569 /// Prompt tokens served from the provider's prompt cache (issue #560):
570 /// absent unless the call actually read one. Reported separately from
571 /// `input_tokens` — the wire's `input_tokens` counts only the
572 /// uncached remainder.
573 #[serde(default)]
574 cache_read_input_tokens: Option<u32>,
575 /// Prompt tokens the call wrote into the provider's cache, billed as
576 /// input: absent unless the call cached something new.
577 #[serde(default)]
578 cache_creation_input_tokens: Option<u32>,
579}
580
581#[derive(serde::Deserialize)]
582struct ErrorEnvelope {
583 #[serde(default)]
584 error: Option<ApiErrorBody>,
585}
586
587#[derive(serde::Deserialize)]
588struct ApiErrorBody {
589 #[serde(default)]
590 message: String,
591}
592
593/// The provider's own words, from its error envelope, with the raw body as
594/// the fallback when the envelope does not parse.
595fn provider_message(body: &str) -> String {
596 match serde_json::from_str::<ErrorEnvelope>(body) {
597 Ok(parsed) => match parsed.error {
598 Some(error) if !error.message.is_empty() => error.message,
599 _ => body.to_string(),
600 },
601 Err(_) => body.to_string(),
602 }
603}
604
605/// The wire's split prompt — the uncached remainder, cache writes (billed
606/// as input) and cache reads — summed into the port's one total plus the
607/// cached subset (issue #560). The port wants every completion's
608/// `input_tokens` to be the total the provider processed; only the read
609/// half is `cached_input_tokens`, because only a read was *served* from
610/// the cache.
611fn reported_usage(usage: WireUsage) -> (u64, u64, Option<u64>) {
612 let input_tokens = usage
613 .input_tokens
614 .saturating_add(usage.cache_creation_input_tokens.unwrap_or_default())
615 .saturating_add(usage.cache_read_input_tokens.unwrap_or_default());
616 (
617 u64::from(input_tokens),
618 u64::from(usage.output_tokens),
619 usage.cache_read_input_tokens.map(u64::from),
620 )
621}
622
623/// Reads a parsed answer into the port's three content shapes: the joined
624/// text, the forced tool's input on the schema path, and the calls the model
625/// asked for on every other path (issue #665). The schema path's truncation
626/// and missing-block guards live here because they depend on what the blocks
627/// turned out to be.
628///
629/// The text may be empty when the model only calls tools, and that is not an
630/// error.
631fn interpret_content(
632 prompt: &Prompt,
633 parsed: &MessagesResponse,
634) -> Result<(String, Option<serde_json::Value>, Vec<ToolCall>), TextModelError> {
635 let mut text = String::new();
636 let mut json = None;
637 let mut tool_calls = Vec::new();
638 for block in &parsed.content {
639 match block {
640 ContentBlock::Text { text: block_text } => text.push_str(block_text),
641 // On the schema path the forced tool's input is the answer; on
642 // every other path a `tool_use` block is a call the model asked
643 // for, collected in the order the model named them. A response
644 // the adapter never asked for tools on can still carry one, and
645 // it is a call either way.
646 ContentBlock::ToolUse { id, name, input } => {
647 if prompt.json_schema.is_some() {
648 json = Some(input.clone());
649 } else {
650 tool_calls.push(tool_use_call(id, name, input.clone()));
651 }
652 }
653 ContentBlock::Other => {}
654 }
655 }
656
657 if prompt.json_schema.is_some() {
658 // `max_tokens` can cut the forced tool call off mid-JSON, leaving
659 // `input` empty or partial (a JSON `null` parses to `Value::Null`
660 // here) — returning that as a schema-shaped success would be a lie.
661 // `Rejected`, not `Transient`: retrying the identical request
662 // truncates identically; only a larger `max_tokens` changes the
663 // outcome.
664 if parsed.stop_reason.as_deref() == Some("max_tokens") {
665 return Err(TextModelError::Rejected(
666 "the response was truncated at max_tokens before the schema-shaped \
667 result was complete; a larger max_tokens is needed"
668 .to_owned(),
669 ));
670 }
671 // Forced tool use was the whole point of the call. A response without
672 // the tool block is not a refusal (that would have been a 4xx) and
673 // not an empty answer — the JSON path broke between provider and
674 // here, which is a transport failure.
675 if json.is_none() {
676 return Err(TextModelError::Transport(
677 "forced tool call came back without a tool_use block".to_owned(),
678 ));
679 }
680 } else {
681 // No schema was requested; `json` stays `None` even if the provider
682 // somehow answered with a tool block — on this path a tool block is a
683 // call, not a structured answer. A text completion cut off by
684 // `max_tokens` is still a useful answer, so it is returned as-is. A
685 // *tool* call cut off at `max_tokens` is not: its `input` may be
686 // partial, and handing that to an executor is exactly what the schema
687 // check downstream exists to prevent. `Rejected`, not `Transient`:
688 // retrying the identical request truncates identically.
689 json = None;
690 if parsed.stop_reason.as_deref() == Some("max_tokens") && !tool_calls.is_empty() {
691 return Err(truncated_tool_call());
692 }
693 }
694
695 Ok((text, json, tool_calls))
696}
697
698/// One `tool_use` block as the port's [`ToolCall`] — the single conversion
699/// the buffered answer and the stream share, so a call reassembled from
700/// `input_json_delta` chunks is exactly the call `complete` builds from
701/// the same block.
702fn tool_use_call(id: &str, name: &str, arguments: serde_json::Value) -> ToolCall {
703 ToolCall::new(id.to_owned(), name.to_owned(), arguments)
704}
705
706/// The truncation refusal `complete` raises when a tool call was cut off
707/// at `max_tokens`, shared with the stream so both paths refuse
708/// identically.
709fn truncated_tool_call() -> TextModelError {
710 TextModelError::Rejected(
711 "the response was truncated at max_tokens before the tool call's arguments \
712 were complete; a larger max_tokens is needed"
713 .to_owned(),
714 )
715}
716
717/// The wire's `stop_reason` → the port's [`FinishReason`]. `refusal` is
718/// the provider withholding content under its own policy: the port's
719/// content-filter bucket.
720fn finish_reason(stop_reason: &str) -> FinishReason {
721 match stop_reason {
722 "end_turn" | "stop_sequence" => FinishReason::Stop,
723 "max_tokens" => FinishReason::Length,
724 "tool_use" => FinishReason::ToolUse,
725 "refusal" => FinishReason::ContentFilter,
726 other => FinishReason::Other(other.to_owned()),
727 }
728}
729
730/// A streamed call's arguments, from the `input_json_delta` text
731/// accumulated by the time `content_block_stop` closes the block. An
732/// empty accumulation is the arguments-less `{}` the buffered wire
733/// carries in the block's `input` — the same value `complete` reads out.
734///
735/// # Errors
736///
737/// [`TextModelError::Transport`] when the accumulation never added up to
738/// JSON: the streaming twin of `complete`'s unparseable-answer failure,
739/// never a half-formed call handed onward.
740fn tool_use_arguments(json: &str) -> Result<serde_json::Value, TextModelError> {
741 if json.is_empty() {
742 return Ok(serde_json::Value::Object(serde_json::Map::new()));
743 }
744 serde_json::from_str(json).map_err(|err| {
745 TextModelError::Transport(format!("the tool call's arguments did not parse: {err}"))
746 })
747}
748
749/// What one streamed event folds into: zero or more deltas, or the one
750/// failure that ends the stream.
751type StreamDeltas = Vec<Result<TextDelta, TextModelError>>;
752
753/// The Messages stream's events, in the wire's shapes. `ping` and any
754/// event type this adapter never learned parse as
755/// [`StreamEvent::Other`](Self::Other) rather than failing the stream —
756/// the same tolerance `complete` gives a content block it does not know.
757#[derive(serde::Deserialize)]
758#[serde(tag = "type", rename_all = "snake_case")]
759enum StreamEvent {
760 /// The answer's head: the model answering it and the prompt-side
761 /// usage.
762 MessageStart { message: StreamMessageHead },
763 /// A content block opened: text, a `tool_use` call — id and name now,
764 /// arguments by delta — or, as [`StreamBlock::Other`], a thinking or
765 /// otherwise unreadable block this adapter follows through its deltas.
766 ContentBlockStart {
767 index: u32,
768 #[serde(rename = "content_block")]
769 block: StreamBlock,
770 },
771 /// One run of content for the block at `index`.
772 ContentBlockDelta { index: u32, delta: StreamBlockDelta },
773 /// The block at `index` is complete.
774 ContentBlockStop { index: u32 },
775 /// The message is wrapping up: its stop reason, and the cumulative
776 /// output count.
777 MessageDelta {
778 delta: StreamStopReason,
779 #[serde(default)]
780 usage: Option<StreamOutputUsage>,
781 },
782 /// The message is over — the stream's one clean end.
783 MessageStop,
784 /// A failure mid-stream, usually `overloaded_error` under load.
785 Error { error: StreamErrorBody },
786 /// `ping`, and whatever ships next.
787 #[serde(other)]
788 Other,
789}
790
791/// The `message` object of a `message_start` event.
792#[derive(serde::Deserialize)]
793struct StreamMessageHead {
794 #[serde(default)]
795 model: String,
796 #[serde(default)]
797 usage: Option<WireUsage>,
798}
799
800/// A content block's opening frame. A text block says nothing of its own
801/// here — its content arrives as deltas.
802#[derive(serde::Deserialize)]
803#[serde(tag = "type", rename_all = "snake_case")]
804enum StreamBlock {
805 Text,
806 /// A call the model asked for: id and name now, arguments by delta.
807 ToolUse {
808 id: String,
809 name: String,
810 },
811 #[serde(other)]
812 Other,
813}
814
815/// One `content_block_delta` frame: a run of answer text, a run of a
816/// `tool_use` block's arguments JSON, or a run of the model's reasoning.
817#[derive(serde::Deserialize)]
818#[serde(tag = "type", rename_all = "snake_case")]
819enum StreamBlockDelta {
820 TextDelta {
821 text: String,
822 },
823 InputJsonDelta {
824 partial_json: String,
825 },
826 ThinkingDelta {
827 thinking: String,
828 },
829 /// `signature_delta`, and whatever ships next: content the port has
830 /// no delta for.
831 #[serde(other)]
832 Other,
833}
834
835/// The `delta` object of a `message_delta` event.
836#[derive(serde::Deserialize)]
837struct StreamStopReason {
838 #[serde(default)]
839 stop_reason: Option<String>,
840}
841
842/// The `usage` object of a `message_delta` event: the output count so
843/// far, cumulative across the whole message.
844#[derive(serde::Deserialize)]
845struct StreamOutputUsage {
846 #[serde(default)]
847 output_tokens: u32,
848}
849
850/// The body of a mid-stream `error` event — the envelope the HTTP errors
851/// use, minus the status line.
852#[derive(serde::Deserialize)]
853struct StreamErrorBody {
854 #[serde(rename = "type", default)]
855 kind: String,
856 #[serde(default)]
857 message: String,
858}
859
860/// One `tool_use` block opened and not yet closed: the ordinal it holds
861/// among the completion's tool calls, the id and name
862/// `content_block_start` named, and the arguments JSON the
863/// `input_json_delta` chunks have accumulated so far.
864struct OpenToolCall {
865 ordinal: usize,
866 id: String,
867 name: String,
868 json: String,
869}
870
871/// Folds the Messages stream's events into the port's [`TextDelta`]s —
872/// the state between two events, owned by the stream
873/// [`Anthropic::stream`] returns alongside the body it reads, so dropping
874/// the stream drops both.
875struct StreamParser {
876 /// The model id this adapter was built with: what `Finish` reports
877 /// when the stream never named one — the same fallback `complete`
878 /// applies to an empty `model` field.
879 fallback_model: String,
880 /// The model `message_start` named.
881 model: Option<String>,
882 /// The prompt side of the usage, summed once at `message_start` by
883 /// [`reported_usage`]: every [`TextDelta::Usage`] this stream emits
884 /// reports it unchanged; only `output_tokens` grows.
885 input_tokens: u64,
886 cached_input_tokens: Option<u64>,
887 /// Completion tokens so far: `message_start`'s first token, then the
888 /// cumulative count each `message_delta` carries.
889 output_tokens: u64,
890 /// The `tool_use` blocks opened and not yet closed, by content-block
891 /// index.
892 open: std::collections::BTreeMap<u32, OpenToolCall>,
893 /// How many `tool_use` blocks have started — the next call's ordinal.
894 /// The port's tool-call `index` is the ordinal among the completion's
895 /// calls (0, 1, …), **not** the stream's content-block index, which
896 /// counts text and thinking blocks too.
897 calls: usize,
898 /// The stop reason `message_delta` named, if it arrived.
899 stop_reason: Option<String>,
900 /// Fused by `Finish` or a failure: every later event is ignored, and
901 /// a body that ends now ends the stream cleanly.
902 done: bool,
903}
904
905impl StreamParser {
906 /// A parser for one streamed answer, falling back to `fallback_model`
907 /// for the `Finish` when the stream names no model.
908 fn new(fallback_model: &str) -> Self {
909 Self {
910 fallback_model: fallback_model.to_owned(),
911 model: None,
912 input_tokens: 0,
913 cached_input_tokens: None,
914 output_tokens: 0,
915 open: std::collections::BTreeMap::new(),
916 calls: 0,
917 stop_reason: None,
918 done: false,
919 }
920 }
921
922 /// Folds one event in, in stream order. Empty for the events the port
923 /// has no delta for: a ping, a text block's start, a signature delta.
924 fn push(&mut self, event: &SseEvent) -> StreamDeltas {
925 if self.done {
926 return Vec::new();
927 }
928 let parsed: StreamEvent = match serde_json::from_str(&event.data) {
929 Ok(parsed) => parsed,
930 Err(err) => {
931 // A 2xx stream whose events do not parse is the streaming
932 // twin of `complete`'s unparseable-body transport failure:
933 // nothing about the prompt was refused.
934 return self.halt(Err(TextModelError::Transport(format!(
935 "model stream event did not parse: {err}"
936 ))));
937 }
938 };
939 match parsed {
940 StreamEvent::MessageStart { message } => self.message_start(message),
941 StreamEvent::ContentBlockStart { index, block } => match block {
942 StreamBlock::ToolUse { id, name } => {
943 let ordinal = self.calls;
944 self.calls += 1;
945 self.open.insert(
946 index,
947 OpenToolCall {
948 ordinal,
949 id: id.clone(),
950 name: name.clone(),
951 json: String::new(),
952 },
953 );
954 vec![Ok(TextDelta::ToolCallStarted {
955 index: ordinal,
956 id,
957 name,
958 })]
959 }
960 StreamBlock::Text | StreamBlock::Other => Vec::new(),
961 },
962 StreamEvent::ContentBlockDelta { index, delta } => match delta {
963 StreamBlockDelta::TextDelta { text } => vec![Ok(TextDelta::Text(text))],
964 StreamBlockDelta::ThinkingDelta { thinking } => {
965 vec![Ok(TextDelta::Reasoning(thinking))]
966 }
967 StreamBlockDelta::InputJsonDelta { partial_json } => {
968 match self.open.get_mut(&index) {
969 Some(call) => {
970 call.json.push_str(&partial_json);
971 vec![Ok(TextDelta::ToolCallArguments {
972 index: call.ordinal,
973 chunk: partial_json,
974 })]
975 }
976 // Arguments for a block that never started have
977 // nowhere to accumulate; a well-formed stream
978 // cannot send one, and inventing a block would
979 // fabricate a call. Ignored.
980 None => Vec::new(),
981 }
982 }
983 StreamBlockDelta::Other => Vec::new(),
984 },
985 StreamEvent::ContentBlockStop { index } => match self.open.remove(&index) {
986 Some(call) => vec![finished_tool_call(&call)],
987 None => Vec::new(),
988 },
989 StreamEvent::MessageDelta { delta, usage } => {
990 self.message_delta(delta.stop_reason, usage)
991 }
992 StreamEvent::MessageStop => self.message_stop(),
993 StreamEvent::Error { error } => self.error_event(error),
994 StreamEvent::Other => Vec::new(),
995 }
996 }
997
998 /// The answer's head: fix the prompt-side counts every later
999 /// [`TextDelta::Usage`] reports, and name them once now.
1000 fn message_start(&mut self, message: StreamMessageHead) -> StreamDeltas {
1001 let (input_tokens, output_tokens, cached_input_tokens) =
1002 reported_usage(message.usage.unwrap_or_default());
1003 self.input_tokens = input_tokens;
1004 self.output_tokens = output_tokens;
1005 self.cached_input_tokens = cached_input_tokens;
1006 self.model = (!message.model.is_empty()).then_some(message.model);
1007 vec![Ok(TextDelta::Usage {
1008 input_tokens,
1009 output_tokens,
1010 cached_input_tokens,
1011 })]
1012 }
1013
1014 /// The wrap-up: the stop reason, and the cumulative output count. A
1015 /// `message_delta` without its `usage` block still wraps up — the
1016 /// last known counts stand, and the omission is a warn line, not a
1017 /// silent free completion.
1018 fn message_delta(
1019 &mut self,
1020 stop_reason: Option<String>,
1021 usage: Option<StreamOutputUsage>,
1022 ) -> StreamDeltas {
1023 self.stop_reason = stop_reason;
1024 // A tool call cut off at `max_tokens` is the one place the stream
1025 // refuses where a text answer would only end: the port promises a
1026 // finished call is the whole call `complete` returns, and
1027 // `complete` refuses exactly this truncation rather than hand a
1028 // partial `input` to an executor — so the stream does too, before
1029 // any caller has gone further on the promise. A *text* answer cut
1030 // off at `max_tokens` is `Finish{Length}`, the answer `complete`
1031 // returns as-is.
1032 if self.stop_reason.as_deref() == Some("max_tokens") && self.calls > 0 {
1033 return self.halt(Err(truncated_tool_call()));
1034 }
1035 if let Some(usage) = usage {
1036 self.output_tokens = u64::from(usage.output_tokens);
1037 vec![Ok(TextDelta::Usage {
1038 input_tokens: self.input_tokens,
1039 output_tokens: self.output_tokens,
1040 cached_input_tokens: self.cached_input_tokens,
1041 })]
1042 } else {
1043 // A missing report must not silently read as a free
1044 // completion: said out loud, the same warning the
1045 // openai-compatible adapter gives a server that omits the
1046 // usage block — except the counts here do not default to
1047 // zero, they keep the last known values (message_start's,
1048 // plus any earlier cumulative count).
1049 let model = self
1050 .model
1051 .as_deref()
1052 .unwrap_or(self.fallback_model.as_str());
1053 tracing::warn!(
1054 provider = "anthropic",
1055 model = %model,
1056 "text model completed without a usage block; the last known token counts stand"
1057 );
1058 Vec::new()
1059 }
1060 }
1061
1062 /// The clean end: one `Finish`, naming the wire's stop reason and the
1063 /// model the stream named — the same outcome line `complete` logs.
1064 fn message_stop(&mut self) -> StreamDeltas {
1065 let reason = match self.stop_reason.as_deref() {
1066 // No `message_delta` named a reason: infer it from the shape,
1067 // the way `completion_deltas` would have from a buffered
1068 // answer.
1069 None if self.calls > 0 => FinishReason::ToolUse,
1070 None => FinishReason::Stop,
1071 Some(stop_reason) => finish_reason(stop_reason),
1072 };
1073 let model = self
1074 .model
1075 .take()
1076 .unwrap_or_else(|| self.fallback_model.clone());
1077 tracing::info!(
1078 provider = "anthropic",
1079 outcome = "completed",
1080 model = %model,
1081 input_tokens = self.input_tokens,
1082 output_tokens = self.output_tokens,
1083 "text model outcome"
1084 );
1085 self.halt(Ok(TextDelta::Finish { reason, model }))
1086 }
1087
1088 /// A failure mid-stream. No `Retry-After` rides an event; the
1089 /// transient kinds are the provider's own rate-limit and server
1090 /// vocabulary — the statuses `map_status` transients — and everything
1091 /// else refused the request.
1092 fn error_event(&mut self, error: StreamErrorBody) -> StreamDeltas {
1093 let detail = if error.message.is_empty() {
1094 error.kind.clone()
1095 } else {
1096 error.message
1097 };
1098 let error = match error.kind.as_str() {
1099 "rate_limit_error" | "api_error" | "overloaded_error" => {
1100 TextModelError::Transient { retry_after: None }
1101 }
1102 _ => TextModelError::Rejected(detail),
1103 };
1104 self.halt(Err(error))
1105 }
1106
1107 /// The stream's tail, once the body has ended: the transport failure
1108 /// when `message_stop` never arrived — a stream that stops short is
1109 /// not a success — and nothing when the stream already ended cleanly.
1110 fn finish(&mut self) -> StreamDeltas {
1111 if self.done {
1112 return Vec::new();
1113 }
1114 self.halt(Err(TextModelError::Transport(
1115 "the model's stream ended without a message_stop event".to_owned(),
1116 )))
1117 }
1118
1119 /// Fuses the parser — no further event is folded, and a body that
1120 /// ends now ends the stream cleanly — and yields the one item that
1121 /// ends the stream.
1122 fn halt(&mut self, item: Result<TextDelta, TextModelError>) -> StreamDeltas {
1123 self.done = true;
1124 vec![item]
1125 }
1126}
1127
1128/// A closed `tool_use` block becomes the port's finished call, through
1129/// the one conversion `complete` uses.
1130fn finished_tool_call(call: &OpenToolCall) -> Result<TextDelta, TextModelError> {
1131 tool_use_arguments(&call.json).map(|arguments| TextDelta::ToolCallFinished {
1132 index: call.ordinal,
1133 call: tool_use_call(&call.id, &call.name, arguments),
1134 })
1135}
1136
1137/// Reads a non-2xx streaming body out, capped at the buffered path's
1138/// response ceiling — the provider's error words live there, and a
1139/// hostile head must not cost more than `complete` would pay to read
1140/// them. A body that fails mid-read just ends the collection: whatever
1141/// arrived is still the best available detail.
1142async fn collect_bounded(mut body: ByteStream) -> String {
1143 let mut collected = Vec::new();
1144 while let Some(item) = body.next().await {
1145 let Ok(chunk) = item else {
1146 break;
1147 };
1148 collected.extend_from_slice(&chunk);
1149 if collected.len() >= MAX_RESPONSE_BYTES {
1150 break;
1151 }
1152 }
1153 String::from_utf8_lossy(&collected).into_owned()
1154}
1155
1156/// Merges the SSE event stream into `parser` and yields the port's
1157/// deltas — the same `unfold` shape core's `sse_events` uses. The state
1158/// owns the event stream, which owns the response body: dropping the
1159/// returned stream drops the upstream exchange, the cancellation contract
1160/// the port's streaming half carries. No detached task — the body is
1161/// only ever polled by the stream itself.
1162fn fold_stream(
1163 events: BoxStream<'static, Result<SseEvent, HttpError>>,
1164 parser: StreamParser,
1165) -> BoxStream<'static, Result<TextDelta, TextModelError>> {
1166 Box::pin(futures_util::stream::unfold(
1167 Fold {
1168 events: Some(events),
1169 parser,
1170 pending: Vec::new().into_iter(),
1171 },
1172 |mut fold| async move {
1173 loop {
1174 if let Some(delta) = fold.pending.next() {
1175 if fold.parser.done {
1176 // Finish or failure emitted: drop the body instead
1177 // of reading the provider's closing frames.
1178 fold.events = None;
1179 }
1180 return Some((delta, fold));
1181 }
1182 let mut events = fold.events.take()?;
1183 match events.next().await {
1184 Some(Ok(event)) => {
1185 fold.events = Some(events);
1186 fold.pending = fold.parser.push(&event).into_iter();
1187 }
1188 Some(Err(err)) => {
1189 // The body failed mid-stream: the port's transport
1190 // error, and the body drops here.
1191 fold.pending =
1192 vec![Err(TextModelError::Transport(err.to_string()))].into_iter();
1193 }
1194 None => {
1195 // The body ended; the parser says whether that was
1196 // a clean end.
1197 fold.pending = fold.parser.finish().into_iter();
1198 }
1199 }
1200 }
1201 },
1202 ))
1203}
1204
1205/// The `unfold` state behind [`fold_stream`]: the event stream being
1206/// drained, the parser folding it, and the deltas a folded event
1207/// produced that have not been yielded yet.
1208struct Fold {
1209 events: Option<BoxStream<'static, Result<SseEvent, HttpError>>>,
1210 parser: StreamParser,
1211 pending: std::vec::IntoIter<Result<TextDelta, TextModelError>>,
1212}
1213
1214#[async_trait]
1215impl TextModel for Anthropic {
1216 async fn complete(&self, prompt: &Prompt) -> Result<Completion, TextModelError> {
1217 // An over-limit image prompt is refused before anything is built or
1218 // sent: the limits are about the request a provider would be handed
1219 // (issue #628).
1220 prompt.check_images()?;
1221
1222 // Outcome logging in the resend adapter's shape: provider, status,
1223 // outcome, model, token counts. The API key is never logged, and
1224 // neither is the prompt nor the completion — a prompt is user
1225 // content, and the operator needs none of it to see what happened.
1226 let Some(api_key) = &self.api_key else {
1227 tracing::info!(
1228 provider = "anthropic",
1229 outcome = "not_configured",
1230 model = %self.model,
1231 "text model outcome"
1232 );
1233 return Err(TextModelError::NotConfigured);
1234 };
1235
1236 let payload = wire_request(&self.model, prompt)?;
1237 let request = messages_request(api_key, &payload)?;
1238
1239 let response = self
1240 .http
1241 .send(request)
1242 .await
1243 .map_err(|err: HttpError| TextModelError::Transport(err.to_string()))?;
1244
1245 let status = response.status();
1246 // One parser for both `Retry-After` forms (issue #214/#278); the
1247 // date form needs the clock this adapter is constructed with.
1248 let retry_after = retry_after(response.headers(), self.clock.as_ref());
1249 let text = String::from_utf8_lossy(response.body()).to_string();
1250
1251 if !status.is_success() {
1252 let error = Self::map_status(status, &text, retry_after);
1253 tracing::warn!(
1254 provider = "anthropic",
1255 code = status.as_u16(),
1256 outcome = "failed",
1257 model = %self.model,
1258 error = %error,
1259 "text model outcome"
1260 );
1261 return Err(error);
1262 }
1263
1264 // A 2xx body that does not parse is not a "rejected": nothing about
1265 // the prompt was refused, the answer just never arrived in usable
1266 // form — the same bucket as a cut-off connection.
1267 let parsed: MessagesResponse = serde_json::from_str(&text).map_err(|err| {
1268 TextModelError::Transport(format!("model response did not parse: {err}"))
1269 })?;
1270
1271 let (completion_text, json, tool_calls) = interpret_content(prompt, &parsed)?;
1272
1273 // `Completion` is non-exhaustive and carries the counts flat, so
1274 // the wire's usage block is unwrapped straight onto the builder
1275 // rather than rebuilt as a struct of its own.
1276 let (input_tokens, output_tokens, cached_input_tokens) =
1277 reported_usage(parsed.usage.unwrap_or_default());
1278 let model = if parsed.model.is_empty() {
1279 self.model.clone()
1280 } else {
1281 parsed.model
1282 };
1283 tracing::info!(
1284 provider = "anthropic",
1285 code = status.as_u16(),
1286 outcome = "completed",
1287 model = %model,
1288 input_tokens,
1289 output_tokens,
1290 "text model outcome"
1291 );
1292 let mut completion =
1293 Completion::new(completion_text, model).usage(input_tokens, output_tokens);
1294 if let Some(cached_input_tokens) = cached_input_tokens {
1295 completion = completion.cached_input_tokens(cached_input_tokens);
1296 }
1297 if let Some(json) = json {
1298 completion = completion.json(json);
1299 }
1300 if !tool_calls.is_empty() {
1301 completion = completion.tool_calls(tool_calls);
1302 }
1303 Ok(completion)
1304 }
1305
1306 /// Streams `prompt` off the Messages API's event stream (issue #859):
1307 /// the request `complete` sends with `stream: true` added, the head
1308 /// under the same [`HttpPolicy`], answered as [`TextDelta`]s as the
1309 /// events arrive — text and reasoning as they are produced, each tool
1310 /// call from its start through its argument chunks to the finished
1311 /// call, running usage, and one `Finish` naming the wire's
1312 /// `stop_reason` (`end_turn`/`stop_sequence` →
1313 /// [`FinishReason::Stop`], `max_tokens` → `Length`, `tool_use` →
1314 /// [`FinishReason::ToolUse`], `refusal` →
1315 /// [`FinishReason::ContentFilter`]).
1316 ///
1317 /// The deltas promise what the buffered answer delivers: a finished
1318 /// call is exactly the [`ToolCall`] `complete` builds — both go
1319 /// through the one shared conversion — and a
1320 /// [`CompletionBuilder`](cratefield_core::CompletionBuilder) over the
1321 /// stream equals `complete`'s completion, field for field. Two
1322 /// deliberate divergences:
1323 ///
1324 /// - a prompt carrying [`Prompt::json_schema`] **completes and
1325 /// decomposes** instead of streaming: `Completion::json` is what
1326 /// validating the whole answer produces, and only a buffered call
1327 /// can fill it — the port default's shape, written inline because
1328 /// an override cannot call the default body;
1329 /// - a **tool call cut off at `max_tokens`** ends the stream with the
1330 /// truncation refusal `complete` raises, not
1331 /// [`FinishReason::Length`]: a stream that reported success at a
1332 /// truncated call would tell a caller who already relayed the
1333 /// call's start and argument chunks that they add up to a runnable
1334 /// call. A *text* answer cut off at `max_tokens` is
1335 /// [`FinishReason::Length`] — the answer `complete` returns as-is.
1336 ///
1337 /// # Cancellation
1338 ///
1339 /// The returned stream owns the response body, so dropping it drops
1340 /// the upstream exchange — the contract
1341 /// [`HttpClient::send_streaming`] carries, and with it the rest of
1342 /// the spend. No detached task: the body is only ever polled by the
1343 /// stream itself.
1344 fn stream<'a>(
1345 &'a self,
1346 prompt: &'a Prompt,
1347 ) -> BoxStream<'a, Result<TextDelta, TextModelError>> {
1348 if prompt.json_schema.is_some() {
1349 return Box::pin(futures_util::stream::once(self.complete(prompt)).flat_map(
1350 |outcome| {
1351 futures_util::stream::iter(match outcome {
1352 Ok(completion) => completion_deltas(&completion)
1353 .into_iter()
1354 .map(Ok)
1355 .collect::<Vec<_>>(),
1356 Err(err) => vec![Err(err)],
1357 })
1358 },
1359 ));
1360 }
1361 Box::pin(
1362 futures_util::stream::once(self.open_stream(prompt)).flat_map(|opened| match opened {
1363 Ok(deltas) => deltas,
1364 Err(err) => Box::pin(futures_util::stream::iter([Err(err)]))
1365 as BoxStream<'static, Result<TextDelta, TextModelError>>,
1366 }),
1367 )
1368 }
1369
1370 fn supports(&self, _tier: ModelTier, capability: Capability) -> bool {
1371 // The Messages API carries tools and inline images, so this adapter
1372 // does both — and nothing else. Every current Claude model accepts
1373 // image input, so `Images` is reported for whatever model id this
1374 // adapter was constructed with. The tier is the router's routing key
1375 // and is ignored; `Capability` is `#[non_exhaustive]`, so an
1376 // unclaimed capability stays "no".
1377 matches!(capability, Capability::Tools | Capability::Images)
1378 }
1379}