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 ...@@ -693,6 +693,8 @@ Qwen is Alibaba Cloud's large language model service that provides OAuth2-based
**Qwen Configuration Fields (qwen_config):** **Qwen Configuration Fields (qwen_config):**
- `credentials_file`: Path to OAuth2 credentials file (default: `~/.aisbf/qwen_credentials.json`) - `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) - `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:** **Authentication Flow:**
1. First request triggers OAuth2 Device Authorization flow if no credentials exist 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 ...@@ -708,10 +710,16 @@ Qwen is Alibaba Cloud's large language model service that provides OAuth2-based
3. Internet connection for OAuth2 flow 3. Internet connection for OAuth2 flow
**Available Models:** **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-plus` - Enhanced model with 32K context
- `qwen-turbo` - Fast model for quick responses - `qwen-turbo` - Fast model for quick responses
- `qwen-max` - Top-tier model with advanced capabilities - `qwen-max` - Top-tier model with advanced capabilities
- `coder-model` - Specialized coding model (maps to qwen3.6-plus) - `coder-model` - Specialized coding model (maps to qwen3.6-plus)
- Additional models fetched from DashScope API endpoint
**Usage:** **Usage:**
Once configured, qwen provider can be used like any other provider in AISBF: 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: ...@@ -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 - Use Qwen models in AISBF rotations alongside other providers
- Automatic failover and load balancing with 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):** **API Key Mode (Optional):**
If you prefer to use an API key instead of OAuth2: - Set `api_key` in `qwen_config` to use API key authentication
1. Obtain an API key from Alibaba Cloud DashScope - Model list is fetched dynamically from DashScope `/models` endpoint
2. Set `api_key` in `qwen_config` - If models are defined in provider configuration, uses those instead
3. The provider will use the API key directly instead of OAuth2 - 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 ### Modifying Configuration
1. Edit files in `~/.aisbf/` for user-specific changes 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 ...@@ -828,7 +853,20 @@ This AI.PROMPT file is automatically updated when significant changes are made t
### Recent Updates ### 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 - 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 - 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 - Previously only providers.json, rotations.json, and autoselect.json were copied
......
...@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2 ...@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2
from .handlers import RequestHandler, RotationHandler, AutoselectHandler from .handlers import RequestHandler, RotationHandler, AutoselectHandler
from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model 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__ = [ __all__ = [
# Config # Config
"config", "config",
......
...@@ -79,7 +79,7 @@ def _generate_client_id(): ...@@ -79,7 +79,7 @@ def _generate_client_id():
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # Official Claude Code 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) 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 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" 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__) logger = logging.getLogger(__name__)
...@@ -97,10 +97,10 @@ class ClaudeAuth: ...@@ -97,10 +97,10 @@ class ClaudeAuth:
CLIENT_ID = CLIENT_ID CLIENT_ID = CLIENT_ID
AUTH_URL = AUTH_URL AUTH_URL = AUTH_URL
TOKEN_URL = TOKEN_URL TOKEN_URL = TOKEN_URL
REDIRECT_URI = REDIRECT_URI REDIRECT_URI = DEFAULT_REDIRECT_URI
CLI_USER_AGENT = CLI_USER_AGENT 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. Initialize Claude authentication.
...@@ -113,6 +113,9 @@ class ClaudeAuth: ...@@ -113,6 +113,9 @@ class ClaudeAuth:
# Store credentials in ~/.aisbf/ directory (AISBF config directory) # Store credentials in ~/.aisbf/ directory (AISBF config directory)
self.credentials_file = Path.home() / ".aisbf" / "claude_credentials.json" 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.tokens = self._load_credentials()
self._oauth_state = None # Store state for OAuth flow self._oauth_state = None # Store state for OAuth flow
self._code_verifier = None # Store verifier for OAuth flow self._code_verifier = None # Store verifier for OAuth flow
...@@ -386,7 +389,7 @@ class ClaudeAuth: ...@@ -386,7 +389,7 @@ class ClaudeAuth:
"code": "true", "code": "true",
"client_id": CLIENT_ID, "client_id": CLIENT_ID,
"response_type": "code", "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", "scope": "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
"code_challenge": challenge, "code_challenge": challenge,
"code_challenge_method": "S256", "code_challenge_method": "S256",
...@@ -450,7 +453,7 @@ class ClaudeAuth: ...@@ -450,7 +453,7 @@ class ClaudeAuth:
"state": state, "state": state,
"grant_type": "authorization_code", "grant_type": "authorization_code",
"client_id": CLIENT_ID, "client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI, "redirect_uri": self.redirect_uri,
"code_verifier": verifier "code_verifier": verifier
} }
...@@ -495,7 +498,7 @@ class ClaudeAuth: ...@@ -495,7 +498,7 @@ class ClaudeAuth:
"code": "true", "code": "true",
"client_id": CLIENT_ID, "client_id": CLIENT_ID,
"response_type": "code", "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", "scope": "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
"code_challenge": challenge, "code_challenge": challenge,
"code_challenge_method": "S256", "code_challenge_method": "S256",
...@@ -585,7 +588,7 @@ class ClaudeAuth: ...@@ -585,7 +588,7 @@ class ClaudeAuth:
"state": state, "state": state,
"grant_type": "authorization_code", "grant_type": "authorization_code",
"client_id": CLIENT_ID, "client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI, "redirect_uri": self.redirect_uri,
"code_verifier": verifier "code_verifier": verifier
} }
......
...@@ -42,6 +42,7 @@ logger = logging.getLogger(__name__) ...@@ -42,6 +42,7 @@ logger = logging.getLogger(__name__)
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
DEFAULT_ISSUER = "https://auth.openai.com" DEFAULT_ISSUER = "https://auth.openai.com"
DEFAULT_PORT = 1455 DEFAULT_PORT = 1455
# IMPORTANT: Scopes must match the codex-cli implementation guide
SCOPES = "openid profile email offline_access api.connectors.read api.connectors.invoke" SCOPES = "openid profile email offline_access api.connectors.read api.connectors.invoke"
...@@ -61,7 +62,16 @@ class CodexOAuth2: ...@@ -61,7 +62,16 @@ class CodexOAuth2:
credentials_file: Path to credentials JSON file (default: ~/.aisbf/codex_credentials.json) credentials_file: Path to credentials JSON file (default: ~/.aisbf/codex_credentials.json)
issuer: OAuth2 issuer URL (default: https://auth.openai.com) 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.issuer = (issuer or DEFAULT_ISSUER).rstrip("/")
self.credentials = None self.credentials = None
self._load_credentials() self._load_credentials()
...@@ -85,20 +95,50 @@ class CodexOAuth2: ...@@ -85,20 +95,50 @@ class CodexOAuth2:
credentials: Credentials dict to save credentials: Credentials dict to save
""" """
try: try:
# Ensure directory exists # Path is already expanded and absolute from __init__
os.makedirs(os.path.dirname(self.credentials_file), exist_ok=True) 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 # Write credentials safely
with open(self.credentials_file, 'w') as f: logger.debug(f"CodexOAuth2: Writing credentials to file")
with open(resolved_path, 'w') as f:
json.dump(credentials, f, indent=2) 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) # 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 self.credentials = credentials
logger.info(f"CodexOAuth2: Saved credentials to {self.credentials_file}")
except Exception as e: 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 raise
@staticmethod @staticmethod
......
...@@ -108,9 +108,8 @@ class KiloOAuth2: ...@@ -108,9 +108,8 @@ class KiloOAuth2:
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
headers = { headers = {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/json',
'Content-Length': '0', 'User-Agent': 'Kilocode/1.0 (Firefox/130.0)'
'User-Agent': 'AISBF/0.99.14 (httpx)'
} }
if attempt > 0: if attempt > 0:
......
...@@ -27,7 +27,9 @@ import hashlib ...@@ -27,7 +27,9 @@ import hashlib
import json import json
import logging import logging
import os import os
import platform
import secrets import secrets
import sys
import time import time
import uuid import uuid
from datetime import datetime from datetime import datetime
...@@ -38,6 +40,20 @@ import httpx ...@@ -38,6 +40,20 @@ import httpx
logger = logging.getLogger(__name__) 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 OAuth2 Constants (from qwen-oauth2-analysis.md)
QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai" QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai"
QWEN_OAUTH_DEVICE_CODE_ENDPOINT = f"{QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code" QWEN_OAUTH_DEVICE_CODE_ENDPOINT = f"{QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code"
...@@ -69,7 +85,7 @@ class QwenOAuth2: ...@@ -69,7 +85,7 @@ class QwenOAuth2:
Args: Args:
credentials_file: Path to credentials JSON file (default: ~/.aisbf/qwen_credentials.json) 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.lock_file = os.path.expanduser("~/.aisbf/qwen_credentials.lock")
self.credentials = None self.credentials = None
self._file_mod_time = 0 self._file_mod_time = 0
...@@ -227,25 +243,43 @@ class QwenOAuth2: ...@@ -227,25 +243,43 @@ class QwenOAuth2:
"code_challenge_method": "S256", "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( response = await client.post(
QWEN_OAUTH_DEVICE_CODE_ENDPOINT, QWEN_OAUTH_DEVICE_CODE_ENDPOINT,
headers={ headers=headers,
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
"x-request-id": str(uuid.uuid4()),
},
data=body_data, data=body_data,
timeout=30.0 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: if response.status_code != 200:
error_body = response.text error_body = response.text
raise Exception( raise Exception(
f"Device authorization failed: {response.status_code} {response.reason_phrase}. Response: {error_body}" 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() 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: if "device_code" not in result:
error = result.get("error", "Unknown error") error = result.get("error", "Unknown error")
...@@ -288,17 +322,38 @@ class QwenOAuth2: ...@@ -288,17 +322,38 @@ class QwenOAuth2:
"code_verifier": code_verifier, "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( response = await client.post(
QWEN_OAUTH_TOKEN_ENDPOINT, QWEN_OAUTH_TOKEN_ENDPOINT,
headers={ headers=headers,
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
},
data=body_data, data=body_data,
timeout=30.0 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: if response.status_code == 200:
result = response.json() result = response.json()
...@@ -355,12 +410,20 @@ class QwenOAuth2: ...@@ -355,12 +410,20 @@ class QwenOAuth2:
if token_data: if token_data:
# Success - save credentials # 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 = { credentials = {
"access_token": token_data["access_token"], "access_token": token_data["access_token"],
"refresh_token": token_data.get("refresh_token"), "refresh_token": token_data.get("refresh_token"),
"token_type": token_data.get("token_type", "Bearer"), "token_type": token_data.get("token_type", "Bearer"),
"resource_url": token_data.get("resource_url"), "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", "last_refresh": datetime.utcnow().isoformat() + "Z",
} }
...@@ -415,13 +478,15 @@ class QwenOAuth2: ...@@ -415,13 +478,15 @@ class QwenOAuth2:
"client_id": QWEN_OAUTH_CLIENT_ID, "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: async with httpx.AsyncClient() as client:
response = await client.post( response = await client.post(
QWEN_OAUTH_TOKEN_ENDPOINT, QWEN_OAUTH_TOKEN_ENDPOINT,
headers={ headers=headers,
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
},
data=body_data, data=body_data,
timeout=30.0 timeout=30.0
) )
...@@ -435,7 +500,8 @@ class QwenOAuth2: ...@@ -435,7 +500,8 @@ class QwenOAuth2:
"token_type": result.get("token_type", "Bearer"), "token_type": result.get("token_type", "Bearer"),
"refresh_token": result.get("refresh_token", self.credentials["refresh_token"]), "refresh_token": result.get("refresh_token", self.credentials["refresh_token"]),
"resource_url": result.get("resource_url", self.credentials.get("resource_url")), "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", "last_refresh": datetime.utcnow().isoformat() + "Z",
} }
......
...@@ -68,8 +68,12 @@ class ProviderConfig(BaseModel): ...@@ -68,8 +68,12 @@ class ProviderConfig(BaseModel):
rate_limit: float = 0.0 rate_limit: float = 0.0
api_key: Optional[str] = None # Optional API key in provider config api_key: Optional[str] = None # Optional API key in provider config
models: Optional[List[ProviderModelConfig]] = None # Optional list of models with their configs models: Optional[List[ProviderModelConfig]] = None # Optional list of models with their configs
kiro_config: Optional[Dict] = None # Optional Kiro-specific configuration (credentials, region, etc.) auth_config: Optional[Dict] = None # Unified provider authentication configuration (for all provider types)
claude_config: Optional[Dict] = None # Optional Claude-specific configuration (credentials file path) 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 settings for models in this provider
default_rate_limit: Optional[float] = None default_rate_limit: Optional[float] = None
default_max_request_tokens: Optional[int] = None default_max_request_tokens: Optional[int] = None
...@@ -257,6 +261,31 @@ class Config: ...@@ -257,6 +261,31 @@ class Config:
self._initialize_error_tracking() self._initialize_error_tracking()
self._log_configuration_summary() 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): def _get_config_source_dir(self):
"""Get the directory containing default config files""" """Get the directory containing default config files"""
# If custom config directory is set, use it first # If custom config directory is set, use it first
......
This diff is collapsed.
This diff is collapsed.
...@@ -729,7 +729,14 @@ class GoogleProviderHandler(BaseProviderHandler): ...@@ -729,7 +729,14 @@ class GoogleProviderHandler(BaseProviderHandler):
await self.apply_rate_limit() await self.apply_rate_limit()
models = self.client.models.list() 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 = [] result = []
for model in models: for model in models:
......
This diff is collapsed.
...@@ -502,7 +502,10 @@ class KiroProviderHandler(BaseProviderHandler): ...@@ -502,7 +502,10 @@ class KiroProviderHandler(BaseProviderHandler):
logging.info(f"KiroProviderHandler: ✓ Nexlab API call successful!") logging.info(f"KiroProviderHandler: ✓ Nexlab API call successful!")
if AISBF_DEBUG: 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', [])) 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): ...@@ -671,7 +674,10 @@ class KiroProviderHandler(BaseProviderHandler):
response_data = response.json() response_data = response.json()
if AISBF_DEBUG: 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', []) models_list = response_data.get('models', [])
......
...@@ -68,7 +68,14 @@ class OllamaProviderHandler(BaseProviderHandler): ...@@ -68,7 +68,14 @@ class OllamaProviderHandler(BaseProviderHandler):
try: try:
health_response = await self.client.get("/api/tags", timeout=10.0) 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"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: except Exception as e:
logger.error(f"Ollama health check failed: {str(e)}") logger.error(f"Ollama health check failed: {str(e)}")
logger.error(f"Cannot connect to Ollama at {self.client.base_url}") logger.error(f"Cannot connect to Ollama at {self.client.base_url}")
......
...@@ -174,7 +174,14 @@ class OpenAIProviderHandler(BaseProviderHandler): ...@@ -174,7 +174,14 @@ class OpenAIProviderHandler(BaseProviderHandler):
await self.apply_rate_limit() await self.apply_rate_limit()
models = self.client.models.list() 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 = [] result = []
for model in models: for model in models:
......
...@@ -104,56 +104,101 @@ class QwenProviderHandler(BaseProviderHandler): ...@@ -104,56 +104,101 @@ class QwenProviderHandler(BaseProviderHandler):
return QwenOAuth2(credentials_file=credentials_file) return QwenOAuth2(credentials_file=credentials_file)
def _get_sdk_client(self): 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 import logging
logger = logging.getLogger(__name__) 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() access_token = self.auth.get_valid_token()
if not access_token: if not access_token:
logger.error("QwenProviderHandler: No OAuth2 access token available") logger.error("QwenProviderHandler: No OAuth2 access token available")
raise Exception("No OAuth2 access token. Please re-authenticate") raise Exception("No OAuth2 access token. Please re-authenticate")
# Get resource URL (API endpoint) logger.info("QwenProviderHandler: Using OAuth2 authentication")
base_url = self.auth.get_resource_url() auth_key = access_token
# Use provider configured endpoint for OAuth2 (fixed endpoints)
base_url = self.provider_config.endpoint
# Normalize endpoint # Normalize endpoint
if not base_url.startswith("http"): if not base_url.startswith("http"):
base_url = f"https://{base_url}" base_url = f"https://{base_url}"
if not base_url.endswith("/v1"): # DashScope endpoint already includes /v1 so do not append again
base_url = f"{base_url}/v1"
self._sdk_client = AsyncOpenAI( self._sdk_client = AsyncOpenAI(
api_key=access_token, api_key=auth_key,
base_url=base_url, base_url=base_url,
max_retries=3, max_retries=3,
timeout=httpx.Timeout(300.0, connect=30.0), 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 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]: 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 import logging
logger = logging.getLogger(__name__) 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() access_token = self.auth.get_valid_token()
if not access_token: if not access_token:
logger.error("QwenProviderHandler: No OAuth2 access token available") logger.error("QwenProviderHandler: No OAuth2 access token available")
raise Exception("No OAuth2 access token. Please re-authenticate") raise Exception("No OAuth2 access token. Please re-authenticate")
auth_value = f"Bearer {access_token}"
auth_type = "qwen-oauth"
headers = { headers = {
"Authorization": f"Bearer {access_token}", "Authorization": auth_value,
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "QwenCode/1.0.0 (linux; x86_64)", "User-Agent": "QwenCode/1.0.0 (linux; x86_64)",
"X-DashScope-CacheControl": "enable", "X-DashScope-CacheControl": "enable",
"X-DashScope-UserAgent": "QwenCode/1.0.0 (linux; x86_64)", "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 return headers
async def handle_request(self, model: str, messages: List[Dict], max_tokens: Optional[int] = None, async def handle_request(self, model: str, messages: List[Dict], max_tokens: Optional[int] = None,
...@@ -390,8 +435,43 @@ class QwenProviderHandler(BaseProviderHandler): ...@@ -390,8 +435,43 @@ class QwenProviderHandler(BaseProviderHandler):
await self.apply_rate_limit() 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: try:
# Get SDK client with current OAuth token # Get SDK client with API key authentication
client = self._get_sdk_client() client = self._get_sdk_client()
# List models using OpenAI SDK # List models using OpenAI SDK
......
This diff is collapsed.
This diff is collapsed.
...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" ...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "aisbf" 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" description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md" readme = "README.md"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
......
...@@ -49,7 +49,7 @@ class InstallCommand(_install): ...@@ -49,7 +49,7 @@ class InstallCommand(_install):
setup( setup(
name="aisbf", name="aisbf",
version="0.99.15", version="0.99.16",
author="AISBF Contributors", author="AISBF Contributors",
author_email="stefy@nexlab.net", 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", 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/>. ...@@ -37,6 +37,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<input type="password" id="password" name="password" required> <input type="password" id="password" name="password" required>
</div> </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> <button type="submit" class="btn" style="width: 100%;">Login</button>
</form> </form>
</div> </div>
......
This diff is collapsed.
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
<!-- Autoselects will be loaded here --> <!-- Autoselects will be loaded here -->
</div> </div>
<button class="btn btn-primary" onclick="showAddAutoselectModal()">Add New Autoselect</button> <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>
</div> </div>
...@@ -181,6 +182,48 @@ document.getElementById('autoselect-form').addEventListener('submit', function(e ...@@ -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(); renderAutoselects();
</script> </script>
......
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
<!-- Providers will be loaded here --> <!-- Providers will be loaded here -->
</div> </div>
<button class="btn btn-primary" onclick="showAddProviderModal()">Add New Provider</button> <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>
</div> </div>
...@@ -397,6 +398,48 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -397,6 +398,48 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
renderProviders(); 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> </script>
<style> <style>
......
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
<!-- Rotations will be loaded here --> <!-- Rotations will be loaded here -->
</div> </div>
<button class="btn btn-primary" onclick="showAddRotationModal()">Add New Rotation</button> <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>
</div> </div>
...@@ -181,6 +182,48 @@ document.getElementById('rotation-form').addEventListener('submit', function(e) ...@@ -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(); renderRotations();
</script> </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