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}