Skip to main content

gproxy_protocol/transform/claude/
utils.rs

1use http::StatusCode;
2
3use crate::claude::count_tokens::types as ct;
4use crate::claude::count_tokens::types::{
5    BetaContentBlockParam, BetaMessageContent, BetaSystemPrompt, Model, ModelKnown,
6};
7use crate::claude::types::{
8    BetaApiError, BetaApiErrorType, BetaAuthenticationError, BetaAuthenticationErrorType,
9    BetaBillingError, BetaBillingErrorType, BetaError, BetaErrorResponse, BetaErrorResponseType,
10    BetaGatewayTimeoutError, BetaGatewayTimeoutErrorType, BetaInvalidRequestError,
11    BetaInvalidRequestErrorType, BetaNotFoundError, BetaNotFoundErrorType, BetaOverloadedError,
12    BetaOverloadedErrorType, BetaPermissionError, BetaPermissionErrorType, BetaRateLimitError,
13    BetaRateLimitErrorType,
14};
15
16pub fn beta_error_response_from_status_message(
17    status_code: StatusCode,
18    message: String,
19) -> BetaErrorResponse {
20    let error = match status_code.as_u16() {
21        400 | 413 => BetaError::InvalidRequest(BetaInvalidRequestError {
22            message,
23            type_: BetaInvalidRequestErrorType::InvalidRequestError,
24        }),
25        401 => BetaError::Authentication(BetaAuthenticationError {
26            message,
27            type_: BetaAuthenticationErrorType::AuthenticationError,
28        }),
29        402 => BetaError::Billing(BetaBillingError {
30            message,
31            type_: BetaBillingErrorType::BillingError,
32        }),
33        403 => BetaError::Permission(BetaPermissionError {
34            message,
35            type_: BetaPermissionErrorType::PermissionError,
36        }),
37        404 => BetaError::NotFound(BetaNotFoundError {
38            message,
39            type_: BetaNotFoundErrorType::NotFoundError,
40        }),
41        429 => BetaError::RateLimit(BetaRateLimitError {
42            message,
43            type_: BetaRateLimitErrorType::RateLimitError,
44        }),
45        504 => BetaError::GatewayTimeout(BetaGatewayTimeoutError {
46            message,
47            type_: BetaGatewayTimeoutErrorType::TimeoutError,
48        }),
49        529 => BetaError::Overloaded(BetaOverloadedError {
50            message,
51            type_: BetaOverloadedErrorType::OverloadedError,
52        }),
53        _ => BetaError::Api(BetaApiError {
54            message,
55            type_: BetaApiErrorType::ApiError,
56        }),
57    };
58
59    BetaErrorResponse {
60        error,
61        request_id: String::new(),
62        type_: BetaErrorResponseType::Error,
63    }
64}
65
66pub fn claude_model_to_string(model: &Model) -> String {
67    match model {
68        Model::Custom(model) => model.clone(),
69        Model::Known(model) => match model {
70            ModelKnown::ClaudeOpus47 => "claude-opus-4-7",
71            ModelKnown::ClaudeOpus46 => "claude-opus-4-6",
72            ModelKnown::ClaudeOpus4520251101 => "claude-opus-4-5-20251101",
73            ModelKnown::ClaudeOpus45 => "claude-opus-4-5",
74            ModelKnown::Claude37SonnetLatest => "claude-3-7-sonnet-latest",
75            ModelKnown::Claude37Sonnet20250219 => "claude-3-7-sonnet-20250219",
76            ModelKnown::Claude35HaikuLatest => "claude-3-5-haiku-latest",
77            ModelKnown::Claude35Haiku20241022 => "claude-3-5-haiku-20241022",
78            ModelKnown::ClaudeHaiku45 => "claude-haiku-4-5",
79            ModelKnown::ClaudeHaiku4520251001 => "claude-haiku-4-5-20251001",
80            ModelKnown::ClaudeSonnet420250514 => "claude-sonnet-4-20250514",
81            ModelKnown::ClaudeSonnet40 => "claude-sonnet-4-0",
82            ModelKnown::Claude4Sonnet20250514 => "claude-4-sonnet-20250514",
83            ModelKnown::ClaudeSonnet45 => "claude-sonnet-4-5",
84            ModelKnown::ClaudeSonnet4520250929 => "claude-sonnet-4-5-20250929",
85            ModelKnown::ClaudeSonnet46 => "claude-sonnet-4-6",
86            ModelKnown::ClaudeOpus40 => "claude-opus-4-0",
87            ModelKnown::ClaudeOpus420250514 => "claude-opus-4-20250514",
88            ModelKnown::Claude4Opus20250514 => "claude-4-opus-20250514",
89            ModelKnown::ClaudeOpus4120250805 => "claude-opus-4-1-20250805",
90            ModelKnown::Claude3OpusLatest => "claude-3-opus-latest",
91            ModelKnown::Claude3Opus20240229 => "claude-3-opus-20240229",
92            ModelKnown::Claude3Haiku20240307 => "claude-3-haiku-20240307",
93        }
94        .to_string(),
95    }
96}
97
98pub fn claude_model_supports_enabled_thinking(model: Option<&Model>) -> bool {
99    !matches!(
100        model.map(claude_model_to_string).as_deref(),
101        Some("claude-opus-4-7")
102    )
103}
104
105pub fn beta_message_content_to_text(content: &BetaMessageContent) -> String {
106    match content {
107        BetaMessageContent::Text(text) => text.clone(),
108        BetaMessageContent::Blocks(blocks) => blocks
109            .iter()
110            .map(beta_content_block_to_text)
111            .collect::<Vec<_>>()
112            .join("\n"),
113    }
114}
115
116pub fn beta_system_prompt_to_text(system: Option<BetaSystemPrompt>) -> Option<String> {
117    let text = match system {
118        Some(BetaSystemPrompt::Text(text)) => text,
119        Some(BetaSystemPrompt::Blocks(blocks)) => blocks
120            .into_iter()
121            .map(|block| block.text)
122            .collect::<Vec<_>>()
123            .join("\n"),
124        None => String::new(),
125    };
126
127    if text.is_empty() { None } else { Some(text) }
128}
129
130fn beta_content_block_to_text(block: &BetaContentBlockParam) -> String {
131    match block {
132        BetaContentBlockParam::Text(block) => block.text.clone(),
133        _ => "[unsupported_content_block]".to_string(),
134    }
135}
136
137/// Placeholder name used for synthetic `tool_use` blocks injected by
138/// [`push_message_block`] when a `tool_result` would otherwise be orphaned
139/// (no preceding assistant `tool_use` with a matching `id`). Exposed so
140/// tests can refer to it.
141pub const ORPHAN_TOOL_USE_PLACEHOLDER_NAME: &str = "tool_use_placeholder";
142
143fn make_placeholder_tool_use(id: String) -> ct::BetaContentBlockParam {
144    ct::BetaContentBlockParam::ToolUse(ct::BetaToolUseBlockParam {
145        id,
146        input: ct::JsonObject::new(),
147        name: ORPHAN_TOOL_USE_PLACEHOLDER_NAME.to_string(),
148        type_: ct::BetaToolUseBlockType::ToolUse,
149        cache_control: None,
150        caller: None,
151    })
152}
153
154fn placeholder_text_block(text: String) -> ct::BetaContentBlockParam {
155    ct::BetaContentBlockParam::Text(ct::BetaTextBlockParam {
156        text,
157        type_: ct::BetaTextBlockType::Text,
158        cache_control: None,
159        citations: None,
160    })
161}
162
163fn promote_content_to_blocks(content: &mut ct::BetaMessageContent) {
164    if let ct::BetaMessageContent::Text(text) = content {
165        let blocks = if text.is_empty() {
166            Vec::new()
167        } else {
168            vec![placeholder_text_block(std::mem::take(text))]
169        };
170        *content = ct::BetaMessageContent::Blocks(blocks);
171    }
172}
173
174fn ensure_tool_use_for(messages: &mut Vec<ct::BetaMessageParam>, id: &str) {
175    // Locate the assistant message that should pair with this tool_result.
176    // If the trailing message is already a user message (for example, an
177    // earlier sibling tool_result we just pushed), the assistant message is
178    // the one before that; otherwise it is the trailing message itself.
179    let trailing_user = matches!(
180        messages.last(),
181        Some(ct::BetaMessageParam {
182            role: ct::BetaMessageRole::User,
183            ..
184        })
185    );
186    let assistant_idx = if trailing_user {
187        messages.len().checked_sub(2)
188    } else {
189        messages.len().checked_sub(1)
190    };
191
192    let already_paired = matches!(
193        assistant_idx.and_then(|j| messages.get(j)),
194        Some(ct::BetaMessageParam {
195            content: ct::BetaMessageContent::Blocks(blocks),
196            role: ct::BetaMessageRole::Assistant,
197        }) if blocks.iter().any(|b|
198            matches!(b, ct::BetaContentBlockParam::ToolUse(tu) if tu.id == id)
199        )
200    );
201    if already_paired {
202        return;
203    }
204
205    let placeholder = make_placeholder_tool_use(id.to_string());
206
207    if let Some(j) = assistant_idx
208        && matches!(messages[j].role, ct::BetaMessageRole::Assistant)
209    {
210        promote_content_to_blocks(&mut messages[j].content);
211        if let ct::BetaMessageContent::Blocks(blocks) = &mut messages[j].content {
212            blocks.push(placeholder);
213            return;
214        }
215    }
216
217    // No suitable assistant slot. Insert a new assistant message before the
218    // trailing user message (or at the end if there is none).
219    let insert_at = if trailing_user {
220        messages.len() - 1
221    } else {
222        messages.len()
223    };
224    messages.insert(
225        insert_at,
226        ct::BetaMessageParam {
227            content: ct::BetaMessageContent::Blocks(vec![placeholder]),
228            role: ct::BetaMessageRole::Assistant,
229        },
230    );
231}
232
233/// Append a single content block to a Claude `messages` list, building a
234/// well-formed conversation as we go:
235///
236/// * Consecutive blocks for the same role are merged into one message,
237///   instead of producing two adjacent same-role messages (which the Claude
238///   API rejects).
239/// * Whenever a `tool_result` block is appended to a `user` message, we make
240///   sure the immediately preceding assistant message contains a matching
241///   `tool_use` block. If none exists (for example, when a client uses the
242///   OpenAI Responses API with `previous_response_id` and only sends new
243///   `function_call_output` items), we synthesize a placeholder `tool_use`
244///   so the request still satisfies the API's pairing rule:
245///   *"Each `tool_result` block must have a corresponding `tool_use` block
246///   in the previous message."*
247///
248/// Use this helper from every transform that produces Claude messages from a
249/// non-Claude source. It centralises the invariants so each converter can
250/// stay focused on its own input format.
251pub fn push_message_block(
252    messages: &mut Vec<ct::BetaMessageParam>,
253    role: ct::BetaMessageRole,
254    block: ct::BetaContentBlockParam,
255) {
256    if matches!(role, ct::BetaMessageRole::User)
257        && let ct::BetaContentBlockParam::ToolResult(tr) = &block
258    {
259        ensure_tool_use_for(messages, &tr.tool_use_id);
260    }
261
262    if let Some(last) = messages.last_mut()
263        && last.role == role
264    {
265        promote_content_to_blocks(&mut last.content);
266        if let ct::BetaMessageContent::Blocks(blocks) = &mut last.content {
267            blocks.push(block);
268            return;
269        }
270    }
271
272    messages.push(ct::BetaMessageParam {
273        content: ct::BetaMessageContent::Blocks(vec![block]),
274        role,
275    });
276}
277
278#[cfg(test)]
279mod push_message_block_tests {
280    use super::*;
281
282    fn tool_result_block(id: &str, body: &str) -> ct::BetaContentBlockParam {
283        ct::BetaContentBlockParam::ToolResult(ct::BetaToolResultBlockParam {
284            tool_use_id: id.to_string(),
285            type_: ct::BetaToolResultBlockType::ToolResult,
286            cache_control: None,
287            content: Some(ct::BetaToolResultBlockParamContent::Text(body.to_string())),
288            is_error: None,
289        })
290    }
291
292    fn tool_use_block(id: &str, name: &str) -> ct::BetaContentBlockParam {
293        ct::BetaContentBlockParam::ToolUse(ct::BetaToolUseBlockParam {
294            id: id.to_string(),
295            input: ct::JsonObject::new(),
296            name: name.to_string(),
297            type_: ct::BetaToolUseBlockType::ToolUse,
298            cache_control: None,
299            caller: None,
300        })
301    }
302
303    fn tool_use_ids_in(message: &ct::BetaMessageParam) -> Vec<String> {
304        match &message.content {
305            ct::BetaMessageContent::Blocks(blocks) => blocks
306                .iter()
307                .filter_map(|block| match block {
308                    ct::BetaContentBlockParam::ToolUse(tu) => Some(tu.id.clone()),
309                    _ => None,
310                })
311                .collect(),
312            _ => Vec::new(),
313        }
314    }
315
316    fn tool_result_ids_in(message: &ct::BetaMessageParam) -> Vec<String> {
317        match &message.content {
318            ct::BetaMessageContent::Blocks(blocks) => blocks
319                .iter()
320                .filter_map(|block| match block {
321                    ct::BetaContentBlockParam::ToolResult(tr) => Some(tr.tool_use_id.clone()),
322                    _ => None,
323                })
324                .collect(),
325            _ => Vec::new(),
326        }
327    }
328
329    #[test]
330    fn injects_assistant_message_for_orphaned_tool_result_at_start() {
331        // Reproduces the upstream 400: a request whose first message is a
332        // user/tool_result with no preceding assistant/tool_use.
333        let mut messages = Vec::new();
334        push_message_block(
335            &mut messages,
336            ct::BetaMessageRole::User,
337            tool_result_block("toolu_abc", "-0.978"),
338        );
339
340        assert_eq!(
341            messages.len(),
342            2,
343            "expected a synthetic assistant prepended"
344        );
345        assert!(matches!(messages[0].role, ct::BetaMessageRole::Assistant));
346        assert!(matches!(messages[1].role, ct::BetaMessageRole::User));
347        assert_eq!(tool_use_ids_in(&messages[0]), vec!["toolu_abc"]);
348        assert_eq!(tool_result_ids_in(&messages[1]), vec!["toolu_abc"]);
349    }
350
351    #[test]
352    fn merges_consecutive_tool_results_into_one_user_message() {
353        // Exact shape from the bug report: two consecutive tool_result pushes
354        // with no matching tool_use ever pushed.
355        let mut messages = Vec::new();
356        push_message_block(
357            &mut messages,
358            ct::BetaMessageRole::User,
359            tool_result_block("toolu_one", "-0.978"),
360        );
361        push_message_block(
362            &mut messages,
363            ct::BetaMessageRole::User,
364            tool_result_block("toolu_two", "{...}"),
365        );
366
367        // The single placeholder assistant message should pair both ids, and
368        // the two tool_results should live in one merged user message.
369        assert_eq!(messages.len(), 2);
370        assert!(matches!(messages[0].role, ct::BetaMessageRole::Assistant));
371        assert_eq!(
372            tool_use_ids_in(&messages[0]),
373            vec!["toolu_one".to_string(), "toolu_two".to_string()]
374        );
375        assert!(matches!(messages[1].role, ct::BetaMessageRole::User));
376        assert_eq!(
377            tool_result_ids_in(&messages[1]),
378            vec!["toolu_one".to_string(), "toolu_two".to_string()]
379        );
380    }
381
382    #[test]
383    fn does_not_inject_when_pair_already_exists() {
384        let mut messages = Vec::new();
385        push_message_block(
386            &mut messages,
387            ct::BetaMessageRole::Assistant,
388            tool_use_block("toolu_real", "search"),
389        );
390        push_message_block(
391            &mut messages,
392            ct::BetaMessageRole::User,
393            tool_result_block("toolu_real", "result"),
394        );
395
396        assert_eq!(messages.len(), 2);
397        assert_eq!(tool_use_ids_in(&messages[0]), vec!["toolu_real"]);
398        assert_eq!(
399            tool_use_ids_in(&messages[0])
400                .iter()
401                .filter(|id| id == &"toolu_real")
402                .count(),
403            1,
404            "no duplicate placeholder should have been injected"
405        );
406    }
407
408    #[test]
409    fn appends_to_existing_assistant_text_when_previous_is_text() {
410        // The previous message is an assistant text message — we must not
411        // insert a second assistant message in a row, so we convert the text
412        // content to blocks and append the placeholder tool_use.
413        let mut messages = vec![ct::BetaMessageParam {
414            content: ct::BetaMessageContent::Text("doing X".to_string()),
415            role: ct::BetaMessageRole::Assistant,
416        }];
417        push_message_block(
418            &mut messages,
419            ct::BetaMessageRole::User,
420            tool_result_block("toolu_xyz", "ok"),
421        );
422
423        assert_eq!(messages.len(), 2);
424        let assistant = &messages[0];
425        assert!(matches!(assistant.role, ct::BetaMessageRole::Assistant));
426        let blocks = match &assistant.content {
427            ct::BetaMessageContent::Blocks(blocks) => blocks,
428            _ => panic!("expected blocks after sanitization"),
429        };
430        assert_eq!(blocks.len(), 2);
431        assert!(matches!(blocks[0], ct::BetaContentBlockParam::Text(_)));
432        match &blocks[1] {
433            ct::BetaContentBlockParam::ToolUse(tu) => {
434                assert_eq!(tu.id, "toolu_xyz");
435                assert_eq!(tu.name, ORPHAN_TOOL_USE_PLACEHOLDER_NAME);
436            }
437            _ => panic!("expected tool_use placeholder"),
438        }
439    }
440
441    #[test]
442    fn inserts_placeholder_when_previous_message_is_user_text() {
443        let mut messages = vec![ct::BetaMessageParam {
444            content: ct::BetaMessageContent::Text("context".to_string()),
445            role: ct::BetaMessageRole::User,
446        }];
447        push_message_block(
448            &mut messages,
449            ct::BetaMessageRole::User,
450            tool_result_block("toolu_orphan", "value"),
451        );
452
453        for window in messages.windows(2) {
454            assert_ne!(
455                window[0].role, window[1].role,
456                "consecutive same-role messages produced: {messages:#?}"
457            );
458        }
459        let result_pos = messages
460            .iter()
461            .position(|m| {
462                matches!(&m.content, ct::BetaMessageContent::Blocks(blocks)
463                    if blocks.iter().any(|b| matches!(b, ct::BetaContentBlockParam::ToolResult(_))))
464            })
465            .expect("tool_result message");
466        assert!(result_pos > 0);
467        let prior = &messages[result_pos - 1];
468        assert!(matches!(prior.role, ct::BetaMessageRole::Assistant));
469        assert_eq!(tool_use_ids_in(prior), vec!["toolu_orphan"]);
470    }
471}