af-llm 0.3.0

Unified async LLM client with retry, timeout and circuit breaking. Talks to any OpenAI-compatible endpoint (LiteLLM proxy, DeepSeek, Anthropic-via-proxy, ...).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
//! Strongly-typed OpenAI-compatible chat-completion request/response shapes.
//!
//! This is the Rust equivalent of the Pydantic models the Python stack relied
//! on: the wire format is validated at the type boundary via `serde`, so a
//! malformed provider response fails loudly at decode time rather than blowing
//! up three layers deep on a missing key.

use af_context::ToolCallId;
use std::fmt;

use serde::{Deserialize, Deserializer, Serialize};

/// Role of a chat message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
    /// Instructions from the product.
    System,
    /// Input from the user.
    User,
    /// Model output.
    Assistant,
    /// Result of a tool call.
    Tool,
}

/// Provider-neutral structured assistant output. Provider adapters normalize
/// native annotations/content into this closed set at the response boundary.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum AssistantBlock {
    /// Plain text.
    Text {
        /// The text.
        text: String,
    },
    /// Reference to an attached resource resolved by the host.
    Resource {
        /// Host-resolvable resource identity.
        resource_id: String,
        /// MIME type of the resource.
        media_type: String,
    },
    /// Product-typed structured data rendered by a registered UI slot.
    Data {
        /// Slot name the product registered for this data.
        slot: String,
        /// Slot payload.
        value: serde_json::Value,
    },
    /// Citation of a retrieved resource.
    Citation {
        /// Cited resource identity.
        resource_id: String,
        /// Display label.
        label: String,
        /// Where the resource can be opened.
        uri: String,
        /// Quoted excerpt supporting the citation.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        excerpt: Option<String>,
    },
}

/// A single chat message, both for requests and for the assistant turn in a
/// response. `content` is optional because a tool-calling assistant turn may
/// carry only `tool_calls`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
    /// Message role.
    pub role: Role,

    /// Content blocks carried by this record.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub content: Option<String>,

    /// Present on assistant turns that invoke tools.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_calls: Option<Vec<ToolCall>>,

    /// Set on a `role: tool` message to bind the result to its call.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_call_id: Option<ToolCallId>,

    /// Optional name (tool name / participant name).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
}

impl ChatMessage {
    /// A `system` message.
    pub fn system(content: impl Into<String>) -> Self {
        Self::text(Role::System, content)
    }
    /// A `user` message.
    pub fn user(content: impl Into<String>) -> Self {
        Self::text(Role::User, content)
    }
    /// An `assistant` message without tool calls.
    pub fn assistant(content: impl Into<String>) -> Self {
        Self::text(Role::Assistant, content)
    }

    fn text(role: Role, content: impl Into<String>) -> Self {
        Self {
            role,
            content: Some(content.into()),
            tool_calls: None,
            tool_call_id: None,
            name: None,
        }
    }
}

/// A tool the model is allowed to call. Only `function` tools exist today.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tool {
    /// Discriminator naming the variant of this record.
    #[serde(rename = "type")]
    pub kind: String,
    /// Function invoked by the tool call.
    pub function: FunctionDef,
}

impl Tool {
    /// Build a function tool. `parameters` is a JSON-Schema object.
    pub fn function(
        name: impl Into<String>,
        description: impl Into<String>,
        parameters: serde_json::Value,
    ) -> Self {
        Self {
            kind: "function".to_string(),
            function: FunctionDef {
                name: name.into(),
                description: Some(description.into()),
                parameters: Some(parameters),
            },
        }
    }
}

/// Function-calling definition advertised to the model.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionDef {
    /// Display name.
    pub name: String,
    /// Human-readable description.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// JSON-Schema for the arguments.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parameters: Option<serde_json::Value>,
}

/// A tool invocation emitted by the model.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
    /// Stable identifier of this record.
    pub id: ToolCallId,
    /// Discriminator naming the variant of this record.
    #[serde(rename = "type")]
    pub kind: String,
    /// Function invoked by the tool call.
    pub function: FunctionCall,
}

/// Function name and JSON-encoded arguments of a tool call.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCall {
    /// Display name.
    pub name: String,
    /// Raw JSON string of arguments — the provider does not pre-parse it.
    pub arguments: String,
}

/// How the model should choose tools. Defaults to `auto` when tools are present.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ToolChoice {
    /// The model decides whether to call a tool.
    Auto,
    /// The model must not call tools.
    None,
    /// The model must call at least one tool.
    Required,
}

/// Provider-specific reasoning budget carried on the single request path.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReasoningEffort {
    /// Minimal reasoning.
    Low,
    /// Balanced reasoning.
    Medium,
    /// Maximum reasoning.
    High,
}

/// A chat-completion request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletionRequest {
    /// Model identifier as registered in the model registry.
    pub model: String,
    /// Conversation messages in request order.
    pub messages: Vec<ChatMessage>,

    /// Tool names referenced by this record.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tools: Option<Vec<Tool>>,

    /// How the model may use the advertised tools.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_choice: Option<ToolChoice>,

    /// Sampling temperature.
    pub temperature: f32,
    /// Upper bound on prompt plus completion tokens.
    pub max_tokens: u32,

    /// Reasoning budget, when the provider supports it.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reasoning_effort: Option<ReasoningEffort>,

    /// Durable runtime identity used for provider idempotency and reconciliation.
    #[serde(skip)]
    pub provider_attempt_id: Option<String>,

    /// OpenAI-compatible streaming flag. Defaults to false and is omitted on
    /// the wire in that case.
    #[serde(skip_serializing_if = "std::ops::Not::not")]
    pub stream: bool,

    /// OpenAI-compatible streaming options. Providers only include the final
    /// usage frame when `include_usage` is requested explicitly.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stream_options: Option<StreamOptions>,
}

