1use 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#[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 #[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 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#[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#[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 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
270pub 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}