feat(auth): add subscription auth profiles and codex/claude flows
This commit is contained in:
parent
6d8725c9e6
commit
007368d586
13 changed files with 1981 additions and 12 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
198
src/providers/openai_codex.rs
Normal file
198
src/providers/openai_codex.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue