token-monitor/providers/anthropic-teams.js
Vigilio Desto ab9c60b67c
Handle policy_rejected status (Anthropic April 4 billing change)
- anthropic-teams.js: detect HTTP 400 extra-usage policy blocks, return
  status='policy_rejected' with quota headers still readable
- report.js: display policy_rejected as CRITICAL with 'POLICY BLOCKED' label
- getSeverity: treat policy_rejected as critical

Currently the direct API (used by monitor) returns 200; pi's OAuth path
returns 400. This fix future-proofs against the block extending to direct
API calls, and correctly classifies the status if it does.

Refs: trentuna/commons#17, trentuna/token-monitor#4
2026-04-08 05:38:46 +00:00

149 lines
6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* anthropic-teams.js — Unified schema parser for Anthropic Teams direct providers.
*
* Teams providers (team-vigilio, team-ludo, team-molto, team-nadja, team-buio) use the
* anthropic-ratelimit-unified-* header family. Headers are present on EVERY response
* (200 and 429). A 429 is expected when the 7d budget is exhausted and contains valid
* quota data — do not treat it as an error, extract the headers normally.
*
* Header reference (from Face's recon, issue trentuna/a-team#91):
* anthropic-ratelimit-unified-status allowed|rejected
* anthropic-ratelimit-unified-5h-status allowed|rejected
* anthropic-ratelimit-unified-5h-utilization 0.01.0
* anthropic-ratelimit-unified-5h-reset Unix timestamp
* anthropic-ratelimit-unified-7d-status allowed|rejected
* anthropic-ratelimit-unified-7d-utilization 0.01.0
* anthropic-ratelimit-unified-7d-reset Unix timestamp
* anthropic-ratelimit-unified-7d-surpassed-threshold (present only when maxed)
* anthropic-ratelimit-unified-representative-claim five_hour|seven_day
* anthropic-ratelimit-unified-fallback-percentage 0.01.0
* anthropic-ratelimit-unified-reset Unix timestamp (binding reset)
* anthropic-ratelimit-unified-overage-status rejected
* anthropic-ratelimit-unified-overage-disabled-reason org_level_disabled
* anthropic-organization-id UUID
* retry-after seconds (only on 429)
*/
import { getSeverity } from '../report.js';
/**
* Parse unified rate-limit headers from a Teams API response.
*
* @param {Object} headers — fetch Response.headers (or compatible mock with .get(name))
* @param {number} httpStatus — HTTP status code of the response
* @param {string} providerName — name for logging/context
* @returns {Object} normalized provider result
*/
export function parseTeamsHeaders(headers, httpStatus, providerName) {
const h = (name) => headers.get(name);
// 401 = invalid API key — no quota data available
if (httpStatus === 401) {
return {
type: 'teams-direct',
status: 'invalid_key',
utilization_5h: null,
utilization_7d: null,
severity: 'unknown',
};
}
const status = h('anthropic-ratelimit-unified-status') || (httpStatus === 429 ? 'rejected' : 'allowed');
const util5h = parseFloat(h('anthropic-ratelimit-unified-5h-utilization'));
const util7d = parseFloat(h('anthropic-ratelimit-unified-7d-utilization'));
const resetTs = parseInt(h('anthropic-ratelimit-unified-reset'), 10);
const retryAfter = h('retry-after') ? parseInt(h('retry-after'), 10) : null;
// Compute reset_in_seconds from the binding reset Unix timestamp
const nowSec = Math.floor(Date.now() / 1000);
const resetInSeconds = !isNaN(resetTs) ? Math.max(0, resetTs - nowSec) : null;
const result = {
type: 'teams-direct',
status,
utilization_5h: isNaN(util5h) ? null : util5h,
utilization_7d: isNaN(util7d) ? null : util7d,
representative_claim: h('anthropic-ratelimit-unified-representative-claim') || null,
reset_timestamp: isNaN(resetTs) ? null : resetTs,
reset_in_seconds: resetInSeconds,
organization_id: h('anthropic-organization-id') || null,
// Additional detail headers
status_5h: h('anthropic-ratelimit-unified-5h-status') || null,
status_7d: h('anthropic-ratelimit-unified-7d-status') || null,
overage_status: h('anthropic-ratelimit-unified-overage-status') || null,
fallback_percentage: h('anthropic-ratelimit-unified-fallback-percentage')
? parseFloat(h('anthropic-ratelimit-unified-fallback-percentage'))
: null,
};
// Include retry_after_seconds only when present (429 responses)
if (retryAfter !== null) {
result.retry_after_seconds = retryAfter;
}
// Include surpassed threshold when present (maxed budget)
const surpassed = h('anthropic-ratelimit-unified-7d-surpassed-threshold');
if (surpassed !== null) {
result.surpassed_threshold_7d = parseFloat(surpassed);
}
result.severity = getSeverity(result);
return result;
}
/**
* Probe a single Teams provider by making a minimal API call.
* Extracts headers regardless of whether the response is 200 or 429.
*
* @param {string} providerName
* @param {string} baseUrl
* @param {string} apiKey
* @returns {Promise<Object>} normalized provider result
*/
export async function probeTeamsProvider(providerName, baseUrl, apiKey) {
try {
const response = await fetch(`${baseUrl}/v1/messages`, {
method: 'POST',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-haiku-4-5-20251001',
max_tokens: 1,
messages: [{ role: 'user', content: 'Hi' }],
}),
});
// HTTP 400 with "extra_usage" policy: Anthropic changed billing April 4 2026.
// Third-party apps (incl. pi) no longer draw from Teams plan limits.
// Rate-limit headers ARE present but the provider is unusable for sessions.
// Detect this before parseTeamsHeaders so we surface a clear status.
if (response.status === 400) {
let bodyText = '';
try { bodyText = await response.text(); } catch (_) {}
if (bodyText.includes('extra usage') || bodyText.includes('invalid_request_error')) {
// Still parse headers for quota visibility, but override status
const base = parseTeamsHeaders(response.headers, response.status, providerName);
return {
...base,
status: 'policy_rejected',
policy_message: 'Third-party apps blocked (Anthropic April 2026 billing change)',
severity: 'critical',
};
}
}
return parseTeamsHeaders(response.headers, response.status, providerName);
} catch (err) {
return {
type: 'teams-direct',
status: 'error',
message: err.message,
utilization_5h: null,
utilization_7d: null,
severity: 'unknown',
};
}
}