Skip to main content

cortexkit_role_classifier/
provider.rs

1//! The provider side: what a runner sends each provider for one item, and
2//! what the provider answers. Both starting providers share one shape
3//! (Cloudflare calls clef "fully Jev-API compatible"); `CONTRACT.md` cites
4//! the documentation each shape comes from.
5//!
6//! One provider call carries one item's `state` with every question of the
7//! batch. The runner maps the batch's catalog id to the provider's own model
8//! value ([`models`]) and forwards `state`, the questions and the images as
9//! the caller wrote them.
10
11use indexmap::IndexMap;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use crate::{
16    question::{Image, Question},
17    reply::{Answer, Usage},
18};
19
20/// Catalog ids of the starting providers' models: models.dev's provider key,
21/// then `/`, then models.dev's model id. The first `/` splits the two, so `@`
22/// and any further `/` belong to the model id. Each pins a model version,
23/// never a moving alias.
24pub mod catalog_ids {
25    pub const CLEF: &str = "cloudflare-workers-ai/@cf/cloudflare/clef";
26    pub const CLEF_FLASH: &str = "cloudflare-workers-ai/@cf/cloudflare/clef-flash";
27    pub const JEV_1_13_0: &str = "typesafe/jev-1.13.0";
28}
29
30/// The `model` value each provider expects in its request body.
31///
32/// A runner serving a catalog row sends that row's pinned value, never a
33/// moving alias: an alias can start naming a newer model whose pricing and
34/// behaviour differ from the row's.
35pub mod models {
36    /// Cloudflare Workers AI `@cf/cloudflare/clef`.
37    pub const CLEF: &str = "clef";
38    /// Cloudflare Workers AI's faster clef variant.
39    pub const CLEF_FLASH: &str = "clef-flash";
40    /// The `model` value for catalog row `typesafe/jev-1.13.0`
41    /// ([`super::catalog_ids::JEV_1_13_0`]). TypeSafe accepts versioned ids
42    /// in `model` and names the version that answered in the reply's
43    /// `model`.
44    pub const JEV_1_13_0: &str = "jev-1.13.0";
45    /// TypeSafe's moving alias for its newest model, for callers that do not
46    /// pin a version. A runner serving a catalog row never sends it; it sends
47    /// the row's pinned value, such as [`JEV_1_13_0`].
48    pub const JEV_LATEST: &str = "jev-latest";
49}
50
51/// Provider hosts a `role.describe` names as `egress_host`.
52pub mod hosts {
53    pub const CLOUDFLARE: &str = "api.cloudflare.com";
54    pub const TYPESAFE: &str = "api.typesafe.ai";
55}
56
57/// TypeSafe's classification endpoint.
58pub const TYPESAFE_ENDPOINT: &str = "https://api.typesafe.ai/v1/systemone";
59
60/// clef's limits on one request, from Cloudflare's documentation.
61pub mod clef_limits {
62    /// Fewest questions in one request.
63    pub const MIN_QUESTIONS: usize = 1;
64    /// Most questions in one request.
65    pub const MAX_QUESTIONS: usize = 64;
66    /// Longest question id; ids use letters, digits, `_`, `.` and `-`.
67    pub const MAX_QUESTION_ID_LEN: usize = 100;
68    /// Most images in one request.
69    pub const MAX_IMAGES: usize = 4;
70    /// Largest request body, in bytes (13 MiB).
71    pub const MAX_BODY_BYTES: usize = 13 * 1024 * 1024;
72}
73
74/// The provider request for one item: `{model, state, questions, images?}`.
75/// `images` is clef only; TypeSafe takes text only.
76#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
77#[non_exhaustive]
78pub struct ProviderRequest {
79    /// The provider's own model value, one of [`models`].
80    pub model: String,
81    /// A string, or structured data (an object or array). The provider
82    /// silently truncates a long state.
83    pub state: Value,
84    pub questions: IndexMap<String, Question>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub images: Option<Vec<Image>>,
87}
88
89impl ProviderRequest {
90    pub fn new(
91        model: impl Into<String>,
92        state: impl Into<Value>,
93        questions: IndexMap<String, Question>,
94    ) -> Self {
95        Self {
96            model: model.into(),
97            state: state.into(),
98            questions,
99            images: None,
100        }
101    }
102
103    pub fn with_images(mut self, images: Vec<Image>) -> Self {
104        self.images = Some(images);
105        self
106    }
107}
108
109/// The provider response for one item: `{model, answers, usage}`.
110#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
111#[non_exhaustive]
112pub struct ProviderResponse {
113    pub model: String,
114    pub answers: IndexMap<String, Answer>,
115    /// Absent when the provider reported none; a count it left out stays
116    /// absent.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub usage: Option<Usage>,
119}
120
121impl ProviderResponse {
122    pub fn new(model: impl Into<String>, answers: IndexMap<String, Answer>) -> Self {
123        Self {
124            model: model.into(),
125            answers,
126            usage: None,
127        }
128    }
129
130    pub fn with_usage(mut self, usage: Usage) -> Self {
131        self.usage = Some(usage);
132        self
133    }
134}
135
136// The mapping from a provider's error to a per-item code is the shared
137// model-batch rules (`cortexkit-model-batch`, its `RULES.md` ยง15),
138// re-exported here. TypeSafe documents no error codes, so its errors map by
139// status (`item_code_for_status`). clef is a Cloudflare Workers AI model, so
140// its errors map by Workers AI code first (`workers_ai_item_code`), and a
141// code that table does not list falls back to the status.
142pub use cortexkit_model_batch::status::{
143    item_code_for_status, workers_ai_codes, workers_ai_item_code,
144};
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::errors::{
150        ACCOUNT_LIMIT_EXHAUSTED, ACCOUNT_NOT_ENTITLED, AUTH_FAILED, INVALID_ITEM,
151        MODEL_UNAVAILABLE, PROVIDER_ERROR, RATE_LIMITED,
152    };
153
154    #[test]
155    fn documented_statuses_map_to_item_codes() {
156        for status in [429, 529] {
157            assert_eq!(item_code_for_status(status), Some(RATE_LIMITED));
158        }
159        for status in [408, 500, 502, 503] {
160            assert_eq!(item_code_for_status(status), Some(PROVIDER_ERROR));
161        }
162        for status in [400, 413, 422, 409] {
163            assert_eq!(item_code_for_status(status), Some(INVALID_ITEM));
164        }
165        for status in [401, 403] {
166            assert_eq!(item_code_for_status(status), Some(AUTH_FAILED));
167        }
168        assert_eq!(item_code_for_status(404), Some(MODEL_UNAVAILABLE));
169        assert_eq!(item_code_for_status(200), None);
170    }
171
172    #[test]
173    fn clef_errors_map_by_workers_ai_code_first() {
174        assert_eq!(
175            workers_ai_item_code(429, Some(3036)),
176            Some(ACCOUNT_LIMIT_EXHAUSTED)
177        );
178        for code in [5035, 5016, 3023] {
179            assert_eq!(
180                workers_ai_item_code(403, Some(code)),
181                Some(ACCOUNT_NOT_ENTITLED)
182            );
183        }
184        assert_eq!(
185            workers_ai_item_code(400, Some(5007)),
186            Some(MODEL_UNAVAILABLE)
187        );
188        assert_eq!(workers_ai_item_code(429, Some(3040)), Some(RATE_LIMITED));
189        assert_eq!(workers_ai_item_code(403, None), Some(AUTH_FAILED));
190    }
191}