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}
47
48impl CompletionRequest {
49    /// Creates a new request for the given `model` with sensible defaults:
50    /// empty `messages`, `tools`, and `stop` lists; `tool_choice` set to
51    /// [`ToolChoice::Auto`]; all optional fields `None`.
52    #[must_use]
53    pub fn new(model: impl Into<String>) -> Self {
54        Self {
55            model: model.into(),
56            system: None,
57            messages: Vec::new(),
58            tools: Vec::new(),
59            tool_choice: ToolChoice::Auto,
60            response_format: None,
61            max_tokens: None,
62            temperature: None,
63            stop: Vec::new(),
64            web_search: false,
65        }
66    }
67}
68
69// ── Message ───────────────────────────────────────────────────────────────────
70
71/// A single turn in a conversation, composed of one or more [`Content`] parts.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct Message {
74    /// The participant that produced this message.
75    pub role: Role,
76    /// Ordered content blocks that make up the message body.
77    pub content: Vec<Content>,
78}
79
80impl Message {
81    /// Creates a [`Role::User`] message with a single [`Content::Text`] block.
82    #[must_use]
83    pub fn user(text: impl Into<String>) -> Self {
84        Self {
85            role: Role::User,
86            content: vec![Content::Text(text.into())],
87        }
88    }
89
90    /// Creates a [`Role::Assistant`] message with a single [`Content::Text`] block.
91    #[must_use]
92    pub fn assistant(text: impl Into<String>) -> Self {
93        Self {
94            role: Role::Assistant,
95            content: vec![Content::Text(text.into())],
96        }
97    }
98
99    /// Creates a [`Role::System`] message with a single [`Content::Text`] block.
100    #[must_use]
101    pub fn system(text: impl Into<String>) -> Self {
102        Self {
103            role: Role::System,
104            content: vec![Content::Text(text.into())],
105        }
106    }
107}
108
109// ── Role ──────────────────────────────────────────────────────────────────────
110
111/// The participant role for a [`Message`].
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114#[non_exhaustive]
115pub enum Role {
116    /// A human turn.
117    User,
118    /// A model-generated turn.
119    Assistant,
120    /// A system-level instruction (not all providers support this as a role).
121    System,
122    /// A tool-result turn injected back into the conversation.
123    Tool,
124}
125
126// ── Content ───────────────────────────────────────────────────────────────────
127
128/// A single content block within a [`Message`].
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131#[non_exhaustive]
132pub enum Content {
133    /// Plain text.
134    Text(String),
135    /// A tool invocation emitted by the model.
136    ToolUse(ToolCall),
137    /// The result of a prior [`Content::ToolUse`], fed back to the model.
138    ToolResult(ToolResult),
139    /// A reference to an image (HTTP URL or `data:` URI).
140    Image(ImageRef),
141}
142
143impl Content {
144    /// Wraps `s` in a [`Content::Text`] variant.
145    #[must_use]
146    pub fn text(s: impl Into<String>) -> Self {
147        Self::Text(s.into())
148    }
149
150    /// Constructs a [`Content::ToolUse`] block.
151    #[must_use]
152    pub fn tool_use(
153        id: impl Into<String>,
154        name: impl Into<String>,
155        args_json: impl Into<String>,
156    ) -> Self {
157        Self::ToolUse(ToolCall {
158            id: id.into(),
159            name: name.into(),
160            args_json: args_json.into(),
161            signature: None,
162        })
163    }
164
165    /// Constructs a [`Content::ToolUse`] block carrying an opaque
166    /// provider-specific `signature` (e.g. a thinking model's thought
167    /// signature, which some providers require echoed back on the next
168    /// request that includes this call).
169    #[must_use]
170    pub fn tool_use_signed(
171        id: impl Into<String>,
172        name: impl Into<String>,
173        args_json: impl Into<String>,
174        signature: Option<String>,
175    ) -> Self {
176        Self::ToolUse(ToolCall {
177            id: id.into(),
178            name: name.into(),
179            args_json: args_json.into(),
180            signature,
181        })
182    }
183
184    /// Constructs a [`Content::ToolResult`] block.
185    #[must_use]
186    pub fn tool_result(
187        tool_call_id: impl Into<String>,
188        result_json: impl Into<String>,
189        is_error: bool,
190    ) -> Self {
191        Self::ToolResult(ToolResult {
192            tool_call_id: tool_call_id.into(),
193            result_json: result_json.into(),
194            is_error,
195        })
196    }
197
198    /// Constructs a [`Content::Image`] block.
199    #[must_use]
200    pub fn image(url: impl Into<String>, mime_type: Option<String>) -> Self {
201        Self::Image(ImageRef {
202            url: url.into(),
203            mime_type,
204        })
205    }
206}
207
208// ── ToolCall ──────────────────────────────────────────────────────────────────
209
210/// A tool call emitted by the model inside an assistant [`Message`].
211///
212/// Mirrors the wire-side `polychrome.agent.v1.ToolCall`; surfaced inside a
213/// [`Content::ToolUse`] block.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct ToolCall {
216    /// Provider-assigned call identifier, used to correlate with [`ToolResult`].
217    pub id: String,
218    /// Name of the tool being called.
219    pub name: String,
220    /// Arguments serialized as a JSON string (opaque at this layer).
221    pub args_json: String,
222    /// Opaque, provider-specific signature attached to this call (e.g. a
223    /// thinking model's thought signature). Some providers require it to be
224    /// echoed back verbatim on the follow-up request that carries this call
225    /// in the history; `None` when the provider emits no such token.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub signature: Option<String>,
228}
229
230// ── ToolResult ────────────────────────────────────────────────────────────────
231
232/// The result of executing a tool, fed back to the model as a [`Content`] block.
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct ToolResult {
235    /// Matches the [`ToolCall::id`] this result corresponds to.
236    pub tool_call_id: String,
237    /// Serialized JSON payload returned by the tool executor.
238    pub result_json: String,
239    /// `true` when the tool raised an error rather than producing output.
240    pub is_error: bool,
241}
242
243// ── ImageRef ──────────────────────────────────────────────────────────────────
244
245/// A reference to an image attached to a [`Message`].
246#[derive(Debug, Clone, Default, Serialize, Deserialize)]
247pub struct ImageRef {
248    /// HTTP URL or `data:` URI for the image bytes.
249    pub url: String,
250    /// Optional MIME type hint (e.g. `"image/png"`).
251    pub mime_type: Option<String>,
252}
253
254// ── ToolSpec ──────────────────────────────────────────────────────────────────
255
256/// Declaration of a tool the model may invoke.
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct ToolSpec {
259    /// Unique tool name; the model references this when emitting a [`ToolCall`].
260    pub name: String,
261    /// Human-readable description of what the tool does.
262    pub description: String,
263    /// JSON Schema object describing the tool's argument shape.
264    pub schema_json: serde_json::Value,
265    /// MCP-style human display name for this tool (the `title` annotation):
266    /// a friendly label shown to people (e.g. in an approval prompt) while the
267    /// machine-facing [`name`](ToolSpec::name) stays the audit identifier.
268    ///
269    /// `None` means no curated label was provided; callers derive a display
270    /// name from [`name`](ToolSpec::name) via [`humanize_tool_name`].
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub title: Option<String>,
273    /// Intrinsic "this tool is side-effecting / requires human approval" flag.
274    ///
275    /// When `true` the tool must be routed through the harness's
276    /// human-in-the-loop (HITL) approval gate before it executes, even when no
277    /// operator-side allow-list names it. Pure, read-only tools leave this
278    /// `false`.
279    ///
280    /// This is the per-tool generalization of the old hard-coded
281    /// approval-by-name list: it maps from the MCP `destructiveHint` tool
282    /// annotation, so an upstream connector that advertises a destructive tool
283    /// is gated per-tool rather than per-connector.
284    ///
285    /// Defaults to `false` and is skipped when serializing the safe default, so
286    /// older payloads that omit the field still deserialize as ungated.
287    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
288    pub needs_approval: bool,
289}
290
291/// Derives a human display name from a machine tool `name`.
292///
293/// Splits on `_`/`-`/whitespace, lowercases each token, then capitalizes the
294/// first letter of the first word: `paid_fetch` → `"Paid fetch"`,
295/// `delete-file` → `"Delete file"`, `calculator` → `"Calculator"`. Input that
296/// is already spaced is normalized the same way (`"delete file"` →
297/// `"Delete file"`). An empty input yields an empty string.
298#[must_use]
299pub fn humanize_tool_name(name: &str) -> String {
300    let words: Vec<&str> = name
301        .split(|c: char| c == '_' || c == '-' || c.is_whitespace())
302        .filter(|w| !w.is_empty())
303        .collect();
304    if words.is_empty() {
305        return String::new();
306    }
307    let mut out = String::new();
308    for (i, word) in words.iter().enumerate() {
309        if i > 0 {
310            out.push(' ');
311        }
312        let lower = word.to_lowercase();
313        if i == 0 {
314            let mut chars = lower.chars();
315            if let Some(first) = chars.next() {
316                out.extend(first.to_uppercase());
317                out.push_str(chars.as_str());
318            }
319        } else {
320            out.push_str(&lower);
321        }
322    }
323    out
324}
325
326// ── ToolChoice ────────────────────────────────────────────────────────────────
327
328/// Controls whether and how the model calls tools.
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330#[serde(rename_all = "snake_case")]
331#[non_exhaustive]
332pub enum ToolChoice {
333    /// The model decides whether to call a tool (default).
334    Auto,
335    /// The model must not call any tool.
336    None,
337    /// The model must call at least one tool.
338    Required,
339    /// Force the model to call the named tool.
340    Named(String),
341}
342
343// ── JsonSchema ────────────────────────────────────────────────────────────────
344
345/// Wrapper for a response-format JSON Schema.
346///
347/// Instructs the provider to return structured output conforming to the schema.
348/// Serializes transparently as the inner [`serde_json::Value`].
349#[derive(Debug, Clone, Serialize, Deserialize)]
350#[serde(transparent)]
351pub struct JsonSchema(
352    /// The raw JSON Schema value.
353    pub serde_json::Value,
354);
355
356// ── Tests ─────────────────────────────────────────────────────────────────────
357
358#[cfg(test)]
359mod tests {
360    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
361
362    use serde_json::{Value, json};
363
364    use super::*;
365
366    #[test]
367    fn new_sets_model_and_defaults() {
368        let req = CompletionRequest::new("fast-2");
369        assert_eq!(req.model, "fast-2");
370        assert!(req.messages.is_empty());
371        assert!(req.tools.is_empty());
372        assert!(req.stop.is_empty());
373        assert!(req.system.is_none());
374        assert!(req.max_tokens.is_none());
375        assert!(req.temperature.is_none());
376        assert!(req.response_format.is_none());
377        assert_eq!(req.tool_choice, ToolChoice::Auto);
378    }
379
380    #[test]
381    fn role_serializes_to_snake_case() {
382        assert_eq!(serde_json::to_string(&Role::User).unwrap(), r#""user""#);
383        assert_eq!(
384            serde_json::to_string(&Role::Assistant).unwrap(),
385            r#""assistant""#
386        );
387        assert_eq!(serde_json::to_string(&Role::System).unwrap(), r#""system""#);
388        assert_eq!(serde_json::to_string(&Role::Tool).unwrap(), r#""tool""#);
389    }
390
391    #[test]
392    fn role_round_trips() {
393        for role in [Role::User, Role::Assistant, Role::System, Role::Tool] {
394            let json = serde_json::to_string(&role).unwrap();
395            let back: Role = serde_json::from_str(&json).unwrap();
396            assert_eq!(back, role);
397        }
398    }
399
400    #[test]
401    fn tool_choice_unit_variants_serialize_as_strings() {
402        assert_eq!(
403            serde_json::to_string(&ToolChoice::Auto).unwrap(),
404            r#""auto""#
405        );
406        assert_eq!(
407            serde_json::to_string(&ToolChoice::None).unwrap(),
408            r#""none""#
409        );
410        assert_eq!(
411            serde_json::to_string(&ToolChoice::Required).unwrap(),
412            r#""required""#
413        );
414    }
415
416    #[test]
417    fn tool_choice_named_serializes_as_object() {
418        let tc = ToolChoice::Named("my_tool".to_owned());
419        let v: Value = serde_json::to_value(&tc).unwrap();
420        assert_eq!(v, json!({"named": "my_tool"}));
421    }
422
423    #[test]
424    fn tool_choice_round_trips() {
425        for tc in [
426            ToolChoice::Auto,
427            ToolChoice::None,
428            ToolChoice::Required,
429            ToolChoice::Named("search".to_owned()),
430        ] {
431            let json = serde_json::to_string(&tc).unwrap();
432            let back: ToolChoice = serde_json::from_str(&json).unwrap();
433            assert_eq!(back, tc);
434        }
435    }
436
437    #[test]
438    fn content_text_constructor() {
439        let c = Content::text("hello");
440        assert!(matches!(c, Content::Text(s) if s == "hello"));
441    }
442
443    #[test]
444    fn content_tool_use_constructor() {
445        let c = Content::tool_use("call-1", "search", r#"{"q":"rust"}"#);
446        match c {
447            Content::ToolUse(tu) => {
448                assert_eq!(tu.id, "call-1");
449                assert_eq!(tu.name, "search");
450                assert_eq!(tu.args_json, r#"{"q":"rust"}"#);
451            }
452            _ => panic!("wrong variant"),
453        }
454    }
455
456    #[test]
457    fn content_tool_result_constructor() {
458        let c = Content::tool_result("call-1", r#"{"result":"ok"}"#, false);
459        match c {
460            Content::ToolResult(tr) => {
461                assert_eq!(tr.tool_call_id, "call-1");
462                assert_eq!(tr.result_json, r#"{"result":"ok"}"#);
463                assert!(!tr.is_error);
464            }
465            _ => panic!("wrong variant"),
466        }
467    }
468
469    #[test]
470    fn content_image_constructor() {
471        let c = Content::image("https://example.com/img.png", Some("image/png".to_owned()));
472        match c {
473            Content::Image(img) => {
474                assert_eq!(img.url, "https://example.com/img.png");
475                assert_eq!(img.mime_type.as_deref(), Some("image/png"));
476            }
477            _ => panic!("wrong variant"),
478        }
479    }
480
481    #[test]
482    fn message_user_constructor() {
483        let m = Message::user("hi");
484        assert_eq!(m.role, Role::User);
485        assert_eq!(m.content.len(), 1);
486        assert!(matches!(&m.content[0], Content::Text(s) if s == "hi"));
487    }
488
489    #[test]
490    fn message_assistant_constructor() {
491        let m = Message::assistant("hello back");
492        assert_eq!(m.role, Role::Assistant);
493        assert_eq!(m.content.len(), 1);
494        assert!(matches!(&m.content[0], Content::Text(s) if s == "hello back"));
495    }
496
497    #[test]
498    fn message_system_constructor() {
499        let m = Message::system("You are helpful.");
500        assert_eq!(m.role, Role::System);
501        assert_eq!(m.content.len(), 1);
502        assert!(matches!(&m.content[0], Content::Text(_)));
503    }
504
505    #[test]
506    fn tool_use_args_json_preserved_as_opaque_string() {
507        let original = r#"{"nested":{"key":42},"arr":[1,2,3]}"#;
508        let c = Content::tool_use("id-42", "complex_tool", original);
509        let serialized = serde_json::to_string(&c).unwrap();
510        let back: Content = serde_json::from_str(&serialized).unwrap();
511        match back {
512            Content::ToolUse(tu) => assert_eq!(tu.args_json, original),
513            _ => panic!("wrong variant"),
514        }
515    }
516
517    #[test]
518    fn completion_request_round_trips_all_content_variants() {
519        let mut req = CompletionRequest::new("test-model");
520        req.system = Some("Be concise.".to_owned());
521        req.max_tokens = Some(256);
522        req.temperature = Some(0.7);
523        req.stop = vec!["<end>".to_owned()];
524        req.tool_choice = ToolChoice::Named("calculator".to_owned());
525        req.response_format = Some(JsonSchema(json!({"type": "object"})));
526        req.tools = vec![ToolSpec {
527            name: "calculator".to_owned(),
528            description: "Evaluates math expressions.".to_owned(),
529            schema_json: json!({"type": "object", "properties": {"expr": {"type": "string"}}}),
530            title: None,
531            needs_approval: false,
532        }];
533        req.messages = vec![
534            Message::user("Compute 2+2"),
535            Message {
536                role: Role::Assistant,
537                content: vec![Content::tool_use(
538                    "call-1",
539                    "calculator",
540                    r#"{"expr":"2+2"}"#,
541                )],
542            },
543            Message {
544                role: Role::Tool,
545                content: vec![Content::tool_result("call-1", r#"{"value":4}"#, false)],
546            },
547            Message {
548                role: Role::User,
549                content: vec![Content::image(
550                    "https://example.com/chart.png",
551                    Some("image/png".to_owned()),
552                )],
553            },
554        ];
555
556        let json_str = serde_json::to_string(&req).unwrap();
557        let back: CompletionRequest = serde_json::from_str(&json_str).unwrap();
558
559        assert_eq!(back.model, "test-model");
560        assert_eq!(back.system.as_deref(), Some("Be concise."));
561        assert_eq!(back.max_tokens, Some(256));
562        assert_eq!(back.messages.len(), 4);
563        assert_eq!(back.tools.len(), 1);
564        assert_eq!(back.tool_choice, ToolChoice::Named("calculator".to_owned()));
565    }
566
567    #[test]
568    fn json_schema_serializes_transparently() {
569        let schema = JsonSchema(json!({"type": "object", "required": ["name"]}));
570        let v: Value = serde_json::to_value(&schema).unwrap();
571        assert_eq!(v["type"], "object");
572        assert_eq!(v["required"][0], "name");
573    }
574
575    #[test]
576    fn json_schema_round_trips() {
577        let inner = json!({"type": "string", "maxLength": 100});
578        let schema = JsonSchema(inner.clone());
579        let json_str = serde_json::to_string(&schema).unwrap();
580        let back: JsonSchema = serde_json::from_str(&json_str).unwrap();
581        assert_eq!(back.0, inner);
582    }
583
584    #[test]
585    fn image_ref_default_is_sensible() {
586        let img = ImageRef::default();
587        assert!(img.url.is_empty());
588        assert!(img.mime_type.is_none());
589    }
590
591    #[test]
592    fn humanize_snake_case() {
593        assert_eq!(humanize_tool_name("paid_fetch"), "Paid fetch");
594        assert_eq!(humanize_tool_name("delete_file"), "Delete file");
595    }
596
597    #[test]
598    fn humanize_kebab_case() {
599        assert_eq!(humanize_tool_name("delete-file"), "Delete file");
600    }
601
602    #[test]
603    fn humanize_single_word() {
604        assert_eq!(humanize_tool_name("calculator"), "Calculator");
605    }
606
607    #[test]
608    fn humanize_empty() {
609        assert_eq!(humanize_tool_name(""), "");
610    }
611
612    #[test]
613    fn humanize_already_spaced_passes_through() {
614        assert_eq!(humanize_tool_name("Pay for a page"), "Pay for a page");
615        assert_eq!(humanize_tool_name("delete file"), "Delete file");
616    }
617
618    #[test]
619    fn tool_spec_carries_optional_title() {
620        let spec = ToolSpec {
621            name: "paid_fetch".to_owned(),
622            description: "d".to_owned(),
623            schema_json: json!({}),
624            title: Some("Pay for & fetch a web page".to_owned()),
625            needs_approval: false,
626        };
627        assert_eq!(spec.title.as_deref(), Some("Pay for & fetch a web page"));
628    }
629
630    #[test]
631    fn tool_spec_carries_needs_approval_flag() {
632        let spec = ToolSpec {
633            name: "delete_file".to_owned(),
634            description: "d".to_owned(),
635            schema_json: json!({}),
636            title: None,
637            needs_approval: true,
638        };
639        assert!(spec.needs_approval);
640    }
641
642    /// `needs_approval` is `skip_serializing_if` false, so a non-gated spec
643    /// omits the field on the wire; deserialization must read that absence back
644    /// as `false` (the `#[serde(default)]` counterpart).
645    #[test]
646    fn tool_spec_needs_approval_defaults_false_on_deserialize() {
647        let payload = json!({
648            "name": "calculator",
649            "description": "math",
650            "schema_json": {"type": "object"}
651        });
652        let spec: ToolSpec = serde_json::from_value(payload).unwrap();
653        assert!(
654            !spec.needs_approval,
655            "omitted needs_approval must default to false"
656        );
657    }
658}