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:
parent
419312b65f
commit
904fbd02d7
5 changed files with 1061 additions and 113 deletions
111
Cargo.lock
generated
111
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
977
src/bridge/claude_subscription.rs
Normal file
977
src/bridge/claude_subscription.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue