Spend

Spend

이 문서에서는 SpendLimits capability를 소개해요. 에이전트가 얼마나 비용이 드는지 추적하고, 예산이 소진되면 멈추게 해요.

출처: 문서

본문

Pydantic AI Harness가 0.x 릴리스인 동안 API는 마이너 릴리스 사이에 바뀔 수 있어요. 바뀌면 deprecation 경고와 릴리스 노트 마이그레이션 안내가 정확히 어떻게 업그레이드할지 알려줘요. 버전 정책을 참고하세요.

문제 (The problem)

한 모델에 도달하지 않는 조건까지 계속 호출하는 루프는 누군가 멈출 때까지 계속 호출해요. Pydantic AI의 UsageLimits는 한 번의 실행을 위한 그러한 중지예요. 단일 run() 동안 토큰, 요청, 비용을 상한으로 둬요. 그것이 다루지 않는 것은 한 번의 실행보다 긴 기간, 공유 허용액의 테넌트별 몫, 여러 워커 프로세스가 동의하는 카운터예요. 큐의 워커들에 퍼진 일일 상한은 정확히 각 워커가 독립적으로 전체 예산을 가진다고 믿는 경우예요.

프로바이더 사용량 API는 그 틈을 닫지 않아요. 그것들은 청구·관찰 가능성 파이프라인이에요. 사용량은 사후에 집계되고 폴링으로 읽히므로, 그곳의 숫자는 그 뒤의 요청이 이미 만들어져야 움직여요. 그것은 원장을 조정하기에 충분하고, 폭주 루프가 하려는 요청을 거부하기에는 불충분해요.

해결책 (The solution)

SpendLimitsModelResponse.cost()로 모든 모델 응답에 가격을 매기고, 구성하는 각 창(window)에 더하며, 창이 소진되면 다음 요청을 거부해요.

from decimal import Decimal

from pydantic_ai import Agent
from pydantic_ai_harness import SpendLimits
from pydantic_ai_harness.spend import Budget

agent = Agent(
    'openai:gpt-5.4',
    capabilities=[SpendLimits(budgets=[Budget(usd=Decimal('100'), window='day')])],
)

UTC 하루에 $100를 넘으면 다음 요청이 SpendLimitExceeded를 발생시켜요.

예산 (Budgets)

예산은 상한, 기간, 선택적 파티션으로 구성돼요. 그것들은 조합되므로 여러 개가 동시에 적용돼요:

from decimal import Decimal

from pydantic_ai_harness import SpendLimits
from pydantic_ai_harness.spend import Budget

SpendLimits(
    budgets=[
        Budget(usd=Decimal('5'), window='run'),  # 폭주 실행 하나
        Budget(usd=Decimal('100'), window='day'),  # 전체 배포, 하루별
        Budget(usd=Decimal('2000'), window='month', warn_at=0.8),
        Budget(usd=Decimal('10'), window='day', scope=lambda ctx: ctx.deps.tenant_id, name='tenant'),
    ]
)
필드 의미
usd / tokens 상한; 하나, 둘 다, 또는 아무것도 설정
window run, conversation, day, month, total
scope 실행에서 파티션 키를 도출해 테넌트가 별도로 계산되게 함; 에이전트의 deps에 대해 타입 지정
warn_at 넘으면 BudgetStatus.warning이 설정되는 분율; 절대 차단하지 않음
name 같은 창·범위를 공유하는 예산을 구분
retain 마지막 쓰기 후 카운터를 얼마나 유지할지; 'window default', 'forever', 또는 timedelta

창은 카운터를 리셋하는 대신 다른 스토어 키를 만드는 것으로 롤오버되므로, 새 하루는 그저 새 키이고 자정에 실행할 것이 없어요. total 카운터는 결코 만료되지 않아요. runconversation 버킷도 절대 롤오버하지 않으므로, 그곳의 만료는 새 기간을 시작하는 대신 상한을 되돌려줘요 — 그래도 실행이나 대화마다 키를 새로 만들어 긴 지평(24시간·30일)을 지니고, 그 이상은 카운터가 영원히 유지되는 대신 버려져요. 그 기본값은 절충이며 보여요. 지평을 지나 재개된 대화는 0에서 다시 시작하므로, 대화 상한이 대화만큼 오래 지속되어야 하는 곳에서는 retain='forever'를 설정하고 어떤 다른 방식으로 키를 정리하세요.

name, window, scope를 공유하는 예산은 하나의 카운터를 공유해요. 이것이 단일 창이 USD와 토큰 상한을 모두 운반하는 방식이에요. 응답은 예산마다가 아니라 그 카운터에 한 번 더해져요. namewindow를 공유하지만 다른 scope callable을 선언하는 두 예산은 구성 시 거부돼요. 그것들은 다른 차원(예: 테넌트별, 사용자별)이지만, 둘이 같은 문자열을 반환하는 것을 막는 것이 없어 두 카운터 하나로 합쳐질 수 있기 때문이에요. 다른 이름을 주거나 두 예산에 같은 callable을 전달하세요. 카운터를 공유하는 예산은 retain도 동의해야 해요. 하나의 카운터는 하나의 만료를 갖고, accrual은 처음 나열된 것을 쓰므로, 불일치하면 선언 순서가 'forever' 상한이 언제 롤오버할지 결정하게 돼요.

Budget은 에이전트 의존성 타입에 대해 제네릭이므로 scope가 그것에 대해 검사돼요. capability를 deps_type이 있는 Agent에 전달하고, 그 deps가 없는 필드를 더듬는 scope는 첫 요청의 AttributeError가 아니라 타입 오류예요.

상한이 없는 예산은 카운터예요. 그것은 누적·보고하고 아무것도 거부하지 않아요. 이것이 상한 없는 테넌트별 회계를 표현하는 방식이에요:

from pydantic_ai_harness import SpendLimits
from pydantic_ai_harness.spend import Budget

SpendLimits(budgets=[Budget(window='month', scope=lambda ctx: ctx.deps.tenant_id, name='chargeback')])

게이트가 보장하는 것 (What the gate guarantees)

예산이 소진된 후에는 어떤 요청도 시작하지 않아요.

아닌 것: 지출이 상한 아래 유지된다는 것. 경계를 넘는 요청은 완료되고, 동시 실행은 그 중 어느 것도 기록하기 전에 각각 검사를 통과할 수 있어요. 발견하기보다는 알 만한 세 가지 추가 틈이 있어요. 호출자가 중간에 버린 스트림은 회계 훅에 닿지 않아 그 토큰이 프로바이더에 청구되고 여기서 보이지 않아요. 프로바이더를 호출하지 않고 캐시에서 답하는 capability는 반환하는 응답에 대해 레지스트리 가격이 청구돼요. 연속 체인(Anthropic pause_turn, OpenAI background mode)은 하나의 병합된 응답으로 훅에 도착하며, Pydantic AI가 하나의 요청으로 세는 것과 같아요. 그래서 그 세그먼트는 한 번에 하나가 아니라 합산 사용량으로 가격이 매겨져요 — 차이는 가격이 선형이 아니라 계층적인 곳에서만 드러나요. 이것을 회계 원장이 아니라 폭주 루프의 브레이크로 취급하고, 두 번째 것이 필요하면 프로바이더 자체 숫자와 조정하세요.

숫자 읽기 (Reading the numbers)

from decimal import Decimal

from pydantic_ai import Agent
from pydantic_ai_harness import SpendLimits
from pydantic_ai_harness.spend import Budget, SpendRecordedEvent

limits = SpendLimits(budgets=[Budget(usd=Decimal('100'))])
agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[limits])

@agent.on_event(SpendRecordedEvent)
async def show(ctx, event):
    print(f'{event.model} cost ${event.usd}')

SpendRecordedEventon_unpriced='raise'가 곧 거부할 것을 포함해 모든 응답 후에 방출돼요. 그것의 평평한 페이로드는 응답 사용량과 직렬화 가능한 예산 판독을 지녀요. 지속 실행 하에서 오케스트레이션은 저널링된 accrual이 한 번만 실행됐어도 그것을 다시 전달할 수 있으므로, 감사 기록을 쓰거나 청구 이벤트를 방출하는 리스너를 멱등하게 유지하세요.

마이그레이션: on_spend는 여전히 지원되지만 deprecated예요. 그 콜백 본문을 SpendRecordedEvent 구독으로 옮기세요. 같은 멱등성 요구가 적용돼요.

status()는 실행 없이 같은 숫자를 읽어요. UI의 비용 표시가 원하는 것이에요:

from pydantic_ai_harness import SpendLimits


async def report(limits: SpendLimits[None]) -> None:
    for status in await limits.status(scope='acme'):
        print(status.budget.name, status.spent.usd, status.exhausted)

실행 컨텍스트 없이는 run이나 conversation 창의 예산이 생략되고, scope=가 읽을 파티션을 지명하지 않으면 scope를 선언한 예산도 생략돼요. 실행 안에서 ctx를 전달하면 모든 예산이 해결돼요.

