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 first.id != page.first_id || last.id != page.last_id {
138                return Err(progress.invalid(
139                    "pagination",
140                    "Claude page boundary IDs disagree with model data",
141                ));
142            }
143        } else if page.has_more {
144            return Err(progress.invalid("pagination", "empty Claude page claims continuation"));
145        }
146        if first_id.is_none() {
147            first_id = Some(page.first_id);
148        }
149        for model in page.data {
150            progress.item(&model.id)?;
151            data.push(model);
152        }
153        if !page.has_more {
154            let last_id = data
155                .last()
156                .map(|model| model.id.clone())
157                .unwrap_or(page.last_id);
158            return Ok(ModelDirectory {
159                value: c::ListModelsResponseBody {
160                    data,
161                    first_id: first_id.expect("first page was decoded"),
162                    last_id,
163                    has_more: false,
164                    rest: Default::default(),
165                },
166                completed_calls: progress.calls,
167            });
168        }
169        if page.last_id.is_empty() || !cursors.insert(page.last_id.clone()) {
170            return Err(progress.invalid(
171                "pagination.last_id",
172                "empty or repeated continuation cursor",
173            ));
174        }
175        cursor = Some(page.last_id);
176    }
177}
178
179/// Fetch all Gemini pages using each opaque `nextPageToken` verbatim.
180pub async fn collect_gemini<U: Upstream>(
181    upstream: &U,
182    target: &U::Target,
183    request: WireRequest<()>,
184    query: g::ListModelsQuery,
185    limits: ModelListLimits,
186) -> Result<ModelDirectory<g::ListModelsResponseBody>, ModelListError> {
187    let query = query.into_declared();
188    query::page_size(query.page_size, i64::MAX)?;
189    if query.page_token.is_some() {
190        return Err(TransformError::shape(
191            "query.page_token",
192            "full-directory collection must begin at the first page",
193        )
194        .into());
195    }
196    let mut progress = Progress::new(&request, limits)?;
197    let mut cursor = None;
198    let mut cursors = BTreeSet::new();
199    let mut models = Vec::new();
200    loop {
201        let mut pairs = Vec::new();
202        if let Some(size) = query.page_size {
203            pairs.push(("pageSize", size.to_string()));
204        }
205        if let Some(cursor) = &cursor {
206            pairs.push(("pageToken", String::clone(cursor)));
207        }
208        let mut next = WireRequest {
209            method: request.method.clone(),
210            path: request.path.clone(),
211            query: None,
212            headers: request.headers.clone(),
213            body: (),
214        };
215        next.query = query::encode(&pairs);
216        let page: g::ListModelsResponseBody = progress.page(upstream, target, next).await?;
217        for model in page.models.unwrap_or_default() {
218            progress.item(&model.name)?;
219            models.push(model);
220        }
221        // Google pagination uses an empty token as the terminal marker.
222        match page.next_page_token.filter(|token| !token.is_empty()) {
223            None => {
224                return Ok(ModelDirectory {
225                    value: g::ListModelsResponseBody {
226                        models: Some(models),
227                        next_page_token: None,
228                        rest: Default::default(),
229                    },
230                    completed_calls: progress.calls,
231                });
232            }
233            Some(token) => {
234                if !cursors.insert(token.clone()) {
235                    return Err(
236                        progress.invalid("pagination.nextPageToken", "repeated continuation token")
237                    );
238                }
239                cursor = Some(token);
240            }
241        }
242    }
243}
244
245/// OpenAI model lists are unpaged, but still obey aggregate/model limits.
246pub async fn collect_openai<U: Upstream>(
247    upstream: &U,
248    target: &U::Target,
249    request: WireRequest<()>,
250    limits: ModelListLimits,
251) -> Result<ModelDirectory<o::ListModelsResponseBody>, ModelListError> {
252    let mut progress = Progress::new(&request, limits)?;
253    let page: o::ListModelsResponseBody = progress.page(upstream, target, request).await?;
254    for model in &page.data {
255        progress.item(&model.id)?;
256    }
257    Ok(ModelDirectory {
258        value: page,
259        completed_calls: progress.calls,
260    })
261}