Skip to main content

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    ///
252    /// `first_party` is the ingestion-time provenance bit (see
253    /// [`ToolResult::first_party`]) — pass the caller's already-computed
254    /// verdict (the static per-tool-name check for an ordinary tool, or the
255    /// worker-derived verdict for a `__delegate_to` result), not a fixed
256    /// default: the whole point of carrying this on [`ToolResult`] is that
257    /// the live same-turn taint scan needs the REAL per-call answer.
258    #[must_use]
259    pub fn tool_result(
260        tool_call_id: impl Into<String>,
261        result_json: impl Into<String>,
262        is_error: bool,
263        first_party: bool,
264    ) -> Self {
265        Self::ToolResult(ToolResult {
266            tool_call_id: tool_call_id.into(),
267            result_json: result_json.into(),
268            is_error,
269            first_party,
270        })
271    }
272
273    /// Constructs a [`Content::Image`] block.
274    #[must_use]
275    pub fn image(url: impl Into<String>, mime_type: Option<String>) -> Self {
276        Self::Image(ImageRef {
277            url: url.into(),
278            mime_type,
279        })
280    }
281}
282
283// ── ToolCall ──────────────────────────────────────────────────────────────────
284
285/// A tool call emitted by the model inside an assistant [`Message`].
286///
287/// Mirrors the wire-side `polychrome.agent.v1.ToolCall`; surfaced inside a
288/// [`Content::ToolUse`] block.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct ToolCall {
291    /// Provider-assigned call identifier, used to correlate with [`ToolResult`].
292    pub id: String,
293    /// Name of the tool being called.
294    pub name: String,
295    /// Arguments serialized as a JSON string (opaque at this layer).
296    pub args_json: String,
297    /// Opaque, provider-specific signature attached to this call (e.g. a
298    /// thinking model's thought signature). Some providers require it to be
299    /// echoed back verbatim on the follow-up request that carries this call
300    /// in the history; `None` when the provider emits no such token.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub signature: Option<String>,
303}
304
305// ── ToolResult ────────────────────────────────────────────────────────────────
306
307/// The result of executing a tool, fed back to the model as a [`Content`] block.
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct ToolResult {
310    /// Matches the [`ToolCall::id`] this result corresponds to.
311    pub tool_call_id: String,
312    /// Serialized JSON payload returned by the tool executor.
313    pub result_json: String,
314    /// `true` when the tool raised an error rather than producing output.
315    pub is_error: bool,
316    /// Ingestion-time provenance: `true` when the producing tool is
317    /// first-party and closed-domain (does not ingest untrusted open-world
318    /// content). Mirrors `polyc_proto`'s wire `ToolResultContent.first_party`
319    /// — this is the SAME bit, carried on the in-memory, provider-facing
320    /// representation so the live same-turn taint scan
321    /// (`untrusted_content_in_context`) reads a per-call signal instead of
322    /// re-deriving it from the tool name. A `__delegate_to` call's result
323    /// reflects what the delegated worker actually touched, not a static
324    /// per-tool-name check — see `polyc_agent`'s `DelegateRecord::first_party`.
325    pub first_party: bool,
326}
327
328// ── ImageRef ──────────────────────────────────────────────────────────────────
329
330/// A reference to an image attached to a [`Message`].
331#[derive(Debug, Clone, Default, Serialize, Deserialize)]
332pub struct ImageRef {
333    /// HTTP URL or `data:` URI for the image bytes.
334    pub url: String,
335    /// Optional MIME type hint (e.g. `"image/png"`).
336    pub mime_type: Option<String>,
337}
338
339// ── ToolSpec ──────────────────────────────────────────────────────────────────
340
341/// Declaration of a tool the model may invoke.
342///
343/// Mirrors the MCP `Tool` shape so built-in and connector tools are described
344/// uniformly: `title` is the MCP `title` annotation, and `read_only` /
345/// `destructive` / `open_world` are the `readOnlyHint` / `destructiveHint` /
346/// `openWorldHint` annotations. Build with [`ToolSpec::new`] + the chainable
347/// setters rather than a struct literal.
348#[derive(Debug, Clone, Serialize, Deserialize, Default)]
349// The flags are independent MCP-style annotation hints (each serialized as an
350// optional bool); folding them into an enum/bitflags would fight serde's
351// per-field optional-default model for no gain.
352#[allow(clippy::struct_excessive_bools)]
353pub struct ToolSpec {
354    /// Unique tool name; the model references this when emitting a [`ToolCall`].
355    pub name: String,
356    /// Human-readable description of what the tool does.
357    pub description: String,
358    /// JSON Schema object describing the tool's argument shape.
359    pub schema_json: serde_json::Value,
360    /// MCP-style human display name for this tool (the `title` annotation):
361    /// a friendly label shown to people (e.g. in an approval prompt) while the
362    /// machine-facing [`name`](ToolSpec::name) stays the audit identifier.
363    ///
364    /// `None` means no curated label was provided; callers derive a display
365    /// name from [`name`](ToolSpec::name) via `polyc_proto::humanize_tool_name`.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub title: Option<String>,
368    /// Intrinsic "this tool is side-effecting / requires human approval" flag.
369    ///
370    /// When `true` the tool must be routed through the harness's
371    /// human-in-the-loop (HITL) approval gate before it executes, even when no
372    /// operator-side allow-list names it. Pure, read-only tools leave this
373    /// `false`.
374    ///
375    /// This is the per-tool generalization of the old hard-coded
376    /// approval-by-name list: it maps from the MCP `destructiveHint` tool
377    /// annotation, so an upstream connector that advertises a destructive tool
378    /// is gated per-tool rather than per-connector.
379    ///
380    /// Defaults to `false` and is skipped when serializing the safe default, so
381    /// older payloads that omit the field still deserialize as ungated.
382    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
383    pub needs_approval: bool,
384    /// MCP `readOnlyHint`: the tool does not modify its environment. Advisory —
385    /// surfaced to the model and usable by callers (e.g. sandbox-mode gating
386    /// never gates a read-only tool). Defaults to `false`.
387    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
388    pub read_only: bool,
389    /// MCP `destructiveHint`: the tool may perform irreversible / side-effecting
390    /// changes. Drives sandbox-mode gating (destructive tools gate in read-only
391    /// mode) and maps from a connector's `destructiveHint`. Defaults to `false`.
392    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
393    pub destructive: bool,
394    /// MCP `openWorldHint`: the tool may interact with an open world of external
395    /// entities, so its RESULT can carry content of uncontrolled provenance. This
396    /// is the INBOUND ("untrusted content in context") leg of the lethal
397    /// trifecta — a tool with `open_world = true` seeds the leg when its result
398    /// is in context (see `polyc_agent`'s `untrusted_content_in_context`). The
399    /// built-in web fetchers set it; the sandbox coding tools do not. For a
400    /// dialed connector it is read from `openWorldHint` at connect. Defaults to
401    /// `false`.
402    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
403    pub open_world: bool,
404    /// Whether a single human approval for this tool may be *remembered* for the
405    /// rest of a conversation session (per-caller) and reused for later calls,
406    /// instead of re-prompting every time. Defaults to `false`.
407    ///
408    /// The session grant is **per-tool, not per-argument**: approving one call
409    /// authorizes the tool for ANY arguments for the rest of the session. So set
410    /// this ONLY when the tool's ENTIRE argument space is safe to auto-run within
411    /// the sandbox boundary — i.e. it is both idempotent AND can't reach anything
412    /// the human wouldn't have blanket-approved. `file_read` qualifies because it
413    /// is workspace-confined (`coding::workspace::resolve` rejects absolute/`..`
414    /// paths), so "approve one read" only ever grants reads inside the sandbox.
415    /// NEVER set it on a tool that spends money, has side effects, or whose risk
416    /// varies by argument (e.g. it could read/write outside a confined root):
417    /// those must get a fresh decision per call.
418    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
419    pub cacheable_approval: bool,
420}
421
422impl ToolSpec {
423    /// A tool spec with the given `name`, `description`, and JSON-Schema
424    /// `schema_json`; all annotations default off. Chain the setters below to
425    /// add a title or mark it read-only / destructive / approval-gated.
426    #[must_use]
427    pub fn new(
428        name: impl Into<String>,
429        description: impl Into<String>,
430        schema_json: serde_json::Value,
431    ) -> Self {
432        Self {
433            name: name.into(),
434            description: description.into(),
435            schema_json,
436            title: None,
437            needs_approval: false,
438            read_only: false,
439            destructive: false,
440            open_world: false,
441            cacheable_approval: false,
442        }
443    }
444
445    /// Set the MCP `title` display annotation.
446    #[must_use]
447    pub fn titled(mut self, title: impl Into<String>) -> Self {
448        self.title = Some(title.into());
449        self
450    }
451
452    /// Mark the tool read-only (MCP `readOnlyHint`).
453    #[must_use]
454    pub const fn read_only(mut self) -> Self {
455        self.read_only = true;
456        self
457    }
458
459    /// Mark the tool destructive (MCP `destructiveHint`).
460    #[must_use]
461    pub const fn destructive(mut self) -> Self {
462        self.destructive = true;
463        self
464    }
465
466    /// Mark the tool open-world (MCP `openWorldHint`): its result can carry
467    /// content of uncontrolled provenance, seeding the untrusted-content leg.
468    #[must_use]
469    pub const fn open_world(mut self) -> Self {
470        self.open_world = true;
471        self
472    }
473
474    /// Mark a single approval for this tool as rememberable for the rest of a
475    /// conversation session (per-caller). Only set this on idempotent tools (see
476    /// [`Self::cacheable_approval`] field docs).
477    #[must_use]
478    pub const fn cacheable_approval(mut self) -> Self {
479        self.cacheable_approval = true;
480        self
481    }
482
483    /// Mark the tool as intrinsically requiring HITL approval (independent of
484    /// sandbox mode — e.g. `paid_fetch`).
485    #[must_use]
486    pub const fn approval_required(mut self) -> Self {
487        self.needs_approval = true;
488        self
489    }
490}
491
492/// The model-facing note appended to a gated tool's [`ToolSpec::description`]
493/// (`#743`) so the tool is self-describing as propose-first.
494///
495/// The model stops guessing at approval/execution status, which the runtime —
496/// never the model — owns and reports (the approval card, then the resume's
497/// genuine result narration).
498///
499/// This lives here, in `polyc-llm`, rather than in `polyc-agent` or
500/// `polyc-capability`, because the layer graph runs foundation ⇒ component:
501/// `polyc-agent` composes the intrinsic [`ToolSpec::needs_approval`] flag with
502/// the capability gate to decide WHICH specs are gated, then appends this
503/// shared literal ONCE per turn at spec-pinning time — so every provider
504/// builder that forwards [`ToolSpec::description`] verbatim carries the same
505/// wording without either of them depending back down into this crate's
506/// caller.
507///
508/// Per the project's copy rules: a complete, warm, plain sentence — no
509/// vendor names, no internal jargon, no apology words.
510pub const GATED_TOOL_APPROVAL_NOTE: &str = "Calling this pauses while a person reviews the \
511    request — the system shows them what's pending and tells the user; don't describe approval \
512    status yourself. A result from this tool means it was approved and has already run.";
513
514// ── ToolChoice ────────────────────────────────────────────────────────────────
515
516/// Controls whether and how the model calls tools.
517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
518#[serde(rename_all = "snake_case")]
519#[non_exhaustive]
520pub enum ToolChoice {
521    /// The model decides whether to call a tool (default).
522    Auto,
523    /// The model must not call any tool.
524    None,
525    /// The model must call at least one tool.
526    Required,
527    /// Force the model to call the named tool.
528    Named(String),
529}
530
531// ── JsonSchema ────────────────────────────────────────────────────────────────
532
533/// Wrapper for a response-format JSON Schema.
534///
535/// Instructs the provider to return structured output conforming to the schema.
536/// Serializes transparently as the inner [`serde_json::Value`].
537#[derive(Debug, Clone, Serialize, Deserialize)]
538#[serde(transparent)]
539pub struct JsonSchema(
540    /// The raw JSON Schema value.
541    pub serde_json::Value,
542);
543
544// ── Tests ─────────────────────────────────────────────────────────────────────
545
546#[cfg(test)]
547mod tests {
548    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
549
550    use serde_json::{Value, json};
551
552    use super::*;
553
554    #[test]
555    fn new_sets_model_and_defaults() {
556        let req = CompletionRequest::new("fast-2");
557        assert_eq!(req.model, "fast-2");
558        assert!(req.messages.is_empty());
559        assert!(req.tools.is_empty());
560        assert!(req.stop.is_empty());
561        assert!(req.system.is_none());
562        assert!(req.max_tokens.is_none());
563        assert!(req.temperature.is_none());
564        assert!(req.response_format.is_none());
565        assert_eq!(req.tool_choice, ToolChoice::Auto);
566    }
567
568    #[test]
569    fn role_serializes_to_snake_case() {
570        assert_eq!(serde_json::to_string(&Role::User).unwrap(), r#""user""#);
571        assert_eq!(
572            serde_json::to_string(&Role::Assistant).unwrap(),
573            r#""assistant""#
574        );
575        assert_eq!(serde_json::to_string(&Role::System).unwrap(), r#""system""#);
576        assert_eq!(serde_json::to_string(&Role::Tool).unwrap(), r#""tool""#);
577    }
578
579    #[test]
580    fn role_round_trips() {
581        for role in [Role::User, Role::Assistant, Role::System, Role::Tool] {
582            let json = serde_json::to_string(&role).unwrap();
583            let back: Role = serde_json::from_str(&json).unwrap();
584            assert_eq!(back, role);
585        }
586    }
587
588    #[test]
589    fn tool_choice_unit_variants_serialize_as_strings() {
590        assert_eq!(
591            serde_json::to_string(&ToolChoice::Auto).unwrap(),
592            r#""auto""#
593        );
594        assert_eq!(
595            serde_json::to_string(&ToolChoice::None).unwrap(),
596            r#""none""#
597        );
598        assert_eq!(
599            serde_json::to_string(&ToolChoice::Required).unwrap(),
600            r#""required""#
601        );
602    }
603
604    #[test]
605    fn tool_choice_named_serializes_as_object() {
606        let tc = ToolChoice::Named("my_tool".to_owned());
607        let v: Value = serde_json::to_value(&tc).unwrap();
608        assert_eq!(v, json!({"named": "my_tool"}));
609    }
610
611    #[test]
612    fn tool_choice_round_trips() {
613        for tc in [
614            ToolChoice::Auto,
615            ToolChoice::None,
616            ToolChoice::Required,
617            ToolChoice::Named("search".to_owned()),
618        ] {
619            let json = serde_json::to_string(&tc).unwrap();
620            let back: ToolChoice = serde_json::from_str(&json).unwrap();
621            assert_eq!(back, tc);
622        }
623    }
624
625    #[test]
626    fn content_text_constructor() {
627        let c = Content::text("hello");
628        assert!(matches!(c, Content::Text(s) if s == "hello"));
629    }
630
631    #[test]
632    fn content_tool_use_constructor() {
633        let c = Content::tool_use("call-1", "search", r#"{"q":"rust"}"#);
634        match c {
635            Content::ToolUse(tu) => {
636                assert_eq!(tu.id, "call-1");
637                assert_eq!(tu.name, "search");
638                assert_eq!(tu.args_json, r#"{"q":"rust"}"#);
639            }
640            _ => panic!("wrong variant"),
641        }
642    }
643
644    #[test]
645    fn content_tool_result_constructor() {
646        let c = Content::tool_result("call-1", r#"{"result":"ok"}"#, false, true);
647        match c {
648            Content::ToolResult(tr) => {
649                assert_eq!(tr.tool_call_id, "call-1");
650                assert_eq!(tr.result_json, r#"{"result":"ok"}"#);
651                assert!(!tr.is_error);
652                assert!(tr.first_party);
653            }
654            _ => panic!("wrong variant"),
655        }
656    }
657
658    #[test]
659    fn content_image_constructor() {
660        let c = Content::image("https://example.com/img.png", Some("image/png".to_owned()));
661        match c {
662            Content::Image(img) => {
663                assert_eq!(img.url, "https://example.com/img.png");
664                assert_eq!(img.mime_type.as_deref(), Some("image/png"));
665            }
666            _ => panic!("wrong variant"),
667        }
668    }
669
670    #[test]
671    fn message_user_constructor() {
672        let m = Message::user("hi");
673        assert_eq!(m.role, Role::User);
674        assert_eq!(m.content.len(), 1);
675        assert!(matches!(&m.content[0], Content::Text(s) if s == "hi"));
676    }
677
678    #[test]
679    fn message_assistant_constructor() {
680        let m = Message::assistant("hello back");
681        assert_eq!(m.role, Role::Assistant);
682        assert_eq!(m.content.len(), 1);
683        assert!(matches!(&m.content[0], Content::Text(s) if s == "hello back"));
684    }
685
686    #[test]
687    fn message_system_constructor() {
688        let m = Message::system("You are helpful.");
689        assert_eq!(m.role, Role::System);
690        assert_eq!(m.content.len(), 1);
691        assert!(matches!(&m.content[0], Content::Text(_)));
692    }
693
694    #[test]
695    fn tool_use_args_json_preserved_as_opaque_string() {
696        let original = r#"{"nested":{"key":42},"arr":[1,2,3]}"#;
697        let c = Content::tool_use("id-42", "complex_tool", original);
698        let serialized = serde_json::to_string(&c).unwrap();
699        let back: Content = serde_json::from_str(&serialized).unwrap();
700        match back {
701            Content::ToolUse(tu) => assert_eq!(tu.args_json, original),
702            _ => panic!("wrong variant"),
703        }
704    }
705
706    #[test]
707    fn completion_request_round_trips_all_content_variants() {
708        let mut req = CompletionRequest::new("test-model");
709        req.system = Some("Be concise.".to_owned());
710        req.max_tokens = Some(256);
711        req.temperature = Some(0.7);
712        req.stop = vec!["<end>".to_owned()];
713        req.tool_choice = ToolChoice::Named("calculator".to_owned());
714        req.response_format = Some(JsonSchema(json!({"type": "object"})));
715        req.tools = vec![ToolSpec::new(
716            "calculator",
717            "Evaluates math expressions.",
718            json!({"type": "object", "properties": {"expr": {"type": "string"}}}),
719        )];
720        req.messages = vec![
721            Message::user("Compute 2+2"),
722            Message {
723                role: Role::Assistant,
724                content: vec![Content::tool_use(
725                    "call-1",
726                    "calculator",
727                    r#"{"expr":"2+2"}"#,
728                )],
729            },
730            Message {
731                role: Role::Tool,
732                content: vec![Content::tool_result(
733                    "call-1",
734                    r#"{"value":4}"#,
735                    false,
736                    true,
737                )],
738            },
739            Message {
740                role: Role::User,
741                content: vec![Content::image(
742                    "https://example.com/chart.png",
743                    Some("image/png".to_owned()),
744                )],
745            },
746        ];
747
748        let json_str = serde_json::to_string(&req).unwrap();
749        let back: CompletionRequest = serde_json::from_str(&json_str).unwrap();
750
751        assert_eq!(back.model, "test-model");
752        assert_eq!(back.system.as_deref(), Some("Be concise."));
753        assert_eq!(back.max_tokens, Some(256));
754        assert_eq!(back.messages.len(), 4);
755        assert_eq!(back.tools.len(), 1);
756        assert_eq!(back.tool_choice, ToolChoice::Named("calculator".to_owned()));
757    }
758
759    #[test]
760    fn cache_hint_defaults_to_none_and_round_trips_on_the_request() {
761        // A fresh request opts out of caching.
762        assert_eq!(CompletionRequest::new("m").cache, CacheHint::None);
763
764        // The stable-prefix hint (with a routing key) survives a serde round trip.
765        let mut req = CompletionRequest::new("m");
766        req.cache = CacheHint::StablePrefix {
767            key: Some("conv-7".to_owned()),
768        };
769        let json_str = serde_json::to_string(&req).unwrap();
770        let back: CompletionRequest = serde_json::from_str(&json_str).unwrap();
771        assert_eq!(
772            back.cache,
773            CacheHint::StablePrefix {
774                key: Some("conv-7".to_owned())
775            }
776        );
777    }
778
779    #[test]
780    fn cache_hint_round_trips_through_its_flat_key_form() {
781        // The single-string form the harness turn input carries: a non-empty
782        // key is a keyed stable-prefix hint, an empty key is no hint.
783        let keyed = CacheHint::StablePrefix {
784            key: Some("conv-9".to_owned()),
785        };
786        assert_eq!(keyed.key(), Some("conv-9"));
787        assert_eq!(CacheHint::from_key("conv-9".to_owned()), keyed);
788
789        assert_eq!(CacheHint::None.key(), None);
790        assert_eq!(CacheHint::from_key(String::new()), CacheHint::None);
791
792        // The one lossy case, by design: a KEYLESS stable-prefix hint has no
793        // flat form (the control plane always keys by conversation).
794        assert_eq!((CacheHint::StablePrefix { key: None }).key(), None);
795    }
796
797    #[test]
798    fn cache_hint_snake_case_wire_shape() {
799        let hint = CacheHint::StablePrefix { key: None };
800        let v: Value = serde_json::to_value(&hint).unwrap();
801        assert_eq!(v, json!({"stable_prefix": {"key": null}}));
802        assert_eq!(
803            serde_json::to_value(CacheHint::None).unwrap(),
804            json!("none")
805        );
806    }
807
808    #[test]
809    fn json_schema_serializes_transparently() {
810        let schema = JsonSchema(json!({"type": "object", "required": ["name"]}));
811        let v: Value = serde_json::to_value(&schema).unwrap();
812        assert_eq!(v["type"], "object");
813        assert_eq!(v["required"][0], "name");
814    }
815
816    #[test]
817    fn json_schema_round_trips() {
818        let inner = json!({"type": "string", "maxLength": 100});
819        let schema = JsonSchema(inner.clone());
820        let json_str = serde_json::to_string(&schema).unwrap();
821        let back: JsonSchema = serde_json::from_str(&json_str).unwrap();
822        assert_eq!(back.0, inner);
823    }
824
825    #[test]
826    fn image_ref_default_is_sensible() {
827        let img = ImageRef::default();
828        assert!(img.url.is_empty());
829        assert!(img.mime_type.is_none());
830    }
831
832    #[test]
833    fn tool_spec_carries_optional_title() {
834        let spec = ToolSpec::new("paid_fetch", "d", json!({})).titled("Pay for & fetch a web page");
835        assert_eq!(spec.title.as_deref(), Some("Pay for & fetch a web page"));
836    }
837
838    #[test]
839    fn tool_spec_carries_needs_approval_flag() {
840        let spec = ToolSpec::new("delete_file", "d", json!({})).approval_required();
841        assert!(spec.needs_approval);
842    }
843
844    /// `needs_approval` is `skip_serializing_if` false, so a non-gated spec
845    /// omits the field on the wire; deserialization must read that absence back
846    /// as `false` (the `#[serde(default)]` counterpart).
847    #[test]
848    fn tool_spec_needs_approval_defaults_false_on_deserialize() {
849        let payload = json!({
850            "name": "calculator",
851            "description": "math",
852            "schema_json": {"type": "object"}
853        });
854        let spec: ToolSpec = serde_json::from_value(payload).unwrap();
855        assert!(
856            !spec.needs_approval,
857            "omitted needs_approval must default to false"
858        );
859    }
860
861    /// `#743`: the shared gated-tool note is a complete, plain sentence that
862    /// never leaks internal jargon or apology words — the same banned-word
863    /// list every user-facing string in the project is checked against.
864    #[test]
865    fn gated_tool_approval_note_is_clean_user_facing_copy() {
866        let lower = GATED_TOOL_APPROVAL_NOTE.to_lowercase();
867        for banned in [
868            "please",
869            "sorry",
870            "unfortunately",
871            "operator",
872            "sub-agent",
873            "lethal-trifecta",
874            "state-changing action",
875        ] {
876            assert!(
877                !lower.contains(banned),
878                "gated-tool note leaked banned word {banned:?}: {GATED_TOOL_APPROVAL_NOTE}"
879            );
880        }
881        assert!(
882            GATED_TOOL_APPROVAL_NOTE.contains("pauses"),
883            "note must say the call pauses, not that it ran"
884        );
885        assert!(
886            GATED_TOOL_APPROVAL_NOTE.contains("already run"),
887            "note must say a result means the tool already ran"
888        );
889    }
890}