expose_tools=True를 설정하면 에이전트에 get_spend 도구를 줘요. 기본 꺼짐. 도구는 매 요청에서 스키마 토큰을 쓰고, 대부분 애플리케이션은 모델의 컨텍스트보다 화면의 숫자를 원하기 때문이에요.

임계값에 반응하기 (Reacting to a threshold)

Spend 이벤트는 승인 지점이 아니라 보고 신호예요. 최종 답을 지니는 응답을 따를 수 있어요.

요청 전이 아니라 응답 후에 실행되는 이음매는 before_model_request예요. 자신의 작은 capability가 거기서 status(ctx)를 읽고 누군가 결정할 때까지 실행을 붙잡을 수 있어요:

import asyncio
from dataclasses import dataclass
from decimal import Decimal

from pydantic_ai import Agent
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.models import ModelRequestContext
from pydantic_ai.tools import RunContext

from pydantic_ai_harness import SpendLimits
from pydantic_ai_harness.spend import Budget

limits = SpendLimits[None](budgets=[Budget(usd=Decimal('100'), warn_at=0.8)])
approvals: asyncio.Queue[bool] = asyncio.Queue()


@dataclass
class ApproveBeforeSpending(AbstractCapability[None]):
    async def before_model_request(
        self, ctx: RunContext[None], request_context: ModelRequestContext
    ) -> ModelRequestContext:
        if any(status.warning for status in await limits.status(ctx)) and not await approvals.get():
            raise RuntimeError('spending past the warning threshold was not approved')
        return request_context


agent = Agent('openai:gpt-5.4', deps_type=type(None), capabilities=[limits, ApproveBeforeSpending()])

게이트는 SpendLimits가 이미 누적한 숫자를 읽어요. 이전 응답이 이 요청이 준비되기 전에 wrap_model_request 안에서 세어졌기 때문이에요. 그것은 실행의 첫 요청도 게이트해요. 이전 실행이 넘은 임계값을 다음 실행으로 옮기는 것이에요. 그것 뒤에 나열된 capability는 여전히 SkipModelRequest로 요청을 건너뛸 수 있으므로, 여기서 취한 승인은 요청이 뒤따랐다는 증거가 아니에요.

그 일시 중지는 코루틴을 붙잡으므로 프로세스가 사는 한 오래 지속되고 그 이상은 아니에요. 모델 요청 경계의 직렬화 가능한 일시 중지는 사용할 수 없어요. Pydantic AI의 지연 경로는 도구 경계뿐이에요. CallDeferredApprovalRequired는 도구 호출이 검증·실행되는 곳에서 존중되지만, 모델 요청 훅에서 raise하면 아무것도 잡지 못해 실행이 베어 예외로 끝나는데 그것은 자체 메시지를 지니지 않아요. #151이 직렬화 가능한 연속을 가진 일반 인터럽트를 추적해요.

멈추기보다 확장하는 상한을 위해, budgets는 매 요청에서 새로 읽히므로 거부 후에 교체하면 더 큰 상한에 맞춰 작업이 계속돼요. 카운터는 상한이 아니라 name, window, scope, 창이 현재 있는 기간으로 키가 매겨지므로, 이미 쓴 것은 이어져요.

거부는 도구 호출이 이미 실행되고 지불된 후 실행 중간에 떨어질 수 있어요. 원래 프롬프트를 다시 실행하면 그 작업과 그 부작용을 반복하므로, 거부된 실행이 만든 것에서 재개하세요. capture_run_messages가 부분 히스토리를 보유하고, 그 히스토리와 새 프롬프트 없이 주어진 실행은 거부된 요청에서 계속 돼요.

import dataclasses
from decimal import Decimal

from pydantic_ai import Agent, capture_run_messages

from pydantic_ai_harness import SpendLimits
from pydantic_ai_harness.spend import Budget, SpendLimitExceeded

limits = SpendLimits[None](budgets=[Budget(usd=Decimal('1'), name='daily')])
agent = Agent('openai:gpt-5.4', deps_type=type(None), capabilities=[limits])


async def ask(prompt: str, ceiling: Decimal) -> str:
    with capture_run_messages() as messages:
        try:
            return (await agent.run(prompt)).output
        except SpendLimitExceeded:
            (budget,) = limits.budgets
            limits.budgets = [dataclasses.replace(budget, usd=ceiling)]
            resumable = list(messages)
    return (await agent.run(message_history=resumable)).output

budgets를 할당해도 생성자가 예산 조합에 대해 실행하는 검사를 반복하지 않으므로, 교체물을 같은 이름·창·범위로 유지하세요. 그것은 또한 거부된 하나뿐 아니라 이 SpendLimits를 공유하는 모든 실행의 상한을 올리고, 아무것도 다시 낮추지 않아요.

프로세스 간 카운터 공유 (Sharing a counter across processes)

기본 스토어는 카운터를 프로세스 안에 유지해, 한 워커 안의 폭주 루프를 잡고 큐에 퍼진 예산에 대해서는 아무것도 하지 않아요. RedisSpendStore가 공유 카운터예요:

from decimal import Decimal

from redis.asyncio import Redis

from pydantic_ai_harness import SpendLimits
from pydantic_ai_harness.spend import Budget, RedisSpendStore

store = RedisSpendStore(Redis.from_url('redis://localhost'))
limits = SpendLimits(budgets=[Budget(usd=Decimal('100'), window='day')], store=store)

그것은 의존성을 추가하지 않아요. RedisClient는 사용되는 두 코루틴의 프로토콜이므로 어떤 호환 클라이언트도 만족시켜요. 금액은 INCRBYFLOAT가 아니라 달러의 정수 10억분의 1로 저장돼요. INCRBYFLOAT는 바쁜 하루가 만드는 수만 요청에 걸쳐 반올림 오류를 누적하기 때문이에요. 잔여물이 평균되지 않으므로 10억분의 1이 백만분의 1보다 나아요. 에이전트가 거의 같은 모양의 요청을 반복해서, 같은 분율이 매번 같은 방식으로 반올림되기 때문이에요.

응답이 계산되는 모든 창은 하나의 Lua 스크립트로 적용되므로, 다른 클라이언트가 응답이 부분 적용된 것을 보지 못해요. 한 창의 네 카운터를 가로질러서도, 창들 사이에서도 안 돼요. 일일 예산과 월 예산에 계산되는 응답은 두 개가 아니라 하나의 스크립트라, 그것들 사이에 실패가 있어 하루는 세고 월은 세지 않는 일이 없어요. 유일한 예외는 아래 오버플로로, 그것이 일어나는 곳에서 스크립트를 중단하고 이미 적용된 창을 남겨요. 그것은 카운터가 약 $92억 근처에 있어야 도달해요. 아래 호환성 폴백이 있는 동안 각 키는 두 번째 읽기도 써요.

서버가 스크립트를 실행한 후의 실패는 커밋됐는지 말하지 않아요. EVAL이 도착하면 연결이 끊길 수 있으므로, 오류가 난 쓰기는 실패한 게 아니라 결과가 알 수 없는 것이에요. 아무것도 재시도하지 않아요. 청구된 응답을 두 번 세는 것은 브레이크가 견디는 방향이고, 0번 세는 것은 견디지 못해요.

카운터는 반올림하지 않아요. HINCRBY는 64비트 정수 산술이고 증가분을 문자열로 받으므로 Redis가 보유하는 것은 정확해요. 합계는 HINCRBY가 반환하는 정수 답변이 아니라 HMGET로 읽는 벌크 문자열로 돌아와요. 그것들은 Lua 숫자가 되는데 그것은 double이라 나가는 길에 2**53 10억분의 1을 넘는 합계를 반올림할 것이기 때문이에요. 남는 것은 HINCRBY 자신의 범위예요. 단일 키에 대해 부호 있는 64비트 범위를 지나는 카운터 — 약 $92억 — 는 Redis가 그 필드를 쓰기 전에 거부해요.

키는 {prefix}:budget-key이고, prefix 리터럴 주변에 중괄호가 있어요. 그것은 Redis Cluster 해시 태그라 슬롯이 prefix만에서 나와 한 스토어의 모든 키가 같은 슬롯에 떨어지며, 이것이 하나의 스크립트가 여러 키를 취하게 해요. 대가는 클러스터가 스토어의 키를 노드에 펼칠 수 없고, 텅 비었거나 자체 중괄호를 지닌 prefix는 구성 시 거부된다는 것이에요. 둘 다 태그가 하나로 읽히는 것을 막기 때문이에요. BudgetStatus.key는 prefix 없이 예산 키를 보고하므로 보여주는 것은 변하지 않아요. 아래 dedup 마커는 그 이름공간을 공유하므로 dedup|로 시작하는 키는 거부돼요. 그것은 문자열을 지니는 마커를 지명할 것이고, 카운터가 누적하는 대신 WRONGTYPE으로 실패할 것이기 때문이에요. 어떤 예산도 그런 키를 만들지 않아요 — 두 번째 세그먼트는 항상 창이어요 — 그래서 이것은 스토어 자체를 구동하는 호출자에게만 닿아요.

