/** Native Anthropic binding for AgentGuard Spend. */ import type { CapabilityTier, AttestationChainInput, CallContext, Provider, SpendPolicy, SpendScope } from '../types'; import { AgentGuardBlockedError, SpendGuard, type SpendGuardConfig, extractText, outputTokensFromParams, usageCountsFromObject, wrapUsageStream, type UsageCounts, } from '../spend-guard'; export interface AnthropicBindingOptions { policy: SpendPolicy; scope: SpendScope; capabilityClaim?: CapabilityTier; /** Optional DAG-TAT chain backing the capability claim (required when policy.attestation lists it). */ attestation?: AttestationChainInput; config?: Omit; } export function withSpendGuardAnthropic( // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any, opts: AnthropicBindingOptions, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): any { const guard = new SpendGuard({ policy: opts.policy, ...(opts.config ?? {}) }); const originalCreate = client?.messages?.create?.bind(client.messages); if (!originalCreate) throw new Error('withSpendGuardAnthropic: expected client.messages.create'); // eslint-disable-next-line @typescript-eslint/no-explicit-any client.messages.create = async (params: any) => { const call = buildAnthropicCall(params, guard, opts); const { decision } = await guard.decide(call); if (decision.action === 'block') throw new AgentGuardBlockedError(decision, opts.scope, opts.config?.locale); const nextParams = decision.action === 'downgrade' && decision.modelResolved !== decision.modelRequested ? { ...params, model: decision.modelResolved } : params; const response = await originalCreate(nextParams); if (nextParams?.stream === true || isAsyncIterable(response)) { return wrapAnthropicStream(response, guard, decision.decisionId, call); } const usage = usageCountsFromObject((response as { usage?: unknown } | null)?.usage); if (usage) await guard.settleStreamUsage(decision.decisionId, usage.inputTokens, usage.outputTokens); return response; }; const originalStream = client?.messages?.stream?.bind(client.messages); if (originalStream) { // eslint-disable-next-line @typescript-eslint/no-explicit-any client.messages.stream = async (params: any) => { const call = buildAnthropicCall({ ...params, stream: true }, guard, opts); const { decision } = await guard.decide(call); if (decision.action === 'block') throw new AgentGuardBlockedError(decision, opts.scope, opts.config?.locale); const nextParams = decision.action === 'downgrade' && decision.modelResolved !== decision.modelRequested ? { ...params, model: decision.modelResolved } : params; const stream = await originalStream(nextParams); return wrapAnthropicStream(stream, guard, decision.decisionId, call); }; } client.__agentguard = guard; return client; } function buildAnthropicCall( params: Record, guard: SpendGuard, opts: AnthropicBindingOptions, ): CallContext { const inputText = [extractText(params.system), extractText(params.messages)].filter(Boolean).join('\n'); const metadata = params.metadata as Record | undefined; return { provider: 'anthropic' as Provider, model: String(params.model ?? 'unknown'), inputTokens: guard.estimateTokens(inputText), outputTokens: outputTokensFromParams(params), scope: opts.scope, capabilityClaim: opts.capabilityClaim, attestation: opts.attestation, label: stringValue(metadata?.label), workflowId: stringValue(metadata?.workflow_id) || stringValue(metadata?.workflowId), subagentId: stringValue(metadata?.subagent_id) || stringValue(metadata?.subagentId), callFingerprint: stringValue(metadata?.call_fingerprint) || stringValue(metadata?.callFingerprint), planRevision: numberValue(metadata?.plan_revision ?? metadata?.planRevision), reasoningStep: numberValue(metadata?.reasoning_step ?? metadata?.reasoningStep), stateProgress: booleanValue(metadata?.state_progress ?? metadata?.stateProgress), requestShape: { model: String(params.model ?? 'unknown'), maxTokens: outputTokensFromParams(params), stream: params.stream === true }, }; } function stringValue(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } function numberValue(value: unknown): number | undefined { return Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : undefined; } function booleanValue(value: unknown): boolean | undefined { return typeof value === 'boolean' ? value : undefined; } function wrapAnthropicStream( stream: AsyncIterable, guard: SpendGuard, decisionId: string, call: CallContext, ): AsyncIterable { const tracker = new AnthropicStreamUsageTracker(guard, call); return wrapUsageStream(stream, { onChunk: (chunk) => tracker.observe(chunk), settle: (partial, reason) => { const usage = tracker.usage(); return guard.settleStreamUsage(decisionId, usage.inputTokens, usage.outputTokens, { partial, reason }); }, }); } class AnthropicStreamUsageTracker { private input: number | null = null; private output: number | null = null; private content = ''; constructor(private guard: SpendGuard, private call: CallContext) {} observe(event: unknown): void { const obj = event as Record | null; const startUsage = ((obj?.message as Record | undefined)?.usage ?? obj?.usage) as unknown; const input = tokenField(startUsage, ['input_tokens', 'inputTokens', 'prompt_tokens']); const output = tokenField(startUsage, ['output_tokens', 'outputTokens', 'completion_tokens']); if (input !== null) this.input = input; if (output !== null) this.output = output; const delta = obj?.delta as Record | undefined; if (typeof delta?.text === 'string') this.content += delta.text; if (typeof delta?.partial_json === 'string') this.content += delta.partial_json; } usage(): UsageCounts { return { inputTokens: this.input ?? this.call.inputTokens, outputTokens: this.output ?? this.guard.estimateTokens(this.content), }; } } function tokenField(value: unknown, names: string[]): number | null { if (!value || typeof value !== 'object') return null; const obj = value as Record; for (const name of names) { const token = obj[name]; if (Number.isSafeInteger(token) && (token as number) >= 0) return token as number; } return null; } function isAsyncIterable(value: unknown): value is AsyncIterable { return Boolean(value && typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function'); }