feat: support provider usage via configurable JSON credentials

This commit is contained in:
zhi
2026-03-11 13:13:07 +00:00
parent 5b8f84d87d
commit ff4baf6113

View File

@@ -15,6 +15,16 @@ def _now():
return datetime.now(timezone.utc) 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): def get_issue_stats_cached(db: Session, ttl_seconds: int = 1800):
key = 'issue_stats_24h' key = 'issue_stats_24h'
now = _now() now = _now()
@@ -41,25 +51,37 @@ def get_issue_stats_cached(db: Session, ttl_seconds: int = 1800):
return data 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': if provider == 'openai':
return {'Authorization': f'Bearer {credential}'} return {'Authorization': f'Bearer {credential}'}
if provider == 'anthropic': if provider == 'anthropic':
return {'x-api-key': credential, 'anthropic-version': '2023-06-01'} return {'x-api-key': credential, 'anthropic-version': '2023-06-01'}
return None return {}
def test_provider_connection(provider: str, credential: str): def test_provider_connection(provider: str, credential: str):
provider = provider.lower() provider = provider.lower()
info = _parse_credential(credential)
key = info.get('api_key') or credential
try: try:
if provider == 'openai': 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}' return r.status_code == 200, f'status={r.status_code}'
if provider == 'anthropic': 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}' 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'}: 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 True, 'accepted (connectivity check pending provider-specific adapter)'
return False, 'unsupported provider' return False, 'unsupported provider'
except Exception as e: 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]]: def _openai_usage(credential: str) -> Tuple[str, Dict[str, Any]]:
# Uses legacy billing endpoints; may be disabled in some orgs. info = _parse_credential(credential)
headers = _provider_headers('openai', credential) key = info.get('api_key') or credential
headers = _provider_headers('openai', key, info)
today = _now().date() today = _now().date()
start = (today - timedelta(days=7)).isoformat() start = (today - timedelta(days=7)).isoformat()
end = today.isoformat() end = today.isoformat()
usage_url = f'https://api.openai.com/v1/dashboard/billing/usage?start_date={start}&end_date={end}' usage_url = info.get('usage_url') or 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' sub_url = info.get('subscription_url') or 'https://api.openai.com/v1/dashboard/billing/subscription'
u = requests.get(usage_url, headers=headers, timeout=12) u = requests.get(usage_url, headers=headers, timeout=12)
s = requests.get(sub_url, headers=headers, timeout=12) s = requests.get(sub_url, headers=headers, timeout=12)
if u.status_code != 200 or s.status_code != 200: 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() sub = s.json()
total_usage = usage.get('total_usage') total_usage = usage.get('total_usage')
hard_limit = sub.get('hard_limit_usd') hard_limit = sub.get('hard_limit_usd')
reset_at = sub.get('billing_cycle_anchor') reset_at_ts = sub.get('billing_cycle_anchor')
if reset_at: reset_at = datetime.fromtimestamp(reset_at_ts, tz=timezone.utc) if reset_at_ts else None
reset_at = datetime.fromtimestamp(reset_at, tz=timezone.utc).isoformat()
usage_pct = None usage_pct = None
if total_usage is not None and hard_limit: if total_usage is not None and hard_limit:
usage_pct = round(total_usage / hard_limit * 100, 2) 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]]: def _anthropic_usage(credential: str) -> Tuple[str, Dict[str, Any]]:
# Anthropic usage endpoints are enterprise-specific; keep placeholder. info = _parse_credential(credential)
# We return accepted status; detail will be filled once API confirmed. key = info.get('api_key') or credential
return 'unsupported', {'error': 'anthropic usage API not configured'} 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): def refresh_provider_usage_once(db: Session):
@@ -114,6 +171,8 @@ def refresh_provider_usage_once(db: Session):
status, payload = _openai_usage(a.credential) status, payload = _openai_usage(a.credential)
elif a.provider == 'anthropic': elif a.provider == 'anthropic':
status, payload = _anthropic_usage(a.credential) status, payload = _anthropic_usage(a.credential)
elif a.provider in {'minimax', 'kimi', 'qwen'}:
status, payload = _generic_usage(a.provider, a.credential)
else: else:
ok, msg = test_provider_connection(a.provider, a.credential) ok, msg = test_provider_connection(a.provider, a.credential)
status = 'ok' if ok else 'error' 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, 'usage_pct': snap.usage_pct if snap else None,
'used': snap.used if snap else None, 'used': snap.used if snap else None,
'limit': snap.limit 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', 'status': snap.status if snap else 'pending',
'error': snap.error if snap else None, 'error': snap.error if snap else None,
'fetched_at': snap.fetched_at.isoformat() if snap and snap.fetched_at else None, 'fetched_at': snap.fetched_at.isoformat() if snap and snap.fetched_at else None,