- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
- 3
use tokio_util::sync::CancellationToken; - 4
- 5
use vak_llm::Provider; - 6
use vak_llm::error::LlmError; - 7
use vak_llm::google::{GoogleConfig, GoogleProvider, build_body}; - 8
use vak_llm::stream::StreamEvent; - 9
use vak_llm::types::{ChatRequest, ContentBlock, Message, Role, StopReason, ToolDefinition}; - 10
- 11
fn sample_request() -> ChatRequest { - 12
let mut req = ChatRequest::new("gemini-3-pro"); - 13
req.system = Some("You are a coding agent.".into()); - 14
req.messages = vec![ - 15
Message::user_text("list the files"), - 16
Message::assistant(vec![ - 17
ContentBlock::text("Checking."), - 18
ContentBlock::ToolUse { - 19
id: "call_abc".into(), - 20
name: "bash".into(), - 21
input: serde_json::json!({"command": "ls"}), - 22
}, - 23
]), - 24
Message { - 25
role: Role::User, - 26
content: vec![ContentBlock::tool_result("call_abc", "src\nREADME.md")], - 27
}, - 28
Message::user_text("and now?"), - 29
]; - 30
req.tools = vec![ToolDefinition::new( - 31
"bash", - 32
"Run a shell command", - 33
serde_json::json!({"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}), - 34
)]; - 35
req - 36
} - 37
- 38
#[test] - 39
fn body_maps_neutral_history_to_gemini_shape() { - 40
let body = build_body(&sample_request()).unwrap(); - 41
assert_eq!( - 42
body["systemInstruction"]["parts"][0]["text"], - 43
"You are a coding agent." - 44
); - 45
- 46
let contents = body["contents"].as_array().unwrap(); - 47
// user, model(text+functionCall), user(functionResponse), user(text) - 48
assert_eq!(contents.len(), 4); - 49
- 50
assert_eq!(contents[0]["role"], "user"); - 51
assert_eq!(contents[0]["parts"][0]["text"], "list the files"); - 52
- 53
assert_eq!(contents[1]["role"], "model"); - 54
let parts = contents[1]["parts"].as_array().unwrap(); - 55
assert_eq!(parts[0]["text"], "Checking."); - 56
assert_eq!(parts[1]["functionCall"]["name"], "bash"); - 57
// Gemini args are OBJECTS, not strings. - 58
assert_eq!( - 59
parts[1]["functionCall"]["args"], - 60
serde_json::json!({"command": "ls"}) - 61
); - 62
- 63
assert_eq!(contents[2]["role"], "user"); - 64
let resp_parts = contents[2]["parts"].as_array().unwrap(); - 65
// functionResponse keyed by NAME resolved from the prior functionCall. - 66
assert_eq!( - 67
resp_parts[0]["functionResponse"]["name"], "bash", - 68
"tool_use_id must resolve to the function name via history" - 69
); - 70
assert_eq!( - 71
resp_parts[0]["functionResponse"]["response"]["result"], - 72
"src\nREADME.md" - 73
); - 74
- 75
assert_eq!(contents[3]["role"], "user"); - 76
assert_eq!(contents[3]["parts"][0]["text"], "and now?"); - 77
- 78
let decls = body["tools"][0]["functionDeclarations"].as_array().unwrap(); - 79
assert_eq!(decls[0]["name"], "bash"); - 80
} - 81
- 82
const FIXTURE_STREAM: &str = "\ - 83
data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Read\"}],\"role\":\"model\"}}],\"usageMetadata\":{\"promptTokenCount\":21,\"candidatesTokenCount\":2}}\n\ - 84
\n\ - 85
data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ing now.\"}],\"role\":\"model\"}}]}\n\ - 86
\n\ - 87
data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"read\",\"args\":{\"path\":\"a.txt\"}},\"thoughtSignature\":\"test_sig_value\"}],\"role\":\"model\"},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":21,\"candidatesTokenCount\":9}}\n\ - 88
\n"; - 89
- 90
#[tokio::test] - 91
async fn full_stream_accumulates_text_and_function_calls() { - 92
let provider = GoogleProvider::new(GoogleConfig { - 93
api_key: "k".into(), - 94
base_url: mock_url(FIXTURE_STREAM).await, - 95
}) - 96
.unwrap(); - 97
- 98
let mut es = provider - 99
.stream(sample_request(), CancellationToken::new()) - 100
.await - 101
.unwrap(); - 102
let mut text = String::new(); - 103
while let Some(ev) = futures::StreamExt::next(&mut es).await { - 104
if let StreamEvent::TextDelta { delta, .. } = ev { - 105
text.push_str(&delta); - 106
} - 107
} - 108
let msg = es.result().await.unwrap(); - 109
- 110
assert_eq!(text, "Reading now."); - 111
assert_eq!(msg.stop_reason, StopReason::ToolUse); - 112
assert_eq!(msg.usage.input_tokens, 21); - 113
assert_eq!(msg.usage.output_tokens, 9); - 114
- 115
let calls: Vec<&ContentBlock> = msg - 116
.content - 117
.iter() - 118
.filter(|b| matches!(b, ContentBlock::ToolUse { .. })) - 119
.collect(); - 120
assert_eq!(calls.len(), 1); - 121
if let ContentBlock::ToolUse { name, input, .. } = calls[0] { - 122
assert_eq!(name, "read"); - 123
assert_eq!(input, &serde_json::json!({"path": "a.txt"})); - 124
} else { - 125
unreachable!() - 126
} - 127
- 128
let sigs: Vec<Option<&str>> = msg - 129
.content - 130
.iter() - 131
.filter_map(|b| match b { - 132
ContentBlock::Thinking { signature, .. } => Some(signature.as_deref()), - 133
_ => None, - 134
}) - 135
.collect(); - 136
assert_eq!(sigs, vec![Some("test_sig_value")]); - 137
} - 138
- 139
// Realistic cache-bearing usage (docs/design/68-context-engine.md §1): - 140
// `promptTokenCount` is the whole prompt, `cachedContentTokenCount` a - 141
// SUBSET of it, not an addition. - 142
const FIXTURE_CACHED_USAGE: &str = "\ - 143
data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}],\"role\":\"model\"},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8000,\"candidatesTokenCount\":40,\"cachedContentTokenCount\":7500}}\n\ - 144
\n"; - 145
- 146
#[tokio::test] - 147
async fn cached_tokens_are_split_out_of_prompt_token_count_not_added_on_top() { - 148
let provider = GoogleProvider::new(GoogleConfig { - 149
api_key: "k".into(), - 150
base_url: mock_url(FIXTURE_CACHED_USAGE).await, - 151
}) - 152
.unwrap(); - 153
let mut req = ChatRequest::new("m"); - 154
req.messages = vec![Message::user_text("hi")]; - 155
let mut es = provider - 156
.stream(req, CancellationToken::new()) - 157
.await - 158
.unwrap(); - 159
while futures::StreamExt::next(&mut es).await.is_some() {} - 160
let msg = es.result().await.unwrap(); - 161
assert_eq!(msg.usage.input_tokens, 500); - 162
assert_eq!(msg.usage.cache_read_input_tokens, Some(7500)); - 163
assert_eq!(msg.usage.prompt_tokens(), 8000); - 164
} - 165
- 166
const FIXTURE_STOP: &str = "\ - 167
data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"done\"}],\"role\":\"model\"},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":5,\"candidatesTokenCount\":1}}\n\ - 168
\n"; - 169
- 170
#[tokio::test] - 171
async fn finish_stop_maps_to_end_turn() { - 172
let provider = GoogleProvider::new(GoogleConfig { - 173
api_key: "k".into(), - 174
base_url: mock_url(FIXTURE_STOP).await, - 175
}) - 176
.unwrap(); - 177
let mut req = ChatRequest::new("m"); - 178
req.messages = vec![Message::user_text("hi")]; - 179
let mut es = provider - 180
.stream(req, CancellationToken::new()) - 181
.await - 182
.unwrap(); - 183
while futures::StreamExt::next(&mut es).await.is_some() {} - 184
let msg = es.result().await.unwrap(); - 185
assert_eq!(msg.stop_reason, StopReason::EndTurn); - 186
assert_eq!(msg.text_content(), "done"); - 187
} - 188
- 189
#[tokio::test] - 190
async fn http_error_maps_to_typed_value() { - 191
let url = error_url(429, r#"{"error":{"message":"quota exceeded"}}"#).await; - 192
let provider = GoogleProvider::new(GoogleConfig { - 193
api_key: "k".into(), - 194
base_url: url, - 195
}) - 196
.unwrap(); - 197
let err = provider - 198
.stream(ChatRequest::new("m"), CancellationToken::new()) - 199
.await - 200
.err() - 201
.expect("expected error"); - 202
assert!(matches!(err, LlmError::RateLimit { .. }), "got {err:?}"); - 203
} - 204
- 205
async fn spawn_server(body: Vec<u8>) -> String { - 206
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - 207
let addr = listener.local_addr().unwrap(); - 208
tokio::spawn(async move { - 209
let (mut sock, _) = listener.accept().await.unwrap(); - 210
drain_headers(&mut sock).await; - 211
use tokio::io::AsyncWriteExt; - 212
let _ = sock.write_all(&body).await; - 213
let _ = sock.flush().await; - 214
let _ = sock.shutdown().await; - 215
}); - 216
format!("http://{addr}") - 217
} - 218
- 219
async fn drain_headers<S>(sock: &mut S) - 220
where - 221
S: tokio::io::AsyncRead + Unpin, - 222
{ - 223
use tokio::io::AsyncReadExt; - 224
let mut buf = Vec::new(); - 225
let mut chunk = [0u8; 1024]; - 226
loop { - 227
let n = sock.read(&mut chunk).await.unwrap_or(0); - 228
if n == 0 { - 229
return; - 230
} - 231
buf.extend_from_slice(&chunk[..n]); - 232
if buf.windows(4).any(|w| w == b"\r\n\r\n") { - 233
return; - 234
} - 235
} - 236
} - 237
- 238
async fn mock_url(sse_body: &str) -> String { - 239
let owned = format!( - 240
"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\n{sse_body}" - 241
); - 242
spawn_server(owned.into_bytes()).await - 243
} - 244
- 245
async fn error_url(status: u16, json: &str) -> String { - 246
let owned = format!( - 247
"HTTP/1.1 {status} Err\r\ncontent-type: application/json\r\nconnection: close\r\n\r\n{json}" - 248
); - 249
spawn_server(owned.into_bytes()).await - 250
} - 251
- 252
#[test] - 253
fn body_sanitizes_unsupported_schema_keywords_for_gemini() { - 254
let mut req = ChatRequest::new("gemini-2.5-flash"); - 255
req.messages = vec![Message::user_text("test")]; - 256
req.tools = vec![ToolDefinition::new( - 257
"complex_tool", - 258
"A tool with schema keywords unsupported by Gemini", - 259
serde_json::json!({ - 260
"$schema": "http://json-schema.org/draft-07/schema#", - 261
"type": "object", - 262
"additionalProperties": false, - 263
"properties": { - 264
"name": { - 265
"type": "string", - 266
"description": "The name" - 267
}, - 268
"nested": { - 269
"type": "object", - 270
"additionalProperties": false, - 271
"properties": { - 272
"inner": {"type": "string"} - 273
} - 274
} - 275
}, - 276
"patternProperties": { - 277
"^x-": {"type": "string"} - 278
}, - 279
"definitions": { - 280
"something": {} - 281
} - 282
}), - 283
)]; - 284
- 285
let body = build_body(&req).unwrap(); - 286
let params = &body["tools"][0]["functionDeclarations"][0]["parameters"]; - 287
assert!(params.get("$schema").is_none()); - 288
assert!(params.get("additionalProperties").is_none()); - 289
assert!(params.get("patternProperties").is_none()); - 290
assert!(params.get("definitions").is_none()); - 291
assert!( - 292
params["properties"]["nested"] - 293
.get("additionalProperties") - 294
.is_none() - 295
); - 296
assert_eq!(params["properties"]["name"]["type"], "string"); - 297
} - 298
- 299
#[test] - 300
fn body_serializes_thought_signature_on_assistant_function_calls() { - 301
let mut req = ChatRequest::new("gemini-3.8-flash"); - 302
req.messages = vec![ - 303
Message::user_text("what is the weather?"), - 304
Message::assistant(vec![ - 305
ContentBlock::Thinking { - 306
text: String::new(), - 307
signature: Some("sig_abc_123".into()), - 308
}, - 309
ContentBlock::ToolUse { - 310
id: "call_1".into(), - 311
name: "get_weather".into(), - 312
input: serde_json::json!({"location": "Tokyo"}), - 313
}, - 314
]), - 315
Message { - 316
role: Role::User, - 317
content: vec![ContentBlock::tool_result("call_1", "Sunny 22C")], - 318
}, - 319
]; - 320
- 321
let body = build_body(&req).unwrap(); - 322
let contents = body["contents"].as_array().unwrap(); - 323
assert_eq!(contents.len(), 3); - 324
let model_parts = contents[1]["parts"].as_array().unwrap(); - 325
assert_eq!(model_parts.len(), 1); - 326
assert_eq!(model_parts[0]["functionCall"]["name"], "get_weather"); - 327
assert_eq!(model_parts[0]["thoughtSignature"], "sig_abc_123"); - 328
} - 329
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.