refactor: fold provider crate into engine

This commit is contained in:
2026-07-15 00:58:29 +09:00
parent 1505c5e8a3
commit 839fc7b40c
33 changed files with 263 additions and 279 deletions
+7 -1
View File
@@ -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]
+1
View File
@@ -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;
-8
View File
@@ -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
}
}
+4 -4
View File
@@ -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",
));
}
}
+115 -43
View File
@@ -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);
}
}
+4
View File
@@ -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,