Watch
1
0
Fork
You've already forked souveraine
0

feat(bridge): LlmProvider trait + OAuth-riding ChatGPT provider

Introduce an `LlmProvider` trait (the engine<->LLM seam, sibling to the
`Backend` harness<->engine trait) so inference can route to providers
beyond the Bifrost gateway. Two impls behind it:

- `BifrostClient` - existing OpenAI-compatible gateway (default).
- `OpenAiOAuthProvider` - rides the Codex CLI's ChatGPT login
  (`~/.codex/auth.json`) and drives `chatgpt.com/backend-api/codex/responses`
  (Responses API) with no API key. Self-refreshes the token (single-flight,
  write-back, CLI re-read fallback) and translates the engine's OpenAI-chat
  request to/from the Responses API + SSE accumulation.

Selected via `[bifrost] provider` ("bifrost" | "openai-oauth"). The engine
keeps speaking the existing ChatCompletionRequest/CompletionResult/
InferenceStrain currency, so all six inference call-sites are unchanged -
only the field type flips to `Arc<dyn LlmProvider>`.

Model ids are translated at the provider boundary (oauth/catalog.rs::resolve):
Bifrost-namespaced ids (`openai/...`, `-precision`) map onto served ChatGPT
models; `-fast` -> priority service tier.

Verified live to the wire level: builds+links, server boots in oauth mode
(reads the Codex token), and chatgpt.com accepts the request (auth, endpoint,
headers, payload all valid). The SSE->CompletionResult accumulation is NOT yet
verified against a successful completion (blocked by a subscription usage limit
at test time) - needs one live turn to confirm end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
E 2026-05-29 14:27:53 -04:00
commit bc6ee12d47
19 changed files with 895 additions and 35 deletions

View file

@ -3,12 +3,25 @@
# === BIFROST INFERENCE ===
[bifrost]
# Active provider implementation:
# "bifrost" (default) — any OpenAI-compatible gateway via base_url below.
# "openai-oauth" — ride the Codex CLI's ChatGPT login (run
# `codex login` first); drives
# chatgpt.com/backend-api/codex/responses with no
# API key. base_url/api_key/virtual_key are ignored
# in this mode; set primary_model to a ChatGPT model
# (e.g. "gpt-5.5"). Bifrost-namespaced ids are
# tolerated and mapped to the provider default.
# provider = "bifrost"
# Bifrost is the OpenAI-compatible API gateway
base_url = "http://127.0.0.1:3360"
api_key = "" # Set via `souveraine auth set` or BIFROST_KEY env var
virtual_key = "" # x-bf-vk header if required by provider
# Default model for conversation
# Default model for conversation.
# Bifrost mode: a gateway route like "openai/kimi-k2.6".
# openai-oauth mode: a ChatGPT model like "gpt-5.5".
primary_model = "openai/kimi-k2.6"
# Request timeout in seconds for each LLM call attempt (default: 120).

View file

@ -1,8 +1,11 @@
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tracing::{debug, info, warn};
use crate::bridge::provider::LlmProvider;
/// Bifrost Inference Client
///
/// Bifrost is an OpenAI-compatible API gateway: http://127.0.0.1:3360/v1
@ -664,6 +667,35 @@ impl BifrostClient {
}
}
/// `BifrostClient` is the OpenAI-compatible gateway implementation of the
/// provider seam. The inherent methods do the work; the trait just exposes them
/// behind `dyn LlmProvider` so the engine can hold any provider uniformly.
#[async_trait]
impl LlmProvider for BifrostClient {
fn id(&self) -> &str {
"bifrost"
}
fn default_model(&self) -> &str {
&self.default_model
}
async fn list_models(&self) -> Result<Vec<String>> {
BifrostClient::list_models(self).await
}
async fn chat_completion_with_strain(
&self,
request: ChatCompletionRequest,
) -> Result<(CompletionResult, Vec<InferenceStrain>)> {
BifrostClient::chat_completion_with_strain(self, request).await
}
async fn chat_completion(&self, request: ChatCompletionRequest) -> Result<CompletionResult> {
BifrostClient::chat_completion(self, request).await
}
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -1,8 +1,53 @@
/// Bridge module — LLM inference client
/// Bridge module — LLM inference providers.
///
/// Connects Souveraine to inference providers (Bifrost, Ollama, etc.)
/// Abstraction over HTTP providers with streaming support.
/// Connects Souveraine to inference providers behind the [`LlmProvider`] trait.
/// `BifrostClient` speaks to any OpenAI-compatible gateway; the OAuth-riding
/// ChatGPT provider lives in [`providers`]. [`build_provider`] selects the
/// active one from config.
pub mod bifrost;
pub mod model_router;
pub mod oauth;
pub mod provider;
pub mod providers;
pub use bifrost::BifrostClient;
pub use provider::LlmProvider;
use std::sync::Arc;
use crate::core::config::ConsciousnessConfig;
/// Build the active LLM provider from config.
///
/// `[bifrost] provider` selects the implementation:
/// - `"bifrost"` (default) — any OpenAI-compatible gateway via `BifrostClient`.
/// - `"openai-oauth"` — ride the Codex CLI's ChatGPT login and drive
/// `backend-api/codex/responses`.
pub fn build_provider(config: &ConsciousnessConfig) -> anyhow::Result<Arc<dyn LlmProvider>> {
let bf = &config.bifrost;
match bf.provider.as_str() {
"openai-oauth" | "openai-codex" => {
let provider = providers::openai_oauth::OpenAiOAuthProvider::from_codex_login(
bf.primary_model.clone(),
bf.timeout_secs,
)?;
Ok(Arc::new(provider))
}
_ => {
// Mirror the precision fallback the server used to build inline.
let mut fallbacks = Vec::new();
if !bf.primary_model.ends_with("-precision") {
fallbacks.push(format!("{}-precision", bf.primary_model));
}
let client = BifrostClient::new(
&bf.base_url,
&bf.api_key,
&bf.virtual_key,
&bf.primary_model,
bf.timeout_secs,
)
.with_fallbacks(fallbacks);
Ok(Arc::new(client))
}
}
}

View file

@ -0,0 +1,74 @@
//! The model catalog for the OAuth provider.
//!
//! The ChatGPT codex backend exposes no usable `/v1/models` listing, so — like
//! Letta — we ship a curated list of models a ChatGPT Plus/Pro/Team
//! subscription can drive through `backend-api/codex`. The `/models` *poll*
//! lives on the OpenAI-compatible (gateway) provider; this is its OAuth-side
//! counterpart.
/// `(model id, context window tokens)`.
pub const MODELS: &[(&str, usize)] = &[
("gpt-5.5", 272_000),
("gpt-5.5-codex", 272_000),
("gpt-5.4", 272_000),
("gpt-5.4-codex", 272_000),
("gpt-5.1", 272_000),
("gpt-5.1-codex", 272_000),
("gpt-4o", 128_000),
("o3", 200_000),
("o4-mini", 200_000),
];
/// Used when neither the requested model nor the configured fallback names a
/// model this backend serves.
pub const DEFAULT_MODEL: &str = "gpt-5.5";
pub fn model_ids() -> Vec<String> {
MODELS.iter().map(|(name, _)| (*name).to_string()).collect()
}
pub fn context_window(model: &str) -> usize {
MODELS
.iter()
.find(|(name, _)| *name == model)
.map(|(_, ctx)| *ctx)
.unwrap_or(128_000)
}
pub fn is_known(model: &str) -> bool {
MODELS.iter().any(|(name, _)| *name == model)
}
/// Strip a gateway routing prefix (`"openai/gpt-5.5"` → `"gpt-5.5"`) and
/// Bifrost's `-precision` fallback suffix, which the ChatGPT backend has no
/// concept of. A trailing `-fast` is preserved so the payload builder can map
/// it to a priority service tier.
fn strip_routing(requested: &str) -> &str {
let bare = requested.rsplit('/').next().unwrap_or(requested);
bare.strip_suffix("-precision").unwrap_or(bare)
}
/// Resolve an engine-supplied model id onto one the ChatGPT codex backend
/// actually serves.
///
/// The engine is provider-agnostic and threads Bifrost-namespaced ids
/// (`openai/kimi-k2.6`, hardcoded subsystem fallbacks like `openai/glm-5.1`,
/// etc.) through every call. Those are meaningless to the codex backend, so the
/// provider translates at its boundary: strip the routing prefix/suffix, and if
/// the result still isn't in the catalog, fall back to `fallback` (the
/// provider's own default), then to [`DEFAULT_MODEL`]. This keeps the OAuth
/// provider runnable no matter what vocabulary the engine speaks.
pub fn resolve(requested: &str, fallback: &str) -> String {
let bare = strip_routing(requested);
let base = bare.strip_suffix("-fast").unwrap_or(bare);
if is_known(base) {
return bare.to_string();
}
let fb = strip_routing(fallback);
let fb_base = fb.strip_suffix("-fast").unwrap_or(fb);
if is_known(fb_base) {
fb.to_string()
} else {
DEFAULT_MODEL.to_string()
}
}

View file

@ -0,0 +1,107 @@
//! Reading and writing the Codex CLI's ChatGPT OAuth login (`~/.codex/auth.json`).
use std::path::PathBuf;
use anyhow::{anyhow, Context, Result};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use serde_json::Value;
/// The ChatGPT OAuth credential, as minted by `codex login`.
#[derive(Debug, Clone)]
pub struct CodexCredentials {
pub access_token: String,
pub refresh_token: String,
/// `ChatGPT-Account-Id` header value.
pub account_id: String,
/// Unix epoch milliseconds when the access token expires.
pub expires_at: i64,
}
pub fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}
/// `$CODEX_HOME` if set, else `~/.codex`.
fn codex_home() -> PathBuf {
if let Ok(dir) = std::env::var("CODEX_HOME") {
if !dir.trim().is_empty() {
return PathBuf::from(shellexpand::tilde(&dir).into_owned());
}
}
dirs::home_dir().unwrap_or_default().join(".codex")
}
fn auth_path() -> PathBuf {
codex_home().join("auth.json")
}
/// Decode a JWT's `exp` claim (seconds) into epoch milliseconds.
fn jwt_exp_ms(token: &str) -> Option<i64> {
let payload = token.split('.').nth(1)?;
let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?;
let json: Value = serde_json::from_slice(&bytes).ok()?;
Some(json.get("exp")?.as_i64()? * 1000)
}
/// Read the Codex CLI login from disk.
pub fn read() -> Result<CodexCredentials> {
let path = auth_path();
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading codex auth at {}", path.display()))?;
let json: Value = serde_json::from_str(&raw)
.with_context(|| format!("parsing codex auth at {}", path.display()))?;
let tokens = json
.get("tokens")
.ok_or_else(|| anyhow!("codex auth.json has no `tokens` (run `codex login`)"))?;
let access = tokens
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("codex auth.json missing tokens.access_token"))?
.to_string();
let refresh = tokens
.get("refresh_token")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("codex auth.json missing tokens.refresh_token"))?
.to_string();
let account_id = tokens
.get("account_id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
// Prefer the JWT's own expiry; fall back to file mtime + 1h.
let expires_at = jwt_exp_ms(&access).unwrap_or_else(|| {
let mtime = std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64);
mtime.unwrap_or_else(now_ms) + 3_600_000
});
Ok(CodexCredentials {
access_token: access,
refresh_token: refresh,
account_id,
expires_at,
})
}
/// Write rotated tokens back into `auth.json`, preserving every other field.
/// Best-effort: the Codex CLI may own the file, so callers ignore failure.
pub fn write_back(creds: &CodexCredentials) -> Result<()> {
let path = auth_path();
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading codex auth at {}", path.display()))?;
let mut json: Value = serde_json::from_str(&raw)?;
let tokens = json
.get_mut("tokens")
.ok_or_else(|| anyhow!("codex auth.json has no `tokens`"))?;
tokens["access_token"] = Value::String(creds.access_token.clone());
tokens["refresh_token"] = Value::String(creds.refresh_token.clone());
std::fs::write(&path, serde_json::to_string_pretty(&json)?)
.with_context(|| format!("writing codex auth at {}", path.display()))?;
Ok(())
}

