Skip to main content

runifold_model/
request.rs

1use std::collections::BTreeMap;
2
3use schemars::{JsonSchema, schema_for};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::{ExtensionMap, Message};
8
9const PROVIDER_TOOLS_METADATA_KEY: &str = "runifold.request.provider_tools.v1";
10const RESPONSE_MODE_METADATA_KEY: &str = "runifold.request.response_mode.v1";
11
12/// A provider-qualified model identity.
13#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
14pub struct ModelRef {
15    /// Provider namespace.
16    pub provider: String,
17    /// Provider model name.
18    pub name: String,
19}
20
21impl ModelRef {
22    /// Creates a model reference.
23    pub fn new(provider: impl Into<String>, name: impl Into<String>) -> Self {
24        Self {
25            provider: provider.into(),
26            name: name.into(),
27        }
28    }
29}
30
31/// Behavior when a requested feature is not natively supported.
32#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
33#[serde(rename_all = "snake_case")]
34#[non_exhaustive]
35pub enum FeaturePolicy {
36    /// Reject unsupported, unknown, or emulated features.
37    #[default]
38    Strict,
39    /// Permit documented emulation but reject ignored features.
40    AllowEmulation,
41    /// Permit degradation when it is reported as a warning.
42    BestEffort,
43}
44
45/// Sampling and output-length options common to providers.
46#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
47pub struct GenerationOptions {
48    /// Sampling temperature.
49    pub temperature: Option<f64>,
50    /// Nucleus-sampling probability.
51    pub top_p: Option<f64>,
52    /// Maximum output tokens.
53    pub max_output_tokens: Option<u64>,
54    /// Optional deterministic seed.
55    pub seed: Option<u64>,
56    /// Stop sequences.
57    pub stop: Vec<String>,
58}
59
60/// How the provider should deliver a model response.
61#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
62#[serde(rename_all = "snake_case")]
63#[non_exhaustive]
64pub enum ResponseMode {
65    /// Deliver incremental events when the provider supports streaming.
66    #[default]
67    Streaming,
68    /// Request one complete provider response and normalize it into events.
69    Complete,
70}
71
72/// Desired final-output format.
73#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
74#[serde(tag = "type", rename_all = "snake_case")]
75#[non_exhaustive]
76pub enum OutputFormat {
77    /// Unconstrained text.
78    #[default]
79    Text,
80    /// Any valid JSON value.
81    Json,
82    /// JSON constrained by a schema.
83    JsonSchema {
84        /// Schema name sent to providers that require one.
85        name: String,
86        /// JSON Schema.
87        schema: Value,
88        /// Whether the provider should enforce its strictest mode.
89        strict: bool,
90    },
91}
92
93impl OutputFormat {
94    /// Builds a strict JSON-schema format from a Rust type.
95    ///
96    /// Provider enforcement is only one boundary. Callers should still decode
97    /// the response locally with [`crate::ModelResponse::structured`].
98    pub fn typed<T>(name: impl Into<String>) -> Self
99    where
100        T: JsonSchema,
101    {
102        Self::JsonSchema {
103            name: name.into(),
104            schema: schema_for!(T).to_value(),
105            strict: true,
106        }
107    }
108
109    /// Builds a JSON-schema format from a Rust type with explicit provider
110    /// strictness.
111    pub fn typed_with_strictness<T>(name: impl Into<String>, strict: bool) -> Self
112    where
113        T: JsonSchema,
114    {
115        Self::JsonSchema {
116            name: name.into(),
117            schema: schema_for!(T).to_value(),
118            strict,
119        }
120    }
121}
122
123/// A model-facing tool definition.
124#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
125pub struct ToolSpec {
126    /// Tool name.
127    pub name: String,
128    /// Model-facing description.
129    pub description: String,
130    /// JSON Schema for arguments.
131    pub input_schema: Value,
132    /// Optional JSON Schema for results.
133    pub output_schema: Option<Value>,
134    /// Namespaced metadata not automatically exposed to a provider.
135    pub metadata: ExtensionMap,
136}
137
138/// A provider-native hosted tool that has no lossless canonical function-tool form.
139///
140/// Adapters only consume entries matching their provider namespace. The `options`
141/// object contains fields beside the wire-level `type`, which is owned by
142/// `tool_type` and cannot be overridden.
143#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
144pub struct ProviderToolSpec {
145    /// Provider namespace, such as `ark` or `openai`.
146    pub provider: String,
147    /// Provider wire-level tool type, such as `web_search`.
148    pub tool_type: String,
149    /// Provider-specific tool configuration excluding `type`.
150    pub options: BTreeMap<String, Value>,
151}
152
153impl ProviderToolSpec {
154    /// Creates a provider-native tool definition.
155    ///
156    /// # Errors
157    ///
158    /// Returns [`crate::ModelErrorKind::InvalidRequest`] for blank or unsafe
159    /// provider and tool-type tokens.
160    pub fn new(
161        provider: impl Into<String>,
162        tool_type: impl Into<String>,
163    ) -> Result<Self, crate::ModelError> {
164        let provider = provider.into();
165        let tool_type = tool_type.into();
166        if !is_provider_token(&provider) {
167            return Err(crate::ModelError::local(
168                crate::ModelErrorKind::InvalidRequest,
169                "provider-native tool provider must be a non-empty ASCII token",
170            ));
171        }
172        if !is_provider_token(&tool_type) {
173            return Err(crate::ModelError::local(
174                crate::ModelErrorKind::InvalidRequest,
175                "provider-native tool type must be a non-empty ASCII token",
176            ));
177        }
178        Ok(Self {
179            provider,
180            tool_type,
181            options: BTreeMap::new(),
182        })
183    }
184
185    /// Adds one provider-specific option.
186    #[must_use]
187    pub fn option(mut self, name: impl Into<String>, value: impl Into<Value>) -> Self {
188        self.options.insert(name.into(), value.into());
189        self
190    }
191}
192
193fn is_provider_token(value: &str) -> bool {
194    !value.is_empty()
195        && value.len() <= 128
196        && value
197            .bytes()
198            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
199}
200
201/// How a model may select tools.
202#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
203#[serde(tag = "type", rename_all = "snake_case")]
204#[non_exhaustive]
205pub enum ToolChoice {
206    /// The model decides whether to call a tool.
207    #[default]
208    Auto,
209    /// The model must not call tools.
210    None,
211    /// The model must call at least one tool.
212    Required,
213    /// The model must call a named tool.
214    Named {
215        /// Required tool name.
216        name: String,
217    },
218}
219
220/// A complete provider-neutral model request.
221#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
222pub struct ModelRequest {
223    /// Selected model.
224    pub model: ModelRef,
225    /// Ordered conversation messages.
226    pub messages: Vec<Message>,
227    /// Model-facing tools.
228    pub tools: Vec<ToolSpec>,
229    /// Tool-selection behavior.
230    pub tool_choice: ToolChoice,
231    /// Desired final-output format.
232    pub output_format: OutputFormat,
233    /// Common generation options.
234    pub generation: GenerationOptions,
235    /// Feature-degradation behavior.
236    pub feature_policy: FeaturePolicy,
237    /// Typed adapters serialize options into their provider namespace.
238    pub provider_options: BTreeMap<String, Value>,
239    /// Host-only namespaced metadata.
240    pub metadata: ExtensionMap,
241}
242
243impl ModelRequest {
244    /// Creates a request with one initial message.
245    pub fn new(model: ModelRef, message: Message) -> Self {
246        Self {
247            model,
248            messages: vec![message],
249            tools: Vec::new(),
250            tool_choice: ToolChoice::Auto,
251            output_format: OutputFormat::Text,
252            generation: GenerationOptions::default(),
253            feature_policy: FeaturePolicy::Strict,
254            provider_options: BTreeMap::new(),
255            metadata: BTreeMap::new(),
256        }
257    }
258
259    /// Appends a conversation message.
260    #[must_use]
261    pub fn message(mut self, message: Message) -> Self {
262        self.messages.push(message);
263        self
264    }
265
266    /// Adds a model-facing tool.
267    #[must_use]
268    pub fn tool(mut self, tool: ToolSpec) -> Self {
269        self.tools.push(tool);
270        self
271    }
272
273    /// Adds a provider-hosted tool.
274    #[must_use]
275    pub fn provider_tool(mut self, tool: ProviderToolSpec) -> Self {
276        let mut tools = self.provider_tools();
277        tools.push(tool);
278        self.metadata.insert(
279            PROVIDER_TOOLS_METADATA_KEY.into(),
280            Value::Array(tools.into_iter().map(provider_tool_value).collect()),
281        );
282        self
283    }
284
285    /// Returns provider-hosted tools separately from application function tools.
286    #[must_use]
287    pub fn provider_tools(&self) -> Vec<ProviderToolSpec> {
288        self.metadata
289            .get(PROVIDER_TOOLS_METADATA_KEY)
290            .and_then(|value| serde_json::from_value(value.clone()).ok())
291            .unwrap_or_default()
292    }
293
294    /// Replaces common generation controls.
295    #[must_use]
296    pub fn generation(mut self, generation: GenerationOptions) -> Self {
297        self.generation = generation;
298        self
299    }
300
301    /// Sets response delivery behavior.
302    #[must_use]
303    pub fn response_mode(mut self, response_mode: ResponseMode) -> Self {
304        let value = match response_mode {
305            ResponseMode::Streaming => "streaming",
306            ResponseMode::Complete => "complete",
307        };
308        self.metadata.insert(
309            RESPONSE_MODE_METADATA_KEY.into(),
310            Value::String(value.into()),
311        );
312        self
313    }
314
315    /// Returns the requested response delivery mode.
316    #[must_use]
317    pub fn selected_response_mode(&self) -> ResponseMode {
318        match self
319            .metadata
320            .get(RESPONSE_MODE_METADATA_KEY)
321            .and_then(Value::as_str)
322        {
323            Some("complete") => ResponseMode::Complete,
324            _ => ResponseMode::Streaming,
325        }
326    }
327
328    /// Adds an adapter-owned namespaced option object.
329    #[must_use]
330    pub fn provider_option(mut self, provider: impl Into<String>, options: Value) -> Self {
331        self.provider_options.insert(provider.into(), options);
332        self
333    }
334
335    /// Sets the desired output format.
336    #[must_use]
337    pub fn output_format(mut self, output_format: OutputFormat) -> Self {
338        self.output_format = output_format;
339        self
340    }
341
342    /// Requests strict structured output described by the Rust type `T`.
343    #[must_use]
344    pub fn structured_output<T>(self, name: impl Into<String>) -> Self
345    where
346        T: JsonSchema,
347    {
348        self.output_format(OutputFormat::typed::<T>(name))
349    }
350
351    /// Sets the feature-degradation policy.
352    #[must_use]
353    pub const fn feature_policy(mut self, feature_policy: FeaturePolicy) -> Self {
354        self.feature_policy = feature_policy;
355        self
356    }
357}
358
359fn provider_tool_value(tool: ProviderToolSpec) -> Value {
360    Value::Object(
361        [
362            ("provider".into(), Value::String(tool.provider)),
363            ("tool_type".into(), Value::String(tool.tool_type)),
364            (
365                "options".into(),
366                Value::Object(tool.options.into_iter().collect()),
367            ),
368        ]
369        .into_iter()
370        .collect(),
371    )
372}