1use monoloop_contracts::{
7 Bytes, CanonicalMessage, CanonicalToolOutput, CanonicalToolResult, CanonicalToolResultOutcome,
8 DialectDescriptor, EncodedExchange, EncodingError, ExchangeInputPolicy, InitialEncodeRequest,
9 OutboundDialectEncoder, ReasoningEffort, ResponseFormat, ToolContinuationEncodeRequest,
10 ToolSpec,
11};
12use serde_json::{json, Map, Value};
13
14#[derive(Clone, Debug)]
16pub struct OpenAiEncoderOptions {
17 pub use_max_completion_tokens: bool,
19 pub allow_reasoning_effort: bool,
21 pub max_encoded_bytes: usize,
23}
24
25impl Default for OpenAiEncoderOptions {
26 fn default() -> Self {
27 Self {
28 use_max_completion_tokens: false,
29 allow_reasoning_effort: false,
30 max_encoded_bytes: 4 * 1024 * 1024,
31 }
32 }
33}
34
35#[derive(Clone, Debug, Default)]
37pub struct OpenAiChatCompletionsEncoder {
38 pub options: OpenAiEncoderOptions,
40}
41
42impl OpenAiChatCompletionsEncoder {
43 pub fn new(options: OpenAiEncoderOptions) -> Self {
45 Self { options }
46 }
47
48 fn dialect() -> DialectDescriptor {
49 DialectDescriptor::openai_chat_completions("v1")
50 }
51
52 fn encode_body(
53 &self,
54 messages: Value,
55 tools: &[ToolSpec],
56 config: &monoloop_contracts::EffectiveConfig,
57 ) -> Result<EncodedExchange, EncodingError> {
58 let mut body = Map::new();
59 let model = config
60 .model
61 .clone()
62 .ok_or(EncodingError::InvalidConfiguration)?;
63 body.insert("model".into(), Value::String(model));
64 body.insert("messages".into(), messages);
65 body.insert("stream".into(), Value::Bool(true));
66
67 if let Some(t) = config.temperature {
68 body.insert("temperature".into(), json!(t));
69 }
70 if let Some(max) = config.max_output_tokens {
71 let key = if self.options.use_max_completion_tokens {
72 "max_completion_tokens"
73 } else {
74 "max_tokens"
75 };
76 body.insert(key.into(), json!(max));
77 }
78 if !config.stop.is_empty() {
79 body.insert("stop".into(), json!(config.stop));
80 }
81 if let Some(fmt) = &config.response_format {
82 match fmt {
83 ResponseFormat::Text => {
84 body.insert("response_format".into(), json!({ "type": "text" }));
85 }
86 ResponseFormat::JsonObject => {
87 body.insert("response_format".into(), json!({ "type": "json_object" }));
88 }
89 }
90 }
91 if let Some(effort) = config.reasoning_effort {
92 if !self.options.allow_reasoning_effort {
93 return Err(EncodingError::Unsupported("reasoning_effort"));
94 }
95 let label = match effort {
96 ReasoningEffort::Low => "low",
97 ReasoningEffort::Medium => "medium",
98 ReasoningEffort::High => "high",
99 };
100 body.insert("reasoning_effort".into(), Value::String(label.into()));
101 }
102
103 encode_openai_extensions(&mut body, &config.extensions)?;
105
106 if !tools.is_empty() {
107 let tool_defs: Vec<Value> = tools
108 .iter()
109 .map(|t| {
110 json!({
111 "type": "function",
112 "function": {
113 "name": t.name.as_str(),
114 "description": t.description,
115 "parameters": t.input_schema.as_value(),
116 }
117 })
118 })
119 .collect();
120 body.insert("tools".into(), Value::Array(tool_defs));
121 }
122
123 let bytes = serde_json::to_vec(&Value::Object(body))
124 .map_err(|_| EncodingError::UnrepresentableInput)?;
125 if bytes.len() > self.options.max_encoded_bytes {
126 return Err(EncodingError::LimitExceeded);
127 }
128 Ok(EncodedExchange {
129 bytes: Bytes::from(bytes),
130 required_input_dialect: Self::dialect(),
131 input_policy: ExchangeInputPolicy::SendAndFinish,
132 })
133 }
134}
135
136impl OutboundDialectEncoder for OpenAiChatCompletionsEncoder {
137 fn encode_initial(
138 &self,
139 request: InitialEncodeRequest<'_>,
140 ) -> Result<EncodedExchange, EncodingError> {
141 let messages = encode_messages(request.input.messages())?;
142 self.encode_body(messages, request.tools, request.config)
143 }
144
145 fn encode_tool_continuation(
146 &self,
147 request: ToolContinuationEncodeRequest<'_>,
148 ) -> Result<EncodedExchange, EncodingError> {
149 let _ = request.results;
152 let msgs = encode_messages_vec(request.context.messages())?;
153 self.encode_body(Value::Array(msgs), request.tools, request.config)
154 }
155}
156
157fn encode_openai_extensions(
161 body: &mut Map<String, Value>,
162 extensions: &std::collections::BTreeMap<
163 monoloop_contracts::ExtensionKey,
164 monoloop_contracts::VersionedExtension,
165 >,
166) -> Result<(), EncodingError> {
167 for (key, ext) in extensions {
168 let Some(field) = key.as_str().strip_prefix("openai.") else {
169 return Err(EncodingError::Unsupported("non-openai extension"));
170 };
171 match field {
172 "seed" | "user" | "top_p" | "n" | "frequency_penalty" | "presence_penalty"
173 | "logit_bias" | "logprobs" | "top_logprobs" | "metadata" => {
174 if body.contains_key(field) {
175 return Err(EncodingError::Unsupported("extension overrides body field"));
176 }
177 body.insert(field.to_string(), ext.value.clone());
178 }
179 _ => return Err(EncodingError::Unsupported("openai extension field")),
180 }
181 }
182 Ok(())
183}
184
185fn encode_messages(messages: &[CanonicalMessage]) -> Result<Value, EncodingError> {
186 Ok(Value::Array(encode_messages_vec(messages)?))
187}
188
189fn encode_messages_vec(messages: &[CanonicalMessage]) -> Result<Vec<Value>, EncodingError> {
190 let mut out = Vec::with_capacity(messages.len());
191 for msg in messages {
192 out.push(encode_message(msg)?);
193 }
194 Ok(out)
195}
196
197fn encode_message(msg: &CanonicalMessage) -> Result<Value, EncodingError> {
198 match msg {
199 CanonicalMessage::System { content, name } => {
200 let mut m = Map::new();
201 m.insert("role".into(), Value::String("system".into()));
202 m.insert("content".into(), Value::String(join_text(content)));
203 if let Some(n) = name {
204 m.insert("name".into(), Value::String(n.clone()));
205 }
206 Ok(Value::Object(m))
207 }
208 CanonicalMessage::User { content, name } => {
209 let mut m = Map::new();
210 m.insert("role".into(), Value::String("user".into()));
211 m.insert("content".into(), Value::String(join_text(content)));
212 if let Some(n) = name {
213 m.insert("name".into(), Value::String(n.clone()));
214 }
215 Ok(Value::Object(m))
216 }
217 CanonicalMessage::Assistant {
218 content,
219 tool_calls,
220 } => {
221 let mut m = Map::new();
222 m.insert("role".into(), Value::String("assistant".into()));
223 if content.is_empty() {
224 m.insert("content".into(), Value::Null);
225 } else {
226 m.insert("content".into(), Value::String(join_text(content)));
227 }
228 if !tool_calls.is_empty() {
229 let calls: Vec<Value> = tool_calls
230 .iter()
231 .map(|c| {
232 let args =
233 serde_json::to_string(&c.arguments).unwrap_or_else(|_| "{}".into());
234 json!({
235 "id": c.tool_call_id,
236 "type": "function",
237 "function": {
238 "name": c.tool_name.as_str(),
239 "arguments": args,
240 }
241 })
242 })
243 .collect();
244 m.insert("tool_calls".into(), Value::Array(calls));
245 }
246 Ok(Value::Object(m))
247 }
248 CanonicalMessage::Tool {
249 tool_call_id,
250 content,
251 } => Ok(json!({
252 "role": "tool",
253 "tool_call_id": tool_call_id,
254 "content": join_text(content),
255 })),
256 }
257}
258
259#[allow(dead_code)] fn encode_tool_result_message(result: &CanonicalToolResult) -> Result<Value, EncodingError> {
261 let content = match &result.outcome {
262 CanonicalToolResultOutcome::Succeeded(CanonicalToolOutput::Json(v)) => {
263 serde_json::to_string(v).map_err(|_| EncodingError::UnrepresentableInput)?
264 }
265 CanonicalToolResultOutcome::Succeeded(CanonicalToolOutput::Text(t)) => t.clone(),
266 CanonicalToolResultOutcome::DomainFailed(err) => serde_json::to_string(&json!({
267 "error": { "code": err.code, "message": err.message, "data": err.data },
268 }))
269 .map_err(|_| EncodingError::UnrepresentableInput)?,
270 };
271 Ok(json!({
273 "role": "tool",
274 "tool_call_id": result.provider_tool_call_id,
275 "content": content,
276 }))
277}
278
279fn join_text(parts: &[monoloop_contracts::TextPart]) -> String {
280 parts.iter().map(|p| p.text()).collect::<Vec<_>>().join("")
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use monoloop_contracts::merge_effective_config;
287 use monoloop_contracts::{
288 user_text_input, CanonicalInput, ChannelDefaults, EffectiveConfig, ExchangeId,
289 InvocationConfig, JsonSchema, ToolExecutionClass, ToolId, ToolLimits, ToolName,
290 ToolOutputContract, ToolSuccessContract, TransactionId,
291 };
292 use monoloop_contracts::{ExtensionLimits, OptionPolicy};
293
294 fn effective(model: &str) -> EffectiveConfig {
295 let inv = InvocationConfig {
296 model: Some(model.into()),
297 temperature: Some(0.2),
298 max_output_tokens: Some(128),
299 ..Default::default()
300 };
301 merge_effective_config(
302 &ChannelDefaults::default(),
303 None,
304 None,
305 &inv,
306 &OptionPolicy {
307 supported_invocation: [
308 monoloop_contracts::ConfigOption::Model,
309 monoloop_contracts::ConfigOption::Temperature,
310 monoloop_contracts::ConfigOption::MaxOutputTokens,
311 monoloop_contracts::ConfigOption::ContinuationPolicy,
312 ]
313 .into_iter()
314 .collect(),
315 ..Default::default()
316 },
317 &ExtensionLimits::default(),
318 )
319 .unwrap()
320 }
321
322 #[test]
323 fn initial_encode_golden_shape() {
324 let enc = OpenAiChatCompletionsEncoder::default();
325 let input = user_text_input("Hello").unwrap();
326 let tid = TransactionId::generate();
327 let eid = ExchangeId::generate();
328 let cfg = effective("gpt-test");
329 let encoded = enc
330 .encode_initial(InitialEncodeRequest {
331 transaction_id: &tid,
332 exchange_id: &eid,
333 input: &input,
334 config: &cfg,
335 tools: &[],
336 })
337 .unwrap();
338 assert_eq!(
339 encoded.required_input_dialect.family,
340 monoloop_contracts::DialectFamily::OpenAiChatCompletions
341 );
342 assert_eq!(encoded.input_policy, ExchangeInputPolicy::SendAndFinish);
343 let v: Value = serde_json::from_slice(&encoded.bytes).unwrap();
344 assert_eq!(v["model"], "gpt-test");
345 assert_eq!(v["stream"], true);
346 assert_eq!(v["messages"][0]["role"], "user");
347 assert_eq!(v["messages"][0]["content"], "Hello");
348 assert_eq!(v["max_tokens"], 128);
349 assert!((v["temperature"].as_f64().unwrap() - 0.2).abs() < 1e-5);
350 }
351
352 #[test]
353 fn tools_and_max_completion_tokens() {
354 let enc = OpenAiChatCompletionsEncoder::new(OpenAiEncoderOptions {
355 use_max_completion_tokens: true,
356 ..Default::default()
357 });
358 let schema = JsonSchema::try_new(json!({"type": "object"})).unwrap();
359 let tool = ToolSpec::try_new(
360 ToolId::try_new("search").unwrap(),
361 ToolName::try_new("search").unwrap(),
362 "Search",
363 schema.clone(),
364 ToolOutputContract {
365 success: ToolSuccessContract::json(schema),
366 error_data_schema: None,
367 },
368 ToolLimits::default(),
369 ToolExecutionClass::AbortableAtYield {
370 grace: std::time::Duration::from_secs(1),
371 },
372 )
373 .unwrap();
374 let input = user_text_input("q").unwrap();
375 let tid = TransactionId::generate();
376 let eid = ExchangeId::generate();
377 let cfg = effective("m");
378 let encoded = enc
379 .encode_initial(InitialEncodeRequest {
380 transaction_id: &tid,
381 exchange_id: &eid,
382 input: &input,
383 config: &cfg,
384 tools: &[tool],
385 })
386 .unwrap();
387 let v: Value = serde_json::from_slice(&encoded.bytes).unwrap();
388 assert!(v.get("max_tokens").is_none());
389 assert_eq!(v["max_completion_tokens"], 128);
390 assert_eq!(v["tools"][0]["function"]["name"], "search");
391 }
392
393 #[test]
394 fn reasoning_effort_rejected_without_capability() {
395 let enc = OpenAiChatCompletionsEncoder::default();
396 let inv = InvocationConfig {
397 model: Some("m".into()),
398 reasoning_effort: Some(ReasoningEffort::High),
399 ..Default::default()
400 };
401 let cfg = merge_effective_config(
402 &ChannelDefaults::default(),
403 None,
404 None,
405 &inv,
406 &OptionPolicy {
407 supported_invocation: [
408 monoloop_contracts::ConfigOption::Model,
409 monoloop_contracts::ConfigOption::ReasoningEffort,
410 monoloop_contracts::ConfigOption::ContinuationPolicy,
411 ]
412 .into_iter()
413 .collect(),
414 ..Default::default()
415 },
416 &ExtensionLimits::default(),
417 )
418 .unwrap();
419 let input = user_text_input("x").unwrap();
420 let tid = TransactionId::generate();
421 let eid = ExchangeId::generate();
422 let err = enc
423 .encode_initial(InitialEncodeRequest {
424 transaction_id: &tid,
425 exchange_id: &eid,
426 input: &input,
427 config: &cfg,
428 tools: &[],
429 })
430 .unwrap_err();
431 assert!(matches!(
432 err,
433 EncodingError::Unsupported("reasoning_effort")
434 ));
435 }
436
437 #[test]
438 fn missing_model_invalid() {
439 let enc = OpenAiChatCompletionsEncoder::default();
440 let input = user_text_input("x").unwrap();
441 let tid = TransactionId::generate();
442 let eid = ExchangeId::generate();
443 let cfg = EffectiveConfig {
444 model: None,
445 ..effective("unused")
446 };
447 let mut cfg = cfg;
449 cfg.model = None;
450 let err = enc
451 .encode_initial(InitialEncodeRequest {
452 transaction_id: &tid,
453 exchange_id: &eid,
454 input: &input,
455 config: &cfg,
456 tools: &[],
457 })
458 .unwrap_err();
459 assert!(matches!(err, EncodingError::InvalidConfiguration));
460 let _ = CanonicalInput::try_new(vec![], &Default::default());
461 }
462
463 #[test]
464 fn openai_seed_extension_round_trip() {
465 use monoloop_contracts::{ExtensionKey, VersionedExtension};
466 let enc = OpenAiChatCompletionsEncoder::default();
467 let input = user_text_input("Hi").unwrap();
468 let tid = TransactionId::generate();
469 let eid = ExchangeId::generate();
470 let mut cfg = effective("gpt-test");
471 let key = ExtensionKey::try_new("openai.seed", 64).unwrap();
472 cfg.extensions.insert(
473 key,
474 VersionedExtension {
475 version: 1,
476 value: serde_json::json!(42),
477 },
478 );
479 let encoded = enc
480 .encode_initial(InitialEncodeRequest {
481 transaction_id: &tid,
482 exchange_id: &eid,
483 input: &input,
484 config: &cfg,
485 tools: &[],
486 })
487 .unwrap();
488 let v: Value = serde_json::from_slice(&encoded.bytes).unwrap();
489 assert_eq!(v["seed"], 42);
490 }
491
492 #[test]
493 fn unknown_openai_extension_fails_encode() {
494 use monoloop_contracts::{ExtensionKey, VersionedExtension};
495 let enc = OpenAiChatCompletionsEncoder::default();
496 let input = user_text_input("Hi").unwrap();
497 let tid = TransactionId::generate();
498 let eid = ExchangeId::generate();
499 let mut cfg = effective("gpt-test");
500 let key = ExtensionKey::try_new("openai.not_a_real_field", 64).unwrap();
501 cfg.extensions.insert(
502 key,
503 VersionedExtension {
504 version: 1,
505 value: serde_json::json!(1),
506 },
507 );
508 let err = enc
509 .encode_initial(InitialEncodeRequest {
510 transaction_id: &tid,
511 exchange_id: &eid,
512 input: &input,
513 config: &cfg,
514 tools: &[],
515 })
516 .unwrap_err();
517 assert!(matches!(err, EncodingError::Unsupported(_)));
518 }
519
520 #[test]
521 fn non_openai_extension_fails_encode() {
522 use monoloop_contracts::{ExtensionKey, VersionedExtension};
523 let enc = OpenAiChatCompletionsEncoder::default();
524 let input = user_text_input("Hi").unwrap();
525 let tid = TransactionId::generate();
526 let eid = ExchangeId::generate();
527 let mut cfg = effective("gpt-test");
528 let key = ExtensionKey::try_new("other.vendor", 64).unwrap();
529 cfg.extensions.insert(
530 key,
531 VersionedExtension {
532 version: 1,
533 value: serde_json::json!(true),
534 },
535 );
536 assert!(enc
537 .encode_initial(InitialEncodeRequest {
538 transaction_id: &tid,
539 exchange_id: &eid,
540 input: &input,
541 config: &cfg,
542 tools: &[],
543 })
544 .is_err());
545 }
546}