10
src/bridge/oauth/mod.rs Normal file
View file

@ -0,0 +1,10 @@
//! Riding the Codex CLI's ChatGPT OAuth login.
//!
//! Souveraine does not run its own ChatGPT login flow — it reads the token the
//! Codex CLI already minted (`~/.codex/auth.json`), refreshes it against
//! OpenAI's token endpoint when it ages out, and writes the rotated token back
//! so the two stay in sync. The same "ride the CLI" move OpenClaw makes.
pub mod catalog;
pub mod codex_creds;
pub mod refresh;

View file

@ -0,0 +1,91 @@
//! ChatGPT OAuth token refresh.
//!
//! Self-refresh against OpenAI's token endpoint using the Codex CLI's public
//! client id, then write the rotated token back to `~/.codex/auth.json`.
//! Refresh tokens are one-time-use, so two processes refreshing the same token
//! race; the caller serializes us behind a mutex (single-flight), and if the
//! refresh token was already spent we re-read the file — the CLI may have
//! rotated it out from under us.
use anyhow::{anyhow, Context, Result};
use serde_json::Value;
use super::codex_creds::{self, now_ms, CodexCredentials};
const TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
/// Public client id of the Codex CLI OAuth app — the refresh must be attributed
/// to the same client that minted the token.
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
/// Refresh this many ms before the token actually expires.
const REFRESH_BUFFER_MS: i64 = 300_000;
fn is_expired(creds: &CodexCredentials) -> bool {
now_ms() >= creds.expires_at - REFRESH_BUFFER_MS
}
/// Ensure `creds` holds a fresh access token, refreshing in place if needed.
pub async fn ensure_fresh(http: &reqwest::Client, creds: &mut CodexCredentials) -> Result<()> {
if !is_expired(creds) {
return Ok(());
}
match refresh(http, creds).await {
Ok(fresh) => {
*creds = fresh;
// Best-effort: keep the CLI's file in sync. The CLI may own it.
let _ = codex_creds::write_back(creds);
Ok(())
}
Err(e) => {
// Refresh failed — perhaps the CLI already rotated the token. If the
// file now holds a newer, still-valid token, adopt it.
if let Ok(reread) = codex_creds::read() {
if reread.access_token != creds.access_token && now_ms() < reread.expires_at {
*creds = reread;
return Ok(());
}
}
Err(e)
}
}
}
async fn refresh(http: &reqwest::Client, creds: &CodexCredentials) -> Result<CodexCredentials> {
let resp = http
.post(TOKEN_URL)
.form(&[
("grant_type", "refresh_token"),
("refresh_token", creds.refresh_token.as_str()),
("client_id", CLIENT_ID),
])
.send()
.await
.context("posting ChatGPT OAuth token refresh")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("ChatGPT token refresh failed ({}): {}", status, body));
}
let json: Value = resp.json().await.context("parsing token refresh response")?;
let access = json
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("token refresh response missing access_token"))?
.to_string();
// OpenAI may or may not rotate the refresh token; keep the old one if not.
let refresh_token = json
.get("refresh_token")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| creds.refresh_token.clone());
let expires_in = json.get("expires_in").and_then(|v| v.as_i64()).unwrap_or(3600);
Ok(CodexCredentials {
access_token: access,
refresh_token,
account_id: creds.account_id.clone(),
expires_at: now_ms() + expires_in * 1000,
})
}

