Commit fd228746 authored by Your Name's avatar Your Name

Release 0.99.16

parent cd3769d8
......@@ -693,6 +693,8 @@ Qwen is Alibaba Cloud's large language model service that provides OAuth2-based
**Qwen Configuration Fields (qwen_config):**
- `credentials_file`: Path to OAuth2 credentials file (default: `~/.aisbf/qwen_credentials.json`)
- `api_key`: Optional API key to bypass OAuth2 (leave empty to use OAuth2)
- `region`: Region for API key authentication (china-beijing, singapore, us-virginia, china-hongkong, germany-frankfurt)
- `workspace_id`: Workspace ID for Germany region (default: "Default Workspace")
**Authentication Flow:**
1. First request triggers OAuth2 Device Authorization flow if no credentials exist
......@@ -708,10 +710,16 @@ Qwen is Alibaba Cloud's large language model service that provides OAuth2-based
3. Internet connection for OAuth2 flow
**Available Models:**
*OAuth2 Authentication (Fixed Model List):*
- `coder-model` - Specialized coding model with 1M context (fixed for OAuth2 auth)
*API Key Authentication (Dynamic Model List):*
- `qwen-plus` - Enhanced model with 32K context
- `qwen-turbo` - Fast model for quick responses
- `qwen-max` - Top-tier model with advanced capabilities
- `coder-model` - Specialized coding model (maps to qwen3.6-plus)
- Additional models fetched from DashScope API endpoint
**Usage:**
Once configured, qwen provider can be used like any other provider in AISBF:
......@@ -726,11 +734,28 @@ Once configured, qwen provider can be used like any other provider in AISBF:
- Use Qwen models in AISBF rotations alongside other providers
- Automatic failover and load balancing with other providers
**Authentication Behavior:**
**OAuth2 Mode (Default - No API Key):**
- Uses OAuth2 Device Authorization Grant with PKCE
- Model list is fixed to only `coder-model` with 1M context tokens
- Endpoint: `https://dashscope.aliyuncs.com/compatible-mode/v1`
- Requires browser-based authentication flow
- Automatic token refresh
**API Key Mode (Optional):**
If you prefer to use an API key instead of OAuth2:
1. Obtain an API key from Alibaba Cloud DashScope
2. Set `api_key` in `qwen_config`
3. The provider will use the API key directly instead of OAuth2
- Set `api_key` in `qwen_config` to use API key authentication
- Model list is fetched dynamically from DashScope `/models` endpoint
- If models are defined in provider configuration, uses those instead
- No OAuth2 authentication flow required
- Obtain API key from Alibaba Cloud DashScope console
**Region-Based Endpoints (API Key Mode):**
- `china-beijing`: `https://dashscope.aliyuncs.com/compatible-mode/v1` (default)
- `singapore`: `https://dashscope-intl.aliyuncs.com/compatible-mode/v1`
- `us-virginia`: `https://dashscope-us.aliyuncs.com/compatible-mode/v1`
- `china-hongkong`: `https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1`
- `germany-frankfurt`: `https://{workspace_id}.eu-central-1.maas.aliyuncs.com/compatible-mode/v1`
### Modifying Configuration
1. Edit files in `~/.aisbf/` for user-specific changes
......@@ -828,7 +853,20 @@ This AI.PROMPT file is automatically updated when significant changes are made t
### Recent Updates
**2026-04-03 - Version 0.9.6 - Fixed aisbf.json Installation Path**
**2026-04-11 - Qwen Provider Authentication-Based Model Handling & Region Support**
- Modified QwenProviderHandler.get_models() to handle OAuth2 vs API key authentication differently
- OAuth2 authentication: Returns fixed model list with only "coder-model" (1M context)
- API key authentication: Fetches full model list from DashScope /models endpoint (if no models configured)
- Added region-based endpoint support for API key authentication:
- Singapore: dashscope-intl.aliyuncs.com
- US (Virginia): dashscope-us.aliyuncs.com
- China (Beijing): dashscope.aliyuncs.com
- China (Hong Kong): cn-hongkong.dashscope.aliyuncs.com
- Germany (Frankfurt): {workspace_id}.eu-central-1.maas.aliyuncs.com
- Added region and workspace_id configuration fields to qwen_config
- Updated dashboard templates to show region selector and workspace ID field
- Updated _get_sdk_client() and _get_auth_headers() to support both OAuth2 and API key authentication
- Updated AI.PROMPT documentation to reflect authentication-specific model behavior and region configuration
- Fixed aisbf.json not being copied to ~/.aisbf/ directory on first run
- Updated _ensure_config_directory() in aisbf/config.py to include aisbf.json in the list of files to copy
- Previously only providers.json, rotations.json, and autoselect.json were copied
......
......@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2
from .handlers import RequestHandler, RotationHandler, AutoselectHandler
from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model
__version__ = "0.99.15"
__version__ = "0.99.16"
__all__ = [
# Config
"config",
......
......@@ -79,7 +79,7 @@ def _generate_client_id():
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # Official Claude Code client ID
AUTH_URL = "https://claude.com/cai/oauth/authorize" # Authorization endpoint (note: /cai path is required)
TOKEN_URL = "https://api.anthropic.com/v1/oauth/token" # Token exchange endpoint
REDIRECT_URI = "http://localhost:54545/callback" # OAuth2 callback URI
DEFAULT_REDIRECT_URI = "http://localhost:54545/callback" # Default local OAuth2 callback URI
CLI_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
logger = logging.getLogger(__name__)
......@@ -97,10 +97,10 @@ class ClaudeAuth:
CLIENT_ID = CLIENT_ID
AUTH_URL = AUTH_URL
TOKEN_URL = TOKEN_URL
REDIRECT_URI = REDIRECT_URI
REDIRECT_URI = DEFAULT_REDIRECT_URI
CLI_USER_AGENT = CLI_USER_AGENT
def __init__(self, credentials_file: Optional[str] = None):
def __init__(self, credentials_file: Optional[str] = None, redirect_uri: Optional[str] = None):
"""
Initialize Claude authentication.
......@@ -113,6 +113,9 @@ class ClaudeAuth:
# Store credentials in ~/.aisbf/ directory (AISBF config directory)
self.credentials_file = Path.home() / ".aisbf" / "claude_credentials.json"
# Allow overriding redirect URI for reverse proxy deployments
self.redirect_uri = redirect_uri if redirect_uri is not None else DEFAULT_REDIRECT_URI
self.tokens = self._load_credentials()
self._oauth_state = None # Store state for OAuth flow
self._code_verifier = None # Store verifier for OAuth flow
......@@ -386,7 +389,7 @@ class ClaudeAuth:
"code": "true",
"client_id": CLIENT_ID,
"response_type": "code",
"redirect_uri": REDIRECT_URI,
"redirect_uri": self.redirect_uri,
"scope": "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
"code_challenge": challenge,
"code_challenge_method": "S256",
......@@ -450,7 +453,7 @@ class ClaudeAuth:
"state": state,
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"redirect_uri": self.redirect_uri,
"code_verifier": verifier
}
......@@ -495,7 +498,7 @@ class ClaudeAuth:
"code": "true",
"client_id": CLIENT_ID,
"response_type": "code",
"redirect_uri": REDIRECT_URI,
"redirect_uri": self.redirect_uri,
"scope": "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
"code_challenge": challenge,
"code_challenge_method": "S256",
......@@ -585,7 +588,7 @@ class ClaudeAuth:
"state": state,
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"redirect_uri": self.redirect_uri,
"code_verifier": verifier
}
......
......@@ -42,6 +42,7 @@ logger = logging.getLogger(__name__)
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
DEFAULT_ISSUER = "https://auth.openai.com"
DEFAULT_PORT = 1455
# IMPORTANT: Scopes must match the codex-cli implementation guide
SCOPES = "openid profile email offline_access api.connectors.read api.connectors.invoke"
......@@ -61,7 +62,16 @@ class CodexOAuth2:
credentials_file: Path to credentials JSON file (default: ~/.aisbf/codex_credentials.json)
issuer: OAuth2 issuer URL (default: https://auth.openai.com)
"""
self.credentials_file = credentials_file or os.path.expanduser("~/.aisbf/codex_credentials.json")
# Expand and resolve path immediately to absolute path
default_path = os.path.expanduser("~/.aisbf/codex_credentials.json")
if credentials_file:
# Expand user directory and convert to absolute path
expanded = os.path.expanduser(credentials_file)
# If still relative, make it absolute
self.credentials_file = os.path.abspath(expanded)
else:
self.credentials_file = default_path
self.issuer = (issuer or DEFAULT_ISSUER).rstrip("/")
self.credentials = None
self._load_credentials()
......@@ -80,25 +90,55 @@ class CodexOAuth2:
def _save_credentials(self, credentials: Dict[str, Any]) -> None:
"""
Save credentials to file with secure permissions.
Args:
credentials: Credentials dict to save
"""
try:
# Ensure directory exists
os.makedirs(os.path.dirname(self.credentials_file), exist_ok=True)
# Path is already expanded and absolute from __init__
resolved_path = self.credentials_file
# Write credentials
with open(self.credentials_file, 'w') as f:
logger.debug(f"CodexOAuth2: Saving credentials to resolved path: {resolved_path}")
# Ensure parent directory exists
parent_dir = os.path.dirname(resolved_path)
if parent_dir:
logger.debug(f"CodexOAuth2: Creating parent directory: {parent_dir}")
os.makedirs(parent_dir, exist_ok=True)
# Secure directory permissions
try:
os.chmod(parent_dir, 0o700)
logger.debug(f"CodexOAuth2: Set directory permissions to 0o700")
except Exception as e:
logger.debug(f"CodexOAuth2: Could not set directory permissions: {e}")
# Write credentials safely
logger.debug(f"CodexOAuth2: Writing credentials to file")
with open(resolved_path, 'w') as f:
json.dump(credentials, f, indent=2)
f.flush()
os.fsync(f.fileno())
logger.debug(f"CodexOAuth2: File written successfully")
# Set file permissions to 0o600 (user read/write only)
os.chmod(self.credentials_file, 0o600)
try:
os.chmod(resolved_path, 0o600)
logger.debug(f"CodexOAuth2: Set file permissions to 0o600")
except Exception as e:
logger.debug(f"CodexOAuth2: Could not set file permissions: {e}")
# Verify file was created
if os.path.exists(resolved_path):
file_size = os.path.getsize(resolved_path)
logger.info(f"CodexOAuth2: Saved credentials to {resolved_path} ({file_size} bytes)")
else:
logger.error(f"CodexOAuth2: File was not created at {resolved_path}")
raise IOError(f"Failed to create credentials file at {resolved_path}")
self.credentials = credentials
logger.info(f"CodexOAuth2: Saved credentials to {self.credentials_file}")
except Exception as e:
logger.error(f"CodexOAuth2: Failed to save credentials: {e}")
logger.error(f"CodexOAuth2: Failed to save credentials to {self.credentials_file}: {e}", exc_info=True)
raise
@staticmethod
......
......@@ -108,9 +108,8 @@ class KiloOAuth2:
for attempt in range(max_retries):
try:
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': '0',
'User-Agent': 'AISBF/0.99.14 (httpx)'
'Content-Type': 'application/json',
'User-Agent': 'Kilocode/1.0 (Firefox/130.0)'
}
if attempt > 0:
......
......@@ -27,7 +27,9 @@ import hashlib
import json
import logging
import os
import platform
import secrets
import sys
import time
import uuid
from datetime import datetime
......@@ -38,6 +40,20 @@ import httpx
logger = logging.getLogger(__name__)
# Qwen CLI-style headers
def _get_qwen_headers() -> Dict[str, str]:
"""Get headers that mimic the Qwen CLI."""
# Detect platform for user-agent
system = platform.system() # 'Linux', 'Darwin', 'Windows'
machine = platform.machine() # 'x86_64', 'aarch64', etc.
user_agent = f"QwenCode/1.0.0 ({system.lower()}; {machine})"
return {
"User-Agent": user_agent,
"Accept": "application/json",
"x-request-id": str(uuid.uuid4()),
}
# Qwen OAuth2 Constants (from qwen-oauth2-analysis.md)
QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai"
QWEN_OAUTH_DEVICE_CODE_ENDPOINT = f"{QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code"
......@@ -69,7 +85,7 @@ class QwenOAuth2:
Args:
credentials_file: Path to credentials JSON file (default: ~/.aisbf/qwen_credentials.json)
"""
self.credentials_file = credentials_file or os.path.expanduser("~/.aisbf/qwen_credentials.json")
self.credentials_file = os.path.expanduser(credentials_file) if credentials_file else os.path.expanduser("~/.aisbf/qwen_credentials.json")
self.lock_file = os.path.expanduser("~/.aisbf/qwen_credentials.lock")
self.credentials = None
self._file_mod_time = 0
......@@ -227,25 +243,43 @@ class QwenOAuth2:
"code_challenge_method": "S256",
}
async with httpx.AsyncClient() as client:
# Build headers mimicking Qwen CLI
headers = _get_qwen_headers()
headers["Content-Type"] = "application/x-www-form-urlencoded"
headers["X-DashScope-CacheControl"] = "enable"
async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.post(
QWEN_OAUTH_DEVICE_CODE_ENDPOINT,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
"x-request-id": str(uuid.uuid4()),
},
headers=headers,
data=body_data,
timeout=30.0
)
logger.debug(f"QwenOAuth2: Device code request response status: {response.status_code}")
logger.debug(f"QwenOAuth2: Device code request response headers: {dict(response.headers)}")
if response.status_code != 200:
error_body = response.text
raise Exception(
f"Device authorization failed: {response.status_code} {response.reason_phrase}. Response: {error_body}"
)
result = response.json()
# Try to parse JSON, handle empty or non-JSON responses
response_text = response.text
logger.debug(f"QwenOAuth2: Device code request response body: {response_text[:500] if response_text else 'empty'}")
if not response_text or not response_text.strip():
raise Exception(
f"Device authorization failed: Empty response from server. Status: {response.status_code}"
)
try:
result = response.json()
except json.JSONDecodeError as e:
raise Exception(
f"Device authorization failed: Invalid JSON response. Status: {response.status_code}, Response: {response_text[:500]}"
)
if "device_code" not in result:
error = result.get("error", "Unknown error")
......@@ -288,17 +322,38 @@ class QwenOAuth2:
"code_verifier": code_verifier,
}
async with httpx.AsyncClient() as client:
# Build headers mimicking Qwen CLI
headers = _get_qwen_headers()
headers["Content-Type"] = "application/x-www-form-urlencoded"
headers["X-DashScope-CacheControl"] = "enable"
async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.post(
QWEN_OAUTH_TOKEN_ENDPOINT,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
},
headers=headers,
data=body_data,
timeout=30.0
)
# Check Content-Type to determine response type
content_type = response.headers.get("content-type", "")
# If not JSON, it's likely a pending/authorization page
if "application/json" not in content_type:
response_text = response.text.lower()
# Check if this is an authorization pending page
if ("pending" in response_text or
"authorize" in response_text or
"waiting" in response_text or
response.status_code == 200):
# Still pending - user hasn't approved yet
logger.debug("QwenOAuth2: Authorization still pending (HTML response)")
return None
# Otherwise it's an error
raise Exception(
f"Device token poll failed: HTTP {response.status_code}, Content-Type: {content_type}"
)
if response.status_code == 200:
result = response.json()
......@@ -355,12 +410,20 @@ class QwenOAuth2:
if token_data:
# Success - save credentials
# OAuth2: expires_in is in seconds
expires_in = token_data.get("expires_in", 7200)
expires_in_ms = expires_in * 1000 # Convert seconds to milliseconds
# Minimum 1 hour
if expires_in_ms < 3600000:
expires_in_ms = 3600000
credentials = {
"access_token": token_data["access_token"],
"refresh_token": token_data.get("refresh_token"),
"token_type": token_data.get("token_type", "Bearer"),
"resource_url": token_data.get("resource_url"),
"expiry_date": int(time.time() * 1000) + token_data.get("expires_in", 7200) * 1000,
"expiry_date": int(time.time() * 1000) + expires_in_ms,
"last_refresh": datetime.utcnow().isoformat() + "Z",
}
......@@ -415,13 +478,15 @@ class QwenOAuth2:
"client_id": QWEN_OAUTH_CLIENT_ID,
}
# Build headers mimicking Qwen CLI
headers = _get_qwen_headers()
headers["Content-Type"] = "application/x-www-form-urlencoded"
headers["X-DashScope-CacheControl"] = "enable"
async with httpx.AsyncClient() as client:
response = await client.post(
QWEN_OAUTH_TOKEN_ENDPOINT,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
},
headers=headers,
data=body_data,
timeout=30.0
)
......@@ -435,7 +500,8 @@ class QwenOAuth2:
"token_type": result.get("token_type", "Bearer"),
"refresh_token": result.get("refresh_token", self.credentials["refresh_token"]),
"resource_url": result.get("resource_url", self.credentials.get("resource_url")),
"expiry_date": int(time.time() * 1000) + result.get("expires_in", 7200) * 1000,
# OAuth2: expires_in is in seconds, convert to ms with minimum 1 hour
"expiry_date": int(time.time() * 1000) + max(3600000, result.get("expires_in", 7200) * 1000),
"last_refresh": datetime.utcnow().isoformat() + "Z",
}
......
......@@ -68,8 +68,12 @@ class ProviderConfig(BaseModel):
rate_limit: float = 0.0
api_key: Optional[str] = None # Optional API key in provider config
models: Optional[List[ProviderModelConfig]] = None # Optional list of models with their configs
kiro_config: Optional[Dict] = None # Optional Kiro-specific configuration (credentials, region, etc.)
claude_config: Optional[Dict] = None # Optional Claude-specific configuration (credentials file path)
auth_config: Optional[Dict] = None # Unified provider authentication configuration (for all provider types)
kiro_config: Optional[Dict] = None # Optional Kiro-specific configuration (credentials, region, etc.) - DEPRECATED
kilo_config: Optional[Dict] = None # Optional Kilo-specific configuration (credentials file path) - DEPRECATED
claude_config: Optional[Dict] = None # Optional Claude-specific configuration (credentials file path) - DEPRECATED
codex_config: Optional[Dict] = None # Optional Codex-specific configuration - DEPRECATED
qwen_config: Optional[Dict] = None # Optional Qwen-specific configuration - DEPRECATED
# Default settings for models in this provider
default_rate_limit: Optional[float] = None
default_max_request_tokens: Optional[int] = None
......@@ -257,6 +261,31 @@ class Config:
self._initialize_error_tracking()
self._log_configuration_summary()
def reload(self):
"""Reload all configuration files from disk"""
import logging
logger = logging.getLogger(__name__)
logger.info("=== Config.reload() START ===")
# Clear existing config
self.providers.clear()
self.rotations.clear()
self.autoselect.clear()
self.error_tracking.clear()
self._loaded_files.clear()
# Re-load everything
self._load_providers()
self._load_rotations()
self._load_condensation()
self._load_tor()
self._load_aisbf_config()
self._load_autoselect()
self._initialize_error_tracking()
self._log_configuration_summary()
logger.info("=== Config.reload() END ===")
def _get_config_source_dir(self):
"""Get the directory containing default config files"""
# If custom config directory is set, use it first
......
......@@ -98,11 +98,29 @@ class RequestHandler:
def _load_user_configs(self):
"""Load user-specific configurations from database"""
self.reload_user_config()
def reload_user_config(self):
"""Reload user configuration from database"""
import logging
logger = logging.getLogger(__name__)
from .database import get_database
db = get_database()
self.user_providers = db.get_user_providers(self.user_id)
self.user_rotations = db.get_user_rotations(self.user_id)
self.user_autoselects = db.get_user_autoselects(self.user_id)
# Convert list to dictionary with id as key
providers = db.get_user_providers(self.user_id)
self.user_providers = {p['provider_id']: p['config'] for p in providers}
rotations = db.get_user_rotations(self.user_id)
self.user_rotations = {r['rotation_id']: r['config'] for r in rotations}
autoselects = db.get_user_autoselects(self.user_id)
self.user_autoselects = {a['autoselect_id']: a['config'] for a in autoselects}
logger.info(f"Reloaded user configuration for user_id={self.user_id}")
logger.info(f" Loaded {len(self.user_providers)} user providers")
logger.info(f" Loaded {len(self.user_rotations)} user rotations")
logger.info(f" Loaded {len(self.user_autoselects)} user autoselects")
def _should_cache_response(self, provider_config=None, model_config=None, rotation_config=None, autoselect_config=None):
"""
......@@ -1117,6 +1135,121 @@ class RequestHandler:
else:
api_key = None
# First check if we already have models for same provider type + same endpoint from same user
import logging
logger = logging.getLogger(__name__)
# Check all other providers with same type, same endpoint and same user
same_providers = []
if self.user_id:
# Check user providers
for pid, pconfig in self.user_providers.items():
if pid != provider_id and pconfig.type == provider_config.type and pconfig.endpoint == provider_config.endpoint:
same_providers.append(pid)
else:
# Check global providers
for pid, pconfig in self.config.providers.items():
if pid != provider_id and pconfig.type == provider_config.type and pconfig.endpoint == provider_config.endpoint:
same_providers.append(pid)
# If there are matching providers, check if they have cached models
for same_pid in same_providers:
# Check if this provider already has models loaded
try:
same_handler = get_provider_handler(same_pid, user_id=self.user_id)
if hasattr(same_handler, '_cached_models') and same_handler._cached_models and len(same_handler._cached_models) > 0:
logger.info(f"Reusing models from existing provider {same_pid} for {provider_id} (same type and endpoint)")
# Copy models and apply correct provider_id
models = []
for model in same_handler._cached_models:
# Create new model instance with correct provider_id
model_copy = model.copy()
model_copy.provider_id = provider_id
models.append(model_copy)
# Also cache on this handler
handler = get_provider_handler(provider_id, api_key, user_id=self.user_id)
handler._cached_models = models
# Skip rate limit and direct model fetch
model_filter = getattr(provider_config, 'model_filter', None)
if model_filter and (not provider_config.models or len(provider_config.models) == 0):
logger.info(f"Applying model filter '{model_filter}' to provider {provider_id}")
original_count = len(models)
models = [m for m in models if model_filter.lower() in m.id.lower()]
logger.info(f"Model filter applied: {original_count} -> {len(models)} models")
# Enhance model information with context window and capabilities
enhanced_models = []
current_time = int(time_module.time())
for model in models:
model_dict = model.dict()
model_name = model_dict.get('id', '')
# Add OpenAI-compatible required fields
model_dict['object'] = 'model'
model_dict['created'] = current_time
model_dict['owned_by'] = provider_config.name
# Try to find model config in provider config
model_config = None
if provider_config.models:
for m in provider_config.models:
if m.name == model_name:
model_config = m
break
# Add context window information - use dynamically fetched value unless manually configured
# Priority: manually configured > dynamically fetched > inferred
if model_config and hasattr(model_config, 'context_size') and model_config.context_size:
# Manually configured - use this value
model_dict['context_window'] = model_config.context_size
elif model_dict.get('context_size'):
# Dynamically fetched from provider - use this value
model_dict['context_window'] = model_dict['context_size']
else:
# Fall back to inference
model_dict['context_window'] = self._infer_context_window(model_name, provider_config.type)
# Add context_length for compatibility - same priority order as context_window
if model_config and hasattr(model_config, 'context_size') and model_config.context_size:
model_dict['context_length'] = model_config.context_size
elif model_dict.get('context_size'):
model_dict['context_length'] = model_dict['context_size']
elif model_dict.get('context_length'):
model_dict['context_length'] = model_dict['context_length']
# Add pricing if available (from dynamic fetch)
if model_dict.get('pricing'):
model_dict['pricing'] = model_dict['pricing']
# Add description if available (from dynamic fetch)
if model_dict.get('description'):
model_dict['description'] = model_dict['description']
# Add top_provider info if available (from dynamic fetch)
if model_dict.get('top_provider'):
model_dict['top_provider'] = model_dict['top_provider']
# Add supported_parameters if available (from dynamic fetch)
if model_dict.get('supported_parameters'):
model_dict['supported_parameters'] = model_dict['supported_parameters']
# Add capabilities information
if model_config and hasattr(model_config, 'capabilities'):
model_dict['capabilities'] = model_config.capabilities
elif 'capabilities' not in model_dict:
# Auto-detect capabilities based on model name and provider type
model_dict['capabilities'] = self._detect_capabilities(model_name, provider_config.type)
enhanced_models.append(model_dict)
return enhanced_models
except Exception as e:
logger.debug(f"Failed to reuse models from {same_pid}: {e}")
continue
# No existing models found, proceed normally
handler = get_provider_handler(provider_id, api_key, user_id=self.user_id)
try:
# Apply rate limiting
......@@ -1124,6 +1257,16 @@ class RequestHandler:
models = await handler.get_models()
# Check if this is an auth status response (dictionary instead of Model list)
# Some providers (Kilo, Qwen, Claude) return auth status instead of models when not authenticated
if isinstance(models, dict) and 'status' in models:
logger.info(f"Provider {provider_id} returned auth status instead of models: {models.get('status')}")
# Return empty models list but include auth status in response headers
models = []
# Cache the models on the handler
handler._cached_models = models
# Apply model filter if configured and no models are manually specified
model_filter = getattr(provider_config, 'model_filter', None)
if model_filter and (not provider_config.models or len(provider_config.models) == 0):
......@@ -1623,6 +1766,21 @@ class RotationHandler:
self.user_rotations = db.get_user_rotations(self.user_id)
self.user_autoselects = db.get_user_autoselects(self.user_id)
def reload_user_configs(self):
"""Reload user-specific configurations from database"""
if self.user_id:
self._load_user_configs()
def reload_user_configs(self):
"""Reload user-specific configurations from database"""
if self.user_id:
self._load_user_configs()
def reload_user_configs(self):
"""Reload user-specific configurations from database"""
if self.user_id:
self._load_user_configs()
def _get_provider_type(self, provider_id: str) -> str:
"""Get the provider type from configuration"""
provider_config = self.config.get_provider(provider_id)
......
......@@ -4,7 +4,7 @@ Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
AISBF - AI Service Broker Framework || AI Should Be Free
Codex provider handler.
Uses the same protocol as OpenAI but with OAuth2 authentication.
Supports both API key mode (OpenAI API) and OAuth2 mode (ChatGPT Responses API).
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
......@@ -21,11 +21,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
Why did the programmer quit his job? Because he didn't get arrays!
"""
import json
import logging
import time
from typing import Dict, List, Optional, Union
import uuid
from typing import Dict, List, Optional, Union, AsyncIterator
from openai import OpenAI
import httpx
from ..models import Model
from ..config import config
......@@ -33,13 +36,23 @@ from ..utils import count_messages_tokens
from .base import BaseProviderHandler, AISBF_DEBUG
from ..auth.codex import CodexOAuth2
logger = logging.getLogger(__name__)
class CodexProviderHandler(BaseProviderHandler):
"""
Codex provider handler.
Codex provider handler with dual-mode support.
**API Key Mode** (api_key provided):
- Uses standard OpenAI API: https://api.openai.com/v1
- Uses Chat Completions endpoint: /v1/chat/completions
- Standard OpenAI protocol with Bearer token
Uses the same OpenAI-compatible protocol but authenticates via OAuth2
using the Codex OAuth2 flow (device code or browser-based PKCE).
**OAuth2 Mode** (no api_key, OAuth2 credentials):
- Uses ChatGPT backend API: https://chatgpt.com/backend-api/codex
- Uses Responses API endpoint: /v1/responses
- ChatGPT-specific protocol with SSE streaming
- Includes ChatGPT-Account-ID header
For admin users (user_id=None), credentials are loaded from file.
For non-admin users, credentials are loaded from the database.
......@@ -51,7 +64,6 @@ class CodexProviderHandler(BaseProviderHandler):
# Get provider config
provider_config = config.providers.get(provider_id)
endpoint = provider_config.endpoint if provider_config else "https://api.openai.com/v1"
# Initialize OAuth2 client
codex_config = getattr(provider_config, 'codex_config', {}) if provider_config else {}
......@@ -69,19 +81,35 @@ class CodexProviderHandler(BaseProviderHandler):
issuer=issuer,
)
# Resolve API key: use provided key, or get from OAuth2, or use stored API key
resolved_api_key = api_key
if not resolved_api_key:
# Try to get OAuth2 access token
resolved_api_key = self.oauth2.get_valid_token()
# Determine mode: API key mode or OAuth2 mode
self._use_api_key_mode = bool(api_key or (provider_config and provider_config.api_key))
self._account_id = None # Will be extracted from ID token in OAuth2 mode
if not resolved_api_key:
# Fall back to provider config API key
if provider_config and provider_config.api_key:
resolved_api_key = provider_config.api_key
# Set base URL from config (default endpoint)
# This will be overridden for OAuth2 mode when credentials are validated
self.base_url = provider_config.endpoint if provider_config else "https://api.openai.com/v1"
self.client = OpenAI(base_url=endpoint, api_key=resolved_api_key or "dummy")
self._oauth2_enabled = not api_key and provider_config and not provider_config.api_key_required
# API Key Mode: Initialize OpenAI client with configured endpoint
if self._use_api_key_mode:
resolved_api_key = api_key or (provider_config.api_key if provider_config else None)
self.client = OpenAI(
base_url=self.base_url,
api_key=resolved_api_key or "dummy",
default_headers={
"User-Agent": "codex-cli/1.0.0",
}
)
logger.info(f"CodexProviderHandler: Initialized in API Key mode with endpoint: {self.base_url}")
else:
# OAuth2 Mode: Check if OAuth2 is authenticated
# If authenticated, use ChatGPT backend; otherwise use configured endpoint
if self.oauth2.is_authenticated():
self.base_url = "https://chatgpt.com/backend-api/codex"
logger.info(f"CodexProviderHandler: Initialized in OAuth2 mode with ChatGPT backend: {self.base_url}")
else:
# Not yet authenticated, keep configured endpoint
logger.info(f"CodexProviderHandler: Initialized in OAuth2 mode (not authenticated yet) with endpoint: {self.base_url}")
self.client = None # Not used in OAuth2 mode
def _load_oauth2_from_db(self, provider_id: str, credentials_file: str, issuer: str) -> CodexOAuth2:
"""
......@@ -127,10 +155,387 @@ class CodexProviderHandler(BaseProviderHandler):
# Try OAuth2 token
token = await self.oauth2.get_valid_token_with_refresh()
if token:
# Extract account ID from credentials if available
if self.oauth2.credentials and self.oauth2.credentials.get('tokens'):
self._account_id = self.oauth2.credentials['tokens'].get('account_id')
# Switch to ChatGPT backend if OAuth2 is now authenticated
if not self._use_api_key_mode and self.base_url != "https://chatgpt.com/backend-api/codex":
self.base_url = "https://chatgpt.com/backend-api/codex"
logger.info(f"CodexProviderHandler: Switched to ChatGPT backend after OAuth2 authentication: {self.base_url}")
# Update the configuration with the new endpoint
await self._update_provider_endpoint(self.base_url)
return token
raise Exception("Codex authentication required. Please authenticate via dashboard or provide API key.")
async def _update_provider_endpoint(self, new_endpoint: str) -> None:
"""Update the provider endpoint in configuration."""
try:
provider_config = config.providers.get(self.provider_id)
if provider_config:
# Update the endpoint in the config object
provider_config.endpoint = new_endpoint
# Save to configuration file or database
if self.user_id is not None:
# User-specific provider: update in database
from ..database import get_database
db = get_database()
if db:
# Update user provider endpoint in database
db.update_user_provider_endpoint(
user_id=self.user_id,
provider_id=self.provider_id,
endpoint=new_endpoint
)
logger.info(f"CodexProviderHandler: Updated endpoint in database for user {self.user_id}: {new_endpoint}")
else:
# Global provider: update in config file
config.save_providers()
logger.info(f"CodexProviderHandler: Updated endpoint in config file: {new_endpoint}")
except Exception as e:
logger.warning(f"CodexProviderHandler: Failed to update endpoint in configuration: {e}")
# =========================================================================
# API Key Mode Methods (Standard OpenAI API)
# =========================================================================
async def _handle_request_api_key_mode(
self,
model: str,
messages: List[Dict],
max_tokens: Optional[int] = None,
temperature: Optional[float] = 1.0,
stream: Optional[bool] = False,
tools: Optional[List[Dict]] = None,
tool_choice: Optional[Union[str, Dict]] = None,
) -> Union[Dict, object]:
"""Handle request using standard OpenAI Chat Completions API."""
# Build request parameters
request_params = {
"model": model,
"messages": [],
"temperature": temperature,
"stream": stream
}
# Only add max_tokens if it's not None
if max_tokens is not None:
request_params["max_tokens"] = max_tokens
# Build messages with all fields
for msg in messages:
message = {"role": msg["role"]}
if msg["role"] == "tool":
if "tool_call_id" in msg and msg["tool_call_id"] is not None:
message["tool_call_id"] = msg["tool_call_id"]
else:
logger.warning(f"Skipping tool message without tool_call_id: {msg}")
continue
if "content" in msg and msg["content"] is not None:
message["content"] = msg["content"]
if "tool_calls" in msg and msg["tool_calls"] is not None:
message["tool_calls"] = msg["tool_calls"]
if "name" in msg and msg["name"] is not None:
message["name"] = msg["name"]
request_params["messages"].append(message)
if tools is not None:
request_params["tools"] = tools
if tool_choice is not None:
request_params["tool_choice"] = tool_choice
response = self.client.chat.completions.create(**request_params)
return response
# =========================================================================
# OAuth2 Mode Methods (ChatGPT Responses API)
# =========================================================================
def _convert_messages_to_responses_format(self, messages: List[Dict]) -> List[Dict]:
"""
Convert OpenAI Chat Completions messages to Responses API format.
OpenAI format: {"role": "user", "content": "text"}
Responses format: {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "text"}]}
"""
result = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
# Handle tool messages
if role == "tool":
result.append({
"type": "function_call_output",
"call_id": msg.get("tool_call_id", ""),
"output": content
})
continue
# Handle assistant messages with tool calls
if role == "assistant" and "tool_calls" in msg:
# Add the assistant message first
if content:
result.append({
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": content}]
})
# Add function calls
for tool_call in msg.get("tool_calls", []):
result.append({
"type": "function_call",
"call_id": tool_call.get("id", ""),
"name": tool_call.get("function", {}).get("name", ""),
"arguments": tool_call.get("function", {}).get("arguments", "{}")
})
continue
# Handle regular messages
content_items = []
if isinstance(content, str):
content_type = "input_text" if role in ["user", "system", "developer"] else "output_text"
content_items.append({"type": content_type, "text": content})
elif isinstance(content, list):
# Handle multimodal content
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
content_type = "input_text" if role in ["user", "system", "developer"] else "output_text"
content_items.append({"type": content_type, "text": item.get("text", "")})
elif item.get("type") == "image_url":
content_items.append({
"type": "input_image",
"image_url": item.get("image_url", {}).get("url", "")
})
if content_items:
result.append({
"type": "message",
"role": role,
"content": content_items
})
return result
def _build_responses_request(
self,
model: str,
messages: List[Dict],
max_tokens: Optional[int] = None,
temperature: Optional[float] = 1.0,
tools: Optional[List[Dict]] = None,
tool_choice: Optional[Union[str, Dict]] = None,
) -> Dict:
"""Build a Responses API request payload."""
# Convert messages to Responses format
input_items = self._convert_messages_to_responses_format(messages)
# Build base request
request = {
"model": model,
"instructions": "You are Codex, a helpful AI assistant for coding tasks.",
"input": input_items,
"stream": True,
"store": False,
}
# Add optional parameters
if max_tokens is not None:
request["max_tokens"] = max_tokens
if temperature is not None:
request["temperature"] = temperature
if tools:
# Convert OpenAI tool format to Responses API format
request["tools"] = tools
if tool_choice:
request["tool_choice"] = tool_choice if isinstance(tool_choice, str) else "auto"
return request
def _build_headers(self, api_key: str, conversation_id: Optional[str] = None) -> Dict[str, str]:
"""Build request headers for Responses API."""
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
"User-Agent": "codex-cli/1.0.0",
"originator": "codex_cli_rs",
}
# Add ChatGPT-Account-ID if available (OAuth2 mode)
if self._account_id:
headers["ChatGPT-Account-ID"] = self._account_id
# Add conversation tracking headers
if conversation_id:
headers["x-client-request-id"] = conversation_id
headers["session_id"] = conversation_id
return headers
async def _parse_sse_stream(self, response: httpx.Response) -> AsyncIterator[Dict]:
"""Parse Server-Sent Events stream from Responses API."""
buffer = ""
event_type = None
async for line in response.aiter_lines():
if not line:
# Empty line marks end of event
if buffer and event_type:
try:
data = json.loads(buffer)
yield {"event": event_type, "data": data}
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse SSE data: {e}")
buffer = ""
event_type = None
continue
if line.startswith("event:"):
event_type = line[6:].strip()
elif line.startswith("data:"):
data_line = line[5:].strip()
if buffer:
buffer += "\n" + data_line
else:
buffer = data_line
def _convert_sse_to_openai_format(self, events: List[Dict], model: str) -> Dict:
"""Convert Responses API SSE events to OpenAI Chat Completions format."""
# Accumulate response
response_id = None
content = ""
tool_calls = []
finish_reason = None
usage = {}
for event in events:
event_type = event.get("event")
data = event.get("data", {})
if event_type == "response.created":
response_id = data.get("response_id")
elif event_type == "response.content_part.delta":
delta = data.get("delta", {})
if "text" in delta:
content += delta["text"]
elif event_type == "response.output_item.done":
item = data.get("item", {})
if item.get("type") == "function_call":
tool_calls.append({
"id": item.get("call_id"),
"type": "function",
"function": {
"name": item.get("name"),
"arguments": item.get("arguments", "{}")
}
})
elif event_type == "response.done":
finish_reason = data.get("status", "stop")
usage = data.get("usage", {})
# Build OpenAI-compatible response
message = {
"role": "assistant",
"content": content if content else None,
}
if tool_calls:
message["tool_calls"] = tool_calls
return {
"id": response_id or f"chatcmpl-{uuid.uuid4().hex[:8]}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [{
"index": 0,
"message": message,
"finish_reason": finish_reason or "stop"
}],
"usage": {
"prompt_tokens": usage.get("input_tokens", 0),
"completion_tokens": usage.get("output_tokens", 0),
"total_tokens": usage.get("total_tokens", 0)
}
}
async def _handle_request_oauth2_mode(
self,
model: str,
messages: List[Dict],
max_tokens: Optional[int] = None,
temperature: Optional[float] = 1.0,
stream: Optional[bool] = False,
tools: Optional[List[Dict]] = None,
tool_choice: Optional[Union[str, Dict]] = None,
) -> Union[Dict, object]:
"""Handle request using ChatGPT Responses API."""
# Get valid API key (with OAuth2 refresh if needed)
api_key = await self._get_valid_api_key()
# Build Responses API request
request_payload = self._build_responses_request(
model=model,
messages=messages,
max_tokens=max_tokens,
temperature=temperature,
tools=tools,
tool_choice=tool_choice
)
# Generate conversation ID for tracking
conversation_id = str(uuid.uuid4())
# Build headers
headers = self._build_headers(api_key, conversation_id)
# Make request to Responses API
url = f"{self.base_url}/v1/responses"
logger.info(f"CodexProviderHandler: Sending request to {url}")
async with httpx.AsyncClient(timeout=300.0) as client:
response = await client.post(
url,
headers=headers,
json=request_payload,
)
response.raise_for_status()
# Parse SSE stream
events = []
async for event in self._parse_sse_stream(response):
events.append(event)
# For streaming, we would yield events here
# For now, accumulate all events
# Convert to OpenAI format
openai_response = self._convert_sse_to_openai_format(events, model)
return openai_response
# =========================================================================
# Main Request Handler (Routes to appropriate mode)
# =========================================================================
async def handle_request(
self,
model: str,
......@@ -145,8 +550,7 @@ class CodexProviderHandler(BaseProviderHandler):
raise Exception("Provider rate limited")
try:
logger = logging.getLogger(__name__)
logger.info(f"CodexProviderHandler: Handling request for model {model}")
logger.info(f"CodexProviderHandler: Handling request for model {model} (mode: {'API Key' if self._use_api_key_mode else 'OAuth2'})")
if AISBF_DEBUG:
logger.info(f"CodexProviderHandler: Messages: {messages}")
else:
......@@ -154,159 +558,139 @@ class CodexProviderHandler(BaseProviderHandler):
# Apply rate limiting
await self.apply_rate_limit()
# Get valid API key (with OAuth2 refresh if needed)
api_key = await self._get_valid_api_key()
# Re-initialize client with fresh token if OAuth2 is enabled
if self._oauth2_enabled:
provider_config = config.providers.get(self.provider_id)
endpoint = provider_config.endpoint if provider_config else "https://api.openai.com/v1"
self.client = OpenAI(base_url=endpoint, api_key=api_key)
# Check if native caching is enabled for this provider
provider_config = config.providers.get(self.provider_id)
enable_native_caching = getattr(provider_config, 'enable_native_caching', False)
min_cacheable_tokens = getattr(provider_config, 'min_cacheable_tokens', 1024)
prompt_cache_key = getattr(provider_config, 'prompt_cache_key', None)
# Build request parameters
request_params = {
"model": model,
"messages": [],
"temperature": temperature,
"stream": stream
}
# Only add max_tokens if it's not None
if max_tokens is not None:
request_params["max_tokens"] = max_tokens
# Add prompt_cache_key if provided
if enable_native_caching and prompt_cache_key:
request_params["prompt_cache_key"] = prompt_cache_key
# Build messages with all fields
if enable_native_caching:
cumulative_tokens = 0
for i, msg in enumerate(messages):
message_tokens = count_messages_tokens([msg], model)
cumulative_tokens += message_tokens
message = {"role": msg["role"]}
if msg["role"] == "tool":
if "tool_call_id" in msg and msg["tool_call_id"] is not None:
message["tool_call_id"] = msg["tool_call_id"]
else:
logger.warning(f"Skipping tool message without tool_call_id: {msg}")
continue
if "content" in msg and msg["content"] is not None:
message["content"] = msg["content"]
if "tool_calls" in msg and msg["tool_calls"] is not None:
message["tool_calls"] = msg["tool_calls"]
if "name" in msg and msg["name"] is not None:
message["name"] = msg["name"]
if (msg["role"] == "system" or
(i < len(messages) - 2 and cumulative_tokens >= min_cacheable_tokens)):
message["cache_control"] = {"type": "ephemeral"}
request_params["messages"].append(message)
# Route to appropriate handler based on mode
if self._use_api_key_mode:
response = await self._handle_request_api_key_mode(
model=model,
messages=messages,
max_tokens=max_tokens,
temperature=temperature,
stream=stream,
tools=tools,
tool_choice=tool_choice
)
else:
for msg in messages:
message = {"role": msg["role"]}
if msg["role"] == "tool":
if "tool_call_id" in msg and msg["tool_call_id"] is not None:
message["tool_call_id"] = msg["tool_call_id"]
else:
logger.warning(f"Skipping tool message without tool_call_id: {msg}")
continue
if "content" in msg and msg["content"] is not None:
message["content"] = msg["content"]
if "tool_calls" in msg and msg["tool_calls"] is not None:
message["tool_calls"] = msg["tool_calls"]
if "name" in msg and msg["name"] is not None:
message["name"] = msg["name"]
request_params["messages"].append(message)
if tools is not None:
request_params["tools"] = tools
if tool_choice is not None:
request_params["tool_choice"] = tool_choice
response = self.client.chat.completions.create(**request_params)
response = await self._handle_request_oauth2_mode(
model=model,
messages=messages,
max_tokens=max_tokens,
temperature=temperature,
stream=stream,
tools=tools,
tool_choice=tool_choice
)
logger.info(f"CodexProviderHandler: Response received")
self.record_success()
if AISBF_DEBUG:
logger.info(f"=== RAW CODEX RESPONSE ===")
logger.info(f"Raw response type: {type(response)}")
logger.info(f"Raw response: {response}")
logger.info(f"=== END RAW CODEX RESPONSE ===")
return response
except Exception as e:
logger = logging.getLogger(__name__)
logger.error(f"CodexProviderHandler: Error: {str(e)}", exc_info=True)
self.record_failure()
raise e
async def get_models(self) -> List[Model]:
try:
logger = logging.getLogger(__name__)
logger.info("CodexProviderHandler: Getting models list")
# Apply rate limiting
await self.apply_rate_limit()
# Get valid API key for models list
api_key = await self._get_valid_api_key()
provider_config = config.providers.get(self.provider_id)
endpoint = provider_config.endpoint if provider_config else "https://api.openai.com/v1"
# Create temporary client with fresh token
temp_client = OpenAI(base_url=endpoint, api_key=api_key)
# Route to appropriate endpoint based on mode
if self._use_api_key_mode:
# API Key Mode: Use standard OpenAI models endpoint
models_url = f"{self.base_url}/models"
headers = {
"Authorization": f"Bearer {self.client.api_key}",
"Content-Type": "application/json",
"User-Agent": "codex-cli/1.0.0",
}
else:
# OAuth2 Mode: Use ChatGPT backend models endpoint
# https://chatgpt.com/backend-api/codex/models
api_key = await self._get_valid_api_key()
models_url = "https://chatgpt.com/backend-api/codex/models"
headers = self._build_headers(api_key)
headers["Accept"] = "application/json" # Not SSE for models
models = temp_client.models.list()
logger.info(f"CodexProviderHandler: Models received")
result = []
for model in models:
context_size = None
if hasattr(model, 'context_window') and model.context_window:
context_size = model.context_window
elif hasattr(model, 'context_length') and model.context_length:
context_size = model.context_length
elif hasattr(model, 'max_context_length') and model.max_context_length:
context_size = model.max_context_length
logger.info(f"CodexProviderHandler: Using models endpoint: {models_url}")
async with httpx.AsyncClient() as client:
params = {"client_version": "1.0.0"} if not self._use_api_key_mode else {}
response = await client.get(
models_url,
headers=headers,
params=params,
timeout=30.0
)
logger.info(f"CodexProviderHandler: Response status: {response.status_code}")
pricing = None
if hasattr(model, 'pricing') and model.pricing:
pricing = model.pricing
elif hasattr(model, 'top_provider') and model.top_provider:
top_provider = model.top_provider
if hasattr(top_provider, 'dict'):
top_provider = top_provider.dict()
if isinstance(top_provider, dict):
tp_pricing = top_provider.get('pricing')
if tp_pricing:
pricing = tp_pricing
if response.status_code == 403:
logger.error(f"CodexProviderHandler: 403 Unauthorized - Full response: {response.text}")
result.append(Model(
id=model.id,
name=model.id,
provider_id=self.provider_id,
context_size=context_size,
context_length=context_size,
pricing=pricing
))
response.raise_for_status()
models_data = response.json()
logger.info(f"CodexProviderHandler: Models data received")
# Parse response based on mode
result = []
if self._use_api_key_mode:
# Standard OpenAI format: {"data": [{"id": "...", ...}], "object": "list"}
if isinstance(models_data, dict) and 'data' in models_data:
for model_info in models_data['data']:
model_id = model_info.get('id')
if model_id:
result.append(Model(
id=model_id,
name=model_info.get('name', model_id),
provider_id=self.provider_id,
context_size=model_info.get('context_window') or model_info.get('context_length'),
context_length=model_info.get('context_length') or model_info.get('context_window'),
pricing=model_info.get('pricing')
))
else:
# Codex format: {"models": [{"slug": "...", "display_name": "...", ...}]}
if isinstance(models_data, dict) and 'models' in models_data:
for model_info in models_data['models']:
model_id = model_info.get('slug') or model_info.get('id')
if model_id:
result.append(Model(
id=model_id,
name=model_info.get('display_name', model_id),
provider_id=self.provider_id,
context_size=model_info.get('context_window'),
context_length=model_info.get('context_window'),
pricing=None
))
logger.info(f"CodexProviderHandler: Parsed {len(result)} models")
return result
except Exception as e:
logger = logging.getLogger(__name__)
logger.error(f"CodexProviderHandler: Error getting models: {str(e)}", exc_info=True)
raise e
error_msg = str(e)
logger.error(f"CodexProviderHandler: Full error type: {type(e).__name__}")
logger.error(f"CodexProviderHandler: Full error message: {error_msg}")
# Return default known Codex models as fallback
logger.warning(f"CodexProviderHandler: Returning default Codex models")
default_models = [
Model(id="gpt-4o", name="GPT-4o", provider_id=self.provider_id, context_size=128000, context_length=128000),
Model(id="gpt-4o-mini", name="GPT-4o Mini", provider_id=self.provider_id, context_size=128000, context_length=128000),
Model(id="gpt-4-turbo", name="GPT-4 Turbo", provider_id=self.provider_id, context_size=128000, context_length=128000),
Model(id="gpt-4", name="GPT-4", provider_id=self.provider_id, context_size=8192, context_length=8192),
Model(id="o1-preview", name="O1 Preview", provider_id=self.provider_id, context_size=128000, context_length=128000),
]
logger.info(f"CodexProviderHandler: Returned {len(default_models)} default models as fallback")
return default_models
......@@ -729,7 +729,14 @@ class GoogleProviderHandler(BaseProviderHandler):
await self.apply_rate_limit()
models = self.client.models.list()
logging.info(f"GoogleProviderHandler: Models received: {models}")
if AISBF_DEBUG:
response_str = str(models)
if len(response_str) > 1024:
response_str = response_str[:1024] + f" ... [TRUNCATED, total length: {len(response_str)} chars]"
logging.info(f"GoogleProviderHandler: Models received: {response_str}")
else:
model_count = len(models) if isinstance(models, (list, dict)) else 'N/A'
logging.info(f"GoogleProviderHandler: Models received: {model_count} models")
result = []
for model in models:
......
......@@ -22,6 +22,7 @@ Why did the programmer quit his job? Because he didn't get arrays!
"""
import httpx
import time
import os
from typing import Dict, List, Optional, Union
from openai import OpenAI
from ..models import Model
......@@ -42,23 +43,45 @@ class KiloProviderHandler(BaseProviderHandler):
self.user_id = user_id
self.provider_config = config.get_provider(provider_id)
kilo_config = getattr(self.provider_config, 'kilo_config', None)
# Unified auth config with backward compatibility
kilo_config = getattr(self.provider_config, 'auth_config', None)
if not kilo_config:
kilo_config = getattr(self.provider_config, 'kilo_config', None)
if not kilo_config:
kilo_config = getattr(self.provider_config, 'kiro_config', None)
self._credentials_file = None
self._api_base = None
self._use_api_key_auth = False
if kilo_config and isinstance(kilo_config, dict):
self._credentials_file = kilo_config.get('credentials_file')
self._api_base = kilo_config.get('api_base')
# Only the ONE config admin (user_id=None from aisbf.json) uses file-based credentials
# All other users (including database admins with user_id) use database credentials
if user_id is not None:
self.oauth2 = self._load_oauth2_from_db(provider_id, self._credentials_file, self._api_base)
# If explicit API key is provided OR provider config has API key configured, use direct API key authentication - NO OAUTH
configured_api_key = getattr(self.provider_config, 'api_key', None)
if (self.api_key and self.api_key != "placeholder") or (configured_api_key and configured_api_key != "placeholder"):
self._use_api_key_auth = True
self.oauth2 = None
# Use the configured provider api key if not explicitly passed
if not self.api_key or self.api_key == "placeholder":
self.api_key = configured_api_key
else:
# Config admin (from aisbf.json): use file-based credentials
from ..auth.kilo import KiloOAuth2
self.oauth2 = KiloOAuth2(credentials_file=self._credentials_file, api_base=self._api_base)
# No API key provided - use OAuth2 flow
if kilo_config and isinstance(kilo_config, dict):
credentials_path = kilo_config.get('credentials_file')
if credentials_path:
self._credentials_file = os.path.expanduser(credentials_path)
self._api_base = kilo_config.get('api_base')
else:
# Set default credentials file path when not explicitly configured
self._credentials_file = os.path.expanduser("~/.kilo_credentials.json")
self._api_base = None
# Only the ONE config admin (user_id=None from aisbf.json) uses file-based credentials
# All other users (including database admins with user_id) use database credentials
if user_id is not None:
self.oauth2 = self._load_oauth2_from_db(provider_id, self._credentials_file, self._api_base)
else:
# Config admin (from aisbf.json): use file-based credentials
from ..auth.kilo import KiloOAuth2
self.oauth2 = KiloOAuth2(credentials_file=self._credentials_file, api_base=self._api_base)
configured_endpoint = getattr(self.provider_config, 'endpoint', None)
if configured_endpoint:
......@@ -130,80 +153,57 @@ class KiloProviderHandler(BaseProviderHandler):
import logging
logging.getLogger(__name__).warning(f"KiloProviderHandler: Failed to save credentials to database: {e}")
async def _ensure_authenticated(self) -> str:
async def _ensure_authenticated(self):
"""Ensure user is authenticated and return valid token.
If the token is expired, this will attempt to re-authenticate using
the device flow. The device code is automatically renewed when it
expires, allowing the flow to continue until the user completes
authorization or explicitly cancels.
Returns immediately with status, never blocks polling in HTTP request.
For device flow: only initiates flow, does NOT poll inside handler.
"""
import logging
import asyncio
logger = logging.getLogger(__name__)
# If API key authentication is configured, use it directly - NO OAUTH EVER
if self._use_api_key_auth:
logger.info("KiloProviderHandler: Using configured API key authentication - skipping OAuth2 flow")
return {
"status": "authenticated",
"token": self.api_key
}
token = self.oauth2.get_valid_token()
if token:
logger.info("KiloProviderHandler: Using existing OAuth2 token")
return token
return {
"status": "authenticated",
"token": token
}
# Try to reload credentials one more time - this handles the case where credentials
# were saved by another process/handler instance after this handler was created
self.oauth2._load_credentials()
token = self.oauth2.get_valid_token()
if self.api_key and self.api_key != "placeholder":
logger.info("KiloProviderHandler: Using API key authentication")
return self.api_key
if token:
logger.info("KiloProviderHandler: Found OAuth2 token after reloading credentials")
return {
"status": "authenticated",
"token": token
}
logger.info("KiloProviderHandler: No valid OAuth2 token, initiating device flow")
# Start the non-blocking device flow
# Start the non-blocking device flow - ONLY initiate, DO NOT poll
flow_info = await self.oauth2.initiate_device_flow()
# Poll for completion with auto-renewal of device code
# The device code expires in ~10 minutes, but we auto-renew it
# so the user has up to 1 hour to complete authorization
poll_interval = flow_info.get("poll_interval", 3.0)
max_duration_seconds = 3600 # 1 hour max
max_attempts = int(max_duration_seconds / poll_interval)
attempts = 0
logger.info(f"KiloProviderHandler: Waiting for device authorization...")
logger.info(f"KiloProviderHandler: Please visit {flow_info['verification_url']} and enter code: {flow_info['code']}")
logger.info(f"KiloProviderHandler: Device code will auto-renew when expired")
while attempts < max_attempts:
attempts += 1
await asyncio.sleep(poll_interval)
result = await self.oauth2.poll_device_flow_completion()
status = result.get("status")
if status == "approved":
token = result.get("token")
logger.info(f"KiloProviderHandler: OAuth2 authentication successful")
# For database users, also save credentials to the database
# This ensures the next request (which creates a new handler instance)
# can load the credentials from the database
if self.user_id is not None and self.oauth2.credentials:
self._save_oauth2_to_db(self.oauth2.credentials)
return token
elif status == "denied":
raise Exception(f"OAuth2 authentication denied: {result.get('error', 'Authorization denied')}")
elif status == "error":
raise Exception(f"OAuth2 authentication error: {result.get('error', 'Unknown error')}")
# status == "pending" - check if code was renewed
if result.get("code_renewed"):
new_code = result.get("new_code", "unknown")
logger.info(f"KiloProviderHandler: Device code renewed - new code: {new_code}")
# Log progress every 20 attempts (~1 minute)
if attempts % 20 == 0:
logger.debug(f"KiloProviderHandler: Still waiting for authorization... ({attempts} attempts)")
raise Exception("OAuth2 authentication timeout: User did not complete authorization within 1 hour")
# Return immediately with pending status - NEVER block on poll in HTTP handler
return {
"status": "pending_authorization",
"verification_url": flow_info["verification_url"],
"code": flow_info["code"],
"expires_in": flow_info["expires_in"],
"poll_interval": flow_info["poll_interval"]
}
async def handle_request(self, model: str, messages: List[Dict], max_tokens: Optional[int] = None,
temperature: Optional[float] = 1.0, stream: Optional[bool] = False,
......@@ -222,7 +222,13 @@ class KiloProviderHandler(BaseProviderHandler):
logging.info(f"KiloProviderHandler: Messages count: {len(messages)}")
logging.info(f"KiloProviderHandler: Tools count: {len(tools) if tools else 0}")
token = await self._ensure_authenticated()
auth_result = await self._ensure_authenticated()
if auth_result["status"] == "pending_authorization":
# Return authorization required status instead of proceeding
raise Exception(f"AUTHORIZATION_REQUIRED:{json.dumps(auth_result)}")
token = auth_result["token"]
self.client.api_key = token
......@@ -376,13 +382,20 @@ class KiloProviderHandler(BaseProviderHandler):
await response.aclose()
await streaming_client.aclose()
async def get_models(self) -> List[Model]:
async def get_models(self):
try:
import logging
import json
logging.info("KiloProviderHandler: Getting models list")
token = await self._ensure_authenticated()
auth_result = await self._ensure_authenticated()
if auth_result["status"] == "pending_authorization":
# Return authorization required status instead of models list
logging.info("KiloProviderHandler: Returning pending authorization status for models request")
return auth_result
token = auth_result["token"]
await self.apply_rate_limit()
......@@ -413,7 +426,14 @@ class KiloProviderHandler(BaseProviderHandler):
response.raise_for_status()
models_data = response.json()
logging.info(f"KiloProviderHandler: Models received: {models_data}")
if AISBF_DEBUG:
response_str = str(models_data)
if len(response_str) > 1024:
response_str = response_str[:1024] + f" ... [TRUNCATED, total length: {len(response_str)} chars]"
logging.info(f"KiloProviderHandler: Models received: {response_str}")
else:
model_count = len(models_data) if isinstance(models_data, (list, dict)) else 'N/A'
logging.info(f"KiloProviderHandler: Models received: {model_count} models")
models_list = models_data.get('data', []) if isinstance(models_data, dict) else models_data
......
......@@ -502,7 +502,10 @@ class KiroProviderHandler(BaseProviderHandler):
logging.info(f"KiroProviderHandler: ✓ Nexlab API call successful!")
if AISBF_DEBUG:
logging.info(f"KiroProviderHandler: Nexlab response: {nexlab_data}")
response_str = str(nexlab_data)
if len(response_str) > 1024:
response_str = response_str[:1024] + f" ... [TRUNCATED, total length: {len(response_str)} chars]"
logging.info(f"KiroProviderHandler: Nexlab response: {response_str}")
models_list = nexlab_data if isinstance(nexlab_data, list) else nexlab_data.get('data', nexlab_data.get('models', []))
......@@ -671,7 +674,10 @@ class KiroProviderHandler(BaseProviderHandler):
response_data = response.json()
if AISBF_DEBUG:
logging.info(f"KiroProviderHandler: Response data: {json.dumps(response_data, indent=2)}")
response_str = json.dumps(response_data, indent=2)
if len(response_str) > 1024:
response_str = response_str[:1024] + f"\n... [TRUNCATED, total length: {len(response_str)} chars]"
logging.info(f"KiroProviderHandler: Response data: {response_str}")
models_list = response_data.get('models', [])
......
......@@ -68,7 +68,14 @@ class OllamaProviderHandler(BaseProviderHandler):
try:
health_response = await self.client.get("/api/tags", timeout=10.0)
logger.info(f"Ollama health check passed: {health_response.status_code}")
logger.info(f"Available models: {health_response.json().get('models', [])}")
models = health_response.json().get('models', [])
if AISBF_DEBUG:
response_str = str(models)
if len(response_str) > 1024:
response_str = response_str[:1024] + f" ... [TRUNCATED, total length: {len(response_str)} chars]"
logger.info(f"Available models: {response_str}")
else:
logger.info(f"Available models: {len(models)} models")
except Exception as e:
logger.error(f"Ollama health check failed: {str(e)}")
logger.error(f"Cannot connect to Ollama at {self.client.base_url}")
......
......@@ -174,7 +174,14 @@ class OpenAIProviderHandler(BaseProviderHandler):
await self.apply_rate_limit()
models = self.client.models.list()
logging.info(f"OpenAIProviderHandler: Models received: {models}")
if AISBF_DEBUG:
response_str = str(models)
if len(response_str) > 1024:
response_str = response_str[:1024] + f" ... [TRUNCATED, total length: {len(response_str)} chars]"
logging.info(f"OpenAIProviderHandler: Models received: {response_str}")
else:
model_count = len(models.data) if hasattr(models, 'data') else len(models) if isinstance(models, (list, dict)) else 'N/A'
logging.info(f"OpenAIProviderHandler: Models received: {model_count} models")
result = []
for model in models:
......
......@@ -104,56 +104,101 @@ class QwenProviderHandler(BaseProviderHandler):
return QwenOAuth2(credentials_file=credentials_file)
def _get_sdk_client(self):
"""Get or create an OpenAI SDK client configured with OAuth2 auth token."""
"""Get or create an OpenAI SDK client configured with authentication (OAuth2 or API key)."""
import logging
logger = logging.getLogger(__name__)
access_token = self.auth.get_valid_token()
if not access_token:
logger.error("QwenProviderHandler: No OAuth2 access token available")
raise Exception("No OAuth2 access token. Please re-authenticate")
# Get resource URL (API endpoint)
base_url = self.auth.get_resource_url()
# Check if API key is configured (vs OAuth2)
qwen_config = getattr(self.provider_config, 'qwen_config', None)
api_key = None
if qwen_config and isinstance(qwen_config, dict):
api_key = qwen_config.get('api_key')
if api_key:
# Use API key authentication
logger.info("QwenProviderHandler: Using API key authentication")
auth_key = api_key
# Use region-based endpoint for API key authentication
base_url = self._get_region_endpoint(qwen_config)
else:
# Use OAuth2 authentication
access_token = self.auth.get_valid_token()
if not access_token:
logger.error("QwenProviderHandler: No OAuth2 access token available")
raise Exception("No OAuth2 access token. Please re-authenticate")
logger.info("QwenProviderHandler: Using OAuth2 authentication")
auth_key = access_token
# Use provider configured endpoint for OAuth2 (fixed endpoints)
base_url = self.provider_config.endpoint
# Normalize endpoint
if not base_url.startswith("http"):
base_url = f"https://{base_url}"
if not base_url.endswith("/v1"):
base_url = f"{base_url}/v1"
# DashScope endpoint already includes /v1 so do not append again
self._sdk_client = AsyncOpenAI(
api_key=access_token,
api_key=auth_key,
base_url=base_url,
max_retries=3,
timeout=httpx.Timeout(300.0, connect=30.0),
)
logger.info(f"QwenProviderHandler: Created SDK client with OAuth2 auth token (endpoint: {base_url})")
logger.info(f"QwenProviderHandler: Created SDK client (endpoint: {base_url})")
return self._sdk_client
def _get_region_endpoint(self, qwen_config: Dict) -> str:
"""Get the appropriate endpoint URL based on the configured region."""
region = qwen_config.get('region', 'china-beijing') # Default to China (Beijing)
region_endpoints = {
'singapore': 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
'us-virginia': 'https://dashscope-us.aliyuncs.com/compatible-mode/v1',
'china-beijing': 'https://dashscope.aliyuncs.com/compatible-mode/v1',
'china-hongkong': 'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1',
'germany-frankfurt': f"https://{qwen_config.get('workspace_id', 'Default Workspace')}.eu-central-1.maas.aliyuncs.com/compatible-mode/v1"
}
endpoint = region_endpoints.get(region, region_endpoints['china-beijing'])
return endpoint
def _get_auth_headers(self) -> Dict[str, str]:
"""Get HTTP headers with OAuth2 Bearer token and DashScope-specific headers."""
"""Get HTTP headers with authentication (OAuth2 or API key) and DashScope-specific headers."""
import logging
logger = logging.getLogger(__name__)
access_token = self.auth.get_valid_token()
if not access_token:
logger.error("QwenProviderHandler: No OAuth2 access token available")
raise Exception("No OAuth2 access token. Please re-authenticate")
# Check if API key is configured (vs OAuth2)
qwen_config = getattr(self.provider_config, 'qwen_config', None)
api_key = None
if qwen_config and isinstance(qwen_config, dict):
api_key = qwen_config.get('api_key')
if api_key:
# Use API key authentication
auth_value = f"Bearer {api_key}"
auth_type = "api-key"
else:
# Use OAuth2 authentication
access_token = self.auth.get_valid_token()
if not access_token:
logger.error("QwenProviderHandler: No OAuth2 access token available")
raise Exception("No OAuth2 access token. Please re-authenticate")
auth_value = f"Bearer {access_token}"
auth_type = "qwen-oauth"
headers = {
"Authorization": f"Bearer {access_token}",
"Authorization": auth_value,
"Content-Type": "application/json",
"User-Agent": "QwenCode/1.0.0 (linux; x86_64)",
"X-DashScope-CacheControl": "enable",
"X-DashScope-UserAgent": "QwenCode/1.0.0 (linux; x86_64)",
"X-DashScope-AuthType": "qwen-oauth",
"X-DashScope-AuthType": auth_type,
}
logger.debug("QwenProviderHandler: Created auth headers with OAuth2 token")
logger.debug(f"QwenProviderHandler: Created auth headers with {auth_type} authentication")
return headers
async def handle_request(self, model: str, messages: List[Dict], max_tokens: Optional[int] = None,
......@@ -385,29 +430,64 @@ class QwenProviderHandler(BaseProviderHandler):
"""Return list of available Qwen models."""
import logging
logger = logging.getLogger(__name__)
logger.info("QwenProviderHandler: Fetching available models")
await self.apply_rate_limit()
# Check if API token is configured (vs OAuth2)
qwen_config = getattr(self.provider_config, 'qwen_config', None)
using_api_key = qwen_config and isinstance(qwen_config, dict) and qwen_config.get('api_key')
if not using_api_key:
# OAuth2 authentication: return fixed model list
logger.info("QwenProviderHandler: Using OAuth2 authentication, returning fixed model list")
return [
Model(
id="coder-model",
name="Coder Model",
provider_id=self.provider_id,
context_size=1000000,
context_length=1000000,
)
]
# API token authentication: fetch from models endpoint
logger.info("QwenProviderHandler: Using API token authentication, fetching from models endpoint")
# Check if models are already defined in provider configuration
if self.provider_config.models and len(self.provider_config.models) > 0:
# Models are defined in configuration, use those instead of fetching
logger.info("QwenProviderHandler: Models defined in configuration, using configured models")
models = []
for model_config in self.provider_config.models:
models.append(Model(
id=model_config.get('name', ''),
name=model_config.get('name', ''),
provider_id=self.provider_id,
context_size=model_config.get('context_size', 32000),
context_length=model_config.get('context_size', 32000),
))
return models
try:
# Get SDK client with current OAuth token
# Get SDK client with API key authentication
client = self._get_sdk_client()
# List models using OpenAI SDK
models_response = await client.models.list()
models = []
for model_data in models_response.data:
model_id = model_data.id
# Extract context size if available
context_size = None
if hasattr(model_data, 'context_window'):
context_size = model_data.context_window
elif hasattr(model_data, 'max_model_len'):
context_size = model_data.max_model_len
models.append(Model(
id=model_id,
name=model_id,
......@@ -415,9 +495,9 @@ class QwenProviderHandler(BaseProviderHandler):
context_size=context_size,
context_length=context_size,
))
logger.debug(f"QwenProviderHandler: Found model: {model_id}")
if not models:
# Fallback to static model list
logger.warning("QwenProviderHandler: No models returned from API, using static list")
......@@ -427,13 +507,13 @@ class QwenProviderHandler(BaseProviderHandler):
Model(id="qwen-max", name="Qwen Max", provider_id=self.provider_id, context_size=8000),
Model(id="coder-model", name="Qwen Coder", provider_id=self.provider_id, context_size=32000),
]
logger.info(f"QwenProviderHandler: Returning {len(models)} models")
return models
except Exception as e:
logger.error(f"QwenProviderHandler: Failed to fetch models: {e}", exc_info=True)
# Return static fallback list
logger.info("QwenProviderHandler: Using static fallback model list")
return [
......
# ChatGPT API Implementation Guide for Codex-CLI
This document provides a comprehensive analysis of how codex-cli communicates with ChatGPT API endpoints, including exact endpoints, headers, authentication, request schemas, and implementation details.
## Table of Contents
1. [Overview](#overview)
2. [API Endpoints](#api-endpoints)
3. [Authentication](#authentication)
4. [Request Headers](#request-headers)
5. [Request/Response Schemas](#requestresponse-schemas)
6. [Model List Retrieval](#model-list-retrieval)
7. [Streaming Responses](#streaming-responses)
8. [WebSocket Support](#websocket-support)
9. [Message Conversion Between OpenAI and Codex Formats](#message-conversion-between-openai-and-codex-formats)
10. [Python Implementation Examples](#python-implementation-examples)
11. [Implementation Examples (Rust)](#implementation-examples-rust)
12. [Developer Role Messages](#developer-role-messages)
13. [Session Flow](#session-flow)
---
## Overview
Codex-CLI uses OpenAI's **Responses API** (not the Chat Completions API) to communicate with ChatGPT. The primary endpoints are:
- **Base URL (ChatGPT mode)**: `https://chatgpt.com/backend-api/codex`
- **Base URL (API Key mode)**: `https://api.openai.com/v1`
The client supports both HTTP/SSE and WebSocket transports for streaming responses.
---
## API Endpoints
### Primary Endpoints
#### 1. Responses Endpoint (Streaming)
- **Path**: `/v1/responses` (or `/responses` relative to base)
- **Method**: `POST`
- **Purpose**: Stream AI responses for a given prompt
- **Transport**: HTTP with Server-Sent Events (SSE) or WebSocket
#### 2. Models Endpoint
- **Path**: `/v1/models` (or `/models` relative to base)
- **Method**: `GET`
- **Purpose**: Retrieve available models and their capabilities
- **Query Parameters**: `client_version=<version>` (e.g., `0.99.0`)
#### 3. Compact Endpoint
- **Path**: `/v1/responses/compact` (or `/responses/compact` relative to base)
- **Method**: `POST`
- **Purpose**: Compact conversation history
#### 4. Memory Summarization Endpoint
- **Path**: `/v1/memories/trace_summarize` (or `/memories/trace_summarize` relative to base)
- **Method**: `POST`
- **Purpose**: Summarize memory traces
### ChatGPT-Specific Backend Endpoints (OAuth Mode)
When using ChatGPT authentication, additional endpoints are available:
#### 5. Config Requirements
- **Path**: `/backend-api/wham/config/requirements`
- **Method**: `GET`
- **Purpose**: Retrieve cloud configuration requirements
#### 6. Rate Limits
- **Path**: `/backend-api/api/codex/usage`
- **Method**: `GET`
- **Purpose**: Get account rate limits
#### 7. Plugin/App Management
- **Path**: `/backend-api/plugins/list`
- **Method**: `GET`
- **Purpose**: List installed plugins
- **Path**: `/backend-api/plugins/featured`
- **Method**: `GET`
- **Query Parameters**: `platform=codex`
- **Purpose**: Get featured plugins
- **Path**: `/backend-api/plugins/{plugin_id}/enable`
- **Method**: `POST`
- **Purpose**: Enable a plugin
- **Path**: `/backend-api/plugins/{plugin_id}/uninstall`
- **Method**: `POST`
- **Purpose**: Uninstall a plugin
#### 8. MCP Apps
- **Path**: `/backend-api/wham/apps`
- **Method**: WebSocket connection
- **Purpose**: MCP (Model Context Protocol) server communication
**Important Note**: When using ChatGPT OAuth authentication, the base instructions field in the request is **required** and should reference "Codex" specifically. The example from the blog post shows:
```json
{
"instructions": "You are Codex, based on GPT-5. You are running as a coding agent ..."
}
```
This appears to be a requirement for the ChatGPT backend API to accept requests properly.
---
## Authentication
### Two Authentication Modes
#### 1. API Key Mode
- **Header**: `Authorization: Bearer <api_key>`
- **Source**: Environment variable (typically `OPENAI_API_KEY`)
- **Base URL**: `https://api.openai.com/v1`
#### 2. ChatGPT Mode (OAuth2)
- **Header**: `Authorization: Bearer <access_token>`
- **Additional Header**: `ChatGPT-Account-ID: <account_id>`
- **Base URL**: `https://chatgpt.com/backend-api/codex`
- **Token Management**: Automatic refresh on 401 responses
### Authentication Implementation
The authentication is handled through the `AuthProvider` trait:
```rust
pub trait AuthProvider: Send + Sync {
fn bearer_token(&self) -> Option<String>;
fn account_id(&self) -> Option<String> {
None
}
}
```
Headers are added via:
```rust
pub(crate) fn add_auth_headers_to_header_map<A: AuthProvider>(auth: &A, headers: &mut HeaderMap) {
if let Some(token) = auth.bearer_token()
&& let Ok(header) = HeaderValue::from_str(&format!("Bearer {token}"))
{
let _ = headers.insert(http::header::AUTHORIZATION, header);
}
if let Some(account_id) = auth.account_id()
&& let Ok(header) = HeaderValue::from_str(&account_id)
{
let _ = headers.insert("ChatGPT-Account-ID", header);
}
}
```
**Location**: `codex-rs/codex-api/src/auth.rs`
---
## Request Headers
### Standard Headers (All Requests)
1. **User-Agent**
- Format: `{originator}/{version} ({os} {os_version}; {arch}) {terminal_type} ({suffix})`
- Example: `codex_cli_rs/0.99.0 (Linux 6.12; x86_64) xterm-256color (vscode; 1.86.0)`
- **Location**: `codex-rs/login/src/auth/default_client.rs:131-155`
2. **originator**
- Value: `codex_cli_rs` (default) or custom via `CODEX_INTERNAL_ORIGINATOR_OVERRIDE`
- Purpose: Identifies the client application
3. **Content-Type**
- Value: `application/json` (for POST requests)
4. **Accept**
- Value: `text/event-stream` (for SSE streaming)
### Responses API Specific Headers
5. **x-client-request-id**
- Value: Thread/conversation ID
- Purpose: Request correlation
6. **session_id**
- Value: Thread/conversation ID
- Purpose: Session tracking
7. **x-codex-turn-state**
- Value: Sticky routing token from previous response
- Purpose: Maintain routing to same backend instance within a turn
8. **x-codex-turn-metadata**
- Value: Optional turn metadata (JSON)
- Purpose: Observability and debugging
9. **x-codex-window-id**
- Format: `{conversation_id}:{window_generation}`
- Purpose: Window/context tracking
10. **x-openai-subagent**
- Values: `review`, `compact`, `memory_consolidation`, `collab_spawn`
- Purpose: Identify subagent requests
11. **x-codex-parent-thread-id**
- Value: Parent thread ID (for spawned threads)
- Purpose: Thread hierarchy tracking
12. **x-codex-beta-features**
- Value: Comma-separated beta feature keys
- Purpose: Enable experimental features
13. **x-responsesapi-include-timing-metrics**
- Value: `true`
- Purpose: Request timing metrics in response
### WebSocket Specific Headers
14. **OpenAI-Beta**
- Value: `responses_websockets=2026-02-06`
- Purpose: Enable WebSocket protocol version
### Developer Role Messages
When using the ChatGPT OAuth API, requests often include a `developer` role message in the input array. This is distinct from the `instructions` field:
- **`instructions` field**: Base system instructions (e.g., "You are Codex, based on GPT-5...")
- **`developer` role message**: Additional contextual instructions injected as a message in the conversation
Example from the blog post:
```json
{
"input": [
{
"type": "message",
"role": "developer",
"content": [
{
"type": "input_text",
"text": "You are a helpful assistant. Respond directly to the user request without running tools or shell commands."
}
]
},
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "Generate an SVG of a pelican riding a bicycle"
}
]
}
]
}
```
The `developer` role is used for:
- Permission instructions (sandbox mode, approval policies)
- Capability instructions (available tools, restrictions)
- Context-specific guidance (collaboration mode, personality specs)
- Model switching notifications
- Realtime conversation boundaries
**Location**: `codex-rs/protocol/src/models.rs:755-767`
### Optional Headers
15. **OpenAI-Organization**
- Source: `OPENAI_ORGANIZATION` environment variable
- Purpose: Organization routing
16. **OpenAI-Project**
- Source: `OPENAI_PROJECT` environment variable
- Purpose: Project routing
17. **version**
- Value: Package version (e.g., `0.99.0`)
- Purpose: Client version tracking
### Request Compression
When using ChatGPT authentication with OpenAI provider:
- **Content-Encoding**: `zstd`
- Body is compressed using Zstandard algorithm
**Location**: `codex-rs/core/src/client.rs:1040-1049`
---
## Request/Response Schemas
### Responses API Request Schema
```json
{
"model": "gpt-4",
"instructions": "You are a helpful assistant...",
"input": [
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "Hello, how are you?"
}
]
}
],
"tools": [],
"tool_choice": "auto",
"parallel_tool_calls": true,
"reasoning": {
"effort": "medium",
"summary": "auto"
},
"store": false,
"stream": true,
"include": ["reasoning.encrypted_content"],
"service_tier": "default",
"prompt_cache_key": "<conversation_id>",
"text": {
"type": "text",
"verbosity": "normal"
}
}
```
**Key Fields**:
- `model`: Model identifier (e.g., `gpt-4`, `o1-preview`)
- `instructions`: System instructions/base prompt
- `input`: Array of conversation items (messages, tool calls, tool results)
- `tools`: Available tools in JSON Schema format
- `reasoning`: Reasoning configuration (effort level, summary mode)
- `store`: Whether to store in Azure (provider-specific)
- `stream`: Always `true` for streaming
- `include`: Additional fields to include in response
- `service_tier`: Priority level (`default`, `priority`, `flex`)
- `prompt_cache_key`: Cache key for prompt caching
- `text`: Text generation parameters (verbosity, output schema)
**Location**: `codex-rs/codex-api/src/endpoint/responses.rs`
### Response Stream Events (SSE)
The server sends Server-Sent Events with the following event types:
1. **response.created**
```json
event: response.created
data: {"response_id": "resp_123", "status": "in_progress"}
```
2. **response.output_item.added**
```json
event: response.output_item.added
data: {"item": {"type": "message", "role": "assistant", "content": []}}
```
3. **response.content_part.added**
```json
event: response.content_part.added
data: {"part": {"type": "text", "text": ""}}
```
4. **response.content_part.delta**
```json
event: response.content_part.delta
data: {"delta": {"text": "Hello"}}
```
5. **response.output_item.done**
```json
event: response.output_item.done
data: {"item": {"type": "message", "role": "assistant", "content": [...]}}
```
6. **response.done**
```json
event: response.done
data: {
"response_id": "resp_123",
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"total_tokens": 150,
"cached_input_tokens": 0,
"reasoning_output_tokens": 0
}
}
```
7. **error**
```json
event: error
data: {"error": {"message": "Rate limit exceeded", "code": "rate_limit_exceeded"}}
```
**Location**: Event parsing in `codex-rs/codex-api/src/sse.rs`
### Models Response Schema
```json
{
"models": [
{
"slug": "gpt-4",
"display_name": "GPT-4",
"description": "Most capable model",
"default_reasoning_level": "medium",
"supported_reasoning_levels": [
{"effort": "low", "description": "Fast"},
{"effort": "medium", "description": "Balanced"},
{"effort": "high", "description": "Thorough"}
],
"shell_type": "shell_command",
"visibility": "list",
"minimal_client_version": [0, 99, 0],
"supported_in_api": true,
"priority": 1,
"upgrade": null,
"base_instructions": "You are a helpful assistant",
"supports_reasoning_summaries": true,
"support_verbosity": true,
"default_verbosity": "normal",
"apply_patch_tool_type": "unified_diff",
"truncation_policy": {"mode": "bytes", "limit": 100000},
"supports_parallel_tool_calls": true,
"supports_image_detail_original": true,
"context_window": 128000,
"experimental_supported_tools": []
}
]
}
```
**Location**: `codex-rs/codex-api/src/endpoint/models.rs`
---
## Model List Retrieval
### Request Details
**Endpoint**: `GET /v1/models?client_version=<version>`
**Headers**:
- `Authorization: Bearer <token>`
- `ChatGPT-Account-ID: <account_id>` (if ChatGPT mode)
- `User-Agent: <codex_user_agent>`
- `originator: <originator>`
**Query Parameters**:
- `client_version`: Client version string (e.g., `0.99.0`)
### Response Handling
The response includes an `ETag` header for caching:
```rust
let header_etag = resp
.headers
.get(ETAG)
.and_then(|value| value.to_str().ok())
.map(ToString::to_string);
```
**Location**: `codex-rs/codex-api/src/endpoint/models.rs:58-62`
### Model Selection
Models are filtered based on:
1. `visibility`: Must be `"list"` to appear in UI
2. `minimal_client_version`: Client version must meet minimum
3. `supported_in_api`: Must be `true` for API usage
---
## Streaming Responses
### HTTP/SSE Transport
1. **Connection Setup**
- POST request to `/v1/responses`
- `Accept: text/event-stream` header
- Optional `Content-Encoding: zstd` for compression
2. **Event Stream Processing**
- Parse SSE events line-by-line
- Handle `event:` and `data:` lines
- Reconstruct JSON from multi-line data
- Parse event-specific payloads
3. **Idle Timeout**
- Default: 300 seconds (5 minutes)
- Configurable via `stream_idle_timeout_ms`
- Connection reset if no data received within timeout
4. **Retry Logic**
- Default max retries: 5 attempts
- Exponential backoff: 200ms base delay
- Retry on: 5xx errors, transport errors
- No retry on: 429 (rate limit), 401 (unauthorized)
**Location**: `codex-rs/codex-api/src/sse.rs`
### WebSocket Transport
1. **Connection Handshake**
- Upgrade HTTP connection to WebSocket
- URL: `wss://chatgpt.com/backend-api/codex/responses`
- Headers: Same as HTTP plus `OpenAI-Beta: responses_websockets=2026-02-06`
2. **Request Format**
```json
{
"type": "response.create",
"response": {
"model": "gpt-4",
"instructions": "...",
"input": [...],
"client_metadata": {
"x-codex-window-id": "...",
"x-openai-subagent": "...",
"x-codex-parent-thread-id": "...",
"x-codex-turn-metadata": "..."
}
}
}
```
3. **Incremental Requests**
- Reuse WebSocket connection for multiple requests
- Send only delta items with `previous_response_id`
- Server maintains conversation state
4. **Connection Reuse**
- Connection cached per turn
- Reused across multiple requests in same turn
- Reset on window generation change
5. **Fallback to HTTP**
- On `426 Upgrade Required` response
- On connection timeout (15 seconds default)
- On WebSocket errors
**Location**: `codex-rs/codex-api/src/websocket.rs`
---
## Message Conversion Between OpenAI and Codex Formats
### Overview
Codex uses the OpenAI Responses API format which is more structured than the traditional Chat Completions API. Understanding how to convert between standard OpenAI message formats and Codex's internal format is essential for implementing compatible clients.
### Key Type Definitions
#### Codex Internal Types (`ResponseInputItem` and `ResponseItem`)
Codex uses two main types for messages:
1. **`ResponseInputItem`** - Messages sent TO the API
2. **`ResponseItem`** - Messages received FROM the API
```rust
// From codex-rs/protocol/src/models.rs
// Input items (sent to API)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseInputItem {
Message {
role: String,
content: Vec<ContentItem>,
},
FunctionCallOutput {
call_id: String,
output: FunctionCallOutputPayload,
},
McpToolCallOutput {
call_id: String,
output: CallToolResult,
},
CustomToolCallOutput {
call_id: String,
name: Option<String>,
output: FunctionCallOutputPayload,
},
ToolSearchOutput {
call_id: String,
status: String,
execution: String,
tools: Vec<serde_json::Value>,
},
}
// Output items (received from API)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseItem {
Message { id: Option<String>, role: String, content: Vec<ContentItem>, ... },
Reasoning { id: String, summary: Vec<ReasoningItemReasoningSummary>, ... },
FunctionCall { id: Option<String>, name: String, arguments: String, call_id: String, ... },
CustomToolCall { id: Option<String>, status: Option<String>, call_id: String, name: String, ... },
// ... and more
}
// Content items within messages
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentItem {
InputText { text: String },
InputImage { image_url: String },
OutputText { text: String },
}
```
**Location**: `codex-rs/protocol/src/models.rs:119-159`
### Converting User Input to Codex Format
#### Simple Text Messages
**Standard OpenAI format:**
```json
{
"messages": [
{"role": "user", "content": "Hello, how are you?"}
]
}
```
**Codex format:**
```json
{
"input": [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "Hello, how are you?"}
]
}
]
}
```
**Conversion logic:**
```rust
// From codex-rs/protocol/src/models.rs:1015-1053
impl From<Vec<UserInput>> for ResponseInputItem {
fn from(items: Vec<UserInput>) -> Self {
Self::Message {
role: "user".to_string(),
content: items
.into_iter()
.flat_map(|c| match c {
UserInput::Text { text, .. } => {
vec![ContentItem::InputText { text }]
}
// ... handle images, local images, etc.
})
.collect(),
}
}
}
```
#### Messages with Images
**Standard OpenAI format:**
```json
{
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "What's in this image?"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,..."}
}
]
}
]
}
```
**Codex format (with image tags):**
```json
{
"input": [
{
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "<image>"},
{"type": "input_image", "image_url": "data:image/png;base64,..."},
{"type": "input_text", "text": "</image>What's in this image?"}
]
}
]
}
```
**Image tagging rules:**
- Remote images: wrapped with `<image>` and `</image>` tags
- Local images: wrapped with `<image name=[Image #N]>` and `</image>` tags
- Multiple images share a sequential label counter
**Location**: `codex-rs/protocol/src/models.rs:867-906`
### Converting Tool Calls/Function Calls
#### Tool Call Request (Model → API)
**Codex receives from model:**
```rust
// From codex-rs/protocol/src/models.rs:227-240
ResponseItem::FunctionCall {
name: "shell",
arguments: "{\"command\": [\"ls\"]}",
call_id: "call_123",
namespace: Some("mcp"),
}
```
**Wire format (Responses API):**
```json
{
"type": "function_call",
"name": "shell",
"arguments": "{\"command\": [\"ls\"]}",
"call_id": "call_123"
}
```
#### Tool Result Response (API → Model)
**Codex sends back to API:**
```rust
ResponseInputItem::FunctionCallOutput {
call_id: "call_123".to_string(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text("total 0".to_string()),
success: Some(true),
},
}
```
**Wire format:**
```json
{
"type": "function_call_output",
"call_id": "call_123",
"output": "total 0"
}
```
Or for multimodal tool outputs:
```json
{
"type": "function_call_output",
"call_id": "call_123",
"output": [
{"type": "input_text", "text": "File listing:"},
{"type": "input_image", "image_url": "data:image/png;base64,..."}
]
}
```
**Location**: `codex-rs/protocol/src/models.rs:1180-1288`
### Tool Definition Format
**Codex tool format (JSON Schema):**
```rust
// Tools are passed to the Responses API in OpenAI function format
{
"type": "function",
"name": "shell",
"description": "Execute a shell command",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "array",
"items": {"type": "string"},
"description": "Command as array of strings"
},
"workdir": {
"type": "string",
"description": "Working directory"
}
},
"required": ["command"]
}
}
```
**Location**: `codex-rs/codex-tools/src/lib.rs`
### Streaming Message Handling
#### SSE Events to ResponseItem Conversion
The Responses API streams events that must be assembled into `ResponseItem` objects:
```rust
// From codex-rs/codex-api/src/sse.rs
// Event sequence:
// 1. response.created - Start of response
// 2. response.output_item.added - New item created
// 3. response.content_part.added - Content started
// 4. response.content_part.delta - Incremental content
// 5. response.output_item.done - Item complete
// 6. response.done - All done
// Each event type maps to internal types:
// - response.output_item.added (type=message) -> ResponseItem::Message
// - response.output_item.added (type=function_call) -> ResponseItem::FunctionCall
// - response.output_item.added (type=reasoning) -> ResponseItem::Reasoning
```
**Location**: `codex-rs/codex-api/src/sse.rs`
#### Converting Streaming Deltas to Codex Format
```rust
// Incremental text delta
{
"event": "response.content_part.delta",
"data": {
"delta": {"text": "Hello"}
}
}
// Becomes:
ResponseItem::Message {
content: vec![ContentItem::OutputText { text: "Hello" }],
// ...
}
```
### Complete Conversion Flow
#### Non-Streaming Request Flow
1. **User Input → Codex Input:**
```rust
// User input (text, images, etc.)
let user_input = vec![UserInput::Text { text: "Hello".to_string() }];
// Convert to ResponseInputItem
let input_item: ResponseInputItem = user_input.into();
// Results in: ResponseInputItem::Message { role: "user", content: [...] }
```
2. **Build Request:**
```rust
// From codex-rs/core/src/client.rs:749-815
let request = ResponsesApiRequest {
model: "gpt-4".to_string(),
instructions: base_instructions.text,
input: vec![input_item], // ResponseInputItem array
tools: create_tools_json(tools)?,
// ... other fields
};
```
3. **Response → ResponseItem:**
```rust
// Parse JSON response into ResponseItem
let response_item: ResponseItem = serde_json::from_value(response_json)?;
// Handle Message, FunctionCall, Reasoning, etc.
```
#### Streaming Request Flow
1. **Send Request** (same as non-streaming)
2. **Process SSE Events:**
```rust
// From codex-rs/core/src/client.rs:1496-1576
while let Some(event) = stream.next().await {
match event {
Ok(ResponseEvent::OutputItemDone(item)) => {
// Item is complete, convert to ResponseItem
items_added.push(item);
}
Ok(ResponseEvent::ContentPartDelta { delta }) => {
// Accumulate incremental text
}
Ok(ResponseEvent::Completed { response_id, usage }) => {
// Final response with usage stats
}
}
}
```
3. **Reconstruct Full Message:**
```rust
// Multiple deltas are assembled into complete ResponseItem
// e.g., multiple response.content_part.delta events -> complete message
```
### WebSocket Message Format
For WebSocket transport, messages use a different structure:
```rust
// From codex-rs/codex-api/src/websocket.rs
// Request (WebSocket)
{
"type": "response.create",
"response": {
"model": "gpt-4",
"instructions": "...",
"input": [...],
"client_metadata": {
"x-codex-window-id": "...",
"x-openai-subagent": "..."
}
}
}
// Incremental request (subsequent requests in same turn)
{
"type": "response.create",
"previous_response_id": "resp_123",
"input": [...], // Only new items, not full history
"response": { ... }
}
```
**Location**: `codex-rs/codex-api/src/websocket.rs`
### Summary: Key Conversion Points
| Aspect | OpenAI Standard | Codex Internal |
|--------|----------------|----------------|
| Messages field | `messages` | `input` |
| Message structure | `{role, content}` | `{type: message, role, content[]}` |
| Content structure | `{type, text}` or `{type, image_url}` | `{type: input_text/input_image/output_text}` |
| Tool calls | `tool_calls` array | `ResponseItem::FunctionCall` |
| Tool results | `function_call_output` (string) | `FunctionCallOutputPayload` (string or array) |
| Images | Simple array | Wrapped with `<image>` tags |
| Streaming | SSE with deltas | Assembled into complete `ResponseItem` |
### Important Implementation Notes
1. **Content Array**: Messages always have a `content` array, even for single text
2. **Type Tags**: All items use snake_case type tags (`message`, `function_call`, `input_text`)
3. **Image Handling**: Images require special tagging with `<image>` and `</image>`
4. **Tool Output**: Can be plain text OR array of content items for multimodal
5. **Namespace**: MCP tools include `namespace` field; built-in tools don't
---
## WebSocket Support
### Prewarm/Preconnect
Before sending the first request, the client can establish a WebSocket connection:
```rust
pub async fn preconnect_websocket(
&mut self,
session_telemetry: &SessionTelemetry,
_model_info: &ModelInfo,
) -> std::result::Result<(), ApiError>
```
This reduces latency for the first actual request.
### Warmup Request
A special request with `generate: false` to establish connection without generating output:
```json
{
"type": "response.create",
"response": {
"model": "gpt-4",
"generate": false,
...
}
}
```
The client waits for completion before sending the actual request.
**Location**: `codex-rs/core/src/client.rs:1303-1351`
---
## Implementation Examples
### Example 1: Basic Request (HTTP/SSE)
```rust
use codex_api::{ResponsesClient, ResponsesApiRequest, ResponsesOptions};
use codex_api::requests::responses::Compression;
// Setup
let transport = ReqwestTransport::new(build_reqwest_client());
let provider = Provider {
name: "OpenAI".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
query_params: None,
headers: HeaderMap::new(),
retry: RetryConfig { /* ... */ },
stream_idle_timeout: Duration::from_secs(300),
};
let auth = /* implement AuthProvider */;
let client = ResponsesClient::new(transport, provider, auth);
// Build request
let request = ResponsesApiRequest {
model: "gpt-4".to_string(),
instructions: "You are a helpful assistant".to_string(),
input: vec![/* conversation items */],
tools: vec![],
tool_choice: "auto".to_string(),
parallel_tool_calls: true,
reasoning: None,
store: false,
stream: true,
include: vec![],
service_tier: None,
prompt_cache_key: Some("conversation_123".to_string()),
text: None,
};
let options = ResponsesOptions {
conversation_id: Some("conversation_123".to_string()),
session_source: None,
extra_headers: HeaderMap::new(),
compression: Compression::None,
turn_state: None,
};
// Stream response
let mut stream = client.stream_request(request, options).await?;
while let Some(event) = stream.next().await {
match event {
Ok(ResponseEvent::ContentPartDelta { delta }) => {
print!("{}", delta.text);
}
Ok(ResponseEvent::Completed { response_id, token_usage }) => {
println!("\nCompleted: {}", response_id);
}
Err(e) => {
eprintln!("Error: {}", e);
break;
}
_ => {}
}
}
```
### Example 2: Retrieve Models
```rust
use codex_api::{ModelsClient, Provider};
let transport = ReqwestTransport::new(build_reqwest_client());
let provider = Provider { /* ... */ };
let auth = /* implement AuthProvider */;
let client = ModelsClient::new(transport, provider, auth);
let (models, etag) = client
.list_models("0.99.0", HeaderMap::new())
.await?;
for model in models {
println!("{}: {}", model.slug, model.display_name);
}
```
### Example 3: WebSocket Request
```rust
use codex_api::{ResponsesWebsocketClient, ResponseCreateWsRequest};
let provider = Provider { /* ... */ };
let auth = /* implement AuthProvider */;
let ws_client = ResponsesWebsocketClient::new(provider, auth);
// Connect
let mut connection = ws_client
.connect(headers, default_headers, turn_state, telemetry)
.await?;
// Send request
let request = ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest {
model: "gpt-4".to_string(),
instructions: "You are a helpful assistant".to_string(),
input: vec![/* items */],
client_metadata: HashMap::new(),
/* ... */
});
let mut stream = connection.stream_request(request, false).await?;
// Process events
while let Some(event) = stream.next().await {
// Handle events
}
```
### Example 4: Authentication Headers
```rust
// API Key Mode
struct ApiKeyAuth {
api_key: String,
}
impl AuthProvider for ApiKeyAuth {
fn bearer_token(&self) -> Option<String> {
Some(self.api_key.clone())
}
}
// ChatGPT Mode
struct ChatGptAuth {
access_token: String,
account_id: String,
}
impl AuthProvider for ChatGptAuth {
fn bearer_token(&self) -> Option<String> {
Some(self.access_token.clone())
}
fn account_id(&self) -> Option<String> {
Some(self.account_id.clone())
}
}
```
### Example 5: User-Agent Construction
```rust
pub fn get_codex_user_agent() -> String {
let build_version = env!("CARGO_PKG_VERSION");
let os_info = os_info::get();
let originator = originator();
let prefix = format!(
"{}/{build_version} ({} {}; {}) {}",
originator.value.as_str(),
os_info.os_type(),
os_info.version(),
os_info.architecture().unwrap_or("unknown"),
user_agent() // terminal detection
);
let suffix = USER_AGENT_SUFFIX
.lock()
.ok()
.and_then(|guard| guard.clone());
let suffix = suffix
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map_or_else(String::new, |value| format!(" ({value})"));
format!("{prefix}{suffix}")
}
```
**Result**: `codex_cli_rs/0.99.0 (Linux 6.12; x86_64) xterm-256color (vscode; 1.86.0)`
---
## Key Implementation Details
### 1. Base URL Selection
```rust
pub fn to_api_provider(&self, auth_mode: Option<AuthMode>) -> CodexResult<ApiProvider> {
let default_base_url = if matches!(auth_mode, Some(AuthMode::Chatgpt)) {
"https://chatgpt.com/backend-api/codex"
} else {
"https://api.openai.com/v1"
};
let base_url = self
.base_url
.clone()
.unwrap_or_else(|| default_base_url.to_string());
// ...
}
```
**Location**: `codex-rs/model-provider-info/src/lib.rs:184-193`
### 2. Request Compression
```rust
fn responses_request_compression(&self, auth: Option<&CodexAuth>) -> Compression {
if self.client.state.enable_request_compression
&& auth.is_some_and(CodexAuth::is_chatgpt_auth)
&& self.client.state.provider.is_openai()
{
Compression::Zstd
} else {
Compression::None
}
}
```
**Location**: `codex-rs/core/src/client.rs:1040-1049`
### 3. Retry Logic
```rust
pub struct RetryConfig {
pub max_attempts: u64,
pub base_delay: Duration,
pub retry_429: bool,
pub retry_5xx: bool,
pub retry_transport: bool,
}
```
Default values:
- `max_attempts`: 4 (requests), 5 (streams)
- `base_delay`: 200ms
- `retry_429`: false
- `retry_5xx`: true
- `retry_transport`: true
**Location**: `codex-rs/codex-api/src/provider.rs:16-22`
### 4. Sticky Routing (Turn State)
```rust
/// Turn state for sticky routing.
///
/// This is an `OnceLock` that stores the turn state value received from the server
/// on turn start via the `x-codex-turn-state` response header. Once set, this value
/// should be sent back to the server in the `x-codex-turn-state` request header for
/// all subsequent requests within the same turn to maintain sticky routing.
turn_state: Arc<OnceLock<String>>,
```
**Location**: `codex-rs/core/src/client.rs:209-219`
### 5. Session vs Turn Scope
- **Session-scoped**: `ModelClient` - lives for entire conversation
- **Turn-scoped**: `ModelClientSession` - created per turn, manages WebSocket connection
```rust
pub fn new_session(&self) -> ModelClientSession {
ModelClientSession {
client: self.clone(),
websocket_session: self.take_cached_websocket_session(),
turn_state: Arc::new(OnceLock::new()),
}
}
```
**Location**: `codex-rs/core/src/client.rs:300-306`
---
## Session Flow
### Overview of a Complete Session
A session represents an entire conversation from start to finish. Understanding the flow of requests and how conversation state is maintained is essential for implementing a compatible client.
### Lifecycle of a Session
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ SESSION LIFETIME │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Initialization │
│ ├── Load/validate authentication credentials │
│ ├── Fetch available models from /v1/models │
│ └── Create ModelClient (session-scoped) │
│ │
│ 2. Turn 1: User Input → First Response │
│ ├── Create ModelClientSession (turn-scoped) │
│ ├── Preconnect WebSocket (optional, recommended) │
│ ├── Build request with base instructions │
│ ├── Send user message in input array │
│ ├── Receive streaming response (SSE events) │
│ └── Extract response items, tool calls │
│ │
│ 3. Tool Execution Loop (within Turn N) │
│ ├── API emits FunctionCall item │
│ ├── Client executes tool locally │
│ ├── Send FunctionCallOutput back in next request │
│ └── Continue receiving response (may loop) │
│ │
│ 4. Turn N: Subsequent Requests │
│ ├── Build request with previous_response_id │
│ ├── Include all tool call results since turn start │
│ ├── Optionally compact conversation history │
│ └── Receive updated conversation state │
│ │
│ 5. Session End │
│ ├── Final response received │
│ ├── Close WebSocket connection │
│ └── Session cleanup │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Detailed Turn Flow
#### Turn 1: Initial Request
```rust
// 1. Create session (turn-scoped)
let session = client.new_session();
// 2. Optionally preconnect WebSocket for lower latency
session.preconnect_websocket(telemetry, &model_info).await?;
// 3. Build first request
let request = ResponsesApiRequest {
model: "gpt-4".to_string(),
instructions: base_instructions.text, // Required for ChatGPT mode
input: vec![
// Developer message (optional, for system context)
ResponseInputItem::Message {
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: permissions_instructions
}],
},
// User message
ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: user_input.clone()
}],
},
],
tools: create_tools_json(tools)?,
// ...other fields
};
// 4. Send request and process streaming response
let mut stream = session.stream_request(request, options).await?;
while let Some(event) = stream.next().await {
match event {
Ok(ResponseEvent::OutputItemAdded { item }) => {
// New item created - could be Message, FunctionCall, Reasoning
match item {
ResponseItem::FunctionCall { name, arguments, call_id, .. } => {
// Tool call to execute
tool_calls_to_execute.push((call_id, name, arguments));
}
ResponseItem::Message { content, .. } => {
// Text response
}
_ => {}
}
}
Ok(ResponseEvent::ContentPartDelta { delta }) => {
// Accumulate incremental text
}
Ok(ResponseEvent::Completed { response_id, usage }) => {
// Turn complete
}
Err(e) => { /* Handle error */ }
}
}
```
**Location**: `codex-rs/core/src/client.rs:1303-1576`
#### Subsequent Turns: Tool Execution Loop
```rust
// After receiving a FunctionCall, execute the tool and send results back
// 1. Execute tool (shell command, file operation, etc.)
let tool_result = execute_tool(&tool_name, tool_args).await?;
// 2. Send tool result back to API
let next_request = ResponsesApiRequest {
model: "gpt-4".to_string(),
instructions: base_instructions.text,
input: vec![
// Include original user message
user_message.clone(),
// Include the assistant's function call
ResponseItem::FunctionCall { ... }.into(),
// Send tool result
ResponseInputItem::FunctionCallOutput {
call_id: tool_call.call_id,
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(tool_result),
success: Some(true),
},
},
],
// ...
};
```
**Location**: `codex-rs/protocol/src/models.rs:1180-1288`
### Conversation State Management
#### Maintaining Conversation History
Codex maintains conversation state across requests within a turn. This is handled in two ways:
1. **HTTP/SSE**: Full `input` array sent with each request
2. **WebSocket**: Server maintains state, client sends `previous_response_id`
```rust
// For HTTP: Include full conversation history
let input_items: Vec<ResponseInputItem> = conversation
.items()
.iter()
.flat_map(|item| item.to_input_item())
.collect();
let request = ResponsesApiRequest {
// ...
input: input_items,
// ...
};
// For WebSocket: Only send new items + previous_response_id
let request = ResponseCreateWsRequest {
previous_response_id: last_response_id, // Links to previous
input: new_items_only, // Just the new messages
// ...
};
```
**Location**: `codex-rs/codex-api/src/websocket.rs:89-156`
#### Session vs Turn
Understanding the distinction between session and turn is critical:
| Concept | Scope | Description |
|--------------|---------------------|----------------------------------------------------------|
| **Session** | Entire conversation | From first user message to session end |
| **Turn** | Single exchange | One user message + all subsequent tool calls + response |
| **Window** | UI context | Visible context in the interface |
```rust
// Session lives for the entire conversation
pub struct ModelClient {
// Authentication and transport (permanent)
transport: T,
provider: Provider,
auth: Box<dyn CodexAuth>,
}
// Turn is created fresh for each user message
pub struct ModelClientSession {
client: ModelClient,
websocket_session: Option<WebSocketSession>,
turn_state: Arc<OnceLock<String>>, // Turn-scoped sticky routing
}
```
**Location**: `codex-rs/core/src/client.rs:183-306`
### Compact/Summarize Flow
When conversation history grows too large, Codex can compact it:
```rust
// Compact request (HTTP)
let compact_request = ResponsesApiRequest {
model: model.slug.clone(),
instructions: base_instructions.text,
input: vec![/* conversation items to compact */],
tools: vec![],
// ...
};
// Or via MCP subagent
let subagent_request = ResponsesApiRequestWithMetadata {
// ...
metadata: RequestMetadata {
x_openai_subagent: Some("compact"),
// ...
},
};
```
**Location**: `codex-rs/core/src/client.rs:1610-1650`
### Error Handling and Recovery
#### Retry on Transient Errors
```rust
async fn with_retry<R, F, Fut>(&self, request: R, mut attempts: u64) -> Result<F::Output, ApiError>
where
R: Clone,
F: Fn(T, R) -> Fut,
Fut: Future<Output = Result<F::Output, ApiError>>,
{
let base_delay = Duration::from_millis(200);
loop {
match self.send_request(request.clone()).await {
Ok(response) => return Ok(response),
Err(ApiError::ServerError(status)) if status.as_u16() >= 500 && attempts > 0 => {
// Exponential backoff
tokio::time::sleep(base_delay * 2_u64.pow(4 - attempts)).await;
attempts -= 1;
}
Err(e) => return Err(e),
}
}
}
```
#### Rate Limit Handling
```rust
match error {
ApiError::RateLimited { retry_after } => {
// Wait and retry (or notify user)
tokio::time::sleep(Duration::from_secs(retry_after)).await;
}
_ => return Err(error),
}
```
---
## Summary
To implement a compatible client that mimics codex-cli:
1. **Use the Responses API** (`/v1/responses`), not Chat Completions
2. **Implement proper authentication** with Bearer token and optional ChatGPT-Account-ID
3. **Set correct User-Agent** following the format: `{originator}/{version} ({os} {version}; {arch}) {terminal}`
4. **Include required headers**: `originator`, `x-client-request-id`, `session_id`
5. **Handle SSE streaming** with proper event parsing
6. **Implement retry logic** with exponential backoff
7. **Support WebSocket transport** for better performance
8. **Handle turn state** for sticky routing
9. **Compress requests** with zstd when using ChatGPT auth
10. **Parse model capabilities** from `/v1/models` endpoint
All code references are from the `codex-rs` directory in the repository.
......@@ -464,7 +464,10 @@ def url_for(request: Request, path: str) -> str:
# Note: config will be imported after parsing CLI args if --config is provided
# For now, we'll delay the import and initialization
app = FastAPI(title="AI Proxy Server")
app = FastAPI(
title="AI Proxy Server",
max_request_size=100 * 1024 * 1024 # 100MB max request size
)
# Add proxy headers middleware (must be added before other middleware)
app.add_middleware(ProxyHeadersMiddleware)
......@@ -483,10 +486,40 @@ def setup_template_globals():
# Call setup after templates are initialized
setup_template_globals()
# Add session middleware at module level with a temporary secret key
# Add session middleware at module level with a persistent secret key
# This is needed for uvicorn import (when main() doesn't run)
_default_session_secret = secrets.token_urlsafe(32)
app.add_middleware(SessionMiddleware, secret_key=_default_session_secret)
# Use a persistent secret key so sessions survive server restarts
def _get_or_create_session_secret():
"""Get or create a persistent session secret key"""
secret_file = Path.home() / '.aisbf' / 'session_secret.key'
if secret_file.exists():
try:
with open(secret_file, 'r') as f:
return f.read().strip()
except Exception as e:
logger.warning(f"Failed to read session secret, generating new one: {e}")
# Generate new secret
secret = secrets.token_urlsafe(32)
# Save it for future use
try:
secret_file.parent.mkdir(parents=True, exist_ok=True)
with open(secret_file, 'w') as f:
f.write(secret)
# Set restrictive permissions
import os
os.chmod(secret_file, 0o600)
except Exception as e:
logger.warning(f"Failed to save session secret: {e}")
return secret
_session_secret = _get_or_create_session_secret()
# Configure session middleware: 30 days max age (cookie expiration)
# Note: Session data is stored in signed cookies, so it persists across restarts
app.add_middleware(SessionMiddleware, secret_key=_session_secret, max_age=30 * 24 * 60 * 60) # 30 days max age
# These will be initialized in startup event or main() after config is loaded
request_handler = None
......@@ -592,12 +625,15 @@ def initialize_app(custom_config_dir=None):
_initialized = True
logger.info("App initialization complete")
async def fetch_provider_models(provider_id: str) -> list:
async def fetch_provider_models(provider_id: str, user_id: Optional[int] = None) -> list:
"""Fetch models from provider API and cache them"""
global _model_cache, _model_cache_timestamps
try:
logger.debug(f"Fetching models from provider: {provider_id}")
logger.debug(f"Fetching models from provider: {provider_id} (user_id: {user_id})")
# Create request handler with correct user context
request_handler = RequestHandler(user_id=user_id)
# Create a dummy request object for the handler
from starlette.requests import Request
from starlette.datastructures import Headers
......@@ -611,12 +647,13 @@ async def fetch_provider_models(provider_id: str) -> list:
}
dummy_request = Request(scope)
# Fetch models from provider API (use global handler for model fetching)
# Fetch models from provider API (use user context if available)
models = await request_handler.handle_model_list(dummy_request, provider_id)
# Cache the results
_model_cache[provider_id] = models
_model_cache_timestamps[provider_id] = time.time()
# Cache the results - separate cache for users vs global
cache_key = f"{provider_id}:{user_id}" if user_id else provider_id
_model_cache[cache_key] = models
_model_cache_timestamps[cache_key] = time.time()
logger.info(f"Cached {len(models)} models from provider: {provider_id}")
return models
......@@ -745,7 +782,7 @@ def validate_kiro_credentials(provider_id: str, provider_config) -> bool:
logger.debug(f"Provider {provider_id}: No valid credential source configured")
return False
async def get_provider_models(provider_id: str, provider_config) -> list:
async def get_provider_models(provider_id: str, provider_config, user_id: Optional[int] = None) -> list:
"""Get models for a provider from local config or cache"""
global _model_cache, _model_cache_timestamps
......@@ -759,10 +796,53 @@ async def get_provider_models(provider_id: str, provider_config) -> list:
logger.debug(f"Skipping provider {provider_id}: API key required but not configured")
return []
# Validate provider authentication status
provider_type = getattr(provider_config, 'type', '')
# Validate kiro/kiro-cli credentials
if not validate_kiro_credentials(provider_id, provider_config):
logger.debug(f"Skipping provider {provider_id}: Kiro credentials not available or invalid")
return []
if provider_type in ('kiro', 'kiro-cli'):
if not validate_kiro_credentials(provider_id, provider_config):
logger.debug(f"Skipping provider {provider_id}: Kiro credentials not available or invalid")
return []
# Validate Codex OAuth2 credentials
if provider_type == 'codex':
try:
from aisbf.auth.codex import CodexOAuth2
codex_config = getattr(provider_config, 'codex_config', {})
credentials_file = codex_config.get('credentials_file', '~/.aisbf/codex_credentials.json')
auth = CodexOAuth2(credentials_file=credentials_file)
if not auth.is_authenticated():
logger.debug(f"Skipping provider {provider_id}: Codex OAuth2 not authenticated")
return []
except Exception as e:
logger.debug(f"Codex auth check failed for {provider_id}: {e}")
# Validate Qwen OAuth2 credentials
if provider_type == 'qwen':
try:
from aisbf.auth.qwen import QwenOAuth2
qwen_config = getattr(provider_config, 'qwen_config', {})
credentials_file = qwen_config.get('credentials_file', '~/.aisbf/qwen_credentials.json')
auth = QwenOAuth2(credentials_file=credentials_file)
if not auth.is_authenticated():
logger.debug(f"Skipping provider {provider_id}: Qwen OAuth2 not authenticated")
return []
except Exception as e:
logger.debug(f"Qwen auth check failed for {provider_id}: {e}")
# Validate Claude OAuth2 credentials
if provider_type == 'claude':
try:
from aisbf.auth.claude import ClaudeAuth
claude_config = getattr(provider_config, 'claude_config', {})
credentials_file = claude_config.get('credentials_file', '~/.claude_credentials.json')
auth = ClaudeAuth(credentials_file=credentials_file)
if not auth.is_authenticated():
logger.debug(f"Skipping provider {provider_id}: Claude OAuth2 not authenticated")
return []
except Exception as e:
logger.debug(f"Claude auth check failed for {provider_id}: {e}")
current_time = int(time.time())
......@@ -792,7 +872,8 @@ async def get_provider_models(provider_id: str, provider_config) -> list:
return models
# Check if we have cached models
if provider_id in _model_cache:
cache_key = f"{provider_id}:{user_id}" if user_id else provider_id
if cache_key in _model_cache:
cache_age = time.time() - _model_cache_timestamps.get(provider_id, 0)
if cache_age < _cache_refresh_interval:
# Cache is still fresh, use it
......@@ -819,7 +900,7 @@ async def get_provider_models(provider_id: str, provider_config) -> list:
# No local config and no cache, try to fetch from API (only if API key is valid or not required)
if not api_key_required or (api_key and not api_key.startswith('YOUR_')):
try:
fetched_models = await fetch_provider_models(provider_id)
fetched_models = await fetch_provider_models(provider_id, user_id=user_id)
if fetched_models:
# Add provider prefix to model IDs and ensure all required fields
models = []
......@@ -1398,7 +1479,7 @@ async def dashboard_login_page(request: Request):
raise
@app.post("/dashboard/login")
async def dashboard_login(request: Request, username: str = Form(...), password: str = Form(...)):
async def dashboard_login(request: Request, username: str = Form(...), password: str = Form(...), remember_me: bool = Form(False)):
"""Handle dashboard login"""
from aisbf.database import get_database
......@@ -1415,6 +1496,13 @@ async def dashboard_login(request: Request, username: str = Form(...), password:
request.session['username'] = username
request.session['role'] = user['role']
request.session['user_id'] = user['id']
request.session['remember_me'] = remember_me
if remember_me:
# Set session to expire in 30 days for remember me
request.session['expires_at'] = int(time.time()) + 30 * 24 * 60 * 60
else:
# For non-remember-me sessions, set expiry to 2 weeks (default session length)
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
# Fallback to config admin
......@@ -1427,6 +1515,13 @@ async def dashboard_login(request: Request, username: str = Form(...), password:
request.session['username'] = username
request.session['role'] = 'admin'
request.session['user_id'] = None # Config admin has no user_id
request.session['remember_me'] = remember_me
if remember_me:
# Set session to expire in 30 days for remember me
request.session['expires_at'] = int(time.time()) + 30 * 24 * 60 * 60
else:
# For non-remember-me sessions, set expiry to 2 weeks (default session length)
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
return templates.TemplateResponse(
......@@ -1445,6 +1540,22 @@ def require_dashboard_auth(request: Request):
"""Check if user is logged in to dashboard"""
if not request.session.get('logged_in'):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
# Check if session has expired
expires_at = request.session.get('expires_at')
if expires_at and int(time.time()) > expires_at:
# Session expired
request.session.clear()
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
# Extend session expiry for remember me users on each request (sliding expiration)
if request.session.get('remember_me'):
# Refresh expiry to 30 days from now for remember me
request.session['expires_at'] = int(time.time()) + 30 * 24 * 60 * 60
elif expires_at:
# For non-remember-me sessions, refresh to 2 weeks from now (sliding expiration)
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
return None
def require_admin(request: Request):
......@@ -1837,7 +1948,21 @@ async def dashboard_providers_get_models(request: Request):
handler = get_provider_handler(provider_key, api_key)
# Fetch models from provider
models = await handler.get_models()
models_result = await handler.get_models()
# Handle pending authorization status
if isinstance(models_result, dict) and models_result.get("status") == "pending_authorization":
return JSONResponse({
"success": False,
"authorization_required": True,
"authorization_url": models_result.get("verification_url"),
"device_code": models_result.get("code"),
"expires_in": models_result.get("expires_in"),
"poll_interval": models_result.get("poll_interval"),
"message": f"Please visit {models_result.get('verification_url')} and enter code: {models_result.get('code')}"
}, status_code=401)
models = models_result
# Convert Model objects to dicts with all available fields
models_data = []
......@@ -2630,26 +2755,43 @@ async def dashboard_users_delete(request: Request, user_id: int):
@app.post("/dashboard/restart")
async def dashboard_restart(request: Request):
"""Restart the server"""
"""Reload configuration from disk"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
import signal
logger.info("Server restart requested from dashboard")
# Schedule restart after response is sent
def restart_server():
import time
time.sleep(1) # Give time for response to be sent
logger.info("Restarting server...")
os.execv(sys.executable, [sys.executable] + _original_argv)
import threading
threading.Thread(target=restart_server, daemon=True).start()
return JSONResponse({"message": "Server is restarting..."})
logger.info("Configuration reload requested from dashboard")
try:
# Reload configuration
from aisbf.config import config
config.reload()
# Re-initialize database if config changed
db_config = config.aisbf.database if config.aisbf and config.aisbf.database else None
if db_config:
from aisbf.database import initialize_database
initialize_database(db_config)
# Re-initialize cache if config changed
cache_config = config.aisbf.cache if config.aisbf and config.aisbf.cache else None
if cache_config:
from aisbf.cache import initialize_cache
initialize_cache(cache_config)
# Re-initialize response cache if config changed
response_cache_config = config.aisbf.response_cache if config.aisbf and config.aisbf.response_cache else None
if response_cache_config:
from aisbf.cache import initialize_response_cache
initialize_response_cache(response_cache_config.model_dump() if hasattr(response_cache_config, 'model_dump') else response_cache_config)
logger.info("Configuration reloaded successfully")
return JSONResponse({"message": "Configuration reloaded successfully. All changes have been applied."})
except Exception as e:
logger.error(f"Error reloading configuration: {e}")
return JSONResponse({"error": f"Failed to reload configuration: {str(e)}"}, status_code=500)
# User-specific configuration management routes
@app.get("/dashboard/user/providers", response_class=HTMLResponse)
......@@ -2767,7 +2909,7 @@ async def dashboard_user_provider_upload(
try:
# Validate file type
allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials']
allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials', 'sqlite_db', 'creds_file']
if file_type not in allowed_types:
return JSONResponse(
status_code=400,
......@@ -2928,7 +3070,7 @@ async def dashboard_provider_upload(
try:
# Validate file type
allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials']
allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials', 'sqlite_db', 'creds_file']
if file_type not in allowed_types:
return JSONResponse(
status_code=400,
......@@ -2955,6 +3097,7 @@ async def dashboard_provider_upload(
logger.info(f"Config admin uploaded auth file: {file_path}")
return JSONResponse({
"success": True,
"message": "File uploaded successfully",
"file_path": str(file_path),
"stored_filename": stored_filename
......@@ -2992,6 +3135,7 @@ async def dashboard_provider_upload(
logger.info(f"User {current_user_id} uploaded auth file: {file_path}")
return JSONResponse({
"success": True,
"message": "File uploaded successfully",
"file_id": file_id,
"file_path": str(file_path),
......@@ -2999,7 +3143,211 @@ async def dashboard_provider_upload(
})
except Exception as e:
logger.error(f"Error uploading file: {e}")
return JSONResponse(status_code=500, content={"error": str(e)})
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@app.post("/dashboard/providers/upload-auth-file")
async def dashboard_provider_upload_form(
request: Request,
provider_key: str = Form(...),
file_type: str = Form(...),
file: UploadFile = File(...)
):
"""Upload authentication file for a provider (form variant used by frontend)."""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
# Check if current user is config admin (from aisbf.json)
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
# Validate file type
allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials', 'sqlite_db', 'creds_file']
if file_type not in allowed_types:
return JSONResponse(
status_code=400,
content={"success": False, "error": f"Invalid file type. Allowed: {', '.join(allowed_types)}"}
)
# Read file content
content = await file.read()
if is_config_admin:
# Config admin: save to files
auth_files_dir = get_admin_auth_files_dir()
# Generate unique filename
import uuid
file_ext = Path(file.filename).suffix if file.filename else '.json'
stored_filename = f"{provider_key}_{file_type}_{uuid.uuid4().hex[:8]}{file_ext}"
file_path = auth_files_dir / stored_filename
# Save file
with open(file_path, 'wb') as f:
f.write(content)
logger.info(f"Config admin uploaded auth file: {file_path}")
return JSONResponse({
"success": True,
"message": "File uploaded successfully",
"file_path": str(file_path),
"stored_filename": stored_filename
})
else:
# Database user: save to database
from aisbf.database import get_database
db = get_database()
# Get user auth files directory
auth_files_dir = get_user_auth_files_dir(current_user_id)
# Generate unique filename
import uuid
file_ext = Path(file.filename).suffix if file.filename else '.json'
stored_filename = f"{provider_key}_{file_type}_{uuid.uuid4().hex[:8]}{file_ext}"
file_path = auth_files_dir / stored_filename
# Save file
with open(file_path, 'wb') as f:
f.write(content)
# Save metadata to database
file_id = db.save_user_auth_file(
user_id=current_user_id,
provider_id=provider_key,
file_type=file_type,
original_filename=file.filename or stored_filename,
stored_filename=stored_filename,
file_path=str(file_path),
file_size=len(content),
mime_type=file.content_type
)
logger.info(f"User {current_user_id} uploaded auth file: {file_path}")
return JSONResponse({
"success": True,
"message": "File uploaded successfully",
"file_id": file_id,
"file_path": str(file_path),
"stored_filename": stored_filename
})
except Exception as e:
logger.error(f"Error uploading file: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@app.post("/dashboard/providers/upload-auth-file/chunk")
async def dashboard_provider_upload_chunk(
request: Request,
provider_key: str = Form(...),
file_type: str = Form(...),
file_name: str = Form(...),
chunk_number: int = Form(...),
total_chunks: int = Form(...),
total_size: int = Form(...),
file: UploadFile = File(...)
):
"""Chunked file upload endpoint - handles very large files behind proxies."""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
try:
# Validate file type
allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials', 'sqlite_db', 'creds_file']
if file_type not in allowed_types:
return JSONResponse(
status_code=400,
content={"success": False, "error": f"Invalid file type. Allowed: {', '.join(allowed_types)}"}
)
import uuid
import hashlib
# Generate unique upload ID
upload_id = hashlib.sha256(f"{current_user_id}:{provider_key}:{file_name}:{total_size}".encode()).hexdigest()[:16]
file_ext = Path(file_name).suffix if file_name else '.bin'
# Create temporary upload directory
temp_dir = Path('/tmp/aisbf_uploads')
temp_dir.mkdir(parents=True, exist_ok=True)
# Save chunk
chunk_path = temp_dir / f"{upload_id}.part{chunk_number}"
content = await file.read()
with open(chunk_path, 'wb') as f:
f.write(content)
# Check if all chunks are received
received_chunks = list(temp_dir.glob(f"{upload_id}.part*"))
if len(received_chunks) == total_chunks:
# Assemble final file
import uuid
stored_filename = f"{provider_key}_{file_type}_{uuid.uuid4().hex[:8]}{file_ext}"
if is_config_admin:
auth_files_dir = get_admin_auth_files_dir()
else:
auth_files_dir = get_user_auth_files_dir(current_user_id)
file_path = auth_files_dir / stored_filename
# Combine chunks
with open(file_path, 'wb') as outfile:
for i in range(1, total_chunks + 1):
chunk_path = temp_dir / f"{upload_id}.part{i}"
with open(chunk_path, 'rb') as infile:
outfile.write(infile.read())
chunk_path.unlink()
# Save metadata to database if not admin
if not is_config_admin:
from aisbf.database import get_database
db = get_database()
db.save_user_auth_file(
user_id=current_user_id,
provider_id=provider_key,
file_type=file_type,
original_filename=file_name,
stored_filename=stored_filename,
file_path=str(file_path),
file_size=total_size,
mime_type=file.content_type
)
logger.info(f"Upload complete: {file_path} ({total_size} bytes, {total_chunks} chunks)")
return JSONResponse({
"success": True,
"complete": True,
"message": "File uploaded successfully",
"file_path": str(file_path),
"stored_filename": stored_filename
})
return JSONResponse({
"success": True,
"complete": False,
"received_chunks": len(received_chunks),
"total_chunks": total_chunks
})
except Exception as e:
logger.error(f"Chunk upload error: {e}")
# Cleanup failed upload
try:
for chunk_path in temp_dir.glob(f"{upload_id}.part*"):
chunk_path.unlink()
except:
pass
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@app.get("/dashboard/providers/{provider_name}/files")
......@@ -3260,6 +3608,48 @@ async def dashboard_user_autoselects_delete(request: Request, autoselect_name: s
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@app.post("/dashboard/user/reload-config")
async def dashboard_user_reload_config(request: Request):
"""Reload user configuration from database"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
if not user_id:
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
try:
global _user_handlers_cache
# Clear all cached handler instances for this user
cache_keys_to_remove = [
key for key in _user_handlers_cache.keys()
if key.endswith(f"_{user_id}")
]
for key in cache_keys_to_remove:
del _user_handlers_cache[key]
logger.info(f"Cleared {len(cache_keys_to_remove)} cached handler(s) for user {user_id}")
# Force create new handler instances to verify reload works
get_user_handler('request', user_id)
get_user_handler('rotation', user_id)
get_user_handler('autoselect', user_id)
logger.info(f"User {user_id} configuration reloaded successfully from database")
return JSONResponse({
"message": "Configuration reloaded successfully. All changes have been applied immediately.",
"handlers_cleared": len(cache_keys_to_remove)
})
except Exception as e:
logger.error(f"Error reloading user configuration: {e}")
return JSONResponse({"error": f"Failed to reload configuration: {str(e)}"}, status_code=500)
# User API token management routes
@app.get("/dashboard/user/tokens", response_class=HTMLResponse)
async def dashboard_user_tokens(request: Request):
......@@ -3772,13 +4162,29 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest):
async def list_all_models(request: Request):
"""List all available models from all providers (public endpoint)"""
logger.info("=== LIST ALL MODELS REQUEST ===")
all_models = []
# Check authentication for user-specific models
user_id = None
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
# Try to authenticate user
try:
from aisbf.database import get_database
db = get_database()
token = auth_header.split(" ")[1]
user = db.get_user_by_token(token)
if user:
user_id = user.get("id")
logger.info(f"Authenticated user {user_id} for models request")
except Exception as e:
logger.debug(f"Auth check failed for models request: {e}")
# PATH 1: Add provider models (from local config or cached API results)
for provider_id, provider_config in config.providers.items():
try:
provider_models = await get_provider_models(provider_id, provider_config)
provider_models = await get_provider_models(provider_id, provider_config, user_id=user_id)
all_models.extend(provider_models)
except Exception as e:
logger.warning(f"Error listing models for provider {provider_id}: {e}")
......@@ -3825,11 +4231,27 @@ async def v1_list_all_models(request: Request):
logger.info("=== V1 LIST ALL MODELS REQUEST ===")
all_models = []
# Check authentication for user-specific models
user_id = None
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
# Try to authenticate user
try:
from aisbf.database import get_database
db = get_database()
token = auth_header.split(" ")[1]
user = db.get_user_by_token(token)
if user:
user_id = user.get("id")
logger.info(f"Authenticated user {user_id} for models request")
except Exception as e:
logger.debug(f"Auth check failed for models request: {e}")
# PATH 1: Add provider models (from local config or cached API results)
for provider_id, provider_config in config.providers.items():
try:
provider_models = await get_provider_models(provider_id, provider_config)
provider_models = await get_provider_models(provider_id, provider_config, user_id=user_id)
all_models.extend(provider_models)
except Exception as e:
logger.warning(f"Error listing models for provider {provider_id}: {e}")
......@@ -3870,6 +4292,24 @@ async def v1_list_all_models(request: Request):
logger.info(f"Returning {len(all_models)} total models")
return {"object": "list", "data": all_models}
@app.get("/v1/models")
async def v1_list_all_models_alias(request: Request):
"""Alias for /api/v1/models for client compatibility"""
return await v1_list_all_models(request)
@app.get("/v1/chat/models")
async def v1_chat_models_alias(request: Request):
"""Alias for /api/v1/models for OpenAI client compatibility"""
return await v1_list_all_models(request)
@app.get("/models")
async def models_root_alias(request: Request):
"""Alias for /api/v1/models for client compatibility"""
return await v1_list_all_models(request)
@app.post("/api/v1/audio/transcriptions")
async def v1_audio_transcriptions(request: Request):
"""Standard audio transcription endpoint (supports all three proxy paths)"""
......@@ -4406,6 +4846,7 @@ async def chat_completions(provider_id: str, request: Request, body: ChatComplet
@app.get("/api/{provider_id}/models")
async def list_models(request: Request, provider_id: str):
logger.debug(f"Received list_models request for provider: {provider_id}")
AISBF_DEBUG = os.environ.get('AISBF_DEBUG', '').lower() in ('true', '1', 'yes')
# Get user-specific handler based on the type
user_id = getattr(request.state, 'user_id', None)
......@@ -4416,7 +4857,16 @@ async def list_models(request: Request, provider_id: str):
handler = get_user_handler('autoselect', user_id)
try:
result = await handler.handle_autoselect_model_list(provider_id)
logger.debug(f"Autoselect models result: {result}")
if AISBF_DEBUG:
result_str = str(result)
if len(result_str) > 1024:
result_str = result_str[:1024] + " ... [TRUNCATED, total length: " + str(len(result_str)) + " chars]"
logger.debug(f"Autoselect models result: {result_str}")
else:
model_count = len(result)
first_models = [m.get('id', m.get('name')) for m in result[:3]]
preview = f"[{' | '.join(first_models)}" + (f" ... and {model_count - 3} more]" if model_count > 3 else ']')
logger.debug(f"Autoselect models result: {model_count} models {preview}")
return result
except Exception as e:
logger.error(f"Error handling autoselect model list: {str(e)}", exc_info=True)
......@@ -4443,7 +4893,16 @@ async def list_models(request: Request, provider_id: str):
try:
logger.debug("Handling model list request")
result = await handler.handle_model_list(request, provider_id)
logger.debug(f"Models result: {result}")
if AISBF_DEBUG:
result_str = str(result)
if len(result_str) > 1024:
result_str = result_str[:1024] + " ... [TRUNCATED, total length: " + str(len(result_str)) + " chars]"
logger.debug(f"Models result: {result_str}")
else:
model_count = len(result)
first_models = [m.get('id', m.get('name')) for m in result[:3]]
preview = f"[{' | '.join(first_models)}" + (f" ... and {model_count - 3} more]" if model_count > 3 else ']')
logger.debug(f"Models result: {model_count} models {preview}")
return result
except Exception as e:
logger.error(f"Error handling list_models: {str(e)}", exc_info=True)
......@@ -7000,7 +7459,8 @@ async def dashboard_codex_auth_poll(request: Request):
return JSONResponse({
"success": True,
"status": "approved",
"message": "Authentication completed successfully"
"message": "Authentication completed successfully",
"new_endpoint": "https://chatgpt.com/backend-api/codex"
})
elif result['status'] == 'pending':
return JSONResponse({
......@@ -7165,5 +7625,388 @@ async def dashboard_codex_auth_logout(request: Request):
)
# Qwen OAuth2 authentication endpoints - Save credentials after successful poll
def _save_qwen_credentials(credentials_file: str, token_response: dict) -> None:
"""Save Qwen OAuth2 credentials to file"""
from pathlib import Path
import time
try:
# OAuth2 standard: expires_in is always in seconds
# Convert to milliseconds for storage
expires_in = token_response.get("expires_in", 7200)
expires_in_ms = expires_in * 1000 # Convert seconds to milliseconds
# Minimum expiry: 1 hour (3600 seconds = 3,600,000 ms)
if expires_in_ms < 3600000:
expires_in_ms = 3600000 # Default to 1 hour minimum
credentials = {
"access_token": token_response["access_token"],
"refresh_token": token_response.get("refresh_token"),
"token_type": token_response.get("token_type", "Bearer"),
"resource_url": token_response.get("resource_url"),
"expiry_date": int(time.time() * 1000) + expires_in_ms,
"last_refresh": datetime.utcnow().isoformat() + "Z",
}
# Ensure directory exists
cred_path = Path(credentials_file).expanduser()
cred_path.parent.mkdir(parents=True, exist_ok=True)
# Write credentials atomically (temp file + rename)
import tempfile
with tempfile.NamedTemporaryFile(mode='w', dir=cred_path.parent, delete=False) as f:
json.dump(credentials, f, indent=2)
temp_path = f.name
# Atomic rename
import os
os.rename(temp_path, cred_path)
# Set file permissions to 0o600
os.chmod(cred_path, 0o600)
logger.info(f"QwenOAuth2: Saved credentials to {credentials_file}")
except Exception as e:
logger.error(f"QwenOAuth2: Failed to save credentials: {e}")
raise
@app.post("/dashboard/qwen/auth/start")
async def dashboard_qwen_auth_start(request: Request):
"""Start Qwen OAuth2 Device Authorization Grant flow"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.aisbf/qwen_credentials.json')
if not provider_key:
return JSONResponse(
status_code=400,
content={"success": False, "error": "Provider key is required"}
)
# Import QwenOAuth2
from aisbf.auth.qwen import QwenOAuth2
# Create auth instance
auth = QwenOAuth2(credentials_file=credentials_file)
logger.info(f"QwenOAuth2: Requesting device code for provider: {provider_key}")
# Request device code
device_info = await auth.request_device_code()
if not device_info:
return JSONResponse(
status_code=500,
content={"success": False, "error": "Failed to initiate device authorization"}
)
logger.info(f"QwenOAuth2: Device code obtained: {device_info.get('user_code')}")
# Store in session for polling
request.session['qwen_device_code'] = device_info['device_code']
request.session['qwen_code_verifier'] = device_info['code_verifier']
request.session['qwen_provider'] = provider_key
request.session['qwen_credentials_file'] = credentials_file
request.session['qwen_expires_at'] = time.time() + device_info['expires_in']
return JSONResponse({
"success": True,
"user_code": device_info['user_code'],
"verification_uri": device_info['verification_uri_complete'],
"expires_in": device_info['expires_in'],
"interval": device_info['interval'],
"message": f"Please visit {device_info['verification_uri_complete']} and enter code: {device_info['user_code']}"
})
except Exception as e:
logger.error(f"Error starting Qwen auth: {e}", exc_info=True)
return JSONResponse(
status_code=500,
content={"success": False, "error": str(e)}
)
@app.post("/dashboard/qwen/auth/poll")
async def dashboard_qwen_auth_poll(request: Request):
"""Poll Qwen OAuth2 device authorization status"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
# Get device code from session
device_code = request.session.get('qwen_device_code')
code_verifier = request.session.get('qwen_code_verifier')
credentials_file = request.session.get('qwen_credentials_file', '~/.aisbf/qwen_credentials.json')
expires_at = request.session.get('qwen_expires_at', 0)
if not device_code:
return JSONResponse(
status_code=400,
content={"success": False, "status": "error", "error": "No device authorization in progress"}
)
# Check if expired
if time.time() > expires_at:
request.session.pop('qwen_device_code', None)
request.session.pop('qwen_code_verifier', None)
request.session.pop('qwen_provider', None)
request.session.pop('qwen_credentials_file', None)
request.session.pop('qwen_expires_at', None)
return JSONResponse({
"success": False,
"status": "expired",
"error": "Device authorization expired"
})
# Import QwenOAuth2
from aisbf.auth.qwen import QwenOAuth2
# Create auth instance
auth = QwenOAuth2(credentials_file=credentials_file)
# Poll for token - returns token dict if approved, None if still pending
result = await auth.poll_device_token(device_code, code_verifier)
if result and result.get("access_token"):
# Authentication successful - save the tokens
# Only the ONE config admin (user_id=None from aisbf.json) saves to file
# All other users (including database admins) save to database
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
# First save to file (for config admin), then copy to DB if needed
_save_qwen_credentials(credentials_file, result)
if not is_config_admin:
# Non-config-admin user: also save credentials to database
try:
from aisbf.database import get_database
db = get_database()
provider_key = request.session.get('qwen_provider')
if db and current_user_id and provider_key:
# Read the credentials that were just saved to file
credentials_path = Path(credentials_file).expanduser()
if credentials_path.exists():
with open(credentials_path, 'r') as f:
db_credentials = json.load(f)
# Save to database
db.save_user_oauth2_credentials(
user_id=current_user_id,
provider_id=provider_key,
auth_type='qwen_oauth2',
credentials=db_credentials
)
logger.info(f"QwenOAuth2: Saved credentials to database for user {current_user_id}")
# Remove the file since we're using database storage for non-admin
credentials_path.unlink(missing_ok=True)
except Exception as e:
logger.error(f"QwenOAuth2: Failed to save credentials to database: {e}")
# Clear session
request.session.pop('qwen_device_code', None)
request.session.pop('qwen_code_verifier', None)
request.session.pop('qwen_provider', None)
request.session.pop('qwen_credentials_file', None)
request.session.pop('qwen_expires_at', None)
return JSONResponse({
"success": True,
"status": "approved",
"message": "Authentication completed successfully"
})
elif result is None:
# Still pending (None is returned when waiting for user approval)
return JSONResponse({
"success": True,
"status": "pending",
"message": "Waiting for user authorization. Please approve the device on your Qwen account."
})
else:
# Unexpected result (not None, but no access_token) - treat as pending
return JSONResponse({
"success": True,
"status": "pending",
"message": "Waiting for user authorization"
})
except Exception as e:
error_msg = str(e).lower()
if "authorization_pending" in error_msg or "pending" in error_msg:
return JSONResponse({
"success": True,
"status": "pending",
"message": "Waiting for user authorization. Please approve the device on your Qwen account."
})
elif "slow_down" in error_msg or "429" in error_msg:
return JSONResponse({
"success": True,
"status": "slow_down",
"message": "Polling too frequently, slowing down"
})
elif "expired" in error_msg:
request.session.pop('qwen_device_code', None)
request.session.pop('qwen_code_verifier', None)
request.session.pop('qwen_provider', None)
request.session.pop('qwen_credentials_file', None)
request.session.pop('qwen_expires_at', None)
return JSONResponse({
"success": False,
"status": "expired",
"error": "Device authorization expired"
})
else:
logger.error(f"Error polling Qwen auth: {e}", exc_info=True)
return JSONResponse(
status_code=500,
content={"success": False, "status": "error", "error": str(e)}
)
@app.post("/dashboard/qwen/auth/status")
async def dashboard_qwen_auth_status(request: Request):
"""Check Qwen authentication status"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.aisbf/qwen_credentials.json')
if not provider_key:
return JSONResponse(
status_code=400,
content={"authenticated": False, "error": "Provider key is required"}
)
# Import QwenOAuth2
from aisbf.auth.qwen import QwenOAuth2
# Check if current user is config admin
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
if not is_config_admin:
# Non-config-admin user: check database for credentials
try:
from aisbf.database import get_database
db = get_database()
if db and current_user_id:
db_creds = db.get_user_oauth2_credentials(
user_id=current_user_id,
provider_id=provider_key,
auth_type='qwen_oauth2'
)
if db_creds and db_creds.get('credentials'):
# Check if tokens are still valid
creds = db_creds['credentials']
access_token = creds.get('access_token')
expiry_date = creds.get('expiry_date', 0)
if access_token and time.time() * 1000 < expiry_date:
return JSONResponse({
"authenticated": True,
"expires_in": max(0, (expiry_date - int(time.time() * 1000)) / 1000)
})
except Exception as e:
logger.warning(f"QwenOAuth2: Failed to check database credentials: {e}")
# Config admin or no database credentials: check file
auth = QwenOAuth2(credentials_file=credentials_file)
# Check if authenticated
if auth.is_authenticated():
# Try to get a valid token (will refresh if needed)
token = await auth.get_valid_token_with_refresh()
if token:
# Get token expiration info
expiry_date = auth.credentials.get('expiry_date', 0)
return JSONResponse({
"authenticated": True,
"expires_in": max(0, (expiry_date - int(time.time() * 1000)) / 1000)
})
return JSONResponse({
"authenticated": False
})
except Exception as e:
logger.error(f"Error checking Qwen auth status: {e}")
return JSONResponse(
status_code=500,
content={"authenticated": False, "error": str(e)}
)
@app.post("/dashboard/qwen/auth/logout")
async def dashboard_qwen_auth_logout(request: Request):
"""Logout from Qwen OAuth2 (clear stored credentials)"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.aisbf/qwen_credentials.json')
if not provider_key:
return JSONResponse(
status_code=400,
content={"success": False, "error": "Provider key is required"}
)
# Import QwenOAuth2
from aisbf.auth.qwen import QwenOAuth2
# Clear file credentials (for config admin)
auth = QwenOAuth2(credentials_file=credentials_file)
auth.clear_credentials()
# Also clear database credentials (for database users)
current_user_id = request.session.get('user_id')
if current_user_id:
try:
from aisbf.database import get_database
db = get_database()
if db:
db.delete_user_oauth2_credentials(current_user_id, provider_key, 'qwen_oauth2')
except Exception as e:
logger.warning(f"QwenOAuth2: Failed to clear database credentials: {e}")
# Clear session data
request.session.pop('qwen_device_code', None)
request.session.pop('qwen_code_verifier', None)
request.session.pop('qwen_provider', None)
request.session.pop('qwen_credentials_file', None)
request.session.pop('qwen_expires_at', None)
return JSONResponse({
"success": True,
"message": "Logged out successfully"
})
except Exception as e:
logger.error(f"Error logging out from Qwen: {e}")
return JSONResponse(
status_code=500,
content={"success": False, "error": str(e)}
)
if __name__ == "__main__":
main()
......@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "aisbf"
version = "0.99.15"
version = "0.99.16"
description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md"
license = "GPL-3.0-or-later"
......
......@@ -49,7 +49,7 @@ class InstallCommand(_install):
setup(
name="aisbf",
version="0.99.15",
version="0.99.16",
author="AISBF Contributors",
author_email="stefy@nexlab.net",
description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations",
......
......@@ -37,6 +37,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="remember_me" name="remember_me" style="width: auto; margin-right: 10px;">
Remember me
</label>
</div>
<button type="submit" class="btn" style="width: 100%;">Login</button>
</form>
</div>
......
......@@ -49,6 +49,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<option value="kiro">Kiro (Amazon Q Developer)</option>
<option value="claude">Claude (OAuth2)</option>
<option value="kilocode">Kilocode (OAuth2)</option>
<option value="qwen">Qwen (OAuth2)</option>
<option value="codex">Codex (OpenAI OAuth2)</option>
</select>
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Select the type of provider to configure appropriate settings</small>
......@@ -128,6 +129,7 @@ function renderProviderDetails(key) {
const isKiroProvider = provider.type === 'kiro';
const isClaudeProvider = provider.type === 'claude';
const isKiloProvider = provider.type === 'kilocode';
const isQwenProvider = provider.type === 'qwen';
const isCodexProvider = provider.type === 'codex';
// Initialize kiro_config if this is a kiro provider and doesn't have it
......@@ -158,6 +160,16 @@ function renderProviderDetails(key) {
};
}
// Initialize qwen_config if this is a qwen provider and doesn't have it
if (isQwenProvider && !provider.qwen_config) {
provider.qwen_config = {
credentials_file: '~/.aisbf/qwen_credentials.json',
api_key: '',
region: 'china-beijing',
workspace_id: 'Default Workspace'
};
}
// Initialize codex_config if this is a codex provider and doesn't have it
if (isCodexProvider && !provider.codex_config) {
provider.codex_config = {
......@@ -169,6 +181,7 @@ function renderProviderDetails(key) {
const kiroConfig = provider.kiro_config || {};
const claudeConfig = provider.claude_config || {};
const kiloConfig = provider.kilo_config || {};
const qwenConfig = provider.qwen_config || {};
const codexConfig = provider.codex_config || {};
// Build authentication fields based on provider type
......@@ -333,6 +346,82 @@ function renderProviderDetails(key) {
<div id="claude-upload-status-${key}" style="margin-top: 10px;"></div>
</div>
`;
} else if (isQwenProvider) {
// Qwen authentication fields - supports both API key and OAuth2
authFieldsHtml = `
<div style="background: #0f2840; padding: 15px; border-radius: 5px; margin-bottom: 15px; border-left: 3px solid #4a9eff;">
<h4 style="margin: 0 0 15px 0; color: #4a9eff;">Qwen Authentication</h4>
<small style="color: #a0a0a0; display: block; margin-bottom: 15px;">
Choose your authentication method: API Key (recommended for simplicity) or OAuth2 Device Authorization Grant.
</small>
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Option 1: API Key</h5>
<div class="form-group">
<label>API Key</label>
<input type="password" value="${qwenConfig.api_key || ''}" onchange="updateQwenConfig('${key}', 'api_key', this.value)" placeholder="Enter your Qwen API key">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">If provided, API key authentication will be used instead of OAuth2</small>
</div>
<div id="qwen-region-config-${key}" style="display: ${qwenConfig.api_key ? 'block' : 'none'}; margin-top: 15px;">
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Region Configuration (API Key Mode)</h5>
<small style="color: #a0a0a0; display: block; margin-bottom: 15px;">
Select your preferred region for API key authentication. Different regions have different endpoints.
</small>
<div class="form-group">
<label>Region</label>
<select onchange="updateQwenConfig('${key}', 'region', this.value)">
<option value="china-beijing" ${qwenConfig.region === 'china-beijing' || !qwenConfig.region ? 'selected' : ''}>China (Beijing) - dashscope.aliyuncs.com</option>
<option value="singapore" ${qwenConfig.region === 'singapore' ? 'selected' : ''}>Singapore - dashscope-intl.aliyuncs.com</option>
<option value="us-virginia" ${qwenConfig.region === 'us-virginia' ? 'selected' : ''}>US (Virginia) - dashscope-us.aliyuncs.com</option>
<option value="china-hongkong" ${qwenConfig.region === 'china-hongkong' ? 'selected' : ''}>China (Hong Kong) - cn-hongkong.dashscope.aliyuncs.com</option>
<option value="germany-frankfurt" ${qwenConfig.region === 'germany-frankfurt' ? 'selected' : ''}>Germany (Frankfurt) - Requires Workspace ID</option>
</select>
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Select the region for your Qwen API endpoint</small>
</div>
<div id="qwen-workspace-config-${key}" style="display: ${qwenConfig.region === 'germany-frankfurt' ? 'block' : 'none'};">
<div class="form-group">
<label>Workspace ID</label>
<input type="text" value="${qwenConfig.workspace_id || 'Default Workspace'}" onchange="updateQwenConfig('${key}', 'workspace_id', this.value)" placeholder="Default Workspace">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Workspace ID for Germany region (default: "Default Workspace")</small>
</div>
</div>
</div>
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Option 2: OAuth2 Authentication</h5>
<small style="color: #a0a0a0; display: block; margin-bottom: 15px;">
If no API key is provided, OAuth2 will be used automatically.
</small>
<div class="form-group">
<label>Credentials File Path</label>
<input type="text" value="${qwenConfig.credentials_file || '~/.aisbf/qwen_credentials.json'}" onchange="updateQwenConfig('${key}', 'credentials_file', this.value)" placeholder="~/.aisbf/qwen_credentials.json">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Path where OAuth2 credentials will be stored</small>
</div>
<div style="margin-top: 15px;">
<button type="button" class="btn" onclick="authenticateQwen('${key}')" style="background: #4a9eff;">
🔐 Authenticate with Qwen OAuth2
</button>
<button type="button" class="btn btn-secondary" onclick="checkQwenAuth('${key}')" style="margin-left: 10px;">
Check Status
</button>
</div>
<div id="qwen-auth-status-${key}" style="margin-top: 10px; padding: 10px; border-radius: 3px; display: none;">
<!-- Auth status will be displayed here -->
</div>
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Or Upload OAuth2 Credentials File</h5>
<div class="form-group">
<label>Upload Credentials File</label>
<input type="file" id="qwen-creds-file-${key}" accept=".json" onchange="uploadQwenFile('${key}', this.files[0])">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Upload Qwen OAuth2 credentials JSON file</small>
</div>
<div id="qwen-upload-status-${key}" style="margin-top: 10px;"></div>
</div>
`;
} else if (isCodexProvider) {
// Codex OAuth2 authentication fields
authFieldsHtml = `
......@@ -412,10 +501,12 @@ function renderProviderDetails(key) {
<div class="form-group">
<label>Endpoint</label>
<input type="text" value="${provider.endpoint}" onchange="updateProvider('${key}', 'endpoint', this.value)" ${provider.type === 'kilocode' ? 'readonly style="background: #0f2840; cursor: not-allowed;"' : ''} required>
<input type="text" value="${provider.endpoint}" onchange="updateProvider('${key}', 'endpoint', this.value)" ${(provider.type === 'kilocode' || provider.type === 'qwen' || provider.type === 'claude' || provider.type === 'codex') ? 'readonly style="background: #0f2840; cursor: not-allowed;"' : ''} required>
${isKiroProvider ? '<small style="color: #a0a0a0; display: block; margin-top: 5px;">Typically: https://q.us-east-1.amazonaws.com</small>' : ''}
${provider.type === 'kilocode' ? '<small style="color: #a0a0a0; display: block; margin-top: 5px;">Fixed endpoint for Kilocode provider</small>' : ''}
${isCodexProvider ? '<small style="color: #a0a0a0; display: block; margin-top: 5px;">Typically: https://api.openai.com/v1 (OpenAI-compatible)</small>' : ''}
${provider.type === 'kilocode' ? '<small style="color: #a0a0a0; display: block; margin-top: 5px;">Fixed endpoint for Kilocode OAuth2 provider</small>' : ''}
${isQwenProvider ? '<small style="color: #a0a0a0; display: block; margin-top: 5px;">Fixed endpoint for Qwen OAuth2 provider (https://dashscope.aliyuncs.com/compatible-mode/v1)</small>' : ''}
${isClaudeProvider ? '<small style="color: #a0a0a0; display: block; margin-top: 5px;">Fixed endpoint for Claude OAuth2 provider (https://api.anthropic.com/v1)</small>' : ''}
${isCodexProvider ? '<small style="color: #a0a0a0; display: block; margin-top: 5px;">Fixed endpoint for Codex OAuth2 provider (https://api.openai.com/v1)</small>' : ''}
</div>
<div class="form-group">
......@@ -428,6 +519,7 @@ function renderProviderDetails(key) {
<option value="kiro" ${provider.type === 'kiro' ? 'selected' : ''}>Kiro (Amazon Q Developer)</option>
<option value="claude" ${provider.type === 'claude' ? 'selected' : ''}>Claude (OAuth2)</option>
<option value="kilocode" ${provider.type === 'kilocode' ? 'selected' : ''}>Kilocode (OAuth2)</option>
<option value="qwen" ${provider.type === 'qwen' ? 'selected' : ''}>Qwen (OAuth2)</option>
<option value="codex" ${provider.type === 'codex' ? 'selected' : ''}>Codex (OpenAI OAuth2)</option>
</select>
</div>
......@@ -654,6 +746,7 @@ function updateNewProviderDefaults() {
'kiro': 'Kiro (Amazon Q Developer) provider. Uses Kiro credentials (IDE, CLI, or direct tokens). Endpoint: https://q.us-east-1.amazonaws.com',
'claude': 'Claude Code provider. Uses OAuth2 authentication (browser-based login). Endpoint: https://api.anthropic.com/v1',
'kilocode': 'Kilocode provider. Uses OAuth2 Device Authorization Grant. Endpoint: https://api.kilo.ai/api/gateway',
'qwen': 'Qwen provider. Uses OAuth2 Device Authorization Grant or API key. Endpoint: https://dashscope.aliyuncs.com/compatible-mode/v1',
'codex': 'Codex provider. Uses OAuth2 Device Authorization Grant (same protocol as OpenAI). Endpoint: https://api.openai.com/v1'
};
......@@ -690,7 +783,7 @@ function confirmAddProvider() {
name: key,
endpoint: '',
type: providerType,
api_key_required: providerType !== 'kiro' && providerType !== 'ollama' && providerType !== 'claude' && providerType !== 'kilocode' && providerType !== 'codex',
api_key_required: providerType !== 'kiro' && providerType !== 'ollama' && providerType !== 'claude' && providerType !== 'kilocode' && providerType !== 'qwen' && providerType !== 'codex',
rate_limit: 0,
default_rate_limit: 0,
models: []
......@@ -722,6 +815,15 @@ function confirmAddProvider() {
credentials_file: '~/.kilo_credentials.json',
api_base: 'https://api.kilo.ai/api/gateway'
};
} else if (providerType === 'qwen') {
newProvider.endpoint = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
newProvider.name = key + ' (Qwen OAuth2)';
newProvider.qwen_config = {
credentials_file: '~/.aisbf/qwen_credentials.json',
api_key: '',
region: 'china-beijing',
workspace_id: 'Default Workspace'
};
} else if (providerType === 'codex') {
newProvider.endpoint = 'https://api.openai.com/v1';
newProvider.name = key + ' (Codex OAuth2)';
......@@ -863,12 +965,28 @@ function updateProviderType(key, newType) {
if (!providersData[key].endpoint || providersData[key].endpoint === '') {
providersData[key].endpoint = 'https://api.openai.com/v1';
}
} else if (newType !== 'kiro' && newType !== 'claude' && newType !== 'kilocode' && newType !== 'codex' && (oldType === 'kiro' || oldType === 'claude' || oldType === 'kilocode' || oldType === 'codex')) {
// Transitioning FROM kiro/claude/kilocode/codex: remove special configs, set api_key_required to true
} else if (newType === 'qwen' && oldType !== 'qwen') {
// Transitioning TO qwen: initialize qwen_config, set api_key_required to false
providersData[key].api_key_required = false;
providersData[key].qwen_config = {
credentials_file: '~/.aisbf/qwen_credentials.json',
api_key: ''
};
delete providersData[key].kiro_config;
delete providersData[key].claude_config;
delete providersData[key].kilo_config;
delete providersData[key].codex_config;
// Set default endpoint for qwen
if (!providersData[key].endpoint || providersData[key].endpoint === '') {
providersData[key].endpoint = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
}
} else if (newType !== 'kiro' && newType !== 'claude' && newType !== 'kilocode' && newType !== 'qwen' && newType !== 'codex' && (oldType === 'kiro' || oldType === 'claude' || oldType === 'kilocode' || oldType === 'qwen' || oldType === 'codex')) {
// Transitioning FROM kiro/claude/kilocode/qwen/codex: remove special configs, set api_key_required to true
providersData[key].api_key_required = true;
delete providersData[key].kiro_config;
delete providersData[key].claude_config;
delete providersData[key].kilo_config;
delete providersData[key].qwen_config;
delete providersData[key].codex_config;
}
......@@ -897,6 +1015,26 @@ function updateKiloConfig(key, field, value) {
providersData[key].kilo_config[field] = value;
}
function updateQwenConfig(key, field, value) {
if (!providersData[key].qwen_config) {
providersData[key].qwen_config = {};
}
providersData[key].qwen_config[field] = value;
// Handle dynamic UI updates for region/workspace configuration
if (field === 'api_key') {
const regionConfigEl = document.getElementById(`qwen-region-config-${key}`);
if (regionConfigEl) {
regionConfigEl.style.display = value ? 'block' : 'none';
}
} else if (field === 'region') {
const workspaceConfigEl = document.getElementById(`qwen-workspace-config-${key}`);
if (workspaceConfigEl) {
workspaceConfigEl.style.display = value === 'germany-frankfurt' ? 'block' : 'none';
}
}
}
function updateCodexConfig(key, field, value) {
if (!providersData[key].codex_config) {
providersData[key].codex_config = {};
......@@ -904,6 +1042,209 @@ function updateCodexConfig(key, field, value) {
providersData[key].codex_config[field] = value;
}
async function authenticateQwen(key) {
const statusEl = document.getElementById(`qwen-auth-status-${key}`);
statusEl.style.display = 'block';
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Starting Qwen OAuth2 Device Authorization flow...</p>';
try {
const response = await fetch('{{ url_for(request, "/dashboard/qwen/auth/start") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: key,
credentials_file: providersData[key].qwen_config?.credentials_file || '~/.aisbf/qwen_credentials.json'
})
});
const data = await response.json();
if (!data.success) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Failed to start authentication: ${data.error || 'Unknown error'}</p>`;
return;
}
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = `
<div style="margin: 0;">
<p style="margin: 0 0 10px 0; color: #4a9eff; font-weight: bold;">🔐 Qwen Device Authorization</p>
<p style="margin: 0 0 10px 0; color: #e0e0e0;">
Please visit: <a href="${data.verification_uri}" target="_blank" style="color: #4eff9e; text-decoration: underline;">${data.verification_uri}</a>
</p>
<p style="margin: 0 0 10px 0; color: #e0e0e0;">
Enter code: <strong style="color: #4eff9e; font-size: 18px; letter-spacing: 2px;">${data.user_code}</strong>
</p>
<p style="margin: 0; color: #a0a0a0; font-size: 13px;">
Waiting for authorization... (expires in ${Math.floor(data.expires_in / 60)} minutes)
</p>
</div>
`;
try {
window.open(data.verification_uri, 'qwen-auth', 'width=600,height=700');
} catch (e) {
console.error('Could not open auth window:', e);
}
let pollCount = 0;
const maxPolls = Math.floor(data.expires_in / data.interval);
const pollInterval = setInterval(async () => {
pollCount++;
try {
const pollResponse = await fetch('{{ url_for(request, "/dashboard/qwen/auth/poll") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const pollData = await pollResponse.json();
if (pollData.status === 'approved') {
clearInterval(pollInterval);
statusEl.style.background = '#0f4020';
statusEl.style.border = '1px solid #4eff9e';
statusEl.innerHTML = '<p style="margin: 0; color: #4eff9e;">✓ Qwen authentication successful! Credentials saved.</p>';
} else if (pollData.status === 'denied') {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authorization denied by user.</p>';
} else if (pollData.status === 'expired') {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authorization code expired. Please try again.</p>';
}
} catch (error) {
console.error('Error polling Qwen auth:', error);
}
if (pollCount >= maxPolls) {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authentication timeout. Please try again.</p>';
}
}, data.interval * 1000);
} catch (error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
}
async function checkQwenAuth(key) {
const statusEl = document.getElementById(`qwen-auth-status-${key}`);
statusEl.style.display = 'block';
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Checking Qwen authentication status...</p>';
try {
const response = await fetch('{{ url_for(request, "/dashboard/qwen/auth/status") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: key,
credentials_file: providersData[key].qwen_config?.credentials_file || '~/.aisbf/qwen_credentials.json'
})
});
const data = await response.json();
if (data.authenticated) {
statusEl.style.background = '#0f4020';
statusEl.style.border = '1px solid #4eff9e';
const expiresIn = data.expires_in ? ` (expires in ${Math.floor(data.expires_in / (24 * 60 * 60))} days)` : '';
statusEl.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ Qwen authenticated${expiresIn}</p>`;
} else {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Not authenticated. Click "Authenticate with Qwen" to log in.</p>';
}
} catch (error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
}
async function uploadFileChunked(providerKey, fileType, file, statusElement, updateCallback) {
if (!file) return;
const CHUNK_SIZE = 256 * 1024; // 256KB chunks - works with every proxy
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
let uploadedChunks = 0;
statusElement.innerHTML = `<p style="margin: 0; color: #4a9eff;">🔄 Uploading 0% (0/${totalChunks} chunks)...</p>`;
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('provider_key', providerKey);
formData.append('file_type', fileType);
formData.append('file_name', file.name);
formData.append('chunk_number', i + 1);
formData.append('total_chunks', totalChunks);
formData.append('total_size', file.size);
try {
const response = await fetch('{{ url_for(request, "/dashboard/providers/upload-auth-file/chunk") }}', {
method: 'POST',
body: formData
});
const data = await response.json();
if (!data.success) {
statusElement.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Upload failed: ${data.error}</p>`;
return;
}
uploadedChunks++;
const percent = Math.round((uploadedChunks / totalChunks) * 100);
statusElement.innerHTML = `<p style="margin: 0; color: #4a9eff;">🔄 Uploading ${percent}% (${uploadedChunks}/${totalChunks} chunks)...</p>`;
if (data.complete) {
statusElement.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ File uploaded successfully! Path: ${data.file_path}</p>`;
if (updateCallback) {
updateCallback(data.file_path);
}
}
} catch (error) {
statusElement.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
return;
}
}
}
async function uploadQwenFile(providerKey, file) {
if (!file) return;
const statusEl = document.getElementById(`qwen-upload-status-${providerKey}`);
await uploadFileChunked(providerKey, 'credentials', file, statusEl, (filePath) => {
updateQwenConfig(providerKey, 'credentials_file', filePath);
});
}
async function authenticateCodex(key) {
const statusEl = document.getElementById(`codex-auth-status-${key}`);
statusEl.style.display = 'block';
......@@ -976,6 +1317,17 @@ async function authenticateCodex(key) {
statusEl.style.background = '#0f4020';
statusEl.style.border = '1px solid #4eff9e';
statusEl.innerHTML = '<p style="margin: 0; color: #4eff9e;">✓ Codex authentication successful! Credentials saved.</p>';
// Update endpoint field to ChatGPT backend for OAuth2 mode
const endpointInput = document.querySelector(`input[onchange*="updateProvider('${key}', 'endpoint', this.value)"]`);
if (endpointInput && pollData.new_endpoint) {
// Update both the UI input field AND the underlying providersData object
endpointInput.value = pollData.new_endpoint;
providersData[key].endpoint = pollData.new_endpoint;
// Remove readonly attribute if present
endpointInput.removeAttribute('readonly');
endpointInput.style.background = '#1a1a2e';
endpointInput.style.cursor = 'text';
}
} else if (pollData.status === 'denied') {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
......@@ -1047,32 +1399,11 @@ async function checkCodexAuth(key) {
async function uploadCodexFile(providerKey, file) {
if (!file) return;
const statusEl = document.getElementById(`codex-upload-status-${providerKey}`);
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Uploading file...</p>';
const formData = new FormData();
formData.append('file', file);
formData.append('provider_key', providerKey);
formData.append('file_type', 'credentials');
try {
const response = await fetch('{{ url_for(request, "/dashboard/providers/upload-auth-file") }}', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
statusEl.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ File uploaded successfully! Path: ${data.file_path}</p>`;
updateCodexConfig(providerKey, 'credentials_file', data.file_path);
} else {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Upload failed: ${data.error}</p>`;
}
} catch (error) {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
await uploadFileChunked(providerKey, 'credentials', file, statusEl, (filePath) => {
updateCodexConfig(providerKey, 'credentials_file', filePath);
});
}
async function authenticateKilo(key) {
......@@ -1226,33 +1557,11 @@ async function checkKiloAuth(key) {
async function uploadKiloFile(providerKey, file) {
if (!file) return;
const statusEl = document.getElementById(`kilo-upload-status-${providerKey}`);
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Uploading file...</p>';
const formData = new FormData();
formData.append('file', file);
formData.append('provider_key', providerKey);
formData.append('file_type', 'credentials');
try {
const response = await fetch('{{ url_for(request, "/dashboard/providers/upload-auth-file") }}', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
statusEl.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ File uploaded successfully! Path: ${data.file_path}</p>`;
// Update the config with the new file path
updateKiloConfig(providerKey, 'credentials_file', data.file_path);
} else {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Upload failed: ${data.error}</p>`;
}
} catch (error) {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
await uploadFileChunked(providerKey, 'credentials', file, statusEl, (filePath) => {
updateKiloConfig(providerKey, 'credentials_file', filePath);
});
}
// Extension detection and configuration
......@@ -1307,14 +1616,14 @@ async function checkExtensionInstalled() {
async function configureExtension() {
try {
const serverUrl = window.location.origin;
const serverUrl = window.location.href.replace(/\/dashboard.*$/, '');
// Send configuration to extension using postMessage
window.postMessage({
type: 'aisbf-extension-configure',
serverUrl: serverUrl
}, '*');
console.log('Extension configuration sent');
} catch (error) {
console.error('Error configuring extension:', error);
......@@ -1783,68 +2092,24 @@ async function saveProviders() {
async function uploadKiroFile(providerKey, fileType, file) {
if (!file) return;
const statusEl = document.getElementById(`kiro-upload-status-${providerKey}`);
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Uploading file...</p>';
const formData = new FormData();
formData.append('file', file);
formData.append('provider_key', providerKey);
formData.append('file_type', fileType);
try {
const response = await fetch('{{ url_for(request, "/dashboard/providers/upload-auth-file") }}', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
statusEl.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ File uploaded successfully! Path: ${data.file_path}</p>`;
// Update the config with the new file path
if (fileType === 'creds_file') {
updateKiroConfig(providerKey, 'creds_file', data.file_path);
} else if (fileType === 'sqlite_db') {
updateKiroConfig(providerKey, 'sqlite_db', data.file_path);
}
} else {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Upload failed: ${data.error}</p>`;
await uploadFileChunked(providerKey, fileType, file, statusEl, (filePath) => {
if (fileType === 'creds_file') {
updateKiroConfig(providerKey, 'creds_file', filePath);
} else if (fileType === 'sqlite_db') {
updateKiroConfig(providerKey, 'sqlite_db', filePath);
}
} catch (error) {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
});
}
async function uploadClaudeFile(providerKey, file) {
if (!file) return;
const statusEl = document.getElementById(`claude-upload-status-${providerKey}`);
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Uploading file...</p>';
const formData = new FormData();
formData.append('file', file);
formData.append('provider_key', providerKey);
formData.append('file_type', 'credentials');
try {
const response = await fetch('{{ url_for(request, "/dashboard/providers/upload-auth-file") }}', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
statusEl.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ File uploaded successfully! Path: ${data.file_path}</p>`;
// Update the config with the new file path
updateClaudeConfig(providerKey, 'credentials_file', data.file_path);
} else {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Upload failed: ${data.error}</p>`;
}
} catch (error) {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
await uploadFileChunked(providerKey, 'credentials', file, statusEl, (filePath) => {
updateClaudeConfig(providerKey, 'credentials_file', filePath);
});
}
async function getModelsFromProvider(providerKey) {
......
......@@ -21,6 +21,7 @@
<!-- Autoselects will be loaded here -->
</div>
<button class="btn btn-primary" onclick="showAddAutoselectModal()">Add New Autoselect</button>
<button class="btn btn-success" onclick="applyChanges()" style="margin-left: 10px;">✓ Apply Changes</button>
</div>
</div>
......@@ -181,6 +182,48 @@ document.getElementById('autoselect-form').addEventListener('submit', function(e
});
});
async function applyChanges() {
const button = event.target;
const originalText = button.innerHTML;
try {
button.innerHTML = '🔄 Reloading...';
button.disabled = true;
const response = await fetch('{{ url_for(request, "/dashboard/user/reload-config") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
button.innerHTML = '✓ Applied Successfully!';
button.style.background = '#10b981';
setTimeout(() => {
button.innerHTML = originalText;
button.style.background = '';
button.disabled = false;
}, 2000);
} else {
const data = await response.json();
throw new Error(data.error || 'Failed to apply changes');
}
} catch (error) {
button.innerHTML = '✗ Error';
button.style.background = '#ef4444';
setTimeout(() => {
button.innerHTML = originalText;
button.style.background = '';
button.disabled = false;
}, 2000);
alert('Error applying changes: ' + error.message);
}
}
renderAutoselects();
</script>
......
......@@ -21,6 +21,7 @@
<!-- Providers will be loaded here -->
</div>
<button class="btn btn-primary" onclick="showAddProviderModal()">Add New Provider</button>
<button class="btn btn-success" onclick="applyChanges()" style="margin-left: 10px;">✓ Apply Changes</button>
</div>
</div>
......@@ -397,6 +398,48 @@ document.addEventListener('DOMContentLoaded', function() {
});
renderProviders();
async function applyChanges() {
const button = event.target;
const originalText = button.innerHTML;
try {
button.innerHTML = '🔄 Reloading...';
button.disabled = true;
const response = await fetch('{{ url_for(request, "/dashboard/user/reload-config") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
button.innerHTML = '✓ Applied Successfully!';
button.style.background = '#10b981';
setTimeout(() => {
button.innerHTML = originalText;
button.style.background = '';
button.disabled = false;
}, 2000);
} else {
const data = await response.json();
throw new Error(data.error || 'Failed to apply changes');
}
} catch (error) {
button.innerHTML = '✗ Error';
button.style.background = '#ef4444';
setTimeout(() => {
button.innerHTML = originalText;
button.style.background = '';
button.disabled = false;
}, 2000);
alert('Error applying changes: ' + error.message);
}
}
</script>
<style>
......
......@@ -21,6 +21,7 @@
<!-- Rotations will be loaded here -->
</div>
<button class="btn btn-primary" onclick="showAddRotationModal()">Add New Rotation</button>
<button class="btn btn-success" onclick="applyChanges()" style="margin-left: 10px;">✓ Apply Changes</button>
</div>
</div>
......@@ -181,6 +182,48 @@ document.getElementById('rotation-form').addEventListener('submit', function(e)
});
});
async function applyChanges() {
const button = event.target;
const originalText = button.innerHTML;
try {
button.innerHTML = '🔄 Reloading...';
button.disabled = true;
const response = await fetch('{{ url_for(request, "/dashboard/user/reload-config") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
button.innerHTML = '✓ Applied Successfully!';
button.style.background = '#10b981';
setTimeout(() => {
button.innerHTML = originalText;
button.style.background = '';
button.disabled = false;
}, 2000);
} else {
const data = await response.json();
throw new Error(data.error || 'Failed to apply changes');
}
} catch (error) {
button.innerHTML = '✗ Error';
button.style.background = '#ef4444';
setTimeout(() => {
button.innerHTML = originalText;
button.style.background = '';
button.disabled = false;
}, 2000);
alert('Error applying changes: ' + error.message);
}
}
renderRotations();
</script>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment