liter_llm 1.0.0.pre.rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +239 -0
  3. data/ext/liter_llm_rb/extconf.rb +65 -0
  4. data/ext/liter_llm_rb/native/.cargo/config.toml +23 -0
  5. data/ext/liter_llm_rb/native/Cargo.lock +3713 -0
  6. data/ext/liter_llm_rb/native/Cargo.toml +32 -0
  7. data/ext/liter_llm_rb/native/build.rs +15 -0
  8. data/ext/liter_llm_rb/native/src/lib.rs +1079 -0
  9. data/lib/liter_llm.rb +8 -0
  10. data/sig/liter_llm.rbs +416 -0
  11. data/vendor/Cargo.toml +54 -0
  12. data/vendor/liter-llm/Cargo.toml +92 -0
  13. data/vendor/liter-llm/README.md +252 -0
  14. data/vendor/liter-llm/schemas/pricing.json +40 -0
  15. data/vendor/liter-llm/schemas/providers.json +1662 -0
  16. data/vendor/liter-llm/src/auth/azure_ad.rs +264 -0
  17. data/vendor/liter-llm/src/auth/bedrock_sts.rs +353 -0
  18. data/vendor/liter-llm/src/auth/mod.rs +68 -0
  19. data/vendor/liter-llm/src/auth/vertex_oauth.rs +353 -0
  20. data/vendor/liter-llm/src/client/config.rs +351 -0
  21. data/vendor/liter-llm/src/client/managed.rs +622 -0
  22. data/vendor/liter-llm/src/client/mod.rs +864 -0
  23. data/vendor/liter-llm/src/cost.rs +212 -0
  24. data/vendor/liter-llm/src/error.rs +190 -0
  25. data/vendor/liter-llm/src/http/eventstream.rs +860 -0
  26. data/vendor/liter-llm/src/http/mod.rs +12 -0
  27. data/vendor/liter-llm/src/http/request.rs +438 -0
  28. data/vendor/liter-llm/src/http/retry.rs +72 -0
  29. data/vendor/liter-llm/src/http/streaming.rs +289 -0
  30. data/vendor/liter-llm/src/lib.rs +37 -0
  31. data/vendor/liter-llm/src/provider/anthropic.rs +2250 -0
  32. data/vendor/liter-llm/src/provider/azure.rs +579 -0
  33. data/vendor/liter-llm/src/provider/bedrock.rs +1543 -0
  34. data/vendor/liter-llm/src/provider/cohere.rs +654 -0
  35. data/vendor/liter-llm/src/provider/custom.rs +404 -0
  36. data/vendor/liter-llm/src/provider/google_ai.rs +281 -0
  37. data/vendor/liter-llm/src/provider/mistral.rs +188 -0
  38. data/vendor/liter-llm/src/provider/mod.rs +616 -0
  39. data/vendor/liter-llm/src/provider/vertex.rs +1504 -0
  40. data/vendor/liter-llm/src/tests.rs +1425 -0
  41. data/vendor/liter-llm/src/tokenizer.rs +281 -0
  42. data/vendor/liter-llm/src/tower/budget.rs +599 -0
  43. data/vendor/liter-llm/src/tower/cache.rs +502 -0
  44. data/vendor/liter-llm/src/tower/cache_opendal.rs +270 -0
  45. data/vendor/liter-llm/src/tower/cooldown.rs +231 -0
  46. data/vendor/liter-llm/src/tower/cost.rs +404 -0
  47. data/vendor/liter-llm/src/tower/fallback.rs +121 -0
  48. data/vendor/liter-llm/src/tower/health.rs +219 -0
  49. data/vendor/liter-llm/src/tower/hooks.rs +369 -0
  50. data/vendor/liter-llm/src/tower/mod.rs +77 -0
  51. data/vendor/liter-llm/src/tower/rate_limit.rs +300 -0
  52. data/vendor/liter-llm/src/tower/router.rs +436 -0
  53. data/vendor/liter-llm/src/tower/service.rs +181 -0
  54. data/vendor/liter-llm/src/tower/tests.rs +539 -0
  55. data/vendor/liter-llm/src/tower/tests_common.rs +252 -0
  56. data/vendor/liter-llm/src/tower/tracing.rs +209 -0
  57. data/vendor/liter-llm/src/tower/types.rs +170 -0
  58. data/vendor/liter-llm/src/types/audio.rs +52 -0
  59. data/vendor/liter-llm/src/types/batch.rs +77 -0
  60. data/vendor/liter-llm/src/types/chat.rs +214 -0
  61. data/vendor/liter-llm/src/types/common.rs +244 -0
  62. data/vendor/liter-llm/src/types/embedding.rs +84 -0
  63. data/vendor/liter-llm/src/types/files.rs +58 -0
  64. data/vendor/liter-llm/src/types/image.rs +40 -0
  65. data/vendor/liter-llm/src/types/mod.rs +27 -0
  66. data/vendor/liter-llm/src/types/models.rs +21 -0
  67. data/vendor/liter-llm/src/types/moderation.rs +80 -0
  68. data/vendor/liter-llm/src/types/ocr.rs +87 -0
  69. data/vendor/liter-llm/src/types/rerank.rs +46 -0
  70. data/vendor/liter-llm/src/types/responses.rs +55 -0
  71. data/vendor/liter-llm/src/types/search.rs +45 -0
  72. data/vendor/liter-llm/tests/contract.rs +332 -0
  73. data/vendor/liter-llm-ffi/Cargo.toml +30 -0
  74. data/vendor/liter-llm-ffi/build.rs +66 -0
  75. data/vendor/liter-llm-ffi/cbindgen.toml +60 -0
  76. data/vendor/liter-llm-ffi/liter_llm.h +850 -0
  77. data/vendor/liter-llm-ffi/src/lib.rs +2488 -0
  78. metadata +286 -0
