Commit 3d925b69 authored by Your Name's avatar Your Name

Add model metadata fields (top_provider, pricing, description,...

Add model metadata fields (top_provider, pricing, description, supported_parameters, architecture) and dashboard Get Models button

- Update providers.py to extract all fields from provider API responses
- Add max_input_tokens support for Claude provider context size
- Add top_provider, pricing, description, supported_parameters, architecture fields
- Update cache functions to save/load new metadata fields
- Update handlers.py to expose new fields in model list response
- Add Get Models button to dashboard
parent b0dfd636
......@@ -4,6 +4,9 @@ venv/
env/
ENV/
# vendors directory
vendors/
# Python cache directories
__pycache__/
*.py[cod]
......@@ -160,4 +163,4 @@ build/
# OS files
.DS_Store
Thumbs.db
\ No newline at end of file
Thumbs.db
Requirement already satisfied: curl_cffi in /home/nextime/aisbf/venv/lib/python3.13/site-packages (0.14.0)
Requirement already satisfied: cffi>=1.12.0 in /home/nextime/aisbf/venv/lib/python3.13/site-packages (from curl_cffi) (2.0.0)
Requirement already satisfied: certifi>=2024.2.2 in /home/nextime/aisbf/venv/lib/python3.13/site-packages (from curl_cffi) (2026.2.25)
Requirement already satisfied: pycparser in /home/nextime/aisbf/venv/lib/python3.13/site-packages (from cffi>=1.12.0->curl_cffi) (3.0)
......@@ -525,6 +525,87 @@ Once configured, kiro-gateway can be used like any other provider in AISBF:
- Use Claude models in AISBF rotations alongside other providers
- Automatic failover and load balancing with other providers
### Claude Code Provider Integration
**Overview:**
Claude Code is an OAuth2-based authentication method for accessing Claude models through the official Anthropic API with subscription-based access. It uses the same authentication flow as the official claude-cli tool.
**What is Claude Code:**
- OAuth2 authentication for Claude API access
- Uses subscription-based access (claude-code)
- Compatible with claude-cli credentials
- Provides access to latest Claude models (3.7 Sonnet, 3.5 Sonnet, 3.5 Haiku, etc.)
- Supports all Claude features: streaming, tools, vision, extended thinking
**Integration Architecture:**
- [`ClaudeAuth`](aisbf/claude_auth.py) class handles OAuth2 PKCE flow
- [`ClaudeProviderHandler`](aisbf/providers.py) in [`aisbf/providers.py`](aisbf/providers.py) manages API requests
- Supports all standard AISBF features: streaming, tools, rate limiting, error tracking
**Configuration:**
**IMPORTANT:** Claude providers use OAuth2 authentication instead of API keys. The `claude_config` object contains authentication settings.
**Claude Provider Configuration:**
```json
{
"claude": {
"id": "claude",
"name": "Claude Code (OAuth2)",
"endpoint": "https://api.anthropic.com/v1",
"type": "claude",
"api_key_required": false,
"rate_limit": 0,
"claude_config": {
"credentials_file": "~/.claude_credentials.json"
},
"models": [
{
"name": "claude-3-7-sonnet-20250219",
"rate_limit": 0,
"max_request_tokens": 200000,
"context_size": 200000
}
]
}
}
```
**Claude Configuration Fields (claude_config):**
- `credentials_file`: Path to OAuth2 credentials file (default: `~/.claude_credentials.json`)
**Authentication Flow:**
1. First request triggers OAuth2 flow if no credentials exist
2. Browser opens to https://claude.ai for authentication
3. User logs in with Claude account
4. Authorization code exchanged for access token
5. Credentials saved to file for future use
6. Automatic token refresh when expired
**Setup Requirements:**
1. Claude subscription (claude-code access)
2. Web browser for initial authentication
3. Port 54545 available for OAuth callback
**Available Models:**
- `claude-3-7-sonnet-20250219` - Latest Claude 3.7 Sonnet
- `claude-3-5-sonnet-20241022` - Claude 3.5 Sonnet
- `claude-3-5-haiku-20241022` - Claude 3.5 Haiku (fast)
- `claude-3-opus-20240229` - Claude 3 Opus (top-tier)
**Usage:**
Once configured, claude provider can be used like any other provider in AISBF:
- Direct provider access: `/api/claude/chat/completions`
- Rotation access: `/api/claude-code/chat/completions`
- Model listing: `/api/claude/models`
**Benefits:**
- Access Claude models through OAuth2 subscription
- No need to manage API keys
- Automatic token refresh
- Use Claude models in AISBF rotations alongside other providers
- Automatic failover and load balancing with other providers
### Modifying Configuration
1. Edit files in `~/.aisbf/` for user-specific changes
2. Edit files in installed location for system-wide defaults
......
# Claude OAuth2 Authentication Deep Dive
If you have ever typed `claude auth login` into a terminal and watched a browser tab pop open, you already know the surface-level experience. You sign in, something happens behind the scenes, and a moment later your terminal says you are authenticated. But what actually happened during those few seconds is a surprisingly detailed chain of cryptographic handshakes, HTTP exchanges, and local file writes that together form a complete OAuth 2.0 authorization-code flow with PKCE. This essay pulls that chain apart, link by link, so that when something inevitably breaks you will know exactly where to look.
## The Problem
Claude Code is a command-line tool. It runs in your terminal. But the credentials that prove your identity live on Anthropic's servers, and the only trusted way to prove you are who you say you are is through the same login page you would use in a browser on claude.ai. The terminal cannot render that login page. It cannot handle CAPTCHAs, two-factor prompts, or account selection screens. So the CLI needs a way to delegate the authentication step to a browser, get the result back, and then store that result locally for future use.
OAuth 2.0 is the protocol that makes this delegation possible. It was designed for exactly this kind of situation: one application needs to act on behalf of a user, but the user's actual credentials should never pass through that application. Instead of your password, the CLI ends up with a pair of tokens, one short-lived access token and one longer-lived refresh token, that together let it make authenticated requests to Anthropic's API without ever knowing your password.
PKCE, which stands for Proof Key for Code Exchange and is pronounced "pixy," is an extension to OAuth that protects against a specific class of attack. Without PKCE, if someone intercepted the authorization code during the redirect back to your machine, they could exchange it for tokens themselves. PKCE prevents that by tying the token exchange to a secret that only the original client knows.
## Preparation
Before the browser opens, the CLI has some prep work to do. It generates three values.
The first is a PKCE verifier. This is a high-entropy random string, typically between 43 and 128 characters, drawn from the unreserved URI character set. Think of it as a one-time secret that the CLI creates and keeps to itself. In most implementations this is generated using a cryptographically secure random number generator, then base64url-encoded.
The second value is the PKCE challenge. This is derived from the verifier by taking its SHA-256 hash and then base64url-encoding the result. The relationship between verifier and challenge is one-way: given the challenge, you cannot recover the verifier, but given the verifier, anyone can recompute the challenge. That asymmetry is the whole point.
```python
import hashlib, base64, os
verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
```
The third value is a random state parameter. This is an anti-CSRF measure. The CLI generates it, includes it in the authorize request, and later checks that the same value comes back in the callback. If it does not match, the response is discarded.
## The Authorization Request
With those three values ready, the CLI constructs a URL that points to Anthropic's authorization endpoint. For Claude Code, that endpoint is:
```
https://claude.ai/oauth/authorize
```
The URL includes several query parameters. The `client_id` identifies the application requesting access. For Claude Code, the observed client ID is `9d1c250a-e61b-44d9-88ed-5944d1962f5e`. The `response_type` is set to `code`, which tells the server this is an authorization-code flow rather than an implicit flow. The `redirect_uri` tells the server where to send the user after they authenticate. The `scope` parameter lists the permissions being requested. The `code_challenge` carries the PKCE challenge computed a moment ago, and `code_challenge_method` is set to `S256` to indicate that SHA-256 was used. Finally, the `state` parameter carries the random anti-CSRF value.
A fully assembled authorize URL might look something like:
```
https://claude.ai/oauth/authorize?client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=http://localhost:54545/callback&code_challenge=...&code_challenge_method=S256&state=xyz123&scope=user:profile+user:inference+user:sessions:claude_code+user:mcp_servers
```
The CLI opens this URL in the user's default browser. From this point on, the CLI is waiting. It has started a tiny HTTP server on localhost, listening on a specific port (typically 54545), ready to catch the callback.
## The Browser Flow
What happens next is entirely in the browser. The user sees Anthropic's login page. They might enter an email and password, they might use a social login, they might go through a two-factor authentication step. The CLI has no visibility into any of this. It does not need to.
Once the user successfully authenticates and grants consent, Anthropic's server constructs a redirect response. The redirect URL points back to the localhost address the CLI registered as its `redirect_uri`, and it includes two query parameters: `code` and `state`.
```
http://localhost:54545/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=xyz123
```
The authorization code in that URL is short-lived, typically valid for only a few minutes, and can only be used once. It is also useless on its own. To turn it into actual tokens, you need the PKCE verifier that matches the challenge sent earlier. This is why intercepting the code alone is not enough for an attacker.
## The Token Exchange
The CLI's localhost server receives the callback, extracts the `code` and `state`, and immediately verifies that the state matches what it originally generated. If the state does not match, the whole flow is aborted.
Then the CLI makes a POST request to Anthropic's token endpoint. For Claude Code, that endpoint is:
```
https://platform.claude.com/v1/oauth/token
```
**One detail worth highlighting here:** this endpoint expects JSON in the request body, not `application/x-www-form-urlencoded`. Many OAuth implementations use form encoding for the token exchange, so if you are building or debugging tooling around this, sending form data will silently fail or return an unhelpful error.
The request body contains:
```json
{
"grant_type": "authorization_code",
"code": "SplxlOBeZQQYbYS6WxSbIA",
"redirect_uri": "http://localhost:54545/callback",
"client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
"state": "xyz123"
}
```
The server receives this, recomputes the SHA-256 hash of the provided `code_verifier`, and checks that it matches the `code_challenge` from the original authorize request. If it matches, the server knows that whoever is making this token exchange is the same party that initiated the flow. The authorization code is consumed and a token set is returned.
The response typically includes:
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA...",
"expires_in": 3600,
"scope": "user:profile user:inference user:sessions:claude_code user:mcp_servers"
}
```
The access token is what gets sent with every authenticated API request. The refresh token is what gets used later to obtain new access tokens without going through the browser flow again. The `expires_in` value tells you how many seconds the access token will remain valid.
## Local Storage
Once the tokens are in hand, Claude Code writes them to disk. The primary storage location is `~/.claude/.credentials.json`. The token data sits under a key called `claudeAiOauth`:
```json
{
"claudeAiOauth": {
"accessToken": "<bearer-token>",
"refreshToken": "<refresh-token>",
"expiresAt": 1760000000000,
"scopes": [
"user:profile",
"user:inference",
"user:sessions:claude_code",
"user:mcp_servers"
],
"subscriptionType": "max",
"rateLimitTier": "default_claude_max_20x"
}
}
```
Note that `expiresAt` is stored as a Unix timestamp in milliseconds. Comparing it against `Date.now()` in JavaScript or `time.time() * 1000` in Python tells you whether the token is still valid.
A second file, `~/.claude.json`, holds account-level metadata: the account UUID, email address, organization UUID, organization name, and billing type. This file is used by Claude Code to display status information and to set context for API requests, but it does not contain the actual bearer tokens.
On macOS, there is an additional storage layer. Claude Code may store credentials in the system keychain under a service name like `Claude Code-credentials`. When reading credentials programmatically, the keychain entry can be fresher than what is on disk, especially if a recent re-login updated the keychain but the file write was interrupted or delayed. On Linux, the file is generally the authoritative source.
## Using the Tokens
With a valid access token stored locally, every subsequent request to Anthropic's API includes it as a bearer token in the Authorization header:
```bash
curl -H "Authorization: Bearer <access-token>" \
-H "Content-Type: application/json" \
https://api.anthropic.com/v1/messages \
-d '{"model":"claude-sonnet-4-20250514","max_tokens":1024,"messages":[{"role":"user","content":"hello"}]}'
```
The API server validates the token, checks its scopes and expiration, and either processes the request or returns a 401 if something is wrong.
## Diagnostic Endpoints
Two diagnostic endpoints are worth knowing about when you are troubleshooting. The profile endpoint tells you which account and organization the token resolves to:
```bash
curl -H "Authorization: Bearer <access-token>" \
-H "Content-Type: application/json" \
https://api.anthropic.com/api/oauth/profile
```
The CLI roles endpoint reveals what permissions and rate-limit tier the token carries, though it requires a beta header:
```bash
curl -H "Authorization: Bearer <access-token>" \
-H "anthropic-beta: oauth-2025-04-20" \
-H "Content-Type: application/json" \
https://api.anthropic.com/api/oauth/claude_cli/roles
```
These are invaluable when you can see that authentication is succeeding but the behavior is not what you expect—maybe you are hitting an unexpected rate limit, or the token is resolving to a different organization than intended.
## Token Refresh
Access tokens expire. The `expires_in` value from the original token response tells you the window, and once that window closes, any request using the old access token will fail. The refresh token exists so you do not have to send the user through the browser flow every time this happens.
The refresh request is simpler than the initial token exchange:
```json
{
"grant_type": "refresh_token",
"client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
"refresh_token": "<refresh-token>"
}
```
This goes to the same token endpoint, `https://platform.claude.com/v1/oauth/token`, and the response has the same shape as the original token response: a new access token, a new expiration, and potentially a new refresh token as well.
That last point is important. When the server issues a new refresh token alongside the new access token, the old refresh token is typically invalidated. This is called refresh token rotation, and it is a security measure. If an attacker somehow captured an old refresh token, it would already be dead by the time they tried to use it. But it also means that any system holding a copy of the old refresh token is now holding a useless string.
## The Refresh Token Problem
This is where things get interesting in practice. The initial login is rarely the problem. The problems come later, when multiple processes or tools share the same identity and tokens start rotating out from under each other.
Consider this scenario. You log in with `claude auth login`. Refresh token A is stored in `~/.claude/.credentials.json`. Some time later, maybe an hour, maybe a day, the access token expires. Claude Code transparently refreshes it, receiving a new access token and a new refresh token B. Refresh token A is now dead.
But what if another process, maybe a long-running automation script, read the credentials file earlier and cached refresh token A in memory? When that process tries to refresh, it sends the revoked token A to Anthropic's token endpoint and gets back an error:
```json
{
"error": "invalid_grant",
"error_description": "Refresh token not found or invalid"
}
```
The same thing happens if you log in a second time from a different terminal session, or if you log in from a different machine using the same Anthropic account. Each new login can rotate the refresh token, killing whatever was stored before.
This is not a bug in OAuth. It is the intended security behavior. But it creates a real operational challenge for any system that caches credentials.
## Headless Authentication
The entire PKCE flow described above assumes the CLI can open a browser and listen on a localhost port for the callback. On a normal desktop, that works. On a headless server, an SSH session, or a remote container, it does not.
There are a few workarounds. One approach is to use a manual PKCE flow. The idea is to separate the steps that need a browser from the steps that need a terminal. You generate the PKCE verifier and challenge on the headless machine, construct the authorize URL, copy that URL to a machine that does have a browser, complete the login there, and then paste the resulting authorization code back into the headless machine's terminal. The headless machine already has the verifier, so it can complete the token exchange.
When using the manual approach with Claude's OAuth, the redirect URI is typically set to `https://platform.claude.com/oauth/code/callback`, which displays the authorization code on a web page instead of redirecting to localhost. You then copy the code and paste it back.
A minimal Python implementation of the verifier and challenge generation looks like this:
```python
import hashlib, base64, secrets
def generate_pkce():
verifier = secrets.token_urlsafe(32)
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode("ascii")).digest()
).rstrip(b"=").decode("ascii")
return verifier, challenge
verifier, challenge = generate_pkce()
state = secrets.token_urlsafe(16)
```
You would then assemble the authorize URL with these values, open it in any browser you have access to, complete the login, grab the code from the callback, and run the token exchange from the headless machine using curl or a script:
```bash
curl -X POST https://platform.claude.com/v1/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "authorization_code",
"code": "<paste-code-here>",
"redirect_uri": "https://platform.claude.com/oauth/code/callback",
"client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
"code_verifier": "<your-verifier>",
"state": "<your-state>"
}'
```
If that returns a valid token set, you write it into `~/.claude/.credentials.json` in the shape described earlier, and Claude Code will pick it up.
## Scopes
The `scope` parameter in the authorize request determines what the resulting tokens are allowed to do. For Claude Code, the observed scopes include `user:profile`, `user:inference`, `user:sessions:claude_code`, and `user:mcp_servers`.
The `user:profile` scope allows reading account information. The `user:inference` scope is what actually grants permission to send messages to Claude models. The `user:sessions:claude_code` scope ties the session specifically to Claude Code usage. The `user:mcp_servers` scope allows interaction with MCP (Model Context Protocol) server configurations associated with the account.
If you request fewer scopes, you get a token that can do less. If you request scopes that the account or organization does not permit, the authorization server will either strip them silently or reject the request.
## Token Lifetime
Access tokens from Anthropic's OAuth flow are short-lived. The exact duration can vary, but a common value is around one hour. This is a deliberate design choice. Short-lived access tokens limit the damage if one is leaked: an attacker who steals an access token only has a narrow window to use it.
Refresh tokens last longer, but they are not immortal. They can be revoked explicitly by the server, rotated during a refresh operation, or invalidated by a new login. In practice, a refresh token that is not used for a long period may also expire, though the exact policy is up to Anthropic's implementation.
The `expiresAt` field stored in the credentials file is your best guide. Before making an API call, check whether the current time has passed that value. If it has, refresh first. A simple check in JavaScript:
```javascript
const creds = JSON.parse(fs.readFileSync(
path.join(os.homedir(), ".claude", ".credentials.json"),
"utf8"
));
const oauth = creds.claudeAiOauth;
if (Date.now() >= oauth.expiresAt) {
// refresh needed
}
```
## Security Properties
A few security properties of this flow are worth calling out explicitly.
The PKCE verifier never leaves the client machine during the authorize phase. Only the challenge, which is a one-way hash of the verifier, is sent to the server. An attacker who intercepts the authorize request sees the challenge but cannot derive the verifier from it. When the token exchange happens, the verifier is sent directly to the token endpoint over HTTPS, so it is protected by TLS.
The state parameter protects against CSRF attacks. Without it, an attacker could craft a malicious authorize URL and trick a user into completing the login, then intercept the callback. With a random state value that the client checks, this attack fails because the attacker cannot predict the state.
Refresh token rotation means that even if a refresh token leaks, it becomes useless after the next legitimate refresh operation. The tradeoff is the synchronization complexity described earlier, but the security benefit is substantial.
The credentials file at `~/.claude/.credentials.json` should be treated like any other secret on disk. Its permissions should be restricted to the owning user. On a shared machine, anyone who can read that file can impersonate the authenticated user against Anthropic's API.
## Debugging
When authentication stops working, a methodical approach saves time. Start by checking whether Claude Code itself thinks it is logged in:
```bash
claude auth status --json
```
If that shows a valid session, the problem is probably not in the login flow itself. If it shows expired or missing credentials, check the file directly:
```bash
cat ~/.claude/.credentials.json | python3 -m json.tool
```
Look for the `claudeAiOauth` object. Is the `accessToken` present? Is `expiresAt` in the future? Is the `refreshToken` present and non-empty?
On macOS, also check whether the keychain has a different (possibly fresher) credential:
```bash
security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null
```
If the access token is expired but the refresh token looks valid, try a manual refresh:
```bash
curl -X POST https://platform.claude.com/v1/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "refresh_token",
"client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
"refresh_token": "<your-refresh-token>"
}'
```
If that returns `invalid_grant`, the refresh token has been revoked. You need to log in again from scratch with `claude auth login` or the manual PKCE flow.
If the refresh succeeds but API calls still fail, hit the profile endpoint to confirm the token resolves to the expected account and organization. A token that works but belongs to a different org than you expect is a surprisingly common source of confusion, especially on machines where multiple accounts have been used.
## Conclusion
This is, at its core, a well-trodden OAuth flow. The PKCE extension adds a small amount of complexity at the start, but it meaningfully raises the security bar for CLI-based authentication. The local storage model is straightforward once you know which files to look at. And the refresh mechanics, while they can create headaches when multiple consumers share a single identity, follow standard OAuth 2.0 conventions. When something goes wrong, the debugging path is almost always the same: check the files, check the expiration, try a manual refresh, and if all else fails, log in again.
# Claude OAuth2 Provider Setup Guide
## Overview
AISBF now supports Claude Code (claude.ai) as a provider using OAuth2 authentication. This implementation mimics the official Claude CLI authentication flow and includes a Chrome extension to handle OAuth2 callbacks when AISBF runs on a remote server.
## Architecture
### 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
2. **ClaudeProviderHandler** (`aisbf/providers.py`)
- Implements the provider interface for Claude API
- Handles authentication header injection
- Supports automatic token refresh
3. **Chrome Extension** (`static/extension/`)
- Intercepts localhost OAuth2 callbacks (port 54545)
- Redirects callbacks to remote AISBF server
- Auto-configures with server URL
4. **Dashboard Integration** (`templates/dashboard/providers.html`)
- Extension detection and installation prompt
- OAuth2 flow initiation
- Authentication status checking
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
## Setup Instructions
### 1. Add Claude Provider to Configuration
Edit `~/.aisbf/providers.json` or use the dashboard:
```json
{
"providers": {
"claude": {
"id": "claude",
"name": "Claude Code (OAuth2)",
"endpoint": "https://api.anthropic.com/v1",
"type": "claude",
"api_key_required": false,
"rate_limit": 0,
"claude_config": {
"credentials_file": "~/.claude_credentials.json"
},
"models": [
{
"name": "claude-3-7-sonnet-20250219",
"context_size": 200000,
"rate_limit": 0
}
]
}
}
}
```
### 2. Install Chrome Extension (For Remote Servers)
If AISBF runs on a remote server (not localhost), you need the OAuth2 redirect extension:
1. **Download Extension**:
- Go to AISBF Dashboard → Providers
- Expand the Claude provider
- Click "Authenticate with Claude"
- If extension is not detected, click "Download Extension"
2. **Install in Chrome**:
- Extract the downloaded ZIP file
- Open Chrome and go to `chrome://extensions/`
- Enable "Developer mode" (toggle in top-right)
- Click "Load unpacked"
- Select the extracted extension folder
3. **Verify Installation**:
- Extension icon should appear in toolbar
- Click "Check Status" in dashboard to verify
### 3. Authenticate with Claude
1. Go to AISBF Dashboard → Providers
2. Expand the Claude provider
3. Click "🔐 Authenticate with Claude"
4. A browser window will open to claude.ai
5. Log in with your Claude account
6. Authorize the application
7. The window will close automatically
8. Dashboard will show "✓ Authentication successful!"
### 4. Use Claude Provider
Once authenticated, you can use Claude models via the API:
```bash
curl -X POST http://your-server:17765/api/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"model": "claude/claude-3-7-sonnet-20250219",
"messages": [
{"role": "user", "content": "Hello, Claude!"}
]
}'
```
## How It Works
### OAuth2 Flow
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
2. **Authorization**:
- 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
4. **Token Exchange**:
- 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
### Extension Configuration
The extension automatically configures itself with your AISBF server URL. It intercepts requests to:
- `http://localhost:54545/*`
- `http://127.0.0.1:54545/*`
And redirects them to:
- `https://your-server/dashboard/oauth2/callback?...`
## Troubleshooting
### Extension Not Detected
**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
### Authentication Timeout
**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
### Token Expired
**Problem**: API requests fail with 401 Unauthorized
**Solution**:
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
**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
## Security Considerations
1. **Credentials Storage**:
- 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
3. **Token Refresh**:
- Access tokens expire after ~1 hour
- Automatically refreshed using refresh_token
- Refresh tokens are long-lived
## API Compatibility
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)
## Required Headers
When using Claude provider, the following headers are automatically added:
```
Authorization: Bearer <access_token>
anthropic-version: 2023-06-01
anthropic-beta: claude-code-20250219
Content-Type: application/json
```
## Example Configuration
Complete provider configuration with multiple models:
```json
{
"providers": {
"claude": {
"id": "claude",
"name": "Claude Code",
"endpoint": "https://api.anthropic.com/v1",
"type": "claude",
"api_key_required": false,
"rate_limit": 0,
"default_rate_limit_TPM": 40000,
"default_rate_limit_TPH": 400000,
"default_context_size": 200000,
"claude_config": {
"credentials_file": "~/.claude_credentials.json"
},
"models": [
{
"name": "claude-3-7-sonnet-20250219",
"context_size": 200000,
"rate_limit": 0,
"rate_limit_TPM": 40000,
"rate_limit_TPH": 400000
},
{
"name": "claude-3-5-sonnet-20241022",
"context_size": 200000,
"rate_limit": 0,
"rate_limit_TPM": 40000,
"rate_limit_TPH": 400000
}
]
}
}
}
```
## Files Modified/Created
### 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
### 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
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
## 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/
......@@ -10,4 +10,6 @@ recursive-include config *.md
recursive-include aisbf *.py
recursive-include templates *.html
recursive-include templates *.css
recursive-include templates *.js
\ No newline at end of file
recursive-include templates *.js
recursive-include static *.zip
recursive-include static/extension *.js *.json *.html *.md *.png *.svg
\ No newline at end of file
......@@ -36,7 +36,7 @@ Access the dashboard at `http://localhost:17765/dashboard` (default credentials:
- **Provider-Level Defaults**: Set default condensation settings at provider level with cascading fallback logic
- **Effective Context Tracking**: Reports total tokens used (effective_context) for every request
- **Enhanced Context Condensation**: 8 condensation methods including hierarchical, conversational, semantic, algorithmic, sliding window, importance-based, entity-aware, and code-aware condensation
- **Provider-Native Caching**: 50-70% cost reduction using Anthropic `cache_control` and Google Context Caching APIs
- **Provider-Native Caching**: 50-70% cost reduction using Anthropic `cache_control`, Google Context Caching, and OpenAI-compatible APIs (including prompt_cache_key for OpenAI load balancer routing)
- **Response Caching**: 20-30% cache hit rate with semantic deduplication across multiple backends (memory, Redis, SQLite, MySQL)
- **Smart Request Batching**: 15-25% latency reduction by batching similar requests within 100ms window with provider-specific configurations
- **Streaming Response Optimization**: 10-20% memory reduction with chunk pooling, backpressure handling, and provider-specific streaming optimizations for Google and Kiro providers
......@@ -363,6 +363,73 @@ Edit `~/.aisbf/aisbf.json`:
- **Lower Latency**: Redis provides sub-millisecond cache access
- **Scalability**: Distributed Redis supports multiple AISBF instances
### Provider-Native Caching Configuration
AISBF supports provider-native caching for reduced API costs and latency across multiple provider types:
#### Supported Providers
| Provider | Caching Method | Configuration |
|----------|---------------|---------------|
| **OpenAI** | Automatic prefix caching (no code change needed) | Enabled by default for prompts >1024 tokens |
| **DeepSeek** | Automatic in 64-token chunks | Enabled by default |
| **Anthropic** | `cache_control` with `{"type": "ephemeral"}` | Requires `enable_native_caching: true` |
| **OpenRouter** | `cache_control` (wraps Anthropic) | Requires `enable_native_caching: true` |
| **Google** | Context Caching API (`cached_contents.create`) | Requires `enable_native_caching: true` and `cache_ttl` |
#### Configuration Options
Add to provider configuration in `providers.json`:
```json
{
"providers": {
"my_provider": {
"type": "openai",
"endpoint": "https://api.openrouter.ai/v1",
"enable_native_caching": true,
"min_cacheable_tokens": 1024,
"prompt_cache_key": "optional-cache-key-for-load-balancer"
},
"anthropic_provider": {
"type": "anthropic",
"api_key_required": true,
"enable_native_caching": true,
"min_cacheable_tokens": 1024
},
"google_provider": {
"type": "google",
"api_key_required": true,
"enable_native_caching": true,
"cache_ttl": 3600,
"min_cacheable_tokens": 1024
}
}
}
```
#### Configuration Fields
- **`enable_native_caching`**: Enable provider-native caching (boolean, default: false)
- **`min_cacheable_tokens`**: Minimum token count for caching (default: 1024, matches OpenAI)
- **`cache_ttl`**: Cache TTL in seconds for Google Context Caching (optional)
- **`prompt_cache_key`**: Optional cache key for OpenAI's load balancer routing optimization
#### How It Works
1. **Automatic Providers** (OpenAI, DeepSeek): Caching is handled automatically by the provider based on prompt length and prefix matching - no code changes required in AISBF.
2. **Explicit Providers** (Anthropic, OpenRouter, Google): AISBF adds `cache_control` blocks to messages or creates cached content objects after the first request, then reuses them for subsequent requests with similar prefixes.
3. **OpenAI-Compatible**: Custom providers using OpenAI-compatible endpoints (like OpenRouter) support both automatic caching and explicit `cache_control` blocks.
#### Cost Reduction
- **Anthropic**: 50-70% cost reduction with `cache_control` on long conversations
- **Google**: Up to 75% cost reduction with Context Caching API
- **OpenAI**: Automatic savings on repeated prompt prefixes
- **OpenRouter**: Passes through caching benefits from underlying providers
### Provider-Level Defaults
Providers can now define default settings that cascade to all models:
......
# AISBF Performance & Caching Improvements TODO
# AISBF CLI Tools Integration TODO
**Date**: 2026-03-23
**Context**: Analysis of prompt caching alternatives for AISBF
**Conclusion**: Prompt caching has low ROI for AISBF's architecture. Focus on these high-value alternatives instead.
**Date**: 2026-03-29
**Context**: Integration and verification of various AI CLI tools as AISBF providers
**Goal**: Expand AISBF's provider ecosystem by supporting popular CLI tools and verifying compatibility with existing services
---
## 🔥 HIGH PRIORITY (Implement Soon)
### 1. Provider-Native Caching Integration ✅ COMPLETED
**Estimated Effort**: 2-3 days | **Actual Effort**: 2 days
**Expected Benefit**: 50-70% cost reduction for supported providers
**ROI**: ⭐⭐⭐⭐⭐ Very High
### 1. Gemini CLI Integration
**Estimated Effort**: 2-3 days
**Expected Benefit**: Direct access to Google's Gemini models via official CLI
**ROI**: ⭐⭐⭐⭐ High
**Status**: ✅ **COMPLETED** - Provider-native caching successfully implemented with Anthropic `cache_control` and Google Context Caching framework.
#### ✅ Completed Tasks:
- [x] Add Anthropic `cache_control` support
- [x] Modify `AnthropicProviderHandler.handle_request()` in `aisbf/providers.py:1203`
- [x] Add `cache_control` parameter to message formatting
- [x] Mark system prompts and conversation prefixes as cacheable
- [x] Test with long system prompts (>1000 tokens)
- [x] Update documentation with cache_control examples
- [x] Add Google Context Caching API support
- [x] Modify `GoogleProviderHandler.handle_request()` in `aisbf/providers.py:450`
- [x] Implement context caching API calls (framework ready)
- [x] Add cache TTL configuration
- [x] Test with Gemini 1.5/2.0 models
- [x] Update documentation with context caching examples
- [x] Add configuration options
- [x] Add `enable_native_caching` to provider config
- [x] Add `cache_ttl` configuration
- [x] Add `min_cacheable_tokens` threshold
- [x] Update `config/providers.json` schema
- [x] Update dashboard UI for cache settings
**Files modified**:
- `aisbf/providers.py` (AnthropicProviderHandler, GoogleProviderHandler)
- `aisbf/config.py` (ProviderConfig model)
- `config/providers.json` (add cache config)
- `templates/dashboard/providers.html` (UI for cache settings)
- `DOCUMENTATION.md` (add native caching guide)
- `README.md` (add native caching section)
**Description**: Integrate Google's official Gemini CLI tool as a provider type in AISBF, allowing users to leverage their Gemini CLI credentials and configurations.
**Tasks**:
- [ ] Research Gemini CLI authentication and API structure
- [ ] Create `GeminiCLIProviderHandler` class in `aisbf/providers.py`
- [ ] Implement CLI command execution and response parsing
- [ ] Add configuration schema to `config/providers.json`
- [ ] Test with various Gemini models (Flash, Pro, Ultra)
- [ ] Add streaming support
- [ ] Update documentation with setup instructions
- [ ] Add dashboard UI support for Gemini CLI configuration
**Configuration Example**:
```json
{
"gemini-cli": {
"id": "gemini-cli",
"name": "Gemini CLI",
"type": "gemini-cli",
"api_key_required": false,
"gemini_cli_config": {
"cli_path": "/usr/local/bin/gemini",
"config_file": "~/.config/gemini/config.json"
}
}
}
```
---
### 2. Response Caching (Semantic Deduplication) ✅ COMPLETED
**Estimated Effort**: 2 days | **Actual Effort**: 1 day
**Expected Benefit**: 20-30% cache hit rate in multi-user scenarios
### 2. Qwen CLI Integration
**Estimated Effort**: 2-3 days
**Expected Benefit**: Access to Alibaba's Qwen models via CLI
**ROI**: ⭐⭐⭐⭐ High
**Status**: ✅ **COMPLETED** - Response caching successfully implemented with multiple backend support and granular cache control.
#### ✅ Completed Tasks:
- [x] Create response cache module
- [x] Create `aisbf/response_cache.py`
- [x] Implement `ResponseCache` class with multiple backends (memory, Redis, SQLite, MySQL)
- [x] Add in-memory LRU cache with configurable max size
- [x] Implement cache key generation (SHA256 hash of request data)
- [x] Add TTL support (default: 600 seconds / 10 minutes)
- [x] Integrate with request handlers
- [x] Add cache check in `RequestHandler.handle_chat_completion()`
- [x] Add cache check in `RotationHandler.handle_rotation_request()`
- [x] Add cache check in `AutoselectHandler.handle_autoselect_request()`
- [x] Skip cache for streaming requests
- [x] Add cache statistics tracking (hits, misses, hit rate, evictions)
- [x] Add configuration
- [x] Add `response_cache` section to `config/aisbf.json`
- [x] Add `enabled`, `backend`, `ttl`, `max_memory_cache` options
- [x] Add granular cache control (model, provider, rotation, autoselect levels)
- [x] Add dashboard UI endpoints for cache statistics and clearing
- [x] Testing
- [x] Test cache hit/miss scenarios
- [x] Test cache expiration (TTL)
- [x] Test multi-user scenarios
- [x] Test LRU eviction when max size reached
- [x] Test cache clearing functionality
**Files created**:
- `aisbf/response_cache.py` (new module with 740+ lines)
- `test_response_cache.py` (comprehensive test suite)
**Files modified**:
- `aisbf/handlers.py` (RequestHandler, RotationHandler, AutoselectHandler - added cache integration and granular control)
- `aisbf/config.py` (added ResponseCacheConfig and enable_response_cache fields to all config models)
- `config/aisbf.json` (added response_cache configuration section)
- `main.py` (added response cache initialization in startup event)
**Features**:
- Multiple backend support: memory (LRU), Redis, SQLite, MySQL
- Granular cache control hierarchy: Model > Provider > Rotation > Autoselect > Global
- Cache statistics tracking and dashboard endpoints
- TTL-based expiration
- LRU eviction for memory backend
- SHA256-based cache key generation
**Description**: Integrate Qwen CLI tool as a provider type, enabling access to Qwen's language models through their official command-line interface.
**Tasks**:
- [ ] Research Qwen CLI authentication and API structure
- [ ] Create `QwenCLIProviderHandler` class in `aisbf/providers.py`
- [ ] Implement CLI command execution and response parsing
- [ ] Add configuration schema to `config/providers.json`
- [ ] Test with available Qwen models
- [ ] Add streaming support if available
- [ ] Update documentation with setup instructions
- [ ] Add dashboard UI support for Qwen CLI configuration
**Configuration Example**:
```json
{
"qwen-cli": {
"id": "qwen-cli",
"name": "Qwen CLI",
"type": "qwen-cli",
"api_key_required": false,
"qwen_cli_config": {
"cli_path": "/usr/local/bin/qwen",
"api_key": "YOUR_QWEN_API_KEY"
}
}
}
```
---
### 3. Enhanced Context Condensation ✅ COMPLETED
**Estimated Effort**: 3-4 days | **Actual Effort**: 1 day
**Expected Benefit**: 30-50% token reduction
**ROI**: ⭐⭐⭐⭐ High
### 3. GitHub Copilot CLI Integration
**Estimated Effort**: 3-4 days
**Expected Benefit**: Leverage GitHub Copilot's code-focused models
**ROI**: ⭐⭐⭐⭐⭐ Very High
**Status**: ✅ **COMPLETED** - Enhanced context condensation successfully implemented with 8 condensation methods, internal model improvements, and analytics tracking.
#### ✅ Completed Tasks:
- [x] Improve existing condensation methods
- [x] Optimize `_hierarchical_condense()` in `aisbf/context.py:357`
- [x] Optimize `_conversational_condense()` in `aisbf/context.py:428`
- [x] Optimize `_semantic_condense()` in `aisbf/context.py:547`
- [x] Optimize `_algorithmic_condense()` in `aisbf/context.py:678`
- [x] Add new condensation methods
- [x] Implement sliding window with overlap
- [x] Implement importance-based pruning
- [x] Implement entity-aware condensation (preserve key entities)
- [x] Implement code-aware condensation (preserve code blocks)
- [x] Optimize internal model usage
- [x] Improve `_run_internal_model_condensation()` in `aisbf/context.py:224`
- [x] Add model warm-up on startup
- [x] Implement model pooling for concurrent requests
- [x] Add GPU memory management
- [x] Test with different model sizes (0.5B, 1B, 3B)
- [x] Add condensation analytics
- [x] Track condensation effectiveness (token reduction %)
- [x] Track condensation latency
- [x] Add dashboard visualization
- [x] Log condensation decisions for debugging
- [x] Configuration improvements
- [x] Add per-model condensation thresholds
- [x] Add adaptive condensation (based on context size)
- [x] Add condensation method chaining
- [x] Add condensation bypass for short contexts
**Files modified**:
- `aisbf/context.py` (ContextManager improvements with 8 condensation methods)
- `config/aisbf.json` (condensation config)
- `config/condensation_*.md` (update prompts)
- `templates/dashboard/settings.html` (condensation analytics)
**Features**:
- 8 condensation methods: hierarchical, conversational, semantic, algorithmic, sliding_window, importance_based, entity_aware, code_aware
- Internal model improvements with warm-up and pooling
- Condensation analytics tracking (effectiveness, latency)
- Per-model condensation thresholds
- Adaptive condensation based on context size
- Condensation method chaining
- Condensation bypass for short contexts
**Description**: Integrate GitHub Copilot CLI as a provider type, allowing users to access Copilot's models through their GitHub authentication.
**Tasks**:
- [ ] Research GitHub Copilot CLI authentication flow
- [ ] Create `CopilotCLIProviderHandler` class in `aisbf/providers.py`
- [ ] Implement GitHub OAuth integration if needed
- [ ] Implement CLI command execution and response parsing
- [ ] Add configuration schema to `config/providers.json`
- [ ] Test with Copilot models
- [ ] Add streaming support
- [ ] Handle GitHub authentication tokens
- [ ] Update documentation with setup instructions
- [ ] Add dashboard UI support for Copilot CLI configuration
**Configuration Example**:
```json
{
"copilot-cli": {
"id": "copilot-cli",
"name": "GitHub Copilot CLI",
"type": "copilot-cli",
"api_key_required": false,
"copilot_cli_config": {
"cli_path": "/usr/local/bin/github-copilot-cli",
"auth_token": "ghp_xxxxxxxxxxxxx"
}
}
}
```
---
## 🔶 MEDIUM PRIORITY
### 5. Smart Request Batching ✅ COMPLETED
**Estimated Effort**: 3-4 days | **Actual Effort**: 1 day
**Expected Benefit**: 15-25% latency reduction
**ROI**: ⭐⭐⭐ Medium-High
**Status**: ✅ **COMPLETED** - Smart request batching successfully implemented with time-based and size-based batching, provider-specific configurations, and graceful error handling.
#### ✅ Completed Tasks:
- [x] Create request batching module
- [x] Create `aisbf/batching.py`
- [x] Implement `RequestBatcher` class
- [x] Add request queue with 100ms window
- [x] Implement batch request combining
- [x] Implement response splitting
- [x] Integrate with providers
- [x] Add batching support to `BaseProviderHandler`
- [x] Implement provider-specific batching (OpenAI, Anthropic)
- [x] Handle batch size limits per provider
- [x] Handle batch failures gracefully
- [x] Configuration
- [x] Add `batching` config to `config/aisbf.json`
- [x] Add `enabled`, `window_ms`, `max_batch_size` options
- [x] Add per-provider batching settings
**Files created**:
- `aisbf/batching.py` (new module with 373 lines)
**Files modified**:
- `aisbf/providers.py` (BaseProviderHandler with batching support)
- `aisbf/config.py` (BatchingConfig model)
- `config/aisbf.json` (batching configuration section)
- `main.py` (batching initialization in startup event)
- `setup.py` (version 0.8.0, includes batching.py)
- `pyproject.toml` (version 0.8.0)
**Features**:
- Time-based batching (100ms window)
- Size-based batching (configurable max batch size)
- Provider-specific configurations (OpenAI: 10, Anthropic: 5)
- Automatic batch formation and processing
- Response splitting and distribution
- Statistics tracking (batches formed, requests batched, avg batch size)
- Graceful error handling and fallback
- Non-blocking async queue management
- Streaming request bypass (batching disabled for streams)
### 4. Bolt.new Verification
**Estimated Effort**: 1-2 days
**Expected Benefit**: Verify compatibility with Bolt.new service
**ROI**: ⭐⭐⭐ Medium
**Description**: Verify that AISBF can work with Bolt.new (StackBlitz's AI-powered full-stack web development tool) and document any integration requirements.
**Tasks**:
- [ ] Research Bolt.new API structure and authentication
- [ ] Test existing AISBF providers with Bolt.new
- [ ] Identify any compatibility issues
- [ ] Document integration steps
- [ ] Create example configurations
- [ ] Test with various Bolt.new features
- [ ] Update documentation with Bolt.new integration guide
---
### 6. Streaming Response Optimization ✅ COMPLETED
**Estimated Effort**: 2 days | **Actual Effort**: 0.5 days
**Expected Benefit**: Better memory usage, faster streaming
### 5. DeepSeek Verification
**Estimated Effort**: 1-2 days
**Expected Benefit**: Verify compatibility with DeepSeek API
**ROI**: ⭐⭐⭐ Medium
**Status**: ✅ **COMPLETED** - Streaming response optimization fully implemented with chunk pooling, backpressure handling, and provider-specific optimizations.
#### ✅ Completed Tasks:
- [x] Optimize chunk handling
- [x] Review `handle_streaming_chat_completion()` in `aisbf/handlers.py:480`
- [x] Reduce memory allocations in streaming loops
- [x] Implement chunk pooling via `ChunkPool` class
- [x] Add backpressure handling via `BackpressureController` class
- [x] Optimize Google streaming
- [x] Optimize Google chunk processing in handlers
- [x] Reduce accumulated text copying via `OptimizedTextAccumulator`
- [x] Implement incremental delta calculation via `calculate_google_delta()`
- [x] Optimize Kiro streaming
- [x] Review Kiro streaming in `_handle_streaming_request()` in `aisbf/providers.py:1757`
- [x] Optimize SSE parsing via `KiroSSEParser` class
- [x] Reduce string allocations via optimized parsing
**Files created**:
- `aisbf/streaming_optimization.py` (new module with 387 lines)
**Files modified**:
- `aisbf/handlers.py` (streaming optimizations in `handle_streaming_chat_completion()`)
- `aisbf/providers.py` (KiroProviderHandler streaming optimizations)
**Features**:
- `ChunkPool`: Memory-efficient chunk object reuse pool
- `BackpressureController`: Flow control to prevent overwhelming consumers
- `KiroSSEParser`: Optimized SSE parser for Kiro streaming
- `calculate_google_delta`: Incremental delta calculation for Google
- `OptimizedTextAccumulator`: Memory-efficient text accumulation with truncation
- `StreamingOptimizer`: Main coordinator combining all optimizations
- Delta-based streaming for Google and Kiro providers
- Configurable optimization settings via `StreamingConfig`
**Description**: Verify that AISBF's existing OpenAI-compatible provider handler works correctly with DeepSeek's API, or create a dedicated handler if needed.
**Tasks**:
- [ ] Research DeepSeek API structure and authentication
- [ ] Test with existing OpenAI provider handler
- [ ] Identify any API differences or incompatibilities
- [ ] Create dedicated `DeepSeekProviderHandler` if needed
- [ ] Add configuration example to `config/providers.json`
- [ ] Test with various DeepSeek models
- [ ] Document any special requirements or limitations
- [ ] Update documentation with DeepSeek integration guide
**Configuration Example**:
```json
{
"deepseek": {
"id": "deepseek",
"name": "DeepSeek",
"endpoint": "https://api.deepseek.com/v1",
"type": "openai",
"api_key_required": true,
"api_key": "YOUR_DEEPSEEK_API_KEY"
}
}
```
---
## 🔵 LOW PRIORITY (Future Enhancements)
### 7. Token Usage Analytics ✅ COMPLETED
**Estimated Effort**: 1-2 days | **Actual Effort**: 1 day
**Expected Benefit**: Better cost visibility
### 6. Rovo Dev CLI Verification
**Estimated Effort**: 1-2 days
**Expected Benefit**: Verify compatibility with Atlassian Rovo Dev CLI
**ROI**: ⭐⭐⭐ Medium
**Status**: ✅ **COMPLETED** - Token usage analytics fully implemented with comprehensive dashboard, cost estimation, and optimization recommendations.
#### ✅ Completed Tasks:
- [x] Create analytics module
- [x] Create `aisbf/analytics.py`
- [x] Use existing database for token usage queries
- [x] Add request counts and latency tracking
- [x] Track error rates and types
- [x] Query historical data from database
- [x] Dashboard integration
- [x] Create analytics dashboard page
- [x] Add charts for token usage over time
- [x] Add cost estimation per provider
- [x] Add model performance comparison
- [x] Add export functionality (CSV, JSON)
- [x] Optimization recommendations
- [x] Identify high-cost models
- [x] Suggest rotation weight adjustments
- [x] Suggest condensation threshold adjustments
**Files created**:
- `aisbf/analytics.py` (new module with 510+ lines)
- `templates/dashboard/analytics.html` (new page with 7915+ bytes)
**Files modified**:
- `aisbf/handlers.py` (added analytics hooks to RequestHandler, RotationHandler, AutoselectHandler)
- `aisbf/database.py` (optimized token_usage table schema)
- `templates/base.html` (added analytics link)
- `main.py` (added analytics dashboard route)
**Features**:
- Token usage tracking with database persistence
- Request counts and latency tracking (real-time)
- Error rates and types tracking
- Cost estimation per provider (Anthropic, OpenAI, Google, Kiro, OpenRouter)
- Model performance comparison
- Token usage over time visualization (1h, 6h, 24h, 7d)
- Optimization recommendations
- Export functionality (JSON, CSV)
- Integration with all request handlers
- Support for rotation_id and autoselect_id tracking
**Description**: Verify that AISBF can integrate with Atlassian's Rovo Dev CLI tool and document the integration process.
**Tasks**:
- [ ] Research Rovo Dev CLI authentication and API structure
- [ ] Test existing AISBF providers with Rovo Dev CLI
- [ ] Identify integration requirements
- [ ] Create dedicated handler if needed
- [ ] Add configuration schema
- [ ] Test with Rovo Dev features
- [ ] Document integration steps
- [ ] Update documentation with Rovo Dev CLI guide
---
### 8. Adaptive Rate Limiting ✅ COMPLETED
**Estimated Effort**: 2 days | **Actual Effort**: 1 day
**Expected Benefit**: 90%+ reduction in 429 errors
**ROI**: ⭐⭐⭐⭐ High
## 📋 Implementation Notes
### General CLI Integration Pattern
When integrating CLI tools, follow this pattern:
1. **Authentication**: Determine how the CLI tool handles authentication (config files, environment variables, OAuth tokens)
2. **Command Execution**: Use Python's `subprocess` module to execute CLI commands
3. **Response Parsing**: Parse CLI output (JSON, plain text, etc.) into AISBF's standard format
4. **Error Handling**: Handle CLI errors, timeouts, and authentication failures
5. **Streaming**: Implement streaming if the CLI tool supports it (parse output line-by-line)
6. **Configuration**: Add CLI-specific configuration fields (cli_path, config_file, etc.)
### Testing Checklist
For each CLI tool integration:
- [ ] Test authentication flow
- [ ] Test basic chat completion
- [ ] Test streaming responses
- [ ] Test error handling
- [ ] Test with multiple models
- [ ] Test rate limiting
- [ ] Test in rotations
- [ ] Test in autoselect
- [ ] Verify dashboard configuration UI
- [ ] Update documentation
---
## 🔵 Future Enhancements
### Additional CLI Tools to Consider
- Claude CLI (official Anthropic CLI)
- Mistral CLI
- Cohere CLI
- AI21 CLI
- Perplexity CLI
### CLI Tool Management Features
- [ ] CLI tool version detection and compatibility checking
- [ ] Automatic CLI tool installation/update
- [ ] CLI tool health monitoring
- [ ] CLI tool performance benchmarking
- [ ] Unified CLI tool configuration interface
---
**Status**: ✅ **COMPLETED** - Adaptive rate limiting fully implemented with intelligent 429 handling, dynamic rate limit learning, and comprehensive dashboard monitoring.
#### ✅ Completed Tasks:
- [x] Enhance 429 handling
- [x] Improve `parse_429_response()` in `aisbf/providers.py:271`
- [x] Add exponential backoff with jitter via `calculate_backoff_with_jitter()`
- [x] Track 429 patterns per provider via `_429_history`
- [x] Dynamic rate limit adjustment
- [x] Implement `AdaptiveRateLimiter` class in `aisbf/providers.py:46`
- [x] Learn optimal rate limits from 429 responses via `record_429()`
- [x] Adjust `rate_limit` dynamically via `get_rate_limit()`
- [x] Add rate limit headroom (stays below learned limits)
- [x] Add rate limit recovery (gradually increase after cooldown)
- [x] Configuration
- [x] Add `AdaptiveRateLimitingConfig` to `aisbf/config.py:186`
- [x] Add `adaptive_rate_limiting` to `config/aisbf.json`
- [x] Add learning rate and adjustment parameters
- [x] Add dashboard UI for rate limit status
- [x] Dashboard integration
- [x] Create `templates/dashboard/rate_limits.html`
- [x] Add `GET /dashboard/rate-limits` route
- [x] Add `GET /dashboard/rate-limits/data` API endpoint
- [x] Add `POST /dashboard/rate-limits/{provider_id}/reset` endpoint
- [x] Add quick access button to dashboard overview
**Files created**:
- `templates/dashboard/rate_limits.html` (new dashboard page)
**Files modified**:
- `aisbf/providers.py` (AdaptiveRateLimiter class, BaseProviderHandler integration)
- `aisbf/config.py` (AdaptiveRateLimitingConfig model)
- `config/aisbf.json` (adaptive_rate_limiting config section)
- `main.py` (dashboard routes)
- `templates/dashboard/index.html` (quick access button)
**Features**:
- Per-provider adaptive rate limiters with learning capability
- Exponential backoff with jitter (configurable base and jitter factor)
- Rate limit headroom (stays 10% below learned limits)
- Gradual recovery after consecutive successful requests
- 429 pattern tracking with configurable history window
- Real-time dashboard showing current limits, 429 counts, success rates
- Per-provider reset functionality
- Configurable via aisbf.json
## 📚 Documentation Updates Required
When completing CLI tool integrations:
1. Update `README.md` with CLI tool support section
2. Update `DOCUMENTATION.md` with detailed CLI tool setup guides
3. Update `AI.PROMPT` with CLI tool configuration patterns
4. Add CLI tool examples to `API_EXAMPLES.md`
5. Update dashboard help text for CLI tool configuration
"""
Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
AISBF - AI Service Broker Framework || AI Should Be Free
Claude OAuth2 authentication handler for AISBF.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Why did the programmer quit his job? Because he didn't get arrays!
"""
import os
import json
import secrets
import hashlib
import base64
import webbrowser
import time
import httpx
from pathlib import Path
from typing import Optional, Dict
from flask import Flask, request
import threading
import logging
# Try to import curl_cffi for TLS fingerprinting (optional)
try:
from curl_cffi import requests as curl_requests
HAS_CURL_CFFI = True
except ImportError:
HAS_CURL_CFFI = False
# Configuration matching the official Claude CLI
# Try to load client_id from credentials file first, fallback to generated UUID
import json
import os
from pathlib import Path
def _load_client_id_from_credentials():
"""Attempt to load client_id from existing Claude credentials file"""
try:
creds_path = Path.home() / ".claude" / ".credentials.json"
if creds_path.exists():
with open(creds_path, 'r') as f:
creds = json.load(f)
# Try to extract client_id from various possible locations
if 'client_id' in creds:
return creds['client_id']
elif 'oauth' in creds and 'client_id' in creds['oauth']:
return creds['oauth']['client_id']
elif 'claudeAiOauth' in creds and 'client_id' in creds['claudeAiOauth']:
return creds['claudeAiOauth']['client_id']
except Exception:
pass
return None
def _generate_client_id():
"""Generate a stable client_id UUID based on machine characteristics"""
# Use machine hostname and platform to generate a stable UUID
import uuid
import platform
machine_id = f"{platform.node()}-{platform.machine()}-claude-code"
# Generate UUID5 (name-based) from the machine ID
return str(uuid.uuid5(uuid.NAMESPACE_DNS, machine_id))
# Use the provided client ID for Claude OAuth2
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
AUTH_URL = "https://claude.ai/oauth/authorize"
TOKEN_URL = "https://api.anthropic.com/v1/oauth/token" # Correct endpoint from CLIProxyAPI
REDIRECT_URI = "http://localhost:54545/callback"
CLI_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
logger = logging.getLogger(__name__)
class ClaudeAuth:
"""
OAuth2 authentication handler for Claude Code.
Implements the full OAuth2 PKCE flow used by the official claude-cli,
including token refresh and automatic re-authentication.
"""
# Class-level constants
CLIENT_ID = CLIENT_ID
AUTH_URL = AUTH_URL
TOKEN_URL = TOKEN_URL
REDIRECT_URI = REDIRECT_URI
CLI_USER_AGENT = CLI_USER_AGENT
def __init__(self, credentials_file: Optional[str] = None):
"""
Initialize Claude authentication.
Args:
credentials_file: Path to credentials file (default: ~/.aisbf/claude_credentials.json)
"""
if credentials_file:
self.credentials_file = Path(credentials_file).expanduser()
else:
# Store credentials in ~/.aisbf/ directory (AISBF config directory)
self.credentials_file = Path.home() / ".aisbf" / "claude_credentials.json"
self.tokens = self._load_credentials()
self._oauth_state = None # Store state for OAuth flow
self._code_verifier = None # Store verifier for OAuth flow
# Log TLS fingerprinting capability
if HAS_CURL_CFFI:
logger.info(f"ClaudeAuth initialized with TLS fingerprinting (curl_cffi) - credentials: {self.credentials_file}")
else:
logger.warning(f"ClaudeAuth initialized without TLS fingerprinting (curl_cffi not available) - credentials: {self.credentials_file}")
logger.warning("Install curl_cffi for better Cloudflare bypass: pip install curl_cffi")
def _load_credentials(self) -> Optional[Dict]:
"""Load credentials from file if they exist."""
if self.credentials_file.exists():
try:
with open(self.credentials_file, 'r') as f:
tokens = json.load(f)
logger.info("Loaded existing Claude credentials")
return tokens
except Exception as e:
logger.warning(f"Failed to load credentials: {e}")
return None
return None
def _save_credentials(self, data: Dict):
"""Save credentials to file with file locking to prevent race conditions."""
try:
self.tokens = data
# Add local expiry timestamp for easier checking
self.tokens['expires_at'] = time.time() + data.get('expires_in', 3600)
# Ensure directory exists
self.credentials_file.parent.mkdir(parents=True, exist_ok=True)
# Use file locking to prevent race conditions with CLI
import fcntl
with open(self.credentials_file, 'w') as f:
try:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
json.dump(self.tokens, f, indent=2)
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
except (IOError, OSError):
# If locking fails, write anyway (Windows compatibility)
json.dump(self.tokens, f, indent=2)
# Set file permissions to 600 (owner read/write only)
os.chmod(self.credentials_file, 0o600)
logger.info(f"Saved Claude credentials to {self.credentials_file}")
except Exception as e:
logger.error(f"Failed to save credentials: {e}")
raise
def _generate_pkce(self):
"""Generate PKCE code verifier and challenge."""
verifier = secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).decode().replace('=', '')
return verifier, challenge
def _make_request(self, method: str, url: str, headers: Dict, json_data: Dict = None, timeout: float = 30.0):
"""
Make HTTP request with TLS fingerprinting when available.
Uses curl_cffi with Chrome impersonation to bypass Cloudflare's TLS fingerprinting,
matching CLIProxyAPI's utls implementation. Falls back to httpx if curl_cffi is not available.
Args:
method: HTTP method (GET, POST, etc.)
url: Request URL
headers: Request headers
json_data: JSON body data
timeout: Request timeout in seconds
Returns:
Response object with status_code, text, and json() method
"""
if HAS_CURL_CFFI:
# Use curl_cffi with Chrome impersonation (matches CLIProxyAPI's Chrome fingerprint)
try:
response = curl_requests.request(
method=method,
url=url,
headers=headers,
json=json_data,
timeout=timeout,
impersonate="chrome120" # Chrome 120 fingerprint
)
return response
except Exception as e:
logger.warning(f"curl_cffi request failed, falling back to httpx: {e}")
# Fall through to httpx
# Fallback to httpx (standard TLS)
response = httpx.request(
method=method,
url=url,
headers=headers,
json=json_data,
timeout=timeout
)
return response
def refresh_token(self, max_retries: int = 3) -> bool:
"""
Use the refresh token to get a new access token without logging in.
Args:
max_retries: Maximum number of retry attempts for rate limits
Returns:
True if refresh was successful, False otherwise
"""
import time
if not self.tokens or 'refresh_token' not in self.tokens:
logger.warning("No refresh token available")
return False
logger.info("Refreshing Claude access token...")
for attempt in range(max_retries):
try:
# Claude's token endpoint expects JSON (not form-encoded like standard OAuth2)
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": CLI_USER_AGENT
}
response = self._make_request(
method="POST",
url=TOKEN_URL,
headers=headers,
json_data={
"grant_type": "refresh_token",
"client_id": CLIENT_ID,
"refresh_token": self.tokens['refresh_token']
},
timeout=30.0
)
if response.status_code == 200:
self._save_credentials(response.json())
logger.info("Successfully refreshed access token")
return True
elif response.status_code == 429:
# Rate limited - wait and retry with exponential backoff
wait_time = (2 ** attempt) * 5 # 5, 10, 20 seconds
logger.warning(f"Rate limited (429). Waiting {wait_time} seconds before retry {attempt + 1}/{max_retries}")
time.sleep(wait_time)
continue
else:
logger.error(f"Token refresh failed: {response.status_code} - {response.text}")
return False
except Exception as e:
logger.error(f"Token refresh error: {e}")
if attempt < max_retries - 1:
wait_time = (2 ** attempt) * 5
logger.info(f"Retrying in {wait_time} seconds...")
time.sleep(wait_time)
continue
return False
logger.error(f"Token refresh failed after {max_retries} attempts")
return False
def get_valid_token(self) -> str:
"""
Get a valid access token, refreshing it if necessary.
Returns:
Valid access token
"""
if not self.tokens:
logger.info("No tokens available, starting login flow")
self.login()
# Refresh if less than 5 minutes remain
if time.time() > (self.tokens.get('expires_at', 0) - 300):
logger.info("Token expiring soon, refreshing...")
if not self.refresh_token():
logger.warning("Refresh failed, re-authenticating...")
self.login()
return self.tokens['access_token']
def login(self, use_local_server=True):
"""
Start a local server and open browser for the full OAuth2 flow.
This implements the PKCE flow used by claude-cli:
1. Generate PKCE verifier and challenge
2. Generate state parameter for CSRF protection
3. Start local callback server (if use_local_server=True)
4. Open browser to authorization URL
5. Wait for callback with authorization code
6. Exchange code for tokens
Args:
use_local_server: If True, starts a local Flask server for callback.
If False, expects external handling of the callback.
"""
logger.info("Starting Claude OAuth2 login flow...")
verifier, challenge = self._generate_pkce()
state = secrets.token_urlsafe(16) # Generate random state for CSRF protection
# Store state and verifier for later use
self._oauth_state = state
self._code_verifier = verifier
if not use_local_server:
# Return the auth URL and verifier for external handling
auth_params = {
"code": "true",
"client_id": CLIENT_ID,
"response_type": "code",
"redirect_uri": REDIRECT_URI,
"scope": "org:create_api_key user:profile user:inference",
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": state
}
url = f"{AUTH_URL}?{'&'.join(f'{k}={v}' for k, v in auth_params.items())}"
return {
'auth_url': url,
'verifier': verifier,
'challenge': challenge,
'state': state
}
# Create Flask app for callback
app = Flask(__name__)
app.logger.disabled = True # Disable Flask logging
# Store the authorization code
auth_code = {'code': None, 'error': None}
@app.route('/callback')
def callback():
code = request.args.get('code')
error = request.args.get('error')
callback_state = request.args.get('state')
if error:
auth_code['error'] = error
logger.error(f"OAuth error: {error}")
return f"Authentication failed: {error}. You can close this window.", 400
# Verify state parameter to prevent CSRF
if callback_state != state:
auth_code['error'] = 'state_mismatch'
logger.error(f"State mismatch: expected {state}, got {callback_state}")
return "Authentication failed: state mismatch. You can close this window.", 400
if code:
auth_code['code'] = code
logger.info("Received authorization code")
# Exchange code for tokens
try:
# Parse code - it might have state appended with #
code_parts = code.split('#')
parsed_code = code_parts[0]
parsed_state = code_parts[1] if len(code_parts) > 1 else None
# Claude's token endpoint expects JSON (not form-encoded like standard OAuth2)
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": CLI_USER_AGENT
}
# Build token request matching CLIProxyAPI exactly:
# 1. Start with provided state
# 2. Override with parsed state if present
token_request = {
"code": parsed_code,
"state": state,
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"code_verifier": verifier
}
# Override state if parsed from code (matching CLIProxyAPI lines 149-151)
if parsed_state:
token_request["state"] = parsed_state
# Log the request (hide sensitive data)
safe_request = {k: v if k not in ['code', 'code_verifier'] else '***' for k, v in token_request.items()}
logger.info(f"Token exchange request: {safe_request}")
logger.debug(f"Token exchange full request: {token_request}")
response = self._make_request(
method="POST",
url=TOKEN_URL,
headers=headers,
json_data=token_request,
timeout=30.0
)
logger.info(f"Token exchange response status: {response.status_code}")
if response.status_code != 200:
logger.error(f"Token exchange response body: {response.text}")
if response.status_code == 200:
self._save_credentials(response.json())
logger.info("Successfully obtained access token")
return "Authenticated! You can close this window."
else:
logger.error(f"Token exchange failed: {response.status_code} - {response.text}")
return f"Token exchange failed: {response.status_code}. You can close this window.", 400
except Exception as e:
logger.error(f"Token exchange error: {e}")
return f"Token exchange error: {e}. You can close this window.", 500
return "No authorization code received. You can close this window.", 400
# Build authorization URL
# Claude OAuth2 scopes for full access
# Note: "code": "true" is required by Claude's OAuth implementation
auth_params = {
"code": "true",
"client_id": CLIENT_ID,
"response_type": "code",
"redirect_uri": REDIRECT_URI,
"scope": "org:create_api_key user:profile user:inference",
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": state
}
url = f"{AUTH_URL}?{'&'.join(f'{k}={v}' for k, v in auth_params.items())}"
logger.info(f"Opening browser for authentication: {url}")
print(f"\n{'='*80}")
print(f"Claude Authentication Required")
print(f"{'='*80}")
print(f"\nPlease log in at: {url}")
print(f"\nYour browser should open automatically.")
print(f"If it doesn't, please copy and paste the URL above into your browser.")
print(f"\n{'='*80}\n")
# Open browser
webbrowser.open(url)
# Run Flask server in a separate thread with timeout
def run_server():
app.run(port=54545, debug=False, use_reloader=False)
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Wait for callback (with timeout)
timeout = 300 # 5 minutes
start_time = time.time()
while auth_code['code'] is None and auth_code['error'] is None:
if time.time() - start_time > timeout:
logger.error("Authentication timeout")
raise TimeoutError("Authentication timeout after 5 minutes")
time.sleep(0.5)
if auth_code['error']:
raise Exception(f"Authentication failed: {auth_code['error']}")
logger.info("OAuth2 login flow completed successfully")
def exchange_code_for_tokens(self, code: str, state: str, verifier: str = None, max_retries: int = 3) -> bool:
"""
Exchange authorization code for access tokens.
Matches CLIProxyAPI implementation exactly.
Args:
code: Authorization code from OAuth2 callback
state: State parameter for CSRF protection (REQUIRED)
verifier: PKCE code verifier (uses stored verifier if not provided)
max_retries: Maximum number of retry attempts for rate limits
Returns:
True if successful, False otherwise
"""
import time
# Use stored verifier if not provided
if verifier is None:
verifier = self._code_verifier
if not verifier:
raise ValueError("No code verifier available")
if not state:
raise ValueError("State parameter is required for token exchange")
for attempt in range(max_retries):
try:
# Claude's token endpoint expects JSON (not form-encoded like standard OAuth2)
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": CLI_USER_AGENT
}
# Parse code - it might have state appended with # (matching CLIProxyAPI parseCodeAndState)
code_parts = code.split('#')
parsed_code = code_parts[0]
parsed_state = code_parts[1] if len(code_parts) > 1 else ""
# Build token request matching CLIProxyAPI exactly:
# 1. Start with provided state
# 2. Override with parsed state if present
token_request = {
"code": parsed_code,
"state": state,
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"code_verifier": verifier
}
# Override state if parsed from code (matching CLIProxyAPI lines 149-151)
if parsed_state != "":
token_request["state"] = parsed_state
# Log the request (hide sensitive data)
safe_request = {k: v if k not in ['code', 'code_verifier'] else '***' for k, v in token_request.items()}
logger.info(f"Token exchange request: {safe_request}")
logger.debug(f"Token exchange full request: {token_request}")
response = self._make_request(
method="POST",
url=TOKEN_URL,
headers=headers,
json_data=token_request,
timeout=30.0
)
logger.info(f"Token exchange response status: {response.status_code}")
if response.status_code != 200:
logger.error(f"Token exchange response body: {response.text}")
if response.status_code == 200:
self._save_credentials(response.json())
logger.info("Successfully exchanged code for tokens")
return True
elif response.status_code == 429:
# Rate limited - wait and retry with exponential backoff
wait_time = (2 ** attempt) * 5 # 5, 10, 20 seconds
logger.warning(f"Rate limited (429). Waiting {wait_time} seconds before retry {attempt + 1}/{max_retries}")
time.sleep(wait_time)
continue
else:
logger.error(f"Token exchange failed: {response.status_code} - {response.text}")
return False
except Exception as e:
logger.error(f"Token exchange error: {e}")
if attempt < max_retries - 1:
wait_time = (2 ** attempt) * 5
logger.info(f"Retrying in {wait_time} seconds...")
time.sleep(wait_time)
continue
return False
logger.error(f"Token exchange failed after {max_retries} attempts")
return False
def is_authenticated(self) -> bool:
"""Check if we have valid credentials."""
return self.tokens is not None and 'access_token' in self.tokens
def clear_credentials(self):
"""Clear stored credentials."""
if self.credentials_file.exists():
self.credentials_file.unlink()
logger.info("Cleared Claude credentials")
self.tokens = None
# Example usage
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
auth = ClaudeAuth()
token = auth.get_valid_token()
# Use the token for an API call
client = httpx.Client()
response = client.post(
"https://api.anthropic.com/v1/messages",
headers={
"Authorization": f"Bearer {token}",
"anthropic-version": "2023-06-01",
"anthropic-beta": "claude-code-20250219", # Required for subscription usage
"Content-Type": "application/json"
},
json={
"model": "claude-3-7-sonnet-20250219",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "How's the weather in the CLI today?"}]
}
)
print(response.json())
......@@ -69,6 +69,7 @@ class ProviderConfig(BaseModel):
api_key: Optional[str] = None # Optional API key in provider config
models: Optional[List[ProviderModelConfig]] = None # Optional list of models with their configs
kiro_config: Optional[Dict] = None # Optional Kiro-specific configuration (credentials, region, etc.)
claude_config: Optional[Dict] = None # Optional Claude-specific configuration (credentials file path)
# Default settings for models in this provider
default_rate_limit: Optional[float] = None
default_max_request_tokens: Optional[int] = None
......@@ -80,9 +81,10 @@ class ProviderConfig(BaseModel):
default_condense_method: Optional[Union[str, List[str]]] = None
default_error_cooldown: Optional[int] = None # Default cooldown period in seconds after 3 consecutive failures (default: 300)
# Provider-native caching configuration
enable_native_caching: bool = False # Enable provider-native caching (Anthropic cache_control, Google Context Caching)
enable_native_caching: bool = False # Enable provider-native caching (Anthropic cache_control, Google Context Caching, OpenAI-compatible APIs)
cache_ttl: Optional[int] = None # Cache TTL in seconds for Google Context Caching API
min_cacheable_tokens: Optional[int] = 1000 # Minimum token count for content to be cacheable
min_cacheable_tokens: Optional[int] = 1024 # Minimum token count for content to be cacheable (default matches OpenAI)
prompt_cache_key: Optional[str] = None # Optional cache key for OpenAI's load balancer routing optimization
# Response caching control
enable_response_cache: Optional[bool] = None # Enable/disable response caching for this provider (None = use global default)
......
......@@ -995,8 +995,16 @@ def get_context_config_for_model(
# Try to find model-specific config in provider
if hasattr(provider_config, 'models') and provider_config.models:
for model in provider_config.models:
if model.get('name') == model_name:
model_specific_config = model
# Handle both Pydantic objects and dictionaries
model_name_value = model.name if hasattr(model, 'name') else model.get('name')
if model_name_value == model_name:
# Convert Pydantic object to dict if needed
if hasattr(model, 'model_dump'):
model_specific_config = model.model_dump()
elif hasattr(model, 'dict'):
model_specific_config = model.dict()
else:
model_specific_config = model
break
# Build base config from provider (model-specific > provider defaults)
......
......@@ -269,6 +269,36 @@ class DatabaseManager:
conn.commit()
logger.info("Database tables initialized successfully")
# Create user_auth_files table for storing authentication file metadata
cursor.execute(f'''
CREATE TABLE IF NOT EXISTS user_auth_files (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
provider_id VARCHAR(255) NOT NULL,
file_type VARCHAR(50) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
stored_filename VARCHAR(255) NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER,
mime_type VARCHAR(100),
created_at TIMESTAMP DEFAULT {timestamp_default},
updated_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, provider_id, file_type)
)
''')
try:
cursor.execute('''
CREATE INDEX idx_user_auth_files_user_provider
ON user_auth_files(user_id, provider_id)
''')
except:
pass # Index might already exist
conn.commit()
logger.info("User auth files table initialized")
def record_context_dimension(
self,
......@@ -609,16 +639,17 @@ class DatabaseManager:
Args:
user_id: User ID to delete
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
# Delete user configurations first (due to foreign key constraints)
cursor.execute('DELETE FROM user_providers WHERE user_id = ?', (user_id,))
cursor.execute('DELETE FROM user_rotations WHERE user_id = ?', (user_id,))
cursor.execute('DELETE FROM user_autoselects WHERE user_id = ?', (user_id,))
cursor.execute('DELETE FROM user_api_tokens WHERE user_id = ?', (user_id,))
cursor.execute('DELETE FROM user_token_usage WHERE user_id = ?', (user_id,))
cursor.execute(f'DELETE FROM user_providers WHERE user_id = {placeholder}', (user_id,))
cursor.execute(f'DELETE FROM user_rotations WHERE user_id = {placeholder}', (user_id,))
cursor.execute(f'DELETE FROM user_autoselects WHERE user_id = {placeholder}', (user_id,))
cursor.execute(f'DELETE FROM user_api_tokens WHERE user_id = {placeholder}', (user_id,))
cursor.execute(f'DELETE FROM user_token_usage WHERE user_id = {placeholder}', (user_id,))
# Delete the user
cursor.execute('DELETE FROM users WHERE id = ?', (user_id,))
cursor.execute(f'DELETE FROM users WHERE id = {placeholder}', (user_id,))
conn.commit()
def update_user(self, user_id: int, username: str, password_hash: str = None, role: str = None, is_active: bool = None):
......@@ -671,13 +702,22 @@ class DatabaseManager:
provider_name: Provider name
config: Provider configuration dictionary
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
config_json = json.dumps(config)
cursor.execute('''
INSERT OR REPLACE INTO user_providers (user_id, provider_id, config, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
''', (user_id, provider_name, config_json))
placeholder = '?' if self.db_type == 'sqlite' else '%s'
if self.db_type == 'sqlite':
cursor.execute(f'''
INSERT OR REPLACE INTO user_providers (user_id, provider_id, config, updated_at)
VALUES ({placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
''', (user_id, provider_name, config_json))
else: # mysql
cursor.execute(f'''
INSERT INTO user_providers (user_id, provider_id, config, updated_at)
VALUES ({placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE config=VALUES(config), updated_at=CURRENT_TIMESTAMP
''', (user_id, provider_name, config_json))
conn.commit()
def get_user_providers(self, user_id: int) -> List[Dict]:
......@@ -690,12 +730,13 @@ class DatabaseManager:
Returns:
List of provider configurations
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
SELECT provider_id, config, created_at, updated_at
FROM user_providers
WHERE user_id = ?
WHERE user_id = {placeholder}
ORDER BY provider_id
''', (user_id,))
......@@ -720,12 +761,13 @@ class DatabaseManager:
Returns:
Provider configuration dict or None
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
SELECT config, created_at, updated_at
FROM user_providers
WHERE user_id = ? AND provider_id = ?
WHERE user_id = {placeholder} AND provider_id = {placeholder}
''', (user_id, provider_name))
row = cursor.fetchone()
......@@ -745,11 +787,12 @@ class DatabaseManager:
user_id: User ID
provider_name: Provider name
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
DELETE FROM user_providers
WHERE user_id = ? AND provider_id = ?
WHERE user_id = {placeholder} AND provider_id = {placeholder}
''', (user_id, provider_name))
conn.commit()
......@@ -763,13 +806,22 @@ class DatabaseManager:
rotation_name: Rotation name
config: Rotation configuration dictionary
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
config_json = json.dumps(config)
cursor.execute('''
INSERT OR REPLACE INTO user_rotations (user_id, rotation_id, config, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
''', (user_id, rotation_name, config_json))
placeholder = '?' if self.db_type == 'sqlite' else '%s'
if self.db_type == 'sqlite':
cursor.execute(f'''
INSERT OR REPLACE INTO user_rotations (user_id, rotation_id, config, updated_at)
VALUES ({placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
''', (user_id, rotation_name, config_json))
else: # mysql
cursor.execute(f'''
INSERT INTO user_rotations (user_id, rotation_id, config, updated_at)
VALUES ({placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE config=VALUES(config), updated_at=CURRENT_TIMESTAMP
''', (user_id, rotation_name, config_json))
conn.commit()
def get_user_rotations(self, user_id: int) -> List[Dict]:
......@@ -782,12 +834,13 @@ class DatabaseManager:
Returns:
List of rotation configurations
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
SELECT rotation_id, config, created_at, updated_at
FROM user_rotations
WHERE user_id = ?
WHERE user_id = {placeholder}
ORDER BY rotation_id
''', (user_id,))
......@@ -812,12 +865,13 @@ class DatabaseManager:
Returns:
Rotation configuration dict or None
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
SELECT config, created_at, updated_at
FROM user_rotations
WHERE user_id = ? AND rotation_id = ?
WHERE user_id = {placeholder} AND rotation_id = {placeholder}
''', (user_id, rotation_name))
row = cursor.fetchone()
......@@ -837,11 +891,12 @@ class DatabaseManager:
user_id: User ID
rotation_name: Rotation name
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
DELETE FROM user_rotations
WHERE user_id = ? AND rotation_id = ?
WHERE user_id = {placeholder} AND rotation_id = {placeholder}
''', (user_id, rotation_name))
conn.commit()
......@@ -855,13 +910,22 @@ class DatabaseManager:
autoselect_name: Autoselect name
config: Autoselect configuration dictionary
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
config_json = json.dumps(config)
cursor.execute('''
INSERT OR REPLACE INTO user_autoselects (user_id, autoselect_id, config, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
''', (user_id, autoselect_name, config_json))
placeholder = '?' if self.db_type == 'sqlite' else '%s'
if self.db_type == 'sqlite':
cursor.execute(f'''
INSERT OR REPLACE INTO user_autoselects (user_id, autoselect_id, config, updated_at)
VALUES ({placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
''', (user_id, autoselect_name, config_json))
else: # mysql
cursor.execute(f'''
INSERT INTO user_autoselects (user_id, autoselect_id, config, updated_at)
VALUES ({placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE config=VALUES(config), updated_at=CURRENT_TIMESTAMP
''', (user_id, autoselect_name, config_json))
conn.commit()
def get_user_autoselects(self, user_id: int) -> List[Dict]:
......@@ -874,12 +938,13 @@ class DatabaseManager:
Returns:
List of autoselect configurations
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
SELECT autoselect_id, config, created_at, updated_at
FROM user_autoselects
WHERE user_id = ?
WHERE user_id = {placeholder}
ORDER BY autoselect_id
''', (user_id,))
......@@ -904,12 +969,13 @@ class DatabaseManager:
Returns:
Autoselect configuration dict or None
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
SELECT config, created_at, updated_at
FROM user_autoselects
WHERE user_id = ? AND autoselect_id = ?
WHERE user_id = {placeholder} AND autoselect_id = {placeholder}
''', (user_id, autoselect_name))
row = cursor.fetchone()
......@@ -929,11 +995,12 @@ class DatabaseManager:
user_id: User ID
autoselect_name: Autoselect name
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
DELETE FROM user_autoselects
WHERE user_id = ? AND autoselect_id = ?
WHERE user_id = {placeholder} AND autoselect_id = {placeholder}
''', (user_id, autoselect_name))
conn.commit()
......@@ -950,11 +1017,12 @@ class DatabaseManager:
Returns:
Token ID
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
INSERT INTO user_api_tokens (user_id, token, description)
VALUES (?, ?, ?)
VALUES ({placeholder}, {placeholder}, {placeholder})
''', (user_id, token, description))
conn.commit()
return cursor.lastrowid
......@@ -969,12 +1037,13 @@ class DatabaseManager:
Returns:
List of token dictionaries
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
SELECT id, token, description, created_at, last_used, is_active
FROM user_api_tokens
WHERE user_id = ?
WHERE user_id = {placeholder}
ORDER BY created_at DESC
''', (user_id,))
......@@ -1000,13 +1069,14 @@ class DatabaseManager:
Returns:
User dict if authenticated, None otherwise
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
SELECT u.id, u.username, u.role, t.id as token_id
FROM users u
JOIN user_api_tokens t ON u.id = t.user_id
WHERE t.token = ? AND t.is_active = 1 AND u.is_active = 1
WHERE t.token = {placeholder} AND t.is_active = 1 AND u.is_active = 1
''', (token,))
row = cursor.fetchone()
......@@ -1027,11 +1097,12 @@ class DatabaseManager:
user_id: User ID
token_id: Token ID
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
DELETE FROM user_api_tokens
WHERE id = ? AND user_id = ?
WHERE id = {placeholder} AND user_id = {placeholder}
''', (token_id, user_id))
conn.commit()
......@@ -1047,18 +1118,19 @@ class DatabaseManager:
model_name: Model name
tokens_used: Number of tokens used
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
INSERT INTO user_token_usage (user_id, token_id, provider_id, model_name, tokens_used)
VALUES (?, ?, ?, ?, ?)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder})
''', (user_id, token_id, provider_id, model_name, tokens_used))
# Update last_used timestamp for the token
cursor.execute('''
cursor.execute(f'''
UPDATE user_api_tokens
SET last_used = CURRENT_TIMESTAMP
WHERE id = ?
WHERE id = {placeholder}
''', (token_id,))
conn.commit()
......@@ -1073,12 +1145,13 @@ class DatabaseManager:
Returns:
List of token usage records
"""
with sqlite3.connect(self.db_path) as conn:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
SELECT provider_id, model_name, tokens_used, timestamp
FROM user_token_usage
WHERE user_id = ?
WHERE user_id = {placeholder}
ORDER BY timestamp DESC
LIMIT 1000
''', (user_id,))
......@@ -1092,6 +1165,185 @@ class DatabaseManager:
'timestamp': row[3]
})
return usage
# User authentication file methods
def save_user_auth_file(self, user_id: int, provider_id: str, file_type: str,
original_filename: str, stored_filename: str,
file_path: str, file_size: int, mime_type: str = None) -> int:
"""
Save user authentication file metadata.
Args:
user_id: User ID
provider_id: Provider identifier
file_type: Type of file (e.g., 'credentials', 'database', 'config')
original_filename: Original uploaded filename
stored_filename: Filename stored on disk
file_path: Full path to stored file
file_size: File size in bytes
mime_type: MIME type of the file
Returns:
File record ID
"""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
if self.db_type == 'sqlite':
cursor.execute(f'''
INSERT OR REPLACE INTO user_auth_files
(user_id, provider_id, file_type, original_filename, stored_filename,
file_path, file_size, mime_type, updated_at)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder},
{placeholder}, {placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
''', (user_id, provider_id, file_type, original_filename, stored_filename,
file_path, file_size, mime_type))
else: # mysql
cursor.execute(f'''
INSERT INTO user_auth_files
(user_id, provider_id, file_type, original_filename, stored_filename,
file_path, file_size, mime_type, updated_at)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder},
{placeholder}, {placeholder}, {placeholder}, {placeholder}, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
original_filename=VALUES(original_filename), stored_filename=VALUES(stored_filename),
file_path=VALUES(file_path), file_size=VALUES(file_size), mime_type=VALUES(mime_type),
updated_at=CURRENT_TIMESTAMP
''', (user_id, provider_id, file_type, original_filename, stored_filename,
file_path, file_size, mime_type))
conn.commit()
return cursor.lastrowid
def get_user_auth_files(self, user_id: int, provider_id: str = None) -> List[Dict]:
"""
Get all authentication files for a user.
Args:
user_id: User ID
provider_id: Optional provider ID to filter by
Returns:
List of file metadata dictionaries
"""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
if provider_id:
cursor.execute(f'''
SELECT id, provider_id, file_type, original_filename, stored_filename,
file_path, file_size, mime_type, created_at, updated_at
FROM user_auth_files
WHERE user_id = {placeholder} AND provider_id = {placeholder}
ORDER BY provider_id, file_type
''', (user_id, provider_id))
else:
cursor.execute(f'''
SELECT id, provider_id, file_type, original_filename, stored_filename,
file_path, file_size, mime_type, created_at, updated_at
FROM user_auth_files
WHERE user_id = {placeholder}
ORDER BY provider_id, file_type
''', (user_id,))
files = []
for row in cursor.fetchall():
files.append({
'id': row[0],
'provider_id': row[1],
'file_type': row[2],
'original_filename': row[3],
'stored_filename': row[4],
'file_path': row[5],
'file_size': row[6],
'mime_type': row[7],
'created_at': row[8],
'updated_at': row[9]
})
return files
def get_user_auth_file(self, user_id: int, provider_id: str, file_type: str) -> Optional[Dict]:
"""
Get a specific authentication file for a user.
Args:
user_id: User ID
provider_id: Provider identifier
file_type: Type of file
Returns:
File metadata dictionary or None
"""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
SELECT id, provider_id, file_type, original_filename, stored_filename,
file_path, file_size, mime_type, created_at, updated_at
FROM user_auth_files
WHERE user_id = {placeholder} AND provider_id = {placeholder} AND file_type = {placeholder}
''', (user_id, provider_id, file_type))
row = cursor.fetchone()
if row:
return {
'id': row[0],
'provider_id': row[1],
'file_type': row[2],
'original_filename': row[3],
'stored_filename': row[4],
'file_path': row[5],
'file_size': row[6],
'mime_type': row[7],
'created_at': row[8],
'updated_at': row[9]
}
return None
def delete_user_auth_file(self, user_id: int, provider_id: str, file_type: str) -> bool:
"""
Delete an authentication file record.
Args:
user_id: User ID
provider_id: Provider identifier
file_type: Type of file
Returns:
True if deleted, False if not found
"""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
DELETE FROM user_auth_files
WHERE user_id = {placeholder} AND provider_id = {placeholder} AND file_type = {placeholder}
''', (user_id, provider_id, file_type))
conn.commit()
return cursor.rowcount > 0
def delete_user_auth_files_by_provider(self, user_id: int, provider_id: str) -> int:
"""
Delete all authentication files for a provider.
Args:
user_id: User ID
provider_id: Provider identifier
Returns:
Number of files deleted
"""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
DELETE FROM user_auth_files
WHERE user_id = {placeholder} AND provider_id = {placeholder}
''', (user_id, provider_id))
conn.commit()
return cursor.rowcount
# Global database manager instance
......
......@@ -992,6 +992,18 @@ class RequestHandler:
models = await handler.get_models()
# Apply model filter if configured and no models are manually specified
model_filter = getattr(provider_config, 'model_filter', None)
if model_filter and (not provider_config.models or len(provider_config.models) == 0):
import logging
logger = logging.getLogger(__name__)
logger.info(f"Applying model filter '{model_filter}' to provider {provider_id}")
# Filter models whose ID contains the filter word (case-insensitive)
original_count = len(models)
models = [m for m in models if model_filter.lower() in m.id.lower()]
logger.info(f"Model filter applied: {original_count} -> {len(models)} models")
# Enhance model information with context window and capabilities
enhanced_models = []
current_time = int(time_module.time())
......@@ -1012,13 +1024,42 @@ class RequestHandler:
model_config = m
break
# Add context window information
if model_config and hasattr(model_config, 'context_size'):
# Add context window information - use dynamically fetched value unless manually configured
# Priority: manually configured > dynamically fetched > inferred
if model_config and hasattr(model_config, 'context_size') and model_config.context_size:
# Manually configured - use this value
model_dict['context_window'] = model_config.context_size
elif 'context_window' not in model_dict:
# Try to infer from model name or set a default
elif model_dict.get('context_size'):
# Dynamically fetched from provider - use this value
model_dict['context_window'] = model_dict['context_size']
else:
# Fall back to inference
model_dict['context_window'] = self._infer_context_window(model_name, provider_config.type)
# Add context_length for compatibility - same priority order as context_window
if model_config and hasattr(model_config, 'context_size') and model_config.context_size:
model_dict['context_length'] = model_config.context_size
elif model_dict.get('context_size'):
model_dict['context_length'] = model_dict['context_size']
elif model_dict.get('context_length'):
model_dict['context_length'] = model_dict['context_length']
# Add pricing if available (from dynamic fetch)
if model_dict.get('pricing'):
model_dict['pricing'] = model_dict['pricing']
# Add description if available (from dynamic fetch)
if model_dict.get('description'):
model_dict['description'] = model_dict['description']
# Add top_provider info if available (from dynamic fetch)
if model_dict.get('top_provider'):
model_dict['top_provider'] = model_dict['top_provider']
# Add supported_parameters if available (from dynamic fetch)
if model_dict.get('supported_parameters'):
model_dict['supported_parameters'] = model_dict['supported_parameters']
# Add capabilities information
if model_config and hasattr(model_config, 'capabilities'):
model_dict['capabilities'] = model_config.capabilities
......
......@@ -311,15 +311,25 @@ class KiroAuthManager:
json.dump(data, f, indent=2)
def get_auth_headers(self, token: str) -> dict:
"""Get headers for Kiro API requests"""
fingerprint = self._get_machine_fingerprint()
"""Get headers for Kiro API requests - matches kiro-cli format exactly"""
import platform
import sys
# Get system info for User-Agent (matching kiro-cli's format)
os_name = platform.system().lower()
python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
# Build User-Agent matching kiro-cli's AWS SDK Rust format
# Format: aws-sdk-rust/{version} os/{os} lang/rust/{version} md/appVersion/{version} app/AmazonQ-For-CLI
# We adapt this to Python: aws-sdk-python/{version} os/{os} lang/python/{version} md/appVersion/{version} app/AmazonQ-For-CLI
user_agent = f"aws-sdk-python/1.0.0 os/{os_name} lang/python/{python_version} md/appVersion/1.0.0 app/AmazonQ-For-CLI"
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"User-Agent": f"aws-sdk-js/1.0.27 KiroIDE-0.7.45-{fingerprint}",
"x-amz-user-agent": f"aws-sdk-js/1.0.27 KiroIDE-0.7.45-{fingerprint}",
"x-amz-codewhisperer-optout": "true",
"x-amzn-kiro-agent-mode": "vibe",
"User-Agent": user_agent,
"x-amz-user-agent": user_agent,
"x-amz-codewhisperer-optout": "false",
"amz-sdk-invocation-id": str(uuid.uuid4()),
"amz-sdk-request": "attempt=1; max=3"
}
......
......@@ -456,8 +456,16 @@ class BaseProviderHandler:
provider_config = config.providers.get(self.provider_id)
if provider_config and hasattr(provider_config, 'models') and provider_config.models:
for model_config in provider_config.models:
if model_config.get('name') == model:
return model_config
# Handle both Pydantic objects and dictionaries
model_name_value = model_config.name if hasattr(model_config, 'name') else model_config.get('name')
if model_name_value == model:
# Convert Pydantic object to dict if needed
if hasattr(model_config, 'model_dump'):
return model_config.model_dump()
elif hasattr(model_config, 'dict'):
return model_config.dict()
else:
return model_config
return None
def _check_token_rate_limit(self, model: str, token_count: int) -> bool:
......@@ -749,6 +757,8 @@ class GoogleProviderHandler(BaseProviderHandler):
# Initialize google-genai library
from google import genai
self.client = genai.Client(api_key=api_key)
# Cache storage for Google Context Caching
self._cached_content_refs = {} # {cache_key: (cached_content_name, expiry_time)}
async def handle_request(self, model: str, messages: List[Dict], max_tokens: Optional[int] = None,
temperature: Optional[float] = 1.0, stream: Optional[bool] = False,
......@@ -779,17 +789,55 @@ class GoogleProviderHandler(BaseProviderHandler):
provider_config = config.providers.get(self.provider_id)
enable_native_caching = getattr(provider_config, 'enable_native_caching', False)
cache_ttl = getattr(provider_config, 'cache_ttl', None)
min_cacheable_tokens = getattr(provider_config, 'min_cacheable_tokens', 1000)
logging.info(f"GoogleProviderHandler: Native caching enabled: {enable_native_caching}")
# Initialize cached_content_name for this request (will be set if we use caching)
cached_content_name = None
if enable_native_caching:
logging.info(f"GoogleProviderHandler: Cache TTL: {cache_ttl} seconds")
# Note: Google Context Caching API implementation would go here
# For now, we log that caching is enabled but don't implement the full caching logic
# Full implementation would require:
# 1. Creating cached content using context_cache.create()
# 2. Storing cache references and managing TTL
# 3. Referencing cached content in generate_content calls
logging.info(f"GoogleProviderHandler: Context caching configured but not yet implemented")
logging.info(f"GoogleProviderHandler: Cache TTL: {cache_ttl} seconds, min_cacheable_tokens: {min_cacheable_tokens}")
# Calculate total token count to determine if caching is beneficial
total_tokens = count_messages_tokens(messages, model)
logging.info(f"GoogleProviderHandler: Total message tokens: {total_tokens}")
# Only use caching if total tokens exceed minimum threshold
if total_tokens >= min_cacheable_tokens:
# Generate a cache key based on system message and early conversation
# We cache system message + early messages (not the last few turns)
cache_key = self._generate_cache_key(messages, model)
logging.info(f"GoogleProviderHandler: Generated cache_key: {cache_key}")
# Check if we have a valid cached content
if cache_key in self._cached_content_refs:
cached_content_name, expiry_time = self._cached_content_refs[cache_key]
current_time = time.time()
if current_time < expiry_time:
logging.info(f"GoogleProviderHandler: Using cached content: {cached_content_name} (expires in {expiry_time - current_time:.0f}s)")
else:
# Cache expired, remove it
logging.info(f"GoogleProviderHandler: Cache expired, removing: {cached_content_name}")
del self._cached_content_refs[cache_key]
cached_content_name = None
else:
logging.info(f"GoogleProviderHandler: No cached content found for cache_key")
# If no cached content, and we have a TTL, mark to create cache after first request
if cached_content_name is None and cache_ttl:
# We'll set this flag to create cache after first request
self._pending_cache_key = (cache_key, cache_ttl, messages)
logging.info(f"GoogleProviderHandler: Will create cached content after first request")
else:
self._pending_cache_key = None
else:
logging.info(f"GoogleProviderHandler: Total tokens ({total_tokens}) below min_cacheable_tokens ({min_cacheable_tokens}), skipping cache")
self._pending_cache_key = None
else:
self._pending_cache_key = None
# Build content from messages
content = "\n\n".join([f"{msg['role']}: {msg['content']}" for msg in messages])
......@@ -829,6 +877,10 @@ class GoogleProviderHandler(BaseProviderHandler):
# Handle streaming request
if stream:
logging.info(f"GoogleProviderHandler: Using streaming API")
# For streaming, we don't use cached content (API limitations)
# But we can still prepare for future caching by tracking the pending cache
# Create a new client instance for streaming to ensure it stays open
from google import genai
stream_client = genai.Client(api_key=self.api_key)
......@@ -847,6 +899,20 @@ class GoogleProviderHandler(BaseProviderHandler):
logging.info(f"GoogleProviderHandler: Streaming response received (total chunks: {len(chunks)})")
self.record_success()
# After successful streaming response, create cached content if pending
if hasattr(self, '_pending_cache_key') and self._pending_cache_key:
cache_key, cache_ttl, cache_messages = self._pending_cache_key
try:
new_cached_name = self._create_cached_content(cache_messages, model, cache_ttl)
if new_cached_name:
# Calculate expiry time
expiry_time = time.time() + cache_ttl
self._cached_content_refs[cache_key] = (new_cached_name, expiry_time)
logging.info(f"GoogleProviderHandler: Cached content stored (streaming): {new_cached_name}, expires in {cache_ttl}s")
except Exception as e:
logging.warning(f"GoogleProviderHandler: Failed to create cache after streaming: {e}")
self._pending_cache_key = None
# Now yield chunks asynchronously - yield raw chunk objects
# The handlers.py will handle the conversion to OpenAI format
async def async_generator():
......@@ -856,15 +922,96 @@ class GoogleProviderHandler(BaseProviderHandler):
return async_generator()
else:
# Non-streaming request
# Determine if we should use cached content
use_cached = cached_content_name is not None
# Build content from messages
if use_cached and cached_content_name:
# When using cached content, only send the last few messages
# (the ones not included in the cache)
last_msg_count = min(3, len(messages))
last_messages = messages[-last_msg_count:] if messages else []
content = "\n\n".join([f"{msg['role']}: {msg['content']}" for msg in last_messages])
logging.info(f"GoogleProviderHandler: Using cached content, sending last {last_msg_count} messages")
else:
content = "\n\n".join([f"{msg['role']}: {msg['content']}" for msg in messages])
# Build config with only non-None values
config = {"temperature": temperature}
if max_tokens is not None:
config["max_output_tokens"] = max_tokens
# Convert OpenAI tools to Google's function calling format
google_tools = None
if tools:
function_declarations = []
for tool in tools:
if tool.get("type") == "function":
function = tool.get("function", {})
# Use Google's SDK types for proper validation
from google.genai import types as genai_types
function_declaration = genai_types.FunctionDeclaration(
name=function.get("name"),
description=function.get("description", ""),
parameters=function.get("parameters", {})
)
function_declarations.append(function_declaration)
logging.info(f"GoogleProviderHandler: Converted tool to Google format: {function_declaration}")
if function_declarations:
# Google API expects tools to be a Tool object with function_declarations
from google.genai import types as genai_types
google_tools = genai_types.Tool(function_declarations=function_declarations)
logging.info(f"GoogleProviderHandler: Added {len(function_declarations)} tools to google_tools")
# Add tools to config for both streaming and non-streaming
config["tools"] = google_tools
logging.info(f"GoogleProviderHandler: Added tools to config")
# Generate content using the google-genai client
response = self.client.models.generate_content(
model=model,
contents=content,
config=config
)
if use_cached and cached_content_name:
# Use cached content in the request
try:
logging.info(f"GoogleProviderHandler: Making request with cached_content: {cached_content_name}")
response = self.client.models.generate_content(
model=model,
contents=content,
config=config,
cached_content=cached_content_name
)
except TypeError as e:
# cached_content parameter may not be available in this SDK version
# Fall back to regular request
logging.warning(f"GoogleProviderHandler: cached_content param not supported, using regular request: {e}")
response = self.client.models.generate_content(
model=model,
contents=content,
config=config
)
else:
# Regular request without caching
response = self.client.models.generate_content(
model=model,
contents=content,
config=config
)
logging.info(f"GoogleProviderHandler: Response received: {response}")
self.record_success()
# After successful response, create cached content if pending
if hasattr(self, '_pending_cache_key') and self._pending_cache_key:
cache_key, cache_ttl, cache_messages = self._pending_cache_key
try:
new_cached_name = self._create_cached_content(cache_messages, model, cache_ttl)
if new_cached_name:
# Calculate expiry time
expiry_time = time.time() + cache_ttl
self._cached_content_refs[cache_key] = (new_cached_name, expiry_time)
logging.info(f"GoogleProviderHandler: Cached content stored: {new_cached_name}, expires in {cache_ttl}s")
except Exception as e:
logging.warning(f"GoogleProviderHandler: Failed to create cache after response: {e}")
self._pending_cache_key = None
# Dump raw response if AISBF_DEBUG is enabled
if AISBF_DEBUG:
......@@ -1399,10 +1546,21 @@ class GoogleProviderHandler(BaseProviderHandler):
# Convert to our Model format
result = []
for model in models:
# Extract context size if available - check multiple field names
context_size = None
if hasattr(model, 'context_window') and model.context_window:
context_size = model.context_window
elif hasattr(model, 'context_length') and model.context_length:
context_size = model.context_length
elif hasattr(model, 'max_context_length') and model.max_context_length:
context_size = model.max_context_length
result.append(Model(
id=model.name,
name=model.display_name or model.name,
provider_id=self.provider_id
provider_id=self.provider_id,
context_size=context_size,
context_length=context_size
))
return result
......@@ -1411,6 +1569,169 @@ class GoogleProviderHandler(BaseProviderHandler):
logging.error(f"GoogleProviderHandler: Error getting models: {str(e)}", exc_info=True)
raise e
def _generate_cache_key(self, messages: List[Dict], model: str) -> str:
"""
Generate a cache key based on the early messages (system + early conversation).
We only cache the system message and early conversation turns, not the most recent ones.
Args:
messages: List of message dicts
model: Model name
Returns:
Cache key string
"""
import hashlib
import json
# Extract system message and first part of conversation
# We cache system + first few messages (excluding last 2 messages for dynamic content)
cacheable_messages = []
for i, msg in enumerate(messages):
# Include system messages and early conversation (first half, up to last 2)
if msg.get('role') == 'system' or i < max(0, len(messages) - 3):
cacheable_messages.append({
'role': msg.get('role'),
'content': msg.get('content', '')[:1000] # Truncate long content for key
})
# Create hash from messages + model
cache_data = json.dumps({
'model': model,
'messages': cacheable_messages
}, sort_keys=True)
return hashlib.sha256(cache_data.encode()).hexdigest()[:32]
def _create_cached_content(self, messages: List[Dict], model: str, cache_ttl: int) -> Optional[str]:
"""
Create a cached content object in Google API.
Args:
messages: Messages to cache
model: Model name
cache_ttl: Cache TTL in seconds
Returns:
Cached content name or None on failure
"""
import logging
try:
# Extract the cacheable content (system + early messages)
cacheable_parts = []
for i, msg in enumerate(messages):
# Include system messages and early conversation (first half, up to last 2)
if msg.get('role') == 'system' or i < max(0, len(messages) - 3):
role = msg.get('role', 'user')
content = msg.get('content', '')
cacheable_parts.append(f"{role}: {content}")
if not cacheable_parts:
logging.info("GoogleProviderHandler: No cacheable content to create")
return None
cached_content_text = "\n\n".join(cacheable_parts)
# Create cache name
cache_name = f"cached_content_{int(time.time())}"
logging.info(f"GoogleProviderHandler: Creating cached content: {cache_name}")
logging.info(f"GoogleProviderHandler: Cached content length: {len(cached_content_text)} chars")
# Use the google-genai client to create cached content
# The cached content is created via the content caching API
from google.genai import types as genai_types
# Create cached content using the client
# Note: The actual API call depends on the SDK version and model support
# For models that support context caching, we create the cached content
try:
# Try to create cached content through the API
# This may not be available in all SDK versions
cached_content = self.client.cached_contents.create(
model=model,
display_name=cache_name,
system_instruction=cached_content_text,
ttl=f"{cache_ttl}s"
)
logging.info(f"GoogleProviderHandler: Cached content created: {cached_content.name}")
return cached_content.name
except AttributeError as e:
# cached_contents may not be available in this SDK version
logging.info(f"GoogleProviderHandler: Cached content API not available in this SDK: {e}")
# Fall back to just storing the content locally as a reference
# The next request will still process normally but we track this attempt
return None
except Exception as e:
logging.warning(f"GoogleProviderHandler: Failed to create cached content: {e}")
return None
except Exception as e:
logging.error(f"GoogleProviderHandler: Error creating cached content: {e}")
return None
def _use_cached_content_in_request(self, cached_content_name: str, model: str,
last_messages: List[Dict], max_tokens: Optional[int],
temperature: float, tools: Optional[List[Dict]]) -> Union[Dict, object]:
"""
Make a request using cached content.
Args:
cached_content_name: Name of the cached content
model: Model name
last_messages: The non-cached messages (last few turns)
max_tokens: Max output tokens
temperature: Temperature setting
tools: Tool definitions
Returns:
Response from API
"""
import logging
from google.genai import types as genai_types
logging.info(f"GoogleProviderHandler: Using cached content: {cached_content_name}")
# Build content from only the non-cached (last) messages
content = "\n\n".join([f"{msg['role']}: {msg['content']}" for msg in last_messages])
# Build config
config = {"temperature": temperature}
if max_tokens is not None:
config["max_output_tokens"] = max_tokens
if tools:
# Convert tools to Google format (same as before)
function_declarations = []
for tool in tools:
if tool.get("type") == "function":
function = tool.get("function", {})
function_declaration = genai_types.FunctionDeclaration(
name=function.get("name"),
description=function.get("description", ""),
parameters=function.get("parameters", {})
)
function_declarations.append(function_declaration)
if function_declarations:
google_tools = genai_types.Tool(function_declarations=function_declarations)
config["tools"] = google_tools
# Make request using cached content
# Reference the cached content in the request
response = self.client.models.generate_content(
model=model,
contents=content,
config=config,
cached_content=cached_content_name # Use the cached content
)
return response
class OpenAIProviderHandler(BaseProviderHandler):
def __init__(self, provider_id: str, api_key: str):
super().__init__(provider_id, api_key)
......@@ -1436,6 +1757,16 @@ class OpenAIProviderHandler(BaseProviderHandler):
# Apply rate limiting
await self.apply_rate_limit()
# Check if native caching is enabled for this provider
provider_config = config.providers.get(self.provider_id)
enable_native_caching = getattr(provider_config, 'enable_native_caching', False)
min_cacheable_tokens = getattr(provider_config, 'min_cacheable_tokens', 1024)
prompt_cache_key = getattr(provider_config, 'prompt_cache_key', None)
logging.info(f"OpenAIProviderHandler: Native caching enabled: {enable_native_caching}")
if enable_native_caching:
logging.info(f"OpenAIProviderHandler: Min cacheable tokens: {min_cacheable_tokens}, prompt_cache_key: {prompt_cache_key}")
# Build request parameters
request_params = {
"model": model,
......@@ -1448,26 +1779,70 @@ class OpenAIProviderHandler(BaseProviderHandler):
if max_tokens is not None:
request_params["max_tokens"] = max_tokens
# Build messages with all fields (including tool_calls and tool_call_id)
for msg in messages:
message = {"role": msg["role"]}
# For tool role, tool_call_id is required
if msg["role"] == "tool":
if "tool_call_id" in msg and msg["tool_call_id"] is not None:
message["tool_call_id"] = msg["tool_call_id"]
# Add prompt_cache_key if provided (for OpenAI's load balancer routing optimization)
if enable_native_caching and prompt_cache_key:
request_params["prompt_cache_key"] = prompt_cache_key
logging.info(f"OpenAIProviderHandler: Added prompt_cache_key to request")
# Build messages with all fields (including tool_calls, tool_call_id, and cache_control)
if enable_native_caching:
# Count cumulative tokens for cache decision
cumulative_tokens = 0
for i, msg in enumerate(messages):
# Count tokens in this message
message_tokens = count_messages_tokens([msg], model)
cumulative_tokens += message_tokens
message = {"role": msg["role"]}
# For tool role, tool_call_id is required
if msg["role"] == "tool":
if "tool_call_id" in msg and msg["tool_call_id"] is not None:
message["tool_call_id"] = msg["tool_call_id"]
else:
# Skip tool messages without tool_call_id
logger.warning(f"Skipping tool message without tool_call_id: {msg}")
continue
if "content" in msg and msg["content"] is not None:
message["content"] = msg["content"]
if "tool_calls" in msg and msg["tool_calls"] is not None:
message["tool_calls"] = msg["tool_calls"]
if "name" in msg and msg["name"] is not None:
message["name"] = msg["name"]
# Apply cache_control based on position and token count
# Cache system messages and long conversation prefixes
# This is compatible with Anthropic via OpenRouter, DeepSeek, and other OpenAI-compatible APIs
if (msg["role"] == "system" or
(i < len(messages) - 2 and cumulative_tokens >= min_cacheable_tokens)):
message["cache_control"] = {"type": "ephemeral"}
logging.info(f"OpenAIProviderHandler: Applied cache_control to message {i} ({message_tokens} tokens, cumulative: {cumulative_tokens})")
else:
# Skip tool messages without tool_call_id
logger.warning(f"Skipping tool message without tool_call_id: {msg}")
continue
if "content" in msg and msg["content"] is not None:
message["content"] = msg["content"]
if "tool_calls" in msg and msg["tool_calls"] is not None:
message["tool_calls"] = msg["tool_calls"]
if "name" in msg and msg["name"] is not None:
message["name"] = msg["name"]
request_params["messages"].append(message)
logging.info(f"OpenAIProviderHandler: Not caching message {i} ({message_tokens} tokens, cumulative: {cumulative_tokens})")
request_params["messages"].append(message)
else:
# Standard message formatting without caching
for msg in messages:
message = {"role": msg["role"]}
# For tool role, tool_call_id is required
if msg["role"] == "tool":
if "tool_call_id" in msg and msg["tool_call_id"] is not None:
message["tool_call_id"] = msg["tool_call_id"]
else:
# Skip tool messages without tool_call_id
logger.warning(f"Skipping tool message without tool_call_id: {msg}")
continue
if "content" in msg and msg["content"] is not None:
message["content"] = msg["content"]
if "tool_calls" in msg and msg["tool_calls"] is not None:
message["tool_calls"] = msg["tool_calls"]
if "name" in msg and msg["name"] is not None:
message["name"] = msg["name"]
request_params["messages"].append(message)
# Add tools and tool_choice if provided
if tools is not None:
......@@ -1508,7 +1883,26 @@ class OpenAIProviderHandler(BaseProviderHandler):
models = self.client.models.list()
logging.info(f"OpenAIProviderHandler: Models received: {models}")
return [Model(id=model.id, name=model.id, provider_id=self.provider_id) for model in models]
result = []
for model in models:
# Extract context size if available - check multiple field names
context_size = None
if hasattr(model, 'context_window') and model.context_window:
context_size = model.context_window
elif hasattr(model, 'context_length') and model.context_length:
context_size = model.context_length
elif hasattr(model, 'max_context_length') and model.max_context_length:
context_size = model.max_context_length
result.append(Model(
id=model.id,
name=model.id,
provider_id=self.provider_id,
context_size=context_size,
context_length=context_size
))
return result
except Exception as e:
import logging
logging.error(f"OpenAIProviderHandler: Error getting models: {str(e)}", exc_info=True)
......@@ -1731,12 +2125,701 @@ class AnthropicProviderHandler(BaseProviderHandler):
raise e
async def get_models(self) -> List[Model]:
# Anthropic doesn't have a models list endpoint, so we'll return a static list
return [
Model(id="claude-3-haiku-20240307", name="Claude 3 Haiku", provider_id=self.provider_id),
Model(id="claude-3-sonnet-20240229", name="Claude 3 Sonnet", provider_id=self.provider_id),
Model(id="claude-3-opus-20240229", name="Claude 3 Opus", provider_id=self.provider_id)
]
"""
Return list of available Anthropic models.
Note: Anthropic's API doesn't provide a public models endpoint,
so we return a curated static list of available models.
"""
try:
import logging
logging.info("=" * 80)
logging.info("AnthropicProviderHandler: Starting model list retrieval")
logging.info("=" * 80)
# Apply rate limiting
await self.apply_rate_limit()
# Try to fetch models from API (in case Anthropic adds this endpoint)
try:
logging.info("AnthropicProviderHandler: Attempting to fetch models from API...")
logging.info("AnthropicProviderHandler: Note: Anthropic doesn't currently provide a public models endpoint")
logging.info("AnthropicProviderHandler: Checking if endpoint is now available...")
response = self.client.models.list()
if response:
logging.info(f"AnthropicProviderHandler: ✓ API call successful!")
logging.info(f"AnthropicProviderHandler: Retrieved models from API")
models = [Model(id=model.id, name=model.id, provider_id=self.provider_id) for model in response]
for model in models:
logging.info(f"AnthropicProviderHandler: - {model.id}")
logging.info("=" * 80)
logging.info(f"AnthropicProviderHandler: ✓ SUCCESS - Returning {len(models)} models from API")
logging.info(f"AnthropicProviderHandler: Source: Dynamic API retrieval")
logging.info("=" * 80)
return models
except AttributeError as attr_error:
logging.info(f"AnthropicProviderHandler: ✗ API endpoint not available")
logging.info(f"AnthropicProviderHandler: Error: {type(attr_error).__name__} - {str(attr_error)}")
logging.info("AnthropicProviderHandler: Reason: Anthropic SDK doesn't expose models.list() method")
logging.info("AnthropicProviderHandler: Action: Falling back to static list")
except Exception as api_error:
logging.warning(f"AnthropicProviderHandler: ✗ Exception during API call")
logging.warning(f"AnthropicProviderHandler: Error type: {type(api_error).__name__}")
logging.warning(f"AnthropicProviderHandler: Error message: {str(api_error)}")
logging.warning("AnthropicProviderHandler: Action: Falling back to static list")
if AISBF_DEBUG:
logging.warning(f"AnthropicProviderHandler: Full traceback:", exc_info=True)
# Return static list (Anthropic doesn't have a public models endpoint as of 2025)
logging.info("-" * 80)
logging.info("AnthropicProviderHandler: Using static fallback model list")
logging.info("AnthropicProviderHandler: Note: This is the expected behavior for Anthropic provider")
static_models = [
Model(id="claude-3-7-sonnet-20250219", name="Claude 3.7 Sonnet", provider_id=self.provider_id, context_size=200000, context_length=200000),
Model(id="claude-3-5-sonnet-20241022", name="Claude 3.5 Sonnet", provider_id=self.provider_id, context_size=200000, context_length=200000),
Model(id="claude-3-5-haiku-20241022", name="Claude 3.5 Haiku", provider_id=self.provider_id, context_size=200000, context_length=200000),
Model(id="claude-3-opus-20240229", name="Claude 3 Opus", provider_id=self.provider_id, context_size=200000, context_length=200000),
Model(id="claude-3-haiku-20240307", name="Claude 3 Haiku", provider_id=self.provider_id, context_size=200000, context_length=200000),
Model(id="claude-3-sonnet-20240229", name="Claude 3 Sonnet", provider_id=self.provider_id, context_size=200000, context_length=200000),
]
for model in static_models:
logging.info(f"AnthropicProviderHandler: - {model.id} ({model.name})")
logging.info("=" * 80)
logging.info(f"AnthropicProviderHandler: ✓ Returning {len(static_models)} models from static list")
logging.info(f"AnthropicProviderHandler: Source: Static fallback configuration")
logging.info("=" * 80)
return static_models
except Exception as e:
import logging
logging.error("=" * 80)
logging.error(f"AnthropicProviderHandler: ✗ FATAL ERROR getting models: {str(e)}")
logging.error("=" * 80)
logging.error(f"AnthropicProviderHandler: Error details:", exc_info=True)
raise e
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).
"""
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)
# Get credentials file path from config
claude_config = getattr(self.provider_config, 'claude_config', None)
credentials_file = None
if claude_config and isinstance(claude_config, dict):
credentials_file = claude_config.get('credentials_file')
# Initialize ClaudeAuth with credentials file
from .claude_auth import ClaudeAuth
self.auth = ClaudeAuth(credentials_file=credentials_file)
# HTTP client for making requests
self.client = httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=30.0))
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]:
if self.is_rate_limited():
raise Exception("Provider rate limited")
try:
import logging
logging.info(f"ClaudeProviderHandler: Handling request for model {model}")
if AISBF_DEBUG:
logging.info(f"ClaudeProviderHandler: Messages: {messages}")
else:
logging.info(f"ClaudeProviderHandler: Messages count: {len(messages)}")
# 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']
})
# Build request payload
request_payload = {
'model': model,
'messages': anthropic_messages,
'max_tokens': max_tokens or 4096,
'temperature': temperature,
'stream': stream
}
if system_message:
request_payload['system'] = system_message
if tools:
request_payload['tools'] = 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'
}
if AISBF_DEBUG:
logging.info(f"ClaudeProviderHandler: Request payload: {request_payload}")
# Make request to Claude API
if stream:
logging.info(f"ClaudeProviderHandler: Using streaming mode")
return await self._handle_streaming_request(headers, request_payload, model)
# Non-streaming request
response = await self.client.post(
'https://api.anthropic.com/v1/messages',
headers=headers,
json=request_payload
)
# Check for 429 rate limit error before raising
if response.status_code == 429:
try:
response_data = response.json()
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()
response.raise_for_status()
response_data = response.json()
if AISBF_DEBUG:
logging.info(f"ClaudeProviderHandler: Raw response: {response_data}")
logging.info(f"ClaudeProviderHandler: Response received successfully")
self.record_success()
# Convert to OpenAI format
openai_response = self._convert_to_openai_format(response_data, model)
return openai_response
except Exception as e:
import logging
logging.error(f"ClaudeProviderHandler: Error: {str(e)}", exc_info=True)
self.record_failure()
raise e
async def _handle_streaming_request(self, headers: Dict, payload: Dict, model: str):
"""Handle streaming request to Claude API."""
import logging
import json
logger = logging.getLogger(__name__)
logger.info(f"ClaudeProviderHandler: Starting streaming request")
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",
headers=headers,
json=payload
) as response:
logger.info(f"ClaudeProviderHandler: Streaming response status: {response.status_code}")
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}")
# 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 = ""
accumulated_tool_calls = []
# Process the streaming response (SSE format)
async for line in response.aiter_lines():
if not line or not line.startswith('data: '):
continue
# Remove 'data: ' prefix
data_str = line[6:]
if data_str == '[DONE]':
break
try:
chunk_data = json.loads(data_str)
# Handle different event types
event_type = chunk_data.get('type')
if event_type == 'content_block_delta':
delta = chunk_data.get('delta', {})
if delta.get('type') == 'text_delta':
text = delta.get('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"
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse streaming chunk: {e}")
continue
def _convert_to_openai_format(self, claude_response: Dict, model: str) -> Dict:
"""Convert Claude API response to OpenAI format."""
import logging
# Extract content
content_text = ""
tool_calls = []
if 'content' in claude_response:
for block in claude_response['content']:
if block.get('type') == 'text':
content_text += block.get('text', '')
elif block.get('type') == 'tool_use':
tool_calls.append({
'id': block.get('id', f"call_{len(tool_calls)}"),
'type': 'function',
'function': {
'name': block.get('name', ''),
'arguments': json.dumps(block.get('input', {}))
}
})
# Map stop reason
stop_reason_map = {
'end_turn': 'stop',
'max_tokens': 'length',
'stop_sequence': 'stop',
'tool_use': 'tool_calls'
}
stop_reason = claude_response.get('stop_reason', 'end_turn')
finish_reason = stop_reason_map.get(stop_reason, 'stop')
# Build OpenAI-style response
openai_response = {
'id': f"claude-{model}-{int(time.time())}",
'object': 'chat.completion',
'created': int(time.time()),
'model': f'{self.provider_id}/{model}',
'choices': [{
'index': 0,
'message': {
'role': 'assistant',
'content': content_text if not tool_calls else None
},
'finish_reason': finish_reason
}],
'usage': {
'prompt_tokens': claude_response.get('usage', {}).get('input_tokens', 0),
'completion_tokens': claude_response.get('usage', {}).get('output_tokens', 0),
'total_tokens': (
claude_response.get('usage', {}).get('input_tokens', 0) +
claude_response.get('usage', {}).get('output_tokens', 0)
)
}
}
# Add tool_calls if present
if tool_calls:
openai_response['choices'][0]['message']['tool_calls'] = tool_calls
return openai_response
def _get_models_cache_path(self) -> str:
"""Get the path to the models cache file."""
import os
cache_dir = os.path.expanduser("~/.aisbf")
os.makedirs(cache_dir, exist_ok=True)
return os.path.join(cache_dir, f"claude_models_cache_{self.provider_id}.json")
def _save_models_cache(self, models: List[Model]) -> None:
"""Save models to cache file."""
import logging
import json
try:
cache_path = self._get_models_cache_path()
cache_data = {
'timestamp': time.time(),
'models': []
}
for m in models:
model_dict = {'id': m.id, 'name': m.name}
# Save optional fields
if m.context_size:
model_dict['context_size'] = m.context_size
if m.context_length:
model_dict['context_length'] = m.context_length
if m.description:
model_dict['description'] = m.description
if m.pricing:
model_dict['pricing'] = m.pricing
if m.top_provider:
model_dict['top_provider'] = m.top_provider
if m.supported_parameters:
model_dict['supported_parameters'] = m.supported_parameters
cache_data['models'].append(model_dict)
with open(cache_path, 'w') as f:
json.dump(cache_data, f, indent=2)
logging.info(f"ClaudeProviderHandler: ✓ Saved {len(models)} models to cache: {cache_path}")
except Exception as e:
logging.warning(f"ClaudeProviderHandler: Failed to save models cache: {e}")
def _load_models_cache(self) -> Optional[List[Model]]:
"""Load models from cache file if available and not too old."""
import logging
import json
import os
try:
cache_path = self._get_models_cache_path()
if not os.path.exists(cache_path):
logging.info(f"ClaudeProviderHandler: No cache file found at {cache_path}")
return None
with open(cache_path, 'r') as f:
cache_data = json.load(f)
cache_age = time.time() - cache_data.get('timestamp', 0)
cache_age_hours = cache_age / 3600
logging.info(f"ClaudeProviderHandler: Found cache file (age: {cache_age_hours:.1f} hours)")
# Cache is valid for 24 hours
if cache_age > 86400:
logging.info(f"ClaudeProviderHandler: Cache is too old (>{cache_age_hours:.1f} hours), ignoring")
return None
models = []
for m in cache_data.get('models', []):
models.append(Model(
id=m['id'],
name=m['name'],
provider_id=self.provider_id,
context_size=m.get('context_size'),
context_length=m.get('context_length'),
description=m.get('description'),
pricing=m.get('pricing'),
top_provider=m.get('top_provider'),
supported_parameters=m.get('supported_parameters')
))
if models:
logging.info(f"ClaudeProviderHandler: ✓ Loaded {len(models)} models from cache")
return models
else:
logging.info(f"ClaudeProviderHandler: Cache file is empty")
return None
except Exception as e:
logging.warning(f"ClaudeProviderHandler: Failed to load models cache: {e}")
return None
async def get_models(self) -> List[Model]:
"""Return list of available Claude models by querying the API."""
try:
import logging
import json
logging.info("=" * 80)
logging.info("ClaudeProviderHandler: Starting model list retrieval")
logging.info("=" * 80)
# Apply rate limiting
await self.apply_rate_limit()
# Try to fetch models from the primary API
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'
}
# 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")
# Query the models endpoint
response = await self.client.get(api_endpoint, headers=headers)
logging.info(f"ClaudeProviderHandler: API response status: {response.status_code}")
if response.status_code == 200:
models_data = response.json()
logging.info(f"ClaudeProviderHandler: ✓ Primary API call successful!")
logging.info(f"ClaudeProviderHandler: Response data keys: {list(models_data.keys())}")
logging.info(f"ClaudeProviderHandler: Retrieved {len(models_data.get('data', []))} models from API")
if AISBF_DEBUG:
logging.info(f"ClaudeProviderHandler: Full API response: {models_data}")
# Convert API response to Model objects
models = []
for model_data in models_data.get('data', []):
model_id = model_data.get('id', '')
display_name = model_data.get('display_name') or model_data.get('name') or model_id
# Extract context size from API response
# For Anthropic/Claude models, max_input_tokens is the correct field
context_size = (
model_data.get('max_input_tokens') or
model_data.get('context_window') or
model_data.get('context_length') or
model_data.get('max_tokens')
)
# Extract description if available
description = model_data.get('description')
models.append(Model(
id=model_id,
name=display_name,
provider_id=self.provider_id,
context_size=context_size,
context_length=context_size,
description=description
))
logging.info(f"ClaudeProviderHandler: - {model_id} ({display_name}, context: {context_size})")
if models:
# Save to cache
self._save_models_cache(models)
logging.info("=" * 80)
logging.info(f"ClaudeProviderHandler: ✓ SUCCESS - Returning {len(models)} models from primary API")
logging.info(f"ClaudeProviderHandler: Source: Dynamic API retrieval (Anthropic)")
logging.info("=" * 80)
return models
else:
logging.warning("ClaudeProviderHandler: ✗ Primary API returned empty model list")
else:
logging.warning(f"ClaudeProviderHandler: ✗ Primary API call failed with status {response.status_code}")
try:
error_body = response.json()
logging.warning(f"ClaudeProviderHandler: Error response: {error_body}")
except:
logging.warning(f"ClaudeProviderHandler: Error response (text): {response.text[:200]}")
except Exception as api_error:
logging.warning(f"ClaudeProviderHandler: ✗ Exception during primary API call")
logging.warning(f"ClaudeProviderHandler: Error type: {type(api_error).__name__}")
logging.warning(f"ClaudeProviderHandler: Error message: {str(api_error)}")
if AISBF_DEBUG:
logging.warning(f"ClaudeProviderHandler: Full traceback:", exc_info=True)
# Try fallback endpoint
try:
logging.info("-" * 80)
logging.info("ClaudeProviderHandler: [2/3] Attempting fallback endpoint...")
fallback_endpoint = 'http://lisa.nexlab.net:5000/claude/models'
logging.info(f"ClaudeProviderHandler: Calling fallback endpoint: {fallback_endpoint}")
# Create a new client with shorter timeout for fallback
fallback_client = httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0))
try:
fallback_response = await fallback_client.get(fallback_endpoint)
logging.info(f"ClaudeProviderHandler: Fallback response status: {fallback_response.status_code}")
if fallback_response.status_code == 200:
fallback_data = fallback_response.json()
logging.info(f"ClaudeProviderHandler: ✓ Fallback API call successful!")
if AISBF_DEBUG:
logging.info(f"ClaudeProviderHandler: Fallback response: {fallback_data}")
# Parse fallback response - expect array of models or {data: [...]}
models_list = fallback_data if isinstance(fallback_data, list) else fallback_data.get('data', fallback_data.get('models', []))
models = []
for model_data in models_list:
if isinstance(model_data, str):
# Simple string model ID
models.append(Model(id=model_data, name=model_data, provider_id=self.provider_id))
elif isinstance(model_data, dict):
# Dict with id/name
model_id = model_data.get('id', model_data.get('model', ''))
display_name = model_data.get('name', model_data.get('display_name', model_id))
# Extract context size - include max_input_tokens for Claude providers
context_size = (
model_data.get('max_input_tokens') or
model_data.get('context_window') or
model_data.get('context_length') or
model_data.get('context_size') or
model_data.get('max_tokens')
)
# Extract description if available
description = model_data.get('description')
models.append(Model(
id=model_id,
name=display_name,
provider_id=self.provider_id,
context_size=context_size,
context_length=context_size,
description=description
))
if models:
for model in models:
logging.info(f"ClaudeProviderHandler: - {model.id} ({model.name})")
# Save to cache
self._save_models_cache(models)
logging.info("=" * 80)
logging.info(f"ClaudeProviderHandler: ✓ SUCCESS - Returning {len(models)} models from fallback API")
logging.info(f"ClaudeProviderHandler: Source: Dynamic API retrieval (Fallback)")
logging.info("=" * 80)
return models
else:
logging.warning("ClaudeProviderHandler: ✗ Fallback API returned empty model list")
else:
logging.warning(f"ClaudeProviderHandler: ✗ Fallback API call failed with status {fallback_response.status_code}")
try:
error_body = fallback_response.json()
logging.warning(f"ClaudeProviderHandler: Fallback error response: {error_body}")
except:
logging.warning(f"ClaudeProviderHandler: Fallback error response (text): {fallback_response.text[:200]}")
finally:
await fallback_client.aclose()
except Exception as fallback_error:
logging.warning(f"ClaudeProviderHandler: ✗ Exception during fallback API call")
logging.warning(f"ClaudeProviderHandler: Error type: {type(fallback_error).__name__}")
logging.warning(f"ClaudeProviderHandler: Error message: {str(fallback_error)}")
if AISBF_DEBUG:
logging.warning(f"ClaudeProviderHandler: Full traceback:", exc_info=True)
# Try to load from cache
logging.info("-" * 80)
logging.info("ClaudeProviderHandler: [3/3] Attempting to load from cache...")
cached_models = self._load_models_cache()
if cached_models:
for model in cached_models:
logging.info(f"ClaudeProviderHandler: - {model.id} ({model.name})")
logging.info("=" * 80)
logging.info(f"ClaudeProviderHandler: ✓ Returning {len(cached_models)} models from cache")
logging.info(f"ClaudeProviderHandler: Source: Cached model list")
logging.info("=" * 80)
return cached_models
# Final fallback to static list
logging.info("-" * 80)
logging.info("ClaudeProviderHandler: Using static fallback model list")
static_models = [
Model(id="claude-3-7-sonnet-20250219", name="Claude 3.7 Sonnet", provider_id=self.provider_id, context_size=200000, context_length=200000),
Model(id="claude-3-5-sonnet-20241022", name="Claude 3.5 Sonnet", provider_id=self.provider_id, context_size=200000, context_length=200000),
Model(id="claude-3-5-haiku-20241022", name="Claude 3.5 Haiku", provider_id=self.provider_id, context_size=200000, context_length=200000),
Model(id="claude-3-opus-20240229", name="Claude 3 Opus", provider_id=self.provider_id, context_size=200000, context_length=200000),
]
for model in static_models:
logging.info(f"ClaudeProviderHandler: - {model.id} ({model.name})")
logging.info("=" * 80)
logging.info(f"ClaudeProviderHandler: ✓ Returning {len(static_models)} models from static list")
logging.info(f"ClaudeProviderHandler: Source: Static fallback configuration")
logging.info("=" * 80)
return static_models
except Exception as e:
import logging
logging.error("=" * 80)
logging.error(f"ClaudeProviderHandler: ✗ FATAL ERROR getting models: {str(e)}")
logging.error("=" * 80)
logging.error(f"ClaudeProviderHandler: Error details:", exc_info=True)
raise e
class KiroProviderHandler(BaseProviderHandler):
"""
......@@ -2149,26 +3232,424 @@ class KiroProviderHandler(BaseProviderHandler):
yield f"data: {json.dumps(final_chunk, ensure_ascii=False)}\n\n".encode('utf-8')
yield b"data: [DONE]\n\n"
def _get_models_cache_path(self) -> str:
"""Get the path to the models cache file."""
import os
cache_dir = os.path.expanduser("~/.aisbf")
os.makedirs(cache_dir, exist_ok=True)
return os.path.join(cache_dir, f"kiro_models_cache_{self.provider_id}.json")
def _save_models_cache(self, models: List[Model]) -> None:
"""Save models to cache file."""
import logging
import json
try:
cache_path = self._get_models_cache_path()
cache_data = {
'timestamp': time.time(),
'models': []
}
for m in models:
model_dict = {'id': m.id, 'name': m.name}
# Save optional fields
if m.context_size:
model_dict['context_size'] = m.context_size
if m.context_length:
model_dict['context_length'] = m.context_length
if m.description:
model_dict['description'] = m.description
if m.pricing:
model_dict['pricing'] = m.pricing
if m.top_provider:
model_dict['top_provider'] = m.top_provider
if m.supported_parameters:
model_dict['supported_parameters'] = m.supported_parameters
cache_data['models'].append(model_dict)
with open(cache_path, 'w') as f:
json.dump(cache_data, f, indent=2)
logging.info(f"KiroProviderHandler: ✓ Saved {len(models)} models to cache: {cache_path}")
except Exception as e:
logging.warning(f"KiroProviderHandler: Failed to save models cache: {e}")
def _load_models_cache(self) -> Optional[List[Model]]:
"""Load models from cache file if available and not too old."""
import logging
import json
import os
try:
cache_path = self._get_models_cache_path()
if not os.path.exists(cache_path):
logging.info(f"KiroProviderHandler: No cache file found at {cache_path}")
return None
with open(cache_path, 'r') as f:
cache_data = json.load(f)
cache_age = time.time() - cache_data.get('timestamp', 0)
cache_age_hours = cache_age / 3600
logging.info(f"KiroProviderHandler: Found cache file (age: {cache_age_hours:.1f} hours)")
# Cache is valid for 24 hours
if cache_age > 86400:
logging.info(f"KiroProviderHandler: Cache is too old (>{cache_age_hours:.1f} hours), ignoring")
return None
models = []
for m in cache_data.get('models', []):
models.append(Model(
id=m['id'],
name=m['name'],
provider_id=self.provider_id,
context_size=m.get('context_size'),
context_length=m.get('context_length'),
description=m.get('description'),
pricing=m.get('pricing'),
top_provider=m.get('top_provider'),
supported_parameters=m.get('supported_parameters')
))
if models:
logging.info(f"KiroProviderHandler: ✓ Loaded {len(models)} models from cache")
return models
else:
logging.info(f"KiroProviderHandler: Cache file is empty")
return None
except Exception as e:
logging.warning(f"KiroProviderHandler: Failed to load models cache: {e}")
return None
async def get_models(self) -> List[Model]:
"""
Return list of available models using fallback strategy.
Priority order:
1. Nexlab endpoint (http://lisa.nexlab.net:5000/kiro/models)
2. Cache (if available and not too old)
3. AWS Q API (ListAvailableModels)
4. Static fallback list
"""
try:
import logging
logging.info("KiroProviderHandler: Getting models list")
import json
logging.info("=" * 80)
logging.info("KiroProviderHandler: Starting model list retrieval")
logging.info("=" * 80)
# Apply rate limiting
await self.apply_rate_limit()
# Return static list of Claude models available through Kiro
return [
Model(id="anthropic.claude-3-5-sonnet-20241022-v2:0", name="Claude 3.5 Sonnet v2", provider_id=self.provider_id),
Model(id="anthropic.claude-3-5-haiku-20241022-v1:0", name="Claude 3.5 Haiku", provider_id=self.provider_id),
Model(id="anthropic.claude-3-5-sonnet-20240620-v1:0", name="Claude 3.5 Sonnet v1", provider_id=self.provider_id),
Model(id="anthropic.claude-sonnet-3-5-v2", name="Claude 3.5 Sonnet v2 (alias)", provider_id=self.provider_id),
Model(id="claude-sonnet-4-5", name="Claude 3.5 Sonnet v2 (short)", provider_id=self.provider_id),
Model(id="claude-haiku-4-5", name="Claude 3.5 Haiku (short)", provider_id=self.provider_id),
# Try nexlab endpoint first
try:
logging.info("KiroProviderHandler: [1/4] Attempting nexlab endpoint...")
nexlab_endpoint = 'http://lisa.nexlab.net:5000/kiro/models'
logging.info(f"KiroProviderHandler: Calling nexlab endpoint: {nexlab_endpoint}")
# Create a new client with shorter timeout for nexlab
nexlab_client = httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0))
try:
nexlab_response = await nexlab_client.get(nexlab_endpoint)
logging.info(f"KiroProviderHandler: Nexlab response status: {nexlab_response.status_code}")
if nexlab_response.status_code == 200:
nexlab_data = nexlab_response.json()
logging.info(f"KiroProviderHandler: ✓ Nexlab API call successful!")
if AISBF_DEBUG:
logging.info(f"KiroProviderHandler: Nexlab response: {nexlab_data}")
# Parse nexlab response - expect array of models or {data: [...]}
models_list = nexlab_data if isinstance(nexlab_data, list) else nexlab_data.get('data', nexlab_data.get('models', []))
models = []
for model_data in models_list:
if isinstance(model_data, str):
# Simple string model ID
models.append(Model(id=model_data, name=model_data, provider_id=self.provider_id))
elif isinstance(model_data, dict):
# Dict with id/name - check multiple field name variations
model_id = model_data.get('model_id') or model_data.get('id') or model_data.get('model', '')
display_name = model_data.get('model_name') or model_data.get('name') or model_data.get('display_name') or model_id
# Extract context size/length - check all possible sources
# Priority: direct field > top_provider > nested
top_provider = model_data.get('top_provider', {})
context_size = (
model_data.get('context_window_tokens') or
model_data.get('context_window') or
model_data.get('context_length') or
model_data.get('context_size') or
model_data.get('max_tokens') or
(top_provider.get('context_length') if isinstance(top_provider, dict) else None)
)
# Extract all available metadata
pricing = model_data.get('pricing')
description = model_data.get('description')
supported_parameters = model_data.get('supported_parameters')
architecture = model_data.get('architecture')
# Extract top_provider info (contains context_length, max_completion_tokens, is_moderated)
if isinstance(top_provider, dict):
top_provider_data = {
'context_length': top_provider.get('context_length'),
'max_completion_tokens': top_provider.get('max_completion_tokens'),
'is_moderated': top_provider.get('is_moderated')
}
else:
top_provider_data = None
if model_id:
models.append(Model(
id=model_id,
name=display_name,
provider_id=self.provider_id,
context_size=context_size,
context_length=context_size,
description=description,
pricing=pricing,
top_provider=top_provider_data,
supported_parameters=supported_parameters
))
if models:
for model in models:
logging.info(f"KiroProviderHandler: - {model.id} ({model.name})")
# Save to cache
self._save_models_cache(models)
logging.info("=" * 80)
logging.info(f"KiroProviderHandler: ✓ SUCCESS - Returning {len(models)} models from nexlab endpoint")
logging.info(f"KiroProviderHandler: Source: Dynamic API retrieval (Nexlab)")
logging.info("=" * 80)
return models
else:
logging.warning("KiroProviderHandler: ✗ Nexlab endpoint returned empty model list")
else:
logging.warning(f"KiroProviderHandler: ✗ Nexlab API call failed with status {nexlab_response.status_code}")
try:
error_body = nexlab_response.json()
logging.warning(f"KiroProviderHandler: Nexlab error response: {error_body}")
except:
logging.warning(f"KiroProviderHandler: Nexlab error response (text): {nexlab_response.text[:200]}")
finally:
await nexlab_client.aclose()
except Exception as nexlab_error:
logging.warning(f"KiroProviderHandler: ✗ Exception during nexlab API call")
logging.warning(f"KiroProviderHandler: Error type: {type(nexlab_error).__name__}")
logging.warning(f"KiroProviderHandler: Error message: {str(nexlab_error)}")
if AISBF_DEBUG:
logging.warning(f"KiroProviderHandler: Full traceback:", exc_info=True)
# Try to load from cache
logging.info("-" * 80)
logging.info("KiroProviderHandler: [2/4] Attempting to load from cache...")
cached_models = self._load_models_cache()
if cached_models:
for model in cached_models:
logging.info(f"KiroProviderHandler: - {model.id} ({model.name})")
logging.info("=" * 80)
logging.info(f"KiroProviderHandler: ✓ Returning {len(cached_models)} models from cache")
logging.info(f"KiroProviderHandler: Source: Cached model list")
logging.info("=" * 80)
return cached_models
# Try to fetch models from AWS Q API using OAuth2 bearer token with pagination
try:
logging.info("-" * 80)
logging.info("KiroProviderHandler: [3/4] Attempting to fetch from AWS Q API...")
if not self.auth_manager:
raise Exception("Auth manager not initialized")
# Get access token
access_token = await self.auth_manager.get_access_token()
profile_arn = self.auth_manager.profile_arn
# For ListAvailableModels, always include profileArn if available (like kiro-cli)
effective_profile_arn = profile_arn or ""
if effective_profile_arn:
logging.info(f"KiroProviderHandler: Using profileArn for models API")
else:
logging.info(f"KiroProviderHandler: No profileArn available for models API")
# Prepare headers for AWS JSON 1.0 protocol
headers = self.auth_manager.get_auth_headers(access_token)
headers['Content-Type'] = 'application/x-amz-json-1.0'
headers['x-amz-target'] = 'AmazonCodeWhispererService.ListAvailableModels'
# Build URL (AWS JSON protocol style)
base_url = f"https://q.{self.region}.amazonaws.com/"
# Handle pagination - keep fetching until no nextToken
all_models = []
next_token = None
page_num = 0
while True:
page_num += 1
logging.info(f"KiroProviderHandler: Fetching page {page_num}...")
# Build JSON body with fields (not query params!)
# Based on SDK serialization: origin, profileArn, nextToken go in the body
# Origin::Cli.as_str() returns "CLI" (all uppercase) - see _origin.rs line 162
request_body = {
"origin": "CLI"
}
if effective_profile_arn:
request_body["profileArn"] = effective_profile_arn
if next_token:
request_body["nextToken"] = next_token
logging.info(f"KiroProviderHandler: Calling {base_url} with AWS JSON 1.0 protocol")
if AISBF_DEBUG:
logging.info(f"KiroProviderHandler: Request body: {json.dumps(request_body, indent=2)}")
# AWS JSON protocol: POST with JSON body containing the fields
response = await self.client.post(
base_url,
json=request_body,
headers=headers
)
logging.info(f"KiroProviderHandler: API response status: {response.status_code}")
if response.status_code != 200:
logging.warning(f"KiroProviderHandler: ✗ API call failed with status {response.status_code}")
try:
error_body = response.json()
logging.warning(f"KiroProviderHandler: Error response: {error_body}")
except:
logging.warning(f"KiroProviderHandler: Error response (text): {response.text[:200]}")
break
response_data = response.json()
if AISBF_DEBUG:
logging.info(f"KiroProviderHandler: Response data: {json.dumps(response_data, indent=2)}")
# Parse response - expecting structure similar to AWS SDK response
models_list = response_data.get('models', [])
for model_data in models_list:
# Extract model ID and name
model_id = model_data.get('modelId', model_data.get('id', ''))
model_name = model_data.get('modelName', model_data.get('name', model_id))
# Extract context size/length
context_size = (
model_data.get('contextWindow') or
model_data.get('context_window') or
model_data.get('contextLength') or
model_data.get('context_length') or
model_data.get('max_context_length') or
model_data.get('maxTokens') or
model_data.get('max_tokens')
)
# Extract all available metadata
pricing = model_data.get('pricing')
description = model_data.get('description')
supported_parameters = model_data.get('supported_parameters')
# Extract top_provider info if present
top_provider = model_data.get('topProvider') or model_data.get('top_provider')
if isinstance(top_provider, dict):
top_provider_data = {
'context_length': top_provider.get('context_length') or top_provider.get('contextLength'),
'max_completion_tokens': top_provider.get('max_completion_tokens') or top_provider.get('maxCompletionTokens'),
'is_moderated': top_provider.get('is_moderated') or top_provider.get('isModerated')
}
else:
top_provider_data = None
if model_id:
all_models.append(Model(
id=model_id,
name=model_name,
provider_id=self.provider_id,
context_size=context_size,
context_length=context_size,
description=description,
pricing=pricing,
top_provider=top_provider_data,
supported_parameters=supported_parameters
))
logging.info(f"KiroProviderHandler: - {model_id} ({model_name})")
# Check for pagination token
next_token = response_data.get('nextToken')
if not next_token:
logging.info(f"KiroProviderHandler: No more pages (total pages: {page_num})")
break
logging.info(f"KiroProviderHandler: Found nextToken, fetching next page...")
if all_models:
logging.info(f"KiroProviderHandler: ✓ API call successful!")
logging.info(f"KiroProviderHandler: Retrieved {len(all_models)} models across {page_num} page(s)")
# Save to cache
self._save_models_cache(all_models)
logging.info("=" * 80)
logging.info(f"KiroProviderHandler: ✓ SUCCESS - Returning {len(all_models)} models from API")
logging.info(f"KiroProviderHandler: Source: Dynamic API retrieval (AWS Q)")
logging.info("=" * 80)
return all_models
else:
logging.warning("KiroProviderHandler: ✗ API returned empty model list")
except Exception as api_error:
logging.warning(f"KiroProviderHandler: ✗ Exception during AWS Q API call")
logging.warning(f"KiroProviderHandler: Error type: {type(api_error).__name__}")
logging.warning(f"KiroProviderHandler: Error message: {str(api_error)}")
if AISBF_DEBUG:
logging.warning(f"KiroProviderHandler: Full traceback:", exc_info=True)
# Final fallback to static list
logging.info("-" * 80)
logging.info("KiroProviderHandler: [4/4] Using static fallback model list")
static_models = [
Model(id="anthropic.claude-3-5-sonnet-20241022-v2:0", name="Claude 3.5 Sonnet v2", provider_id=self.provider_id, context_size=200000, context_length=200000),
Model(id="anthropic.claude-3-5-haiku-20241022-v1:0", name="Claude 3.5 Haiku", provider_id=self.provider_id, context_size=200000, context_length=200000),
Model(id="anthropic.claude-3-5-sonnet-20240620-v1:0", name="Claude 3.5 Sonnet v1", provider_id=self.provider_id, context_size=200000, context_length=200000),
Model(id="anthropic.claude-sonnet-3-5-v2", name="Claude 3.5 Sonnet v2 (alias)", provider_id=self.provider_id, context_size=200000, context_length=200000),
Model(id="claude-sonnet-4-5", name="Claude 3.5 Sonnet v2 (short)", provider_id=self.provider_id, context_size=200000, context_length=200000),
Model(id="claude-haiku-4-5", name="Claude 3.5 Haiku (short)", provider_id=self.provider_id, context_size=200000, context_length=200000),
]
for model in static_models:
logging.info(f"KiroProviderHandler: - {model.id} ({model.name})")
logging.info("=" * 80)
logging.info(f"KiroProviderHandler: ✓ Returning {len(static_models)} models from static list")
logging.info(f"KiroProviderHandler: Source: Static fallback configuration")
logging.info("=" * 80)
return static_models
except Exception as e:
import logging
logging.error(f"KiroProviderHandler: Error getting models: {str(e)}", exc_info=True)
logging.error("=" * 80)
logging.error(f"KiroProviderHandler: ✗ FATAL ERROR getting models: {str(e)}")
logging.error("=" * 80)
logging.error(f"KiroProviderHandler: Error details:", exc_info=True)
raise e
class OllamaProviderHandler(BaseProviderHandler):
......@@ -2362,7 +3843,8 @@ PROVIDER_HANDLERS = {
'openai': OpenAIProviderHandler,
'anthropic': AnthropicProviderHandler,
'ollama': OllamaProviderHandler,
'kiro': KiroProviderHandler
'kiro': KiroProviderHandler,
'claude': ClaudeProviderHandler
}
def get_provider_handler(provider_id: str, api_key: Optional[str] = None) -> BaseProviderHandler:
......
......@@ -170,8 +170,13 @@ def get_max_request_tokens_for_model(
# Then check provider models config
if hasattr(provider_config, 'models') and provider_config.models:
for model in provider_config.models:
if model.get('name') == model_name:
max_tokens = model.get('max_request_tokens')
# Handle both Pydantic objects and dictionaries
model_name_value = model.name if hasattr(model, 'name') else model.get('name')
if model_name_value == model_name:
max_tokens = model.max_request_tokens if hasattr(model, 'max_request_tokens') else model.get('max_request_tokens')
if max_tokens:
logger.info(f"Found max_request_tokens in provider model config: {max_tokens}")
return max_tokens
if max_tokens:
logger.info(f"Found max_request_tokens in provider model config: {max_tokens}")
return max_tokens
......
......@@ -59,6 +59,18 @@ if ! python -m build --version &> /dev/null; then
pip_install build twine
fi
# Build the extension first
echo ""
echo "Building OAuth2 extension..."
if [ -f "static/extension/build.sh" ]; then
cd static/extension
bash build.sh
cd ../..
echo "Extension built successfully"
else
echo "Warning: Extension build script not found, skipping extension build"
fi
# Clean previous builds
echo ""
echo "Cleaning previous build artifacts..."
......
......@@ -16,7 +16,7 @@
"rate_limit": 0,
"enable_native_caching": false,
"cache_ttl": 3600,
"min_cacheable_tokens": 1000,
"min_cacheable_tokens": 1024,
"models": [
{
"name": "gemini-2.0-flash",
......@@ -71,7 +71,7 @@
"rate_limit": 0,
"enable_native_caching": false,
"cache_ttl": null,
"min_cacheable_tokens": 1000
"min_cacheable_tokens": 1024
},
"ollama": {
"id": "ollama",
......@@ -277,6 +277,49 @@
"sqlite_db": "~/.local/share/kiro-cli/data.sqlite3",
"region": "us-east-1"
}
},
"claude": {
"id": "claude",
"name": "Claude Code (OAuth2)",
"endpoint": "https://api.anthropic.com/v1",
"type": "claude",
"api_key_required": false,
"nsfw": false,
"privacy": false,
"rate_limit": 0,
"claude_config": {
"_comment": "Uses OAuth2 authentication flow (claude-cli compatible)",
"credentials_file": "~/.claude_credentials.json"
},
"models": [
{
"name": "claude-3-7-sonnet-20250219",
"nsfw": false,
"privacy": false,
"rate_limit": 0,
"max_request_tokens": 200000,
"context_size": 200000,
"capabilities": ["t2t", "vision", "function_calling"]
},
{
"name": "claude-3-5-sonnet-20241022",
"nsfw": false,
"privacy": false,
"rate_limit": 0,
"max_request_tokens": 200000,
"context_size": 200000,
"capabilities": ["t2t", "vision", "function_calling"]
},
{
"name": "claude-3-5-haiku-20241022",
"nsfw": false,
"privacy": false,
"rate_limit": 0,
"max_request_tokens": 200000,
"context_size": 200000,
"capabilities": ["t2t", "vision", "function_calling"]
}
]
}
}
}
......@@ -23,7 +23,7 @@ Why did the programmer quit his job? Because he didn't get arrays!
Main application for AISBF.
"""
from typing import Optional
from fastapi import FastAPI, HTTPException, Request, status, Form, Query
from fastapi import FastAPI, HTTPException, Request, status, Form, Query, UploadFile, File
from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse, RedirectResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.exceptions import RequestValidationError
......@@ -917,6 +917,24 @@ async def startup_event():
else:
logger.warning("TOR hidden service initialization failed")
# Pre-fetch models at startup for providers without local model config
logger.info("Pre-fetching models from providers with dynamic model lists...")
prefetch_count = 0
for provider_id, provider_config in config.providers.items():
if not (hasattr(provider_config, 'models') and provider_config.models):
try:
models = await fetch_provider_models(provider_id)
if models:
prefetch_count += 1
logger.info(f"Pre-fetched {len(models)} models from provider: {provider_id}")
except Exception as e:
logger.warning(f"Failed to pre-fetch models from provider {provider_id}: {e}")
if prefetch_count > 0:
logger.info(f"Pre-fetched models from {prefetch_count} provider(s) at startup")
else:
logger.info("No providers with dynamic model lists found for pre-fetching")
# Start background task for model cache refresh
if _cache_refresh_task is None:
_cache_refresh_task = asyncio.create_task(refresh_model_cache())
......@@ -985,8 +1003,11 @@ async def shutdown_event():
async def auth_middleware(request: Request, call_next):
"""Check API token authentication if enabled"""
if server_config and server_config.get('auth_enabled', False):
# Skip auth for root endpoint and dashboard routes
if request.url.path == "/" or request.url.path.startswith("/dashboard"):
# Skip auth for root endpoint, dashboard routes, favicon, and browser metadata
if (request.url.path == "/" or
request.url.path.startswith("/dashboard") or
request.url.path == "/favicon.ico" or
request.url.path.startswith("/.well-known/")):
response = await call_next(request)
return response
......@@ -1436,6 +1457,83 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"error": f"Invalid JSON: {str(e)}"
})
@app.post("/dashboard/providers/get-models")
async def dashboard_providers_get_models(request: Request):
"""Fetch models from provider API"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
# Parse request body
body = await request.json()
provider_key = body.get('provider_key')
if not provider_key:
return JSONResponse({
"success": False,
"error": "provider_key is required"
}, status_code=400)
# Check if provider exists in config
if not config or provider_key not in config.providers:
return JSONResponse({
"success": False,
"error": f"Provider '{provider_key}' not found in configuration"
}, status_code=404)
# Get provider handler
from aisbf.providers import get_provider_handler
provider_config = config.providers[provider_key]
api_key = provider_config.api_key if hasattr(provider_config, 'api_key') else None
handler = get_provider_handler(provider_key, api_key)
# Fetch models from provider
models = await handler.get_models()
# Convert Model objects to dicts with all available fields
models_data = []
for model in models:
model_dict = {
"id": model.id,
"name": model.name,
"provider_id": model.provider_id
}
# Add all optional fields if present
optional_fields = [
'weight', 'rate_limit', 'max_request_tokens',
'rate_limit_TPM', 'rate_limit_TPH', 'rate_limit_TPD',
'context_size', 'context_length', 'condense_context', 'condense_method',
'error_cooldown', 'description', 'architecture', 'pricing',
'top_provider', 'supported_parameters', 'default_parameters'
]
for field in optional_fields:
if hasattr(model, field):
value = getattr(model, field)
if value is not None:
model_dict[field] = value
models_data.append(model_dict)
return JSONResponse({
"success": True,
"models": models_data
})
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error fetching models for provider: {str(e)}", exc_info=True)
return JSONResponse({
"success": False,
"error": str(e)
}, status_code=500)
@app.get("/dashboard/rotations", response_class=HTMLResponse)
async def dashboard_rotations(request: Request):
"""Edit rotations configuration"""
......@@ -2138,6 +2236,312 @@ async def dashboard_user_providers_delete(request: Request, provider_name: str):
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
# User authentication file management routes
def get_user_auth_files_dir(user_id: int) -> Path:
"""Get the directory for user authentication files"""
auth_files_dir = Path.home() / '.aisbf' / 'user_auth_files' / str(user_id)
auth_files_dir.mkdir(parents=True, exist_ok=True)
return auth_files_dir
@app.post("/dashboard/user/providers/{provider_name}/upload")
async def dashboard_user_provider_upload(
request: Request,
provider_name: str,
file_type: str = Form(...),
file: UploadFile = File(...)
):
"""Upload authentication file for a provider"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
if not user_id:
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
from aisbf.database import get_database
db = get_database()
try:
# Validate file type
allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials']
if file_type not in allowed_types:
return JSONResponse(
status_code=400,
content={"error": f"Invalid file type. Allowed: {', '.join(allowed_types)}"}
)
# Get user auth files directory
auth_files_dir = get_user_auth_files_dir(user_id)
# Generate unique filename
import uuid
file_ext = Path(file.filename).suffix if file.filename else '.json'
stored_filename = f"{provider_name}_{file_type}_{uuid.uuid4().hex[:8]}{file_ext}"
file_path = auth_files_dir / stored_filename
# Save file
content = await file.read()
with open(file_path, 'wb') as f:
f.write(content)
# Save metadata to database
file_id = db.save_user_auth_file(
user_id=user_id,
provider_id=provider_name,
file_type=file_type,
original_filename=file.filename or stored_filename,
stored_filename=stored_filename,
file_path=str(file_path),
file_size=len(content),
mime_type=file.content_type
)
return JSONResponse({
"message": "File uploaded successfully",
"file_id": file_id,
"file_path": str(file_path),
"stored_filename": stored_filename
})
except Exception as e:
logger.error(f"Error uploading file: {e}")
return JSONResponse(status_code=500, content={"error": str(e)})
@app.get("/dashboard/user/providers/{provider_name}/files")
async def dashboard_user_provider_files(request: Request, provider_name: str):
"""Get all authentication files for a provider"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
if not user_id:
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
from aisbf.database import get_database
db = get_database()
try:
files = db.get_user_auth_files(user_id, provider_name)
return JSONResponse({"files": files})
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@app.get("/dashboard/user/providers/{provider_name}/files/{file_type}/download")
async def dashboard_user_provider_file_download(
request: Request,
provider_name: str,
file_type: str
):
"""Download an authentication file"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
if not user_id:
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
from aisbf.database import get_database
from fastapi.responses import FileResponse
db = get_database()
try:
file_info = db.get_user_auth_file(user_id, provider_name, file_type)
if not file_info:
return JSONResponse(status_code=404, content={"error": "File not found"})
file_path = Path(file_info['file_path'])
if not file_path.exists():
return JSONResponse(status_code=404, content={"error": "File not found on disk"})
return FileResponse(
path=str(file_path),
filename=file_info['original_filename'],
media_type=file_info['mime_type'] or 'application/octet-stream'
)
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@app.delete("/dashboard/user/providers/{provider_name}/files/{file_type}")
async def dashboard_user_provider_file_delete(
request: Request,
provider_name: str,
file_type: str
):
"""Delete an authentication file"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
user_id = request.session.get('user_id')
if not user_id:
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
from aisbf.database import get_database
db = get_database()
try:
file_info = db.get_user_auth_file(user_id, provider_name, file_type)
if file_info:
# Delete file from disk
file_path = Path(file_info['file_path'])
if file_path.exists():
file_path.unlink()
# Delete from database
db.delete_user_auth_file(user_id, provider_name, file_type)
return JSONResponse({"message": "File deleted successfully"})
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
# Admin authentication file management routes
def get_admin_auth_files_dir() -> Path:
"""Get the directory for admin authentication files"""
auth_files_dir = Path.home() / '.aisbf' / 'admin_auth_files'
auth_files_dir.mkdir(parents=True, exist_ok=True)
return auth_files_dir
@app.post("/dashboard/providers/{provider_name}/upload")
async def dashboard_provider_upload(
request: Request,
provider_name: str,
file_type: str = Form(...),
file: UploadFile = File(...)
):
"""Upload authentication file for a global provider (admin only)"""
auth_check = require_admin(request)
if auth_check:
return auth_check
try:
# Validate file type
allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials']
if file_type not in allowed_types:
return JSONResponse(
status_code=400,
content={"error": f"Invalid file type. Allowed: {', '.join(allowed_types)}"}
)
# Get admin auth files directory
auth_files_dir = get_admin_auth_files_dir()
# Generate unique filename
import uuid
file_ext = Path(file.filename).suffix if file.filename else '.json'
stored_filename = f"{provider_name}_{file_type}_{uuid.uuid4().hex[:8]}{file_ext}"
file_path = auth_files_dir / stored_filename
# Save file
content = await file.read()
with open(file_path, 'wb') as f:
f.write(content)
logger.info(f"Admin uploaded auth file: {file_path}")
return JSONResponse({
"message": "File uploaded successfully",
"file_path": str(file_path),
"stored_filename": stored_filename
})
except Exception as e:
logger.error(f"Error uploading admin file: {e}")
return JSONResponse(status_code=500, content={"error": str(e)})
@app.get("/dashboard/providers/{provider_name}/files")
async def dashboard_provider_files(request: Request, provider_name: str):
"""Get all authentication files for a global provider (admin only)"""
auth_check = require_admin(request)
if auth_check:
return auth_check
try:
auth_files_dir = get_admin_auth_files_dir()
files = []
for file_path in auth_files_dir.glob(f"{provider_name}_*"):
if file_path.is_file():
stat = file_path.stat()
files.append({
"filename": file_path.name,
"file_path": str(file_path),
"file_size": stat.st_size,
"modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat()
})
return JSONResponse({"files": files})
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@app.get("/dashboard/providers/{provider_name}/files/{filename}/download")
async def dashboard_provider_file_download(
request: Request,
provider_name: str,
filename: str
):
"""Download an authentication file for a global provider (admin only)"""
auth_check = require_admin(request)
if auth_check:
return auth_check
try:
auth_files_dir = get_admin_auth_files_dir()
file_path = auth_files_dir / filename
if not file_path.exists() or not file_path.is_file():
return JSONResponse(status_code=404, content={"error": "File not found"})
# Security check: ensure file belongs to this provider
if not filename.startswith(f"{provider_name}_"):
return JSONResponse(status_code=403, content={"error": "Access denied"})
return FileResponse(
path=str(file_path),
filename=filename,
media_type='application/octet-stream'
)
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
@app.delete("/dashboard/providers/{provider_name}/files/{filename}")
async def dashboard_provider_file_delete(
request: Request,
provider_name: str,
filename: str
):
"""Delete an authentication file for a global provider (admin only)"""
auth_check = require_admin(request)
if auth_check:
return auth_check
try:
auth_files_dir = get_admin_auth_files_dir()
file_path = auth_files_dir / filename
if not file_path.exists():
return JSONResponse(status_code=404, content={"error": "File not found"})
# Security check: ensure file belongs to this provider
if not filename.startswith(f"{provider_name}_"):
return JSONResponse(status_code=403, content={"error": "Access denied"})
file_path.unlink()
return JSONResponse({"message": "File deleted successfully"})
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
# User-specific rotation management routes
@app.get("/dashboard/user/rotations", response_class=HTMLResponse)
async def dashboard_user_rotations(request: Request):
......@@ -2630,6 +3034,30 @@ async def root():
}
@app.get("/favicon.ico")
async def favicon():
"""Serve favicon"""
from fastapi.responses import FileResponse
# Try to find favicon in multiple locations
search_paths = [
Path(__file__).parent / 'static' / 'extension' / 'icons' / 'icon16.png',
Path(__file__).parent / 'static' / 'favicon.ico',
Path.home() / '.local' / 'share' / 'aisbf' / 'static' / 'extension' / 'icons' / 'icon16.png',
]
for favicon_path in search_paths:
if favicon_path.exists():
return FileResponse(
path=favicon_path,
media_type="image/png" if favicon_path.suffix == '.png' else "image/x-icon"
)
# Return 204 No Content if favicon not found (better than 404)
from fastapi.responses import Response
return Response(status_code=204)
@app.get("/health")
async def health():
return {"status": "ok"}
......@@ -4218,5 +4646,550 @@ async def mcp_call_tool(request: Request):
)
# Chrome extension download endpoint
@app.get("/dashboard/extension/download")
async def dashboard_extension_download(request: Request):
"""Download the OAuth2 redirect extension as a ZIP file"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
from fastapi.responses import FileResponse
# Path to pre-packaged extension ZIP
extension_zip = Path(__file__).parent / 'static' / 'aisbf-oauth2-extension.zip'
if not extension_zip.exists():
# Fallback: try to build it dynamically
logger.warning("Pre-packaged extension not found, creating dynamically...")
import zipfile
import io
from fastapi.responses import Response
extension_dir = Path(__file__).parent / 'static' / 'extension'
if not extension_dir.exists():
return JSONResponse(
status_code=404,
content={"error": "Extension files not found"}
)
# Create ZIP file in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for file_path in extension_dir.rglob('*'):
if file_path.is_file() and not file_path.name.endswith('.sh'):
arcname = file_path.relative_to(extension_dir)
zip_file.write(file_path, arcname)
zip_buffer.seek(0)
return Response(
content=zip_buffer.getvalue(),
media_type="application/zip",
headers={
"Content-Disposition": "attachment; filename=aisbf-oauth2-extension.zip"
}
)
# Serve pre-packaged ZIP file
return FileResponse(
path=extension_zip,
media_type="application/zip",
filename="aisbf-oauth2-extension.zip"
)
except Exception as e:
logger.error(f"Error serving extension ZIP: {e}")
return JSONResponse(
status_code=500,
content={"error": str(e)}
)
# Global storage for pending OAuth2 callbacks (for localhost flow)
_pending_oauth2_callbacks = {}
_oauth2_callback_server = None
def _start_localhost_callback_server():
"""Start a temporary HTTP server on port 54545 to catch OAuth2 callbacks."""
global _oauth2_callback_server
if _oauth2_callback_server is not None:
logger.info("Localhost callback server already running")
return
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import threading
class CallbackHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
# Suppress default logging
pass
def do_GET(self):
"""Handle GET request - OAuth2 callback"""
parsed = urlparse(self.path)
if parsed.path == '/callback':
query_params = parse_qs(parsed.query)
code = query_params.get('code', [None])[0]
state = query_params.get('state', [None])[0]
error = query_params.get('error', [None])[0]
logger.info(f"Localhost callback server received - Code: {code[:10] if code else 'None'}...")
# Store the callback data
_pending_oauth2_callbacks['latest'] = {
'code': code,
'state': state,
'error': error,
'timestamp': time.time()
}
# Send success response
if error:
response_html = f"""
<html>
<head><title>Authentication Error</title></head>
<body style="font-family: Arial; text-align: center; padding: 50px;">
<h1 style="color: #e74c3c;">✗ Authentication Error</h1>
<p>Error: {error}</p>
<p>You can close this window.</p>
</body>
</html>
"""
self.send_response(400)
else:
response_html = """
<html>
<head><title>Authentication Successful</title></head>
<body style="font-family: Arial; text-align: center; padding: 50px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; height: 100vh; margin: 0; display: flex; justify-content: center; align-items: center;">
<div style="background: rgba(255,255,255,0.1); padding: 40px; border-radius: 10px;">
<h1>✓ Authentication Successful</h1>
<p>You can close this window and return to the dashboard.</p>
</div>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
</html>
"""
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(response_html.encode())
else:
self.send_response(404)
self.end_headers()
def run_server():
global _oauth2_callback_server
try:
_oauth2_callback_server = HTTPServer(('127.0.0.1', 54545), CallbackHandler)
logger.info("Started localhost OAuth2 callback server on port 54545")
_oauth2_callback_server.serve_forever()
except OSError as e:
if "Address already in use" in str(e):
logger.warning("Port 54545 already in use - another callback server may be running")
else:
logger.error(f"Failed to start callback server: {e}")
except Exception as e:
logger.error(f"Callback server error: {e}")
finally:
_oauth2_callback_server = None
# Start server in a daemon thread
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Give it a moment to start
time.sleep(0.1)
def _stop_localhost_callback_server():
"""Stop the localhost callback server."""
global _oauth2_callback_server
if _oauth2_callback_server:
_oauth2_callback_server.shutdown()
_oauth2_callback_server = None
logger.info("Stopped localhost OAuth2 callback server")
# OAuth2 callback endpoint (receives callbacks from extension OR direct localhost)
@app.get("/dashboard/oauth2/callback")
async def dashboard_oauth2_callback(
request: Request,
code: str = Query(None),
state: str = Query(None),
error: str = Query(None)
):
"""
Handle OAuth2 callback redirected from localhost.
This endpoint handles two scenarios:
1. Direct localhost callback (when browser is on same machine as AISBF)
2. Redirected callback from browser extension (when browser is remote)
"""
try:
if error:
logger.error(f"OAuth2 callback error: {error}")
return HTMLResponse(
content=f"""
<html>
<head><title>Authentication Error</title></head>
<body>
<h1>Authentication Error</h1>
<p>Error: {error}</p>
<p><a href="/dashboard/providers">Return to Dashboard</a></p>
</body>
</html>
""",
status_code=400
)
if not code:
return HTMLResponse(
content="""
<html>
<head><title>Authentication Error</title></head>
<body>
<h1>Authentication Error</h1>
<p>No authorization code received</p>
<p><a href="/dashboard/providers">Return to Dashboard</a></p>
</body>
</html>
""",
status_code=400
)
# Store the code in session for the auth completion
request.session['oauth2_code'] = code
request.session['oauth2_state'] = state
# Detect if this is a direct localhost callback (no extension involved)
referer = request.headers.get('referer', '')
is_direct_callback = 'localhost:54545' in referer or '127.0.0.1:54545' in referer
logger.info(f"OAuth2 callback received - Direct: {is_direct_callback}, Code: {code[:10]}...")
# Return success page with auto-close script
return HTMLResponse(
content="""
<html>
<head>
<title>Authentication Successful</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
text-align: center;
padding: 40px;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
backdrop-filter: blur(10px);
}
h1 { margin-bottom: 20px; }
p { margin: 10px 0; }
a {
color: #fff;
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<h1>✓ Authentication Successful</h1>
<p>You can close this window and return to the dashboard.</p>
<p><a href="/dashboard/providers">Return to Dashboard</a></p>
</div>
<script>
// Auto-close after 3 seconds
setTimeout(() => {
window.close();
}, 3000);
</script>
</body>
</html>
"""
)
except Exception as e:
logger.error(f"Error handling OAuth2 callback: {e}")
return HTMLResponse(
content=f"""
<html>
<head><title>Authentication Error</title></head>
<body>
<h1>Authentication Error</h1>
<p>Error: {str(e)}</p>
<p><a href="/dashboard/providers">Return to Dashboard</a></p>
</body>
</html>
""",
status_code=500
)
# Claude OAuth2 authentication endpoints
@app.post("/dashboard/claude/auth/start")
async def dashboard_claude_auth_start(request: Request):
"""Start Claude OAuth2 authentication flow - returns URL for browser opening"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.claude_credentials.json')
if not provider_key:
return JSONResponse(
status_code=400,
content={"success": False, "error": "Provider key is required"}
)
# Import ClaudeAuth
from aisbf.claude_auth import ClaudeAuth
# Create auth instance
auth = ClaudeAuth()
# Override credentials file if specified
auth.credentials_file = Path(credentials_file).expanduser()
# Generate PKCE challenge
verifier, challenge = auth._generate_pkce()
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
# Store verifier and state in session for later use
request.session['oauth2_verifier'] = verifier
request.session['oauth2_state'] = state
request.session['oauth2_provider'] = provider_key
request.session['oauth2_credentials_file'] = credentials_file
# Detect if the browser is accessing from localhost/127.0.0.1
# If so, we can use direct localhost callback without the extension
client_host = request.client.host if request.client else None
is_local_access = client_host in ['127.0.0.1', '::1', 'localhost']
# Get the request host to determine the callback URL
request_host = request.headers.get('host', '').split(':')[0]
is_localhost_request = request_host in ['127.0.0.1', 'localhost', '::1']
# Use local callback if accessing from localhost
use_extension = not (is_local_access or is_localhost_request)
# If using localhost, start the callback server
if not use_extension:
_start_localhost_callback_server()
logger.info("Started localhost callback server for direct OAuth2 flow")
# Build OAuth2 URL (Claude requires full scope set)
auth_params = {
"client_id": auth.CLIENT_ID,
"response_type": "code",
"code_challenge": challenge,
"code_challenge_method": "S256",
"redirect_uri": auth.REDIRECT_URI,
"scope": "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
"state": state
}
auth_url = f"{auth.AUTH_URL}?{'&'.join(f'{k}={v}' for k, v in auth_params.items())}"
return JSONResponse({
"success": True,
"auth_url": auth_url,
"use_extension": use_extension,
"message": "Please complete authentication in the browser window" if use_extension else "Authentication will use direct localhost callback"
})
except Exception as e:
logger.error(f"Error starting Claude auth: {e}")
return JSONResponse(
status_code=500,
content={"success": False, "error": str(e)}
)
@app.post("/dashboard/claude/auth/complete")
async def dashboard_claude_auth_complete(request: Request):
"""Complete Claude OAuth2 authentication using the code from callback"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
# Get code from session (stored by callback endpoint) or from localhost callback server
code = request.session.get('oauth2_code')
verifier = request.session.get('oauth2_verifier')
state = request.session.get('oauth2_state')
credentials_file = request.session.get('oauth2_credentials_file', '~/.claude_credentials.json')
# Check for callback data from localhost server if not in session
if not code and 'latest' in _pending_oauth2_callbacks:
callback_data = _pending_oauth2_callbacks['latest']
# Only use if received within the last 5 minutes
if time.time() - callback_data.get('timestamp', 0) < 300:
code = callback_data.get('code')
state = callback_data.get('state') or state # Use callback state if available
if callback_data.get('error'):
return JSONResponse(
status_code=400,
content={"success": False, "error": f"OAuth2 error: {callback_data['error']}"}
)
logger.info(f"Using code from localhost callback server: {code[:10] if code else 'None'}...")
if not code or not verifier:
return JSONResponse(
status_code=400,
content={"success": False, "error": "No authorization code found. Please restart authentication."}
)
# Import ClaudeAuth
from aisbf.claude_auth import ClaudeAuth
# Create auth instance
auth = ClaudeAuth()
auth.credentials_file = Path(credentials_file).expanduser()
# Use the new exchange_code_for_tokens method with retry logic
# Pass state as the second parameter (required), verifier as third (optional)
success = auth.exchange_code_for_tokens(code, state, verifier)
if success:
# Clear session data
request.session.pop('oauth2_code', None)
request.session.pop('oauth2_verifier', None)
request.session.pop('oauth2_state', None)
request.session.pop('oauth2_provider', None)
request.session.pop('oauth2_credentials_file', None)
# Clear pending callback data
_pending_oauth2_callbacks.pop('latest', None)
return JSONResponse({
"success": True,
"message": "Authentication completed successfully"
})
else:
# Check if it was a rate limit issue
return JSONResponse(
status_code=400,
content={"success": False, "error": "Token exchange failed. If you see rate_limit_error, please wait 1-2 minutes before trying again."}
)
except Exception as e:
logger.error(f"Error completing Claude auth: {e}")
return JSONResponse(
status_code=500,
content={"success": False, "error": str(e)}
)
@app.get("/dashboard/claude/auth/callback-status")
async def dashboard_claude_auth_callback_status(request: Request):
"""Check if OAuth2 callback has been received (for localhost flow)"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
# Check if we have callback data from the localhost server
if 'latest' in _pending_oauth2_callbacks:
callback_data = _pending_oauth2_callbacks['latest']
# Only valid if received within the last 5 minutes
if time.time() - callback_data.get('timestamp', 0) < 300:
if callback_data.get('error'):
return JSONResponse({
"received": True,
"error": callback_data['error']
})
elif callback_data.get('code'):
return JSONResponse({
"received": True,
"has_code": True
})
# Also check session (for extension flow)
if request.session.get('oauth2_code'):
return JSONResponse({
"received": True,
"has_code": True
})
return JSONResponse({
"received": False
})
@app.post("/dashboard/claude/auth/status")
async def dashboard_claude_auth_status(request: Request):
"""Check Claude authentication status"""
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
try:
data = await request.json()
provider_key = data.get('provider_key')
credentials_file = data.get('credentials_file', '~/.claude_credentials.json')
if not provider_key:
return JSONResponse(
status_code=400,
content={"authenticated": False, "error": "Provider key is required"}
)
# Import ClaudeAuth
from aisbf.claude_auth import ClaudeAuth
# Create auth instance
auth = ClaudeAuth(credentials_file=credentials_file)
# Check if credentials exist and are valid
if auth.tokens:
# Check if token is expired (with 5 minute buffer)
expires_at = auth.tokens.get('expires_at', 0)
if time.time() < (expires_at - 300):
# Token is valid
return JSONResponse({
"authenticated": True,
"expires_in": expires_at - time.time()
})
else:
# Token expired, try to refresh
if auth.refresh_token():
return JSONResponse({
"authenticated": True,
"expires_in": auth.tokens.get('expires_at', 0) - time.time()
})
else:
return JSONResponse({
"authenticated": False
})
else:
return JSONResponse({
"authenticated": False
})
except Exception as e:
logger.error(f"Error checking Claude auth status: {e}")
return JSONResponse(
status_code=500,
content={"authenticated": False, "error": str(e)}
)
if __name__ == "__main__":
main()
......@@ -22,4 +22,6 @@ protobuf>=3.20,<4
markdown
stem
mysql-connector-python
redis
\ No newline at end of file
redis
flask
curl_cffi>=0.5.0 # Optional: For TLS fingerprinting to bypass Cloudflare (Claude OAuth2)
\ No newline at end of file
......@@ -111,6 +111,7 @@ setup(
'aisbf/kiro_models.py',
'aisbf/kiro_parsers.py',
'aisbf/kiro_utils.py',
'aisbf/claude_auth.py',
'aisbf/semantic_classifier.py',
'aisbf/batching.py',
'aisbf/cache.py',
......
# AISBF OAuth2 Relay Extension
A Chrome extension that intercepts localhost OAuth2 callbacks and redirects them to your remote AISBF server.
## Why This Extension?
Many OAuth2 providers (like Claude/Anthropic) lock their redirect URIs to `localhost` or `127.0.0.1`. When AISBF runs on a remote server, it cannot receive these localhost callbacks directly. This extension solves that problem by intercepting the OAuth2 callback in your browser and redirecting it to your remote AISBF server.
## Features
- **Automatic Redirect**: Intercepts `http://localhost:*` and `http://127.0.0.1:*` OAuth2 callbacks
- **Configurable**: Set your remote AISBF server URL, ports, and callback paths
- **Secure**: Only redirects specific OAuth2 callback paths, not all localhost traffic
- **Easy Setup**: Auto-configuration from AISBF dashboard
- **Visual Status**: Badge shows when relay is active
## Installation
### From AISBF Dashboard (Recommended)
1. Open your AISBF dashboard
2. Go to Providers page
3. When configuring a Claude provider, you'll see an extension installation prompt
4. Click "Download Extension" to get the extension files
5. Follow the installation instructions shown in the dashboard
### Manual Installation
1. Download or clone this extension directory
2. Open Chrome and go to `chrome://extensions/`
3. Enable "Developer mode" (toggle in top right)
4. Click "Load unpacked"
5. Select the `static/extension` directory
6. The extension icon should appear in your toolbar
## Configuration
### Automatic Configuration (Recommended)
1. Install the extension
2. Open your AISBF dashboard
3. The dashboard will automatically detect and configure the extension
4. Click "Configure Extension" when prompted
### Manual Configuration
1. Click the extension icon in your toolbar
2. Click "Configure Server"
3. Enter your AISBF server URL (e.g., `https://192.168.1.100:17765`)
4. The extension will automatically intercept OAuth2 callbacks
### Advanced Options
Click the extension icon → "Advanced Options" to configure:
- **Ports to Intercept**: Comma-separated list (default: `54545`)
- **Callback Paths**: One per line (default: `/callback`, `/oauth/callback`, `/auth/callback`)
## How It Works
1. OAuth2 provider redirects to `http://localhost:54545/callback?code=...`
2. Extension intercepts this request before it fails
3. Extension redirects to `https://your-server.com/dashboard/oauth2/callback?code=...`
4. AISBF receives the callback and completes authentication
## Supported Providers
- Claude (Anthropic) - Port 54545
- Any OAuth2 provider that requires localhost redirect URIs
## Security
- Extension only intercepts specific callback paths, not all localhost traffic
- Only accepts configuration from HTTPS sites or localhost
- No data is stored or transmitted except the OAuth2 callback redirect
- Open source - review the code yourself
## Troubleshooting
### Extension Not Working
1. Check that the extension is enabled in `chrome://extensions/`
2. Verify the remote server URL is correct (click extension icon)
3. Check that the port matches your OAuth2 provider (default: 54545)
4. Look for errors in the extension console (chrome://extensions/ → Details → Inspect views: service worker)
### OAuth2 Still Failing
1. Ensure the extension badge shows "ON" (click extension icon)
2. Verify the callback path is in the configured paths
3. Check AISBF server logs for incoming requests
4. Try disabling and re-enabling the extension
### Configuration Not Saving
1. Check Chrome sync is enabled (extension uses chrome.storage.sync)
2. Try using chrome.storage.local instead (modify background.js)
3. Check browser console for errors
## Development
### File Structure
```
static/extension/
├── manifest.json # Extension manifest (v3)
├── background.js # Service worker with redirect logic
├── popup.html # Extension popup UI
├── popup.js # Popup logic
├── options.html # Options page UI
├── options.js # Options page logic
├── icons/ # Extension icons
│ ├── icon16.svg
│ ├── icon48.svg
│ └── icon128.svg
└── README.md # This file
```
### Testing
1. Load extension in developer mode
2. Configure with your AISBF server URL
3. Try authenticating with a Claude provider
4. Check extension console for logs (look for `[AISBF]` prefix)
### Debugging
Enable verbose logging:
1. Open `chrome://extensions/`
2. Find "AISBF OAuth2 Relay"
3. Click "Inspect views: service worker"
4. Console will show all intercepted requests
## License
Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
## Support
For issues or questions:
- Check AISBF documentation
- Review extension console logs
- Open an issue on the AISBF repository
/**
* AISBF OAuth2 Relay - Background Service Worker
*
* This extension intercepts localhost OAuth2 callbacks and redirects them
* to the remote AISBF server. This is necessary because many OAuth2 providers
* (like Claude/Anthropic) lock their redirect URIs to localhost.
*
* Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
* Licensed under GPL-3.0
*/
// Default configuration
const DEFAULT_CONFIG = {
enabled: true,
remoteServer: '', // Will be set from AISBF dashboard
ports: [54545], // Default OAuth callback ports to intercept
paths: ['/callback', '/oauth/callback', '/auth/callback']
};
// Current configuration
let config = { ...DEFAULT_CONFIG };
// Load configuration from storage
async function loadConfig() {
try {
const result = await chrome.storage.sync.get(['aisbfConfig']);
if (result.aisbfConfig) {
config = { ...DEFAULT_CONFIG, ...result.aisbfConfig };
}
console.log('[AISBF] Configuration loaded:', config);
await updateRules();
} catch (error) {
console.error('[AISBF] Failed to load config:', error);
}
}
// Save configuration to storage
async function saveConfig(newConfig) {
config = { ...DEFAULT_CONFIG, ...newConfig };
try {
await chrome.storage.sync.set({ aisbfConfig: config });
console.log('[AISBF] Configuration saved:', config);
await updateRules();
return true;
} catch (error) {
console.error('[AISBF] Failed to save config:', error);
return false;
}
}
// Generate declarativeNetRequest rules for interception
function generateRules() {
const rules = [];
let ruleId = 1;
if (!config.enabled || !config.remoteServer) {
return rules;
}
// Clean up remote server URL
let remoteBase = config.remoteServer.replace(/\/$/, '');
// Parse remote server URL to check if it's localhost
let remoteUrl;
try {
remoteUrl = new URL(remoteBase);
} catch (e) {
console.error('[AISBF] Invalid remote server URL:', remoteBase);
return rules;
}
// Check if remote server is localhost/127.0.0.1
const isRemoteLocal = remoteUrl.hostname === 'localhost' ||
remoteUrl.hostname === '127.0.0.1' ||
remoteUrl.hostname === '::1';
// If the remote server is on localhost, we don't need to intercept
// The OAuth2 callback can go directly to localhost without redirection
if (isRemoteLocal) {
console.log('[AISBF] Remote server is localhost - no interception needed');
return rules;
}
for (const port of config.ports) {
for (const path of config.paths) {
// Rule for 127.0.0.1
rules.push({
id: ruleId++,
priority: 1,
action: {
type: 'redirect',
redirect: {
regexSubstitution: `${remoteBase}/dashboard/oauth2/callback\\1`
}
},
condition: {
regexFilter: `^http://127\\.0\\.0\\.1:${port}${path.replace(/\//g, '\\/')}(.*)$`,
resourceTypes: ['main_frame']
}
});
// Rule for localhost
rules.push({
id: ruleId++,
priority: 1,
action: {
type: 'redirect',
redirect: {
regexSubstitution: `${remoteBase}/dashboard/oauth2/callback\\1`
}
},
condition: {
regexFilter: `^http://localhost:${port}${path.replace(/\//g, '\\/')}(.*)$`,
resourceTypes: ['main_frame']
}
});
}
}
return rules;
}
// Update declarativeNetRequest rules
async function updateRules() {
try {
// Get existing rules
const existingRules = await chrome.declarativeNetRequest.getDynamicRules();
const existingRuleIds = existingRules.map(rule => rule.id);
// Generate new rules
const newRules = generateRules();
// Update rules
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: existingRuleIds,
addRules: newRules
});
console.log('[AISBF] Rules updated:', newRules.length, 'rules active');
// Update badge to show status
updateBadge(config.enabled && newRules.length > 0);
} catch (error) {
console.error('[AISBF] Failed to update rules:', error);
}
}
// Update extension badge
function updateBadge(active) {
if (active) {
chrome.action.setBadgeText({ text: 'ON' });
chrome.action.setBadgeBackgroundColor({ color: '#4CAF50' });
} else {
chrome.action.setBadgeText({ text: 'OFF' });
chrome.action.setBadgeBackgroundColor({ color: '#9E9E9E' });
}
}
// Handle messages from popup and options page
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
console.log('[AISBF] Received message:', message);
switch (message.type) {
case 'GET_CONFIG':
sendResponse({ success: true, config: config });
break;
case 'SET_CONFIG':
saveConfig(message.config).then(success => {
sendResponse({ success });
});
return true; // Will respond asynchronously
case 'TOGGLE_ENABLED':
config.enabled = !config.enabled;
saveConfig(config).then(success => {
sendResponse({ success, enabled: config.enabled });
});
return true;
case 'GET_STATUS':
chrome.declarativeNetRequest.getDynamicRules().then(rules => {
sendResponse({
success: true,
enabled: config.enabled,
rulesCount: rules.length,
remoteServer: config.remoteServer
});
});
return true;
default:
sendResponse({ success: false, error: 'Unknown message type' });
}
});
// Handle external messages from AISBF dashboard
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
console.log('[AISBF] External message from:', sender.url, message);
// Security: Only accept messages from HTTPS sites or localhost
const senderUrl = new URL(sender.url);
const isSecure = senderUrl.protocol === 'https:' ||
senderUrl.hostname === 'localhost' ||
senderUrl.hostname === '127.0.0.1';
if (!isSecure) {
sendResponse({ success: false, error: 'Insecure origin' });
return;
}
switch (message.type) {
case 'CONFIGURE':
// AISBF dashboard is setting up the extension
const newConfig = {
enabled: true,
remoteServer: message.remoteServer || sender.url.replace(/\/dashboard.*$/, ''),
ports: message.ports || config.ports,
paths: message.paths || config.paths
};
saveConfig(newConfig).then(success => {
sendResponse({ success, config: newConfig });
});
return true;
case 'PING':
sendResponse({ success: true, version: chrome.runtime.getManifest().version });
break;
case 'GET_STATUS':
chrome.declarativeNetRequest.getDynamicRules().then(rules => {
sendResponse({
success: true,
enabled: config.enabled,
rulesCount: rules.length,
remoteServer: config.remoteServer,
version: chrome.runtime.getManifest().version
});
});
return true;
default:
sendResponse({ success: false, error: 'Unknown message type' });
}
});
// Initialize on install
chrome.runtime.onInstalled.addListener(async (details) => {
console.log('[AISBF] Extension installed:', details.reason);
await loadConfig();
if (details.reason === 'install') {
// Open options page on first install
chrome.runtime.openOptionsPage();
}
});
// Initialize on startup
chrome.runtime.onStartup.addListener(async () => {
console.log('[AISBF] Extension started');
await loadConfig();
});
// Initial load
loadConfig();
#!/bin/bash
# Build script for AISBF OAuth2 Redirect Extension
# Creates a packaged ZIP file ready for distribution
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUTPUT_DIR="$SCRIPT_DIR/.."
OUTPUT_FILE="$OUTPUT_DIR/aisbf-oauth2-extension.zip"
echo "Building AISBF OAuth2 Redirect Extension..."
echo "Source directory: $SCRIPT_DIR"
echo "Output file: $OUTPUT_FILE"
# Remove old ZIP if it exists
if [ -f "$OUTPUT_FILE" ]; then
echo "Removing old package..."
rm "$OUTPUT_FILE"
fi
# Create ZIP file
echo "Creating package..."
cd "$SCRIPT_DIR"
zip -r "$OUTPUT_FILE" \
manifest.json \
background.js \
content.js \
popup.html \
popup.js \
options.html \
options.js \
README.md \
icons/ \
-x "*.sh" "*.md~" "*~" ".DS_Store"
echo ""
echo "✓ Extension packaged successfully!"
echo "Package location: $OUTPUT_FILE"
echo ""
echo "To install:"
echo "1. Extract the ZIP file"
echo "2. Open Chrome and go to chrome://extensions/"
echo "3. Enable 'Developer mode'"
echo "4. Click 'Load unpacked'"
echo "5. Select the extracted folder"
/**
* AISBF OAuth2 Relay - Content Script
*
* This content script bridges communication between the AISBF dashboard
* and the extension's background service worker.
*
* Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
* Licensed under GPL-3.0
*/
// Inject marker to indicate extension is installed
window.aisbfOAuth2Extension = true;
// Listen for messages from the web page
window.addEventListener('message', async (event) => {
// Only accept messages from the same window
if (event.source !== window) {
return;
}
const message = event.data;
// Handle ping request
if (message.type === 'aisbf-extension-ping') {
window.postMessage({ type: 'aisbf-extension-pong' }, '*');
return;
}
// Handle configuration request
if (message.type === 'aisbf-extension-configure') {
try {
const response = await chrome.runtime.sendMessage({
type: 'SET_CONFIG',
config: {
enabled: true,
remoteServer: message.serverUrl,
ports: message.ports || [54545],
paths: message.paths || ['/callback', '/oauth/callback', '/auth/callback']
}
});
window.postMessage({
type: 'aisbf-extension-configured',
success: response.success
}, '*');
} catch (error) {
console.error('[AISBF Content] Configuration error:', error);
window.postMessage({
type: 'aisbf-extension-configured',
success: false,
error: error.message
}, '*');
}
return;
}
// Handle status request
if (message.type === 'aisbf-extension-status') {
try {
const response = await chrome.runtime.sendMessage({
type: 'GET_STATUS'
});
window.postMessage({
type: 'aisbf-extension-status-response',
...response
}, '*');
} catch (error) {
console.error('[AISBF Content] Status error:', error);
window.postMessage({
type: 'aisbf-extension-status-response',
success: false,
error: error.message
}, '*');
}
return;
}
});
// Notify that content script is ready
console.log('[AISBF Content] Extension content script loaded');
#!/usr/bin/env python3
"""
Generate PNG icons from SVG for the AISBF OAuth2 Relay extension.
"""
from PIL import Image, ImageDraw
import os
def create_icon(size):
"""Create a simple gradient icon with checkmark."""
# Create image with transparency
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Draw gradient circle (simplified - solid color)
color = (102, 126, 234, 255) # #667eea
margin = 2
draw.ellipse([margin, margin, size-margin, size-margin], fill=color)
# Draw checkmark (simplified)
if size >= 48:
line_width = max(2, size // 16)
# Checkmark path
points = [
(size * 0.3, size * 0.5),
(size * 0.45, size * 0.65),
(size * 0.7, size * 0.35)
]
draw.line(points, fill='white', width=line_width, joint='curve')
return img
# Create icons directory if it doesn't exist
icons_dir = 'static/extension/icons'
os.makedirs(icons_dir, exist_ok=True)
# Generate icons
for size in [16, 48, 128]:
icon = create_icon(size)
icon.save(f'{icons_dir}/icon{size}.png', 'PNG')
print(f'Created icon{size}.png')
print('All icons created successfully!')
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<linearGradient id="grad128" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<circle cx="64" cy="64" r="60" fill="url(#grad128)"/>
<path d="M38 64 L54 80 L90 44" stroke="white" stroke-width="8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M64 20 L64 32 M64 96 L64 108 M20 64 L32 64 M96 64 L108 64" stroke="white" stroke-width="4" opacity="0.3" stroke-linecap="round"/>
<circle cx="64" cy="64" r="56" fill="none" stroke="white" stroke-width="2" opacity="0.2"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<defs>
<linearGradient id="grad16" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<circle cx="8" cy="8" r="7" fill="url(#grad16)"/>
<path d="M5 8 L7 10 L11 6" stroke="white" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<defs>
<linearGradient id="grad48" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<circle cx="24" cy="24" r="22" fill="url(#grad48)"/>
<path d="M14 24 L20 30 L34 16" stroke="white" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M24 8 L24 12 M24 36 L24 40 M8 24 L12 24 M36 24 L40 24" stroke="white" stroke-width="2" opacity="0.3" stroke-linecap="round"/>
</svg>
{
"manifest_version": 3,
"name": "AISBF OAuth2 Relay",
"description": "Intercepts localhost OAuth2 callbacks and redirects them to the remote AISBF server for providers that require localhost redirect URIs.",
"version": "1.0.1",
"permissions": [
"declarativeNetRequest",
"storage"
],
"host_permissions": [
"http://127.0.0.1/*",
"http://localhost/*"
],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_start"
}
],
"options_page": "options.html",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"action": {
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"default_title": "AISBF OAuth2 Relay",
"default_popup": "popup.html"
}
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>AISBF OAuth2 Relay - Options</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
margin: 0;
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 600px;
margin: 0 auto;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 12px 12px 0 0;
margin-bottom: 0;
}
.header h1 {
margin: 0 0 8px 0;
font-size: 24px;
display: flex;
align-items: center;
gap: 10px;
}
.header h1 img {
width: 32px;
height: 32px;
}
.header p {
margin: 0;
opacity: 0.9;
font-size: 14px;
}
.card {
background: white;
border-radius: 0 0 12px 12px;
padding: 25px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.section {
margin-bottom: 25px;
}
.section:last-child {
margin-bottom: 0;
}
.section h2 {
font-size: 16px;
color: #333;
margin: 0 0 15px 0;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.form-group {
margin-bottom: 15px;
}
.form-group:last-child {
margin-bottom: 0;
}
label {
display: block;
font-size: 13px;
color: #555;
margin-bottom: 6px;
font-weight: 500;
}
input[type="text"],
input[type="url"],
textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s;
}
input[type="text"]:focus,
input[type="url"]:focus,
textarea:focus {
outline: none;
border-color: #667eea;
}
textarea {
resize: vertical;
min-height: 80px;
font-family: monospace;
}
.help-text {
font-size: 12px;
color: #888;
margin-top: 5px;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 10px;
}
.checkbox-group input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #667eea;
}
.checkbox-group label {
margin: 0;
font-weight: normal;
}
.btn-group {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
flex: 1;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #f5f5f5;
color: #333;
}
.btn-secondary:hover {
background: #eeeeee;
}
.status-bar {
padding: 10px 15px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 20px;
display: none;
}
.status-bar.success {
display: block;
background: #e8f5e9;
color: #2e7d32;
border: 1px solid #a5d6a7;
}
.status-bar.error {
display: block;
background: #ffebee;
color: #c62828;
border: 1px solid #ef9a9a;
}
.info-box {
background: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 8px;
padding: 15px;
font-size: 13px;
color: #1565c0;
}
.info-box h3 {
margin: 0 0 10px 0;
font-size: 14px;
}
.info-box ul {
margin: 0;
padding-left: 20px;
}
.info-box li {
margin-bottom: 5px;
}
.info-box li:last-child {
margin-bottom: 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>
<img src="icons/icon48.png" alt="">
AISBF OAuth2 Relay
</h1>
<p>Configure OAuth2 callback interception for remote AISBF deployments</p>
</div>
<div class="card">
<div id="statusBar" class="status-bar"></div>
<div class="section">
<h2>General Settings</h2>
<div class="form-group">
<div class="checkbox-group">
<input type="checkbox" id="enabled" checked>
<label for="enabled">Enable OAuth2 relay</label>
</div>
</div>
<div class="form-group">
<label for="remoteServer">AISBF Server URL</label>
<input type="url" id="remoteServer" placeholder="https://aisbf.example.com">
<div class="help-text">The URL of your remote AISBF server (e.g., https://192.168.1.100:17765)</div>
</div>
</div>
<div class="section">
<h2>Interception Settings</h2>
<div class="form-group">
<label for="ports">Ports to Intercept</label>
<input type="text" id="ports" placeholder="54545">
<div class="help-text">Comma-separated list of localhost ports to intercept (default: 54545 for Claude)</div>
</div>
<div class="form-group">
<label for="paths">Callback Paths</label>
<textarea id="paths" placeholder="/callback&#10;/oauth/callback&#10;/auth/callback"></textarea>
<div class="help-text">One path per line. These paths on localhost will be redirected to AISBF.</div>
</div>
</div>
<div class="section">
<h2>How It Works</h2>
<div class="info-box">
<h3>OAuth2 Localhost Redirect</h3>
<ul>
<li>Many OAuth2 providers (like Claude) require localhost redirect URIs</li>
<li>When AISBF runs on a remote server, it can't receive these localhost callbacks</li>
<li>This extension intercepts localhost OAuth2 callbacks in your browser</li>
<li>It redirects them to your remote AISBF server automatically</li>
</ul>
</div>
</div>
<div class="btn-group">
<button id="saveBtn" class="btn btn-primary">Save Configuration</button>
<button id="resetBtn" class="btn btn-secondary">Reset to Defaults</button>
</div>
</div>
</div>
<script src="options.js"></script>
</body>
</html>
/**
* AISBF OAuth2 Relay - Options Page Script
*
* Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
* Licensed under GPL-3.0
*/
document.addEventListener('DOMContentLoaded', async () => {
const statusBar = document.getElementById('statusBar');
const enabledEl = document.getElementById('enabled');
const remoteServerEl = document.getElementById('remoteServer');
const portsEl = document.getElementById('ports');
const pathsEl = document.getElementById('paths');
const saveBtn = document.getElementById('saveBtn');
const resetBtn = document.getElementById('resetBtn');
// Default configuration
const DEFAULT_CONFIG = {
enabled: true,
remoteServer: '',
ports: [54545],
paths: ['/callback', '/oauth/callback', '/auth/callback']
};
// Show status message
function showStatus(message, isError = false) {
statusBar.textContent = message;
statusBar.className = 'status-bar ' + (isError ? 'error' : 'success');
setTimeout(() => {
statusBar.className = 'status-bar';
}, 3000);
}
// Load current configuration
async function loadConfig() {
try {
const response = await chrome.runtime.sendMessage({ type: 'GET_CONFIG' });
if (response.success) {
const config = response.config;
enabledEl.checked = config.enabled;
remoteServerEl.value = config.remoteServer || '';
portsEl.value = (config.ports || DEFAULT_CONFIG.ports).join(', ');
pathsEl.value = (config.paths || DEFAULT_CONFIG.paths).join('\n');
}
} catch (error) {
showStatus('Failed to load configuration: ' + error.message, true);
}
}
// Save configuration
async function saveConfig() {
const ports = portsEl.value
.split(/[,\s]+/)
.map(p => parseInt(p.trim(), 10))
.filter(p => !isNaN(p) && p > 0 && p < 65536);
const paths = pathsEl.value
.split('\n')
.map(p => p.trim())
.filter(p => p.length > 0);
if (!remoteServerEl.value) {
showStatus('Please enter a remote server URL', true);
remoteServerEl.focus();
return;
}
if (ports.length === 0) {
showStatus('Please enter at least one valid port', true);
portsEl.focus();
return;
}
if (paths.length === 0) {
showStatus('Please enter at least one callback path', true);
pathsEl.focus();
return;
}
const config = {
enabled: enabledEl.checked,
remoteServer: remoteServerEl.value.replace(/\/$/, ''),
ports: ports,
paths: paths
};
try {
const response = await chrome.runtime.sendMessage({
type: 'SET_CONFIG',
config: config
});
if (response.success) {
showStatus('Configuration saved successfully!');
} else {
showStatus('Failed to save configuration', true);
}
} catch (error) {
showStatus('Error: ' + error.message, true);
}
}
// Reset to defaults
async function resetConfig() {
if (!confirm('Reset all settings to defaults?')) {
return;
}
enabledEl.checked = DEFAULT_CONFIG.enabled;
remoteServerEl.value = '';
portsEl.value = DEFAULT_CONFIG.ports.join(', ');
pathsEl.value = DEFAULT_CONFIG.paths.join('\n');
try {
const response = await chrome.runtime.sendMessage({
type: 'SET_CONFIG',
config: { ...DEFAULT_CONFIG, remoteServer: '' }
});
if (response.success) {
showStatus('Configuration reset to defaults');
}
} catch (error) {
showStatus('Error: ' + error.message, true);
}
}
// Event listeners
saveBtn.addEventListener('click', saveConfig);
resetBtn.addEventListener('click', resetConfig);
// Save on Enter in text fields
remoteServerEl.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
saveConfig();
}
});
portsEl.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
saveConfig();
}
});
// Load initial config
await loadConfig();
});
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>AISBF OAuth2 Relay</title>
<style>
body {
width: 300px;
padding: 15px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
}
.container {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
h1 {
margin: 0 0 15px 0;
font-size: 18px;
color: #333;
display: flex;
align-items: center;
gap: 8px;
}
h1 img {
width: 24px;
height: 24px;
}
.status {
padding: 12px;
border-radius: 8px;
margin-bottom: 15px;
font-size: 13px;
}
.status.active {
background: #e8f5e9;
color: #2e7d32;
border: 1px solid #a5d6a7;
}
.status.inactive {
background: #ffebee;
color: #c62828;
border: 1px solid #ef9a9a;
}
.status.warning {
background: #fff3e0;
color: #ef6c00;
border: 1px solid #ffcc80;
}
.server-info {
font-size: 12px;
color: #666;
margin-top: 8px;
word-break: break-all;
}
.toggle-container {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 15px;
}
.toggle-label {
font-size: 14px;
color: #333;
}
.toggle {
position: relative;
width: 50px;
height: 26px;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.3s;
border-radius: 26px;
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #667eea;
}
input:checked + .slider:before {
transform: translateX(24px);
}
.btn {
display: block;
width: 100%;
padding: 10px;
border: none;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
text-align: center;
box-sizing: border-box;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #f5f5f5;
color: #333;
margin-top: 8px;
}
.btn-secondary:hover {
background: #eeeeee;
}
.rules-count {
text-align: center;
font-size: 11px;
color: #999;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>
<img src="icons/icon48.png" alt="">
AISBF OAuth2 Relay
</h1>
<div id="status" class="status inactive">
Checking status...
</div>
<div class="toggle-container">
<span class="toggle-label">Enable Relay</span>
<label class="toggle">
<input type="checkbox" id="enableToggle">
<span class="slider"></span>
</label>
</div>
<button id="configBtn" class="btn btn-primary">Configure Server</button>
<button id="optionsBtn" class="btn btn-secondary">Advanced Options</button>
<div id="rulesCount" class="rules-count"></div>
</div>
<script src="popup.js"></script>
</body>
</html>
/**
* AISBF OAuth2 Relay - Popup Script
*
* Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
* Licensed under GPL-3.0
*/
document.addEventListener('DOMContentLoaded', async () => {
const statusEl = document.getElementById('status');
const toggleEl = document.getElementById('enableToggle');
const configBtn = document.getElementById('configBtn');
const optionsBtn = document.getElementById('optionsBtn');
const rulesCountEl = document.getElementById('rulesCount');
// Get current status
async function updateStatus() {
try {
const response = await chrome.runtime.sendMessage({ type: 'GET_STATUS' });
if (response.success) {
toggleEl.checked = response.enabled;
if (!response.remoteServer) {
statusEl.className = 'status warning';
statusEl.innerHTML = `
<strong>Not Configured</strong>
<div class="server-info">Click "Configure Server" to set up the AISBF server URL.</div>
`;
} else if (response.enabled && response.rulesCount > 0) {
statusEl.className = 'status active';
statusEl.innerHTML = `
<strong>✓ Active</strong>
<div class="server-info">Redirecting to: ${response.remoteServer}</div>
`;
} else if (response.enabled) {
statusEl.className = 'status warning';
statusEl.innerHTML = `
<strong>⚠ Enabled but no rules</strong>
<div class="server-info">Server: ${response.remoteServer}</div>
`;
} else {
statusEl.className = 'status inactive';
statusEl.innerHTML = `
<strong>Disabled</strong>
<div class="server-info">Server: ${response.remoteServer}</div>
`;
}
rulesCountEl.textContent = `${response.rulesCount} redirect rules active`;
}
} catch (error) {
statusEl.className = 'status inactive';
statusEl.innerHTML = `<strong>Error:</strong> ${error.message}`;
}
}
// Toggle enabled state
toggleEl.addEventListener('change', async () => {
try {
const response = await chrome.runtime.sendMessage({ type: 'TOGGLE_ENABLED' });
if (response.success) {
await updateStatus();
}
} catch (error) {
console.error('Failed to toggle:', error);
}
});
// Configure server button
configBtn.addEventListener('click', async () => {
const serverUrl = prompt(
'Enter your AISBF server URL:\n\n' +
'Examples:\n' +
'• http://192.168.1.100:17765\n' +
'• https://aisbf.example.com\n' +
'• https://example.com/aisbf'
);
if (serverUrl) {
try {
const response = await chrome.runtime.sendMessage({
type: 'SET_CONFIG',
config: {
enabled: true,
remoteServer: serverUrl.replace(/\/$/, '')
}
});
if (response.success) {
await updateStatus();
} else {
alert('Failed to save configuration');
}
} catch (error) {
alert('Error: ' + error.message);
}
}
});
// Options button
optionsBtn.addEventListener('click', () => {
chrome.runtime.openOptionsPage();
});
// Initial status update
await updateStatus();
});
......@@ -20,6 +20,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}AISBF Dashboard{% endblock %}</title>
<link rel="icon" type="image/png" href="/favicon.ico">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: #1a1a2e; color: #e0e0e0; }
......@@ -94,6 +95,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<h1>AISBF Dashboard</h1>
{% if request.session.logged_in %}
<div class="header-actions">
<a href="{{ url_for(request, '/dashboard/license') }}" class="btn btn-secondary">License</a>
<button onclick="restartServer()" class="btn btn-warning">Restart Server</button>
<a href="{{ url_for(request, '/dashboard/logout') }}" class="btn btn-secondary">Logout</a>
</div>
......@@ -116,7 +118,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<a href="{{ url_for(request, '/dashboard/settings') }}" {% if '/settings' in request.path %}class="active"{% endif %}>Settings</a>
<a href="{{ url_for(request, '/dashboard/docs') }}" {% if '/docs' in request.path %}class="active"{% endif %}>Docs</a>
<a href="{{ url_for(request, '/dashboard/about') }}" {% if '/about' in request.path %}class="active"{% endif %}>About</a>
<a href="{{ url_for(request, '/dashboard/license') }}" {% if '/license' in request.path %}class="active"{% endif %}>License</a>
</div>
</div>
{% endif %}
......
......@@ -47,6 +47,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<option value="anthropic">Anthropic</option>
<option value="ollama">Ollama</option>
<option value="kiro">Kiro (Amazon Q Developer)</option>
<option value="claude">Claude (OAuth2)</option>
</select>
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Select the type of provider to configure appropriate settings</small>
</div>
......@@ -123,6 +124,7 @@ function renderProviderDetails(key) {
const container = document.getElementById(`provider-details-${key}`);
const provider = providersData[key];
const isKiroProvider = provider.type === 'kiro';
const isClaudeProvider = provider.type === 'claude';
// Initialize kiro_config if this is a kiro provider and doesn't have it
if (isKiroProvider && !provider.kiro_config) {
......@@ -137,7 +139,15 @@ function renderProviderDetails(key) {
};
}
// Initialize claude_config if this is a claude provider and doesn't have it
if (isClaudeProvider && !provider.claude_config) {
provider.claude_config = {
credentials_file: '~/.claude_credentials.json'
};
}
const kiroConfig = provider.kiro_config || {};
const claudeConfig = provider.claude_config || {};
// Build authentication fields based on provider type
let authFieldsHtml = '';
......@@ -194,6 +204,56 @@ function renderProviderDetails(key) {
<input type="password" value="${kiroConfig.client_secret || ''}" onchange="updateKiroConfig('${key}', 'client_secret', this.value)" placeholder="OAuth client secret">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">OAuth client secret for AWS SSO OIDC authentication</small>
</div>
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Option 4: Upload Files</h5>
<div class="form-group">
<label>Upload Credentials File</label>
<input type="file" id="kiro-creds-file-${key}" accept=".json" onchange="uploadKiroFile('${key}', 'creds_file', this.files[0])">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Upload Kiro IDE credentials JSON file</small>
</div>
<div class="form-group">
<label>Upload SQLite Database</label>
<input type="file" id="kiro-db-file-${key}" accept=".sqlite3,.db,.sqlite" onchange="uploadKiroFile('${key}', 'sqlite_db', this.files[0])">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Upload kiro-cli SQLite database file</small>
</div>
<div id="kiro-upload-status-${key}" style="margin-top: 10px;"></div>
</div>
`;
} else if (isClaudeProvider) {
// Claude OAuth2 authentication fields
authFieldsHtml = `
<div style="background: #0f2840; padding: 15px; border-radius: 5px; margin-bottom: 15px; border-left: 3px solid #4a9eff;">
<h4 style="margin: 0 0 15px 0; color: #4a9eff;">Claude OAuth2 Authentication</h4>
<small style="color: #a0a0a0; display: block; margin-bottom: 15px;">
Claude uses OAuth2 authentication. Click "Authenticate" to start the OAuth2 flow in your browser.
</small>
<div class="form-group">
<label>Credentials File Path</label>
<input type="text" value="${claudeConfig.credentials_file || '~/.claude_credentials.json'}" onchange="updateClaudeConfig('${key}', 'credentials_file', this.value)" placeholder="~/.claude_credentials.json">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Path where OAuth2 credentials will be stored</small>
</div>
<div style="margin-top: 15px;">
<button type="button" class="btn" onclick="authenticateClaude('${key}')" style="background: #4a9eff;">
🔐 Authenticate with Claude
</button>
<button type="button" class="btn btn-secondary" onclick="checkClaudeAuth('${key}')" style="margin-left: 10px;">
Check Status
</button>
</div>
<div id="claude-auth-status-${key}" style="margin-top: 10px; padding: 10px; border-radius: 3px; display: none;">
<!-- Auth status will be displayed here -->
</div>
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Or Upload Credentials File</h5>
<div class="form-group">
<label>Upload Credentials File</label>
<input type="file" id="claude-creds-file-${key}" accept=".json" onchange="uploadClaudeFile('${key}', this.files[0])">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">Upload Claude OAuth2 credentials JSON file</small>
</div>
<div id="claude-upload-status-${key}" style="margin-top: 10px;"></div>
</div>
`;
} else {
......@@ -238,6 +298,7 @@ function renderProviderDetails(key) {
<option value="anthropic" ${provider.type === 'anthropic' ? 'selected' : ''}>Anthropic</option>
<option value="ollama" ${provider.type === 'ollama' ? 'selected' : ''}>Ollama</option>
<option value="kiro" ${provider.type === 'kiro' ? 'selected' : ''}>Kiro (Amazon Q Developer)</option>
<option value="claude" ${provider.type === 'claude' ? 'selected' : ''}>Claude (OAuth2)</option>
</select>
</div>
......@@ -327,8 +388,27 @@ function renderProviderDetails(key) {
</div>
<h4 style="margin-top: 20px; margin-bottom: 10px;">Models</h4>
<small style="color: #a0a0a0; display: block; margin-bottom: 15px;">
Configure specific models for this provider, or leave empty to automatically fetch all available models from the provider's API.
</small>
<div class="form-group" style="background: #0f2840; padding: 15px; border-radius: 5px; margin-bottom: 15px; border-left: 3px solid #4a9eff;">
<label>Model Filter (for auto-fetched models)</label>
<input type="text" value="${provider.model_filter || ''}" onchange="updateProvider('${key}', 'model_filter', this.value)" placeholder="e.g., free, pro, flash">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">
When no models are manually configured, only expose models whose ID contains this filter word (case-insensitive wildcard matching).
<br>Example: "free" will match models like "model-name-free", "free-model", "gpt-4-free-tier", etc.
</small>
</div>
<div id="models-${key}"></div>
<button type="button" class="btn btn-secondary" onclick="addModel('${key}')" style="margin-top: 10px;">Add Model</button>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<button type="button" class="btn" onclick="getModelsFromProvider('${key}')" style="background: #4a9eff;">
🔄 Get Models from Provider
</button>
<button type="button" class="btn btn-secondary" onclick="addModel('${key}')">Add Model Manually</button>
</div>
<div id="get-models-status-${key}" style="margin-top: 10px;"></div>
`;
renderModels(key);
......@@ -441,7 +521,8 @@ function updateNewProviderDefaults() {
'google': 'Google AI provider (Gemini). Uses API key authentication. Endpoint: https://generativelanguage.googleapis.com/v1beta',
'anthropic': 'Anthropic provider (Claude). Uses API key authentication. Endpoint: https://api.anthropic.com/v1',
'ollama': 'Ollama local provider. No API key required by default. Endpoint: http://localhost:11434/api',
'kiro': 'Kiro (Amazon Q Developer) provider. Uses Kiro credentials (IDE, CLI, or direct tokens). Endpoint: https://q.us-east-1.amazonaws.com'
'kiro': 'Kiro (Amazon Q Developer) provider. Uses Kiro credentials (IDE, CLI, or direct tokens). Endpoint: https://q.us-east-1.amazonaws.com',
'claude': 'Claude Code provider. Uses OAuth2 authentication (browser-based login). Endpoint: https://api.anthropic.com/v1'
};
descriptionEl.textContent = descriptions[providerType] || 'Standard provider configuration.';
......@@ -477,7 +558,7 @@ function confirmAddProvider() {
name: key,
endpoint: '',
type: providerType,
api_key_required: providerType !== 'kiro' && providerType !== 'ollama',
api_key_required: providerType !== 'kiro' && providerType !== 'ollama' && providerType !== 'claude',
rate_limit: 0,
default_rate_limit: 0,
models: []
......@@ -496,6 +577,12 @@ function confirmAddProvider() {
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 === 'openai') {
newProvider.endpoint = 'https://api.openai.com/v1';
newProvider.api_key = '';
......@@ -586,14 +673,27 @@ function updateProviderType(key, newType) {
client_id: '',
client_secret: ''
};
delete providersData[key].claude_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 !== 'kiro' && oldType === 'kiro') {
// Transitioning FROM kiro: remove kiro_config, set api_key_required to true
} 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;
// Set default endpoint for claude
if (!providersData[key].endpoint || providersData[key].endpoint === '') {
providersData[key].endpoint = 'https://api.anthropic.com/v1';
}
} else if (newType !== 'kiro' && newType !== 'claude' && (oldType === 'kiro' || oldType === 'claude')) {
// Transitioning FROM kiro/claude: 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;
}
// Re-render to show appropriate fields
......@@ -607,6 +707,460 @@ function updateKiroConfig(key, field, value) {
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;
}
// 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.origin;
// 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('/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="/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('/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('/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('/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>
</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('/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 = [];
......@@ -683,6 +1237,167 @@ async function saveProviders() {
}
}
async function uploadKiroFile(providerKey, fileType, file) {
if (!file) return;
const statusEl = document.getElementById(`kiro-upload-status-${providerKey}`);
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Uploading file...</p>';
const formData = new FormData();
formData.append('file', file);
formData.append('provider_key', providerKey);
formData.append('file_type', fileType);
try {
const response = await fetch('/dashboard/providers/upload-auth-file', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
statusEl.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ File uploaded successfully! Path: ${data.file_path}</p>`;
// Update the config with the new file path
if (fileType === 'creds_file') {
updateKiroConfig(providerKey, 'creds_file', data.file_path);
} else if (fileType === 'sqlite_db') {
updateKiroConfig(providerKey, 'sqlite_db', data.file_path);
}
} else {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Upload failed: ${data.error}</p>`;
}
} catch (error) {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
}
async function uploadClaudeFile(providerKey, file) {
if (!file) return;
const statusEl = document.getElementById(`claude-upload-status-${providerKey}`);
statusEl.innerHTML = '<p style="margin: 0; color: #4a9eff;">🔄 Uploading file...</p>';
const formData = new FormData();
formData.append('file', file);
formData.append('provider_key', providerKey);
formData.append('file_type', 'credentials');
try {
const response = await fetch('/dashboard/providers/upload-auth-file', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
statusEl.innerHTML = `<p style="margin: 0; color: #4eff9e;">✓ File uploaded successfully! Path: ${data.file_path}</p>`;
// Update the config with the new file path
updateClaudeConfig(providerKey, 'credentials_file', data.file_path);
} else {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Upload failed: ${data.error}</p>`;
}
} catch (error) {
statusEl.innerHTML = `<p style="margin: 0; color: #ff4a4a;">✗ Error: ${error.message}</p>`;
}
}
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('/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>`;
}
}
// Initial render
renderProvidersList();
</script>
......
......@@ -77,6 +77,48 @@ function renderProviders() {
<div class="provider-details">
<p><strong>Created:</strong> ${new Date(provider.created_at).toLocaleString()}</p>
<p><strong>Last Updated:</strong> ${new Date(provider.updated_at).toLocaleString()}</p>
${provider.config.type === 'claude' ? `
<div class="claude-auth-section">
<h4>Claude Authentication</h4>
<div id="claude-auth-status-${provider.provider_id}" style="display: none; margin: 10px 0; padding: 10px; border-radius: 4px;"></div>
<button class="btn btn-secondary" onclick="authenticateClaudeUser('${provider.provider_id}')">
Authenticate with Claude
</button>
<button class="btn btn-secondary" onclick="checkClaudeAuthUser('${provider.provider_id}')" style="margin-left: 10px;">
Check Status
</button>
<div class="auth-files-section" style="margin-top: 15px;">
<h5>Authentication Files</h5>
<div id="auth-files-${provider.provider_id}"></div>
<div class="upload-form" style="margin-top: 10px;">
<select id="file-type-${provider.provider_id}" style="margin-right: 10px; padding: 5px;">
<option value="claude_credentials">Claude Credentials</option>
<option value="config">Configuration</option>
</select>
<input type="file" id="file-input-${provider.provider_id}" style="margin-right: 10px;">
<button class="btn btn-secondary btn-sm" onclick="uploadAuthFile('${provider.provider_id}')">Upload</button>
</div>
</div>
</div>
` : ''}
${provider.config.type === 'kiro' ? `
<div class="kiro-auth-section">
<h4>Kiro Authentication</h4>
<div class="auth-files-section" style="margin-top: 15px;">
<h5>Authentication Files</h5>
<div id="auth-files-${provider.provider_id}"></div>
<div class="upload-form" style="margin-top: 10px;">
<select id="file-type-${provider.provider_id}" style="margin-right: 10px; padding: 5px;">
<option value="kiro_credentials">Kiro Credentials</option>
<option value="database">SQLite Database</option>
<option value="config">Configuration</option>
</select>
<input type="file" id="file-input-${provider.provider_id}" style="margin-right: 10px;">
<button class="btn btn-secondary btn-sm" onclick="uploadAuthFile('${provider.provider_id}')">Upload</button>
</div>
</div>
</div>
` : ''}
<details>
<summary>Configuration (JSON)</summary>
<pre>${JSON.stringify(provider.config, null, 2)}</pre>
......@@ -181,6 +223,179 @@ document.getElementById('provider-form').addEventListener('submit', function(e)
});
});
async function authenticateClaudeUser(providerId) {
const statusEl = document.getElementById(`claude-auth-status-${providerId}`);
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 {
const response = await fetch('/dashboard/claude/auth/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: providerId,
credentials_file: '~/.claude_credentials.json'
})
});
const data = await response.json();
if (data.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 failed: ${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>`;
}
}
async function checkClaudeAuthUser(providerId) {
const statusEl = document.getElementById(`claude-auth-status-${providerId}`);
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('/dashboard/claude/auth/status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_key: providerId,
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>`;
}
}
async function uploadAuthFile(providerId) {
const fileTypeSelect = document.getElementById(`file-type-${providerId}`);
const fileInput = document.getElementById(`file-input-${providerId}`);
if (!fileInput.files || fileInput.files.length === 0) {
alert('Please select a file to upload');
return;
}
const formData = new FormData();
formData.append('file_type', fileTypeSelect.value);
formData.append('file', fileInput.files[0]);
try {
const response = await fetch(`/dashboard/user/providers/${encodeURIComponent(providerId)}/upload`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (response.ok) {
alert('File uploaded successfully');
fileInput.value = '';
loadAuthFiles(providerId);
} else {
alert('Upload failed: ' + (data.error || 'Unknown error'));
}
} catch (error) {
alert('Error: ' + error.message);
}
}
async function loadAuthFiles(providerId) {
try {
const response = await fetch(`/dashboard/user/providers/${encodeURIComponent(providerId)}/files`);
const data = await response.json();
const container = document.getElementById(`auth-files-${providerId}`);
if (!container) return;
if (data.files && data.files.length > 0) {
container.innerHTML = data.files.map(file => `
<div class="auth-file-item" style="background: #0f3460; padding: 8px; margin: 5px 0; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
<span>
<strong>${file.file_type}</strong>: ${file.original_filename}
<span style="color: #a0a0a0; font-size: 0.9em;"> (${formatFileSize(file.file_size)})</span>
</span>
<span>
<a href="/dashboard/user/providers/${encodeURIComponent(providerId)}/files/${file.file_type}/download" class="btn btn-secondary btn-sm" style="margin-right: 5px;">Download</a>
<button class="btn btn-danger btn-sm" onclick="deleteAuthFile('${providerId}', '${file.file_type}')">Delete</button>
</span>
</div>
`).join('');
} else {
container.innerHTML = '<p style="color: #a0a0a0; font-style: italic;">No authentication files uploaded</p>';
}
} catch (error) {
console.error('Error loading auth files:', error);
}
}
async function deleteAuthFile(providerId, fileType) {
if (!confirm(`Are you sure you want to delete this ${fileType} file?`)) return;
try {
const response = await fetch(`/dashboard/user/providers/${encodeURIComponent(providerId)}/files/${fileType}`, {
method: 'DELETE'
});
if (response.ok) {
loadAuthFiles(providerId);
} else {
const data = await response.json();
alert('Delete failed: ' + (data.error || 'Unknown error'));
}
} catch (error) {
alert('Error: ' + error.message);
}
}
function formatFileSize(bytes) {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Load auth files for all providers on page load
document.addEventListener('DOMContentLoaded', function() {
providers.forEach(provider => {
if (provider.config.type === 'claude' || provider.config.type === 'kiro') {
loadAuthFiles(provider.provider_id);
}
});
});
renderProviders();
</script>
......
#!/usr/bin/env python3
"""
Test script to verify Kiro models retrieval with correct origin parameter.
"""
import asyncio
import sys
import os
import logging
# Add aisbf to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from aisbf.providers import KiroProviderHandler
from aisbf.config import config
# Enable debug logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
async def test_kiro_models():
"""Test Kiro models retrieval with origin='Cli'"""
print("=" * 80)
print("Testing Kiro Models Retrieval")
print("=" * 80)
# Get Kiro provider config
kiro_providers = [p for p in config.providers.keys() if config.providers[p].type == 'kiro']
if not kiro_providers:
print("ERROR: No Kiro providers configured")
return
provider_id = kiro_providers[0]
print(f"Using provider: {provider_id}")
print()
# Create handler
handler = KiroProviderHandler(provider_id, api_key=None)
# Get models
print("Calling get_models()...")
print()
try:
models = await handler.get_models()
print("=" * 80)
print(f"SUCCESS: Retrieved {len(models)} models")
print("=" * 80)
print()
for i, model in enumerate(models, 1):
print(f"{i}. {model.id}")
if model.name != model.id:
print(f" Name: {model.name}")
print()
print("=" * 80)
print("Test completed successfully!")
print("=" * 80)
except Exception as e:
print("=" * 80)
print(f"ERROR: {e}")
print("=" * 80)
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(test_kiro_models())
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