Skip to main content

kora_models/
lib.rs

1//! kora-models: model-provider clients with schema-constrained JSON output
2//! for the `analyze()` language primitive.
3//!
4//! Synchronous/blocking HTTP only (the interpreter is a sync tree-walker).
5
6mod base64;
7mod provider;
8mod schema;
9mod validate;
10
11use std::fmt;
12use std::rc::Rc;
13
14pub use provider::{parse_model_spec, DEFAULT_TIMEOUT_SECS};
15
16/// JSON-schema-ish description of the expected result shape.
17/// Built by the runtime from Kora `type` declarations.
18#[derive(Debug, Clone, serde::Serialize)]
19pub struct Schema {
20    pub type_name: String,
21    /// Ordered field list.
22    pub fields: Vec<SchemaField>,
23}
24
25/// A model-visible field and its native Kora metadata.
26#[derive(Debug, Clone, serde::Serialize)]
27pub struct SchemaField {
28    pub name: String,
29    pub field_type: FieldType,
30    pub description: Option<String>,
31    pub pattern: Option<String>,
32}
33
34#[derive(Debug, Clone, serde::Serialize)]
35pub enum FieldType {
36    Str,
37    Int,
38    Float,
39    Bool,
40    ListOfStr,
41    /// Another declared type, nested inline. Shared rather than copied since
42    /// the same nested schema is rebuilt on every recursive field lookup.
43    Object(Rc<Schema>),
44    /// `list[T]` where `T` is a declared type, not `str`.
45    ListOfObject(Rc<Schema>),
46}
47
48impl FieldType {
49    /// Human-readable name used in validation error messages.
50    pub(crate) fn display_name(&self) -> String {
51        match self {
52            FieldType::Str => "string".to_string(),
53            FieldType::Int => "integer".to_string(),
54            FieldType::Float => "float".to_string(),
55            FieldType::Bool => "boolean".to_string(),
56            FieldType::ListOfStr => "list of strings".to_string(),
57            FieldType::Object(schema) => format!("object `{}`", schema.type_name),
58            FieldType::ListOfObject(schema) => format!("list of `{}` objects", schema.type_name),
59        }
60    }
61}
62
63#[derive(Debug, Clone)]
64pub struct ModelConfig {
65    pub provider: Provider,
66    /// e.g. "gpt-4o" or "llama3.1:8b"
67    pub model: String,
68    /// Ollama base URL override; default http://localhost:11434
69    pub endpoint: Option<String>,
70    /// OpenAI; read from OPENAI_API_KEY if None.
71    pub api_key: Option<String>,
72    /// Default 4096.
73    pub max_output_tokens: u32,
74    /// How long to wait for one response. There is no "off": a request that
75    /// waits forever is the most common way a program hangs.
76    pub timeout_secs: u64,
77    /// How many times to retry a request that failed for a reason that may
78    /// not repeat: a refused connection, a timeout, a 429, a 5xx.
79    ///
80    /// There is no "off" for the same reason `http` retries a GET: a provider
81    /// under load is the ordinary case, not the exceptional one, and a
82    /// program that gives up on the first 429 is a program that gives up
83    /// several times an hour.
84    pub max_retries: u32,
85}
86
87#[derive(Debug, Clone, PartialEq)]
88pub enum Provider {
89    OpenAI,
90    Ollama,
91}
92
93#[derive(Debug, Clone)]
94pub struct AnalyzeRequest {
95    /// The user's natural-language instruction.
96    pub prompt: String,
97    /// The input data serialized as JSON text.
98    pub data_json: String,
99    /// Images accompanying the data, in the order the program listed them.
100    ///
101    /// Text and pixels travel together in one request: a receipt is not a
102    /// JSON blob with a picture attached, it *is* the picture, and splitting
103    /// them into two calls loses the association the model needs.
104    pub images: Vec<ImagePart>,
105    pub schema: Schema,
106    /// Tools the model may call before producing its final answer.
107    pub tools: Vec<ToolSpec>,
108    /// Results of tool calls already performed, appended to the conversation
109    /// as the loop progresses.
110    pub tool_history: Vec<ToolExchange>,
111}
112
113/// One image travelling to a multimodal model.
114///
115/// Bytes, not base64: the encoding is a wire detail each provider spells
116/// differently, so it happens at request construction rather than being
117/// carried around pre-encoded.
118#[derive(Debug, Clone)]
119pub struct ImagePart {
120    /// An image MIME type, e.g. `image/png`.
121    pub mime: String,
122    pub bytes: Vec<u8>,
123}
124
125/// A function the model may call. Built from a Kora `tool` declaration:
126/// the signature becomes the schema, the docstring becomes the description.
127#[derive(Debug, Clone)]
128pub struct ToolSpec {
129    pub name: String,
130    pub description: String,
131    pub params: Vec<(String, FieldType)>,
132}
133
134/// One completed tool call and its result.
135#[derive(Debug, Clone)]
136pub struct ToolExchange {
137    pub name: String,
138    pub arguments_json: String,
139    pub result_json: String,
140}
141
142/// What the model wants next.
143#[derive(Debug, Clone)]
144pub enum Step {
145    /// Final answer produced.
146    Done(AnalyzeOutcome),
147    /// Model asked to run a tool; the runtime should execute it and loop.
148    CallTool {
149        name: String,
150        arguments_json: String,
151        tokens_in: u64,
152        tokens_out: u64,
153    },
154}
155
156#[derive(Debug, Clone)]
157pub enum AnalyzeOutcome {
158    /// Model produced a JSON object conforming to the schema.
159    Ok {
160        fields_json: serde_json::Map<String, serde_json::Value>,
161        tokens_in: u64,
162        tokens_out: u64,
163    },
164    /// Model explicitly refused / could not comply.
165    Uncertain {
166        reason: String,
167        tokens_in: u64,
168        tokens_out: u64,
169    },
170    /// The provider did not answer: refused connection, timeout, rate limit,
171    /// server error, or a response that was not a model response at all.
172    ///
173    /// Distinct from `Uncertain`, which is the model answering "no". These
174    /// have different fixes -- one is a prompt, the other is a provider --
175    /// and collapsing them would hide an outage inside a refusal.
176    ///
177    /// Token counts are what the call had already spent when it failed: a
178    /// tool loop may have completed several turns before the provider
179    /// stopped answering, and that spend is real.
180    Failed {
181        reason: String,
182        tokens_in: u64,
183        tokens_out: u64,
184    },
185}
186
187#[derive(Debug)]
188pub struct ModelError {
189    pub message: String,
190    /// Whether trying the same request again could plausibly succeed.
191    ///
192    /// Set where the failure is observed rather than guessed at from the
193    /// message afterwards: only the transport knows that a 429 is worth
194    /// waiting on and a 401 never will be.
195    pub retryable: bool,
196}
197
198impl ModelError {
199    pub fn new(message: impl Into<String>) -> Self {
200        ModelError {
201            message: message.into(),
202            retryable: false,
203        }
204    }
205
206    /// A failure that may not repeat: connection refused, timeout, 429, 5xx.
207    pub fn retryable(message: impl Into<String>) -> Self {
208        ModelError {
209            message: message.into(),
210            retryable: true,
211        }
212    }
213}
214
215impl fmt::Display for ModelError {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        write!(f, "{}", self.message)
218    }
219}
220
221impl std::error::Error for ModelError {}
222
223/// Run one schema-constrained analyze call against the configured provider.
224pub fn analyze(config: &ModelConfig, req: &AnalyzeRequest) -> Result<AnalyzeOutcome, ModelError> {
225    match provider::step_with(config, req, &*provider::transport_for(config))? {
226        Step::Done(outcome) => Ok(outcome),
227        // Without tools declared the model has nothing to call, so a tool
228        // request here means it ignored the contract.
229        Step::CallTool { name, .. } => Err(ModelError::new(format!(
230            "model tried to call tool `{name}`, but no tools were provided"
231        ))),
232    }
233}
234
235/// One turn of the tool loop: either the final answer, or a tool to run.
236pub fn step(config: &ModelConfig, req: &AnalyzeRequest) -> Result<Step, ModelError> {
237    provider::step_with(config, req, &*provider::transport_for(config))
238}