use super::access_token::get_access_token; use super::claude_oauth::ClaudeOAuthProvider; use super::oauth::{self, OAuthProvider}; use super::*; use crate::utils::strip_think_tag; use anyhow::{Context, Result, bail}; use reqwest::{Client as ReqwestClient, RequestBuilder}; use serde::Deserialize; use serde_json::{Value, json}; const API_BASE: &str = "https://api.anthropic.com/v1"; const CLAUDE_CODE_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude."; #[derive(Debug, Clone, Deserialize)] pub struct ClaudeConfig { pub name: Option, pub api_key: Option, pub api_base: Option, pub auth: Option, #[serde(default)] pub models: Vec, pub patch: Option, pub extra: Option, } impl ClaudeClient { config_get_fn!(api_key, get_api_key); config_get_fn!(api_base, get_api_base); create_oauth_supported_client_config!(); } #[async_trait::async_trait] impl Client for ClaudeClient { client_common_fns!(); fn supports_oauth(&self) -> bool { self.config.auth.as_deref() == Some("oauth") } async fn chat_completions_inner( &self, client: &ReqwestClient, data: ChatCompletionsData, ) -> Result { let request_data = prepare_chat_completions(self, client, data).await?; let builder = self.request_builder(client, request_data); claude_chat_completions(builder, self.model()).await } async fn chat_completions_streaming_inner( &self, client: &ReqwestClient, handler: &mut SseHandler, data: ChatCompletionsData, ) -> Result<()> { let request_data = prepare_chat_completions(self, client, data).await?; let builder = self.request_builder(client, request_data); claude_chat_completions_streaming(builder, handler, self.model()).await } } async fn prepare_chat_completions( self_: &ClaudeClient, client: &ReqwestClient, data: ChatCompletionsData, ) -> Result { let api_base = self_ .get_api_base() .unwrap_or_else(|_| API_BASE.to_string()); let url = format!("{}/messages", api_base.trim_end_matches('/')); let body = claude_build_chat_completions_body(data, &self_.model)?; let mut request_data = RequestData::new(url, body); request_data.header("anthropic-version", "2023-06-01"); let uses_oauth = self_.config.auth.as_deref() == Some("oauth"); if uses_oauth { let provider = ClaudeOAuthProvider; let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?; if !ready { bail!( "OAuth configured but no tokens found for '{}'. Run: 'loki --authenticate {}' or '.authenticate' in the REPL", self_.name(), self_.name() ); } let token = get_access_token(self_.name())?; request_data.bearer_auth(token); for (key, value) in provider.extra_request_headers() { request_data.header(key, value); } inject_oauth_system_prompt(&mut request_data.body); } else if let Ok(api_key) = self_.get_api_key() { request_data.header("x-api-key", api_key); } else { bail!( "No authentication configured for '{}'. Set `api_key` or use `auth: oauth` with `loki --authenticate {}`.", self_.name(), self_.name() ); } Ok(request_data) } /// Anthropic requires OAuth-authenticated requests to include a Claude Code /// system prompt prefix in order to consider a request body as "valid". /// /// This behavior was discovered 2026-03-17. /// /// So this function injects the Claude Code system prompt into the request /// body to make it a valid request. fn inject_oauth_system_prompt(body: &mut Value) { let prefix_block = json!({ "type": "text", "text": CLAUDE_CODE_PREFIX, }); match body.get("system") { Some(Value::String(existing)) => { let existing_block = json!({ "type": "text", "text": existing, }); body["system"] = json!([prefix_block, existing_block]); } Some(Value::Array(_)) => { if let Some(arr) = body["system"].as_array_mut() { let already_injected = arr .iter() .any(|block| block["text"].as_str() == Some(CLAUDE_CODE_PREFIX)); if !already_injected { arr.insert(0, prefix_block); } } } _ => { body["system"] = json!([prefix_block]); } } } pub async fn claude_chat_completions( builder: RequestBuilder, _model: &Model, ) -> Result { let res = builder.send().await?; let status = res.status(); let data: Value = res.json().await?; if !status.is_success() { catch_error(&data, status.as_u16())?; } debug!("non-stream-data: {data}"); claude_extract_chat_completions(&data) } pub async fn claude_chat_completions_streaming( builder: RequestBuilder, handler: &mut SseHandler, _model: &Model, ) -> Result<()> { let mut function_name = String::new(); let mut function_arguments = String::new(); let mut function_id = String::new(); let mut reasoning_state = 0; let handle = |message: SseMessage| -> Result { let data: Value = serde_json::from_str(&message.data)?; debug!("stream-data: {data}"); if let Some(typ) = data["type"].as_str() { match typ { "content_block_start" => { if let (Some("tool_use"), Some(name), Some(id)) = ( data["content_block"]["type"].as_str(), data["content_block"]["name"].as_str(), data["content_block"]["id"].as_str(), ) { if !function_name.is_empty() { let arguments: Value = if function_arguments.is_empty() { json!({}) } else { function_arguments.parse().with_context(|| { format!("Tool call '{function_name}' has non-JSON arguments '{function_arguments}'") })? }; handler.tool_call(ToolCall::new( function_name.clone(), arguments, Some(function_id.clone()), ))?; } function_name = name.into(); function_arguments.clear(); function_id = id.into(); } } "content_block_delta" => { if let Some(text) = data["delta"]["text"].as_str() { handler.text(text)?; } else if let Some(text) = data["delta"]["thinking"].as_str() { if reasoning_state == 0 { handler.text("\n")?; reasoning_state = 1; } handler.text(text)?; } else if let (true, Some(partial_json)) = ( !function_name.is_empty(), data["delta"]["partial_json"].as_str(), ) { function_arguments.push_str(partial_json); } } "content_block_stop" => { if reasoning_state == 1 { handler.text("\n\n\n")?; reasoning_state = 0; } if !function_name.is_empty() { let arguments: Value = if function_arguments.is_empty() { json!({}) } else { function_arguments.parse().with_context(|| { format!("Tool call '{function_name}' has non-JSON arguments '{function_arguments}'") })? }; handler.tool_call(ToolCall::new( function_name.clone(), arguments, Some(function_id.clone()), ))?; } } _ => {} } } Ok(false) }; sse_stream(builder, handle).await } pub fn claude_build_chat_completions_body( data: ChatCompletionsData, model: &Model, ) -> Result { let ChatCompletionsData { mut messages, temperature, top_p, functions, stream, } = data; let system_message = extract_system_message(&mut messages); let mut network_image_urls = vec![]; let messages_len = messages.len(); let messages: Vec = messages .into_iter() .enumerate() .flat_map(|(i, message)| { let Message { role, content } = message; match content { MessageContent::Text(text) if role.is_assistant() && i != messages_len - 1 => { vec![json!({ "role": role, "content": strip_think_tag(&text) })] } MessageContent::Text(text) => vec![json!({ "role": role, "content": text, })], MessageContent::Array(list) => { let content: Vec<_> = list .into_iter() .map(|item| match item { MessageContentPart::Text { text } => { json!({"type": "text", "text": text}) } MessageContentPart::ImageUrl { image_url: ImageUrl { url }, } => { if let Some((mime_type, data)) = url .strip_prefix("data:") .and_then(|v| v.split_once(";base64,")) { json!({ "type": "image", "source": { "type": "base64", "media_type": mime_type, "data": data, } }) } else { network_image_urls.push(url.clone()); json!({ "url": url }) } } }) .collect(); vec![json!({ "role": role, "content": content, })] } MessageContent::ToolCalls(MessageContentToolCalls { tool_results, text, .. }) => { let mut assistant_parts = vec![]; let mut user_parts = vec![]; if !text.is_empty() { assistant_parts.push(json!({ "type": "text", "text": text, })) } for tool_result in tool_results { assistant_parts.push(json!({ "type": "tool_use", "id": tool_result.call.id, "name": tool_result.call.name, "input": tool_result.call.arguments, })); user_parts.push(json!({ "type": "tool_result", "tool_use_id": tool_result.call.id, "content": tool_result.output.to_string(), })); } vec![ json!({ "role": "assistant", "content": assistant_parts, }), json!({ "role": "user", "content": user_parts, }), ] } } }) .collect(); if !network_image_urls.is_empty() { bail!( "The model does not support network images: {:?}", network_image_urls ); } let mut body = json!({ "model": model.real_name(), "messages": messages, }); if let Some(v) = system_message { body["system"] = v.into(); } if let Some(v) = model.max_tokens_param() { body["max_tokens"] = v.into(); } if let Some(v) = temperature { body["temperature"] = v.into(); } if let Some(v) = top_p { body["top_p"] = v.into(); } if stream { body["stream"] = true.into(); } if let Some(functions) = functions { body["tools"] = functions .iter() .map(|v| { if v.parameters.is_empty_properties() { json!({ "name": v.name, "description": v.description, "input_schema": { "type": "object", "properties": {}, "required": [] }, }) } else { json!({ "name": v.name, "description": v.description, "input_schema": v.parameters, }) } }) .collect(); } Ok(body) } pub fn claude_extract_chat_completions(data: &Value) -> Result { let mut text = String::new(); let mut reasoning = None; let mut tool_calls = vec![]; if let Some(list) = data["content"].as_array() { for item in list { match item["type"].as_str() { Some("thinking") => { if let Some(v) = item["thinking"].as_str() { reasoning = Some(v.to_string()); } } Some("text") => { if let Some(v) = item["text"].as_str() { if !text.is_empty() { text.push_str("\n\n"); } text.push_str(v); } } Some("tool_use") => { if let (Some(name), Some(input), Some(id)) = ( item["name"].as_str(), item.get("input"), item["id"].as_str(), ) { tool_calls.push(ToolCall::new( name.to_string(), input.clone(), Some(id.to_string()), )); } } _ => {} } } } if let Some(reasoning) = reasoning { text = format!("\n{reasoning}\n\n\n{text}") } if text.is_empty() && tool_calls.is_empty() { bail!("Invalid response data: {data}"); } let output = ChatCompletionsOutput { text: text.to_string(), tool_calls, }; Ok(output) }