Skip to main content

jamjet_models/
adapter.rs

1//! Unified model adapter trait and shared types.
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7// ── Error ─────────────────────────────────────────────────────────────────────
8
9#[derive(Debug, Error)]
10pub enum ModelError {
11    #[error("provider API error ({status}): {body}")]
12    Api { status: u16, body: String },
13
14    #[error("rate limited — retry after {retry_after_secs}s")]
15    RateLimited { retry_after_secs: u64 },
16
17    #[error("context window exceeded: {input_tokens} tokens > {limit} limit")]
18    ContextWindowExceeded { input_tokens: u64, limit: u64 },
19
20    #[error("network error: {0}")]
21    Network(String),
22
23    #[error("serialization error: {0}")]
24    Serialization(String),
25
26    #[error("timeout")]
27    Timeout,
28}
29
30// ── Shared types ──────────────────────────────────────────────────────────────
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum ChatRole {
35    System,
36    User,
37    Assistant,
38    Tool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ChatMessage {
43    pub role: ChatRole,
44    pub content: String,
45}
46
47impl ChatMessage {
48    pub fn system(content: impl Into<String>) -> Self {
49        Self {
50            role: ChatRole::System,
51            content: content.into(),
52        }
53    }
54    pub fn user(content: impl Into<String>) -> Self {
55        Self {
56            role: ChatRole::User,
57            content: content.into(),
58        }
59    }
60    pub fn assistant(content: impl Into<String>) -> Self {
61        Self {
62            role: ChatRole::Assistant,
63            content: content.into(),
64        }
65    }
66}
67
68/// Configuration for a single model call (overrides adapter defaults).
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct ModelConfig {
71    /// Model name (e.g. "claude-sonnet-4-6", "gpt-4o").
72    pub model: Option<String>,
73    /// Max tokens to generate.
74    pub max_tokens: Option<u32>,
75    /// Sampling temperature (0.0–1.0).
76    pub temperature: Option<f32>,
77    /// System prompt to prepend (overrides messages).
78    pub system_prompt: Option<String>,
79    /// Stop sequences.
80    pub stop_sequences: Option<Vec<String>>,
81}
82
83/// A tool call returned by the model.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ToolCall {
86    /// Provider-issued call id (e.g. "call_abc123").
87    pub id: String,
88    /// Name of the tool to invoke (matches a name in the request `tools` list).
89    pub name: String,
90    /// Arguments as a JSON value.  Providers (and the Python sidecar) normalise
91    /// the arguments string to a JSON object when possible; callers should
92    /// tolerate a `Value::String` if the model emits malformed JSON.
93    pub arguments: serde_json::Value,
94}
95
96/// A request to a chat model.
97#[derive(Debug, Clone)]
98pub struct ModelRequest {
99    pub messages: Vec<ChatMessage>,
100    pub config: ModelConfig,
101    /// OpenAI-format tool/function schemas passed to the model.  Empty means no
102    /// tools are offered to the model for this call.
103    pub tools: Vec<serde_json::Value>,
104}
105
106impl ModelRequest {
107    pub fn new(messages: Vec<ChatMessage>) -> Self {
108        Self {
109            messages,
110            config: ModelConfig::default(),
111            tools: vec![],
112        }
113    }
114
115    pub fn with_config(mut self, config: ModelConfig) -> Self {
116        self.config = config;
117        self
118    }
119
120    pub fn with_tools(mut self, tools: Vec<serde_json::Value>) -> Self {
121        self.tools = tools;
122        self
123    }
124}
125
126/// A request for structured (JSON) output.
127#[derive(Debug, Clone)]
128pub struct StructuredRequest {
129    pub messages: Vec<ChatMessage>,
130    pub config: ModelConfig,
131    /// JSON Schema describing the expected output object.
132    pub output_schema: serde_json::Value,
133}
134
135/// A response from a chat model.
136#[derive(Debug, Clone)]
137pub struct ModelResponse {
138    /// The generated text content.  Empty string when finish_reason is
139    /// "tool_calls" (the model is requesting tool invocations, not producing text).
140    pub content: String,
141    /// The model that actually served the request (may differ from requested).
142    pub model: String,
143    /// Finish reason: "stop", "length", "tool_calls", "content_filter".
144    pub finish_reason: String,
145    /// Input tokens consumed.
146    pub input_tokens: u64,
147    /// Output tokens generated.
148    pub output_tokens: u64,
149    /// Structured output parsed from JSON (for `structured_output()` calls).
150    pub structured: Option<serde_json::Value>,
151    /// Tool calls requested by the model.  Empty when finish_reason != "tool_calls".
152    pub tool_calls: Vec<ToolCall>,
153}
154
155// ── Trait ─────────────────────────────────────────────────────────────────────
156
157/// Unified interface for LLM providers.
158///
159/// Implement this trait to add a new model provider.
160/// The `system` string returned by `system_name()` is used as the
161/// `gen_ai.system` OTel attribute.
162#[async_trait]
163pub trait ModelAdapter: Send + Sync {
164    /// OTel GenAI system name (e.g. "anthropic", "openai").
165    fn system_name(&self) -> &'static str;
166
167    /// Default model for this adapter (e.g. "claude-sonnet-4-6").
168    fn default_model(&self) -> &str;
169
170    /// Send a chat request and return the response.
171    async fn chat(&self, request: ModelRequest) -> Result<ModelResponse, ModelError>;
172
173    /// Send a structured output request, returning a JSON value.
174    ///
175    /// The response is validated against `request.output_schema` if possible.
176    async fn structured_output(
177        &self,
178        request: StructuredRequest,
179    ) -> Result<ModelResponse, ModelError>;
180}
181
182/// One-time-per-adapter warning that a native (direct-to-provider) adapter
183/// received tool schemas it does not forward.
184///
185/// The native adapters (anthropic/openai/google/ollama) map only `role` +
186/// `content` and never send `request.tools` to the provider, so a Model call
187/// that carries tools silently no-ops and an agent tool loop degenerates. Tool
188/// calls must route through the model-seam sidecar (`JAMJET_MODEL_SEAM_URL`),
189/// which forwards tools. Logged once per distinct adapter so a degenerate loop's
190/// repeated calls do not spam the log, while a second adapter still gets its own
191/// warning (a single global `Once` would let the first adapter silence the rest).
192pub(crate) fn warn_tools_not_forwarded(adapter: &'static str) {
193    use std::collections::HashSet;
194    use std::sync::{Mutex, OnceLock};
195    static WARNED: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
196    let warned = WARNED.get_or_init(|| Mutex::new(HashSet::new()));
197    // `insert` returns true the first time this adapter name is seen.
198    if warned
199        .lock()
200        .expect("warn_tools_not_forwarded mutex poisoned")
201        .insert(adapter)
202    {
203        tracing::warn!(
204            adapter,
205            "tools provided but this adapter does not forward them; \
206             use the model seam sidecar (JAMJET_MODEL_SEAM_URL) for tool calls"
207        );
208    }
209}