Skip to main content

gproxy_protocol/adapt/models/
collect.rs

1use super::{ModelDirectory, ModelListError, ModelListFailure, ModelListLimits, query};
2use crate::{
3    WireRequest,
4    adapt::{JsonInvocation, invoke_empty},
5    capability::Upstream,
6    codec,
7    transform::{TransformError, TransformErrorKind},
8    wire::{DeclaredFields, claude::models as c, gemini::models as g, openai::models as o},
9};
10use serde::{Serialize, de::DeserializeOwned};
11use std::collections::BTreeSet;
12
13struct Progress {
14    calls: usize,
15    models: usize,
16    bytes: u64,
17    ids: BTreeSet<String>,
18    limits: ModelListLimits,
19}
20
21impl Progress {
22    fn new(request: &WireRequest<()>, limits: ModelListLimits) -> Result<Self, ModelListError> {
23        query::template(request)?;
24
25        Ok(Self {
26            calls: 0,
27            models: 0,
28            bytes: 0,
29            ids: BTreeSet::new(),
30            limits,
31        })
32    }
33    fn error(&self, error: TransformError) -> ModelListError {
34        ModelListError {
35            completed_calls: self.calls,
36            completed_models: self.models,
37            failure: ModelListFailure::Transform(error),
38        }
39    }
40    fn invalid(&self, path: &str, detail: &str) -> ModelListError {
41        self.error(TransformError::new(
42            TransformErrorKind::InvalidResult,
43            path,
44            detail,
45        ))
46    }
47    async fn page<U: Upstream, O: DeserializeOwned + DeclaredFields + Serialize>(
48        &mut self,
49        upstream: &U,
50        target: &U::Target,
51        request: WireRequest<()>,
52    ) -> Result<O, ModelListError> {
53        let result = invoke_empty(upstream, target, request, self.limits.codec)
54            .await
55            .map_err(|e| self.error(e))?;
56        let response = match result {
57            JsonInvocation::Success(response) => response,
58            JsonInvocation::Rejected(response) => {
59                return Err(ModelListError {
60                    completed_calls: self.calls,
61                    completed_models: self.models,
62                    failure: ModelListFailure::Rejected(Box::new(response)),
63                });
64            }
65        };
66        self.calls += 1;
67        let encoded = codec::encode_json(&response.body, self.limits.codec).map_err(|e| {
68            self.error(TransformError::new(
69                TransformErrorKind::Limit,
70                "pagination.page",
71                e.to_string(),
72            ))
73        })?;
74        self.bytes = self
75            .bytes
76            .checked_add(encoded.len() as u64)
77            .ok_or_else(|| self.invalid("pagination.bytes", "byte counter overflow"))?;
78        if self.bytes > self.limits.max_declared_bytes {
79            return Err(self.error(TransformError::new(
80                TransformErrorKind::Limit,
81                "pagination.max_declared_bytes",
82                "aggregate declared page bytes exceeded",
83            )));
84        }
85        Ok(response.body)
86    }
87    fn item(&mut self, id: &str) -> Result<(), ModelListError> {
88        if id.is_empty() || !self.ids.insert(id.to_owned()) {
89            return Err(self.invalid("models.id", "empty or repeated model identity across pages"));
90        }
91
92        self.models += 1;
93        Ok(())
94    }
95}
96
97/// Fetch all Claude pages in forward order, starting without a cursor.
98pub async fn collect_claude<U: Upstream>(
99    upstream: &U,
100    target: &U::Target,
101    request: WireRequest<()>,
102    query: c::ListModelsQuery,
103    limits: ModelListLimits,
104) -> Result<ModelDirectory<c::ListModelsResponseBody>, ModelListError> {
105    let query = query.into_declared();
106    query::page_size(query.limit, 1000)?;
107    if query.after_id.is_some() || query.before_id.is_some() {
108        return Err(TransformError::shape(
109            "query.cursor",
110            "full-directory collection must begin at the first page",
111        )
112        .into());
113    }
114    let mut progress = Progress::new(&request, limits)?;
115    let mut cursor = None;
116    let mut cursors = BTreeSet::new();
117    let mut data = Vec::new();
118    let mut first_id = None;
119    loop {
120        let mut pairs = Vec::new();
121        if let Some(limit) = query.limit {
122            pairs.push(("limit", limit.to_string()));
123        }
124        if let Some(cursor) = &cursor {
125            pairs.push(("after_id", String::clone(cursor)));
126        }
127        let mut next = WireRequest {
128            method: request.method.clone(),
129            path: request.path.clone(),
130            query: None,
131            headers: request.headers.clone(),
132            body: (),
133        };
134        next.query = query::encode(&pairs);
135        let page: c::ListModelsResponseBody = progress.page(upstream, target, next).await?;
136        if let (Some(first), Some(last)) = (page.data.first(), page.data.last()) {
137            if page.first_id.as_deref() != Some(first.id.as_str())
138                || page.last_id.as_deref() != Some(last.id.as_str())
139            {
140                return Err(progress.invalid(
141                    "pagination",
142                    "Claude page boundary IDs disagree with model data",
143                ));
144            }
145        } else if page.has_more {
146            return Err(progress.invalid("pagination", "empty Claude page claims continuation"));
147        }
148        if first_id.is_none() {
149            first_id = page.first_id;
150        }
151        for model in page.data {
152            progress.item(&model.id)?;
153            data.push(model);
154        }
155        if !page.has_more {
156            let last_id = data.last().map(|model| model.id.clone()).or(page.last_id);
157            return Ok(ModelDirectory {
158                value: c::ListModelsResponseBody {
159                    data,
160                    first_id,
161                    last_id,
162                    has_more: false,
163                    rest: Default::default(),
164                },
165                completed_calls: progress.calls,
166            });
167        }
168        let next_cursor = page.last_id.filter(|id| !id.is_empty()).ok_or_else(|| {
169            progress.invalid("pagination.last_id", "missing or empty continuation cursor")
170        })?;
171        if !cursors.insert(next_cursor.clone()) {
172            return Err(progress.invalid(
173                "pagination.last_id",
174                "empty or repeated continuation cursor",
175            ));
176        }
177        cursor = Some(next_cursor);
178    }
179}
180
181/// Fetch all Gemini pages using each opaque `nextPageToken` verbatim.
182pub async fn collect_gemini<U: Upstream>(
183    upstream: &U,
184    target: &U::Target,
185    request: WireRequest<()>,
186    query: g::ListModelsQuery,
187    limits: ModelListLimits,
188) -> Result<ModelDirectory<g::ListModelsResponseBody>, ModelListError> {
189    let query = query.into_declared();
190    query::page_size(query.page_size, i64::MAX)?;
191    if query.page_token.is_some() {
192        return Err(TransformError::shape(
193            "query.page_token",
194            "full-directory collection must begin at the first page",
195        )
196        .into());
197    }
198    let mut progress = Progress::new(&request, limits)?;
199    let mut cursor = None;
200    let mut cursors = BTreeSet::new();
201    let mut models = Vec::new();
202    loop {
203        let mut pairs = Vec::new();
204        if let Some(size) = query.page_size {
205            pairs.push(("pageSize", size.to_string()));
206        }
207        if let Some(cursor) = &cursor {
208            pairs.push(("pageToken", String::clone(cursor)));
209        }
210        let mut next = WireRequest {
211            method: request.method.clone(),
212            path: request.path.clone(),
213            query: None,
214            headers: request.headers.clone(),
215            body: (),
216        };
217        next.query = query::encode(&pairs);
218        let page: g::ListModelsResponseBody = progress.page(upstream, target, next).await?;
219        for model in page.models.unwrap_or_default() {
220            progress.item(&model.name)?;
221            models.push(model);
222        }
223        // Google pagination uses an empty token as the terminal marker.
224        match page.next_page_token.filter(|token| !token.is_empty()) {
225            None => {
226                return Ok(ModelDirectory {
227                    value: g::ListModelsResponseBody {
228                        models: Some(models),
229                        next_page_token: None,
230                        rest: Default::default(),
231                    },
232                    completed_calls: progress.calls,
233                });
234            }
235            Some(token) => {
236                if !cursors.insert(token.clone()) {
237                    return Err(
238                        progress.invalid("pagination.nextPageToken", "repeated continuation token")
239                    );
240                }
241                cursor = Some(token);
242            }
243        }
244    }
245}
246
247/// OpenAI model lists are unpaged, but still obey aggregate/model limits.
248pub async fn collect_openai<U: Upstream>(
249    upstream: &U,
250    target: &U::Target,
251    request: WireRequest<()>,
252    limits: ModelListLimits,
253) -> Result<ModelDirectory<o::ListModelsResponseBody>, ModelListError> {
254    let mut progress = Progress::new(&request, limits)?;
255    let page: o::ListModelsResponseBody = progress.page(upstream, target, request).await?;
256    let page = crate::transform::models::normalize_openai_list(page)
257        .map_err(|error| progress.error(error))?;
258    for model in &page.data {
259        progress.item(&model.id)?;
260    }
261    Ok(ModelDirectory {
262        value: page,
263        completed_calls: progress.calls,
264    })
265}