feat(auth): add subscription auth profiles and codex/claude flows

This commit is contained in:
Codex 2026-02-15 19:02:41 +03:00 committed by Chummy
parent 6d8725c9e6
commit 007368d586
13 changed files with 1981 additions and 12 deletions

View file

@ -6,6 +6,7 @@ use crate::tools::ToolSpec;
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub struct AnthropicProvider {
credential: Option<String>,
@ -614,4 +615,10 @@ mod tests {
assert!(json.contains(&format!("{temp}")));
}
}
#[test]
fn detects_auth_from_jwt_shape() {
let kind = detect_auth_kind("a.b.c", None);
assert_eq!(kind, AnthropicAuthKind::Authorization);
}
}

View file

@ -4,6 +4,7 @@ pub mod copilot;
pub mod gemini;
pub mod ollama;
pub mod openai;
pub mod openai_codex;
pub mod openrouter;
pub mod reliable;
pub mod router;
@ -17,6 +18,7 @@ pub use traits::{
use compatible::{AuthStyle, OpenAiCompatibleProvider};
use reliable::ReliableProvider;
use std::path::PathBuf;
const MAX_API_ERROR_CHARS: usize = 200;
const MINIMAX_INTL_BASE_URL: &str = "https://api.minimax.io/v1";
@ -178,6 +180,23 @@ fn zai_base_url(name: &str) -> Option<&'static str> {
}
}
#[derive(Debug, Clone)]
pub struct ProviderRuntimeOptions {
pub auth_profile_override: Option<String>,
pub zeroclaw_dir: Option<PathBuf>,
pub secrets_encrypt: bool,
}
impl Default for ProviderRuntimeOptions {
fn default() -> Self {
Self {
auth_profile_override: None,
zeroclaw_dir: None,
secrets_encrypt: true,
}
}
}
fn is_secret_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':')
}
@ -538,6 +557,21 @@ pub fn create_resilient_provider(
api_key: Option<&str>,
api_url: Option<&str>,
reliability: &crate::config::ReliabilityConfig,
) -> anyhow::Result<Box<dyn Provider>> {
create_resilient_provider_with_options(
primary_name,
api_key,
reliability,
&ProviderRuntimeOptions::default(),
)
}
/// Create provider chain with retry/fallback behavior and auth runtime options.
pub fn create_resilient_provider_with_options(
primary_name: &str,
api_key: Option<&str>,
reliability: &crate::config::ReliabilityConfig,
options: &ProviderRuntimeOptions,
) -> anyhow::Result<Box<dyn Provider>> {
let mut providers: Vec<(String, Box<dyn Provider>)> = Vec::new();
@ -943,6 +977,12 @@ mod tests {
assert!(create_provider("openai", Some("provider-test-credential")).is_ok());
}
#[test]
fn factory_openai_codex() {
let options = ProviderRuntimeOptions::default();
assert!(create_provider_with_options("openai-codex", None, &options).is_ok());
}
#[test]
fn factory_ollama() {
assert!(create_provider("ollama", None).is_ok());

View file

@ -0,0 +1,198 @@
use crate::auth::AuthService;
use crate::providers::traits::Provider;
use crate::providers::ProviderRuntimeOptions;
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
const CODEX_RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses";
pub struct OpenAiCodexProvider {
auth: AuthService,
auth_profile_override: Option<String>,
client: Client,
}
#[derive(Debug, Serialize)]
struct ResponsesRequest {
model: String,
input: Vec<ResponsesInput>,
#[serde(skip_serializing_if = "Option::is_none")]
instructions: Option<String>,
stream: bool,
}
#[derive(Debug, Serialize)]
struct ResponsesInput {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct ResponsesResponse {
#[serde(default)]
output: Vec<ResponsesOutput>,
#[serde(default)]
output_text: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ResponsesOutput {
#[serde(default)]
content: Vec<ResponsesContent>,
}
#[derive(Debug, Deserialize)]
struct ResponsesContent {
#[serde(rename = "type")]
kind: Option<String>,
text: Option<String>,
}
impl OpenAiCodexProvider {
pub fn new(options: &ProviderRuntimeOptions) -> Self {
let state_dir = options
.zeroclaw_dir
.clone()
.unwrap_or_else(default_zeroclaw_dir);
let auth = AuthService::new(&state_dir, options.secrets_encrypt);
Self {
auth,
auth_profile_override: options.auth_profile_override.clone(),
client: Client::builder()
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
}
}
}
fn default_zeroclaw_dir() -> PathBuf {
directories::UserDirs::new().map_or_else(
|| PathBuf::from(".zeroclaw"),
|dirs| dirs.home_dir().join(".zeroclaw"),
)
}
fn first_nonempty(text: Option<&str>) -> Option<String> {
text.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn extract_responses_text(response: &ResponsesResponse) -> Option<String> {
if let Some(text) = first_nonempty(response.output_text.as_deref()) {
return Some(text);
}
for item in &response.output {
for content in &item.content {
if content.kind.as_deref() == Some("output_text") {
if let Some(text) = first_nonempty(content.text.as_deref()) {
return Some(text);
}
}
}
}
for item in &response.output {
for content in &item.content {
if let Some(text) = first_nonempty(content.text.as_deref()) {
return Some(text);
}
}
}
None
}
#[async_trait]
impl Provider for OpenAiCodexProvider {
async fn chat_with_system(
&self,
system_prompt: Option<&str>,
message: &str,
model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
let access_token = self
.auth
.get_valid_openai_access_token(self.auth_profile_override.as_deref())
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"OpenAI Codex auth profile not found. Run `zeroclaw auth login --provider openai-codex`."
)
})?;
let request = ResponsesRequest {
model: model.to_string(),
input: vec![ResponsesInput {
role: "user".to_string(),
content: message.to_string(),
}],
instructions: system_prompt.map(str::to_string),
stream: false,
};
let response = self
.client
.post(CODEX_RESPONSES_URL)
.header("Authorization", format!("Bearer {access_token}"))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await?;
if !response.status().is_success() {
return Err(super::api_error("OpenAI Codex", response).await);
}
let parsed: ResponsesResponse = response.json().await?;
extract_responses_text(&parsed)
.ok_or_else(|| anyhow::anyhow!("No response from OpenAI Codex"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_output_text_first() {
let response = ResponsesResponse {
output: vec![],
output_text: Some("hello".into()),
};
assert_eq!(extract_responses_text(&response).as_deref(), Some("hello"));
}
#[test]
fn extracts_nested_output_text() {
let response = ResponsesResponse {
output: vec![ResponsesOutput {
content: vec![ResponsesContent {
kind: Some("output_text".into()),
text: Some("nested".into()),
}],
}],
output_text: None,
};
assert_eq!(extract_responses_text(&response).as_deref(), Some("nested"));
}
#[test]
fn default_state_dir_is_non_empty() {
let path = default_zeroclaw_dir();
assert!(!path.as_os_str().is_empty());
}
}