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 mut msgs = encode_messages_vec(request.context.messages())?;
150 let mut results: Vec<&CanonicalToolResult> = request.results.iter().collect();
152 results.sort_by_key(|r| r.request_ordinal);
153 for result in results {
154 msgs.push(encode_tool_result_message(result)?);
155 }
156 self.encode_body(Value::Array(msgs), request.tools, request.config)
157 }
158}
159
160fn encode_openai_extensions(
164 body: &mut Map<String, Value>,
165 extensions: &std::collections::BTreeMap<
166 monoloop_contracts::ExtensionKey,
167 monoloop_contracts::VersionedExtension,
168 >,
169) -> Result<(), EncodingError> {
170 for (key, ext) in extensions {
171 let Some(field) = key.as_str().strip_prefix("openai.") else {
172 return Err(EncodingError::Unsupported("non-openai extension"));
173 };
174 match field {
175 "seed" | "user" | "top_p" | "n" | "frequency_penalty" | "presence_penalty"
176 | "logit_bias" | "logprobs" | "top_logprobs" | "metadata" => {
177 if body.contains_key(field) {
178 return Err(EncodingError::Unsupported("extension overrides body field"));
179 }
180 body.insert(field.to_string(), ext.value.clone());
181 }
182 _ => return Err(EncodingError::Unsupported("openai extension field")),
183 }
184 }
185 Ok(())
186}
187
188fn encode_messages(messages: &[CanonicalMessage]) -> Result<Value, EncodingError> {
189 Ok(Value::Array(encode_messages_vec(messages)?))
190}
191
192fn encode_messages_vec(messages: &[CanonicalMessage]) -> Result<Vec<Value>, EncodingError> {
193 let mut out = Vec::with_capacity(messages.len());
194 for msg in messages {
195 out.push(encode_message(msg)?);
196 }
197 Ok(out)
198}
199
200fn encode_message(msg: &CanonicalMessage) -> Result<Value, EncodingError> {
201 match msg {
202 CanonicalMessage::System { content, name } => {
203 let mut m = Map::new();
204 m.insert("role".into(), Value::String("system".into()));
205 m.insert("content".into(), Value::String(join_text(content)));
206 if let Some(n) = name {
207 m.insert("name".into(), Value::String(n.clone()));
208 }
209 Ok(Value::Object(m))
210 }
211 CanonicalMessage::User { content, name } => {
212 let mut m = Map::new();
213 m.insert("role".into(), Value::String("user".into()));
214 m.insert("content".into(), Value::String(join_text(content)));
215 if let Some(n) = name {
216 m.insert("name".into(), Value::String(n.clone()));
217 }
218 Ok(Value::Object(m))
219 }
220 CanonicalMessage::Assistant {
221 content,
222 tool_calls,
223 } => {
224 let mut m = Map::new();
225 m.insert("role".into(), Value::String("assistant".into()));
226 if content.is_empty() {
227 m.insert("content".into(), Value::Null);
228 } else {
229 m.insert("content".into(), Value::String(join_text(content)));
230 }
231 if !tool_calls.is_empty() {
232 let calls: Vec<Value> = tool_calls
233 .iter()
234 .map(|c| {
235 let args =
236 serde_json::to_string(&c.arguments).unwrap_or_else(|_| "{}".into());
237 json!({
238 "id": c.tool_call_id,
239 "type": "function",
240 "function": {
241 "name": c.tool_name.as_str(),
242 "arguments": args,
243 }
244 })
245 })
246 .collect();
247 m.insert("tool_calls".into(), Value::Array(calls));
248 }
249 Ok(Value::Object(m))
250 }
251 CanonicalMessage::Tool {
252 tool_call_id,
253 content,
254 } => Ok(json!({
255 "role": "tool",
256 "tool_call_id": tool_call_id,
257 "content": join_text(content),
258 })),
259 }
260}
261
262fn encode_tool_result_message(result: &CanonicalToolResult) -> Result<Value, EncodingError> {
263 let content = match &result.outcome {
264 CanonicalToolResultOutcome::Succeeded(CanonicalToolOutput::Json(v)) => {
265 serde_json::to_string(v).map_err(|_| EncodingError::UnrepresentableInput)?
266 }
267 CanonicalToolResultOutcome::Succeeded(CanonicalToolOutput::Text(t)) => t.clone(),
268 CanonicalToolResultOutcome::DomainFailed(err) => serde_json::to_string(&json!({
269 "error": { "code": err.code, "message": err.message, "data": err.data },
270 }))
271 .map_err(|_| EncodingError::UnrepresentableInput)?,
272 };
273 Ok(json!({
275 "role": "tool",
276 "tool_call_id": result.provider_tool_call_id,
277 "content": content,
278 }))
279}
280
281fn join_text(parts: &[monoloop_contracts::TextPart]) -> String {
282 parts.iter().map(|p| p.text()).collect::<Vec<_>>().join("")
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use monoloop_contracts::merge_effective_config;
289 use monoloop_contracts::{
290 user_text_input, CanonicalInput, ChannelDefaults, EffectiveConfig, ExchangeId,
291 InvocationConfig, JsonSchema, ToolCancellationPolicy, ToolId, ToolLimits, ToolName,
292 ToolOutputContract, ToolSuccessContract, TransactionId,
293 };
294 use monoloop_contracts::{ExtensionLimits, OptionPolicy};
295
296 fn effective(model: &str) -> EffectiveConfig {
297 let inv = InvocationConfig {
298 model: Some(model.into()),
299 temperature: Some(0.2),
300 max_output_tokens: Some(128),
301 ..Default::default()
302 };
303 merge_effective_config(
304 &ChannelDefaults::default(),
305 None,
306 None,
307 &inv,
308 &OptionPolicy {
309 supported_invocation: [
310 monoloop_contracts::ConfigOption::Model,
311 monoloop_contracts::ConfigOption::Temperature,
312 monoloop_contracts::ConfigOption::MaxOutputTokens,
313 monoloop_contracts::ConfigOption::ContinuationPolicy,
314 ]
315 .into_iter()
316 .collect(),
317 ..Default::default()
318 },
319 &ExtensionLimits::default(),
320 )
321 .unwrap()
322 }
323
324 #[test]
325 fn initial_encode_golden_shape() {
326 let enc = OpenAiChatCompletionsEncoder::default();
327 let input = user_text_input("Hello").unwrap();
328 let tid = TransactionId::generate();
329 let eid = ExchangeId::generate();
330 let cfg = effective("gpt-test");
331 let encoded = enc
332 .encode_initial(InitialEncodeRequest {
333 transaction_id: &tid,
334 exchange_id: &eid,
335 input: &input,
336 config: &cfg,
337 tools: &[],
338 })
339 .unwrap();
340 assert_eq!(
341 encoded.required_input_dialect.family,
342 monoloop_contracts::DialectFamily::OpenAiChatCompletions
343 );
344 assert_eq!(encoded.input_policy, ExchangeInputPolicy::SendAndFinish);
345 let v: Value = serde_json::from_slice(&encoded.bytes).unwrap();
346 assert_eq!(v["model"], "gpt-test");
347 assert_eq!(v["stream"], true);
348 assert_eq!(v["messages"][0]["role"], "user");
349 assert_eq!(v["messages"][0]["content"], "Hello");
350 assert_eq!(v["max_tokens"], 128);
351 assert!((v["temperature"].as_f64().unwrap() - 0.2).abs() < 1e-5);
352 }
353
354 #[test]
355 fn tools_and_max_completion_tokens() {
356 let enc = OpenAiChatCompletionsEncoder::new(OpenAiEncoderOptions {
357 use_max_completion_tokens: true,
358 ..Default::default()
359 });
360 let schema = JsonSchema::try_new(json!({"type": "object"})).unwrap();
361 let tool = ToolSpec::try_new(
362 ToolId::try_new("search").unwrap(),
363 ToolName::try_new("search").unwrap(),
364 "Search",
365 schema.clone(),
366 ToolOutputContract {
367 success: ToolSuccessContract::json(schema),
368 error_data_schema: None,
369 },
370 ToolLimits::default(),
371 ToolCancellationPolicy::Abortable,
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}