Skip to main content

gproxy_protocol/protocol/openai/
models.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use super::common::*;
6
7pub type ModelsWireModel = OpenAiWireModel<(), ModelListResponse>;
8pub type ModelRetrieveWireModel = OpenAiWireModel<(), Model>;
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, gproxy_protocol_macros::WireBuilder)]
11#[non_exhaustive]
12pub struct ModelListResponse {
13    pub data: Vec<Model>,
14    pub object: ListObjectType,
15    #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
16    pub extra: Extra,
17}
18
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, gproxy_protocol_macros::WireBuilder)]
20#[non_exhaustive]
21pub struct Model {
22    pub id: OpenAiModelId,
23    // OpenAI-compatible providers (e.g. DeepSeek) omit `created`; keep it
24    // optional so decoding their model list for a response transform doesn't
25    // fail on the missing field.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub created: Option<u64>,
28    // gproxy extension: limits and capabilities surfaced from providers that
29    // report them (Claude, Gemini, Codex). The official OpenAI model object has
30    // no such fields.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub display_name: Option<String>,
33    /// Input allowance. OpenAI has no separate input limit — the context window
34    /// *is* the accepted input size — so peers that split the two (Claude's
35    /// `max_input_tokens`, Gemini's `inputTokenLimit`) map onto this field.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub context_window: Option<u64>,
38    /// Codex reports the override ceiling separately from the default budget;
39    /// kept verbatim so a client can see the headroom, while `context_window`
40    /// carries the value the catalogue consumes.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub max_context_window: Option<u64>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub max_output_tokens: Option<u64>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub thinking_supported: Option<bool>,
47    pub object: ModelObjectType,
48    pub owned_by: String,
49    #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
50    pub extra: Extra,
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    /// DeepSeek (and other OpenAI-compatible providers) return model entries
58    /// without `created`; decoding for a response transform must not fail.
59    #[test]
60    fn model_decodes_without_created() {
61        let m: Model = serde_json::from_str(
62            r#"{"id":"deepseek-chat","object":"model","owned_by":"deepseek"}"#,
63        )
64        .expect("decode without created");
65        assert_eq!(m.created, None);
66        // …and a missing `created` is omitted on re-encode, not fabricated.
67        let s = serde_json::to_string(&m).unwrap();
68        assert!(!s.contains("created"), "{s}");
69    }
70}