mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Merge pull request #1462 from nearai/staging-promote/cba1bc37-23334371795
chore: promote staging to staging-promote/c1762616-23332963145 (2026-03-20 08:09 UTC)
This commit is contained in:
+8
-1
@@ -4,7 +4,7 @@ DATABASE_POOL_SIZE=10
|
||||
|
||||
# LLM Provider
|
||||
# LLM_BACKEND=nearai # default
|
||||
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil
|
||||
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil, openai_codex
|
||||
# LLM_REQUEST_TIMEOUT_SECS=120 # Increase for local LLMs (Ollama, vLLM, LM Studio)
|
||||
|
||||
# === Anthropic Direct ===
|
||||
@@ -92,6 +92,13 @@ NEARAI_AUTH_URL=https://private.near.ai
|
||||
# long = 1-hour TTL, 2.0× (200%) write surcharge
|
||||
# ANTHROPIC_CACHE_RETENTION=short
|
||||
|
||||
# === OpenAI Codex (ChatGPT subscription, OAuth) ===
|
||||
# LLM_BACKEND=openai_codex
|
||||
# OPENAI_CODEX_MODEL=gpt-5.3-codex # default
|
||||
# OPENAI_CODEX_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann # override (rare)
|
||||
# OPENAI_CODEX_AUTH_URL=https://auth.openai.com # override (rare)
|
||||
# OPENAI_CODEX_API_URL=https://chatgpt.com/backend-api/codex # override (rare)
|
||||
|
||||
# For full provider setup guide see docs/LLM_PROVIDERS.md
|
||||
|
||||
# Channel Configuration
|
||||
|
||||
@@ -421,6 +421,7 @@ mod tests {
|
||||
job_id: job_id.to_string(),
|
||||
status: "completed".to_string(),
|
||||
session_id: None,
|
||||
fallback_deliverable: None,
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
@@ -468,6 +469,7 @@ mod tests {
|
||||
job_id: job_id.to_string(),
|
||||
status: "failed".to_string(),
|
||||
session_id: None,
|
||||
fallback_deliverable: None,
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
@@ -506,6 +508,7 @@ mod tests {
|
||||
job_id: job_id.to_string(),
|
||||
status: "completed".to_string(),
|
||||
session_id: None,
|
||||
fallback_deliverable: None,
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
@@ -696,6 +696,7 @@ impl AppBuilder {
|
||||
// fail early with a clear error instead of a confusing runtime failure.
|
||||
if self.config.llm.backend != "nearai"
|
||||
&& self.config.llm.backend != "bedrock"
|
||||
&& self.config.llm.backend != "openai_codex"
|
||||
&& self.config.llm.provider.is_none()
|
||||
{
|
||||
let backend = &self.config.llm.backend;
|
||||
|
||||
@@ -3314,6 +3314,7 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::channels::Channel;
|
||||
use crate::channels::OutgoingResponse;
|
||||
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
||||
use crate::channels::wasm::runtime::{
|
||||
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
||||
@@ -3401,6 +3402,16 @@ mod tests {
|
||||
assert!(channel.health_check().await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_broadcast_delegates_to_call_on_broadcast() {
|
||||
let channel = create_test_channel();
|
||||
// With `component: None`, call_on_broadcast short-circuits to Ok(()).
|
||||
let result = channel
|
||||
.broadcast("146032821", OutgoingResponse::text("hello"))
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_poll_no_wasm_returns_empty() {
|
||||
// When there's no WASM module (None component), execute_poll
|
||||
|
||||
@@ -344,6 +344,7 @@ pub async fn start_server(
|
||||
.route("/", get(index_handler))
|
||||
.route("/style.css", get(css_handler))
|
||||
.route("/app.js", get(js_handler))
|
||||
.route("/theme-init.js", get(theme_init_handler))
|
||||
.route("/favicon.ico", get(favicon_handler))
|
||||
.route("/i18n/index.js", get(i18n_index_handler))
|
||||
.route("/i18n/en.js", get(i18n_en_handler))
|
||||
@@ -465,6 +466,16 @@ async fn js_handler() -> impl IntoResponse {
|
||||
)
|
||||
}
|
||||
|
||||
async fn theme_init_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/theme-init.js"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn favicon_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
// IronClaw Web Gateway - Client
|
||||
|
||||
// --- Theme Management (dark / light / system) ---
|
||||
// Icon switching is handled by pure CSS via data-theme-mode on <html>.
|
||||
|
||||
function getSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
const VALID_THEME_MODES = { dark: true, light: true, system: true };
|
||||
|
||||
function getThemeMode() {
|
||||
const stored = localStorage.getItem('ironclaw-theme');
|
||||
return (stored && VALID_THEME_MODES[stored]) ? stored : 'system';
|
||||
}
|
||||
|
||||
function resolveTheme(mode) {
|
||||
return mode === 'system' ? getSystemTheme() : mode;
|
||||
}
|
||||
|
||||
function applyTheme(mode) {
|
||||
const resolved = resolveTheme(mode);
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
document.documentElement.setAttribute('data-theme-mode', mode);
|
||||
const titleKeys = { dark: 'theme.tooltipDark', light: 'theme.tooltipLight', system: 'theme.tooltipSystem' };
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (btn) btn.title = (typeof I18n !== 'undefined' && titleKeys[mode]) ? I18n.t(titleKeys[mode]) : ('Theme: ' + mode);
|
||||
const announce = document.getElementById('theme-announce');
|
||||
if (announce) announce.textContent = (typeof I18n !== 'undefined') ? I18n.t('theme.announce', { mode: mode }) : ('Theme: ' + mode);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const cycle = { dark: 'light', light: 'system', system: 'dark' };
|
||||
const current = getThemeMode();
|
||||
const next = cycle[current] || 'dark';
|
||||
localStorage.setItem('ironclaw-theme', next);
|
||||
applyTheme(next);
|
||||
}
|
||||
|
||||
// Apply theme immediately (FOUC prevention is done via inline script in <head>,
|
||||
// but we call again here to ensure tooltip is set after DOM is ready).
|
||||
applyTheme(getThemeMode());
|
||||
|
||||
// Delay enabling theme transition to avoid flash on initial load.
|
||||
requestAnimationFrame(function() {
|
||||
requestAnimationFrame(function() {
|
||||
document.body.classList.add('theme-transition');
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for OS theme changes — only re-apply when in 'system' mode.
|
||||
const mql = window.matchMedia('(prefers-color-scheme: light)');
|
||||
const onSchemeChange = function() {
|
||||
if (getThemeMode() === 'system') {
|
||||
applyTheme('system');
|
||||
}
|
||||
};
|
||||
if (mql.addEventListener) {
|
||||
mql.addEventListener('change', onSchemeChange);
|
||||
} else if (mql.addListener) {
|
||||
mql.addListener(onSchemeChange);
|
||||
}
|
||||
|
||||
// Bind theme toggle button (CSP-compliant — no inline onclick).
|
||||
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
|
||||
|
||||
let token = '';
|
||||
let eventSource = null;
|
||||
let logEventSource = null;
|
||||
|
||||
@@ -24,6 +24,12 @@ I18n.register('en', {
|
||||
'restart.progressSubtitle': 'Please wait for the process to restart...',
|
||||
'restart.checkLogs': 'Check the Logs tab for details after restart completes.',
|
||||
|
||||
// Theme
|
||||
'theme.tooltipDark': 'Theme: Dark (click for Light)',
|
||||
'theme.tooltipLight': 'Theme: Light (click for System)',
|
||||
'theme.tooltipSystem': 'Theme: System (click for Dark)',
|
||||
'theme.announce': 'Theme: {mode}',
|
||||
|
||||
// Tabs
|
||||
'tab.chat': 'Chat',
|
||||
'tab.memory': 'Memory',
|
||||
|
||||
@@ -24,6 +24,12 @@ I18n.register('zh-CN', {
|
||||
'restart.progressSubtitle': '请等待进程重启...',
|
||||
'restart.checkLogs': '重启完成后,请查看日志标签页了解详情。',
|
||||
|
||||
// 主题
|
||||
'theme.tooltipDark': '主题:深色(点击切换浅色)',
|
||||
'theme.tooltipLight': '主题:浅色(点击切换跟随系统)',
|
||||
'theme.tooltipSystem': '主题:跟随系统(点击切换深色)',
|
||||
'theme.announce': '主题:{mode}',
|
||||
|
||||
// 标签页
|
||||
'tab.chat': '聊天',
|
||||
'tab.memory': '记忆',
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script src="/theme-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Auth Screen -->
|
||||
@@ -109,6 +110,18 @@
|
||||
</div>
|
||||
|
||||
<button class="status-logs-btn" data-tab="logs" data-i18n="tab.logs" title="Logs">Logs</button>
|
||||
<button class="theme-toggle-btn" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
|
||||
<svg class="theme-icon icon-dark" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
<svg class="theme-icon icon-light" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
<svg class="theme-icon icon-system" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span id="theme-announce" class="sr-only" aria-live="polite"></span>
|
||||
<div class="tee-shield" id="tee-shield" style="display:none" title="Running in a Trusted Execution Environment">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
|
||||
+292
-118
@@ -18,10 +18,46 @@
|
||||
--radius-lg: 12px;
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace;
|
||||
--text-muted: #71717a;
|
||||
--bg-hover: rgba(255, 255, 255, 0.03);
|
||||
--danger-soft: rgba(230, 76, 76, 0.15);
|
||||
--warning-soft: rgba(245, 166, 35, 0.15);
|
||||
--bg-overlay: rgba(0, 0, 0, 0.5);
|
||||
--bg-modal: #1a1a1a;
|
||||
--border-modal: #333;
|
||||
--border-soft: #2a2a2a;
|
||||
--text-tertiary: #e0e0e0;
|
||||
--text-muted: #888;
|
||||
--text-dimmed: #666;
|
||||
--text-on-accent: #09090b;
|
||||
--accent-brand: #00D894;
|
||||
--accent-brand-hover: #00be82;
|
||||
--warning-bg: #1e1400;
|
||||
--warning-border: #3a2a00;
|
||||
--warning-text: #facc15;
|
||||
--tab-bg: rgba(9, 9, 11, 0.75);
|
||||
--popover-bg: rgba(15, 15, 17, 0.9);
|
||||
--badge-sandbox-bg: rgba(136, 132, 216, 0.15);
|
||||
--badge-sandbox-text: #b4b0e8;
|
||||
--hover-surface: rgba(255, 255, 255, 0.03);
|
||||
--focus-ring: rgba(52, 211, 153, 0.1);
|
||||
--accent-subtle: rgba(52, 211, 153, 0.15);
|
||||
--accent-border-subtle: rgba(52, 211, 153, 0.3);
|
||||
--danger-subtle: rgba(230, 76, 76, 0.15);
|
||||
--danger-border-subtle: rgba(230, 76, 76, 0.3);
|
||||
--warning-subtle: rgba(245, 166, 35, 0.15);
|
||||
--border-hover: rgba(255, 255, 255, 0.15);
|
||||
--user-msg-bg: rgba(52, 211, 153, 0.08);
|
||||
--user-msg-border: rgba(52, 211, 153, 0.2);
|
||||
--danger-error-bg: rgba(230, 76, 76, 0.1);
|
||||
--accent-tee-bg: rgba(52, 211, 153, 0.1);
|
||||
--accent-tee-border: rgba(52, 211, 153, 0.25);
|
||||
--accent-tee-hover: rgba(52, 211, 153, 0.18);
|
||||
--text-on-danger: #fff;
|
||||
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
--shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
--danger-error-border: rgba(230, 76, 76, 0.2);
|
||||
--note-bg: rgba(255, 255, 255, 0.04);
|
||||
--overlay-heavy: rgba(0, 0, 0, 0.6);
|
||||
--highlight-bg: rgba(52, 211, 153, 0.3);
|
||||
--hover-subtle: rgba(255, 255, 255, 0.06);
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 0.2s ease;
|
||||
}
|
||||
@@ -62,7 +98,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.auth-brand {
|
||||
@@ -106,13 +142,13 @@ body {
|
||||
#auth-screen input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.3);
|
||||
box-shadow: 0 0 0 3px var(--accent-border-subtle);
|
||||
}
|
||||
|
||||
#auth-screen button {
|
||||
padding: 10px 16px;
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
@@ -156,7 +192,7 @@ body {
|
||||
/* Tab Bar */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
background: rgba(9, 9, 11, 0.75);
|
||||
background: var(--tab-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
will-change: backdrop-filter;
|
||||
@@ -212,7 +248,7 @@ body {
|
||||
.tab-bar .status-logs-btn.active {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
background: var(--accent-tee-bg);
|
||||
}
|
||||
|
||||
.tab-bar .status {
|
||||
@@ -245,8 +281,8 @@ body {
|
||||
color: var(--success);
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
border: 1px solid rgba(52, 211, 153, 0.25);
|
||||
background: var(--accent-tee-bg);
|
||||
border: 1px solid var(--accent-tee-border);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
margin-right: 8px;
|
||||
@@ -254,7 +290,7 @@ body {
|
||||
}
|
||||
|
||||
.tee-shield:hover {
|
||||
background: rgba(52, 211, 153, 0.18);
|
||||
background: var(--accent-tee-hover);
|
||||
}
|
||||
|
||||
.tee-shield svg {
|
||||
@@ -275,20 +311,20 @@ body {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid #00d894;
|
||||
color: #00d894;
|
||||
border: 1px solid var(--accent-brand);
|
||||
color: var(--accent-brand);
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
transition: color 150ms, background-color 150ms, border-color 150ms;
|
||||
}
|
||||
|
||||
.tab-bar .restart-btn:hover:not(:disabled) {
|
||||
background-color: rgba(0, 216, 148, 0.1);
|
||||
background-color: var(--accent-tee-bg);
|
||||
}
|
||||
|
||||
.tab-bar .restart-btn:disabled {
|
||||
border-color: #333;
|
||||
color: #666;
|
||||
border-color: var(--border-modal);
|
||||
color: var(--text-dimmed);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -330,7 +366,7 @@ body {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: var(--bg-overlay);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: -1;
|
||||
}
|
||||
@@ -338,10 +374,10 @@ body {
|
||||
.restart-loader-content {
|
||||
position: relative;
|
||||
z-index: 10000;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
background-color: var(--bg-modal);
|
||||
border: 1px solid var(--border-modal);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
margin: 0 1rem;
|
||||
@@ -358,7 +394,7 @@ body {
|
||||
}
|
||||
|
||||
.restart-title {
|
||||
color: var(--text);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 0;
|
||||
@@ -387,17 +423,17 @@ body {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: var(--bg-overlay);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.restart-modal-content {
|
||||
position: relative;
|
||||
z-index: 10000;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
background-color: var(--bg-modal);
|
||||
border: 1px solid var(--border-modal);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
margin: 0 1rem;
|
||||
@@ -409,17 +445,17 @@ body {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.restart-modal-header h2 {
|
||||
color: var(--text);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.restart-modal-close {
|
||||
color: #888;
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: transparent;
|
||||
@@ -432,8 +468,8 @@ body {
|
||||
}
|
||||
|
||||
.restart-modal-close:hover {
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
background-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.restart-modal-body {
|
||||
@@ -448,14 +484,14 @@ body {
|
||||
|
||||
.restart-modal-warning {
|
||||
margin-top: 1rem;
|
||||
background-color: var(--warning-soft);
|
||||
border: 1px solid rgba(245, 166, 35, 0.25);
|
||||
background-color: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.restart-modal-warning p {
|
||||
color: var(--warning);
|
||||
color: var(--warning-text);
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -466,7 +502,7 @@ body {
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.restart-modal-btn {
|
||||
@@ -479,28 +515,28 @@ body {
|
||||
}
|
||||
|
||||
.restart-modal-btn.cancel {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.restart-modal-btn.cancel:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
background-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.restart-modal-btn.confirm {
|
||||
background-color: var(--accent);
|
||||
color: #09090b;
|
||||
background-color: var(--accent-brand);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.restart-modal-btn.confirm:hover {
|
||||
background-color: var(--accent-hover);
|
||||
background-color: var(--accent-brand-hover);
|
||||
}
|
||||
|
||||
/* Progress Bar for Restart */
|
||||
.restart-progress-bar {
|
||||
width: 100%;
|
||||
height: 0.375rem;
|
||||
background-color: var(--bg-tertiary);
|
||||
background-color: var(--border-soft);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -508,7 +544,7 @@ body {
|
||||
.restart-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--accent);
|
||||
background-color: var(--accent-brand);
|
||||
width: 40%;
|
||||
animation: indeterminate 1.5s ease-in-out infinite;
|
||||
}
|
||||
@@ -529,14 +565,14 @@ body {
|
||||
}
|
||||
|
||||
.restart-modal-info {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-dimmed);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.restart-modal-info a {
|
||||
color: var(--accent);
|
||||
color: var(--accent-brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -550,7 +586,7 @@ body {
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
background: rgba(15, 15, 17, 0.9);
|
||||
background: var(--popover-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border);
|
||||
@@ -887,11 +923,11 @@ body {
|
||||
}
|
||||
|
||||
.activity-tool-card[data-status="running"] {
|
||||
border-color: rgba(52, 211, 153, 0.3);
|
||||
border-color: var(--accent-border-subtle);
|
||||
}
|
||||
|
||||
.activity-tool-card[data-status="fail"] {
|
||||
border-color: rgba(230, 76, 76, 0.3);
|
||||
border-color: var(--danger-border-subtle);
|
||||
}
|
||||
|
||||
.activity-tool-card[data-status="fail"] .activity-tool-name {
|
||||
@@ -1132,21 +1168,21 @@ body {
|
||||
.approval-card .approval-actions button.approve {
|
||||
background: var(--success);
|
||||
border-color: var(--success);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.approval-card .approval-actions button.always {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.approval-card .approval-actions button.deny {
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
color: #fff;
|
||||
color: var(--text-on-danger);
|
||||
}
|
||||
|
||||
.approval-resolved {
|
||||
@@ -1308,7 +1344,7 @@ body {
|
||||
.auth-card .auth-token-input input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.auth-card .auth-actions {
|
||||
@@ -1335,7 +1371,7 @@ body {
|
||||
.auth-card .auth-actions button.auth-submit {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1347,7 +1383,7 @@ body {
|
||||
.auth-card .auth-actions button.auth-oauth {
|
||||
background: var(--success);
|
||||
border-color: var(--success);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1413,7 +1449,7 @@ body {
|
||||
.chat-input-wrapper textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.chat-input-wrapper textarea:disabled {
|
||||
@@ -1451,7 +1487,7 @@ body {
|
||||
.chat-input button {
|
||||
padding: 8px 20px;
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
@@ -1518,7 +1554,7 @@ body {
|
||||
.memory-sidebar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.memory-tree {
|
||||
@@ -1712,7 +1748,7 @@ body {
|
||||
}
|
||||
|
||||
.summary-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.summary-card .count {
|
||||
@@ -1756,7 +1792,7 @@ body {
|
||||
}
|
||||
|
||||
.jobs-table tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--hover-surface);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -1768,13 +1804,13 @@ body {
|
||||
}
|
||||
|
||||
.badge.pending { background: var(--bg-tertiary); color: var(--text-secondary); }
|
||||
.badge.in_progress { background: rgba(52, 211, 153, 0.15); color: var(--accent); }
|
||||
.badge.completed { background: rgba(52, 211, 153, 0.15); color: var(--success); }
|
||||
.badge.failed { background: rgba(230, 76, 76, 0.15); color: var(--danger); }
|
||||
.badge.stuck { background: rgba(245, 166, 35, 0.15); color: var(--warning); }
|
||||
.badge.in_progress { background: var(--accent-subtle); color: var(--accent); }
|
||||
.badge.completed { background: var(--accent-subtle); color: var(--success); }
|
||||
.badge.failed { background: var(--danger-subtle); color: var(--danger); }
|
||||
.badge.stuck { background: var(--warning-subtle); color: var(--warning); }
|
||||
.badge.cancelled { background: var(--bg-tertiary); color: var(--text-secondary); }
|
||||
.badge.interrupted { background: rgba(245, 166, 35, 0.15); color: var(--warning); }
|
||||
.badge.source-sandbox { background: rgba(136, 132, 216, 0.15); color: #b4b0e8; }
|
||||
.badge.interrupted { background: var(--warning-subtle); color: var(--warning); }
|
||||
.badge.source-sandbox { background: var(--badge-sandbox-bg); color: var(--badge-sandbox-text); }
|
||||
.badge.source-direct { background: var(--bg-tertiary); color: var(--text-secondary); }
|
||||
|
||||
.btn-cancel {
|
||||
@@ -1788,7 +1824,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: rgba(230, 76, 76, 0.15);
|
||||
background: var(--danger-subtle);
|
||||
}
|
||||
|
||||
.btn-restart {
|
||||
@@ -1802,7 +1838,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-restart:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.btn-browse {
|
||||
@@ -1817,7 +1853,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-browse:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
/* Job started card in chat */
|
||||
@@ -1835,7 +1871,7 @@ body {
|
||||
}
|
||||
|
||||
.job-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.job-card-icon {
|
||||
@@ -1872,7 +1908,7 @@ body {
|
||||
}
|
||||
|
||||
.job-card-view:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.job-card-browse {
|
||||
@@ -1882,7 +1918,7 @@ body {
|
||||
}
|
||||
|
||||
.job-card-browse:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
/* Clickable job rows */
|
||||
@@ -2145,7 +2181,7 @@ body {
|
||||
}
|
||||
|
||||
.action-error {
|
||||
background: rgba(230, 76, 76, 0.1);
|
||||
background: var(--danger-error-bg);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
@@ -2187,8 +2223,8 @@ body {
|
||||
.conv-system .conv-body { color: var(--text-secondary); font-size: 13px; }
|
||||
|
||||
.conv-user {
|
||||
background: rgba(52, 211, 153, 0.08);
|
||||
border: 1px solid rgba(52, 211, 153, 0.2);
|
||||
background: var(--user-msg-bg);
|
||||
border: 1px solid var(--user-msg-border);
|
||||
}
|
||||
|
||||
.conv-user .conv-role { color: var(--accent); }
|
||||
@@ -2335,7 +2371,7 @@ body {
|
||||
}
|
||||
|
||||
.routines-table tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--hover-surface);
|
||||
}
|
||||
|
||||
.routine-row {
|
||||
@@ -2346,9 +2382,9 @@ body {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.badge.enabled { background: rgba(52, 211, 153, 0.15); color: var(--success); }
|
||||
.badge.enabled { background: var(--accent-subtle); color: var(--success); }
|
||||
.badge.disabled { background: var(--bg-tertiary); color: var(--text-secondary); }
|
||||
.badge.failing { background: rgba(230, 76, 76, 0.15); color: var(--danger); }
|
||||
.badge.failing { background: var(--danger-subtle); color: var(--danger); }
|
||||
|
||||
.btn-trigger {
|
||||
padding: 4px 10px;
|
||||
@@ -2361,7 +2397,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-trigger:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.btn-toggle {
|
||||
@@ -2375,7 +2411,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-toggle:hover {
|
||||
background: rgba(245, 166, 35, 0.15);
|
||||
background: var(--warning-subtle);
|
||||
}
|
||||
|
||||
/* Logs Tab */
|
||||
@@ -2419,7 +2455,7 @@ body {
|
||||
.logs-toolbar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.logs-checkbox {
|
||||
@@ -2580,7 +2616,7 @@ body {
|
||||
}
|
||||
|
||||
.ext-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.ext-header {
|
||||
@@ -2605,17 +2641,17 @@ body {
|
||||
}
|
||||
|
||||
.ext-kind.kind-mcp_server {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ext-kind.kind-wasm_tool {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.ext-kind.kind-wasm_channel {
|
||||
background: rgba(245, 166, 35, 0.15);
|
||||
background: var(--warning-subtle);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
@@ -2720,7 +2756,7 @@ body {
|
||||
|
||||
.stepper-step.failed .stepper-circle {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
color: var(--text-on-danger);
|
||||
}
|
||||
|
||||
.stepper-step.failed .stepper-label {
|
||||
@@ -2773,8 +2809,8 @@ body {
|
||||
.ext-error {
|
||||
font-size: 11px;
|
||||
color: var(--danger);
|
||||
background: rgba(230, 76, 76, 0.1);
|
||||
border: 1px solid rgba(230, 76, 76, 0.2);
|
||||
background: var(--danger-error-bg);
|
||||
border: 1px solid var(--danger-error-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 8px;
|
||||
margin-top: 6px;
|
||||
@@ -2783,7 +2819,7 @@ body {
|
||||
.ext-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: var(--note-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 8px;
|
||||
@@ -2821,7 +2857,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-ext.activate:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.btn-ext.remove {
|
||||
@@ -2830,7 +2866,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-ext.remove:hover {
|
||||
background: rgba(230, 76, 76, 0.15);
|
||||
background: var(--danger-subtle);
|
||||
}
|
||||
|
||||
.btn-ext.install {
|
||||
@@ -2839,7 +2875,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-ext.install:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.btn-ext.install:disabled {
|
||||
@@ -2863,7 +2899,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-ext.configure:hover {
|
||||
background: rgba(136, 132, 216, 0.15);
|
||||
background: var(--badge-sandbox-bg);
|
||||
}
|
||||
|
||||
/* Pairing requests */
|
||||
@@ -2911,7 +2947,7 @@ body {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
background: var(--overlay-heavy);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
@@ -3076,6 +3112,32 @@ body {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tools-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.tools-table th,
|
||||
.tools-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tools-table th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.tools-table tr:hover td {
|
||||
background: var(--hover-surface);
|
||||
}
|
||||
|
||||
|
||||
/* --- Activity tab (unified sandbox job events) --- */
|
||||
|
||||
.activity-terminal {
|
||||
@@ -3094,7 +3156,7 @@ body {
|
||||
|
||||
.activity-event {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-bottom: 1px solid var(--note-bg);
|
||||
}
|
||||
|
||||
.activity-event-message .activity-role {
|
||||
@@ -3197,13 +3259,13 @@ body {
|
||||
.activity-input-bar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.activity-input-bar button {
|
||||
padding: 8px 16px;
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
@@ -3280,13 +3342,13 @@ body {
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
color: var(--text-on-danger);
|
||||
pointer-events: auto;
|
||||
transform: translateX(120%);
|
||||
transition: transform 0.25s ease;
|
||||
max-width: 360px;
|
||||
word-break: break-word;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--shadow-toast);
|
||||
}
|
||||
|
||||
.toast.visible {
|
||||
@@ -3308,7 +3370,7 @@ body {
|
||||
/* --- Memory search highlighting --- */
|
||||
|
||||
mark {
|
||||
background: rgba(52, 211, 153, 0.3);
|
||||
background: var(--highlight-bg);
|
||||
color: inherit;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
@@ -3361,7 +3423,7 @@ mark {
|
||||
}
|
||||
|
||||
.thread-new-btn:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.assistant-item {
|
||||
@@ -3379,11 +3441,11 @@ mark {
|
||||
}
|
||||
|
||||
.assistant-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--hover-subtle);
|
||||
}
|
||||
|
||||
.assistant-item.active {
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
background: var(--accent-tee-bg);
|
||||
color: var(--accent);
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
@@ -3471,14 +3533,14 @@ mark {
|
||||
letter-spacing: 0.5px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--border);
|
||||
color: var(--text-secondary);
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thread-badge-routine { background: rgba(52, 211, 153, 0.15); color: var(--accent); }
|
||||
.thread-badge-heartbeat { background: rgba(245, 166, 35, 0.15); color: var(--warning); }
|
||||
.thread-badge-routine { background: var(--accent-subtle); color: var(--accent); }
|
||||
.thread-badge-heartbeat { background: var(--warning-subtle); color: var(--warning); }
|
||||
.thread-badge-telegram { background: rgba(0, 136, 204, 0.15); color: #0088cc; }
|
||||
.thread-badge-signal { background: rgba(59, 118, 240, 0.15); color: #3b76f0; }
|
||||
.thread-badge-slack { background: rgba(74, 21, 75, 0.15); color: #e01e5a; }
|
||||
@@ -3546,7 +3608,7 @@ mark {
|
||||
.memory-editor textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.memory-editor-actions {
|
||||
@@ -3557,7 +3619,7 @@ mark {
|
||||
.btn-save {
|
||||
padding: 6px 16px;
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
@@ -3638,7 +3700,7 @@ mark {
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
background: rgba(15, 15, 17, 0.9);
|
||||
background: var(--popover-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border);
|
||||
@@ -3736,13 +3798,13 @@ mark {
|
||||
.ext-install-form input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.ext-install-form button {
|
||||
padding: 6px 16px;
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
@@ -3786,13 +3848,13 @@ mark {
|
||||
.skill-search-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.skill-search-box button {
|
||||
padding: 8px 20px;
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
@@ -3816,7 +3878,7 @@ mark {
|
||||
}
|
||||
|
||||
.skill-trust.trust-trusted {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
@@ -3861,7 +3923,7 @@ mark {
|
||||
.activity-toolbar select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
/* --- Mobile responsive --- */
|
||||
@@ -4103,7 +4165,7 @@ mark {
|
||||
}
|
||||
|
||||
.settings-row:hover {
|
||||
background: var(--bg-hover);
|
||||
background: var(--hover-surface);
|
||||
}
|
||||
|
||||
.settings-row.hidden {
|
||||
@@ -4170,8 +4232,8 @@ mark {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--warning-soft);
|
||||
border: 1px solid rgba(245, 166, 35, 0.25);
|
||||
background: var(--warning-subtle);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
@@ -4331,7 +4393,7 @@ input[type="checkbox"]:focus-visible {
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
color: var(--text-on-danger);
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
@@ -4341,7 +4403,7 @@ input[type="checkbox"]:focus-visible {
|
||||
}
|
||||
|
||||
.image-preview-remove:hover {
|
||||
background: #c33;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
/* Generated Image */
|
||||
@@ -4627,3 +4689,115 @@ input[type="checkbox"]:focus-visible {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Screen-reader only utility */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Light Theme
|
||||
============================================================ */
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #ffffff;
|
||||
--bg-secondary: #f5f5f7;
|
||||
--bg-tertiary: #ebebed;
|
||||
--border: rgba(0, 0, 0, 0.1);
|
||||
--text: #1a1a2e;
|
||||
--text-secondary: #555555;
|
||||
--accent: #059669;
|
||||
--accent-hover: #047857;
|
||||
--success: #059669;
|
||||
--warning: #d97706;
|
||||
--danger: #dc2626;
|
||||
--code-bg: #f0f0f2;
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
--bg-overlay: rgba(0, 0, 0, 0.3);
|
||||
--bg-modal: #ffffff;
|
||||
--border-modal: #e0e0e0;
|
||||
--border-soft: #e5e5e5;
|
||||
--text-tertiary: #333333;
|
||||
--text-muted: #777777;
|
||||
--text-dimmed: #999999;
|
||||
--text-on-accent: #ffffff;
|
||||
--accent-brand: #059669;
|
||||
--accent-brand-hover: #047857;
|
||||
--warning-bg: #fffbeb;
|
||||
--warning-border: #fde68a;
|
||||
--warning-text: #92400e;
|
||||
--tab-bg: rgba(255, 255, 255, 0.9);
|
||||
--popover-bg: rgba(255, 255, 255, 0.95);
|
||||
--badge-sandbox-bg: rgba(136, 132, 216, 0.1);
|
||||
--badge-sandbox-text: #6b67b0;
|
||||
--hover-surface: rgba(0, 0, 0, 0.03);
|
||||
--focus-ring: rgba(5, 150, 105, 0.15);
|
||||
--accent-subtle: rgba(5, 150, 105, 0.1);
|
||||
--accent-border-subtle: rgba(5, 150, 105, 0.3);
|
||||
--danger-subtle: rgba(220, 38, 38, 0.1);
|
||||
--danger-border-subtle: rgba(220, 38, 38, 0.2);
|
||||
--warning-subtle: rgba(217, 119, 6, 0.1);
|
||||
--border-hover: rgba(0, 0, 0, 0.15);
|
||||
--user-msg-bg: rgba(5, 150, 105, 0.08);
|
||||
--user-msg-border: rgba(5, 150, 105, 0.2);
|
||||
--danger-error-bg: rgba(220, 38, 38, 0.06);
|
||||
--accent-tee-bg: rgba(5, 150, 105, 0.08);
|
||||
--accent-tee-border: rgba(5, 150, 105, 0.2);
|
||||
--accent-tee-hover: rgba(5, 150, 105, 0.15);
|
||||
--text-on-danger: #fff;
|
||||
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||
--shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.1);
|
||||
--danger-error-border: rgba(220, 38, 38, 0.15);
|
||||
--note-bg: rgba(0, 0, 0, 0.02);
|
||||
--overlay-heavy: rgba(0, 0, 0, 0.4);
|
||||
--highlight-bg: rgba(5, 150, 105, 0.2);
|
||||
--hover-subtle: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Theme transition (delayed via JS to avoid FOUC)
|
||||
============================================================ */
|
||||
|
||||
body.theme-transition,
|
||||
body.theme-transition *:not(svg):not(path):not(line):not(circle):not(rect) {
|
||||
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Theme toggle button
|
||||
============================================================ */
|
||||
|
||||
.theme-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
margin-right: 8px;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* CSS-only icon switching via data-theme-mode on <html> */
|
||||
.theme-icon { display: none; }
|
||||
[data-theme-mode="dark"] .icon-dark { display: block; }
|
||||
[data-theme-mode="light"] .icon-light { display: block; }
|
||||
[data-theme-mode="system"] .icon-system { display: block; }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Prevent FOUC: apply saved theme before first paint.
|
||||
// This script must be loaded synchronously in <head> (no defer/async).
|
||||
(function() {
|
||||
const stored = localStorage.getItem('ironclaw-theme');
|
||||
const mode = (stored === 'dark' || stored === 'light' || stored === 'system') ? stored : 'system';
|
||||
let resolved = mode;
|
||||
if (mode === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
document.documentElement.setAttribute('data-theme-mode', mode);
|
||||
})();
|
||||
@@ -239,6 +239,17 @@ pub enum Command {
|
||||
)]
|
||||
Import(ImportCommand),
|
||||
|
||||
/// Authenticate with a provider (re-login)
|
||||
#[command(
|
||||
about = "Authenticate with a provider",
|
||||
long_about = "Re-authenticate with an LLM provider.\nExample: ironclaw login --openai-codex"
|
||||
)]
|
||||
Login {
|
||||
/// Authenticate with OpenAI Codex (ChatGPT subscription)
|
||||
#[arg(long)]
|
||||
openai_codex: bool,
|
||||
},
|
||||
|
||||
/// Run as a sandboxed worker inside a Docker container (internal use).
|
||||
/// This is invoked automatically by the orchestrator, not by users directly.
|
||||
#[command(hide = true)]
|
||||
|
||||
@@ -24,6 +24,7 @@ Commands:
|
||||
status Show system status
|
||||
completion Generate completions
|
||||
import Import from other AI systems
|
||||
login Authenticate with a provider
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
|
||||
@@ -23,6 +23,7 @@ Commands:
|
||||
logs View and manage gateway logs
|
||||
status Show system status
|
||||
completion Generate completions
|
||||
login Authenticate with a provider
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
|
||||
@@ -27,6 +27,7 @@ Commands:
|
||||
status Show system status
|
||||
completion Generate completions
|
||||
import Import from other AI systems
|
||||
login Authenticate with a provider
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
|
||||
@@ -26,6 +26,7 @@ Commands:
|
||||
logs View and manage gateway logs
|
||||
status Show system status
|
||||
completion Generate completions
|
||||
login Authenticate with a provider
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
|
||||
+198
-3
@@ -37,6 +37,7 @@ impl LlmConfig {
|
||||
},
|
||||
provider: None,
|
||||
bedrock: None,
|
||||
openai_codex: None,
|
||||
request_timeout_secs: 120,
|
||||
cheap_model: None,
|
||||
smart_routing_cascade: false,
|
||||
@@ -72,8 +73,12 @@ impl LlmConfig {
|
||||
backend_lower == "nearai" || backend_lower == "near_ai" || backend_lower == "near";
|
||||
let is_bedrock =
|
||||
backend_lower == "bedrock" || backend_lower == "aws_bedrock" || backend_lower == "aws";
|
||||
let is_openai_codex = backend_lower == "openai_codex"
|
||||
|| backend_lower == "openai-codex"
|
||||
|| backend_lower == "codex";
|
||||
|
||||
if !is_nearai && !is_bedrock && registry.find(&backend_lower).is_none() {
|
||||
if !is_nearai && !is_bedrock && !is_openai_codex && registry.find(&backend_lower).is_none()
|
||||
{
|
||||
tracing::warn!(
|
||||
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
|
||||
backend
|
||||
@@ -126,8 +131,8 @@ impl LlmConfig {
|
||||
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
|
||||
};
|
||||
|
||||
// Resolve registry provider config (for non-NearAI, non-Bedrock backends)
|
||||
let provider = if is_nearai || is_bedrock {
|
||||
// Resolve registry provider config (for non-NearAI, non-Bedrock, non-Codex backends)
|
||||
let provider = if is_nearai || is_bedrock || is_openai_codex {
|
||||
None
|
||||
} else {
|
||||
Some(Self::resolve_registry_provider(
|
||||
@@ -174,6 +179,38 @@ impl LlmConfig {
|
||||
None
|
||||
};
|
||||
|
||||
// Resolve OpenAI Codex config
|
||||
let openai_codex = if is_openai_codex {
|
||||
// Model: OPENAI_CODEX_MODEL > OPENAI_MODEL > settings.selected_model > default
|
||||
let model = optional_env("OPENAI_CODEX_MODEL")?
|
||||
.or(optional_env("OPENAI_MODEL")?)
|
||||
.or_else(|| settings.selected_model.clone())
|
||||
.unwrap_or_else(|| "gpt-5.3-codex".to_string());
|
||||
let auth_endpoint = optional_env("OPENAI_CODEX_AUTH_URL")?
|
||||
.unwrap_or_else(|| "https://auth.openai.com".to_string());
|
||||
validate_base_url(&auth_endpoint, "OPENAI_CODEX_AUTH_URL")?;
|
||||
let api_base_url = optional_env("OPENAI_CODEX_API_URL")?
|
||||
.unwrap_or_else(|| "https://chatgpt.com/backend-api/codex".to_string());
|
||||
validate_base_url(&api_base_url, "OPENAI_CODEX_API_URL")?;
|
||||
let client_id = optional_env("OPENAI_CODEX_CLIENT_ID")?
|
||||
.unwrap_or_else(|| "app_EMoamEEZ73f0CkXaXp7hrann".to_string());
|
||||
let session_path = optional_env("OPENAI_CODEX_SESSION_PATH")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| ironclaw_base_dir().join("openai_codex_session.json"));
|
||||
let token_refresh_margin_secs =
|
||||
parse_optional_env("OPENAI_CODEX_REFRESH_MARGIN_SECS", 300)?;
|
||||
Some(OpenAiCodexConfig {
|
||||
model,
|
||||
auth_endpoint,
|
||||
api_base_url,
|
||||
client_id,
|
||||
session_path,
|
||||
token_refresh_margin_secs,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?;
|
||||
|
||||
// Generic cheap model (works with any backend).
|
||||
@@ -189,6 +226,8 @@ impl LlmConfig {
|
||||
"nearai".to_string()
|
||||
} else if is_bedrock {
|
||||
"bedrock".to_string()
|
||||
} else if is_openai_codex {
|
||||
"openai_codex".to_string()
|
||||
} else if let Some(ref p) = provider {
|
||||
p.provider_id.clone()
|
||||
} else {
|
||||
@@ -198,6 +237,7 @@ impl LlmConfig {
|
||||
nearai,
|
||||
provider,
|
||||
bedrock,
|
||||
openai_codex,
|
||||
request_timeout_secs,
|
||||
cheap_model,
|
||||
smart_routing_cascade,
|
||||
@@ -1069,4 +1109,159 @@ mod tests {
|
||||
std::env::remove_var("LLM_REQUEST_TIMEOUT_SECS");
|
||||
}
|
||||
}
|
||||
|
||||
// ── OpenAI Codex tests ──────────────────────────────────────────
|
||||
|
||||
/// Clear all openai-codex-related env vars.
|
||||
fn clear_openai_codex_env() {
|
||||
// SAFETY: Only called under ENV_MUTEX in tests.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
std::env::remove_var("OPENAI_CODEX_MODEL");
|
||||
std::env::remove_var("OPENAI_MODEL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_codex_resolves_config() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_codex_env();
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_codex".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert_eq!(cfg.backend, "openai_codex");
|
||||
let codex = cfg.openai_codex.expect("codex config should be present");
|
||||
assert_eq!(codex.model, "gpt-5.3-codex"); // default
|
||||
assert!(
|
||||
cfg.provider.is_none(),
|
||||
"codex should not use registry provider"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_codex_model_env_resolution() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_codex_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("OPENAI_CODEX_MODEL", "o3-pro");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_codex".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let codex = cfg.openai_codex.expect("codex config should be present");
|
||||
assert_eq!(codex.model, "o3-pro");
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("OPENAI_CODEX_MODEL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_codex_falls_back_to_openai_model() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_codex_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("OPENAI_MODEL", "gpt-4o");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_codex".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let codex = cfg.openai_codex.expect("codex config should be present");
|
||||
assert_eq!(codex.model, "gpt-4o");
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("OPENAI_MODEL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_codex_falls_back_to_selected_model() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_codex_env();
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_codex".to_string()),
|
||||
selected_model: Some("gpt-4o-mini".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let codex = cfg.openai_codex.expect("codex config should be present");
|
||||
assert_eq!(codex.model, "gpt-4o-mini");
|
||||
}
|
||||
|
||||
/// Regression: SSRF validation on OPENAI_CODEX_API_URL (#1103).
|
||||
#[test]
|
||||
fn openai_codex_rejects_ssrf_api_url() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_codex_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
"OPENAI_CODEX_API_URL",
|
||||
"http://169.254.169.254/latest/meta-data",
|
||||
);
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_codex".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = LlmConfig::resolve(&settings).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("OPENAI_CODEX_API_URL"),
|
||||
"error should reference the field name: {msg}"
|
||||
);
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("OPENAI_CODEX_API_URL");
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: SSRF validation on OPENAI_CODEX_AUTH_URL (#1103).
|
||||
#[test]
|
||||
fn openai_codex_rejects_ssrf_auth_url() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_codex_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("OPENAI_CODEX_AUTH_URL", "http://10.0.0.1");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_codex".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = LlmConfig::resolve(&settings).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("OPENAI_CODEX_AUTH_URL"),
|
||||
"error should reference the field name: {msg}"
|
||||
);
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("OPENAI_CODEX_AUTH_URL");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -54,7 +54,7 @@ pub use self::transcription::TranscriptionConfig;
|
||||
pub use self::tunnel::TunnelConfig;
|
||||
pub use self::wasm::WasmConfig;
|
||||
pub use crate::llm::config::{
|
||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
|
||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
|
||||
RegistryProviderConfig,
|
||||
};
|
||||
pub use crate::llm::session::SessionConfig;
|
||||
@@ -377,7 +377,7 @@ pub(crate) fn resolve_owner_id(settings: &Settings) -> Result<String, ConfigErro
|
||||
/// are read by `optional_env()` before falling back to `std::env::var()`,
|
||||
/// so explicit env vars always win.
|
||||
///
|
||||
/// Also loads tokens from OS credential stores (macOS Keychain, Linux
|
||||
/// Also loads tokens from OS credential stores (macOS Keychain / Linux
|
||||
/// credentials files) which don't require the secrets DB.
|
||||
pub async fn inject_llm_keys_from_secrets(
|
||||
secrets: &dyn crate::secrets::SecretsStore,
|
||||
|
||||
+23
-1
@@ -13,6 +13,9 @@ Multi-provider LLM integration with circuit breaker, retry, failover, and respon
|
||||
| `nearai_chat.rs` | NEAR AI Chat Completions provider (dual auth: session token or API key) |
|
||||
| `codex_auth.rs` | Reads Codex CLI `auth.json`, extracts tokens, refreshes ChatGPT OAuth access tokens |
|
||||
| `codex_chatgpt.rs` | Custom Responses API provider for Codex ChatGPT backend (`/backend-api/codex`) |
|
||||
| `openai_codex_provider.rs` | OpenAI Codex Responses API client (SSE streaming, JWT auth, subscription billing) |
|
||||
| `openai_codex_session.rs` | OAuth 2.0 session manager for OpenAI Codex (device code flow, token persistence) |
|
||||
| `token_refreshing.rs` | Token-refreshing `LlmProvider` decorator for OpenAI Codex (pre-emptive refresh, zero-cost billing) |
|
||||
| `reasoning.rs` | `Reasoning` struct, `ReasoningContext`, `RespondResult`, `ActionPlan`, `ToolSelection`; thinking-tag stripping; `SILENT_REPLY_TOKEN` |
|
||||
| `session.rs` | NEAR AI session token management with disk + DB persistence, OAuth login flow |
|
||||
| `circuit_breaker.rs` | Circuit breaker: Closed → Open → HalfOpen state machine |
|
||||
@@ -38,6 +41,7 @@ Set via `LLM_BACKEND` env var:
|
||||
| `openai_compatible` | Any OpenAI-compatible endpoint | `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL` |
|
||||
| `tinfoil` | Tinfoil TEE inference | `TINFOIL_API_KEY`, `TINFOIL_MODEL` |
|
||||
| `bedrock` | AWS Bedrock (requires `--features bedrock`) | `BEDROCK_REGION`, `BEDROCK_MODEL`, `AWS_PROFILE` |
|
||||
| `openai_codex` | OpenAI Codex (ChatGPT subscription) | `OPENAI_CODEX_MODEL`, `OPENAI_CODEX_CLIENT_ID` |
|
||||
|
||||
Codex auth reuse:
|
||||
- Set `LLM_USE_CODEX_AUTH=true` to load credentials from `~/.codex/auth.json` (override with `CODEX_AUTH_PATH`).
|
||||
@@ -148,9 +152,27 @@ To add a new provider:
|
||||
|
||||
Set `LLM_EXTRA_HEADERS=Key:Value,Key2:Value2` to inject headers into every request. Useful for OpenRouter attribution (`HTTP-Referer`, `X-Title`). Invalid header names/values are skipped with a warning (not a fatal error).
|
||||
|
||||
## OpenAI Codex Provider
|
||||
|
||||
Uses the Responses API at `chatgpt.com/backend-api/codex/responses` with ChatGPT subscription OAuth tokens (zero API cost — billing through subscription).
|
||||
|
||||
**Auth flow:** Device code OAuth via `auth.openai.com/api/accounts/deviceauth/*` endpoints. On first run, displays a code for the user to enter at a URL. Tokens are persisted to `~/.ironclaw/openai_codex_session.json` (mode 0600) and auto-refreshed before expiry.
|
||||
|
||||
**Provider chain:** `OpenAiCodexProvider` → `TokenRefreshingProvider` (pre-emptive refresh + retry on 401) → standard decorator chain. The `TokenRefreshingProvider` intercepts `AuthFailed`/`SessionExpired` errors, refreshes the OAuth token, and retries once.
|
||||
|
||||
**Key differences from other providers:**
|
||||
- Uses Responses API (not Chat Completions) — SSE streaming with different event types
|
||||
- System messages are sent as `instructions` field, not in `input` array
|
||||
- Tool schemas are normalized via `normalize_schema_strict()` for OpenAI strict mode
|
||||
- `cost_per_token()` returns `(0, 0)` — subscription-based billing
|
||||
- `set_model()` returns error — model is fixed at construction time
|
||||
- Image attachments are silently dropped with a warning log
|
||||
|
||||
**Env vars:** `OPENAI_CODEX_MODEL` (default: `gpt-5.3-codex`), `OPENAI_CODEX_CLIENT_ID`, `OPENAI_CODEX_AUTH_URL`, `OPENAI_CODEX_API_URL`.
|
||||
|
||||
## Provider Chain Construction
|
||||
|
||||
`build_provider_chain()` in `mod.rs` is the single source of truth for assembling decorators. The chain is:
|
||||
`build_provider_chain()` in `mod.rs` is the single source of truth for assembling decorators. It creates the base provider (dispatching to `create_openai_codex_provider()` for codex, `create_llm_provider()` for everything else), then applies all decorators inline:
|
||||
|
||||
```
|
||||
Raw provider
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Shared test helpers for OpenAI Codex provider tests.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use crate::config::OpenAiCodexConfig;
|
||||
|
||||
/// Build a minimal JWT for testing (header.payload.signature).
|
||||
pub(crate) fn make_test_jwt(account_id: &str) -> String {
|
||||
use base64::Engine;
|
||||
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
|
||||
let header = engine.encode(b"{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
|
||||
let payload_json = serde_json::json!({
|
||||
"sub": "user123",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": account_id,
|
||||
},
|
||||
});
|
||||
let payload = engine.encode(payload_json.to_string().as_bytes());
|
||||
let sig = engine.encode(b"fake-signature");
|
||||
format!("{header}.{payload}.{sig}")
|
||||
}
|
||||
|
||||
/// Build a test `OpenAiCodexConfig` with a given session path.
|
||||
pub(crate) fn test_codex_config(session_path: std::path::PathBuf) -> OpenAiCodexConfig {
|
||||
OpenAiCodexConfig {
|
||||
model: "gpt-5.3-codex".to_string(),
|
||||
auth_endpoint: "https://auth.openai.com".to_string(),
|
||||
api_base_url: "https://chatgpt.com/backend-api/codex".to_string(),
|
||||
client_id: "test_client_id".to_string(),
|
||||
session_path,
|
||||
token_refresh_margin_secs: 300,
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use std::path::PathBuf;
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::llm::registry::ProviderProtocol;
|
||||
use crate::llm::session::SessionConfig;
|
||||
|
||||
@@ -102,6 +103,36 @@ pub struct RegistryProviderConfig {
|
||||
pub unsupported_params: Vec<String>,
|
||||
}
|
||||
|
||||
/// Configuration for OpenAI Codex (ChatGPT subscription OAuth).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenAiCodexConfig {
|
||||
/// Model to use (default: "gpt-5.3-codex").
|
||||
pub model: String,
|
||||
/// OAuth authorization server (default: "https://auth.openai.com").
|
||||
pub auth_endpoint: String,
|
||||
/// Responses API base URL (default: "https://chatgpt.com/backend-api/codex").
|
||||
pub api_base_url: String,
|
||||
/// OAuth client ID (default: OpenAI's public Codex client).
|
||||
pub client_id: String,
|
||||
/// Path to session file (default: ~/.ironclaw/openai_codex_session.json).
|
||||
pub session_path: PathBuf,
|
||||
/// Seconds before expiry to proactively refresh (default: 300).
|
||||
pub token_refresh_margin_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for OpenAiCodexConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "gpt-5.3-codex".to_string(),
|
||||
auth_endpoint: "https://auth.openai.com".to_string(),
|
||||
api_base_url: "https://chatgpt.com/backend-api/codex".to_string(),
|
||||
client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
|
||||
session_path: ironclaw_base_dir().join("openai_codex_session.json"),
|
||||
token_refresh_margin_secs: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for AWS Bedrock (native Converse API).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BedrockConfig {
|
||||
@@ -134,6 +165,8 @@ pub struct LlmConfig {
|
||||
pub provider: Option<RegistryProviderConfig>,
|
||||
/// AWS Bedrock config (populated when backend=bedrock, requires --features bedrock).
|
||||
pub bedrock: Option<BedrockConfig>,
|
||||
/// OpenAI Codex config (populated when backend=openai_codex).
|
||||
pub openai_codex: Option<OpenAiCodexConfig>,
|
||||
/// HTTP request timeout in seconds for LLM API calls.
|
||||
/// Default: 120. Increase for local LLMs (Ollama, vLLM, LM Studio) that
|
||||
/// need more time for prompt evaluation on consumer hardware.
|
||||
|
||||
+66
-2
@@ -20,6 +20,8 @@ pub mod error;
|
||||
pub mod failover;
|
||||
mod nearai_chat;
|
||||
pub mod oauth_helpers;
|
||||
pub mod openai_codex_provider;
|
||||
pub mod openai_codex_session;
|
||||
mod provider;
|
||||
mod reasoning;
|
||||
pub mod recording;
|
||||
@@ -29,6 +31,10 @@ pub mod retry;
|
||||
mod rig_adapter;
|
||||
pub mod session;
|
||||
pub mod smart_routing;
|
||||
mod token_refreshing;
|
||||
|
||||
#[cfg(test)]
|
||||
mod codex_test_helpers;
|
||||
|
||||
pub mod image_models;
|
||||
pub mod models;
|
||||
@@ -37,12 +43,14 @@ pub mod vision_models;
|
||||
|
||||
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
|
||||
pub use config::{
|
||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
|
||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
|
||||
RegistryProviderConfig,
|
||||
};
|
||||
pub use error::LlmError;
|
||||
pub use failover::{CooldownConfig, FailoverProvider};
|
||||
pub use nearai_chat::{DEFAULT_MODEL, ModelInfo, NearAiChatProvider, default_models};
|
||||
pub use openai_codex_provider::OpenAiCodexProvider;
|
||||
pub use openai_codex_session::{OpenAiCodexSession, OpenAiCodexSessionManager};
|
||||
pub use provider::{
|
||||
ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, ImageUrl,
|
||||
LlmProvider, ModelMetadata, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
|
||||
@@ -59,6 +67,7 @@ pub use retry::{RetryConfig, RetryProvider};
|
||||
pub use rig_adapter::RigAdapter;
|
||||
pub use session::{SessionConfig, SessionManager, create_session_manager};
|
||||
pub use smart_routing::{SmartRoutingConfig, SmartRoutingProvider, TaskComplexity};
|
||||
pub use token_refreshing::TokenRefreshingProvider;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -97,6 +106,15 @@ pub async fn create_llm_provider(
|
||||
}
|
||||
}
|
||||
|
||||
if config.backend == "openai_codex" {
|
||||
return Err(LlmError::RequestFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason:
|
||||
"OpenAI Codex uses a dedicated factory path. Use build_provider_chain() instead of create_llm_provider()."
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let reg_config = config
|
||||
.provider
|
||||
.as_ref()
|
||||
@@ -374,6 +392,47 @@ fn create_ollama_from_registry(
|
||||
Ok(Arc::new(adapter))
|
||||
}
|
||||
|
||||
/// Create an OpenAI Codex provider with OAuth authentication.
|
||||
///
|
||||
/// This is async because it needs to ensure authentication before
|
||||
/// creating the provider (which requires a valid Bearer token).
|
||||
///
|
||||
/// Uses the Responses API (`chatgpt.com/backend-api/codex/responses`)
|
||||
/// instead of the Chat Completions API, matching OpenClaw's approach.
|
||||
async fn create_openai_codex_provider(
|
||||
config: &LlmConfig,
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let codex = config
|
||||
.openai_codex
|
||||
.as_ref()
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
})?;
|
||||
|
||||
let session_mgr = Arc::new(OpenAiCodexSessionManager::new(codex.clone())?);
|
||||
session_mgr.ensure_authenticated().await?;
|
||||
|
||||
let token = session_mgr.get_access_token().await?;
|
||||
|
||||
let provider = Arc::new(OpenAiCodexProvider::new(
|
||||
&codex.model,
|
||||
&codex.api_base_url,
|
||||
token.expose_secret(),
|
||||
config.request_timeout_secs,
|
||||
)?);
|
||||
|
||||
tracing::info!(
|
||||
"Using OpenAI Codex (Responses API, model: {}, base: {})",
|
||||
codex.model,
|
||||
codex.api_base_url,
|
||||
);
|
||||
|
||||
Ok(Arc::new(TokenRefreshingProvider::new(
|
||||
provider,
|
||||
session_mgr,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
|
||||
///
|
||||
/// Resolution order:
|
||||
@@ -460,7 +519,11 @@ pub async fn build_provider_chain(
|
||||
),
|
||||
LlmError,
|
||||
> {
|
||||
let llm = create_llm_provider(config, session.clone()).await?;
|
||||
let llm: Arc<dyn LlmProvider> = if config.backend == "openai_codex" {
|
||||
create_openai_codex_provider(config).await?
|
||||
} else {
|
||||
create_llm_provider(config, session.clone()).await?
|
||||
};
|
||||
tracing::debug!("LLM provider initialized: {}", llm.model_name());
|
||||
|
||||
// 1. Retry
|
||||
@@ -632,6 +695,7 @@ mod tests {
|
||||
request_timeout_secs: 120,
|
||||
cheap_model: None,
|
||||
smart_routing_cascade: true,
|
||||
openai_codex: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -347,5 +347,6 @@ pub(crate) fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
|
||||
request_timeout_secs: 120,
|
||||
cheap_model: None,
|
||||
smart_routing_cascade: false,
|
||||
openai_codex: None,
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,731 @@
|
||||
//! OAuth 2.0 session manager for OpenAI Codex (ChatGPT subscription).
|
||||
//!
|
||||
//! Supports two auth flows:
|
||||
//! - **Device Code** (primary): Works on headless servers, no browser needed.
|
||||
//! - **Browser PKCE** (fallback): Standard OAuth for local machines.
|
||||
//!
|
||||
//! Tokens are persisted to `~/.ironclaw/openai_codex_session.json` and
|
||||
//! auto-refreshed before expiry.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
|
||||
use secrecy::SecretString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
use crate::config::OpenAiCodexConfig;
|
||||
use crate::error::LlmError;
|
||||
|
||||
/// Persisted OAuth session data.
|
||||
///
|
||||
/// Note: `Debug` is manually implemented to redact tokens.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct OpenAiCodexSession {
|
||||
pub(crate) access_token: String,
|
||||
pub(crate) refresh_token: String,
|
||||
pub(crate) expires_at: DateTime<Utc>,
|
||||
pub(crate) created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OpenAiCodexSession {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OpenAiCodexSession")
|
||||
.field("access_token", &"[REDACTED]")
|
||||
.field("refresh_token", &"[REDACTED]")
|
||||
.field("expires_at", &self.expires_at)
|
||||
.field("created_at", &self.created_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body for the device code usercode endpoint.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UserCodeRequest {
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
/// Response from the device code usercode endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserCodeResponse {
|
||||
/// Unique ID for this device auth session.
|
||||
device_auth_id: String,
|
||||
/// Code the user enters in their browser.
|
||||
user_code: String,
|
||||
/// URL where the user enters the code (may not be present).
|
||||
#[serde(default = "default_verification_uri")]
|
||||
verification_uri: String,
|
||||
/// Polling interval in seconds (OpenAI sends this as a string).
|
||||
#[serde(
|
||||
default = "default_interval",
|
||||
deserialize_with = "deserialize_string_or_u64"
|
||||
)]
|
||||
interval: u64,
|
||||
/// Expiry timestamp (OpenAI sends `expires_at` as ISO-8601).
|
||||
#[serde(default)]
|
||||
expires_at: Option<String>,
|
||||
/// Seconds until the device code expires (standard field, may not be present).
|
||||
#[serde(default)]
|
||||
expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
fn default_verification_uri() -> String {
|
||||
"https://auth.openai.com/codex/device".to_string()
|
||||
}
|
||||
|
||||
fn default_interval() -> u64 {
|
||||
5
|
||||
}
|
||||
|
||||
/// Deserialize a value that may be either a string or a number as u64.
|
||||
fn deserialize_string_or_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
|
||||
struct StringOrU64;
|
||||
impl<'de> de::Visitor<'de> for StringOrU64 {
|
||||
type Value = u64;
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a string or integer")
|
||||
}
|
||||
fn visit_u64<E: de::Error>(self, v: u64) -> Result<u64, E> {
|
||||
Ok(v)
|
||||
}
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> Result<u64, E> {
|
||||
v.parse().map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_any(StringOrU64)
|
||||
}
|
||||
|
||||
impl UserCodeResponse {
|
||||
/// Get the expiry duration in seconds, from either `expires_in` or `expires_at`.
|
||||
fn expires_in_secs(&self) -> u64 {
|
||||
if let Some(secs) = self.expires_in {
|
||||
return secs;
|
||||
}
|
||||
if let Some(ref ts) = self.expires_at
|
||||
&& let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts)
|
||||
{
|
||||
let remaining = dt.signed_duration_since(Utc::now()).num_seconds();
|
||||
return remaining.max(0) as u64;
|
||||
}
|
||||
900 // default 15 minutes
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body for polling the device auth token endpoint.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DeviceTokenPollRequest {
|
||||
device_auth_id: String,
|
||||
user_code: String,
|
||||
}
|
||||
|
||||
/// Successful response from the device auth token endpoint.
|
||||
/// Returns an authorization code + PKCE pair for the final token exchange.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeviceAuthCodeResponse {
|
||||
authorization_code: String,
|
||||
#[allow(dead_code)]
|
||||
code_challenge: String,
|
||||
code_verifier: String,
|
||||
}
|
||||
|
||||
/// Response from the final OAuth token exchange.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
#[serde(default)]
|
||||
refresh_token: String,
|
||||
#[serde(default)]
|
||||
expires_in: u64,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
token_type: String,
|
||||
}
|
||||
|
||||
/// Manages OpenAI Codex OAuth sessions with persistence and auto-refresh.
|
||||
pub struct OpenAiCodexSessionManager {
|
||||
config: OpenAiCodexConfig,
|
||||
client: Client,
|
||||
session: RwLock<Option<OpenAiCodexSession>>,
|
||||
renewal_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl OpenAiCodexSessionManager {
|
||||
/// Create a new session manager. Tries to load existing session from disk.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `LlmError` if the HTTP client cannot be constructed.
|
||||
pub fn new(config: OpenAiCodexConfig) -> Result<Self, LlmError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
USER_AGENT,
|
||||
HeaderValue::from_static(concat!("ironclaw/", env!("CARGO_PKG_VERSION"))),
|
||||
);
|
||||
let client = Client::builder()
|
||||
.default_headers(headers)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "openai_codex".into(),
|
||||
reason: format!("HTTP client build failed: {e}"),
|
||||
})?;
|
||||
|
||||
let mgr = Self {
|
||||
config,
|
||||
client,
|
||||
session: RwLock::new(None),
|
||||
renewal_lock: Mutex::new(()),
|
||||
};
|
||||
|
||||
// Try synchronous load from disk during construction
|
||||
if let Ok(data) = std::fs::read_to_string(&mgr.config.session_path)
|
||||
&& let Ok(session) = serde_json::from_str::<OpenAiCodexSession>(&data)
|
||||
&& let Ok(mut guard) = mgr.session.try_write()
|
||||
{
|
||||
*guard = Some(session);
|
||||
tracing::info!(
|
||||
"Loaded OpenAI Codex session from {}",
|
||||
mgr.config.session_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(mgr)
|
||||
}
|
||||
|
||||
/// Check if we have a session (may be expired).
|
||||
pub async fn has_session(&self) -> bool {
|
||||
self.session.read().await.is_some()
|
||||
}
|
||||
|
||||
/// Check if the current access token needs refreshing.
|
||||
pub async fn needs_refresh(&self) -> bool {
|
||||
let guard = self.session.read().await;
|
||||
match guard.as_ref() {
|
||||
None => true,
|
||||
Some(s) => {
|
||||
let margin =
|
||||
chrono::Duration::seconds(self.config.token_refresh_margin_secs as i64);
|
||||
Utc::now() + margin >= s.expires_at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current access token, refreshing if needed.
|
||||
///
|
||||
/// If the token is within the refresh margin, silently refreshes first.
|
||||
/// If no session exists, returns an AuthFailed error.
|
||||
pub async fn get_access_token(&self) -> Result<SecretString, LlmError> {
|
||||
if self.needs_refresh().await {
|
||||
let has_refresh = self
|
||||
.session
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(|s| !s.refresh_token.is_empty())
|
||||
.unwrap_or(false);
|
||||
if has_refresh {
|
||||
self.refresh_tokens().await?;
|
||||
} else {
|
||||
return Err(LlmError::AuthFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let guard = self.session.read().await;
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|s| SecretString::from(s.access_token.clone()))
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensure we have a valid session. Loads from disk, refreshes, or prompts login.
|
||||
pub async fn ensure_authenticated(&self) -> Result<(), LlmError> {
|
||||
// Try loading from disk if we don't have a session
|
||||
if !self.has_session().await {
|
||||
let _ = self.load_session().await;
|
||||
}
|
||||
|
||||
if !self.has_session().await {
|
||||
// No session at all -- need to authenticate
|
||||
return self.device_code_login().await;
|
||||
}
|
||||
|
||||
if self.needs_refresh().await {
|
||||
// Try refresh; if it fails, re-authenticate
|
||||
match self.refresh_tokens().await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
tracing::info!("Token refresh failed ({}), re-authenticating...", e);
|
||||
self.device_code_login().await
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Run OpenAI's device code auth flow.
|
||||
///
|
||||
/// Uses OpenAI's custom `/api/accounts/deviceauth/*` endpoints (not the standard
|
||||
/// Auth0 `/oauth/device/code` which is behind Cloudflare managed challenge).
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. POST `/api/accounts/deviceauth/usercode` → get device_auth_id + user_code
|
||||
/// 2. Poll POST `/api/accounts/deviceauth/token` → get authorization_code + PKCE
|
||||
/// 3. Exchange via POST `/oauth/token` → get access_token + refresh_token
|
||||
pub async fn device_code_login(&self) -> Result<(), LlmError> {
|
||||
let _guard = self.renewal_lock.lock().await;
|
||||
|
||||
let auth_base = format!("{}/api/accounts", self.config.auth_endpoint);
|
||||
|
||||
// Step 1: Request device code
|
||||
let usercode_url = format!("{}/deviceauth/usercode", auth_base);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&usercode_url)
|
||||
.json(&UserCodeRequest {
|
||||
client_id: self.config.client_id.clone(),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Device code request failed: {}", e),
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Device code request failed: HTTP {} -- {}", status, body),
|
||||
});
|
||||
}
|
||||
|
||||
let body_text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Failed to read device code response: {}", e),
|
||||
})?;
|
||||
tracing::debug!("Device code response received ({} bytes)", body_text.len());
|
||||
let device: UserCodeResponse =
|
||||
serde_json::from_str(&body_text).map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!(
|
||||
"Failed to parse device code response: {} ({} bytes)",
|
||||
e,
|
||||
body_text.len()
|
||||
),
|
||||
})?;
|
||||
|
||||
// Step 2: Display code to user
|
||||
println!();
|
||||
println!("===========================================================");
|
||||
println!(" OpenAI Codex Authentication ");
|
||||
println!("===========================================================");
|
||||
println!();
|
||||
println!(" 1. Open this URL in any browser:");
|
||||
println!(" {}", device.verification_uri);
|
||||
println!();
|
||||
println!(" 2. Enter this code:");
|
||||
println!();
|
||||
println!(" [ {} ]", device.user_code);
|
||||
println!();
|
||||
let expires_secs = device.expires_in_secs();
|
||||
println!(
|
||||
" Waiting for authorization... (expires in {} min)",
|
||||
expires_secs / 60
|
||||
);
|
||||
println!("===========================================================");
|
||||
println!();
|
||||
|
||||
// Step 3: Poll for authorization code
|
||||
let poll_url = format!("{}/deviceauth/token", auth_base);
|
||||
let mut interval = std::time::Duration::from_secs(device.interval.max(5));
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(expires_secs);
|
||||
|
||||
let auth_code = loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: "Device code authorization timed out".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&poll_url)
|
||||
.json(&DeviceTokenPollRequest {
|
||||
device_auth_id: device.device_auth_id.clone(),
|
||||
user_code: device.user_code.clone(),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Token poll request failed: {}", e),
|
||||
})?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
let code_resp: DeviceAuthCodeResponse =
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Failed to parse auth code response: {}", e),
|
||||
})?;
|
||||
break code_resp;
|
||||
}
|
||||
|
||||
// 403 = authorization_pending, keep polling
|
||||
// 404 = device code not found / not enabled
|
||||
if status == reqwest::StatusCode::FORBIDDEN {
|
||||
continue;
|
||||
}
|
||||
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: "Device code login is not enabled. Please check your OpenAI account settings.".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Slow down on 429, cap at 60s to avoid unbounded growth
|
||||
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
interval = (interval + std::time::Duration::from_secs(5))
|
||||
.min(std::time::Duration::from_secs(60));
|
||||
continue;
|
||||
}
|
||||
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Device auth poll failed: HTTP {} -- {}", status, body),
|
||||
});
|
||||
};
|
||||
|
||||
// Step 4: Exchange authorization code for tokens (form-encoded, per Auth0 spec)
|
||||
let token_url = format!("{}/oauth/token", self.config.auth_endpoint);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&token_url)
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", &auth_code.authorization_code),
|
||||
("code_verifier", &auth_code.code_verifier),
|
||||
("client_id", &self.config.client_id),
|
||||
(
|
||||
"redirect_uri",
|
||||
&format!("{}/deviceauth/callback", self.config.auth_endpoint),
|
||||
),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Token exchange failed: {}", e),
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Token exchange failed: HTTP {} -- {}", status, body),
|
||||
});
|
||||
}
|
||||
|
||||
let token_resp: TokenResponse =
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Failed to parse token response: {}", e),
|
||||
})?;
|
||||
|
||||
let session = OpenAiCodexSession {
|
||||
access_token: token_resp.access_token,
|
||||
refresh_token: token_resp.refresh_token,
|
||||
expires_at: Utc::now()
|
||||
+ chrono::Duration::seconds(if token_resp.expires_in > 0 {
|
||||
token_resp.expires_in
|
||||
} else {
|
||||
tracing::warn!("Token response has expires_in=0, defaulting to 3600s");
|
||||
3600
|
||||
} as i64),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
self.save_session(&session).await?;
|
||||
self.set_session(session).await;
|
||||
|
||||
println!();
|
||||
println!("Authentication successful!");
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh the access token using the refresh token.
|
||||
pub async fn refresh_tokens(&self) -> Result<(), LlmError> {
|
||||
let _guard = self.renewal_lock.lock().await;
|
||||
|
||||
// Double-check: another task may have refreshed while we waited on the lock
|
||||
if !self.needs_refresh().await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let refresh_token = {
|
||||
let guard = self.session.read().await;
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|s| s.refresh_token.clone())
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
})?
|
||||
};
|
||||
|
||||
let token_url = format!("{}/oauth/token", self.config.auth_endpoint);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&token_url)
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", refresh_token.as_str()),
|
||||
("client_id", self.config.client_id.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Token refresh request failed: {}", e),
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Token refresh failed: HTTP {} -- {}", status, body),
|
||||
});
|
||||
}
|
||||
|
||||
let token_resp: TokenResponse =
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Failed to parse refresh response: {}", e),
|
||||
})?;
|
||||
|
||||
let session = OpenAiCodexSession {
|
||||
access_token: token_resp.access_token,
|
||||
refresh_token: token_resp.refresh_token,
|
||||
expires_at: Utc::now()
|
||||
+ chrono::Duration::seconds(if token_resp.expires_in > 0 {
|
||||
token_resp.expires_in
|
||||
} else {
|
||||
tracing::warn!("Token response has expires_in=0, defaulting to 3600s");
|
||||
3600
|
||||
} as i64),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
self.save_session(&session).await?;
|
||||
self.set_session(session).await;
|
||||
|
||||
tracing::debug!("OpenAI Codex token refreshed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save session data to disk with restrictive permissions.
|
||||
pub async fn save_session(&self, session: &OpenAiCodexSession) -> Result<(), LlmError> {
|
||||
if let Some(parent) = self.config.session_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
||||
LlmError::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
format!("Failed to create session directory: {}", e),
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
let json =
|
||||
serde_json::to_string_pretty(session).map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Failed to serialize session: {}", e),
|
||||
})?;
|
||||
|
||||
tokio::fs::write(&self.config.session_path, &json)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
LlmError::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
format!("Failed to write session file: {}", e),
|
||||
))
|
||||
})?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
tokio::fs::set_permissions(&self.config.session_path, perms)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
LlmError::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
format!("Failed to set permissions: {}", e),
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load session from disk.
|
||||
pub async fn load_session(&self) -> Result<(), LlmError> {
|
||||
let data = tokio::fs::read_to_string(&self.config.session_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
LlmError::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
format!("Failed to read session file: {}", e),
|
||||
))
|
||||
})?;
|
||||
|
||||
let session: OpenAiCodexSession =
|
||||
serde_json::from_str(&data).map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Failed to parse session file: {}", e),
|
||||
})?;
|
||||
|
||||
let mut guard = self.session.write().await;
|
||||
*guard = Some(session);
|
||||
tracing::info!(
|
||||
"Loaded OpenAI Codex session from {}",
|
||||
self.config.session_path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set session directly (for testing or after auth).
|
||||
pub async fn set_session(&self, session: OpenAiCodexSession) {
|
||||
let mut guard = self.session.write().await;
|
||||
*guard = Some(session);
|
||||
}
|
||||
|
||||
/// Handle a 401 response by refreshing, or re-authenticating.
|
||||
pub async fn handle_auth_failure(&self) -> Result<(), LlmError> {
|
||||
match self.refresh_tokens().await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => self.device_code_login().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::codex_test_helpers::test_codex_config as test_config;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_session() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("session.json");
|
||||
let config = test_config(path.clone());
|
||||
|
||||
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
|
||||
|
||||
// No session initially
|
||||
assert!(!mgr.has_session().await);
|
||||
|
||||
// Save a session
|
||||
let session = OpenAiCodexSession {
|
||||
access_token: "access_abc".to_string(),
|
||||
refresh_token: "refresh_xyz".to_string(),
|
||||
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
mgr.save_session(&session).await.unwrap();
|
||||
mgr.set_session(session).await;
|
||||
|
||||
assert!(mgr.has_session().await);
|
||||
|
||||
// Load from disk in a new manager
|
||||
let config2 = test_config(path);
|
||||
let mgr2 = OpenAiCodexSessionManager::new(config2).unwrap();
|
||||
mgr2.load_session().await.unwrap();
|
||||
assert!(mgr2.has_session().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_needs_refresh_when_near_expiry() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config = test_config(dir.path().join("session.json"));
|
||||
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
|
||||
|
||||
// Token expiring in 2 minutes (margin is 300s = 5 min)
|
||||
let session = OpenAiCodexSession {
|
||||
access_token: "access_abc".to_string(),
|
||||
refresh_token: "refresh_xyz".to_string(),
|
||||
expires_at: chrono::Utc::now() + chrono::Duration::minutes(2),
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
mgr.set_session(session).await;
|
||||
|
||||
assert!(mgr.needs_refresh().await);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_code_parse_error_redacts_body() {
|
||||
// Regression: the parse error used to include raw body_text which could
|
||||
// contain sensitive auth data. Now it only shows byte count.
|
||||
let body_text = r#"{"secret_token":"sk-12345","error":"unexpected"}"#;
|
||||
let err: Result<UserCodeResponse, _> = serde_json::from_str(body_text);
|
||||
assert!(err.is_err());
|
||||
let e = err.unwrap_err();
|
||||
let error_msg = format!(
|
||||
"Failed to parse device code response: {} ({} bytes)",
|
||||
e,
|
||||
body_text.len()
|
||||
);
|
||||
assert!(
|
||||
!error_msg.contains("sk-12345"),
|
||||
"error message must not contain raw body: {error_msg}"
|
||||
);
|
||||
assert!(
|
||||
error_msg.contains("bytes"),
|
||||
"error message should show byte count"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_refresh_when_fresh() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config = test_config(dir.path().join("session.json"));
|
||||
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
|
||||
|
||||
// Token expiring in 30 minutes (margin is 300s = 5 min)
|
||||
let session = OpenAiCodexSession {
|
||||
access_token: "access_abc".to_string(),
|
||||
refresh_token: "refresh_xyz".to_string(),
|
||||
expires_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
mgr.set_session(session).await;
|
||||
|
||||
assert!(!mgr.needs_refresh().await);
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ fn round_f32_to_f64(val: f32) -> f64 {
|
||||
///
|
||||
/// This is applied as a clone-and-transform at the provider boundary so the
|
||||
/// original tool definitions remain unchanged for other providers.
|
||||
fn normalize_schema_strict(schema: &JsonValue) -> JsonValue {
|
||||
pub(crate) fn normalize_schema_strict(schema: &JsonValue) -> JsonValue {
|
||||
let mut schema = schema.clone();
|
||||
normalize_schema_recursive(&mut schema);
|
||||
schema
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
+41
@@ -139,6 +139,47 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Some(Command::Login { openai_codex }) => {
|
||||
init_cli_tracing();
|
||||
if *openai_codex {
|
||||
// Resolve codex config so OPENAI_CODEX_* env overrides are
|
||||
// honoured even when LLM_BACKEND isn't set to openai_codex.
|
||||
let codex_config = {
|
||||
let config = Config::from_env()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
config.llm.openai_codex.unwrap_or_else(|| {
|
||||
use ironclaw::llm::OpenAiCodexConfig;
|
||||
let mut cfg = OpenAiCodexConfig::default();
|
||||
if let Ok(v) = std::env::var("OPENAI_CODEX_AUTH_URL") {
|
||||
cfg.auth_endpoint = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var("OPENAI_CODEX_API_URL") {
|
||||
cfg.api_base_url = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var("OPENAI_CODEX_CLIENT_ID") {
|
||||
cfg.client_id = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var("OPENAI_CODEX_SESSION_PATH") {
|
||||
cfg.session_path = std::path::PathBuf::from(v);
|
||||
}
|
||||
cfg
|
||||
})
|
||||
};
|
||||
let mgr = ironclaw::llm::OpenAiCodexSessionManager::new(codex_config)
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
mgr.device_code_login()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
println!(
|
||||
"OpenAI Codex authentication complete. Set LLM_BACKEND=openai_codex to use it."
|
||||
);
|
||||
} else {
|
||||
println!("Specify a provider to authenticate with:");
|
||||
println!(" ironclaw login --openai-codex (ChatGPT subscription)");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Some(Command::Onboard {
|
||||
skip_auth,
|
||||
channels_only,
|
||||
|
||||
+41
-4
@@ -3,7 +3,7 @@
|
||||
//! The wizard guides users through:
|
||||
//! 1. Database connection
|
||||
//! 2. Security (secrets master key)
|
||||
//! 3. Inference provider (NEAR AI, Anthropic, OpenAI, Ollama, OpenAI-compatible)
|
||||
//! 3. Inference provider (NEAR AI, Anthropic, OpenAI, OpenAI Codex, Ollama, OpenAI-compatible)
|
||||
//! 4. Model selection
|
||||
//! 5. Embeddings
|
||||
//! 6. Channel configuration
|
||||
@@ -1083,8 +1083,10 @@ impl SetupWizard {
|
||||
print_info(&format!("Current provider: {}", display));
|
||||
println!();
|
||||
|
||||
let is_known =
|
||||
current == "nearai" || current == "bedrock" || registry.is_known(¤t);
|
||||
let is_known = current == "nearai"
|
||||
|| current == "bedrock"
|
||||
|| current == "openai_codex"
|
||||
|| registry.is_known(¤t);
|
||||
|
||||
if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? {
|
||||
if current == "bedrock" {
|
||||
@@ -1093,6 +1095,10 @@ impl SetupWizard {
|
||||
print_info("Keeping existing AWS Bedrock configuration.");
|
||||
return Ok(());
|
||||
}
|
||||
if current == "openai_codex" {
|
||||
print_info("Keeping existing OpenAI Codex configuration.");
|
||||
return Ok(());
|
||||
}
|
||||
return self.run_provider_setup(¤t, ®istry).await;
|
||||
}
|
||||
|
||||
@@ -1107,7 +1113,7 @@ impl SetupWizard {
|
||||
print_info("Select your inference provider:");
|
||||
println!();
|
||||
|
||||
// Build menu: NearAI first, then all registry providers with setup hints, then Bedrock
|
||||
// Build menu: NearAI first, then OpenAI Codex, then registry providers, then Bedrock
|
||||
let selectable = registry.selectable();
|
||||
let mut options: Vec<String> = Vec::with_capacity(2 + selectable.len());
|
||||
let mut provider_ids: Vec<String> = Vec::with_capacity(2 + selectable.len());
|
||||
@@ -1115,6 +1121,9 @@ impl SetupWizard {
|
||||
options.push("NEAR AI - multi-model access via NEAR account".to_string());
|
||||
provider_ids.push("nearai".to_string());
|
||||
|
||||
options.push("OpenAI Codex - ChatGPT subscription (Plus/Pro/Max)".to_string());
|
||||
provider_ids.push("openai_codex".to_string());
|
||||
|
||||
for def in &selectable {
|
||||
let label = format!(
|
||||
"{:<17}- {}",
|
||||
@@ -1158,6 +1167,10 @@ impl SetupWizard {
|
||||
return self.setup_nearai().await;
|
||||
}
|
||||
|
||||
if provider_id == "openai_codex" {
|
||||
return self.setup_openai_codex().await;
|
||||
}
|
||||
|
||||
let def = registry
|
||||
.find(provider_id)
|
||||
.ok_or_else(|| SetupError::Config(format!("Unknown provider: {}", provider_id)))?;
|
||||
@@ -1490,6 +1503,29 @@ impl SetupWizard {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OpenAI Codex (ChatGPT subscription) setup: device code OAuth flow.
|
||||
async fn setup_openai_codex(&mut self) -> Result<(), SetupError> {
|
||||
self.settings.llm_backend = Some("openai_codex".to_string());
|
||||
if self.settings.selected_model.is_some() {
|
||||
self.settings.selected_model = None;
|
||||
}
|
||||
|
||||
use crate::config::OpenAiCodexConfig;
|
||||
use crate::llm::OpenAiCodexSessionManager;
|
||||
|
||||
let config = OpenAiCodexConfig::default();
|
||||
|
||||
let mgr = OpenAiCodexSessionManager::new(config).map_err(|e| {
|
||||
SetupError::Config(format!("OpenAI Codex session manager init failed: {}", e))
|
||||
})?;
|
||||
mgr.device_code_login().await.map_err(|e| {
|
||||
SetupError::Config(format!("OpenAI Codex authentication failed: {}", e))
|
||||
})?;
|
||||
|
||||
print_success("OpenAI Codex configured (ChatGPT subscription)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generic Ollama-style setup: just needs a base URL, no API key.
|
||||
fn setup_ollama_generic(
|
||||
&mut self,
|
||||
@@ -2963,6 +2999,7 @@ impl SetupWizard {
|
||||
"ollama" => "Ollama",
|
||||
"openai_compatible" => "OpenAI-compatible",
|
||||
"bedrock" => "AWS Bedrock",
|
||||
"openai_codex" => "OpenAI Codex",
|
||||
other => other,
|
||||
};
|
||||
println!(" Provider: {}", display);
|
||||
|
||||
Reference in New Issue
Block a user