Skip to main content

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}