Skip to main content

harness_core/
model.rs

1use crate::{Context, error::ModelError};
2use async_trait::async_trait;
3use serde::{Deserialize, Serialize};
4
5/// Information about a configured model — uniform across providers.
6///
7/// `handle` is the user-chosen logical identifier (used in logs, metrics,
8/// and `harness.toml` selectors); `model` is the wire-protocol model id
9/// sent to the provider.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ModelInfo {
12    pub handle: String,
13    pub provider: String,
14    pub model: String,
15    pub context_window: u32,
16    pub input_cost_usd_per_million_tokens: Option<f64>,
17    pub output_cost_usd_per_million_tokens: Option<f64>,
18    pub supports_tool_use: bool,
19    pub supports_streaming: bool,
20    /// Whether the provider will answer from live web results using its own
21    /// built-in search (Gemini's `google_search`, and friends). Distinct from
22    /// `supports_tool_use`: this is search the provider runs server-side, not a
23    /// tool we hand it.
24    #[serde(default)]
25    pub supports_web_grounding: bool,
26}
27
28#[derive(Debug, Clone, Default, Serialize, Deserialize)]
29pub struct ModelOutput {
30    pub text: Option<String>,
31    pub tool_calls: Vec<ToolCall>,
32    pub usage: Usage,
33    pub stop_reason: StopReason,
34    /// Provider-specific reasoning trace (DeepSeek `reasoning_content`,
35    /// Anthropic `thinking` blocks). Pushed back to the API verbatim on
36    /// subsequent calls; required by providers that gate on it.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub reasoning: Option<String>,
39    /// Images the model emitted this turn. Empty for text-only models.
40    ///
41    /// Populated from the OpenAI-compatible `message.images[]` channel, which
42    /// is how Gemini image models answer through a chat endpoint. Before this
43    /// field existed those images were silently discarded by serde and the
44    /// caller saw only `text: None` — an empty answer with no way to learn
45    /// why.
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub images: Vec<crate::image::GeneratedImage>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ToolCall {
52    pub id: String,
53    pub name: String,
54    pub args: serde_json::Value,
55}
56
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58pub struct Usage {
59    pub input_tokens: u32,
60    pub output_tokens: u32,
61    pub cached_input_tokens: u32,
62    /// Tokens written into a provider prompt cache on this call — billed at a
63    /// premium (Anthropic: 1.25x) to make later reads cheap. Zero on providers
64    /// that cache automatically without a write surcharge.
65    #[serde(default)]
66    pub cache_write_input_tokens: u32,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71#[non_exhaustive]
72pub enum StopReason {
73    #[default]
74    EndTurn,
75    ToolUse,
76    MaxTokens,
77    StopSequence,
78    Other,
79}
80
81/// Streaming delta — incremental output from the model.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[non_exhaustive]
84pub enum ModelDelta {
85    Text(String),
86    ToolCallStart {
87        id: String,
88        name: String,
89    },
90    ToolCallArgs {
91        id: String,
92        partial_json: String,
93    },
94    ToolCallEnd {
95        id: String,
96    },
97    Usage(Usage),
98    Stop(StopReason),
99    /// Provider-specific reasoning trace that must round-trip on the next
100    /// request. DeepSeek thinking content, Anthropic thinking blocks, and
101    /// Gemini raw `parts` (with thoughtSignatures) all flow through this —
102    /// the AgentLoop folds them into the final `ModelOutput.reasoning`
103    /// without surfacing them to user-visible token streams.
104    Reasoning(String),
105}
106
107#[async_trait]
108pub trait Model: Send + Sync + 'static {
109    async fn complete(&self, ctx: &Context) -> Result<ModelOutput, ModelError>;
110
111    /// Answer `query` from live web results, using the provider's OWN search.
112    ///
113    /// Returns `None` when the provider has no such thing, which is the default
114    /// — callers fall back to a search index. The answer comes back as prose
115    /// with its sources, not as a result list, because that is what these
116    /// providers return.
117    ///
118    /// Why it is a separate call rather than a tool the loop can pick: the
119    /// providers that offer built-in search generally refuse to accept it in
120    /// the same request as function declarations. Gemini answers *"Please
121    /// enable tool_config.include_server_side_tool_invocations to use Built-in
122    /// tools with Function calling"*, and OpenAI-compatible gateways in front
123    /// of it usually drop that switch. So grounding has to happen in its own
124    /// tool-free request, and hiding that here keeps every caller from
125    /// rediscovering it.
126    async fn search_web(&self, query: &str) -> Option<Result<String, ModelError>> {
127        let _ = query;
128        None
129    }
130
131    /// Streaming is optional; default implementation falls back to `complete`.
132    async fn stream(
133        &self,
134        ctx: &Context,
135    ) -> Result<futures::stream::BoxStream<'static, Result<ModelDelta, ModelError>>, ModelError>
136    {
137        let out = self.complete(ctx).await?;
138        let deltas: Vec<Result<ModelDelta, ModelError>> = out
139            .text
140            .into_iter()
141            .map(|t| Ok(ModelDelta::Text(t)))
142            .chain(std::iter::once(Ok(ModelDelta::Stop(out.stop_reason))))
143            .collect();
144        Ok(Box::pin(futures::stream::iter(deltas)))
145    }
146
147    fn info(&self) -> ModelInfo;
148}
149
150/// A concrete newtype wrapping a boxed model, so an `Arc<dyn Model>` can be used
151/// where a concrete `M: Model` is required (e.g. `Subagent::new` / `AgentLoop<M>`).
152///
153/// We deliberately do NOT `impl Model for Arc<dyn Model>` directly. Doing so
154/// changes `.stream()` method resolution on EVERY `Arc<dyn Model>` value in the
155/// program (from a deref to `dyn Model` into the Arc impl's `async fn stream`
156/// RPITIT), and proving that boxed streaming future is `Send` inside a `Send`
157/// context (e.g. an axum handler driving the streaming loop) overflows the
158/// auto-trait solver (E0275). Wrapping in this concrete newtype gives callers
159/// `DynModel: Model` without touching resolution for bare `Arc<dyn Model>`.
160pub struct DynModel(pub std::sync::Arc<dyn Model>);
161
162#[async_trait]
163impl Model for DynModel {
164    async fn complete(&self, ctx: &Context) -> Result<ModelOutput, ModelError> {
165        self.0.complete(ctx).await
166    }
167    async fn stream(
168        &self,
169        ctx: &Context,
170    ) -> Result<futures::stream::BoxStream<'static, Result<ModelDelta, ModelError>>, ModelError>
171    {
172        self.0.stream(ctx).await
173    }
174    fn info(&self) -> ModelInfo {
175        self.0.info()
176    }
177    // Forwarded explicitly: the trait's default returns `None`, so leaning on
178    // it here would silently strip grounding from every boxed model — a
179    // capability the inner model advertises via `supports_web_grounding` and
180    // then could never deliver.
181    async fn search_web(&self, query: &str) -> Option<Result<String, ModelError>> {
182        self.0.search_web(query).await
183    }
184}
185
186#[cfg(test)]
187mod arc_model_tests {
188    use super::*;
189    use std::sync::Arc;
190
191    struct Dummy;
192
193    #[async_trait]
194    impl Model for Dummy {
195        async fn complete(&self, _ctx: &Context) -> Result<ModelOutput, ModelError> {
196            Ok(ModelOutput {
197                text: Some("ok".into()),
198                ..Default::default()
199            })
200        }
201        fn info(&self) -> ModelInfo {
202            ModelInfo {
203                handle: "dummy".into(),
204                provider: "test".into(),
205                model: "dummy".into(),
206                context_window: 8192,
207                input_cost_usd_per_million_tokens: None,
208                output_cost_usd_per_million_tokens: None,
209                supports_tool_use: false,
210                supports_streaming: false,
211                supports_web_grounding: false,
212            }
213        }
214    }
215
216    fn assert_is_model<M: Model>(_m: &M) {}
217
218    #[tokio::test]
219    async fn dyn_model_wrapper_is_a_model() {
220        let m: Arc<dyn Model> = Arc::new(Dummy);
221        let wrapped = DynModel(m);
222        assert_is_model(&wrapped); // compiles only if DynModel: Model
223        let out = wrapped
224            .complete(&Context::new(crate::Task {
225                description: "x".into(),
226                source: None,
227                deadline: None,
228            }))
229            .await
230            .unwrap();
231        assert_eq!(out.text.as_deref(), Some("ok"));
232    }
233}