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}