mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
* feat(llm): add OpenAI Codex backend config and OAuth session manager Add OpenAiCodex as a new LLM backend variant with config for auth endpoint, API base URL, client ID, and session persistence path. The session manager implements OpenAI's device code auth flow (headless-friendly, no browser required on the server) with automatic token refresh, following the same persistence pattern as the existing NEAR AI session manager. Closes #742 Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(llm): add Responses API client and token-refreshing decorator Native Responses API client for chatgpt.com/backend-api/codex/responses, the endpoint that works with ChatGPT subscription tokens. Handles SSE streaming, text completions, and tool call round-trips. Token-refreshing decorator wraps the provider to pre-emptively refresh OAuth tokens before API calls and retry once on auth failures. Reports zero cost since billing is through subscription. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(llm): wire OpenAI Codex into provider factory, CLI, and setup wizard Connect the new provider to the LLM factory, add openai_codex to the CLI --backend flag, and add it as an option in the onboarding wizard. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(llm): address PR #744 review feedback (20 items) Review fixes for the OpenAI Codex provider PR: - Remove dead `generate_pkce()` code (device flow gets PKCE from server) - Fix `refresh_tokens()` to use `.form()` instead of `.json()` per OAuth spec - Inline codex dispatch into `build_provider_chain()` (single async function, no separate `assemble_provider_chain()` helper — matches main's pattern) - Remove Clone from `OpenAiCodexSession`, restrict fields to `pub(crate)` - Propagate HTTP client builder error instead of silent fallback - Redact device code response body from debug log - Change `set_model()` in TokenRefreshingProvider to delegate to inner - Replace hardcoded `/tmp/` test path with `tempfile::tempdir()` - Accept `request_timeout_secs` from config instead of hardcoded 300s - Parse `Retry-After` header on 429 responses (matches nearai_chat.rs pattern) - Reuse `normalize_schema_strict()` for Codex tool definitions - Add warning log for dropped image attachments - Add doc comments on `list_models()` and `include` field - Add `OPENAI_CODEX_API_URL` to `.env.example` - Fix codex error message in `create_llm_provider()` for clarity - Revert unrelated `.worktrees` addition to `.gitignore` - Update `src/llm/CLAUDE.md` with Codex provider docs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review feedback and harden OpenAI Codex provider (takeover #744) Security: - Add SSRF validation (validate_base_url) on OPENAI_CODEX_AUTH_URL and OPENAI_CODEX_API_URL, matching the pattern used by all other base URL configs (regression test for #1103 included) Correctness: - Add missing cache_write_multiplier() and cache_read_discount() trait delegation in TokenRefreshingProvider - Cap device-code polling backoff at 60s to prevent unbounded interval growth on repeated 429 responses - Default expires_in to 3600s when server returns 0, preventing immediately-expired sessions - Fix pre-existing SseEvent::JobResult missing fallback_deliverable field in job_monitor.rs tests Cleanup: - Extract duplicated make_test_jwt() and test_codex_config() into shared codex_test_helpers module Co-Authored-By: Sanjeev-S <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review feedback on OpenAI Codex provider (#1461) - Login command now resolves OPENAI_CODEX_* env overrides even when LLM_BACKEND isn't set to openai_codex (Copilot review) - Setup wizard "Keep current provider?" for codex no longer re-triggers device code login — mirrors Bedrock's keep-and-return pattern (Copilot) - Revert provider init log from info back to debug (Copilot) - Add warning log when token expires_in=0, before defaulting to 3600s (Gemini review) Co-Authored-By: Sanjeev-S <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Sanjeev Suresh <[email protected]> Co-authored-by: Claude Opus 4.6 <[email protected]>
192 lines
6.6 KiB
Rust
192 lines
6.6 KiB
Rust
//! Token-refreshing LlmProvider decorator for OpenAI Codex.
|
|
//!
|
|
//! Wraps an `OpenAiCodexProvider` and:
|
|
//! - Pre-emptively refreshes the OAuth access token before each call if near expiry
|
|
//! - Updates the inner provider's token after refresh (no client rebuild needed)
|
|
//! - Retries once on `AuthFailed` / `SessionExpired` after refreshing
|
|
//! - Overrides `cost_per_token()` to return (0, 0) since billing is through subscription
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use rust_decimal::Decimal;
|
|
use secrecy::ExposeSecret;
|
|
|
|
use crate::error::LlmError;
|
|
use crate::llm::openai_codex_provider::OpenAiCodexProvider;
|
|
use crate::llm::openai_codex_session::OpenAiCodexSessionManager;
|
|
use crate::llm::provider::{
|
|
CompletionRequest, CompletionResponse, LlmProvider, ModelMetadata, ToolCompletionRequest,
|
|
ToolCompletionResponse,
|
|
};
|
|
|
|
/// Decorator that refreshes OAuth tokens before API calls and reports zero cost.
|
|
///
|
|
/// The inner `OpenAiCodexProvider` manages its own token state, so after a
|
|
/// refresh we just call `update_token()` -- no client rebuild is needed.
|
|
pub struct TokenRefreshingProvider {
|
|
inner: Arc<OpenAiCodexProvider>,
|
|
session: Arc<OpenAiCodexSessionManager>,
|
|
}
|
|
|
|
impl TokenRefreshingProvider {
|
|
pub fn new(inner: Arc<OpenAiCodexProvider>, session: Arc<OpenAiCodexSessionManager>) -> Self {
|
|
Self { inner, session }
|
|
}
|
|
|
|
/// Push a fresh token from the session manager into the inner provider.
|
|
async fn update_inner_token(&self) -> Result<(), LlmError> {
|
|
let token = self.session.get_access_token().await?;
|
|
self.inner.update_token(token.expose_secret()).await?;
|
|
tracing::debug!("Updated inner provider token after refresh");
|
|
Ok(())
|
|
}
|
|
|
|
/// Best-effort pre-emptive token refresh before an API call.
|
|
///
|
|
/// If refresh fails (e.g., no refresh token), we log and continue so the
|
|
/// actual request still fires and the retry-on-auth-failure path can kick in.
|
|
async fn ensure_fresh_token(&self) {
|
|
if self.session.needs_refresh().await {
|
|
match self.session.refresh_tokens().await {
|
|
Ok(()) => {
|
|
if let Err(e) = self.update_inner_token().await {
|
|
tracing::warn!(
|
|
"Pre-emptive token update failed: {e}, will retry on auth failure"
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Pre-emptive token refresh failed: {e}, will retry on auth failure"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl LlmProvider for TokenRefreshingProvider {
|
|
fn model_name(&self) -> &str {
|
|
self.inner.model_name()
|
|
}
|
|
|
|
fn cost_per_token(&self) -> (Decimal, Decimal) {
|
|
(Decimal::ZERO, Decimal::ZERO)
|
|
}
|
|
|
|
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
|
self.ensure_fresh_token().await;
|
|
|
|
match self.inner.complete(request.clone()).await {
|
|
Err(LlmError::AuthFailed { .. } | LlmError::SessionExpired { .. }) => {
|
|
tracing::info!("Auth failure during complete(), refreshing and retrying once");
|
|
self.session.handle_auth_failure().await?;
|
|
self.update_inner_token().await?;
|
|
self.inner.complete(request).await
|
|
}
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
async fn complete_with_tools(
|
|
&self,
|
|
request: ToolCompletionRequest,
|
|
) -> Result<ToolCompletionResponse, LlmError> {
|
|
self.ensure_fresh_token().await;
|
|
|
|
match self.inner.complete_with_tools(request.clone()).await {
|
|
Err(LlmError::AuthFailed { .. } | LlmError::SessionExpired { .. }) => {
|
|
tracing::info!(
|
|
"Auth failure during complete_with_tools(), refreshing and retrying once"
|
|
);
|
|
self.session.handle_auth_failure().await?;
|
|
self.update_inner_token().await?;
|
|
self.inner.complete_with_tools(request).await
|
|
}
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
async fn list_models(&self) -> Result<Vec<String>, LlmError> {
|
|
self.ensure_fresh_token().await;
|
|
self.inner.list_models().await
|
|
}
|
|
|
|
async fn model_metadata(&self) -> Result<ModelMetadata, LlmError> {
|
|
self.ensure_fresh_token().await;
|
|
self.inner.model_metadata().await
|
|
}
|
|
|
|
fn active_model_name(&self) -> String {
|
|
self.inner.model_name().to_string()
|
|
}
|
|
|
|
fn effective_model_name(&self, requested_model: Option<&str>) -> String {
|
|
self.inner.effective_model_name(requested_model)
|
|
}
|
|
|
|
fn set_model(&self, model: &str) -> Result<(), LlmError> {
|
|
self.inner.set_model(model)
|
|
}
|
|
|
|
fn calculate_cost(&self, _input_tokens: u32, _output_tokens: u32) -> Decimal {
|
|
Decimal::ZERO
|
|
}
|
|
|
|
fn cache_write_multiplier(&self) -> Decimal {
|
|
self.inner.cache_write_multiplier()
|
|
}
|
|
|
|
fn cache_read_discount(&self) -> Decimal {
|
|
self.inner.cache_read_discount()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::llm::codex_test_helpers::{make_test_jwt, test_codex_config};
|
|
use crate::llm::openai_codex_session::OpenAiCodexSessionManager;
|
|
use tempfile::tempdir;
|
|
|
|
fn make_provider_and_session() -> (TokenRefreshingProvider, tempfile::TempDir) {
|
|
let dir = tempdir().unwrap();
|
|
let config = test_codex_config(dir.path().join("session.json"));
|
|
let jwt = make_test_jwt("acct_test");
|
|
let inner = Arc::new(
|
|
OpenAiCodexProvider::new(&config.model, &config.api_base_url, &jwt, 300)
|
|
.expect("provider creation should succeed"),
|
|
);
|
|
let session = Arc::new(OpenAiCodexSessionManager::new(config).unwrap());
|
|
(TokenRefreshingProvider::new(inner, session), dir)
|
|
}
|
|
|
|
#[test]
|
|
fn test_model_name_delegates() {
|
|
let (provider, _dir) = make_provider_and_session();
|
|
assert_eq!(provider.model_name(), "gpt-5.3-codex");
|
|
}
|
|
|
|
#[test]
|
|
fn test_cost_per_token_zero() {
|
|
let (provider, _dir) = make_provider_and_session();
|
|
let (input, output) = provider.cost_per_token();
|
|
assert_eq!(input, Decimal::ZERO);
|
|
assert_eq!(output, Decimal::ZERO);
|
|
}
|
|
|
|
#[test]
|
|
fn test_calculate_cost_zero() {
|
|
let (provider, _dir) = make_provider_and_session();
|
|
assert_eq!(provider.calculate_cost(1000, 500), Decimal::ZERO);
|
|
}
|
|
|
|
#[test]
|
|
fn test_active_model_name_delegates() {
|
|
let (provider, _dir) = make_provider_and_session();
|
|
assert_eq!(provider.active_model_name(), "gpt-5.3-codex");
|
|
}
|
|
}
|