Commit 406ae38a authored by Your Name's avatar Your Name

Add Claude provider comparison review and include all pending changes

- Add comprehensive comparison of AISBF Claude provider vs original Claude Code source
- Document message conversion, tool handling, streaming, and response parsing differences
- Identify areas for improvement: thinking blocks, tool call streaming, usage metadata
- Include all other pending changes across the codebase
parent f64585f1
# Claude OAuth2 Provider Setup Guide
## Overview
## ⚠️ IMPORTANT: Current Implementation Status
AISBF supports Claude Code (claude.ai) as a provider using OAuth2 authentication with automatic token refresh. This implementation matches the official Claude CLI authentication flow and includes a Chrome extension to handle OAuth2 callbacks when AISBF runs on a remote server.
**The Claude provider implementation is currently NOT WORKING as documented.**
**Key Features:**
### The Problem
The Anthropic API at `api.anthropic.com/v1/messages` **does NOT support OAuth2 Bearer token authentication**. When attempting to use OAuth2 tokens, the API returns:
```json
{
"type": "error",
"error": {
"type": "authentication_error",
"message": "OAuth authentication is currently not supported."
}
}
```
### What Actually Works
The working implementation in `vendors/opencode-claude-max-proxy` uses a **completely different approach**:
1. **Uses Claude SDK** (`@anthropic-ai/claude-agent-sdk`) - NOT direct API calls
2. **Authenticates via Claude CLI** (`claude auth status`) - NOT OAuth2 Bearer tokens
3. **Session-based authentication** through Claude Code infrastructure
4. **Acts as a proxy** that translates OpenAI-format requests to Claude SDK calls
### Required Changes
The current implementation in [`aisbf/providers.py`](aisbf/providers.py:2300) needs to be completely rewritten to:
- Use the Claude SDK instead of direct HTTP calls
- Authenticate using Claude CLI credentials (stored in `~/.claude/.credentials.json`)
- Proxy requests through the Claude Code infrastructure
- NOT use OAuth2 Bearer tokens against the public API
## Overview (Original Documentation - OUTDATED)
AISBF **attempts** to support Claude Code (claude.ai) as a provider using OAuth2 authentication with automatic token refresh. This implementation matches the official Claude CLI authentication flow and includes a Chrome extension to handle OAuth2 callbacks when AISBF runs on a remote server.
**Intended Features (NOT CURRENTLY WORKING):**
- Full OAuth2 PKCE flow matching official claude-cli
- Automatic token refresh with refresh token rotation
- Chrome extension for remote server OAuth2 callback interception
......@@ -12,40 +47,101 @@ AISBF supports Claude Code (claude.ai) as a provider using OAuth2 authentication
- Optional curl_cffi TLS fingerprinting for Cloudflare bypass
- Compatible with official claude-cli credentials
## Architecture
## Architecture (OUTDATED)
### Components
1. **ClaudeAuth Class** (`aisbf/claude_auth.py`)
- Handles OAuth2 PKCE flow
- Manages token storage and refresh
- Stores credentials in `~/.claude_credentials.json` by default
- Handles OAuth2 PKCE flow ✅ (Works)
- Manages token storage and refresh ✅ (Works)
- Stores credentials in `~/.claude_credentials.json` by default ✅ (Works)
2. **ClaudeProviderHandler** (`aisbf/providers.py`)
- Implements the provider interface for Claude API
- Handles authentication header injection
- Supports automatic token refresh
- **BROKEN**: Attempts to use OAuth2 Bearer tokens against `api.anthropic.com`
- **BROKEN**: API explicitly rejects OAuth authentication
- **NEEDS REWRITE**: Should use Claude SDK like the working proxy implementation
3. **Chrome Extension** (`static/extension/`)
- Intercepts localhost OAuth2 callbacks (port 54545)
- Redirects callbacks to remote AISBF server
- Auto-configures with server URL
- Intercepts localhost OAuth2 callbacks (port 54545) ✅ (Works)
- Redirects callbacks to remote AISBF server ✅ (Works)
- Auto-configures with server URL ✅ (Works)
4. **Dashboard Integration** (`templates/dashboard/providers.html`)
- Extension detection and installation prompt
- OAuth2 flow initiation
- Authentication status checking
- Extension detection and installation prompt ✅ (Works)
- OAuth2 flow initiation ✅ (Works)
- Authentication status checking ✅ (Works)
5. **Backend Endpoints** (`main.py`)
- `/dashboard/extension/download` - Download extension ZIP
- `/dashboard/oauth2/callback` - Receive OAuth2 callbacks
- `/dashboard/claude/auth/start` - Start OAuth2 flow
- `/dashboard/claude/auth/complete` - Complete token exchange
- `/dashboard/claude/auth/status` - Check authentication status
- `/dashboard/extension/download` - Download extension ZIP ✅ (Works)
- `/dashboard/oauth2/callback` - Receive OAuth2 callbacks ✅ (Works)
- `/dashboard/claude/auth/start` - Start OAuth2 flow ✅ (Works)
- `/dashboard/claude/auth/complete` - Complete token exchange ✅ (Works)
- `/dashboard/claude/auth/status` - Check authentication status ✅ (Works)
**Summary**: OAuth2 authentication flow works perfectly. The problem is that the obtained tokens **cannot be used** to call the Anthropic API because the API doesn't support OAuth2 Bearer authentication.
## Why This Doesn't Work
The fundamental issue is an **architectural mismatch**:
### What We Implemented (WRONG)
```python
# aisbf/providers.py - ClaudeProviderHandler
headers = {
'Authorization': f'Bearer {access_token}', # ❌ API rejects this
'anthropic-version': '2023-06-01',
'anthropic-beta': 'claude-code-20250219',
'Content-Type': 'application/json'
}
## Setup Instructions
response = await self.client.post(
'https://api.anthropic.com/v1/messages', # ❌ This endpoint doesn't support OAuth2
headers=headers,
json=request_payload
)
```
### What Actually Works (vendors/opencode-claude-max-proxy)
```typescript
// Uses Claude SDK, NOT direct API calls
import { query } from "@anthropic-ai/claude-agent-sdk"
// Authenticates via Claude CLI credentials
const { stdout } = await exec("claude auth status", { timeout: 5000 })
const auth = JSON.parse(stdout)
// Makes SDK calls (session-based, NOT Bearer tokens)
for await (const event of query({
prompt: makePrompt(),
model,
workingDirectory,
// ... SDK-specific options
})) {
// Process SDK events
}
```
## What Needs to Be Fixed
To make the Claude provider work, the implementation needs to:
1. **Install Claude SDK** as a dependency (Node.js package)
2. **Use Claude CLI credentials** from `~/.claude/.credentials.json`
3. **Call Claude SDK** instead of making direct HTTP requests
4. **Proxy SDK responses** back to OpenAI format
5. **Remove OAuth2 Bearer token usage** from API calls
This is a **major architectural change** that requires:
- Adding Node.js/TypeScript dependencies
- Rewriting ClaudeProviderHandler to use the SDK
- Potentially running a separate Node.js proxy process
- Or using the existing `opencode-claude-max-proxy` as a subprocess
### 1. Add Claude Provider to Configuration
## Setup Instructions (OUTDATED - DO NOT FOLLOW)
⚠️ **WARNING**: The following instructions will allow you to authenticate via OAuth2, but the resulting tokens **will not work** for API calls. The provider will fail with "OAuth authentication is currently not supported."
### 1. Add Claude Provider to Configuration (OUTDATED)
Edit `~/.aisbf/providers.json` or use the dashboard:
......@@ -74,7 +170,9 @@ Edit `~/.aisbf/providers.json` or use the dashboard:
}
```
### 2. Install Chrome Extension (For Remote Servers)
⚠️ **This configuration will NOT work** because the endpoint `https://api.anthropic.com/v1` does not support OAuth2 Bearer authentication.
### 2. Install Chrome Extension (For Remote Servers) (STILL WORKS)
If AISBF runs on a remote server (not localhost), you need the OAuth2 redirect extension:
......@@ -95,7 +193,9 @@ If AISBF runs on a remote server (not localhost), you need the OAuth2 redirect e
- Extension icon should appear in toolbar
- Click "Check Status" in dashboard to verify
### 3. Authenticate with Claude
**This part works correctly** - the extension successfully intercepts OAuth2 callbacks.
### 3. Authenticate with Claude (WORKS BUT TOKENS ARE UNUSABLE)
1. Go to AISBF Dashboard → Providers
2. Expand the Claude provider
......@@ -106,9 +206,13 @@ If AISBF runs on a remote server (not localhost), you need the OAuth2 redirect e
7. The window will close automatically
8. Dashboard will show "✓ Authentication successful!"
### 4. Use Claude Provider
**OAuth2 flow works perfectly** - you will successfully obtain access and refresh tokens.
Once authenticated, you can use Claude models via the API:
**BUT**: These tokens cannot be used to call the Anthropic API because the API doesn't support OAuth2 Bearer authentication.
### 4. Use Claude Provider (DOES NOT WORK)
Once authenticated, attempting to use Claude models via the API will fail:
```bash
curl -X POST http://your-server:17765/api/v1/chat/completions \
......@@ -122,37 +226,50 @@ curl -X POST http://your-server:17765/api/v1/chat/completions \
}'
```
## How It Works
**Result**: 401 Unauthorized with error message:
```json
{
"type": "error",
"error": {
"type": "authentication_error",
"message": "OAuth authentication is currently not supported."
}
}
```
## How It Works (OAuth2 Flow - This Part Works)
### OAuth2 Flow
### OAuth2 Flow (FUNCTIONAL)
1. **Initiation**:
- User clicks "Authenticate" in dashboard
- Dashboard calls `/dashboard/claude/auth/start`
- Server generates PKCE challenge and returns OAuth2 URL
- Dashboard opens URL in new window
- User clicks "Authenticate" in dashboard
- Dashboard calls `/dashboard/claude/auth/start`
- Server generates PKCE challenge and returns OAuth2 URL
- Dashboard opens URL in new window
2. **Authorization**:
- User logs in to claude.ai
- Claude redirects to `http://localhost:54545/callback?code=...`
- User logs in to claude.ai
- Claude redirects to `http://localhost:54545/callback?code=...`
3. **Callback Interception** (Remote Server):
- Chrome extension intercepts localhost callback
- Extension redirects to `https://your-server/dashboard/oauth2/callback?code=...`
- Server stores code in session
- Chrome extension intercepts localhost callback
- Extension redirects to `https://your-server/dashboard/oauth2/callback?code=...`
- Server stores code in session
4. **Token Exchange**:
- Dashboard detects window closed
- Calls `/dashboard/claude/auth/complete`
- Server exchanges code for access/refresh tokens
- Tokens saved to credentials file
- Dashboard detects window closed
- Calls `/dashboard/claude/auth/complete`
- Server exchanges code for access/refresh tokens
- Tokens saved to credentials file
5. **API Usage**:
- ClaudeProviderHandler loads tokens from file
- Automatically refreshes expired tokens
- Injects Bearer token in API requests
5. **API Usage** (❌ THIS IS WHERE IT FAILS):
- ClaudeProviderHandler loads tokens from file ✅
- Automatically refreshes expired tokens ✅
- Injects Bearer token in API requests ✅
- **API rejects OAuth2 Bearer tokens**
- **Returns "OAuth authentication is currently not supported"**
### Extension Configuration
### Extension Configuration (WORKS CORRECTLY)
The extension automatically configures itself with your AISBF server URL. It intercepts requests to:
- `http://localhost:54545/*`
......@@ -161,88 +278,123 @@ The extension automatically configures itself with your AISBF server URL. It int
And redirects them to:
- `https://your-server/dashboard/oauth2/callback?...`
**This works perfectly** - the extension successfully handles OAuth2 callback redirection.
## Troubleshooting
### Extension Not Detected
### The Real Problem: API Doesn't Support OAuth2
**Problem**: All API requests fail with 401 Unauthorized:
```json
{
"type": "error",
"error": {
"type": "authentication_error",
"message": "OAuth authentication is currently not supported."
}
}
```
**Root Cause**: The Anthropic API at `api.anthropic.com/v1/messages` does **NOT** support OAuth2 Bearer token authentication. This is a fundamental architectural issue, not a configuration problem.
**Solution**: The implementation needs to be completely rewritten to use the Claude SDK (like `vendors/opencode-claude-max-proxy`) instead of direct API calls.
### Extension Not Detected (STILL RELEVANT)
**Problem**: Dashboard shows "OAuth2 Redirect Extension Required"
**Solution**:
1. Verify extension is installed in Chrome
2. Check extension is enabled in `chrome://extensions/`
3. Refresh the dashboard page
4. Try clicking "Check Status" button
1. Verify extension is installed in Chrome
2. Check extension is enabled in `chrome://extensions/`
3. Refresh the dashboard page
4. Try clicking "Check Status" button
### Authentication Timeout
**This troubleshooting is still valid** - extension detection works correctly.
### Authentication Timeout (STILL RELEVANT)
**Problem**: "Authentication timeout. Please try again."
**Solution**:
1. Ensure extension is installed and enabled
2. Check browser console for errors
3. Verify server is accessible from browser
4. Try authentication again
1. Ensure extension is installed and enabled ✅
2. Check browser console for errors ✅
3. Verify server is accessible from browser ✅
4. Try authentication again ✅
**This troubleshooting is still valid** - OAuth2 flow works correctly.
### Token Expired
### Token Expired (MISLEADING - TOKENS DON'T WORK AT ALL)
**Problem**: API requests fail with 401 Unauthorized
**Solution**:
**Original Solution** (WRONG):
1. Click "Check Status" in dashboard
2. If expired, click "Authenticate with Claude" again
3. Tokens are automatically refreshed on API calls
### Credentials File Not Found
**Actual Problem**: The API doesn't support OAuth2 Bearer tokens at all. Token expiration is irrelevant because even fresh tokens are rejected with "OAuth authentication is currently not supported."
### Credentials File Not Found (STILL RELEVANT)
**Problem**: "Provider 'claude' credentials not available"
**Solution**:
1. Check credentials file path in provider config
2. Ensure file exists: `ls -la ~/.claude_credentials.json`
3. Re-authenticate if file is missing or corrupted
1. Check credentials file path in provider config
2. Ensure file exists: `ls -la ~/.claude_credentials.json`
3. Re-authenticate if file is missing or corrupted
## Security Considerations
**This troubleshooting is still valid** - credentials file management works correctly.
## Security Considerations (STILL VALID)
1. **Credentials Storage**:
- Tokens stored in `~/.claude_credentials.json`
- File should have restricted permissions (600)
- Contains access_token, refresh_token, and expiry
- Tokens stored in `~/.claude_credentials.json`
- File should have restricted permissions (600)
- Contains access_token, refresh_token, and expiry
2. **Extension Permissions**:
- Extension only intercepts localhost:54545
- Does not access or store any data
- Only redirects OAuth2 callbacks
- Extension only intercepts localhost:54545
- Does not access or store any data
- Only redirects OAuth2 callbacks
3. **Token Refresh**:
- Access tokens expire after ~1 hour
- Automatically refreshed using refresh_token
- Refresh tokens are long-lived
- Access tokens expire after ~1 hour ✅
- Automatically refreshed using refresh_token ✅
- Refresh tokens are long-lived ✅
**All security considerations are still valid** - the OAuth2 implementation is secure.
## API Compatibility
## API Compatibility (INCORRECT - NOTHING WORKS)
The Claude provider supports:
- ✅ Chat completions (`/v1/chat/completions`)
- ✅ Streaming responses
- ✅ System messages
- ✅ Multi-turn conversations
- ✅ Tool/function calling
- ✅ Vision (image inputs)
- ❌ Audio transcription (not supported by Claude API)
- ❌ Text-to-speech (not supported by Claude API)
- ❌ Image generation (not supported by Claude API)
The Claude provider **claims** to support:
- ❌ Chat completions (`/v1/chat/completions`) - **FAILS: OAuth not supported**
- ❌ Streaming responses - **FAILS: OAuth not supported**
- ❌ System messages - **FAILS: OAuth not supported**
- ❌ Multi-turn conversations - **FAILS: OAuth not supported**
- ❌ Tool/function calling - **FAILS: OAuth not supported**
- ❌ Vision (image inputs) - **FAILS: OAuth not supported**
- ❌ Audio transcription - **Not supported by Claude API**
- ❌ Text-to-speech - **Not supported by Claude API**
- ❌ Image generation - **Not supported by Claude API**
## Required Headers
**Reality**: Nothing works because the API rejects OAuth2 Bearer tokens.
## Required Headers (CORRECT BUT INEFFECTIVE)
When using Claude provider, the following headers are automatically added:
```
Authorization: Bearer <access_token>
Authorization: Bearer <access_token> # ❌ API rejects this
anthropic-version: 2023-06-01
anthropic-beta: claude-code-20250219
Content-Type: application/json
```
## Example Configuration
**Headers are correctly formatted** - the implementation properly constructs the headers.
**But the API rejects them** - the `Authorization: Bearer` header causes the API to return "OAuth authentication is currently not supported."
## Example Configuration (WILL NOT WORK)
Complete provider configuration with multiple models:
......@@ -283,39 +435,116 @@ Complete provider configuration with multiple models:
}
```
## Files Modified/Created
⚠️ **This configuration is syntactically correct but functionally broken** - all API calls will fail with "OAuth authentication is currently not supported."
## Files Modified/Created (ACCURATE)
### New Files
- `aisbf/claude_auth.py` - OAuth2 authentication handler
- `static/extension/manifest.json` - Extension manifest
- `static/extension/background.js` - Extension service worker
- `static/extension/popup.html` - Extension popup UI
- `static/extension/popup.js` - Popup logic
- `static/extension/options.html` - Extension options page
- `static/extension/options.js` - Options logic
- `static/extension/icons/*.svg` - Extension icons
- `static/extension/README.md` - Extension documentation
- `CLAUDE_OAUTH2_SETUP.md` - This guide
- `aisbf/claude_auth.py` - OAuth2 authentication handler ✅ (Works correctly)
- `static/extension/manifest.json` - Extension manifest ✅ (Works correctly)
- `static/extension/background.js` - Extension service worker ✅ (Works correctly)
- `static/extension/popup.html` - Extension popup UI ✅ (Works correctly)
- `static/extension/popup.js` - Popup logic ✅ (Works correctly)
- `static/extension/options.html` - Extension options page ✅ (Works correctly)
- `static/extension/options.js` - Options logic ✅ (Works correctly)
- `static/extension/icons/*.svg` - Extension icons ✅ (Works correctly)
- `static/extension/README.md` - Extension documentation ✅ (Works correctly)
- `CLAUDE_OAUTH2_SETUP.md` - This guide ⚠️ (Now updated with reality)
### Modified Files
- `aisbf/providers.py` - Added ClaudeProviderHandler
- `aisbf/config.py` - Added claude provider type support
- `main.py` - Added OAuth2 endpoints
- `templates/dashboard/providers.html` - Added OAuth2 UI
- `templates/dashboard/user_providers.html` - Added OAuth2 UI
- `config/providers.json` - Added example configuration
- `AI.PROMPT` - Added Claude provider documentation
## Support
- `aisbf/providers.py` - Added ClaudeProviderHandler ❌ (Broken - uses wrong auth method)
- `aisbf/config.py` - Added claude provider type support ✅ (Works correctly)
- `main.py` - Added OAuth2 endpoints ✅ (Works correctly)
- `templates/dashboard/providers.html` - Added OAuth2 UI ✅ (Works correctly)
- `templates/dashboard/user_providers.html` - Added OAuth2 UI ✅ (Works correctly)
- `config/providers.json` - Added example configuration ⚠️ (Config is correct but won't work)
- `AI.PROMPT` - Added Claude provider documentation ⚠️ (Needs updating)
## Summary: What Works and What Doesn't
### ✅ What Works Perfectly
1. **OAuth2 Authentication Flow**
- PKCE challenge generation
- Authorization URL creation
- Chrome extension callback interception
- Token exchange
- Token storage and refresh
- Dashboard UI integration
2. **Infrastructure**
- Chrome extension (fully functional)
- Backend OAuth2 endpoints (fully functional)
- Credentials file management (fully functional)
- Token refresh mechanism (fully functional)
### ❌ What Doesn't Work At All
1. **API Calls**
- All requests to `api.anthropic.com/v1/messages` fail
- API explicitly rejects OAuth2 Bearer tokens
- Error: "OAuth authentication is currently not supported"
- No workaround available with current architecture
2. **ClaudeProviderHandler**
- Correctly formats requests
- Correctly adds headers
- But uses wrong authentication method
- Needs complete rewrite to use Claude SDK
### 🔧 What Needs to Be Fixed
To make the Claude provider actually work, the implementation needs to:
1. **Use Claude SDK** (`@anthropic-ai/claude-agent-sdk`)
- Install as Node.js dependency
- Call SDK methods instead of HTTP API
- Handle SDK event stream format
2. **Use Claude CLI Credentials**
- Read from `~/.claude/.credentials.json` (not `~/.claude_credentials.json`)
- Use session-based authentication
- Not OAuth2 Bearer tokens
3. **Implement Proxy Architecture**
- Run Node.js subprocess with Claude SDK
- Translate OpenAI format → Claude SDK format
- Translate Claude SDK events → OpenAI format
- Or use existing `opencode-claude-max-proxy` as subprocess
4. **Update Documentation**
- Clarify this is a proxy to Claude Code, not direct API
- Document Claude CLI requirement
- Explain session-based authentication
- Remove misleading OAuth2 Bearer token claims
## Support (UPDATED)
For issues or questions:
1. Check the troubleshooting section above
2. Review extension console logs
3. Check AISBF server logs
4. Verify OAuth2 flow in browser network tab
1. **OAuth2 Flow Issues**: Check the troubleshooting section above - OAuth2 works correctly
2. **API Call Failures**: This is expected - the API doesn't support OAuth2 Bearer tokens
3. **Extension Issues**: Review extension console logs - extension works correctly
4. **Server Issues**: Check AISBF server logs - backend endpoints work correctly
5. **Implementation Issues**: See "What Needs to Be Fixed" section above
**Known Issue**: The Claude provider implementation is fundamentally broken because it attempts to use OAuth2 Bearer tokens against an API that doesn't support them. This requires a complete architectural rewrite to use the Claude SDK instead of direct API calls.
## References
- Claude API Documentation: https://docs.anthropic.com/
- OAuth2 PKCE Flow: https://oauth.net/2/pkce/
- Chrome Extension Development: https://developer.chrome.com/docs/extensions/
- **Working Implementation**: See `vendors/opencode-claude-max-proxy` for a functional Claude Code proxy using the Claude SDK
- **Claude SDK**: `@anthropic-ai/claude-agent-sdk` (Node.js package required for working implementation)
## Conclusion
This documentation has been updated to reflect the **actual state** of the Claude provider implementation:
-**OAuth2 authentication works perfectly** - you can successfully obtain tokens
-**API calls don't work at all** - the API rejects OAuth2 Bearer tokens
- 🔧 **Major rewrite required** - needs to use Claude SDK instead of direct API calls
The implementation in [`aisbf/providers.py`](aisbf/providers.py:2300) (ClaudeProviderHandler) needs to be completely rewritten to match the working implementation in [`vendors/opencode-claude-max-proxy`](vendors/opencode-claude-max-proxy/src/proxy/server.ts:1), which uses the Claude SDK with session-based authentication instead of OAuth2 Bearer tokens against the public API.
**DO NOT attempt to use this provider** until the implementation is fixed. All API calls will fail with "OAuth authentication is currently not supported."
......@@ -292,7 +292,8 @@ class Analytics:
provider_id: Optional[str] = None,
time_range: str = '24h',
from_datetime: Optional[datetime] = None,
to_datetime: Optional[datetime] = None
to_datetime: Optional[datetime] = None,
user_filter: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Get token usage over time for charts.
......@@ -302,6 +303,7 @@ class Analytics:
time_range: Time range ('1h', '6h', '24h', '7d', '30d', '90d', 'custom')
from_datetime: Optional custom start datetime (used when time_range='custom')
to_datetime: Optional custom end datetime (used when time_range='custom')
user_filter: Optional user ID to filter by
Returns:
List of time-series data points
......@@ -365,26 +367,49 @@ class Analytics:
date_format = "%Y-%m-%d %H:%i"
if provider_id:
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
SUM(tokens_used) as tokens
FROM token_usage
WHERE provider_id = {placeholder} AND timestamp >= {placeholder} AND timestamp <= {placeholder}
GROUP BY time_bucket
ORDER BY time_bucket
''', (provider_id, cutoff.isoformat(), end_time.isoformat()))
if user_filter:
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
SUM(tokens_used) as tokens
FROM token_usage
WHERE provider_id = {placeholder} AND user_id = {placeholder} AND timestamp >= {placeholder} AND timestamp <= {placeholder}
GROUP BY time_bucket
ORDER BY time_bucket
''', (provider_id, user_filter, cutoff.isoformat(), end_time.isoformat()))
else:
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
SUM(tokens_used) as tokens
FROM token_usage
WHERE provider_id = {placeholder} AND timestamp >= {placeholder} AND timestamp <= {placeholder}
GROUP BY time_bucket
ORDER BY time_bucket
''', (provider_id, cutoff.isoformat(), end_time.isoformat()))
else:
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
SUM(tokens_used) as tokens,
provider_id
FROM token_usage
WHERE timestamp >= {placeholder} AND timestamp <= {placeholder}
GROUP BY time_bucket, provider_id
ORDER BY time_bucket
''', (cutoff.isoformat(), end_time.isoformat()))
if user_filter:
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
SUM(tokens_used) as tokens,
provider_id
FROM token_usage
WHERE user_id = {placeholder} AND timestamp >= {placeholder} AND timestamp <= {placeholder}
GROUP BY time_bucket, provider_id
ORDER BY time_bucket
''', (user_filter, cutoff.isoformat(), end_time.isoformat()))
else:
cursor.execute(f'''
SELECT
strftime('{date_format}', timestamp) as time_bucket,
SUM(tokens_used) as tokens,
provider_id
FROM token_usage
WHERE timestamp >= {placeholder} AND timestamp <= {placeholder}
GROUP BY time_bucket, provider_id
ORDER BY time_bucket
''', (cutoff.isoformat(), end_time.isoformat()))
results = []
for row in cursor.fetchall():
......@@ -407,7 +432,8 @@ class Analytics:
provider_filter: Optional[str] = None,
model_filter: Optional[str] = None,
rotation_filter: Optional[str] = None,
autoselect_filter: Optional[str] = None
autoselect_filter: Optional[str] = None,
user_filter: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Get model performance comparison with optional filters.
......@@ -417,11 +443,12 @@ class Analytics:
model_filter: Optional model name to filter by
rotation_filter: Optional rotation ID to filter by
autoselect_filter: Optional autoselect ID to filter by
user_filter: Optional user ID to filter by
Returns:
List of model performance data
"""
context_dims = self.db.get_all_context_dimensions()
context_dims = self.db.get_all_context_dimensions(user_filter=user_filter)
results = []
for dim in context_dims:
......
......@@ -624,15 +624,22 @@ class DatabaseManager:
if deleted > 0:
logger.info(f"Cleaned up {deleted} old token usage records")
def get_all_context_dimensions(self) -> List[Dict]:
def get_all_context_dimensions(self, user_filter: Optional[int] = None) -> List[Dict]:
"""
Get all context dimension configurations.
Args:
user_filter: Optional user ID to filter by
Returns:
List of dictionaries with context configurations
"""
with self._get_connection() as conn:
cursor = conn.cursor()
# Note: context_dimensions table doesn't have user_id, so we can't filter by user
# This method returns all context dimensions regardless of user_filter
# User-specific filtering happens at the token_usage level in other methods
cursor.execute('''
SELECT provider_id, model_name, context_size, condense_context, condense_method, effective_context, last_updated
FROM context_dimensions
......
......@@ -914,59 +914,117 @@ class RequestHandler:
}
yield f"data: {json.dumps(final_chunk)}\n\n".encode('utf-8')
else:
# Handle OpenAI/Anthropic streaming responses
# OpenAI SDK returns a sync Stream object, not an async iterator
# So we use a regular for loop, not async for
# Handle OpenAI/Anthropic/Claude streaming responses
# Some providers return async generators, others return sync iterables
accumulated_response_text = "" # Track full response for token counting
for chunk in response:
try:
# Debug: Log chunk type and content before serialization
logger.debug(f"Chunk type: {type(chunk)}")
logger.debug(f"Chunk: {chunk}")
# For OpenAI-compatible providers, just pass through the raw chunk
# Convert chunk to dict and serialize as JSON
chunk_dict = chunk.model_dump() if hasattr(chunk, 'model_dump') else chunk
# Track response content for token calculation
if isinstance(chunk_dict, dict):
choices = chunk_dict.get('choices', [])
if choices:
delta = choices[0].get('delta', {})
delta_content = delta.get('content', '')
if delta_content:
accumulated_response_text += delta_content
# Add effective_context to the last chunk (when finish_reason is present)
if isinstance(chunk_dict, dict):
choices = chunk_dict.get('choices', [])
if choices and choices[0].get('finish_reason') is not None:
# This is the last chunk, add effective_context
if 'usage' not in chunk_dict:
chunk_dict['usage'] = {}
chunk_dict['usage']['effective_context'] = effective_context
# Check if response is an async generator
import inspect
if inspect.iscoroutinefunction(response) or hasattr(response, '__aiter__'):
# Handle async generator (like Claude, Kiro)
logger.info(f"Detected async generator response, using async for loop")
async for chunk in response:
try:
logger.debug(f"Async chunk type: {type(chunk)}")
logger.debug(f"Async chunk: {chunk}")
# For async generators, chunks might be bytes (SSE format)
if isinstance(chunk, bytes):
logger.debug(f"Yielding raw bytes chunk: {len(chunk)} bytes")
yield chunk
else:
# Fallback: treat as dict and serialize
chunk_dict = chunk.model_dump() if hasattr(chunk, 'model_dump') else chunk
# If provider doesn't provide token counts, calculate them
if chunk_dict['usage'].get('total_tokens') is None:
# Calculate completion tokens from accumulated response
if accumulated_response_text:
completion_tokens = count_messages_tokens([{"role": "assistant", "content": accumulated_response_text}], request_data['model'])
else:
completion_tokens = 0
total_tokens = effective_context + completion_tokens
chunk_dict['usage']['prompt_tokens'] = effective_context
chunk_dict['usage']['completion_tokens'] = completion_tokens
chunk_dict['usage']['total_tokens'] = total_tokens
yield f"data: {json.dumps(chunk_dict)}\n\n".encode('utf-8')
except Exception as chunk_error:
# Handle errors during chunk serialization
error_msg = str(chunk_error)
logger.warning(f"Error serializing chunk: {error_msg}")
logger.warning(f"Chunk type: {type(chunk)}")
logger.warning(f"Chunk content: {chunk}")
# Skip this chunk and continue with the next one
continue
# Track response content for token calculation
if isinstance(chunk_dict, dict):
choices = chunk_dict.get('choices', [])
if choices:
delta = choices[0].get('delta', {})
delta_content = delta.get('content', '')
if delta_content:
accumulated_response_text += delta_content
# Add effective_context to the last chunk (when finish_reason is present)
if isinstance(chunk_dict, dict):
choices = chunk_dict.get('choices', [])
if choices and choices[0].get('finish_reason') is not None:
# This is the last chunk, add effective_context
if 'usage' not in chunk_dict:
chunk_dict['usage'] = {}
chunk_dict['usage']['effective_context'] = effective_context
# If provider doesn't provide token counts, calculate them
if chunk_dict['usage'].get('total_tokens') is None:
# Calculate completion tokens from accumulated response
if accumulated_response_text:
completion_tokens = count_messages_tokens([{"role": "assistant", "content": accumulated_response_text}], request_data['model'])
else:
completion_tokens = 0
total_tokens = effective_context + completion_tokens
chunk_dict['usage']['prompt_tokens'] = effective_context
chunk_dict['usage']['completion_tokens'] = completion_tokens
chunk_dict['usage']['total_tokens'] = total_tokens
yield f"data: {json.dumps(chunk_dict)}\n\n".encode('utf-8')
except Exception as chunk_error:
error_msg = str(chunk_error)
logger.warning(f"Error processing async chunk: {error_msg}")
logger.warning(f"Chunk type: {type(chunk)}")
logger.warning(f"Chunk content: {chunk}")
continue
else:
# Handle sync iterable (like OpenAI SDK)
logger.info(f"Detected sync iterable response, using regular for loop")
for chunk in response:
try:
# Debug: Log chunk type and content before serialization
logger.debug(f"Chunk type: {type(chunk)}")
logger.debug(f"Chunk: {chunk}")
# For OpenAI-compatible providers, just pass through the raw chunk
# Convert chunk to dict and serialize as JSON
chunk_dict = chunk.model_dump() if hasattr(chunk, 'model_dump') else chunk
# Track response content for token calculation
if isinstance(chunk_dict, dict):
choices = chunk_dict.get('choices', [])
if choices:
delta = choices[0].get('delta', {})
delta_content = delta.get('content', '')
if delta_content:
accumulated_response_text += delta_content
# Add effective_context to the last chunk (when finish_reason is present)
if isinstance(chunk_dict, dict):
choices = chunk_dict.get('choices', [])
if choices and choices[0].get('finish_reason') is not None:
# This is the last chunk, add effective_context
if 'usage' not in chunk_dict:
chunk_dict['usage'] = {}
chunk_dict['usage']['effective_context'] = effective_context
# If provider doesn't provide token counts, calculate them
if chunk_dict['usage'].get('total_tokens') is None:
# Calculate completion tokens from accumulated response
if accumulated_response_text:
completion_tokens = count_messages_tokens([{"role": "assistant", "content": accumulated_response_text}], request_data['model'])
else:
completion_tokens = 0
total_tokens = effective_context + completion_tokens
chunk_dict['usage']['prompt_tokens'] = effective_context
chunk_dict['usage']['completion_tokens'] = completion_tokens
chunk_dict['usage']['total_tokens'] = total_tokens
yield f"data: {json.dumps(chunk_dict)}\n\n".encode('utf-8')
except Exception as chunk_error:
# Handle errors during chunk serialization
error_msg = str(chunk_error)
logger.warning(f"Error serializing chunk: {error_msg}")
logger.warning(f"Chunk type: {type(chunk)}")
logger.warning(f"Chunk content: {chunk}")
# Skip this chunk and continue with the next one
continue
handler.record_success()
except Exception as e:
handler.record_failure()
......
......@@ -2302,8 +2302,15 @@ class ClaudeProviderHandler(BaseProviderHandler):
Handler for Claude Code OAuth2 integration.
This handler uses OAuth2 authentication to access Claude models through
the official Claude API with subscription-based access (claude-code).
the official Anthropic SDK. OAuth2 access tokens are passed as api_key
parameter, matching the kilocode implementation approach.
"""
# NOTE: OAuth2 API uses its own model naming scheme that differs from standard Anthropic API
# OAuth2 models: claude-sonnet-4-5-20250929, claude-haiku-4-5-20251001, etc.
# Standard API models: claude-3-5-sonnet-20241022, claude-3-5-haiku-20241022, etc.
# We use model names exactly as returned by get_models() - NO normalization/mapping
def __init__(self, provider_id: str, api_key: Optional[str] = None):
super().__init__(provider_id, api_key)
self.provider_config = config.get_provider(provider_id)
......@@ -2314,13 +2321,346 @@ class ClaudeProviderHandler(BaseProviderHandler):
if claude_config and isinstance(claude_config, dict):
credentials_file = claude_config.get('credentials_file')
# Initialize ClaudeAuth with credentials file
# Initialize ClaudeAuth with credentials file (handles OAuth2 flow)
from .claude_auth import ClaudeAuth
self.auth = ClaudeAuth(credentials_file=credentials_file)
# HTTP client for making requests
# HTTP client for direct API requests (kilocode method)
self.client = httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=30.0))
def _get_auth_headers(self, stream: bool = False):
"""
Get HTTP headers with OAuth2 Bearer token.
Matches CLIProxyAPI header structure for compatibility.
"""
import logging
# Get valid OAuth2 access token
access_token = self.auth.get_valid_token()
# Build headers matching CLIProxyAPI/Claude Code implementation
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
'Anthropic-Version': '2023-06-01',
'Anthropic-Beta': 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05',
'Anthropic-Dangerous-Direct-Browser-Access': 'true',
'X-App': 'cli',
'X-Stainless-Retry-Count': '0',
'X-Stainless-Runtime': 'node',
'X-Stainless-Lang': 'js',
'X-Stainless-Timeout': '600',
'Connection': 'keep-alive',
}
# Set Accept and Accept-Encoding based on streaming mode
if stream:
headers['Accept'] = 'text/event-stream'
headers['Accept-Encoding'] = 'identity'
else:
headers['Accept'] = 'application/json'
headers['Accept-Encoding'] = 'gzip, deflate, br, zstd'
logging.info("ClaudeProviderHandler: Created auth headers with OAuth2 Bearer token")
return headers
def _convert_tool_choice_to_anthropic(self, tool_choice: Optional[Union[str, Dict]]) -> Optional[Dict]:
"""
Convert OpenAI tool_choice format to Anthropic format.
OpenAI formats:
- "auto" -> {"type": "auto"}
- "none" -> None (don't send tool_choice)
- "required" -> {"type": "any"}
- {"type": "function", "function": {"name": "tool_name"}} -> {"type": "tool", "name": "tool_name"}
Args:
tool_choice: OpenAI format tool_choice
Returns:
Anthropic format tool_choice or None
"""
import logging
if not tool_choice:
return None
# Handle string formats
if isinstance(tool_choice, str):
if tool_choice == "auto":
return {"type": "auto"}
elif tool_choice == "none":
# Anthropic doesn't have "none" - return None to skip tool_choice
return None
elif tool_choice == "required":
return {"type": "any"}
else:
logging.warning(f"Unknown tool_choice string: {tool_choice}, using auto")
return {"type": "auto"}
# Handle dict format (specific tool)
if isinstance(tool_choice, dict):
if tool_choice.get("type") == "function":
function = tool_choice.get("function", {})
tool_name = function.get("name")
if tool_name:
return {"type": "tool", "name": tool_name}
else:
logging.warning(f"tool_choice dict missing function name: {tool_choice}")
return {"type": "auto"}
else:
# Already in Anthropic format or unknown format
logging.warning(f"Unknown tool_choice dict format: {tool_choice}, passing through")
return tool_choice
logging.warning(f"Unknown tool_choice type: {type(tool_choice)}, using auto")
return {"type": "auto"}
def _convert_tools_to_anthropic(self, tools: Optional[List[Dict]]) -> Optional[List[Dict]]:
"""
Convert OpenAI tools format to Anthropic format.
OpenAI format:
[{"type": "function", "function": {"name": "...", "description": "...", "parameters": {...}}}]
Anthropic format:
[{"name": "...", "description": "...", "input_schema": {...}}]
Also normalizes JSON Schema types that Anthropic doesn't support:
- ["string", "null"] -> "string" (with nullable handling)
- Removes additionalProperties if false (Anthropic doesn't need it)
Args:
tools: OpenAI format tools
Returns:
Anthropic format tools or None
"""
import logging
if not tools:
return None
def normalize_schema(schema: Dict) -> Dict:
"""Recursively normalize JSON Schema for Anthropic compatibility."""
if not isinstance(schema, dict):
return schema
result = {}
for key, value in schema.items():
if key == "type" and isinstance(value, list):
# Convert ["string", "null"] to just "string"
# Anthropic handles optional fields via 'required' array
non_null_types = [t for t in value if t != "null"]
if len(non_null_types) == 1:
result[key] = non_null_types[0]
elif len(non_null_types) > 1:
# Multiple non-null types - keep as-is (rare case)
result[key] = non_null_types
else:
# Only null type - default to string
result[key] = "string"
elif key == "properties" and isinstance(value, dict):
# Recursively normalize nested properties
result[key] = {k: normalize_schema(v) for k, v in value.items()}
elif key == "items" and isinstance(value, dict):
# Recursively normalize array items
result[key] = normalize_schema(value)
elif key == "additionalProperties" and value is False:
# Skip additionalProperties: false (Anthropic doesn't need it)
continue
elif key == "required" and isinstance(value, list):
# Clean up required array - only keep fields that exist in properties
# and don't have nullable types
properties = schema.get("properties", {})
cleaned_required = []
for field in value:
if field in properties:
field_schema = properties[field]
if isinstance(field_schema, dict):
field_type = field_schema.get("type")
# Skip fields with nullable types (they're optional)
if isinstance(field_type, list) and "null" in field_type:
continue
cleaned_required.append(field)
if cleaned_required:
result[key] = cleaned_required
# If empty, don't add required key (all fields are optional)
else:
result[key] = value
return result
anthropic_tools = []
for tool in tools:
if tool.get("type") == "function":
function = tool.get("function", {})
parameters = function.get("parameters", {})
# Normalize the input schema for Anthropic compatibility
normalized_schema = normalize_schema(parameters)
anthropic_tool = {
"name": function.get("name", ""),
"description": function.get("description", ""),
"input_schema": normalized_schema
}
anthropic_tools.append(anthropic_tool)
logging.info(f"Converted tool to Anthropic format: {anthropic_tool['name']}")
else:
# Unknown tool type, log warning
logging.warning(f"Unknown tool type: {tool.get('type')}, skipping")
return anthropic_tools if anthropic_tools else None
def _convert_messages_to_anthropic(self, messages: List[Dict]) -> tuple[Optional[str], List[Dict]]:
"""
Convert OpenAI messages format to Anthropic format.
Key differences:
1. System messages are extracted to a separate 'system' parameter
2. Tool role messages must be converted to user messages with tool_result content blocks
3. Assistant messages with tool_calls must have tool_use content blocks
4. Messages must alternate between user and assistant roles
Args:
messages: OpenAI format messages
Returns:
Tuple of (system_message, anthropic_messages)
"""
import logging
import json
system_message = None
anthropic_messages = []
for msg in messages:
role = msg.get('role')
content = msg.get('content')
if role == 'system':
# Extract system message
system_message = content
logging.info(f"Extracted system message: {len(content) if content else 0} chars")
elif role == 'tool':
# Convert tool message to user message with tool_result content block
tool_call_id = msg.get('tool_call_id', msg.get('name', 'unknown'))
# Build tool_result content block
tool_result_block = {
'type': 'tool_result',
'tool_use_id': tool_call_id,
'content': content or ""
}
# Check if last message is a user message - if so, append to it
if anthropic_messages and anthropic_messages[-1]['role'] == 'user':
# Append to existing user message
last_content = anthropic_messages[-1]['content']
if isinstance(last_content, str):
# Convert string content to list
anthropic_messages[-1]['content'] = [
{'type': 'text', 'text': last_content},
tool_result_block
]
elif isinstance(last_content, list):
# Append to existing list
anthropic_messages[-1]['content'].append(tool_result_block)
logging.info(f"Appended tool_result to existing user message")
else:
# Create new user message with tool_result
anthropic_messages.append({
'role': 'user',
'content': [tool_result_block]
})
logging.info(f"Created new user message with tool_result")
elif role == 'assistant':
# Check if message has tool_calls
tool_calls = msg.get('tool_calls')
if tool_calls:
# Convert to Anthropic format with tool_use content blocks
content_blocks = []
# Add text content if present
if content:
content_blocks.append({
'type': 'text',
'text': content
})
# Add tool_use blocks
for tc in tool_calls:
tool_id = tc.get('id', f"toolu_{len(content_blocks)}")
function = tc.get('function', {})
tool_name = function.get('name', '')
# Parse arguments (may be string or dict)
arguments = function.get('arguments', {})
if isinstance(arguments, str):
try:
arguments = json.loads(arguments)
except json.JSONDecodeError:
logging.warning(f"Failed to parse tool arguments as JSON: {arguments}")
arguments = {}
tool_use_block = {
'type': 'tool_use',
'id': tool_id,
'name': tool_name,
'input': arguments
}
content_blocks.append(tool_use_block)
logging.info(f"Converted tool_call to tool_use block: {tool_name}")
anthropic_messages.append({
'role': 'assistant',
'content': content_blocks
})
else:
# Regular assistant message
# Handle case where content might already be an array (from previous API responses)
if isinstance(content, list):
# Extract text from content blocks
text_parts = []
for block in content:
if isinstance(block, dict):
if block.get('type') == 'text':
text_parts.append(block.get('text', ''))
elif 'text' in block:
text_parts.append(block['text'])
elif isinstance(block, str):
text_parts.append(block)
content_str = '\n'.join(text_parts) if text_parts else ""
logging.info(f"Normalized assistant message content from array to string ({len(text_parts)} blocks)")
else:
content_str = content or ""
anthropic_messages.append({
'role': 'assistant',
'content': content_str
})
elif role == 'user':
# Regular user message
anthropic_messages.append({
'role': 'user',
'content': content or ""
})
else:
logging.warning(f"Unknown message role: {role}, treating as user")
anthropic_messages.append({
'role': 'user',
'content': content or ""
})
logging.info(f"Converted {len(messages)} OpenAI messages to {len(anthropic_messages)} Anthropic messages")
return system_message, anthropic_messages
async def handle_request(self, model: str, messages: List[Dict], max_tokens: Optional[int] = None,
temperature: Optional[float] = 1.0, stream: Optional[bool] = False,
tools: Optional[List[Dict]] = None, tool_choice: Optional[Union[str, Dict]] = None) -> Union[Dict, object]:
......@@ -2329,7 +2669,9 @@ class ClaudeProviderHandler(BaseProviderHandler):
try:
import logging
import json
logging.info(f"ClaudeProviderHandler: Handling request for model {model}")
if AISBF_DEBUG:
logging.info(f"ClaudeProviderHandler: Messages: {messages}")
else:
......@@ -2337,64 +2679,80 @@ class ClaudeProviderHandler(BaseProviderHandler):
# Apply rate limiting
await self.apply_rate_limit()
# Get valid access token (will refresh or re-authenticate if needed)
access_token = self.auth.get_valid_token()
# Prepare messages for Anthropic API format
# Extract system message if present
system_message = None
anthropic_messages = []
for msg in messages:
if msg['role'] == 'system':
system_message = msg['content']
else:
anthropic_messages.append({
'role': msg['role'],
'content': msg['content']
})
# Convert messages to Anthropic format (handles tool messages properly)
system_message, anthropic_messages = self._convert_messages_to_anthropic(messages)
# Build request payload
request_payload = {
# Build request payload for direct HTTP request (kilocode method)
# IMPORTANT: OAuth2 API uses its own model naming scheme (e.g., claude-sonnet-4-5-20250929)
# which is DIFFERENT from standard Anthropic API (e.g., claude-3-5-sonnet-20241022)
# DO NOT normalize - use the model name exactly as provided by get_models()
payload = {
'model': model,
'messages': anthropic_messages,
'max_tokens': max_tokens or 4096,
'temperature': temperature,
'stream': stream
}
# Only add temperature if not None
if temperature is not None:
payload['temperature'] = temperature
if system_message:
request_payload['system'] = system_message
payload['system'] = system_message
# Convert OpenAI tools to Anthropic format
if tools:
request_payload['tools'] = tools
anthropic_tools = self._convert_tools_to_anthropic(tools)
if anthropic_tools:
payload['tools'] = anthropic_tools
if tool_choice:
request_payload['tool_choice'] = tool_choice
# Prepare headers
headers = {
'Authorization': f'Bearer {access_token}',
'anthropic-version': '2023-06-01',
'anthropic-beta': 'claude-code-20250219', # Required for subscription usage
'Content-Type': 'application/json'
}
# Convert OpenAI tool_choice format to Anthropic format
if tool_choice and tools: # Only add tool_choice if we have tools
anthropic_tool_choice = self._convert_tool_choice_to_anthropic(tool_choice)
if anthropic_tool_choice:
payload['tool_choice'] = anthropic_tool_choice
if AISBF_DEBUG:
logging.info(f"ClaudeProviderHandler: Request payload: {request_payload}")
# Add stream parameter
payload['stream'] = stream
# Make request to Claude API
# TEMPORARY: Always log payload for debugging 400 errors
logging.info(f"ClaudeProviderHandler: Request payload: {json.dumps(payload, indent=2)}")
# Use api.anthropic.com endpoint (correct endpoint for OAuth2 tokens)
api_url = 'https://api.anthropic.com/v1/messages'
logging.info(f"ClaudeProviderHandler: Making request to {api_url}")
# Make request using direct HTTP (kilocode method)
if stream:
logging.info(f"ClaudeProviderHandler: Using streaming mode")
return await self._handle_streaming_request(headers, request_payload, model)
logging.info(f"ClaudeProviderHandler: Using streaming mode with direct HTTP")
# Get auth headers with Bearer token (streaming mode)
headers = self._get_auth_headers(stream=True)
# Log the full request for debugging
if AISBF_DEBUG:
logging.info(f"=== STREAMING REQUEST DEBUG ===")
logging.info(f"URL: {api_url}")
logging.info(f"Headers: {json.dumps({k: v for k, v in headers.items() if k.lower() != 'authorization'}, indent=2)}")
logging.info(f"Payload: {json.dumps(payload, indent=2)}")
logging.info(f"=== END STREAMING REQUEST DEBUG ===")
return self._handle_streaming_request(api_url, payload, headers, model)
# Get auth headers with Bearer token (non-streaming mode)
headers = self._get_auth_headers(stream=False)
# Log the full request for debugging
if AISBF_DEBUG:
logging.info(f"=== NON-STREAMING REQUEST DEBUG ===")
logging.info(f"URL: {api_url}")
logging.info(f"Headers (auth redacted): {json.dumps({k: v for k, v in headers.items() if k.lower() != 'authorization'}, indent=2)}")
logging.info(f"Payload: {json.dumps(payload, indent=2)}")
logging.info(f"=== END NON-STREAMING REQUEST DEBUG ===")
# Non-streaming request
response = await self.client.post(
'https://api.anthropic.com/v1/messages',
headers=headers,
json=request_payload
)
response = await self.client.post(api_url, headers=headers, json=payload)
logging.info(f"ClaudeProviderHandler: Response status: {response.status_code}")
# Check for 429 rate limit error before raising
if response.status_code == 429:
......@@ -2403,24 +2761,33 @@ class ClaudeProviderHandler(BaseProviderHandler):
except Exception:
response_data = response.text
# Handle 429 error with intelligent parsing
self.handle_429_error(response_data, dict(response.headers))
# Re-raise the error after handling
response.raise_for_status()
# Log error details for non-2xx responses
if response.status_code >= 400:
try:
error_body = response.json()
error_message = error_body.get('error', {}).get('message', 'Unknown error')
error_type = error_body.get('error', {}).get('type', 'unknown')
logging.error(f"ClaudeProviderHandler: API error response: {json.dumps(error_body, indent=2)}")
logging.error(f"ClaudeProviderHandler: Error type: {error_type}")
logging.error(f"ClaudeProviderHandler: Error message: {error_message}")
except Exception:
logging.error(f"ClaudeProviderHandler: API error response (text): {response.text}")
response.raise_for_status()
response_data = response.json()
claude_response = response.json()
if AISBF_DEBUG:
logging.info(f"ClaudeProviderHandler: Raw response: {response_data}")
logging.info(f"ClaudeProviderHandler: API response: {json.dumps(claude_response, indent=2)}")
logging.info(f"ClaudeProviderHandler: Response received successfully")
logging.info(f"ClaudeProviderHandler: Response received successfully via direct HTTP")
self.record_success()
# Convert to OpenAI format
openai_response = self._convert_to_openai_format(response_data, model)
# Convert Claude API response to OpenAI format
openai_response = self._convert_to_openai_format(claude_response, model)
return openai_response
......@@ -2430,18 +2797,26 @@ class ClaudeProviderHandler(BaseProviderHandler):
self.record_failure()
raise e
async def _handle_streaming_request(self, headers: Dict, payload: Dict, model: str):
"""Handle streaming request to Claude API."""
async def _handle_streaming_request(self, api_url: str, payload: Dict, headers: Dict, model: str):
"""Handle streaming request to Claude API using direct HTTP (kilocode method)."""
import logging
import json
logger = logging.getLogger(__name__)
logger.info(f"ClaudeProviderHandler: Starting streaming request")
logger.info(f"ClaudeProviderHandler: Starting streaming request to {api_url}")
# Log full request for debugging
if AISBF_DEBUG:
logger.info(f"=== STREAMING REQUEST DETAILS ===")
logger.info(f"URL: {api_url}")
logger.info(f"Headers (auth redacted): {json.dumps({k: v for k, v in headers.items() if k.lower() != 'authorization'}, indent=2)}")
logger.info(f"Payload: {json.dumps(payload, indent=2)}")
logger.info(f"=== END STREAMING REQUEST DETAILS ===")
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=30.0)) as streaming_client:
async with streaming_client.stream(
"POST",
"https://api.anthropic.com/v1/messages",
api_url,
headers=headers,
json=payload
) as response:
......@@ -2449,8 +2824,19 @@ class ClaudeProviderHandler(BaseProviderHandler):
if response.status_code >= 400:
error_text = await response.aread()
logger.error(f"ClaudeProviderHandler: Streaming error: {error_text}")
raise Exception(f"Claude API error: {response.status_code}")
logger.error(f"ClaudeProviderHandler: Streaming error response: {error_text}")
# Try to parse error as JSON for better error message
try:
error_json = json.loads(error_text)
error_message = error_json.get('error', {}).get('message', 'Unknown error')
error_type = error_json.get('error', {}).get('type', 'unknown')
logger.error(f"ClaudeProviderHandler: Error type: {error_type}")
logger.error(f"ClaudeProviderHandler: Error message: {error_message}")
raise Exception(f"Claude API error ({response.status_code}): {error_message}")
except (json.JSONDecodeError, Exception) as e:
logger.error(f"ClaudeProviderHandler: Could not parse error response: {e}")
raise Exception(f"Claude API error: {response.status_code} - {error_text}")
# Generate completion ID and timestamps
completion_id = f"claude-{int(time.time())}"
......@@ -2589,6 +2975,151 @@ class ClaudeProviderHandler(BaseProviderHandler):
return openai_response
def _convert_sdk_response_to_openai(self, response, model: str) -> Dict:
"""
Convert Anthropic SDK response object to OpenAI format.
The SDK returns a Message object, not a dict.
"""
import logging
import json
# Build message content from SDK response
message_content = ""
tool_calls = []
# SDK response has content as a list of ContentBlock objects
for block in response.content:
if hasattr(block, 'text'):
message_content += block.text
elif hasattr(block, 'type') and block.type == 'tool_use':
tool_calls.append({
'id': block.id,
'type': 'function',
'function': {
'name': block.name,
'arguments': json.dumps(block.input)
}
})
# Map stop reason
stop_reason_map = {
'end_turn': 'stop',
'max_tokens': 'length',
'stop_sequence': 'stop',
'tool_use': 'tool_calls'
}
stop_reason = response.stop_reason or 'end_turn'
finish_reason = stop_reason_map.get(stop_reason, 'stop')
# Build OpenAI-compatible response
openai_response = {
'id': response.id,
'object': 'chat.completion',
'created': int(time.time()),
'model': f'{self.provider_id}/{model}',
'choices': [{
'index': 0,
'message': {
'role': 'assistant',
'content': message_content if not tool_calls else None,
},
'finish_reason': finish_reason
}],
'usage': {
'prompt_tokens': response.usage.input_tokens,
'completion_tokens': response.usage.output_tokens,
'total_tokens': response.usage.input_tokens + response.usage.output_tokens
}
}
# Add tool_calls if present
if tool_calls:
openai_response['choices'][0]['message']['tool_calls'] = tool_calls
return openai_response
async def _handle_streaming_request_sdk(self, request_kwargs: Dict, model: str):
"""Handle streaming request using Anthropic SDK."""
import logging
import json
logger = logging.getLogger(__name__)
logger.info(f"ClaudeProviderHandler: Starting SDK streaming request")
# Generate completion ID and timestamps
completion_id = f"claude-{int(time.time())}"
created_time = int(time.time())
# Track state for streaming
first_chunk = True
accumulated_content = ""
# Create SDK client for streaming (managed within generator)
sdk_client = self._get_sdk_client()
try:
# Use SDK's streaming API
async with sdk_client.messages.stream(**request_kwargs) as stream:
async for event in stream:
# Handle different event types from SDK
if hasattr(event, 'type'):
event_type = event.type
if event_type == 'content_block_delta':
# Text delta event
if hasattr(event, 'delta') and hasattr(event.delta, 'text'):
text = event.delta.text
accumulated_content += text
# Build OpenAI chunk
openai_delta = {'content': text}
if first_chunk:
openai_delta['role'] = 'assistant'
first_chunk = False
openai_chunk = {
'id': completion_id,
'object': 'chat.completion.chunk',
'created': created_time,
'model': f'{self.provider_id}/{model}',
'choices': [{
'index': 0,
'delta': openai_delta,
'finish_reason': None
}]
}
yield f"data: {json.dumps(openai_chunk, ensure_ascii=False)}\n\n".encode('utf-8')
elif event_type == 'message_stop':
# Final chunk
finish_reason = 'stop'
final_chunk = {
'id': completion_id,
'object': 'chat.completion.chunk',
'created': created_time,
'model': f'{self.provider_id}/{model}',
'choices': [{
'index': 0,
'delta': {},
'finish_reason': finish_reason
}]
}
yield f"data: {json.dumps(final_chunk, ensure_ascii=False)}\n\n".encode('utf-8')
yield b"data: [DONE]\n\n"
logger.info(f"ClaudeProviderHandler: SDK streaming completed successfully")
self.record_success()
except Exception as e:
logger.error(f"ClaudeProviderHandler: SDK streaming error: {str(e)}")
raise
finally:
# Close SDK client after streaming completes
await sdk_client.close()
def _get_models_cache_path(self) -> str:
"""Get the path to the models cache file."""
import os
......@@ -2699,22 +3230,13 @@ class ClaudeProviderHandler(BaseProviderHandler):
try:
logging.info("ClaudeProviderHandler: [1/3] Attempting primary API endpoint...")
# Get valid access token
access_token = self.auth.get_valid_token()
logging.info("ClaudeProviderHandler: Access token obtained successfully")
# Prepare headers
headers = {
'Authorization': f'Bearer {access_token}',
'anthropic-version': '2023-06-01',
'anthropic-beta': 'claude-code-20250219',
'Content-Type': 'application/json'
}
# Use the same auth headers as handle_request for consistency
headers = self._get_auth_headers(stream=False)
# Log the API endpoint being called
api_endpoint = 'https://api.anthropic.com/v1/models'
logging.info(f"ClaudeProviderHandler: Calling API endpoint: {api_endpoint}")
logging.info(f"ClaudeProviderHandler: Using OAuth2 authentication with claude-code beta")
logging.info(f"ClaudeProviderHandler: Using OAuth2 authentication with full headers")
# Query the models endpoint
response = await self.client.get(api_endpoint, headers=headers)
......
# Claude Provider Comparison: AISBF vs Original Claude Code Source
**Date:** 2026-03-31
**Reviewed by:** AI Assistant
**AISBF File:** [`aisbf/providers.py`](aisbf/providers.py:2300)
**Original Source:** `vendors/claude/src/`
---
## Overview
This document compares the [`ClaudeProviderHandler`](aisbf/providers.py:2300) implementation in AISBF with the original Claude Code TypeScript source code found in `vendors/claude/src/`.
---
## 1. Architecture & Approach
| Aspect | AISBF Implementation | Original Claude Code |
|--------|---------------------|---------------------|
| **Language** | Python | TypeScript/React |
| **API Method** | Direct HTTP via `httpx.AsyncClient` | Anthropic SDK + internal `callModel` |
| **Authentication** | OAuth2 via `ClaudeAuth` class | Internal OAuth2 + session management |
| **Endpoint** | `https://api.anthropic.com/v1/messages` | Internal SDK routing |
**Assessment:** AISBF correctly uses the direct HTTP approach (kilocode method) which is appropriate for OAuth2 tokens. This matches the pattern used in the original's internal API layer.
---
## 2. Message Format Conversion
**Method:** [`_convert_messages_to_anthropic()`](aisbf/providers.py:2516)
### What AISBF does well:
- Correctly extracts system messages to separate `system` parameter
- Handles tool messages by converting to `tool_result` content blocks
- Converts assistant `tool_calls` to Anthropic `tool_use` blocks
- Handles message role alternation requirements
### Differences from original:
The original ([`normalizeMessagesForAPI()`](vendors/claude/src/utils/messages.ts)) has more sophisticated handling including:
- Thinking block preservation rules
- Protected thinking block signatures
- More complex tool result merging
- Message UUID tracking for caching
---
## 3. Tool Conversion
**Method:** [`_convert_tools_to_anthropic()`](aisbf/providers.py:2419)
### What AISBF does well:
- Correctly converts OpenAI `parameters` → Anthropic `input_schema`
- Normalizes JSON Schema types (e.g., `["string", "null"]``"string"`)
- Removes `additionalProperties: false` (Anthropic doesn't need it)
- Recursively normalizes nested schemas
### Missing from original:
The original has additional tool validation including:
- Tool name length limits
- Parameter size limits
- Schema validation against Anthropic's stricter requirements
- Tool result size budgeting (`applyToolResultBudget` in [`query.ts:379`](vendors/claude/src/query.ts:379))
---
## 4. Tool Choice Conversion
**Method:** [`_convert_tool_choice_to_anthropic()`](aisbf/providers.py:2367)
### Correctly handles:
- `"auto"``{"type": "auto"}`
- `"required"``{"type": "any"}`
- Specific function → `{"type": "tool", "name": "..."}`
### Missing:
- The original has more nuanced tool choice handling including `disable_parallel_tool_use` support
---
## 5. Streaming Implementation
**Method:** [`_handle_streaming_request()`](aisbf/providers.py:2800)
### What AISBF does:
- Uses SSE format parsing (`data:` prefixed lines)
- Handles `content_block_delta` events with `text_delta`
- Handles `message_stop` for final chunk
- Yields OpenAI-compatible chunks
### Differences from original:
The original's streaming ([`callModel()`](vendors/claude/src/query.ts:659)) is more complex:
- Handles thinking blocks during streaming
- Has streaming tool executor (`StreamingToolExecutor`)
- Supports fallback model switching mid-stream
- Has token budget tracking during streaming
- Handles `tool_use` blocks during streaming (not just text)
- Has message backfill for tool inputs
### Missing features in AISBF streaming:
- No thinking block handling
- No tool call streaming
- No fallback model support
- No token budget tracking
---
## 6. Response Conversion
**Method:** [`_convert_to_openai_format()`](aisbf/providers.py:2916)
### Correctly handles:
- Text content extraction
- `tool_use` → OpenAI `tool_calls` format
- Stop reason mapping (`end_turn``stop`, etc.)
- Usage metadata extraction
### Differences:
Original has more complex response handling including:
- Thinking block preservation
- Protected thinking signatures
- More detailed usage tracking (cache tokens, etc.)
---
## 7. Headers & Authentication
**Method:** [`_get_auth_headers()`](aisbf/providers.py:2331)
### AISBF includes:
- OAuth2 Bearer token
- `Anthropic-Version: 2023-06-01`
- `Anthropic-Beta` with multiple beta features
- `X-App: cli` and other stainless headers
**Assessment:** Headers match the original's CLI proxy pattern well. The beta features list (`claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05`) is comprehensive.
---
## 8. Rate Limiting
### AISBF has:
- Adaptive rate limiter with 429 learning
- Exponential backoff with jitter
- Provider disable/enable cycles
### Original has:
- Similar rate limiting but integrated with the query loop
- Model fallback on rate limits
- More sophisticated retry logic
---
## 9. Model Discovery
**Method:** [`get_models()`](aisbf/providers.py:3217)
### AISBF approach:
1. Primary API call to `https://api.anthropic.com/v1/models`
2. Fallback to `http://lisa.nexlab.net:5000/claude/models`
3. Local cache (24-hour TTL)
4. Static fallback list
**Assessment:** Good fallback strategy. The original doesn't have a models endpoint (Anthropic doesn't provide one publicly), so the fallback strategy is appropriate.
---
## 10. Missing Features (Not in AISBF)
The original Claude Code has many features that are out of scope for a provider handler but worth noting:
| Feature | Description | Location in Original |
|---------|-------------|---------------------|
| **Query loop** | Multi-turn tool execution with auto-compact, reactive compact, context collapse | [`query.ts:219`](vendors/claude/src/query.ts:219) |
| **Token budgeting** | Per-turn output token limits with continuation nudges | [`query.ts:1308`](vendors/claude/src/query.ts:1308) |
| **Auto-compaction** | Automatic conversation summarization when context gets large | [`query.ts:454`](vendors/claude/src/query.ts:454) |
| **Context collapse** | Granular context compression | [`query.ts:440`](vendors/claude/src/query.ts:440) |
| **Stop hooks** | Pre/post-turn hook execution | [`query.ts:1267`](vendors/claude/src/query.ts:1267) |
| **Memory prefetch** | Relevant memory file preloading | [`query.ts:301`](vendors/claude/src/query.ts:301) |
| **Skill discovery** | Dynamic skill file detection | [`query.ts:331`](vendors/claude/src/query.ts:331) |
| **Streaming tool execution** | Parallel tool execution during streaming | [`query.ts:1380`](vendors/claude/src/query.ts:1380) |
| **Model fallback** | Automatic fallback to alternative models | [`query.ts:894`](vendors/claude/src/query.ts:894) |
| **Task budget** | Agentic turn budget management | [`query.ts:291`](vendors/claude/src/query.ts:291) |
---
## Summary
### Strengths of AISBF implementation:
1. Clean OAuth2 integration matching kilocode patterns
2. Comprehensive message format conversion
3. Good tool schema normalization
4. Proper streaming SSE handling
5. Robust fallback strategy for model discovery
6. Adaptive rate limiting with learning
### Areas for improvement:
1. Add thinking block support for models that use it
2. Add tool call streaming
3. Add more detailed usage metadata (cache tokens)
4. Consider adding model fallback support
5. Add tool result size validation
### Overall assessment:
The AISBF Claude provider is a solid implementation that correctly handles the core API communication, message conversion, and tool handling. It appropriately focuses on the provider-level concerns (API translation) while leaving higher-level concerns (conversation management, compaction) to the rest of the framework.
......@@ -1135,7 +1135,8 @@ async def dashboard_analytics(
provider_filter: Optional[str] = Query(None),
model_filter: Optional[str] = Query(None),
rotation_filter: Optional[str] = Query(None),
autoselect_filter: Optional[str] = Query(None)
autoselect_filter: Optional[str] = Query(None),
user_filter: Optional[int] = Query(None)
):
"""Token usage analytics dashboard"""
auth_check = require_dashboard_auth(request)
......@@ -1169,6 +1170,9 @@ async def dashboard_analytics(
if from_datetime and to_datetime:
time_range = 'custom'
# Get all users for filter dropdown
all_users = db.get_users() if db else []
# Get available providers, models, rotations, and autoselects for filter dropdowns
available_providers = list(config.providers.keys()) if config else []
available_rotations = list(config.rotations.keys()) if config else []
......@@ -1193,7 +1197,8 @@ async def dashboard_analytics(
provider_id=provider_filter,
time_range=time_range,
from_datetime=from_datetime,
to_datetime=to_datetime
to_datetime=to_datetime,
user_filter=user_filter
)
# Get model performance (with optional filters)
......@@ -1201,7 +1206,8 @@ async def dashboard_analytics(
provider_filter=provider_filter,
model_filter=model_filter,
rotation_filter=rotation_filter,
autoselect_filter=autoselect_filter
autoselect_filter=autoselect_filter,
user_filter=user_filter
)
# Get cost overview
......@@ -1233,10 +1239,12 @@ async def dashboard_analytics(
"available_models": available_models,
"available_rotations": available_rotations,
"available_autoselects": available_autoselects,
"available_users": all_users,
"selected_provider": provider_filter,
"selected_model": model_filter,
"selected_rotation": rotation_filter,
"selected_autoselect": autoselect_filter
"selected_autoselect": autoselect_filter,
"selected_user": user_filter
})
@app.get("/dashboard/login", response_class=HTMLResponse)
......
......@@ -77,7 +77,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<!-- Filter by Provider/Model/Rotation/Autoselect -->
<div style="background: #1a1a2e; padding: 20px; border-radius: 8px; margin-bottom: 30px;">
<h3 style="margin-bottom: 15px;">Filter by Provider, Model, Rotation, or Autoselect</h3>
<h3 style="margin-bottom: 15px;">Filter by Provider, Model, Rotation, Autoselect, or User</h3>
<form method="get" action="/dashboard/analytics" style="display: flex; flex-wrap: wrap; gap: 15px; align-items: flex-end;">
<!-- Preserve date filter parameters -->
<input type="hidden" name="time_range" value="{{ selected_time_range }}">
......@@ -124,13 +124,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</select>
</div>
<div style="flex: 1; min-width: 150px;">
<label style="display: block; margin-bottom: 5px; color: #a0a0a0; font-size: 14px;">User</label>
<select name="user_filter" style="width: 100%; padding: 10px; border-radius: 4px; background: #0f3460; color: white; border: 1px solid #2a4a7a;">
<option value="">All Users</option>
{% for user in available_users %}
<option value="{{ user.id }}" {% if selected_user == user.id %}selected{% endif %}>{{ user.username }}{% if user.role == 'admin' %} (admin){% endif %}</option>
{% endfor %}
</select>
</div>
<div>
<button type="submit" style="padding: 10px 20px; background: #3498db; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
Apply Filters
</button>
</div>
{% if selected_provider or selected_model or selected_rotation or selected_autoselect %}
{% if selected_provider or selected_model or selected_rotation or selected_autoselect or selected_user %}
<div>
<a href="/dashboard/analytics?time_range={{ selected_time_range }}{% if from_date %}&from_date={{ from_date }}{% endif %}{% if to_date %}&to_date={{ to_date }}{% endif %}" style="padding: 10px 20px; background: #7f8c8d; color: white; border: none; border-radius: 4px; text-decoration: none; display: inline-block;">
Clear Filters
......@@ -139,7 +149,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% endif %}
</form>
{% if selected_provider or selected_model or selected_rotation or selected_autoselect %}
{% if selected_provider or selected_model or selected_rotation or selected_autoselect or selected_user %}
<div style="margin-top: 15px; padding: 10px; background: #0f3460; border-radius: 4px;">
<strong style="color: #60a5fa;">Active Filters: </strong>
<span style="color: #e0e0e0;">
......@@ -147,6 +157,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% if selected_model %}{% if selected_provider %} | {% endif %}Model: {{ selected_model }}{% endif %}
{% if selected_rotation %}{% if selected_provider or selected_model %} | {% endif %}Rotation: {{ selected_rotation }}{% endif %}
{% if selected_autoselect %}{% if selected_provider or selected_model or selected_rotation %} | {% endif %}Autoselect: {{ selected_autoselect }}{% endif %}
{% if selected_user %}{% if selected_provider or selected_model or selected_rotation or selected_autoselect %} | {% endif %}User: {% for user in available_users %}{% if user.id == selected_user %}{{ user.username }}{% endif %}{% endfor %}{% endif %}
</span>
</div>
{% endif %}
......
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