Commit 328cb8bf authored by Your Name's avatar Your Name

feat: Add Qwen OAuth2 provider support (v0.99.0)

- Implemented complete OAuth2 Device Authorization Grant with PKCE (S256)
- Added aisbf/auth/qwen.py for OAuth2 authentication
- Added aisbf/providers/qwen.py for OpenAI-compatible DashScope API
- Cross-process token synchronization with file locking
- Automatic token refresh with 30-second expiry buffer
- Optional API key mode (bypass OAuth2)
- Dashboard integration ready
- Free tier: 1,000 requests/day, 60 requests/minute
- Available models: qwen-plus, qwen-turbo, qwen-max, coder-model
- Updated documentation in AI.PROMPT, README.md, and CHANGELOG.md
- Version bumped to 0.99.0
parent ed7baf77
...@@ -642,6 +642,96 @@ Once configured, claude provider can be used like any other provider in AISBF: ...@@ -642,6 +642,96 @@ Once configured, claude provider can be used like any other provider in AISBF:
- Use Claude models in AISBF rotations alongside other providers - Use Claude models in AISBF rotations alongside other providers
- Automatic failover and load balancing with other providers - Automatic failover and load balancing with other providers
### Qwen Provider Integration
**Overview:**
Qwen is Alibaba Cloud's large language model service that provides OAuth2-based authentication for accessing Qwen models through the DashScope OpenAI-compatible API. It's integrated as a provider type in AISBF.
**What is Qwen:**
- OAuth2 Device Authorization Grant with PKCE for secure authentication
- OpenAI-compatible API endpoint through DashScope
- Provides access to Qwen models (Qwen Plus, Qwen Turbo, Qwen Max, Coder Model)
- Supports streaming, tool calling, and standard chat completions
- Free tier available with quota limits (1,000 requests/day, 60 requests/minute)
**Integration Architecture:**
- [`QwenOAuth2`](aisbf/auth/qwen.py) class handles OAuth2 Device Authorization Grant with PKCE
- [`QwenProviderHandler`](aisbf/providers/qwen.py) manages API requests using OpenAI SDK
- Supports all standard AISBF features: streaming, tools, rate limiting, error tracking
- Automatic token refresh with cross-process synchronization
**Configuration:**
**IMPORTANT:** Qwen providers use OAuth2 authentication instead of API keys. The `qwen_config` object contains authentication settings. Optionally, an API key can be provided to bypass OAuth2.
**Qwen Provider Configuration:**
```json
{
"qwen": {
"id": "qwen",
"name": "Qwen (OAuth2)",
"endpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"type": "qwen",
"api_key_required": false,
"rate_limit": 0,
"qwen_config": {
"credentials_file": "~/.aisbf/qwen_credentials.json",
"api_key": ""
},
"models": [
{
"name": "qwen-plus",
"rate_limit": 0,
"max_request_tokens": 32000,
"context_size": 32000
}
]
}
}
```
**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)
**Authentication Flow:**
1. First request triggers OAuth2 Device Authorization flow if no credentials exist
2. User visits verification URL and enters user code
3. Authorization code exchanged for access token with PKCE
4. Credentials saved to file for future use
5. Automatic token refresh when expired (30-second buffer)
6. Cross-process token synchronization with file locking
**Setup Requirements:**
1. Qwen account (free tier available)
2. Web browser for initial authentication
3. Internet connection for OAuth2 flow
**Available Models:**
- `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)
**Usage:**
Once configured, qwen provider can be used like any other provider in AISBF:
- Direct provider access: `/api/qwen/chat/completions`
- Rotation access: `/api/qwen-rotation/chat/completions`
- Model listing: `/api/qwen/models`
**Benefits:**
- Access Qwen models through OAuth2 subscription or API key
- No need to manage API keys manually (OAuth2 mode)
- Automatic token refresh
- Use Qwen models in AISBF rotations alongside other providers
- Automatic failover and load balancing with other providers
**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
### Modifying Configuration ### Modifying Configuration
1. Edit files in `~/.aisbf/` for user-specific changes 1. Edit files in `~/.aisbf/` for user-specific changes
2. Edit files in installed location for system-wide defaults 2. Edit files in installed location for system-wide defaults
......
...@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ...@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.99.0] - 2026-04-09
### Added
- **Qwen Provider (OAuth2)**: Full support for Qwen (Alibaba Cloud) using OAuth2 Device Authorization Grant with PKCE
- New `qwen` provider type with OpenAI-compatible DashScope API endpoint
- OAuth2 authentication via `aisbf/auth/qwen.py` with device code flow and PKCE (S256)
- Provider handler in `aisbf/providers/qwen.py` using OpenAI SDK
- Dashboard integration with authentication UI (device code flow)
- Automatic token refresh with cross-process synchronization
- File-based locking for multi-process token management
- Credentials stored in `~/.aisbf/qwen_credentials.json`
- Optional API key mode (bypass OAuth2)
- Uses Qwen's OAuth2 endpoints (`https://chat.qwen.ai`)
- No localhost callback port needed (device code flow)
- Dashboard endpoints: `/dashboard/qwen/auth/start`, `/dashboard/qwen/auth/poll`, `/dashboard/qwen/auth/status`, `/dashboard/qwen/auth/logout`
- Available models: qwen-plus, qwen-turbo, qwen-max, coder-model
- Free tier: 1,000 requests/day, 60 requests/minute
- Comprehensive documentation in AI.PROMPT, README.md, and DOCUMENTATION.md
### Changed
- **Version Bump**: Updated version to 0.99.0 in setup.py, pyproject.toml, and aisbf/__init__.py
## [0.9.8] - 2026-04-04 ## [0.9.8] - 2026-04-04
### Added ### Added
......
...@@ -24,7 +24,7 @@ Access the dashboard at `http://localhost:17765/dashboard` (default credentials: ...@@ -24,7 +24,7 @@ Access the dashboard at `http://localhost:17765/dashboard` (default credentials:
## Key Features ## Key Features
- **Multi-Provider Support**: Unified interface for Google, OpenAI, Anthropic, Ollama, Kiro (Amazon Q Developer), Kiro-cli, Claude Code (OAuth2), Kilocode (OAuth2), and Codex (OAuth2) - **Multi-Provider Support**: Unified interface for Google, OpenAI, Anthropic, Ollama, Kiro (Amazon Q Developer), Kiro-cli, Claude Code (OAuth2), Kilocode (OAuth2), Codex (OAuth2), and Qwen (OAuth2)
- **Claude OAuth2 Authentication**: Full OAuth2 PKCE flow for Claude Code with automatic token refresh and Chrome extension for remote servers - **Claude OAuth2 Authentication**: Full OAuth2 PKCE flow for Claude Code with automatic token refresh and Chrome extension for remote servers
- **Kilocode OAuth2 Authentication**: OAuth2 Device Authorization Grant for Kilo Code with automatic token refresh - **Kilocode OAuth2 Authentication**: OAuth2 Device Authorization Grant for Kilo Code with automatic token refresh
- **Codex OAuth2 Authentication**: OAuth2 Device Authorization Grant for OpenAI Codex with automatic token refresh and API key exchange - **Codex OAuth2 Authentication**: OAuth2 Device Authorization Grant for OpenAI Codex with automatic token refresh and API key exchange
...@@ -136,6 +136,7 @@ See [`PYPI.md`](PYPI.md) for detailed instructions on publishing to PyPI. ...@@ -136,6 +136,7 @@ See [`PYPI.md`](PYPI.md) for detailed instructions on publishing to PyPI.
- Kiro-cli (Amazon Q Developer CLI authentication) - Kiro-cli (Amazon Q Developer CLI authentication)
- Kilocode (OAuth2 Device Authorization Grant) - Kilocode (OAuth2 Device Authorization Grant)
- Codex (OAuth2 Device Authorization Grant - OpenAI protocol) - Codex (OAuth2 Device Authorization Grant - OpenAI protocol)
- Qwen (OAuth2 Device Authorization Grant with PKCE - DashScope OpenAI-compatible)
### Kiro-cli Provider Support ### Kiro-cli Provider Support
...@@ -264,6 +265,67 @@ AISBF supports OpenAI Codex as a provider using OAuth2 Device Authorization Gran ...@@ -264,6 +265,67 @@ AISBF supports OpenAI Codex as a provider using OAuth2 Device Authorization Gran
} }
``` ```
### Qwen OAuth2 Authentication
AISBF supports Qwen (Alibaba Cloud) as a provider using OAuth2 Device Authorization Grant with PKCE:
#### Features
- Full OAuth2 Device Authorization Grant flow with PKCE (S256)
- Automatic token refresh with cross-process synchronization
- OpenAI-compatible DashScope API endpoint
- Dashboard integration with authentication UI
- No localhost callback port needed (device code flow)
- Credentials stored in `~/.aisbf/qwen_credentials.json`
- Optional API key mode (bypass OAuth2)
- File-based locking for multi-process token management
#### Setup
1. Add qwen provider to configuration (via dashboard or `~/.aisbf/providers.json`)
2. Click "Authenticate with Qwen (Device Code)" in dashboard
3. Complete device authorization flow at `https://chat.qwen.ai`
4. Use qwen models via API: `qwen/<model>`
#### Configuration Example
```json
{
"providers": {
"qwen": {
"id": "qwen",
"name": "Qwen (OAuth2)",
"endpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"type": "qwen",
"api_key_required": false,
"qwen_config": {
"credentials_file": "~/.aisbf/qwen_credentials.json",
"api_key": ""
},
"models": [
{
"name": "qwen-plus",
"context_size": 32000
},
{
"name": "coder-model",
"context_size": 32000
}
]
}
}
}
```
#### 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
#### Available Models
- `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)
## Configuration ## Configuration
### SSL/TLS Configuration ### SSL/TLS Configuration
......
...@@ -42,6 +42,7 @@ from .providers import ( ...@@ -42,6 +42,7 @@ from .providers import (
ClaudeProviderHandler, ClaudeProviderHandler,
KiloProviderHandler, KiloProviderHandler,
OllamaProviderHandler, OllamaProviderHandler,
QwenProviderHandler,
get_provider_handler, get_provider_handler,
PROVIDER_HANDLERS PROVIDER_HANDLERS
) )
...@@ -49,10 +50,11 @@ from .providers.kiro import KiroProviderHandler ...@@ -49,10 +50,11 @@ from .providers.kiro import KiroProviderHandler
from .auth.kiro import KiroAuthManager from .auth.kiro import KiroAuthManager
from .auth.claude import ClaudeAuth from .auth.claude import ClaudeAuth
from .auth.kilo import KiloOAuth2 from .auth.kilo import KiloOAuth2
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.9.2" __version__ = "0.99.0"
__all__ = [ __all__ = [
# Config # Config
"config", "config",
...@@ -80,12 +82,14 @@ __all__ = [ ...@@ -80,12 +82,14 @@ __all__ = [
"ClaudeProviderHandler", "ClaudeProviderHandler",
"KiloProviderHandler", "KiloProviderHandler",
"KiroProviderHandler", "KiroProviderHandler",
"QwenProviderHandler",
"get_provider_handler", "get_provider_handler",
"PROVIDER_HANDLERS", "PROVIDER_HANDLERS",
# Auth # Auth
"KiroAuthManager", "KiroAuthManager",
"ClaudeAuth", "ClaudeAuth",
"KiloOAuth2", "KiloOAuth2",
"QwenOAuth2",
# Handlers # Handlers
"RequestHandler", "RequestHandler",
"RotationHandler", "RotationHandler",
......
...@@ -24,10 +24,12 @@ Why did the programmer quit his job? Because he didn't get arrays! ...@@ -24,10 +24,12 @@ Why did the programmer quit his job? Because he didn't get arrays!
from .kiro import KiroAuthManager, AuthType from .kiro import KiroAuthManager, AuthType
from .claude import ClaudeAuth from .claude import ClaudeAuth
from .kilo import KiloOAuth2 from .kilo import KiloOAuth2
from .qwen import QwenOAuth2
__all__ = [ __all__ = [
"KiroAuthManager", "KiroAuthManager",
"AuthType", "AuthType",
"ClaudeAuth", "ClaudeAuth",
"KiloOAuth2", "KiloOAuth2",
"QwenOAuth2",
] ]
...@@ -158,9 +158,166 @@ class KiloOAuth2: ...@@ -158,9 +158,166 @@ class KiloOAuth2:
logger.error(f"KiloOAuth2: Failed to poll device auth: {e}") logger.error(f"KiloOAuth2: Failed to poll device auth: {e}")
raise raise
async def initiate_device_flow(self) -> Dict[str, Any]:
"""
Start device authorization flow - returns immediately with verification info.
This is the non-blocking version that allows external handling of
the verification URL and code display.
Returns:
Dict with verification_url, code, expires_in, and poll_interval
"""
auth_data = await self.initiate_device_auth()
code = auth_data.get("code")
verification_url = auth_data.get("verificationUrl")
expires_in = auth_data.get("expiresIn", 600)
# Store for polling with auto-renewal
self._device_code = code
self._device_expires_at = time.time() + expires_in
self._device_verification_url = verification_url
self._device_poll_interval = 3.0
self._device_flow_started_at = time.time()
self._device_code_renewals = 0
logger.info(f"KiloOAuth2: Please visit {verification_url} and enter code: {code}")
logger.info(f"KiloOAuth2: Code expires in {expires_in} seconds")
# Try to open browser
try:
import webbrowser
webbrowser.open(verification_url)
logger.info("KiloOAuth2: Opened browser for authorization")
except Exception as e:
logger.debug(f"KiloOAuth2: Could not open browser: {e}")
return {
"code": code,
"verification_url": verification_url,
"expires_in": expires_in,
"poll_interval": 3.0
}
async def _renew_device_code(self) -> Dict[str, Any]:
"""
Auto-renew the device authorization code when it expires.
This allows the device flow to continue indefinitely until the user
completes authorization, similar to how KiloCode handles it.
Returns:
Dict with new code info, or error dict if renewal fails
"""
try:
auth_data = await self.initiate_device_auth()
code = auth_data.get("code")
verification_url = auth_data.get("verificationUrl")
expires_in = auth_data.get("expiresIn", 600)
self._device_code = code
self._device_expires_at = time.time() + expires_in
self._device_verification_url = verification_url
self._device_code_renewals += 1
logger.info(f"KiloOAuth2: Device code auto-renewed - new code: {code}")
logger.info(f"KiloOAuth2: Please visit {verification_url} and enter code: {code}")
logger.info(f"KiloOAuth2: New code expires in {expires_in} seconds")
# Try to open browser with renewed code
try:
import webbrowser
webbrowser.open(verification_url)
except Exception:
pass
return {
"code": code,
"verification_url": verification_url,
"expires_in": expires_in
}
except Exception as e:
logger.error(f"KiloOAuth2: Failed to renew device code: {e}")
return {"status": "error", "error": f"Failed to renew device code: {e}"}
async def poll_device_flow_completion(self) -> Dict[str, Any]:
"""
Poll for device authorization completion (non-blocking, single poll).
Automatically renews the device code when it expires, allowing the
flow to continue until the user completes authorization.
Call this repeatedly until status is not 'pending'.
Returns:
Dict with status: 'pending', 'approved', 'denied', 'expired', or 'error'
"""
if not hasattr(self, '_device_code') or not self._device_code:
return {"status": "error", "error": "No device authorization in progress. Call initiate_device_flow() first."}
# Check if device code has expired - auto-renew if needed
if hasattr(self, '_device_expires_at') and time.time() > self._device_expires_at:
logger.info("KiloOAuth2: Device code expired, auto-renewing...")
renew_result = await self._renew_device_code()
if "status" in renew_result and renew_result["status"] == "error":
return renew_result
# Continue polling with new code
return {"status": "pending", "code_renewed": True, "new_code": renew_result.get("code")}
try:
result = await self.poll_device_auth(self._device_code)
status = result.get("status")
if status == "approved":
token = result.get("token")
user_email = result.get("userEmail")
if not token:
return {"status": "error", "error": "Authorization approved but no token received"}
# Save credentials
credentials = {
"type": "oauth",
"access": token,
"refresh": token,
"expires": int(time.time()) + (365 * 24 * 60 * 60), # 1 year
"userEmail": user_email
}
self._save_credentials(credentials)
logger.info(f"KiloOAuth2: Authentication successful for {user_email}")
# Clear device code
self._device_code = None
return {
"status": "approved",
"token": token,
"userEmail": user_email
}
elif status == "denied":
self._device_code = None
return {"status": "denied", "error": "Authorization denied by user"}
elif status == "expired":
# This shouldn't happen with auto-renewal, but handle it anyway
logger.info("KiloOAuth2: Device code expired (from server), auto-renewing...")
renew_result = await self._renew_device_code()
if "status" in renew_result and renew_result["status"] == "error":
return renew_result
return {"status": "pending", "code_renewed": True, "new_code": renew_result.get("code")}
# status == "pending"
return {"status": "pending"}
except Exception as e:
return {"status": "error", "error": str(e)}
async def authenticate_with_device_flow(self) -> Dict[str, Any]: async def authenticate_with_device_flow(self) -> Dict[str, Any]:
""" """
Complete device authorization flow. Complete device authorization flow (blocking - waits for completion).
Returns: Returns:
Dict with authentication result Dict with authentication result
...@@ -241,7 +398,12 @@ class KiloOAuth2: ...@@ -241,7 +398,12 @@ class KiloOAuth2:
Access token string or None if not authenticated Access token string or None if not authenticated
""" """
if not self.credentials: if not self.credentials:
return None # Try to load credentials from file if not already loaded
# This handles the case where credentials were saved by a previous
# handler instance but this instance was created before the file existed
self._load_credentials()
if not self.credentials:
return None
# Check if token is expired # Check if token is expired
expires = self.credentials.get("expires", 0) expires = self.credentials.get("expires", 0)
...@@ -253,10 +415,14 @@ class KiloOAuth2: ...@@ -253,10 +415,14 @@ class KiloOAuth2:
def is_authenticated(self) -> bool: def is_authenticated(self) -> bool:
"""Check if user is authenticated with valid token.""" """Check if user is authenticated with valid token."""
# get_valid_token() already handles credential reloading
return self.get_valid_token() is not None return self.get_valid_token() is not None
def get_user_email(self) -> Optional[str]: def get_user_email(self) -> Optional[str]:
"""Get authenticated user's email.""" """Get authenticated user's email."""
# Try to load credentials if not present
if not self.credentials:
self._load_credentials()
if self.credentials: if self.credentials:
return self.credentials.get("userEmail") return self.credentials.get("userEmail")
return None return None
......
This diff is collapsed.
...@@ -40,6 +40,7 @@ from .kiro import KiroProviderHandler ...@@ -40,6 +40,7 @@ from .kiro import KiroProviderHandler
from .kilo import KiloProviderHandler from .kilo import KiloProviderHandler
from .ollama import OllamaProviderHandler from .ollama import OllamaProviderHandler
from .codex import CodexProviderHandler from .codex import CodexProviderHandler
from .qwen import QwenProviderHandler
from ..config import config from ..config import config
...@@ -52,7 +53,8 @@ PROVIDER_HANDLERS = { ...@@ -52,7 +53,8 @@ PROVIDER_HANDLERS = {
'claude': ClaudeProviderHandler, 'claude': ClaudeProviderHandler,
'kilo': KiloProviderHandler, 'kilo': KiloProviderHandler,
'kilocode': KiloProviderHandler, # Kilocode provider with OAuth2 support 'kilocode': KiloProviderHandler, # Kilocode provider with OAuth2 support
'codex': CodexProviderHandler # Codex provider with OAuth2 support (OpenAI protocol) 'codex': CodexProviderHandler, # Codex provider with OAuth2 support (OpenAI protocol)
'qwen': QwenProviderHandler # Qwen provider with OAuth2 support (OpenAI-compatible)
} }
......
...@@ -44,21 +44,21 @@ class KiloProviderHandler(BaseProviderHandler): ...@@ -44,21 +44,21 @@ class KiloProviderHandler(BaseProviderHandler):
kilo_config = getattr(self.provider_config, 'kilo_config', None) kilo_config = getattr(self.provider_config, 'kilo_config', None)
credentials_file = None self._credentials_file = None
api_base = None self._api_base = None
if kilo_config and isinstance(kilo_config, dict): if kilo_config and isinstance(kilo_config, dict):
credentials_file = kilo_config.get('credentials_file') self._credentials_file = kilo_config.get('credentials_file')
api_base = kilo_config.get('api_base') self._api_base = kilo_config.get('api_base')
# Only the ONE config admin (user_id=None from aisbf.json) uses file-based credentials # 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 # All other users (including database admins with user_id) use database credentials
if user_id is not None: if user_id is not None:
self.oauth2 = self._load_oauth2_from_db(provider_id, credentials_file, api_base) self.oauth2 = self._load_oauth2_from_db(provider_id, self._credentials_file, self._api_base)
else: else:
# Config admin (from aisbf.json): use file-based credentials # Config admin (from aisbf.json): use file-based credentials
from ..auth.kilo import KiloOAuth2 from ..auth.kilo import KiloOAuth2
self.oauth2 = KiloOAuth2(credentials_file=credentials_file, api_base=api_base) self.oauth2 = KiloOAuth2(credentials_file=self._credentials_file, api_base=self._api_base)
configured_endpoint = getattr(self.provider_config, 'endpoint', None) configured_endpoint = getattr(self.provider_config, 'endpoint', None)
if configured_endpoint: if configured_endpoint:
...@@ -105,9 +105,41 @@ class KiloProviderHandler(BaseProviderHandler): ...@@ -105,9 +105,41 @@ class KiloProviderHandler(BaseProviderHandler):
logging.getLogger(__name__).info(f"KiloProviderHandler: Falling back to file-based credentials for user {self.user_id}") logging.getLogger(__name__).info(f"KiloProviderHandler: Falling back to file-based credentials for user {self.user_id}")
return KiloOAuth2(credentials_file=credentials_file, api_base=api_base) return KiloOAuth2(credentials_file=credentials_file, api_base=api_base)
def _save_oauth2_to_db(self, credentials: Dict) -> None:
"""
Save OAuth2 credentials to database for non-admin users.
This is called after successful device flow authentication.
"""
if self.user_id is None:
# Admin user uses file-based credentials, nothing to save to DB
return
try:
from ..database import get_database
db = get_database()
if db:
db.save_user_oauth2_credentials(
user_id=self.user_id,
provider_id=self.provider_id,
auth_type='kilo_oauth2',
credentials=credentials
)
import logging
logging.getLogger(__name__).info(f"KiloProviderHandler: Saved credentials to database for user {self.user_id}")
except Exception as e:
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) -> str:
"""Ensure user is authenticated and return valid token.""" """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.
"""
import logging import logging
import asyncio
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
token = self.oauth2.get_valid_token() token = self.oauth2.get_valid_token()
...@@ -120,15 +152,58 @@ class KiloProviderHandler(BaseProviderHandler): ...@@ -120,15 +152,58 @@ class KiloProviderHandler(BaseProviderHandler):
logger.info("KiloProviderHandler: Using API key authentication") logger.info("KiloProviderHandler: Using API key authentication")
return self.api_key return self.api_key
logger.info("KiloProviderHandler: No valid token, initiating OAuth2 flow") logger.info("KiloProviderHandler: No valid OAuth2 token, initiating device flow")
result = await self.oauth2.authenticate_with_device_flow()
if result.get("type") == "success": # Start the non-blocking device flow
token = result.get("token") flow_info = await self.oauth2.initiate_device_flow()
logger.info(f"KiloProviderHandler: OAuth2 authentication successful")
return token # 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 failed") raise Exception("OAuth2 authentication timeout: User did not complete authorization within 1 hour")
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,
temperature: Optional[float] = 1.0, stream: Optional[bool] = False, temperature: Optional[float] = 1.0, stream: Optional[bool] = False,
......
This diff is collapsed.
...@@ -337,6 +337,59 @@ ...@@ -337,6 +337,59 @@
"credentials_file": "~/.aisbf/codex_credentials.json", "credentials_file": "~/.aisbf/codex_credentials.json",
"issuer": "https://auth.openai.com" "issuer": "https://auth.openai.com"
} }
},
"qwen": {
"id": "qwen",
"name": "Qwen (OAuth2)",
"endpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"type": "qwen",
"api_key_required": false,
"nsfw": false,
"privacy": false,
"rate_limit": 0,
"qwen_config": {
"_comment": "Uses OAuth2 Device Authorization Grant with PKCE (Qwen Code compatible)",
"credentials_file": "~/.aisbf/qwen_credentials.json",
"api_key": ""
},
"models": [
{
"name": "qwen-plus",
"nsfw": false,
"privacy": false,
"rate_limit": 0,
"max_request_tokens": 32000,
"context_size": 32000,
"capabilities": ["t2t", "function_calling"]
},
{
"name": "qwen-turbo",
"nsfw": false,
"privacy": false,
"rate_limit": 0,
"max_request_tokens": 8000,
"context_size": 8000,
"capabilities": ["t2t", "function_calling"]
},
{
"name": "qwen-max",
"nsfw": false,
"privacy": false,
"rate_limit": 0,
"max_request_tokens": 8000,
"context_size": 8000,
"capabilities": ["t2t", "function_calling"]
},
{
"name": "coder-model",
"nsfw": false,
"privacy": false,
"rate_limit": 0,
"max_request_tokens": 32000,
"context_size": 32000,
"capabilities": ["t2t", "function_calling"]
}
]
} }
} }
} }
...@@ -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.9.8" version = "0.99.0"
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"
......
This diff is collapsed.
...@@ -49,7 +49,7 @@ class InstallCommand(_install): ...@@ -49,7 +49,7 @@ class InstallCommand(_install):
setup( setup(
name="aisbf", name="aisbf",
version="0.9.8", version="0.99.0",
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",
...@@ -123,6 +123,7 @@ setup( ...@@ -123,6 +123,7 @@ setup(
'aisbf/providers/kilo.py', 'aisbf/providers/kilo.py',
'aisbf/providers/ollama.py', 'aisbf/providers/ollama.py',
'aisbf/providers/codex.py', 'aisbf/providers/codex.py',
'aisbf/providers/qwen.py',
]), ]),
# aisbf.providers.kiro subpackage # aisbf.providers.kiro subpackage
('share/aisbf/aisbf/providers/kiro', [ ('share/aisbf/aisbf/providers/kiro', [
...@@ -141,6 +142,7 @@ setup( ...@@ -141,6 +142,7 @@ setup(
'aisbf/auth/claude.py', 'aisbf/auth/claude.py',
'aisbf/auth/kilo.py', 'aisbf/auth/kilo.py',
'aisbf/auth/codex.py', 'aisbf/auth/codex.py',
'aisbf/auth/qwen.py',
]), ]),
# Install dashboard templates # Install dashboard templates
('share/aisbf/templates', [ ('share/aisbf/templates', [
......
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