llm_bridge_core/model.rs
1//! Core data model for protocol transforms.
2//!
3//! Defines the internal types used to represent API formats, content blocks,
4//! streaming events, stop reasons, and transform errors across the supported
5//! provider protocols.
6
7use std::collections::HashMap;
8
9use bytes::Bytes;
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12use typed_builder::TypedBuilder;
13use validator::Validate;
14
15// ---------------------------------------------------------------------------
16// Newtype domain primitives
17// ---------------------------------------------------------------------------
18
19/// A stable message identifier for stream correlation.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct MessageId(String);
22
23impl MessageId {
24 /// Creates a new `MessageId` from a validated string.
25 #[must_use]
26 pub fn new(value: String) -> Self {
27 Self(value)
28 }
29
30 /// Returns the inner string value.
31 #[must_use]
32 pub fn as_str(&self) -> &str {
33 &self.0
34 }
35}
36
37impl From<String> for MessageId {
38 fn from(value: String) -> Self {
39 Self::new(value)
40 }
41}
42
43impl From<MessageId> for String {
44 fn from(id: MessageId) -> Self {
45 id.0
46 }
47}
48
49/// A model name identifier (e.g., `"claude-sonnet-4-6"`).
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct ModelName(String);
52
53impl ModelName {
54 /// Creates a new `ModelName` from a validated string.
55 #[must_use]
56 pub fn new(value: String) -> Self {
57 Self(value)
58 }
59
60 /// Returns the inner string value.
61 #[must_use]
62 pub fn as_str(&self) -> &str {
63 &self.0
64 }
65}
66
67impl From<String> for ModelName {
68 fn from(value: String) -> Self {
69 Self::new(value)
70 }
71}
72
73impl From<ModelName> for String {
74 fn from(name: ModelName) -> Self {
75 name.0
76 }
77}
78
79// ---------------------------------------------------------------------------
80// API Format enumeration
81// ---------------------------------------------------------------------------
82
83/// The target API format for protocol transformation.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86#[non_exhaustive]
87pub enum ApiFormat {
88 /// Anthropic Messages API (`/v1/messages`)
89 AnthropicMessages,
90 /// `OpenAI` Chat Completions API (`/v1/chat/completions`)
91 OpenaiChat,
92 /// `OpenAI` Responses API (`/v1/responses`)
93 OpenaiResponses,
94}
95
96// ---------------------------------------------------------------------------
97// Image source
98// ---------------------------------------------------------------------------
99
100/// The source of an image content block.
101///
102/// Separated to distinguish between inline base64 data and URL references.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum ImageSource {
105 /// Inline base64-encoded image data.
106 Inline {
107 /// MIME type of the image (e.g., `"image/png"`).
108 media_type: String,
109 /// Base64-encoded image data.
110 data: Bytes,
111 },
112 /// External URL reference (HTTPS only).
113 Url {
114 /// The image URL.
115 url: String,
116 },
117}
118
119// ---------------------------------------------------------------------------
120// Content block
121// ---------------------------------------------------------------------------
122
123/// A single content block in a message.
124///
125/// Mirrors the Anthropic content block types but is used as the internal
126/// canonical representation regardless of source protocol.
127#[derive(Debug, Clone)]
128#[non_exhaustive]
129pub enum ContentBlock {
130 /// Plain text content.
131 Text { text: String },
132 /// Image content, either inline or URL-referenced.
133 Image { source: ImageSource },
134 /// Tool use request from assistant.
135 ToolUse {
136 /// Unique tool use identifier (e.g., `"toolu_..."` for Anthropic).
137 id: String,
138 /// Tool name.
139 name: String,
140 /// Tool input parameters as a JSON value.
141 input: serde_json::Value,
142 },
143 /// Tool execution result returned by user/system.
144 ToolResult {
145 /// The tool use ID this result corresponds to.
146 tool_use_id: String,
147 /// The result content (may be text or structured).
148 content: Vec<ContentBlock>,
149 },
150 /// Extended thinking block (Anthropic-specific).
151 /// Only valid in Anthropic direction; dropped with debug log otherwise.
152 Thinking {
153 /// The thinking content text.
154 text: String,
155 /// Number of tokens used for thinking.
156 usage: Option<u64>,
157 },
158}
159
160// ---------------------------------------------------------------------------
161// Stop reason
162// ---------------------------------------------------------------------------
163
164/// The reason a generation stopped.
165///
166/// Only represents normal termination reasons. Errors are never encoded as
167/// a `StopReason`; they use the `StreamEvent::Error` variant instead.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case")]
170#[non_exhaustive]
171pub enum StopReason {
172 /// Model naturally produced an end-of-turn token.
173 EndTurn,
174 /// Hit the `max_tokens` limit.
175 MaxTokens,
176 /// Model produced a tool use.
177 ToolUse,
178 /// Hit a stop sequence.
179 StopSequence,
180 /// Content was filtered for safety reasons.
181 ContentFilter,
182}
183
184// ---------------------------------------------------------------------------
185// Transform error
186// ---------------------------------------------------------------------------
187
188/// Errors that can occur during protocol transformation.
189#[derive(Debug, Error)]
190pub enum TransformError {
191 /// Input could not be parsed as the expected protocol format.
192 #[error("invalid format: {0}")]
193 InvalidFormat(String),
194
195 /// A required field was missing from the input.
196 #[error("missing required field: {0}")]
197 MissingRequiredField(String),
198
199 /// A buffer limit was exceeded (stream total, tool call params, etc.).
200 #[error("buffer limit exceeded: {0}")]
201 BufferLimitExceeded(String),
202
203 /// The stream was interrupted before normal termination.
204 #[error("stream interrupted: {0}")]
205 StreamInterrupted(String),
206
207 /// An error from the upstream provider.
208 #[error("upstream error: {0}")]
209 UpstreamError(String),
210
211 /// A feature was explicitly unsupported, triggering lossy downgrade.
212 #[error("lossy downgrade: {0}")]
213 LossyDowngrade(String),
214}
215
216impl TransformError {
217 /// Wrap this error with an underlying source for richer error chains.
218 #[must_use]
219 pub fn with_source(
220 self,
221 source: impl std::error::Error + Send + Sync + 'static,
222 ) -> anyhow::Error {
223 anyhow::Error::new(self).context(source.to_string())
224 }
225
226 /// Return a client-safe error message with internal details redacted.
227 ///
228 /// Serde parse errors (e.g., "expected value at line 3 column 17") leak
229 /// implementation details. This strips them to a generic category.
230 #[must_use]
231 pub fn sanitized_message(&self) -> String {
232 match self {
233 Self::InvalidFormat(_) => "invalid request format".to_string(),
234 Self::MissingRequiredField(field) => {
235 format!("missing required field: {field}")
236 }
237 Self::BufferLimitExceeded(_) => "request too large".to_string(),
238 Self::StreamInterrupted(_) => "stream was interrupted".to_string(),
239 Self::UpstreamError(_) => "upstream provider error".to_string(),
240 Self::LossyDowngrade(_) => "feature not supported".to_string(),
241 }
242 }
243}
244
245// ---------------------------------------------------------------------------
246// Stream event (Anthropic canonical target)
247// ---------------------------------------------------------------------------
248
249/// A single event in the Anthropic SSE event stream.
250///
251/// All streaming providers are normalized to this event model:
252/// `message_start -> content_block_* -> message_delta -> message_stop`.
253#[derive(Debug, Clone, PartialEq, Eq)]
254#[non_exhaustive]
255pub enum StreamDelta {
256 /// Incremental text emitted for a text content block.
257 Text {
258 /// Text fragment to append.
259 text: String,
260 },
261 /// Incremental thinking emitted for an Anthropic thinking content block.
262 Thinking {
263 /// Thinking fragment to append.
264 thinking: String,
265 },
266 /// Signature emitted for a completed Anthropic thinking content block.
267 Signature {
268 /// Opaque signature string for multi-turn continuity.
269 signature: String,
270 },
271 /// Incremental JSON fragment emitted for a tool-use input payload.
272 InputJson {
273 /// Partial JSON string fragment.
274 partial_json: String,
275 },
276}
277
278#[derive(Debug, Clone)]
279#[non_exhaustive]
280pub enum StreamEvent {
281 /// Marks the beginning of an assistant message.
282 MessageStart {
283 /// The role (always `"assistant"`).
284 role: String,
285 /// Stable message identifier for stream accumulation.
286 message_id: String,
287 /// Model name associated with the generated message.
288 model: String,
289 /// Initial usage counters (typically zero at start).
290 usage: Usage,
291 },
292 /// Marks the beginning of a content block.
293 ContentBlockStart {
294 /// Zero-based index of this content block.
295 index: usize,
296 /// The content block that is starting.
297 content_block: ContentBlock,
298 },
299 /// A delta for an existing content block.
300 ContentBlockDelta {
301 /// Zero-based index of the content block.
302 index: usize,
303 /// The typed delta payload to append.
304 delta: StreamDelta,
305 },
306 /// Marks the end of a content block.
307 ContentBlockStop {
308 /// Zero-based index of the content block.
309 index: usize,
310 },
311 /// A delta for the overall message (usage, stop reason).
312 MessageDelta {
313 /// The reason the message stopped (if known).
314 stop_reason: Option<StopReason>,
315 /// Stop sequence string if applicable.
316 stop_sequence: Option<String>,
317 /// Final usage counters.
318 usage: Usage,
319 },
320 /// Marks the end of the message (best-effort after error).
321 MessageStop,
322 /// An error event. Sent before best-effort `MessageStop` if stream was started.
323 Error {
324 /// Error type identifier.
325 error_type: String,
326 /// Human-readable error message.
327 message: String,
328 },
329}
330
331// ---------------------------------------------------------------------------
332// Usage
333// ---------------------------------------------------------------------------
334
335/// Token usage counters.
336#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
337pub struct Usage {
338 /// Number of input tokens consumed.
339 #[serde(default)]
340 pub input_tokens: u64,
341 /// Number of output tokens produced.
342 #[serde(default)]
343 pub output_tokens: u64,
344 /// Anthropic cache hit tokens.
345 #[serde(default)]
346 pub cache_read_input_tokens: u64,
347 /// Anthropic cache write tokens.
348 #[serde(default)]
349 pub cache_creation_input_tokens: u64,
350 /// `cached_tokens` from `OpenAI` `prompt_tokens_details`.
351 #[serde(default)]
352 pub cached_tokens: u64,
353 /// OpenAI/Anthropic reasoning tokens.
354 #[serde(default)]
355 pub reasoning_tokens: u64,
356}
357
358// ---------------------------------------------------------------------------
359// Request / Response types for non-streaming transforms
360// ---------------------------------------------------------------------------
361
362/// A non-streaming protocol transform request.
363///
364/// Carries the full HTTP request context: headers, path, and body.
365#[derive(Debug, Clone)]
366pub struct TransformRequest {
367 /// HTTP headers from the original request.
368 pub headers: HashMap<String, String>,
369 /// The request path (e.g., `/v1/messages`).
370 pub path: String,
371 /// The raw request body bytes.
372 pub body: Bytes,
373}
374
375/// A non-streaming protocol transform response.
376///
377/// Carries the transformed HTTP response context: headers, path, and body.
378#[derive(Debug, Clone)]
379pub struct TransformResponse {
380 /// Transformed HTTP headers for the target provider.
381 pub headers: HashMap<String, String>,
382 /// The transformed request path (e.g., `/v1/chat/completions`).
383 pub path: String,
384 /// The transformed body bytes.
385 pub body: Bytes,
386}
387
388// ---------------------------------------------------------------------------
389// Stream state
390// ---------------------------------------------------------------------------
391
392/// Per-connection streaming state for accumulating events.
393///
394/// Each connection owns its own `StreamState` and must not share it across
395/// requests. The state tracks content block indices, tool call accumulation,
396/// and the overall message lifecycle.
397#[derive(Debug, Clone, Copy, PartialEq, Eq)]
398#[non_exhaustive]
399pub enum StreamContentBlockKind {
400 /// A plain text content block is currently open.
401 Text,
402 /// A thinking content block is currently open.
403 Thinking,
404 /// A tool use content block is currently open.
405 ToolUse,
406}
407
408/// `OpenAI` Responses-specific streaming state, nested to keep `StreamState`
409/// focused on protocol-agnostic concerns.
410#[derive(Debug, Default)]
411pub struct ResponsesStreamState {
412 /// Sequence number for `OpenAI` Responses SSE events.
413 pub sequence_number: u64,
414 /// Synthetic creation time for `OpenAI` Responses objects.
415 pub created_at: Option<u64>,
416 /// Stable output item IDs keyed by content-block index.
417 pub item_ids: HashMap<usize, String>,
418 /// Stable function call IDs keyed by content-block index.
419 pub call_ids: HashMap<usize, String>,
420 /// Tool names keyed by content-block index.
421 pub tool_names: HashMap<usize, String>,
422 /// Accumulated text fragments keyed by content-block index.
423 pub text_fragments: HashMap<usize, String>,
424 /// Accumulated reasoning fragments keyed by content-block index.
425 pub reasoning_fragments: HashMap<usize, String>,
426 /// Accumulated function-call argument fragments keyed by content-block index.
427 pub function_arguments: HashMap<usize, String>,
428 /// Final stop reason observed for the stream.
429 pub final_stop_reason: Option<StopReason>,
430}
431
432#[derive(Debug, Default, TypedBuilder, Validate)]
433pub struct StreamState {
434 /// Whether `message_start` has been sent.
435 pub started: bool,
436 /// Whether `message_stop` has already been emitted.
437 pub finished: bool,
438 /// Stable message identifier for the current stream, if known.
439 pub message_id: Option<String>,
440 /// Upstream model name for the current stream, if known.
441 pub model_name: Option<String>,
442 /// Total accumulated bytes for the stream (enforces 1 MB limit).
443 pub total_buffer_bytes: usize,
444 /// Index of the next content block.
445 pub content_block_index: usize,
446 /// Currently open content block index, if any.
447 pub active_content_block_index: Option<usize>,
448 /// Currently open content block kind, if any.
449 pub active_content_block_kind: Option<StreamContentBlockKind>,
450 /// Last observed usage counters from the upstream stream.
451 pub last_usage: Usage,
452 /// Tool call ID mapping for cross-protocol correlation (e.g., `tool_call_id -> tool_use_id`).
453 pub tool_correlation: HashMap<String, String>,
454 /// Mapping from upstream tool-call indices to Anthropic content block indices.
455 pub tool_block_indices: HashMap<usize, usize>,
456 /// Mapping from content-block index to content-block kind for downstream re-serialization.
457 pub content_block_kinds: HashMap<usize, StreamContentBlockKind>,
458 /// `OpenAI` Responses-specific streaming state.
459 pub responses: ResponsesStreamState,
460}
461
462// ---------------------------------------------------------------------------
463// Resource limits
464// ---------------------------------------------------------------------------
465
466/// Maximum allowed nesting depth for JSON payloads (prevents stack overflow).
467pub const MAX_JSON_DEPTH: usize = 64;
468
469/// Maximum allowed messages array length in a request body.
470/// Guard against unbounded memory allocation; the actual upstream limit
471/// is token-based (modern models support 1M+ context), so a generous count
472/// is safe. At ~1 KB per message, 10K messages ≈ 10 MB.
473pub const MAX_MESSAGES_COUNT: usize = 10_000;
474
475/// Maximum accumulated SSE stream data in bytes (1 MB).
476pub const MAX_SSE_STREAM_BYTES: usize = 1024 * 1024;
477
478/// Validate that a `serde_json::Value` does not exceed the allowed nesting depth.
479///
480/// # Errors
481///
482/// Returns `TransformError::InvalidFormat` if the nesting depth exceeds
483/// `MAX_JSON_DEPTH`.
484pub fn validate_json_depth(value: &serde_json::Value) -> Result<(), TransformError> {
485 fn depth(v: &serde_json::Value) -> usize {
486 match v {
487 serde_json::Value::Object(map) => 1 + map.values().map(depth).max().unwrap_or(0),
488 serde_json::Value::Array(arr) => 1 + arr.iter().map(depth).max().unwrap_or(0),
489 _ => 0,
490 }
491 }
492
493 let d = depth(value);
494 if d > MAX_JSON_DEPTH {
495 Err(TransformError::InvalidFormat(
496 "JSON nesting depth exceeds maximum allowed".to_string(),
497 ))
498 } else {
499 Ok(())
500 }
501}