Skip to main content

sim_lib_openai_server/routes/
models.rs

1use std::collections::BTreeSet;
2
3use serde_json::{Map, Value, json};
4use sim_kernel::{Args, Cx, Error, Expr, Result, Symbol, Value as SimValue};
5use sim_lib_agent_runner_core::ModelCard;
6
7use crate::server::{GatewayRequest, GatewayResponse, GatewayRouteState};
8
9/// Route path for the OpenAI-shaped `GET /v1/models` discovery endpoint.
10pub const MODELS_PATH: &str = "/v1/models";
11
12const FIXTURE_ECHO_MODEL: &str = "fixture/echo";
13const SIM_BROWSE_PLAN_MODEL: &str = "sim/browse/plan";
14
15/// Represents a single entry in the model catalog, mapped to the OpenAI
16/// `model` object shape (`id` plus `owned_by`).
17#[derive(Clone, Debug, PartialEq)]
18pub struct OpenAiModel {
19    id: String,
20    owned_by: String,
21    metadata: Map<String, Value>,
22}
23
24impl OpenAiModel {
25    /// Builds a model entry from an id and the owning provider name.
26    pub fn new(id: impl Into<String>, owned_by: impl Into<String>) -> Self {
27        Self {
28            id: id.into(),
29            owned_by: owned_by.into(),
30            metadata: Map::new(),
31        }
32    }
33
34    /// Returns the built-in `fixture/echo` model owned by `sim`.
35    pub fn fixture_echo() -> Self {
36        Self::new(FIXTURE_ECHO_MODEL, "sim")
37    }
38
39    /// Returns a SIM-native model with the given id owned by `sim`.
40    pub fn sim_native(id: impl Into<String>) -> Self {
41        Self::new(id, "sim")
42    }
43
44    /// Builds a model entry from a runner [`ModelCard`], taking the card's
45    /// model id and provider.
46    pub fn from_model_card(card: ModelCard) -> Self {
47        let mut metadata = Map::new();
48        metadata.insert("runner".to_owned(), Value::String(card.runner.to_string()));
49        metadata.insert(
50            "locality".to_owned(),
51            Value::String(card.locality.to_string()),
52        );
53        for (key, value) in card.extra {
54            metadata.insert(metadata_key(&key), metadata_value(&value));
55        }
56        Self {
57            id: card.model,
58            owned_by: card.provider.to_string(),
59            metadata,
60        }
61    }
62
63    /// Returns the model id.
64    pub fn id(&self) -> &str {
65        &self.id
66    }
67
68    fn to_json(&self) -> Value {
69        let mut object = Map::new();
70        object.insert("id".to_owned(), Value::String(self.id.clone()));
71        object.insert("object".to_owned(), Value::String("model".to_owned()));
72        object.insert("created".to_owned(), json!(0));
73        object.insert("owned_by".to_owned(), Value::String(self.owned_by.clone()));
74        if !self.metadata.is_empty() {
75            object.insert("metadata".to_owned(), Value::Object(self.metadata.clone()));
76        }
77        Value::Object(object)
78    }
79}
80
81/// Represents the deduplicated set of models advertised by `/v1/models`,
82/// always including the built-in fixture and `sim/browse/plan` entries.
83#[derive(Clone, Debug, PartialEq)]
84pub struct ModelCatalog {
85    models: Vec<OpenAiModel>,
86}
87
88impl ModelCatalog {
89    /// Returns the catalog containing only the built-in fixture models.
90    pub fn default_fixture() -> Self {
91        Self::from_parts(Vec::new())
92    }
93
94    /// Builds a catalog from runner model-card transcripts, parsing each
95    /// expression into a [`ModelCard`] and failing on a malformed transcript.
96    pub fn from_runner_card_exprs(cards: impl IntoIterator<Item = Expr>) -> Result<Self> {
97        let cards = cards
98            .into_iter()
99            .map(ModelCard::try_from)
100            .collect::<Result<Vec<_>>>()?;
101        Ok(Self::from_model_cards(cards))
102    }
103
104    /// Builds a catalog from already-parsed runner model cards.
105    pub fn from_model_cards(cards: impl IntoIterator<Item = ModelCard>) -> Self {
106        let runner_models = cards
107            .into_iter()
108            .map(OpenAiModel::from_model_card)
109            .collect::<Vec<_>>();
110        Self::from_parts(runner_models)
111    }
112
113    /// Builds a catalog by calling the `runner/cards` function with the given
114    /// arguments and parsing the returned list of model-card transcripts.
115    ///
116    /// Returns the fixture-only catalog when no arguments are supplied, and
117    /// errors if `runner/cards` does not return a list.
118    pub fn from_runner_args(cx: &mut Cx, args: Vec<SimValue>) -> Result<Self> {
119        if args.is_empty() {
120            return Ok(Self::default_fixture());
121        }
122        let cards = cx.call_function(&runner_cards_symbol(), Args::new(args))?;
123        let expr = cards.object().as_expr(cx)?;
124        let Expr::List(items) = expr else {
125            return Err(Error::Eval(
126                "runner/cards must return a list of model-card transcripts".to_owned(),
127            ));
128        };
129        Self::from_runner_card_exprs(items)
130    }
131
132    /// Returns the catalog's models in advertised order.
133    pub fn models(&self) -> &[OpenAiModel] {
134        &self.models
135    }
136
137    fn from_parts(runner_models: Vec<OpenAiModel>) -> Self {
138        let mut seen = BTreeSet::new();
139        let mut models = Vec::new();
140        push_unique(&mut models, &mut seen, OpenAiModel::fixture_echo());
141        for model in runner_models {
142            push_unique(&mut models, &mut seen, model);
143        }
144        push_unique(
145            &mut models,
146            &mut seen,
147            OpenAiModel::sim_native(SIM_BROWSE_PLAN_MODEL),
148        );
149        Self { models }
150    }
151
152    fn to_json(&self) -> Value {
153        json!({
154            "object": "list",
155            "data": self.models.iter().map(OpenAiModel::to_json).collect::<Vec<_>>(),
156        })
157    }
158}
159
160/// Handles `GET /v1/models`, returning the catalog built from the registered
161/// runner model cards.
162pub fn handle_models(_request: &GatewayRequest, state: &GatewayRouteState) -> GatewayResponse {
163    models_response_for_catalog(&ModelCatalog::from_model_cards(state.runners().cards()))
164}
165
166/// Returns a `/v1/models` response for the fixture-only catalog.
167pub fn models_response() -> GatewayResponse {
168    models_response_for_catalog(&ModelCatalog::default_fixture())
169}
170
171/// Returns a `/v1/models` response for the catalog produced by calling
172/// `runner/cards` with the given arguments.
173pub fn models_response_for_runner_args(
174    cx: &mut Cx,
175    args: Vec<SimValue>,
176) -> Result<GatewayResponse> {
177    let catalog = ModelCatalog::from_runner_args(cx, args)?;
178    Ok(models_response_for_catalog(&catalog))
179}
180
181/// Encodes the given catalog as the OpenAI `list`-of-models JSON body.
182pub fn models_response_for_catalog(catalog: &ModelCatalog) -> GatewayResponse {
183    GatewayResponse::json(200, catalog.to_json().to_string().into_bytes())
184}
185
186/// Returns the `runner/cards` function symbol that fetches model cards.
187pub fn runner_cards_symbol() -> Symbol {
188    Symbol::qualified("runner", "cards")
189}
190
191fn push_unique(models: &mut Vec<OpenAiModel>, seen: &mut BTreeSet<String>, model: OpenAiModel) {
192    if seen.insert(model.id.clone()) {
193        models.push(model);
194    }
195}
196
197fn metadata_key(expr: &Expr) -> String {
198    match expr {
199        Expr::Symbol(symbol) | Expr::Local(symbol) if symbol.namespace.is_none() => {
200            normalize_metadata_key(symbol.name.as_ref())
201        }
202        Expr::Symbol(symbol) | Expr::Local(symbol) => normalize_metadata_key(&symbol.to_string()),
203        Expr::String(value) => normalize_metadata_key(value),
204        other => normalize_metadata_key(&format!("{other:?}")),
205    }
206}
207
208fn normalize_metadata_key(value: &str) -> String {
209    value
210        .chars()
211        .map(|ch| {
212            if ch.is_ascii_alphanumeric() || ch == '_' {
213                ch
214            } else {
215                '_'
216            }
217        })
218        .collect()
219}
220
221fn metadata_value(expr: &Expr) -> Value {
222    match expr {
223        Expr::Nil => Value::Null,
224        Expr::Bool(value) => Value::Bool(*value),
225        Expr::Number(value) => number_json(&value.canonical),
226        Expr::Symbol(symbol) | Expr::Local(symbol) if symbol.namespace.is_none() => {
227            Value::String(symbol.name.as_ref().to_owned())
228        }
229        Expr::Symbol(symbol) | Expr::Local(symbol) => Value::String(symbol.to_string()),
230        Expr::String(value) => Value::String(value.clone()),
231        Expr::Bytes(bytes) => Value::Array(bytes.iter().map(|byte| json!(byte)).collect()),
232        Expr::List(values) | Expr::Vector(values) | Expr::Set(values) | Expr::Block(values) => {
233            Value::Array(values.iter().map(metadata_value).collect())
234        }
235        Expr::Map(entries) => {
236            let mut object = Map::new();
237            for (key, value) in entries {
238                object.insert(metadata_key(key), metadata_value(value));
239            }
240            Value::Object(object)
241        }
242        other => Value::String(format!("{other:?}")),
243    }
244}
245
246fn number_json(canonical: &str) -> Value {
247    serde_json::from_str(canonical).unwrap_or_else(|_| Value::String(canonical.to_owned()))
248}