polyc_llm/request.rs
1//! Request-side LLM types: [`CompletionRequest`], [`Message`], [`Content`],
2//! [`ToolSpec`], [`ToolChoice`], and [`JsonSchema`].
3
4use serde::{Deserialize, Serialize};
5
6// ── CompletionRequest ─────────────────────────────────────────────────────────
7
8/// Top-level request to an LLM provider.
9///
10/// Construct via [`CompletionRequest::new`], then populate fields directly.
11///
12/// `#[non_exhaustive]`: provider-shaped sampling fields (`top_p`, `seed`, …)
13/// will be added over time; build through [`new`](CompletionRequest::new) so
14/// such additions stay non-breaking.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[non_exhaustive]
17pub struct CompletionRequest {
18 /// The model identifier (e.g. `"fast-2"`, `"reasoning-pro"`).
19 pub model: String,
20 /// Optional system prompt text prepended before the conversation.
21 pub system: Option<String>,
22 /// Ordered list of messages in the conversation.
23 pub messages: Vec<Message>,
24 /// Tool definitions available to the model.
25 pub tools: Vec<ToolSpec>,
26 /// How the model should decide whether to call a tool.
27 pub tool_choice: ToolChoice,
28 /// When set, instructs the provider to return structured JSON output.
29 pub response_format: Option<JsonSchema>,
30 /// Maximum number of tokens the model may generate.
31 pub max_tokens: Option<u32>,
32 /// Sampling temperature in `[0.0, 2.0]`. Lower is more deterministic.
33 pub temperature: Option<f32>,
34 /// Token sequences that cause the model to stop generating.
35 pub stop: Vec<String>,
36 /// When `true`, the model may search the public web to ground its answer.
37 ///
38 /// This is a provider-agnostic capability hint: a provider maps it to its
39 /// native mechanism (Vertex Gemini → the `googleSearch` grounding tool,
40 /// alongside any `tools` function declarations) and a provider without web
41 /// search ignores it. Defaults to `false`; the agent's answering loop
42 /// (`run_turn`) sets it from its `RunTurnOptions.web_search`, so auxiliary
43 /// calls (summarization, classification) that bypass that loop never offer
44 /// search.
45 pub web_search: bool,
46 /// Provider-agnostic hint about caching the request's stable prefix.
47 ///
48 /// See [`CacheHint`]. Defaults to [`CacheHint::None`]; the agent's answering
49 /// loop sets it from its `RunTurnOptions` so a multi-step turn can cache the
50 /// system text + tool-spec block once and skip re-processing it each step.
51 pub cache: CacheHint,
52}
53
54impl CompletionRequest {
55 /// Creates a new request for the given `model` with sensible defaults:
56 /// empty `messages`, `tools`, and `stop` lists; `tool_choice` set to
57 /// [`ToolChoice::Auto`]; all optional fields `None`.
58 #[must_use]
59 pub fn new(model: impl Into<String>) -> Self {
60 Self {
61 model: model.into(),
62 system: None,
63 messages: Vec::new(),
64 tools: Vec::new(),
65 tool_choice: ToolChoice::Auto,
66 response_format: None,
67 max_tokens: None,
68 temperature: None,
69 stop: Vec::new(),
70 web_search: false,
71 cache: CacheHint::None,
72 }
73 }
74}
75
76// ── CacheHint ─────────────────────────────────────────────────────────────────
77
78/// Provider-agnostic hint about caching a request's stable prefix.
79///
80/// A multi-step turn re-sends a growing conversation behind an unchanging
81/// prefix — the system text plus the tool-spec block (built once per turn). This
82/// hint marks that prefix as stable so a provider that supports prompt caching
83/// can cache it and skip re-processing it on every step, the single biggest
84/// latency lever on multi-step turns. It is purely advisory: a provider maps it
85/// to its native mechanism, and a provider with no caching ignores it with no
86/// behavior change.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
88#[serde(rename_all = "snake_case")]
89#[non_exhaustive]
90pub enum CacheHint {
91 /// No caching is requested; a provider processes the full prompt on every
92 /// call. The default so auxiliary calls that bypass the answering loop never
93 /// opt in accidentally.
94 #[default]
95 None,
96 /// The request's leading stable prefix (system text + tool-spec block) stays
97 /// byte-identical across the steps of a turn — and across turns of a
98 /// conversation — so a provider that supports prompt caching should cache it.
99 StablePrefix {
100 /// Optional stable per-conversation identifier a provider MAY use to pin
101 /// cache routing (a provider maps it to its own cache-key field). A
102 /// provider with only implicit prefix caching, or none at all, ignores
103 /// it. `None` leaves routing to the provider's implicit prefix match.
104 key: Option<String>,
105 },
106}
107
108impl CacheHint {
109 /// Reconstructs a hint from its flat single-string form: a non-empty `key`
110 /// is a keyed [`CacheHint::StablePrefix`]; an empty one is
111 /// [`CacheHint::None`]. The inverse of [`Self::key`] — together they carry
112 /// the hint across a boundary that has one string field (the control-plane
113 /// → harness turn input), which cannot express a keyless stable-prefix
114 /// hint (the control plane always keys by conversation).
115 #[must_use]
116 pub fn from_key(key: String) -> Self {
117 if key.is_empty() {
118 Self::None
119 } else {
120 Self::StablePrefix { key: Some(key) }
121 }
122 }
123
124 /// The cache-routing key this hint carries, if any. See [`Self::from_key`]
125 /// for the flat form the pair round-trips.
126 #[must_use]
127 pub fn key(&self) -> Option<&str> {
128 match self {
129 Self::StablePrefix { key: Some(key) } => Some(key),
130 _ => None,
131 }
132 }
133}
134
135// ── Message ───────────────────────────────────────────────────────────────────
136
137/// A single turn in a conversation, composed of one or more [`Content`] parts.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Message {
140 /// The participant that produced this message.
141 pub role: Role,
142 /// Ordered content blocks that make up the message body.
143 pub content: Vec<Content>,
144}
145
146impl Message {
147 /// Creates a [`Role::User`] message with a single [`Content::Text`] block.
148 #[must_use]
149 pub fn user(text: impl Into<String>) -> Self {
150 Self {
151 role: Role::User,
152 content: vec![Content::Text(text.into())],
153 }
154 }
155
156 /// Creates a [`Role::Assistant`] message with a single [`Content::Text`] block.
157 #[must_use]
158 pub fn assistant(text: impl Into<String>) -> Self {
159 Self {
160 role: Role::Assistant,
161 content: vec![Content::Text(text.into())],
162 }
163 }
164
165 /// Creates a [`Role::System`] message with a single [`Content::Text`] block.
166 #[must_use]
167 pub fn system(text: impl Into<String>) -> Self {
168 Self {
169 role: Role::System,
170 content: vec![Content::Text(text.into())],
171 }
172 }
173}
174
175// ── Role ──────────────────────────────────────────────────────────────────────
176
177/// The participant role for a [`Message`].
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180#[non_exhaustive]
181pub enum Role {
182 /// A human turn.
183 User,
184 /// A model-generated turn.
185 Assistant,
186 /// A system-level instruction (not all providers support this as a role).
187 System,
188 /// A tool-result turn injected back into the conversation.
189 Tool,
190}
191
192// ── Content ───────────────────────────────────────────────────────────────────
193
194/// A single content block within a [`Message`].
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(rename_all = "snake_case")]
197#[non_exhaustive]
198pub enum Content {
199 /// Plain text.
200 Text(String),
201 /// A tool invocation emitted by the model.
202 ToolUse(ToolCall),
203 /// The result of a prior [`Content::ToolUse`], fed back to the model.
204 ToolResult(ToolResult),
205 /// A reference to an image (HTTP URL or `data:` URI).
206 Image(ImageRef),
207}
208
209impl Content {
210 /// Wraps `s` in a [`Content::Text`] variant.
211 #[must_use]
212 pub fn text(s: impl Into<String>) -> Self {
213 Self::Text(s.into())
214 }
215
216 /// Constructs a [`Content::ToolUse`] block.
217 #[must_use]
218 pub fn tool_use(
219 id: impl Into<String>,
220 name: impl Into<String>,
221 args_json: impl Into<String>,
222 ) -> Self {
223 Self::ToolUse(ToolCall {
224 id: id.into(),
225 name: name.into(),
226 args_json: args_json.into(),
227 signature: None,
228 })
229 }
230
231 /// Constructs a [`Content::ToolUse`] block carrying an opaque
232 /// provider-specific `signature` (e.g. a thinking model's thought
233 /// signature, which some providers require echoed back on the next
234 /// request that includes this call).
235 #[must_use]
236 pub fn tool_use_signed(
237 id: impl Into<String>,
238 name: impl Into<String>,
239 args_json: impl Into<String>,
240 signature: Option<String>,
241 ) -> Self {
242 Self::ToolUse(ToolCall {
243 id: id.into(),
244 name: name.into(),
245 args_json: args_json.into(),
246 signature,
247 })
248 }
249
250 /// Constructs a [`Content::ToolResult`] block.
251 #[must_use]
252 pub fn tool_result(
253 tool_call_id: impl Into<String>,
254 result_json: impl Into<String>,
255 is_error: bool,
256 ) -> Self {
257 Self::ToolResult(ToolResult {
258 tool_call_id: tool_call_id.into(),
259 result_json: result_json.into(),
260 is_error,
261 })
262 }
263
264 /// Constructs a [`Content::Image`] block.
265 #[must_use]
266 pub fn image(url: impl Into<String>, mime_type: Option<String>) -> Self {
267 Self::Image(ImageRef {
268 url: url.into(),
269 mime_type,
270 })
271 }
272}
273
274// ── ToolCall ──────────────────────────────────────────────────────────────────
275
276/// A tool call emitted by the model inside an assistant [`Message`].
277///
278/// Mirrors the wire-side `polychrome.agent.v1.ToolCall`; surfaced inside a
279/// [`Content::ToolUse`] block.
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct ToolCall {
282 /// Provider-assigned call identifier, used to correlate with [`ToolResult`].
283 pub id: String,
284 /// Name of the tool being called.
285 pub name: String,
286 /// Arguments serialized as a JSON string (opaque at this layer).
287 pub args_json: String,
288 /// Opaque, provider-specific signature attached to this call (e.g. a
289 /// thinking model's thought signature). Some providers require it to be
290 /// echoed back verbatim on the follow-up request that carries this call
291 /// in the history; `None` when the provider emits no such token.
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub signature: Option<String>,
294}
295
296// ── ToolResult ────────────────────────────────────────────────────────────────
297
298/// The result of executing a tool, fed back to the model as a [`Content`] block.
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ToolResult {
301 /// Matches the [`ToolCall::id`] this result corresponds to.
302 pub tool_call_id: String,
303 /// Serialized JSON payload returned by the tool executor.
304 pub result_json: String,
305 /// `true` when the tool raised an error rather than producing output.
306 pub is_error: bool,
307}
308
309// ── ImageRef ──────────────────────────────────────────────────────────────────
310
311/// A reference to an image attached to a [`Message`].
312#[derive(Debug, Clone, Default, Serialize, Deserialize)]
313pub struct ImageRef {
314 /// HTTP URL or `data:` URI for the image bytes.
315 pub url: String,
316 /// Optional MIME type hint (e.g. `"image/png"`).
317 pub mime_type: Option<String>,
318}
319
320// ── ToolSpec ──────────────────────────────────────────────────────────────────
321
322/// Declaration of a tool the model may invoke.
323///
324/// Mirrors the MCP `Tool` shape so built-in and connector tools are described
325/// uniformly: `title` is the MCP `title` annotation, and `read_only` /
326/// `destructive` / `open_world` are the `readOnlyHint` / `destructiveHint` /
327/// `openWorldHint` annotations. Build with [`ToolSpec::new`] + the chainable
328/// setters rather than a struct literal.
329#[derive(Debug, Clone, Serialize, Deserialize, Default)]
330// The flags are independent MCP-style annotation hints (each serialized as an
331// optional bool); folding them into an enum/bitflags would fight serde's
332// per-field optional-default model for no gain.
333#[allow(clippy::struct_excessive_bools)]
334pub struct ToolSpec {
335 /// Unique tool name; the model references this when emitting a [`ToolCall`].
336 pub name: String,
337 /// Human-readable description of what the tool does.
338 pub description: String,
339 /// JSON Schema object describing the tool's argument shape.
340 pub schema_json: serde_json::Value,
341 /// MCP-style human display name for this tool (the `title` annotation):
342 /// a friendly label shown to people (e.g. in an approval prompt) while the
343 /// machine-facing [`name`](ToolSpec::name) stays the audit identifier.
344 ///
345 /// `None` means no curated label was provided; callers derive a display
346 /// name from [`name`](ToolSpec::name) via `polyc_proto::humanize_tool_name`.
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub title: Option<String>,
349 /// Intrinsic "this tool is side-effecting / requires human approval" flag.
350 ///
351 /// When `true` the tool must be routed through the harness's
352 /// human-in-the-loop (HITL) approval gate before it executes, even when no
353 /// operator-side allow-list names it. Pure, read-only tools leave this
354 /// `false`.
355 ///
356 /// This is the per-tool generalization of the old hard-coded
357 /// approval-by-name list: it maps from the MCP `destructiveHint` tool
358 /// annotation, so an upstream connector that advertises a destructive tool
359 /// is gated per-tool rather than per-connector.
360 ///
361 /// Defaults to `false` and is skipped when serializing the safe default, so
362 /// older payloads that omit the field still deserialize as ungated.
363 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
364 pub needs_approval: bool,
365 /// MCP `readOnlyHint`: the tool does not modify its environment. Advisory —
366 /// surfaced to the model and usable by callers (e.g. sandbox-mode gating
367 /// never gates a read-only tool). Defaults to `false`.
368 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
369 pub read_only: bool,
370 /// MCP `destructiveHint`: the tool may perform irreversible / side-effecting
371 /// changes. Drives sandbox-mode gating (destructive tools gate in read-only
372 /// mode) and maps from a connector's `destructiveHint`. Defaults to `false`.
373 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
374 pub destructive: bool,
375 /// MCP `openWorldHint`: the tool may interact with an open world of external
376 /// entities, so its RESULT can carry content of uncontrolled provenance. This
377 /// is the INBOUND ("untrusted content in context") leg of the lethal
378 /// trifecta — a tool with `open_world = true` seeds the leg when its result
379 /// is in context (see `polyc_agent`'s `untrusted_content_in_context`). The
380 /// built-in web fetchers set it; the sandbox coding tools do not. For a
381 /// dialed connector it is read from `openWorldHint` at connect. Defaults to
382 /// `false`.
383 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
384 pub open_world: bool,
385 /// Whether a single human approval for this tool may be *remembered* for the
386 /// rest of a conversation session (per-caller) and reused for later calls,
387 /// instead of re-prompting every time. Defaults to `false`.
388 ///
389 /// The session grant is **per-tool, not per-argument**: approving one call
390 /// authorizes the tool for ANY arguments for the rest of the session. So set
391 /// this ONLY when the tool's ENTIRE argument space is safe to auto-run within
392 /// the sandbox boundary — i.e. it is both idempotent AND can't reach anything
393 /// the human wouldn't have blanket-approved. `file_read` qualifies because it
394 /// is workspace-confined (`coding::workspace::resolve` rejects absolute/`..`
395 /// paths), so "approve one read" only ever grants reads inside the sandbox.
396 /// NEVER set it on a tool that spends money, has side effects, or whose risk
397 /// varies by argument (e.g. it could read/write outside a confined root):
398 /// those must get a fresh decision per call.
399 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
400 pub cacheable_approval: bool,
401}
402
403impl ToolSpec {
404 /// A tool spec with the given `name`, `description`, and JSON-Schema
405 /// `schema_json`; all annotations default off. Chain the setters below to
406 /// add a title or mark it read-only / destructive / approval-gated.
407 #[must_use]
408 pub fn new(
409 name: impl Into<String>,
410 description: impl Into<String>,
411 schema_json: serde_json::Value,
412 ) -> Self {
413 Self {
414 name: name.into(),
415 description: description.into(),
416 schema_json,
417 title: None,
418 needs_approval: false,
419 read_only: false,
420 destructive: false,
421 open_world: false,
422 cacheable_approval: false,
423 }
424 }
425
426 /// Set the MCP `title` display annotation.
427 #[must_use]
428 pub fn titled(mut self, title: impl Into<String>) -> Self {
429 self.title = Some(title.into());
430 self
431 }
432
433 /// Mark the tool read-only (MCP `readOnlyHint`).
434 #[must_use]
435 pub const fn read_only(mut self) -> Self {
436 self.read_only = true;
437 self
438 }
439
440 /// Mark the tool destructive (MCP `destructiveHint`).
441 #[must_use]
442 pub const fn destructive(mut self) -> Self {
443 self.destructive = true;
444 self
445 }
446
447 /// Mark the tool open-world (MCP `openWorldHint`): its result can carry
448 /// content of uncontrolled provenance, seeding the untrusted-content leg.
449 #[must_use]
450 pub const fn open_world(mut self) -> Self {
451 self.open_world = true;
452 self
453 }
454
455 /// Mark a single approval for this tool as rememberable for the rest of a
456 /// conversation session (per-caller). Only set this on idempotent tools (see
457 /// [`Self::cacheable_approval`] field docs).
458 #[must_use]
459 pub const fn cacheable_approval(mut self) -> Self {
460 self.cacheable_approval = true;
461 self
462 }
463
464 /// Mark the tool as intrinsically requiring HITL approval (independent of
465 /// sandbox mode — e.g. `paid_fetch`).
466 #[must_use]
467 pub const fn approval_required(mut self) -> Self {
468 self.needs_approval = true;
469 self
470 }
471}
472
473// ── ToolChoice ────────────────────────────────────────────────────────────────
474
475/// Controls whether and how the model calls tools.
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477#[serde(rename_all = "snake_case")]
478#[non_exhaustive]
479pub enum ToolChoice {
480 /// The model decides whether to call a tool (default).
481 Auto,
482 /// The model must not call any tool.
483 None,
484 /// The model must call at least one tool.
485 Required,
486 /// Force the model to call the named tool.
487 Named(String),
488}
489
490// ── JsonSchema ────────────────────────────────────────────────────────────────
491
492/// Wrapper for a response-format JSON Schema.
493///
494/// Instructs the provider to return structured output conforming to the schema.
495/// Serializes transparently as the inner [`serde_json::Value`].
496#[derive(Debug, Clone, Serialize, Deserialize)]
497#[serde(transparent)]
498pub struct JsonSchema(
499 /// The raw JSON Schema value.
500 pub serde_json::Value,
501);
502
503// ── Tests ─────────────────────────────────────────────────────────────────────
504
505#[cfg(test)]
506mod tests {
507 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
508
509 use serde_json::{Value, json};
510
511 use super::*;
512
513 #[test]
514 fn new_sets_model_and_defaults() {
515 let req = CompletionRequest::new("fast-2");
516 assert_eq!(req.model, "fast-2");
517 assert!(req.messages.is_empty());
518 assert!(req.tools.is_empty());
519 assert!(req.stop.is_empty());
520 assert!(req.system.is_none());
521 assert!(req.max_tokens.is_none());
522 assert!(req.temperature.is_none());
523 assert!(req.response_format.is_none());
524 assert_eq!(req.tool_choice, ToolChoice::Auto);
525 }
526
527 #[test]
528 fn role_serializes_to_snake_case() {
529 assert_eq!(serde_json::to_string(&Role::User).unwrap(), r#""user""#);
530 assert_eq!(
531 serde_json::to_string(&Role::Assistant).unwrap(),
532 r#""assistant""#
533 );
534 assert_eq!(serde_json::to_string(&Role::System).unwrap(), r#""system""#);
535 assert_eq!(serde_json::to_string(&Role::Tool).unwrap(), r#""tool""#);
536 }
537
538 #[test]
539 fn role_round_trips() {
540 for role in [Role::User, Role::Assistant, Role::System, Role::Tool] {
541 let json = serde_json::to_string(&role).unwrap();
542 let back: Role = serde_json::from_str(&json).unwrap();
543 assert_eq!(back, role);
544 }
545 }
546
547 #[test]
548 fn tool_choice_unit_variants_serialize_as_strings() {
549 assert_eq!(
550 serde_json::to_string(&ToolChoice::Auto).unwrap(),
551 r#""auto""#
552 );
553 assert_eq!(
554 serde_json::to_string(&ToolChoice::None).unwrap(),
555 r#""none""#
556 );
557 assert_eq!(
558 serde_json::to_string(&ToolChoice::Required).unwrap(),
559 r#""required""#
560 );
561 }
562
563 #[test]
564 fn tool_choice_named_serializes_as_object() {
565 let tc = ToolChoice::Named("my_tool".to_owned());
566 let v: Value = serde_json::to_value(&tc).unwrap();
567 assert_eq!(v, json!({"named": "my_tool"}));
568 }
569
570 #[test]
571 fn tool_choice_round_trips() {
572 for tc in [
573 ToolChoice::Auto,
574 ToolChoice::None,
575 ToolChoice::Required,
576 ToolChoice::Named("search".to_owned()),
577 ] {
578 let json = serde_json::to_string(&tc).unwrap();
579 let back: ToolChoice = serde_json::from_str(&json).unwrap();
580 assert_eq!(back, tc);
581 }
582 }
583
584 #[test]
585 fn content_text_constructor() {
586 let c = Content::text("hello");
587 assert!(matches!(c, Content::Text(s) if s == "hello"));
588 }
589
590 #[test]
591 fn content_tool_use_constructor() {
592 let c = Content::tool_use("call-1", "search", r#"{"q":"rust"}"#);
593 match c {
594 Content::ToolUse(tu) => {
595 assert_eq!(tu.id, "call-1");
596 assert_eq!(tu.name, "search");
597 assert_eq!(tu.args_json, r#"{"q":"rust"}"#);
598 }
599 _ => panic!("wrong variant"),
600 }
601 }
602
603 #[test]
604 fn content_tool_result_constructor() {
605 let c = Content::tool_result("call-1", r#"{"result":"ok"}"#, false);
606 match c {
607 Content::ToolResult(tr) => {
608 assert_eq!(tr.tool_call_id, "call-1");
609 assert_eq!(tr.result_json, r#"{"result":"ok"}"#);
610 assert!(!tr.is_error);
611 }
612 _ => panic!("wrong variant"),
613 }
614 }
615
616 #[test]
617 fn content_image_constructor() {
618 let c = Content::image("https://example.com/img.png", Some("image/png".to_owned()));
619 match c {
620 Content::Image(img) => {
621 assert_eq!(img.url, "https://example.com/img.png");
622 assert_eq!(img.mime_type.as_deref(), Some("image/png"));
623 }
624 _ => panic!("wrong variant"),
625 }
626 }
627
628 #[test]
629 fn message_user_constructor() {
630 let m = Message::user("hi");
631 assert_eq!(m.role, Role::User);
632 assert_eq!(m.content.len(), 1);
633 assert!(matches!(&m.content[0], Content::Text(s) if s == "hi"));
634 }
635
636 #[test]
637 fn message_assistant_constructor() {
638 let m = Message::assistant("hello back");
639 assert_eq!(m.role, Role::Assistant);
640 assert_eq!(m.content.len(), 1);
641 assert!(matches!(&m.content[0], Content::Text(s) if s == "hello back"));
642 }
643
644 #[test]
645 fn message_system_constructor() {
646 let m = Message::system("You are helpful.");
647 assert_eq!(m.role, Role::System);
648 assert_eq!(m.content.len(), 1);
649 assert!(matches!(&m.content[0], Content::Text(_)));
650 }
651
652 #[test]
653 fn tool_use_args_json_preserved_as_opaque_string() {
654 let original = r#"{"nested":{"key":42},"arr":[1,2,3]}"#;
655 let c = Content::tool_use("id-42", "complex_tool", original);
656 let serialized = serde_json::to_string(&c).unwrap();
657 let back: Content = serde_json::from_str(&serialized).unwrap();
658 match back {
659 Content::ToolUse(tu) => assert_eq!(tu.args_json, original),
660 _ => panic!("wrong variant"),
661 }
662 }
663
664 #[test]
665 fn completion_request_round_trips_all_content_variants() {
666 let mut req = CompletionRequest::new("test-model");
667 req.system = Some("Be concise.".to_owned());
668 req.max_tokens = Some(256);
669 req.temperature = Some(0.7);
670 req.stop = vec!["<end>".to_owned()];
671 req.tool_choice = ToolChoice::Named("calculator".to_owned());
672 req.response_format = Some(JsonSchema(json!({"type": "object"})));
673 req.tools = vec![ToolSpec::new(
674 "calculator",
675 "Evaluates math expressions.",
676 json!({"type": "object", "properties": {"expr": {"type": "string"}}}),
677 )];
678 req.messages = vec![
679 Message::user("Compute 2+2"),
680 Message {
681 role: Role::Assistant,
682 content: vec![Content::tool_use(
683 "call-1",
684 "calculator",
685 r#"{"expr":"2+2"}"#,
686 )],
687 },
688 Message {
689 role: Role::Tool,
690 content: vec![Content::tool_result("call-1", r#"{"value":4}"#, false)],
691 },
692 Message {
693 role: Role::User,
694 content: vec![Content::image(
695 "https://example.com/chart.png",
696 Some("image/png".to_owned()),
697 )],
698 },
699 ];
700
701 let json_str = serde_json::to_string(&req).unwrap();
702 let back: CompletionRequest = serde_json::from_str(&json_str).unwrap();
703
704 assert_eq!(back.model, "test-model");
705 assert_eq!(back.system.as_deref(), Some("Be concise."));
706 assert_eq!(back.max_tokens, Some(256));
707 assert_eq!(back.messages.len(), 4);
708 assert_eq!(back.tools.len(), 1);
709 assert_eq!(back.tool_choice, ToolChoice::Named("calculator".to_owned()));
710 }
711
712 #[test]
713 fn cache_hint_defaults_to_none_and_round_trips_on_the_request() {
714 // A fresh request opts out of caching.
715 assert_eq!(CompletionRequest::new("m").cache, CacheHint::None);
716
717 // The stable-prefix hint (with a routing key) survives a serde round trip.
718 let mut req = CompletionRequest::new("m");
719 req.cache = CacheHint::StablePrefix {
720 key: Some("conv-7".to_owned()),
721 };
722 let json_str = serde_json::to_string(&req).unwrap();
723 let back: CompletionRequest = serde_json::from_str(&json_str).unwrap();
724 assert_eq!(
725 back.cache,
726 CacheHint::StablePrefix {
727 key: Some("conv-7".to_owned())
728 }
729 );
730 }
731
732 #[test]
733 fn cache_hint_round_trips_through_its_flat_key_form() {
734 // The single-string form the harness turn input carries: a non-empty
735 // key is a keyed stable-prefix hint, an empty key is no hint.
736 let keyed = CacheHint::StablePrefix {
737 key: Some("conv-9".to_owned()),
738 };
739 assert_eq!(keyed.key(), Some("conv-9"));
740 assert_eq!(CacheHint::from_key("conv-9".to_owned()), keyed);
741
742 assert_eq!(CacheHint::None.key(), None);
743 assert_eq!(CacheHint::from_key(String::new()), CacheHint::None);
744
745 // The one lossy case, by design: a KEYLESS stable-prefix hint has no
746 // flat form (the control plane always keys by conversation).
747 assert_eq!((CacheHint::StablePrefix { key: None }).key(), None);
748 }
749
750 #[test]
751 fn cache_hint_snake_case_wire_shape() {
752 let hint = CacheHint::StablePrefix { key: None };
753 let v: Value = serde_json::to_value(&hint).unwrap();
754 assert_eq!(v, json!({"stable_prefix": {"key": null}}));
755 assert_eq!(
756 serde_json::to_value(CacheHint::None).unwrap(),
757 json!("none")
758 );
759 }
760
761 #[test]
762 fn json_schema_serializes_transparently() {
763 let schema = JsonSchema(json!({"type": "object", "required": ["name"]}));
764 let v: Value = serde_json::to_value(&schema).unwrap();
765 assert_eq!(v["type"], "object");
766 assert_eq!(v["required"][0], "name");
767 }
768
769 #[test]
770 fn json_schema_round_trips() {
771 let inner = json!({"type": "string", "maxLength": 100});
772 let schema = JsonSchema(inner.clone());
773 let json_str = serde_json::to_string(&schema).unwrap();
774 let back: JsonSchema = serde_json::from_str(&json_str).unwrap();
775 assert_eq!(back.0, inner);
776 }
777
778 #[test]
779 fn image_ref_default_is_sensible() {
780 let img = ImageRef::default();
781 assert!(img.url.is_empty());
782 assert!(img.mime_type.is_none());
783 }
784
785 #[test]
786 fn tool_spec_carries_optional_title() {
787 let spec = ToolSpec::new("paid_fetch", "d", json!({})).titled("Pay for & fetch a web page");
788 assert_eq!(spec.title.as_deref(), Some("Pay for & fetch a web page"));
789 }
790
791 #[test]
792 fn tool_spec_carries_needs_approval_flag() {
793 let spec = ToolSpec::new("delete_file", "d", json!({})).approval_required();
794 assert!(spec.needs_approval);
795 }
796
797 /// `needs_approval` is `skip_serializing_if` false, so a non-gated spec
798 /// omits the field on the wire; deserialization must read that absence back
799 /// as `false` (the `#[serde(default)]` counterpart).
800 #[test]
801 fn tool_spec_needs_approval_defaults_false_on_deserialize() {
802 let payload = json!({
803 "name": "calculator",
804 "description": "math",
805 "schema_json": {"type": "object"}
806 });
807 let spec: ToolSpec = serde_json::from_value(payload).unwrap();
808 assert!(
809 !spec.needs_approval,
810 "omitted needs_approval must default to false"
811 );
812 }
813}