Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:01:16.450Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

openai_client.rs

706 lines · 25.1 KB · rust
1use anyhow::Result;
2use http_client::HttpClient;
3use indoc::indoc;
4use open_ai::{
5    MessageContent, OPEN_AI_API_URL, Request as OpenAiRequest, RequestMessage,
6    Response as OpenAiResponse, batches, non_streaming_completion,
7};
8use reqwest_client::ReqwestClient;
9use sqlez::bindable::Bind;
10use sqlez::bindable::StaticColumnCount;
11use sqlez_macros::sql;
12use std::hash::Hash;
13use std::hash::Hasher;
14use std::path::Path;
15use std::sync::{Arc, Mutex};
16
17pub struct PlainOpenAiClient {
18    pub http_client: Arc<dyn HttpClient>,
19    pub api_key: String,
20}
21
22impl PlainOpenAiClient {
23    pub fn new() -> Result<Self> {
24        let http_client: Arc<dyn http_client::HttpClient> = Arc::new(ReqwestClient::new());
25        let api_key = std::env::var("OPENAI_API_KEY")
26            .map_err(|_| anyhow::anyhow!("OPENAI_API_KEY environment variable not set"))?;
27        Ok(Self {
28            http_client,
29            api_key,
30        })
31    }
32
33    pub async fn generate(
34        &self,
35        model: &str,
36        max_tokens: u64,
37        messages: Vec<RequestMessage>,
38    ) -> Result<OpenAiResponse> {
39        let request = OpenAiRequest {
40            model: model.to_string(),
41            messages,
42            stream: false,
43            stream_options: None,
44            max_completion_tokens: Some(max_tokens),
45            max_tokens: None,
46            stop: Vec::new(),
47            temperature: None,
48            tool_choice: None,
49            parallel_tool_calls: None,
50            service_tier: None,
51            tools: Vec::new(),
52            prompt_cache_key: None,
53            reasoning_effort: None,
54        };
55
56        let response = non_streaming_completion(
57            self.http_client.as_ref(),
58            OPEN_AI_API_URL,
59            &self.api_key,
60            request,
61        )
62        .await
63        .map_err(|e| anyhow::anyhow!("{:?}", e))?;
64
65        Ok(response)
66    }
67}
68
69pub struct BatchingOpenAiClient {
70    connection: Mutex<sqlez::connection::Connection>,
71    http_client: Arc<dyn HttpClient>,
72    api_key: String,
73}
74
75struct CacheRow {
76    request_hash: String,
77    request: Option<String>,
78    response: Option<String>,
79    batch_id: Option<String>,
80}
81
82impl StaticColumnCount for CacheRow {
83    fn column_count() -> usize {
84        4
85    }
86}
87
88impl Bind for CacheRow {
89    fn bind(&self, statement: &sqlez::statement::Statement, start_index: i32) -> Result<i32> {
90        let next_index = statement.bind(&self.request_hash, start_index)?;
91        let next_index = statement.bind(&self.request, next_index)?;
92        let next_index = statement.bind(&self.response, next_index)?;
93        let next_index = statement.bind(&self.batch_id, next_index)?;
94        Ok(next_index)
95    }
96}
97
98#[derive(serde::Serialize, serde::Deserialize)]
99struct SerializableRequest {
100    model: String,
101    max_tokens: u64,
102    messages: Vec<SerializableMessage>,
103}
104
105#[derive(serde::Serialize, serde::Deserialize)]
106struct SerializableMessage {
107    role: String,
108    content: String,
109}
110
111impl BatchingOpenAiClient {
112    fn new(cache_path: &Path) -> Result<Self> {
113        let http_client: Arc<dyn http_client::HttpClient> = Arc::new(ReqwestClient::new());
114        let api_key = std::env::var("OPENAI_API_KEY")
115            .map_err(|_| anyhow::anyhow!("OPENAI_API_KEY environment variable not set"))?;
116
117        let connection = sqlez::connection::Connection::open_file(cache_path.to_str().unwrap());
118        let mut statement = sqlez::statement::Statement::prepare(
119            &connection,
120            indoc! {"
121                CREATE TABLE IF NOT EXISTS openai_cache (
122                    request_hash TEXT PRIMARY KEY,
123                    request TEXT,
124                    response TEXT,
125                    batch_id TEXT
126                );
127                "},
128        )?;
129        statement.exec()?;
130        drop(statement);
131
132        Ok(Self {
133            connection: Mutex::new(connection),
134            http_client,
135            api_key,
136        })
137    }
138
139    pub fn lookup(
140        &self,
141        model: &str,
142        max_tokens: u64,
143        messages: &[RequestMessage],
144        seed: Option<usize>,
145    ) -> Result<Option<OpenAiResponse>> {
146        let request_hash_str = Self::request_hash(model, max_tokens, messages, seed);
147        let connection = self.connection.lock().unwrap();
148        let response: Vec<String> = connection.select_bound(
149            &sql!(SELECT response FROM openai_cache WHERE request_hash = ?1 AND response IS NOT NULL;),
150        )?(request_hash_str.as_str())?;
151        Ok(response
152            .into_iter()
153            .next()
154            .and_then(|text| serde_json::from_str(&text).ok()))
155    }
156
157    pub fn mark_for_batch(
158        &self,
159        model: &str,
160        max_tokens: u64,
161        messages: &[RequestMessage],
162        seed: Option<usize>,
163    ) -> Result<()> {
164        let request_hash = Self::request_hash(model, max_tokens, messages, seed);
165
166        let serializable_messages: Vec<SerializableMessage> = messages
167            .iter()
168            .map(|msg| SerializableMessage {
169                role: message_role_to_string(msg),
170                content: message_content_to_string(msg),
171            })
172            .collect();
173
174        let serializable_request = SerializableRequest {
175            model: model.to_string(),
176            max_tokens,
177            messages: serializable_messages,
178        };
179
180        let request = Some(serde_json::to_string(&serializable_request)?);
181        let cache_row = CacheRow {
182            request_hash,
183            request,
184            response: None,
185            batch_id: None,
186        };
187        let connection = self.connection.lock().unwrap();
188        connection.exec_bound::<CacheRow>(sql!(
189            INSERT OR IGNORE INTO openai_cache(request_hash, request, response, batch_id) VALUES (?, ?, ?, ?)))?(
190            cache_row,
191        )
192    }
193
194    async fn generate(
195        &self,
196        model: &str,
197        max_tokens: u64,
198        messages: Vec<RequestMessage>,
199        seed: Option<usize>,
200        cache_only: bool,
201    ) -> Result<Option<OpenAiResponse>> {
202        let response = self.lookup(model, max_tokens, &messages, seed)?;
203        if let Some(response) = response {
204            return Ok(Some(response));
205        }
206
207        if !cache_only {
208            self.mark_for_batch(model, max_tokens, &messages, seed)?;
209        }
210
211        Ok(None)
212    }
213
214    async fn sync_batches(&self) -> Result<()> {
215        let _batch_ids = self.upload_pending_requests().await?;
216        self.download_finished_batches().await
217    }
218
219    pub fn pending_batch_count(&self) -> Result<usize> {
220        let connection = self.connection.lock().unwrap();
221        let counts: Vec<i32> = connection.select(
222            sql!(SELECT COUNT(*) FROM openai_cache WHERE batch_id IS NOT NULL AND response IS NULL),
223        )?()?;
224        Ok(counts.into_iter().next().unwrap_or(0) as usize)
225    }
226
227    pub async fn import_batches(&self, batch_ids: &[String]) -> Result<()> {
228        for batch_id in batch_ids {
229            log::info!("Importing OpenAI batch {}", batch_id);
230
231            let batch_status = batches::retrieve_batch(
232                self.http_client.as_ref(),
233                OPEN_AI_API_URL,
234                &self.api_key,
235                batch_id,
236            )
237            .await
238            .map_err(|e| anyhow::anyhow!("Failed to retrieve batch {}: {:?}", batch_id, e))?;
239
240            log::info!("Batch {} status: {}", batch_id, batch_status.status);
241
242            if batch_status.status != "completed" {
243                log::warn!(
244                    "Batch {} is not completed (status: {}), skipping",
245                    batch_id,
246                    batch_status.status
247                );
248                continue;
249            }
250
251            let output_file_id = batch_status.output_file_id.ok_or_else(|| {
252                anyhow::anyhow!("Batch {} completed but has no output file", batch_id)
253            })?;
254
255            let results_content = batches::download_file(
256                self.http_client.as_ref(),
257                OPEN_AI_API_URL,
258                &self.api_key,
259                &output_file_id,
260            )
261            .await
262            .map_err(|e| {
263                anyhow::anyhow!("Failed to download batch results for {}: {:?}", batch_id, e)
264            })?;
265
266            let results = batches::parse_batch_output(&results_content)
267                .map_err(|e| anyhow::anyhow!("Failed to parse batch output: {:?}", e))?;
268
269            let mut updates: Vec<(String, String, String)> = Vec::new();
270            let mut success_count = 0;
271            let mut error_count = 0;
272
273            for result in results {
274                let request_hash = result
275                    .custom_id
276                    .strip_prefix("req_hash_")
277                    .unwrap_or(&result.custom_id)
278                    .to_string();
279
280                if let Some(response_body) = result.response {
281                    if response_body.status_code == 200 {
282                        let response_json = serde_json::to_string(&response_body.body)?;
283                        updates.push((request_hash, response_json, batch_id.clone()));
284                        success_count += 1;
285                    } else {
286                        log::error!(
287                            "Batch request {} failed with status {}",
288                            request_hash,
289                            response_body.status_code
290                        );
291                        let error_json = serde_json::json!({
292                            "error": {
293                                "type": "http_error",
294                                "status_code": response_body.status_code
295                            }
296                        })
297                        .to_string();
298                        updates.push((request_hash, error_json, batch_id.clone()));
299                        error_count += 1;
300                    }
301                } else if let Some(error) = result.error {
302                    log::error!(
303                        "Batch request {} failed: {}: {}",
304                        request_hash,
305                        error.code,
306                        error.message
307                    );
308                    let error_json = serde_json::json!({
309                        "error": {
310                            "type": error.code,
311                            "message": error.message
312                        }
313                    })
314                    .to_string();
315                    updates.push((request_hash, error_json, batch_id.clone()));
316                    error_count += 1;
317                }
318            }
319
320            let connection = self.connection.lock().unwrap();
321            connection.with_savepoint("batch_import", || {
322                let q = sql!(
323                    INSERT OR REPLACE INTO openai_cache(request_hash, request, response, batch_id)
324                    VALUES (?, (SELECT request FROM openai_cache WHERE request_hash = ?), ?, ?)
325                );
326                let mut exec = connection.exec_bound::<(&str, &str, &str, &str)>(q)?;
327                for (request_hash, response_json, batch_id) in &updates {
328                    exec((
329                        request_hash.as_str(),
330                        request_hash.as_str(),
331                        response_json.as_str(),
332                        batch_id.as_str(),
333                    ))?;
334                }
335                Ok(())
336            })?;
337
338            log::info!(
339                "Imported batch {}: {} successful, {} errors",
340                batch_id,
341                success_count,
342                error_count
343            );
344        }
345
346        Ok(())
347    }
348
349    async fn download_finished_batches(&self) -> Result<()> {
350        let batch_ids: Vec<String> = {
351            let connection = self.connection.lock().unwrap();
352            let q = sql!(SELECT DISTINCT batch_id FROM openai_cache WHERE batch_id IS NOT NULL AND response IS NULL);
353            connection.select(q)?()?
354        };
355
356        for batch_id in &batch_ids {
357            let batch_status = batches::retrieve_batch(
358                self.http_client.as_ref(),
359                OPEN_AI_API_URL,
360                &self.api_key,
361                batch_id,
362            )
363            .await
364            .map_err(|e| anyhow::anyhow!("{:?}", e))?;
365
366            log::info!("Batch {} status: {}", batch_id, batch_status.status);
367
368            if batch_status.status == "completed" {
369                let output_file_id = match batch_status.output_file_id {
370                    Some(id) => id,
371                    None => {
372                        log::warn!("Batch {} completed but has no output file", batch_id);
373                        continue;
374                    }
375                };
376
377                let results_content = batches::download_file(
378                    self.http_client.as_ref(),
379                    OPEN_AI_API_URL,
380                    &self.api_key,
381                    &output_file_id,
382                )
383                .await
384                .map_err(|e| anyhow::anyhow!("{:?}", e))?;
385
386                let results = batches::parse_batch_output(&results_content)
387                    .map_err(|e| anyhow::anyhow!("Failed to parse batch output: {:?}", e))?;
388
389                let mut updates: Vec<(String, String)> = Vec::new();
390                let mut success_count = 0;
391
392                for result in results {
393                    let request_hash = result
394                        .custom_id
395                        .strip_prefix("req_hash_")
396                        .unwrap_or(&result.custom_id)
397                        .to_string();
398
399                    if let Some(response_body) = result.response {
400                        if response_body.status_code == 200 {
401                            let response_json = serde_json::to_string(&response_body.body)?;
402                            updates.push((response_json, request_hash));
403                            success_count += 1;
404                        } else {
405                            log::error!(
406                                "Batch request {} failed with status {}",
407                                request_hash,
408                                response_body.status_code
409                            );
410                            let error_json = serde_json::json!({
411                                "error": {
412                                    "type": "http_error",
413                                    "status_code": response_body.status_code
414                                }
415                            })
416                            .to_string();
417                            updates.push((error_json, request_hash));
418                        }
419                    } else if let Some(error) = result.error {
420                        log::error!(
421                            "Batch request {} failed: {}: {}",
422                            request_hash,
423                            error.code,
424                            error.message
425                        );
426                        let error_json = serde_json::json!({
427                            "error": {
428                                "type": error.code,
429                                "message": error.message
430                            }
431                        })
432                        .to_string();
433                        updates.push((error_json, request_hash));
434                    }
435                }
436
437                let connection = self.connection.lock().unwrap();
438                connection.with_savepoint("batch_download", || {
439                    let q = sql!(UPDATE openai_cache SET response = ? WHERE request_hash = ?);
440                    let mut exec = connection.exec_bound::<(&str, &str)>(q)?;
441                    for (response_json, request_hash) in &updates {
442                        exec((response_json.as_str(), request_hash.as_str()))?;
443                    }
444                    Ok(())
445                })?;
446                log::info!("Downloaded {} successful requests", success_count);
447            }
448        }
449
450        Ok(())
451    }
452
453    async fn upload_pending_requests(&self) -> Result<Vec<String>> {
454        const BATCH_CHUNK_SIZE: i32 = 16_000;
455        let mut all_batch_ids = Vec::new();
456        let mut total_uploaded = 0;
457
458        loop {
459            let rows: Vec<(String, String)> = {
460                let connection = self.connection.lock().unwrap();
461                let q = sql!(
462                    SELECT request_hash, request FROM openai_cache
463                    WHERE batch_id IS NULL AND response IS NULL
464                    LIMIT ?
465                );
466                connection.select_bound(q)?(BATCH_CHUNK_SIZE)?
467            };
468
469            if rows.is_empty() {
470                break;
471            }
472
473            let request_hashes: Vec<String> = rows.iter().map(|(hash, _)| hash.clone()).collect();
474
475            let mut jsonl_content = String::new();
476            for (hash, request_str) in &rows {
477                let serializable_request: SerializableRequest =
478                    serde_json::from_str(request_str).unwrap();
479
480                let messages: Vec<RequestMessage> = serializable_request
481                    .messages
482                    .into_iter()
483                    .map(|msg| match msg.role.as_str() {
484                        "user" => RequestMessage::User {
485                            content: MessageContent::Plain(msg.content),
486                        },
487                        "assistant" => RequestMessage::Assistant {
488                            content: Some(MessageContent::Plain(msg.content)),
489                            tool_calls: Vec::new(),
490                            reasoning_content: None,
491                        },
492                        "system" => RequestMessage::System {
493                            content: MessageContent::Plain(msg.content),
494                        },
495                        _ => RequestMessage::User {
496                            content: MessageContent::Plain(msg.content),
497                        },
498                    })
499                    .collect();
500
501                let request = OpenAiRequest {
502                    model: serializable_request.model,
503                    messages,
504                    stream: false,
505                    stream_options: None,
506                    max_completion_tokens: Some(serializable_request.max_tokens),
507                    max_tokens: None,
508                    stop: Vec::new(),
509                    temperature: None,
510                    tool_choice: None,
511                    parallel_tool_calls: None,
512                    service_tier: None,
513                    tools: Vec::new(),
514                    prompt_cache_key: None,
515                    reasoning_effort: None,
516                };
517
518                let custom_id = format!("req_hash_{}", hash);
519                let batch_item = batches::BatchRequestItem::new(custom_id, request);
520                let line = batch_item
521                    .to_jsonl_line()
522                    .map_err(|e| anyhow::anyhow!("Failed to serialize batch item: {:?}", e))?;
523                jsonl_content.push_str(&line);
524                jsonl_content.push('\n');
525            }
526
527            let filename = format!("batch_{}.jsonl", chrono::Utc::now().timestamp());
528            let file_obj = batches::upload_batch_file(
529                self.http_client.as_ref(),
530                OPEN_AI_API_URL,
531                &self.api_key,
532                &filename,
533                jsonl_content.into_bytes(),
534            )
535            .await
536            .map_err(|e| anyhow::anyhow!("Failed to upload batch file: {:?}", e))?;
537
538            let batch = batches::create_batch(
539                self.http_client.as_ref(),
540                OPEN_AI_API_URL,
541                &self.api_key,
542                batches::CreateBatchRequest::new(file_obj.id),
543            )
544            .await
545            .map_err(|e| anyhow::anyhow!("Failed to create batch: {:?}", e))?;
546
547            {
548                let connection = self.connection.lock().unwrap();
549                connection.with_savepoint("batch_upload", || {
550                    let q = sql!(UPDATE openai_cache SET batch_id = ? WHERE request_hash = ?);
551                    let mut exec = connection.exec_bound::<(&str, &str)>(q)?;
552                    for hash in &request_hashes {
553                        exec((batch.id.as_str(), hash.as_str()))?;
554                    }
555                    Ok(())
556                })?;
557            }
558
559            let batch_len = rows.len();
560            total_uploaded += batch_len;
561            log::info!(
562                "Uploaded batch {} with {} requests ({} total)",
563                batch.id,
564                batch_len,
565                total_uploaded
566            );
567
568            all_batch_ids.push(batch.id);
569        }
570
571        if !all_batch_ids.is_empty() {
572            log::info!(
573                "Finished uploading {} batches with {} total requests",
574                all_batch_ids.len(),
575                total_uploaded
576            );
577        }
578
579        Ok(all_batch_ids)
580    }
581
582    fn request_hash(
583        model: &str,
584        max_tokens: u64,
585        messages: &[RequestMessage],
586        seed: Option<usize>,
587    ) -> String {
588        let mut hasher = std::hash::DefaultHasher::new();
589        "openai".hash(&mut hasher);
590        model.hash(&mut hasher);
591        max_tokens.hash(&mut hasher);
592        for msg in messages {
593            message_content_to_string(msg).hash(&mut hasher);
594        }
595        if let Some(seed) = seed {
596            seed.hash(&mut hasher);
597        }
598        let request_hash = hasher.finish();
599        format!("{request_hash:016x}")
600    }
601}
602
603fn message_role_to_string(msg: &RequestMessage) -> String {
604    match msg {
605        RequestMessage::User { .. } => "user".to_string(),
606        RequestMessage::Assistant { .. } => "assistant".to_string(),
607        RequestMessage::System { .. } => "system".to_string(),
608        RequestMessage::Tool { .. } => "tool".to_string(),
609    }
610}
611
612fn message_content_to_string(msg: &RequestMessage) -> String {
613    match msg {
614        RequestMessage::User { content } => content_to_string(content),
615        RequestMessage::Assistant { content, .. } => {
616            content.as_ref().map(content_to_string).unwrap_or_default()
617        }
618        RequestMessage::System { content } => content_to_string(content),
619        RequestMessage::Tool { content, .. } => content_to_string(content),
620    }
621}
622
623fn content_to_string(content: &MessageContent) -> String {
624    match content {
625        MessageContent::Plain(text) => text.clone(),
626        MessageContent::Multipart(parts) => parts
627            .iter()
628            .filter_map(|part| match part {
629                open_ai::MessagePart::Text { text } => Some(text.clone()),
630                _ => None,
631            })
632            .collect::<Vec<String>>()
633            .join("\n"),
634    }
635}
636
637pub enum OpenAiClient {
638    Plain(PlainOpenAiClient),
639    Batch(BatchingOpenAiClient),
640    #[allow(dead_code)]
641    Dummy,
642}
643
644impl OpenAiClient {
645    pub fn plain() -> Result<Self> {
646        Ok(Self::Plain(PlainOpenAiClient::new()?))
647    }
648
649    pub fn batch(cache_path: &Path) -> Result<Self> {
650        Ok(Self::Batch(BatchingOpenAiClient::new(cache_path)?))
651    }
652
653    #[allow(dead_code)]
654    pub fn dummy() -> Self {
655        Self::Dummy
656    }
657
658    pub async fn generate(
659        &self,
660        model: &str,
661        max_tokens: u64,
662        messages: Vec<RequestMessage>,
663        seed: Option<usize>,
664        cache_only: bool,
665    ) -> Result<Option<OpenAiResponse>> {
666        match self {
667            OpenAiClient::Plain(plain_client) => plain_client
668                .generate(model, max_tokens, messages)
669                .await
670                .map(Some),
671            OpenAiClient::Batch(batching_client) => {
672                batching_client
673                    .generate(model, max_tokens, messages, seed, cache_only)
674                    .await
675            }
676            OpenAiClient::Dummy => panic!("Dummy OpenAI client is not expected to be used"),
677        }
678    }
679
680    pub async fn sync_batches(&self) -> Result<()> {
681        match self {
682            OpenAiClient::Plain(_) => Ok(()),
683            OpenAiClient::Batch(batching_client) => batching_client.sync_batches().await,
684            OpenAiClient::Dummy => panic!("Dummy OpenAI client is not expected to be used"),
685        }
686    }
687
688    pub fn pending_batch_count(&self) -> Result<usize> {
689        match self {
690            OpenAiClient::Plain(_) => Ok(0),
691            OpenAiClient::Batch(batching_client) => batching_client.pending_batch_count(),
692            OpenAiClient::Dummy => panic!("Dummy OpenAI client is not expected to be used"),
693        }
694    }
695
696    pub async fn import_batches(&self, batch_ids: &[String]) -> Result<()> {
697        match self {
698            OpenAiClient::Plain(_) => {
699                anyhow::bail!("Import batches is only supported with batching client")
700            }
701            OpenAiClient::Batch(batching_client) => batching_client.import_batches(batch_ids).await,
702            OpenAiClient::Dummy => panic!("Dummy OpenAI client is not expected to be used"),
703        }
704    }
705}
706
Served at tenant.openagents/omega Member data and write actions are omitted.