- 1
use serde::{Deserialize, Serialize}; - 2
use serde_json::Value; - 3
- 4
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 5
#[serde(rename_all = "lowercase")] - 6
pub enum Role { - 7
User, - 8
Assistant, - 9
} - 10
- 11
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 12
#[serde(tag = "type", rename_all = "snake_case")] - 13
pub enum ContentBlock { - 14
Text { - 15
text: String, - 16
}, - 17
Thinking { - 18
text: String, - 19
#[serde(default, skip_serializing_if = "Option::is_none")] - 20
signature: Option<String>, - 21
}, - 22
ToolUse { - 23
id: String, - 24
name: String, - 25
input: Value, - 26
}, - 27
ToolResult { - 28
tool_use_id: String, - 29
content: String, - 30
#[serde(default)] - 31
is_error: bool, - 32
}, - 33
/// Base64 image in Anthropic's native wire shape — the serde - 34
/// passthrough in anthropic.rs sends it verbatim. User messages only. - 35
Image { - 36
source: ImageSource, - 37
}, - 38
/// A provider-native block this build does not interpret: Anthropic's - 39
/// `server_tool_use` and `tool_search_tool_result` (docs/design/68 - 40
/// §5/§11/§12). `raw` is the exact block the provider sent, `"type"` - 41
/// included, so the Anthropic adapter can replay it on the wire - 42
/// unchanged; `kind` mirrors `raw["type"]` for cheap matching without - 43
/// re-parsing. Never executed by the agent loop — persisted and - 44
/// replayed verbatim. Every other adapter must skip this variant when - 45
/// rendering its own wire format. - 46
Provider { - 47
kind: String, - 48
raw: Value, - 49
}, - 50
} - 51
- 52
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 53
pub struct ImageSource { - 54
pub r#type: String, - 55
pub media_type: String, - 56
pub data: String, - 57
} - 58
- 59
impl ContentBlock { - 60
pub fn text(s: impl Into<String>) -> Self { - 61
ContentBlock::Text { text: s.into() } - 62
} - 63
- 64
/// Base64-encoded image block (vision input). - 65
pub fn image_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self { - 66
ContentBlock::Image { - 67
source: ImageSource { - 68
r#type: "base64".into(), - 69
media_type: media_type.into(), - 70
data: data.into(), - 71
}, - 72
} - 73
} - 74
- 75
pub fn tool_result(tool_use_id: impl Into<String>, content: impl Into<String>) -> Self { - 76
ContentBlock::ToolResult { - 77
tool_use_id: tool_use_id.into(), - 78
content: content.into(), - 79
is_error: false, - 80
} - 81
} - 82
- 83
pub fn tool_error(tool_use_id: impl Into<String>, content: impl Into<String>) -> Self { - 84
ContentBlock::ToolResult { - 85
tool_use_id: tool_use_id.into(), - 86
content: content.into(), - 87
is_error: true, - 88
} - 89
} - 90
} - 91
- 92
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 93
pub struct Message { - 94
pub role: Role, - 95
pub content: Vec<ContentBlock>, - 96
} - 97
- 98
impl Message { - 99
pub fn user_text(s: impl Into<String>) -> Self { - 100
Message { - 101
role: Role::User, - 102
content: vec![ContentBlock::text(s)], - 103
} - 104
} - 105
- 106
pub fn assistant(content: Vec<ContentBlock>) -> Self { - 107
Message { - 108
role: Role::Assistant, - 109
content, - 110
} - 111
} - 112
- 113
pub fn text_content(&self) -> String { - 114
self.content - 115
.iter() - 116
.filter_map(|b| match b { - 117
ContentBlock::Text { text } => Some(text.as_str()), - 118
_ => None, - 119
}) - 120
.collect::<Vec<_>>() - 121
.join("\n") - 122
} - 123
} - 124
- 125
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 126
pub struct ToolDefinition { - 127
pub name: String, - 128
pub description: String, - 129
#[serde(rename = "input_schema")] - 130
pub parameters: Value, - 131
/// Anthropic-only scheduling hint (docs/design/68 §5/§11): render - 132
/// `defer_loading: true` and keep the schema out of the stable prefix. - 133
/// Every other adapter ignores this field entirely — they build their - 134
/// tool JSON field-by-field and never read it. - 135
#[serde(default, skip_serializing_if = "std::ops::Not::not")] - 136
pub defer: bool, - 137
} - 138
- 139
impl ToolDefinition { - 140
pub fn new(name: impl Into<String>, description: impl Into<String>, parameters: Value) -> Self { - 141
ToolDefinition { - 142
name: name.into(), - 143
description: description.into(), - 144
parameters, - 145
defer: false, - 146
} - 147
} - 148
- 149
/// The single tool offered on every horizon-ladder probe rung - 150
/// (docs/design/68-context-engine.md §1): a no-op the model can only - 151
/// reach by following the instruction placed at the end of the probe - 152
/// prompt, so "was it called" is a clean instruction-following signal - 153
/// independent of the filler content around it. - 154
pub fn probe_ack() -> Self { - 155
ToolDefinition::new( - 156
"probe_ack", - 157
"Acknowledge that you read this far. Call this with {\"ok\": true} \ - 158
and nothing else.", - 159
serde_json::json!({ - 160
"type": "object", - 161
"properties": { "ok": { "type": "boolean" } }, - 162
"required": ["ok"], - 163
}), - 164
) - 165
} - 166
- 167
/// Same tool, marked deferred (Anthropic `defer_loading`). - 168
pub fn deferred(mut self) -> Self { - 169
self.defer = true; - 170
self - 171
} - 172
} - 173
- 174
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] - 175
pub struct Usage { - 176
#[serde(default)] - 177
pub input_tokens: u64, - 178
#[serde(default)] - 179
pub output_tokens: u64, - 180
#[serde(default, skip_serializing_if = "Option::is_none")] - 181
pub cache_read_input_tokens: Option<u64>, - 182
#[serde(default, skip_serializing_if = "Option::is_none")] - 183
pub cache_creation_input_tokens: Option<u64>, - 184
/// Provider-reported prefill (prompt evaluation) latency, when the - 185
/// provider reports it directly rather than only through usage counts - 186
/// (Ollama's `prompt_eval_duration`). Used by the capacity probe to - 187
/// measure prefill throughput without inferring it from wall-clock time. - 188
#[serde(default, skip_serializing_if = "Option::is_none")] - 189
pub prefill_ms: Option<u64>, - 190
/// Provider-reported model load latency folded into the same response - 191
/// (Ollama's `load_duration`), so the probe can separate "model was - 192
/// already warm" from "cold load" when explaining a slow first token. - 193
#[serde(default, skip_serializing_if = "Option::is_none")] - 194
pub load_ms: Option<u64>, - 195
} - 196
- 197
impl Usage { - 198
pub fn total_tokens(&self) -> u64 { - 199
self.input_tokens + self.output_tokens - 200
} - 201
- 202
/// Every prompt token the provider actually processed for this - 203
/// request, regardless of cache tier: `input_tokens` (never cached) + - 204
/// `cache_read_input_tokens` (served from cache) + - 205
/// `cache_creation_input_tokens` (written to cache). Every adapter - 206
/// normalizes to that split (docs/design/68-context-engine.md §1), so - 207
/// this is the number to use wherever a caller wants "how big was the - 208
/// prompt" rather than "how much fresh compute did it cost" — - 209
/// calibrating chars-per-token against `input_tokens` alone collapses - 210
/// toward zero as cache hits grow, because a full cache hit reports - 211
/// `input_tokens == 0` for a prompt that was not remotely empty. - 212
pub fn prompt_tokens(&self) -> u64 { - 213
self.input_tokens - 214
+ self.cache_read_input_tokens.unwrap_or(0) - 215
+ self.cache_creation_input_tokens.unwrap_or(0) - 216
} - 217
} - 218
- 219
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 220
#[serde(rename_all = "snake_case")] - 221
pub enum StopReason { - 222
EndTurn, - 223
ToolUse, - 224
MaxTokens, - 225
Aborted, - 226
} - 227
- 228
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 229
pub struct AssistantMessage { - 230
pub content: Vec<ContentBlock>, - 231
pub stop_reason: StopReason, - 232
pub usage: Usage, - 233
pub model: String, - 234
/// Provider-assigned identity for this response, when the provider - 235
/// exposes one (OpenAI Responses `response.id`). `previous_response_id` - 236
/// on a later `ChatRequest` chains from this value to avoid replaying - 237
/// the turn's history. - 238
#[serde(default, skip_serializing_if = "Option::is_none")] - 239
pub response_id: Option<String>, - 240
} - 241
- 242
impl AssistantMessage { - 243
pub fn empty(model: impl Into<String>) -> Self { - 244
AssistantMessage { - 245
content: Vec::new(), - 246
stop_reason: StopReason::EndTurn, - 247
usage: Usage::default(), - 248
model: model.into(), - 249
response_id: None, - 250
} - 251
} - 252
- 253
pub fn into_message(self) -> Message { - 254
Message { - 255
role: Role::Assistant, - 256
content: self.content, - 257
} - 258
} - 259
- 260
pub fn text_content(&self) -> String { - 261
self.content - 262
.iter() - 263
.filter_map(|b| match b { - 264
ContentBlock::Text { text } => Some(text.as_str()), - 265
_ => None, - 266
}) - 267
.collect::<Vec<_>>() - 268
.join("\n") - 269
} - 270
} - 271
- 272
/// A cache breakpoint's position relative to `ChatRequest::messages`. - 273
/// `None` places it immediately after the stable prefix (system + tools), - 274
/// before any message — the position a fresh session with no history yet - 275
/// still wants cached. `Some(i)` places it after `messages[i]`. - 276
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 277
pub struct CacheBreakpoint { - 278
pub after_message: Option<usize>, - 279
} - 280
- 281
/// Cache hints the assembler attaches to a request; each provider adapter - 282
/// renders them in its own wire shape (§10/§11 of docs/design/68). Absent - 283
/// entirely, adapters fall back to their unconditional defaults (e.g. - 284
/// Anthropic still marks the system prompt ephemeral). - 285
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] - 286
pub struct CacheHints { - 287
/// Stable identity for this session, sent as a routing/cache key to - 288
/// providers that key cache reuse off an opaque string rather than - 289
/// content-addressing the prefix (OpenAI `prompt_cache_key`, OpenRouter - 290
/// `session_id`). - 291
pub session_key: String, - 292
pub breakpoints: Vec<CacheBreakpoint>, - 293
} - 294
- 295
/// Reasoning depth on models that support `output_config.effort` - 296
/// (docs/design/68-context-engine.md §11 "Anthropic" row). Rendered by the - 297
/// Anthropic adapter only; every other adapter ignores this field entirely - 298
/// — none of them expose an equivalent knob today. - 299
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 300
pub enum Effort { - 301
Low, - 302
Medium, - 303
High, - 304
XHigh, - 305
Max, - 306
} - 307
- 308
impl Effort { - 309
pub fn as_str(self) -> &'static str { - 310
match self { - 311
Effort::Low => "low", - 312
Effort::Medium => "medium", - 313
Effort::High => "high", - 314
Effort::XHigh => "xhigh", - 315
Effort::Max => "max", - 316
} - 317
} - 318
} - 319
- 320
#[derive(Debug, Clone)] - 321
pub struct ChatRequest { - 322
pub model: String, - 323
pub system: Option<String>, - 324
pub messages: Vec<Message>, - 325
pub tools: Vec<ToolDefinition>, - 326
pub max_tokens: u32, - 327
pub temperature: Option<f32>, - 328
pub cache: Option<CacheHints>, - 329
/// When set, an OpenAI Responses adapter chains from this prior - 330
/// response instead of replaying the full history: only messages after - 331
/// the last assistant message are sent, alongside this id. - 332
pub previous_response_id: Option<String>, - 333
/// Whether the model may spend tokens in a thinking channel before - 334
/// answering. `None` leaves the provider's default; `Some(false)` asks - 335
/// for a direct answer (Ollama `think`), which a strict-JSON - 336
/// classification needs — measured live, a thinking model spent its - 337
/// whole output budget deliberating and returned no JSON at all. - 338
/// Adapters without such a switch ignore it. On Anthropic this never - 339
/// disables thinking (current models reject that, or silently degrade - 340
/// tool-call reliability on the ones that still accept it) — instead, - 341
/// when `effort` is unset, the adapter reads `think == Some(false)` as - 342
/// "spend as little as possible" and sends `effort: low`. - 343
pub think: Option<bool>, - 344
/// Explicit reasoning depth (Anthropic `output_config.effort`). `None` - 345
/// leaves the provider's default, except that the Anthropic adapter - 346
/// falls back to `Low` when `think == Some(false)` (see `think`). - 347
pub effort: Option<Effort>, - 348
} - 349
- 350
impl ChatRequest { - 351
pub fn new(model: impl Into<String>) -> Self { - 352
ChatRequest { - 353
model: model.into(), - 354
system: None, - 355
messages: Vec::new(), - 356
tools: Vec::new(), - 357
max_tokens: 8192, - 358
temperature: None, - 359
cache: None, - 360
previous_response_id: None, - 361
think: None, - 362
effort: None, - 363
} - 364
} - 365
} - 366
- 367
#[cfg(test)] - 368
#[allow(clippy::unwrap_used, clippy::expect_used)] - 369
mod tests { - 370
use super::*; - 371
- 372
#[test] - 373
fn prompt_tokens_sums_fresh_and_both_cache_tiers() { - 374
let usage = Usage { - 375
input_tokens: 100, - 376
cache_read_input_tokens: Some(4_000), - 377
cache_creation_input_tokens: Some(300), - 378
..Default::default() - 379
}; - 380
assert_eq!(usage.prompt_tokens(), 4_400); - 381
} - 382
- 383
#[test] - 384
fn prompt_tokens_on_a_full_cache_hit_is_not_zero() { - 385
// input_tokens == 0 is what a 100%-cached prompt reports; the whole - 386
// point of `prompt_tokens` is that it does not collapse to zero here - 387
// the way reading `input_tokens` alone would. - 388
let usage = Usage { - 389
input_tokens: 0, - 390
cache_read_input_tokens: Some(12_000), - 391
cache_creation_input_tokens: None, - 392
..Default::default() - 393
}; - 394
assert_eq!(usage.prompt_tokens(), 12_000); - 395
} - 396
- 397
#[test] - 398
fn prompt_tokens_with_no_cache_fields_equals_input_tokens() { - 399
let usage = Usage { - 400
input_tokens: 42, - 401
..Default::default() - 402
}; - 403
assert_eq!(usage.prompt_tokens(), 42); - 404
} - 405
- 406
#[test] - 407
fn effort_renders_the_documented_wire_strings() { - 408
assert_eq!(Effort::Low.as_str(), "low"); - 409
assert_eq!(Effort::Medium.as_str(), "medium"); - 410
assert_eq!(Effort::High.as_str(), "high"); - 411
assert_eq!(Effort::XHigh.as_str(), "xhigh"); - 412
assert_eq!(Effort::Max.as_str(), "max"); - 413
} - 414
- 415
#[test] - 416
fn chat_request_defaults_carry_no_effort() { - 417
let req = ChatRequest::new("m"); - 418
assert_eq!(req.effort, None); - 419
assert_eq!(req.think, None); - 420
} - 421
} - 422
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.