Files
optimclaw/src/llm/models.rs
T
3da9810e87 feat(llm): Add OpenAI Codex (ChatGPT subscription) as LLM provider (#1461)
* 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]>
2026-03-20 08:14:20 -07:00

353 lines
10 KiB
Rust

//! Model discovery and fetching for multiple LLM providers.
/// Fetch models from the Anthropic API.
///
/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error.
pub(crate) async fn fetch_anthropic_models(cached_key: Option<&str>) -> Vec<(String, String)> {
let static_defaults = vec![
(
"claude-opus-4-6".into(),
"Claude Opus 4.6 (latest flagship)".into(),
),
("claude-sonnet-4-6".into(), "Claude Sonnet 4.6".into()),
("claude-opus-4-5".into(), "Claude Opus 4.5".into()),
("claude-sonnet-4-5".into(), "Claude Sonnet 4.5".into()),
("claude-haiku-4-5".into(), "Claude Haiku 4.5 (fast)".into()),
];
let api_key = cached_key
.map(String::from)
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
.filter(|k| !k.is_empty() && k != crate::config::OAUTH_PLACEHOLDER);
// Fall back to OAuth token if no API key
let oauth_token = if api_key.is_none() {
crate::config::helpers::optional_env("ANTHROPIC_OAUTH_TOKEN")
.ok()
.flatten()
.filter(|t| !t.is_empty())
} else {
None
};
let (key_or_token, is_oauth) = match (api_key, oauth_token) {
(Some(k), _) => (k, false),
(None, Some(t)) => (t, true),
(None, None) => return static_defaults,
};
let client = reqwest::Client::new();
let mut request = client
.get("https://api.anthropic.com/v1/models")
.header("anthropic-version", "2023-06-01")
.timeout(std::time::Duration::from_secs(5));
if is_oauth {
request = request
.bearer_auth(&key_or_token)
.header("anthropic-beta", "oauth-2025-04-20");
} else {
request = request.header("x-api-key", &key_or_token);
}
let resp = match request.send().await {
Ok(r) if r.status().is_success() => r,
_ => return static_defaults,
};
#[derive(serde::Deserialize)]
struct ModelEntry {
id: String,
}
#[derive(serde::Deserialize)]
struct ModelsResponse {
data: Vec<ModelEntry>,
}
match resp.json::<ModelsResponse>().await {
Ok(body) => {
let mut models: Vec<(String, String)> = body
.data
.into_iter()
.filter(|m| !m.id.contains("embedding") && !m.id.contains("audio"))
.map(|m| {
let label = m.id.clone();
(m.id, label)
})
.collect();
if models.is_empty() {
return static_defaults;
}
models.sort_by(|a, b| a.0.cmp(&b.0));
models
}
Err(_) => static_defaults,
}
}
/// Fetch models from the OpenAI API.
///
/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error.
pub(crate) async fn fetch_openai_models(cached_key: Option<&str>) -> Vec<(String, String)> {
let static_defaults = vec![
(
"gpt-5.3-codex".into(),
"GPT-5.3 Codex (latest flagship)".into(),
),
("gpt-5.2-codex".into(), "GPT-5.2 Codex".into()),
("gpt-5.2".into(), "GPT-5.2".into()),
(
"gpt-5.1-codex-mini".into(),
"GPT-5.1 Codex Mini (fast)".into(),
),
("gpt-5".into(), "GPT-5".into()),
("gpt-5-mini".into(), "GPT-5 Mini".into()),
("gpt-4.1".into(), "GPT-4.1".into()),
("gpt-4.1-mini".into(), "GPT-4.1 Mini".into()),
("o4-mini".into(), "o4-mini (fast reasoning)".into()),
("o3".into(), "o3 (reasoning)".into()),
];
let api_key = cached_key
.map(String::from)
.or_else(|| std::env::var("OPENAI_API_KEY").ok())
.filter(|k| !k.is_empty());
let api_key = match api_key {
Some(k) => k,
None => return static_defaults,
};
let client = reqwest::Client::new();
let resp = match client
.get("https://api.openai.com/v1/models")
.bearer_auth(&api_key)
.timeout(std::time::Duration::from_secs(5))
.send()
.await
{
Ok(r) if r.status().is_success() => r,
_ => return static_defaults,
};
#[derive(serde::Deserialize)]
struct ModelEntry {
id: String,
}
#[derive(serde::Deserialize)]
struct ModelsResponse {
data: Vec<ModelEntry>,
}
match resp.json::<ModelsResponse>().await {
Ok(body) => {
let mut models: Vec<(String, String)> = body
.data
.into_iter()
.filter(|m| is_openai_chat_model(&m.id))
.map(|m| {
let label = m.id.clone();
(m.id, label)
})
.collect();
if models.is_empty() {
return static_defaults;
}
sort_openai_models(&mut models);
models
}
Err(_) => static_defaults,
}
}
pub(crate) fn is_openai_chat_model(model_id: &str) -> bool {
let id = model_id.to_ascii_lowercase();
let is_chat_family = id.starts_with("gpt-")
|| id.starts_with("chatgpt-")
|| id.starts_with("o1")
|| id.starts_with("o3")
|| id.starts_with("o4")
|| id.starts_with("o5");
let is_non_chat_variant = id.contains("realtime")
|| id.contains("audio")
|| id.contains("transcribe")
|| id.contains("tts")
|| id.contains("embedding")
|| id.contains("moderation")
|| id.contains("image");
is_chat_family && !is_non_chat_variant
}
pub(crate) fn openai_model_priority(model_id: &str) -> usize {
let id = model_id.to_ascii_lowercase();
const EXACT_PRIORITY: &[&str] = &[
"gpt-5.3-codex",
"gpt-5.2-codex",
"gpt-5.2",
"gpt-5.1-codex-mini",
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"o4-mini",
"o3",
"o1",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4o",
"gpt-4o-mini",
];
if let Some(pos) = EXACT_PRIORITY.iter().position(|m| id == *m) {
return pos;
}
const PREFIX_PRIORITY: &[&str] = &[
"gpt-5.", "gpt-5-", "o3-", "o4-", "o1-", "gpt-4.1-", "gpt-4o-", "gpt-3.5-", "chatgpt-",
];
if let Some(pos) = PREFIX_PRIORITY
.iter()
.position(|prefix| id.starts_with(prefix))
{
return EXACT_PRIORITY.len() + pos;
}
EXACT_PRIORITY.len() + PREFIX_PRIORITY.len() + 1
}
pub(crate) fn sort_openai_models(models: &mut [(String, String)]) {
models.sort_by(|a, b| {
openai_model_priority(&a.0)
.cmp(&openai_model_priority(&b.0))
.then_with(|| a.0.cmp(&b.0))
});
}
/// Fetch installed models from a local Ollama instance.
///
/// Returns `(model_name, display_label)` pairs. Falls back to static defaults on error.
pub(crate) async fn fetch_ollama_models(base_url: &str) -> Vec<(String, String)> {
let static_defaults = vec![
("llama3".into(), "llama3".into()),
("mistral".into(), "mistral".into()),
("codellama".into(), "codellama".into()),
];
let url = format!("{}/api/tags", base_url.trim_end_matches('/'));
let client = reqwest::Client::new();
let resp = match client
.get(&url)
.timeout(std::time::Duration::from_secs(5))
.send()
.await
{
Ok(r) if r.status().is_success() => r,
Ok(_) => return static_defaults,
Err(_) => {
tracing::warn!(
"Could not connect to Ollama at {base_url}. Is it running? Using static defaults."
);
return static_defaults;
}
};
#[derive(serde::Deserialize)]
struct ModelEntry {
name: String,
}
#[derive(serde::Deserialize)]
struct TagsResponse {
models: Vec<ModelEntry>,
}
match resp.json::<TagsResponse>().await {
Ok(body) => {
let models: Vec<(String, String)> = body
.models
.into_iter()
.map(|m| {
let label = m.name.clone();
(m.name, label)
})
.collect();
if models.is_empty() {
return static_defaults;
}
models
}
Err(_) => static_defaults,
}
}
/// Fetch models from a generic OpenAI-compatible /v1/models endpoint.
///
/// Used for registry providers like Groq, NVIDIA NIM, etc.
pub(crate) async fn fetch_openai_compatible_models(
base_url: &str,
cached_key: Option<&str>,
) -> Vec<(String, String)> {
if base_url.is_empty() {
return vec![];
}
let url = format!("{}/models", base_url.trim_end_matches('/'));
let client = reqwest::Client::new();
let mut req = client.get(&url).timeout(std::time::Duration::from_secs(5));
if let Some(key) = cached_key {
req = req.bearer_auth(key);
}
let resp = match req.send().await {
Ok(r) if r.status().is_success() => r,
_ => return vec![],
};
#[derive(serde::Deserialize)]
struct Model {
id: String,
}
#[derive(serde::Deserialize)]
struct ModelsResponse {
data: Vec<Model>,
}
match resp.json::<ModelsResponse>().await {
Ok(body) => body
.data
.into_iter()
.map(|m| {
let label = m.id.clone();
(m.id, label)
})
.collect(),
Err(_) => vec![],
}
}
/// Build the `LlmConfig` used by `fetch_nearai_models` to list available models.
///
/// Uses [`NearAiConfig::for_model_discovery()`] to construct a minimal NEAR AI
/// config, then wraps it in an `LlmConfig` with session config for auth.
pub(crate) fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
let auth_base_url =
std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string());
crate::config::LlmConfig {
backend: "nearai".to_string(),
session: crate::llm::session::SessionConfig {
auth_base_url,
session_path: crate::config::llm::default_session_path(),
},
nearai: crate::config::NearAiConfig::for_model_discovery(),
provider: None,
bedrock: None,
request_timeout_secs: 120,
cheap_model: None,
smart_routing_cascade: false,
openai_codex: None,
}
}