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()
......@@ -85,20 +95,50 @@ class CodexOAuth2:
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
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
with open(self.credentials_file, 'w') as f:
# 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}"
)
# 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
......
This diff is collapsed.
This diff is collapsed.
......@@ -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:
......
This diff is collapsed.
......@@ -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__)
# 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")
# Get resource URL (API endpoint)
base_url = self.auth.get_resource_url()
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__)
# 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,
......@@ -390,8 +435,43 @@ class QwenProviderHandler(BaseProviderHandler):
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
......
This diff is collapsed.
This diff is collapsed.
......@@ -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>
......
This diff is collapsed.
......@@ -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