asimov_openai_module/
lib.rs1#![no_std]
4#![forbid(unsafe_code)]
5
6use asimov_module::{
7 prelude::*,
8 secrecy::{ExposeSecret, SecretString},
9 tracing,
10};
11use core::error::Error;
12use serde_json::{Value, json};
13
14#[derive(Clone, Debug, bon::Builder)]
15#[builder(on(String, into))]
16pub struct Options {
17 #[builder(default = "https://api.openai.com")]
18 pub endpoint: String,
19
20 #[builder(default = "gpt-5-mini")]
21 pub model: String,
22
23 pub max_tokens: Option<usize>,
24
25 #[builder(into)]
26 pub api_key: SecretString,
27}
28
29pub fn generate(input: impl AsRef<str>, options: &Options) -> Result<Vec<String>, Box<dyn Error>> {
30 let mut req = json!({
31 "model": options.model,
32 "messages": [{
33 "role": "user",
34 "content": input.as_ref(),
35 }],
36 });
37
38 if let Some(max_tokens) = options.max_tokens {
39 req["max_output_tokens"] = max_tokens.into();
40 }
41
42 let mut resp = ureq::Agent::config_builder()
43 .http_status_as_error(false)
44 .user_agent("asimov-openai-module")
45 .build()
46 .new_agent()
47 .post(format!("{}/v1/chat/completions", options.endpoint))
48 .header(
49 "Authorization",
50 format!("Bearer {}", options.api_key.expose_secret()),
51 )
52 .header("content-type", "application/json")
53 .send_json(&req)
54 .inspect_err(|e| tracing::error!("HTTP request failed: {e}"))?;
55 tracing::debug!(response = ?resp);
56
57 let status = resp.status();
58 tracing::debug!(status = status.to_string());
59
60 let resp: Value = resp
61 .body_mut()
62 .read_json()
63 .inspect_err(|e| tracing::error!("unable to read HTTP response body: {e}"))?;
64 tracing::debug!(body = ?resp);
65
66 if !status.is_success() {
67 tracing::error!("Received an error response: {status}");
68
69 if let Some(message) = resp["error"]["message"].as_str() {
78 return Err(message.into());
79 }
80 }
81
82 let mut responses = Vec::new();
119
120 if let Some(choices) = resp["choices"].as_array() {
121 for choice in choices {
123 if choice["message"]["role"]
124 .as_str()
125 .is_none_or(|r| r != "assistant")
126 {
127 tracing::debug!("skipping output not from assistant: {choice}");
128 continue;
129 }
130
131 if let Some(content) = choice["message"]["content"].as_str() {
132 responses.push(content.to_string())
133 } else if let Some(refusal) = choice["message"]["refusal"].as_str() {
134 tracing::error!("Request refused: {refusal}")
135 }
136
137 if let Some(finish_reason) = choice["finish_reason"].as_str() {
138 tracing::debug!(finish_reason);
139 }
140 }
141 }
142
143 Ok(responses)
144}