Skip to main content

kunobi_decision/client/
models.rs

1//! The Models API resource.
2
3use reqwest::Method;
4use serde::Deserialize;
5
6use super::call::{Call, DecodeFailure};
7use super::{Client, Provider};
8use crate::types::ModelCard;
9use crate::{Questions, SystemOneRequest, SystemOneResult, noul};
10
11/// Access to the Models API resource.
12#[derive(Debug, Clone)]
13pub struct Models {
14    client: Client,
15}
16
17impl Models {
18    pub(crate) fn new(client: Client) -> Self {
19        Self { client }
20    }
21
22    /// List the models available to the account.
23    ///
24    /// Liquid has no documented catalog endpoint: this sends a small inference
25    /// probe and returns the responding model. It consumes input tokens.
26    /// OpenRouter returns decision models from its public catalog; use
27    /// [`Client::check`] to verify credentials too.
28    pub fn list(&self) -> Call<Vec<ModelCard>> {
29        match self.client.provider() {
30            Provider::OpenRouter => Call::new(
31                self.client.clone(),
32                Method::GET,
33                "/v1/models?output_modalities=decisions",
34                Ok(None),
35                unwrap_openrouter_models,
36            ),
37            Provider::Cloudflare { account_id } => Call::new(
38                self.client.clone(),
39                Method::GET,
40                format!(
41                    "/client/v4/accounts/{account_id}/ai/models/search?search=%40cf%2Fcloudflare%2Fclef&per_page=100"
42                ),
43                Ok(None),
44                unwrap_clef_models,
45            ),
46            // Liquid documents inference but no model catalog. Authenticate by
47            // evaluating a small probe with the documented default model.
48            Provider::Liquid => {
49                let mut questions = Questions::new();
50                questions.add("available", noul("Does the state say ready?"));
51                let body =
52                    SystemOneRequest::new("ready", questions).to_body(self.client.default_model());
53                Call::new(
54                    self.client.clone(),
55                    Method::POST,
56                    "/v1/systemone",
57                    body.map(Some),
58                    unwrap_liquid_model,
59                )
60            }
61            _ => Call::new(
62                self.client.clone(),
63                Method::GET,
64                "/v1/models",
65                Ok(None),
66                unwrap_models,
67            ),
68        }
69    }
70}
71
72fn unwrap_models(body: &[u8]) -> Result<Vec<ModelCard>, DecodeFailure> {
73    #[derive(Deserialize)]
74    struct Wire {
75        models: Vec<ModelCard>,
76    }
77
78    serde_json::from_slice::<Wire>(body)
79        .map(|wire| wire.models)
80        .map_err(|err| DecodeFailure {
81            message: "Unexpected response shape from GET /v1/models; expected { models: [...] }."
82                .into(),
83            source: Some(err),
84        })
85}
86
87fn unwrap_clef_models(body: &[u8]) -> Result<Vec<ModelCard>, DecodeFailure> {
88    let models: Vec<ModelCard> = super::provider::unwrap_cloudflare(body)?;
89    let models: Vec<_> = models
90        .into_iter()
91        .filter_map(|mut model| {
92            let name = model.name.strip_prefix("@cf/cloudflare/")?;
93            if !matches!(name, "clef" | "clef-flash") {
94                return None;
95            }
96            model.name = name.to_owned();
97            Some(model)
98        })
99        .collect();
100    if models.is_empty() {
101        return Err(DecodeFailure {
102            message: "Cloudflare did not list any Clef models for this account".into(),
103            source: None,
104        });
105    }
106    Ok(models)
107}
108
109fn unwrap_liquid_model(body: &[u8]) -> Result<Vec<ModelCard>, DecodeFailure> {
110    let result: SystemOneResult = super::call::parse_json(body)?;
111    Ok(vec![ModelCard {
112        name: result.model,
113        description: "Liquid decision model (inference probe)".into(),
114        release_date: String::new(),
115    }])
116}
117
118fn unwrap_openrouter_models(body: &[u8]) -> Result<Vec<ModelCard>, DecodeFailure> {
119    #[derive(Deserialize)]
120    struct Catalog {
121        data: Vec<Model>,
122    }
123    #[derive(Deserialize)]
124    struct Model {
125        id: String,
126        #[serde(default)]
127        description: String,
128        architecture: Architecture,
129    }
130    #[derive(Deserialize)]
131    struct Architecture {
132        output_modalities: Vec<String>,
133    }
134    let catalog: Catalog = super::call::parse_json(body)?;
135    Ok(catalog
136        .data
137        .into_iter()
138        .filter(|m| {
139            m.architecture
140                .output_modalities
141                .iter()
142                .any(|kind| kind == "decisions")
143        })
144        .map(|m| ModelCard {
145            name: m.id,
146            description: m.description,
147            release_date: String::new(),
148        })
149        .collect())
150}