codexのOAuthを使う実装

This commit is contained in:
2026-04-20 23:13:52 +09:00
parent 24ade197d1
commit 6c6eb0dcb6
16 changed files with 1652 additions and 69 deletions
+26 -1
View File
@@ -1,4 +1,4 @@
//! `Scheme` 実装と通信層が要求する認証要件。
//! `Scheme` 実装と通信層が要求する認証要件、および動的認証プロバイダ
//!
//! マニフェスト側の型(`ModelConfig` / `SchemeKind` / `AuthRef`)は
//! `crates/manifest` に置き、llm-worker はそれを知らずに済む。
@@ -6,6 +6,15 @@
//! 期待するか」のランタイム記述で、manifest 側の `AuthRef` との
//! 照合(`AuthRef → ResolvedAuth` 変換の適否)は `crates/provider`
//! で行う。
//!
//! Codex OAuth のようにリクエスト毎にトークンが変わり得る認証は
//! [`AuthProvider`] trait を `crates/provider` 側で実装し、
//! [`super::transport::ResolvedAuth::Custom`] 経由で transport に渡す。
use async_trait::async_trait;
use reqwest::header::{HeaderName, HeaderValue};
use super::error::ClientError;
/// `Scheme::required_auth()` が返す認証要件。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -21,3 +30,19 @@ pub enum AuthRequirement {
/// 複合ヘッダ(Codex OAuth 等、`crates/provider` 側で解決)
Custom,
}
/// リクエスト毎に認証ヘッダを動的に組み立てるプロバイダ。
///
/// Codex OAuth のように access_token が refresh で更新されたり、
/// `ChatGPT-Account-Id` / `X-OpenAI-Fedramp` のような複数ヘッダを
/// 同時に注入する必要があるケースで使う。実体は `crates/provider`
/// 側に置き、llm-worker は trait を知るだけ。
///
/// 返したヘッダはそのまま `HeaderMap` に挿入される。`Authorization`
/// 含む scheme 既定の認証ヘッダは送出されないので、必要なら
/// 実装側でセットすること。
#[async_trait]
pub trait AuthProvider: Send + Sync + std::fmt::Debug {
/// 1 リクエスト分の認証ヘッダを返す。refresh が必要なら内部で行う。
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError>;
}
@@ -3,6 +3,11 @@
//! モデル family 判定は `scheme/openai_chat/capability.rs::classify` を
//! 共有する。Responses 側は `ReasoningSupport::Effort` 固定で、prompt
//! caching はサーバ側自動(`CacheStrategy::Auto`)。
//!
//! `gpt-5-codex` は `gpt-5` prefix 経由で Reasoning 扱いされるが、
//! `codex-mini-latest` 等 `codex-` prefix のモデルは ChatGPT backend
//! 経由(CodexOAuth)でしか使えないため、このテーブルでだけ Reasoning
//! にフォールバックする。
use crate::llm_client::capability::{
CacheStrategy, ModelCapability, ReasoningSupport, StructuredOutput, ToolCallingSupport,
@@ -10,7 +15,14 @@ use crate::llm_client::capability::{
use crate::llm_client::scheme::openai_chat::capability::{OpenAiFamily, classify};
pub(crate) fn lookup(model_id: &str) -> Option<ModelCapability> {
classify(model_id).map(|family| match family {
let family = classify(model_id).or_else(|| {
if model_id.starts_with("codex-") {
Some(OpenAiFamily::Reasoning)
} else {
None
}
})?;
Some(match family {
OpenAiFamily::Reasoning => ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
structured_output: StructuredOutput::JsonSchema,
@@ -35,6 +47,30 @@ pub(crate) fn lookup(model_id: &str) -> Option<ModelCapability> {
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gpt_5_codex_is_reasoning() {
// `gpt-5` prefix で classify される
let cap = lookup("gpt-5-codex").unwrap();
assert!(cap.reasoning.is_some());
}
#[test]
fn codex_mini_latest_is_reasoning() {
// ChatGPT backend 専用モデル。`codex-` prefix で Reasoning にフォールバック
let cap = lookup("codex-mini-latest").unwrap();
assert!(cap.reasoning.is_some());
}
#[test]
fn unknown_model_returns_none() {
assert!(lookup("foo-bar-3000").is_none());
}
}
pub(crate) fn default_capability() -> ModelCapability {
ModelCapability {
tool_calling: ToolCallingSupport::Parallel,
+28 -16
View File
@@ -5,13 +5,14 @@
//! scheme 固有の差分は [`Scheme`] trait 実装に委譲する。
use std::pin::Pin;
use std::sync::Arc;
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::{Stream, StreamExt, TryStreamExt};
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
use super::auth::AuthRequirement;
use super::auth::{AuthProvider, AuthRequirement};
use super::capability::ModelCapability;
use super::client::{ConfigWarning, LlmClient};
use super::error::ClientError;
@@ -21,24 +22,28 @@ use super::types::{Request, RequestConfig};
/// `AuthRef` を解決したランタイム表現。`crates/provider` が構築する。
///
/// `AuthRef::ApiKey` → 読み取った文字列、`AuthRef::None` → `None`。
/// `CodexOAuth` 等、動的に更新される認証は別途 `Custom` バリアントを
/// 追加する余地を残す(本チケットでは未実装)。
/// - `None`: 認証ヘッダを送らない(Ollama 等の opt-out
/// - `ApiKey`: 静的な API key 文字列
/// - `Custom`: リクエスト毎に動的にヘッダを組み立てる(Codex OAuth 等)
#[derive(Debug, Clone)]
pub enum ResolvedAuth {
None,
ApiKey(String),
Custom(Arc<dyn AuthProvider>),
}
impl ResolvedAuth {
/// 認証要件と実際の解決値が噛み合うか検査する。構築時検証用。
///
/// `ResolvedAuth::None` は認証を付けないという宣言なので、どの
/// `AuthRequirement` でも受け入れる(Ollama の Anthropic scheme
/// 流用は `required_auth = XApiKey` だが認証ヘッダなしで動く)
/// - `ResolvedAuth::None` は認証を付けない宣言なので、どの
/// `AuthRequirement` でも受け入れる(Ollama の Anthropic scheme
/// 流用は `required_auth = XApiKey` だが認証ヘッダなしで動く)
/// - `ResolvedAuth::Custom` は「ヘッダ組立を全部こちらで行う」
/// 宣言なので、scheme が要求する形式によらず受け入れる
pub fn matches(&self, req: AuthRequirement) -> bool {
match (self, req) {
(Self::None, _) => true,
(Self::Custom(_), _) => true,
(
Self::ApiKey(_),
AuthRequirement::Bearer | AuthRequirement::XApiKey | AuthRequirement::QueryParam { .. },
@@ -100,29 +105,36 @@ impl<S: Scheme> HttpTransport<S> {
}
}
fn build_headers(&self) -> Result<HeaderMap, ClientError> {
async fn build_headers(&self) -> Result<HeaderMap, ClientError> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
match (self.scheme.required_auth(), &self.auth) {
(AuthRequirement::None, _) | (_, ResolvedAuth::None) => {}
(AuthRequirement::Bearer, ResolvedAuth::ApiKey(key)) => {
match (&self.auth, self.scheme.required_auth()) {
(ResolvedAuth::None, _) | (_, AuthRequirement::None) => {}
(ResolvedAuth::Custom(provider), _) => {
for (name, mut value) in provider.headers().await? {
value.set_sensitive(true);
headers.insert(name, value);
}
}
(ResolvedAuth::ApiKey(key), AuthRequirement::Bearer) => {
let mut val = HeaderValue::from_str(&format!("Bearer {key}"))
.map_err(|e| ClientError::Config(format!("invalid api key: {e}")))?;
val.set_sensitive(true);
headers.insert("Authorization", val);
}
(AuthRequirement::XApiKey, ResolvedAuth::ApiKey(key)) => {
(ResolvedAuth::ApiKey(key), AuthRequirement::XApiKey) => {
let mut val = HeaderValue::from_str(key.as_str())
.map_err(|e| ClientError::Config(format!("invalid api key: {e}")))?;
val.set_sensitive(true);
headers.insert("x-api-key", val);
}
(AuthRequirement::QueryParam { .. }, _) => {
(_, AuthRequirement::QueryParam { .. }) => {
// クエリパラメータは `build_url` で付与済み
}
(AuthRequirement::Custom, _) => {
// 今チケットでは Custom は使わない。Codex OAuth で追加予
(ResolvedAuth::ApiKey(_), AuthRequirement::Custom) => {
// scheme が Custom を要求する組合せに ApiKey は流れてこない想
// `matches()` で弾かれる)。安全側で何もしない
}
}
@@ -164,7 +176,7 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
let url = self.build_url();
let headers = self.build_headers()?;
let headers = self.build_headers().await?;
let body = self
.scheme
.build_request_body(&self.model_id, &request, &self.capability);