anyllm_translate 0.9.7

Pure translation layer between Anthropic Messages API and OpenAI Chat Completions
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
use crate::anthropic;
use crate::mapping::tools_map;
use crate::openai;
use crate::util;

/// Convert an Anthropic MessageCreateRequest to an OpenAI ChatCompletionRequest.
///
/// Anthropic: <https://docs.anthropic.com/en/api/messages>
/// OpenAI: <https://platform.openai.com/docs/api-reference/chat/create>
pub fn anthropic_to_openai_request(
    req: &anthropic::MessageCreateRequest,
) -> openai::ChatCompletionRequest {
    let mut messages = Vec::new();

    if let Some(ref system) = req.system {
        let text = super::extract_system_text(system);
        // Uses System role instead of Developer for backward compat with
        // local LLMs (vLLM, Ollama, llama-server) that don't recognize
        // "developer". Trade-off: OpenAI o1/o3 require "developer" and
        // reject "system". We chose broader compat over o-series support
        // because most proxy users target GPT-4o or local models.
        messages.push(openai::ChatMessage {
            role: openai::ChatRole::System,
            content: Some(openai::ChatContent::Text(text)),
            name: None,
            tool_calls: None,
            tool_call_id: None,
            refusal: None,
            reasoning_content: None,
        });
    }

    for msg in &req.messages {
        convert_anthropic_message(msg, &mut messages);
    }

    let tools = req
        .tools
        .as_ref()
        .map(|t| tools_map::anthropic_tools_to_openai(t));

    let tool_choice = req
        .tool_choice
        .as_ref()
        .map(tools_map::anthropic_tool_choice_to_openai);

    // Map disable_parallel_tool_use to OpenAI parallel_tool_calls.
    // Compat spec: "Fully supported". See: https://docs.anthropic.com/en/api/openai-sdk#tools--functions-fields
    let parallel_tool_calls = match req.tool_choice.as_ref() {
        Some(anthropic::ToolChoice::Auto {
            disable_parallel_tool_use: Some(true),
        })
        | Some(anthropic::ToolChoice::Any {
            disable_parallel_tool_use: Some(true),
        }) => Some(false),
        _ => None,
    };

    // Map metadata.user_id to OpenAI user field.
    // Compat spec: user is "Ignored", but we forward it for traceability.
    // See: https://docs.anthropic.com/en/api/openai-sdk#simple-fields
    let user = req.metadata.as_ref().and_then(|m| m.user_id.clone());
    if req.top_k.is_some() {
        tracing::warn!("top_k parameter dropped: no OpenAI equivalent");
    }
    // Map Anthropic thinking config to OpenAI reasoning_effort (via extra).
    // Thinking content blocks in messages are separately mapped to reasoning_content.
    // Thresholds (4 k / 16 k tokens) are proxy-inferred approximations; OpenAI does not
    // document exact token counts for each effort level. Chosen to match common usage:
    //   low  ≈ quick CoT drafts (< 4 k), medium ≈ standard reasoning, high ≈ extended thinking.
    let reasoning_effort = match &req.thinking {
        Some(crate::anthropic::ThinkingConfig::Enabled { budget_tokens }) => {
            let effort = if *budget_tokens < 4_000 {
                "low"
            } else if *budget_tokens < 16_000 {
                "medium"
            } else {
                "high"
            };
            tracing::info!(
                budget_tokens,
                reasoning_effort = effort,
                "thinking config mapped to reasoning_effort"
            );
            Some(effort)
        }
        _ => None,
    };

    // OpenAI caps stop sequences at 4; empty array is invalid (requires 1-4 elements)
    let stop = req.stop_sequences.as_ref().and_then(|seqs| {
        if seqs.is_empty() {
            return None;
        }
        if seqs.len() > 4 {
            tracing::warn!(
                count = seqs.len(),
                "stop_sequences truncated from {} to 4 (OpenAI limit)",
                seqs.len()
            );
        }
        let capped: Vec<String> = seqs.iter().take(4).cloned().collect();
        Some(if capped.len() == 1 {
            openai::Stop::Single(capped.into_iter().next().unwrap())
        } else {
            openai::Stop::Multiple(capped)
        })
    });

    let mut oai_req = openai::ChatCompletionRequest {
        model: req.model.clone(),
        messages,
        // Default: set both for local LLM compat (vLLM, ollama only
        // recognize max_tokens). Overridden below for o-series models.
        max_tokens: Some(req.max_tokens),
        max_completion_tokens: Some(req.max_tokens),
        // Compat spec: "Between 0 and 1 (inclusive). Values greater than 1 are capped at 1."
        // See: https://docs.anthropic.com/en/api/openai-sdk#simple-fields
        temperature: req.temperature.map(|t| t.clamp(0.0, 1.0)),
        top_p: req.top_p,
        stop,
        tools,
        tool_choice,
        stream: req.stream,
        // Required for the streaming translator: without include_usage=true,
        // OpenAI omits the final usage chunk and we cannot report token counts
        // back to the Anthropic client. Local LLMs that don't support
        // stream_options may reject this with 400.
        stream_options: if req.stream == Some(true) {
            Some(openai::StreamOptions {
                include_usage: true,
            })
        } else {
            None
        },
        presence_penalty: None,
        frequency_penalty: None,
        response_format: None,
        user,
        parallel_tool_calls,
        extra: req.extra.clone(),
    };

    // Inject reasoning_effort if derived from thinking config and not already set.
    if let Some(effort) = reasoning_effort {
        oai_req
            .extra
            .entry("reasoning_effort")
            .or_insert_with(|| serde_json::Value::String(effort.to_owned()));
    }

    // Anthropic API returns single completions only; strip n to avoid
    // wasting tokens on choices that get discarded (only choices[0] is used).
    if let Some(n_val) = oai_req.extra.remove("n") {
        if n_val != serde_json::Value::Number(1.into()) {
            tracing::warn!(n = %n_val, "n parameter stripped: Anthropic API returns single completions only");
        }
    }

    // o-series reasoning models require "developer" role instead of "system",
    // reject max_tokens (use max_completion_tokens instead), and reject
    // temperature/top_p — all variants, including GA releases.
    if is_o_series_model(&oai_req.model) {
        oai_req.max_tokens = None;
        oai_req.temperature = None;
        oai_req.top_p = None;
        for msg in &mut oai_req.messages {
            if msg.role == openai::ChatRole::System {
                msg.role = openai::ChatRole::Developer;
            }
        }
    }

    // When a specific tool is forced, enable OpenAI strict structured outputs for it.
    // This guarantees the returned JSON exactly matches the schema.
    if let Some(forced_name) = req.tool_choice.as_ref().and_then(extract_forced_tool_name) {
        if let Some(ref mut tools) = oai_req.tools {
            apply_strict_mode_to_tool(tools, &forced_name);
        }
    }

    oai_req
}

/// Extract the forced tool name from an Anthropic ToolChoice, if any.
fn extract_forced_tool_name(tc: &anthropic::ToolChoice) -> Option<String> {
    match tc {
        anthropic::ToolChoice::Tool { name } => Some(name.clone()),
        _ => None,
    }
}

/// Set strict=true and normalize the parameter schema for the named tool.
/// Other tools in the vec are left unchanged.
fn apply_strict_mode_to_tool(tools: &mut [openai::ChatTool], forced_name: &str) {
    for tool in tools.iter_mut() {
        if tool.function.name == forced_name {
            tool.function.strict = Some(true);
            if let Some(params) = tool.function.parameters.take() {
                tool.function.parameters = Some(tools_map::normalize_schema_for_strict(params));
            }
            // Tool names are unique; stop after the first match.
            break;
        }
    }
}

/// Returns true if the model name matches an OpenAI o-series reasoning model
/// (o1, o2, o3, o4, o5, o10, etc.). Does not match "gpt-4o" where 'o' is a suffix.
/// Pattern: starts with 'o' or 'O', then one or more ASCII digits, then end-of-string or '-'.
pub(crate) fn is_o_series_model(model: &str) -> bool {
    // Matches: o1, o2, o3, o10, o1-mini, O4-preview, etc.
    // Rejects: gpt-4o (suffix 'o'), openai-x, o-preview (no digit after 'o').
    let bytes = model.as_bytes();
    if bytes.is_empty() || !bytes[0].eq_ignore_ascii_case(&b'o') {
        return false;
    }
    // Must have at least one digit after the 'o'.
    if bytes.len() < 2 || !bytes[1].is_ascii_digit() {
        return false;
    }
    // Skip remaining digits.
    let after_digits = bytes[1..]
        .iter()
        .position(|b| !b.is_ascii_digit())
        .map(|p| 1 + p)
        .unwrap_or(bytes.len());
    // After the digits: either end-of-string or a '-'.
    after_digits == bytes.len() || bytes[after_digits] == b'-'
}

