Skip to main content

provider_core/
anthropic_chat.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3//! Anthropic Messages API adapter (PRD-001y S2) serving the `claude_subscription`
4//! and `claude_api` phases.
5//!
6//! Wire format, base URL, and auth scheme were verified at build time against
7//! the published Anthropic Messages API documentation at
8//! https://platform.claude.com/docs/en/api/messages (no live call). The Claude
9//! phases are NOT OpenAI-compatible: the request is a Messages API body
10//! (`model`, `max_tokens`, `system`, `messages`, `tools`, `stream`) sent to
11//! `POST https://api.anthropic.com/v1/messages` with the `x-api-key` header and
12//! the required `anthropic-version` header. Streaming is SSE with
13//! `message_start`/`content_block_*`/`message_delta`/`message_stop` events.
14//!
15//! The model id is a request-body field, so the route endpoint path is static.
16//! The route carries the credential reference; this adapter only wraps it for
17//! the transport layer, which resolves it through the secret boundary.
18
19use crate::ollama_chat::{HttpMethod, NetworkPolicy, ReplayBoundary, ReplayDecision};
20use router_protocol::{
21    CanonicalRequest, ContentBlock, InputItem, MessageRole, PhaseKind, RouteIdentity,
22    RouteIdentityError, ToolChoice, TransportKind,
23};
24use serde_json::{Map, Value, json};
25use std::fmt;
26
27/// The required Anthropic API version header value, pinned from the published
28/// docs (build-time verification).
29pub const ANTHROPIC_VERSION: &str = router_protocol::ANTHROPIC_VERSION;
30/// The header name carrying the Anthropic API key.
31pub const ANTHROPIC_API_KEY_HEADER: &str = router_protocol::ANTHROPIC_API_KEY_HEADER;
32/// The header name carrying the required Anthropic API version.
33pub const ANTHROPIC_VERSION_HEADER: &str = router_protocol::ANTHROPIC_VERSION_HEADER;
34/// Default `max_tokens` used when the canonical request does not bound output
35/// (Anthropic requires the field).
36pub const DEFAULT_MAX_TOKENS: u32 = 1024;
37
38/// The Claude/Anthropic phases this adapter serves.
39pub const fn is_claude_phase(phase: PhaseKind) -> bool {
40    matches!(phase, PhaseKind::ClaudeSubscription | PhaseKind::ClaudeApi)
41}
42
43/// Per-route MAv2 capability projection for the Claude phases (PRD-001l/001u):
44/// the Anthropic wire API cannot represent namespaces, so SteadyMux
45/// normalizes at the wire boundary under the non-reserved `agents` namespace.
46/// Mirrors the DeepSeek/OpenAI-compat follow-on projection.
47pub const fn capability_projection() -> router_protocol::MultiAgentCapabilityProjection {
48    router_protocol::MultiAgentCapabilityProjection {
49        namespace_tools: false,
50        multi_agent_version: router_protocol::MultiAgentVersion::V2,
51        wire_api: router_protocol::MultiAgentWireApi::Responses,
52        observed_namespace_calls:
53            router_protocol::ObservedNamespaceCallBehavior::BareNameNoNamespace,
54    }
55}
56
57#[derive(Clone, PartialEq, Eq)]
58pub struct AnthropicSecretReferenceMarker(String);
59
60impl AnthropicSecretReferenceMarker {
61    pub fn as_str(&self) -> &str {
62        &self.0
63    }
64}
65
66impl fmt::Debug for AnthropicSecretReferenceMarker {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        formatter.write_str("AnthropicSecretReferenceMarker([REDACTED])")
69    }
70}
71
72#[derive(Clone, PartialEq)]
73pub struct AnthropicRequestSpec {
74    pub method: HttpMethod,
75    pub origin: String,
76    pub path: String,
77    pub content_type: &'static str,
78    pub accept: &'static str,
79    pub api_key_secret: AnthropicSecretReferenceMarker,
80    pub network: NetworkPolicy,
81    /// The required `anthropic-version` header value, exposed so the transport
82    /// layer can write the fixed header.
83    pub version_header: &'static str,
84    body: Value,
85}
86
87impl AnthropicRequestSpec {
88    pub fn body(&self) -> &Value {
89        &self.body
90    }
91
92    pub fn body_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
93        serde_json::to_vec(&self.body)
94    }
95}
96
97impl fmt::Debug for AnthropicRequestSpec {
98    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99        formatter
100            .debug_struct("AnthropicRequestSpec")
101            .field("method", &self.method)
102            .field("origin", &self.origin)
103            .field("path", &self.path)
104            .field("content_type", &self.content_type)
105            .field("accept", &self.accept)
106            .field("api_key_secret", &self.api_key_secret)
107            .field("network", &self.network)
108            .field("version_header", &self.version_header)
109            .field("body", &"[REDACTED]")
110            .finish()
111    }
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub enum AnthropicRequestErrorKind {
116    InvalidRouteIdentity,
117    UnsupportedRoute,
118    InvalidCanonicalRequest,
119    AmbiguousToolName,
120}
121
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub struct AnthropicRequestError {
124    pub kind: AnthropicRequestErrorKind,
125}
126
127impl fmt::Display for AnthropicRequestError {
128    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
129        formatter.write_str("the Anthropic Messages request could not be prepared")
130    }
131}
132
133impl std::error::Error for AnthropicRequestError {}
134
135impl From<RouteIdentityError> for AnthropicRequestError {
136    fn from(_: RouteIdentityError) -> Self {
137        Self {
138            kind: AnthropicRequestErrorKind::InvalidRouteIdentity,
139        }
140    }
141}
142
143pub fn build_request(
144    route: &RouteIdentity,
145    request: &CanonicalRequest,
146) -> Result<AnthropicRequestSpec, AnthropicRequestError> {
147    route.validate()?;
148    if !is_claude_phase(route.phase) || route.transport != TransportKind::DirectHttps {
149        return Err(AnthropicRequestError {
150            kind: AnthropicRequestErrorKind::UnsupportedRoute,
151        });
152    }
153    request.validate().map_err(|_| AnthropicRequestError {
154        kind: AnthropicRequestErrorKind::InvalidCanonicalRequest,
155    })?;
156    let secret_ref = route.credential_ref.clone().ok_or(AnthropicRequestError {
157        kind: AnthropicRequestErrorKind::InvalidRouteIdentity,
158    })?;
159
160    Ok(AnthropicRequestSpec {
161        method: HttpMethod::Post,
162        origin: route.endpoint.origin.clone(),
163        path: route.endpoint.path.clone(),
164        content_type: "application/json",
165        accept: "text/event-stream",
166        api_key_secret: AnthropicSecretReferenceMarker(secret_ref),
167        network: NetworkPolicy {
168            allow_proxy: false,
169            allow_dns: true,
170            follow_redirects: false,
171            require_tls: true,
172        },
173        version_header: ANTHROPIC_VERSION,
174        body: build_body(route, request)?,
175    })
176}
177
178fn build_body(
179    route: &RouteIdentity,
180    request: &CanonicalRequest,
181) -> Result<Value, AnthropicRequestError> {
182    let mut object = Map::new();
183    object.insert("model".to_owned(), Value::String(route.model_id.clone()));
184    object.insert(
185        "max_tokens".to_owned(),
186        Value::from(request.max_output_tokens.unwrap_or(DEFAULT_MAX_TOKENS)),
187    );
188    object.insert("stream".to_owned(), Value::Bool(true));
189
190    // The Anthropic Messages API has no system message role: developer/system
191    // turns become the top-level `system` field, and user/assistant turns stay
192    // in `messages`.
193    let system = build_system(request);
194    if !system.is_empty() {
195        object.insert("system".to_owned(), Value::String(system));
196    }
197
198    // `tool_choice` is only valid alongside a `tools` array; emit both or
199    // neither.
200    let emit_tools = !request.tools.is_empty() && request.tool_choice != ToolChoice::None;
201    if emit_tools {
202        object.insert("tool_choice".to_owned(), anthropic_tool_choice(request));
203        let mut names = std::collections::HashSet::new();
204        let mut tools = Vec::with_capacity(request.tools.len());
205        for tool in &request.tools {
206            let name = provider_tool_name(tool.namespace.as_deref(), &tool.name);
207            if !names.insert(name.clone()) {
208                return Err(AnthropicRequestError {
209                    kind: AnthropicRequestErrorKind::AmbiguousToolName,
210                });
211            }
212            tools.push(json!({
213                "name": name,
214                "description": tool.description,
215                "input_schema": tool.parameters,
216            }));
217        }
218        object.insert("tools".to_owned(), Value::Array(tools));
219    }
220    object.insert("messages".to_owned(), Value::Array(build_messages(request)));
221    Ok(Value::Object(object))
222}
223
224fn build_system(request: &CanonicalRequest) -> String {
225    request
226        .input
227        .iter()
228        .filter_map(|item| match item {
229            InputItem::Message {
230                role: MessageRole::Developer | MessageRole::System,
231                content,
232            } => Some(content),
233            _ => None,
234        })
235        .flat_map(|content| {
236            content.iter().filter_map(|block| match block {
237                ContentBlock::Text { text } => Some(text.as_str()),
238                ContentBlock::ImageReference { .. } => None,
239            })
240        })
241        .collect::<Vec<_>>()
242        .join("\n")
243}
244
245fn build_messages(request: &CanonicalRequest) -> Vec<Value> {
246    request
247        .input
248        .iter()
249        .map(|item| match item {
250            InputItem::Message {
251                role: MessageRole::User,
252                content,
253            } => json!({
254                "role": "user",
255                "content": anthropic_content(content),
256            }),
257            InputItem::Message {
258                role: MessageRole::Assistant,
259                content,
260            } => json!({
261                "role": "assistant",
262                "content": anthropic_content(content),
263            }),
264            InputItem::Message {
265                role: MessageRole::Developer | MessageRole::System,
266                ..
267            } => json!({
268                "role": "user",
269                "content": [],
270            }),
271            InputItem::FunctionCall {
272                call_id,
273                namespace,
274                name,
275                arguments,
276            } => json!({
277                "role": "assistant",
278                "content": [{
279                    "type": "tool_use",
280                    "id": call_id.as_str(),
281                    "name": provider_tool_name(namespace.as_deref(), name),
282                    "input": arguments,
283                }]
284            }),
285            InputItem::FunctionCallOutput { call_id, output } => json!({
286                "role": "user",
287                "content": [{
288                    "type": "tool_result",
289                    "tool_use_id": call_id.as_str(),
290                    "content": output.to_string(),
291                }]
292            }),
293        })
294        .collect()
295}
296
297fn anthropic_content(content: &[ContentBlock]) -> Value {
298    if content
299        .iter()
300        .all(|block| matches!(block, ContentBlock::Text { .. }))
301    {
302        let text = content
303            .iter()
304            .filter_map(|block| match block {
305                ContentBlock::Text { text } => Some(text.as_str()),
306                ContentBlock::ImageReference { .. } => None,
307            })
308            .collect::<String>();
309        return Value::String(text);
310    }
311    Value::Array(
312        content
313            .iter()
314            .filter_map(|block| match block {
315                ContentBlock::Text { text } => Some(json!({"type": "text", "text": text})),
316                ContentBlock::ImageReference { .. } => {
317                    // The Anthropic Messages API image sources require base64
318                    // data, which the canonical request does not carry. Images
319                    // are omitted from the wire body; text blocks still flow.
320                    None
321                }
322            })
323            .collect(),
324    )
325}
326
327fn provider_tool_name(namespace: Option<&str>, name: &str) -> String {
328    namespace.map_or_else(
329        || name.to_owned(),
330        |namespace| format!("{namespace}__{name}"),
331    )
332}
333
334fn anthropic_tool_choice(request: &CanonicalRequest) -> Value {
335    match &request.tool_choice {
336        ToolChoice::Auto => Value::String("auto".to_owned()),
337        ToolChoice::None => Value::String("none".to_owned()),
338        ToolChoice::Required => Value::String("any".to_owned()),
339        ToolChoice::Named(name) => {
340            let provider_name = request
341                .tools
342                .iter()
343                .find(|tool| tool.name == *name)
344                .map_or_else(
345                    || name.clone(),
346                    |tool| provider_tool_name(tool.namespace.as_deref(), &tool.name),
347                );
348            json!({"type": "tool", "name": provider_name})
349        }
350    }
351}
352
353#[derive(Clone, Debug, PartialEq, Eq)]
354pub struct AnthropicUsage {
355    pub input_tokens: u64,
356    pub output_tokens: u64,
357}
358
359#[derive(Clone, Debug, PartialEq, Eq)]
360pub enum AnthropicStreamEvent {
361    ContentDelta { text: String },
362    FinishReason { reason: String },
363    Usage(AnthropicUsage),
364    Done,
365}
366
367#[derive(Clone, Copy, Debug, PartialEq, Eq)]
368pub enum AnthropicStreamErrorKind {
369    InvalidUtf8,
370    InvalidJson,
371    InvalidFrame,
372    EventTooLarge,
373    MissingTerminal,
374}
375
376#[derive(Clone, Copy, Debug, PartialEq, Eq)]
377pub struct AnthropicStreamError {
378    pub kind: AnthropicStreamErrorKind,
379}
380
381impl fmt::Display for AnthropicStreamError {
382    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
383        formatter.write_str("the Anthropic stream could not be normalized")
384    }
385}
386
387impl std::error::Error for AnthropicStreamError {}
388
389#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
390pub struct AnthropicReplayGuard {
391    boundary: Option<ReplayBoundary>,
392    body_written: bool,
393}
394
395impl AnthropicReplayGuard {
396    pub fn mark_body_written(&mut self) {
397        self.body_written = true;
398    }
399
400    pub fn observe(&mut self, event: &AnthropicStreamEvent) {
401        match event {
402            AnthropicStreamEvent::ContentDelta { text } if !text.is_empty() => {
403                self.cross(ReplayBoundary::PublicContent);
404            }
405            AnthropicStreamEvent::ContentDelta { .. }
406            | AnthropicStreamEvent::FinishReason { .. }
407            | AnthropicStreamEvent::Usage(_)
408            | AnthropicStreamEvent::Done => {}
409        }
410    }
411
412    pub fn mark_transport_outcome_unknown(&mut self) {
413        if self.body_written {
414            self.cross(ReplayBoundary::AmbiguousDispatch);
415        }
416    }
417
418    pub fn decision(&self) -> ReplayDecision {
419        self.boundary
420            .map_or(ReplayDecision::Allowed, ReplayDecision::Denied)
421    }
422
423    fn cross(&mut self, boundary: ReplayBoundary) {
424        if self.boundary.is_none() {
425            self.boundary = Some(boundary);
426        }
427    }
428}
429
430/// Minimal Anthropic Messages SSE parser. Consumes the documented event stream
431/// (`message_start`, `content_block_delta` with `text_delta`,
432/// `message_delta` with `stop_reason`/`usage`, `message_stop`). Tool-use
433/// streaming (`tool_use` blocks) is not yet normalized for these phases; a
434/// text-only response normalizes fully and the terminal is required exactly
435/// once.
436pub struct AnthropicSseParser {
437    _attempt_id: AttemptId,
438    replay: AnthropicReplayGuard,
439    terminal: bool,
440    input_tokens: u64,
441    output_tokens: u64,
442}
443
444impl AnthropicSseParser {
445    pub fn new(attempt_id: &AttemptId) -> Self {
446        Self {
447            _attempt_id: attempt_id.clone(),
448            replay: AnthropicReplayGuard::default(),
449            terminal: false,
450            input_tokens: 0,
451            output_tokens: 0,
452        }
453    }
454
455    pub fn replay_decision(&self) -> ReplayDecision {
456        self.replay.decision()
457    }
458
459    pub fn mark_body_written(&mut self) {
460        self.replay.mark_body_written();
461    }
462
463    pub fn mark_transport_outcome_unknown(&mut self) {
464        self.replay.mark_transport_outcome_unknown();
465    }
466
467    pub fn feed(
468        &mut self,
469        chunk: &[u8],
470    ) -> Result<Vec<AnthropicStreamEvent>, AnthropicStreamError> {
471        let mut events = Vec::new();
472        for frame in split_sse_frames(chunk)? {
473            let value: Value =
474                serde_json::from_slice(&frame).map_err(|_| AnthropicStreamError {
475                    kind: AnthropicStreamErrorKind::InvalidJson,
476                })?;
477            events.extend(self.observe_json(value)?);
478        }
479        for event in &events {
480            self.replay.observe(event);
481        }
482        Ok(events)
483    }
484
485    pub fn finish(&mut self) -> Result<Vec<AnthropicStreamEvent>, AnthropicStreamError> {
486        if !self.terminal {
487            return Err(AnthropicStreamError {
488                kind: AnthropicStreamErrorKind::MissingTerminal,
489            });
490        }
491        Ok(Vec::new())
492    }
493
494    fn observe_json(
495        &mut self,
496        value: Value,
497    ) -> Result<Vec<AnthropicStreamEvent>, AnthropicStreamError> {
498        let event_type = value.get("type").and_then(Value::as_str);
499        let mut events = Vec::new();
500        match event_type {
501            Some("message_start") => {
502                if let Some(input) = value
503                    .pointer("/message/usage/input_tokens")
504                    .and_then(Value::as_u64)
505                {
506                    self.input_tokens = input;
507                }
508            }
509            Some("content_block_delta") => {
510                match value.pointer("/delta/type").and_then(Value::as_str) {
511                    Some("text_delta") => {
512                        if let Some(text) = value.pointer("/delta/text").and_then(Value::as_str) {
513                            events.push(AnthropicStreamEvent::ContentDelta {
514                                text: text.to_owned(),
515                            });
516                        }
517                    }
518                    // Tool-use `input_json_delta` streaming is not yet
519                    // normalized; the block is skipped.
520                    Some(_) => {}
521                    None => {
522                        return Err(AnthropicStreamError {
523                            kind: AnthropicStreamErrorKind::InvalidFrame,
524                        });
525                    }
526                }
527            }
528            Some("message_delta") => {
529                if let Some(reason) = value.pointer("/delta/stop_reason").and_then(Value::as_str) {
530                    events.push(AnthropicStreamEvent::FinishReason {
531                        reason: reason.to_owned(),
532                    });
533                }
534                if let Some(output) = value
535                    .pointer("/usage/output_tokens")
536                    .and_then(Value::as_u64)
537                {
538                    self.output_tokens = output;
539                }
540            }
541            Some("message_stop") => {
542                self.terminal = true;
543                events.push(AnthropicStreamEvent::Usage(AnthropicUsage {
544                    input_tokens: self.input_tokens,
545                    output_tokens: self.output_tokens,
546                }));
547                events.push(AnthropicStreamEvent::Done);
548            }
549            Some("ping") | Some("content_block_start") | Some("content_block_stop") => {}
550            _ => {
551                return Err(AnthropicStreamError {
552                    kind: AnthropicStreamErrorKind::InvalidFrame,
553                });
554            }
555        }
556        Ok(events)
557    }
558}
559
560/// Split an SSE byte buffer into one or more `data:` payload frames. Each
561/// frame is the concatenation of the `data:` lines of one SSE event, without
562/// the `data: ` prefix. Malformed framing (a non-`data:` non-empty line before
563/// an event) is rejected.
564fn split_sse_frames(buffer: &[u8]) -> Result<Vec<Vec<u8>>, AnthropicStreamError> {
565    let text = std::str::from_utf8(buffer).map_err(|_| AnthropicStreamError {
566        kind: AnthropicStreamErrorKind::InvalidUtf8,
567    })?;
568    let mut frames = Vec::new();
569    let mut current = Vec::new();
570    for line in text.split('\n') {
571        let line = line.strip_suffix('\r').unwrap_or(line);
572        if line.is_empty() {
573            if !current.is_empty() {
574                frames.push(std::mem::take(&mut current));
575            }
576            continue;
577        }
578        if let Some(data) = line.strip_prefix("data:") {
579            let data = data.strip_prefix(' ').unwrap_or(data);
580            if !current.is_empty() {
581                current.push(b'\n');
582            }
583            current.extend_from_slice(data.as_bytes());
584        }
585        // Non-`data:` lines (event:, id:) are metadata and are skipped.
586    }
587    if !current.is_empty() {
588        frames.push(current);
589    }
590    if frames.is_empty() {
591        return Err(AnthropicStreamError {
592            kind: AnthropicStreamErrorKind::InvalidFrame,
593        });
594    }
595    Ok(frames)
596}
597
598use router_protocol::AttemptId;
599
600#[derive(Clone, Copy, Debug, PartialEq, Eq)]
601pub enum AnthropicFailureClass {
602    InvalidRequest,
603    Authentication,
604    PaymentRequired,
605    Authorization,
606    NotFound,
607    Timeout,
608    Unprocessable,
609    RateLimited,
610    UpstreamServer,
611    UnexpectedStatus,
612    MismatchedContentType,
613    RedirectRejected,
614    Cancelled,
615    TransportTimeout,
616    AmbiguousDispatch,
617}
618
619#[derive(Clone, Copy, Debug, PartialEq, Eq)]
620pub struct AnthropicFailure {
621    pub class: AnthropicFailureClass,
622    pub status: Option<u16>,
623    pub retry_after: Option<crate::ollama_chat::BoundedRetryAfter>,
624}
625
626#[derive(Clone, Copy, Debug, PartialEq, Eq)]
627pub enum AnthropicBodyWriteState {
628    NotWritten,
629    Written,
630    Unknown,
631}
632
633#[derive(Clone, Copy, Debug, PartialEq, Eq)]
634pub enum AnthropicTransportFailureSignal {
635    Cancelled,
636    Timeout,
637}
638
639/// Maps a non-2xx/redirected HTTP response to the failure class. The
640/// Anthropic Messages protocol is SSE from `POST /v1/messages`; a 200 with any
641/// other media type is rejected.
642pub fn normalize_http_response(
643    status: u16,
644    content_type: Option<&str>,
645    retry_after: Option<&str>,
646    redirected: bool,
647) -> Result<(), AnthropicFailure> {
648    if redirected || (300..400).contains(&status) {
649        return Err(anthropic_failure(
650            AnthropicFailureClass::RedirectRejected,
651            Some(status),
652            retry_after,
653        ));
654    }
655    if status != 200 {
656        let class = match status {
657            400 => AnthropicFailureClass::InvalidRequest,
658            401 => AnthropicFailureClass::Authentication,
659            402 => AnthropicFailureClass::PaymentRequired,
660            403 => AnthropicFailureClass::Authorization,
661            404 => AnthropicFailureClass::NotFound,
662            408 => AnthropicFailureClass::Timeout,
663            422 => AnthropicFailureClass::Unprocessable,
664            429 => AnthropicFailureClass::RateLimited,
665            500..=599 => AnthropicFailureClass::UpstreamServer,
666            _ => AnthropicFailureClass::UnexpectedStatus,
667        };
668        return Err(anthropic_failure(class, Some(status), retry_after));
669    }
670    let media_type = content_type
671        .and_then(|value| value.split(';').next())
672        .map(str::trim);
673    if media_type != Some("text/event-stream") {
674        return Err(anthropic_failure(
675            AnthropicFailureClass::MismatchedContentType,
676            Some(status),
677            None,
678        ));
679    }
680    Ok(())
681}
682
683pub fn normalize_transport_failure(
684    signal: AnthropicTransportFailureSignal,
685    body_write: AnthropicBodyWriteState,
686) -> AnthropicFailure {
687    let class = match body_write {
688        AnthropicBodyWriteState::Written | AnthropicBodyWriteState::Unknown => {
689            AnthropicFailureClass::AmbiguousDispatch
690        }
691        AnthropicBodyWriteState::NotWritten => match signal {
692            AnthropicTransportFailureSignal::Cancelled => AnthropicFailureClass::Cancelled,
693            AnthropicTransportFailureSignal::Timeout => AnthropicFailureClass::TransportTimeout,
694        },
695    };
696    anthropic_failure(class, None, None)
697}
698
699fn anthropic_failure(
700    class: AnthropicFailureClass,
701    status: Option<u16>,
702    retry_after: Option<&str>,
703) -> AnthropicFailure {
704    AnthropicFailure {
705        class,
706        status,
707        retry_after: crate::ollama_chat::BoundedRetryAfter::parse(retry_after),
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use router_protocol::{
715        BillingAuthorityClass, CapabilityRequirements, CatalogSourceKind, CredentialSourceKind,
716        EndpointIdentity, ExecutionLocation, FunctionTool, HarnessInstanceId, M1_WIRE_VERSION,
717        MessageRole, ModelNamespace, PhaseKind, PhasePlacement, ProviderService, ReasoningIntent,
718        SessionId, TurnId,
719    };
720
721    fn claude_route(phase: PhaseKind, billing: BillingAuthorityClass) -> RouteIdentity {
722        RouteIdentity {
723            phase,
724            placement: PhasePlacement::Builtin,
725            provider: ProviderService::Claude,
726            transport: TransportKind::DirectHttps,
727            endpoint: EndpointIdentity {
728                origin: router_protocol::ANTHROPIC_ORIGIN.to_owned(),
729                path: router_protocol::ANTHROPIC_CHAT_PATH.to_owned(),
730            },
731            credential_source: CredentialSourceKind::SecretRef,
732            credential_ref: Some("env:STEADYMUX_CLAUDE_KEY".to_owned()),
733            credential_refs: vec!["env:STEADYMUX_CLAUDE_KEY".to_owned()],
734            catalog_source: CatalogSourceKind::ClaudeCatalog,
735            catalog_ref: "catalog:claude:verified".to_owned(),
736            billing_authority: billing,
737            model_namespace: ModelNamespace::Claude,
738            model_id: "claude-sonnet-4-5".to_owned(),
739            execution_location: ExecutionLocation::RemoteCloud,
740            account_ref: "account:claude".to_owned(),
741            capacity_domain_ref: "capacity:claude".to_owned(),
742            local_execution_proof: false,
743            approved_local_inventory: Vec::new(),
744            reasoning_modes: Vec::new(),
745        }
746    }
747
748    fn request(prompt: &str) -> CanonicalRequest {
749        CanonicalRequest {
750            wire_version: M1_WIRE_VERSION.to_owned(),
751            model: "router-auto".to_owned(),
752            stream: true,
753            harness_instance: HarnessInstanceId::parse("fixture-cli").expect("fixture"),
754            session_id: SessionId::parse("session-claude").expect("fixture"),
755            turn_id: TurnId::parse("turn-claude").expect("fixture"),
756            input: vec![InputItem::Message {
757                role: MessageRole::User,
758                content: vec![ContentBlock::Text {
759                    text: prompt.to_owned(),
760                }],
761            }],
762            tools: vec![FunctionTool {
763                namespace: Some("weather".to_owned()),
764                name: "lookup".to_owned(),
765                description: "lookup weather".to_owned(),
766                parameters: json!({"type":"object"}),
767                strict: true,
768            }],
769            tool_choice: ToolChoice::Auto,
770            structured_output: None,
771            reasoning: ReasoningIntent::None,
772            max_output_tokens: Some(64),
773            max_spend_micro_usd: None,
774            capabilities: CapabilityRequirements::default(),
775        }
776    }
777
778    #[test]
779    fn claude_routes_build_verified_anthropic_wire_shape() {
780        // PRD-001y AC-05: the Claude phases build an Anthropic Messages request
781        // against `https://api.anthropic.com/v1/messages` with the verified
782        // `x-api-key` auth scheme and the pinned `anthropic-version` header.
783        for (phase, billing) in [
784            (
785                PhaseKind::ClaudeSubscription,
786                BillingAuthorityClass::ClaudeSubscription,
787            ),
788            (
789                PhaseKind::ClaudeApi,
790                BillingAuthorityClass::ClaudeMeteredApi,
791            ),
792        ] {
793            let route = claude_route(phase, billing);
794            route.validate().expect("valid claude route");
795            let spec = build_request(&route, &request("claude-canary")).expect("request");
796            assert_eq!(spec.method, HttpMethod::Post);
797            assert_eq!(spec.origin, router_protocol::ANTHROPIC_ORIGIN);
798            assert_eq!(spec.path, router_protocol::ANTHROPIC_CHAT_PATH);
799            assert_eq!(spec.accept, "text/event-stream");
800            assert_eq!(spec.version_header, "2023-06-01");
801            assert_eq!(spec.api_key_secret.as_str(), "env:STEADYMUX_CLAUDE_KEY");
802            assert!(spec.network.require_tls);
803            assert!(!spec.network.follow_redirects);
804            assert!(!spec.network.allow_proxy);
805            assert_eq!(spec.body()["model"], json!("claude-sonnet-4-5"));
806            assert_eq!(spec.body()["max_tokens"], json!(64));
807            assert_eq!(spec.body()["stream"], json!(true));
808            assert_eq!(
809                spec.body()["messages"][0]["content"],
810                json!("claude-canary")
811            );
812            assert_eq!(spec.body()["tools"][0]["name"], json!("weather__lookup"));
813            assert_eq!(
814                spec.body()["tools"][0]["input_schema"],
815                json!({"type": "object"})
816            );
817            assert_eq!(spec.body()["tool_choice"], json!("auto"));
818        }
819    }
820
821    #[test]
822    fn claude_adapter_has_no_hardcoded_model_id_and_exposes_projection() {
823        // PRD-001y AC-07: model ids are catalog driven, never hardcoded in the
824        // adapter, and the adapter exposes the per-route MAv2 capability
825        // projection.
826        let adapter_core = include_str!("anthropic_chat.rs")
827            .split("#[cfg(test)]")
828            .next()
829            .expect("adapter body precedes the test module");
830        for model_literal in [
831            concat!("claude", "-sonnet"),
832            concat!("claude", "-opus"),
833            concat!("claude", "-haiku"),
834        ] {
835            assert!(
836                !adapter_core.contains(model_literal),
837                "adapter hardcodes the model literal {model_literal}"
838            );
839        }
840        assert!(is_claude_phase(PhaseKind::ClaudeSubscription));
841        assert!(is_claude_phase(PhaseKind::ClaudeApi));
842        assert!(!is_claude_phase(PhaseKind::DeepseekApi));
843        assert!(!capability_projection().namespace_tools);
844        assert_eq!(
845            PhaseKind::ClaudeApi.multi_agent_v2_projection(),
846            capability_projection()
847        );
848    }
849
850    #[test]
851    fn anthropic_sse_parses_text_finish_and_usage_once() {
852        let transcript = concat!(
853            "event: message_start\n",
854            "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n",
855            "event: content_block_delta\n",
856            "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello \"}}\n\n",
857            "event: content_block_delta\n",
858            "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"world\"}}\n\n",
859            "event: message_delta\n",
860            "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":7}}\n\n",
861            "event: message_stop\n",
862            "data: {\"type\":\"message_stop\"}\n\n",
863        );
864        let attempt = AttemptId::parse("attempt-anthropic-1").expect("attempt");
865        let mut parser = AnthropicSseParser::new(&attempt);
866        let events = parser
867            .feed(transcript.as_bytes())
868            .expect("feed anthropic stream");
869        assert_eq!(
870            events,
871            vec![
872                AnthropicStreamEvent::ContentDelta {
873                    text: "hello ".to_owned()
874                },
875                AnthropicStreamEvent::ContentDelta {
876                    text: "world".to_owned()
877                },
878                AnthropicStreamEvent::FinishReason {
879                    reason: "end_turn".to_owned()
880                },
881                AnthropicStreamEvent::Usage(AnthropicUsage {
882                    input_tokens: 12,
883                    output_tokens: 7,
884                }),
885                AnthropicStreamEvent::Done,
886            ]
887        );
888        assert_eq!(parser.finish(), Ok(vec![]));
889        assert!(parser.replay_decision() != ReplayDecision::Allowed);
890    }
891
892    #[test]
893    fn anthropic_stream_requires_terminal() {
894        let attempt = AttemptId::parse("attempt-anthropic-2").expect("attempt");
895        let mut parser = AnthropicSseParser::new(&attempt);
896        parser
897            .feed(
898                b"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\n",
899            )
900            .expect("feed");
901        assert_eq!(
902            parser.finish(),
903            Err(AnthropicStreamError {
904                kind: AnthropicStreamErrorKind::MissingTerminal
905            })
906        );
907    }
908}