- 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
149 lines
6 KiB
JavaScript
149 lines
6 KiB
JavaScript
/**
|
||
* 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.0–1.0
|
||
* anthropic-ratelimit-unified-5h-reset Unix timestamp
|
||
* anthropic-ratelimit-unified-7d-status allowed|rejected
|
||
* anthropic-ratelimit-unified-7d-utilization 0.0–1.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.0–1.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',
|
||
};
|
||
}
|
||
}
|