Skip to main content

gproxy_protocol/transform/openai/count_tokens/claude/
utils.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::claude::count_tokens::types as ct;
4use crate::openai::count_tokens::types as ot;
5use crate::transform::claude::utils::claude_model_supports_enabled_thinking;
6
7const CLAUDE_TOOL_USE_ID_PREFIX: &str = "toolu_";
8const CLAUDE_SERVER_TOOL_USE_ID_PREFIX: &str = "srvtoolu_";
9
10fn claude_tool_use_id_matches(id: &str, prefix: &str) -> bool {
11    id.strip_prefix(prefix).is_some_and(|suffix| {
12        !suffix.is_empty()
13            && suffix
14                .chars()
15                .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
16    })
17}
18
19fn sanitize_claude_tool_use_suffix(id: &str) -> String {
20    let mut suffix = String::new();
21    let mut previous_was_underscore = false;
22
23    for ch in id.chars() {
24        let mapped = if ch.is_ascii_alphanumeric() || ch == '_' {
25            ch
26        } else {
27            '_'
28        };
29
30        if mapped == '_' {
31            if suffix.is_empty() || previous_was_underscore {
32                continue;
33            }
34            previous_was_underscore = true;
35        } else {
36            previous_was_underscore = false;
37        }
38
39        suffix.push(mapped);
40    }
41
42    while suffix.ends_with('_') {
43        suffix.pop();
44    }
45
46    if suffix.is_empty() {
47        "generated".to_string()
48    } else {
49        suffix
50    }
51}
52
53fn normalize_claude_tool_use_id(
54    mappings: &mut BTreeMap<String, String>,
55    used_ids: &mut BTreeSet<String>,
56    original: String,
57    prefix: &str,
58) -> String {
59    if let Some(existing) = mappings.get(&original) {
60        return existing.clone();
61    }
62
63    let base = if claude_tool_use_id_matches(&original, prefix) {
64        original.clone()
65    } else {
66        let raw_suffix = original.strip_prefix(prefix).unwrap_or(&original);
67        format!("{prefix}{}", sanitize_claude_tool_use_suffix(raw_suffix))
68    };
69
70    let mut candidate = base.clone();
71    let mut suffix = 1usize;
72    while used_ids.contains(&candidate) {
73        candidate = format!("{base}_{suffix}");
74        suffix += 1;
75    }
76
77    mappings.insert(original, candidate.clone());
78    used_ids.insert(candidate.clone());
79    candidate
80}
81
82#[derive(Debug, Default)]
83pub struct ClaudeToolUseIdMapper {
84    tool_use_ids: BTreeMap<String, String>,
85    used_tool_use_ids: BTreeSet<String>,
86    server_tool_use_ids: BTreeMap<String, String>,
87    used_server_tool_use_ids: BTreeSet<String>,
88}
89
90impl ClaudeToolUseIdMapper {
91    pub fn tool_use_id(&mut self, original: impl Into<String>) -> String {
92        normalize_claude_tool_use_id(
93            &mut self.tool_use_ids,
94            &mut self.used_tool_use_ids,
95            original.into(),
96            CLAUDE_TOOL_USE_ID_PREFIX,
97        )
98    }
99
100    pub fn server_tool_use_id(&mut self, original: impl Into<String>) -> String {
101        normalize_claude_tool_use_id(
102            &mut self.server_tool_use_ids,
103            &mut self.used_server_tool_use_ids,
104            original.into(),
105            CLAUDE_SERVER_TOOL_USE_ID_PREFIX,
106        )
107    }
108}
109
110// `push_message_block` is the shared Claude-message-builder helper used by
111// every transform that emits Claude `messages`. It is defined in
112// `transform::utils` so that the gemini and openai transform sub-trees can
113// both reach it without cross-module dependencies. Re-exported here so the
114// existing openai-side call sites can keep importing it from this module.
115pub use crate::transform::utils::push_message_block;
116
117fn text_block(text: String) -> ct::BetaContentBlockParam {
118    ct::BetaContentBlockParam::Text(ct::BetaTextBlockParam {
119        text,
120        type_: ct::BetaTextBlockType::Text,
121        cache_control: None,
122        citations: None,
123    })
124}
125
126fn parse_data_url_to_image_source(url: &str) -> Option<ct::BetaImageSource> {
127    if !url.starts_with("data:") {
128        return None;
129    }
130
131    let data_index = url.find(";base64,")?;
132    let mime = &url[5..data_index];
133    let data = &url[(data_index + ";base64,".len())..];
134
135    let media_type = match mime {
136        "image/jpeg" => ct::BetaImageMediaType::ImageJpeg,
137        "image/png" => ct::BetaImageMediaType::ImagePng,
138        "image/gif" => ct::BetaImageMediaType::ImageGif,
139        "image/webp" => ct::BetaImageMediaType::ImageWebp,
140        _ => return None,
141    };
142
143    Some(ct::BetaImageSource::Base64(ct::BetaBase64ImageSource {
144        data: data.to_string(),
145        media_type,
146        type_: ct::BetaBase64SourceType::Base64,
147    }))
148}
149
150fn openai_content_to_claude_block(
151    content: ot::ResponseInputContent,
152) -> Option<ct::BetaContentBlockParam> {
153    match content {
154        ot::ResponseInputContent::Text(part) => Some(text_block(part.text)),
155        ot::ResponseInputContent::Image(part) => {
156            if let Some(file_id) = part.file_id {
157                return Some(ct::BetaContentBlockParam::Image(ct::BetaImageBlockParam {
158                    source: ct::BetaImageSource::File(ct::BetaFileImageSource {
159                        file_id,
160                        type_: ct::BetaFileSourceType::File,
161                    }),
162                    type_: ct::BetaImageBlockType::Image,
163                    cache_control: None,
164                }));
165            }
166            if let Some(image_url) = part.image_url {
167                if let Some(source) = parse_data_url_to_image_source(&image_url) {
168                    return Some(ct::BetaContentBlockParam::Image(ct::BetaImageBlockParam {
169                        source,
170                        type_: ct::BetaImageBlockType::Image,
171                        cache_control: None,
172                    }));
173                }
174                if !image_url.is_empty() {
175                    return Some(ct::BetaContentBlockParam::Image(ct::BetaImageBlockParam {
176                        source: ct::BetaImageSource::Url(ct::BetaUrlImageSource {
177                            type_: ct::BetaUrlSourceType::Url,
178                            url: image_url,
179                        }),
180                        type_: ct::BetaImageBlockType::Image,
181                        cache_control: None,
182                    }));
183                }
184            }
185            None
186        }
187        ot::ResponseInputContent::File(part) => {
188            if let Some(file_url) = part.file_url {
189                return Some(text_block(file_url));
190            }
191            if let Some(file_id) = part.file_id {
192                return Some(text_block(format!("file_id:{file_id}")));
193            }
194            if let Some(filename) = part.filename {
195                return Some(text_block(filename));
196            }
197            part.file_data.map(text_block)
198        }
199    }
200}
201
202pub fn openai_message_content_to_claude(
203    content: ot::ResponseInputMessageContent,
204) -> ct::BetaMessageContent {
205    match content {
206        ot::ResponseInputMessageContent::Text(text) => ct::BetaMessageContent::Text(text),
207        ot::ResponseInputMessageContent::List(parts) => {
208            let blocks = parts
209                .into_iter()
210                .filter_map(openai_content_to_claude_block)
211                .collect::<Vec<_>>();
212
213            if blocks.is_empty() {
214                ct::BetaMessageContent::Text(String::new())
215            } else {
216                ct::BetaMessageContent::Blocks(blocks)
217            }
218        }
219    }
220}
221
222pub fn response_input_content_to_claude_block(
223    content: ot::ResponseInputContent,
224) -> Option<ct::BetaContentBlockParam> {
225    openai_content_to_claude_block(content)
226}
227
228pub fn response_input_contents_to_tool_result_content(
229    parts: Vec<ot::ResponseInputContent>,
230) -> Option<ct::BetaToolResultBlockParamContent> {
231    let mut text_parts = Vec::new();
232    let mut content_blocks = Vec::new();
233
234    for part in parts {
235        match openai_content_to_claude_block(part)? {
236            ct::BetaContentBlockParam::Text(block) => text_parts.push(block.text),
237            ct::BetaContentBlockParam::Image(block) => {
238                content_blocks.push(ct::BetaToolResultContentBlockParam::Image(block))
239            }
240            ct::BetaContentBlockParam::SearchResult(block) => {
241                content_blocks.push(ct::BetaToolResultContentBlockParam::SearchResult(block))
242            }
243            ct::BetaContentBlockParam::RequestDocument(block) => {
244                content_blocks.push(ct::BetaToolResultContentBlockParam::Document(block))
245            }
246            _ => return None,
247        }
248    }
249
250    if !content_blocks.is_empty() {
251        if !text_parts.is_empty() {
252            content_blocks.insert(
253                0,
254                ct::BetaToolResultContentBlockParam::Text(ct::BetaTextBlockParam {
255                    text: text_parts.join("\n"),
256                    type_: ct::BetaTextBlockType::Text,
257                    cache_control: None,
258                    citations: None,
259                }),
260            );
261        }
262        Some(ct::BetaToolResultBlockParamContent::Blocks(content_blocks))
263    } else if text_parts.is_empty() {
264        None
265    } else {
266        Some(ct::BetaToolResultBlockParamContent::Text(
267            text_parts.join("\n"),
268        ))
269    }
270}
271
272pub fn openai_role_to_claude(role: ot::ResponseInputMessageRole) -> ct::BetaMessageRole {
273    match role {
274        ot::ResponseInputMessageRole::Assistant => ct::BetaMessageRole::Assistant,
275        ot::ResponseInputMessageRole::User
276        | ot::ResponseInputMessageRole::System
277        | ot::ResponseInputMessageRole::Developer => ct::BetaMessageRole::User,
278    }
279}
280
281pub fn openai_reasoning_to_claude(
282    reasoning: Option<ot::ResponseReasoning>,
283    max_tokens: Option<u64>,
284    model: Option<&ct::Model>,
285) -> Option<ct::BetaThinkingConfigParam> {
286    const MIN_BUDGET_TOKENS: u64 = 1_024;
287
288    fn effort_ratio(effort: &ot::ResponseReasoningEffort) -> (u64, u64) {
289        match effort {
290            ot::ResponseReasoningEffort::Minimal => (1, 8),
291            ot::ResponseReasoningEffort::Low => (1, 4),
292            ot::ResponseReasoningEffort::Medium => (1, 2),
293            ot::ResponseReasoningEffort::High => (3, 4),
294            ot::ResponseReasoningEffort::XHigh => (19, 20),
295            ot::ResponseReasoningEffort::None => (0, 1),
296        }
297    }
298
299    fn budget_for_effort(effort: &ot::ResponseReasoningEffort, max_tokens: u64) -> Option<u64> {
300        if max_tokens < MIN_BUDGET_TOKENS {
301            return None;
302        }
303        let (num, den) = effort_ratio(effort);
304        let target = max_tokens.saturating_mul(num) / den;
305        let upper = max_tokens.saturating_sub(1);
306        if upper < MIN_BUDGET_TOKENS {
307            return None;
308        }
309        Some(target.clamp(MIN_BUDGET_TOKENS, upper))
310    }
311
312    let effort = reasoning.and_then(|config| config.effort)?;
313    if !claude_model_supports_enabled_thinking(model) {
314        return Some(match effort {
315            ot::ResponseReasoningEffort::None => {
316                ct::BetaThinkingConfigParam::Disabled(ct::BetaThinkingConfigDisabled {
317                    type_: ct::BetaThinkingConfigDisabledType::Disabled,
318                })
319            }
320            ot::ResponseReasoningEffort::Minimal
321            | ot::ResponseReasoningEffort::Low
322            | ot::ResponseReasoningEffort::Medium
323            | ot::ResponseReasoningEffort::High
324            | ot::ResponseReasoningEffort::XHigh => {
325                ct::BetaThinkingConfigParam::Adaptive(ct::BetaThinkingConfigAdaptive {
326                    type_: ct::BetaThinkingConfigAdaptiveType::Adaptive,
327                    display: None,
328                })
329            }
330        });
331    }
332    if !matches!(effort, ot::ResponseReasoningEffort::None)
333        && max_tokens.is_some_and(|tokens| tokens < MIN_BUDGET_TOKENS)
334    {
335        return Some(ct::BetaThinkingConfigParam::Disabled(
336            ct::BetaThinkingConfigDisabled {
337                type_: ct::BetaThinkingConfigDisabledType::Disabled,
338            },
339        ));
340    }
341    Some(match effort {
342        ot::ResponseReasoningEffort::None => {
343            ct::BetaThinkingConfigParam::Disabled(ct::BetaThinkingConfigDisabled {
344                type_: ct::BetaThinkingConfigDisabledType::Disabled,
345            })
346        }
347        ot::ResponseReasoningEffort::Minimal
348        | ot::ResponseReasoningEffort::Low
349        | ot::ResponseReasoningEffort::Medium
350        | ot::ResponseReasoningEffort::High
351        | ot::ResponseReasoningEffort::XHigh => {
352            if let Some(max_tokens) = max_tokens {
353                match budget_for_effort(&effort, max_tokens) {
354                    Some(budget_tokens) => {
355                        ct::BetaThinkingConfigParam::Enabled(ct::BetaThinkingConfigEnabled {
356                            budget_tokens,
357                            type_: ct::BetaThinkingConfigEnabledType::Enabled,
358                            display: None,
359                        })
360                    }
361                    None => ct::BetaThinkingConfigParam::Disabled(ct::BetaThinkingConfigDisabled {
362                        type_: ct::BetaThinkingConfigDisabledType::Disabled,
363                    }),
364                }
365            } else {
366                ct::BetaThinkingConfigParam::Adaptive(ct::BetaThinkingConfigAdaptive {
367                    type_: ct::BetaThinkingConfigAdaptiveType::Adaptive,
368                    display: None,
369                })
370            }
371        }
372    })
373}
374
375pub fn parallel_disable(parallel_tool_calls: Option<bool>) -> Option<bool> {
376    parallel_tool_calls.map(|enabled| !enabled)
377}
378
379pub fn openai_tool_choice_to_claude(
380    tool_choice: Option<ot::ResponseToolChoice>,
381    disable_parallel_tool_use: Option<bool>,
382) -> Option<ct::BetaToolChoice> {
383    match tool_choice {
384        Some(ot::ResponseToolChoice::Options(ot::ResponseToolChoiceOptions::Auto)) => {
385            Some(ct::BetaToolChoice::Auto(ct::BetaToolChoiceAuto {
386                type_: ct::BetaToolChoiceAutoType::Auto,
387                disable_parallel_tool_use,
388            }))
389        }
390        Some(ot::ResponseToolChoice::Options(ot::ResponseToolChoiceOptions::Required)) => {
391            Some(ct::BetaToolChoice::Any(ct::BetaToolChoiceAny {
392                type_: ct::BetaToolChoiceAnyType::Any,
393                disable_parallel_tool_use,
394            }))
395        }
396        Some(ot::ResponseToolChoice::Options(ot::ResponseToolChoiceOptions::None)) => {
397            Some(ct::BetaToolChoice::None(ct::BetaToolChoiceNone {
398                type_: ct::BetaToolChoiceNoneType::None,
399            }))
400        }
401        Some(ot::ResponseToolChoice::Function(tool)) => {
402            Some(ct::BetaToolChoice::Tool(ct::BetaToolChoiceTool {
403                name: tool.name,
404                type_: ct::BetaToolChoiceToolType::Tool,
405                disable_parallel_tool_use,
406            }))
407        }
408        Some(ot::ResponseToolChoice::Custom(tool)) => {
409            Some(ct::BetaToolChoice::Tool(ct::BetaToolChoiceTool {
410                name: tool.name,
411                type_: ct::BetaToolChoiceToolType::Tool,
412                disable_parallel_tool_use,
413            }))
414        }
415        Some(ot::ResponseToolChoice::Mcp(tool)) => {
416            if let Some(name) = tool.name {
417                Some(ct::BetaToolChoice::Tool(ct::BetaToolChoiceTool {
418                    name,
419                    type_: ct::BetaToolChoiceToolType::Tool,
420                    disable_parallel_tool_use,
421                }))
422            } else {
423                Some(ct::BetaToolChoice::Any(ct::BetaToolChoiceAny {
424                    type_: ct::BetaToolChoiceAnyType::Any,
425                    disable_parallel_tool_use,
426                }))
427            }
428        }
429        Some(ot::ResponseToolChoice::Allowed(choice)) => match choice.mode {
430            ot::ResponseToolChoiceAllowedMode::Auto => {
431                Some(ct::BetaToolChoice::Auto(ct::BetaToolChoiceAuto {
432                    type_: ct::BetaToolChoiceAutoType::Auto,
433                    disable_parallel_tool_use,
434                }))
435            }
436            ot::ResponseToolChoiceAllowedMode::Required => {
437                Some(ct::BetaToolChoice::Any(ct::BetaToolChoiceAny {
438                    type_: ct::BetaToolChoiceAnyType::Any,
439                    disable_parallel_tool_use,
440                }))
441            }
442        },
443        Some(ot::ResponseToolChoice::Types(tool)) => {
444            let name = match tool.type_ {
445                ot::ResponseToolChoiceBuiltinType::FileSearch => "tool_search_tool_bm25",
446                ot::ResponseToolChoiceBuiltinType::Computer
447                | ot::ResponseToolChoiceBuiltinType::ComputerUsePreview
448                | ot::ResponseToolChoiceBuiltinType::ComputerUse => "computer",
449                ot::ResponseToolChoiceBuiltinType::WebSearchPreview
450                | ot::ResponseToolChoiceBuiltinType::WebSearchPreview20250311 => "web_search",
451                ot::ResponseToolChoiceBuiltinType::CodeInterpreter => "code_execution",
452                ot::ResponseToolChoiceBuiltinType::ImageGeneration => {
453                    return Some(ct::BetaToolChoice::Any(ct::BetaToolChoiceAny {
454                        type_: ct::BetaToolChoiceAnyType::Any,
455                        disable_parallel_tool_use,
456                    }));
457                }
458            };
459            Some(ct::BetaToolChoice::Tool(ct::BetaToolChoiceTool {
460                name: name.to_string(),
461                type_: ct::BetaToolChoiceToolType::Tool,
462                disable_parallel_tool_use,
463            }))
464        }
465        Some(ot::ResponseToolChoice::ApplyPatch(_)) => {
466            Some(ct::BetaToolChoice::Tool(ct::BetaToolChoiceTool {
467                name: "str_replace_based_edit_tool".to_string(),
468                type_: ct::BetaToolChoiceToolType::Tool,
469                disable_parallel_tool_use,
470            }))
471        }
472        Some(ot::ResponseToolChoice::Shell(_)) => {
473            Some(ct::BetaToolChoice::Tool(ct::BetaToolChoiceTool {
474                name: "bash".to_string(),
475                type_: ct::BetaToolChoiceToolType::Tool,
476                disable_parallel_tool_use,
477            }))
478        }
479        None => None,
480    }
481}
482
483pub fn mcp_allowed_tools_to_configs(
484    allowed_tools: Option<&ot::ResponseMcpAllowedTools>,
485) -> Option<BTreeMap<String, ct::BetaMcpToolConfig>> {
486    let names = match allowed_tools {
487        Some(ot::ResponseMcpAllowedTools::ToolNames(names)) => names.clone(),
488        Some(ot::ResponseMcpAllowedTools::Filter(filter)) => {
489            filter.tool_names.clone().unwrap_or_default()
490        }
491        None => Vec::new(),
492    };
493
494    let mut configs = BTreeMap::new();
495    for name in names {
496        configs.insert(
497            name,
498            ct::BetaMcpToolConfig {
499                defer_loading: None,
500                enabled: Some(true),
501            },
502        );
503    }
504
505    if configs.is_empty() {
506        None
507    } else {
508        Some(configs)
509    }
510}
511
512pub fn openai_mcp_tool_to_server(
513    tool: &ot::ResponseMcpTool,
514) -> Option<ct::BetaRequestMcpServerUrlDefinition> {
515    let url = tool.server_url.clone()?;
516    let allowed_tools = match &tool.allowed_tools {
517        Some(ot::ResponseMcpAllowedTools::ToolNames(names)) => Some(names.clone()),
518        Some(ot::ResponseMcpAllowedTools::Filter(filter)) => filter.tool_names.clone(),
519        None => None,
520    };
521
522    Some(ct::BetaRequestMcpServerUrlDefinition {
523        name: tool.server_label.clone(),
524        type_: ct::BetaRequestMcpServerType::Url,
525        url,
526        authorization_token: tool.authorization.clone(),
527        tool_configuration: Some(ct::BetaRequestMcpServerToolConfiguration {
528            allowed_tools,
529            enabled: Some(true),
530        }),
531    })
532}
533
534pub fn tool_from_function(tool: ot::ResponseFunctionTool) -> ct::BetaToolUnion {
535    let input_schema = function_parameters_to_tool_input_schema(tool.parameters);
536    ct::BetaToolUnion::Custom(ct::BetaTool {
537        input_schema,
538        name: tool.name,
539        common: ct::BetaToolCommonFields {
540            strict: tool.strict,
541            ..ct::BetaToolCommonFields::default()
542        },
543        description: tool.description,
544        eager_input_streaming: None,
545        type_: None,
546    })
547}
548
549fn function_parameters_to_tool_input_schema(
550    mut parameters: ot::JsonObject,
551) -> ct::BetaToolInputSchema {
552    let required = parameters.remove("required").and_then(|value| match value {
553        serde_json::Value::Array(items) => Some(
554            items
555                .iter()
556                .filter_map(|item| item.as_str().map(ToOwned::to_owned))
557                .collect::<Vec<_>>(),
558        )
559        .filter(|items| !items.is_empty()),
560        _ => None,
561    });
562
563    let properties = parameters
564        .remove("properties")
565        .as_ref()
566        .and_then(json_object_to_btree);
567
568    // Keep "type" normalized to object in the typed field.
569    let _ = parameters.remove("type");
570
571    // Preserve the rest of the JSON Schema payload (e.g. additionalProperties, $defs, oneOf...).
572    let mut extra_fields = parameters;
573
574    let properties = properties.or_else(|| {
575        let fallback_keys = extra_fields
576            .iter()
577            .filter(|(key, _)| !is_json_schema_keyword(key))
578            .map(|(key, _)| key.clone())
579            .collect::<Vec<_>>();
580
581        if fallback_keys.is_empty() {
582            return None;
583        }
584
585        let fallback = fallback_keys
586            .iter()
587            .filter_map(|key| extra_fields.remove(key).map(|value| (key.clone(), value)))
588            .collect::<ct::JsonObject>();
589
590        if fallback.is_empty() {
591            None
592        } else {
593            Some(fallback)
594        }
595    });
596
597    ct::BetaToolInputSchema {
598        type_: ct::BetaToolInputSchemaType::Object,
599        properties,
600        required,
601        extra_fields,
602    }
603}
604
605fn is_json_schema_keyword(key: &str) -> bool {
606    matches!(
607        key,
608        "$schema"
609            | "$id"
610            | "$defs"
611            | "definitions"
612            | "$ref"
613            | "type"
614            | "properties"
615            | "required"
616            | "additionalProperties"
617            | "patternProperties"
618            | "propertyNames"
619            | "unevaluatedProperties"
620            | "items"
621            | "prefixItems"
622            | "contains"
623            | "minContains"
624            | "maxContains"
625            | "allOf"
626            | "anyOf"
627            | "oneOf"
628            | "not"
629            | "if"
630            | "then"
631            | "else"
632            | "dependentSchemas"
633            | "dependentRequired"
634            | "const"
635            | "enum"
636            | "format"
637            | "default"
638            | "title"
639            | "description"
640            | "examples"
641            | "readOnly"
642            | "writeOnly"
643            | "deprecated"
644            | "nullable"
645            | "minimum"
646            | "maximum"
647            | "exclusiveMinimum"
648            | "exclusiveMaximum"
649            | "multipleOf"
650            | "minLength"
651            | "maxLength"
652            | "pattern"
653            | "minItems"
654            | "maxItems"
655            | "uniqueItems"
656            | "minProperties"
657            | "maxProperties"
658            | "contentEncoding"
659            | "contentMediaType"
660            | "contentSchema"
661    )
662}
663
664fn json_object_to_btree(value: &serde_json::Value) -> Option<ct::JsonObject> {
665    let serde_json::Value::Object(map) = value else {
666        return None;
667    };
668    Some(
669        map.iter()
670            .map(|(key, value)| (key.clone(), value.clone()))
671            .collect::<ct::JsonObject>(),
672    )
673}
674
675#[cfg(test)]
676mod tests {
677    use super::openai_reasoning_to_claude;
678    use crate::claude::count_tokens::types as ct;
679    use crate::openai::count_tokens::types as ot;
680
681    fn reasoning(effort: ot::ResponseReasoningEffort) -> ot::ResponseReasoning {
682        ot::ResponseReasoning {
683            effort: Some(effort),
684            generate_summary: None,
685            summary: None,
686        }
687    }
688
689    #[test]
690    fn opus_47_reasoning_uses_adaptive_thinking_instead_of_enabled_budget() {
691        let thinking = openai_reasoning_to_claude(
692            Some(reasoning(ot::ResponseReasoningEffort::High)),
693            Some(8_192),
694            Some(&ct::Model::Known(ct::ModelKnown::ClaudeOpus47)),
695        )
696        .expect("thinking config");
697
698        assert!(matches!(thinking, ct::BetaThinkingConfigParam::Adaptive(_)));
699    }
700
701    #[test]
702    fn older_models_keep_enabled_thinking_budget_mapping() {
703        let thinking = openai_reasoning_to_claude(
704            Some(reasoning(ot::ResponseReasoningEffort::High)),
705            Some(8_192),
706            Some(&ct::Model::Known(ct::ModelKnown::ClaudeOpus46)),
707        )
708        .expect("thinking config");
709
710        assert!(matches!(thinking, ct::BetaThinkingConfigParam::Enabled(_)));
711    }
712}