Commit 1c25697e authored by Your Name's avatar Your Name

Correcyt templating

parent 483b2f62
{
"name": ".kilo",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@kilocode/plugin": "7.2.14"
}
},
"node_modules/@kilocode/plugin": {
"version": "7.2.14",
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.14.tgz",
"integrity": "sha512-mS+WA9HZIBH2qQ9ARA+v0q4MdQTSdfOvKbe4AOSkjP+P5hVA70OM/UVM9DVcvmjSOxU+wuUxmOy+j/EQIrgFmw==",
"license": "MIT",
"dependencies": {
"@kilocode/sdk": "7.2.14",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.97",
"@opentui/solid": ">=0.1.97"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@kilocode/sdk": {
"version": "7.2.14",
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.14.tgz",
"integrity": "sha512-Naz83lFrsbavuDp6UwxRuglOaSNvRBsZfcRNvb7RpWYAwbuJP0dBdhpXj6uO3ta5qxeQ2JzxKNC9Ffz+LCLLDg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
......@@ -28,10 +28,11 @@ Access the dashboard at `http://localhost:17765/dashboard` (default credentials:
## Key Features
- **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)
- **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 (API Key/OAuth2 - OAuth2 discontinued)
- **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
- **Codex OAuth2 Authentication**: OAuth2 Device Authorization Grant for OpenAI Codex with automatic token refresh and API key exchange
- **Qwen Authentication**: API key authentication (recommended) or OAuth2 (discontinued as of April 2026)
- **Rotation Models**: Weighted load balancing across multiple providers with automatic failover
- **Autoselect Models**: AI-powered model selection based on content analysis and request characteristics
- **Semantic Classification**: Fast hybrid BM25 + semantic model selection using sentence transformers (optional)
......@@ -140,7 +141,7 @@ See [`PYPI.md`](PYPI.md) for detailed instructions on publishing to PyPI.
- Kiro-cli (Amazon Q Developer CLI authentication)
- Kilocode (OAuth2 Device Authorization Grant)
- Codex (OAuth2 Device Authorization Grant - OpenAI protocol)
- Qwen (OAuth2 Device Authorization Grant with PKCE - DashScope OpenAI-compatible)
- Qwen (API Key or OAuth2 - OAuth2 discontinued April 2026)
### Kiro-cli Provider Support
......@@ -271,11 +272,18 @@ 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:
> **⚠️ IMPORTANT NOTICE: Qwen OAuth2 Service Discontinued (April 2026)**
>
> Qwen has completely disabled OAuth2 subscriptions for Qwen Code. OAuth2 tokens from `chat.qwen.ai` are no longer accepted by the DashScope API.
>
> **Please use API key authentication instead.** OAuth2 support is maintained in the code for potential future re-enablement, but it is currently non-functional.
AISBF supports Qwen (Alibaba Cloud) as a provider using OAuth2 Device Authorization Grant with PKCE (currently discontinued) or API key authentication:
#### Features
- Full OAuth2 Device Authorization Grant flow with PKCE (S256)
- Automatic token refresh with cross-process synchronization
- **API Key Authentication (Recommended)**: Direct API key authentication with DashScope
- Full OAuth2 Device Authorization Grant flow with PKCE (S256) - **DISCONTINUED**
- Automatic token refresh with cross-process synchronization - **DISCONTINUED**
- OpenAI-compatible DashScope API endpoint
- Dashboard integration with authentication UI
- No localhost callback port needed (device code flow)
......@@ -284,11 +292,21 @@ AISBF supports Qwen (Alibaba Cloud) as a provider using OAuth2 Device Authorizat
- File-based locking for multi-process token management
#### Setup
**Using API Key (Recommended):**
1. Add qwen provider to configuration (via dashboard or `~/.aisbf/providers.json`)
2. Enter your Qwen API key in the dashboard
3. Select your region (China, Singapore, US, etc.)
4. Use qwen models via API: `qwen/<model>`
**Using OAuth2 (Discontinued - Not Working):**
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>`
**Note:** OAuth2 authentication will fail with 401 errors. Use API key authentication instead.
#### Configuration Example
```json
{
......
......@@ -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.33"
__version__ = "0.99.35"
__all__ = [
# Config
"config",
......
......@@ -5,6 +5,16 @@ AISBF - AI Service Broker Framework || AI Should Be Free
Qwen OAuth2 Device Authorization Grant implementation.
⚠️ WARNING: QWEN OAUTH2 SERVICE DISCONTINUED ⚠️
As of April 2026, Qwen has completely disabled OAuth2 subscriptions for Qwen Code.
The OAuth2 tokens obtained from chat.qwen.ai are no longer valid for the DashScope API.
This implementation is maintained in the hope that Qwen will re-enable OAuth2 support
in the future. If the service remains discontinued, this code will eventually be removed.
For now, please use API key authentication instead of OAuth2 for Qwen/DashScope services.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
......@@ -74,6 +84,14 @@ class QwenOAuth2:
"""
OAuth2 Device Authorization Grant implementation for Qwen.
⚠️ WARNING: QWEN OAUTH2 SERVICE DISCONTINUED ⚠️
As of April 2026, Qwen has completely disabled OAuth2 subscriptions for Qwen Code.
OAuth2 tokens from chat.qwen.ai are no longer accepted by the DashScope API.
This implementation is maintained for potential future re-enablement by Qwen.
Please use API key authentication instead.
Implements RFC 8628 device authorization flow with PKCE for CLI/desktop applications.
Supports authentication with Qwen's OAuth2 endpoints and automatic token refresh.
"""
......@@ -82,9 +100,17 @@ class QwenOAuth2:
"""
Initialize Qwen OAuth2 client.
⚠️ WARNING: OAuth2 authentication for Qwen has been discontinued.
This client will not work with DashScope API. Use API key authentication instead.
Args:
credentials_file: Path to credentials JSON file (default: ~/.aisbf/qwen_credentials.json)
"""
logger.warning(
"⚠️ Qwen OAuth2 service has been discontinued by Qwen. "
"OAuth2 tokens are no longer accepted by DashScope API. "
"Please use API key authentication instead."
)
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
......@@ -419,11 +445,20 @@ class QwenOAuth2:
if expires_in_ms < 3600000:
expires_in_ms = 3600000
# WORKAROUND: OAuth2 server returns incorrect resource_url ("portal.qwen.ai")
# Always use the correct DashScope API endpoint
resource_url = token_data.get("resource_url")
if resource_url and "portal.qwen.ai" in resource_url:
logger.warning(f"QwenOAuth2: OAuth2 server returned incorrect resource_url '{resource_url}', using correct endpoint")
resource_url = DEFAULT_DASHSCOPE_BASE_URL
elif not resource_url:
resource_url = DEFAULT_DASHSCOPE_BASE_URL
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"),
"resource_url": resource_url,
"expiry_date": int(time.time() * 1000) + expires_in_ms,
"last_refresh": datetime.utcnow().isoformat() + "Z",
}
......@@ -495,12 +530,21 @@ class QwenOAuth2:
if response.status_code == 200:
result = response.json()
# WORKAROUND: OAuth2 server returns incorrect resource_url ("portal.qwen.ai")
# Always use the correct DashScope API endpoint
resource_url = result.get("resource_url", self.credentials.get("resource_url"))
if resource_url and "portal.qwen.ai" in resource_url:
logger.warning(f"QwenOAuth2: OAuth2 server returned incorrect resource_url '{resource_url}', using correct endpoint")
resource_url = DEFAULT_DASHSCOPE_BASE_URL
elif not resource_url:
resource_url = DEFAULT_DASHSCOPE_BASE_URL
# Update credentials
credentials = {
"access_token": result["access_token"],
"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")),
"resource_url": resource_url,
# 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",
......
......@@ -85,7 +85,7 @@ class ProviderConfig(BaseModel):
default_condense_method: Optional[Union[str, List[str]]] = None
default_error_cooldown: Optional[int] = None # Default cooldown period in seconds after 3 consecutive failures (default: 300)
# Provider-native caching configuration
enable_native_caching: bool = False # Enable provider-native caching (Anthropic cache_control, Google Context Caching, OpenAI-compatible APIs)
enable_native_caching: bool = False # Enable provider-native caching (Anthropic cache_control, Google Context Caching, OpenAI and Kilo-compatible APIs)
cache_ttl: Optional[int] = None # Cache TTL in seconds for Google Context Caching API
min_cacheable_tokens: Optional[int] = 1024 # Minimum token count for content to be cacheable (default matches OpenAI)
prompt_cache_key: Optional[str] = None # Optional cache key for OpenAI's load balancer routing optimization
......
......@@ -66,28 +66,70 @@ def get_provider_handler(provider_id: str, api_key: Optional[str] = None, user_i
logger.info(f"API key provided: {bool(api_key)}")
logger.info(f"User ID: {user_id}")
# First check for user-specific provider configuration if user_id is provided
provider_config = None
if user_id is not None:
try:
from ..database import DatabaseRegistry
db = DatabaseRegistry.get_config_database()
user_provider = db.get_user_provider(user_id, provider_id)
if user_provider:
provider_config = user_provider['config']
logger.info(f"Using user-specific provider configuration for user {user_id}")
except Exception as e:
logger.debug(f"Failed to load user-specific provider config: {e}")
# For authenticated users: NO fallback to global providers
if user_id is not None and not provider_config:
logger.error(f"User {user_id} attempted to access provider '{provider_id}' which does not exist in their configuration")
raise ValueError(f"Provider '{provider_id}' not found in user configuration")
# Fallback to global config only if no user_id is provided
if not provider_config:
provider_config = config.get_provider(provider_id)
logger.info(f"Using global provider configuration")
logger.info(f"Provider config: {provider_config}")
logger.info(f"Provider type: {provider_config.type}")
logger.info(f"Provider endpoint: {provider_config.endpoint}")
handler_class = PROVIDER_HANDLERS.get(provider_config.type)
# Handle both dict (user providers) and object (global providers)
if isinstance(provider_config, dict):
provider_type = provider_config.get('type')
api_key = provider_config.get('api_key')
else:
provider_type = provider_config.type
api_key = provider_config.api_key if hasattr(provider_config, 'api_key') else None
logger.info(f"Provider type: {provider_type}")
handler_class = PROVIDER_HANDLERS.get(provider_type)
logger.info(f"Handler class: {handler_class.__name__ if handler_class else 'None'}")
logger.info(f"Available handler types: {list(PROVIDER_HANDLERS.keys())}")
if not handler_class:
logger.error(f"Unsupported provider type: {provider_config.type}")
raise ValueError(f"Unsupported provider type: {provider_config.type}")
logger.error(f"Unsupported provider type: {provider_type}")
raise ValueError(f"Unsupported provider type: {provider_type}")
# Check if handler supports user_id parameter (CodexProviderHandler does)
# Check if handler supports user_id parameter
import inspect
sig = inspect.signature(handler_class.__init__)
if 'user_id' in sig.parameters:
logger.info(f"Creating handler with provider_id, optional api_key, and user_id")
logger.info(f"Creating handler with provider_id, api_key, and user_id")
handler = handler_class(provider_id, api_key, user_id=user_id)
else:
logger.info(f"Creating handler with provider_id and optional api_key")
# For older providers that don't accept user_id parameter
logger.info(f"Creating handler with provider_id and api_key (no user_id support)")
# Create a patched instance with user_id for base class initialization
handler = handler_class(provider_id, api_key)
# Set user_id manually for base class compatibility
handler.user_id = user_id
# Fix error tracking and rate limit for user providers
if user_id is not None:
handler.error_tracking = {
"enabled": True,
"max_errors": 5,
"cooldown_seconds": 60
}
handler.rate_limit = 60
logger.info(f"Handler created: {handler.__class__.__name__}")
logger.info(f"=== get_provider_handler END ===")
......
......@@ -718,11 +718,33 @@ def get_all_adaptive_rate_limiters() -> Dict[str, AdaptiveRateLimiter]:
class BaseProviderHandler:
def __init__(self, provider_id: str, api_key: Optional[str] = None):
def __init__(self, provider_id: str, api_key: Optional[str] = None, user_id: Optional[int] = None):
self.provider_id = provider_id
self.api_key = api_key
self.user_id = user_id
# Get error tracking config - use user-specific or default to global
if user_id is not None and provider_id in config.error_tracking:
self.error_tracking = config.error_tracking[provider_id]
elif user_id is not None:
# Default error tracking for user providers
self.error_tracking = {
"enabled": True,
"max_errors": 5,
"cooldown_seconds": 60
}
else:
self.error_tracking = config.error_tracking[provider_id]
self.last_request_time = 0
# Get rate limit - use user-specific or default to global
if user_id is not None and provider_id in config.providers:
self.rate_limit = config.providers[provider_id].rate_limit
elif user_id is not None:
# Default rate limit for user providers
self.rate_limit = 60
else:
self.rate_limit = config.providers[provider_id].rate_limit
# Add model-level rate limit tracking
self.model_last_request_time = {} # {model_name: timestamp}
......
......@@ -47,8 +47,7 @@ class ClaudeProviderHandler(BaseProviderHandler):
# NOTE: OAuth2 API uses its own model naming scheme that differs from standard Anthropic API
def __init__(self, provider_id: str, api_key: Optional[str] = None, user_id: Optional[int] = None):
super().__init__(provider_id, api_key)
self.user_id = user_id
super().__init__(provider_id, api_key, user_id=user_id)
self.provider_config = config.get_provider(provider_id)
# Get credentials file path from config
......
......@@ -59,8 +59,7 @@ class CodexProviderHandler(BaseProviderHandler):
"""
def __init__(self, provider_id: str, api_key: Optional[str] = None, user_id: Optional[int] = None):
super().__init__(provider_id, api_key)
self.user_id = user_id
super().__init__(provider_id, api_key, user_id=user_id)
# Get provider config
provider_config = config.providers.get(provider_id)
......
......@@ -27,6 +27,7 @@ from typing import Dict, List, Optional, Union
from openai import OpenAI
from ..models import Model
from ..config import config
from ..utils import count_messages_tokens
from .base import BaseProviderHandler, AISBF_DEBUG
......@@ -39,8 +40,7 @@ class KiloProviderHandler(BaseProviderHandler):
"""
def __init__(self, provider_id: str, api_key: Optional[str] = None, user_id: Optional[int] = None):
super().__init__(provider_id, api_key)
self.user_id = user_id
super().__init__(provider_id, api_key, user_id=user_id)
self.provider_config = config.get_provider(provider_id)
# Unified auth config with backward compatibility
......@@ -252,6 +252,16 @@ class KiloProviderHandler(BaseProviderHandler):
await self.apply_rate_limit()
# Check if native caching is enabled for this provider
provider_config = config.providers.get(self.provider_id)
enable_native_caching = getattr(provider_config, 'enable_native_caching', False)
min_cacheable_tokens = getattr(provider_config, 'min_cacheable_tokens', 1024)
prompt_cache_key = getattr(provider_config, 'prompt_cache_key', None)
logging.info(f"KiloProviderHandler: Native caching enabled: {enable_native_caching}")
if enable_native_caching:
logging.info(f"KiloProviderHandler: Min cacheable tokens: {min_cacheable_tokens}, prompt_cache_key: {prompt_cache_key}")
request_params = {
"model": model,
"messages": [],
......@@ -262,6 +272,47 @@ class KiloProviderHandler(BaseProviderHandler):
if max_tokens is not None:
request_params["max_tokens"] = max_tokens
# Add prompt_cache_key if provided (for OpenAI-compatible load balancer routing optimization)
if enable_native_caching and prompt_cache_key:
request_params["prompt_cache_key"] = prompt_cache_key
logging.info(f"KiloProviderHandler: Added prompt_cache_key to request")
# Build messages with all fields (including tool_calls, tool_call_id, and cache_control)
if enable_native_caching:
# Count cumulative tokens for cache decision
cumulative_tokens = 0
for i, msg in enumerate(messages):
# Count tokens in this message
message_tokens = count_messages_tokens([msg], model)
cumulative_tokens += message_tokens
message = {"role": msg["role"]}
# For tool role, tool_call_id is required
if msg["role"] == "tool":
if "tool_call_id" in msg and msg["tool_call_id"] is not None:
message["tool_call_id"] = msg["tool_call_id"]
else:
# Skip tool messages without tool_call_id
logging.warning(f"Skipping tool message without tool_call_id: {msg}")
continue
if "content" in msg and msg["content"] is not None:
message["content"] = msg["content"]
if "tool_calls" in msg and msg["tool_calls"] is not None:
message["tool_calls"] = msg["tool_calls"]
if "name" in msg and msg["name"] is not None:
message["name"] = msg["name"]
# Apply cache_control based on position and token count
if (msg["role"] == "system" or
(i < len(messages) - 2 and cumulative_tokens >= min_cacheable_tokens)):
message["cache_control"] = {"type": "ephemeral"}
logging.info(f"KiloProviderHandler: Applied cache_control to message {i} ({message_tokens} tokens, cumulative: {cumulative_tokens})")
request_params["messages"].append(message)
else:
# Native caching disabled - build messages without cache_control
for msg in messages:
message = {"role": msg["role"]}
......
......@@ -5,6 +5,16 @@ AISBF - AI Service Broker Framework || AI Should Be Free
Qwen OAuth2 provider handler.
⚠️ WARNING: QWEN OAUTH2 SERVICE DISCONTINUED ⚠️
As of April 2026, Qwen has completely disabled OAuth2 subscriptions for Qwen Code.
The OAuth2 tokens obtained from chat.qwen.ai are no longer valid for the DashScope API.
This implementation is maintained in the hope that Qwen will re-enable OAuth2 support
in the future. If the service remains discontinued, this code will eventually be removed.
For now, please use API key authentication instead of OAuth2 for Qwen/DashScope services.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
......@@ -37,6 +47,14 @@ class QwenProviderHandler(BaseProviderHandler):
"""
Handler for Qwen OAuth2 integration using OpenAI-compatible API.
⚠️ WARNING: QWEN OAUTH2 SERVICE DISCONTINUED ⚠️
As of April 2026, Qwen has completely disabled OAuth2 subscriptions for Qwen Code.
OAuth2 tokens from chat.qwen.ai are no longer accepted by the DashScope API.
This implementation is maintained for potential future re-enablement by Qwen.
Please use API key authentication instead.
This handler uses OAuth2 authentication to access Qwen models through
the DashScope OpenAI-compatible endpoint. OAuth2 access tokens are passed
as the api_key parameter to the OpenAI SDK.
......@@ -46,8 +64,15 @@ class QwenProviderHandler(BaseProviderHandler):
"""
def __init__(self, provider_id: str, api_key: Optional[str] = None, user_id: Optional[int] = None):
super().__init__(provider_id, api_key)
self.user_id = user_id
super().__init__(provider_id, api_key, user_id=user_id)
import logging
logger = logging.getLogger(__name__)
logger.warning(
"⚠️ Qwen OAuth2 service has been discontinued by Qwen. "
"OAuth2 tokens are no longer accepted by DashScope API. "
"Please use API key authentication instead."
)
self.provider_config = config.get_provider(provider_id)
# Get credentials file path from config
......
......@@ -136,6 +136,11 @@ else
echo " - htmlcov/ not found (skipping)"
fi
# Remove additional files:
rm -f debug.log || true
rm -f *.db || true
rm -f *.sqlite3 || true
echo ""
echo "=========================================="
echo "Clean completed successfully!"
......
......@@ -3372,11 +3372,11 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
cursor.execute(f'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = {placeholder}', (user_id,))
conn.commit()
# Check if this is a popup window request BEFORE cleaning up session
is_popup = request.session.pop('oauth2_popup', False) or request.session.pop('oauth2_popup_mode', False)
# Cleanup session data
request.session.pop('oauth2_google', None)
# Check if this is a popup window request
is_popup = request.session.pop('oauth2_popup', False) or request.session.pop('oauth2_popup_mode', False)
if is_popup:
# Return HTML with postMessage to opener
return HTMLResponse(content=f'''
......@@ -3556,11 +3556,11 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state
cursor.execute(f'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = {placeholder}', (user_id,))
conn.commit()
# Check if this is a popup window request BEFORE cleaning up session
is_popup = request.session.pop('oauth2_popup', False) or request.session.pop('oauth2_popup_mode', False)
# Cleanup session data
request.session.pop('oauth2_github', None)
# Check if this is a popup window request
is_popup = request.session.pop('oauth2_popup', False) or request.session.pop('oauth2_popup_mode', False)
if is_popup:
# Return HTML with postMessage to opener
return HTMLResponse(content=f'''
......@@ -3794,6 +3794,8 @@ async def dashboard_providers(request: Request):
# Check for success parameter
success = request.query_params.get('success')
if is_config_admin:
# Config admin: use admin template
return templates.TemplateResponse(
request=request,
name="dashboard/providers.html",
......@@ -3805,6 +3807,20 @@ async def dashboard_providers(request: Request):
"success": "Configuration saved successfully! Restart server for changes to take effect." if success else None
}
)
else:
# Database user: use user template with proper context
return templates.TemplateResponse(
request=request,
name="dashboard/user_providers.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_providers_json": json.dumps(providers_data),
"user_id": current_user_id,
"success": "Configuration saved successfully!" if success else None
}
)
async def _auto_detect_provider_models(provider_key: str, provider: dict) -> list:
"""
......@@ -3989,20 +4005,43 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
logger.info(f"Saved {len(providers_data)} provider(s) to database for user {current_user_id}")
if is_config_admin:
success_msg = "Configuration saved successfully! Restart server for changes to take effect."
return templates.TemplateResponse(
request=request,
name="dashboard/providers.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"providers_json": json.dumps(providers_data),
"success": success_msg
}
)
else:
success_msg = "Configuration saved successfully!"
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id)
return templates.TemplateResponse(
request=request,
name="dashboard/user_providers.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_providers_json": json.dumps(user_providers),
"user_id": current_user_id,
"success": success_msg
}
)
except json.JSONDecodeError as e:
# Reload current config on error
current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None
if is_config_admin:
config_path = Path.home() / '.aisbf' / 'providers.json'
if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'providers.json'
......@@ -4021,10 +4060,28 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
context={
"request": request,
"session": request.session,
"__version__": __version__,
"providers_json": json.dumps(providers_data),
"error": f"Invalid JSON: {str(e)}"
}
)
else:
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id)
return templates.TemplateResponse(
request=request,
name="dashboard/user_providers.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_providers_json": json.dumps(user_providers),
"user_id": current_user_id,
"error": f"Invalid JSON: {str(e)}"
}
)
@app.post("/dashboard/providers/get-models")
async def dashboard_providers_get_models(request: Request):
......@@ -4153,6 +4210,8 @@ async def dashboard_rotations(request: Request):
# Check for success parameter
success = request.query_params.get('success')
if is_config_admin:
# Config admin: use admin template
return templates.TemplateResponse(
request=request,
name="dashboard/rotations.html",
......@@ -4164,6 +4223,21 @@ async def dashboard_rotations(request: Request):
"success": "Configuration saved successfully! Restart server for changes to take effect." if success else None
}
)
else:
# Database user: use user template with proper context
return templates.TemplateResponse(
request=request,
name="dashboard/user_rotations.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers),
"user_id": current_user_id,
"success": "Configuration saved successfully!" if success else None
}
)
@app.post("/dashboard/rotations")
async def dashboard_rotations_save(request: Request, config: str = Form(...)):
......@@ -4208,6 +4282,7 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)):
logger.info(f"Saved {len(rotations)} rotation(s) to database for user {current_user_id}")
if is_config_admin:
# Get global config safely
from aisbf.config import config as global_config
available_providers = list(global_config.providers.keys()) if global_config else []
......@@ -4224,6 +4299,28 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)):
"success": "Configuration saved successfully! Restart server for changes to take effect."
}
)
else:
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
user_rotations = db.get_user_rotations(current_user_id)
# For database users, get their own providers
user_providers = db.get_user_providers(current_user_id)
available_providers = [p['provider_id'] for p in user_providers]
return templates.TemplateResponse(
request=request,
name="dashboard/user_rotations.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers),
"user_id": current_user_id,
"success": "Configuration saved successfully!"
}
)
except json.JSONDecodeError as e:
# Reload current config on error
current_user_id = request.session.get('user_id')
......@@ -4243,6 +4340,7 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)):
for rotation in user_rotations:
rotations_data["rotations"][rotation['rotation_id']] = rotation['config']
if is_config_admin:
available_providers = list(config.providers.keys()) if config else []
return templates.TemplateResponse(
......@@ -4251,11 +4349,31 @@ async def dashboard_rotations_save(request: Request, config: str = Form(...)):
context={
"request": request,
"session": request.session,
"__version__": __version__,
"rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers),
"error": f"Invalid JSON: {str(e)}"
}
)
else:
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id)
available_providers = [p['provider_id'] for p in user_providers]
return templates.TemplateResponse(
request=request,
name="dashboard/user_rotations.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_rotations_json": json.dumps(rotations_data),
"available_providers": json.dumps(available_providers),
"user_id": current_user_id,
"error": f"Invalid JSON: {str(e)}"
}
)
@app.get("/dashboard/autoselect", response_class=HTMLResponse)
async def dashboard_autoselect(request: Request):
......@@ -4324,6 +4442,8 @@ async def dashboard_autoselect(request: Request):
# Check for success parameter
success = request.query_params.get('success')
if is_config_admin:
# Config admin: use admin template
return templates.TemplateResponse(
request=request,
name="dashboard/autoselect.html",
......@@ -4336,6 +4456,54 @@ async def dashboard_autoselect(request: Request):
"success": "Configuration saved successfully! Restart server for changes to take effect." if success else None
}
)
else:
# Database user: use user template with proper context
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
user_autoselects = db.get_user_autoselects(current_user_id)
# For database users, get available user rotations
user_rotations = db.get_user_rotations(current_user_id)
available_rotations = [rot['rotation_id'] for rot in user_rotations]
# For database users, get available user providers
user_providers = db.get_user_providers(current_user_id)
available_models = []
# Add user rotation IDs
for rotation_id in available_rotations:
available_models.append({
'id': rotation_id,
'name': f'{rotation_id} (rotation)',
'type': 'rotation'
})
# Add user provider models
for provider in user_providers:
provider_config = provider['config']
if 'models' in provider_config and isinstance(provider_config['models'], list):
for model in provider_config['models']:
model_id = f"{provider['provider_id']}/{model['name']}"
available_models.append({
'id': model_id,
'name': f"{model_id} (provider model)",
'type': 'provider'
})
return templates.TemplateResponse(
request=request,
name="dashboard/user_autoselects.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_autoselects_json": json.dumps(autoselect_data),
"available_rotations": json.dumps(available_rotations),
"available_models": json.dumps(available_models),
"user_id": current_user_id,
"success": "Configuration saved successfully!" if success else None
}
)
@app.post("/dashboard/autoselect")
async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
......@@ -4368,10 +4536,42 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
logger.info(f"Saved {len(autoselect_data)} autoselect(s) to database for user {current_user_id}")
if is_config_admin:
# Get global config safely
from aisbf.config import config as global_config
available_rotations = list(global_config.rotations.keys()) if global_config else []
# Get available provider models
available_models = []
# Add rotation IDs
for rotation_id in available_rotations:
available_models.append({
'id': rotation_id,
'name': f'{rotation_id} (rotation)',
'type': 'rotation'
})
# Add provider models
providers_path = Path.home() / '.aisbf' / 'providers.json'
if not providers_path.exists():
providers_path = Path(__file__).parent / 'config' / 'providers.json'
if providers_path.exists():
with open(providers_path) as f:
providers_config = json.load(f)
providers_data = providers_config.get('providers', {})
for provider_id, provider in providers_data.items():
if 'models' in provider and isinstance(provider['models'], list):
for model in provider['models']:
model_id = f"{provider_id}/{model['name']}"
available_models.append({
'id': model_id,
'name': f"{model_id} (provider model)",
'type': 'provider'
})
return templates.TemplateResponse(
request=request,
name="dashboard/autoselect.html",
......@@ -4381,9 +4581,57 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
"__version__": __version__,
"autoselect_json": json.dumps(autoselect_data),
"available_rotations": json.dumps(available_rotations),
"available_models": json.dumps(available_models),
"success": "Configuration saved successfully! Restart server for changes to take effect."
}
)
else:
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
user_autoselects = db.get_user_autoselects(current_user_id)
# For database users, get available user rotations
user_rotations = db.get_user_rotations(current_user_id)
available_rotations = [rot['rotation_id'] for rot in user_rotations]
# For database users, get available user providers
user_providers = db.get_user_providers(current_user_id)
available_models = []
# Add user rotation IDs
for rotation_id in available_rotations:
available_models.append({
'id': rotation_id,
'name': f'{rotation_id} (rotation)',
'type': 'rotation'
})
# Add user provider models
for provider in user_providers:
provider_config = provider['config']
if 'models' in provider_config and isinstance(provider_config['models'], list):
for model in provider_config['models']:
model_id = f"{provider['provider_id']}/{model['name']}"
available_models.append({
'id': model_id,
'name': f"{model_id} (provider model)",
'type': 'provider'
})
return templates.TemplateResponse(
request=request,
name="dashboard/user_autoselects.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_autoselects_json": json.dumps(autoselect_data),
"available_rotations": json.dumps(available_rotations),
"available_models": json.dumps(available_models),
"user_id": current_user_id,
"success": "Configuration saved successfully!"
}
)
except json.JSONDecodeError as e:
# Reload current config on error
current_user_id = request.session.get('user_id')
......@@ -4403,16 +4651,96 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
for autoselect in user_autoselects:
autoselect_data[autoselect['autoselect_id']] = autoselect['config']
if is_config_admin:
available_rotations = list(config.rotations.keys()) if config else []
# Get available provider models
available_models = []
# Add rotation IDs
for rotation_id in available_rotations:
available_models.append({
'id': rotation_id,
'name': f'{rotation_id} (rotation)',
'type': 'rotation'
})
# Add provider models
providers_path = Path.home() / '.aisbf' / 'providers.json'
if not providers_path.exists():
providers_path = Path(__file__).parent / 'config' / 'providers.json'
if providers_path.exists():
with open(providers_path) as f:
providers_config = json.load(f)
providers_data = providers_config.get('providers', {})
for provider_id, provider in providers_data.items():
if 'models' in provider and isinstance(provider['models'], list):
for model in provider['models']:
model_id = f"{provider_id}/{model['name']}"
available_models.append({
'id': model_id,
'name': f"{model_id} (provider model)",
'type': 'provider'
})
return templates.TemplateResponse(
request=request,
name="dashboard/autoselect.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"autoselect_json": json.dumps(autoselect_data),
"available_rotations": json.dumps(available_rotations),
"available_models": json.dumps(available_models),
"error": f"Invalid JSON: {str(e)}"
}
)
else:
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
# For database users, get available user rotations
user_rotations = db.get_user_rotations(current_user_id)
available_rotations = [rot['rotation_id'] for rot in user_rotations]
# For database users, get available user providers
user_providers = db.get_user_providers(current_user_id)
available_models = []
# Add user rotation IDs
for rotation_id in available_rotations:
available_models.append({
'id': rotation_id,
'name': f'{rotation_id} (rotation)',
'type': 'rotation'
})
# Add user provider models
for provider in user_providers:
provider_config = provider['config']
if 'models' in provider_config and isinstance(provider_config['models'], list):
for model in provider_config['models']:
model_id = f"{provider['provider_id']}/{model['name']}"
available_models.append({
'id': model_id,
'name': f"{model_id} (provider model)",
'type': 'provider'
})
return templates.TemplateResponse(
request=request,
name="dashboard/user_autoselects.html",
context={
"request": request,
"session": request.session,
"__version__": __version__,
"user_autoselects_json": json.dumps(autoselect_data),
"available_rotations": json.dumps(available_rotations),
"available_models": json.dumps(available_models),
"user_id": current_user_id,
"error": f"Invalid JSON: {str(e)}"
}
)
......@@ -8473,8 +8801,8 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest):
else:
return await handler.handle_autoselect_request(actual_model, body_dict, user_id, token_id)
# PATH 2: Check if it's a rotation (format: rotation/{name})
if provider_id == "rotation":
# PATH 2: Check if it's a rotation (format: rotation/{name} or rotations/{name})
if provider_id == "rotation" or provider_id == "rotations":
if actual_model not in config.rotations:
raise HTTPException(
status_code=400,
......@@ -8487,6 +8815,23 @@ async def v1_chat_completions(request: Request, body: ChatCompletionRequest):
handler = get_user_handler('rotation', user_id)
return await handler.handle_rotation_request(actual_model, body_dict, user_id, token_id)
# PATH 2a: Check if it's an autoselect (format: autoselect/{name} or autoselections/{name})
if provider_id == "autoselect" or provider_id == "autoselections":
if actual_model not in config.autoselect:
raise HTTPException(
status_code=400,
detail=f"Autoselect '{actual_model}' not found. Available: {list(config.autoselect.keys())}"
)
body_dict['model'] = actual_model
# Get user-specific handler
user_id = getattr(request.state, 'user_id', None)
token_id = getattr(request.state, 'token_id', None)
handler = get_user_handler('autoselect', user_id)
if body.stream:
return await handler.handle_autoselect_streaming_request(actual_model, body_dict)
else:
return await handler.handle_autoselect_request(actual_model, body_dict, user_id, token_id)
# PATH 1: Direct provider model (format: {provider}/{model})
if provider_id not in config.providers:
raise HTTPException(
......@@ -9124,6 +9469,12 @@ async def list_autoselect_models():
logger.info(f"Total autoselect models available: {len(all_models)}")
return {"data": all_models}
@app.get("/api/autoselections/models")
async def list_autoselection_models():
"""List all models across all autoselect configurations (alias for /api/autoselect/models)"""
return await list_autoselect_models()
@app.post("/api/{provider_id}/chat/completions")
async def chat_completions(provider_id: str, request: Request, body: ChatCompletionRequest):
logger.info(f"=== CHAT COMPLETION REQUEST START ===")
......@@ -11180,11 +11531,42 @@ async def user_chat_completions_by_username(request: Request, username: str, bod
if not provider_id:
raise HTTPException(
status_code=400,
detail="Model must be in format 'provider/model', 'rotation/name', 'autoselect/name', 'user-provider/model', 'user-rotation/name', or 'user-autoselect/name'"
detail="Model must be in format 'provider/model', 'rotation/name', 'autoselect/name', 'rotations/name', 'autoselections/name', 'user-provider/model', 'user-rotation/name', or 'user-autoselect/name'"
)
body_dict = body.model_dump()
# Handle new formats: rotations/{rotation_name} and autoselections/{autoselection_name}
if provider_id == "rotations" or provider_id == "rotation":
# Normalize to user-rotation handler
rotation_name = actual_model
handler = get_user_handler('rotation', target_user_id)
if rotation_name not in handler.user_rotations:
raise HTTPException(
status_code=400,
detail=f"User rotation '{rotation_name}' not found. Available: {list(handler.user_rotations.keys())}"
)
body_dict['model'] = rotation_name
token_id = getattr(request.state, 'token_id', None)
return await handler.handle_rotation_request(rotation_name, body_dict, authenticated_user_id, token_id)
if provider_id == "autoselections" or provider_id == "autoselect":
# Normalize to user-autoselect handler
autoselect_name = actual_model
handler = get_user_handler('autoselect', target_user_id)
if autoselect_name not in handler.user_autoselects:
raise HTTPException(
status_code=400,
detail=f"User autoselect '{autoselect_name}' not found. Available: {list(handler.user_autoselects.keys())}"
)
body_dict['model'] = autoselect_name
if body.stream:
return await handler.handle_autoselect_streaming_request(autoselect_name, body_dict)
else:
token_id = getattr(request.state, 'token_id', None)
return await handler.handle_autoselect_request(autoselect_name, body_dict, authenticated_user_id, token_id)
if provider_id == "user-autoselect":
handler = get_user_handler('autoselect', target_user_id)
if actual_model not in handler.user_autoselects:
......@@ -11211,28 +11593,31 @@ async def user_chat_completions_by_username(request: Request, username: str, bod
token_id = getattr(request.state, 'token_id', None)
return await handler.handle_rotation_request(actual_model, body_dict, authenticated_user_id, token_id)
if provider_id == "user-provider":
if provider_id == "user-provider" or provider_id in (p['provider_id'] for p in (get_user_handler('request', target_user_id).user_providers.values() if target_user_id else [])):
# Check if this is a user provider
handler = get_user_handler('request', target_user_id)
if actual_model not in handler.user_providers:
provider_name = actual_model if provider_id == "user-provider" else provider_id
if provider_name not in handler.user_providers:
raise HTTPException(
status_code=400,
detail=f"User provider '{actual_model}' not found. Available: {list(handler.user_providers.keys())}"
detail=f"User provider '{provider_name}' not found. Available: {list(handler.user_providers.keys())}"
)
provider_config = handler.user_providers[actual_model]
provider_config = handler.user_providers[provider_name]
if not validate_kiro_credentials(actual_model, provider_config):
if not validate_kiro_credentials(provider_name, provider_config):
raise HTTPException(
status_code=403,
detail=f"Provider '{actual_model}' credentials not available."
detail=f"Provider '{provider_name}' credentials not available."
)
body_dict['model'] = actual_model
body_dict['model'] = provider_name
if body.stream:
return await handler.handle_streaming_chat_completion(request, actual_model, body_dict)
return await handler.handle_streaming_chat_completion(request, provider_name, body_dict)
else:
return await handler.handle_chat_completion(request, actual_model, body_dict)
return await handler.handle_chat_completion(request, provider_name, body_dict)
if is_global_token or is_admin:
if provider_id == "autoselect":
......@@ -11284,6 +11669,95 @@ async def user_chat_completions_by_username(request: Request, username: str, bod
)
@app.get("/api/u/{username}/rotations/models")
async def user_list_rotation_models_by_username(request: Request, username: str):
"""
List all models for user rotations.
"""
return await user_list_config_models_by_username(request, username, "rotations")
@app.get("/api/u/{username}/autoselections/models")
async def user_list_autoselection_models_by_username(request: Request, username: str):
"""
List all models for user autoselections.
"""
return await user_list_config_models_by_username(request, username, "autoselects")
@app.get("/api/u/{username}/{user_provider_id}/models")
async def user_list_provider_models_by_username(request: Request, username: str, user_provider_id: str):
"""
List models for a specific user provider.
"""
from aisbf.database import get_database
db = DatabaseRegistry.get_config_database()
target_user = db.get_user_by_username(username)
if not target_user:
return JSONResponse(
status_code=404,
content={"error": f"User '{username}' not found"}
)
target_user_id = target_user['id']
is_admin = getattr(request.state, 'is_admin', False)
is_global_token = getattr(request.state, 'is_global_token', False)
authenticated_user_id = getattr(request.state, 'user_id', None)
if not (is_admin or is_global_token or authenticated_user_id == target_user_id):
return JSONResponse(
status_code=403,
content={"error": "You do not have permission to access this user's configurations"}
)
if not target_user_id:
return JSONResponse(
status_code=401,
content={"error": "Authentication required. Use a valid API token."}
)
handler = get_user_handler('request', target_user_id)
if user_provider_id not in handler.user_providers:
return JSONResponse(
status_code=404,
content={"error": f"User provider '{user_provider_id}' not found"}
)
provider_config = handler.user_providers[user_provider_id]
all_models = []
try:
if hasattr(provider_config, 'models') and provider_config.models:
for model in provider_config.models:
all_models.append({
"id": f"{user_provider_id}/{model.name}",
"name": model.name,
"object": "model",
"created": int(time.time()),
"owned_by": user_provider_id,
"provider_id": user_provider_id,
"type": "user_provider"
})
else:
# Try to fetch models from provider API
models = await fetch_provider_models(user_provider_id, user_id=target_user_id)
for model in models:
all_models.append({
"id": f"{user_provider_id}/{model.get('id', model.get('name', ''))}",
"name": model.get('name', ''),
"object": "model",
"created": int(time.time()),
"owned_by": user_provider_id,
"provider_id": user_provider_id,
"type": "user_provider"
})
except Exception as e:
logger.warning(f"Error listing models for user provider {user_provider_id}: {e}")
return {"data": all_models}
@app.get("/api/u/{username}/{config_type}/models")
async def user_list_config_models_by_username(request: Request, username: str, config_type: str):
"""
......
......@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "aisbf"
version = "0.99.33"
version = "0.99.35"
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.33",
version="0.99.35",
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",
......
......@@ -120,16 +120,22 @@ function openOAuthPopup(url) {
// Listen for popup messages
const messageListener = function(event) {
if (event.data.type === 'oauth2_complete') {
console.log('Received OAuth message:', event.data, event.origin);
if (event.data && event.data.type === 'oauth2_complete') {
console.log('OAuth complete, redirecting to:', event.data.redirect_url || '/dashboard');
// Close popup
popup.close();
// Remove listener
window.removeEventListener('message', messageListener);
// Redirect to dashboard
window.location.href = event.data.redirect_url || '/dashboard';
} else if (event.data.type === 'oauth2_error') {
// Force redirect to dashboard - use relative path for reliability
window.location.replace('/dashboard');
} else if (event.data && event.data.type === 'oauth2_error') {
console.log('OAuth error:', event.data.error);
// Close popup
popup.close();
......@@ -148,6 +154,24 @@ function openOAuthPopup(url) {
if (popup.closed) {
clearInterval(popupCheck);
window.removeEventListener('message', messageListener);
// Check if authentication was successful by trying to load dashboard
// Use fetch with credentials: 'same-origin' to send cookies
fetch('/dashboard', {
method: 'GET',
credentials: 'same-origin',
redirect: 'follow'
}).then(response => {
// If we get a 200 OK (HTML) or redirect to dashboard, auth was successful
if (response.ok || response.redirected) {
console.log('Auth detected on popup close, redirecting to dashboard');
window.location.replace('/dashboard');
}
}).catch(error => {
console.log('Dashboard check failed:', error);
// Fallback: try direct redirect anyway for authenticated users
window.location.replace('/dashboard');
});
}
}, 500);
}
......
......@@ -524,13 +524,23 @@ function renderProviderDetails(key) {
} else if (isQwenProvider) {
// Qwen authentication fields - supports both API key and OAuth2
authFieldsHtml = `
<div style="background: #ff6b35; border: 2px solid #d63031; border-radius: 8px; padding: 20px; margin-bottom: 15px;">
<h4 style="margin: 0 0 10px 0; color: #fff;">⚠️ Qwen OAuth2 Service Discontinued</h4>
<p style="margin: 0; color: #fff;">
As of April 2026, Qwen has completely disabled OAuth2 subscriptions for Qwen Code.
OAuth2 tokens are no longer accepted by the DashScope API.
<br><br><strong>Please use API Key authentication instead.</strong>
<br><br>OAuth2 support is maintained in the code for potential future re-enablement, but it is currently non-functional.
</p>
</div>
<div style="background: #0f2840; padding: 15px; border-radius: 5px; margin-bottom: 15px; border-left: 3px solid #4a9eff;">
<h4 style="margin: 0 0 15px 0; color: #4a9eff;">Qwen Authentication</h4>
<small style="color: #a0a0a0; display: block; margin-bottom: 15px;">
Choose your authentication method: API Key (recommended for simplicity) or OAuth2 Device Authorization Grant.
</small>
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Option 1: API Key</h5>
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Option 1: API Key (Recommended)</h5>
<div class="form-group">
<label>API Key</label>
<input type="password" value="${qwenConfig.api_key || ''}" onchange="updateQwenConfig('${key}', 'api_key', this.value)" placeholder="Enter your Qwen API key">
......@@ -564,9 +574,9 @@ function renderProviderDetails(key) {
</div>
</div>
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Option 2: OAuth2 Authentication</h5>
<small style="color: #a0a0a0; display: block; margin-bottom: 15px;">
If no API key is provided, OAuth2 will be used automatically.
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Option 2: OAuth2 Authentication (DISCONTINUED - NOT WORKING)</h5>
<small style="color: #ff6b35; display: block; margin-bottom: 15px;">
⚠️ OAuth2 authentication is no longer functional. Qwen has discontinued this service. Use API Key instead.
</small>
<div class="form-group">
......@@ -793,7 +803,7 @@ function renderProviderDetails(key) {
<h4 style="margin-top: 20px; margin-bottom: 10px;">Native Caching</h4>
<small style="color: #a0a0a0; display: block; margin-bottom: 15px;">
Provider-native caching features (Anthropic cache_control, Google Context Caching) for cost reduction.
Provider-native caching features (Anthropic cache_control, Google Context Caching, OpenAI and Kilo-compatible APIs) for cost reduction.
</small>
<div class="form-group">
......@@ -816,6 +826,12 @@ function renderProviderDetails(key) {
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Minimum token count for content to be cacheable (default: 1000)</small>
</div>
<div class="form-group">
<label>Prompt Cache Key (OpenAI/Kilo)</label>
<input type="text" value="${provider.prompt_cache_key || ''}" onchange="updateProvider('${key}', 'prompt_cache_key', this.value)" placeholder="Optional cache key for load balancer optimization">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Optional cache key for OpenAI/Kilo load balancer routing optimization</small>
</div>
<h4 style="margin-top: 20px; margin-bottom: 10px;">Models</h4>
<small style="color: #a0a0a0; display: block; margin-bottom: 15px;">
Configure specific models for this provider, or leave empty to automatically fetch all available models from the provider's API.
......
This source diff could not be displayed because it is too large. You can view the blob instead.
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