42
src/bridge/provider.rs Normal file
View file

@ -0,0 +1,42 @@
//! The `LlmProvider` trait — the engine↔LLM seam.
//!
//! This is the inference-side sibling of the harness↔engine [`Backend`] trait
//! (`crate::backend`). Where `Backend` decides *where the engine runs* (in
//! process vs. a remote `souveraine server`), `LlmProvider` decides *who
//! answers the model call*. Everything the engine speaks is the existing
//! OpenAI-chat currency (`ChatCompletionRequest` / `CompletionResult` /
//! `InferenceStrain`); an implementation that talks a different wire format
//! (e.g. the ChatGPT Responses API) adapts internally and hands back that same
//! currency, so the primary loop, subconscious, archivist, reflection, and
//! compaction paths never learn which provider they are talking to.
use anyhow::Result;
use async_trait::async_trait;
use super::bifrost::{ChatCompletionRequest, CompletionResult, InferenceStrain};
#[async_trait]
pub trait LlmProvider: Send + Sync {
/// Stable provider id for logs / routing, e.g. `"bifrost"` or `"openai-oauth"`.
fn id(&self) -> &str;
/// The model used when a request does not pin one of its own.
fn default_model(&self) -> &str;
/// Models this provider can drive — feeds the `/model` picker and
/// `ModelRouter`'s merged catalog.
async fn list_models(&self) -> Result<Vec<String>>;
/// The one inference verb. Returns the completion plus any strain the body
/// felt (retries, rate limits) so the organism can register provider health
/// on the event bus / TUI.
async fn chat_completion_with_strain(
&self,
request: ChatCompletionRequest,
) -> Result<(CompletionResult, Vec<InferenceStrain>)>;
/// Convenience wrapper that drops the strain channel.
async fn chat_completion(&self, request: ChatCompletionRequest) -> Result<CompletionResult> {
Ok(self.chat_completion_with_strain(request).await?.0)
}
}

View file

@ -0,0 +1,7 @@
//! Concrete [`LlmProvider`](crate::bridge::provider::LlmProvider) implementations.
//!
//! `BifrostClient` (the OpenAI-compatible gateway client) implements the trait
//! in `bridge::bifrost`; the OAuth-riding ChatGPT provider lives here.
pub mod openai_oauth;
pub mod responses;

View file

@ -0,0 +1,173 @@
//! OpenAI as an OAuth-riding provider.
//!
//! Drives ChatGPT Plus/Pro/Team models through
//! `chatgpt.com/backend-api/codex/responses`, authenticated by the Codex CLI's
//! OAuth token (no API key). Translates the engine's OpenAI-chat request into
//! the Responses API, accumulates the SSE stream back into a `CompletionResult`,
//! and synthesizes `InferenceStrain` from retries so the body still feels a
//! hoarse provider.
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use tokio::sync::Mutex;
use tracing::warn;
use crate::bridge::bifrost::{ChatCompletionRequest, CompletionResult, InferenceStrain, RetryPolicy};
use crate::bridge::oauth::codex_creds::{self, CodexCredentials};
use crate::bridge::oauth::{catalog, refresh};
use crate::bridge::provider::LlmProvider;
use super::responses;
const ENDPOINT: &str = "https://chatgpt.com/backend-api/codex/responses";
pub struct OpenAiOAuthProvider {
http: reqwest::Client,
/// The mutex serializes refreshes — a one-time-use refresh token must not be
/// spent by two concurrent calls (single-flight).
creds: Arc<Mutex<CodexCredentials>>,
default_model: String,
retry: RetryPolicy,
}
impl OpenAiOAuthProvider {
/// Build by riding the Codex CLI's existing ChatGPT login.
pub fn from_codex_login(default_model: String, timeout_secs: u64) -> Result<Self> {
let creds = codex_creds::read()
.context("reading Codex CLI ChatGPT login (run `codex login` first)")?;
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.expect("reqwest Client::builder() should never fail with static config");
// The configured `primary_model` may be a Bifrost-namespaced id
// (`openai/…`); pin the provider default to a model this backend serves.
let default_model = catalog::resolve(&default_model, catalog::DEFAULT_MODEL);
Ok(Self {
http,
creds: Arc::new(Mutex::new(creds)),
default_model,
retry: RetryPolicy::default(),
})
}
/// Refresh if needed and return a usable `(access_token, account_id)`.
async fn auth(&self) -> Result<(String, String)> {
let mut creds = self.creds.lock().await;
refresh::ensure_fresh(&self.http, &mut creds).await?;
Ok((creds.access_token.clone(), creds.account_id.clone()))
}
}
#[async_trait]
impl LlmProvider for OpenAiOAuthProvider {
fn id(&self) -> &str {
"openai-oauth"
}
fn default_model(&self) -> &str {
&self.default_model
}
async fn list_models(&self) -> Result<Vec<String>> {
Ok(catalog::model_ids())
}
async fn chat_completion_with_strain(
&self,
request: ChatCompletionRequest,
) -> Result<(CompletionResult, Vec<InferenceStrain>)> {
// Translate the engine's (possibly Bifrost-namespaced) model id onto a
// model the codex backend actually serves before building the payload.
let mut request = request;
request.model = catalog::resolve(&request.model, &self.default_model);
let payload = responses::build_payload(&request);
let model = request.model.clone();
let mut strain: Vec<InferenceStrain> = Vec::new();
for attempt in 0..=self.retry.max_retries {
let (access, account) = self.auth().await?;
let resp = self
.http
.post(ENDPOINT)
.header("Authorization", format!("Bearer {}", access))
.header("ChatGPT-Account-Id", account)
.header("OpenAI-Beta", "responses=v1")
.header("OpenAI-Originator", "codex")
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.json(&payload)
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) if e.is_timeout() || e.is_connect() => {
if attempt == self.retry.max_retries {
return Err(anyhow!(
"ChatGPT backend unreachable after {} attempts: {}",
attempt + 1,
e
));
}
let delay = backoff(attempt, &self.retry);
warn!("ChatGPT backend connect failed (attempt {}), retrying in {:?}: {}", attempt, delay, e);
strain.push(InferenceStrain::Transient {
attempt,
status: 0,
model: model.clone(),
delay_ms: delay.as_millis() as u64,
});
tokio::time::sleep(delay).await;
continue;
}
Err(e) => return Err(e.into()),
};
let status = resp.status();
if status.is_success() {
let body = resp.text().await.context("reading ChatGPT responses stream")?;
let result = responses::accumulate_sse(&body)?;
return Ok((result, strain));
}
let body = resp.text().await.unwrap_or_default();
let transient = matches!(status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504);
if transient && attempt < self.retry.max_retries {
let delay = backoff(attempt, &self.retry);
warn!("ChatGPT backend {} (attempt {}), retrying in {:?}", status.as_u16(), attempt, delay);
strain.push(InferenceStrain::Transient {
attempt,
status: status.as_u16(),
model: model.clone(),
delay_ms: delay.as_millis() as u64,
});
tokio::time::sleep(delay).await;
continue;
}
strain.push(InferenceStrain::Exhausted {
attempts: attempt + 1,
status: status.as_u16(),
model: model.clone(),
body: body.chars().take(300).collect(),
});
return Err(anyhow!(
"ChatGPT backend returned {} after {} attempt(s): {}",
status,
attempt + 1,
body.chars().take(500).collect::<String>()
));
}
unreachable!("retry loop returns or bails")
}
}
fn backoff(attempt: u32, policy: &RetryPolicy) -> Duration {
let base = policy.base_delay_ms.saturating_mul(2u64.saturating_pow(attempt));
Duration::from_millis(base.min(policy.max_delay_ms))
}

View file