/// Convert a single Anthropic InputMessage into one or more OpenAI ChatMessages.
/// An assistant message with tool_use blocks produces tool_calls.
/// A user message with tool_result blocks produces OpenAI tool-role messages.
fn convert_anthropic_message(msg: &anthropic::InputMessage, out: &mut Vec<openai::ChatMessage>) {
    let role = match msg.role {
        anthropic::Role::User => openai::ChatRole::User,
        anthropic::Role::Assistant => openai::ChatRole::Assistant,
    };

    match &msg.content {
        anthropic::Content::Text(text) => {
            out.push(openai::ChatMessage {
                role,
                content: Some(openai::ChatContent::Text(text.clone())),
                name: None,
                tool_calls: None,
                tool_call_id: None,
                refusal: None,
                reasoning_content: None,
            });
        }
        anthropic::Content::Blocks(blocks) => {
            if msg.role == anthropic::Role::Assistant {
                convert_assistant_blocks(blocks, out);
            } else {
                convert_user_blocks(blocks, out);
            }
        }
    }
}

/// Assistant blocks: text parts become content, tool_use blocks become tool_calls.
fn convert_assistant_blocks(
    blocks: &[anthropic::ContentBlock],
    out: &mut Vec<openai::ChatMessage>,
) {
    let mut text_parts = Vec::new();
    let mut tool_calls = Vec::new();
    let mut thinking_parts = Vec::new();

    for block in blocks {
        match block {
            anthropic::ContentBlock::Text { text } => {
                text_parts.push(text.clone());
            }
            anthropic::ContentBlock::ToolUse { id, name, input } => {
                tool_calls.push(openai::ToolCall {
                    id: id.clone(),
                    call_type: "function".to_string(),
                    function: openai::FunctionCall {
                        name: name.clone(),
                        arguments: util::json::value_to_json_string(input),
                    },
                });
            }
            anthropic::ContentBlock::Thinking { thinking, .. } => {
                thinking_parts.push(thinking.clone());
            }
            // RedactedThinking has no meaningful content to forward
            _ => {}
        }
    }

    let content = if text_parts.is_empty() {
        None
    } else {
        Some(openai::ChatContent::Text(text_parts.join("")))
    };

    let reasoning_content = if thinking_parts.is_empty() {
        None
    } else {
        Some(thinking_parts.join(""))
    };

    out.push(openai::ChatMessage {
        role: openai::ChatRole::Assistant,
        content,
        name: None,
        tool_calls: if tool_calls.is_empty() {
            None
        } else {
            Some(tool_calls)
        },
        tool_call_id: None,
        refusal: None,
        reasoning_content,
    });
}

/// Resolve an Anthropic ImageSource to a URL string (data URI or direct URL).
fn image_source_to_url(source: &anthropic::messages::ImageSource) -> Option<String> {
    if let Some(ref url) = source.url {
        Some(url.clone())
    } else if let Some(ref data) = source.data {
        let mt = source.media_type.as_deref().unwrap_or("image/png");
        Some(format!("data:{};base64,{}", mt, data))
    } else {
        None
    }
}

/// Simplify a Vec of content parts: use plain Text when there's a single text part,
/// multipart array otherwise. Moves data out of the Vec to avoid cloning.
fn simplify_content_parts(mut parts: Vec<openai::ChatContentPart>) -> openai::ChatContent {
    if parts.len() == 1 {
        match parts.remove(0) {
            openai::ChatContentPart::Text { text } => openai::ChatContent::Text(text),
            other => openai::ChatContent::Parts(vec![other]),
        }
    } else {
        openai::ChatContent::Parts(parts)
    }
}