이전 릴리스가 태그 없는 이름 아래 쓴 카운터는 태그된 것 옆에서 읽혀 그것에 더해지므로 업그레이드에 마이그레이션 단계가 필요 없어요. 이동이 아니라 추가예요. 이동은 완료된 때를 결정해야 하는데, 여기서 아는 것이 없어요. 여전히 이전 릴리스에 있는 워커가 롤링 배포 중 언제든 이전 이름에 쓸 수 있고 이미 실행된 이동은 그것을 절대 집지 않기 때문이에요. 대가는 읽기와 쓰기 모두에서 키당 추가 읽기 하나예요.

호환성은 한 방향으로만 움직이므로 세 가지에 대해 계획할 만해요. 롤링 배포는 지속되는 동안 과소 계산해요. 업그레이드된 워커는 두 이름을 모두 보지만, 여전히 이전 릴리스에 있는 하나는 태그 없는 것만 읽고 업그레이드된 워커가 쓴 것을 볼 수 없어, 그것들이 빠진 합계에 대항해 요청을 받아들여요. 다운그레이드는 같은 이유로 태그된 카운터를 통째로 잃어요. 이 릴리스는 이전 이름을 절대 쓰지 않으므로 되돌아오는 것이 없어요. 그리고 이전 키의 만료는 마지막 이전 릴리스 쓰기가 설정한 것에 얼어붙어요. 여기서는 아무것도 그것을 갱신하지 않으므로, 언제 가면 가고 합계는 그것이 담은 만큼 떨어져요. 배포를 짧게 유지하고, 다운그레이드를 롤백이 아니라 리셋으로 취급하세요.

폴백은 0.28.0에서 사라져요. 그 시점에 여전히 이전 이름 아래 살아있는 카운터는 세는 것을 멈추므로, retain='forever'로 설정된 창이나 지평이 두 릴리스 사이의 틈보다 오래 가는 창은 그 전에 손으로 옮길 만해요.

add_many는 응답을 식별하는 토큰을 지니고, RedisSpendStore는 같은 스크립트 안에서 증가 전에 마커를 읽고 증가 후에 써요. InMemorySpendStore는 잠금 아래 같은 토큰을 기억하므로 기본 스토어는 프로세스가 사는 동안 같은 방식으로 동작해요. 토큰은 run id와 단계를 재생 안정 응답 콘텐츠·사용량·프로바이더 신원의 다이제스트와 결합해요. 그것은 클록 파생 및 임의 프로바이더 장부를 제외해요. 토큰 계층은 저널에 상의하지 않고 항목을 제시하는 복구를 보호하는데, 호출자가 원래 실행과 그 복구에서 Agent.run에 같은 run_id를 공급할 때만 그래요. run_id가 생략되면 Pydantic AI가 새 것을 만들고 스토어가 항목을 인식하지 못해요. 일반적인 지속 재생은 어느 쪽이든 저널링된 _accrue 작업으로 보호돼요. 마커는 두 스토어 모두의 필드인 dedup_retain(기본 1시간, 또는 더 짧다면 창 자신의 지평) 동안 보유되고, 창당 응답당 작은 키 하나를 써요. 그 지평은 지속 저널 밖 복구가 인식되는 창이지 카운터 수명이 아니에요. 나중에 다시 제시된 응답은 다시 세어지는데, 이것은 실수해도 되는 방향이에요. 일찍 끊기는 브레이크는 견디고 늦게 풀리는 것은 견디지 못하니까요. dedup_retain=None을 설정하면 마커를 보유하지 않고 모든 항목을 적용하며, 그것은 스토어 측 보호도 포기해요. 어느 쪽이든 인식은 업그레이드에서 시작해요. 이전 릴리스가 세어본 응답은 마커를 남기지 않았으므로, 그 응답을 다시 제시하면 다시 세어져요.

기본 스토어는 capability별로 빌드되므로 두 SpendLimits 인스턴스는 조용히 하나의 카운터를 공유하지 않아요. 원할 때 둘 다에 같은 스토어 객체를 전달하세요. InMemorySpendStore는 워커 교체를 견디지 못해요. 교체 프로세스는 첫 번째가 누적한 카운터도 dedup 마커도 없어요. 다른 워커에서 회복할 수 있는 지속 워크플로에는 공유 스토어를 사용하세요.

실패하는 스토어는 조용히 실패하지 않아요. 카운터 읽기 오류는 요청을 거부하는데, 이것이 안전한 방향이에요. 쓰기 오류는 모델이 이미 답하고 청구된 후 실행에서 전파돼요. 그것은 의도적이에요. 삼켜진 쓰기는 카운터를 낮게 표류시켜 게이트를 약화시키는데, 보이는 실패보다 나쁘기 때문이에요. 배포가 카운트보다 답을 유지하길 원하면 그 스토어를 감싸고 거기서 결정하세요.

get_manyadd_many를 가진 어떤 객체든 작동하므로, Postgres나 DynamoDB 카운터는 포크가 아니라 작은 클래스예요. 하나를 쓰는 데 네 가지 의무가 따라와요. 받은 모든 키에 대해 SpendEntry.key로 키가 매겨진 합계를 반환하고, 재생으로 건너뛴 것도 포함해요. 누락된 합계는 원인이 이 스토어 계약이거나 지속 재생 중 비결정적 scope임을 지명하는 UserError를 발생시켜요. 절대 쓰이지 않은 키를 생략하지 말고 0으로 읽어요. token이 이미 그 키에 적용된 항목은 건너뛰어요. 그렇지 않으면 지속 저널 밖 복구가 하나의 응답을 두 번 세어요. 그리고 전체 호출을 적용하거나 아무것도 적용하지 마세요. 이 절의 상단 보장은 뒤의 백엔드만큼만 좋고, 항목마다 커밋하는 스토어는 이 이음매가 제거하려는 분할 쓰기를 되돌려놓아요. 어느 메서드에도 빈 시퀀스는 절대 전달되지 않으므로 그런 경우에 답할 것이 없어요.

SpendStore, 0.17.0에서 릴리스된 단일 키 getadd 쌍은 deprecated예요. 그 모양의 스토어는 여전히 작동하고, 호출당 하나의 창을 구동하며, 그것을 보유한 SpendLimits가 구성될 때 HarnessDeprecationWarning 하나를 방출해요. 경고는 두 손실을 모두 지명해요. 창이 한 번에 하나씩 적용되고, 토큰이 갈 곳이 없어요. 지속 저널은 그 기록이 가능한 동안 중복 실행을 여전히 막지만, 그 저널에 상의할 수 없는 복구는 스토어 측 중복 제거가 없어요. InMemorySpendStoreRedisSpendStore의 단일 키 getadd도 deprecated예요. 둘 중 하나를 직접 호출해도 경고하지 않으므로, 이것이 공지예요. get_manyadd_many를 사용하세요. 그중 하나를 배치 쌍도 재정의하지 않고 재정의한 하위 클래스는 스토어 자체가 구성될 때 경고받아요. 그 경우 deprecated 메서드를 지명하는 것뿐 아니라 동작을 잃기 때문이에요. SpendLimitsget_manyadd_many를 구동하므로, get이나 add의 재정의는 절대 호출되지 않고 그것이 더한 것 — 감사, 미러 쓰기 — 이 멈춰요. 그것을 get_manyadd_many로 옮기세요. 그것이 또한 경고를 멈추게 해요.

가격 책정 (Pricing)

가격은 ModelResponse.cost()를 통해 genai-prices에서, 응답당으로 와요. 캐시·계층 가격은 요청당이므로, 요청 간 사용량을 합산하고 합계에 가격을 매기면 잘못된 숫자가 나와요.

레지스트리가 모르는 모델 — 로컬 배포, 협상된 요율 — 은 price로 처리돼요:

from decimal import Decimal

from pydantic_ai_harness import SpendLimits

SpendLimits(price=lambda response: Decimal('0.002') if response.model_name == 'internal-7b' else None)

price가 반환한 금액은 유한하고 음수가 아니어야 해요. 그 밖의 것 — 크레딧, NaN, 무한대 — 은 UserError로 실행을 실패시켜요. 크레딧은 예산을 상한에서 멀리 옮기고, 다른 둘은 가격이 아니라 깨진 가격 함수이기 때문이에요. 응답은 여전히 먼저 기록돼요. 함수가 무엇을 반환하든 프로바이더가 청구했으므로, 그 토큰과 요청 횟수가 accrual되고 오류가 발생하기 전에 on_spend가 발화해요.

지속 실행 하에서 price와 각 예산의 scope callable은 같은 응답·실행 컨텍스트에 대해 결정적이어야 해요. 가격 책정은 저널링된 accrual 밖의 오케스트레이션에서 실행되므로, 바뀐 결과는 on_spend가 기록된 카운터와 불일치하게 하거나 성공적인 복구를 가격 오류로 만들 수 있어요. 그것을 지속 작업으로 옮기려면 지속성 백엔드가 임의 메타데이터를 포함해 완전한 프로바이더 응답을 직렬화해야 하므로, callable은 그 경계 밖에 남아요. 바뀐 scope는 기록된 accrual의 것과 다른 스토어 키를 선택해요. SpendLimits는 그 불일치를 결정성 요구를 지명하는 UserError로 보고해요.

