Watch
1
0
Fork
You've already forked souveraine
0

bridge: claude-subscription provider (Claude Code OAuth wire)

Speaks the Claude Code subscription wire protocol against api.anthropic.com
using the OAuth creds claude already stored, so Souveraine can drive a
Claude.ai subscription directly alongside Bifrost/openai-oauth. Translates
OpenAI-shaped internal requests to/from Anthropic /v1/messages, applies the
subscription shaping, refreshes the OAuth token under a mutex.
This commit is contained in:
Fimeg 2026-07-12 14:56:38 -04:00
commit 904fbd02d7
5 changed files with 1061 additions and 113 deletions

111
Cargo.lock generated
View file

@ -3612,8 +3612,6 @@ dependencies = [
"libc",
"libgit2-sys",
"log",
"openssl-probe 0.1.6",
"openssl-sys",
"url",
]
@ -4120,22 +4118,6 @@ dependencies = [
"webpki-roots 1.0.7",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@ -4154,11 +4136,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@ -4972,9 +4952,7 @@ checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224"
dependencies = [
"cc",
"libc",
"libssh2-sys",
"libz-sys",
"openssl-sys",
"pkg-config",
]
@ -5036,20 +5014,6 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "libssh2-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9"
dependencies = [
"cc",
"libc",
"libz-sys",
"openssl-sys",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libz-sys"
version = "1.1.28"
@ -5628,23 +5592,6 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe 0.2.1",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.8.0"
@ -6485,12 +6432,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-probe"
version = "0.2.1"
@ -7564,7 +7505,6 @@ dependencies = [
"base64-simd",
"icy_sixel",
"image",
"pkg-config",
"rand 0.8.6",
"ratatui",
"rustix 0.38.44",
@ -7825,22 +7765,17 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@ -7851,7 +7786,6 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower 0.5.3",
@ -8276,7 +8210,7 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe 0.2.1",
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
@ -9171,6 +9105,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"sha2",
"shellexpand",
"sqlx",
"sysinfo",
@ -9718,27 +9653,6 @@ dependencies = [
"windows 0.52.0",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.1",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@ -10503,16 +10417,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@ -12521,17 +12425,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.1.2"

View file

@ -28,7 +28,7 @@ serde_json = "1"
toml = "0.8"
# Git operations
git2 = "0.19"
git2 = { version = "0.19", default-features = false }
# Database (server mode)
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "migrate", "chrono", "json"] }
@ -45,7 +45,7 @@ tempfile = "3"
walkdir = "2"
# HTTP client (for Bifrost/Ollama)
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls", "multipart"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "multipart"] }
# Voice channel — mic capture, mp3 playback, WAV encoding, STT/TTS HTTP clients
cpal = "0.15"
@ -65,7 +65,7 @@ unicode-width = "0.1"
arboard = { version = "3", features = ["wayland-data-control"] } # Clipboard — click-to-copy a message bubble
colored = "2" # Color gradients and effects
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } # Per-agent portraits (assets/portrait.{png,jpg}) + multimodal images
ratatui-image = "11" # Real photo rendering in TUI (kitty/sixel/halfblock)
ratatui-image = { version = "11", default-features = false, features = ["crossterm", "image-defaults"] } # Real photo rendering in TUI (kitty/sixel/halfblock) — chafa-dyn default dropped, souveraine uses picker/protocols only, avoids a cross chafa+glib sysroot
ratatui-ratty = { version = "0.2", optional = true } # Inline 3D graphics via Ratty Graphics Protocol
# Logging/tracing
@ -100,6 +100,7 @@ keyring-core = "1"
# Cryptographic identity (seed-id, event signing, federation trust root)
ed25519-dalek = { version = "2", features = ["rand_core", "pem"] }
sha2 = "0.10"
rand = "0.8"
# Cron expression parsing (schedule system)

View file

@ -0,0 +1,977 @@
#![allow(dead_code)] // WIP scaffolding not yet wired
//! Claude Code subscription inference provider.
//!
//! Speaks the Claude Code (claude.ai OAuth) wire protocol directly against
//! `api.anthropic.com`, using the OAuth credentials Claude Code already stored
//! at `~/.claude/.credentials.json`. Implements [`LlmProvider`] so it drops
//! into the existing provider registry alongside Bifrost and OpenAI OAuth.
//!
//! Internally it translates OpenAI chat-completion requests (Souveraine's
//! internal shape) to Anthropic `/v1/messages`, applies the subscription wire
//! shaping (attribution block + fingerprint, `metadata.user_id`, betas, identity
//! headers), refreshes the OAuth token under a mutex (writing it back so
//! `claude` stays in sync), and translates the Anthropic response back to
//! Souveraine's OpenAI-shaped `CompletionResult`.
//!
//! Wire constants and rules trace to the Claude Code subscription wire protocol.
//! On the wire the client presents as Claude Code because Anthropic's backend
//! validates the client fingerprint/salt against the first-party client to gate
//! subscription access. It uses YOUR token, from YOUR login, on YOUR machine.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use async_trait::async_trait;
use rand::RngCore;
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
use super::bifrost::{
ChatCompletionRequest, CompletionResult, InferenceStrain, Message, ParsedToolCall,
ToolDefinition, Usage,
};
use super::provider::LlmProvider;
// ── Claude Code subscription wire constants ───────────────────────────────
const PROD_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
const ROLES_URL: &str = "https://api.anthropic.com/api/oauth/claude_cli/roles";
const OAUTH_BETA: &str = "oauth-2025-04-20";
const CC_PRODUCT_BETA: &str = "claude-code-20250219";
const SCOPES: &str =
"user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
const FINGERPRINT_SALT: &str = "59cf53e54c78";
const CC_ENTRYPOINT: &str = "cli";
const CC_PLATFORM: &str = "claude_code_cli";
const FALLBACK_CC_VERSION: &str = "2.1.196";
const TOKEN_REFRESH_BUFFER: Duration = Duration::from_secs(5 * 60);
const DEFAULT_MAX_TOKENS: u32 = 8192;
/// Direct Claude Code subscription inference provider.
pub struct ClaudeSubscriptionProvider {
name: String,
http: reqwest::Client,
upstream_base: String,
cc_version: String,
user_agent: String,
device_id: String,
account_uuid: String,
extra_metadata: Map<String, Value>,
default_model: String,
token: Arc<Mutex<TokenState>>,
}
#[derive(Clone)]
struct TokenState {
access_token: Option<String>,
refresh_token: Option<String>,
expires_at_ms: Option<u64>,
source_path: Option<PathBuf>,
}
impl ClaudeSubscriptionProvider {
/// Build from a provider config entry. Reads `~/.claude/.credentials.json`
/// (or the `credential_file` override), fetches `account_uuid` best-effort,
/// and persists a device id.
pub fn new(
name: &str,
base_url: &str,
primary_model: &str,
timeout_secs: u64,
credential_file: Option<&str>,
cc_version: Option<&str>,
account_uuid: Option<&str>,
device_id: Option<&str>,
extra_metadata: Option<HashMap<String, Value>>,
) -> Result<Self> {
let cc_version = cc_version
.filter(|v| !v.is_empty())
.map(str::to_string)
.unwrap_or_else(resolve_cc_version);
let user_agent = format!("claude-cli/{cc_version} (undefined, {CC_ENTRYPOINT})");
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.user_agent(user_agent.clone())
.build()
.context("failed to build HTTP client")?;
let cred_path = resolve_credential_path(credential_file).context(
"no Claude Code credential file found (set credential_file or log in with `claude`)",
)?;
let mut token = load_credentials(&cred_path).with_context(|| {
format!(
"failed to read Claude Code credentials at {}",
cred_path.display()
)
})?;
if token.access_token.is_none() && token.refresh_token.is_none() {
anyhow::bail!(
"credential file has no access_token/refresh_token; log in with `claude` first"
);
}
let device_id = match device_id.filter(|v| is_device_id(v)) {
Some(id) => id.to_ascii_lowercase(),
None => get_or_create_device_id()?,
};
let account_uuid = account_uuid
.filter(|v| !v.is_empty())
.unwrap_or_default()
.to_string();
let extra = extra_metadata
.map(|m| m.into_iter().collect::<Map<String, Value>>())
.unwrap_or_default();
info!(
"🔐 Claude subscription provider initialized — model: {}, cc_version: {}, creds: {}",
primary_model,
cc_version,
cred_path.display()
);
Ok(Self {
name: name.to_string(),
http,
upstream_base: base_url.trim_end_matches('/').to_string(),
cc_version,
user_agent,
device_id,
account_uuid,
extra_metadata: extra,
default_model: primary_model.to_string(),
token: Arc::new(Mutex::new(token)),
})
}
fn beta_header(&self) -> String {
format!("{OAUTH_BETA},{CC_PRODUCT_BETA}")
}
/// Translate an OpenAI chat-completion request to a wire-shaped Anthropic
/// `/v1/messages` body.
fn shape_body(&self, model: &str, request: &ChatCompletionRequest) -> Result<Vec<u8>> {
let (messages, system_text, first_user_text) = translate_messages(&request.messages);
let mut body = json!({
"model": model,
"messages": messages,
"max_tokens": request.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
"stream": false,
});
if let Some(t) = request.temperature {
body["temperature"] = json!(t);
}
if let Some(system) = system_text {
body["system"] = json!(system);
}
if let Some(tools) = &request.tools {
if !tools.is_empty() {
body["tools"] = json!(tools.iter().map(translate_tool).collect::<Vec<_>>());
}
}
let obj = body.as_object_mut().unwrap();
inject_attribution(obj, &self.cc_version, &first_user_text);
inject_metadata(obj, self);
Ok(serde_json::to_vec(&body)?)
}
/// Return a usable access token, refreshing under the mutex if it is
/// missing or inside the proactive window.
async fn current_access_token(&self) -> Result<String> {
let mut token = self.token.lock().await;
let access_empty = token.access_token.as_deref().map_or(true, str::is_empty);
let expiring = token
.expires_at_ms
.map_or(true, |exp| exp <= now_ms() + TOKEN_REFRESH_BUFFER.as_millis() as u64);
if access_empty || expiring {
if let Err(e) = refresh_token(&self.http, &self.user_agent, &mut token).await {
warn!("claude token refresh failed: {e}");
}
}
token
.access_token
.clone()
.filter(|t| !t.is_empty())
.ok_or_else(|| {
anyhow::anyhow!("no claude access_token available (refresh failed and none cached)")
})
}
}
#[async_trait]
impl LlmProvider for ClaudeSubscriptionProvider {
fn id(&self) -> &str {
&self.name
}
fn default_model(&self) -> &str {
&self.default_model
}
async fn list_models(&self) -> Result<Vec<String>> {
let access_token = self.current_access_token().await?;
let resp = self
.http
.get(format!("{}/v1/models", self.upstream_base))
.header("authorization", format!("Bearer {access_token}"))
.header("anthropic-version", "2023-06-01")
.header("anthropic-beta", self.beta_header())
.header("user-agent", &self.user_agent)
.header("x-app", CC_ENTRYPOINT)
.header("anthropic-client-platform", CC_PLATFORM)
.send()
.await?
.error_for_status()?
.json::<Value>()
.await?;
let models = resp
.get("data")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|m| m.get("id").and_then(Value::as_str).map(String::from))
.collect::<Vec<_>>()
})
.unwrap_or_default();
Ok(models)
}
async fn chat_completion_with_strain(
&self,
request: ChatCompletionRequest,
) -> Result<(CompletionResult, Vec<InferenceStrain>)> {
let model = if request.model.is_empty() {
self.default_model.clone()
} else {
request.model.clone()
};
let mut strain = Vec::new();
let max_attempts = 4u32;
let body = self.shape_body(&model, &request)?;
for attempt in 0..max_attempts {
let access_token = self.current_access_token().await?;
let resp = self
.http
.post(format!("{}/v1/messages", self.upstream_base))
.header("authorization", format!("Bearer {access_token}"))
.header("content-type", "application/json")
.header("anthropic-version", "2023-06-01")
.header("anthropic-beta", self.beta_header())
.header("user-agent", &self.user_agent)
.header("x-app", CC_ENTRYPOINT)
.header("anthropic-client-platform", CC_PLATFORM)
.body(body.clone())
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) if e.is_timeout() || e.is_connect() => {
if attempt + 1 == max_attempts {
anyhow::bail!(
"claude subscription unreachable after {max_attempts} attempts: {e}"
);
}
let delay = backoff(attempt);
warn!(
"claude subscription connect failed (attempt {}), retry in {:?}: {e}",
attempt + 1,
delay
);
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 text = resp.text().await.context("reading claude response body")?;
return parse_completion(&text).map(|r| (r, strain));
}
let body_text = resp.text().await.unwrap_or_default();
match classify_status(status) {
ErrorClass::Transient if attempt + 1 < max_attempts => {
let delay = backoff(attempt);
warn!(
"claude subscription {status} on {model} (attempt {}), retry in {:?}",
attempt + 1,
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;
}
_ => {
strain.push(InferenceStrain::Exhausted {
attempts: attempt + 1,
status: status.as_u16(),
model: model.clone(),
body: body_text.chars().take(300).collect(),
});
anyhow::bail!(
"claude subscription returned {status} after {} attempt(s) on {model}: {}",
attempt + 1,
&body_text[..body_text.len().min(500)]
);
}
}
}
unreachable!("retry loop returns or bails")
}
}
// ── token refresh ==========================================================
async fn refresh_token(
http: &reqwest::Client,
user_agent: &str,
token: &mut TokenState,
) -> Result<()> {
let refresh = token
.refresh_token
.clone()
.ok_or_else(|| anyhow::anyhow!("no refresh_token"))?;
let resp = http
.post(TOKEN_URL)
.header("content-type", "application/json")
.header("user-agent", user_agent)
.json(&json!({
"grant_type": "refresh_token",
"refresh_token": refresh,
"client_id": PROD_CLIENT_ID,
"scope": SCOPES,
}))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("token endpoint returned {status}: {body}");
}
let value: Value = resp.json().await?;
let new_access = value
.get("access_token")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("refresh response missing access_token"))?
.to_string();
let new_refresh = value
.get("refresh_token")
.and_then(Value::as_str)
.map(str::to_string);
let expires_in = value
.get("expires_in")
.and_then(Value::as_u64)
.unwrap_or(3600);
let new_expires = now_ms() + expires_in * 1000;
if let Some(path) = token.source_path.clone() {
if let Err(e) =
write_back_credentials(&path, &new_access, new_refresh.as_deref(), new_expires)
{
warn!("credential write-back failed (continuing in-memory): {e}");
}
}
token.access_token = Some(new_access);
token.refresh_token = Some(new_refresh.unwrap_or(refresh));
token.expires_at_ms = Some(new_expires);
Ok(())
}
/// Best-effort `account_uuid` lookup via the roles/profile endpoint.
#[allow(dead_code)]
async fn fetch_account_uuid(http: &reqwest::Client, access_token: &str, user_agent: &str) -> String {
match http
.get(ROLES_URL)
.bearer_auth(access_token)
.header("user-agent", user_agent)
.send()
.await
{
Ok(r) if r.status().is_success() => match r.json::<Value>().await {
Ok(v) => v
.get("account")
.and_then(|a| a.get("uuid"))
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_default(),
Err(_) => String::new(),
},
_ => String::new(),
}
}
// ── credentials file =======================================================
fn resolve_credential_path(configured: Option<&str>) -> Option<PathBuf> {
if let Some(path) = configured.filter(|p| !p.is_empty()) {
let p = PathBuf::from(shellexpand::tilde(path).into_owned());
return Some(p);
}
let home = std::env::var("HOME").ok()?;
let path = PathBuf::from(home).join(".claude/.credentials.json");
path.exists().then_some(path)
}
/// `{ claudeAiOauth: { accessToken, refreshToken, expiresAt(ms), ... }, ... }`
fn load_credentials(path: &Path) -> Result<TokenState> {
let raw =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let value: Value = serde_json::from_str(&raw).context("parsing credentials json")?;
let oauth = value
.get("claudeAiOauth")
.ok_or_else(|| anyhow::anyhow!("credential file missing `claudeAiOauth` object"))?;
Ok(TokenState {
access_token: oauth
.get("accessToken")
.and_then(Value::as_str)
.map(str::to_string),
refresh_token: oauth
.get("refreshToken")
.and_then(Value::as_str)
.map(str::to_string),
expires_at_ms: oauth.get("expiresAt").and_then(Value::as_u64),
source_path: Some(path.to_path_buf()),
})
}
/// Atomically write refreshed tokens back, preserving all other fields.
fn write_back_credentials(
path: &Path,
access: &str,
new_refresh: Option<&str>,
expires_at_ms: u64,
) -> Result<()> {
let raw = std::fs::read_to_string(path).unwrap_or_else(|_| "{}".to_string());
let mut value: Value = serde_json::from_str(&raw).unwrap_or_else(|_| json!({}));
let oauth = value
.as_object_mut()
.context("cred file is not an object")?
.entry("claudeAiOauth")
.or_insert_with(|| json!({}));
let object = oauth
.as_object_mut()
.context("claudeAiOauth is not an object")?;
object.insert("accessToken".to_string(), json!(access));
if let Some(refresh) = new_refresh {
object.insert("refreshToken".to_string(), json!(refresh));
}
object.insert("expiresAt".to_string(), json!(expires_at_ms));
let bytes = serde_json::to_vec_pretty(&value)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, bytes)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
}
std::fs::rename(&tmp, path)?;
Ok(())
}
// ── wire shaping ===========================================================
/// Inject the attribution block into `system[0]`, preserving the rest.
fn inject_attribution(body: &mut Map<String, Value>, cc_version: &str, first_user_text: &str) {
let fingerprint = compute_fingerprint(first_user_text, cc_version);
let block = json!({
"type": "text",
"text": format!(
"x-anthropic-billing-header: cc_version={cc_version}.{fingerprint}; cc_entrypoint={CC_ENTRYPOINT};"
),
});
let system = body.remove("system");
let next = match system {
Some(Value::String(text)) if !text.is_empty() => {
Value::Array(vec![block, json!({ "type": "text", "text": text })])
}
Some(Value::Array(items)) => {
let rest = if items.first().is_some_and(is_attribution_block) {
items.into_iter().skip(1).collect::<Vec<_>>()
} else {
items
};
let mut out = vec![block];
out.extend(rest);
Value::Array(out)
}
Some(other) => Value::Array(vec![block, other]),
None => Value::Array(vec![block]),
};
body.insert("system".to_string(), next);
}
fn is_attribution_block(value: &Value) -> bool {
value
.get("text")
.and_then(Value::as_str)
.is_some_and(|t| t.starts_with("x-anthropic-billing-header:"))
}
/// `metadata.user_id` = JSON string of `{device_id, account_uuid, session_id, …extra}`.
fn inject_metadata(body: &mut Map<String, Value>, client: &ClaudeSubscriptionProvider) {
let mut metadata = body
.remove("metadata")
.and_then(|v| v.as_object().cloned())
.unwrap_or_default();
let mut user_id = client.extra_metadata.clone();
user_id.insert(
"device_id".to_string(),
Value::String(client.device_id.clone()),
);
user_id.insert(
"session_id".to_string(),
Value::String(uuid::Uuid::new_v4().to_string()),
);
user_id.insert(
"account_uuid".to_string(),
Value::String(client.account_uuid.clone()),
);
metadata.insert(
"user_id".to_string(),
Value::String(Value::Object(user_id).to_string()),
);
body.insert("metadata".to_string(), Value::Object(metadata));
}
/// `fingerprint = sha256( SALT + chars[4,7,20] + VERSION )[:3]`, JS UTF-16 indexing.
fn compute_fingerprint(first_user_text: &str, version: &str) -> String {
let units: Vec<u16> = first_user_text.encode_utf16().collect();
let mut chars = String::new();
for &index in &[4usize, 7, 20] {
match units.get(index) {
Some(unit) => chars.push(char::from_u32(*unit as u32).unwrap_or('0')),
None => chars.push('0'),
}
}
let mut hasher = Sha256::new();
hasher.update(FINGERPRINT_SALT.as_bytes());
hasher.update(chars.as_bytes());
hasher.update(version.as_bytes());
hex::encode(hasher.finalize())[..3].to_string()
}
// ── OpenAI ↔ Anthropic translation =========================================
/// Translate OpenAI messages into Anthropic messages + a joined system string.
fn translate_messages(openai: &[Message]) -> (Vec<Value>, Option<String>, String) {
let mut system_parts: Vec<String> = Vec::new();
let mut merged: Vec<(String, Vec<Value>)> = Vec::new();
for msg in openai {
match msg.role.as_str() {
"system" => {
if !msg.content.is_empty() {
system_parts.push(msg.content.as_text());
}
}
"tool" => {
let block = json!({
"type": "tool_result",
"tool_use_id": msg.tool_call_id.clone().unwrap_or_default(),
"content": msg.content.as_text(),
});
push_merge(&mut merged, "user", block);
}
"assistant" => {
if !msg.content.is_empty() {
push_merge(
&mut merged,
"assistant",
json!({ "type": "text", "text": msg.content.as_text() }),
);
}
if let Some(calls) = &msg.tool_calls {
for call in calls {
let input: Value = serde_json::from_str(&call.function.arguments)
.unwrap_or(json!({}));
push_merge(
&mut merged,
"assistant",
json!({
"type": "tool_use",
"id": call.id,
"name": call.function.name,
"input": input,
}),
);
}
}
}
_ => {
push_merge(
&mut merged,
"user",
json!({ "type": "text", "text": msg.content.as_text() }),
);
}
}
}
// Anthropic requires the first role to be "user".
while merged.first().is_some_and(|(role, _)| role == "assistant") {
merged.remove(0);
}
let messages: Vec<Value> = merged
.into_iter()
.map(|(role, blocks)| {
let content = if blocks.len() == 1
&& blocks[0].get("type").and_then(Value::as_str) == Some("text")
{
Value::String(
blocks[0]
.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
)
} else {
Value::Array(blocks)
};
json!({ "role": role, "content": content })
})
.collect();
let system = if system_parts.is_empty() {
None
} else {
Some(system_parts.join("\n\n"))
};
let first_user_text = openai
.iter()
.find(|m| m.role == "user")
.map(|m| m.content.as_text())
.unwrap_or_default();
(messages, system, first_user_text)
}
fn push_merge(merged: &mut Vec<(String, Vec<Value>)>, role: &str, block: Value) {
if let Some(last) = merged.last_mut() {
if last.0 == role {
last.1.push(block);
return;
}
}
merged.push((role.to_string(), vec![block]));
}
fn translate_tool(tool: &ToolDefinition) -> Value {
json!({
"name": tool.function.name,
"description": tool.function.description,
"input_schema": tool.function.parameters,
})
}
/// Anthropic non-stream response → Souveraine's OpenAI-shaped `CompletionResult`.
fn parse_completion(text: &str) -> Result<CompletionResult> {
let value: Value =
serde_json::from_str(text).context("parsing claude messages response")?;
let content_parts: Vec<String> = value
.get("content")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|b| b.get("text").and_then(Value::as_str).map(str::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let content = content_parts.concat();
let reasoning = value
.get("content")
.and_then(Value::as_array)
.and_then(|arr| {
let r: Vec<String> = arr
.iter()
.filter(|b| b.get("type").and_then(Value::as_str) == Some("thinking"))
.filter_map(|b| {
b.get("thinking")
.and_then(Value::as_str)
.map(str::to_string)
})
.collect();
if r.is_empty() {
None
} else {
Some(r.concat())
}
});
let tool_calls = value
.get("content")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"))
.filter_map(|b| {
Some(ParsedToolCall {
id: b.get("id").and_then(Value::as_str)?.to_string(),
name: b.get("name").and_then(Value::as_str)?.to_string(),
arguments: b.get("input").cloned().unwrap_or(json!({})),
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let finish_reason = value
.get("stop_reason")
.and_then(Value::as_str)
.map(map_stop_reason)
.or(Some("stop".to_string()));
let usage = value.get("usage").map(|u| {
let input = u.get("input_tokens").and_then(Value::as_u64).unwrap_or(0) as u32;
let output = u.get("output_tokens").and_then(Value::as_u64).unwrap_or(0) as u32;
Usage {
prompt_tokens: input,
completion_tokens: output,
total_tokens: input + output,
}
});
Ok(CompletionResult {
content,
reasoning,
tool_calls,
finish_reason,
usage,
})
}
fn map_stop_reason(reason: &str) -> String {
match reason {
"end_turn" | "stop_sequence" => "stop".to_string(),
"max_tokens" => "length".to_string(),
"tool_use" => "tool_calls".to_string(),
other => other.to_string(),
}
}
// ── misc helpers ===========================================================
fn resolve_cc_version() -> String {
if let Ok(output) = std::process::Command::new("claude")
.arg("--version")
.output()
{
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
if let Some(version) = parse_version(&text) {
return version;
}
}
}
FALLBACK_CC_VERSION.to_string()
}
fn parse_version(text: &str) -> Option<String> {
let first = text.split_whitespace().next()?;
let core: String = first
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
(!core.is_empty() && core.matches('.').count() >= 2).then_some(core)
}
fn get_or_create_device_id() -> Result<String> {
let state_dir = match std::env::var("SOUVERAINE_STATE_DIR") {
Ok(v) => PathBuf::from(v),
Err(_) => dirs::state_dir()
.or_else(|| dirs::home_dir())
.unwrap_or_else(|| PathBuf::from("."))
.join("souveraine"),
};
std::fs::create_dir_all(&state_dir)?;
let path = state_dir.join("claude-subscription-device-id");
if let Ok(existing) = std::fs::read_to_string(&path) {
let existing = existing.trim();
if is_device_id(existing) {
return Ok(existing.to_ascii_lowercase());
}
}
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
let id = hex::encode(bytes);
std::fs::write(&path, format!("{id}\n"))?;
Ok(id)
}
fn is_device_id(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
fn backoff(attempt: u32) -> Duration {
let base = 400u64 * 2u64.pow(attempt);
Duration::from_millis(base.min(8_000))
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ErrorClass {
Transient,
Permanent,
}
fn classify_status(status: reqwest::StatusCode) -> ErrorClass {
match status.as_u16() {
429 | 500 | 502 | 503 | 504 | 408 => ErrorClass::Transient,
_ => ErrorClass::Permanent,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::bifrost::{
MessageToolCall, MessageToolCallFunction, ToolFunction,
};
#[test]
fn fingerprint_matches_spec_worked_example() {
assert_eq!(compute_fingerprint("hello", "2.1.196"), "68b");
}
#[test]
fn attribution_has_no_invented_fields() {
let mut body = json!({ "messages": [{"role":"user","content":"hi"}] })
.as_object_mut()
.unwrap()
.clone();
inject_attribution(&mut body, "2.1.196", "hello");
let text = body["system"].as_array().unwrap()[0]
.get("text")
.and_then(Value::as_str)
.unwrap();
assert!(text.starts_with("x-anthropic-billing-header:"));
assert!(text.contains("cc_version=2.1.196."));
}
#[test]
fn translates_user_assistant_tool_roundtrip() {
let msgs = vec![
Message::text("user", "please read /etc/hostname"),
Message {
role: "assistant".into(),
content: String::new(),
tool_calls: Some(vec![MessageToolCall {
id: "call_1".into(),
tool_type: "function".into(),
function: MessageToolCallFunction {
name: "read".into(),
arguments: r#"{"path":"/etc/hostname"}"#.into(),
},
}]),
tool_call_id: None,
name: None,
},
Message::tool_result("call_1", "read", "example-host"),
Message::text("user", "thanks"),
];
let (messages, system, first_user) = translate_messages(&msgs);
assert!(system.is_none());
let roles: Vec<&str> = messages.iter().map(|m| m["role"].as_str().unwrap()).collect();
assert_eq!(roles[0], "user");
assert!(messages.iter().any(|m| {
m["role"] == "user"
&& m["content"].is_array()
&& m["content"]
.as_array()
.unwrap()
.iter()
.any(|b| b["type"] == "tool_result")
}));
assert_eq!(first_user, "please read /etc/hostname");
}
#[test]
fn translates_system_message_into_system_field() {
let msgs = vec![
Message::text("system", "you are souvie"),
Message::text("user", "hi"),
];
let (_, system, _) = translate_messages(&msgs);
assert_eq!(system.as_deref(), Some("you are souvie"));
}
#[test]
fn parses_anthropic_text_response() {
let raw = r#"{
"id":"msg_1","type":"message","role":"assistant","stop_reason":"end_turn",
"content":[{"type":"text","text":"pong"}],
"usage":{"input_tokens":16,"output_tokens":4}
}"#;
let r = parse_completion(raw).unwrap();
assert_eq!(r.content, "pong");
assert_eq!(r.finish_reason.as_deref(), Some("stop"));
assert_eq!(r.usage.unwrap().prompt_tokens, 16);
assert!(r.tool_calls.is_empty());
}
#[test]
fn parses_anthropic_tool_use_response() {
let raw = r#"{
"id":"msg_2","stop_reason":"tool_use",
"content":[
{"type":"text","text":"reading it"},
{"type":"tool_use","id":"call_9","name":"read","input":{"path":"/etc/hostname"}}
],
"usage":{"input_tokens":10,"output_tokens":8}
}"#;
let r = parse_completion(raw).unwrap();
assert_eq!(r.content, "reading it");
assert_eq!(r.finish_reason.as_deref(), Some("tool_calls"));
assert_eq!(r.tool_calls.len(), 1);
assert_eq!(r.tool_calls[0].name, "read");
assert_eq!(r.tool_calls[0].arguments["path"], "/etc/hostname");
}
#[test]
fn translates_openai_tool_definition_to_anthropic_schema() {
let tool = ToolDefinition {
tool_type: "function".to_string(),
function: ToolFunction {
name: "read".to_string(),
description: "read a file".to_string(),
parameters: json!({"type":"object","properties":{"path":{"type":"string"}}}),
},
};
let v = translate_tool(&tool);
assert_eq!(v["name"], "read");
assert_eq!(v["input_schema"]["type"], "object");
}
}

View file

@ -6,6 +6,7 @@
/// ChatGPT provider lives in [`providers`]. [`build_provider`] selects the
/// active one from config; [`ProviderRegistry`] resolves a provider per agent.
pub mod bifrost;
pub mod claude_subscription;
pub mod model_router;
pub mod oauth;
pub mod provider;
@ -42,6 +43,20 @@ pub fn build_provider_from_config(
)?;
Ok(Arc::new(provider))
}
"claude-subscription" => {
let provider = claude_subscription::ClaudeSubscriptionProvider::new(
name,
&cfg.base_url,
&cfg.primary_model,
cfg.timeout_secs,
cfg.credential_file.as_deref(),
cfg.cc_version.as_deref(),
cfg.account_uuid.as_deref(),
cfg.device_id.as_deref(),
cfg.extra_metadata.clone(),
)?;
Ok(Arc::new(provider))
}
_ => {
// "openai-compatible" and any unrecognized type → OpenAI-compatible gateway.
let client = BifrostClient::new(

View file

@ -2,6 +2,7 @@
use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::core::compact::CompactionConfig;
@ -280,7 +281,8 @@ fn default_zai_timeout() -> u64 {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
/// Provider implementation: `"openai-compatible"` (any OpenAI-style gateway,
/// including Bifrost and z.ai) or `"openai-oauth"` (Codex CLI ChatGPT login).
/// including Bifrost and z.ai), `"openai-oauth"` (Codex CLI ChatGPT login),
/// or `"claude-subscription"` (Claude Code OAuth subscription).
#[serde(rename = "type")]
pub provider_type: String,
@ -303,6 +305,30 @@ pub struct ProviderConfig {
/// Per-attempt timeout in seconds.
#[serde(default = "default_bifrost_timeout")]
pub timeout_secs: u64,
/// Path to Claude Code credentials file (for `claude-subscription` provider).
/// Defaults to `~/.claude/.credentials.json`.
#[serde(default)]
pub credential_file: Option<String>,
/// Claude Code version string for wire fingerprinting (for `claude-subscription`).
/// Auto-detected from `claude --version` if unset.
#[serde(default)]
pub cc_version: Option<String>,
/// Account UUID for metadata.user_id (for `claude-subscription`).
/// Empty is valid wire shape.
#[serde(default)]
pub account_uuid: Option<String>,
/// Device ID for metadata.user_id (for `claude-subscription`).
/// Auto-generated hex string if unset.
#[serde(default)]
pub device_id: Option<String>,
/// Extra metadata fields to merge into metadata.user_id (for `claude-subscription`).
#[serde(default)]
pub extra_metadata: Option<HashMap<String, Value>>,
}
// ── Built-in provider catalog ──────────────────────────────────────────────
@ -922,6 +948,11 @@ impl ConsciousnessConfig {
virtual_key: self.bifrost.virtual_key.clone(),
primary_model: self.bifrost.primary_model.clone(),
timeout_secs: self.bifrost.timeout_secs,
credential_file: None,
cc_version: None,
account_uuid: None,
device_id: None,
extra_metadata: None,
},
);
}
@ -936,6 +967,11 @@ impl ConsciousnessConfig {
virtual_key: String::new(),
primary_model: self.zai.primary_model.clone(),
timeout_secs: self.zai.timeout_secs,
credential_file: None,
cc_version: None,
account_uuid: None,
device_id: None,
extra_metadata: None,
},
);
}
@ -951,6 +987,32 @@ impl ConsciousnessConfig {
virtual_key: String::new(),
primary_model: self.bifrost.primary_model.clone(),
timeout_secs: self.bifrost.timeout_secs,
credential_file: None,
cc_version: None,
account_uuid: None,
device_id: None,
extra_metadata: None,
},
);
}
if self.inference.provider == "claude-subscription"
&& !self.providers.contains_key("claude-subscription")
{
self.providers.insert(
"claude-subscription".to_string(),
ProviderConfig {
provider_type: "claude-subscription".to_string(),
base_url: "https://api.anthropic.com".to_string(),
api_key: String::new(),
virtual_key: String::new(),
primary_model: self.bifrost.primary_model.clone(),
timeout_secs: self.bifrost.timeout_secs,
credential_file: None,
cc_version: None,
account_uuid: None,
device_id: None,
extra_metadata: None,
},
);
}