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:
parent
d8707611da
commit
bc6ee12d47
19 changed files with 895 additions and 35 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
74
src/bridge/oauth/catalog.rs
Normal file
74
src/bridge/oauth/catalog.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
107
src/bridge/oauth/codex_creds.rs
Normal file
107
src/bridge/oauth/codex_creds.rs
Normal 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
10
src/bridge/oauth/mod.rs
Normal 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;
|
||||
91
src/bridge/oauth/refresh.rs
Normal file
91
src/bridge/oauth/refresh.rs
Normal 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
42
src/bridge/provider.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
7
src/bridge/providers/mod.rs
Normal file
7
src/bridge/providers/mod.rs
Normal 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;
|
||||
173
src/bridge/providers/openai_oauth.rs
Normal file
173
src/bridge/providers/openai_oauth.rs
Normal 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))
|
||||
}
|
||||
244
src/bridge/providers/responses.rs
Normal file
244
src/bridge/providers/responses.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue