Skip to main content

harn_vm/llm/managed_supply/
provider_wire.rs

1//! Closed OpenAI-compatible chat contract for managed provider supply.
2//!
3//! Harn owns the provider protocol. Hosted envelopes may add tenancy, budget,
4//! and audit fields around this contract, but those fields never enter the
5//! physical provider request produced here.
6
7use serde::{Deserialize, Serialize};
8
9use super::ManagedSupplyContractError;
10
11#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum HostedRole {
14    System,
15    Developer,
16    User,
17    Assistant,
18    Tool,
19}
20
21/// OpenAI-compatible message content is either text, a closed content-part
22/// array, or null on an assistant message that contains tool calls.
23#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(untagged)]
25pub enum HostedContent {
26    Text(String),
27    Parts(Vec<HostedContentPart>),
28    Null,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
33pub enum HostedContentPart {
34    Text { text: String },
35    ImageUrl { image_url: HostedImageUrl },
36    InputAudio { input_audio: HostedInputAudio },
37}
38
39#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct HostedImageUrl {
42    pub url: String,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub detail: Option<HostedImageDetail>,
45}
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum HostedImageDetail {
50    Auto,
51    Low,
52    High,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct HostedInputAudio {
58    pub data: String,
59    pub format: HostedAudioFormat,
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum HostedAudioFormat {
65    Wav,
66    Mp3,
67}
68
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct HostedToolCallFunction {
72    pub name: String,
73    pub arguments: String,
74}
75
76#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct HostedToolCall {
79    pub id: String,
80    #[serde(rename = "type")]
81    pub kind: HostedToolKind,
82    pub function: HostedToolCallFunction,
83}
84
85#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum HostedToolKind {
88    Function,
89}
90
91#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct HostedChatMessage {
94    pub role: HostedRole,
95    pub content: HostedContent,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub name: Option<String>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub tool_call_id: Option<String>,
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub tool_calls: Vec<HostedToolCall>,
102    /// Provider reasoning round-trip payload. This is the only known
103    /// provider extension Harn deliberately carries through message history.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub reasoning_content: Option<String>,
106}
107
108#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct HostedFunctionDefinition {
111    pub name: String,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub description: Option<String>,
114    /// JSON Schema is recursively open by definition. Construction validates
115    /// that this value is an object before any provider request is emitted.
116    pub parameters: serde_json::Value,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub strict: Option<bool>,
119}
120
121#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct HostedFunctionTool {
124    #[serde(rename = "type")]
125    pub kind: HostedToolKind,
126    pub function: HostedFunctionDefinition,
127}
128
129#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(untagged)]
131pub enum HostedToolChoice {
132    Mode(HostedToolChoiceMode),
133    Function(HostedNamedToolChoice),
134}
135
136#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum HostedToolChoiceMode {
139    Auto,
140    None,
141    Required,
142}
143
144#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct HostedNamedToolChoice {
147    #[serde(rename = "type")]
148    pub kind: HostedToolKind,
149    pub function: HostedNamedFunction,
150}
151
152#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct HostedNamedFunction {
155    pub name: String,
156}
157
158/// Provider-neutral chat input transported through a managed gateway.
159/// Physical provider identity and hosted accounting remain outside this type.
160#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct HostedChatRequest {
163    pub messages: Vec<HostedChatMessage>,
164    #[serde(default, skip_serializing_if = "Vec::is_empty")]
165    pub tools: Vec<HostedFunctionTool>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub tool_choice: Option<HostedToolChoice>,
168    pub max_tokens: u32,
169    pub temperature: f32,
170    pub stream: bool,
171}
172
173#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(deny_unknown_fields)]
175pub struct HostedStreamOptions {
176    pub include_usage: bool,
177}
178
179/// Exact third-party request body. There is intentionally no metadata field:
180/// request identity, routing, and accounting belong to the hosted envelope and
181/// authoritative receipt, not to a provider-specific extension surface.
182#[derive(Clone, Debug, PartialEq, Serialize)]
183pub struct HostedOpenAiRequest {
184    pub model: String,
185    pub messages: Vec<HostedChatMessage>,
186    #[serde(skip_serializing_if = "Vec::is_empty")]
187    pub tools: Vec<HostedFunctionTool>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub tool_choice: Option<HostedToolChoice>,
190    pub max_tokens: u32,
191    pub temperature: f32,
192    pub stream: bool,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub stream_options: Option<HostedStreamOptions>,
195}
196
197fn nonempty(value: &str, field: &str) -> Result<(), ManagedSupplyContractError> {
198    if value.trim().is_empty() {
199        return Err(ManagedSupplyContractError::new(format!(
200            "hosted chat {field} must not be empty"
201        )));
202    }
203    Ok(())
204}
205
206impl HostedChatRequest {
207    /// Validate invariants that Serde's closed shape cannot express by itself.
208    pub fn validate(&self) -> Result<(), ManagedSupplyContractError> {
209        validate_request(self)
210    }
211}
212
213fn validate_request(request: &HostedChatRequest) -> Result<(), ManagedSupplyContractError> {
214    if request.messages.is_empty() || request.max_tokens == 0 || !request.temperature.is_finite() {
215        return Err(ManagedSupplyContractError::new(
216            "hosted chat requires messages, positive max_tokens, and finite temperature",
217        ));
218    }
219    for message in &request.messages {
220        if matches!(&message.content, HostedContent::Text(text) if text.trim().is_empty()) {
221            return Err(ManagedSupplyContractError::new(
222                "hosted chat text content must not be empty",
223            ));
224        }
225        if matches!(&message.content, HostedContent::Null) && message.tool_calls.is_empty() {
226            return Err(ManagedSupplyContractError::new(
227                "hosted chat null content requires assistant tool calls",
228            ));
229        }
230        if let HostedContent::Parts(parts) = &message.content {
231            if parts.is_empty() {
232                return Err(ManagedSupplyContractError::new(
233                    "hosted chat content parts must not be empty",
234                ));
235            }
236            for part in parts {
237                match part {
238                    HostedContentPart::Text { text } => nonempty(text, "content text")?,
239                    HostedContentPart::ImageUrl { image_url } => {
240                        nonempty(&image_url.url, "image URL")?;
241                    }
242                    HostedContentPart::InputAudio { input_audio } => {
243                        nonempty(&input_audio.data, "input audio")?;
244                    }
245                }
246            }
247        }
248        if matches!(message.role, HostedRole::Tool) {
249            nonempty(
250                message.tool_call_id.as_deref().unwrap_or_default(),
251                "tool_call_id",
252            )?;
253        }
254        for call in &message.tool_calls {
255            nonempty(&call.id, "tool call id")?;
256            nonempty(&call.function.name, "tool call function name")?;
257        }
258    }
259    for tool in &request.tools {
260        nonempty(&tool.function.name, "tool name")?;
261        if !tool.function.parameters.is_object() {
262            return Err(ManagedSupplyContractError::new(
263                "hosted chat tool parameters must be a JSON Schema object",
264            ));
265        }
266    }
267    Ok(())
268}
269
270/// Lower one managed chat request to the exact OpenAI-compatible provider
271/// body selected by Harn's provider registry.
272pub fn hosted_openai_request(
273    provider: &str,
274    model: &str,
275    request: HostedChatRequest,
276) -> Result<HostedOpenAiRequest, ManagedSupplyContractError> {
277    nonempty(provider, "provider")?;
278    nonempty(model, "model")?;
279    validate_request(&request)?;
280    let provider = crate::llm_config::provider_config(provider).ok_or_else(|| {
281        ManagedSupplyContractError::new("hosted chat provider is not in Harn's registry")
282    })?;
283    let stream_options = (request.stream && provider.stream_usage_accounting == Some(true))
284        .then_some(HostedStreamOptions {
285            include_usage: true,
286        });
287    Ok(HostedOpenAiRequest {
288        model: model.to_string(),
289        messages: request.messages,
290        tools: request.tools,
291        tool_choice: request.tool_choice,
292        max_tokens: request.max_tokens,
293        temperature: request.temperature,
294        stream: request.stream,
295        stream_options,
296    })
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn request(stream: bool) -> HostedChatRequest {
304        HostedChatRequest {
305            messages: vec![HostedChatMessage {
306                role: HostedRole::User,
307                content: HostedContent::Text("hello".to_string()),
308                name: None,
309                tool_call_id: None,
310                tool_calls: Vec::new(),
311                reasoning_content: None,
312            }],
313            tools: Vec::new(),
314            tool_choice: None,
315            max_tokens: 32,
316            temperature: 0.2,
317            stream,
318        }
319    }
320
321    #[test]
322    fn groq_projection_is_closed_and_requests_terminal_usage() {
323        let value = serde_json::to_value(
324            hosted_openai_request("groq", "llama-3.3-70b-versatile", request(true))
325                .expect("Groq request"),
326        )
327        .expect("JSON");
328        assert_eq!(value["stream_options"]["include_usage"], true);
329        assert!(value.get("metadata").is_none());
330        assert!(value.get("harn_managed_supply").is_none());
331    }
332
333    #[test]
334    fn together_projection_omits_undocumented_stream_usage_extension() {
335        let value = serde_json::to_value(
336            hosted_openai_request(
337                "together",
338                "meta-llama/Llama-3.3-70B-Instruct-Turbo",
339                request(true),
340            )
341            .expect("Together request"),
342        )
343        .expect("JSON");
344        assert!(value.get("stream_options").is_none());
345    }
346
347    #[test]
348    fn deepseek_projection_requests_terminal_usage() {
349        let value = serde_json::to_value(
350            hosted_openai_request("deepseek", "deepseek-chat", request(true))
351                .expect("DeepSeek request"),
352        )
353        .expect("JSON");
354        assert_eq!(value["stream_options"]["include_usage"], true);
355    }
356
357    #[test]
358    fn closed_message_contract_rejects_unknown_fields() {
359        let error = serde_json::from_value::<HostedChatMessage>(serde_json::json!({
360            "role": "user",
361            "content": "hello",
362            "metadata": {"leak": true}
363        }))
364        .expect_err("unknown message field must fail");
365        assert!(error.to_string().contains("unknown field"));
366    }
367
368    #[test]
369    fn tool_schema_must_be_an_object() {
370        let mut request = request(false);
371        request.tools.push(HostedFunctionTool {
372            kind: HostedToolKind::Function,
373            function: HostedFunctionDefinition {
374                name: "search".to_string(),
375                description: None,
376                parameters: serde_json::json!([]),
377                strict: None,
378            },
379        });
380        assert!(hosted_openai_request("groq", "model", request).is_err());
381    }
382}