/// User blocks: text/image parts become content, tool_result blocks become
/// separate OpenAI tool-role messages.
fn convert_user_blocks(blocks: &[anthropic::ContentBlock], out: &mut Vec<openai::ChatMessage>) {
    let mut content_parts: Vec<openai::ChatContentPart> = Vec::new();
    let mut tool_results: Vec<(String, Vec<openai::ChatContentPart>)> = Vec::new();

    for block in blocks {
        match block {
            anthropic::ContentBlock::Text { text } => {
                content_parts.push(openai::ChatContentPart::Text { text: text.clone() });
            }
            anthropic::ContentBlock::Image { source } => {
                if let Some(url) = image_source_to_url(source) {
                    content_parts.push(openai::ChatContentPart::ImageUrl {
                        image_url: openai::chat_completions::ImageUrl { url, detail: None },
                    });
                }
            }
            anthropic::ContentBlock::ToolResult {
                tool_use_id,
                content,
                is_error,
            } => {
                let mut parts: Vec<openai::ChatContentPart> = Vec::new();
                match content {
                    Some(anthropic::messages::ToolResultContent::Text(s)) => {
                        parts.push(openai::ChatContentPart::Text { text: s.clone() });
                    }
                    Some(anthropic::messages::ToolResultContent::Blocks(inner)) => {
                        for b in inner {
                            match b {
                                anthropic::ContentBlock::Text { text } => {
                                    parts
                                        .push(openai::ChatContentPart::Text { text: text.clone() });
                                }
                                anthropic::ContentBlock::Image { source } => {
                                    if let Some(url) = image_source_to_url(source) {
                                        parts.push(openai::ChatContentPart::ImageUrl {
                                            image_url: openai::chat_completions::ImageUrl {
                                                url,
                                                detail: None,
                                            },
                                        });
                                    }
                                }
                                _ => {}
                            }
                        }
                    }
                    None => {}
                };
                // Anthropic's is_error flag has no direct OpenAI equivalent.
                // We surface it as a text prefix so the backend model sees the
                // error context in message history.
                if *is_error == Some(true) {
                    // Prefix the first text part (or add one) with "Error: "
                    if let Some(openai::ChatContentPart::Text { ref mut text }) = parts
                        .iter_mut()
                        .find(|p| matches!(p, openai::ChatContentPart::Text { .. }))
                    {
                        *text = format!("Error: {}", text);
                    } else {
                        parts.insert(
                            0,
                            openai::ChatContentPart::Text {
                                text: "Error".to_string(),
                            },
                        );
                    }
                }
                // Use empty text if no content was provided
                if parts.is_empty() {
                    parts.push(openai::ChatContentPart::Text {
                        text: String::new(),
                    });
                }
                tool_results.push((tool_use_id.clone(), parts));
            }
            anthropic::ContentBlock::Document { source, title } => {
                // OpenAI Chat Completions has no inline document support;
                // the Responses API (input_file.file_data) would be needed
                // for full fidelity. Degrade to a text note so the model
                // still sees that a document was attached.
                let label = title.as_deref().unwrap_or("document");
                tracing::warn!(
                    label = label,
                    "document block degraded to text note: no OpenAI Chat Completions equivalent"
                );
                let note = format!(
                    "[Attached {}: {} ({} bytes base64)]",
                    label,
                    source.media_type,
                    source.data.len()
                );
                content_parts.push(openai::ChatContentPart::Text { text: note });
            }
            // ToolUse and Thinking blocks don't appear in user messages; ignore if present
            anthropic::ContentBlock::ToolUse { .. }
            | anthropic::ContentBlock::Thinking { .. }
            | anthropic::ContentBlock::RedactedThinking { .. } => {}
            // Wildcard to handle new/unknown variants (like ServerToolUse, WebSearchToolResult, WebFetchToolResult, Unknown)
            _ => {}
        }
    }

    // Emit tool results before user content: OpenAI enforces strict turn
    // ordering where Tool messages must immediately follow the Assistant
    // message that produced the tool_calls. Violating this causes 400s.
    for (tool_call_id, parts) in tool_results {
        let content = Some(simplify_content_parts(parts));
        out.push(openai::ChatMessage {
            role: openai::ChatRole::Tool,
            content,
            name: None,
            tool_calls: None,
            tool_call_id: Some(tool_call_id),
            refusal: None,
            reasoning_content: None,
        });
    }

    // Emit user content message after tool results
    if !content_parts.is_empty() {
        let content = Some(simplify_content_parts(content_parts));
        out.push(openai::ChatMessage {
            role: openai::ChatRole::User,
            content,
            name: None,
            tool_calls: None,
            tool_call_id: None,
            refusal: None,
            reasoning_content: None,
        });
    }
}