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;
12
13pub use provider::{parse_model_spec, DEFAULT_TIMEOUT_SECS};
14
15/// JSON-schema-ish description of the expected result shape.
16/// Built by the runtime from Kora `type` declarations.
17#[derive(Debug, Clone, serde::Serialize)]
18pub struct Schema {
19    pub type_name: String,
20    /// Ordered field list.
21    pub fields: Vec<SchemaField>,
22}
23
24/// A model-visible field and its native Kora metadata.
25#[derive(Debug, Clone, serde::Serialize)]
26pub struct SchemaField {
27    pub name: String,
28    pub field_type: FieldType,
29    pub description: Option<String>,
30    pub pattern: Option<String>,
31}
32
33#[derive(Debug, Clone, serde::Serialize)]
34pub enum FieldType {
35    Str,
36    Int,
37    Float,
38    Bool,
39    ListOfStr,
40}
41
42impl FieldType {
43    /// Human-readable name used in validation error messages.
44    pub(crate) fn display_name(&self) -> &'static str {
45        match self {
46            FieldType::Str => "string",
47            FieldType::Int => "integer",
48            FieldType::Float => "float",
49            FieldType::Bool => "boolean",
50            FieldType::ListOfStr => "list of strings",
51        }
52    }
53}
54
55#[derive(Debug, Clone)]
56pub struct ModelConfig {
57    pub provider: Provider,
58    /// e.g. "gpt-4o" or "llama3.1:8b"
59    pub model: String,
60    /// Ollama base URL override; default http://localhost:11434
61    pub endpoint: Option<String>,
62    /// OpenAI; read from OPENAI_API_KEY if None.
63    pub api_key: Option<String>,
64    /// Default 4096.
65    pub max_output_tokens: u32,
66    /// How long to wait for one response. There is no "off": a request that
67    /// waits forever is the most common way a program hangs.
68    pub timeout_secs: u64,
69}
70
71#[derive(Debug, Clone, PartialEq)]
72pub enum Provider {
73    OpenAI,
74    Ollama,
75}
76
77#[derive(Debug, Clone)]
78pub struct AnalyzeRequest {
79    /// The user's natural-language instruction.
80    pub prompt: String,
81    /// The input data serialized as JSON text.
82    pub data_json: String,
83    /// Images accompanying the data, in the order the program listed them.
84    ///
85    /// Text and pixels travel together in one request: a receipt is not a
86    /// JSON blob with a picture attached, it *is* the picture, and splitting
87    /// them into two calls loses the association the model needs.
88    pub images: Vec<ImagePart>,
89    pub schema: Schema,
90    /// Tools the model may call before producing its final answer.
91    pub tools: Vec<ToolSpec>,
92    /// Results of tool calls already performed, appended to the conversation
93    /// as the loop progresses.
94    pub tool_history: Vec<ToolExchange>,
95}
96
97/// One image travelling to a multimodal model.
98///
99/// Bytes, not base64: the encoding is a wire detail each provider spells
100/// differently, so it happens at request construction rather than being
101/// carried around pre-encoded.
102#[derive(Debug, Clone)]
103pub struct ImagePart {
104    /// An image MIME type, e.g. `image/png`.
105    pub mime: String,
106    pub bytes: Vec<u8>,
107}
108
109/// A function the model may call. Built from a Kora `tool` declaration:
110/// the signature becomes the schema, the docstring becomes the description.
111#[derive(Debug, Clone)]
112pub struct ToolSpec {
113    pub name: String,
114    pub description: String,
115    pub params: Vec<(String, FieldType)>,
116}
117
118/// One completed tool call and its result.
119#[derive(Debug, Clone)]
120pub struct ToolExchange {
121    pub name: String,
122    pub arguments_json: String,
123    pub result_json: String,
124}
125
126/// What the model wants next.
127#[derive(Debug, Clone)]
128pub enum Step {
129    /// Final answer produced.
130    Done(AnalyzeOutcome),
131    /// Model asked to run a tool; the runtime should execute it and loop.
132    CallTool {
133        name: String,
134        arguments_json: String,
135        tokens_in: u64,
136        tokens_out: u64,
137    },
138}
139
140#[derive(Debug, Clone)]
141pub enum AnalyzeOutcome {
142    /// Model produced a JSON object conforming to the schema.
143    Ok {
144        fields_json: serde_json::Map<String, serde_json::Value>,
145        tokens_in: u64,
146        tokens_out: u64,
147    },
148    /// Model explicitly refused / could not comply.
149    Uncertain {
150        reason: String,
151        tokens_in: u64,
152        tokens_out: u64,
153    },
154}
155
156#[derive(Debug)]
157pub struct ModelError {
158    pub message: String,
159}
160
161impl ModelError {
162    pub fn new(message: impl Into<String>) -> Self {
163        ModelError {
164            message: message.into(),
165        }
166    }
167}
168
169impl fmt::Display for ModelError {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        write!(f, "{}", self.message)
172    }
173}
174
175impl std::error::Error for ModelError {}
176
177/// Run one schema-constrained analyze call against the configured provider.
178pub fn analyze(config: &ModelConfig, req: &AnalyzeRequest) -> Result<AnalyzeOutcome, ModelError> {
179    match provider::step_with(config, req, &*provider::transport_for(config))? {
180        Step::Done(outcome) => Ok(outcome),
181        // Without tools declared the model has nothing to call, so a tool
182        // request here means it ignored the contract.
183        Step::CallTool { name, .. } => Err(ModelError::new(format!(
184            "model tried to call tool `{name}`, but no tools were provided"
185        ))),
186    }
187}
188
189/// One turn of the tool loop: either the final answer, or a tool to run.
190pub fn step(config: &ModelConfig, req: &AnalyzeRequest) -> Result<Step, ModelError> {
191    provider::step_with(config, req, &*provider::transport_for(config))
192}