asimov_openai_module/
lib.rs

1// This is free and unencumbered software released into the public domain.
2
3#![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        // {
70        //   "error": {
71        //     "message": "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.",
72        //     "type": "insufficient_quota",
73        //     "param": null,
74        //     "code": "insufficient_quota"
75        //   }
76        // }
77        if let Some(message) = resp["error"]["message"].as_str() {
78            return Err(message.into());
79        }
80    }
81
82    // {
83    //   "id": "chatcmpl-...",
84    //   "object": "chat.completion",
85    //   "created": 1741569952,
86    //   "model": "gpt-4.1-2025-04-14",
87    //   "choices": [
88    //     {
89    //       "index": 0,
90    //       "message": {
91    //         "role": "assistant",
92    //         "content": "...",
93    //         "refusal": null,
94    //         "annotations": []
95    //       },
96    //       "logprobs": null,
97    //       "finish_reason": "stop"
98    //     }
99    //   ],
100    //   "usage": {
101    //     "prompt_tokens": 19,
102    //     "completion_tokens": 10,
103    //     "total_tokens": 29,
104    //     "prompt_tokens_details": {
105    //       "cached_tokens": 0,
106    //       "audio_tokens": 0
107    //     },
108    //     "completion_tokens_details": {
109    //       "reasoning_tokens": 0,
110    //       "audio_tokens": 0,
111    //       "accepted_prediction_tokens": 0,
112    //       "rejected_prediction_tokens": 0
113    //     }
114    //   },
115    //   "service_tier": "default"
116    // }
117
118    let mut responses = Vec::new();
119
120    if let Some(choices) = resp["choices"].as_array() {
121        // there is only one "choice" if the request doesn't have an "n" parameter
122        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}