None을 반환하면 레지스트리로 폴백해요. 아무것도 응답에 가격을 매길 수 없으면 on_unpriced가 결정해요. 'zero'(기본)는 그것을 무료로 세고 Spent.unpriced_requests를 증가시켜 틈이 보이게 해요. 'raise'UnpricedModelError로 실행을 실패시켜요. 어느 쪽이든 응답이 먼저 기록되고 토큰이 세어지므로, 토큰 상한은 가격이 없는 모델에서도 유지되고, 오류를 잡는 애플리케이션은 과소 계산된 카운터에 대항해 이어가지 않아요. 'zero' 아래에서 USD 상한은 유지될 수 없는 하나예요. 가격 매길 수 있는 것이 누적되지 않으므로, 그러한 요청이 몇 개든 그것에 닿지 못해요. 그 조합 — 'zero' + usd 예산 — 은 요청당이 아니라 모델당 한 번 UnpricedModelWarning으로 경고해요. 호출자가 모델을 고른다면 'raise'를 선호하거나 price를 공급하세요.

구성 (Composition)

상태는 의도적으로 실행을 가로질러 살아 있으므로 for_run은 재정의되지 않아요. 매 실행마다 리셋되는 일일 예산은 일일 예산이 아니에요. 실행별 격리는 Budget(window='run')에서 오며, 그 키는 run id를 지녀요.

defer_loading=True는 거부돼요. 지연된 capability의 훅은 모델이 그것을 로드할 때까지 실행되지 않으므로, 소진된 예산이 요청을 멈추지 않고 그 사이 만들어진 요청이 세어지지 않을 거예요 — 브레이크가 브레이크되는 것이 언제 적용할지 결정하는 것이에요.

accrual은 wrap_model_request에서, 프로바이더 호출 바로 주변에서 일어나고, capability는 자신을 innermost로 선언해서 그 래퍼가 innermost 티어 밖의 모든 capability 안에 앉아요. 모든 after_model_request는 그것 밖에서 실행되고, innermost 티어에서 그것 뒤에 나열된 capability를 제외한 모든 래퍼도 그렇지요.

after_model_request는 이것에 잘못된 훅이에요. 그것은 전체 wrap 체인이 반환된 후에 실행되므로, 자신의 wrap_model_request가 응답을 기다린 다음 ModelRetry를 raise하는 capability는 실행을 곧장 새 요청으로 보내고, 거부된 것 — 생성·청구·히스토리에 유지 — 은 절대 세어지지 않아요. 순서는 그 경우에 닿을 수 없어요. 거부하는 capability가 innermost일 필요가 없고, SpendLimits 앞에 나열된 것도 여전히 그것 밖에서 감싸요.

래핑은 또한 프로바이더가 절대 보지 못한 요청이 청구되지 않는다는 것을 의미해요. 이전 capability의 before_model_request에서 온 SkipModelRequest는 실행이 결코 지불하지 않은 응답과 함께 after_model_request에 닿지만, 래핑된 핸들러에는 닿지 않아요.

남는 것은 형제들이에요. Pydantic AI는 innermost capability를 non-innermost 것에 대해서만 정렬하고, 그들 사이에서 나중에 나열된 것이 더 안쪽에 중첩돼요. InputGuardrail과 지속성 capability도 스스로 innermost를 선언하므로, SpendLimits 뒤에 나열된 어느 쪽이든 그것 안에 감싸요. 청구된 응답을 세기 전에 거부할 수 있는 것은 InputGuardrail이에요. InputGuardrail(parallel=True)에서 무엇이 결정하는지는 누가 경주에서 이기는가가 아니라 가드가 차단하는지예요. 차단된 프롬프트는 어느 결과에도 세어지지 않아요. 가드가 먼저 정착하면 호출을 취소하고, 모델이 먼저 하면 답을 버리기 때문이에요. 두 번째는 과소 계산이에요. 프로바이더가 청구했지만 SpendLimits가 절대 보지 못한 응답이에요. 지속성 래퍼는 거부가 아니라 발송하므로 그 틈을 만들지 않고 경고에서 생략돼요. 차이가 중요할 때 다른 innermost capability 중 SpendLimits를 마지막에 나열하세요. 가드레일 경우를 완전히 닫으려면 innermost capability를 서로 정렬하는 방법이 필요하며, #534에서 추적돼요.

SpendLimits는 그 배치를 여기서 읽히라고 남겨두는 게 아니라 보고해요. 각 모델 요청 전에 RunContext.root_capability에서 정렬된 체인을 읽고, 자신의 wrap_model_request를 가져오는 그것 뒤에 나열된 capability를 지명하며 SpendCompositionWarning으로 경고해요. 하나의 배치가 한 번 보고돼요. 요청당이 아니에요. 그리고 기억되는 것은 보고했다는 사실이 아니라 배치이므로, 첫 실행이 안전했던 에이전트도 agent.run(capabilities=[...])로 내부 래퍼를 추가하는 이후 실행에서 여전히 읽혀요. 거부가 아니라 경고이고, capability가 그것으로 무엇을 하는지가 아니라 순서에 키가 매겨져요. 위 조건 중 어느 것도 읽히지 않아요. parallel은 목록에서 아무것도 옮기지 않고 뒤집힐 수 있고, 보고가 이루어지는 지점에서는 판정도 경주도 정착되지 않았어요. 그래서 그것은 또한 요청 전에 raise해서 과소 계산할 수 없는 SpendLimits 뒤에 나열된 순차 InputGuardrail을 지명해요. 재정렬이 그것을 잠잠하게 하며, 위 단락이 어쨌든 권장하는 것이에요.