/// Streaming options.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct StreamOptions {
    /// Ask the provider to include a final usage chunk.
    pub include_usage: bool,
}

impl CompletionRequest {
    /// New request with the same defaults as the Python wrapper
    /// (`temperature = 0.3`, `max_tokens = 4096`).
    pub fn new(model: impl Into<String>, messages: Vec<ChatMessage>) -> Self {
        Self {
            model: model.into(),
            messages,
            tools: None,
            tool_choice: None,
            temperature: 0.3,
            max_tokens: 4096,
            reasoning_effort: None,
            provider_attempt_id: None,
            stream: false,
            stream_options: None,
        }
    }

    /// Enable or disable streaming.
    pub fn stream(mut self, enabled: bool) -> Self {
        self.stream = enabled;
        self
    }

    /// Set the sampling temperature.
    pub fn temperature(mut self, t: f32) -> Self {
        self.temperature = t;
        self
    }

    /// Set the maximum completion tokens.
    pub fn max_tokens(mut self, n: u32) -> Self {
        self.max_tokens = n;
        self
    }

    /// Set the reasoning budget.
    pub fn reasoning_effort(mut self, effort: ReasoningEffort) -> Self {
        self.reasoning_effort = Some(effort);
        self
    }

    /// Attach tools. Mirrors the Python default: if a caller adds tools without
    /// an explicit choice, we set `tool_choice = auto`.
    pub fn tools(mut self, tools: Vec<Tool>) -> Self {
        if !tools.is_empty() && self.tool_choice.is_none() {
            self.tool_choice = Some(ToolChoice::Auto);
        }
        self.tools = Some(tools);
        self
    }

    /// Set the tool-choice policy.
    pub fn tool_choice(mut self, choice: ToolChoice) -> Self {
        self.tool_choice = Some(choice);
        self
    }
}

/// A chat-completion response.
#[derive(Debug, Clone, Deserialize)]
pub struct CompletionResponse {
    /// Stable identifier of this record.
    #[serde(default)]
    pub id: String,
    /// Completion choices returned by the provider.
    pub choices: Vec<Choice>,
    /// Token usage reported by the provider.
    #[serde(default)]
    pub usage: Option<Usage>,
}

impl CompletionResponse {
    /// Convenience: text content of the first choice, if any.
    pub fn first_content(&self) -> Option<&str> {
        self.choices
            .first()
            .and_then(|c| c.message.content.as_deref())
    }

    /// Convenience: tool calls of the first choice, if any.
    pub fn first_tool_calls(&self) -> Option<&[ToolCall]> {
        self.choices
            .first()
            .and_then(|c| c.message.tool_calls.as_deref())
    }

    /// Finish reason of the first choice, if the provider supplied one.
    pub fn first_finish_reason(&self) -> Option<&FinishReason> {
        self.choices
            .first()
            .and_then(|choice| choice.finish_reason.as_ref())
    }
}

/// Why the provider stopped generating a completion.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FinishReason {
    /// The model finished its answer.
    Stop,
    /// The model requested tool calls.
    ToolCalls,
    /// Output hit the token limit.
    Length,
    /// The provider filtered the output.
    ContentFilter,
    /// A reason this crate does not model.
    Unknown(String),
}

impl FinishReason {
    /// Provider wire name.
    pub fn as_str(&self) -> &str {
        match self {
            Self::Stop => "stop",
            Self::ToolCalls => "tool_calls",
            Self::Length => "length",
            Self::ContentFilter => "content_filter",
            Self::Unknown(reason) => reason,
        }
    }
}

impl fmt::Display for FinishReason {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl From<&str> for FinishReason {
    fn from(reason: &str) -> Self {
        match reason {
            "stop" => Self::Stop,
            "tool_calls" => Self::ToolCalls,
            "length" => Self::Length,
            "content_filter" => Self::ContentFilter,
            unknown => Self::Unknown(unknown.to_string()),
        }
    }
}

impl From<String> for FinishReason {
    fn from(reason: String) -> Self {
        Self::from(reason.as_str())
    }
}

impl<'de> Deserialize<'de> for FinishReason {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        String::deserialize(deserializer).map(Into::into)
    }
}

/// One completion candidate.
#[derive(Debug, Clone, Deserialize)]
pub struct Choice {
    /// Zero-based position.
    #[serde(default)]
    pub index: u32,
    /// Human-readable message.
    pub message: ChatMessage,
    /// Why the provider stopped generating.
    #[serde(default)]
    pub finish_reason: Option<FinishReason>,
    /// Structured assistant blocks when the provider returns them instead of prose.
    #[serde(default, alias = "content_blocks")]
    pub output_blocks: Vec<AssistantBlock>,
}

/// Token accounting reported by the provider.
#[derive(Debug, Clone, Copy, Default, Deserialize)]
pub struct Usage {
    /// Prompt tokens consumed.
    #[serde(default)]
    pub prompt_tokens: u32,
    /// Completion tokens produced.
    #[serde(default)]
    pub completion_tokens: u32,
    /// Prompt plus completion tokens.
    #[serde(default)]
    pub total_tokens: u32,
}