refactor: fold provider crate into engine
This commit is contained in:
@@ -5,6 +5,10 @@ version = "0.2.1"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
codex = ["dep:base64", "dep:chrono"]
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
@@ -12,11 +16,13 @@ thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
|
||||
tokio = { workspace = true, features = ["fs", "macros", "rt-multi-thread", "sync", "time"] }
|
||||
tokio-util = "0.7"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "native-tls", "http2"] }
|
||||
eventsource-stream = "0.2"
|
||||
zstd = "0.13"
|
||||
base64 = { version = "0.22.1", optional = true }
|
||||
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
|
||||
llm-engine-macros = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -45,6 +45,7 @@ pub(crate) mod callback;
|
||||
pub mod event;
|
||||
pub mod interceptor;
|
||||
pub mod llm_client;
|
||||
pub mod providers;
|
||||
pub mod prune;
|
||||
pub mod state;
|
||||
pub mod timeline;
|
||||
|
||||
@@ -40,12 +40,4 @@ pub enum AuthRequirement {
|
||||
pub trait AuthProvider: Send + Sync + std::fmt::Debug {
|
||||
/// 1 リクエスト分の認証ヘッダを返す。refresh が必要なら内部で行う。
|
||||
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError>;
|
||||
|
||||
/// Conversation header / request compression が必要な backend profile かどうか。
|
||||
///
|
||||
/// transport は呼び出し側の具象型を知らないため、この hook だけで
|
||||
/// 追加の wire behavior を切り替える。
|
||||
fn is_codex_backend(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
//! LLM response stream を開く前の transient error 向けリトライポリシー。
|
||||
//!
|
||||
//! Engine が `LlmClient::stream` の open error に対して `is_retryable` を見て
|
||||
//! retry / backoff / TUI event / cancellation をまとめて管理する。
|
||||
//! `LlmClient::stream` の open error に対して `is_retryable` を見て
|
||||
//! retry / backoff / cancellation をまとめて管理する。
|
||||
//! SSE 読み出し開始後の失敗は対象外。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。
|
||||
///
|
||||
/// `Default` は llm-engine 全体の固定値を返す。manifest 経由の上書きが
|
||||
/// 必要になったら拡張する(現状は不要 → `tickets/llm-engine-transient-retry.md`)。
|
||||
/// `Default` は llm-engine 全体の固定値を返す。呼び出し側からの上書きが
|
||||
/// 必要になったら拡張する。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryPolicy {
|
||||
/// 指数の基準値。`base * 2^attempt` を `cap` で頭打ちにした上限から
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Anthropic scheme の wire-level 既定 capability。
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`claude-*` など)は高レベル構築層
|
||||
//! (`provider::capability`)の責務。ここでは未知モデルでも「この wire で
|
||||
//! モデル ID 固有のテーブル(`claude-*` など)は client construction layer
|
||||
//! の責務。ここでは未知モデルでも「この wire で
|
||||
//! 安全に送れる最小共通項」を返すだけに留める。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Gemini scheme の wire-level 既定 capability。
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`gemini-*` バージョン別の reasoning 有無)は
|
||||
//! 高レベル構築層(`provider::capability`)の責務。ここでは wire の
|
||||
//! client construction layer の責務。ここでは wire の
|
||||
//! 保守的 default のみ。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! OpenAI Chat Completions scheme の wire-level 既定 capability。
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`gpt-5` 系など)は高レベル構築層
|
||||
//! (`provider::capability`)の責務。ここでは wire の保守的 default のみ。
|
||||
//! モデル ID 固有のテーブル(`gpt-5` 系など)は client construction layer
|
||||
//! の責務。ここでは wire の保守的 default のみ。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
|
||||
@@ -65,7 +65,7 @@ impl Scheme for OpenAIResponsesScheme {
|
||||
if !self.send_max_output_tokens && config.max_tokens.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported(
|
||||
"max_tokens",
|
||||
"OpenAI Responses (ChatGPT backend)",
|
||||
"OpenAI Responses compatible backend",
|
||||
));
|
||||
}
|
||||
// Same for `temperature` / `top_p` on compatible backends that
|
||||
@@ -74,13 +74,13 @@ impl Scheme for OpenAIResponsesScheme {
|
||||
if config.temperature.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported(
|
||||
"temperature",
|
||||
"OpenAI Responses (ChatGPT backend)",
|
||||
"OpenAI Responses compatible backend",
|
||||
));
|
||||
}
|
||||
if config.top_p.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported(
|
||||
"top_p",
|
||||
"OpenAI Responses (ChatGPT backend)",
|
||||
"OpenAI Responses compatible backend",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! `HttpTransport<S: Scheme>`: すべての LLM wire scheme を共通の 1 本の
|
||||
//! HTTP クライアントで扱う。
|
||||
//!
|
||||
//! 旧 `providers/{anthropic,openai,gemini,ollama}.rs` を置き換える。
|
||||
//! scheme 固有の差分は [`Scheme`] trait 実装に委譲する。
|
||||
//! scheme 固有の差分は [`Scheme`] trait 実装に委譲し、backend 固有の
|
||||
//! HTTP policy は [`TransportPolicy`] で明示的に差し込む。
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
@@ -63,6 +63,71 @@ impl ResolvedAuth {
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body encoding policy used by [`HttpTransport`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RequestBodyEncoding {
|
||||
/// Send the request body as plain JSON.
|
||||
Json,
|
||||
/// Send the request body as zstd-compressed JSON with `Content-Encoding: zstd`.
|
||||
ZstdJson,
|
||||
}
|
||||
|
||||
impl Default for RequestBodyEncoding {
|
||||
fn default() -> Self {
|
||||
Self::Json
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversation header policy used by [`HttpTransport`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConversationHeaderPolicy {
|
||||
/// Do not derive any transport headers from [`Request::cache_key`].
|
||||
None,
|
||||
/// Send OpenAI-compatible conversation headers from [`Request::cache_key`].
|
||||
OpenAiCompatible {
|
||||
/// Send the legacy `session_id` header in addition to `session-id`.
|
||||
include_legacy_session_id: bool,
|
||||
/// Send `thread-id` with the same value as `session-id`.
|
||||
include_thread_id: bool,
|
||||
/// Send `x-client-request-id` with the same value as `session-id`.
|
||||
include_client_request_id: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for ConversationHeaderPolicy {
|
||||
fn default() -> Self {
|
||||
Self::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend-specific HTTP transport policy.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct TransportPolicy {
|
||||
/// How to serialize and encode the HTTP request body.
|
||||
pub request_body_encoding: RequestBodyEncoding,
|
||||
/// Optional backend-specific conversation headers derived from request metadata.
|
||||
pub conversation_headers: ConversationHeaderPolicy,
|
||||
}
|
||||
|
||||
impl TransportPolicy {
|
||||
/// Plain JSON requests with no derived conversation headers.
|
||||
pub fn standard() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// OpenAI-compatible backend profile that uses zstd JSON bodies and
|
||||
/// conversation headers derived from [`Request::cache_key`].
|
||||
pub fn openai_compatible_zstd() -> Self {
|
||||
Self {
|
||||
request_body_encoding: RequestBodyEncoding::ZstdJson,
|
||||
conversation_headers: ConversationHeaderPolicy::OpenAiCompatible {
|
||||
include_legacy_session_id: true,
|
||||
include_thread_id: true,
|
||||
include_client_request_id: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
fn header_value_for_diagnostics(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get(name)
|
||||
@@ -120,6 +185,7 @@ pub struct HttpTransport<S: Scheme> {
|
||||
base_url: String,
|
||||
auth: ResolvedAuth,
|
||||
capability: ModelCapability,
|
||||
policy: TransportPolicy,
|
||||
}
|
||||
|
||||
impl<S: Scheme> HttpTransport<S> {
|
||||
@@ -141,9 +207,16 @@ impl<S: Scheme> HttpTransport<S> {
|
||||
base_url,
|
||||
auth,
|
||||
capability,
|
||||
policy: TransportPolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a backend-specific HTTP transport policy.
|
||||
pub fn with_transport_policy(mut self, policy: TransportPolicy) -> Self {
|
||||
self.policy = policy;
|
||||
self
|
||||
}
|
||||
|
||||
/// カスタム HTTP クライアントを差し込む(テスト等)。
|
||||
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
|
||||
self.http_client = client;
|
||||
@@ -206,13 +279,6 @@ impl<S: Scheme> HttpTransport<S> {
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn is_codex_backend(&self) -> bool {
|
||||
match &self.auth {
|
||||
ResolvedAuth::Custom(provider) => provider.is_codex_backend(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_stream_headers(
|
||||
&self,
|
||||
headers: &mut HeaderMap,
|
||||
@@ -220,18 +286,26 @@ impl<S: Scheme> HttpTransport<S> {
|
||||
) -> Result<(), ClientError> {
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
|
||||
|
||||
if self.is_codex_backend()
|
||||
if let ConversationHeaderPolicy::OpenAiCompatible {
|
||||
include_legacy_session_id,
|
||||
include_thread_id,
|
||||
include_client_request_id,
|
||||
} = self.policy.conversation_headers
|
||||
&& let Some(cache_key) = request.cache_key.as_deref()
|
||||
{
|
||||
let value = HeaderValue::from_str(cache_key).map_err(|e| {
|
||||
ClientError::Config(format!("invalid Codex conversation header: {e}"))
|
||||
ClientError::Config(format!("invalid conversation header value: {e}"))
|
||||
})?;
|
||||
// Send both current hyphenated conversation headers and the
|
||||
// legacy underscore form for compatibility with existing backends.
|
||||
headers.insert(HeaderName::from_static("session-id"), value.clone());
|
||||
headers.insert(HeaderName::from_static("thread-id"), value.clone());
|
||||
headers.insert(HeaderName::from_static("session_id"), value.clone());
|
||||
headers.insert(HeaderName::from_static("x-client-request-id"), value);
|
||||
if include_thread_id {
|
||||
headers.insert(HeaderName::from_static("thread-id"), value.clone());
|
||||
}
|
||||
if include_legacy_session_id {
|
||||
headers.insert(HeaderName::from_static("session_id"), value.clone());
|
||||
}
|
||||
if include_client_request_id {
|
||||
headers.insert(HeaderName::from_static("x-client-request-id"), value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -242,19 +316,22 @@ impl<S: Scheme> HttpTransport<S> {
|
||||
body: &serde_json::Value,
|
||||
headers: &mut HeaderMap,
|
||||
) -> Result<RequestBody, ClientError> {
|
||||
if !self.is_codex_backend() {
|
||||
return Ok(RequestBody::Json(body.clone()));
|
||||
match self.policy.request_body_encoding {
|
||||
RequestBodyEncoding::Json => Ok(RequestBody::Json(body.clone())),
|
||||
RequestBodyEncoding::ZstdJson => {
|
||||
let raw = serde_json::to_vec(body)?;
|
||||
let raw_json_bytes = raw.len();
|
||||
let compressed =
|
||||
zstd::stream::encode_all(std::io::Cursor::new(raw), 3).map_err(|e| {
|
||||
ClientError::Config(format!("failed to zstd-compress request: {e}"))
|
||||
})?;
|
||||
headers.insert(CONTENT_ENCODING, HeaderValue::from_static("zstd"));
|
||||
Ok(RequestBody::CompressedJson {
|
||||
bytes: compressed,
|
||||
raw_json_bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let raw = serde_json::to_vec(body)?;
|
||||
let raw_json_bytes = raw.len();
|
||||
let compressed = zstd::stream::encode_all(std::io::Cursor::new(raw), 3)
|
||||
.map_err(|e| ClientError::Config(format!("failed to zstd-compress request: {e}")))?;
|
||||
headers.insert(CONTENT_ENCODING, HeaderValue::from_static("zstd"));
|
||||
Ok(RequestBody::CompressedJson {
|
||||
bytes: compressed,
|
||||
raw_json_bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +460,7 @@ impl<S: Scheme + Clone> Clone for HttpTransport<S> {
|
||||
base_url: self.base_url.clone(),
|
||||
auth: self.auth.clone(),
|
||||
capability: self.capability.clone(),
|
||||
policy: self.policy.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,7 +524,8 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
|
||||
"path": path,
|
||||
"auth_kind": auth_kind(&self.auth),
|
||||
"required_auth": format!("{:?}", self.scheme.required_auth()),
|
||||
"codex_backend": self.is_codex_backend(),
|
||||
"request_body_encoding": format!("{:?}", self.policy.request_body_encoding),
|
||||
"conversation_headers": format!("{:?}", self.policy.conversation_headers),
|
||||
"cache_key_present": request.cache_key.is_some(),
|
||||
"stream_open_timeout_ms": DEFAULT_STREAM_OPEN_TIMEOUT.as_millis() as u64,
|
||||
}),
|
||||
@@ -677,9 +756,7 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestAuthProvider {
|
||||
codex: bool,
|
||||
}
|
||||
struct TestAuthProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl AuthProvider for TestAuthProvider {
|
||||
@@ -695,10 +772,6 @@ mod tests {
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn is_codex_backend(&self) -> bool {
|
||||
self.codex
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -827,10 +900,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_backend_adds_conversation_headers_and_zstd_body() {
|
||||
let transport = transport(ResolvedAuth::Custom(Arc::new(TestAuthProvider {
|
||||
codex: true,
|
||||
})));
|
||||
async fn transport_policy_adds_conversation_headers_and_zstd_body() {
|
||||
let transport = transport(ResolvedAuth::Custom(Arc::new(TestAuthProvider)))
|
||||
.with_transport_policy(TransportPolicy::openai_compatible_zstd());
|
||||
let request = Request::new().user("hello").cache_key("segment-123");
|
||||
let mut headers = transport.build_headers().await.unwrap();
|
||||
transport
|
||||
@@ -869,7 +941,7 @@ mod tests {
|
||||
raw_json_bytes,
|
||||
} = encoded
|
||||
else {
|
||||
panic!("Codex backend request body must be zstd-compressed");
|
||||
panic!("transport policy should zstd-compress request body");
|
||||
};
|
||||
assert!(raw_json_bytes > 0);
|
||||
let decoded = zstd::stream::decode_all(std::io::Cursor::new(compressed)).unwrap();
|
||||
@@ -878,7 +950,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_codex_request_does_not_get_codex_only_headers_or_compression() {
|
||||
async fn standard_policy_does_not_get_conversation_headers_or_compression() {
|
||||
let transport = transport(ResolvedAuth::ApiKey("api-key".to_string()));
|
||||
let request = Request::new().user("hello").cache_key("segment-123");
|
||||
let mut headers = transport.build_headers().await.unwrap();
|
||||
@@ -900,7 +972,7 @@ mod tests {
|
||||
assert!(headers.get(CONTENT_ENCODING).is_none());
|
||||
|
||||
let RequestBody::Json(decoded) = encoded else {
|
||||
panic!("non-Codex request body must remain normal JSON");
|
||||
panic!("standard transport policy should keep request body as JSON");
|
||||
};
|
||||
assert_eq!(decoded["prompt_cache_key"], "segment-123");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! `~/.codex/auth.json` の読み書き。
|
||||
//!
|
||||
//! Codex CLI と schema を共有するが、知らないフィールドを失わないよう
|
||||
//! ファイル全体を `serde_json::Value` で保持し、必要箇所
|
||||
//! のみアクセスする。書込は `mode 0o600` を再設定(Codex CLI 同様)、
|
||||
//! ファイルロックは取らない(manager 側で guarded reload)。
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::error::CodexAuthError;
|
||||
|
||||
/// auth.json から取り出した使い回す情報のスナップショット。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthSnapshot {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub account_id: String,
|
||||
pub id_token: String,
|
||||
pub last_refresh: Option<DateTime<Utc>>,
|
||||
/// 書き戻し時に他のフィールドを失わないため、ファイル全体を保持する。
|
||||
pub raw: Value,
|
||||
}
|
||||
|
||||
impl AuthSnapshot {
|
||||
/// `Value` から必要フィールドを抽出。欠落・型不一致は `MalformedAuthJson`。
|
||||
pub fn from_value(raw: Value) -> Result<Self, CodexAuthError> {
|
||||
let tokens = raw
|
||||
.get("tokens")
|
||||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing 'tokens'".into()))?;
|
||||
|
||||
let access_token = tokens
|
||||
.get("access_token")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.access_token".into()))?
|
||||
.to_string();
|
||||
|
||||
let refresh_token = tokens
|
||||
.get("refresh_token")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
CodexAuthError::MalformedAuthJson("missing tokens.refresh_token".into())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let id_token = tokens
|
||||
.get("id_token")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.id_token".into()))?
|
||||
.to_string();
|
||||
|
||||
// account_id は tokens.account_id を優先、無ければ id_token JWT 由来
|
||||
let account_id = tokens
|
||||
.get("account_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.or_else(|| super::jwt::parse_chatgpt_claims(&id_token).and_then(|c| c.account_id))
|
||||
.ok_or_else(|| {
|
||||
CodexAuthError::MalformedAuthJson(
|
||||
"missing account_id in both tokens and id_token claims".into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let last_refresh = raw
|
||||
.get("last_refresh")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc));
|
||||
|
||||
Ok(Self {
|
||||
access_token,
|
||||
refresh_token,
|
||||
account_id,
|
||||
id_token,
|
||||
last_refresh,
|
||||
raw,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// auth.json を読む。存在しなければ `NotLoggedIn`。
|
||||
pub async fn load(path: &Path) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
let bytes = match tokio::fs::read(path).await {
|
||||
Ok(b) => b,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(CodexAuthError::NotLoggedIn(path.to_path_buf()));
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(CodexAuthError::Io(format!(
|
||||
"failed to read {}: {err}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
};
|
||||
let raw: Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| CodexAuthError::MalformedAuthJson(format!("json parse: {e}")))?;
|
||||
AuthSnapshot::from_value(raw)
|
||||
}
|
||||
|
||||
/// 既存ファイルを再読込し、`tokens.{id_token,access_token,refresh_token}` と
|
||||
/// `last_refresh` を更新して書き戻す。Codex CLI の `persist_tokens` 相当。
|
||||
///
|
||||
/// 並行する Codex CLI / 別プロセスが先に refresh していた場合の
|
||||
/// fields を保護するため、書込前に再 load して merge する。
|
||||
pub async fn persist_refreshed(
|
||||
path: &Path,
|
||||
new_id_token: Option<String>,
|
||||
new_access_token: Option<String>,
|
||||
new_refresh_token: Option<String>,
|
||||
) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
let mut current = load(path).await?;
|
||||
let raw = &mut current.raw;
|
||||
let tokens = raw
|
||||
.get_mut("tokens")
|
||||
.and_then(Value::as_object_mut)
|
||||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("tokens not an object".into()))?;
|
||||
if let Some(t) = new_id_token {
|
||||
tokens.insert("id_token".into(), Value::String(t));
|
||||
}
|
||||
if let Some(t) = new_access_token {
|
||||
tokens.insert("access_token".into(), Value::String(t));
|
||||
}
|
||||
if let Some(t) = new_refresh_token {
|
||||
tokens.insert("refresh_token".into(), Value::String(t));
|
||||
}
|
||||
raw.as_object_mut()
|
||||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("auth.json not an object".into()))?
|
||||
.insert(
|
||||
"last_refresh".into(),
|
||||
Value::String(Utc::now().to_rfc3339()),
|
||||
);
|
||||
|
||||
write_atomic(path, raw)?;
|
||||
AuthSnapshot::from_value(raw.clone())
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, value: &Value) -> Result<(), CodexAuthError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| CodexAuthError::Io(format!("create_dir_all {}: {e}", parent.display())))?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(value)
|
||||
.map_err(|e| CodexAuthError::Io(format!("serialize: {e}")))?;
|
||||
let mut options = OpenOptions::new();
|
||||
options.truncate(true).write(true).create(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options
|
||||
.open(path)
|
||||
.map_err(|e| CodexAuthError::Io(format!("open {}: {e}", path.display())))?;
|
||||
file.write_all(&json)
|
||||
.map_err(|e| CodexAuthError::Io(format!("write {}: {e}", path.display())))?;
|
||||
file.flush()
|
||||
.map_err(|e| CodexAuthError::Io(format!("flush {}: {e}", path.display())))?;
|
||||
|
||||
// 既存ファイルが緩いパーミッションだった場合に備えて 0o600 を強制し直す。
|
||||
// OpenOptions の `mode()` は新規作成時しか効かないため。
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|e| CodexAuthError::Io(format!("chmod {}: {e}", path.display())))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write_auth_json(dir: &Path, content: &str) -> std::path::PathBuf {
|
||||
let path = dir.join("auth.json");
|
||||
std::fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_round_trip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = write_auth_json(
|
||||
dir.path(),
|
||||
r#"{
|
||||
"auth_mode":"ChatgptAuthTokens",
|
||||
"tokens":{
|
||||
"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjLTEifX0.s",
|
||||
"access_token":"acc",
|
||||
"refresh_token":"ref",
|
||||
"account_id":"acc-1"
|
||||
},
|
||||
"last_refresh":"2026-04-20T00:00:00Z",
|
||||
"OPENAI_API_KEY":"sk-extra"
|
||||
}"#,
|
||||
);
|
||||
let snap = load(&path).await.unwrap();
|
||||
assert_eq!(snap.access_token, "acc");
|
||||
assert_eq!(snap.refresh_token, "ref");
|
||||
assert_eq!(snap.account_id, "acc-1");
|
||||
assert!(snap.last_refresh.is_some());
|
||||
// 未知フィールドが raw に保持されている
|
||||
assert_eq!(
|
||||
snap.raw.get("OPENAI_API_KEY").and_then(Value::as_str),
|
||||
Some("sk-extra")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_id_falls_back_to_jwt_claim() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// tokens.account_id を欠落させ、id_token JWT 内 claim から拾う
|
||||
let id_token = "h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiZnJvbS1qd3QifX0.s";
|
||||
let path = write_auth_json(
|
||||
dir.path(),
|
||||
&format!(
|
||||
r#"{{ "tokens": {{ "id_token":"{id_token}", "access_token":"a", "refresh_token":"r" }} }}"#
|
||||
),
|
||||
);
|
||||
let snap = load(&path).await.unwrap();
|
||||
assert_eq!(snap.account_id, "from-jwt");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_file_returns_not_logged_in() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let err = load(&dir.path().join("nope.json")).await.unwrap_err();
|
||||
assert!(matches!(err, CodexAuthError::NotLoggedIn(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_preserves_unknown_fields() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = write_auth_json(
|
||||
dir.path(),
|
||||
r#"{
|
||||
"tokens":{"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYSJ9fQ.s","access_token":"old-acc","refresh_token":"old-ref","account_id":"a"},
|
||||
"agent_identity":{"workspace_id":"w","agent_runtime_id":"r","agent_private_key":"k","registered_at":"x"}
|
||||
}"#,
|
||||
);
|
||||
let updated =
|
||||
persist_refreshed(&path, None, Some("new-acc".into()), Some("new-ref".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.access_token, "new-acc");
|
||||
assert_eq!(updated.refresh_token, "new-ref");
|
||||
// 未知フィールド agent_identity が保たれる
|
||||
let on_disk: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert!(on_disk.get("agent_identity").is_some());
|
||||
assert!(on_disk.get("last_refresh").is_some());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn write_uses_mode_600() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = write_auth_json(
|
||||
dir.path(),
|
||||
r#"{"tokens":{"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYSJ9fQ.s","access_token":"a","refresh_token":"r","account_id":"a"}}"#,
|
||||
);
|
||||
// 既存ファイルを 644 に変えてから persist → 600 に直るか
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
persist_refreshed(&path, None, Some("a2".into()), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Codex OAuth サブモジュール内部のエラー型。
|
||||
//!
|
||||
//! `LlmClient` 境界に渡す際は `to_client_error` で `ClientError` に
|
||||
//! 変換する。`Permanent` 系(refresh_token 失効)は `codex login` の
|
||||
//! 再実行案内付きメッセージにする。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::llm_client::ClientError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CodexAuthError {
|
||||
#[error(
|
||||
"not logged in to ChatGPT: {0} not found. Run `codex login` and ensure cli_auth_credentials_store = \"file\"."
|
||||
)]
|
||||
NotLoggedIn(PathBuf),
|
||||
|
||||
#[error("malformed ~/.codex/auth.json: {0}")]
|
||||
MalformedAuthJson(String),
|
||||
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
|
||||
#[error("token refresh failed (transient): {0}")]
|
||||
RefreshTransient(String),
|
||||
|
||||
/// refresh_token が永続的に失効。再ログインが必要。
|
||||
#[error("token refresh failed permanently ({reason:?}): {message}")]
|
||||
RefreshPermanent {
|
||||
reason: PermanentReason,
|
||||
message: String,
|
||||
},
|
||||
|
||||
#[error("invalid header value: {0}")]
|
||||
InvalidHeader(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PermanentReason {
|
||||
Expired,
|
||||
Reused,
|
||||
Revoked,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl CodexAuthError {
|
||||
/// `LlmClient` トランスポート境界向けに変換する。
|
||||
pub fn to_client_error(self) -> ClientError {
|
||||
match self {
|
||||
CodexAuthError::NotLoggedIn(_)
|
||||
| CodexAuthError::MalformedAuthJson(_)
|
||||
| CodexAuthError::Io(_)
|
||||
| CodexAuthError::InvalidHeader(_) => ClientError::Config(self.to_string()),
|
||||
CodexAuthError::RefreshTransient(msg) => ClientError::Api {
|
||||
status: None,
|
||||
code: Some("refresh_transient".into()),
|
||||
message: msg,
|
||||
retry_after: None,
|
||||
},
|
||||
CodexAuthError::RefreshPermanent { reason, message } => ClientError::Api {
|
||||
status: Some(401),
|
||||
code: Some(match reason {
|
||||
PermanentReason::Expired => "refresh_token_expired".into(),
|
||||
PermanentReason::Reused => "refresh_token_reused".into(),
|
||||
PermanentReason::Revoked => "refresh_token_invalidated".into(),
|
||||
PermanentReason::Other => "refresh_token_failed".into(),
|
||||
}),
|
||||
message: format!("{message}. Please run `codex login` again."),
|
||||
retry_after: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! JWT payload の最小限のパース(署名検証なし)。
|
||||
//!
|
||||
//! Codex CLI と同じく、access_token / id_token の payload を base64url
|
||||
//! デコードして `exp` や ChatGPT 固有 claims を取り出すためだけに使う。
|
||||
|
||||
use base64::Engine;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// `Authorization: Bearer` で送る access_token JWT の `exp` を読む。
|
||||
pub fn parse_exp(jwt: &str) -> Option<DateTime<Utc>> {
|
||||
#[derive(Deserialize)]
|
||||
struct Claims {
|
||||
exp: Option<i64>,
|
||||
}
|
||||
let claims: Claims = decode_payload(jwt).ok()?;
|
||||
DateTime::<Utc>::from_timestamp(claims.exp?, 0)
|
||||
}
|
||||
|
||||
/// id_token JWT から ChatGPT 固有 claims を取り出す。
|
||||
pub fn parse_chatgpt_claims(jwt: &str) -> Option<ChatGptClaims> {
|
||||
#[derive(Deserialize)]
|
||||
struct IdClaims {
|
||||
#[serde(rename = "https://api.openai.com/auth", default)]
|
||||
auth: Option<AuthClaims>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct AuthClaims {
|
||||
#[serde(default)]
|
||||
chatgpt_account_id: Option<String>,
|
||||
#[serde(default)]
|
||||
chatgpt_account_is_fedramp: bool,
|
||||
}
|
||||
let claims: IdClaims = decode_payload(jwt).ok()?;
|
||||
let auth = claims.auth?;
|
||||
Some(ChatGptClaims {
|
||||
account_id: auth.chatgpt_account_id,
|
||||
is_fedramp: auth.chatgpt_account_is_fedramp,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatGptClaims {
|
||||
pub account_id: Option<String>,
|
||||
pub is_fedramp: bool,
|
||||
}
|
||||
|
||||
fn decode_payload<T: for<'de> Deserialize<'de>>(jwt: &str) -> Result<T, JwtError> {
|
||||
let payload = jwt.split('.').nth(1).ok_or(JwtError::InvalidFormat)?;
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.map_err(|_| JwtError::InvalidBase64)?;
|
||||
serde_json::from_slice(&bytes).map_err(|_| JwtError::InvalidJson)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum JwtError {
|
||||
InvalidFormat,
|
||||
InvalidBase64,
|
||||
InvalidJson,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_jwt(payload: &serde_json::Value) -> String {
|
||||
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(payload).unwrap());
|
||||
format!("h.{payload_b64}.s")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_exp() {
|
||||
let jwt = make_jwt(&serde_json::json!({ "exp": 1_700_000_000_i64 }));
|
||||
let dt = parse_exp(&jwt).unwrap();
|
||||
assert_eq!(dt.timestamp(), 1_700_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_chatgpt_claims() {
|
||||
let jwt = make_jwt(&serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acc-123",
|
||||
"chatgpt_account_is_fedramp": true,
|
||||
}
|
||||
}));
|
||||
let c = parse_chatgpt_claims(&jwt).unwrap();
|
||||
assert_eq!(c.account_id.as_deref(), Some("acc-123"));
|
||||
assert!(c.is_fedramp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_exp_returns_none() {
|
||||
let jwt = make_jwt(&serde_json::json!({}));
|
||||
assert!(parse_exp(&jwt).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_jwt_returns_none() {
|
||||
assert!(parse_exp("not-a-jwt").is_none());
|
||||
assert!(parse_chatgpt_claims("x.y").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
//! ChatGPT OAuth (`~/.codex/auth.json`) を読んで `Authorization` /
|
||||
//! `ChatGPT-Account-Id` / `X-OpenAI-Fedramp` ヘッダを組み立てる
|
||||
//! [`AuthProvider`] 実装。
|
||||
//!
|
||||
//! 設計:
|
||||
//!
|
||||
//! - HTTP transport は [`AuthProvider`] trait だけを見て、実体である
|
||||
//! [`CodexAuthProvider`] はこの optional module に置く
|
||||
//! - access_token JWT の `exp` を読み、`now` 以下で proactive refresh
|
||||
//! (Codex CLI と同じバッファなし)
|
||||
//! - 並行する Codex CLI / 別プロセスの refresh と取り違えないよう、
|
||||
//! refresh 直前に再 load して account_id 一致を確認(guarded reload)
|
||||
//! - ファイルロックは取らず、書込前に再 load + diff merge で吸収
|
||||
//! - Codex の Keyring storage は対象外。auth.json 不在ならエラーで案内
|
||||
|
||||
mod auth_json;
|
||||
mod error;
|
||||
mod jwt;
|
||||
mod refresh;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::llm_client::{ClientError, auth::AuthProvider};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use reqwest::header::{HeaderName, HeaderValue};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use auth_json::AuthSnapshot;
|
||||
use error::CodexAuthError;
|
||||
|
||||
pub use error::PermanentReason;
|
||||
|
||||
/// Codex CLI の `last_refresh` ベース fallback 期限(Codex CLI 準拠で 8 日)。
|
||||
const TOKEN_REFRESH_INTERVAL_DAYS: i64 = 8;
|
||||
|
||||
/// `~/.codex/auth.json` を読んで Codex 互換のヘッダを返す provider。
|
||||
pub struct CodexAuthProvider {
|
||||
auth_path: PathBuf,
|
||||
refresh_endpoint: String,
|
||||
http: reqwest::Client,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for CodexAuthProvider {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CodexAuthProvider")
|
||||
.field("auth_path", &self.auth_path)
|
||||
.field("refresh_endpoint", &self.refresh_endpoint)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
cached: Option<AuthSnapshot>,
|
||||
}
|
||||
|
||||
impl CodexAuthProvider {
|
||||
/// `CODEX_HOME` → `$HOME/.codex` の順で auth.json の場所を決める。
|
||||
pub fn from_default_home() -> Result<Self, ClientError> {
|
||||
let codex_home = if let Ok(p) = std::env::var("CODEX_HOME") {
|
||||
PathBuf::from(p)
|
||||
} else {
|
||||
let home =
|
||||
std::env::var("HOME").map_err(|_| ClientError::Config("HOME not set".into()))?;
|
||||
PathBuf::from(home).join(".codex")
|
||||
};
|
||||
Ok(Self::new(codex_home))
|
||||
}
|
||||
|
||||
/// 任意の codex_home から構築する。
|
||||
pub fn new(codex_home: PathBuf) -> Self {
|
||||
Self {
|
||||
auth_path: codex_home.join("auth.json"),
|
||||
refresh_endpoint: refresh::REFRESH_URL.to_string(),
|
||||
http: reqwest::Client::new(),
|
||||
state: Arc::new(Mutex::new(State::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// テスト用に refresh エンドポイントを差し替える。
|
||||
#[cfg(test)]
|
||||
fn with_refresh_endpoint(mut self, endpoint: impl Into<String>) -> Self {
|
||||
self.refresh_endpoint = endpoint.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// テスト用に HTTP クライアントを差し替える。
|
||||
#[cfg(test)]
|
||||
fn with_http_client(mut self, client: reqwest::Client) -> Self {
|
||||
self.http = client;
|
||||
self
|
||||
}
|
||||
|
||||
async fn ensure_fresh(&self) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
let mut state = self.state.lock().await;
|
||||
|
||||
// 1. 常にディスクから最新を読む(他プロセスの更新を反映)
|
||||
let snap = auth_json::load(&self.auth_path).await?;
|
||||
|
||||
// 2. stale でなければそのまま返す
|
||||
if !is_stale(&snap) {
|
||||
state.cached = Some(snap.clone());
|
||||
return Ok(snap);
|
||||
}
|
||||
|
||||
// 3. Refresh 直前に再 load し account_id 一致を確認(guarded reload)。
|
||||
// 一致しなければ他プロセスが先に更新済 → 自分は refresh しない
|
||||
let pre_refresh = auth_json::load(&self.auth_path).await?;
|
||||
if !is_stale(&pre_refresh) {
|
||||
state.cached = Some(pre_refresh.clone());
|
||||
return Ok(pre_refresh);
|
||||
}
|
||||
if pre_refresh.account_id != snap.account_id {
|
||||
// account が切り替わった → 新しい auth を採用、refresh はしない
|
||||
state.cached = Some(pre_refresh.clone());
|
||||
return Ok(pre_refresh);
|
||||
}
|
||||
|
||||
// 4. Refresh 実行
|
||||
let refreshed = refresh::request_refresh(
|
||||
&self.http,
|
||||
&self.refresh_endpoint,
|
||||
&pre_refresh.refresh_token,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 5. 書き戻し(書込前に再 load + merge は persist_refreshed 内で実施)
|
||||
let new_snap = auth_json::persist_refreshed(
|
||||
&self.auth_path,
|
||||
refreshed.id_token,
|
||||
refreshed.access_token,
|
||||
refreshed.refresh_token,
|
||||
)
|
||||
.await?;
|
||||
state.cached = Some(new_snap.clone());
|
||||
Ok(new_snap)
|
||||
}
|
||||
|
||||
fn build_headers(
|
||||
snap: &AuthSnapshot,
|
||||
) -> Result<Vec<(HeaderName, HeaderValue)>, CodexAuthError> {
|
||||
let mut out = Vec::with_capacity(5);
|
||||
|
||||
let auth_val = HeaderValue::from_str(&format!("Bearer {}", snap.access_token))
|
||||
.map_err(|e| CodexAuthError::InvalidHeader(format!("Authorization: {e}")))?;
|
||||
out.push((HeaderName::from_static("authorization"), auth_val));
|
||||
|
||||
let acc_val = HeaderValue::from_str(&snap.account_id)
|
||||
.map_err(|e| CodexAuthError::InvalidHeader(format!("ChatGPT-Account-Id: {e}")))?;
|
||||
out.push((HeaderName::from_static("chatgpt-account-id"), acc_val));
|
||||
|
||||
// Cloudflare WAF は互換 backend アクセス元を `originator` /
|
||||
// `User-Agent` で識別する。Codex CLI が送る固定値を流用しないと
|
||||
// HTML challenge (403) を返されて SSE に到達できない。
|
||||
out.push((
|
||||
HeaderName::from_static("originator"),
|
||||
HeaderValue::from_static("codex_cli_rs"),
|
||||
));
|
||||
out.push((
|
||||
HeaderName::from_static("user-agent"),
|
||||
HeaderValue::from_static("codex_cli_rs/0.60.0"),
|
||||
));
|
||||
|
||||
// FedRAMP 組織は id_token JWT 内の claim で判定
|
||||
if jwt::parse_chatgpt_claims(&snap.id_token)
|
||||
.map(|c| c.is_fedramp)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
out.push((
|
||||
HeaderName::from_static("x-openai-fedramp"),
|
||||
HeaderValue::from_static("true"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthProvider for CodexAuthProvider {
|
||||
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError> {
|
||||
let snap = self
|
||||
.ensure_fresh()
|
||||
.await
|
||||
.map_err(CodexAuthError::to_client_error)?;
|
||||
Self::build_headers(&snap).map_err(CodexAuthError::to_client_error)
|
||||
}
|
||||
}
|
||||
|
||||
/// `access_token` の JWT `exp` を見て、期限切れなら true。
|
||||
/// `exp` が読めない場合は `last_refresh + 8 日` で判定(Codex CLI と同じ)。
|
||||
fn is_stale(snap: &AuthSnapshot) -> bool {
|
||||
if let Some(exp) = jwt::parse_exp(&snap.access_token) {
|
||||
return exp <= Utc::now();
|
||||
}
|
||||
match snap.last_refresh {
|
||||
Some(last) => last < Utc::now() - Duration::days(TOKEN_REFRESH_INTERVAL_DAYS),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path as url_path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn make_jwt(payload: serde_json::Value) -> String {
|
||||
let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&payload).unwrap());
|
||||
format!("h.{p}.s")
|
||||
}
|
||||
|
||||
fn write_auth(dir: &std::path::Path, exp: i64, fedramp: bool, refresh: &str) -> PathBuf {
|
||||
let id_token = make_jwt(json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acc-xyz",
|
||||
"chatgpt_account_is_fedramp": fedramp,
|
||||
}
|
||||
}));
|
||||
let access_token = make_jwt(json!({ "exp": exp }));
|
||||
let auth = json!({
|
||||
"tokens": {
|
||||
"id_token": id_token,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh,
|
||||
"account_id": "acc-xyz",
|
||||
},
|
||||
"last_refresh": "2026-04-20T00:00:00Z",
|
||||
});
|
||||
let path = dir.join("auth.json");
|
||||
std::fs::write(&path, serde_json::to_vec_pretty(&auth).unwrap()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_headers_for_fresh_token() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// 期限を遠い未来に
|
||||
write_auth(dir.path(), Utc::now().timestamp() + 3600, false, "rt");
|
||||
let provider = CodexAuthProvider::new(dir.path().to_path_buf());
|
||||
|
||||
let headers = provider.headers().await.unwrap();
|
||||
let names: Vec<_> = headers
|
||||
.iter()
|
||||
.map(|(n, _)| n.as_str().to_string())
|
||||
.collect();
|
||||
assert!(names.contains(&"authorization".to_string()));
|
||||
assert!(names.contains(&"chatgpt-account-id".to_string()));
|
||||
assert!(!names.contains(&"x-openai-fedramp".to_string()));
|
||||
|
||||
let acc = headers
|
||||
.iter()
|
||||
.find(|(n, _)| n.as_str() == "chatgpt-account-id")
|
||||
.unwrap();
|
||||
assert_eq!(acc.1.to_str().unwrap(), "acc-xyz");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fedramp_header_added_when_claim_set() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_auth(dir.path(), Utc::now().timestamp() + 3600, true, "rt");
|
||||
let provider = CodexAuthProvider::new(dir.path().to_path_buf());
|
||||
|
||||
let headers = provider.headers().await.unwrap();
|
||||
let fedramp = headers
|
||||
.iter()
|
||||
.find(|(n, _)| n.as_str() == "x-openai-fedramp");
|
||||
assert!(fedramp.is_some());
|
||||
assert_eq!(fedramp.unwrap().1, "true");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refreshes_when_expired_and_persists() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = write_auth(
|
||||
dir.path(),
|
||||
Utc::now().timestamp() - 60,
|
||||
false,
|
||||
"old-refresh",
|
||||
);
|
||||
|
||||
// refresh エンドポイントを mock。新しい JWT (将来 exp) を返す
|
||||
let server = MockServer::start().await;
|
||||
let new_access = make_jwt(json!({ "exp": Utc::now().timestamp() + 3600 }));
|
||||
let new_refresh = "new-refresh-token";
|
||||
Mock::given(method("POST"))
|
||||
.and(url_path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"access_token": new_access,
|
||||
"refresh_token": new_refresh,
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = CodexAuthProvider::new(dir.path().to_path_buf())
|
||||
.with_refresh_endpoint(format!("{}/oauth/token", server.uri()))
|
||||
.with_http_client(reqwest::Client::new());
|
||||
|
||||
let headers = provider.headers().await.unwrap();
|
||||
let auth = headers
|
||||
.iter()
|
||||
.find(|(n, _)| n.as_str() == "authorization")
|
||||
.unwrap();
|
||||
assert_eq!(auth.1.to_str().unwrap(), format!("Bearer {new_access}"));
|
||||
|
||||
// ファイルに新しい refresh_token が書き戻されている
|
||||
let on_disk: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
on_disk["tokens"]["refresh_token"].as_str(),
|
||||
Some(new_refresh)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permanent_refresh_failure_surfaces_login_message() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_auth(
|
||||
dir.path(),
|
||||
Utc::now().timestamp() - 60,
|
||||
false,
|
||||
"bad-refresh",
|
||||
);
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(url_path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": { "code": "refresh_token_expired" }
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = CodexAuthProvider::new(dir.path().to_path_buf())
|
||||
.with_refresh_endpoint(format!("{}/oauth/token", server.uri()))
|
||||
.with_http_client(reqwest::Client::new());
|
||||
|
||||
let err = provider.headers().await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("codex login"), "expected hint, got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_auth_json_reports_not_logged_in() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let provider = CodexAuthProvider::new(dir.path().to_path_buf());
|
||||
let err = provider.headers().await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("not logged in"), "got: {msg}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! ChatGPT OAuth トークンの refresh HTTP 呼出。
|
||||
//!
|
||||
//! Codex CLI と同じ `POST https://auth.openai.com/oauth/token` 形式。
|
||||
//! 401 + `error.code` で永続失敗を分類する。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::error::{CodexAuthError, PermanentReason};
|
||||
|
||||
pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
pub const REFRESH_URL: &str = "https://auth.openai.com/oauth/token";
|
||||
pub const DEFAULT_REFRESH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshRequest<'a> {
|
||||
client_id: &'static str,
|
||||
grant_type: &'static str,
|
||||
refresh_token: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Default, Clone)]
|
||||
pub struct RefreshResponse {
|
||||
#[serde(default)]
|
||||
pub id_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub access_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
/// refresh_token を使って新しいトークン群を取得する。
|
||||
///
|
||||
/// 永続失敗(401 + `refresh_token_(expired|reused|invalidated)`)は
|
||||
/// `RefreshPermanent`、それ以外は `RefreshTransient`。
|
||||
pub async fn request_refresh(
|
||||
client: &reqwest::Client,
|
||||
endpoint: &str,
|
||||
refresh_token: &str,
|
||||
) -> Result<RefreshResponse, CodexAuthError> {
|
||||
let body = RefreshRequest {
|
||||
client_id: CLIENT_ID,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token,
|
||||
};
|
||||
let response = response_with_timeout(
|
||||
client
|
||||
.post(endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send(),
|
||||
DEFAULT_REFRESH_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
response
|
||||
.json::<RefreshResponse>()
|
||||
.await
|
||||
.map_err(|e| CodexAuthError::RefreshTransient(format!("parse response: {e}")))
|
||||
} else {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let (reason, message) = classify_permanent(&body);
|
||||
Err(CodexAuthError::RefreshPermanent { reason, message })
|
||||
} else {
|
||||
Err(CodexAuthError::RefreshTransient(format!(
|
||||
"{status}: {body}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn response_with_timeout(
|
||||
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
|
||||
timeout: Duration,
|
||||
) -> Result<reqwest::Response, CodexAuthError> {
|
||||
tokio::time::timeout(timeout, future)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
CodexAuthError::RefreshTransient(format!(
|
||||
"oauth_token_refresh timed out after {}s",
|
||||
timeout.as_secs()
|
||||
))
|
||||
})?
|
||||
.map_err(|e| CodexAuthError::RefreshTransient(format!("send: {e}")))
|
||||
}
|
||||
|
||||
fn classify_permanent(body: &str) -> (PermanentReason, String) {
|
||||
let code = extract_error_code(body);
|
||||
let reason = match code.as_deref() {
|
||||
Some("refresh_token_expired") => PermanentReason::Expired,
|
||||
Some("refresh_token_reused") => PermanentReason::Reused,
|
||||
Some("refresh_token_invalidated") => PermanentReason::Revoked,
|
||||
_ => PermanentReason::Other,
|
||||
};
|
||||
let message = match reason {
|
||||
PermanentReason::Expired => "Your refresh token has expired".to_string(),
|
||||
PermanentReason::Reused => "Your refresh token was already used".to_string(),
|
||||
PermanentReason::Revoked => "Your refresh token was revoked".to_string(),
|
||||
PermanentReason::Other => format!("Unknown 401 from refresh endpoint: {body}"),
|
||||
};
|
||||
(reason, message)
|
||||
}
|
||||
|
||||
fn extract_error_code(body: &str) -> Option<String> {
|
||||
let value: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
if let Some(error) = value.get("error") {
|
||||
if let Some(obj) = error.as_object() {
|
||||
if let Some(code) = obj.get("code").and_then(|v| v.as_str()) {
|
||||
return Some(code.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(s) = error.as_str() {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
}
|
||||
value
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_response_timeout_is_transient() {
|
||||
let err = match response_with_timeout(
|
||||
std::future::pending::<Result<reqwest::Response, reqwest::Error>>(),
|
||||
Duration::from_millis(5),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("expected refresh timeout"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(
|
||||
matches!(err, CodexAuthError::RefreshTransient(message) if message.contains("timed out"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_expired() {
|
||||
let body = r#"{"error":{"code":"refresh_token_expired"}}"#;
|
||||
let (r, _) = classify_permanent(body);
|
||||
assert_eq!(r, PermanentReason::Expired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reused() {
|
||||
let body = r#"{"error":{"code":"refresh_token_reused"}}"#;
|
||||
let (r, _) = classify_permanent(body);
|
||||
assert_eq!(r, PermanentReason::Reused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_unknown_falls_to_other() {
|
||||
let body = r#"{"error":{"code":"weird"}}"#;
|
||||
let (r, _) = classify_permanent(body);
|
||||
assert_eq!(r, PermanentReason::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_top_level_code() {
|
||||
let body = r#"{"code":"refresh_token_invalidated"}"#;
|
||||
let (r, _) = classify_permanent(body);
|
||||
assert_eq!(r, PermanentReason::Revoked);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Optional built-in provider/backend helpers.
|
||||
|
||||
#[cfg(feature = "codex")]
|
||||
pub mod codex;
|
||||
@@ -130,13 +130,13 @@ mod tests {
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(collector.clone());
|
||||
|
||||
timeline.dispatch(&Event::tool_use_start(0, "tool_empty", "ListWorkers"));
|
||||
timeline.dispatch(&Event::tool_use_start(0, "tool_empty", "ListItems"));
|
||||
timeline.dispatch(&Event::tool_use_stop(0));
|
||||
|
||||
let calls = collector.take_collected();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].id, "tool_empty");
|
||||
assert_eq!(calls[0].name, "ListWorkers");
|
||||
assert_eq!(calls[0].name, "ListItems");
|
||||
assert!(calls[0].input.is_object());
|
||||
assert_eq!(
|
||||
calls[0].input,
|
||||
|
||||
Reference in New Issue
Block a user