Skip to main content

steer_core/api/claude/
client.rs

1use async_trait::async_trait;
2use futures_util::StreamExt;
3use reqwest::{self, header};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use strum_macros::Display;
7use tokio_util::sync::CancellationToken;
8use tracing::{debug, warn};
9
10use crate::api::error::StreamError;
11use crate::api::provider::{CompletionStream, StreamChunk, TokenUsage};
12use crate::api::sse::parse_sse_stream;
13use crate::api::{CompletionResponse, Provider, error::ApiError};
14use crate::app::SystemContext;
15use crate::app::conversation::{
16    AssistantContent, ImageSource, Message as AppMessage, ThoughtContent, ToolResult, UserContent,
17};
18use crate::auth::{
19    AnthropicAuth, AuthErrorAction, AuthErrorContext, AuthHeaderContext, InstructionPolicy,
20    RequestKind,
21};
22use crate::auth::{ModelId as AuthModelId, ProviderId as AuthProviderId};
23use crate::config::model::{ModelId, ModelParameters};
24use steer_tools::{InputSchema, ToolCall, ToolSchema};
25
26const API_URL: &str = "https://api.anthropic.com/v1/messages";
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Display)]
29pub enum ClaudeMessageRole {
30    #[serde(rename = "user")]
31    #[strum(serialize = "user")]
32    User,
33    #[serde(rename = "assistant")]
34    #[strum(serialize = "assistant")]
35    Assistant,
36    #[serde(rename = "tool")]
37    #[strum(serialize = "tool")]
38    Tool,
39}
40
41/// Represents a message to be sent to the Claude API
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct ClaudeMessage {
44    pub role: ClaudeMessageRole,
45    #[serde(flatten)]
46    pub content: ClaudeMessageContent,
47    #[serde(skip_serializing)]
48    pub id: Option<String>,
49}
50
51/// Content types for Claude API messages
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53#[serde(untagged)]
54pub enum ClaudeMessageContent {
55    /// Simple text content
56    Text { content: String },
57    /// Structured content for tool results or other special content
58    StructuredContent { content: ClaudeStructuredContent },
59}
60
61/// Represents structured content blocks for messages
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63#[serde(transparent)]
64pub struct ClaudeStructuredContent(pub Vec<ClaudeContentBlock>);
65
66#[derive(Clone)]
67enum AuthMode {
68    ApiKey(String),
69    Directive(AnthropicAuth),
70}
71
72#[derive(Clone)]
73pub struct AnthropicClient {
74    http_client: reqwest::Client,
75    auth: AuthMode,
76}
77
78#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
79#[serde(rename_all = "lowercase")]
80#[derive(Default)]
81enum ThinkingType {
82    #[default]
83    Enabled,
84}
85
86#[derive(Debug, Serialize, Deserialize, Clone)]
87struct Thinking {
88    #[serde(rename = "type", default)]
89    thinking_type: ThinkingType,
90    budget_tokens: u32,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
94pub struct ClaudeImageSource {
95    #[serde(rename = "type")]
96    source_type: String,
97    media_type: String,
98    data: String,
99}
100
101#[derive(Debug, Serialize, Clone)]
102struct SystemContentBlock {
103    #[serde(rename = "type")]
104    content_type: String,
105    text: String,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    cache_control: Option<CacheControl>,
108}
109
110#[derive(Debug, Serialize, Clone)]
111#[serde(untagged)]
112enum System {
113    // Structured system prompt represented as a list of content blocks
114    Content(Vec<SystemContentBlock>),
115}
116
117#[derive(Debug, Serialize)]
118struct CompletionRequest {
119    model: String,
120    messages: Vec<ClaudeMessage>,
121    max_tokens: usize,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    system: Option<System>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    tools: Option<Vec<ClaudeTool>>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    temperature: Option<f32>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    top_p: Option<f32>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    top_k: Option<usize>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    stream: Option<bool>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    thinking: Option<Thinking>,
136}
137
138#[derive(Debug, Serialize, Clone)]
139struct ClaudeTool {
140    name: String,
141    description: String,
142    input_schema: InputSchema,
143}
144
145impl From<ToolSchema> for ClaudeTool {
146    fn from(tool: ToolSchema) -> Self {
147        let tool = adapt_tool_schema_for_claude(tool);
148        Self {
149            name: tool.name,
150            description: tool.description,
151            input_schema: tool.input_schema,
152        }
153    }
154}
155
156#[derive(Debug, Serialize, Deserialize, Clone)]
157struct ClaudeCompletionResponse {
158    id: String,
159    content: Vec<ClaudeContentBlock>,
160    model: String,
161    role: String,
162    #[serde(default)]
163    stop_reason: Option<String>,
164    #[serde(default)]
165    stop_sequence: Option<String>,
166    #[serde(default)]
167    usage: ClaudeUsage,
168    // Allow other fields for API flexibility
169    #[serde(flatten)]
170    extra: std::collections::HashMap<String, serde_json::Value>,
171}
172
173fn adapt_tool_schema_for_claude(tool: ToolSchema) -> ToolSchema {
174    let root_schema = tool.input_schema.as_value();
175    let sanitized = sanitize_for_claude(root_schema, root_schema);
176    ToolSchema {
177        input_schema: InputSchema::new(sanitized),
178        ..tool
179    }
180}
181
182fn decode_pointer_segment(segment: &str) -> std::borrow::Cow<'_, str> {
183    if !segment.contains('~') {
184        return std::borrow::Cow::Borrowed(segment);
185    }
186    std::borrow::Cow::Owned(segment.replace("~1", "/").replace("~0", "~"))
187}
188
189fn resolve_ref<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
190    let path = reference.strip_prefix("#/")?;
191    let mut current = root;
192    for segment in path.split('/') {
193        let decoded = decode_pointer_segment(segment);
194        current = current.get(decoded.as_ref())?;
195    }
196    Some(current)
197}
198
199fn infer_type_from_enum(values: &[Value]) -> Option<String> {
200    let mut has_string = false;
201    let mut has_number = false;
202    let mut has_bool = false;
203    let mut has_object = false;
204    let mut has_array = false;
205
206    for value in values {
207        match value {
208            Value::String(_) => has_string = true,
209            Value::Number(_) => has_number = true,
210            Value::Bool(_) => has_bool = true,
211            Value::Object(_) => has_object = true,
212            Value::Array(_) => has_array = true,
213            Value::Null => {}
214        }
215    }
216
217    let kind_count = u8::from(has_string)
218        + u8::from(has_number)
219        + u8::from(has_bool)
220        + u8::from(has_object)
221        + u8::from(has_array);
222
223    if kind_count != 1 {
224        return None;
225    }
226
227    if has_string {
228        Some("string".to_string())
229    } else if has_number {
230        Some("number".to_string())
231    } else if has_bool {
232        Some("boolean".to_string())
233    } else if has_object {
234        Some("object".to_string())
235    } else if has_array {
236        Some("array".to_string())
237    } else {
238        None
239    }
240}
241
242fn normalize_type(value: &Value) -> Value {
243    if let Some(type_str) = value.as_str() {
244        return Value::String(type_str.to_string());
245    }
246
247    if let Some(type_array) = value.as_array()
248        && let Some(primary_type) = type_array
249            .iter()
250            .find_map(|v| if v.is_null() { None } else { v.as_str() })
251    {
252        return Value::String(primary_type.to_string());
253    }
254
255    Value::String("string".to_string())
256}
257
258fn extract_enum_values(value: &Value) -> Vec<Value> {
259    let Some(obj) = value.as_object() else {
260        return Vec::new();
261    };
262
263    if let Some(enum_values) = obj.get("enum").and_then(|v| v.as_array()) {
264        return enum_values
265            .iter()
266            .filter(|v| !v.is_null())
267            .cloned()
268            .collect();
269    }
270
271    if let Some(const_value) = obj.get("const") {
272        if const_value.is_null() {
273            return Vec::new();
274        }
275        return vec![const_value.clone()];
276    }
277
278    Vec::new()
279}
280
281fn merge_property(properties: &mut serde_json::Map<String, Value>, key: &str, value: &Value) {
282    match properties.get_mut(key) {
283        None => {
284            properties.insert(key.to_string(), value.clone());
285        }
286        Some(existing) => {
287            if existing == value {
288                return;
289            }
290
291            let existing_values = extract_enum_values(existing);
292            let incoming_values = extract_enum_values(value);
293            if incoming_values.is_empty() && existing_values.is_empty() {
294                return;
295            }
296
297            let mut combined = existing_values;
298            for item in incoming_values {
299                if !combined.contains(&item) {
300                    combined.push(item);
301                }
302            }
303
304            if combined.is_empty() {
305                return;
306            }
307
308            if let Some(obj) = existing.as_object_mut() {
309                obj.remove("const");
310                obj.insert("enum".to_string(), Value::Array(combined.clone()));
311                if !obj.contains_key("type")
312                    && let Some(inferred) = infer_type_from_enum(&combined)
313                {
314                    obj.insert("type".to_string(), Value::String(inferred));
315                }
316            }
317        }
318    }
319}
320
321fn merge_union_schemas(
322    root: &Value,
323    variants: &[Value],
324    seen_refs: &mut std::collections::HashSet<String>,
325) -> Value {
326    let mut merged_props = serde_json::Map::new();
327    let mut required_intersection: Option<std::collections::BTreeSet<String>> = None;
328    let mut enum_values: Vec<Value> = Vec::new();
329    let mut type_candidates: Vec<String> = Vec::new();
330
331    for variant in variants {
332        let sanitized = sanitize_for_claude_inner(root, variant, seen_refs);
333
334        if let Some(schema_type) = sanitized.get("type").and_then(|v| v.as_str()) {
335            type_candidates.push(schema_type.to_string());
336        }
337
338        if let Some(props) = sanitized.get("properties").and_then(|v| v.as_object()) {
339            for (key, value) in props {
340                merge_property(&mut merged_props, key, value);
341            }
342        }
343
344        if let Some(req) = sanitized.get("required").and_then(|v| v.as_array()) {
345            let req_set: std::collections::BTreeSet<String> = req
346                .iter()
347                .filter_map(|item| item.as_str().map(|s| s.to_string()))
348                .collect();
349
350            required_intersection = match required_intersection.take() {
351                None => Some(req_set),
352                Some(existing) => Some(
353                    existing
354                        .intersection(&req_set)
355                        .cloned()
356                        .collect::<std::collections::BTreeSet<String>>(),
357                ),
358            };
359        }
360
361        if let Some(values) = sanitized.get("enum").and_then(|v| v.as_array()) {
362            for value in values {
363                if value.is_null() {
364                    continue;
365                }
366                if !enum_values.contains(value) {
367                    enum_values.push(value.clone());
368                }
369            }
370        }
371    }
372
373    let schema_type = if !merged_props.is_empty() {
374        "object".to_string()
375    } else if let Some(inferred) = infer_type_from_enum(&enum_values) {
376        inferred
377    } else if let Some(first) = type_candidates.first() {
378        first.clone()
379    } else {
380        "string".to_string()
381    };
382
383    let mut merged = serde_json::Map::new();
384    merged.insert("type".to_string(), Value::String(schema_type));
385
386    if !merged_props.is_empty() {
387        merged.insert("properties".to_string(), Value::Object(merged_props));
388    }
389
390    if let Some(required_set) = required_intersection
391        && !required_set.is_empty()
392    {
393        merged.insert(
394            "required".to_string(),
395            Value::Array(
396                required_set
397                    .into_iter()
398                    .map(Value::String)
399                    .collect::<Vec<_>>(),
400            ),
401        );
402    }
403
404    if !enum_values.is_empty() {
405        merged.insert("enum".to_string(), Value::Array(enum_values));
406    }
407
408    Value::Object(merged)
409}
410
411fn sanitize_for_claude(root: &Value, schema: &Value) -> Value {
412    let mut seen_refs = std::collections::HashSet::new();
413    sanitize_for_claude_inner(root, schema, &mut seen_refs)
414}
415
416fn fallback_schema() -> Value {
417    let mut out = serde_json::Map::new();
418    out.insert("type".to_string(), Value::String("object".to_string()));
419    out.insert(
420        "properties".to_string(),
421        Value::Object(serde_json::Map::new()),
422    );
423    Value::Object(out)
424}
425
426fn sanitize_for_claude_inner(
427    root: &Value,
428    schema: &Value,
429    seen_refs: &mut std::collections::HashSet<String>,
430) -> Value {
431    if let Some(reference) = schema.get("$ref").and_then(|v| v.as_str()) {
432        if !seen_refs.insert(reference.to_string()) {
433            return fallback_schema();
434        }
435        if let Some(resolved) = resolve_ref(root, reference) {
436            let sanitized = sanitize_for_claude_inner(root, resolved, seen_refs);
437            seen_refs.remove(reference);
438            return sanitized;
439        }
440        seen_refs.remove(reference);
441    }
442
443    let Some(obj) = schema.as_object() else {
444        return schema.clone();
445    };
446
447    if let Some(union) = obj
448        .get("oneOf")
449        .or_else(|| obj.get("anyOf"))
450        .or_else(|| obj.get("allOf"))
451        .and_then(|v| v.as_array())
452    {
453        return merge_union_schemas(root, union, seen_refs);
454    }
455
456    let mut out = serde_json::Map::new();
457    for (key, value) in obj {
458        match key.as_str() {
459            "$ref"
460            | "$defs"
461            | "oneOf"
462            | "anyOf"
463            | "allOf"
464            | "const"
465            | "additionalProperties"
466            | "default"
467            | "examples"
468            | "title"
469            | "pattern"
470            | "minLength"
471            | "maxLength"
472            | "minimum"
473            | "maximum"
474            | "minItems"
475            | "maxItems"
476            | "uniqueItems"
477            | "deprecated" => {}
478            "type" => {
479                out.insert("type".to_string(), normalize_type(value));
480            }
481            "properties" => {
482                if let Some(props) = value.as_object() {
483                    let mut sanitized_props = serde_json::Map::new();
484                    for (prop_key, prop_value) in props {
485                        sanitized_props.insert(
486                            prop_key.clone(),
487                            sanitize_for_claude_inner(root, prop_value, seen_refs),
488                        );
489                    }
490                    out.insert("properties".to_string(), Value::Object(sanitized_props));
491                }
492            }
493            "items" => {
494                if let Some(items) = value.as_array() {
495                    let merged = merge_union_schemas(root, items, seen_refs);
496                    out.insert("items".to_string(), merged);
497                } else {
498                    out.insert(
499                        "items".to_string(),
500                        sanitize_for_claude_inner(root, value, seen_refs),
501                    );
502                }
503            }
504            "enum" => {
505                let values = value
506                    .as_array()
507                    .map(|items| {
508                        items
509                            .iter()
510                            .filter(|v| !v.is_null())
511                            .cloned()
512                            .collect::<Vec<_>>()
513                    })
514                    .unwrap_or_default();
515                out.insert("enum".to_string(), Value::Array(values));
516            }
517            _ => {
518                out.insert(
519                    key.clone(),
520                    sanitize_for_claude_inner(root, value, seen_refs),
521                );
522            }
523        }
524    }
525
526    if let Some(const_value) = obj.get("const")
527        && !const_value.is_null()
528    {
529        out.insert("enum".to_string(), Value::Array(vec![const_value.clone()]));
530        if !out.contains_key("type")
531            && let Some(inferred) = infer_type_from_enum(std::slice::from_ref(const_value))
532        {
533            out.insert("type".to_string(), Value::String(inferred));
534        }
535    }
536
537    if out.get("type") == Some(&Value::String("object".to_string()))
538        && !out.contains_key("properties")
539    {
540        out.insert(
541            "properties".to_string(),
542            Value::Object(serde_json::Map::new()),
543        );
544    }
545
546    if !out.contains_key("type") {
547        if out.contains_key("properties") {
548            out.insert("type".to_string(), Value::String("object".to_string()));
549        } else if out.contains_key("items") {
550            out.insert("type".to_string(), Value::String("array".to_string()));
551        } else if let Some(enum_values) = out.get("enum").and_then(|v| v.as_array())
552            && let Some(inferred) = infer_type_from_enum(enum_values)
553        {
554            out.insert("type".to_string(), Value::String(inferred));
555        }
556    }
557
558    Value::Object(out)
559}
560
561fn default_cache_type() -> String {
562    "ephemeral".to_string()
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crate::api::error::SseParseError;
569    use crate::api::sse::{SseEvent, SseStream};
570    use futures_util::{StreamExt, stream};
571    use serde_json::json;
572
573    #[test]
574    fn sanitize_handles_recursive_ref() {
575        let schema = json!({
576            "$defs": {
577                "node": {
578                    "type": "object",
579                    "properties": {
580                        "next": { "$ref": "#/$defs/node" }
581                    }
582                }
583            },
584            "$ref": "#/$defs/node"
585        });
586
587        let sanitized = sanitize_for_claude(&schema, &schema);
588        let next = sanitized
589            .get("properties")
590            .and_then(|v| v.get("next"))
591            .and_then(|v| v.get("type"))
592            .and_then(|v| v.as_str());
593
594        assert_eq!(next, Some("object"));
595    }
596
597    #[test]
598    fn sanitize_collapses_tuple_items() {
599        let schema = json!({
600            "type": "array",
601            "items": [
602                { "type": "string" },
603                { "type": "number" }
604            ]
605        });
606
607        let sanitized = sanitize_for_claude(&schema, &schema);
608        let items = sanitized.get("items");
609
610        assert!(matches!(items, Some(Value::Object(_))));
611    }
612
613    #[test]
614    fn sanitize_removes_unsupported_keywords() {
615        let schema = json!({
616            "type": "object",
617            "title": "ignored",
618            "additionalProperties": false,
619            "properties": {
620                "name": {
621                    "type": "string",
622                    "pattern": "^[a-z]+$",
623                    "default": "x"
624                }
625            }
626        });
627
628        let sanitized = sanitize_for_claude(&schema, &schema);
629        let expected = json!({
630            "type": "object",
631            "properties": {
632                "name": { "type": "string" }
633            }
634        });
635
636        assert_eq!(sanitized, expected);
637    }
638
639    #[test]
640    fn sanitize_converts_const_to_enum_with_type() {
641        let schema = json!({
642            "const": "fixed"
643        });
644
645        let sanitized = sanitize_for_claude(&schema, &schema);
646        let expected = json!({
647            "enum": ["fixed"],
648            "type": "string"
649        });
650
651        assert_eq!(sanitized, expected);
652    }
653
654    #[test]
655    fn sanitize_filters_null_enum_values() {
656        let schema = json!({
657            "enum": ["a", null, "b"]
658        });
659
660        let sanitized = sanitize_for_claude(&schema, &schema);
661        let expected = json!({
662            "enum": ["a", "b"],
663            "type": "string"
664        });
665
666        assert_eq!(sanitized, expected);
667    }
668
669    #[test]
670    fn sanitize_decodes_json_pointer_refs() {
671        let schema = json!({
672            "$defs": {
673                "a/b": { "type": "string" }
674            },
675            "$ref": "#/$defs/a~1b"
676        });
677
678        let sanitized = sanitize_for_claude(&schema, &schema);
679        let expected = json!({
680            "type": "string"
681        });
682
683        assert_eq!(sanitized, expected);
684    }
685
686    #[test]
687    fn sanitize_merges_union_properties_and_required() {
688        let schema = json!({
689            "oneOf": [
690                {
691                    "type": "object",
692                    "properties": {
693                        "a": { "type": "string" },
694                        "b": { "type": "string" }
695                    },
696                    "required": ["a"]
697                },
698                {
699                    "type": "object",
700                    "properties": {
701                        "a": { "type": "string" },
702                        "c": { "type": "string" }
703                    },
704                    "required": ["a", "c"]
705                }
706            ]
707        });
708
709        let sanitized = sanitize_for_claude(&schema, &schema);
710        let expected = json!({
711            "type": "object",
712            "properties": {
713                "a": { "type": "string" },
714                "b": { "type": "string" },
715                "c": { "type": "string" }
716            },
717            "required": ["a"]
718        });
719
720        assert_eq!(sanitized, expected);
721    }
722
723    #[test]
724    fn sanitize_infers_array_type_from_items() {
725        let schema = json!({
726            "items": {
727                "type": "string"
728            }
729        });
730
731        let sanitized = sanitize_for_claude(&schema, &schema);
732        let expected = json!({
733            "type": "array",
734            "items": { "type": "string" }
735        });
736
737        assert_eq!(sanitized, expected);
738    }
739
740    #[test]
741    fn user_image_data_url_to_claude_block() {
742        let image = crate::app::conversation::ImageContent {
743            mime_type: "image/png".to_string(),
744            source: crate::app::conversation::ImageSource::DataUrl {
745                data_url: "data:image/png;base64,Zm9v".to_string(),
746            },
747            width: None,
748            height: None,
749            bytes: None,
750            sha256: None,
751        };
752
753        let block = user_image_to_claude_block(&image).expect("image should convert");
754        let json = serde_json::to_value(block).expect("serialize block");
755        assert_eq!(json["type"], "image");
756        assert_eq!(json["source"]["type"], "base64");
757        assert_eq!(json["source"]["media_type"], "image/png");
758        assert_eq!(json["source"]["data"], "Zm9v");
759    }
760
761    #[test]
762    fn user_image_session_file_source_is_unsupported() {
763        let image = crate::app::conversation::ImageContent {
764            mime_type: "image/png".to_string(),
765            source: crate::app::conversation::ImageSource::SessionFile {
766                relative_path: "session-1/image.png".to_string(),
767            },
768            width: None,
769            height: None,
770            bytes: None,
771            sha256: None,
772        };
773
774        let err = user_image_to_claude_block(&image).expect_err("expected unsupported feature");
775        match err {
776            ApiError::UnsupportedFeature {
777                provider,
778                feature,
779                details,
780            } => {
781                assert_eq!(provider, "anthropic");
782                assert_eq!(feature, "image input source");
783                assert!(details.contains("session file"));
784            }
785            other => panic!("Expected UnsupportedFeature, got {other:?}"),
786        }
787    }
788
789    #[test]
790    fn map_claude_usage_computes_total_when_missing() {
791        let usage = ClaudeUsage {
792            input: 11,
793            output: 7,
794            total: None,
795            cache_creation_input: None,
796            cache_read_input: None,
797        };
798
799        assert_eq!(map_claude_usage(&usage), TokenUsage::new(11, 7, 18));
800    }
801
802    #[test]
803    fn convert_claude_completion_includes_usage() {
804        let claude_completion = ClaudeCompletionResponse {
805            id: "msg_1".to_string(),
806            content: vec![ClaudeContentBlock::Text {
807                text: "hi".to_string(),
808                cache_control: None,
809                extra: Default::default(),
810            }],
811            model: "claude-test".to_string(),
812            role: "assistant".to_string(),
813            stop_reason: Some("end_turn".to_string()),
814            stop_sequence: None,
815            usage: ClaudeUsage {
816                input: 12,
817                output: 4,
818                total: Some(22),
819                cache_creation_input: None,
820                cache_read_input: None,
821            },
822            extra: Default::default(),
823        };
824
825        let response = convert_claude_completion(claude_completion);
826
827        assert_eq!(response.usage, Some(TokenUsage::new(12, 4, 22)));
828        assert!(matches!(
829            response.content.first(),
830            Some(AssistantContent::Text { text }) if text == "hi"
831        ));
832    }
833
834    #[tokio::test]
835    async fn convert_claude_stream_attaches_latest_usage_on_message_stop() {
836        let events = vec![
837            Ok::<SseEvent, SseParseError>(SseEvent {
838                event_type: None,
839                data: r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":"Hello"}}"#.to_string(),
840                id: None,
841            }),
842            Ok(SseEvent {
843                event_type: None,
844                data: r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}"#.to_string(),
845                id: None,
846            }),
847            Ok(SseEvent {
848                event_type: None,
849                data: r#"{"type":"content_block_stop","index":0}"#.to_string(),
850                id: None,
851            }),
852            Ok(SseEvent {
853                event_type: None,
854                data: r#"{"type":"message_delta","delta":{"stop_reason":null},"usage":{"input_tokens":9,"output_tokens":3}}"#.to_string(),
855                id: None,
856            }),
857            Ok(SseEvent {
858                event_type: None,
859                data: r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"output_tokens":5}}"#.to_string(),
860                id: None,
861            }),
862            Ok(SseEvent {
863                event_type: None,
864                data: r#"{"type":"message_stop"}"#.to_string(),
865                id: None,
866            }),
867        ];
868
869        let sse_stream: SseStream = Box::pin(stream::iter(events));
870        let token = CancellationToken::new();
871        let mut stream = std::pin::pin!(convert_claude_stream(sse_stream, token));
872
873        let mut full_text = String::new();
874        let mut completion = None;
875
876        while let Some(chunk) = stream.next().await {
877            match chunk {
878                StreamChunk::TextDelta(delta) => full_text.push_str(&delta),
879                StreamChunk::ContentBlockStop { .. } => {}
880                StreamChunk::MessageComplete(response) => {
881                    completion = Some(response);
882                    break;
883                }
884                unexpected => panic!("unexpected chunk: {unexpected:?}"),
885            }
886        }
887
888        assert_eq!(full_text, "Hello world");
889
890        let response = completion.expect("expected final message completion chunk");
891        assert_eq!(response.usage, Some(TokenUsage::new(10, 5, 15)));
892        assert!(matches!(
893            response.content.first(),
894            Some(AssistantContent::Text { text }) if text == "Hello world"
895        ));
896    }
897}
898
899#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
900pub struct CacheControl {
901    #[serde(rename = "type", default = "default_cache_type")]
902    cache_type: String,
903}
904
905#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
906#[serde(tag = "type")]
907pub enum ClaudeContentBlock {
908    #[serde(rename = "text")]
909    Text {
910        text: String,
911        #[serde(skip_serializing_if = "Option::is_none")]
912        cache_control: Option<CacheControl>,
913        #[serde(flatten)]
914        extra: std::collections::HashMap<String, serde_json::Value>,
915    },
916    #[serde(rename = "image")]
917    Image {
918        source: ClaudeImageSource,
919        #[serde(skip_serializing_if = "Option::is_none")]
920        cache_control: Option<CacheControl>,
921        #[serde(flatten)]
922        extra: std::collections::HashMap<String, serde_json::Value>,
923    },
924    #[serde(rename = "tool_use")]
925    ToolUse {
926        id: String,
927        name: String,
928        input: serde_json::Value,
929        #[serde(skip_serializing_if = "Option::is_none")]
930        cache_control: Option<CacheControl>,
931        #[serde(flatten)]
932        extra: std::collections::HashMap<String, serde_json::Value>,
933    },
934    #[serde(rename = "tool_result")]
935    ToolResult {
936        tool_use_id: String,
937        content: Vec<ClaudeContentBlock>,
938        #[serde(skip_serializing_if = "Option::is_none")]
939        cache_control: Option<CacheControl>,
940        #[serde(skip_serializing_if = "Option::is_none")]
941        is_error: Option<bool>,
942        #[serde(flatten)]
943        extra: std::collections::HashMap<String, serde_json::Value>,
944    },
945    #[serde(rename = "thinking")]
946    Thinking {
947        thinking: String,
948        signature: String,
949        #[serde(skip_serializing_if = "Option::is_none")]
950        cache_control: Option<CacheControl>,
951        #[serde(flatten)]
952        extra: std::collections::HashMap<String, serde_json::Value>,
953    },
954    #[serde(rename = "redacted_thinking")]
955    RedactedThinking {
956        data: String,
957        #[serde(skip_serializing_if = "Option::is_none")]
958        cache_control: Option<CacheControl>,
959        #[serde(flatten)]
960        extra: std::collections::HashMap<String, serde_json::Value>,
961    },
962    #[serde(other)]
963    Unknown,
964}
965
966#[derive(Debug, Serialize, Deserialize, Default, Clone)]
967struct ClaudeUsage {
968    #[serde(rename = "input_tokens")]
969    input: usize,
970    #[serde(rename = "output_tokens")]
971    output: usize,
972    #[serde(rename = "total_tokens")]
973    #[serde(skip_serializing_if = "Option::is_none")]
974    total: Option<usize>,
975    #[serde(rename = "cache_creation_input_tokens")]
976    #[serde(skip_serializing_if = "Option::is_none")]
977    cache_creation_input: Option<usize>,
978    #[serde(rename = "cache_read_input_tokens")]
979    #[serde(skip_serializing_if = "Option::is_none")]
980    cache_read_input: Option<usize>,
981}
982
983#[derive(Debug, Deserialize)]
984#[serde(tag = "type")]
985enum ClaudeStreamEvent {
986    #[serde(rename = "message_start")]
987    MessageStart {
988        #[expect(dead_code)]
989        message: ClaudeMessageStart,
990    },
991    #[serde(rename = "content_block_start")]
992    ContentBlockStart {
993        index: usize,
994        content_block: ClaudeContentBlockStart,
995    },
996    #[serde(rename = "content_block_delta")]
997    ContentBlockDelta { index: usize, delta: ClaudeDelta },
998    #[serde(rename = "content_block_stop")]
999    ContentBlockStop { index: usize },
1000    #[serde(rename = "message_delta")]
1001    MessageDelta {
1002        #[expect(dead_code)]
1003        delta: ClaudeMessageDeltaData,
1004        #[serde(default)]
1005        usage: Option<ClaudeUsage>,
1006    },
1007    #[serde(rename = "message_stop")]
1008    MessageStop,
1009    #[serde(rename = "ping")]
1010    Ping,
1011    #[serde(rename = "error")]
1012    Error { error: ClaudeStreamError },
1013}
1014
1015#[derive(Debug, Deserialize)]
1016struct ClaudeMessageStart {
1017    #[expect(dead_code)]
1018    #[serde(default)]
1019    id: String,
1020    #[expect(dead_code)]
1021    #[serde(default)]
1022    model: String,
1023}
1024
1025#[derive(Debug, Deserialize)]
1026struct ClaudeContentBlockStart {
1027    #[serde(rename = "type")]
1028    block_type: String,
1029    #[serde(default)]
1030    id: Option<String>,
1031    #[serde(default)]
1032    name: Option<String>,
1033    #[serde(default)]
1034    text: Option<String>,
1035}
1036
1037#[derive(Debug, Deserialize)]
1038#[serde(tag = "type")]
1039enum ClaudeDelta {
1040    #[serde(rename = "text_delta")]
1041    Text { text: String },
1042    #[serde(rename = "thinking_delta")]
1043    Thinking { thinking: String },
1044    #[serde(rename = "input_json_delta")]
1045    InputJson { partial_json: String },
1046    #[serde(rename = "signature_delta")]
1047    Signature { signature: String },
1048}
1049
1050#[derive(Debug, Deserialize)]
1051struct ClaudeMessageDeltaData {
1052    #[expect(dead_code)]
1053    #[serde(default)]
1054    stop_reason: Option<String>,
1055}
1056
1057#[derive(Debug, Deserialize)]
1058struct ClaudeStreamError {
1059    #[serde(default)]
1060    message: String,
1061    #[serde(rename = "type", default)]
1062    error_type: String,
1063}
1064
1065impl AnthropicClient {
1066    pub fn new(api_key: &str) -> Result<Self, ApiError> {
1067        Self::with_api_key(api_key)
1068    }
1069
1070    pub fn with_api_key(api_key: &str) -> Result<Self, ApiError> {
1071        Ok(Self {
1072            http_client: Self::build_http_client()?,
1073            auth: AuthMode::ApiKey(api_key.to_string()),
1074        })
1075    }
1076
1077    pub fn with_directive(directive: AnthropicAuth) -> Result<Self, ApiError> {
1078        Ok(Self {
1079            http_client: Self::build_http_client()?,
1080            auth: AuthMode::Directive(directive),
1081        })
1082    }
1083
1084    fn build_http_client() -> Result<reqwest::Client, ApiError> {
1085        let mut headers = header::HeaderMap::new();
1086        headers.insert(
1087            "anthropic-version",
1088            header::HeaderValue::from_static("2023-06-01"),
1089        );
1090        headers.insert(
1091            header::CONTENT_TYPE,
1092            header::HeaderValue::from_static("application/json"),
1093        );
1094
1095        reqwest::Client::builder()
1096            .default_headers(headers)
1097            .build()
1098            .map_err(ApiError::Network)
1099    }
1100
1101    async fn auth_headers(
1102        &self,
1103        ctx: AuthHeaderContext,
1104    ) -> Result<Vec<(String, String)>, ApiError> {
1105        match &self.auth {
1106            AuthMode::ApiKey(key) => Ok(vec![("x-api-key".to_string(), key.clone())]),
1107            AuthMode::Directive(directive) => {
1108                let header_pairs = directive
1109                    .headers
1110                    .headers(ctx)
1111                    .await
1112                    .map_err(|e| ApiError::AuthError(e.to_string()))?;
1113                Ok(header_pairs
1114                    .into_iter()
1115                    .map(|pair| (pair.name, pair.value))
1116                    .collect())
1117            }
1118        }
1119    }
1120
1121    async fn on_auth_error(
1122        &self,
1123        status: u16,
1124        body: &str,
1125        request_kind: RequestKind,
1126    ) -> Result<AuthErrorAction, ApiError> {
1127        let AuthMode::Directive(directive) = &self.auth else {
1128            return Ok(AuthErrorAction::NoAction);
1129        };
1130        let context = AuthErrorContext {
1131            status: Some(status),
1132            body_snippet: Some(truncate_body(body)),
1133            request_kind,
1134        };
1135        directive
1136            .headers
1137            .on_auth_error(context)
1138            .await
1139            .map_err(|e| ApiError::AuthError(e.to_string()))
1140    }
1141
1142    fn request_url(&self) -> Result<String, ApiError> {
1143        let AuthMode::Directive(directive) = &self.auth else {
1144            return Ok(API_URL.to_string());
1145        };
1146
1147        let Some(query_params) = &directive.query_params else {
1148            return Ok(API_URL.to_string());
1149        };
1150
1151        if query_params.is_empty() {
1152            return Ok(API_URL.to_string());
1153        }
1154
1155        let mut url = url::Url::parse(API_URL)
1156            .map_err(|e| ApiError::Configuration(format!("Invalid API_URL '{API_URL}': {e}")))?;
1157        for param in query_params {
1158            url.query_pairs_mut().append_pair(&param.name, &param.value);
1159        }
1160        Ok(url.to_string())
1161    }
1162}
1163
1164// Conversion functions start
1165fn convert_messages(messages: Vec<AppMessage>) -> Result<Vec<ClaudeMessage>, ApiError> {
1166    let claude_messages: Result<Vec<ClaudeMessage>, ApiError> =
1167        messages.into_iter().map(convert_single_message).collect();
1168
1169    // Filter out any User messages that have empty content after removing app commands
1170    claude_messages.map(|messages| {
1171        messages
1172            .into_iter()
1173            .filter(|msg| {
1174                match &msg.content {
1175                    ClaudeMessageContent::Text { content } => !content.trim().is_empty(),
1176                    ClaudeMessageContent::StructuredContent { .. } => true, // Keep all non-text messages
1177                }
1178            })
1179            .collect()
1180    })
1181}
1182
1183fn user_image_to_claude_block(
1184    image: &crate::app::conversation::ImageContent,
1185) -> Result<ClaudeContentBlock, ApiError> {
1186    match &image.source {
1187        ImageSource::DataUrl { data_url } => {
1188            let Some((meta, payload)) = data_url.split_once(',') else {
1189                return Err(ApiError::InvalidRequest {
1190                    provider: "anthropic".to_string(),
1191                    details: "Image data URL is missing ',' separator".to_string(),
1192                });
1193            };
1194
1195            let Some(meta_body) = meta.strip_prefix("data:") else {
1196                return Err(ApiError::InvalidRequest {
1197                    provider: "anthropic".to_string(),
1198                    details: "Image data URL must start with 'data:'".to_string(),
1199                });
1200            };
1201
1202            if !meta_body
1203                .split(';')
1204                .any(|segment| segment.eq_ignore_ascii_case("base64"))
1205            {
1206                return Err(ApiError::InvalidRequest {
1207                    provider: "anthropic".to_string(),
1208                    details: "Anthropic image data URLs must be base64 encoded".to_string(),
1209                });
1210            }
1211
1212            let media_type = meta_body
1213                .split(';')
1214                .next()
1215                .filter(|value| !value.trim().is_empty())
1216                .unwrap_or("application/octet-stream")
1217                .to_string();
1218
1219            if !media_type.starts_with("image/") {
1220                return Err(ApiError::UnsupportedFeature {
1221                    provider: "anthropic".to_string(),
1222                    feature: "image input mime type".to_string(),
1223                    details: format!("Unsupported image media type '{}'", media_type),
1224                });
1225            }
1226
1227            Ok(ClaudeContentBlock::Image {
1228                source: ClaudeImageSource {
1229                    source_type: "base64".to_string(),
1230                    media_type,
1231                    data: payload.to_string(),
1232                },
1233                cache_control: None,
1234                extra: Default::default(),
1235            })
1236        }
1237        ImageSource::Url { url } => Err(ApiError::UnsupportedFeature {
1238            provider: "anthropic".to_string(),
1239            feature: "image input source".to_string(),
1240            details: format!(
1241                "Anthropic image input currently requires data URLs in this adapter; got URL '{}'",
1242                url
1243            ),
1244        }),
1245        ImageSource::SessionFile { relative_path } => Err(ApiError::UnsupportedFeature {
1246            provider: "anthropic".to_string(),
1247            feature: "image input source".to_string(),
1248            details: format!(
1249                "Anthropic adapter cannot access session file '{}' directly; use data URLs",
1250                relative_path
1251            ),
1252        }),
1253    }
1254}
1255
1256fn convert_single_message(msg: AppMessage) -> Result<ClaudeMessage, ApiError> {
1257    match &msg.data {
1258        crate::app::conversation::MessageData::User { content, .. } => {
1259            // Convert UserContent to Claude blocks
1260            let mut claude_blocks = Vec::new();
1261            for user_content in content {
1262                match user_content {
1263                    UserContent::Text { text } => {
1264                        if !text.trim().is_empty() {
1265                            claude_blocks.push(ClaudeContentBlock::Text {
1266                                text: text.clone(),
1267                                cache_control: None,
1268                                extra: Default::default(),
1269                            });
1270                        }
1271                    }
1272                    UserContent::Image { image } => {
1273                        claude_blocks.push(user_image_to_claude_block(image)?);
1274                    }
1275                    UserContent::CommandExecution {
1276                        command,
1277                        stdout,
1278                        stderr,
1279                        exit_code,
1280                    } => {
1281                        let text = UserContent::format_command_execution_as_xml(
1282                            command, stdout, stderr, *exit_code,
1283                        );
1284                        if !text.trim().is_empty() {
1285                            claude_blocks.push(ClaudeContentBlock::Text {
1286                                text,
1287                                cache_control: None,
1288                                extra: Default::default(),
1289                            });
1290                        }
1291                    }
1292                }
1293            }
1294
1295            if claude_blocks.is_empty() {
1296                return Err(ApiError::InvalidRequest {
1297                    provider: "anthropic".to_string(),
1298                    details: format!(
1299                        "User message ID {} resulted in no valid content blocks",
1300                        msg.id
1301                    ),
1302                });
1303            }
1304
1305            Ok(ClaudeMessage {
1306                role: ClaudeMessageRole::User,
1307                content: ClaudeMessageContent::StructuredContent {
1308                    content: ClaudeStructuredContent(claude_blocks),
1309                },
1310                id: Some(msg.id.clone()),
1311            })
1312        }
1313
1314        crate::app::conversation::MessageData::Assistant { content, .. } => {
1315            // Convert AssistantContent to Claude blocks
1316            let claude_blocks: Vec<ClaudeContentBlock> = content
1317                .iter()
1318                .filter_map(|assistant_content| match assistant_content {
1319                    AssistantContent::Text { text } => {
1320                        if text.trim().is_empty() {
1321                            None
1322                        } else {
1323                            Some(ClaudeContentBlock::Text {
1324                                text: text.clone(),
1325                                cache_control: None,
1326                                extra: Default::default(),
1327                            })
1328                        }
1329                    }
1330                    AssistantContent::Image { image } => match user_image_to_claude_block(image) {
1331                        Ok(block) => Some(block),
1332                        Err(err) => {
1333                            debug!(
1334                                target: "claude::convert_message",
1335                                "Skipping unsupported assistant image block: {}",
1336                                err
1337                            );
1338                            None
1339                        }
1340                    },
1341                    AssistantContent::ToolCall { tool_call, .. } => {
1342                        Some(ClaudeContentBlock::ToolUse {
1343                            id: tool_call.id.clone(),
1344                            name: tool_call.name.clone(),
1345                            input: tool_call.parameters.clone(),
1346                            cache_control: None,
1347                            extra: Default::default(),
1348                        })
1349                    }
1350                    AssistantContent::Thought { thought } => {
1351                        match thought {
1352                            ThoughtContent::Signed { text, signature } => {
1353                                Some(ClaudeContentBlock::Thinking {
1354                                    thinking: text.clone(),
1355                                    signature: signature.clone(),
1356                                    cache_control: None,
1357                                    extra: Default::default(),
1358                                })
1359                            }
1360                            ThoughtContent::Redacted { data } => {
1361                                Some(ClaudeContentBlock::RedactedThinking {
1362                                    data: data.clone(),
1363                                    cache_control: None,
1364                                    extra: Default::default(),
1365                                })
1366                            }
1367                            ThoughtContent::Simple { text } => {
1368                                // Claude doesn't have a simple thought type, convert to text
1369                                Some(ClaudeContentBlock::Text {
1370                                    text: format!("[Thought: {text}]"),
1371                                    cache_control: None,
1372                                    extra: Default::default(),
1373                                })
1374                            }
1375                        }
1376                    }
1377                })
1378                .collect();
1379
1380            if claude_blocks.is_empty() {
1381                debug!("No content blocks found: {:?}", content);
1382                Err(ApiError::InvalidRequest {
1383                    provider: "anthropic".to_string(),
1384                    details: format!(
1385                        "Assistant message ID {} resulted in no valid content blocks",
1386                        msg.id
1387                    ),
1388                })
1389            } else {
1390                let claude_blocks = ensure_thinking_first(claude_blocks);
1391                let claude_content = if claude_blocks.len() == 1 {
1392                    if let Some(ClaudeContentBlock::Text { text, .. }) = claude_blocks.first() {
1393                        ClaudeMessageContent::Text {
1394                            content: text.clone(),
1395                        }
1396                    } else {
1397                        ClaudeMessageContent::StructuredContent {
1398                            content: ClaudeStructuredContent(claude_blocks),
1399                        }
1400                    }
1401                } else {
1402                    ClaudeMessageContent::StructuredContent {
1403                        content: ClaudeStructuredContent(claude_blocks),
1404                    }
1405                };
1406
1407                Ok(ClaudeMessage {
1408                    role: ClaudeMessageRole::Assistant,
1409                    content: claude_content,
1410                    id: Some(msg.id.clone()),
1411                })
1412            }
1413        }
1414        crate::app::conversation::MessageData::Tool {
1415            tool_use_id,
1416            result,
1417            ..
1418        } => {
1419            // Convert ToolResult to Claude format
1420            // Claude expects tool results as User messages
1421            let (result_text, is_error) = if let ToolResult::Error(e) = result {
1422                (e.to_string(), Some(true))
1423            } else {
1424                // For all other variants, use llm_format
1425                let text = result.llm_format();
1426                let text = if text.trim().is_empty() {
1427                    "(No output)".to_string()
1428                } else {
1429                    text
1430                };
1431                (text, None)
1432            };
1433
1434            let claude_blocks = vec![ClaudeContentBlock::ToolResult {
1435                tool_use_id: tool_use_id.clone(),
1436                content: vec![ClaudeContentBlock::Text {
1437                    text: result_text,
1438                    cache_control: None,
1439                    extra: Default::default(),
1440                }],
1441                is_error,
1442                cache_control: None,
1443                extra: Default::default(),
1444            }];
1445
1446            Ok(ClaudeMessage {
1447                role: ClaudeMessageRole::User, // Tool results are sent as User messages in Claude
1448                content: ClaudeMessageContent::StructuredContent {
1449                    content: ClaudeStructuredContent(claude_blocks),
1450                },
1451                id: Some(msg.id.clone()),
1452            })
1453        }
1454    }
1455}
1456// Conversion functions end
1457
1458fn ensure_thinking_first(blocks: Vec<ClaudeContentBlock>) -> Vec<ClaudeContentBlock> {
1459    let mut thinking_blocks = Vec::new();
1460    let mut other_blocks = Vec::new();
1461
1462    for block in blocks {
1463        match block {
1464            ClaudeContentBlock::Thinking { .. } | ClaudeContentBlock::RedactedThinking { .. } => {
1465                thinking_blocks.push(block);
1466            }
1467            _ => other_blocks.push(block),
1468        }
1469    }
1470
1471    if thinking_blocks.is_empty() {
1472        other_blocks
1473    } else {
1474        thinking_blocks.extend(other_blocks);
1475        thinking_blocks
1476    }
1477}
1478
1479// Convert Claude's content blocks to our provider-agnostic format
1480fn convert_claude_content(claude_blocks: Vec<ClaudeContentBlock>) -> Vec<AssistantContent> {
1481    claude_blocks
1482        .into_iter()
1483        .filter_map(|block| match block {
1484            ClaudeContentBlock::Text { text, .. } => Some(AssistantContent::Text { text }),
1485            ClaudeContentBlock::Image { source, .. } => {
1486                let media_type = source.media_type;
1487                let data = source.data;
1488                Some(AssistantContent::Image {
1489                    image: crate::app::conversation::ImageContent {
1490                        mime_type: media_type.clone(),
1491                        source: crate::app::conversation::ImageSource::DataUrl {
1492                            data_url: format!("data:{};base64,{}", media_type, data),
1493                        },
1494                        width: None,
1495                        height: None,
1496                        bytes: None,
1497                        sha256: None,
1498                    },
1499                })
1500            }
1501
1502            ClaudeContentBlock::ToolUse {
1503                id, name, input, ..
1504            } => Some(AssistantContent::ToolCall {
1505                tool_call: steer_tools::ToolCall {
1506                    id,
1507                    name,
1508                    parameters: input,
1509                },
1510                thought_signature: None,
1511            }),
1512            ClaudeContentBlock::ToolResult { .. } => {
1513                warn!("Unexpected ToolResult block received in Claude response content");
1514                None
1515            }
1516            ClaudeContentBlock::Thinking {
1517                thinking,
1518                signature,
1519                ..
1520            } => Some(AssistantContent::Thought {
1521                thought: ThoughtContent::Signed {
1522                    text: thinking,
1523                    signature,
1524                },
1525            }),
1526            ClaudeContentBlock::RedactedThinking { data, .. } => Some(AssistantContent::Thought {
1527                thought: ThoughtContent::Redacted { data },
1528            }),
1529            ClaudeContentBlock::Unknown => {
1530                warn!("Unknown content block received in Claude response content");
1531                None
1532            }
1533        })
1534        .collect()
1535}
1536
1537fn saturating_u32(value: usize) -> u32 {
1538    u32::try_from(value).unwrap_or(u32::MAX)
1539}
1540
1541fn map_claude_usage(usage: &ClaudeUsage) -> TokenUsage {
1542    let total = usage
1543        .total
1544        .unwrap_or_else(|| usage.input.saturating_add(usage.output));
1545    TokenUsage::new(
1546        saturating_u32(usage.input),
1547        saturating_u32(usage.output),
1548        saturating_u32(total),
1549    )
1550}
1551
1552fn convert_claude_completion(claude_completion: ClaudeCompletionResponse) -> CompletionResponse {
1553    CompletionResponse {
1554        content: convert_claude_content(claude_completion.content),
1555        usage: Some(map_claude_usage(&claude_completion.usage)),
1556    }
1557}
1558
1559#[async_trait]
1560impl Provider for AnthropicClient {
1561    fn name(&self) -> &'static str {
1562        "anthropic"
1563    }
1564
1565    async fn complete(
1566        &self,
1567        model_id: &ModelId,
1568        messages: Vec<AppMessage>,
1569        system: Option<SystemContext>,
1570        tools: Option<Vec<ToolSchema>>,
1571        call_options: Option<ModelParameters>,
1572        token: CancellationToken,
1573    ) -> Result<CompletionResponse, ApiError> {
1574        let mut claude_messages = convert_messages(messages)?;
1575        let tools = tools.map(|tools| tools.into_iter().map(ClaudeTool::from).collect());
1576
1577        if claude_messages.is_empty() {
1578            return Err(ApiError::InvalidRequest {
1579                provider: self.name().to_string(),
1580                details: "No messages provided".to_string(),
1581            });
1582        }
1583
1584        let last_message = claude_messages
1585            .last_mut()
1586            .ok_or_else(|| ApiError::InvalidRequest {
1587                provider: self.name().to_string(),
1588                details: "No messages provided".to_string(),
1589            })?;
1590        let cache_setting = Some(CacheControl {
1591            cache_type: "ephemeral".to_string(),
1592        });
1593
1594        let instruction_policy = match &self.auth {
1595            AuthMode::Directive(directive) => directive.instruction_policy.as_ref(),
1596            AuthMode::ApiKey(_) => None,
1597        };
1598        let system_text = apply_instruction_policy(system, instruction_policy);
1599        let system_content = build_system_content(system_text, cache_setting.clone());
1600
1601        match &mut last_message.content {
1602            ClaudeMessageContent::StructuredContent { content } => {
1603                for block in &mut content.0 {
1604                    if let ClaudeContentBlock::ToolResult { cache_control, .. } = block {
1605                        cache_control.clone_from(&cache_setting);
1606                    }
1607                }
1608            }
1609            ClaudeMessageContent::Text { content } => {
1610                let text_content = content.clone();
1611                last_message.content = ClaudeMessageContent::StructuredContent {
1612                    content: ClaudeStructuredContent(vec![ClaudeContentBlock::Text {
1613                        text: text_content,
1614                        cache_control: cache_setting,
1615                        extra: Default::default(),
1616                    }]),
1617                };
1618            }
1619        }
1620
1621        // Extract model-specific logic using ModelId
1622        let supports_thinking = call_options
1623            .as_ref()
1624            .and_then(|opts| opts.thinking_config.as_ref())
1625            .is_some_and(|tc| tc.enabled);
1626
1627        let request = if supports_thinking {
1628            // Use catalog/call options to configure thinking budget when provided
1629            let budget = call_options
1630                .as_ref()
1631                .and_then(|o| o.thinking_config)
1632                .and_then(|tc| tc.budget_tokens)
1633                .unwrap_or(4000);
1634            let thinking = Some(Thinking {
1635                thinking_type: ThinkingType::Enabled,
1636                budget_tokens: budget,
1637            });
1638            CompletionRequest {
1639                model: model_id.id.clone(), // Use the model ID string
1640                messages: claude_messages,
1641                max_tokens: call_options
1642                    .as_ref()
1643                    .and_then(|o| o.max_tokens)
1644                    .map_or(32_000, |v| v as usize),
1645                system: system_content.clone(),
1646                tools,
1647                temperature: call_options
1648                    .as_ref()
1649                    .and_then(|o| o.temperature)
1650                    .or(Some(1.0)),
1651                top_p: call_options.as_ref().and_then(|o| o.top_p),
1652                top_k: None,
1653                stream: None,
1654                thinking,
1655            }
1656        } else {
1657            CompletionRequest {
1658                model: model_id.id.clone(), // Use the model ID string
1659                messages: claude_messages,
1660                max_tokens: call_options
1661                    .as_ref()
1662                    .and_then(|o| o.max_tokens)
1663                    .map_or(8000, |v| v as usize),
1664                system: system_content,
1665                tools,
1666                temperature: call_options
1667                    .as_ref()
1668                    .and_then(|o| o.temperature)
1669                    .or(Some(0.7)),
1670                top_p: call_options.as_ref().and_then(|o| o.top_p),
1671                top_k: None,
1672                stream: None,
1673                thinking: None,
1674            }
1675        };
1676
1677        let auth_ctx = auth_header_context(model_id, RequestKind::Complete);
1678        let mut attempts = 0;
1679
1680        loop {
1681            let auth_headers = self.auth_headers(auth_ctx.clone()).await?;
1682            let url = self.request_url()?;
1683            let mut request_builder = self.http_client.post(&url).json(&request);
1684
1685            for (name, value) in auth_headers {
1686                request_builder = request_builder.header(&name, &value);
1687            }
1688
1689            if supports_thinking && matches!(&self.auth, AuthMode::ApiKey(_)) {
1690                request_builder =
1691                    request_builder.header("anthropic-beta", "interleaved-thinking-2025-05-14");
1692            }
1693
1694            let response = tokio::select! {
1695                biased;
1696                () = token.cancelled() => {
1697                    debug!(target: "claude::complete", "Cancellation token triggered before sending request.");
1698                    return Err(ApiError::Cancelled{ provider: self.name().to_string()});
1699                }
1700                res = request_builder.send() => {
1701                    res?
1702                }
1703            };
1704
1705            if token.is_cancelled() {
1706                debug!(target: "claude::complete", "Cancellation token triggered after sending request, before status check.");
1707                return Err(ApiError::Cancelled {
1708                    provider: self.name().to_string(),
1709                });
1710            }
1711
1712            let status = response.status();
1713            if !status.is_success() {
1714                let error_text = tokio::select! {
1715                    biased;
1716                    () = token.cancelled() => {
1717                        debug!(target: "claude::complete", "Cancellation token triggered while reading error response body.");
1718                        return Err(ApiError::Cancelled{ provider: self.name().to_string()});
1719                    }
1720                    text_res = response.text() => {
1721                        text_res?
1722                    }
1723                };
1724
1725                if is_auth_status(status) && matches!(&self.auth, AuthMode::Directive(_)) {
1726                    let action = self
1727                        .on_auth_error(status.as_u16(), &error_text, RequestKind::Complete)
1728                        .await?;
1729                    if matches!(action, AuthErrorAction::RetryOnce) && attempts == 0 {
1730                        attempts += 1;
1731                        continue;
1732                    }
1733                    return Err(ApiError::AuthenticationFailed {
1734                        provider: self.name().to_string(),
1735                        details: error_text,
1736                    });
1737                }
1738
1739                return Err(match status.as_u16() {
1740                    401 | 403 => ApiError::AuthenticationFailed {
1741                        provider: self.name().to_string(),
1742                        details: error_text,
1743                    },
1744                    429 => ApiError::RateLimited {
1745                        provider: self.name().to_string(),
1746                        details: error_text,
1747                    },
1748                    400..=499 => ApiError::InvalidRequest {
1749                        provider: self.name().to_string(),
1750                        details: error_text,
1751                    },
1752                    500..=599 => ApiError::ServerError {
1753                        provider: self.name().to_string(),
1754                        status_code: status.as_u16(),
1755                        details: error_text,
1756                    },
1757                    _ => ApiError::Unknown {
1758                        provider: self.name().to_string(),
1759                        details: error_text,
1760                    },
1761                });
1762            }
1763
1764            let response_text = tokio::select! {
1765                biased;
1766                () = token.cancelled() => {
1767                    debug!(target: "claude::complete", "Cancellation token triggered while reading successful response body.");
1768                    return Err(ApiError::Cancelled { provider: self.name().to_string() });
1769                }
1770                text_res = response.text() => {
1771                    text_res?
1772                }
1773            };
1774
1775            let claude_completion: ClaudeCompletionResponse = serde_json::from_str(&response_text)
1776                .map_err(|e| ApiError::ResponseParsingError {
1777                    provider: self.name().to_string(),
1778                    details: format!("Error: {e}, Body: {response_text}"),
1779                })?;
1780            let completion = convert_claude_completion(claude_completion);
1781
1782            return Ok(completion);
1783        }
1784    }
1785
1786    async fn stream_complete(
1787        &self,
1788        model_id: &ModelId,
1789        messages: Vec<AppMessage>,
1790        system: Option<SystemContext>,
1791        tools: Option<Vec<ToolSchema>>,
1792        call_options: Option<ModelParameters>,
1793        token: CancellationToken,
1794    ) -> Result<CompletionStream, ApiError> {
1795        let mut claude_messages = convert_messages(messages)?;
1796        let tools = tools.map(|tools| tools.into_iter().map(ClaudeTool::from).collect());
1797
1798        if claude_messages.is_empty() {
1799            return Err(ApiError::InvalidRequest {
1800                provider: self.name().to_string(),
1801                details: "No messages provided".to_string(),
1802            });
1803        }
1804
1805        let last_message = claude_messages
1806            .last_mut()
1807            .ok_or_else(|| ApiError::InvalidRequest {
1808                provider: self.name().to_string(),
1809                details: "No messages provided".to_string(),
1810            })?;
1811        let cache_setting = Some(CacheControl {
1812            cache_type: "ephemeral".to_string(),
1813        });
1814
1815        let instruction_policy = match &self.auth {
1816            AuthMode::Directive(directive) => directive.instruction_policy.as_ref(),
1817            AuthMode::ApiKey(_) => None,
1818        };
1819        let system_text = apply_instruction_policy(system, instruction_policy);
1820        let system_content = build_system_content(system_text, cache_setting.clone());
1821
1822        match &mut last_message.content {
1823            ClaudeMessageContent::StructuredContent { content } => {
1824                for block in &mut content.0 {
1825                    if let ClaudeContentBlock::ToolResult { cache_control, .. } = block {
1826                        cache_control.clone_from(&cache_setting);
1827                    }
1828                }
1829            }
1830            ClaudeMessageContent::Text { content } => {
1831                let text_content = content.clone();
1832                last_message.content = ClaudeMessageContent::StructuredContent {
1833                    content: ClaudeStructuredContent(vec![ClaudeContentBlock::Text {
1834                        text: text_content,
1835                        cache_control: cache_setting,
1836                        extra: Default::default(),
1837                    }]),
1838                };
1839            }
1840        }
1841
1842        let supports_thinking = call_options
1843            .as_ref()
1844            .and_then(|opts| opts.thinking_config.as_ref())
1845            .is_some_and(|tc| tc.enabled);
1846
1847        let request = if supports_thinking {
1848            let budget = call_options
1849                .as_ref()
1850                .and_then(|o| o.thinking_config)
1851                .and_then(|tc| tc.budget_tokens)
1852                .unwrap_or(4000);
1853            let thinking = Some(Thinking {
1854                thinking_type: ThinkingType::Enabled,
1855                budget_tokens: budget,
1856            });
1857            CompletionRequest {
1858                model: model_id.id.clone(),
1859                messages: claude_messages,
1860                max_tokens: call_options
1861                    .as_ref()
1862                    .and_then(|o| o.max_tokens)
1863                    .map_or(32_000, |v| v as usize),
1864                system: system_content.clone(),
1865                tools,
1866                temperature: call_options
1867                    .as_ref()
1868                    .and_then(|o| o.temperature)
1869                    .or(Some(1.0)),
1870                top_p: call_options.as_ref().and_then(|o| o.top_p),
1871                top_k: None,
1872                stream: Some(true),
1873                thinking,
1874            }
1875        } else {
1876            CompletionRequest {
1877                model: model_id.id.clone(),
1878                messages: claude_messages,
1879                max_tokens: call_options
1880                    .as_ref()
1881                    .and_then(|o| o.max_tokens)
1882                    .map_or(8000, |v| v as usize),
1883                system: system_content,
1884                tools,
1885                temperature: call_options
1886                    .as_ref()
1887                    .and_then(|o| o.temperature)
1888                    .or(Some(0.7)),
1889                top_p: call_options.as_ref().and_then(|o| o.top_p),
1890                top_k: None,
1891                stream: Some(true),
1892                thinking: None,
1893            }
1894        };
1895
1896        let auth_ctx = auth_header_context(model_id, RequestKind::Stream);
1897        let mut attempts = 0;
1898
1899        loop {
1900            let auth_headers = self.auth_headers(auth_ctx.clone()).await?;
1901            let url = self.request_url()?;
1902            let mut request_builder = self.http_client.post(&url).json(&request);
1903
1904            for (name, value) in auth_headers {
1905                request_builder = request_builder.header(&name, &value);
1906            }
1907
1908            if supports_thinking && matches!(&self.auth, AuthMode::ApiKey(_)) {
1909                request_builder =
1910                    request_builder.header("anthropic-beta", "interleaved-thinking-2025-05-14");
1911            }
1912
1913            let response = tokio::select! {
1914                biased;
1915                () = token.cancelled() => {
1916                    return Err(ApiError::Cancelled { provider: self.name().to_string() });
1917                }
1918                res = request_builder.send() => {
1919                    res?
1920                }
1921            };
1922
1923            let status = response.status();
1924            if !status.is_success() {
1925                let error_text = tokio::select! {
1926                    biased;
1927                    () = token.cancelled() => {
1928                        return Err(ApiError::Cancelled { provider: self.name().to_string() });
1929                    }
1930                    text_res = response.text() => {
1931                        text_res?
1932                    }
1933                };
1934
1935                if is_auth_status(status) && matches!(&self.auth, AuthMode::Directive(_)) {
1936                    let action = self
1937                        .on_auth_error(status.as_u16(), &error_text, RequestKind::Stream)
1938                        .await?;
1939                    if matches!(action, AuthErrorAction::RetryOnce) && attempts == 0 {
1940                        attempts += 1;
1941                        continue;
1942                    }
1943                    return Err(ApiError::AuthenticationFailed {
1944                        provider: self.name().to_string(),
1945                        details: error_text,
1946                    });
1947                }
1948
1949                return Err(match status.as_u16() {
1950                    401 | 403 => ApiError::AuthenticationFailed {
1951                        provider: self.name().to_string(),
1952                        details: error_text,
1953                    },
1954                    429 => ApiError::RateLimited {
1955                        provider: self.name().to_string(),
1956                        details: error_text,
1957                    },
1958                    400..=499 => ApiError::InvalidRequest {
1959                        provider: self.name().to_string(),
1960                        details: error_text,
1961                    },
1962                    500..=599 => ApiError::ServerError {
1963                        provider: self.name().to_string(),
1964                        status_code: status.as_u16(),
1965                        details: error_text,
1966                    },
1967                    _ => ApiError::Unknown {
1968                        provider: self.name().to_string(),
1969                        details: error_text,
1970                    },
1971                });
1972            }
1973
1974            let byte_stream = response.bytes_stream();
1975            let sse_stream = parse_sse_stream(byte_stream);
1976
1977            let stream = convert_claude_stream(sse_stream, token);
1978
1979            return Ok(Box::pin(stream));
1980        }
1981    }
1982}
1983
1984fn auth_header_context(model_id: &ModelId, request_kind: RequestKind) -> AuthHeaderContext {
1985    AuthHeaderContext {
1986        model_id: Some(AuthModelId {
1987            provider_id: AuthProviderId(model_id.provider.as_str().to_string()),
1988            model_id: model_id.id.clone(),
1989        }),
1990        request_kind,
1991    }
1992}
1993
1994fn is_auth_status(status: reqwest::StatusCode) -> bool {
1995    matches!(
1996        status,
1997        reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
1998    )
1999}
2000
2001fn truncate_body(body: &str) -> String {
2002    const LIMIT: usize = 512;
2003    let mut chars = body.chars();
2004    let truncated: String = chars.by_ref().take(LIMIT).collect();
2005    if chars.next().is_some() {
2006        format!("{truncated}...")
2007    } else {
2008        truncated
2009    }
2010}
2011
2012fn apply_instruction_policy(
2013    system: Option<SystemContext>,
2014    policy: Option<&InstructionPolicy>,
2015) -> Option<String> {
2016    let base = system.as_ref().and_then(|context| {
2017        let trimmed = context.prompt.trim();
2018        if trimmed.is_empty() {
2019            None
2020        } else {
2021            Some(trimmed.to_string())
2022        }
2023    });
2024
2025    let context = system
2026        .as_ref()
2027        .and_then(|context| context.render_with_prompt(base.clone()));
2028
2029    match policy {
2030        None => context,
2031        Some(InstructionPolicy::Prefix(prefix)) => {
2032            if let Some(context) = context {
2033                Some(format!("{prefix}\n{context}"))
2034            } else {
2035                Some(prefix.clone())
2036            }
2037        }
2038        Some(InstructionPolicy::DefaultIfEmpty(default)) => {
2039            if context.is_some() {
2040                context
2041            } else {
2042                Some(default.clone())
2043            }
2044        }
2045        Some(InstructionPolicy::Override(override_text)) => {
2046            let mut combined = override_text.clone();
2047            if let Some(system) = system.as_ref() {
2048                let overlay = system.prompt.trim();
2049                if !overlay.is_empty() {
2050                    combined.push_str("\n\n## Operating Mode\n");
2051                    combined.push_str(overlay);
2052                }
2053
2054                let env = system
2055                    .environment
2056                    .as_ref()
2057                    .map(|env| env.as_context())
2058                    .map(|value| value.trim().to_string())
2059                    .filter(|value| !value.is_empty());
2060                if let Some(env) = env {
2061                    combined.push_str("\n\n");
2062                    combined.push_str(&env);
2063                }
2064            }
2065            Some(combined)
2066        }
2067    }
2068}
2069
2070fn build_system_content(
2071    system: Option<String>,
2072    cache_setting: Option<CacheControl>,
2073) -> Option<System> {
2074    system.map(|text| {
2075        System::Content(vec![SystemContentBlock {
2076            content_type: "text".to_string(),
2077            text,
2078            cache_control: cache_setting,
2079        }])
2080    })
2081}
2082
2083#[derive(Debug)]
2084enum BlockState {
2085    Text {
2086        text: String,
2087    },
2088    Thinking {
2089        text: String,
2090        signature: Option<String>,
2091    },
2092    ToolUse {
2093        id: String,
2094        name: String,
2095        input: String,
2096    },
2097    Unknown,
2098}
2099
2100fn block_state_to_content(state: BlockState) -> Option<AssistantContent> {
2101    match state {
2102        BlockState::Text { text } => {
2103            if text.is_empty() {
2104                None
2105            } else {
2106                Some(AssistantContent::Text { text })
2107            }
2108        }
2109        BlockState::Thinking { text, signature } => {
2110            if text.is_empty() {
2111                None
2112            } else {
2113                let thought = if let Some(sig) = signature {
2114                    ThoughtContent::Signed {
2115                        text,
2116                        signature: sig,
2117                    }
2118                } else {
2119                    ThoughtContent::Simple { text }
2120                };
2121                Some(AssistantContent::Thought { thought })
2122            }
2123        }
2124        BlockState::ToolUse { id, name, input } => {
2125            if id.is_empty() || name.is_empty() {
2126                None
2127            } else {
2128                let parameters: serde_json::Value = serde_json::from_str(&input)
2129                    .unwrap_or(serde_json::Value::Object(Default::default()));
2130                Some(AssistantContent::ToolCall {
2131                    tool_call: ToolCall {
2132                        id,
2133                        name,
2134                        parameters,
2135                    },
2136                    thought_signature: None,
2137                })
2138            }
2139        }
2140        BlockState::Unknown => None,
2141    }
2142}
2143
2144fn convert_claude_stream(
2145    sse_stream: crate::api::sse::SseStream,
2146    token: CancellationToken,
2147) -> impl futures_core::Stream<Item = StreamChunk> + Send {
2148    async_stream::stream! {
2149        let mut block_states: std::collections::HashMap<usize, BlockState> =
2150            std::collections::HashMap::new();
2151        let mut completed_content: Vec<AssistantContent> = Vec::new();
2152        let mut latest_usage: Option<TokenUsage> = None;
2153
2154        tokio::pin!(sse_stream);
2155
2156        while let Some(event_result) = sse_stream.next().await {
2157            if token.is_cancelled() {
2158                yield StreamChunk::Error(StreamError::Cancelled);
2159                break;
2160            }
2161
2162            let event = match event_result {
2163                Ok(e) => e,
2164                Err(e) => {
2165                    yield StreamChunk::Error(StreamError::SseParse(e));
2166                    break;
2167                }
2168            };
2169
2170            let parsed: Result<ClaudeStreamEvent, _> = serde_json::from_str(&event.data);
2171            let stream_event = match parsed {
2172                Ok(e) => e,
2173                Err(_) => continue,
2174            };
2175
2176            match stream_event {
2177                ClaudeStreamEvent::ContentBlockStart { index, content_block } => {
2178                    match content_block.block_type.as_str() {
2179                        "text" => {
2180                            let text = content_block.text.unwrap_or_default();
2181                            if !text.is_empty() {
2182                                yield StreamChunk::TextDelta(text.clone());
2183                            }
2184                            block_states.insert(index, BlockState::Text { text });
2185                        }
2186                        "thinking" => {
2187                            block_states.insert(
2188                                index,
2189                                BlockState::Thinking {
2190                                    text: String::new(),
2191                                    signature: None,
2192                                },
2193                            );
2194                        }
2195                        "tool_use" => {
2196                            let id = content_block.id.unwrap_or_default();
2197                            let name = content_block.name.unwrap_or_default();
2198                            if !id.is_empty() && !name.is_empty() {
2199                                yield StreamChunk::ToolUseStart {
2200                                    id: id.clone(),
2201                                    name: name.clone(),
2202                                };
2203                            }
2204                            block_states.insert(
2205                                index,
2206                                BlockState::ToolUse {
2207                                    id,
2208                                    name,
2209                                    input: String::new(),
2210                                },
2211                            );
2212                        }
2213                        _ => {
2214                            block_states.insert(index, BlockState::Unknown);
2215                        }
2216                    }
2217                }
2218                ClaudeStreamEvent::ContentBlockDelta { index, delta } => match delta {
2219                    ClaudeDelta::Text { text } => {
2220                        match block_states.get_mut(&index) {
2221                            Some(BlockState::Text { text: buf }) => buf.push_str(&text),
2222                            _ => {
2223                                block_states.insert(index, BlockState::Text { text: text.clone() });
2224                            }
2225                        }
2226                        yield StreamChunk::TextDelta(text);
2227                    }
2228                    ClaudeDelta::Thinking { thinking } => {
2229                        match block_states.get_mut(&index) {
2230                            Some(BlockState::Thinking { text, .. }) => text.push_str(&thinking),
2231                            _ => {
2232                                block_states.insert(
2233                                    index,
2234                                    BlockState::Thinking {
2235                                        text: thinking.clone(),
2236                                        signature: None,
2237                                    },
2238                                );
2239                            }
2240                        }
2241                        yield StreamChunk::ThinkingDelta(thinking);
2242                    }
2243                    ClaudeDelta::Signature { signature } => {
2244                        if let Some(BlockState::Thinking { signature: sig, .. }) =
2245                            block_states.get_mut(&index)
2246                        {
2247                            *sig = Some(signature);
2248                        }
2249                    }
2250                    ClaudeDelta::InputJson { partial_json } => {
2251                        if let Some(BlockState::ToolUse { id, input, .. }) =
2252                            block_states.get_mut(&index)
2253                        {
2254                            input.push_str(&partial_json);
2255                            if !id.is_empty() {
2256                                yield StreamChunk::ToolUseInputDelta {
2257                                    id: id.clone(),
2258                                    delta: partial_json,
2259                                };
2260                            }
2261                        }
2262                    }
2263                },
2264                ClaudeStreamEvent::ContentBlockStop { index } => {
2265                    if let Some(state) = block_states.remove(&index)
2266                        && let Some(content) = block_state_to_content(state)
2267                    {
2268                        completed_content.push(content);
2269                    }
2270                    yield StreamChunk::ContentBlockStop { index };
2271                }
2272                ClaudeStreamEvent::MessageStop => {
2273                    if !block_states.is_empty() {
2274                        tracing::warn!(
2275                            target: "anthropic::stream",
2276                            "MessageStop received with {} unfinished content blocks",
2277                            block_states.len()
2278                        );
2279                    }
2280                    let content = std::mem::take(&mut completed_content);
2281                    yield StreamChunk::MessageComplete(CompletionResponse {
2282                        content,
2283                        usage: latest_usage,
2284                    });
2285                    break;
2286                }
2287                ClaudeStreamEvent::Error { error } => {
2288                    yield StreamChunk::Error(StreamError::Provider {
2289                        provider: "anthropic".into(),
2290                        error_type: error.error_type,
2291                        message: error.message,
2292                    });
2293                }
2294                ClaudeStreamEvent::MessageDelta { usage, .. } => {
2295                    if let Some(usage) = usage.as_ref() {
2296                        latest_usage = Some(map_claude_usage(usage));
2297                    }
2298                }
2299                ClaudeStreamEvent::MessageStart { .. } | ClaudeStreamEvent::Ping => {}
2300            }
2301        }
2302    }
2303}