0.99.57

Bump version to 0.99.57; add Claude CLI proxy mode

- Add aisbf/cli_mode.py: detect claude binary in PATH at startup
- Add ClaudeCliSessionManager: per-user isolated temp config dirs with
  10-minute idle cleanup and asyncio subprocess-based request proxying
- Add ClaudeProviderHandler CLI methods: _get_cli_credentials,
  _messages_to_cli_prompt, _handle_cli_streaming_request,
  _handle_cli_request, _oauth_tokens_to_cli_credentials
- Wire CLI mode check into _handle_request_with_model; falls through to
  HTTP API mode when no CLI credentials are configured
- Add 'Use Claude CLI mode' checkbox in provider config (both admin and
  user templates): derives credentials from existing OAuth2 tokens
- Add explicit CLI credentials file upload (file_type=cli_credentials);
  DB users: stored in user_oauth2_credentials; admin: path in providers.json
- Update Claude provider warning notices to scope risk to HTTP API mode
  and clarify that claude -p is permitted by Claude's terms of service
- Update CHANGELOG.md, DOCUMENTATION.md, README.md
parent 8fed50ce
...@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ...@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.99.57] - 2026-04-23
### Added
- **Claude CLI Mode** (`aisbf/cli_mode.py`, `aisbf/providers/claude.py`)
- At startup, AISBF checks for the `claude` binary in PATH via `shutil.which`; if found, CLI proxy mode is enabled globally
- New `ClaudeCliSessionManager` class manages per-user isolated temporary config directories (`CLAUDE_CONFIG_DIR`) containing `settings.json` (onboarding bypass) and `.credentials.json`
- Session directories are reused across requests and automatically cleaned up after 10 minutes of idle time; the background cleanup task runs every 60 seconds
- Each request spawns a dedicated `asyncio.create_subprocess_exec` subprocess running `claude -p --input-format stream-json --output-format stream-json --tools "" --dangerously-skip-permissions --no-session-persistence --verbose --model <model>`; multiple parallel requests are fully non-blocking
- Streaming output is parsed from the CLI's `stream-json` format and re-emitted as OpenAI-compatible SSE chunks
- Non-streaming requests collect all `text_delta` events and return a standard `chat.completion` response
- Messages are converted to a flat text prompt (system instructions as prefix, then `Human:`/`Assistant:` turns) — no Anthropic billing-header injection needed in CLI mode
- CLI mode falls through gracefully to HTTP API mode if no CLI credentials are configured for the requesting user
- **"Use Claude CLI mode" checkbox** (provider configuration UI)
- New `claude_config.use_cli_mode` boolean field in the Claude provider configuration
- When checked, AISBF derives CLI credentials automatically from the user's existing OAuth2 tokens already stored in AISBF, converting them to the `claudeAiOauth` schema expected by the CLI (`expires_at` seconds → `expiresAt` milliseconds, `scope` string → `scopes` list)
- No separate file upload required when OAuth2 authentication has already been completed
- Available in both admin (`providers.html`) and user (`user_providers.html`) provider configuration pages, shown only when the `claude` binary is detected at startup
- **Explicit CLI credentials file upload**
- Users can upload their own `~/.claude/.credentials.json` to use a specific Claude account independently of their AISBF OAuth2 session
- Uploaded file takes priority over OAuth2-derived credentials
- Admin: file path saved in `providers.json` under `claude_config.cli_credentials_file`
- Database users (all roles): credentials JSON content stored in `user_oauth2_credentials` with `auth_type='claude_cli_credentials'`; temp file removed after content is persisted to DB
- **`ClaudeProviderHandler._oauth_tokens_to_cli_credentials()`** static method
- Converts AISBF's internal OAuth2 token dict to the `claudeAiOauth` credentials schema used by the Claude CLI
- Handles missing `scope` by defaulting to the standard five-scope set: `user:inference`, `user:file_upload`, `user:profile`, `user:mcp_servers`, `user:sessions:claude_code`
### Changed
- **Claude provider warning notices** updated in both `providers.html` and `user_providers.html`
- Warning now explicitly scopes the "unofficial client" risk to HTTP API / OAuth2 mode
- Added a green ✅ note clarifying that CLI mode uses the official `claude -p` interface, which is the intended programmatic use of the Anthropic CLI and is permitted by Claude's terms of service
## [0.99.49] - 2026-04-21 ## [0.99.49] - 2026-04-21
### Added ### Added
......
...@@ -6,9 +6,10 @@ AISBF is a modular proxy server for managing multiple AI provider integrations. ...@@ -6,9 +6,10 @@ AISBF is a modular proxy server for managing multiple AI provider integrations.
### Key Features ### Key Features
- **Multi-Provider Support**: Unified interface for Google, OpenAI, Anthropic, Claude Code (OAuth2), Ollama, Kiro (Amazon Q Developer), Kilocode (OAuth2), Codex (OAuth2), and Qwen (API Key/OAuth2) - **Multi-Provider Support**: Unified interface for Google, OpenAI, Anthropic, Claude Code (OAuth2 or CLI), Ollama, Kiro (Amazon Q Developer), Kilocode (OAuth2), Codex (OAuth2), and Qwen (API Key/OAuth2)
- **Unified Wallet System**: Fiat wallet with crypto/PayPal/Stripe top-ups and auto top-up for subscription renewals - **Unified Wallet System**: Fiat wallet with crypto/PayPal/Stripe top-ups and auto top-up for subscription renewals
- **Claude OAuth2 Authentication**: Full OAuth2 PKCE flow for Claude Code with automatic token refresh, Chrome extension for remote servers, and curl_cffi TLS fingerprinting support - **Claude OAuth2 Authentication**: Full OAuth2 PKCE flow for Claude Code with automatic token refresh, Chrome extension for remote servers, and curl_cffi TLS fingerprinting support
- **Claude CLI Mode**: When the `claude` binary is present in PATH at startup, AISBF automatically enables CLI proxy mode — requests are piped through the official Anthropic CLI (`claude -p`) instead of the HTTP API, using per-user isolated config directories with a 10-minute idle cleanup
- **Kiro-cli Support**: Full support for Amazon Q Developer CLI authentication with Device Authorization Grant - **Kiro-cli Support**: Full support for Amazon Q Developer CLI authentication with Device Authorization Grant
- **Kilocode OAuth2 Authentication**: OAuth2 Device Authorization Grant for Kilo Code with automatic token refresh - **Kilocode OAuth2 Authentication**: OAuth2 Device Authorization Grant for Kilo Code with automatic token refresh
- **Codex OAuth2 Authentication**: OAuth2 Device Authorization Grant for OpenAI Codex with automatic token refresh and API key exchange - **Codex OAuth2 Authentication**: OAuth2 Device Authorization Grant for OpenAI Codex with automatic token refresh and API key exchange
...@@ -519,12 +520,46 @@ AISBF automatically extracts and tracks model metadata from provider responses: ...@@ -519,12 +520,46 @@ AISBF automatically extracts and tracks model metadata from provider responses:
- Static model list (no dynamic model discovery) - Static model list (no dynamic model discovery)
- cache_control support for cost reduction - cache_control support for cost reduction
### Claude Code (OAuth2) ### Claude Code (OAuth2 / CLI)
AISBF supports two modes for the Claude provider, selected automatically at runtime:
#### HTTP API / OAuth2 mode (default)
- Full OAuth2 PKCE authentication flow - Full OAuth2 PKCE authentication flow
- Automatic token refresh with refresh token rotation - Automatic token refresh with refresh token rotation
- Chrome extension for remote server OAuth2 callback interception - Chrome extension for remote server OAuth2 callback interception
- Proxy-aware extension serving: automatically detects reverse proxy deployments - Proxy-aware extension serving: automatically detects reverse proxy deployments
- Supports all Claude models with streaming, tool calling, vision, and extended thinking - Supports all Claude models with streaming, tool calling, vision, and extended thinking
- **Note**: this mode uses an unofficial client interface; use at your own risk as per Claude's terms of service
#### CLI proxy mode (requires `claude` in PATH)
- Activated automatically at startup when the `claude` binary is found in PATH
- Requests are proxied through the official Anthropic CLI using `claude -p --input-format stream-json --output-format stream-json`
- Uses the official CLI as intended by Anthropic — permitted by Claude's terms of service
- Per-user isolated temporary config directories (`CLAUDE_CONFIG_DIR`) prevent credential cross-contamination
- Idle session cleanup: temp dirs are removed after 10 minutes of inactivity; active requests always get a fresh subprocess
- Multiple parallel requests are handled concurrently via `asyncio.create_subprocess_exec`
- Credentials can be supplied in two ways:
- **"Use Claude CLI mode" checkbox**: derives credentials automatically from existing OAuth2 tokens already stored in AISBF, converting them to the `claudeAiOauth` schema expected by the CLI
- **Explicit upload**: users can upload their own `~/.claude/.credentials.json` file to use a specific account (takes priority over OAuth2-derived credentials)
- Admin (config-file user): CLI credentials file path stored in `providers.json` under `claude_config.cli_credentials_file`
- Database users (all roles): CLI credentials stored in `user_oauth2_credentials` with `auth_type='claude_cli_credentials'`
##### Credentials file schema
The CLI expects a `.credentials.json` with this structure:
```json
{
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-...",
"refreshToken": "sk-ant-ort01-...",
"expiresAt": 1776940481301,
"scopes": ["user:inference", "user:file_upload", "user:profile", "user:mcp_servers", "user:sessions:claude_code"],
"subscriptionType": "pro",
"rateLimitTier": "default_claude_ai"
}
}
```
When "Use Claude CLI mode" is checked without an explicit file upload, AISBF converts the stored OAuth2 tokens to this format automatically (`expires_at` seconds → `expiresAt` milliseconds, `scope` string → `scopes` list).
### Ollama ### Ollama
- Uses direct HTTP API - Uses direct HTTP API
......
...@@ -19,7 +19,8 @@ Also available via TOR for privacy-first access: ...@@ -19,7 +19,8 @@ Also available via TOR for privacy-first access:
## Key Features ## Key Features
- **Multi-Provider Support**: Unified interface for Google, OpenAI, Anthropic, Claude Code (OAuth2), Ollama, Kiro, Kilocode, Codex, and Qwen - **Multi-Provider Support**: Unified interface for Google, OpenAI, Anthropic, Claude Code (OAuth2 or CLI), Ollama, Kiro, Kilocode, Codex, and Qwen
- **Claude CLI Mode**: When the `claude` binary is in PATH, requests are proxied through the official Anthropic CLI (`claude -p`) — uses each user's own account, fully permitted by Claude's terms of service
- **Unified Wallet System**: Fiat wallet with crypto/PayPal/Stripe top-ups and auto top-up for subscription renewals - **Unified Wallet System**: Fiat wallet with crypto/PayPal/Stripe top-ups and auto top-up for subscription renewals
- **Intelligent Routing**: Weighted load balancing and AI-assisted model selection - **Intelligent Routing**: Weighted load balancing and AI-assisted model selection
- **Streaming Support**: Full support for streaming responses from all providers - **Streaming Support**: Full support for streaming responses from all providers
......
...@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2 ...@@ -54,7 +54,7 @@ from .auth.qwen import QwenOAuth2
from .handlers import RequestHandler, RotationHandler, AutoselectHandler from .handlers import RequestHandler, RotationHandler, AutoselectHandler
from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model
__version__ = "0.99.56" __version__ = "0.99.57"
__all__ = [ __all__ = [
# Config # Config
"config", "config",
......
"""
Copyleft (C) 2026 Stefy Lanza <stefy@nexlab.net>
AISBF - AI Service Broker Framework || AI Should Be Free
Claude CLI mode detection module.
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/>.
"""
import shutil
from typing import Optional
CLAUDE_CLI_MODE: bool = False
CLAUDE_CLI_PATH: Optional[str] = None
def detect_claude_cli() -> bool:
global CLAUDE_CLI_MODE, CLAUDE_CLI_PATH
path = shutil.which('claude')
if path:
CLAUDE_CLI_MODE = True
CLAUDE_CLI_PATH = path
return True
return False
...@@ -24,13 +24,108 @@ import httpx ...@@ -24,13 +24,108 @@ import httpx
import asyncio import asyncio
import time import time
import random import random
from typing import Dict, List, Optional, Union, Any import os
import json
import shutil
import tempfile
import logging as _logging
from typing import Dict, List, Optional, Union, Any, Tuple
from anthropic import Anthropic from anthropic import Anthropic
from ..models import Model from ..models import Model
from ..config import config from ..config import config
from .base import BaseProviderHandler, AnthropicFormatConverter, AISBF_DEBUG from .base import BaseProviderHandler, AnthropicFormatConverter, AISBF_DEBUG
class ClaudeCliSessionManager:
"""
Manages per-user temporary config directories for the claude CLI subprocess.
Each (user_id, provider_id) pair gets its own temp dir containing
settings.json and .credentials.json. The dir is reused across requests
and cleaned up after 10 minutes of idle time.
"""
_sessions: Dict[Tuple, Dict] = {}
_cleanup_task: Optional[asyncio.Task] = None
_lock: Optional[asyncio.Lock] = None
_IDLE_TIMEOUT = 600 # 10 minutes
@classmethod
def _get_lock(cls) -> asyncio.Lock:
if cls._lock is None:
cls._lock = asyncio.Lock()
return cls._lock
@classmethod
async def get_config_dir(cls, user_id, provider_id: str, cli_credentials: dict) -> str:
"""Return (creating if needed) the temp config dir for this user/provider."""
logger = _logging.getLogger(__name__)
key = (user_id, provider_id)
async with cls._get_lock():
if key in cls._sessions:
session = cls._sessions[key]
if os.path.exists(session['config_dir']):
session['last_used'] = time.time()
logger.debug(f"ClaudeCliMode: reusing session dir for {key}")
return session['config_dir']
del cls._sessions[key]
temp_dir = tempfile.mkdtemp(prefix='aisbf_claude_cli_')
# settings.json – prevents interactive onboarding hang
with open(os.path.join(temp_dir, 'settings.json'), 'w') as fh:
json.dump({
'hasCompletedOnboarding': True,
'telemetry': 'off',
'theme': 'dark',
}, fh, indent=2)
# .credentials.json – the user's Claude CLI OAuth tokens
with open(os.path.join(temp_dir, '.credentials.json'), 'w') as fh:
json.dump(cli_credentials, fh, indent=2)
cls._sessions[key] = {'config_dir': temp_dir, 'last_used': time.time()}
logger.info(f"ClaudeCliMode: created session dir {temp_dir} for {key}")
cls._ensure_cleanup_task()
return temp_dir
@classmethod
def _ensure_cleanup_task(cls):
try:
loop = asyncio.get_event_loop()
if cls._cleanup_task is None or cls._cleanup_task.done():
cls._cleanup_task = loop.create_task(cls._cleanup_loop())
except RuntimeError:
pass
@classmethod
async def _cleanup_loop(cls):
logger = _logging.getLogger(__name__)
while True:
await asyncio.sleep(60)
async with cls._get_lock():
if not cls._sessions:
cls._cleanup_task = None
return
now = time.time()
expired = [k for k, s in cls._sessions.items()
if now - s['last_used'] > cls._IDLE_TIMEOUT]
for key in expired:
session = cls._sessions.pop(key)
try:
shutil.rmtree(session['config_dir'], ignore_errors=True)
logger.info(
f"ClaudeCliMode: cleaned up idle session "
f"{session['config_dir']} for {key}"
)
except Exception as exc:
logger.warning(
f"ClaudeCliMode: cleanup error for "
f"{session['config_dir']}: {exc}"
)
class ClaudeProviderHandler(BaseProviderHandler): class ClaudeProviderHandler(BaseProviderHandler):
""" """
Handler for Claude Code OAuth2 integration using Anthropic SDK. Handler for Claude Code OAuth2 integration using Anthropic SDK.
...@@ -155,7 +250,294 @@ class ClaudeProviderHandler(BaseProviderHandler): ...@@ -155,7 +250,294 @@ class ClaudeProviderHandler(BaseProviderHandler):
# For regular users, NO file fallback - return empty auth instance # For regular users, NO file fallback - return empty auth instance
logging.getLogger(__name__).info(f"ClaudeProviderHandler: No database credentials found for user {self.user_id}, returning unauthenticated instance") logging.getLogger(__name__).info(f"ClaudeProviderHandler: No database credentials found for user {self.user_id}, returning unauthenticated instance")
return ClaudeAuth(credentials_file=credentials_file, skip_initial_load=True) return ClaudeAuth(credentials_file=credentials_file, skip_initial_load=True)
# ------------------------------------------------------------------ #
# Claude CLI mode helpers #
# ------------------------------------------------------------------ #
@staticmethod
def _oauth_tokens_to_cli_credentials(tokens: dict) -> dict:
"""
Convert AISBF OAuth2 token dict to the Claude CLI .credentials.json schema:
AISBF stores: access_token, refresh_token, expires_at (seconds float), scope
CLI expects: claudeAiOauth.accessToken, .refreshToken, .expiresAt (ms int),
.scopes (list), .subscriptionType, .rateLimitTier
"""
default_scopes = [
'user:file_upload',
'user:inference',
'user:mcp_servers',
'user:profile',
'user:sessions:claude_code',
]
raw_scope = tokens.get('scope', '')
scopes = raw_scope.split() if raw_scope.strip() else default_scopes
expires_at_sec = tokens.get('expires_at', 0)
expires_at_ms = int(expires_at_sec * 1000) if expires_at_sec else 0
return {
'claudeAiOauth': {
'accessToken': tokens.get('access_token', ''),
'refreshToken': tokens.get('refresh_token', ''),
'expiresAt': expires_at_ms,
'scopes': scopes,
'subscriptionType': tokens.get('subscription_type', 'pro'),
'rateLimitTier': tokens.get('rate_limit_tier', 'default_claude_ai'),
}
}
def _get_cli_credentials(self) -> Optional[dict]:
"""
Return the Claude CLI .credentials.json content for this user/provider,
or None if CLI credentials are not configured.
Priority order:
1. Explicit CLI credentials file (admin) or uploaded CLI credentials (DB user)
2. If claude_config.use_cli_mode is true, derive from existing OAuth2 tokens
"""
logger = _logging.getLogger(__name__)
if isinstance(self.provider_config, dict):
claude_cfg = self.provider_config.get('claude_config', {}) or {}
else:
claude_cfg = getattr(self.provider_config, 'claude_config', {}) or {}
use_cli_mode = bool(claude_cfg.get('use_cli_mode')) if isinstance(claude_cfg, dict) else False
if self.user_id is None:
# ── Config admin ──────────────────────────────────────────────
cli_file = claude_cfg.get('cli_credentials_file') if isinstance(claude_cfg, dict) else None
if cli_file:
expanded = os.path.expanduser(cli_file)
if not os.path.exists(expanded):
logger.warning(f"ClaudeCliMode: CLI credentials file not found: {expanded}")
else:
try:
with open(expanded) as fh:
return json.load(fh)
except Exception as exc:
logger.warning(f"ClaudeCliMode: failed to read CLI credentials file: {exc}")
# Fall back: derive from existing OAuth2 tokens when use_cli_mode is set
if use_cli_mode and self.auth and self.auth.tokens:
logger.info("ClaudeCliMode: building CLI credentials from existing OAuth2 tokens (admin)")
return self._oauth_tokens_to_cli_credentials(self.auth.tokens)
return None
else:
# ── DB user ───────────────────────────────────────────────────
try:
from ..database import DatabaseRegistry
db = DatabaseRegistry.get_config_database()
if db:
# 1. Check for explicit uploaded CLI credentials
row = db.get_user_oauth2_credentials(
user_id=self.user_id,
provider_id=self.provider_id,
auth_type='claude_cli_credentials',
)
if row and row.get('credentials'):
return row['credentials'].get('credentials')
# 2. Derive from existing OAuth2 tokens when use_cli_mode is set
if use_cli_mode:
oauth_row = db.get_user_oauth2_credentials(
user_id=self.user_id,
provider_id=self.provider_id,
auth_type='claude_oauth2',
)
if oauth_row and oauth_row.get('credentials'):
tokens = oauth_row['credentials'].get('tokens', {})
if tokens:
logger.info(
f"ClaudeCliMode: building CLI credentials from "
f"OAuth2 tokens for user {self.user_id}"
)
return self._oauth_tokens_to_cli_credentials(tokens)
except Exception as exc:
logger.warning(f"ClaudeCliMode: failed to load credentials: {exc}")
return None
def _messages_to_cli_prompt(self, messages: List[Dict]) -> str:
"""
Convert an OpenAI-style messages list to a flat text prompt for the
claude CLI. System messages are included as a prefix; no Anthropic
system-prompt injection is needed in CLI mode.
"""
system_parts: List[str] = []
turn_parts: List[str] = []
for msg in messages:
role = msg.get('role', '')
content = msg.get('content', '')
# Normalise content to str
if isinstance(content, list):
text_fragments = []
for block in content:
if isinstance(block, dict) and block.get('type') == 'text':
text_fragments.append(block.get('text', ''))
elif isinstance(block, str):
text_fragments.append(block)
content = '\n'.join(text_fragments)
elif not isinstance(content, str):
content = str(content)
if role == 'system':
system_parts.append(content.strip())
elif role == 'user':
turn_parts.append(f'Human: {content}')
elif role == 'assistant':
turn_parts.append(f'Assistant: {content}')
parts: List[str] = []
if system_parts:
parts.append('[System Instructions: ' + '\n'.join(system_parts) + ']')
parts.extend(turn_parts)
return '\n\n'.join(parts)
async def _handle_cli_streaming_request(self, prompt: str, model: str, config_dir: str):
"""
Spawn a claude CLI subprocess, stream its JSON output, and yield
OpenAI-compatible SSE chunks. Multiple parallel calls each get their
own subprocess; the config_dir is shared (read-only at runtime).
"""
logger = _logging.getLogger(__name__)
clean_model = model.split('/')[-1] if '/' in model else model
env = os.environ.copy()
env['CLAUDE_CONFIG_DIR'] = config_dir
env['CLAUDE_CODE_USE_KEYCHAIN'] = 'false'
cmd = [
'claude', '-p',
'--input-format', 'stream-json',
'--output-format', 'stream-json',
'--tools', '',
'--dangerously-skip-permissions',
'--no-session-persistence',
'--verbose',
]
if clean_model:
cmd += ['--model', clean_model]
logger.info(f"ClaudeCliMode: launching subprocess model={clean_model} dir={config_dir}")
process = await asyncio.create_subprocess_exec(
*cmd,
env=env,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# Send the prompt as a stream-json user_message then close stdin
input_msg = json.dumps({
'type': 'user_message',
'content': [{'type': 'text', 'text': prompt}],
}) + '\n'
process.stdin.write(input_msg.encode())
await process.stdin.drain()
process.stdin.close()
completion_id = f'chatcmpl-cli-{int(time.time())}'
created_time = int(time.time())
first_chunk = True
try:
while True:
try:
raw = await asyncio.wait_for(process.stdout.readline(), timeout=120.0)
except asyncio.TimeoutError:
logger.error("ClaudeCliMode: subprocess read timeout (120 s)")
break
if not raw:
break
line_str = raw.decode('utf-8', errors='replace').strip()
if not line_str:
continue
try:
data = json.loads(line_str)
except json.JSONDecodeError:
continue
event_type = data.get('type')
if event_type == 'content_block_delta':
delta = data.get('delta', {})
if delta.get('type') == 'text_delta':
text = delta.get('text', '')
if not text:
continue
if first_chunk:
yield f'data: {json.dumps({"id": completion_id, "object": "chat.completion.chunk", "created": created_time, "model": f"{self.provider_id}/{clean_model}", "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}]})}\n\n'
first_chunk = False
yield f'data: {json.dumps({"id": completion_id, "object": "chat.completion.chunk", "created": created_time, "model": f"{self.provider_id}/{clean_model}", "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": None}]})}\n\n'
elif event_type in ('message_stop', 'result'):
logger.debug(f"ClaudeCliMode: received {event_type}, closing stream")
break
except Exception as exc:
logger.error(f"ClaudeCliMode: streaming error: {exc}", exc_info=True)
try:
stderr_bytes = await asyncio.wait_for(process.stderr.read(), timeout=2.0)
if stderr_bytes:
logger.error(f"ClaudeCliMode: stderr: {stderr_bytes.decode('utf-8', errors='replace')[:500]}")
except Exception:
pass
finally:
try:
process.terminate()
await asyncio.wait_for(process.wait(), timeout=5.0)
except Exception:
try:
process.kill()
except Exception:
pass
# Final stop + DONE
yield f'data: {json.dumps({"id": completion_id, "object": "chat.completion.chunk", "created": created_time, "model": f"{self.provider_id}/{clean_model}", "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]})}\n\n'
yield 'data: [DONE]\n\n'
async def _handle_cli_request(self, prompt: str, model: str, config_dir: str) -> dict:
"""Non-streaming CLI request – collects the full response text."""
accumulated: List[str] = []
async for chunk_str in self._handle_cli_streaming_request(prompt, model, config_dir):
if chunk_str.startswith('data: ') and '[DONE]' not in chunk_str:
try:
data = json.loads(chunk_str[6:])
choices = data.get('choices', [])
if choices:
text = choices[0].get('delta', {}).get('content', '')
if text:
accumulated.append(text)
except json.JSONDecodeError:
pass
clean_model = model.split('/')[-1] if '/' in model else model
full_text = ''.join(accumulated)
return {
'id': f'chatcmpl-cli-{int(time.time())}',
'object': 'chat.completion',
'created': int(time.time()),
'model': f'{self.provider_id}/{clean_model}',
'choices': [{
'index': 0,
'message': {'role': 'assistant', 'content': full_text},
'finish_reason': 'stop',
}],
'usage': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0},
}
def _init_session_identifiers(self): def _init_session_identifiers(self):
"""Initialize persistent session identifiers (device_id, account_uuid, session_id).""" """Initialize persistent session identifiers (device_id, account_uuid, session_id)."""
import uuid import uuid
...@@ -832,13 +1214,33 @@ class ClaudeProviderHandler(BaseProviderHandler): ...@@ -832,13 +1214,33 @@ class ClaudeProviderHandler(BaseProviderHandler):
async def _handle_request_with_model(self, model: str, messages: List[Dict], max_tokens: Optional[int] = None, async def _handle_request_with_model(self, model: str, messages: List[Dict], max_tokens: Optional[int] = None,
temperature: Optional[float] = 1.0, stream: Optional[bool] = False, temperature: Optional[float] = 1.0, stream: Optional[bool] = False,
tools: Optional[List[Dict]] = None, tool_choice: Optional[Union[str, Dict]] = None) -> Union[Dict, object]: tools: Optional[List[Dict]] = None, tool_choice: Optional[Union[str, Dict]] = None) -> Union[Dict, object]:
"""Handle request with a specific model using direct HTTP requests.""" """Handle request with a specific model.
When the claude CLI is detected at startup and the user has CLI
credentials configured, the request is proxied through a subprocess
instead of calling the Anthropic HTTP API directly.
"""
import logging import logging
import json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Claude CLI mode ──────────────────────────────────────────────
import aisbf.cli_mode as cli_mode_mod
if cli_mode_mod.CLAUDE_CLI_MODE:
cli_creds = self._get_cli_credentials()
if cli_creds is not None:
logger.info(f"ClaudeProviderHandler: using CLI subprocess mode for model {model}")
prompt = self._messages_to_cli_prompt(messages)
config_dir = await ClaudeCliSessionManager.get_config_dir(
self.user_id, self.provider_id, cli_creds
)
if stream:
return self._handle_cli_streaming_request(prompt, model, config_dir)
else:
return await self._handle_cli_request(prompt, model, config_dir)
# ── Fall through to HTTP API mode ────────────────────────────────
logger.info(f"ClaudeProviderHandler: Handling request for model {model} (Direct HTTP mode)") logger.info(f"ClaudeProviderHandler: Handling request for model {model} (Direct HTTP mode)")
if AISBF_DEBUG: if AISBF_DEBUG:
logger.info(f"ClaudeProviderHandler: Messages: {messages}") logger.info(f"ClaudeProviderHandler: Messages: {messages}")
else: else:
......
...@@ -663,6 +663,7 @@ autoselect_handler = None ...@@ -663,6 +663,7 @@ autoselect_handler = None
server_config = None server_config = None
config = None config = None
_initialized = False _initialized = False
_claude_cli_mode = False
# Cache for user-specific handlers to avoid recreating them # Cache for user-specific handlers to avoid recreating them
_user_handlers_cache = {} _user_handlers_cache = {}
...@@ -712,20 +713,30 @@ _background_tasks: set = set() ...@@ -712,20 +713,30 @@ _background_tasks: set = set()
def initialize_app(custom_config_dir=None): def initialize_app(custom_config_dir=None):
"""Initialize app globals. Called by startup event or main().""" """Initialize app globals. Called by startup event or main()."""
global config, request_handler, rotation_handler, autoselect_handler, server_config, _initialized global config, request_handler, rotation_handler, autoselect_handler, server_config, _initialized, _claude_cli_mode
if _initialized: if _initialized:
return return
# Set custom config directory if provided # Set custom config directory if provided
if custom_config_dir: if custom_config_dir:
set_config_dir(custom_config_dir) set_config_dir(custom_config_dir)
logger.info(f"Using custom config directory: {custom_config_dir}") logger.info(f"Using custom config directory: {custom_config_dir}")
# Detect claude CLI in PATH and enable CLI proxy mode if found
import shutil as _shutil
from aisbf.cli_mode import detect_claude_cli
if detect_claude_cli():
_claude_cli_mode = True
import aisbf.cli_mode as _cli_mode_mod
logger.info(f"Claude CLI detected at {_cli_mode_mod.CLAUDE_CLI_PATH} – CLI proxy mode enabled")
else:
logger.info("Claude CLI not found in PATH – using HTTP API mode")
# Import config # Import config
from aisbf.config import config as cfg from aisbf.config import config as cfg
from aisbf.handlers import RequestHandler, RotationHandler, AutoselectHandler from aisbf.handlers import RequestHandler, RotationHandler, AutoselectHandler
config = cfg config = cfg
request_handler = RequestHandler() request_handler = RequestHandler()
rotation_handler = RotationHandler() rotation_handler = RotationHandler()
...@@ -4387,6 +4398,7 @@ async def dashboard_providers(request: Request): ...@@ -4387,6 +4398,7 @@ async def dashboard_providers(request: Request):
"session": request.session, "session": request.session,
"__version__": __version__, "__version__": __version__,
"providers_json": json.dumps(providers_data), "providers_json": json.dumps(providers_data),
"claude_cli_mode": _claude_cli_mode,
"success": "Configuration saved successfully! Restart server for changes to take effect." if success else None "success": "Configuration saved successfully! Restart server for changes to take effect." if success else None
} }
) )
...@@ -4401,6 +4413,7 @@ async def dashboard_providers(request: Request): ...@@ -4401,6 +4413,7 @@ async def dashboard_providers(request: Request):
"__version__": __version__, "__version__": __version__,
"user_providers_json": json.dumps(providers_data), "user_providers_json": json.dumps(providers_data),
"user_id": current_user_id, "user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode,
"success": "Configuration saved successfully!" if success else None "success": "Configuration saved successfully!" if success else None
} }
) )
...@@ -4607,12 +4620,13 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)): ...@@ -4607,12 +4620,13 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"session": request.session, "session": request.session,
"__version__": __version__, "__version__": __version__,
"providers_json": json.dumps(providers_data), "providers_json": json.dumps(providers_data),
"claude_cli_mode": _claude_cli_mode,
"success": success_msg "success": success_msg
} }
) )
else: else:
success_msg = "Configuration saved successfully!" success_msg = "Configuration saved successfully!"
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="dashboard/user_providers.html", name="dashboard/user_providers.html",
...@@ -4622,6 +4636,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)): ...@@ -4622,6 +4636,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"__version__": __version__, "__version__": __version__,
"user_providers_json": json.dumps(providers_data), "user_providers_json": json.dumps(providers_data),
"user_id": current_user_id, "user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode,
"success": success_msg "success": success_msg
} }
) )
...@@ -4629,20 +4644,20 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)): ...@@ -4629,20 +4644,20 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
# Reload current config on error # Reload current config on error
current_user_id = request.session.get('user_id') current_user_id = request.session.get('user_id')
is_config_admin = current_user_id is None is_config_admin = current_user_id is None
if is_config_admin: if is_config_admin:
config_path = Path.home() / '.aisbf' / 'providers.json' config_path = Path.home() / '.aisbf' / 'providers.json'
if not config_path.exists(): if not config_path.exists():
config_path = Path(__file__).parent / 'config' / 'providers.json' config_path = Path(__file__).parent / 'config' / 'providers.json'
with open(config_path) as f: with open(config_path) as f:
full_config = json.load(f) full_config = json.load(f)
# Extract providers # Extract providers
if 'providers' in full_config and isinstance(full_config['providers'], dict): if 'providers' in full_config and isinstance(full_config['providers'], dict):
providers_data = full_config['providers'] providers_data = full_config['providers']
else: else:
providers_data = {k: v for k, v in full_config.items() if k != 'condensation'} providers_data = {k: v for k, v in full_config.items() if k != 'condensation'}
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="dashboard/providers.html", name="dashboard/providers.html",
...@@ -4651,13 +4666,14 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)): ...@@ -4651,13 +4666,14 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"session": request.session, "session": request.session,
"__version__": __version__, "__version__": __version__,
"providers_json": json.dumps(providers_data), "providers_json": json.dumps(providers_data),
"claude_cli_mode": _claude_cli_mode,
"error": f"Invalid JSON: {str(e)}" "error": f"Invalid JSON: {str(e)}"
} }
) )
else: else:
db = DatabaseRegistry.get_config_database() db = DatabaseRegistry.get_config_database()
user_providers = db.get_user_providers(current_user_id) user_providers = db.get_user_providers(current_user_id)
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="dashboard/user_providers.html", name="dashboard/user_providers.html",
...@@ -4667,6 +4683,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)): ...@@ -4667,6 +4683,7 @@ async def dashboard_providers_save(request: Request, config: str = Form(...)):
"__version__": __version__, "__version__": __version__,
"user_providers_json": json.dumps(user_providers), "user_providers_json": json.dumps(user_providers),
"user_id": current_user_id, "user_id": current_user_id,
"claude_cli_mode": _claude_cli_mode,
"error": f"Invalid JSON: {str(e)}" "error": f"Invalid JSON: {str(e)}"
} }
) )
...@@ -6321,7 +6338,7 @@ async def dashboard_provider_upload( ...@@ -6321,7 +6338,7 @@ async def dashboard_provider_upload(
try: try:
# Validate file type # Validate file type
allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials', 'sqlite_db', 'creds_file'] allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials', 'sqlite_db', 'creds_file', 'cli_credentials']
if file_type not in allowed_types: if file_type not in allowed_types:
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
...@@ -6486,7 +6503,7 @@ async def dashboard_provider_upload_form( ...@@ -6486,7 +6503,7 @@ async def dashboard_provider_upload_form(
try: try:
# Validate file type # Validate file type
allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials', 'sqlite_db', 'creds_file'] allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials', 'sqlite_db', 'creds_file', 'cli_credentials']
if file_type not in allowed_types: if file_type not in allowed_types:
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
...@@ -6582,7 +6599,7 @@ async def dashboard_provider_upload_chunk( ...@@ -6582,7 +6599,7 @@ async def dashboard_provider_upload_chunk(
try: try:
# Validate file type # Validate file type
allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials', 'sqlite_db', 'creds_file'] allowed_types = ['credentials', 'database', 'config', 'kiro_credentials', 'claude_credentials', 'sqlite_db', 'creds_file', 'cli_credentials']
if file_type not in allowed_types: if file_type not in allowed_types:
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
...@@ -6636,16 +6653,38 @@ async def dashboard_provider_upload_chunk( ...@@ -6636,16 +6653,38 @@ async def dashboard_provider_upload_chunk(
# Save metadata to database if not admin # Save metadata to database if not admin
if not is_config_admin: if not is_config_admin:
db = DatabaseRegistry.get_config_database() db = DatabaseRegistry.get_config_database()
db.save_user_auth_file(
user_id=current_user_id, # CLI credentials for claude providers go directly into
provider_id=provider_key, # user_oauth2_credentials so the provider handler can read them
file_type=file_type, # without touching the filesystem at request time.
original_filename=file_name, if file_type == 'cli_credentials':
stored_filename=stored_filename, try:
file_path=str(file_path), with open(file_path, 'r') as fh:
file_size=total_size, cli_creds_content = json.load(fh)
mime_type=file.content_type db.save_user_oauth2_credentials(
) user_id=current_user_id,
provider_id=provider_key,
auth_type='claude_cli_credentials',
credentials={'credentials': cli_creds_content},
)
file_path.unlink(missing_ok=True)
logger.info(
f"Stored CLI credentials for user {current_user_id} "
f"provider {provider_key} in user_oauth2_credentials"
)
except Exception as exc:
logger.error(f"Failed to save CLI credentials to DB: {exc}")
else:
db.save_user_auth_file(
user_id=current_user_id,
provider_id=provider_key,
file_type=file_type,
original_filename=file_name,
stored_filename=stored_filename,
file_path=str(file_path),
file_size=total_size,
mime_type=file.content_type
)
else: else:
# Config admin: update providers.json with full path # Config admin: update providers.json with full path
try: try:
...@@ -6704,7 +6743,14 @@ async def dashboard_provider_upload_chunk( ...@@ -6704,7 +6743,14 @@ async def dashboard_provider_upload_chunk(
providers[provider_key]['codex_config'] = {} providers[provider_key]['codex_config'] = {}
providers[provider_key]['codex_config']['credentials_file'] = relative_path providers[provider_key]['codex_config']['credentials_file'] = relative_path
logger.info(f"Updated providers.json: {provider_key}.codex_config.credentials_file = {relative_path}") logger.info(f"Updated providers.json: {provider_key}.codex_config.credentials_file = {relative_path}")
# For Claude CLI credentials – store path in claude_config.cli_credentials_file
elif provider_type == 'claude' and file_type == 'cli_credentials':
if 'claude_config' not in providers[provider_key]:
providers[provider_key]['claude_config'] = {}
providers[provider_key]['claude_config']['cli_credentials_file'] = relative_path
logger.info(f"Updated providers.json: {provider_key}.claude_config.cli_credentials_file = {relative_path}")
# Fallback: update top-level field # Fallback: update top-level field
else: else:
providers[provider_key][file_type] = relative_path providers[provider_key][file_type] = relative_path
......
...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" ...@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "aisbf" name = "aisbf"
version = "0.99.56" version = "0.99.57"
description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations" description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md" readme = "README.md"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
......
...@@ -49,7 +49,7 @@ class InstallCommand(_install): ...@@ -49,7 +49,7 @@ class InstallCommand(_install):
setup( setup(
name="aisbf", name="aisbf",
version="0.99.56", version="0.99.57",
author="AISBF Contributors", author="AISBF Contributors",
author_email="stefy@nexlab.net", author_email="stefy@nexlab.net",
description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations", description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations",
......
...@@ -64,11 +64,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -64,11 +64,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div id="new-provider-claude-warning" style="background: #ffdd57; border: 2px solid #ff6b35; border-radius: 8px; padding: 20px; margin-bottom: 15px; display: none;"> <div id="new-provider-claude-warning" style="background: #ffdd57; border: 2px solid #ff6b35; border-radius: 8px; padding: 20px; margin-bottom: 15px; display: none;">
<h4 style="margin: 0 0 10px 0; color: #d63031;">⚠️ Important Notice for Claude Provider</h4> <h4 style="margin: 0 0 10px 0; color: #d63031;">⚠️ Important Notice for Claude Provider</h4>
<p style="margin: 0; color: #2d3436;"> <p style="margin: 0; color: #2d3436;">
Claude.ai policies state that unofficial clients are not allowed. By using AISBF as a client, you acknowledge that: Claude.ai policies state that unofficial clients are not allowed. By using AISBF in <strong>HTTP API / OAuth2 mode</strong>, you acknowledge that:
<br><br>• While we do our best to mimic the claude-code CLI, using an unofficial client can lead to account suspension or cancellation <br><br>• While we do our best to mimic the claude-code CLI, using an unofficial client can lead to account suspension or cancellation
<br>• Claude.ai detects prompt patterns to identify unofficial client usage, so it may not work with complex agents and prompts <br>• Claude.ai detects prompt patterns to identify unofficial client usage, so it may not work with complex agents and prompts
<br>• You use this software at your own risk and responsibility <br>• You use this software at your own risk and responsibility
<br>• We are not affiliated with Anthropic and cannot guarantee compatibility or continued functionality <br>• We are not affiliated with Anthropic and cannot guarantee compatibility or continued functionality
<br><br><strong>CLI mode is different:</strong> when "Use Claude CLI mode" is enabled, AISBF proxies requests through the official <code>claude</code> binary using <code>claude -p</code>, which is the intended programmatic use of the official Anthropic CLI and is permitted by Claude's terms of service.
</p> </p>
</div> </div>
...@@ -134,6 +135,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -134,6 +135,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% block extra_js %} {% block extra_js %}
<script> <script>
const CLAUDE_CLI_MODE = {{ 'true' if claude_cli_mode else 'false' }};
let providersData = {{ providers_json | replace("</script>", "<\\/script>") | safe }}; let providersData = {{ providers_json | replace("</script>", "<\\/script>") | safe }};
let expandedProviders = new Set(); let expandedProviders = new Set();
...@@ -227,6 +229,41 @@ async function uploadClaudeFile(providerKey, file) { ...@@ -227,6 +229,41 @@ async function uploadClaudeFile(providerKey, file) {
await uploadFileChunked(providerKey, 'credentials_file', file, 'claude_config'); await uploadFileChunked(providerKey, 'credentials_file', file, 'claude_config');
} }
// Upload handler for Claude CLI credentials (.credentials.json from ~/.claude/)
async function uploadClaudeCliFile(providerKey, file) {
if (!file) return;
if (!expandedProviders.has(providerKey)) toggleProvider(providerKey);
const statusEl = document.getElementById(`claude-cli-upload-status-${providerKey}`);
if (!statusEl) return;
statusEl.innerHTML = '<div style="color: #4a9eff;">Uploading CLI credentials: 0%</div>';
try {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
for (let chunkNumber = 1; chunkNumber <= totalChunks; chunkNumber++) {
const start = (chunkNumber - 1) * CHUNK_SIZE;
const chunk = file.slice(start, Math.min(start + CHUNK_SIZE, file.size));
const progress = Math.round((chunkNumber / totalChunks) * 100);
statusEl.innerHTML = `<div style="color: #4a9eff;">Uploading CLI credentials: ${progress}%</div>`;
const formData = new FormData();
formData.append('provider_key', providerKey);
formData.append('file_type', 'cli_credentials');
formData.append('file_name', file.name);
formData.append('total_size', file.size);
formData.append('chunk_number', chunkNumber);
formData.append('total_chunks', totalChunks);
formData.append('file', chunk);
const response = await fetch('/dashboard/providers/upload-auth-file/chunk', { method: 'POST', body: formData });
const result = await response.json();
if (!result.success) throw new Error(result.error || 'Upload failed');
if (result.complete) {
statusEl.innerHTML = '<div style="color: #4ade80;">CLI credentials saved to server!</div>';
return;
}
}
} catch (e) {
statusEl.innerHTML = `<div style="color: #f87171;">Upload failed: ${e.message}</div>`;
}
}
// Upload handler for Qwen credential files // Upload handler for Qwen credential files
async function uploadQwenFile(providerKey, file) { async function uploadQwenFile(providerKey, file) {
await uploadFileChunked(providerKey, 'credentials_file', file, 'qwen_config'); await uploadFileChunked(providerKey, 'credentials_file', file, 'qwen_config');
...@@ -505,21 +542,60 @@ function renderProviderDetails(key) { ...@@ -505,21 +542,60 @@ function renderProviderDetails(key) {
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Or Upload Credentials File</h5> <h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Or Upload Credentials File</h5>
<div class="form-group"> <div class="form-group">
<label>Upload Credentials File</label> <label>Upload OAuth2 Credentials File</label>
<input type="file" id="claude-creds-file-${key}" accept=".json" onchange="uploadClaudeFile('${key}', this.files[0])"> <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> <small style="color: #a0a0a0; display: block; margin-top: 5px;">Upload Claude OAuth2 credentials JSON file</small>
</div> </div>
<div id="claude-upload-status-${key}" style="margin-top: 10px;"></div> <div id="claude-upload-status-${key}" style="margin-top: 10px;"></div>
${CLAUDE_CLI_MODE ? `
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #1e3a5f;">
<h5 style="margin: 0 0 8px 0; color: #4ade80;">Claude CLI Mode Active</h5>
<small style="color: #4ade80; display: block; margin-bottom: 14px;">
The claude CLI was detected at startup. When enabled, requests are piped
through the local claude binary instead of the HTTP API.
</small>
<div class="form-group" style="margin-bottom: 16px;">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
<input type="checkbox"
id="claude-use-cli-mode-${key}"
${claudeConfig.use_cli_mode ? 'checked' : ''}
onchange="updateClaudeConfig('${key}', 'use_cli_mode', this.checked)"
style="width: 18px; height: 18px; cursor: pointer; accent-color: #4ade80;">
<span style="color: #e0e0e0; font-weight: 500;">Use Claude CLI mode</span>
</label>
<small style="color: #a0a0a0; display: block; margin-top: 6px; padding-left: 28px;">
When checked and you have already authenticated via OAuth2 above,
your existing tokens are used automatically — no separate file upload needed.
You can also upload an explicit <code style="background:#0f2840;padding:1px 4px;border-radius:3px;">.credentials.json</code>
below to override.
</small>
</div>
<div class="form-group">
<label>Override: Upload CLI Credentials File</label>
<input type="file" id="claude-cli-creds-file-${key}" accept=".json" onchange="uploadClaudeCliFile('${key}', this.files[0])">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">
Optional — upload a <code style="background:#0f2840;padding:1px 4px;border-radius:3px;">~/.claude/.credentials.json</code>
to use a specific account. Path saved in
<code style="background:#0f2840;padding:1px 4px;border-radius:3px;">claude_config.cli_credentials_file</code>.
</small>
</div>
<div id="claude-cli-upload-status-${key}" style="margin-top: 10px;"></div>
</div>
` : ''}
</div> </div>
<div style="background: #ffdd57; border: 2px solid #ff6b35; border-radius: 8px; padding: 20px; margin-bottom: 15px;"> <div style="background: #ffdd57; border: 2px solid #ff6b35; border-radius: 8px; padding: 20px; margin-bottom: 15px;">
<h4 style="margin: 0 0 10px 0; color: #d63031;">⚠️ Important Notice</h4> <h4 style="margin: 0 0 10px 0; color: #d63031;">⚠️ Important Notice</h4>
<p style="margin: 0; color: #2d3436;"> <p style="margin: 0; color: #2d3436;">
Claude.ai policies state that unofficial clients are not allowed. By using AISBF as a client, you acknowledge that: Claude.ai policies state that unofficial clients are not allowed. By using AISBF in <strong>HTTP API / OAuth2 mode</strong>, you acknowledge that:
<br><br>• While we do our best to mimic the claude-code CLI, using an unofficial client can lead to account suspension or cancellation <br><br>• While we do our best to mimic the claude-code CLI, using an unofficial client can lead to account suspension or cancellation
<br>• Claude.ai detects prompt patterns to identify unofficial client usage, so it may not work with complex agents and prompts <br>• Claude.ai detects prompt patterns to identify unofficial client usage, so it may not work with complex agents and prompts
<br>• You use this software at your own risk and responsibility <br>• You use this software at your own risk and responsibility
<br>• We are not affiliated with Anthropic and cannot guarantee compatibility or continued functionality <br>• We are not affiliated with Anthropic and cannot guarantee compatibility or continued functionality
<br><br><strong>CLI mode is different:</strong> when "Use Claude CLI mode" is enabled, AISBF proxies requests through the official <code style="background:#e8e0b0;padding:1px 4px;border-radius:3px;">claude</code> binary using <code style="background:#e8e0b0;padding:1px 4px;border-radius:3px;">claude -p</code>, which is the intended programmatic use of the official Anthropic CLI and is permitted by Claude's terms of service.
</p> </p>
</div> </div>
`; `;
......
...@@ -64,11 +64,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -64,11 +64,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div id="new-provider-claude-warning" style="background: #ffdd57; border: 2px solid #ff6b35; border-radius: 8px; padding: 20px; margin-bottom: 15px; display: none;"> <div id="new-provider-claude-warning" style="background: #ffdd57; border: 2px solid #ff6b35; border-radius: 8px; padding: 20px; margin-bottom: 15px; display: none;">
<h4 style="margin: 0 0 10px 0; color: #d63031;">⚠️ Important Notice for Claude Provider</h4> <h4 style="margin: 0 0 10px 0; color: #d63031;">⚠️ Important Notice for Claude Provider</h4>
<p style="margin: 0; color: #2d3436;"> <p style="margin: 0; color: #2d3436;">
Claude.ai policies state that unofficial clients are not allowed. By using AISBF as a client, you acknowledge that: Claude.ai policies state that unofficial clients are not allowed. By using AISBF as a client in <strong>HTTP API / OAuth2 mode</strong>, you acknowledge that:
<br><br>• While we do our best to mimic the claude-code CLI, using an unofficial client can lead to account suspension or cancellation <br><br>• While we do our best to mimic the claude-code CLI, using an unofficial client can lead to account suspension or cancellation
<br>• Claude.ai detects prompt patterns to identify unofficial client usage, so it may not work with complex agents and prompts <br>• Claude.ai detects prompt patterns to identify unofficial client usage, so it may not work with complex agents and prompts
<br>• You use this software at your own risk and responsibility <br>• You use this software at your own risk and responsibility
<br>• We are not affiliated with Anthropic and cannot guarantee compatibility or continued functionality <br>• We are not affiliated with Anthropic and cannot guarantee compatibility or continued functionality
<br><br><strong>CLI mode is different:</strong> when "Use Claude CLI mode" is enabled, AISBF proxies requests through the official <code>claude</code> binary using <code>claude -p</code>, which is the intended programmatic use of the official Anthropic CLI and is permitted by Claude's terms of service.
</p> </p>
</div> </div>
...@@ -134,6 +135,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -134,6 +135,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% block extra_js %} {% block extra_js %}
<script> <script>
const CLAUDE_CLI_MODE = {{ 'true' if claude_cli_mode else 'false' }};
// Global cache settings cache (pun intended) // Global cache settings cache (pun intended)
let cacheSettings = []; let cacheSettings = [];
...@@ -353,6 +356,41 @@ async function uploadClaudeFile(providerKey, file) { ...@@ -353,6 +356,41 @@ async function uploadClaudeFile(providerKey, file) {
await uploadFileChunked(providerKey, 'credentials_file', file, 'claude_config'); await uploadFileChunked(providerKey, 'credentials_file', file, 'claude_config');
} }
// Upload handler for Claude CLI credentials file (.credentials.json from ~/.claude/)
async function uploadClaudeCliFile(providerKey, file) {
if (!file) return;
if (!expandedProviders.has(providerKey)) toggleProvider(providerKey);
const statusEl = document.getElementById(`claude-cli-upload-status-${providerKey}`);
if (!statusEl) return;
statusEl.innerHTML = '<div style="color: #4a9eff;">Uploading CLI credentials: 0%</div>';
try {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
for (let chunkNumber = 1; chunkNumber <= totalChunks; chunkNumber++) {
const start = (chunkNumber - 1) * CHUNK_SIZE;
const chunk = file.slice(start, Math.min(start + CHUNK_SIZE, file.size));
const progress = Math.round((chunkNumber / totalChunks) * 100);
statusEl.innerHTML = `<div style="color: #4a9eff;">Uploading CLI credentials: ${progress}%</div>`;
const formData = new FormData();
formData.append('provider_key', providerKey);
formData.append('file_type', 'cli_credentials');
formData.append('file_name', file.name);
formData.append('total_size', file.size);
formData.append('chunk_number', chunkNumber);
formData.append('total_chunks', totalChunks);
formData.append('file', chunk);
const response = await fetch('/dashboard/providers/upload-auth-file/chunk', { method: 'POST', body: formData });
const result = await response.json();
if (!result.success) throw new Error(result.error || 'Upload failed');
if (result.complete) {
statusEl.innerHTML = '<div style="color: #4ade80;">CLI credentials uploaded and saved!</div>';
return;
}
}
} catch (e) {
statusEl.innerHTML = `<div style="color: #f87171;">Upload failed: ${e.message}</div>`;
}
}
// Upload handler for Qwen credential files // Upload handler for Qwen credential files
async function uploadQwenFile(providerKey, file) { async function uploadQwenFile(providerKey, file) {
await uploadFileChunked(providerKey, 'credentials_file', file, 'qwen_config'); await uploadFileChunked(providerKey, 'credentials_file', file, 'qwen_config');
...@@ -596,23 +634,61 @@ function renderProviderDetails(key) { ...@@ -596,23 +634,61 @@ function renderProviderDetails(key) {
<!-- Auth status will be displayed here --> <!-- Auth status will be displayed here -->
</div> </div>
<h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Or Upload Credentials File</h5> <h5 style="margin: 20px 0 10px 0; color: #8ec8ff;">Or Upload OAuth2 Credentials File</h5>
<div class="form-group"> <div class="form-group">
<label>Upload Credentials File</label> <label>Upload OAuth2 Credentials File</label>
<input type="file" id="claude-creds-file-${key}" accept=".json" onchange="uploadClaudeFile('${key}', this.files[0])"> <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> <small style="color: #a0a0a0; display: block; margin-top: 5px;">Upload Claude OAuth2 credentials JSON file</small>
</div> </div>
<div id="claude-upload-status-${key}" style="margin-top: 10px;"></div> <div id="claude-upload-status-${key}" style="margin-top: 10px;"></div>
${CLAUDE_CLI_MODE ? `
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #1e3a5f;">
<h5 style="margin: 0 0 8px 0; color: #4ade80;">Claude CLI Mode Active</h5>
<small style="color: #4ade80; display: block; margin-bottom: 14px;">
The claude CLI was detected at startup. When enabled, requests are piped
through the local claude binary instead of the HTTP API.
</small>
<div class="form-group" style="margin-bottom: 16px;">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
<input type="checkbox"
id="claude-use-cli-mode-${key}"
${claudeConfig.use_cli_mode ? 'checked' : ''}
onchange="updateClaudeConfig('${key}', 'use_cli_mode', this.checked)"
style="width: 18px; height: 18px; cursor: pointer; accent-color: #4ade80;">
<span style="color: #e0e0e0; font-weight: 500;">Use Claude CLI mode</span>
</label>
<small style="color: #a0a0a0; display: block; margin-top: 6px; padding-left: 28px;">
When checked and you have already authenticated via OAuth2 above,
your existing tokens will be used automatically — no separate file upload needed.
You can also upload an explicit <code style="background:#0f2840;padding:1px 4px;border-radius:3px;">.credentials.json</code>
below to override.
</small>
</div>
<div class="form-group">
<label>Override: Upload CLI Credentials File</label>
<input type="file" id="claude-cli-creds-file-${key}" accept=".json" onchange="uploadClaudeCliFile('${key}', this.files[0])">
<small style="color: #a0a0a0; display: block; margin-top: 5px;">
Optional — upload a <code style="background:#0f2840;padding:1px 4px;border-radius:3px;">~/.claude/.credentials.json</code>
to use a specific account instead of your OAuth2 tokens.
</small>
</div>
<div id="claude-cli-upload-status-${key}" style="margin-top: 10px;"></div>
</div>
` : ''}
</div> </div>
<div style="background: #ffdd57; border: 2px solid #ff6b35; border-radius: 8px; padding: 20px; margin-bottom: 15px;"> <div style="background: #ffdd57; border: 2px solid #ff6b35; border-radius: 8px; padding: 20px; margin-bottom: 15px;">
<h4 style="margin: 0 0 10px 0; color: #d63031;">⚠️ Important Notice</h4> <h4 style="margin: 0 0 10px 0; color: #d63031;">⚠️ Important Notice</h4>
<p style="margin: 0; color: #2d3436;"> <p style="margin: 0; color: #2d3436;">
Claude.ai policies state that unofficial clients are not allowed. By using AISBF as a client, you acknowledge that: Claude.ai policies state that unofficial clients are not allowed. By using AISBF in <strong>HTTP API / OAuth2 mode</strong>, you acknowledge that:
<br><br>• While we do our best to mimic the claude-code CLI, using an unofficial client can lead to account suspension or cancellation <br><br>• While we do our best to mimic the claude-code CLI, using an unofficial client can lead to account suspension or cancellation
<br>• Claude.ai detects prompt patterns to identify unofficial client usage, so it may not work with complex agents and prompts <br>• Claude.ai detects prompt patterns to identify unofficial client usage, so it may not work with complex agents and prompts
<br>• You use this software at your own risk and responsibility <br>• You use this software at your own risk and responsibility
<br>• We are not affiliated with Anthropic and cannot guarantee compatibility or continued functionality <br>• We are not affiliated with Anthropic and cannot guarantee compatibility or continued functionality
<br><br><strong>CLI mode is different:</strong> when "Use Claude CLI mode" is enabled, AISBF proxies requests through the official <code style="background:#e8e0b0;padding:1px 4px;border-radius:3px;">claude</code> binary using <code style="background:#e8e0b0;padding:1px 4px;border-radius:3px;">claude -p</code>, which is the intended programmatic use of the official Anthropic CLI and is permitted by Claude's terms of service.
</p> </p>
</div> </div>
`; `;
......
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