세 종류의 capability가 그 보고에서 제외돼요. Hooks는 지명되지 않아요. model_request 훅이 등록됐는지 여부에 관계없이 wrap_model_request를 정의하고, 말할 레지스트리는 사적이기 때문이에요(pydantic-ai#7177). WrapperCapability는 자신의 wrap_model_request가 위임만 하므로 그것이 감싸는 것에 답해요. 그래서 실제 거부자 위의 래퍼는 여전히 지명돼요. 지속 실행 capability도 제외돼요. 그 래퍼는 응답을 거부하는 대신 작업을 발송하고, core는 그 발송이 모델 핸들러 주변의 마지막 래퍼여야 한다고 요구해요. SpendLimits는 래퍼를 재정렬하는 대신 자체 지속 작업으로 그 경계를 건너요.

지속 실행. SpendLimits는 Pydantic AI 지속성 capability를 지원해요. 그것의 클록 읽기, 카운터 읽기, accrual은 별개의 지속 작업이에요. 따라서 Temporal은 workflow 오케스트레이션이 아니라 활동에서 클록을 읽고, DBOS나 Prefect는 자체 지속 단위에서 같은 경계를 기록해요. 재생 시 엔진은 클록이나 스토어를 다시 읽지 않고 각 작업의 기록된 결과를 반환해요. 응답은 한 번 accrual되고, 창 키는 원래 기록된 클록 값에서 와요.

지속성 capability를 SpendLimits와 같은 에이전트에 붙이세요. TemporalDurability 없이 Temporal workflow 안에서 SpendLimits 에이전트를 직접 실행하면 클록 읽기가 workflow 오케스트레이션에 남고, 샌드박스 오류가 지속성을 붙이라는 조언으로 번역돼요.

저널은 그 기록이 가능한 동안 재생을 덮어요. 스토어 측 멱등성은 다른 복구 경로를 덮어요. 기록된 accrual에 상의하지 않고 같은 SpendEntry를 제시하는 것이에요. BatchSpendStore는 재생 안정 토큰을 사용해 dedup_retain 안에서 그 항목을 한 번 적용해요. deprecated SpendStore는 토큰을 버리고 경고하므로 이 두 번째 계층을 제공할 수 없어요. InMemorySpendStore는 복구가 같은 프로세스에 닿을 때만 중복 제거할 수 있어요. 복구가 다른 워커에 떨어질 수 있으면 RedisSpendStore 같은 공유 BatchSpendStore를 사용하세요.

exhausted()RunContext 없이 workflow 입장 검사로 여전히 유용해요:

from collections.abc import Awaitable, Callable

from pydantic_ai_harness import SpendLimits


async def start_if_funded(
    limits: SpendLimits[None], tenant_id: str, start_workflow: Callable[[], Awaitable[object]]
) -> None:
    if await limits.exhausted(scope=tenant_id):
        raise RuntimeError('daily budget exhausted')
    await start_workflow()

exhausted (any(s.exhausted for s in await limits.status(...))가 아니라): status는 해결할 수 없는 예산을 생략하고, 남은 것에 대한 any()는 아무것도 검사하지 않고 지나가는 브레이크예요 — 모든 예산이 범위가 지정된 SpendLimits가 범위가 없을 때 반환하는 것이 정확히 그것이에요. exhausted는 대신 거기서 raise하며, scope나 실행 컨텍스트가 필요한 예산을 지명해요. 읽기에는 status, 결정에는 exhausted를 사용하세요.

입장은 그 호출이 하는 전부예요. 아무것도 예약하지 않아요. 그다음 지속 작업이 workflow의 모델 응답을 회계해요. 이슈 #531이 이 지원과 남은 스토어 수명 한도를 추적해요.

한 번의 실행만 다른 것 없이 덮는 상한을 위해, Pydantic AI 자신의 UsageLimits는 스토어·capability 없이 프로세스 안에서 같은 일을 해요. total_tokens_limit가 토큰용, cost_limit가 돈용이고, 둘 다 단일 run()에 걸쳐요. 가격 없는 응답은 RunUsage.cost에 아무것도 더하지 않으므로, cost_limit는 실행을 그것의 가격 매길 수 있는 부분에 대해 측정해요. 전부가 아니면 CostNotFoundWarning, 일부만이면 침묵. SpendLimits는 같은 틈을 세고 on_unpriced가 무엇을 할지 결정하게 해요. UsageLimits는 또한 SpendLimits에 상응하는 것이 없는 두 입력 토큰 세분성을 지녀요. input_tokens_limit는 실행에 걸쳐 누적이고, per_request_input_tokens_limit는 이미 지불한 응답의 프로바이더 보고 입력 토큰에 대해 한 요청을 상한으로 둬요. count_tokens_before_request=True는 모델의 자체 count_tokens로 보류 요청을 세고 보내기 전에 두 한도를 그 카운트에 적용해, 과대 컨텍스트가 count_tokens를 구현하는 프로바이더에서 청구되는 대신 거부돼요. 필드가 그것들을 지명하고, 없는 모델은 대신 NotImplementedError를 raise해요. 같은 구성이 한 번의 실행보다 긴 창, 테넌트 범위, 프로세스 간 공유 카운터도 표현해야 할 때 Budget(tokens=..., window='run')을 사용하세요.

트레이싱 (Tracing)

거부는 spend.budgetspend.window를 지니는 spend budget exhausted 스팬을 방출해요. accrual은 아무것도 방출하지 않아요. 모델 요청당 스팬은 결정을 추가하지 않고 트레이스 크기를 두 배로 만들기 때문이에요. spend.scopeRunContext.trace_include_content가 설정될 때만 붙어요. scope 키는 보통 테넌트나 사용자 id이고 트레이스는 그것을 만든 애플리케이션보다 더 넓은 관객을 갖기 때문이에요.

Specs

Agent.from_spec은 spec이 표현할 수 있는 구성 부분을 지원해요:

- SpendLimits:
    budgets:
      - {usd: '100', window: day}
      - {usd: '2000', window: month, warn_at: 0.8}
    on_unpriced: raise

store, price, on_spend, clock, 예산의 scope는 callable이나 실객체를 받아요. 그것들을 지명하는 spec은 조용히 무시되는 게 아니라 거부돼요. 테넌트별 범위를 약속하고 전달하지 않는 spec은 로드를 거부하는 것보다 나쁘기 때문이에요.

위 필드는 SpendLimits.from_spec이 시그니처에서 지명하는 것인데, Pydantic AI가 spec의 JSON 스키마를 생성하기 위해 읽는 것이기도 해요. $schema 줄을 따르는 에디터가 그것들을 완성·검증해요. BudgetSpec은 코드로 spec을 만드는 사람을 위해 export되는 항목 모양이에요.

출처: pydantic_ai_harness/spend/.

API 참조 (API reference)

SpendLimits

Base: AbstractCapability[AgentDepsT]

창별 지출을 누적하고 창이 소진되면 요청을 거부해요.

from decimal import Decimal

from pydantic_ai import Agent
from pydantic_ai_harness.spend import Budget, SpendLimits

agent = Agent(
    'openai:gpt-5.4',
    capabilities=[SpendLimits(budgets=[Budget(usd=Decimal('100'), window='day')])],
)

예산이 없으면 capability는 SpendRecordedEvent를 통해서만 보고해요. 절대 차단하지 않는 실행 합계를 유지하려면 상한 없는 Budget을 추가하세요.

게이트가 보장하는 것: 예산이 소진된 후에는 어떤 요청도 시작하지 않아요. 안 하는 것: 지출이 상한 아래 유지. 경계를 넘는 요청은 완료되고, 동시 실행은 그 중 어느 것도 기록하기 전에 각각 검사를 통과할 수 있어요. 이것은 폭주 루프의 브레이크이지 회계 원장이 아니에요.

상태는 의도적으로 실행을 가로질러 살아 있으므로 for_run은 건드리지 않아요. 매 실행마다 리셋되는 일일 예산은 일일 예산이 아니에요. 실행별 격리는 Budget(window='run')에서 오며, 그 키는 run id를 지녀요.

지속성 capability 아래에서 클록 읽기, 카운터 읽기, accrual은 지속 작업이에요. 그 기록된 결과는 스토어에 다시 들어가지 않고 재생돼요. 복구가 다른 워커로 이동할 수 있을 때는 여전히 공유 스토어가 필요해요. 기본 InMemorySpendStore는 카운터와 dedup 마커를 프로세스와 함께 잃어요. deprecated SpendStore도 그 단일 키 add 메서드가 SpendEntry.token을 받을 곳이 없으므로 저널 밖의 토큰 기반 중복 제거를 잃어요.

속성 (Attributes)
  • budgets — 누적할 창과 그중에서 요청을 거부할 수 있는 것. 타입: Sequence[Budget[AgentDepsT]] 기본: ()
  • store — 카운터가 사는 곳. 기본값은 프로세스 수명 동안 보유. deprecated SpendStore 쌍만 구현하는 스토어는 어댑터를 통해 호출당 하나의 창으로 구동되며, 구성 시 그것이 무엇을 쓰는지 한 번 경고해요. 타입: SpendStore | BatchSpendStore 기본: field(default_factory=InMemorySpendStore)
  • price — 레지스트리를 조회하기 전에 응답에 가격을 매김. None을 반환하면 genai-prices로 폴백. 자체 호스팅 모델이나 공개 레지스트리가 모르는 협상 요율을 청구하는 방법이에요. 금액은 유한하고 음수가 아니어야 해요. 그 밖의 것은 응답의 토큰·요청 수가 기록된 후 UserError로 실행을 실패시켜요. 크레딧은 예산을 상한에서 멀리 옮기고, NaN이나 무한대는 가격이 아니라 깨진 가격 함수예요. 지속 실행 하에서 이 callable은 같은 응답에 재생될 때 같은 결과를 반환해야 해요. 그것은 저널링된 accrual 밖에서 지속 모델 요청 후 오케스트레이션에서 실행돼요. 타입: PriceFunc | None 기본: None
  • on_spend — 각 응답 후의 deprecated 콜백. 대신 SpendRecordedEvent를 구독하세요. 지속 재생은 응답의 저널링된 accrual이 한 번만 실행된 후에도 이 콜백을 다시 호출할 수 있으므로, 콜백은 멱등해야 해요. 타입: SpendCallback | None 기본: None
  • on_unpriced — 응답에 가격을 매길 수 없을 때 무엇을 할지. 'zero'는 무료로 세고 Spent.unpriced_requests를 증가시켜 틈이 사라지는 대신 보이게 해요. 'raise'UnpricedModelError로 실행을 실패시켜요. 어느 쪽이든 토큰이 세어지므로 레지스트리가 모르는 모델에서도 토큰 상한이 유지돼요. 타입: Literal['zero', 'raise'] 기본: 'zero'
  • expose_tools — 에이전트에 get_spend 도구 제공. 기본 꺼짐. 도구는 매 요청에서 스키마 토큰을 쓰고, 대부분 애플리케이션은 모델 컨텍스트보다 화면의 숫자를 원해요. 타입: bool 기본: False
  • clock — day·month 창이 파생되는 시간을 공급. 기본 구성된 store에 닿지 않아요. 스토어는 만료용 자체 utc_now를 유지해요. 둘 다 절대 인스턴스로 남아, 커스텀 클록은 하나로 버킷하고 다른 하나로 만료해요. 그것이 중요할 때 스토어에 같은 callable을 전달하세요. 타입: Callable[[], datetime] 기본: utc_now
메서드 (Methods)
  • post_init — 평범한 데이터로 도착했고 두 정책 중 하나가 아닌 on_unpriced를 거부. 'raise' 이외의 것은 'zero'로 동작하므로, spec의 오타가 실행을 실패시키는 대신 조용히 가격 없는 응답을 무료로 만들 것.

  • get_serialization_name (@classmethod) — 에이전트 spec 지원용 직렬화 이름.

  • get_ordering — innermost로 앉아 accrual이 순서가 허용하는 만큼 프로바이더 호출에 가깝게 일어나게 해요. innermost는 이 capability의 wrap_model_request를 그 티어 밖의 모든 capability 안에 두므로, 그들의 래퍼 — 그리고 모든 capability의 after_model_request — 가 accrual 밖에서 실행돼 카운터가 이미 보지 못한 응답을 거부할 수 없게 해요. 이는 non-innermost capability에 대해서만 정렬해요. innermost 구성원은 서로 정렬되지 않고, 나중에 나열된 것이 더 안쪽에 중첩되므로, 이것 뒤에 놓인 다른 innermost capability는 여전히 그것 안에 감싸요. InputGuardrail이 카운터보다 먼저 청구된 응답에 닿는 하나예요. 그것이 중요할 때 innermost capability 중 SpendLimits를 마지막에 나열하세요. 완전히 닫는 것은 https://github.com/pydantic/pydantic-ai-harness/issues/534.

  • get_toolsetexpose_tools가 설정되면 get_spend 제공.

  • before_model_request (@async) — 상한이 있는 예산이 이미 소진됐으면 요청을 거부. 또한 get_ordering이 배제할 수 없는 배치가 보고되는 곳. 정렬된 체인은 before_run부터 RunContext.root_capability에서 읽을 수 있지만 그 전은 안 돼요. for_agent는 에이전트가 구성된 capability만 보고, for_run에서는 ctx.root_capability가 여전히 None이므로, 둘 다 agent.run(capabilities=...)로 추가된 capability를 덮지 않아요. before_run도 제공할 만해요. 체인이 실행에 대해 고정돼 있으니까. 읽기는 그것이 대상인 accrual 옆 요청 경로에 있으려고 여기 앉아요. 요청당 재읽기는 _reported_arrangements가 멱등하게 만들므로 비용이 없고, 보고했다는 사실이 아니라 배치에 키를 매기는 것이 실행 간 다른 체인을 덮는 방식이에요.

  • wrap_model_request (@async) — 프로바이더가 반환한 것에 가격을 매기고 모든 창에 더하는데, 바깥 capability가 거부하기 전에. accrual은 after_model_request가 이 체인 밖, 전체 체인이 반환된 후에 실행되므로 이곳에 속하고 after_model_request에는 속하지 않아요. 자신의 wrap_model_request가 응답을 기다린 다음 ModelRetry를 raise하는 capability는 실행을 곧장 새 요청으로 보내고, 그것이 거부한 응답 — 생성·청구·히스토리에 유지 — 은 절대 세어지지 않아요. 순서는 그것을 닫을 수 없어요. 거부하는 래퍼가 innermost일 필요가 없고, 이 capability 앞에 나열된 것도 여전히 그것 밖에 중첩돼요. 래핑은 또한 프로바이더가 절대 보지 못한 요청이 청구되지 않는 이유예요. 이전 before_model_requestSkipModelRequest는 실행이 결코 지불하지 않은 응답과 함께 after_model_request에 닿지요. 그것은 handler에 닿지 않으므로 여기서 아무것도 accrual되지 않아요.

  • status (@async) — 각 예산이 어디 있는지. 실행 안에서 ctx를 전달하면 모든 예산이 해결돼요. 없으면 — 비용 표시가 원하는 읽기, 지속 workflow를 시작하기 전에 할 검사 — run/conversation 창의 예산은 생략돼요(그 기간은 실행 밖에서 의미가 없으므로), scope를 선언한 예산은 scope가 읽을 파티션을 지명하지 않으면 생략돼요(그 callable이 해결할 실행 컨텍스트가 없으므로). 답이 게이트이면 exhausted를 사용하세요. 우연히 빈 튜플에 대한 any(s.exhausted for s in ...)는 집행으로 읽히며 아무것도 검사하지 않는 브레이크이고, 모든 예산이 범위가 지정된 SpendLimits는 정확히 그 튜플을 반환해요.

  • exhausted (@async) — 이 호출이 읽을 수 있는 예산 중 어느 것이 소진됐는지, 나머지에 대해 추측을 거부. 입장 검사이고 그것뿐이에요. 카운터를 읽고, 아무것도 예약·기록하지 않으므로, 그것에 기댄 시작 작업은 에이전트도 이 capability를 지니지 않으면 측정되지 않아요. 지속 실행 하에서 에이전트의 클록 읽기, 카운터 읽기, accrual은 저널링돼요. 그 워커들 사이에 공유되는 예산을 위해 스토어는 여전히 워커 교체를 견뎌야 해요. status()는 해결할 수 없는 것을 생략하고, 나머지에 대한 any(...)는 모든 예산이 범위가 지정될 때 조용히 아무것도 검사하지 않는 브레이크예요 — 그래서 대신 이것이 raise하며, scopectx가 필요한 예산을 지명해요.

  • from_spec (@classmethod) — spec이 표현할 수 있는 필드를 다뤄 에이전트 spec에서 빌드. 모든 파라미터가 이름 붙는 이유는 그 시그니처가 core가 spec의 JSON 스키마를 생성하기 위해 읽는 것이기 때문이에요. build_schema_types*args/**kwargs를 버리므로, catch-all 시그니처는 베어 문자열 'SpendLimits'를 게시하고 에디터가 문서화된 budgets: 블록마다 로드는 되는데도 유효하지 않다고 표시해요. budgets는 매핑으로 도착해 Budget 인스턴스가 되고, usd는 YAML이 float를 통해 가격을 반올림하지 못하도록 문자열로 받아요. callable과 스토어는 spec 표현이 없어 버리는 게 아니라 거부돼요. 테넌트별 범위를 약속하고 전달하지 않는 spec은 로드를 거부하는 것보다 나쁘기 때문이에요. **unsupported는 그 거부가 필드를 지명하게 유지하도록 남아요. core는 그것을 스키마에서 버리므로 거기서 비용이 없어요.

Budget

Base: Generic[AgentDepsT]

하나의 지출 창: 무엇을, 어떤 기간에, 누구에 대해 한계를 두는지. usdtokens도 없는 예산은 순수 카운터예요. 그것은 누적·보고하고 실행을 절대 멈추지 않아요. 이것이 상한 없는 테넌트별 회계를 표현하는 방식이에요.

에이전트 의존성 타입에 제네릭이라 scope가 그것에 대해 검사돼요. 파라미터는 capability가 전달되는 Agent에서 오므로, deps가 없는 필드를 더듬는 scope는 첫 요청의 AttributeError가 아니라 타입 오류예요.

from decimal import Decimal

from pydantic_ai_harness.spend import Budget

Budget(usd=Decimal('100'), window='day')
Budget(usd=Decimal('10'), window='day', scope=lambda ctx: ctx.deps.tenant_id)
Budget(window='month', name='accounting')  # 세고, 절대 차단하지 않음
속성 (Attributes)
  • usd — 미화 상한. None은 이 예산이 지출을 제한하지 않음을 의미. 타입: Decimal | None 기본: None
  • tokens — 총 토큰 상한. None은 이 예산이 토큰을 제한하지 않음을 의미. 타입: int | None 기본: None
  • window — 상한이 적용되는 기간. 타입: Window 기본: 'day'
  • scope — 카운터 파티션 — 테넌트별, 사용자별, 에이전트별. None은 전역으로 셈. 지속 실행 하에서 이 callable은 같은 실행 컨텍스트로 재생될 때 같은 값을 반환해야 해요. 그 결과가 스토어 키의 일부이기 때문이에요. 타입: Callable[[RunContext[AgentDepsT]], str] | None 기본: None
  • warn_at — 넘으면 BudgetStatus.warning이 설정되는 상한 분율. 절대 차단하지 않아요. 타입: float | None 기본: None
  • name — 같은 창·범위를 공유하는 예산을 구분. 스토어 키의 일부. 타입: str 기본: 'default'
  • retain — 마지막 쓰기 후 스토어가 이 창의 카운터를 얼마나 유지할 수 있는지. 'window default'window(_TTLS 참고)에서 지평을 취해요. 시간 창은 롤오버되면 자유롭게 만료될 수 있지만, run/conversation 버킷은 절대 롤오버하지 않으므로 그 기본값은 절대 만료되지 않음과 무제한 스토어 성장 사이의 절충이에요. 그리고 지평을 지나 재개된 대화는 0에서 다시 시작해요. 그것이 중요하고 키를 어떤 다른 방식으로 정리하면 'forever'를 설정하거나, timedelta로 지평을 곧장 고르세요. 타입: timedelta | Literal['window default', 'forever'] 기본: 'window default'
  • enforces — 이 예산이 세기만이 아니라 요청을 거부할 수 있는지. 타입: bool
  • ttl — 마지막 쓰기 후 스토어가 이 창의 카운터를 얼마나 유지할 수 있는지. 타입: timedelta | None
메서드 (Methods)
  • post_init — 조용히 잘못 동작할 구성(실패하지 않고)을 거부. 0 이하의 상한은 아무것도 쓰기 전에 예산을 소진시켜, 첫 요청이 진짜 과지출과 구별할 방법 없이 거부돼요. 그리고 spec의 usd: 0None이 말하는 "제한 없음"일 가능성이 훨씬 커요. 상한 없는 예산의 warn_at은 분율이 될 것이 없으므로 절대 발화할 수 없어요. 둘 다 구성으로 보이고 파손으로 동작하므로, 나중에 놀라지 않도록 여기서 오류예요.

SpendSnapshot

하나의 모델 응답이 얼마나 들었는지, 그리고 그것 후에 각 예산이 어디 있는지.

속성 (Attributes)
  • model — 응답을 만든 모델, 또는 보고한 것이 없으면 None. 타입: str | None
  • usage — 응답의 사용량 그대로, 캐시 읽기·쓰기·오디오 포함. 타입: RequestUsage
  • usd — 이 응답의 비용. 가격을 매길 수 없으면 0. 타입: Decimal
  • pricedusd가 진짜 가격인지 대체 0인지. 타입: bool
  • budgets — 선언된 순서대로 구성된 예산당 항목 하나. 타입: tuple[BudgetStatus, ...]

BudgetStatus

예산과 그중 얼마나 남았는지.

속성 (Attributes)
  • budget — 이것이 기술하는 예산. 읽기이므로 무파라미터. 의존성 타입은 Budget.scope만 타입하고, 아무도 status를 통해 scope를 호출하지 않아요. 타입: Budget[Any]
  • key — 그것이 누적되는 스토어 키. scope나 창 디버깅에 유용. 타입: str
  • spent — 예산의 현재 창이 누적한 것. 타입: Spent
  • remaining_usd — 예산이 USD 한도를 설정하지 않으면 None. 타입: Decimal | None
  • remaining_tokens — 예산이 토큰 한도를 설정하지 않으면 None. 타입: int | None
  • warning — 지출이 Budget.warn_at을 넘었는지. 그것이 없으면 항상 False. 타입: bool
  • exhausted — 추가 요청이 거부될지. 타입: bool

Spent

하나의 창이 지금까지 누적한 모든 것.

속성 (Attributes)
  • usd — 가격이 매겨진 비용. 해결 가능한 가격이 없는 요청은 여기 아무것도 기여하지 않아요. 타입: Decimal 기본: Decimal(0)
  • tokens — 요청이 가격 매길 수 있었는지 여부와 무관하게 세어지는 총 토큰. 타입: int 기본: 0
  • requests — 이 창에 기록된 모델 요청. 타입: int 기본: 0
  • unpriced_requestsrequests 중 해결 가능한 가격이 없어 usd가 그것을 과소 계산하는 수. 타입: int 기본: 0

BatchSpendStore

Base: Protocol

하나의 응답의 모든 창 뒤의 카운터를 읽고 누적해요. add_many는 증가 후의 상태를 반환해 원자 백엔드가 두 번째 왕복 없이 답하게 하고, 순서가 아니라 SpendEntry.key로 키가 매겨져 키를 공유하는 항목이 호출자가 기대하는 방식으로 합쳐져요. 두 메서드는 단일 키가 아니라 시퀀스를 받아 전체 집합을 하나의 단위로 읽거나 적용할 수 있는 백엔드가 그러고, 그렇게 못 하는 것도 전체 집합을 보고 그렇게 말할 수 있게 해요.

메서드 (Methods)
  • get_many (@async) — 각 키가 누적한 것. 절대 쓰이지 않은 키는 0으로 읽혀요.
  • add_many (@async) — 모든 항목을 적용하고 각 키의 새 합계를 반환.

SpendEntry

하나의 응답의 창 하나 몫. key를 제외한 모든 것이 nothing으로 기본 설정되어, 외부 소스에 대항해 드리프트를 수정하는 조정자가 요청 수를 부풀리지 않고 usd 델타를 올릴 수 있게 해요.

속성 (Attributes)
  • key — 창의 스토어 키. 타입: str
  • usd — 더할 가격 비용. 음수일 수 있어 조정자가 드리프트를 수정하는 방식이에요. 타입: Decimal 기본: Decimal(0)
  • tokens — 더할 총 토큰. 타입: int 기본: 0
  • requests — 더할 모델 요청. 수를 움직이지 않고 돈을 움직일 수 있도록 암묵적 += 1이 아니라 명시적. 타입: int 기본: 0
  • unpricedrequests 중 해결 가능한 가격이 없는 수. 타입: int 기본: 0
  • ttl — 이 쓰기 후 키를 얼마나 유지할 수 있는지. None은 무기한을 의미. 타입: timedelta | None 기본: None
  • token — 이 항목이 온 응답을 식별해 최대 한 번 적용되게 해요. 지속 accrual 작업은 저널을 통해 일반적인 재생을 처리해요. 그 기록 없는 복구도 같은 응답을 스토어에 제시할 수 있어요. 이미 key에 적용한 토큰을 인식하는 스토어는 다시 더하는 대신 현재 합계를 반환해요. None은 "무조건 적용"을 의미하며, 조정자가 델타를 게시할 때 원하는 것이에요. 같은 크기의 두 수정은 두 수정이에요. 타입: str | None 기본: None

SpendStore

Base: Protocol

한 번에 하나의 예산 창 뒤의 카운터를 읽고 누적. Deprecated. 대신 BatchSpendStore 구현: 그것은 응답의 모든 창을 한 번의 호출로 받아, 백엔드가 함께 적용하게 하며, 재실행된 accrual이 두 번 세지 않게 하는 재생 토큰을 지녀요. SpendLimits는 이 모양의 스토어를 여전히 받고 어댑터로, 호출당 하나의 창으로 구동하며, 그것이 무엇을 쓰는지 한 번 경고해요. 두 프로토콜은 하나의 두 버전이 아니라 별개 이름이에요. runtime_checkable이 서명이 아니라 메서드 존재를 검사하기 때문이에요. addget을 재사용하면 배치하는 스토어와 하지 못하는 것을 구분할 것이 아무것도 없어요.

메서드 (Methods)
  • get (@async) — key가 누적한 것. 절대 쓰이지 않은 키는 0으로 읽혀요.
  • add (@async) — key에 더하고 결과를 반환. ttl은 키를 얼마나 유지할 수 있는지.

InMemorySpendStore

하나의 프로세스 수명을 위한 카운터. 그것이 실행되는 워커 안의 폭주 루프를 잡아요. 프로세스 간 예산을 집행하지 않고 교체 워커의 지속 복구를 견디지 못해요. 각 프로세스가 자체 카운터와 dedup 마커를 가지니까. RedisSpendStore 같은 공유 스토어가 그것을 위한 거예요.

속성 (Attributes)
  • clock — 만료가 측정되는 시간을 공급. 타입: Callable[[], datetime] 기본: utc_now
  • sweep_every — 만료 스윕 사이의 쓰기. 만료는 키의 다음 읽기를 기다릴 수 없어요. 하루 창은 매일 새 키를 만들므로 어제의 것은 다시 묻히지 않아요. 스캔은 상주 키에 선형이라 이렇게 많은 쓰기에 걸쳐 분할되며 각각에 실행되지 않고, 죽은 항목을 대략 그만큼으로 경계해요. scope가 고카디널리티이고 스캔보다 메모리가 중요할 때 낮추세요. 타입: int 기본: 256
  • dedup_retain — 적용된 SpendEntry.token을 얼마나 기억할지, 또는 None으로 모든 항목 적용. 이것은 재생이 인식되는 창이지 카운터 수명이 아니에요. 이것보다 늦게 재생된 응답은 다시 세어져요. 기억된 토큰은 스윕될 때까지 응답·창당 작은 항목 하나를 써요. 타입: timedelta | None 기본: DEFAULT_DEDUP_RETAIN
메서드 (Methods)
  • post_init — 배치 쌍을 단일 키 재정의가 닿을 수 없게 만든 서브클래스를 보고.
  • len — 아직 살아있는 창 수. 롤오버된 항목은 분할 스윕이 아직 닿지 않았어도 제외되므로, 우연히 상주하는 것이 아니라 추적되는 것을 세요. 예산·scope·기간당 항목 하나이고, run/conversation 예산의 기간은 id라 그 항목이 지평에 닿을 때까지 수가 트래픽과 함께 늘어요. 그곳과 scope가 고카디널리티인 곳에서 지켜볼 만해요. __len__을 정의하면 빈 스토어가 falsy가 되므로 if store is not None이라고 쓰세요.
  • get (@async) — key가 누적한 것. get_many 선호로 deprecated. 키 시퀀스로 호출하세요.
  • add (@async) — key에 더하고 결과 반환. add_many 선호로 deprecated. 항목 시퀀스로 호출하세요.
  • get_many (@async) — 각 키가 누적한 것, 만료된 키를 없는 것으로 취급. 잠금 아래서, _live가 만료된 것을 찾는 키를 삭제하기 때문이에요. 잠금 없이는 그 deladd_many 안의 _sweep 반복과 경주하고(RuntimeError: dictionary changed size during iteration), 두 번째 동시 판독기(KeyError)와도 경주해요. 가드가 스레드 간에 공유될 때마다 — 풀의 run_sync, 동기 엔드포인트 — 그리고 어떤 키가 지평을 지나 읽힐 때 도달 가능.
  • add_many (@async) — 모든 항목을 적용하고 각 키의 새 합계 반환. 변형은 await를 가로지르지 않으므로 한 이벤트 루프의 동시 실행이 중간에 끼어들 수 없고 어떤 판독기도 일부 적용된 응답을 보지 못해요. 잠금은 자유롭지 않은 경우를 덮어요. 스레드 풀에서 호출된 run_sync 또는 프리 스레드 인터프리터에서 읽기-수정-쓰기가 지출을 과소 계산하는 방향으로 업데이트를 잃는 경우. 모든 항목은 어느 것이든 저장되기 전에 계산되므로, 중간에 실패하는 응답 — 산술이 raise하는 금액 등 — 은 실패 앞의 것들이 아니라 어느 창도 적용하지 않아요. 전체 집합을 함께 적용하는 것이 이 메서드가 존재하는 이유예요. 클록은 어떤 것도 적용되기 전에 한 번 읽히고, 토큰은 그것이 대표하는 카운터가 움직인 후에만 기억돼요. 그 쓰기 전에 기억된 토큰은 실패한 호출에 소비되고, 응답을 기록할 수 있었던 재시도가 그것의 재생으로 건너뛰어질 거예요.

RedisSpendStore

Redis의 지출 카운터, 그래서 모든 워커가 하나의 예산을 집행해요. 창당 해시 하나가 네 카운터를 정수로 보유.

from redis.asyncio import Redis

from pydantic_ai_harness.spend import RedisSpendStore

store = RedisSpendStore(Redis.from_url('redis://localhost'))

읽기와 그 뒤의 증가는 별개 왕복이라 동시 실행이 각각 예산을 미소진으로 관찰하고 함께 넘어갈 수 있어요. 그것은 인프로세스 스토어가 가진 것과 같은 오버슈트를 워커 수만큼 넓힌 것이에요. 게이트가 무엇을 보장·보장하지 않는지에 대해서는 README를 참고하세요.

속성 (Attributes)
  • clienthgetalleval을 노출하는 어떤 클라이언트. 타입: RedisClient
  • prefix — 키의 이름공간, 공유 Redis를 정돈하게 유지. 이 스토어가 쓰는 모든 키는 {prefix}:...(중괄호 리터럴)인데, 이것은 Redis Cluster 해시 태그예요. 슬롯이 prefix만에서 계산되므로 스토어의 모든 키가 한 슬롯에 떨어지고 스크립트가 그중 여러 개를 한 번에 취할 수 있어요. 응답 하나를 day·month 창에 한 스크립트로 적용하는 것이 그것이 사는 것이고, 대가는 클러스터가 이 스토어의 키를 노드에 펼칠 수 없다는 것. 자체 중괄호를 지닌 prefix는 구성 시 거부돼요. 태그를 옮겨 한 예산의 두 창을 다른 슬롯에 둘 것이기 때문이에요. 타입: str 기본: 'pydantic-ai-harness:spend'
  • dedup_retain — 적용된 SpendEntry.token을 얼마나 기억할지, 또는 None으로 모든 항목 적용. 이것은 재생이 인식되는 창이지 카운터 수명이 아니에요. 이것보다 늦게 재생된 응답은 다시 세어져요. 그것이 일치했을 마커가 만료됐기 때문이에요. 카운터는 보통 훨씬 더 오래 살아요. 모든 쓰기가 그것을 연장하니까. 각 기억된 토큰은 응답·창당 작은 키 하나. 지속 엔진이 오래 후에 회복할 수 있으면 올리고, 쓰기 속도가 그 메모리를 더 중요하게 만들면 낮추세요. 타입: timedelta | None 기본: DEFAULT_DEDUP_RETAIN
메서드 (Methods)
  • post_init — 그것이 감싸는 해시 태그를 깨뜨릴 prefix를 거부하고 죽은 재정의를 보고. prefix 안의 중괄호는 태그를 옮기거나 잘라, 한 예산의 두 창이 다른 슬롯으로 해시되고 클러스터가 그것들을 함께 적용하는 스크립트를 거부할 것이에요. Budget.name이 그 구분자에 대해 검사되는 것과 같은 이유로 여기서 검사돼요. 그렇지 않으면 실패가 모델 요청의 CROSSSLOT 오류로 도착하기 때문이에요.
  • get (@async) — key가 누적한 것. get_many 선호로 deprecated. 키 시퀀스로 호출하세요.
  • add (@async) — key에 더하고 결과 반환. add_many 선호로 deprecated. 호출당 하나의 창이므로, day·month 예산에 계산되는 응답은 두 호출이고 그 사이의 실패가 하루는 세고 월은 세지 않게 해요. add_many는 모든 창에 걸친 하나의 스크립트로 그것을 닫아요.
  • get_many (@async) — 각 키가 누적한 것. 없는 해시는 0으로 읽혀요. 해시 태그 전 폴백이 있는 동안 키당 두 번의 왕복. 키 자신의 해시와 이전 릴리스가 썼을 하나. _before_hash_tags 참고.
  • add_many (@async) — 모든 항목을 하나의 스크립트로 적용하고 각 키의 새 합계 반환. 하나의 작업 단위. 응답의 모든 창이 떨어지거나 아무것도 없고, 스크립트가 각 새 합계를 반환해 합계가 두 번째 읽기를 필요로 하지 않아요. 읽기가 비용이 되는 것은 해시 태그 전 폴백(키당 하나)뿐, 그것이 사라질 때까지. _before_hash_tags 참고. 서버가 스크립트를 실행하기 전의 실패 — 클라이언트가 연결할 수 없음, 요청이 결코 도착하지 않음 — 은 아무것도 쓰지 않아요. 그 후의 실패는 어느 쪽인지 말하지 않아요. EVAL이 이미 커밋하면 연결이 끊길 수 있으므로, 여기서 오류는 아무것도 일어나지 않았다는 게 아니라 결과가 알 수 없다는 뜻이에요. 따라서 재시도는 응답을 두 번 세는 위험이 있고, 그래서 SpendLimits.wrap_model_request가 재시도하지 않고 오류가 실행을 끝내게 두는 이유예요. 프로바이더가 청구한 응답을 과대 계산하는 것은 브레이크가 견디는 방향이고, 과소 계산은 견디지 못해요. SpendEntry.token안전한 재시도를 만들어요. 이미 커밋한 accrual을 재실행하는 지속 엔진이 그것을 위해 마커를 이미 쓰여진 것으로 찾아, 항목이 건너뛰고 현재 합계가 반환돼요.

SpendLimitExceeded

Base: UsageLimitExceeded

Budget이 소진됐을 때 발생. UsageLimitExceeded를 서브클래싱해 사용 한도에서 이미 멈추는 애플리케이션이 지출 한도에서도 멈추게 하고, "일일 예산이 떨어졌다"와 "이 실행이 너무 많은 토큰을 썼다"를 구분해야 하는 코드는 이 타입을 구체적으로 잡게 해요.

UnpricedModelError

Base: UserError

on_unpriced='raise'이고 응답에 대해 가격을 해결할 수 없을 때 발생. 모델이 genai-prices 레지스트리에 없거나(로컬·커스텀 배포), 응답이 모델 이름을 지니지 않거나. SpendLimits.price를 공급해 직접 가격을 매기거나, on_unpriced='zero'를 사용해 요청을 무료로 세고 Spent.unpriced_requests로 표면화하세요.

UnpricedModelWarning

Base: UserWarning

가격 없는 응답이 USD 상한에 대항해 무료로 셀 때 모델당 한 번 경고. on_unpriced='zero' 아래에서, 그리고 Budgetusd 상한을 지닐 때만 경고해요. 그 조합이 틈이 조용한 곳이에요. 응답이 달러로 아무것도 기여하지 않으므로 그러한 요청이 몇 개든 그 상한에 닿을 수 없어요. 토큰 상한은 여전히 유지돼요. 가격이 발견됐는지 여부와 무관하게 토큰이 세어지니까. capability 인스턴스 수명 동안 모델 이름별로 중복 제거돼, 레지스트리가 모르는 모델이 요청당이 아니라 한 번 보고해요.

SpendCompositionWarning

Base: UserWarning

다른 capability가 accrual 안에 감싸도록 구성됐을 때 경고. Pydantic AI는 innermost 티어를 non-innermost capability에 대해서만 정렬해요. 그들 사이에서 나중에 나열된 것이 더 안쪽에 중첩되므로, SpendLimits 뒤에 나열된 capability가 그것 안에 감싸요. 그런 capability는 응답을 기다렸다 raise할 수 있고, 실행을 새 요청으로 보내는 반면 거부된 응답 — 생성·청구·히스토리에 유지 — 은 절대 세어지지 않아요. 이것은 일어난 과소 계산이 아니라 순서를 보고해요. 닿으려면 중첩 capability가 이미 기다린 응답을 거부해야 하고, 그것을 하는지가 그 자신의 일이에요. InputGuardrailparallel=True일 때, 가드가 차단하고, 프로바이더가 가드보다 먼저 답할 때만 거기 도착해요. 순차적으로 가드는 요청 전에 raise하고, 먼저 차단하는 병렬 가드는 호출을 취소하므로, 어느 쪽도 청구될 것을 남기지 않아요. 그 조건 중 어느 것도 여기서 읽히지 않아요. parallel은 목록에서 아무것도 옮기지 않고 뒤집힐 수 있으므로, 순서가 지속 속성이고 당신이 제어하는 것이에요. innermost capability 중 SpendLimits를 마지막에 나열해 제거하세요. 다음과 같이 조용히 만들 수 있어요:

import warnings
from pydantic_ai_harness.spend import SpendCompositionWarning

warnings.filterwarnings('ignore', category=SpendCompositionWarning)

더 알아보기 (Learn more)