@ -0,0 +1,244 @@
//! Translation between Souveraine's OpenAI-chat shape and the ChatGPT backend
//! Responses API. Modeled on Letta's `chatgpt_oauth_client`.
//!
//! Two directions:
//! - [`build_payload`]: `ChatCompletionRequest` → Responses request body
//! (`input` array, `developer`/`instructions`, flat tools, reasoning).
//! - [`accumulate_sse`]: the Responses SSE stream → one-shot `CompletionResult`.
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use crate::bridge::bifrost::{ChatCompletionRequest, CompletionResult, ParsedToolCall, Usage};
/// Models that take a `reasoning` block (GPT-5.x / o-series).
fn is_reasoning_model(model: &str) -> bool {
let m = model.to_lowercase();
m.contains("gpt-5") || m.starts_with("o1") || m.starts_with("o3") || m.starts_with("o4")
}
/// Build the Responses API request body from a chat-completion request.
pub fn build_payload(req: &ChatCompletionRequest) -> Value {
// `-fast` variants map to the real model + a priority service tier.
let (model, service_tier) = match req.model.strip_suffix("-fast") {
Some(base) => (base.to_string(), Some("priority")),
None => (req.model.clone(), None),
};
// System/developer turns become `instructions`; everything else becomes an
// item in the `input` array.
let mut instructions = String::new();
let mut input: Vec<Value> = Vec::new();
for msg in &req.messages {
match msg.role.as_str() {
"system" | "developer" => {
let text = msg.content.as_text();
if !text.is_empty() {
if !instructions.is_empty() {
instructions.push_str("\n\n");
}
instructions.push_str(&text);
}
}
"tool" => {
if let Some(call_id) = &msg.tool_call_id {
input.push(json!({
"type": "function_call_output",
"call_id": call_id,
"output": msg.content.as_text(),
}));
}
}
"assistant" => {
if let Some(calls) = &msg.tool_calls {
for c in calls {
input.push(json!({
"type": "function_call",
"call_id": c.id,
"name": c.function.name,
"arguments": c.function.arguments,
}));
}
}
let text = msg.content.as_text();
if !text.is_empty() {
input.push(json!({
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": text}],
}));
}
}
_ => {
input.push(json!({
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": msg.content.as_text()}],
}));
}
}
}
// ChatGPT backend requires streaming and stateless operation; it does not
// accept `max_output_tokens`.
let mut body = json!({
"model": model,
"input": input,
"store": false,
"stream": true,
});
// The Codex Responses backend rejects requests without `instructions`
// (HTTP 400 "Instructions are required"), so always send the field; fall
// back to a neutral default when no system/developer message was provided.
body["instructions"] = Value::String(if instructions.is_empty() {
"You are a helpful assistant.".to_string()
} else {
instructions
});
if let Some(tier) = service_tier {
body["service_tier"] = Value::String(tier.to_string());
}
if let Some(tools) = &req.tools {
if !tools.is_empty() {
let converted: Vec<Value> = tools
.iter()
.map(|t| {
json!({
"type": "function",
"name": t.function.name,
"description": t.function.description,
"parameters": t.function.parameters,
})
})
.collect();
body["tools"] = Value::Array(converted);
body["tool_choice"] = Value::String("auto".to_string());
}
}
if is_reasoning_model(&model) {
body["reasoning"] = json!({"effort": "medium", "summary": "auto"});
}
body
}
/// Accumulate the Responses-API SSE body into a one-shot `CompletionResult`.
///
/// Text is taken from `output_text.delta` events; `output_item.done` supplies
/// function calls (and message text only as a fallback when no deltas arrived,
/// to avoid double-counting).
pub fn accumulate_sse(body: &str) -> Result<CompletionResult> {
let mut content = String::new();
let mut reasoning = String::new();
let mut tool_calls: Vec<ParsedToolCall> = Vec::new();
let mut usage: Option<Usage> = None;
for line in body.lines() {
let data = match line.trim_start().strip_prefix("data:") {
Some(d) => d.trim(),
None => continue,
};
if data.is_empty() || data == "[DONE]" {
continue;
}
let event: Value = match serde_json::from_str(data) {
Ok(v) => v,
Err(_) => continue,
};
match event.get("type").and_then(|v| v.as_str()).unwrap_or("") {
"error" | "response.failed" => {
let msg = event
.pointer("/error/message")
.or_else(|| event.pointer("/response/error/message"))
.and_then(|v| v.as_str())
.unwrap_or("ChatGPT backend returned an error");
return Err(anyhow!("ChatGPT responses error: {}", msg));
}
"response.output_text.delta" => {
if let Some(d) = event.get("delta").and_then(|v| v.as_str()) {
content.push_str(d);
}
}
"response.reasoning_summary_text.delta" => {
if let Some(d) = event.get("delta").and_then(|v| v.as_str()) {
reasoning.push_str(d);
}
}
"response.output_item.done" => {
if let Some(item) = event.get("item") {
accumulate_item(item, &mut content, &mut tool_calls);
}
}
"response.completed" | "response.done" => {
if let Some(u) = event.pointer("/response/usage") {
usage = parse_usage(u);
}
}
_ => {}
}
}
let finish_reason = if tool_calls.is_empty() { "stop" } else { "tool_calls" };
Ok(CompletionResult {
content,
reasoning: (!reasoning.is_empty()).then_some(reasoning),
tool_calls,
finish_reason: Some(finish_reason.to_string()),
usage,
})
}
fn accumulate_item(item: &Value, content: &mut String, tool_calls: &mut Vec<ParsedToolCall>) {
match item.get("type").and_then(|v| v.as_str()) {
Some("function_call") => {
let id = item
.get("call_id")
.or_else(|| item.get("id"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let name = item
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let args_str = item.get("arguments").and_then(|v| v.as_str()).unwrap_or("{}");
let arguments =
serde_json::from_str(args_str).unwrap_or_else(|_| Value::Object(Default::default()));
tool_calls.push(ParsedToolCall { id, name, arguments });
}
Some("message") => {
// Fallback only — text normally arrives via output_text.delta.
if content.is_empty() {
if let Some(parts) = item.get("content").and_then(|v| v.as_array()) {
for p in parts {
let is_text = matches!(
p.get("type").and_then(|v| v.as_str()),
Some("output_text") | Some("text")
);
if is_text {
if let Some(t) = p.get("text").and_then(|v| v.as_str()) {
content.push_str(t);
}
}
}
}
}
}
_ => {}
}
}
fn parse_usage(u: &Value) -> Option<Usage> {
let prompt = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let completion = u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
Some(Usage {
prompt_tokens: prompt,
completion_tokens: completion,
total_tokens: prompt + completion,
})
}

View file

@ -35,7 +35,8 @@ use anyhow::Result;
use chrono::{NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use crate::bridge::bifrost::{BifrostClient, ChatCompletionRequest, Message};
use crate::bridge::bifrost::{ChatCompletionRequest, Message};
use crate::bridge::LlmProvider;
use crate::core::config::{ArchivistConfig, SynthesisElement};
use crate::core::memory::MemoryRepo;
use crate::server::AgentInventory;
@ -94,7 +95,7 @@ impl SynthesisReport {
pub struct ArchivistEngine {
agents: Arc<AgentInventory>,
bifrost: Arc<BifrostClient>,
bifrost: Arc<dyn LlmProvider>,
rate_delay: Arc<AtomicU64>,
config: ArchivistConfig,
/// Subconscious model handle — used to resolve `compression_model: "auto"`.
@ -104,7 +105,7 @@ pub struct ArchivistEngine {
impl ArchivistEngine {
pub fn new(
agents: Arc<AgentInventory>,
bifrost: Arc<BifrostClient>,
bifrost: Arc<dyn LlmProvider>,
rate_delay: Arc<AtomicU64>,
config: ArchivistConfig,
subconscious_model: Option<String>,

View file

@ -32,8 +32,8 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use tokio::sync::RwLock;
use crate::bridge::bifrost::BifrostClient;
use crate::bridge::model_router::TokenCounter;
use crate::bridge::LlmProvider;
use crate::core::config::ConsciousnessConfig;
use crate::core::memory::MemoryRepo;
use crate::core::session::ConversationMessage;
@ -85,7 +85,7 @@ pub trait CompactionEngine: Send + Sync {
pub struct DefaultCompactionEngine {
pub config: Arc<RwLock<ConsciousnessConfig>>,
pub counter: TokenCounter,
pub bifrost: Option<BifrostClient>,
pub bifrost: Option<Arc<dyn LlmProvider>>,
pub model: Option<String>,
pub clock: Arc<dyn Clock>,
pub get_messages: Arc<dyn Fn(&str) -> Option<Vec<ConversationMessage>> + Send + Sync>,

View file

@ -1,7 +1,10 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::bridge::bifrost::{BifrostClient, ChatCompletionRequest, Message};
use crate::bridge::bifrost::{ChatCompletionRequest, Message};
use crate::bridge::model_router::TokenCounter;
use crate::bridge::LlmProvider;
use crate::core::session::ConversationMessage;
use super::config::{AgentCompactionConfig, CompactionStrategyKind};
@ -50,7 +53,7 @@ pub trait CompactionStrategy: Send + Sync {
/// Helper: call a Bifrost model with system+user prompt, get text response.
async fn bifrost_complete(
client: &BifrostClient,
client: &Arc<dyn LlmProvider>,
model: &str,
system: &str,
prompt: &str,
@ -78,7 +81,7 @@ async fn bifrost_complete(
/// the agent reads the boundary on the next turn and can resume with full
/// awareness of intent, files, decisions, and pending work.
pub struct SummaryStrategy {
pub client: BifrostClient,
pub client: Arc<dyn LlmProvider>,
pub model: String,
pub prompt_override: Option<String>,
}
@ -505,7 +508,7 @@ impl CompactionStrategy for SlidingWindowStrategy {
/// available, falls back to plain SlidingWindow (no threads lost is better
/// than no compaction at all).
pub struct SlidingReflectStrategy {
pub client: BifrostClient,
pub client: Arc<dyn LlmProvider>,
pub model: String,
/// User-supplied prompt override from [compaction] reflect_prompt in config.
/// When set, replaces the built-in REFLECT_TASK prompt entirely.

View file

@ -187,6 +187,12 @@ pub struct BifrostConfig {
/// Default: 120 (two minutes per attempt, 7 attempts = ~14 min total).
#[serde(default = "default_bifrost_timeout")]
pub timeout_secs: u64,
/// Active provider implementation: "bifrost" (OpenAI-compatible gateway,
/// default) or "openai-oauth" (ride the Codex CLI ChatGPT login →
/// backend-api/codex/responses). See `bridge::build_provider`.
#[serde(default = "default_provider")]
pub provider: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -208,6 +214,7 @@ impl Default for BifrostConfig {
primary_model: default_primary_model(),
models: HashMap::new(),
timeout_secs: default_bifrost_timeout(),
provider: default_provider(),
}
}
}
@ -916,6 +923,7 @@ fn default_warning_2_threshold() -> f32 { 0.95 }
fn default_sub_inter_round_delay() -> u64 { 300 }
fn default_auto_model() -> String { "auto".to_string() }
fn default_bifrost_url() -> String { "http://127.0.0.1:3360".to_string() }
fn default_provider() -> String { "bifrost".to_string() }
fn default_server_bind() -> String { "127.0.0.1".to_string() }
fn default_server_port() -> u16 { 8484 }
fn default_server_url() -> String { "http://127.0.0.1:8484".to_string() }

View file

@ -31,8 +31,9 @@ use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::bridge::bifrost::{
BifrostClient, ChatCompletionRequest, Message, ToolDefinition, ToolFunction,
ChatCompletionRequest, Message, ToolDefinition, ToolFunction,
};
use crate::bridge::LlmProvider;
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
use crate::core::tools::defs::ToolContext;
use crate::server::AgentInventory;
@ -69,7 +70,7 @@ pub struct ReflectionReport {
pub struct ReflectionEngine {
agents: Arc<AgentInventory>,
bifrost: Arc<BifrostClient>,
bifrost: Arc<dyn LlmProvider>,
rate_delay: Arc<AtomicU64>,
/// Model handle for reflection passes. None falls back to the
/// subconscious model, then to a sensible default.
@ -80,7 +81,7 @@ pub struct ReflectionEngine {
impl ReflectionEngine {
pub fn new(
agents: Arc<AgentInventory>,
bifrost: Arc<BifrostClient>,
bifrost: Arc<dyn LlmProvider>,
rate_delay: Arc<AtomicU64>,
model: Option<String>,
max_tokens: Option<u32>,

View file

@ -25,7 +25,8 @@
//! separate agent — it is the same consciousness in a different mode that runs
//! immediately after the primary's turn.
use crate::bridge::bifrost::{BifrostClient, ChatCompletionRequest, Message, ToolDefinition, ToolFunction};
use crate::bridge::bifrost::{ChatCompletionRequest, Message, ToolDefinition, ToolFunction};
use crate::bridge::LlmProvider;
use crate::bridge::model_router::TokenCounter;
use crate::core::compact::CompactionEngine;
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
@ -58,7 +59,7 @@ const SUBCONSCIOUS_INTER_ROUND_DELAY_MS: u64 = 300;
pub struct ConsciousnessEngine {
agents: Arc<AgentInventory>,
sessions: Arc<SessionManager>,
bifrost: Arc<BifrostClient>,
bifrost: Arc<dyn LlmProvider>,
counter: TokenCounter,
/// Optional model override for the subconscious pass (e.g. "openai/glm-5.1").
/// If None, uses the primary agent's model.
@ -155,7 +156,7 @@ impl ConsciousnessEngine {
pub fn new(
agents: Arc<AgentInventory>,
sessions: Arc<SessionManager>,
bifrost: Arc<BifrostClient>,
bifrost: Arc<dyn LlmProvider>,
subconscious_model: Option<String>,
reflection_model: Option<String>,
max_tokens: Option<u32>,

View file

@ -1,4 +1,4 @@
use crate::bridge::BifrostClient;
use crate::bridge::{build_provider, LlmProvider};
use crate::core::compact::{CompactionEngine, DefaultCompactionEngine, UtcClock};
use crate::core::config::ConsciousnessConfig;
use crate::core::identity::SeedId;
@ -41,7 +41,7 @@ pub struct SouveraineServer {
pub sessions: Arc<SessionManager>,
pub consciousness: Arc<ConsciousnessEngine>,
pub compaction_engine: Arc<dyn CompactionEngine>,
pub bifrost: Arc<BifrostClient>,
pub bifrost: Arc<dyn LlmProvider>,
pub config: Arc<RwLock<ServerConfig>>,
pub data_dir: PathBuf,
pub memory: Option<Arc<ServerMemory>>,
@ -133,18 +133,9 @@ impl SouveraineServer {
let event_bus = crate::core::nervous::EventBus::default();
let sessions = Arc::new(SessionManager::with_persistence(data_dir.join("agents")));
let primary = &config.bifrost.primary_model;
let mut fallbacks = Vec::new();
if !primary.ends_with("-precision") {
fallbacks.push(format!("{}-precision", primary));
}
let bifrost = Arc::new(BifrostClient::new(
&config.bifrost.base_url,
&config.bifrost.api_key,
&config.bifrost.virtual_key,
primary,
config.bifrost.timeout_secs,
).with_fallbacks(fallbacks));
// Select the active LLM provider (OpenAI-compatible gateway or the
// OAuth-riding ChatGPT provider) from config.
let bifrost: Arc<dyn LlmProvider> = build_provider(&config)?;
let rate_delay = Arc::new(AtomicU64::new(1000));
tracing::info!("rate delay initialized at 1000ms");
@ -197,7 +188,7 @@ impl SouveraineServer {
let compaction_engine: Arc<dyn CompactionEngine> = Arc::new(DefaultCompactionEngine {
config: app_cfg,
counter: crate::bridge::model_router::TokenCounter::new(),
bifrost: Some((*bifrost).clone()),
bifrost: Some(bifrost.clone()),
model: config.compaction.model.clone().or_else(|| config.subconscious.model.clone()),
clock: Arc::new(UtcClock),
get_messages,

View file

@ -8,7 +8,7 @@ use tokio_util::sync::CancellationToken;
use crate::bridge::bifrost::{ChatCompletionRequest, ContentPart, ImageUrlSource, Message as BifrostMessage};
use crate::bridge::model_router::TokenCounter;
use crate::core::compact::CompactionEngine;
use crate::core::nervous::EventBus;
use crate::core::nervous::{EventBus, SensorEvent};
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
use crate::core::tools::defs::ToolContext;
use crate::server::consciousness_engine::{ConsciousnessEvent, ConsciousnessEngine};
@ -375,6 +375,23 @@ pub(crate) async fn run_turn(
model: model.clone(),
})).await;
bump_on_strain(&server.rate_delay, *status);
// Surface provider strain onto the nervous-system bus too, so a
// second machine watching the firehose sees the voice go hoarse,
// not just the local TUI.
server.event_bus.send(SensorEvent {
sensor_name: "inference".to_string(),
timestamp: chrono::Utc::now(),
event_type: "inference_strain".to_string(),
target: None,
urgency: 0.3,
payload: Some(serde_json::json!({
"attempt": *attempt,
"status": *status,
"model": model,
})),
seed_id: None,
reply_to: None,
});
}
}