@@ -0,0 +1,2250 @@
1
+ use std::borrow::Cow;
2
+
3
+ use serde_json::{Value, json};
4
+
5
+ use crate::error::{LiterLlmError, Result};
6
+ use crate::provider::Provider;
7
+ use crate::types::{ChatCompletionChunk, FinishReason, StreamChoice, StreamDelta, StreamFunctionCall, StreamToolCall};
8
+
9
+ /// Anthropic's stable API version. This is the only GA version as of 2025;
10
+ /// Anthropic gates new features via beta headers (e.g. `anthropic-beta`),
11
+ /// not by bumping the version date.
12
+ static ANTHROPIC_EXTRA_HEADERS: &[(&str, &str)] = &[("anthropic-version", "2023-06-01")];
13
+
14
+ /// Default max_tokens for Anthropic requests when none is specified.
15
+ /// Anthropic requires this field; OpenAI makes it optional.
16
+ const DEFAULT_MAX_TOKENS: u64 = 4096;
17
+
18
+ /// Known Anthropic hosted tool type names that require beta headers.
19
+ const HOSTED_TOOL_TYPES: &[&str] = &[
20
+ "computer_20241022",
21
+ "computer_use_20250124",
22
+ "web_search_20250305",
23
+ "code_execution_20250522",
24
+ ];
25
+
26
+ /// Anthropic beta header values for hosted tool features.
27
+ const BETA_COMPUTER_USE: &str = "computer-use-2025-01-24";
28
+ const BETA_WEB_SEARCH: &str = "web-search-2025-03-05";
29
+ const BETA_CODE_EXECUTION: &str = "code-execution-2025-05-22";
30
+ const BETA_THINKING: &str = "thinking-2025-04-14";
31
+ const BETA_PROMPT_CACHING: &str = "prompt-caching-2024-07-31";
32
+ const BETA_PDFS: &str = "pdfs-2024-09-25";
33
+
34
+ /// Anthropic provider (Claude model family).
35
+ ///
36
+ /// Differences from the OpenAI-compatible baseline:
37
+ /// - Auth uses `x-api-key` instead of `Authorization: Bearer`.
38
+ /// - Requires a mandatory `anthropic-version` header on every request.
39
+ /// - Model names start with `claude-` or are routed via the `anthropic/` prefix.
40
+ /// - Chat endpoint is `/messages`, not `/chat/completions`.
41
+ /// - Request and response JSON formats differ from OpenAI.
42
+ pub struct AnthropicProvider;
43
+
44
+ impl Provider for AnthropicProvider {
45
+ fn name(&self) -> &str {
46
+ "anthropic"
47
+ }
48
+
49
+ fn base_url(&self) -> &str {
50
+ "https://api.anthropic.com/v1"
51
+ }
52
+
53
+ fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
54
+ // Anthropic uses x-api-key, not Authorization: Bearer.
55
+ Some((Cow::Borrowed("x-api-key"), Cow::Borrowed(api_key)))
56
+ }
57
+
58
+ fn extra_headers(&self) -> &'static [(&'static str, &'static str)] {
59
+ ANTHROPIC_EXTRA_HEADERS
60
+ }
61
+
62
+ /// Compute request-dependent beta headers based on the request body.
63
+ ///
64
+ /// Inspects the body for features that require Anthropic beta headers:
65
+ /// - `thinking` field present -> `anthropic-beta: thinking-2025-04-14`
66
+ /// - Hosted tools (computer_use, web_search, code_execution) -> appropriate betas
67
+ ///
68
+ /// Multiple betas are combined with comma separator.
69
+ fn dynamic_headers(&self, body: &serde_json::Value) -> Vec<(String, String)> {
70
+ let mut betas: Vec<&str> = Vec::new();
71
+
72
+ // Check for extended thinking.
73
+ if body.get("thinking").is_some() {
74
+ betas.push(BETA_THINKING);
75
+ }
76
+
77
+ // Check for hosted tools in the tools array.
78
+ if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
79
+ for tool in tools {
80
+ let tool_type = tool.get("type").and_then(|t| t.as_str()).unwrap_or("");
81
+ match tool_type {
82
+ "computer_20241022" | "computer_use_20250124" => {
83
+ if !betas.contains(&BETA_COMPUTER_USE) {
84
+ betas.push(BETA_COMPUTER_USE);
85
+ }
86
+ }
87
+ "web_search_20250305" => {
88
+ if !betas.contains(&BETA_WEB_SEARCH) {
89
+ betas.push(BETA_WEB_SEARCH);
90
+ }
91
+ }
92
+ "code_execution_20250522" => {
93
+ if !betas.contains(&BETA_CODE_EXECUTION) {
94
+ betas.push(BETA_CODE_EXECUTION);
95
+ }
96
+ }
97
+ _ => {}
98
+ }
99
+ }
100
+ }
101
+
102
+ // Check for prompt caching: any `cache_control` field anywhere in the body.
103
+ if body_contains_cache_control(body) && !betas.contains(&BETA_PROMPT_CACHING) {
104
+ betas.push(BETA_PROMPT_CACHING);
105
+ }
106
+
107
+ // Check for PDF/document content blocks.
108
+ if body_contains_document_block(body) && !betas.contains(&BETA_PDFS) {
109
+ betas.push(BETA_PDFS);
110
+ }
111
+
112
+ if betas.is_empty() {
113
+ vec![]
114
+ } else {
115
+ vec![("anthropic-beta".to_owned(), betas.join(","))]
116
+ }
117
+ }
118
+
119
+ fn matches_model(&self, model: &str) -> bool {
120
+ model.starts_with("claude-") || model.starts_with("anthropic/")
121
+ }
122
+
123
+ fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
124
+ model.strip_prefix("anthropic/").unwrap_or(model)
125
+ }
126
+
127
+ /// Anthropic uses `/messages` instead of `/chat/completions`.
128
+ fn chat_completions_path(&self) -> &str {
129
+ "/messages"
130
+ }
131
+
132
+ /// Transform an OpenAI-format request body into Anthropic Messages API format.
133
+ ///
134
+ /// Key differences handled here:
135
+ /// - System messages extracted to top-level `system` field as content blocks.
136
+ /// - User/assistant messages converted to Anthropic content block arrays.
137
+ /// - Tool messages (role=tool) become user messages with `tool_result` blocks.
138
+ /// - Consecutive same-role messages are merged (Anthropic requires alternating roles).
139
+ /// - `max_tokens` defaults to 4096 if not set (Anthropic requires it).
140
+ /// - `stop` renamed to `stop_sequences` and normalised to an array.
141
+ /// - `tool_choice` mapped from OpenAI semantics to Anthropic semantics.
142
+ /// - Tools converted from OpenAI `function` wrappers to Anthropic `input_schema` format.
143
+ /// - Unsupported parameters removed: `n`, `presence_penalty`, `frequency_penalty`,
144
+ /// `logit_bias`, `stream` (the client handles stream separately).
145
+ fn transform_request(&self, body: &mut Value) -> Result<()> {
146
+ // ── 0. Validate messages array is non-empty ────────────────────────────
147
+ // Take ownership of the messages array to avoid cloning.
148
+ let messages = body
149
+ .as_object_mut()
150
+ .and_then(|o| o.remove("messages"))
151
+ .and_then(|v| match v {
152
+ Value::Array(arr) => Some(arr),
153
+ _ => None,
154
+ })
155
+ .unwrap_or_default();
156
+
157
+ if messages.is_empty() {
158
+ return Err(LiterLlmError::BadRequest {
159
+ message: "messages array must not be empty".to_owned(),
160
+ });
161
+ }
162
+
163
+ // ── 1. Extract system messages ────────────────────────────────────────
164
+ let mut system_blocks: Vec<Value> = Vec::new();
165
+ let mut non_system_messages: Vec<Value> = Vec::new();
166
+
167
+ for msg in messages {
168
+ let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
169
+ match role {
170
+ "system" | "developer" => {
171
+ // Both system and developer roles map to Anthropic system content.
172
+ // Content may be a string or a content-block array.
173
+ match msg.get("content") {
174
+ Some(Value::String(s)) if !s.is_empty() => {
175
+ let mut block = json!({"type": "text", "text": s});
176
+ // Propagate cache_control if present.
177
+ if let Some(cc) = msg.get("cache_control") {
178
+ block["cache_control"] = cc.clone();
179
+ }
180
+ system_blocks.push(block);
181
+ }
182
+ Some(Value::Array(parts)) => {
183
+ for part in parts {
184
+ system_blocks.push(part.clone());
185
+ }
186
+ }
187
+ _ => {}
188
+ }
189
+ }
190
+ _ => non_system_messages.push(msg),
191
+ }
192
+ }
193
+
194
+ if !system_blocks.is_empty() {
195
+ body["system"] = json!(system_blocks);
196
+ }
197
+
198
+ // ── 2. Convert non-system messages to Anthropic format ────────────────
199
+ let converted_messages: Vec<Value> = non_system_messages
200
+ .into_iter()
201
+ .map(convert_message_to_anthropic)
202
+ .collect();
203
+
204
+ // ── 3. Merge consecutive same-role messages ───────────────────────────
205
+ // Anthropic requires strictly alternating user/assistant roles.
206
+ let merged_messages = merge_consecutive_same_role(converted_messages);
207
+
208
+ body["messages"] = json!(merged_messages);
209
+
210
+ // ── 4. Ensure max_tokens is present (required by Anthropic) ───────────
211
+ // Also handle the OpenAI alias max_completion_tokens.
212
+ if body.get("max_tokens").is_none() {
213
+ if let Some(mct) = body.get("max_completion_tokens").cloned() {
214
+ body["max_tokens"] = mct;
215
+ } else {
216
+ body["max_tokens"] = json!(DEFAULT_MAX_TOKENS);
217
+ }
218
+ }
219
+ body.as_object_mut().map(|o| o.remove("max_completion_tokens"));
220
+
221
+ // ── 5. Convert stop → stop_sequences (must be array) ─────────────────
222
+ if let Some(stop) = body.as_object_mut().and_then(|o| o.remove("stop")) {
223
+ let stop_sequences = match stop {
224
+ Value::String(s) => json!([s]),
225
+ arr @ Value::Array(_) => arr,
226
+ _ => json!([]),
227
+ };
228
+ body["stop_sequences"] = stop_sequences;
229
+ }
230
+
231
+ // ── 6. Convert tool_choice ─────────────────────────────────────────────
232
+ if let Some(tool_choice) = body.as_object_mut().and_then(|o| o.remove("tool_choice")) {
233
+ let anthropic_tool_choice = convert_tool_choice(&tool_choice);
234
+ match anthropic_tool_choice {
235
+ Some(tc) => {
236
+ body["tool_choice"] = tc;
237
+ }
238
+ None => {
239
+ // tool_choice: "none" → remove tools entirely
240
+ body.as_object_mut().map(|o| o.remove("tools"));
241
+ }
242
+ }
243
+ }
244
+
245
+ // ── 7. Convert tools from OpenAI format to Anthropic format ───────────
246
+ // Hosted tools (computer_use, web_search, code_execution) are passed through
247
+ // as-is in Anthropic's native format; only function-type tools are converted.
248
+ if let Some(tools) = body.as_object_mut().and_then(|o| o.remove("tools"))
249
+ && let Some(tools_array) = tools.as_array()
250
+ {
251
+ let anthropic_tools: Vec<Value> = tools_array
252
+ .iter()
253
+ .map(|tool| {
254
+ let tool_type = tool.get("type").and_then(|t| t.as_str()).unwrap_or("");
255
+ if is_hosted_tool_type(tool_type) {
256
+ // Pass through hosted tool definitions as-is.
257
+ tool.clone()
258
+ } else {
259
+ convert_tool_to_anthropic(tool)
260
+ }
261
+ })
262
+ .collect();
263
+ body["tools"] = json!(anthropic_tools);
264
+ }
265
+
266
+ // ── 7a. Extended thinking / reasoning effort ──────────────────────────
267
+ // Map reasoning_effort (from body or extra_body) to Anthropic's thinking block.
268
+ let reasoning_effort = body
269
+ .as_object_mut()
270
+ .and_then(|o| o.remove("reasoning_effort"))
271
+ .and_then(|v| v.as_str().map(String::from))
272
+ .or_else(|| {
273
+ body.pointer("/extra_body/reasoning_effort")
274
+ .and_then(|v| v.as_str().map(String::from))
275
+ });
276
+
277
+ if let Some(effort) = reasoning_effort {
278
+ let budget_tokens: u64 = match effort.as_str() {
279
+ "low" => 1024,
280
+ "medium" => 4096,
281
+ "high" => 16384,
282
+ _ => 4096, // default to medium for unknown values
283
+ };
284
+ body["thinking"] = json!({
285
+ "type": "enabled",
286
+ "budget_tokens": budget_tokens
287
+ });
288
+
289
+ // Anthropic requires max_tokens >= budget_tokens + 1 when thinking
290
+ // is enabled. Auto-increase if the current value is too low.
291
+ let min_max_tokens = budget_tokens + 1;
292
+ let current_max = body.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
293
+ if current_max < min_max_tokens {
294
+ body["max_tokens"] = json!(min_max_tokens);
295
+ }
296
+ }
297
+
298
+ // ── 7b. Response format → JSON mode ───────────────────────────────────
299
+ // Anthropic doesn't have a native JSON mode, so we prepend system instructions.
300
+ if let Some(response_format) = body.as_object_mut().and_then(|o| o.remove("response_format")) {
301
+ let rf_type = response_format.get("type").and_then(|t| t.as_str()).unwrap_or("");
302
+ match rf_type {
303
+ "json_object" => {
304
+ // Prepend a system instruction to respond with valid JSON.
305
+ let instruction = json!({"type": "text", "text": "Respond with valid JSON only. Do not include any text outside the JSON object."});
306
+ if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) {
307
+ system.insert(0, instruction);
308
+ } else {
309
+ body["system"] = json!([instruction]);
310
+ }
311
+ }
312
+ "json_schema" => {
313
+ // Prepend the schema as a system instruction.
314
+ if let Some(schema_def) = response_format.get("json_schema") {
315
+ let schema_name = schema_def.get("name").and_then(|n| n.as_str()).unwrap_or("output");
316
+ let schema = schema_def.get("schema").cloned().unwrap_or(json!({}));
317
+ let schema_str = serde_json::to_string_pretty(&schema).unwrap_or_default();
318
+ let instruction_text = format!(
319
+ "Respond with valid JSON matching the following schema named '{schema_name}':\n```json\n{schema_str}\n```\nDo not include any text outside the JSON object."
320
+ );
321
+ let instruction = json!({"type": "text", "text": instruction_text});
322
+ if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) {
323
+ system.insert(0, instruction);
324
+ } else {
325
+ body["system"] = json!([instruction]);
326
+ }
327
+ }
328
+ }
329
+ _ => {} // "text" or unknown — no-op
330
+ }
331
+ }
332
+
333
+ // ── 8. Remove unsupported parameters ──────────────────────────────────
334
+ if let Some(obj) = body.as_object_mut() {
335
+ for key in &[
336
+ "n",
337
+ "presence_penalty",
338
+ "frequency_penalty",
339
+ "logit_bias",
340
+ "stream",
341
+ "stream_options",
342
+ "parallel_tool_calls",
343
+ "service_tier",
344
+ "user",
345
+ "reasoning_effort",
346
+ "extra_body",
347
+ ] {
348
+ obj.remove(*key);
349
+ }
350
+ }
351
+
352
+ Ok(())
353
+ }
354
+
355
+ /// Normalize an Anthropic Messages API response into OpenAI chat completion format.
356
+ ///
357
+ /// Anthropic response shape:
358
+ /// ```json
359
+ /// { "id": "msg_...", "type": "message", "role": "assistant",
360
+ /// "content": [{"type": "text", "text": "..."}],
361
+ /// "stop_reason": "end_turn",
362
+ /// "usage": {"input_tokens": N, "output_tokens": M} }
363
+ /// ```
364
+ fn transform_response(&self, body: &mut Value) -> Result<()> {
365
+ // Only transform if this looks like an Anthropic response (has "stop_reason").
366
+ if body.get("stop_reason").is_none() {
367
+ return Ok(());
368
+ }
369
+
370
+ let id = body.get("id").cloned().unwrap_or(json!(""));
371
+ let model = body.get("model").cloned().unwrap_or(json!(""));
372
+
373
+ let content_blocks = body.get("content").and_then(|v| v.as_array()).cloned();
374
+
375
+ // Extract text content from `type: "text"` blocks only.
376
+ // Thinking blocks (`type: "thinking"`) are Anthropic's internal chain-of-thought
377
+ // and are intentionally excluded from the user-facing content field.
378
+ // Citation blocks (`type: "citation"`) are metadata — the text they reference
379
+ // is already included in adjacent text blocks, so they are skipped here.
380
+ let text_content: Option<String> = content_blocks.as_ref().map(|blocks| {
381
+ blocks
382
+ .iter()
383
+ .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
384
+ .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
385
+ .collect::<Vec<_>>()
386
+ .join("")
387
+ });
388
+
389
+ // Extract tool_use blocks into OpenAI-format tool_calls.
390
+ // Treat both "tool_use" and "server_tool_use" the same way.
391
+ let tool_calls: Option<Vec<Value>> = content_blocks.as_ref().map(|blocks| {
392
+ blocks
393
+ .iter()
394
+ .filter(|b| {
395
+ matches!(
396
+ b.get("type").and_then(|t| t.as_str()),
397
+ Some("tool_use") | Some("server_tool_use")
398
+ )
399
+ })
400
+ .map(|b| {
401
+ let arguments = serde_json::to_string(b.get("input").unwrap_or(&json!({}))).unwrap_or_default();
402
+ json!({
403
+ "id": b.get("id").cloned().unwrap_or(json!("")),
404
+ "type": "function",
405
+ "function": {
406
+ "name": b.get("name").cloned().unwrap_or(json!("")),
407
+ "arguments": arguments
408
+ }
409
+ })
410
+ })
411
+ .collect()
412
+ });
413
+
414
+ // Map Anthropic stop_reason → OpenAI finish_reason.
415
+ let stop_reason = body.get("stop_reason").and_then(|v| v.as_str()).unwrap_or("end_turn");
416
+ let finish_reason = map_stop_reason(stop_reason);
417
+
418
+ // Map Anthropic usage → OpenAI usage.
419
+ // Cache tokens count towards prompt tokens for billing purposes.
420
+ let input_tokens = body
421
+ .pointer("/usage/input_tokens")
422
+ .and_then(|v| v.as_u64())
423
+ .unwrap_or(0);
424
+ let cache_creation_tokens = body
425
+ .pointer("/usage/cache_creation_input_tokens")
426
+ .and_then(|v| v.as_u64())
427
+ .unwrap_or(0);
428
+ let cache_read_tokens = body
429
+ .pointer("/usage/cache_read_input_tokens")
430
+ .and_then(|v| v.as_u64())
431
+ .unwrap_or(0);
432
+ let output_tokens = body
433
+ .pointer("/usage/output_tokens")
434
+ .and_then(|v| v.as_u64())
435
+ .unwrap_or(0);
436
+ let prompt_tokens = input_tokens + cache_creation_tokens + cache_read_tokens;
437
+
438
+ // Build the message object — content is null when only tool_calls are present.
439
+ let has_tool_calls = tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
440
+ let message_content = if has_tool_calls && text_content.as_deref().unwrap_or("").is_empty() {
441
+ Value::Null
442
+ } else {
443
+ json!(text_content)
444
+ };
445
+
446
+ let mut message = json!({
447
+ "role": "assistant",
448
+ "content": message_content
449
+ });
450
+
451
+ if let (Some(tc), true) = (tool_calls, has_tool_calls) {
452
+ message["tool_calls"] = json!(tc);
453
+ }
454
+
455
+ *body = json!({
456
+ "id": id,
457
+ "object": "chat.completion",
458
+ "created": super::unix_timestamp_secs(),
459
+ "model": model,
460
+ "choices": [{
461
+ "index": 0,
462
+ "message": message,
463
+ "finish_reason": finish_reason
464
+ }],
465
+ "usage": {
466
+ "prompt_tokens": prompt_tokens,
467
+ "completion_tokens": output_tokens,
468
+ "total_tokens": prompt_tokens + output_tokens
469
+ }
470
+ });
471
+
472
+ Ok(())
473
+ }
474
+
475
+ /// Parse an Anthropic SSE event into an OpenAI-compatible `ChatCompletionChunk`.
476
+ ///
477
+ /// Anthropic event types handled:
478
+ /// - `message_start`: emits a role-only delta chunk.
479
+ /// - `content_block_start`: emits empty delta (tool_use: emits tool_call header chunk).
480
+ /// - `content_block_delta`: emits text, thinking, or tool input JSON delta.
481
+ /// - `message_delta`: emits final chunk with finish_reason and usage.
482
+ /// - `message_stop`: signals end of stream, returns `Ok(None)`.
483
+ /// - `content_block_stop`, `ping`: skipped (returns `Ok(None)` — no content to emit).
484
+ /// - `error`: returns `Err(LiterLlmError::Streaming)`.
485
+ ///
486
+ /// **Note:** The `id` and `model` fields are only populated on the first
487
+ /// chunk (`message_start`). Subsequent chunks emit empty strings for both
488
+ /// fields because this parser is stateless — it cannot carry forward values
489
+ /// from earlier events. This differs from the OpenAI format where every
490
+ /// chunk includes `id` and `model`.
491
+ fn parse_stream_event(&self, event_data: &str) -> Result<Option<ChatCompletionChunk>> {
492
+ // NOTE: `[DONE]` is handled at the SSE parser level; no check needed here.
493
+
494
+ let event: Value = serde_json::from_str(event_data).map_err(|e| LiterLlmError::Streaming {
495
+ message: format!("failed to parse Anthropic SSE event: {e}"),
496
+ })?;
497
+
498
+ let event_type = event.get("type").and_then(|t| t.as_str()).unwrap_or("");
499
+
500
+ match event_type {
501
+ "message_start" => {
502
+ let msg = &event["message"];
503
+ let id = msg.get("id").and_then(|v| v.as_str()).unwrap_or("").to_owned();
504
+ let model = msg.get("model").and_then(|v| v.as_str()).unwrap_or("").to_owned();
505
+
506
+ // Anthropic sends initial usage in message_start (input tokens only).
507
+ // Also account for cache tokens in the prompt count.
508
+ let input_tokens = msg.pointer("/usage/input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
509
+ let cache_creation = msg
510
+ .pointer("/usage/cache_creation_input_tokens")
511
+ .and_then(|v| v.as_u64())
512
+ .unwrap_or(0);
513
+ let cache_read = msg
514
+ .pointer("/usage/cache_read_input_tokens")
515
+ .and_then(|v| v.as_u64())
516
+ .unwrap_or(0);
517
+ let prompt_tokens = input_tokens + cache_creation + cache_read;
518
+
519
+ let usage = if prompt_tokens > 0 {
520
+ Some(crate::types::Usage {
521
+ prompt_tokens,
522
+ completion_tokens: 0,
523
+ total_tokens: prompt_tokens,
524
+ })
525
+ } else {
526
+ None
527
+ };
528
+
529
+ Ok(Some(ChatCompletionChunk {
530
+ id,
531
+ object: "chat.completion.chunk".to_owned(),
532
+ created: super::unix_timestamp_secs(),
533
+ model,
534
+ choices: vec![StreamChoice {
535
+ index: 0,
536
+ delta: StreamDelta {
537
+ role: Some("assistant".to_owned()),
538
+ content: None,
539
+ tool_calls: None,
540
+ function_call: None,
541
+ refusal: None,
542
+ },
543
+ finish_reason: None,
544
+ }],
545
+ usage,
546
+ system_fingerprint: None,
547
+ service_tier: None,
548
+ }))
549
+ }
550
+
551
+ "content_block_start" => {
552
+ // For tool_use blocks, emit the tool_call header (id + name, empty arguments).
553
+ let block = &event["content_block"];
554
+ let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
555
+ // NOTE: We use Anthropic's block index directly, which counts ALL content blocks
556
+ // (text + thinking + tool_use). This differs from OpenAI's sequential tool-call
557
+ // indices (0, 1, 2...) but is safe because:
558
+ // 1. The same index is used in both content_block_start and content_block_delta,
559
+ // so they are consistent with each other.
560
+ // 2. OpenAI clients typically correlate tool calls by `id`, not by index.
561
+ // 3. OpenAI's own streaming format allows index gaps in tool_calls[].
562
+ // Known limitation: indices may have gaps when text/thinking blocks precede tool_use.
563
+ let anthropic_index = event.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
564
+
565
+ if block_type == "tool_use" || block_type == "server_tool_use" {
566
+ let tool_id = block.get("id").and_then(|v| v.as_str()).unwrap_or("").to_owned();
567
+ let tool_name = block.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned();
568
+
569
+ return Ok(Some(make_empty_chunk_with_tool_start(
570
+ anthropic_index,
571
+ tool_id,
572
+ tool_name,
573
+ )));
574
+ }
575
+
576
+ // Text / thinking block start — emit Ok(None) since there is no content yet.
577
+ Ok(None)
578
+ }
579
+
580
+ "content_block_delta" => {
581
+ let delta = &event["delta"];
582
+ let delta_type = delta.get("type").and_then(|t| t.as_str()).unwrap_or("");
583
+ let index = event.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
584
+
585
+ match delta_type {
586
+ "text_delta" => {
587
+ let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("");
588
+ Ok(Some(make_text_chunk("", "", text)))
589
+ }
590
+ "thinking_delta" => {
591
+ // Extended thinking blocks are Anthropic's internal chain-of-thought.
592
+ // Non-streaming already filters thinking blocks from content, so
593
+ // streaming must be consistent: skip them entirely.
594
+ Ok(None)
595
+ }
596
+ "input_json_delta" => {
597
+ // Partial JSON for tool input — emit as tool_call arguments delta.
598
+ let partial_json = delta.get("partial_json").and_then(|v| v.as_str()).unwrap_or("");
599
+ Ok(Some(make_tool_arguments_delta(index, partial_json)))
600
+ }
601
+ _ => Ok(None),
602
+ }
603
+ }
604
+
605
+ "message_delta" => {
606
+ // Final chunk: carries stop_reason and output token count.
607
+ let stop_reason = event.pointer("/delta/stop_reason").and_then(|v| v.as_str());
608
+ let finish_reason = stop_reason.map(map_stop_reason);
609
+ let output_tokens = event.pointer("/usage/output_tokens").and_then(|v| v.as_u64());
610
+
611
+ let finish = finish_reason.map(|fr| match fr {
612
+ "stop" => FinishReason::Stop,
613
+ "length" => FinishReason::Length,
614
+ "tool_calls" => FinishReason::ToolCalls,
615
+ _ => FinishReason::Other,
616
+ });
617
+
618
+ let usage = output_tokens.map(|ct| crate::types::Usage {
619
+ prompt_tokens: 0,
620
+ completion_tokens: ct,
621
+ total_tokens: ct,
622
+ });
623
+
624
+ Ok(Some(ChatCompletionChunk {
625
+ id: String::new(),
626
+ object: "chat.completion.chunk".to_owned(),
627
+ created: super::unix_timestamp_secs(),
628
+ model: String::new(),
629
+ choices: vec![StreamChoice {
630
+ index: 0,
631
+ delta: StreamDelta {
632
+ role: None,
633
+ content: None,
634
+ tool_calls: None,
635
+ function_call: None,
636
+ refusal: None,
637
+ },
638
+ finish_reason: finish,
639
+ }],
640
+ usage,
641
+ system_fingerprint: None,
642
+ service_tier: None,
643
+ }))
644
+ }
645
+
646
+ "message_stop" => Ok(None),
647
+
648
+ "content_block_stop" | "ping" => {
649
+ // These events carry no delta content; return Ok(None).
650
+ Ok(None)
651
+ }
652
+
653
+ "error" => {
654
+ let message = event
655
+ .pointer("/error/message")
656
+ .and_then(|v| v.as_str())
657
+ .unwrap_or("unknown Anthropic streaming error");
658
+ Err(LiterLlmError::Streaming {
659
+ message: message.to_owned(),
660
+ })
661
+ }
662
+
663
+ _ => {
664
+ // Unknown event types are silently skipped.
665
+ Ok(None)
666
+ }
667
+ }
668
+ }
669
+ }
670
+
671
+ // ── Helper functions ──────────────────────────────────────────────────────────
672
+
673
+ /// Sanitize a tool_call_id so it only contains characters allowed by Anthropic: `[a-zA-Z0-9_-]`.
674
+ /// Any other character is replaced with `_`.
675
+ ///
676
+ /// Convert an OpenAI `image_url` URL to an Anthropic image source block.
677
+ ///
678
+ /// Handles two cases:
679
+ /// - Data URIs (`data:<media_type>;base64,<data>`) → base64 source.
680
+ /// - Plain URLs → url source.
681
+ fn convert_image_url_to_anthropic_source(url: &str) -> Value {
682
+ if url.starts_with("data:")
683
+ && let Some((header, data)) = url.split_once(',')
684
+ {
685
+ let media_type = header.trim_start_matches("data:").trim_end_matches(";base64");
686
+ return json!({
687
+ "type": "image",
688
+ "source": {
689
+ "type": "base64",
690
+ "media_type": media_type,
691
+ "data": data
692
+ }
693
+ });
694
+ }
695
+ json!({
696
+ "type": "image",
697
+ "source": {"type": "url", "url": url}
698
+ })
699
+ }
700
+
701
+ /// Returns a borrowed `Cow` when the ID is already valid, avoiding allocation
702
+ /// on the common path (e.g. IDs starting with `toolu_`).
703
+ fn sanitize_tool_call_id(id: &str) -> Cow<'_, str> {
704
+ if id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
705
+ Cow::Borrowed(id)
706
+ } else {
707
+ Cow::Owned(
708
+ id.chars()
709
+ .map(|c| {
710
+ if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
711
+ c
712
+ } else {
713
+ '_'
714
+ }
715
+ })
716
+ .collect(),
717
+ )
718
+ }
719
+ }
720
+
721
+ /// Merge consecutive messages with the same role by concatenating their content blocks.
722
+ /// Anthropic requires strictly alternating user/assistant roles.
723
+ fn merge_consecutive_same_role(messages: Vec<Value>) -> Vec<Value> {
724
+ let mut merged: Vec<Value> = Vec::new();
725
+
726
+ for msg in messages {
727
+ let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
728
+
729
+ if let Some(last) = merged.last_mut() {
730
+ let last_role = last.get("role").and_then(|r| r.as_str()).unwrap_or("");
731
+ if last_role == role {
732
+ // Merge content blocks.
733
+ let incoming_content = match msg.get("content") {
734
+ Some(Value::Array(arr)) => arr.clone(),
735
+ Some(Value::String(s)) => vec![json!({"type": "text", "text": s})],
736
+ Some(other) => vec![json!({"type": "text", "text": other.to_string()})],
737
+ None => vec![],
738
+ };
739
+
740
+ if let Some(Value::Array(existing)) = last.get_mut("content") {
741
+ existing.extend(incoming_content);
742
+ } else {
743
+ // Normalize existing content to array first.
744
+ let existing_content = match last.get("content") {
745
+ Some(Value::String(s)) => vec![json!({"type": "text", "text": s.clone()})],
746
+ Some(Value::Array(arr)) => arr.clone(),
747
+ Some(other) => vec![json!({"type": "text", "text": other.to_string()})],
748
+ None => vec![],
749
+ };
750
+ let mut combined = existing_content;
751
+ combined.extend(incoming_content);
752
+ last["content"] = json!(combined);
753
+ }
754
+ continue;
755
+ }
756
+ }
757
+
758
+ merged.push(msg);
759
+ }
760
+
761
+ merged
762
+ }
763
+
764
+ /// Convert an OpenAI-format message JSON value to Anthropic Messages API format.
765
+ fn convert_message_to_anthropic(msg: Value) -> Value {
766
+ let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
767
+
768
+ match role {
769
+ "user" => {
770
+ let content = convert_user_content_to_anthropic(msg.get("content"));
771
+ let mut user_msg = json!({"role": "user", "content": content});
772
+ // Propagate message-level cache_control to the last content block.
773
+ if let Some(cc) = msg.get("cache_control")
774
+ && let Some(blocks) = user_msg.get_mut("content").and_then(|c| c.as_array_mut())
775
+ && let Some(last) = blocks.last_mut()
776
+ {
777
+ last["cache_control"] = cc.clone();
778
+ }
779
+ user_msg
780
+ }
781
+ "assistant" => {
782
+ let mut blocks: Vec<Value> = Vec::new();
783
+
784
+ // Text content block.
785
+ if let Some(text) = msg.get("content").and_then(|c| c.as_str())
786
+ && !text.is_empty()
787
+ {
788
+ let mut block = json!({"type": "text", "text": text});
789
+ // Propagate cache_control from the assistant message to its text block.
790
+ if let Some(cc) = msg.get("cache_control") {
791
+ block["cache_control"] = cc.clone();
792
+ }
793
+ blocks.push(block);
794
+ }
795
+
796
+ // Tool call blocks.
797
+ if let Some(tool_calls) = msg.get("tool_calls").and_then(|tc| tc.as_array()) {
798
+ for tc in tool_calls {
799
+ let id = tc.get("id").and_then(|v| v.as_str()).unwrap_or("");
800
+ let name = tc.pointer("/function/name").and_then(|v| v.as_str()).unwrap_or("");
801
+ let arguments_str = tc
802
+ .pointer("/function/arguments")
803
+ .and_then(|v| v.as_str())
804
+ .unwrap_or("{}");
805
+ let input: Value = serde_json::from_str(arguments_str).unwrap_or_else(|_| json!({}));
806
+ blocks.push(json!({
807
+ "type": "tool_use",
808
+ "id": id,
809
+ "name": name,
810
+ "input": input
811
+ }));
812
+ }
813
+ }
814
+
815
+ // If no blocks were produced AND there are no tool_calls, emit an empty text block.
816
+ // Do NOT inject empty text when tool_use blocks are present — Anthropic rejects it.
817
+ let has_tool_use = blocks
818
+ .iter()
819
+ .any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"));
820
+ if blocks.is_empty() {
821
+ blocks.push(json!({"type": "text", "text": ""}));
822
+ } else if !has_tool_use {
823
+ // only text blocks: fine as-is
824
+ }
825
+ // When there are tool_use blocks, do not add an empty text block.
826
+
827
+ json!({"role": "assistant", "content": blocks})
828
+ }
829
+ "tool" => {
830
+ // OpenAI tool message → Anthropic user message with tool_result block.
831
+ let raw_id = msg.get("tool_call_id").and_then(|v| v.as_str()).unwrap_or("");
832
+ let tool_use_id = sanitize_tool_call_id(raw_id);
833
+
834
+ // Content may be a string or an array (e.g. images in tool results).
835
+ let result_content = match msg.get("content") {
836
+ Some(Value::Array(arr)) => {
837
+ // Pass through content array (may contain image blocks, etc.)
838
+ arr.iter()
839
+ .map(|part| {
840
+ let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("text");
841
+ match part_type {
842
+ "image_url" => {
843
+ let url = part.pointer("/image_url/url").and_then(|u| u.as_str()).unwrap_or("");
844
+ convert_image_url_to_anthropic_source(url)
845
+ }
846
+ _ => {
847
+ let text = part.get("text").and_then(|t| t.as_str()).unwrap_or("");
848
+ json!({"type": "text", "text": text})
849
+ }
850
+ }
851
+ })
852
+ .collect::<Vec<_>>()
853
+ }
854
+ Some(Value::String(s)) => vec![json!({"type": "text", "text": s})],
855
+ _ => vec![json!({"type": "text", "text": ""})],
856
+ };
857
+
858
+ let mut tool_result_block = json!({
859
+ "type": "tool_result",
860
+ "tool_use_id": tool_use_id,
861
+ "content": result_content
862
+ });
863
+
864
+ // Propagate cache_control if present.
865
+ if let Some(cc) = msg.get("cache_control") {
866
+ tool_result_block["cache_control"] = cc.clone();
867
+ }
868
+
869
+ json!({
870
+ "role": "user",
871
+ "content": [tool_result_block]
872
+ })
873
+ }
874
+ "function" => {
875
+ // Deprecated function-role message — treat as a tool result.
876
+ let name = msg.get("name").and_then(|v| v.as_str()).unwrap_or("");
877
+ let sanitized_name = sanitize_tool_call_id(name);
878
+ let content_text = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
879
+ json!({
880
+ "role": "user",
881
+ "content": [{
882
+ "type": "tool_result",
883
+ "tool_use_id": sanitized_name,
884
+ "content": [{"type": "text", "text": content_text}]
885
+ }]
886
+ })
887
+ }
888
+ _ => {
889
+ // Unknown role — pass through as-is.
890
+ msg
891
+ }
892
+ }
893
+ }
894
+
895
+ /// Convert OpenAI user content (string or content-part array) to Anthropic content blocks.
896
+ fn convert_user_content_to_anthropic(content: Option<&Value>) -> Value {
897
+ match content {
898
+ None => json!([]),
899
+ Some(Value::String(s)) => json!([{"type": "text", "text": s}]),
900
+ Some(Value::Array(parts)) => {
901
+ let blocks: Vec<Value> = parts
902
+ .iter()
903
+ .filter_map(|part| {
904
+ let part_type = part.get("type").and_then(|t| t.as_str())?;
905
+ match part_type {
906
+ "text" => {
907
+ let text = part.get("text").and_then(|t| t.as_str()).unwrap_or("");
908
+ let mut block = json!({"type": "text", "text": text});
909
+ // Propagate cache_control if present.
910
+ if let Some(cc) = part.get("cache_control") {
911
+ block["cache_control"] = cc.clone();
912
+ }
913
+ Some(block)
914
+ }
915
+ "image_url" => {
916
+ let url = part.pointer("/image_url/url").and_then(|u| u.as_str())?;
917
+ let mut block = convert_image_url_to_anthropic_source(url);
918
+ if let Some(cc) = part.get("cache_control") {
919
+ block["cache_control"] = cc.clone();
920
+ }
921
+ Some(block)
922
+ }
923
+ "document" => {
924
+ // Convert ContentPart::Document to Anthropic document block.
925
+ let data = part.pointer("/document/data").and_then(|d| d.as_str())?;
926
+ let media_type = part
927
+ .pointer("/document/media_type")
928
+ .and_then(|m| m.as_str())
929
+ .unwrap_or("application/pdf");
930
+ let mut block = json!({
931
+ "type": "document",
932
+ "source": {
933
+ "type": "base64",
934
+ "media_type": media_type,
935
+ "data": data
936
+ }
937
+ });
938
+ if let Some(cc) = part.get("cache_control") {
939
+ block["cache_control"] = cc.clone();
940
+ }
941
+ Some(block)
942
+ }
943
+ _ => {
944
+ // Unknown content part types: fall back to text representation
945
+ // and log a warning so callers can investigate.
946
+ #[cfg(feature = "tracing")]
947
+ tracing::warn!(
948
+ part_type = part_type,
949
+ "unrecognized user content part type; falling back to text"
950
+ );
951
+ let text = part.get("text").and_then(|t| t.as_str()).unwrap_or("");
952
+ if text.is_empty() {
953
+ None
954
+ } else {
955
+ Some(json!({"type": "text", "text": text}))
956
+ }
957
+ }
958
+ }
959
+ })
960
+ .collect();
961
+ json!(blocks)
962
+ }
963
+ Some(other) => json!([{"type": "text", "text": other.to_string()}]),
964
+ }
965
+ }
966
+
967
+ /// Map an OpenAI `tool_choice` value to Anthropic format.
968
+ ///
969
+ /// Returns `None` when the tool_choice means "none" (tools should be removed entirely).
970
+ fn convert_tool_choice(tool_choice: &Value) -> Option<Value> {
971
+ match tool_choice {
972
+ Value::String(s) => match s.as_str() {
973
+ "none" => None,
974
+ "required" => Some(json!({"type": "any"})),
975
+ _ => Some(json!({"type": "auto"})),
976
+ },
977
+ Value::Object(_) => {
978
+ // {"type": "function", "function": {"name": "X"}} → {"type": "tool", "name": "X"}
979
+ let name = tool_choice.pointer("/function/name").and_then(|v| v.as_str());
980
+ if let Some(name) = name {
981
+ Some(json!({"type": "tool", "name": name}))
982
+ } else {
983
+ Some(json!({"type": "auto"}))
984
+ }
985
+ }
986
+ _ => Some(json!({"type": "auto"})),
987
+ }
988
+ }
989
+
990
+ /// Convert an OpenAI tool definition to Anthropic format.
991
+ ///
992
+ /// OpenAI: `{"type": "function", "function": {"name": "X", "description": "Y", "parameters": Z}}`
993
+ /// Anthropic: `{"name": "X", "description": "Y", "input_schema": Z}`
994
+ ///
995
+ /// Also normalises `input_schema.type` to `"object"` if absent or mis-typed.
996
+ fn convert_tool_to_anthropic(tool: &Value) -> Value {
997
+ let function = tool.get("function");
998
+ let name = function.and_then(|f| f.get("name")).cloned().unwrap_or(json!(""));
999
+ let description = function.and_then(|f| f.get("description")).cloned();
1000
+ let mut parameters = function
1001
+ .and_then(|f| f.get("parameters"))
1002
+ .cloned()
1003
+ .unwrap_or(json!({"type": "object", "properties": {}}));
1004
+
1005
+ // Normalize input_schema.type to "object" — Anthropic rejects other values.
1006
+ if parameters.get("type").and_then(|t| t.as_str()) != Some("object") {
1007
+ parameters["type"] = json!("object");
1008
+ }
1009
+
1010
+ let mut tool_def = json!({
1011
+ "name": name,
1012
+ "input_schema": parameters
1013
+ });
1014
+
1015
+ if let Some(desc) = description {
1016
+ tool_def["description"] = desc;
1017
+ }
1018
+
1019
+ // Propagate cache_control if present on the tool definition.
1020
+ if let Some(cc) = tool.get("cache_control") {
1021
+ tool_def["cache_control"] = cc.clone();
1022
+ } else if let Some(cc) = function.and_then(|f| f.get("cache_control")) {
1023
+ tool_def["cache_control"] = cc.clone();
1024
+ }
1025
+
1026
+ tool_def
1027
+ }
1028
+
1029
+ /// Check whether a tool type string represents an Anthropic hosted tool.
1030
+ fn is_hosted_tool_type(tool_type: &str) -> bool {
1031
+ HOSTED_TOOL_TYPES.contains(&tool_type)
1032
+ }
1033
+
1034
+ /// Return `true` if any `cache_control` key appears anywhere in the JSON body.
1035
+ ///
1036
+ /// Searches messages, system blocks, and tool definitions recursively.
1037
+ fn body_contains_cache_control(body: &Value) -> bool {
1038
+ match body {
1039
+ Value::Object(map) => {
1040
+ if map.contains_key("cache_control") {
1041
+ return true;
1042
+ }
1043
+ map.values().any(body_contains_cache_control)
1044
+ }
1045
+ Value::Array(arr) => arr.iter().any(body_contains_cache_control),
1046
+ _ => false,
1047
+ }
1048
+ }
1049
+
1050
+ /// Return `true` if the body contains any content block with `"type": "document"`.
1051
+ ///
1052
+ /// Scans the messages array for document content parts (PDF uploads, etc.).
1053
+ fn body_contains_document_block(body: &Value) -> bool {
1054
+ if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) {
1055
+ for msg in messages {
1056
+ if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
1057
+ for part in content {
1058
+ if part.get("type").and_then(|t| t.as_str()) == Some("document") {
1059
+ return true;
1060
+ }
1061
+ }
1062
+ }
1063
+ }
1064
+ }
1065
+ false
1066
+ }
1067
+
1068
+ /// Map an Anthropic `stop_reason` string to an OpenAI `finish_reason` string.
1069
+ fn map_stop_reason(stop_reason: &str) -> &'static str {
1070
+ match stop_reason {
1071
+ "end_turn" | "stop_sequence" => "stop",
1072
+ "tool_use" => "tool_calls",
1073
+ "max_tokens" => "length",
1074
+ "content_filtered" | "refusal" => "content_filter",
1075
+ _ => "stop",
1076
+ }
1077
+ }
1078
+
1079
+ /// Build a `ChatCompletionChunk` with a text content delta.
1080
+ fn make_text_chunk(id: &str, model: &str, text: &str) -> ChatCompletionChunk {
1081
+ ChatCompletionChunk {
1082
+ id: id.to_owned(),
1083
+ object: "chat.completion.chunk".to_owned(),
1084
+ created: super::unix_timestamp_secs(),
1085
+ model: model.to_owned(),
1086
+ choices: vec![StreamChoice {
1087
+ index: 0,
1088
+ delta: StreamDelta {
1089
+ role: None,
1090
+ content: Some(text.to_owned()),
1091
+ tool_calls: None,
1092
+ function_call: None,
1093
+ refusal: None,
1094
+ },
1095
+ finish_reason: None,
1096
+ }],
1097
+ usage: None,
1098
+ system_fingerprint: None,
1099
+ service_tier: None,
1100
+ }
1101
+ }
1102
+
1103
+ /// Build a `ChatCompletionChunk` that starts a tool call (id + name, no arguments yet).
1104
+ fn make_empty_chunk_with_tool_start(tool_index: u32, tool_id: String, tool_name: String) -> ChatCompletionChunk {
1105
+ ChatCompletionChunk {
1106
+ id: String::new(),
1107
+ object: "chat.completion.chunk".to_owned(),
1108
+ created: super::unix_timestamp_secs(),
1109
+ model: String::new(),
1110
+ choices: vec![StreamChoice {
1111
+ index: 0,
1112
+ delta: StreamDelta {
1113
+ role: None,
1114
+ content: None,
1115
+ tool_calls: Some(vec![StreamToolCall {
1116
+ index: tool_index,
1117
+ id: Some(tool_id),
1118
+ call_type: Some(crate::types::ToolType::Function),
1119
+ function: Some(StreamFunctionCall {
1120
+ name: Some(tool_name),
1121
+ arguments: None,
1122
+ }),
1123
+ }]),
1124
+ function_call: None,
1125
+ refusal: None,
1126
+ },
1127
+ finish_reason: None,
1128
+ }],
1129
+ usage: None,
1130
+ system_fingerprint: None,
1131
+ service_tier: None,
1132
+ }
1133
+ }
1134
+
1135
+ /// Build a `ChatCompletionChunk` that carries a partial tool arguments JSON delta.
1136
+ fn make_tool_arguments_delta(tool_index: u32, partial_json: &str) -> ChatCompletionChunk {
1137
+ ChatCompletionChunk {
1138
+ id: String::new(),
1139
+ object: "chat.completion.chunk".to_owned(),
1140
+ created: super::unix_timestamp_secs(),
1141
+ model: String::new(),
1142
+ choices: vec![StreamChoice {
1143
+ index: 0,
1144
+ delta: StreamDelta {
1145
+ role: None,
1146
+ content: None,
1147
+ tool_calls: Some(vec![StreamToolCall {
1148
+ index: tool_index,
1149
+ id: None,
1150
+ call_type: None,
1151
+ function: Some(StreamFunctionCall {
1152
+ name: None,
1153
+ arguments: Some(partial_json.to_owned()),
1154
+ }),
1155
+ }]),
1156
+ function_call: None,
1157
+ refusal: None,
1158
+ },
1159
+ finish_reason: None,
1160
+ }],
1161
+ usage: None,
1162
+ system_fingerprint: None,
1163
+ service_tier: None,
1164
+ }
1165
+ }
1166
+
1167
+ // ── Unit tests ────────────────────────────────────────────────────────────────
1168
+
1169
+ #[cfg(test)]
1170
+ mod tests {
1171
+ use serde_json::json;
1172
+
1173
+ use super::*;
1174
+
1175
+ fn provider() -> AnthropicProvider {
1176
+ AnthropicProvider
1177
+ }
1178
+
1179
+ // ── transform_request tests ───────────────────────────────────────────────
1180
+
1181
+ #[test]
1182
+ fn transform_request_extracts_system_message() {
1183
+ let mut body = json!({
1184
+ "model": "claude-3-5-sonnet-20241022",
1185
+ "messages": [
1186
+ {"role": "system", "content": "You are a helpful assistant."},
1187
+ {"role": "user", "content": "Hello!"}
1188
+ ]
1189
+ });
1190
+
1191
+ provider().transform_request(&mut body).unwrap();
1192
+
1193
+ // System messages lifted to top-level `system` field.
1194
+ assert_eq!(
1195
+ body["system"],
1196
+ json!([{"type": "text", "text": "You are a helpful assistant."}])
1197
+ );
1198
+
1199
+ // Only the user message remains in `messages`.
1200
+ let messages = body["messages"].as_array().unwrap();
1201
+ assert_eq!(messages.len(), 1);
1202
+ assert_eq!(messages[0]["role"], "user");
1203
+ }
1204
+
1205
+ #[test]
1206
+ fn transform_request_multiple_system_messages_merged() {
1207
+ let mut body = json!({
1208
+ "model": "claude-3-5-sonnet-20241022",
1209
+ "messages": [
1210
+ {"role": "system", "content": "First instruction."},
1211
+ {"role": "system", "content": "Second instruction."},
1212
+ {"role": "user", "content": "Question"}
1213
+ ]
1214
+ });
1215
+
1216
+ provider().transform_request(&mut body).unwrap();
1217
+
1218
+ let system = body["system"].as_array().unwrap();
1219
+ assert_eq!(system.len(), 2);
1220
+ assert_eq!(system[0]["text"], "First instruction.");
1221
+ assert_eq!(system[1]["text"], "Second instruction.");
1222
+ }
1223
+
1224
+ #[test]
1225
+ fn transform_request_defaults_max_tokens() {
1226
+ let mut body = json!({
1227
+ "model": "claude-3-5-sonnet-20241022",
1228
+ "messages": [{"role": "user", "content": "Hi"}]
1229
+ });
1230
+
1231
+ provider().transform_request(&mut body).unwrap();
1232
+
1233
+ assert_eq!(body["max_tokens"], json!(DEFAULT_MAX_TOKENS));
1234
+ }
1235
+
1236
+ #[test]
1237
+ fn transform_request_preserves_explicit_max_tokens() {
1238
+ let mut body = json!({
1239
+ "model": "claude-3-5-sonnet-20241022",
1240
+ "messages": [{"role": "user", "content": "Hi"}],
1241
+ "max_tokens": 1024
1242
+ });
1243
+
1244
+ provider().transform_request(&mut body).unwrap();
1245
+
1246
+ assert_eq!(body["max_tokens"], json!(1024u64));
1247
+ }
1248
+
1249
+ #[test]
1250
+ fn transform_request_converts_stop_string_to_array() {
1251
+ let mut body = json!({
1252
+ "model": "claude-3-5-sonnet-20241022",
1253
+ "messages": [{"role": "user", "content": "Hi"}],
1254
+ "stop": "\n"
1255
+ });
1256
+
1257
+ provider().transform_request(&mut body).unwrap();
1258
+
1259
+ assert_eq!(body["stop_sequences"], json!(["\n"]));
1260
+ assert!(body.get("stop").is_none(), "old `stop` key should be removed");
1261
+ }
1262
+
1263
+ #[test]
1264
+ fn transform_request_stop_array_passes_through() {
1265
+ let mut body = json!({
1266
+ "model": "claude-3-5-sonnet-20241022",
1267
+ "messages": [{"role": "user", "content": "Hi"}],
1268
+ "stop": ["STOP", "END"]
1269
+ });
1270
+
1271
+ provider().transform_request(&mut body).unwrap();
1272
+
1273
+ assert_eq!(body["stop_sequences"], json!(["STOP", "END"]));
1274
+ assert!(body.get("stop").is_none());
1275
+ }
1276
+
1277
+ #[test]
1278
+ fn transform_request_tool_choice_required_maps_to_any() {
1279
+ let mut body = json!({
1280
+ "model": "claude-3-5-sonnet-20241022",
1281
+ "messages": [{"role": "user", "content": "Hi"}],
1282
+ "tool_choice": "required",
1283
+ "tools": [{"type": "function", "function": {"name": "f", "parameters": {}}}]
1284
+ });
1285
+
1286
+ provider().transform_request(&mut body).unwrap();
1287
+
1288
+ assert_eq!(body["tool_choice"], json!({"type": "any"}));
1289
+ }
1290
+
1291
+ #[test]
1292
+ fn transform_request_tool_choice_none_removes_tools() {
1293
+ let mut body = json!({
1294
+ "model": "claude-3-5-sonnet-20241022",
1295
+ "messages": [{"role": "user", "content": "Hi"}],
1296
+ "tool_choice": "none",
1297
+ "tools": [{"type": "function", "function": {"name": "f", "parameters": {}}}]
1298
+ });
1299
+
1300
+ provider().transform_request(&mut body).unwrap();
1301
+
1302
+ assert!(body.get("tool_choice").is_none(), "tool_choice should be removed");
1303
+ assert!(
1304
+ body.get("tools").is_none(),
1305
+ "tools should be removed for tool_choice=none"
1306
+ );
1307
+ }
1308
+
1309
+ #[test]
1310
+ fn transform_request_tool_choice_specific_function() {
1311
+ let mut body = json!({
1312
+ "model": "claude-3-5-sonnet-20241022",
1313
+ "messages": [{"role": "user", "content": "Hi"}],
1314
+ "tool_choice": {"type": "function", "function": {"name": "my_tool"}},
1315
+ "tools": [{"type": "function", "function": {"name": "my_tool", "parameters": {}}}]
1316
+ });
1317
+
1318
+ provider().transform_request(&mut body).unwrap();
1319
+
1320
+ assert_eq!(body["tool_choice"], json!({"type": "tool", "name": "my_tool"}));
1321
+ }
1322
+
1323
+ #[test]
1324
+ fn transform_request_converts_tools_to_anthropic_format() {
1325
+ let mut body = json!({
1326
+ "model": "claude-3-5-sonnet-20241022",
1327
+ "messages": [{"role": "user", "content": "Hi"}],
1328
+ "tools": [{
1329
+ "type": "function",
1330
+ "function": {
1331
+ "name": "get_weather",
1332
+ "description": "Get current weather",
1333
+ "parameters": {"type": "object", "properties": {}}
1334
+ }
1335
+ }]
1336
+ });
1337
+
1338
+ provider().transform_request(&mut body).unwrap();
1339
+
1340
+ let tools = body["tools"].as_array().unwrap();
1341
+ assert_eq!(tools.len(), 1);
1342
+ assert_eq!(tools[0]["name"], "get_weather");
1343
+ assert_eq!(tools[0]["description"], "Get current weather");
1344
+ assert!(tools[0].get("input_schema").is_some());
1345
+ // OpenAI-style "function" wrapper should be gone.
1346
+ assert!(tools[0].get("function").is_none());
1347
+ }
1348
+
1349
+ #[test]
1350
+ fn transform_request_removes_unsupported_fields() {
1351
+ let mut body = json!({
1352
+ "model": "claude-3-5-sonnet-20241022",
1353
+ "messages": [{"role": "user", "content": "Hi"}],
1354
+ "n": 2,
1355
+ "presence_penalty": 0.5,
1356
+ "frequency_penalty": 0.3,
1357
+ "logit_bias": {"1234": 5},
1358
+ "stream": true
1359
+ });
1360
+
1361
+ provider().transform_request(&mut body).unwrap();
1362
+
1363
+ for key in &["n", "presence_penalty", "frequency_penalty", "logit_bias", "stream"] {
1364
+ assert!(body.get(key).is_none(), "`{key}` should be removed");
1365
+ }
1366
+ }
1367
+
1368
+ #[test]
1369
+ fn transform_request_converts_tool_message_to_tool_result() {
1370
+ let mut body = json!({
1371
+ "model": "claude-3-5-sonnet-20241022",
1372
+ "messages": [
1373
+ {"role": "user", "content": "What is the weather?"},
1374
+ {"role": "assistant", "content": null, "tool_calls": [{
1375
+ "id": "call_abc",
1376
+ "type": "function",
1377
+ "function": {"name": "get_weather", "arguments": "{\"location\": \"London\"}"}
1378
+ }]},
1379
+ {"role": "tool", "tool_call_id": "call_abc", "content": "15°C, sunny"}
1380
+ ]
1381
+ });
1382
+
1383
+ provider().transform_request(&mut body).unwrap();
1384
+
1385
+ let messages = body["messages"].as_array().unwrap();
1386
+ // tool message → user message with tool_result block
1387
+ let tool_result_msg = &messages[2];
1388
+ assert_eq!(tool_result_msg["role"], "user");
1389
+ let content = tool_result_msg["content"].as_array().unwrap();
1390
+ assert_eq!(content[0]["type"], "tool_result");
1391
+ assert_eq!(content[0]["tool_use_id"], "call_abc");
1392
+ }
1393
+
1394
+ #[test]
1395
+ fn transform_request_converts_user_content_parts() {
1396
+ let mut body = json!({
1397
+ "model": "claude-3-5-sonnet-20241022",
1398
+ "messages": [{
1399
+ "role": "user",
1400
+ "content": [
1401
+ {"type": "text", "text": "What is in this image?"},
1402
+ {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/abc=="}}
1403
+ ]
1404
+ }]
1405
+ });
1406
+
1407
+ provider().transform_request(&mut body).unwrap();
1408
+
1409
+ let messages = body["messages"].as_array().unwrap();
1410
+ let content = messages[0]["content"].as_array().unwrap();
1411
+ assert_eq!(content[0]["type"], "text");
1412
+ assert_eq!(content[1]["type"], "image");
1413
+ assert_eq!(content[1]["source"]["type"], "base64");
1414
+ assert_eq!(content[1]["source"]["media_type"], "image/jpeg");
1415
+ }
1416
+
1417
+ // ── transform_response tests ──────────────────────────────────────────────
1418
+
1419
+ #[test]
1420
+ fn transform_response_basic_text() {
1421
+ let mut body = json!({
1422
+ "id": "msg_01Xfn7",
1423
+ "type": "message",
1424
+ "role": "assistant",
1425
+ "content": [{"type": "text", "text": "Hello, world!"}],
1426
+ "model": "claude-3-5-sonnet-20241022",
1427
+ "stop_reason": "end_turn",
1428
+ "usage": {"input_tokens": 10, "output_tokens": 5}
1429
+ });
1430
+
1431
+ provider().transform_response(&mut body).unwrap();
1432
+
1433
+ assert_eq!(body["object"], "chat.completion");
1434
+ assert_eq!(body["id"], "msg_01Xfn7");
1435
+ let choice = &body["choices"][0];
1436
+ assert_eq!(choice["message"]["content"], "Hello, world!");
1437
+ assert_eq!(choice["finish_reason"], "stop");
1438
+ assert_eq!(body["usage"]["prompt_tokens"], 10);
1439
+ assert_eq!(body["usage"]["completion_tokens"], 5);
1440
+ assert_eq!(body["usage"]["total_tokens"], 15);
1441
+ }
1442
+
1443
+ #[test]
1444
+ fn transform_response_stop_reason_max_tokens_maps_to_length() {
1445
+ let mut body = json!({
1446
+ "id": "msg_abc",
1447
+ "type": "message",
1448
+ "role": "assistant",
1449
+ "content": [{"type": "text", "text": "truncated"}],
1450
+ "model": "claude-3-haiku-20240307",
1451
+ "stop_reason": "max_tokens",
1452
+ "usage": {"input_tokens": 5, "output_tokens": 50}
1453
+ });
1454
+
1455
+ provider().transform_response(&mut body).unwrap();
1456
+
1457
+ assert_eq!(body["choices"][0]["finish_reason"], "length");
1458
+ }
1459
+
1460
+ #[test]
1461
+ fn transform_response_tool_use_block() {
1462
+ let mut body = json!({
1463
+ "id": "msg_tool",
1464
+ "type": "message",
1465
+ "role": "assistant",
1466
+ "content": [{
1467
+ "type": "tool_use",
1468
+ "id": "toolu_01abc",
1469
+ "name": "get_weather",
1470
+ "input": {"location": "London"}
1471
+ }],
1472
+ "model": "claude-3-5-sonnet-20241022",
1473
+ "stop_reason": "tool_use",
1474
+ "usage": {"input_tokens": 20, "output_tokens": 10}
1475
+ });
1476
+
1477
+ provider().transform_response(&mut body).unwrap();
1478
+
1479
+ let choice = &body["choices"][0];
1480
+ assert_eq!(choice["finish_reason"], "tool_calls");
1481
+ assert_eq!(choice["message"]["content"], Value::Null);
1482
+
1483
+ let tool_calls = choice["message"]["tool_calls"].as_array().unwrap();
1484
+ assert_eq!(tool_calls.len(), 1);
1485
+ assert_eq!(tool_calls[0]["id"], "toolu_01abc");
1486
+ assert_eq!(tool_calls[0]["function"]["name"], "get_weather");
1487
+
1488
+ // arguments must be a JSON string
1489
+ let args_str = tool_calls[0]["function"]["arguments"].as_str().unwrap();
1490
+ let args: Value = serde_json::from_str(args_str).unwrap();
1491
+ assert_eq!(args["location"], "London");
1492
+ }
1493
+
1494
+ #[test]
1495
+ fn transform_response_is_noop_for_openai_format() {
1496
+ // A body without "stop_reason" should be left unchanged (already OpenAI format).
1497
+ let original = json!({
1498
+ "id": "chatcmpl-xxx",
1499
+ "object": "chat.completion",
1500
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}]
1501
+ });
1502
+ let mut body = original.clone();
1503
+
1504
+ provider().transform_response(&mut body).unwrap();
1505
+
1506
+ assert_eq!(body, original);
1507
+ }
1508
+
1509
+ // ── parse_stream_event tests ──────────────────────────────────────────────
1510
+
1511
+ #[test]
1512
+ fn parse_stream_event_done_is_handled_at_sse_level() {
1513
+ // `[DONE]` is now caught by the SSE parser before reaching the provider.
1514
+ // If it were to reach the provider, it would be invalid JSON.
1515
+ let result = provider().parse_stream_event("[DONE]");
1516
+ assert!(
1517
+ result.is_err(),
1518
+ "[DONE] is not valid JSON and should error if it reaches the provider"
1519
+ );
1520
+ }
1521
+
1522
+ #[test]
1523
+ fn parse_stream_event_message_stop_returns_none() {
1524
+ let event = r#"{"type":"message_stop"}"#;
1525
+ let result = provider().parse_stream_event(event).unwrap();
1526
+ assert!(result.is_none());
1527
+ }
1528
+
1529
+ #[test]
1530
+ fn parse_stream_event_text_delta() {
1531
+ let event = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
1532
+ let chunk = provider().parse_stream_event(event).unwrap().expect("expected chunk");
1533
+ assert_eq!(chunk.choices[0].delta.content.as_deref(), Some("Hello"));
1534
+ }
1535
+
1536
+ #[test]
1537
+ fn parse_stream_event_message_delta_with_finish_reason() {
1538
+ let event = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":12}}"#;
1539
+ let chunk = provider().parse_stream_event(event).unwrap().expect("expected chunk");
1540
+ assert_eq!(chunk.choices[0].finish_reason, Some(FinishReason::Stop));
1541
+ let usage = chunk.usage.unwrap();
1542
+ assert_eq!(usage.completion_tokens, 12);
1543
+ }
1544
+
1545
+ #[test]
1546
+ fn parse_stream_event_message_delta_tool_use_stop_reason() {
1547
+ let event = r#"{"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":5}}"#;
1548
+ let chunk = provider().parse_stream_event(event).unwrap().expect("expected chunk");
1549
+ assert_eq!(chunk.choices[0].finish_reason, Some(FinishReason::ToolCalls));
1550
+ }
1551
+
1552
+ #[test]
1553
+ fn parse_stream_event_message_start() {
1554
+ let event = r#"{"type":"message_start","message":{"id":"msg_abc","type":"message","role":"assistant","content":[],"model":"claude-3-5-sonnet-20241022","stop_reason":null,"usage":{"input_tokens":25,"output_tokens":1}}}"#;
1555
+ let chunk = provider().parse_stream_event(event).unwrap().expect("expected chunk");
1556
+ assert_eq!(chunk.id, "msg_abc");
1557
+ assert_eq!(chunk.model, "claude-3-5-sonnet-20241022");
1558
+ assert_eq!(chunk.choices[0].delta.role.as_deref(), Some("assistant"));
1559
+ let usage = chunk.usage.unwrap();
1560
+ assert_eq!(usage.prompt_tokens, 25);
1561
+ }
1562
+
1563
+ #[test]
1564
+ fn parse_stream_event_input_json_delta() {
1565
+ let event =
1566
+ r#"{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"loc"}}"#;
1567
+ let chunk = provider().parse_stream_event(event).unwrap().expect("expected chunk");
1568
+ let tc = &chunk.choices[0].delta.tool_calls.as_ref().unwrap()[0];
1569
+ assert_eq!(tc.function.as_ref().unwrap().arguments.as_deref(), Some("{\"loc"));
1570
+ }
1571
+
1572
+ #[test]
1573
+ fn parse_stream_event_error_returns_err() {
1574
+ let event = r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#;
1575
+ let result = provider().parse_stream_event(event);
1576
+ assert!(result.is_err());
1577
+ let err = result.unwrap_err();
1578
+ assert!(err.to_string().contains("Overloaded"));
1579
+ }
1580
+
1581
+ #[test]
1582
+ fn parse_stream_event_ping_returns_none() {
1583
+ let event = r#"{"type":"ping"}"#;
1584
+ let result = provider().parse_stream_event(event).unwrap();
1585
+ assert!(result.is_none(), "ping should return Ok(None), not a chunk");
1586
+ }
1587
+
1588
+ #[test]
1589
+ fn parse_stream_event_content_block_stop_returns_none() {
1590
+ let event = r#"{"type":"content_block_stop","index":0}"#;
1591
+ let result = provider().parse_stream_event(event).unwrap();
1592
+ assert!(result.is_none(), "content_block_stop should return Ok(None)");
1593
+ }
1594
+
1595
+ // ── chat_completions_path test ────────────────────────────────────────────
1596
+
1597
+ #[test]
1598
+ fn chat_completions_path_is_messages() {
1599
+ assert_eq!(provider().chat_completions_path(), "/messages");
1600
+ }
1601
+
1602
+ // ── new tests for fixed issues ────────────────────────────────────────────
1603
+
1604
+ #[test]
1605
+ fn transform_request_empty_messages_returns_error() {
1606
+ let mut body = json!({
1607
+ "model": "claude-3-5-sonnet-20241022",
1608
+ "messages": []
1609
+ });
1610
+ let result = provider().transform_request(&mut body);
1611
+ assert!(result.is_err(), "empty messages should return an error");
1612
+ }
1613
+
1614
+ #[test]
1615
+ fn transform_request_sanitizes_tool_call_id() {
1616
+ let mut body = json!({
1617
+ "model": "claude-3-5-sonnet-20241022",
1618
+ "messages": [
1619
+ {"role": "user", "content": "What is the weather?"},
1620
+ {"role": "assistant", "content": null, "tool_calls": [{
1621
+ "id": "call_abc.123",
1622
+ "type": "function",
1623
+ "function": {"name": "get_weather", "arguments": "{}"}
1624
+ }]},
1625
+ {"role": "tool", "tool_call_id": "call_abc.123", "content": "Sunny"}
1626
+ ]
1627
+ });
1628
+ provider().transform_request(&mut body).unwrap();
1629
+ let messages = body["messages"].as_array().unwrap();
1630
+ // The tool_result message is the last one (index 2 after merging nothing)
1631
+ let tool_result_msg = messages
1632
+ .iter()
1633
+ .find(|m| m["role"] == "user" && m["content"][0]["type"] == "tool_result")
1634
+ .unwrap();
1635
+ // Dots should be replaced with underscores.
1636
+ assert_eq!(tool_result_msg["content"][0]["tool_use_id"], "call_abc_123");
1637
+ }
1638
+
1639
+ #[test]
1640
+ fn transform_request_merges_consecutive_user_messages() {
1641
+ let mut body = json!({
1642
+ "model": "claude-3-5-sonnet-20241022",
1643
+ "messages": [
1644
+ {"role": "user", "content": "First"},
1645
+ {"role": "user", "content": "Second"}
1646
+ ]
1647
+ });
1648
+ provider().transform_request(&mut body).unwrap();
1649
+ let messages = body["messages"].as_array().unwrap();
1650
+ // Two consecutive user messages should be merged into one.
1651
+ assert_eq!(messages.len(), 1);
1652
+ assert_eq!(messages[0]["role"], "user");
1653
+ let content = messages[0]["content"].as_array().unwrap();
1654
+ assert_eq!(content.len(), 2);
1655
+ }
1656
+
1657
+ #[test]
1658
+ fn transform_request_system_content_array_passed_through() {
1659
+ let mut body = json!({
1660
+ "model": "claude-3-5-sonnet-20241022",
1661
+ "messages": [
1662
+ {"role": "system", "content": [
1663
+ {"type": "text", "text": "Block one"},
1664
+ {"type": "text", "text": "Block two"}
1665
+ ]},
1666
+ {"role": "user", "content": "Hello"}
1667
+ ]
1668
+ });
1669
+ provider().transform_request(&mut body).unwrap();
1670
+ let system = body["system"].as_array().unwrap();
1671
+ assert_eq!(system.len(), 2);
1672
+ assert_eq!(system[0]["text"], "Block one");
1673
+ }
1674
+
1675
+ #[test]
1676
+ fn transform_request_system_cache_control_propagated() {
1677
+ let mut body = json!({
1678
+ "model": "claude-3-5-sonnet-20241022",
1679
+ "messages": [
1680
+ {"role": "system", "content": "Cached instructions", "cache_control": {"type": "ephemeral"}},
1681
+ {"role": "user", "content": "Hi"}
1682
+ ]
1683
+ });
1684
+ provider().transform_request(&mut body).unwrap();
1685
+ let system = body["system"].as_array().unwrap();
1686
+ assert_eq!(system[0]["cache_control"]["type"], "ephemeral");
1687
+ }
1688
+
1689
+ #[test]
1690
+ fn transform_request_user_content_cache_control_propagated() {
1691
+ let mut body = json!({
1692
+ "model": "claude-3-5-sonnet-20241022",
1693
+ "messages": [{
1694
+ "role": "user",
1695
+ "content": [
1696
+ {"type": "text", "text": "Cached text", "cache_control": {"type": "ephemeral"}}
1697
+ ]
1698
+ }]
1699
+ });
1700
+ provider().transform_request(&mut body).unwrap();
1701
+ let messages = body["messages"].as_array().unwrap();
1702
+ let content = messages[0]["content"].as_array().unwrap();
1703
+ assert_eq!(content[0]["cache_control"]["type"], "ephemeral");
1704
+ }
1705
+
1706
+ #[test]
1707
+ fn transform_request_tool_input_schema_type_normalized() {
1708
+ let mut body = json!({
1709
+ "model": "claude-3-5-sonnet-20241022",
1710
+ "messages": [{"role": "user", "content": "Hi"}],
1711
+ "tools": [{
1712
+ "type": "function",
1713
+ "function": {
1714
+ "name": "my_tool",
1715
+ "parameters": {"properties": {}}
1716
+ // no "type" field
1717
+ }
1718
+ }]
1719
+ });
1720
+ provider().transform_request(&mut body).unwrap();
1721
+ let tools = body["tools"].as_array().unwrap();
1722
+ assert_eq!(tools[0]["input_schema"]["type"], "object");
1723
+ }
1724
+
1725
+ #[test]
1726
+ fn transform_request_max_completion_tokens_mapped() {
1727
+ let mut body = json!({
1728
+ "model": "claude-3-5-sonnet-20241022",
1729
+ "messages": [{"role": "user", "content": "Hi"}],
1730
+ "max_completion_tokens": 512
1731
+ });
1732
+ provider().transform_request(&mut body).unwrap();
1733
+ assert_eq!(body["max_tokens"], json!(512u64));
1734
+ assert!(body.get("max_completion_tokens").is_none());
1735
+ }
1736
+
1737
+ #[test]
1738
+ fn transform_request_tool_result_content_array_preserved() {
1739
+ let mut body = json!({
1740
+ "model": "claude-3-5-sonnet-20241022",
1741
+ "messages": [
1742
+ {"role": "user", "content": "Look"},
1743
+ {"role": "assistant", "content": null, "tool_calls": [{
1744
+ "id": "call_img",
1745
+ "type": "function",
1746
+ "function": {"name": "get_image", "arguments": "{}"}
1747
+ }]},
1748
+ {"role": "tool", "tool_call_id": "call_img", "content": [
1749
+ {"type": "text", "text": "Here is the image"},
1750
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc123"}}
1751
+ ]}
1752
+ ]
1753
+ });
1754
+ provider().transform_request(&mut body).unwrap();
1755
+ let messages = body["messages"].as_array().unwrap();
1756
+ let tool_result_msg = messages
1757
+ .iter()
1758
+ .find(|m| {
1759
+ m["role"] == "user"
1760
+ && m["content"]
1761
+ .as_array()
1762
+ .is_some_and(|c| c.first().is_some_and(|b| b["type"] == "tool_result"))
1763
+ })
1764
+ .unwrap();
1765
+ let result_content = tool_result_msg["content"][0]["content"].as_array().unwrap();
1766
+ assert_eq!(result_content.len(), 2);
1767
+ assert_eq!(result_content[0]["type"], "text");
1768
+ assert_eq!(result_content[1]["type"], "image");
1769
+ }
1770
+
1771
+ #[test]
1772
+ fn transform_response_thinking_block_excluded_from_content() {
1773
+ let mut body = json!({
1774
+ "id": "msg_think",
1775
+ "type": "message",
1776
+ "role": "assistant",
1777
+ "content": [
1778
+ {"type": "thinking", "thinking": "Let me reason..."},
1779
+ {"type": "text", "text": "The answer is 42."}
1780
+ ],
1781
+ "model": "claude-3-5-sonnet-20241022",
1782
+ "stop_reason": "end_turn",
1783
+ "usage": {"input_tokens": 10, "output_tokens": 20}
1784
+ });
1785
+ provider().transform_response(&mut body).unwrap();
1786
+ let content = body["choices"][0]["message"]["content"].as_str().unwrap();
1787
+ // Thinking blocks are internal chain-of-thought and must NOT appear in user-facing content.
1788
+ assert!(
1789
+ !content.contains("Let me reason..."),
1790
+ "thinking blocks should be filtered out"
1791
+ );
1792
+ assert_eq!(content, "The answer is 42.");
1793
+ }
1794
+
1795
+ #[test]
1796
+ fn transform_response_server_tool_use_treated_as_tool_call() {
1797
+ let mut body = json!({
1798
+ "id": "msg_srv",
1799
+ "type": "message",
1800
+ "role": "assistant",
1801
+ "content": [{
1802
+ "type": "server_tool_use",
1803
+ "id": "srvtool_01",
1804
+ "name": "web_search",
1805
+ "input": {"query": "Rust programming"}
1806
+ }],
1807
+ "model": "claude-3-5-sonnet-20241022",
1808
+ "stop_reason": "tool_use",
1809
+ "usage": {"input_tokens": 5, "output_tokens": 5}
1810
+ });
1811
+ provider().transform_response(&mut body).unwrap();
1812
+ let tool_calls = body["choices"][0]["message"]["tool_calls"].as_array().unwrap();
1813
+ assert_eq!(tool_calls.len(), 1);
1814
+ assert_eq!(tool_calls[0]["id"], "srvtool_01");
1815
+ assert_eq!(tool_calls[0]["function"]["name"], "web_search");
1816
+ }
1817
+
1818
+ #[test]
1819
+ fn transform_response_cache_tokens_counted_in_prompt() {
1820
+ let mut body = json!({
1821
+ "id": "msg_cache",
1822
+ "type": "message",
1823
+ "role": "assistant",
1824
+ "content": [{"type": "text", "text": "ok"}],
1825
+ "model": "claude-3-5-sonnet-20241022",
1826
+ "stop_reason": "end_turn",
1827
+ "usage": {
1828
+ "input_tokens": 100,
1829
+ "cache_creation_input_tokens": 50,
1830
+ "cache_read_input_tokens": 25,
1831
+ "output_tokens": 10
1832
+ }
1833
+ });
1834
+ provider().transform_response(&mut body).unwrap();
1835
+ // prompt_tokens = 100 + 50 + 25 = 175
1836
+ assert_eq!(body["usage"]["prompt_tokens"], 175u64);
1837
+ assert_eq!(body["usage"]["completion_tokens"], 10u64);
1838
+ assert_eq!(body["usage"]["total_tokens"], 185u64);
1839
+ }
1840
+
1841
+ #[test]
1842
+ fn transform_response_tool_only_no_empty_text_block_in_request() {
1843
+ // When assistant has only tool_calls, the converted message should NOT inject empty text.
1844
+ let mut body = json!({
1845
+ "model": "claude-3-5-sonnet-20241022",
1846
+ "messages": [
1847
+ {"role": "user", "content": "Call a tool"},
1848
+ {"role": "assistant", "tool_calls": [{
1849
+ "id": "call_xyz",
1850
+ "type": "function",
1851
+ "function": {"name": "my_fn", "arguments": "{}"}
1852
+ }]},
1853
+ {"role": "tool", "tool_call_id": "call_xyz", "content": "result"}
1854
+ ]
1855
+ });
1856
+ provider().transform_request(&mut body).unwrap();
1857
+ let messages = body["messages"].as_array().unwrap();
1858
+ let assistant_msg = messages.iter().find(|m| m["role"] == "assistant").unwrap();
1859
+ let blocks = assistant_msg["content"].as_array().unwrap();
1860
+ // Only the tool_use block should be present; no empty text block.
1861
+ assert!(blocks.iter().all(|b| b["type"] != "text" || b["text"] != ""));
1862
+ assert!(blocks.iter().any(|b| b["type"] == "tool_use"));
1863
+ }
1864
+
1865
+ #[test]
1866
+ fn parse_stream_event_thinking_delta_returns_none() {
1867
+ // Thinking blocks are filtered in both streaming and non-streaming for consistency.
1868
+ let event = r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"I am thinking..."}}"#;
1869
+ let result = provider().parse_stream_event(event).unwrap();
1870
+ assert!(result.is_none(), "thinking_delta should be filtered (return None)");
1871
+ }
1872
+
1873
+ #[test]
1874
+ fn parse_stream_event_message_start_cache_tokens_in_usage() {
1875
+ let event = r#"{"type":"message_start","message":{"id":"msg_x","model":"claude-opus","content":[],"usage":{"input_tokens":100,"cache_creation_input_tokens":50,"cache_read_input_tokens":25,"output_tokens":0}}}"#;
1876
+ let chunk = provider().parse_stream_event(event).unwrap().expect("expected chunk");
1877
+ let usage = chunk.usage.unwrap();
1878
+ // prompt_tokens = 100 + 50 + 25 = 175
1879
+ assert_eq!(usage.prompt_tokens, 175);
1880
+ }
1881
+
1882
+ #[test]
1883
+ fn sanitize_tool_call_id_replaces_invalid_chars() {
1884
+ assert_eq!(sanitize_tool_call_id("call.abc!123").as_ref(), "call_abc_123");
1885
+ assert_eq!(sanitize_tool_call_id("call-abc_123").as_ref(), "call-abc_123");
1886
+ assert_eq!(sanitize_tool_call_id("call abc").as_ref(), "call_abc");
1887
+ // Already-valid IDs should borrow (no allocation).
1888
+ assert!(matches!(sanitize_tool_call_id("toolu_01abc"), Cow::Borrowed(_)));
1889
+ // IDs with invalid chars should allocate.
1890
+ assert!(matches!(sanitize_tool_call_id("call.123"), Cow::Owned(_)));
1891
+ }
1892
+
1893
+ #[test]
1894
+ fn map_stop_reason_content_filter() {
1895
+ assert_eq!(map_stop_reason("content_filtered"), "content_filter");
1896
+ assert_eq!(map_stop_reason("refusal"), "content_filter");
1897
+ }
1898
+
1899
+ // ── Phase 1A: Extended thinking / reasoning effort ──────────────────────
1900
+
1901
+ #[test]
1902
+ fn transform_request_reasoning_effort_low() {
1903
+ let mut body = json!({
1904
+ "model": "claude-sonnet-4-20250514",
1905
+ "messages": [{"role": "user", "content": "Think about this"}],
1906
+ "reasoning_effort": "low"
1907
+ });
1908
+ provider().transform_request(&mut body).unwrap();
1909
+ assert_eq!(body["thinking"]["type"], "enabled");
1910
+ assert_eq!(body["thinking"]["budget_tokens"], 1024);
1911
+ assert!(
1912
+ body.get("reasoning_effort").is_none(),
1913
+ "reasoning_effort should be removed"
1914
+ );
1915
+ }
1916
+
1917
+ #[test]
1918
+ fn transform_request_reasoning_effort_medium() {
1919
+ let mut body = json!({
1920
+ "model": "claude-sonnet-4-20250514",
1921
+ "messages": [{"role": "user", "content": "Think about this"}],
1922
+ "reasoning_effort": "medium"
1923
+ });
1924
+ provider().transform_request(&mut body).unwrap();
1925
+ assert_eq!(body["thinking"]["type"], "enabled");
1926
+ assert_eq!(body["thinking"]["budget_tokens"], 4096);
1927
+ }
1928
+
1929
+ #[test]
1930
+ fn transform_request_reasoning_effort_high() {
1931
+ let mut body = json!({
1932
+ "model": "claude-sonnet-4-20250514",
1933
+ "messages": [{"role": "user", "content": "Think deeply"}],
1934
+ "reasoning_effort": "high"
1935
+ });
1936
+ provider().transform_request(&mut body).unwrap();
1937
+ assert_eq!(body["thinking"]["type"], "enabled");
1938
+ assert_eq!(body["thinking"]["budget_tokens"], 16384);
1939
+ }
1940
+
1941
+ #[test]
1942
+ fn transform_request_reasoning_effort_from_extra_body() {
1943
+ let mut body = json!({
1944
+ "model": "claude-sonnet-4-20250514",
1945
+ "messages": [{"role": "user", "content": "Think"}],
1946
+ "extra_body": {"reasoning_effort": "high"}
1947
+ });
1948
+ provider().transform_request(&mut body).unwrap();
1949
+ assert_eq!(body["thinking"]["type"], "enabled");
1950
+ assert_eq!(body["thinking"]["budget_tokens"], 16384);
1951
+ }
1952
+
1953
+ // ── Phase 1A: Dynamic beta headers ──────────────────────────────────────
1954
+
1955
+ #[test]
1956
+ fn dynamic_headers_thinking_beta() {
1957
+ let body = json!({
1958
+ "thinking": {"type": "enabled", "budget_tokens": 4096},
1959
+ "messages": [{"role": "user", "content": "Hi"}]
1960
+ });
1961
+ let headers = provider().dynamic_headers(&body);
1962
+ assert_eq!(headers.len(), 1);
1963
+ assert_eq!(headers[0].0, "anthropic-beta");
1964
+ assert!(headers[0].1.contains("thinking-2025-04-14"));
1965
+ }
1966
+
1967
+ #[test]
1968
+ fn dynamic_headers_web_search_beta() {
1969
+ let body = json!({
1970
+ "tools": [{"type": "web_search_20250305", "name": "web_search"}],
1971
+ "messages": [{"role": "user", "content": "Search for Rust"}]
1972
+ });
1973
+ let headers = provider().dynamic_headers(&body);
1974
+ assert_eq!(headers.len(), 1);
1975
+ assert!(headers[0].1.contains("web-search-2025-03-05"));
1976
+ }
1977
+
1978
+ #[test]
1979
+ fn dynamic_headers_multiple_betas_combined() {
1980
+ let body = json!({
1981
+ "thinking": {"type": "enabled", "budget_tokens": 4096},
1982
+ "tools": [
1983
+ {"type": "computer_use_20250124", "display_width_px": 1024, "display_height_px": 768},
1984
+ {"type": "web_search_20250305", "name": "web_search"}
1985
+ ]
1986
+ });
1987
+ let headers = provider().dynamic_headers(&body);
1988
+ assert_eq!(headers.len(), 1);
1989
+ let beta_value = &headers[0].1;
1990
+ assert!(beta_value.contains("thinking-2025-04-14"));
1991
+ assert!(beta_value.contains("computer-use-2025-01-24"));
1992
+ assert!(beta_value.contains("web-search-2025-03-05"));
1993
+ }
1994
+
1995
+ #[test]
1996
+ fn dynamic_headers_no_betas_returns_empty() {
1997
+ let body = json!({
1998
+ "messages": [{"role": "user", "content": "Hi"}]
1999
+ });
2000
+ let headers = provider().dynamic_headers(&body);
2001
+ assert!(headers.is_empty());
2002
+ }
2003
+
2004
+ #[test]
2005
+ fn dynamic_headers_code_execution_beta() {
2006
+ let body = json!({
2007
+ "tools": [{"type": "code_execution_20250522"}]
2008
+ });
2009
+ let headers = provider().dynamic_headers(&body);
2010
+ assert_eq!(headers.len(), 1);
2011
+ assert!(headers[0].1.contains("code-execution-2025-05-22"));
2012
+ }
2013
+
2014
+ // ── Phase 1A: Prompt caching on messages + tools ────────────────────────
2015
+
2016
+ #[test]
2017
+ fn transform_request_tool_cache_control_propagated() {
2018
+ let mut body = json!({
2019
+ "model": "claude-3-5-sonnet-20241022",
2020
+ "messages": [{"role": "user", "content": "Hi"}],
2021
+ "tools": [{
2022
+ "type": "function",
2023
+ "cache_control": {"type": "ephemeral"},
2024
+ "function": {
2025
+ "name": "get_weather",
2026
+ "description": "Get weather",
2027
+ "parameters": {"type": "object", "properties": {}}
2028
+ }
2029
+ }]
2030
+ });
2031
+ provider().transform_request(&mut body).unwrap();
2032
+ let tools = body["tools"].as_array().unwrap();
2033
+ assert_eq!(tools[0]["cache_control"]["type"], "ephemeral");
2034
+ }
2035
+
2036
+ #[test]
2037
+ fn transform_request_assistant_message_cache_control() {
2038
+ let mut body = json!({
2039
+ "model": "claude-3-5-sonnet-20241022",
2040
+ "messages": [
2041
+ {"role": "user", "content": "Hi"},
2042
+ {"role": "assistant", "content": "Hello!", "cache_control": {"type": "ephemeral"}},
2043
+ {"role": "user", "content": "How are you?"}
2044
+ ]
2045
+ });
2046
+ provider().transform_request(&mut body).unwrap();
2047
+ let messages = body["messages"].as_array().unwrap();
2048
+ let assistant_msg = messages.iter().find(|m| m["role"] == "assistant").unwrap();
2049
+ let content = assistant_msg["content"].as_array().unwrap();
2050
+ assert_eq!(content[0]["cache_control"]["type"], "ephemeral");
2051
+ }
2052
+
2053
+ #[test]
2054
+ fn transform_request_user_message_level_cache_control() {
2055
+ let mut body = json!({
2056
+ "model": "claude-3-5-sonnet-20241022",
2057
+ "messages": [{
2058
+ "role": "user",
2059
+ "content": "Hello",
2060
+ "cache_control": {"type": "ephemeral"}
2061
+ }]
2062
+ });
2063
+ provider().transform_request(&mut body).unwrap();
2064
+ let messages = body["messages"].as_array().unwrap();
2065
+ let content = messages[0]["content"].as_array().unwrap();
2066
+ assert_eq!(content[0]["cache_control"]["type"], "ephemeral");
2067
+ }
2068
+
2069
+ // ── Phase 1A: Document/PDF handling ─────────────────────────────────────
2070
+
2071
+ #[test]
2072
+ fn transform_request_document_content_part() {
2073
+ let mut body = json!({
2074
+ "model": "claude-3-5-sonnet-20241022",
2075
+ "messages": [{
2076
+ "role": "user",
2077
+ "content": [
2078
+ {"type": "text", "text": "Analyze this document"},
2079
+ {"type": "document", "document": {
2080
+ "data": "JVBERi0xLjQ=",
2081
+ "media_type": "application/pdf"
2082
+ }}
2083
+ ]
2084
+ }]
2085
+ });
2086
+ provider().transform_request(&mut body).unwrap();
2087
+ let messages = body["messages"].as_array().unwrap();
2088
+ let content = messages[0]["content"].as_array().unwrap();
2089
+ assert_eq!(content[0]["type"], "text");
2090
+ assert_eq!(content[1]["type"], "document");
2091
+ assert_eq!(content[1]["source"]["type"], "base64");
2092
+ assert_eq!(content[1]["source"]["media_type"], "application/pdf");
2093
+ assert_eq!(content[1]["source"]["data"], "JVBERi0xLjQ=");
2094
+ }
2095
+
2096
+ #[test]
2097
+ fn transform_request_document_with_cache_control() {
2098
+ let mut body = json!({
2099
+ "model": "claude-3-5-sonnet-20241022",
2100
+ "messages": [{
2101
+ "role": "user",
2102
+ "content": [
2103
+ {"type": "document", "document": {
2104
+ "data": "JVBERi0xLjQ=",
2105
+ "media_type": "application/pdf"
2106
+ }, "cache_control": {"type": "ephemeral"}}
2107
+ ]
2108
+ }]
2109
+ });
2110
+ provider().transform_request(&mut body).unwrap();
2111
+ let messages = body["messages"].as_array().unwrap();
2112
+ let content = messages[0]["content"].as_array().unwrap();
2113
+ assert_eq!(content[0]["cache_control"]["type"], "ephemeral");
2114
+ }
2115
+
2116
+ // ── Phase 1A: Response format -> JSON mode ──────────────────────────────
2117
+
2118
+ #[test]
2119
+ fn transform_request_json_object_response_format() {
2120
+ let mut body = json!({
2121
+ "model": "claude-3-5-sonnet-20241022",
2122
+ "messages": [{"role": "user", "content": "Give me JSON"}],
2123
+ "response_format": {"type": "json_object"}
2124
+ });
2125
+ provider().transform_request(&mut body).unwrap();
2126
+ assert!(body.get("response_format").is_none());
2127
+ let system = body["system"].as_array().unwrap();
2128
+ assert!(system[0]["text"].as_str().unwrap().contains("valid JSON"));
2129
+ }
2130
+
2131
+ #[test]
2132
+ fn transform_request_json_schema_response_format() {
2133
+ let mut body = json!({
2134
+ "model": "claude-3-5-sonnet-20241022",
2135
+ "messages": [{"role": "user", "content": "Give me structured output"}],
2136
+ "response_format": {
2137
+ "type": "json_schema",
2138
+ "json_schema": {
2139
+ "name": "person",
2140
+ "schema": {
2141
+ "type": "object",
2142
+ "properties": {
2143
+ "name": {"type": "string"},
2144
+ "age": {"type": "integer"}
2145
+ }
2146
+ }
2147
+ }
2148
+ }
2149
+ });
2150
+ provider().transform_request(&mut body).unwrap();
2151
+ assert!(body.get("response_format").is_none());
2152
+ let system = body["system"].as_array().unwrap();
2153
+ let instruction = system[0]["text"].as_str().unwrap();
2154
+ assert!(instruction.contains("person"));
2155
+ assert!(instruction.contains("schema"));
2156
+ }
2157
+
2158
+ #[test]
2159
+ fn transform_request_json_object_with_existing_system() {
2160
+ let mut body = json!({
2161
+ "model": "claude-3-5-sonnet-20241022",
2162
+ "messages": [
2163
+ {"role": "system", "content": "You are helpful."},
2164
+ {"role": "user", "content": "Give me JSON"}
2165
+ ],
2166
+ "response_format": {"type": "json_object"}
2167
+ });
2168
+ provider().transform_request(&mut body).unwrap();
2169
+ let system = body["system"].as_array().unwrap();
2170
+ assert_eq!(system.len(), 2);
2171
+ assert!(system[0]["text"].as_str().unwrap().contains("valid JSON"));
2172
+ assert_eq!(system[1]["text"], "You are helpful.");
2173
+ }
2174
+
2175
+ // ── Phase 1A: Hosted tools ──────────────────────────────────────────────
2176
+
2177
+ #[test]
2178
+ fn transform_request_hosted_tool_passed_through() {
2179
+ let mut body = json!({
2180
+ "model": "claude-3-5-sonnet-20241022",
2181
+ "messages": [{"role": "user", "content": "Search the web"}],
2182
+ "tools": [
2183
+ {"type": "web_search_20250305", "name": "web_search", "max_uses": 3},
2184
+ {"type": "function", "function": {
2185
+ "name": "get_weather",
2186
+ "parameters": {"type": "object", "properties": {}}
2187
+ }}
2188
+ ]
2189
+ });
2190
+ provider().transform_request(&mut body).unwrap();
2191
+ let tools = body["tools"].as_array().unwrap();
2192
+ assert_eq!(tools.len(), 2);
2193
+ assert_eq!(tools[0]["type"], "web_search_20250305");
2194
+ assert_eq!(tools[0]["max_uses"], 3);
2195
+ assert_eq!(tools[1]["name"], "get_weather");
2196
+ assert!(tools[1].get("input_schema").is_some());
2197
+ }
2198
+
2199
+ #[test]
2200
+ fn transform_request_computer_use_tool_passed_through() {
2201
+ let mut body = json!({
2202
+ "model": "claude-3-5-sonnet-20241022",
2203
+ "messages": [{"role": "user", "content": "Use the computer"}],
2204
+ "tools": [{
2205
+ "type": "computer_20241022",
2206
+ "display_width_px": 1024,
2207
+ "display_height_px": 768
2208
+ }]
2209
+ });
2210
+ provider().transform_request(&mut body).unwrap();
2211
+ let tools = body["tools"].as_array().unwrap();
2212
+ assert_eq!(tools[0]["type"], "computer_20241022");
2213
+ assert_eq!(tools[0]["display_width_px"], 1024);
2214
+ }
2215
+
2216
+ // ── Phase 1A: Citation blocks in response ───────────────────────────────
2217
+
2218
+ #[test]
2219
+ fn transform_response_citation_blocks_skipped() {
2220
+ let mut body = json!({
2221
+ "id": "msg_cite",
2222
+ "type": "message",
2223
+ "role": "assistant",
2224
+ "content": [
2225
+ {"type": "text", "text": "According to the document, "},
2226
+ {"type": "citation", "cited_text": "Rust is fast", "document_index": 0},
2227
+ {"type": "text", "text": "Rust is a fast language."}
2228
+ ],
2229
+ "model": "claude-3-5-sonnet-20241022",
2230
+ "stop_reason": "end_turn",
2231
+ "usage": {"input_tokens": 50, "output_tokens": 20}
2232
+ });
2233
+ provider().transform_response(&mut body).unwrap();
2234
+ let content = body["choices"][0]["message"]["content"].as_str().unwrap();
2235
+ assert_eq!(content, "According to the document, Rust is a fast language.");
2236
+ assert!(!content.contains("citation"));
2237
+ }
2238
+
2239
+ // ── Phase 1A: is_hosted_tool_type helper ────────────────────────────────
2240
+
2241
+ #[test]
2242
+ fn is_hosted_tool_type_recognizes_all_types() {
2243
+ assert!(is_hosted_tool_type("computer_20241022"));
2244
+ assert!(is_hosted_tool_type("computer_use_20250124"));
2245
+ assert!(is_hosted_tool_type("web_search_20250305"));
2246
+ assert!(is_hosted_tool_type("code_execution_20250522"));
2247
+ assert!(!is_hosted_tool_type("function"));
2248
+ assert!(!is_hosted_tool_type("custom_tool"));
2249
+ }
2250
+ }