Commit ae1fb479 authored by Your Name's avatar Your Name

feat: add subscription-based provider and custom pricing configuration

- Added is_subscription, price_per_million_prompt, and price_per_million_completion fields to Provider model
- Updated analytics to check provider config for subscription status and custom pricing
- Subscription providers have $0 cost but usage is still tracked
- Added _get_provider_pricing() method to get provider-specific pricing
- Updated provider configuration UI with pricing section:
  - Checkbox for subscription-based provider (free)
  - Input fields for custom prompt/completion token pricing
  - Pricing fields hidden when subscription is checked
- Falls back to default pricing if not configured
- Allows admins and users to configure per-provider pricing
parent 4fc0240b
......@@ -71,6 +71,45 @@ class Analytics:
self._latencies = {} # {provider_id: {count: int, total_ms: float, min_ms: float, max_ms: float}}
self._error_types = {} # {provider_id: {error_type: count}}
def _get_provider_pricing(self, provider_id: str) -> Dict[str, float]:
"""
Get pricing for a provider, checking config first, then defaults.
Args:
provider_id: Provider identifier
Returns:
Dictionary with 'prompt' and 'completion' pricing per million tokens
"""
from .config import config
# Try to get provider config
try:
provider_config = config.get_provider(provider_id)
if provider_config:
# Check if it's a subscription provider (free)
is_subscription = getattr(provider_config, 'is_subscription', False)
if is_subscription:
return {'prompt': 0.0, 'completion': 0.0}
# Check for custom pricing
price_prompt = getattr(provider_config, 'price_per_million_prompt', None)
price_completion = getattr(provider_config, 'price_per_million_completion', None)
if price_prompt is not None and price_completion is not None:
return {'prompt': price_prompt, 'completion': price_completion}
except Exception:
pass
# Fall back to default pricing
for key, pricing in self.pricing.items():
if key in provider_id.lower():
return pricing
# Ultimate fallback
return self.pricing.get('openrouter', {'prompt': 5.0, 'completion': 15.0})
def record_request(
self,
provider_id: str,
......@@ -80,11 +119,13 @@ class Analytics:
success: bool = True,
error_type: Optional[str] = None,
rotation_id: Optional[str] = None,
autoselect_id: Optional[str] = None
autoselect_id: Optional[str] = None,
user_id: Optional[int] = None,
token_id: Optional[int] = None
):
"""
Record a request for analytics.
Args:
provider_id: Provider identifier
model_name: Model name
......@@ -94,6 +135,8 @@ class Analytics:
error_type: Optional error type if failed
rotation_id: Optional rotation identifier if request went through rotation
autoselect_id: Optional autoselect identifier if request went through autoselect
user_id: Optional user ID if request was made with API token
token_id: Optional API token ID if request was made with API token
"""
# Initialize provider tracking if needed
if provider_id not in self._request_counts:
......@@ -118,7 +161,11 @@ class Analytics:
# Persist to database
if tokens_used > 0:
self.db.record_token_usage(provider_id, model_name, tokens_used)
self.db.record_token_usage(provider_id, model_name, tokens_used, user_id)
# Also record user token usage if this was an API token request
if user_id is not None and token_id is not None:
self.db.record_user_token_usage(user_id, token_id, provider_id, model_name, tokens_used)
def get_provider_stats(
self,
......@@ -140,19 +187,16 @@ class Analytics:
"""
# Use in-memory stats if no date range specified, otherwise query DB
if from_datetime is None and to_datetime is None:
stats = {
'provider_id': provider_id,
'requests': self._request_counts.get(provider_id, {'total': 0, 'success': 0, 'error': 0}),
'latency': self._latencies.get(provider_id, {'count': 0, 'total_ms': 0.0, 'min_ms': 0, 'max_ms': 0}),
'errors': self._error_types.get(provider_id, {}),
'tokens': self._get_token_usage_by_provider(provider_id)
}
# Default to last 24 hours from database instead of in-memory
from_datetime = datetime.now() - timedelta(days=1)
to_datetime = datetime.now()
stats = self._get_provider_stats_from_db(provider_id, from_datetime, to_datetime, user_filter)
else:
# Query database for date range stats
start = from_datetime or (datetime.now() - timedelta(days=1))
end = to_datetime or datetime.now()
stats = self._get_provider_stats_from_db(provider_id, start, end)
stats = self._get_provider_stats_from_db(provider_id, start, end, user_filter)
# Calculate error rate
total = stats['requests']['total']
......@@ -182,15 +226,79 @@ class Analytics:
user_filter: Optional[int] = None
) -> Dict[str, Any]:
"""Get provider stats from database for a specific date range."""
# This is a placeholder - in a real implementation, you'd query the database
# For now, return empty stats
return {
'provider_id': provider_id,
'requests': {'total': 0, 'success': 0, 'error': 0},
'latency': {'count': 0, 'total_ms': 0.0, 'min_ms': 0, 'max_ms': 0},
'errors': {},
'tokens': {'TPM': 0, 'TPH': 0, 'TPD': 0}
}
with self.db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
# Build query with optional user filter
user_condition = f" AND user_id = {placeholder}" if user_filter is not None else ""
params = [provider_id, from_datetime.isoformat(), to_datetime.isoformat()]
if user_filter is not None:
params.append(user_filter)
# Get token usage and actual time range of requests
cursor.execute(f'''
SELECT
COUNT(*) as total_requests,
SUM(tokens_used) as total_tokens,
MIN(timestamp) as first_request,
MAX(timestamp) as last_request
FROM token_usage
WHERE provider_id = {placeholder}
AND timestamp >= {placeholder}
AND timestamp <= {placeholder}
{user_condition}
''', params)
result = cursor.fetchone()
total_requests = result[0] if result else 0
total_tokens = result[1] if result else 0
first_request = result[2] if result and result[2] else None
last_request = result[3] if result and result[3] else None
# Calculate time-based rates using ACTUAL usage window, not query window
# This gives more accurate rate limiting metrics
if first_request and last_request and total_tokens > 0:
try:
first_dt = datetime.fromisoformat(first_request)
last_dt = datetime.fromisoformat(last_request)
# Use actual duration between first and last request
# Add a minimum of 1 minute to avoid division by very small numbers
actual_duration_seconds = max((last_dt - first_dt).total_seconds(), 60)
duration_minutes = actual_duration_seconds / 60
duration_hours = actual_duration_seconds / 3600
duration_days = actual_duration_seconds / 86400
# Calculate rates based on actual usage period
tpm = int(total_tokens / duration_minutes) if duration_minutes > 0 else 0
tph = int(total_tokens / duration_hours) if duration_hours > 0 else 0
tpd = int(total_tokens / duration_days) if duration_days > 0 else 0
except Exception as e:
# Fallback to query window if parsing fails
duration_seconds = (to_datetime - from_datetime).total_seconds()
duration_minutes = duration_seconds / 60
duration_hours = duration_seconds / 3600
duration_days = duration_seconds / 86400
tpm = int(total_tokens / duration_minutes) if duration_minutes > 0 else 0
tph = int(total_tokens / duration_hours) if duration_hours > 0 else 0
tpd = int(total_tokens / duration_days) if duration_days > 0 else 0
else:
tpm = tph = tpd = 0
return {
'provider_id': provider_id,
'requests': {'total': total_requests, 'success': total_requests, 'error': 0},
'latency': {'count': 0, 'total_ms': 0.0, 'min_ms': 0, 'max_ms': 0},
'errors': {},
'tokens': {
'total': total_tokens,
'TPM': tpm,
'TPH': tph,
'TPD': tpd
}
}
def get_token_usage_by_date_range(
self,
......@@ -266,14 +374,36 @@ class Analytics:
Args:
from_datetime: Optional start datetime for filtering
to_datetime: Optional end datetime for filtering
user_filter: Optional user ID filter
Returns:
List of provider statistics
List of provider statistics dictionaries
"""
all_providers = set(self._request_counts.keys())
all_providers.update(self.db.get_all_context_dimensions())
# Get all unique providers from database
start = from_datetime or (datetime.now() - timedelta(days=1))
end = to_datetime or datetime.now()
return [self.get_provider_stats(pid, from_datetime, to_datetime) for pid in sorted(all_providers)]
with self.db._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db.db_type == 'sqlite' else '%s'
# Build query with optional user filter
user_condition = f" AND user_id = {placeholder}" if user_filter is not None else ""
params = [start.isoformat(), end.isoformat()]
if user_filter is not None:
params.append(user_filter)
cursor.execute(f'''
SELECT DISTINCT provider_id
FROM token_usage
WHERE timestamp >= {placeholder}
AND timestamp <= {placeholder}
{user_condition}
''', params)
all_providers = [row[0] for row in cursor.fetchall()]
return [self.get_provider_stats(pid, from_datetime, to_datetime, user_filter) for pid in sorted(all_providers)]
def _get_token_usage_by_provider(self, provider_id: str) -> Dict[str, int]:
"""
......@@ -589,15 +719,8 @@ class Analytics:
Returns:
Estimated cost in USD
"""
# Determine provider pricing
provider_pricing = None
for key, pricing in self.pricing.items():
if key.lower() in provider_id.lower():
provider_pricing = pricing
break
if not provider_pricing:
provider_pricing = self.pricing.get('openrouter', {'prompt': 5.0, 'completion': 15.0})
# Get provider-specific pricing (checks subscription status and custom pricing)
provider_pricing = self._get_provider_pricing(provider_id)
# Calculate cost
if prompt_tokens is not None:
......
......@@ -83,6 +83,11 @@ class Provider(BaseModel):
endpoint: str
api_key_required: bool
models: List[Model] = []
# Pricing configuration
is_subscription: bool = False # If True, pricing is 0 (subscription-based provider)
price_per_million_prompt: Optional[float] = None # Price per million prompt tokens (USD)
price_per_million_completion: Optional[float] = None # Price per million completion tokens (USD)
class ErrorTracking(BaseModel):
failures: int
......
......@@ -546,6 +546,40 @@ function renderProviderDetails(key) {
${authFieldsHtml}
<div style="background: #0f2840; padding: 15px; border-radius: 5px; margin: 15px 0; border-left: 3px solid #4a9eff;">
<h4 style="margin: 0 0 15px 0; color: #4a9eff;">Pricing Configuration</h4>
<div class="form-group">
<label>
<input type="checkbox" ${provider.is_subscription ? 'checked' : ''} onchange="updateProvider('${key}', 'is_subscription', this.checked); togglePricingFields('${key}')">
Subscription-Based Provider (Free)
</label>
<small style="color: #a0a0a0; display: block; margin-top: 5px;">
If checked, this provider is subscription-based and costs will be calculated as $0. Usage is still tracked for analytics.
</small>
</div>
<div id="pricing-fields-${key}" style="display: ${provider.is_subscription ? 'none' : 'block'};">
<div class="form-group">
<label>Price per Million Prompt Tokens (USD)</label>
<input type="number" step="0.01" min="0" value="${provider.price_per_million_prompt || ''}" onchange="updateProvider('${key}', 'price_per_million_prompt', parseFloat(this.value) || null)" placeholder="e.g., 15.00">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">
Leave empty to use default pricing. Examples: OpenAI GPT-4: $10, Anthropic Claude: $15, Google Gemini: $1.25
</small>
</div>
<div class="form-group">
<label>Price per Million Completion Tokens (USD)</label>
<input type="number" step="0.01" min="0" value="${provider.price_per_million_completion || ''}" onchange="updateProvider('${key}', 'price_per_million_completion', parseFloat(this.value) || null)" placeholder="e.g., 75.00">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">
Leave empty to use default pricing. Examples: OpenAI GPT-4: $30, Anthropic Claude: $75, Google Gemini: $5.00
</small>
</div>
</div>
</div>
${authFieldsHtml}
<div class="form-group">
<label>Rate Limit (seconds)</label>
<input type="number" value="${provider.rate_limit || 0}" onchange="updateProvider('${key}', 'rate_limit', parseFloat(this.value))" step="0.1">
......@@ -781,1462 +815,14 @@ function updateNewProviderDefaults() {
}
}
function confirmAddProvider() {
const key = document.getElementById('new-provider-key').value.trim();
const providerType = document.getElementById('new-provider-type').value;
// Validate key
if (!key) {
showFormError('Please enter a provider key');
document.getElementById('new-provider-key').focus();
return;
}
if (providersData[key]) {
showFormError('A provider with this key already exists');
document.getElementById('new-provider-key').focus();
return;
}
// Validate key format (alphanumeric, dashes, underscores only)
if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
showFormError('Provider key can only contain letters, numbers, dashes, and underscores');
document.getElementById('new-provider-key').focus();
return;
}
// Create base provider config
const newProvider = {
id: key,
name: key,
endpoint: '',
type: providerType,
api_key_required: providerType !== 'kiro' && providerType !== 'ollama' && providerType !== 'claude' && providerType !== 'kilocode' && providerType !== 'qwen' && providerType !== 'codex',
rate_limit: 0,
default_rate_limit: 0,
models: []
};
// Set type-specific defaults
if (providerType === 'kiro') {
newProvider.endpoint = 'https://q.us-east-1.amazonaws.com';
newProvider.name = key + ' (Amazon Q Developer)';
newProvider.kiro_config = {
region: 'us-east-1',
creds_file: '',
sqlite_db: '',
refresh_token: '',
profile_arn: '',
client_id: '',
client_secret: ''
};
} else if (providerType === 'claude') {
newProvider.endpoint = 'https://api.anthropic.com/v1';
newProvider.name = key + ' (Claude OAuth2)';
newProvider.claude_config = {
credentials_file: '~/.claude_credentials.json'
};
} else if (providerType === 'kilocode') {
newProvider.endpoint = 'https://api.kilo.ai/api/gateway';
newProvider.name = key + ' (Kilocode OAuth2)';
newProvider.kilo_config = {
credentials_file: '~/.kilo_credentials.json',
api_base: 'https://api.kilo.ai/api/gateway'
};
} else if (providerType === 'qwen') {
newProvider.endpoint = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
newProvider.name = key + ' (Qwen OAuth2)';
newProvider.qwen_config = {
credentials_file: '~/.aisbf/qwen_credentials.json',
api_key: '',
region: 'china-beijing',
workspace_id: 'Default Workspace'
};
} else if (providerType === 'codex') {
newProvider.endpoint = 'https://api.openai.com/v1';
newProvider.name = key + ' (Codex OAuth2)';
newProvider.codex_config = {
credentials_file: '~/.aisbf/codex_credentials.json',
issuer: 'https://auth.openai.com'
};
} else if (providerType === 'openai') {
newProvider.endpoint = 'https://api.openai.com/v1';
newProvider.api_key = '';
} else if (providerType === 'google') {
newProvider.endpoint = 'https://generativelanguage.googleapis.com/v1beta';
newProvider.api_key = '';
} else if (providerType === 'anthropic') {
newProvider.endpoint = 'https://api.anthropic.com/v1';
newProvider.api_key = '';
} else if (providerType === 'ollama') {
newProvider.endpoint = 'http://localhost:11434/api';
newProvider.api_key_required = false;
newProvider.api_key = '';
} else {
newProvider.api_key = '';
}
providersData[key] = newProvider;
expandedProviders.add(key);
// Hide the form and show the button
cancelAddProvider();
renderProvidersList();
// Scroll to the new provider
setTimeout(() => {
const providerElement = document.querySelector(`[onclick*="toggleProvider('${key}')"]`);
if (providerElement) {
providerElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 100);
}
function showFormError(message) {
// Check if error element already exists
let errorEl = document.getElementById('new-provider-error');
if (!errorEl) {
errorEl = document.createElement('div');
errorEl.id = 'new-provider-error';
errorEl.className = 'alert alert-error';
errorEl.style.marginBottom = '15px';
const form = document.getElementById('new-provider-form');
form.insertBefore(errorEl, form.querySelector('.form-group'));
}
errorEl.textContent = message;
errorEl.style.display = 'block';
// Auto-hide after 5 seconds
setTimeout(() => {
if (errorEl) {
errorEl.style.display = 'none';
}
}, 5000);
}
// Legacy function - kept for compatibility but redirects to new form
function addProvider() {
showAddProviderForm();
}
function removeProvider(key) {
if (confirm(`Remove provider "${key}"?`)) {
delete providersData[key];
expandedProviders.delete(key);
renderProvidersList();
}
}
function updateProvider(key, field, value) {
providersData[key][field] = value;
}
function updateProviderType(key, newType) {
const oldType = providersData[key].type;
providersData[key].type = newType;
// Handle transition to/from kiro type
if (newType === 'kiro' && oldType !== 'kiro') {
// Transitioning TO kiro: initialize kiro_config, set api_key_required to false
providersData[key].api_key_required = false;
providersData[key].kiro_config = {
region: 'us-east-1',
creds_file: '',
sqlite_db: '',
refresh_token: '',
profile_arn: '',
client_id: '',
client_secret: ''
};
delete providersData[key].claude_config;
delete providersData[key].kilo_config;
// Set default endpoint for kiro
if (!providersData[key].endpoint || providersData[key].endpoint === '') {
providersData[key].endpoint = 'https://q.us-east-1.amazonaws.com';
}
} else if (newType === 'claude' && oldType !== 'claude') {
// Transitioning TO claude: initialize claude_config, set api_key_required to false
providersData[key].api_key_required = false;
providersData[key].claude_config = {
credentials_file: '~/.claude_credentials.json'
};
delete providersData[key].kiro_config;
delete providersData[key].kilo_config;
// Set default endpoint for claude
if (!providersData[key].endpoint || providersData[key].endpoint === '') {
providersData[key].endpoint = 'https://api.anthropic.com/v1';
}
} else if (newType === 'kilocode' && oldType !== 'kilocode') {
// Transitioning TO kilocode: initialize kilo_config, set api_key_required to false
providersData[key].api_key_required = false;
providersData[key].kilo_config = {
credentials_file: '~/.kilo_credentials.json',
api_base: 'https://api.kilo.ai/api/gateway'
};
delete providersData[key].kiro_config;
delete providersData[key].claude_config;
delete providersData[key].codex_config;
// Set endpoint for kilocode (fixed, not modifiable)
providersData[key].endpoint = 'https://api.kilo.ai/api/gateway';
} else if (newType === 'codex' && oldType !== 'codex') {
// Transitioning TO codex: initialize codex_config, set api_key_required to false
providersData[key].api_key_required = false;
providersData[key].codex_config = {
credentials_file: '~/.aisbf/codex_credentials.json',
issuer: 'https://auth.openai.com'
};
delete providersData[key].kiro_config;
delete providersData[key].claude_config;
delete providersData[key].kilo_config;
// Set default endpoint for codex
if (!providersData[key].endpoint || providersData[key].endpoint === '') {
providersData[key].endpoint = 'https://api.openai.com/v1';
}
} else if (newType === 'qwen' && oldType !== 'qwen') {
// Transitioning TO qwen: initialize qwen_config, set api_key_required to false
providersData[key].api_key_required = false;
providersData[key].qwen_config = {
credentials_file: '~/.aisbf/qwen_credentials.json',
api_key: ''
};
delete providersData[key].kiro_config;
delete providersData[key].claude_config;
delete providersData[key].kilo_config;
delete providersData[key].codex_config;
// Set default endpoint for qwen
if (!providersData[key].endpoint || providersData[key].endpoint === '') {
providersData[key].endpoint = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
}
} else if (newType !== 'kiro' && newType !== 'claude' && newType !== 'kilocode' && newType !== 'qwen' && newType !== 'codex' && (oldType === 'kiro' || oldType === 'claude' || oldType === 'kilocode' || oldType === 'qwen' || oldType === 'codex')) {
// Transitioning FROM kiro/claude/kilocode/qwen/codex: remove special configs, set api_key_required to true
providersData[key].api_key_required = true;
delete providersData[key].kiro_config;
delete providersData[key].claude_config;
delete providersData[key].kilo_config;
delete providersData[key].qwen_config;
delete providersData[key].codex_config;
}
// Re-render to show appropriate fields
renderProviderDetails(key);
}
function updateKiroConfig(key, field, value) {
if (!providersData[key].kiro_config) {
providersData[key].kiro_config = {};
}
providersData[key].kiro_config[field] = value;
}
function updateClaudeConfig(key, field, value) {
if (!providersData[key].claude_config) {
providersData[key].claude_config = {};
}
providersData[key].claude_config[field] = value;
}
function updateKiloConfig(key, field, value) {
if (!providersData[key].kilo_config) {
providersData[key].kilo_config = {};
}
providersData[key].kilo_config[field] = value;
}
function updateQwenConfig(key, field, value) {
if (!providersData[key].qwen_config) {
providersData[key].qwen_config = {};
}
providersData[key].qwen_config[field] = value;
// Handle dynamic UI updates for region/workspace configuration
if (field === 'api_key') {
const regionConfigEl = document.getElementById(`qwen-region-config-${key}`);
if (regionConfigEl) {
regionConfigEl.style.display = value ? 'block' : 'none';
}
} else if (field === 'region') {
const workspaceConfigEl = document.getElementById(`qwen-workspace-config-${key}`);
if (workspaceConfigEl) {
workspaceConfigEl.style.display = value === 'germany-frankfurt' ? 'block' : 'none';
}
}
}
function updateCodexConfig(key, field, value) {
if (!providersData[key].codex_config) {
providersData[key].codex_config = {};
}
providersData[key].codex_config[field] = value;
}
async function authenticateQwen(key) {
const statusEl = document.getElementById(`qwen-auth-status-${key}`);
statusEl.style.display = 'block';
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Starting Qwen OAuth2 Device Authorization flow...</p>';
try {
const response = await fetch('{{ url_for(request, "/dashboard/qwen/auth/start") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: key,
credentials_file: providersData[key].qwen_config?.credentials_file || '~/.aisbf/qwen_credentials.json'
})
});
const data = await response.json();
if (!data.success) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Failed to start authentication: ${data.error || 'Unknown error'}</p>`;
return;
}
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = `
<div style="margin: 0;">
<p style="margin: 0 0 10px 0; color: #4a9eff; font-weight: bold;">🔐 Qwen Device Authorization</p>
<p style="margin: 0 0 10px 0; color: #e0e0e0;">
Please visit: <a href="${data.verification_uri}" target="_blank" style="color: #4eff9e; text-decoration: underline;">${data.verification_uri}</a>
</p>
<p style="margin: 0 0 10px 0; color: #e0e0e0;">
Enter code: <strong style="color: #4eff9e; font-size: 18px; letter-spacing: 2px;">${data.user_code}</strong>
</p>
<p style="margin: 0; color: #a0a0a0; font-size: 13px;">
Waiting for authorization... (expires in ${Math.floor(data.expires_in / 60)} minutes)
</p>
</div>
`;
try {
window.open(data.verification_uri, 'qwen-auth', 'width=600,height=700');
} catch (e) {
console.error('Could not open auth window:', e);
}
let pollCount = 0;
const maxPolls = Math.floor(data.expires_in / data.interval);
const pollInterval = setInterval(async () => {
pollCount++;
try {
const pollResponse = await fetch('{{ url_for(request, "/dashboard/qwen/auth/poll") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const pollData = await pollResponse.json();
if (pollData.status === 'approved') {
clearInterval(pollInterval);
statusEl.style.background = '#0f4020';
statusEl.style.border = '1px solid #4eff9e';
statusEl.innerHTML = '<p style="margin: 0; color: #4eff9e;">✓ Qwen authentication successful! Credentials saved.</p>';
} else if (pollData.status === 'denied') {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authorization denied by user.</p>';
} else if (pollData.status === 'expired') {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authorization code expired. Please try again.</p>';
}
} catch (error) {
console.error('Error polling Qwen auth:', error);
}
if (pollCount >= maxPolls) {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authentication timeout. Please try again.</p>';
}
}, data.interval * 1000);
} catch (error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
}
async function checkQwenAuth(key) {
const statusEl = document.getElementById(`qwen-auth-status-${key}`);
statusEl.style.display = 'block';
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Checking Qwen authentication status...</p>';
try {
const response = await fetch('{{ url_for(request, "/dashboard/qwen/auth/status") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: key,
credentials_file: providersData[key].qwen_config?.credentials_file || '~/.aisbf/qwen_credentials.json'
})
});
const data = await response.json();
if (data.authenticated) {
statusEl.style.background = '#0f4020';
statusEl.style.border = '1px solid #4eff9e';
const expiresIn = data.expires_in ? ` (expires in ${Math.floor(data.expires_in / (24 * 60 * 60))} days)` : '';
statusEl.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ Qwen authenticated${expiresIn}</p>`;
} else {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Not authenticated. Click "Authenticate with Qwen" to log in.</p>';
}
} catch (error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
}
async function uploadFileChunked(providerKey, fileType, file, statusElement, updateCallback) {
if (!file) return;
const CHUNK_SIZE = 256 * 1024; // 256KB chunks - works with every proxy
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
let uploadedChunks = 0;
statusElement.innerHTML = `<p style="margin: 0; color: #4a9eff;">🔄 Uploading 0% (0/${totalChunks} chunks)...</p>`;
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('provider_key', providerKey);
formData.append('file_type', fileType);
formData.append('file_name', file.name);
formData.append('chunk_number', i + 1);
formData.append('total_chunks', totalChunks);
formData.append('total_size', file.size);
try {
const response = await fetch('{{ url_for(request, "/dashboard/providers/upload-auth-file/chunk") }}', {
method: 'POST',
body: formData
});
const data = await response.json();
if (!data.success) {
statusElement.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Upload failed: ${data.error}</p>`;
return;
}
uploadedChunks++;
const percent = Math.round((uploadedChunks / totalChunks) * 100);
statusElement.innerHTML = `<p style="margin: 0; color: #4a9eff;">🔄 Uploading ${percent}% (${uploadedChunks}/${totalChunks} chunks)...</p>`;
if (data.complete) {
statusElement.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ File uploaded successfully! Path: ${data.file_path}</p>`;
if (updateCallback) {
updateCallback(data.file_path);
}
}
} catch (error) {
statusElement.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
return;
}
}
}
async function uploadQwenFile(providerKey, file) {
if (!file) return;
const statusEl = document.getElementById(`qwen-upload-status-${providerKey}`);
await uploadFileChunked(providerKey, 'credentials', file, statusEl, (filePath) => {
updateQwenConfig(providerKey, 'credentials_file', filePath);
});
}
async function authenticateCodex(key) {
const statusEl = document.getElementById(`codex-auth-status-${key}`);
statusEl.style.display = 'block';
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Starting Codex OAuth2 Device Authorization flow...</p>';
try {
const response = await fetch('{{ url_for(request, "/dashboard/codex/auth/start") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: key,
credentials_file: providersData[key].codex_config?.credentials_file || '~/.aisbf/codex_credentials.json',
issuer: providersData[key].codex_config?.issuer || 'https://auth.openai.com'
})
});
const data = await response.json();
if (!data.success) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Failed to start authentication: ${data.error || 'Unknown error'}</p>`;
return;
}
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = `
<div style="margin: 0;">
<p style="margin: 0 0 10px 0; color: #4a9eff; font-weight: bold;">🔐 Codex Device Authorization</p>
<p style="margin: 0 0 10px 0; color: #e0e0e0;">
Please visit: <a href="${data.verification_uri}" target="_blank" style="color: #4eff9e; text-decoration: underline;">${data.verification_uri}</a>
</p>
<p style="margin: 0 0 10px 0; color: #e0e0e0;">
Enter code: <strong style="color: #4eff9e; font-size: 18px; letter-spacing: 2px;">${data.user_code}</strong>
</p>
<p style="margin: 0; color: #a0a0a0; font-size: 13px;">
Waiting for authorization... (expires in ${Math.floor(data.expires_in / 60)} minutes)
</p>
</div>
`;
try {
window.open(data.verification_uri, 'codex-auth', 'width=600,height=700');
} catch (e) {
console.error('Could not open auth window:', e);
}
let pollCount = 0;
const maxPolls = Math.floor(data.expires_in / data.interval);
const pollInterval = setInterval(async () => {
pollCount++;
try {
const pollResponse = await fetch('{{ url_for(request, "/dashboard/codex/auth/poll") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const pollData = await pollResponse.json();
if (pollData.status === 'approved') {
clearInterval(pollInterval);
statusEl.style.background = '#0f4020';
statusEl.style.border = '1px solid #4eff9e';
statusEl.innerHTML = '<p style="margin: 0; color: #4eff9e;">✓ Codex authentication successful! Credentials saved.</p>';
// Update endpoint field to ChatGPT backend for OAuth2 mode
const endpointInput = document.querySelector(`input[onchange*="updateProvider('${key}', 'endpoint', this.value)"]`);
if (endpointInput && pollData.new_endpoint) {
// Update both the UI input field AND the underlying providersData object
endpointInput.value = pollData.new_endpoint;
providersData[key].endpoint = pollData.new_endpoint;
// Remove readonly attribute if present
endpointInput.removeAttribute('readonly');
endpointInput.style.background = '#1a1a2e';
endpointInput.style.cursor = 'text';
}
} else if (pollData.status === 'denied') {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authorization denied by user.</p>';
} else if (pollData.status === 'expired') {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authorization code expired. Please try again.</p>';
}
} catch (error) {
console.error('Error polling Codex auth:', error);
}
if (pollCount >= maxPolls) {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authentication timeout. Please try again.</p>';
}
}, data.interval * 1000);
} catch (error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
}
async function checkCodexAuth(key) {
const statusEl = document.getElementById(`codex-auth-status-${key}`);
statusEl.style.display = 'block';
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Checking Codex authentication status...</p>';
try {
const response = await fetch('{{ url_for(request, "/dashboard/codex/auth/status") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: key,
credentials_file: providersData[key].codex_config?.credentials_file || '~/.aisbf/codex_credentials.json'
})
});
const data = await response.json();
if (data.authenticated) {
statusEl.style.background = '#0f4020';
statusEl.style.border = '1px solid #4eff9e';
const expiresIn = data.expires_in ? ` (expires in ${Math.floor(data.expires_in / (24 * 60 * 60))} days)` : '';
statusEl.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ Codex authenticated${expiresIn}</p>`;
} else {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Not authenticated. Click "Authenticate with Codex" to log in.</p>';
}
} catch (error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
}
async function uploadCodexFile(providerKey, file) {
if (!file) return;
const statusEl = document.getElementById(`codex-upload-status-${providerKey}`);
await uploadFileChunked(providerKey, 'credentials', file, statusEl, (filePath) => {
updateCodexConfig(providerKey, 'credentials_file', filePath);
});
}
async function authenticateKilo(key) {
const statusEl = document.getElementById(`kilo-auth-status-${key}`);
statusEl.style.display = 'block';
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Starting OAuth2 Device Authorization flow...</p>';
try {
const response = await fetch('{{ url_for(request, "/dashboard/kilo/auth/start") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: key,
credentials_file: providersData[key].kilo_config?.credentials_file || '~/.kilo_credentials.json',
api_base: providersData[key].kilo_config?.api_base || 'https://api.kilo.ai'
})
});
const data = await response.json();
if (!data.success) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Failed to start authentication: ${data.error || 'Unknown error'}</p>`;
return;
}
// Display the verification URL and code to the user
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = `
<div style="margin: 0;">
<p style="margin: 0 0 10px 0; color: #4a9eff; font-weight: bold;">🔐 Authorization Required</p>
<p style="margin: 0 0 10px 0; color: #e0e0e0;">
Please visit: <a href="${data.verification_uri}" target="_blank" style="color: #4eff9e; text-decoration: underline;">${data.verification_uri}</a>
</p>
<p style="margin: 0 0 10px 0; color: #e0e0e0;">
Enter code: <strong style="color: #4eff9e; font-size: 18px; letter-spacing: 2px;">${data.user_code}</strong>
</p>
<p style="margin: 0; color: #a0a0a0; font-size: 13px;">
Waiting for authorization... (expires in ${Math.floor(data.expires_in / 60)} minutes)
</p>
</div>
`;
// Open the verification URL in a new window
try {
window.open(data.verification_uri, 'kilo-auth', 'width=600,height=700');
} catch (e) {
console.error('Could not open auth window:', e);
}
// Poll for authorization completion
let pollCount = 0;
const maxPolls = Math.floor(data.expires_in / data.interval);
const pollInterval = setInterval(async () => {
pollCount++;
try {
const pollResponse = await fetch('{{ url_for(request, "/dashboard/kilo/auth/poll") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const pollData = await pollResponse.json();
if (pollData.status === 'approved') {
clearInterval(pollInterval);
statusEl.style.background = '#0f4020';
statusEl.style.border = '1px solid #4eff9e';
statusEl.innerHTML = '<p style="margin: 0; color: #4eff9e;">✓ Authentication successful! Credentials saved.</p>';
} else if (pollData.status === 'denied') {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authorization denied by user.</p>';
} else if (pollData.status === 'expired') {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authorization code expired. Please try again.</p>';
} else if (pollData.status === 'slow_down') {
// Server asked us to slow down, but we'll continue at the same rate
console.log('Server requested slow down');
}
// status === 'pending', continue polling
} catch (error) {
console.error('Error polling Kilo auth:', error);
}
// Timeout after max polls
if (pollCount >= maxPolls) {
clearInterval(pollInterval);
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authentication timeout. Please try again.</p>';
}
}, data.interval * 1000); // Convert seconds to milliseconds
} catch (error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
}
async function checkKiloAuth(key) {
const statusEl = document.getElementById(`kilo-auth-status-${key}`);
statusEl.style.display = 'block';
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Checking authentication status...</p>';
try {
const response = await fetch('{{ url_for(request, "/dashboard/kilo/auth/status") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: key,
credentials_file: providersData[key].kilo_config?.credentials_file || '~/.kilo_credentials.json'
})
});
const data = await response.json();
if (data.authenticated) {
statusEl.style.background = '#0f4020';
statusEl.style.border = '1px solid #4eff9e';
const expiresIn = data.expires_in ? ` (expires in ${Math.floor(data.expires_in / (24 * 60 * 60))} days)` : '';
statusEl.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ Authenticated${expiresIn}</p>`;
} else {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Not authenticated. Click "Authenticate with Kilo" to log in.</p>';
}
} catch (error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
}
async function uploadKiloFile(providerKey, file) {
if (!file) return;
const statusEl = document.getElementById(`kilo-upload-status-${providerKey}`);
await uploadFileChunked(providerKey, 'credentials', file, statusEl, (filePath) => {
updateKiloConfig(providerKey, 'credentials_file', filePath);
});
}
// Extension detection and configuration
let extensionInstalled = false;
let extensionCheckComplete = false;
async function checkExtensionInstalled() {
if (extensionCheckComplete) {
return extensionInstalled;
}
try {
// Check if the extension has injected a marker into the page
// The extension should set window.aisbfOAuth2Extension when loaded
if (window.aisbfOAuth2Extension) {
extensionInstalled = true;
extensionCheckComplete = true;
return true;
}
// Try using postMessage to detect the extension
return new Promise((resolve) => {
const messageHandler = (event) => {
if (event.data && event.data.type === 'aisbf-extension-pong') {
extensionInstalled = true;
extensionCheckComplete = true;
window.removeEventListener('message', messageHandler);
resolve(true);
}
};
window.addEventListener('message', messageHandler);
// Send ping message
window.postMessage({ type: 'aisbf-extension-ping' }, '*');
// Timeout after 1 second
setTimeout(() => {
window.removeEventListener('message', messageHandler);
extensionInstalled = false;
extensionCheckComplete = true;
resolve(false);
}, 1000);
});
} catch (error) {
console.error('Error checking extension:', error);
extensionInstalled = false;
extensionCheckComplete = true;
return false;
}
}
async function configureExtension() {
try {
const serverUrl = window.location.href.replace(/\/dashboard.*$/, '');
// Send configuration to extension using postMessage
window.postMessage({
type: 'aisbf-extension-configure',
serverUrl: serverUrl
}, '*');
console.log('Extension configuration sent');
} catch (error) {
console.error('Error configuring extension:', error);
}
}
async function authenticateClaude(key) {
const statusEl = document.getElementById(`claude-auth-status-${key}`);
statusEl.style.display = 'block';
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Starting OAuth2 authentication flow...</p>';
try {
// Get OAuth2 URL from server
const response = await fetch('{{ url_for(request, "/dashboard/claude/auth/start") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: key,
credentials_file: providersData[key].claude_config?.credentials_file || '~/.claude_credentials.json'
})
});
const data = await response.json();
if (!data.success || !data.auth_url) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Failed to start authentication: ${data.error || 'Unknown error'}</p>`;
return;
}
// Check if extension is needed (not needed when accessing from localhost)
const needsExtension = data.use_extension;
if (needsExtension) {
// Check if extension is installed
const hasExtension = await checkExtensionInstalled();
if (!hasExtension) {
// Show extension installation prompt
statusEl.style.background = '#403010';
statusEl.style.border = '1px solid #ffaa4a';
statusEl.innerHTML = `
<div style="margin: 0;">
<p style="margin: 0 0 10px 0; color: #ffaa4a;">⚠️ OAuth2 Redirect Extension Required</p>
<p style="margin: 0 0 10px 0; color: #a0a0a0; font-size: 13px;">
For remote server OAuth2 authentication, you need to install the AISBF OAuth2 Redirect Extension.
This extension intercepts localhost OAuth2 callbacks and redirects them to your AISBF server.
</p>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<a href="{{ url_for(request, '/dashboard/extension/download') }}" class="btn" style="background: #4a9eff; color: white; text-decoration: none; padding: 8px 15px; border-radius: 3px; font-size: 13px;">
📥 Download Extension
</a>
<button type="button" class="btn btn-secondary" onclick="showExtensionInstructions()" style="padding: 8px 15px; font-size: 13px;">
📖 Installation Guide
</button>
</div>
</div>
`;
return;
}
// Configure extension with server URL
await configureExtension();
}
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Opening authentication window...</p>';
// Open OAuth2 URL in new window
const authWindow = window.open(data.auth_url, 'claude-auth', 'width=600,height=700');
// Wait for user to interact with the popup before starting to poll
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Please complete authentication in the popup window...</p>';
// Poll for completion
let pollCount = 0;
const maxPolls = 60; // 2 minutes
let pollDelay = 2000; // 2 second delay between polls
let isCompleting = false; // Prevent duplicate complete calls
// Wait 8 seconds before starting to poll (give user time to interact with popup)
await new Promise(resolve => setTimeout(resolve, 8000));
const pollInterval = setInterval(async () => {
pollCount++;
// For localhost flow, check callback status endpoint
if (!needsExtension) {
try {
const statusResponse = await fetch('{{ url_for(request, "/dashboard/claude/auth/callback-status") }}');
const statusData = await statusResponse.json();
if (statusData.received) {
clearInterval(pollInterval);
// Close the auth window if still open
if (authWindow && !authWindow.closed) {
authWindow.close();
}
if (statusData.error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ OAuth2 error: ${statusData.error}</p>`;
return;
}
// Prevent duplicate complete calls
if (isCompleting) return;
isCompleting = true;
// Wait a moment before completing to avoid rate limits
await new Promise(resolve => setTimeout(resolve, 1000));
// Try to complete authentication
try {
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Completing authentication (this may take a moment if rate limited)...</p>';
const completeResponse = await fetch('{{ url_for(request, "/dashboard/claude/auth/complete") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const completeData = await completeResponse.json();
if (completeData.success) {
statusEl.style.background = '#0f4020';
statusEl.style.border = '1px solid #4eff9e';
statusEl.innerHTML = '<p style="margin: 0; color: #4eff9e;">✓ Authentication successful! Credentials saved.</p>';
} else {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
if (completeData.error && completeData.error.includes('rate_limit')) {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Rate limited by Claude. Please wait 1-2 minutes and try again.</p>`;
} else {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Authentication incomplete. ${completeData.error || 'Please try again.'}</p>`;
}
}
} catch (error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error completing authentication: ${error.message}</p>`;
}
}
} catch (error) {
console.error('Error checking callback status:', error);
// Increase delay on error to avoid rate limits
pollDelay = Math.min(pollDelay * 1.5, 10000);
}
} else {
// For extension flow, check if window was closed
if (authWindow && authWindow.closed) {
clearInterval(pollInterval);
// Prevent duplicate complete calls
if (isCompleting) return;
isCompleting = true;
// Wait a moment for the OAuth2 callback to be processed
await new Promise(resolve => setTimeout(resolve, 2000));
// Try to complete authentication
try {
const completeResponse = await fetch('{{ url_for(request, "/dashboard/claude/auth/complete") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const completeData = await completeResponse.json();
if (completeData.success) {
statusEl.style.background = '#0f4020';
statusEl.style.border = '1px solid #4eff9e';
statusEl.innerHTML = '<p style="margin: 0; color: #4eff9e;">✓ Authentication successful! Credentials saved.</p>';
} else {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Authentication incomplete. ${completeData.error || 'Please try again.'}</p>`;
}
} catch (error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error completing authentication: ${error.message}</p>`;
}
}
}
// Timeout after max polls
if (pollCount >= maxPolls) {
clearInterval(pollInterval);
if (authWindow && !authWindow.closed) {
authWindow.close();
}
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Authentication timeout. Please try again.</p>';
}
}, pollDelay); // Use dynamic delay
} catch (error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
}
function showExtensionInstructions() {
// Create modal overlay
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
padding: 20px;
`;
modal.innerHTML = `
<div style="background: #16213e; border: 2px solid #4a9eff; border-radius: 10px; padding: 30px; max-width: 700px; max-height: 90vh; overflow-y: auto;">
<h2 style="margin: 0 0 20px 0; color: #4a9eff; font-size: 24px;">
📦 AISBF OAuth2 Extension - Installation Guide
</h2>
<div style="background: #0f2840; padding: 15px; border-radius: 5px; margin-bottom: 20px; border-left: 3px solid #4eff9e;">
<p style="margin: 0; color: #4eff9e; font-weight: bold;">ℹ️ About This Extension</p>
<p style="margin: 5px 0 0 0; color: #a0a0a0; font-size: 14px;">
This extension intercepts OAuth2 callbacks from localhost:54545 and redirects them to your AISBF server, enabling remote authentication.
<p style="margin: 5px 0 0 0; color: #a0a0a0; font-size: 14px;">
<strong>Proxy Support:</strong> The extension automatically detects reverse proxy deployments (nginx, caddy, etc.) via X-Forwarded-* headers and activates when OAuth flows are initiated from the dashboard.
</p>
</p>
</div>
<div style="margin-bottom: 25px;">
<h3 style="color: #8ec8ff; margin: 0 0 15px 0; font-size: 18px;">Installation Steps:</h3>
<div style="margin-bottom: 20px;">
<div style="display: flex; align-items: start; margin-bottom: 15px;">
<div style="background: #4a9eff; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; flex-shrink: 0; margin-right: 15px;">1</div>
<div>
<strong style="color: #e0e0e0; display: block; margin-bottom: 5px;">Download the Extension</strong>
<p style="margin: 0; color: #a0a0a0; font-size: 14px;">
Click the "📥 Download Extension" button to download <code style="background: #0f2840; padding: 2px 6px; border-radius: 3px;">aisbf-oauth2-extension.zip</code>
</p>
</div>
</div>
<div style="display: flex; align-items: start; margin-bottom: 15px;">
<div style="background: #4a9eff; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; flex-shrink: 0; margin-right: 15px;">2</div>
<div>
<strong style="color: #e0e0e0; display: block; margin-bottom: 5px;">Extract the ZIP File</strong>
<p style="margin: 0; color: #a0a0a0; font-size: 14px;">
Extract the contents to a <strong>permanent location</strong> (e.g., Documents/Extensions/). Don't delete this folder after installation.
</p>
</div>
</div>
<div style="display: flex; align-items: start; margin-bottom: 15px;">
<div style="background: #4a9eff; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; flex-shrink: 0; margin-right: 15px;">3</div>
<div>
<strong style="color: #e0e0e0; display: block; margin-bottom: 5px;">Open Chrome Extensions</strong>
<p style="margin: 0; color: #a0a0a0; font-size: 14px;">
In Chrome, navigate to: <code style="background: #0f2840; padding: 2px 6px; border-radius: 3px;">chrome://extensions/</code>
<br><span style="font-size: 12px; color: #808080;">(Copy and paste this into your address bar)</span>
</p>
</div>
</div>
<div style="display: flex; align-items: start; margin-bottom: 15px;">
<div style="background: #4a9eff; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; flex-shrink: 0; margin-right: 15px;">4</div>
<div>
<strong style="color: #e0e0e0; display: block; margin-bottom: 5px;">Enable Developer Mode</strong>
<p style="margin: 0; color: #a0a0a0; font-size: 14px;">
Toggle the "Developer mode" switch in the top-right corner of the extensions page.
</p>
</div>
</div>
<div style="display: flex; align-items: start; margin-bottom: 15px;">
<div style="background: #4a9eff; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; flex-shrink: 0; margin-right: 15px;">5</div>
<div>
<strong style="color: #e0e0e0; display: block; margin-bottom: 5px;">Load Unpacked Extension</strong>
<p style="margin: 0; color: #a0a0a0; font-size: 14px;">
Click the "Load unpacked" button and select the <strong>extracted folder</strong> (not the ZIP file).
</p>
</div>
</div>
<div style="display: flex; align-items: start; margin-bottom: 15px;">
<div style="background: #4a9eff; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; flex-shrink: 0; margin-right: 15px;">6</div>
<div>
<strong style="color: #e0e0e0; display: block; margin-bottom: 5px;">Verify Installation</strong>
<p style="margin: 0; color: #a0a0a0; font-size: 14px;">
The extension should appear in your extensions list. You may see the extension icon in your browser toolbar.
</p>
</div>
</div>
<div style="display: flex; align-items: start;">
<div style="background: #4eff9e; color: #0a0a0a; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; flex-shrink: 0; margin-right: 15px;">✓</div>
<div>
<strong style="color: #4eff9e; display: block; margin-bottom: 5px;">Ready to Authenticate</strong>
<p style="margin: 0; color: #a0a0a0; font-size: 14px;">
Return to this page and click "🔐 Authenticate with Claude" again. The extension will automatically handle OAuth2 callbacks.
</p>
</div>
</div>
</div>
</div>
<div style="background: #0f2840; padding: 15px; border-radius: 5px; margin-bottom: 20px; border-left: 3px solid #ffaa4a;">
<p style="margin: 0 0 5px 0; color: #ffaa4a; font-weight: bold;">⚠️ Important Notes:</p>
<ul style="margin: 5px 0 0 20px; padding: 0; color: #a0a0a0; font-size: 13px;">
<li style="margin-bottom: 5px;">Extract to a permanent location - don't delete the folder after installation</li>
<li style="margin-bottom: 5px;">The extension only intercepts localhost:54545 OAuth2 callbacks</li>
<li style="margin-bottom: 5px;">No data is collected or stored by the extension</li>
<li>You can disable the extension when not using Claude authentication</li>
</ul>
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button type="button" class="btn" onclick="this.closest('div').parentElement.parentElement.remove()" style="background: #4a9eff; padding: 10px 20px;">
Got It!
</button>
</div>
</div>
`;
// Close modal when clicking outside
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
document.body.appendChild(modal);
}
async function checkClaudeAuth(key) {
const statusEl = document.getElementById(`claude-auth-status-${key}`);
statusEl.style.display = 'block';
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Checking authentication status...</p>';
try {
const response = await fetch('{{ url_for(request, "/dashboard/claude/auth/status") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: key,
credentials_file: providersData[key].claude_config?.credentials_file || '~/.claude_credentials.json'
})
});
const data = await response.json();
if (data.authenticated) {
statusEl.style.background = '#0f4020';
statusEl.style.border = '1px solid #4eff9e';
const expiresIn = data.expires_in ? ` (expires in ${Math.floor(data.expires_in / 60)} minutes)` : '';
statusEl.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ Authenticated${expiresIn}</p>`;
} else {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ff4a4a;">✗ Not authenticated. Click "Authenticate with Claude" to log in.</p>';
}
} catch (error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
}
function addModel(providerKey) {
if (!providersData[providerKey].models) {
providersData[providerKey].models = [];
}
providersData[providerKey].models.push({
name: '',
rate_limit: 0
});
renderModels(providerKey);
}
function removeModel(providerKey, index) {
if (confirm('Remove this model?')) {
providersData[providerKey].models.splice(index, 1);
renderModels(providerKey);
}
}
function updateModel(providerKey, index, field, value) {
providersData[providerKey].models[index][field] = value;
}
function updateModelCondenseMethod(providerKey, index, value) {
const trimmed = value.trim();
if (!trimmed) {
providersData[providerKey].models[index].condense_method = null;
return;
}
// Check if it's a comma-separated list
if (trimmed.includes(',')) {
providersData[providerKey].models[index].condense_method =
trimmed.split(',').map(s => s.trim()).filter(s => s);
} else {
providersData[providerKey].models[index].condense_method = trimmed;
}
}
function updateProviderCondenseMethod(providerKey, value) {
const trimmed = value.trim();
if (!trimmed) {
providersData[providerKey].default_condense_method = null;
return;
}
// Check if it's a comma-separated list
if (trimmed.includes(',')) {
providersData[providerKey].default_condense_method =
trimmed.split(',').map(s => s.trim()).filter(s => s);
} else {
providersData[providerKey].default_condense_method = trimmed;
}
}
async function saveProviders() {
try {
const response = await fetch('{{ url_for(request, "/dashboard/providers") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'config=' + encodeURIComponent(JSON.stringify(providersData, null, 2))
});
if (response.ok) {
window.location.href = '{{ url_for(request, "/dashboard/providers?success=1") }}';
} else {
alert('Error saving configuration');
}
} catch (error) {
alert('Error: ' + error.message);
}
}
async function uploadKiroFile(providerKey, fileType, file) {
if (!file) return;
const statusEl = document.getElementById(`kiro-upload-status-${providerKey}`);
await uploadFileChunked(providerKey, fileType, file, statusEl, (filePath) => {
if (fileType === 'creds_file') {
updateKiroConfig(providerKey, 'creds_file', filePath);
} else if (fileType === 'sqlite_db') {
updateKiroConfig(providerKey, 'sqlite_db', filePath);
}
});
}
async function uploadClaudeFile(providerKey, file) {
if (!file) return;
const statusEl = document.getElementById(`claude-upload-status-${providerKey}`);
await uploadFileChunked(providerKey, 'credentials', file, statusEl, (filePath) => {
updateClaudeConfig(providerKey, 'credentials_file', filePath);
});
}
async function getModelsFromProvider(providerKey) {
const statusEl = document.getElementById(`get-models-status-${providerKey}`);
statusEl.style.display = 'block';
statusEl.style.background = '#0f2840';
statusEl.style.border = '1px solid #4a9eff';
statusEl.style.padding = '10px';
statusEl.style.borderRadius = '3px';
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Fetching models from provider API...</p>';
try {
const response = await fetch('{{ url_for(request, "/dashboard/providers/get-models") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: providerKey
})
});
const data = await response.json();
if (data.success && data.models && data.models.length > 0) {
// Clear existing models
providersData[providerKey].models = [];
// Add fetched models with all available fields
data.models.forEach(model => {
const modelConfig = {
name: model.id || model.name,
rate_limit: 0,
max_request_tokens: null,
context_size: model.context_size || model.context_length || null,
rate_limit_TPM: null,
rate_limit_TPH: null,
rate_limit_TPD: null,
condense_context: null,
condense_method: null,
nsfw: false,
privacy: false
};
// Add optional fields if available from API
if (model.description) {
modelConfig.description = model.description;
}
if (model.context_length) {
modelConfig.context_length = model.context_length;
}
if (model.architecture) {
modelConfig.architecture = model.architecture;
}
if (model.pricing) {
modelConfig.pricing = model.pricing;
}
if (model.top_provider) {
modelConfig.top_provider = model.top_provider;
}
if (model.supported_parameters) {
modelConfig.supported_parameters = model.supported_parameters;
}
if (model.default_parameters) {
modelConfig.default_parameters = model.default_parameters;
}
providersData[providerKey].models.push(modelConfig);
});
// Re-render models
renderModels(providerKey);
statusEl.style.background = '#0f4020';
statusEl.style.border = '1px solid #4eff9e';
statusEl.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ Successfully fetched ${data.models.length} model(s)! Review and save the configuration.</p>`;
// Auto-hide success message after 5 seconds
setTimeout(() => {
statusEl.style.display = 'none';
}, 5000);
} else if (data.success && (!data.models || data.models.length === 0)) {
statusEl.style.background = '#403010';
statusEl.style.border = '1px solid #ffaa4a';
statusEl.innerHTML = '<p style="margin: 0; color: #ffaa4a;">⚠️ No models found from provider API.</p>';
} else {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Failed to fetch models: ${data.error || 'Unknown error'}</p>`;
}
} catch (error) {
statusEl.style.background = '#402010';
statusEl.style.border = '1px solid #ff4a4a';
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
function togglePricingFields(key) {
const provider = providersData[key];
const pricingFields = document.getElementById(`pricing-fields-${key}`);
if (pricingFields) {
pricingFields.style.display = provider.is_subscription ? 'none' : 'block';
}
}
// Initial render
renderProvidersList();
</script>
<style>
......
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