Skip to main content

gproxy_protocol/transform/models/
list.rs

1use super::{
2    common, get,
3    supplement::{
4        ClaudeModelSupplement, Converted, GeminiModelSupplement, ListPageFacts,
5        OpenAiModelSupplement, missing_pagination,
6    },
7};
8use crate::transform::{Report, TransformError};
9use crate::wire::DeclaredFields;
10use crate::wire::{
11    claude::models as claude_models, gemini::models as gemini_models,
12    openai::models as openai_models,
13};
14use std::collections::{BTreeMap, BTreeSet};
15
16/// List supplement maps use the exact source resource field: `id` for
17/// Claude/OpenAI and `name` (including its `models/` prefix) for Gemini.
18fn supplement_for<'a, T>(
19    supplements: &'a BTreeMap<String, T>,
20    source_key: &str,
21) -> Result<&'a T, TransformError> {
22    supplements
23        .get(source_key)
24        .ok_or_else(|| TransformError::missing_metadata(format!("model[{source_key}].supplement")))
25}
26
27/// Reconcile the two OpenAI directory representations using the same typed model.
28/// Standard identity/timestamps and alternate catalog metadata describe one resource.
29pub fn normalize_openai_list(
30    input: openai_models::ListModelsResponseBody,
31) -> Result<openai_models::ListModelsResponseBody, TransformError> {
32    let mut input = input.into_declared();
33    for model in &mut input.data {
34        model.id = common::openai_identity(model)?.to_owned();
35    }
36    let mut index: BTreeMap<String, usize> = input
37        .data
38        .iter()
39        .enumerate()
40        .map(|(index, model)| (model.id.clone(), index))
41        .collect();
42    let mut seen = BTreeSet::new();
43    for mut model in input.models.take().unwrap_or_default() {
44        model.id = common::openai_identity(&model)?.to_owned();
45        if !seen.insert(model.id.clone()) {
46            return Err(TransformError::shape("models", "repeated model identity"));
47        }
48        if let Some(&position) = index.get(&model.id) {
49            merge_openai_model(&mut input.data[position], model)?;
50        } else {
51            index.insert(model.id.clone(), input.data.len());
52            input.data.push(model);
53        }
54    }
55    Ok(input)
56}
57
58fn merge_openai_model(
59    target: &mut openai_models::Model,
60    mut source: openai_models::Model,
61) -> Result<(), TransformError> {
62    macro_rules! merge {
63        ($($field:ident),* $(,)?) => {$(
64            target.$field = common::choose_optional(
65                target.$field.take(), source.$field.take(),
66                &format!("model[{}].{}", target.id, stringify!($field)),
67            )?;
68        )*};
69    }
70    merge!(
71        slug,
72        created,
73        object,
74        owned_by,
75        display_name,
76        description,
77        instructions,
78        context_window,
79        max_context_window,
80        max_output_tokens,
81        thinking_supported,
82        input_modalities,
83        output_modalities,
84        supported_parameters,
85        supported_reasoning_levels,
86        default_reasoning_level,
87        service_tiers,
88        default_service_tier,
89        generation_methods,
90        supported_actions,
91        guardian,
92        shell_type,
93        visibility,
94        supported_in_api,
95        priority,
96        additional_speed_tiers,
97        available_access_programs,
98        availability_nux,
99        upgrade,
100        model_messages,
101        include_skills_usage_instructions,
102        include_plugin_usage_instructions,
103        include_apps_usage_instructions,
104        supports_reasoning_summary_parameter,
105        default_reasoning_summary,
106        support_verbosity,
107        default_verbosity,
108        apply_patch_tool_type,
109        web_search_tool_type,
110        truncation_policy,
111        supports_image_detail_original,
112        auto_compact_token_limit,
113        comp_hash,
114        effective_context_window_percent,
115        experimental_supported_tools,
116        supports_search_tool,
117        supports_experimental_context,
118        use_responses_lite,
119        supports_reasoning_effort_updates,
120        node_repl_auto_review_required,
121        node_repl_disabled,
122        auto_review_model_override,
123        model_specialty,
124        tool_mode,
125        multi_agent_version,
126        multi_agent_reasoning_effort,
127        base_instructions,
128        prefer_websockets,
129        supports_parallel_tool_calls,
130        supports_reasoning_summaries,
131        requires_sandboxed_review,
132        available_in_plans,
133        minimal_client_version,
134    );
135    Ok(())
136}
137
138pub fn claude_to_openai_list(
139    input: claude_models::ListModelsResponseBody,
140    supplements: &BTreeMap<String, OpenAiModelSupplement>,
141    _page: &ListPageFacts,
142) -> Result<Converted<openai_models::ListModelsResponseBody>, TransformError> {
143    let mut report = Report::default();
144    let mut data = Vec::with_capacity(input.data.len());
145    for model in input.data {
146        let supplement = supplement_for(supplements, &model.id)?;
147        let converted = get::claude_to_openai(model, supplement)?;
148        common::merge_report(&mut report, converted.report);
149        data.push(converted.value);
150    }
151
152    report.omitted("pagination", "OpenAI model lists have no cursor envelope");
153    Ok(common::converted(
154        openai_models::ListModelsResponseBody {
155            data,
156            object: openai_models::ListObject::List,
157            models: None,
158            rest: Default::default(),
159        },
160        report,
161    ))
162}
163
164pub fn claude_to_gemini_list(
165    input: claude_models::ListModelsResponseBody,
166    supplements: &BTreeMap<String, GeminiModelSupplement>,
167    page: &ListPageFacts,
168) -> Result<Converted<gemini_models::ListModelsResponseBody>, TransformError> {
169    let mut report = Report::default();
170    let mut models = Vec::with_capacity(input.data.len());
171    for model in input.data {
172        let supplement = supplement_for(supplements, &model.id)?;
173        let converted = get::claude_to_gemini(model, supplement)?;
174        common::merge_report(&mut report, converted.report);
175        models.push(converted.value);
176    }
177
178    Ok(common::converted(
179        gemini_models::ListModelsResponseBody {
180            models: Some(models),
181            next_page_token: page.next_page_token.clone(),
182            rest: Default::default(),
183        },
184        report,
185    ))
186}
187
188pub fn openai_to_claude_list(
189    input: openai_models::ListModelsResponseBody,
190    supplements: &BTreeMap<String, ClaudeModelSupplement>,
191    page: &ListPageFacts,
192) -> Result<Converted<claude_models::ListModelsResponseBody>, TransformError> {
193    let mut report = Report::default();
194    if input.models.is_some() {
195        report.changed(
196            "models",
197            "alternate catalog reconciled with standard model data",
198        );
199    }
200    let input = normalize_openai_list(input)?;
201    let mut data = Vec::with_capacity(input.data.len());
202    for model in input.data {
203        let supplement = supplement_for(supplements, &model.id)?;
204        let converted = get::openai_to_claude(model, supplement)?;
205        common::merge_report(&mut report, converted.report);
206        data.push(converted.value);
207    }
208
209    let (first_id, last_id, has_more) = claude_page(&data, page)?;
210    Ok(common::converted(
211        claude_models::ListModelsResponseBody {
212            first_id,
213            last_id,
214            has_more,
215            data,
216            rest: Default::default(),
217        },
218        report,
219    ))
220}
221
222pub fn openai_to_gemini_list(
223    input: openai_models::ListModelsResponseBody,
224    supplements: &BTreeMap<String, GeminiModelSupplement>,
225    page: &ListPageFacts,
226) -> Result<Converted<gemini_models::ListModelsResponseBody>, TransformError> {
227    let mut report = Report::default();
228    if input.models.is_some() {
229        report.changed(
230            "models",
231            "alternate catalog reconciled with standard model data",
232        );
233    }
234    let input = normalize_openai_list(input)?;
235    let mut models = Vec::with_capacity(input.data.len());
236    for model in input.data {
237        let supplement = supplement_for(supplements, &model.id)?;
238        let converted = get::openai_to_gemini(model, supplement)?;
239        common::merge_report(&mut report, converted.report);
240        models.push(converted.value);
241    }
242
243    Ok(common::converted(
244        gemini_models::ListModelsResponseBody {
245            models: Some(models),
246            next_page_token: page.next_page_token.clone(),
247            rest: Default::default(),
248        },
249        report,
250    ))
251}
252
253pub fn gemini_to_openai_list(
254    input: gemini_models::ListModelsResponseBody,
255    supplements: &BTreeMap<String, OpenAiModelSupplement>,
256    _page: &ListPageFacts,
257) -> Result<Converted<openai_models::ListModelsResponseBody>, TransformError> {
258    let mut report = Report::default();
259    let source_models = input.models.unwrap_or_default();
260    let mut data = Vec::with_capacity(source_models.len());
261    for model in source_models {
262        let supplement = supplement_for(supplements, &model.name)?;
263        let converted = get::gemini_to_openai(model, supplement)?;
264        common::merge_report(&mut report, converted.report);
265        data.push(converted.value);
266    }
267
268    report.omitted("pagination", "OpenAI model lists have no cursor envelope");
269    Ok(common::converted(
270        openai_models::ListModelsResponseBody {
271            data,
272            object: openai_models::ListObject::List,
273            models: None,
274            rest: Default::default(),
275        },
276        report,
277    ))
278}
279
280pub fn gemini_to_claude_list(
281    input: gemini_models::ListModelsResponseBody,
282    supplements: &BTreeMap<String, ClaudeModelSupplement>,
283    page: &ListPageFacts,
284) -> Result<Converted<claude_models::ListModelsResponseBody>, TransformError> {
285    let mut report = Report::default();
286    let source_models = input.models.unwrap_or_default();
287    let mut data = Vec::with_capacity(source_models.len());
288    for model in source_models {
289        let supplement = supplement_for(supplements, &model.name)?;
290        let converted = get::gemini_to_claude(model, supplement)?;
291        common::merge_report(&mut report, converted.report);
292        data.push(converted.value);
293    }
294
295    let (first_id, last_id, has_more) = claude_page(&data, page)?;
296    Ok(common::converted(
297        claude_models::ListModelsResponseBody {
298            first_id,
299            last_id,
300            has_more,
301            data,
302            rest: Default::default(),
303        },
304        report,
305    ))
306}
307
308fn claude_page(
309    models: &[claude_models::ModelInfo],
310    facts: &ListPageFacts,
311) -> Result<(Option<String>, Option<String>, bool), TransformError> {
312    let derived_first = models.first().map(|model| model.id.clone());
313    let derived_last = models.last().map(|model| model.id.clone());
314    let first_id = match (&facts.first_id, derived_first) {
315        (Some(supplied), Some(derived)) if supplied != &derived => {
316            return Err(TransformError::new(
317                crate::transform::TransformErrorKind::Conflict,
318                "pagination.first_id",
319                "page fact disagrees with converted list",
320            ));
321        }
322        (Some(supplied), _) => Some(supplied.clone()),
323        (None, derived) if facts.complete => derived,
324        _ => {
325            return Err(missing_pagination(
326                "Claude first_id requires complete list or explicit page facts (NeedsPagination)",
327            ));
328        }
329    };
330    let last_id = match (&facts.last_id, derived_last) {
331        (Some(supplied), Some(derived)) if supplied != &derived => {
332            return Err(TransformError::new(
333                crate::transform::TransformErrorKind::Conflict,
334                "pagination.last_id",
335                "page fact disagrees with converted list",
336            ));
337        }
338        (Some(supplied), _) => Some(supplied.clone()),
339        (None, derived) if facts.complete => derived,
340        _ => {
341            return Err(missing_pagination(
342                "Claude last_id requires complete list or explicit page facts (NeedsPagination)",
343            ));
344        }
345    };
346    let has_more = match (facts.has_more, facts.complete) {
347        (Some(value), _) => value,
348        (None, true) => false,
349        (None, false) => {
350            return Err(missing_pagination(
351                "Claude has_more requires explicit page facts (NeedsPagination)",
352            ));
353        }
354    };
355    Ok((first_id, last_id, has_more))
356}