open-standards/eoc/eoc-rs/crates/eoc-vendor-api/src/openai_compat.rs
Transaction Science 01d4ad9c7b Ingest batch 2: 9 new crates across openpay / smart-byte / eoc
openpay/ (+3 crates, ~8400 LOC, 162 tests)
  op-iso8583/         ISO 8583 messages for direct acquirer / card-network
                      connectivity. MTIs, primary+secondary bitmaps, full
                      DE catalogue, BCD/ASCII/EBCDIC/LLVAR/LLLVAR codecs,
                      five dialects (Visa Base I, Mastercard MDS, Amex GNS,
                      Discover, JCB), network-management messages
                      (sign-on/echo/cutover), DE 55 EMV TLV.
  op-mobile-wallets/  Apple Pay (ECDH-P256 + AES-256-GCM), Google Pay ECv2
                      (ECDH + HKDF + AES-256-CTR + HMAC), Samsung Pay. PAN
                      never leaves the decryption boundary as a String;
                      lands in VaultRef. No-PAN-leakage assertions per
                      wallet.
  op-routing/         Least-cost routing + MCC-aware routing + intelligent
                      retry / soft-decline recovery. 231-MCC catalogue.
                      Real ISO 8583 DE-39 soft/hard decline taxonomies.

smart-byte/smart-byte-rs/ (+3 crates, ~7100 LOC, 96 tests)
  smart-byte-vc/      W3C Verifiable Credentials 2.0: VCDM, presentations,
                      Data Integrity proofs (eddsa-jcs-2022), VC-JWT,
                      SD-JWT (selective disclosure), Bitstring Status
                      List 2021. Cargo-bridge maps VCs to Smart Byte
                      envelopes with SAID stability.
  smart-byte-did/     W3C DID resolution: did:key (Ed25519/P-256/secp256k1),
                      did:web (HTTPS), did:peer (numalgo 2+4), did:jwk,
                      did:ion (feature-gated stub). DID URL dereferencer.
  smart-byte-pq/      Post-quantum signatures: ML-DSA-44/65/87 (FIPS 204),
                      SLH-DSA across 12 parameter sets (FIPS 205), FN-DSA
                      (draft FIPS 206, feature-gated). Hybrid Ed25519 +
                      ML-DSA pattern for the transition. Secret-key
                      zeroize-on-drop.

eoc/eoc-rs/ (+3 crates, ~6450 LOC, 76 tests)
  eoc-vendor-api/     NeuralBackend implementations: Anthropic, OpenAI,
                      Google, Mistral, Cohere, Groq, Together, Fireworks.
                      SSE streaming. Prompt-caching where supported.
                      Joule estimates per published energy coefficient
                      (12-model curated table).
  eoc-embeddings/     21 embedding models behind one Embedder trait:
                      OpenAI v3 (3), Cohere v3 (3), Voyage (5), Mistral,
                      Jina, plus local ONNX (BGE x3, nomic, mxbai, GTE,
                      E5) behind 'local' feature. Matryoshka-style
                      dimension projection. Per-content-hash cache.
  eoc-route-learned/  RouteLLM-style matrix-factorisation router +
                      multinomial-logreg classifier + LinUCB / Thompson
                      bandits for online learning. LearnedCascade wraps
                      the rule-based cascade with a confidence-threshold
                      skip policy. Synthetic-eval accuracy: MF 0.935,
                      LogReg 0.980.

Quality:
- cargo check across all three workspaces: clean
- cargo clippy --all-targets -- -D warnings on every new crate: clean
- 334 new tests total; all passing
- #![forbid(unsafe_code)] across every new crate
- No unwrap/expect outside #[cfg(test)]

Rust 1.95.0 edition 2024 (inherited via root rust-toolchain.toml).
2026-05-23 18:12:24 -04:00

279 lines
8.9 KiB
Rust

