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    errors::{AUTH_FAILED, INVALID_ITEM, MODEL_UNAVAILABLE, PROVIDER_ERROR, RATE_LIMITED},
17    question::{Image, Question},
18    reply::{Answer, Usage},
19};
20
21/// Catalog ids of the starting providers' models: models.dev's provider key,
22/// then `/`, then models.dev's model id. The first `/` splits the two, so `@`
23/// and any further `/` belong to the model id. Each pins a model version,
24/// never a moving alias.
25pub mod catalog_ids {
26    pub const CLEF: &str = "cloudflare-workers-ai/@cf/cloudflare/clef";
27    pub const CLEF_FLASH: &str = "cloudflare-workers-ai/@cf/cloudflare/clef-flash";
28    pub const JEV_1_13_0: &str = "typesafe/jev-1.13.0";
29}
30
31/// The `model` value each provider expects in its request body.
32pub mod models {
33    /// Cloudflare Workers AI `@cf/cloudflare/clef`.
34    pub const CLEF: &str = "clef";
35    /// Cloudflare Workers AI's faster clef variant.
36    pub const CLEF_FLASH: &str = "clef-flash";
37    /// TypeSafe's current model.
38    pub const JEV_LATEST: &str = "jev-latest";
39}
40
41/// Provider hosts a `role.describe` names as `egress_host`.
42pub mod hosts {
43    pub const CLOUDFLARE: &str = "api.cloudflare.com";
44    pub const TYPESAFE: &str = "api.typesafe.ai";
45}
46
47/// TypeSafe's classification endpoint.
48pub const TYPESAFE_ENDPOINT: &str = "https://api.typesafe.ai/v1/systemone";
49
50/// clef's limits on one request, from Cloudflare's documentation.
51pub mod clef_limits {
52    /// Fewest questions in one request.
53    pub const MIN_QUESTIONS: usize = 1;
54    /// Most questions in one request.
55    pub const MAX_QUESTIONS: usize = 64;
56    /// Longest question id; ids use letters, digits, `_`, `.` and `-`.
57    pub const MAX_QUESTION_ID_LEN: usize = 100;
58    /// Most images in one request.
59    pub const MAX_IMAGES: usize = 4;
60    /// Largest request body, in bytes (13 MiB).
61    pub const MAX_BODY_BYTES: usize = 13 * 1024 * 1024;
62}
63
64/// The provider request for one item: `{model, state, questions, images?}`.
65/// `images` is clef only; TypeSafe takes text only.
66#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
67#[non_exhaustive]
68pub struct ProviderRequest {
69    /// The provider's own model value, one of [`models`].
70    pub model: String,
71    /// A string, or structured data (an object or array). The provider
72    /// silently truncates a long state.
73    pub state: Value,
74    pub questions: IndexMap<String, Question>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub images: Option<Vec<Image>>,
77}
78
79impl ProviderRequest {
80    pub fn new(
81        model: impl Into<String>,
82        state: impl Into<Value>,
83        questions: IndexMap<String, Question>,
84    ) -> Self {
85        Self {
86            model: model.into(),
87            state: state.into(),
88            questions,
89            images: None,
90        }
91    }
92
93    pub fn with_images(mut self, images: Vec<Image>) -> Self {
94        self.images = Some(images);
95        self
96    }
97}
98
99/// The provider response for one item: `{model, answers, usage}`.
100#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
101#[non_exhaustive]
102pub struct ProviderResponse {
103    pub model: String,
104    pub answers: IndexMap<String, Answer>,
105    /// Absent when the provider reported none; a count it left out stays
106    /// absent.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub usage: Option<Usage>,
109}
110
111impl ProviderResponse {
112    pub fn new(model: impl Into<String>, answers: IndexMap<String, Answer>) -> Self {
113        Self {
114            model: model.into(),
115            answers,
116            usage: None,
117        }
118    }
119
120    pub fn with_usage(mut self, usage: Usage) -> Self {
121        self.usage = Some(usage);
122        self
123    }
124}
125
126/// The per-item error code for a provider's HTTP status, once the runner's
127/// own retries are spent. Neither provider documents an error body, so the
128/// status is all the mapping reads:
129///
130/// - 401 and 403 (TypeSafe's 401, clef's 403): `auth_failed`;
131/// - 404 (clef's 404): `model_unavailable`;
132/// - 429 (clef's 3036 account-limited and 3040 out-of-capacity, TypeSafe's
133///   rate limit) and 529 (TypeSafe's Overloaded): `rate_limited`;
134/// - 408 (clef's timeout) and every 5xx: `provider_error`;
135/// - every other 4xx (clef's 400 and 413; TypeSafe's 422): `invalid_item`.
136///
137/// 401, 403 and 404 say nothing about the item, so they are never
138/// `invalid_item`, which a caller reads as "drop this item". `auth_failed`,
139/// `model_unavailable` and `rate_limited` stop the call (see
140/// [`ItemError::stops_the_call`](crate::errors::ItemError::stops_the_call)).
141///
142/// `None` for a status that is not an error. Neither provider documents a
143/// content-refusal status, so nothing here maps to `provider_refused`.
144pub fn item_code_for_status(status: u16) -> Option<&'static str> {
145    match status {
146        401 | 403 => Some(AUTH_FAILED),
147        404 => Some(MODEL_UNAVAILABLE),
148        429 | 529 => Some(RATE_LIMITED),
149        408 | 500..=599 => Some(PROVIDER_ERROR),
150        400..=499 => Some(INVALID_ITEM),
151        _ => None,
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn documented_statuses_map_to_item_codes() {
161        for status in [429, 529] {
162            assert_eq!(item_code_for_status(status), Some(RATE_LIMITED));
163        }
164        for status in [408, 500, 502, 503] {
165            assert_eq!(item_code_for_status(status), Some(PROVIDER_ERROR));
166        }
167        for status in [400, 413, 422, 409] {
168            assert_eq!(item_code_for_status(status), Some(INVALID_ITEM));
169        }
170        for status in [401, 403] {
171            assert_eq!(item_code_for_status(status), Some(AUTH_FAILED));
172        }
173        assert_eq!(item_code_for_status(404), Some(MODEL_UNAVAILABLE));
174        assert_eq!(item_code_for_status(200), None);
175    }
176}