feat: support provider usage via configurable JSON credentials
This commit is contained in:
@@ -15,6 +15,16 @@ def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_credential(raw: str) -> Dict[str, Any]:
|
||||
raw = (raw or '').strip()
|
||||
if raw.startswith('{'):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return {'api_key': raw}
|
||||
return {'api_key': raw}
|
||||
|
||||
|
||||
def get_issue_stats_cached(db: Session, ttl_seconds: int = 1800):
|
||||
key = 'issue_stats_24h'
|
||||
now = _now()
|
||||
@@ -41,25 +51,37 @@ def get_issue_stats_cached(db: Session, ttl_seconds: int = 1800):
|
||||
return data
|
||||
|
||||
|
||||
def _provider_headers(provider: str, credential: str):
|
||||
def _provider_headers(provider: str, credential: str, extra: Dict[str, Any] | None = None):
|
||||
extra = extra or {}
|
||||
if extra.get('auth_header'):
|
||||
val = extra.get('auth_value')
|
||||
if not val:
|
||||
scheme = extra.get('auth_scheme')
|
||||
val = f"{scheme} {credential}" if scheme else credential
|
||||
return {extra['auth_header']: val}
|
||||
if provider == 'openai':
|
||||
return {'Authorization': f'Bearer {credential}'}
|
||||
if provider == 'anthropic':
|
||||
return {'x-api-key': credential, 'anthropic-version': '2023-06-01'}
|
||||
return None
|
||||
return {}
|
||||
|
||||
|
||||
def test_provider_connection(provider: str, credential: str):
|
||||
provider = provider.lower()
|
||||
info = _parse_credential(credential)
|
||||
key = info.get('api_key') or credential
|
||||
try:
|
||||
if provider == 'openai':
|
||||
r = requests.get('https://api.openai.com/v1/models', headers=_provider_headers(provider, credential), timeout=12)
|
||||
r = requests.get('https://api.openai.com/v1/models', headers=_provider_headers(provider, key, info), timeout=12)
|
||||
return r.status_code == 200, f'status={r.status_code}'
|
||||
if provider == 'anthropic':
|
||||
r = requests.get('https://api.anthropic.com/v1/models', headers=_provider_headers(provider, credential), timeout=12)
|
||||
r = requests.get('https://api.anthropic.com/v1/models', headers=_provider_headers(provider, key, info), timeout=12)
|
||||
return r.status_code == 200, f'status={r.status_code}'
|
||||
usage_url = info.get('usage_url') or info.get('test_url')
|
||||
if usage_url:
|
||||
r = requests.get(usage_url, headers=_provider_headers(provider, key, info), timeout=12)
|
||||
return r.status_code < 500, f'status={r.status_code}'
|
||||
if provider in {'minimax', 'kimi', 'qwen'}:
|
||||
# Endpoints/usage API vary by deployment; keep as accepted-but-unverified for now.
|
||||
return True, 'accepted (connectivity check pending provider-specific adapter)'
|
||||
return False, 'unsupported provider'
|
||||
except Exception as e:
|
||||
@@ -67,13 +89,14 @@ def test_provider_connection(provider: str, credential: str):
|
||||
|
||||
|
||||
def _openai_usage(credential: str) -> Tuple[str, Dict[str, Any]]:
|
||||
# Uses legacy billing endpoints; may be disabled in some orgs.
|
||||
headers = _provider_headers('openai', credential)
|
||||
info = _parse_credential(credential)
|
||||
key = info.get('api_key') or credential
|
||||
headers = _provider_headers('openai', key, info)
|
||||
today = _now().date()
|
||||
start = (today - timedelta(days=7)).isoformat()
|
||||
end = today.isoformat()
|
||||
usage_url = f'https://api.openai.com/v1/dashboard/billing/usage?start_date={start}&end_date={end}'
|
||||
sub_url = 'https://api.openai.com/v1/dashboard/billing/subscription'
|
||||
usage_url = info.get('usage_url') or f'https://api.openai.com/v1/dashboard/billing/usage?start_date={start}&end_date={end}'
|
||||
sub_url = info.get('subscription_url') or 'https://api.openai.com/v1/dashboard/billing/subscription'
|
||||
u = requests.get(usage_url, headers=headers, timeout=12)
|
||||
s = requests.get(sub_url, headers=headers, timeout=12)
|
||||
if u.status_code != 200 or s.status_code != 200:
|
||||
@@ -82,9 +105,8 @@ def _openai_usage(credential: str) -> Tuple[str, Dict[str, Any]]:
|
||||
sub = s.json()
|
||||
total_usage = usage.get('total_usage')
|
||||
hard_limit = sub.get('hard_limit_usd')
|
||||
reset_at = sub.get('billing_cycle_anchor')
|
||||
if reset_at:
|
||||
reset_at = datetime.fromtimestamp(reset_at, tz=timezone.utc).isoformat()
|
||||
reset_at_ts = sub.get('billing_cycle_anchor')
|
||||
reset_at = datetime.fromtimestamp(reset_at_ts, tz=timezone.utc) if reset_at_ts else None
|
||||
usage_pct = None
|
||||
if total_usage is not None and hard_limit:
|
||||
usage_pct = round(total_usage / hard_limit * 100, 2)
|
||||
@@ -99,9 +121,44 @@ def _openai_usage(credential: str) -> Tuple[str, Dict[str, Any]]:
|
||||
|
||||
|
||||
def _anthropic_usage(credential: str) -> Tuple[str, Dict[str, Any]]:
|
||||
# Anthropic usage endpoints are enterprise-specific; keep placeholder.
|
||||
# We return accepted status; detail will be filled once API confirmed.
|
||||
return 'unsupported', {'error': 'anthropic usage API not configured'}
|
||||
info = _parse_credential(credential)
|
||||
key = info.get('api_key') or credential
|
||||
usage_url = info.get('usage_url')
|
||||
if not usage_url:
|
||||
return 'unsupported', {'error': 'anthropic usage API not configured'}
|
||||
r = requests.get(usage_url, headers=_provider_headers('anthropic', key, info), timeout=12)
|
||||
if r.status_code != 200:
|
||||
return 'error', {'error': f'usage:{r.status_code}', 'raw': r.text}
|
||||
payload = r.json()
|
||||
# Try to normalize
|
||||
return 'ok', {
|
||||
'window_label': payload.get('window') or payload.get('window_label'),
|
||||
'used': payload.get('used'),
|
||||
'limit': payload.get('limit'),
|
||||
'usage_pct': payload.get('usage_pct'),
|
||||
'reset_at': None,
|
||||
'raw': payload,
|
||||
}
|
||||
|
||||
|
||||
def _generic_usage(provider: str, credential: str) -> Tuple[str, Dict[str, Any]]:
|
||||
info = _parse_credential(credential)
|
||||
key = info.get('api_key') or credential
|
||||
usage_url = info.get('usage_url')
|
||||
if not usage_url:
|
||||
return 'unsupported', {'error': f'{provider} usage API not configured'}
|
||||
r = requests.get(usage_url, headers=_provider_headers(provider, key, info), timeout=12)
|
||||
if r.status_code != 200:
|
||||
return 'error', {'error': f'usage:{r.status_code}', 'raw': r.text}
|
||||
payload = r.json()
|
||||
return 'ok', {
|
||||
'window_label': payload.get('window') or payload.get('window_label'),
|
||||
'used': payload.get('used'),
|
||||
'limit': payload.get('limit'),
|
||||
'usage_pct': payload.get('usage_pct'),
|
||||
'reset_at': None,
|
||||
'raw': payload,
|
||||
}
|
||||
|
||||
|
||||
def refresh_provider_usage_once(db: Session):
|
||||
@@ -114,6 +171,8 @@ def refresh_provider_usage_once(db: Session):
|
||||
status, payload = _openai_usage(a.credential)
|
||||
elif a.provider == 'anthropic':
|
||||
status, payload = _anthropic_usage(a.credential)
|
||||
elif a.provider in {'minimax', 'kimi', 'qwen'}:
|
||||
status, payload = _generic_usage(a.provider, a.credential)
|
||||
else:
|
||||
ok, msg = test_provider_connection(a.provider, a.credential)
|
||||
status = 'ok' if ok else 'error'
|
||||
@@ -148,7 +207,7 @@ def get_provider_usage_view(db: Session):
|
||||
'usage_pct': snap.usage_pct if snap else None,
|
||||
'used': snap.used if snap else None,
|
||||
'limit': snap.limit if snap else None,
|
||||
'reset_at': snap.reset_at if snap else None,
|
||||
'reset_at': snap.reset_at.isoformat() if snap and snap.reset_at else None,
|
||||
'status': snap.status if snap else 'pending',
|
||||
'error': snap.error if snap else None,
|
||||
'fetched_at': snap.fetched_at.isoformat() if snap and snap.fetched_at else None,
|
||||
|
||||
Reference in New Issue
Block a user