//! Shared helper for OpenAI-compatible Chat-Completions endpoints.
//!
//! OpenAI, Mistral, Groq, Together, and Fireworks all accept the same
//! request shape (`{ model, messages: [{role, content}], stream }`) and
//! return the same response shape. This module owns that shared logic;
//! per-vendor modules only provide the endpoint URL and the
//! [`Auth`](crate::Auth) variant.
use std::time::Duration;
use eoc_core::{Query, Response, Stage};
use futures_util::StreamExt;
use reqwest::header::{HeaderMap, CONTENT_TYPE};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::auth::Auth;
use crate::config::{RetryPolicy, VendorConfig};
use crate::error::{VendorError, VendorResult};
/// One chat message in the OpenAI schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
/// `system` | `user` | `assistant`.
pub role: String,
/// Free-form text content.
pub content: String,
}
/// OpenAI-compatible request body.
#[derive(Debug, Clone, Serialize)]
pub struct ChatRequest {
/// Vendor-qualified model name.
pub model: String,
/// Ordered messages.
pub messages: Vec<ChatMessage>,
/// Stream incremental tokens (SSE).
pub stream: bool,
/// Optional max-tokens cap.
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
}
/// OpenAI-compatible non-streaming response body.
#[derive(Debug, Clone, Deserialize)]
pub struct ChatResponse {
/// Per-choice completions; we use the first.
pub choices: Vec<ChatChoice>,
/// Token usage (always present for OpenAI / clones).
pub usage: Option<ChatUsage>,
}
/// One completion alternative.
#[derive(Debug, Clone, Deserialize)]
pub struct ChatChoice {
/// The completed assistant message.
pub message: Option<ChatMessage>,
/// Reason the generation stopped (`stop`, `length`,
/// `content_filter`, etc).
pub finish_reason: Option<String>,
}
/// Token usage counters.
#[derive(Debug, Clone, Copy, Deserialize, Default)]
pub struct ChatUsage {
/// Prompt-side tokens.
pub prompt_tokens: u32,
/// Completion-side tokens.
pub completion_tokens: u32,
}
/// Streaming SSE delta frame.
#[derive(Debug, Clone, Deserialize)]
struct ChatStreamFrame {
choices: Vec<ChatStreamChoice>,
usage: Option<ChatUsage>,
}
#[derive(Debug, Clone, Deserialize)]
struct ChatStreamChoice {
delta: ChatStreamDelta,
finish_reason: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct ChatStreamDelta {
content: Option<String>,
}
/// Build the `ChatRequest` for a [`Query`]. Wraps the prompt in a single
/// `user` message; system prompts are not part of the [`Query`] surface
/// yet (they belong on the EOC side, not the vendor side).
pub fn build_request(model: &str, q: &Query, stream: bool) -> ChatRequest {
ChatRequest {
model: model.to_string(),
messages: vec![ChatMessage {
role: "user".to_string(),
content: q.prompt.clone(),
}],
stream,
max_tokens: None,
}
}
/// Estimate prompt tokens via a 4-chars-per-token heuristic. Used only
/// for joule estimation when the vendor does not return a `usage` block.
fn estimate_tokens(s: &str) -> u32 {
((s.chars().count() as f64) / 4.0).ceil().max(1.0) as u32
}
/// Execute an OpenAI-compatible chat-completions call.
///
/// `endpoint` is the absolute URL (e.g. `https://api.openai.com/v1/chat/completions`).
/// `auth` provides the bearer credentials. `stream` selects between
/// SSE and one-shot.
#[allow(clippy::too_many_arguments)]
pub async fn execute(
client: &reqwest::Client,
endpoint: &str,
auth: &Auth,
model: &str,
q: &Query,
stream: bool,
config: &VendorConfig,
) -> VendorResult<Response> {
let body = build_request(model, q, stream);
let mut attempt: u32 = 0;
let policy = config.retry_policy;
loop {
let outcome = single_call(client, endpoint, auth, &body, stream, config.timeout).await;
match outcome {
Ok((text, usage)) => {
let in_tokens = usage
.map(|u| u.prompt_tokens)
.unwrap_or_else(|| estimate_tokens(&q.prompt));
let out_tokens = usage
.map(|u| u.completion_tokens)
.unwrap_or_else(|| estimate_tokens(&text));
let cost = config.joule_estimator.estimate(in_tokens, out_tokens, model);
return Ok(Response::new(q.id, text, Stage::Neural, cost));
}
Err(e) if is_retryable(&e) && attempt < policy.max_retries => {
warn!(attempt, error = %e, "retryable vendor error; backing off");
tokio::time::sleep(policy.backoff_for(attempt)).await;
attempt += 1;
continue;
}
Err(e) => return Err(e),
}
}
}
fn is_retryable(e: &VendorError) -> bool {
match e {
VendorError::RateLimited { .. }
| VendorError::Timeout
| VendorError::NetworkError(_) => true,
VendorError::Unexpected { status, .. } => (500..=599).contains(status),
_ => false,
}
}
/// Single HTTP call — returns `(decoded_text, optional_usage)`.
async fn single_call(
client: &reqwest::Client,
endpoint: &str,
auth: &Auth,
body: &ChatRequest,
stream: bool,
timeout: Duration,
) -> VendorResult<(String, Option<ChatUsage>)> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/json".parse().expect("static"));
auth.apply(&mut headers)?;
let resp = client
.post(endpoint)
.timeout(timeout)
.headers(headers)
.json(body)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
return Err(classify_error(status, resp).await);
}
if stream {
decode_stream(resp).await
} else {
let parsed: ChatResponse = resp.json().await?;
let text = parsed
.choices
.first()
.and_then(|c| c.message.as_ref())
.map(|m| m.content.clone())
.unwrap_or_default();
Ok((text, parsed.usage))
}
}
async fn decode_stream(resp: reqwest::Response) -> VendorResult<(String, Option<ChatUsage>)> {
let mut text = String::new();
let mut usage: Option<ChatUsage> = None;
let mut buf = String::new();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let bytes = chunk.map_err(VendorError::from)?;
buf.push_str(&String::from_utf8_lossy(&bytes));
// Drain complete SSE events (terminated by blank line).
while let Some(idx) = buf.find("\n\n") {
let event = buf[..idx].to_string();
buf.drain(..idx + 2);
for line in event.lines() {
let line = line.trim_start();
let Some(payload) = line.strip_prefix("data:") else {
continue;
};
let payload = payload.trim();
if payload == "[DONE]" || payload.is_empty() {
continue;
}
match serde_json::from_str::<ChatStreamFrame>(payload) {
Ok(frame) => {
if let Some(u) = frame.usage {
usage = Some(u);
}
for c in frame.choices {
if let Some(delta) = c.delta.content {
text.push_str(&delta);
}
if c.finish_reason.as_deref() == Some("content_filter") {
return Err(VendorError::ContentFiltered(
"content_filter finish_reason".to_string(),
));
}
}
}
Err(e) => {
debug!(?e, "skipping malformed SSE frame");
}
}
}
}
}
Ok((text, usage))
}
async fn classify_error(status: reqwest::StatusCode, resp: reqwest::Response) -> VendorError {
let retry_after = resp
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
let body = resp
.text()
.await
.unwrap_or_default()
.chars()
.take(512)
.collect::<String>();
match status.as_u16() {
429 => VendorError::RateLimited {
retry_after_secs: retry_after,
},
401 | 403 => VendorError::InvalidApiKey,
404 => VendorError::ModelNotFound(body),
s => VendorError::Unexpected { status: s, body },
}
}
#[allow(dead_code)]
pub(crate) const DEFAULT_RETRY: RetryPolicy = RetryPolicy {
max_retries: 3,
initial_backoff: Duration::from_millis(250),
backoff_factor: 2.0,
};