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).
279 lines
8.9 KiB
Rust
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,
|
|
};
|