1use 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
27pub const ANTHROPIC_VERSION: &str = router_protocol::ANTHROPIC_VERSION;
30pub const ANTHROPIC_API_KEY_HEADER: &str = router_protocol::ANTHROPIC_API_KEY_HEADER;
32pub const ANTHROPIC_VERSION_HEADER: &str = router_protocol::ANTHROPIC_VERSION_HEADER;
34pub const DEFAULT_MAX_TOKENS: u32 = 1024;
37
38pub const fn is_claude_phase(phase: PhaseKind) -> bool {
40 matches!(phase, PhaseKind::ClaudeSubscription | PhaseKind::ClaudeApi)
41}
42
43pub 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 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 let system = build_system(request);
194 if !system.is_empty() {
195 object.insert("system".to_owned(), Value::String(system));
196 }
197
198 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 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
430pub 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 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
560fn 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 }
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
639pub 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 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 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}