Deuz SDK 2.4 출시 — 버전 관리 메모리와 컨텍스트 컴파일러, 목표 감독자와 영속 스케줄러, 로컬 Docker 러너, 영속 채팅 스트림. 2.4의 새로운 점
Deuz SDK
Modules

Budgets

Persistent token and USD budgets per user, thread or organisation, shared by every run and process, with rolling windows, warnings, hold recovery and retention.

문서 본문은 영어입니다. 탐색, 검색, UI는 선택한 언어를 따릅니다.

An execution context's ledger bounds one run. A BudgetStore bounds usage that outlives the run: a user's daily tokens, an organisation's monthly dollars, a thread's lifetime cap. Every process that points at the same store shares the same budgets. The store is a second gate after the ledger; it does not replace local admission.

Share a budget between two runs

This complete example runs two native agents with separate execution contexts against one in-memory store. The first run fits; the second does not, because the user scope is already used up. It is deterministic and needs no API key.

import { createExecutionContext, createInMemoryBudgetStore, runAgent } from '@deuz-sdk/core/agent';
import { createMockModel } from '@deuz-sdk/core/testing';

const store = createInMemoryBudgetStore();
const admission = { store, scopes: [{ key: 'user:42', limits: { tokens: 110 } }] };

async function ask(runId: string) {
  return runAgent({
    model: createMockModel({ responses: [{ text: 'done' }] }),
    prompt: 'Summarise the ticket.',
    execution: createExecutionContext({ scopeId: runId, admission }),
    // Admission reserves an estimate before the call and settles the actual usage after it.
    executionEstimate: { tokens: 100, usd: 0.1 },
    deps: { priceProvider: { priceUsage: () => 0.01 } },
  });
}

console.log((await ask('run-1')).status); // 'completed': 100 of 110 reserved, 15 settled
console.log((await ask('run-2')).status); // 'failed': 15 + 100 does not fit in 110
console.log(await store.usage('user:42')); // { tokens: 15, usd: 0.01 }

The second run fails before any model call with a BudgetLedgerError whose code is budget_exceeded and whose message names the scope.

How admission works

Each potentially billable attempt goes through two gates, in order, inside the ledger's queue:

  1. The ledger checks the context's own budgets, as it always has.
  2. The store admits the attempt against every persistent scope, or none. A denial in one scope charges nothing to the others.

Settlement and release are mirrored to the store. When the provider reports no usage, the store keeps the full estimate held. When the tokens are known but the model has no price (see Models without a price), the tokens settle at once: a call reserved with a USD estimate settles that estimate as its charge, and a call reserved without one holds no USD. A price lookup that throws is an outage, not a missing price, so that call keeps its full hold.

Every request is idempotent by its request ID. Repeating an identical reservation returns the recorded outcome, a denial included; repeating it with different amounts throws.

A persistent scope that bounds a dimension needs an estimate for it. A call whose USD estimate is missing because its model has no price follows the budget's unknownPrice policy; any other missing estimate fails closed with missing_reservation. When the store itself fails, the attempt fails with admission_failed and nothing changes locally.

What a reservation holds

Before each attempt, a native request reserves its worst case: the prompt, estimated from its characters, plus the output cap the request actually sends. That cap is maxOutputTokens or, without it, the model's maxOutput from the registry (128 000 tokens for several current models). Anthropic raises it to the thinking budget plus 1 024 while extended thinking runs, even over an explicit maxOutputTokens. Reasoning tokens are part of the output, so the cap covers them. A prompt the call caches with promptCaching is priced at the cache-write rate on Anthropic, the only wire that writes a cache. The other wires bill it as input, so it is priced as input there, long-context tiers included.

Some rows document no output limit: a model the registry does not know, and models whose limit is not published, such as gpt-6-astra, gpt-5.6 and gpt-5-pro. OpenAI Chat Completions, OpenAI Responses and Gemini native then leave the cap off, and the provider's own default applies. When a token or USD bound applies to the call, in the context's budget or in a persistent scope's limits, the request sends the row's placeholder, 4 096 tokens, as maxOutputTokens instead and reserves exactly that, so the reservation still bounds what the provider can bill. The run reports it once as a clamped-setting warning on maxOutputTokens; pass maxOutputTokens to raise the cap. A call with no token or USD bound keeps the cap off.

The ledger therefore admits on estimates; it is not a limit the provider enforces. The prompt estimate can be low, and an actual overage is still recorded and blocks later admission.

Set maxOutputTokens when a USD budget is small. Without it, one call to a large-output model can reserve more than the whole budget and is refused with budget_exceeded before it is sent. 2.2 reserved 4 096 output tokens instead, which let parallel calls spend past the budget.

Models without a price

A model has no price when no deps.priceProvider is configured, or when the provider returns undefined for it, for example a model released after your price table. Under the USD budget of an execution context (runAgent, swarms, evolve, or an execution passed to generateText and streamChat), such a call runs by default:

  • its tokens are still reserved and bounded, and the tokens it used are recorded. Once the call reports its usage, its ledger entry ends in state 'unknown', not 'settled': the tokens are known (actual.tokens), the dollars are not (actual.usd stays undefined);
  • its cost is left out of every USD total, so spent.usd counts the priced calls only. The ledger's totals count the unpriced calls in unpriced (accounting.unpriced on a run's result), and they also stay in unknownUsd because their cost is unknown;
  • each leg of a run reports one unpriced-model warning per model through deps.logger, and an agentic loop also on its result's warnings and as a warning part on its stream. A resumed run is a new leg, so it reports the warning again. Runs that share an execution context each report it; a swarm or evolve run reports it once for all its tasks in each leg.
import { runAgent } from '@deuz-sdk/core/agent';
import { createMockModel } from '@deuz-sdk/core/testing';

// This table knows one model; the mock model is not in it.
const priceProvider = {
  priceUsage: (model: string) => (model === 'gpt-5.4' ? 0.01 : undefined),
};

const run = await runAgent({
  model: createMockModel({ responses: [{ text: 'done' }] }),
  prompt: 'Summarise the ticket.',
  budget: { usd: 1 },
  deps: { priceProvider },
});
console.log(run.status); // 'completed'
console.log(run.accounting?.unpriced); // 1: a call the USD budget could not count

Set unknownPrice: 'reject' to refuse unpriced models instead. The call fails before it is sent, with a BudgetLedgerError whose code is unpriced_model:

import { createExecutionContext, runAgent } from '@deuz-sdk/core/agent';
import { createMockModel } from '@deuz-sdk/core/testing';

const execution = createExecutionContext({ budget: { usd: 1, unknownPrice: 'reject' } });
const run = await runAgent({
  model: createMockModel({ responses: [{ text: 'done' }] }),
  prompt: 'Summarise the ticket.',
  execution,
  deps: { priceProvider: { priceUsage: () => undefined } },
});
if (run.status === 'failed') console.log(run.error.code); // 'unpriced_model'

The policy lives on the budget, so it applies wherever a budget does: an execution context and its children, runAgent, swarm, task and reducer budgets, evolve, and persistent scope limits such as { key: 'org:7', limits: { usd: 50, unknownPrice: 'reject' } }. Budgets intersect, and 'reject' always wins: a child, task or restored budget can tighten the policy but never loosen it. A persistent scope refuses an unpriced call when its own limits or any budget of the run say 'reject'. Without a USD bound the policy has nothing to protect, so it neither warns nor refuses.

A price lookup that throws is an outage, not a missing price: under a USD bound that call still fails closed with missing_reservation, as in 2.2.

The agentic loop of generateText and streamChat without an execution context counts the same way for its budget.usd stop: calls to an unpriced model add nothing to the cost, so the priced calls still stop the loop. In 2.2 a single unpriced call switched that stop off for the rest of the run. Its budget applies the same policy to every model the loop calls: a compaction summary's summarizeModel and an agentTool sub-agent's model are checked before they are called, too. Under 'reject' the run fails with an UnpricedModelError, whose code is also unpriced_model. A call whose usage the provider did not report is different: its cost is unknown, not zero, so from then on budget.usd waits instead of stopping the loop, as after a price lookup that fails (see the budget guardrail).

Unaccounted calls (2.4)

Before 2.4 several helper calls never reached a ledger: embed() only called onUsage, memory LLM callbacks returned a bare string, the RAG embedder bare vectors, and createVerifier's judge call was unbudgeted. 2.4 can charge each of them. Nothing changes unless you opt in: without the option below, these calls behave and send requests exactly as in 2.3.

CallHow it is charged
embed(), embedMany()accounting: { execution, requestId, kind?, tokens?, usd? }; see Embeddings
RAG indexChunks, retrieve, hybridRetrieve{ accounting, onWarning } as the last argument; see RAG
Legacy memory callbacksMemorySeams.execution; in a native run, generateText or streamChat with execution, the run's own context; see Memory
Versioned memorycreateMemorySession({ execution }); see Versioned memory
createVerifierexecution: each judge call is admitted, reserved and settled; a refused call degrades to { ok: true, errorCategory: 'other' } and never throws

A call the SDK makes itself (embed, embedMany, the verifier) reports its provider's usage, so its reservation is settled like a model call. A failed embedding call that nothing could have billed (a single request answered 400, 401, 403, 404, 422 or 429) is released; one that may have been billed without usage coming back stays unknown at its estimate. A request id is charged once.

A host callback reports its usage only through its accounted variant, marked accounted: true: defineAccountedMemoryLLM(async (prompt) => ({ text, usage, modelId })) for a memory LLM, and an embedder whose answer carries usage and modelId next to its vectors. An unmarked callback is never counted as free. Its reservation is flagged unaccounted, and what happens depends on the budget's unknownPrice:

unknownPriceAn unaccounted call
'warn' (default)Is admitted at its estimates, ends unknown with the estimate still held, and reports one unaccounted-usage warning. BudgetTotals.unaccounted (and BudgetAggregate.unaccounted after a compact) counts such attempts
'reject'Is refused before the callback runs, with a BudgetLedgerError whose code is unaccounted_call, whatever the budget bounds
import { createExecutionContext } from '@deuz-sdk/core/agent';
import { createMemoryVectorStore, retrieve } from '@deuz-sdk/core/rag';

const strict = createExecutionContext({ budget: { tokens: 10_000, unknownPrice: 'reject' } });
const plain = { dims: 3, embed: async (texts: string[]) => texts.map(() => [0, 0, 1]) };

try {
  await retrieve('How do I deploy?', { embedder: plain, store: createMemoryVectorStore() }, {
    accounting: { execution: strict, requestId: 'query-1' },
  });
} catch (error) {
  console.log((error as { code?: string }).code); // unaccounted_call: refused before the embedder ran
}

The same refusal comes from any budget, scope budget or persistent BudgetStore scope with unknownPrice: 'reject', so a strict budget needs the accounted variant of every callback it admits.

When a settlement fails

A billed attempt is settled after the call, and a refused one is released. Both are idempotent in the store, so the ledger tries a failing settlement or release three times. When the store stays down, the call fails with admission_failed as before, and nothing changed locally: the hold stays at its estimate, which is the safe direction. A store that answers with a reservation_conflict has already closed the hold, usually through a sweep: that is not a failure, so it is neither retried nor reported, and the local entry closes. The error now names the attempt in error.requestId, and for a settlement error.settlement replays it exactly. Settle it once the store answers again, in the same ledger or in one restored from its durable snapshot:

import { BudgetLedgerError } from '@deuz-sdk/core/agent';
import type { NativeExecutionContext } from '@deuz-sdk/core/agent';

export async function settleLater(
  error: unknown,
  execution: NativeExecutionContext,
): Promise<void> {
  if (!(error instanceof BudgetLedgerError) || !error.settlement) throw error;
  await execution.ledger.settle(error.settlement);
}

The native request forgets an attempt's request ID only after the ledger accepted its settlement. A settlement that still fails after the ledger's three attempts gets one more round of three before the call fails, and when that round closes the hold, the call completes normally.

A process that dies between a call and its settlement leaves the same kind of hold: its request ID is a reserved entry in the last persisted ledger snapshot. Restore the context from that snapshot with its store, then settle the request, or release it when the call was never billed. A replay that a sweep has overtaken closes the entry the same way, and the store keeps what the sweep billed.

Scopes and rolling windows

import { createExecutionContext, createInMemoryBudgetStore } from '@deuz-sdk/core/agent';

const store = createInMemoryBudgetStore();
const execution = createExecutionContext({
  scopeId: 'run-7',
  admission: {
    store,
    scopes: [
      // 50 000 tokens in any rolling hour, counted in 60 one-minute buckets.
      { key: 'user:42', limits: { tokens: 50_000 }, window: { ms: 3_600_000, buckets: 60 } },
      // A lifetime dollar cap for the organisation.
      { key: 'org:acme', limits: { usd: 200 } },
    ],
  },
});
  • key names the budget. Every request that uses the same key shares it.
  • limits holds tokens, usd or both. Each request brings its own limits, so a key can be checked with different limits by different callers.
  • window makes the budget rolling. Usage leaves the window one bucket at a time; the counted span is between ms - ms / buckets and ms. The first request fixes a key's window, and a later request with a different window is rejected. Omit window for a lifetime budget.

A hold is charged to the bucket of its reservation time. A hold leaked by a crash therefore leaves a windowed budget with its bucket. In a lifetime budget it stays charged, the fail-closed outcome, until you close it: see Open holds. A settlement that arrives after its bucket left the window updates history but not the current window.

Warnings

import { createExecutionContext, createInMemoryBudgetStore } from '@deuz-sdk/core/agent';

const execution = createExecutionContext({
  admission: {
    store: createInMemoryBudgetStore(),
    scopes: [{ key: 'user:42', limits: { tokens: 50_000 }, window: { ms: 86_400_000 } }],
    warnAtPercent: 80,
    onWarning: (warning) => {
      console.log(
        `${warning.key} is at ${Math.round(warning.percent)}% of its ${warning.dimension}`,
      );
    },
  },
});

onWarning fires once when committed usage in a scope crosses the threshold on admission, per scope and dimension. It fires again only after usage falls back below the threshold, for example when a window empties. A scope's own warnAtPercent overrides the admission-wide one. A throwing callback never changes admission. Warnings are tracked per ledger: two processes sharing a store each warn once.

Children, snapshots and restore

Child contexts share their parent's ledger, so they inherit its persistent admission. snapshot() records the persistent scopes in the ledger snapshot, which makes the execution snapshot version 2. A 2.1 reader refuses version 2 instead of silently admitting without the store.

Restoring such a snapshot needs the store again:

import { createExecutionContext, createInMemoryBudgetStore } from '@deuz-sdk/core/agent';

const store = createInMemoryBudgetStore();
const saved = createExecutionContext({
  admission: { store, scopes: [{ key: 'user:42', limits: { tokens: 1_000 } }] },
}).snapshot();

// The snapshot supplies the scopes; admission here may only add or tighten them.
const restored = createExecutionContext({ snapshot: saved, admission: { store } });

Restoring without admission throws. Scopes passed on restore are added, and a key the snapshot already has keeps the tighter limits; changing its window throws.

Swarms

A swarm refuses pre-bound execution contexts, so it takes persistent admission as an option instead. Every task's model calls are admitted against the scopes, and the run records them: resuming it needs the store again, and scopes passed on resume only add or tighten.

import { createAgent, createInMemoryBudgetStore } from '@deuz-sdk/core/agent';
import { createInMemorySwarmStore, createSwarm } from '@deuz-sdk/core/swarm';
import { createMockModel } from '@deuz-sdk/core/testing';

const budgets = createInMemoryBudgetStore();
const admission = { store: budgets, scopes: [{ key: 'org:7', limits: { tokens: 1_000_000 } }] };
const swarm = createSwarm({
  store: createInMemorySwarmStore(),
  agents: { worker: createAgent({ model: createMockModel({ responses: [{ text: 'done' }] }) }) },
  admission,
});
const handle = await swarm.run({
  scope: 'org-7',
  tasks: [{ id: 'a', agent: 'worker', prompt: 'Work.' }],
});
await handle.result;

Durable stores

The in-memory store is for tests and single-process deployments. Three durable stores implement the same contract and pass the same conformance suite:

import { createSqliteBudgetStore } from '@deuz-sdk/core/ops/sqlite';
import { createPostgresBudgetStore } from '@deuz-sdk/core/ops/postgres';
import { createRedisBudgetStore } from '@deuz-sdk/core/ops/redis';
import pg from 'pg';
import { createClient } from 'redis';

// Processes on one machine: one database file, BEGIN IMMEDIATE per change.
const local = createSqliteBudgetStore({ path: './budgets.sqlite' });

// Many machines: every admission is one statement that locks the scope rows.
const shared = createPostgresBudgetStore({ client: new pg.Pool(), schema: 'public' });

// Many machines on Redis 7+: every admission is one Lua script (2.3).
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const onRedis = createRedisBudgetStore({ client: redis });
  • SQLite keeps its tables and its own schema version under deuz_budget_*. Windows use the store's clock (the host clock by default), so processes sharing a file should share a clock. A lifetime scope is admitted from a running total (2.3), kept by triggers on the history table, so its cost does not grow with history, and 2.2 processes sharing the file keep the total right.
  • Postgres takes any PgClientLike (pg.Pool, pg.Client, a serverless driver). Admission, settlement and release are each one statement, so a pooled client never splits them across connections. Windows use database time, never host clocks. The schema must exist; the tables are created on first use, and concurrent first uses queue on an advisory lock.
  • Redis (2.3, @deuz-sdk/core/ops/redis) takes a client that runs Lua scripts: a connected node-redis client as it is, or ioredis through wrapIoredisClient. Admission, settlement and release are each one script, atomic on the server, and windows use the server's TIME, never host clocks. It keeps the Postgres store's model (a ring of window slots per scope, history per model) under keys that share the hash tag {<prefix>:budget}, so a Redis Cluster keeps them in one slot. Needs Redis 7 or later. { url } instead of { client } imports the optional redis peer on first use, and close() then quits that connection. The server must never evict these keys: an evicted scope starts again empty and admits its whole limit again. Use maxmemory-policy noeviction or a volatile-* policy, and AOF; see Redis in Operations.

usage(key, { since, byModel }) reports committed usage (settled amounts plus held estimates) over the key's history, or since a point in time; a bucket that straddles since counts whole. The stores keep history and request records until you prune them.

Open holds

A hold stays open when its caller never settles or releases it: the process crashed after reserving, or every attempt to settle failed. It also stays open, by design, when a model call fails without reporting its usage (a network error, a transport timeout, a 5xx or 529 answer, a stream that broke off), since nothing says what the provider billed. That includes the failed attempt of a call that failed over to the next of its fallbackModels: the attempt stays held at its estimate, in the ledger as an entry in state 'unknown' and, with persistent admission, in the store as an open hold, so a fallback never spends past the shared budget. An answer the provider does not bill (400, 401, 403, 404, 422 or 429) releases the attempt's hold instead.

An open hold keeps its estimate charged. In a windowed budget it leaves with its bucket; in a lifetime budget it stays until you close it. Since 2.3 the memory, SQLite, Postgres and Redis stores list and close open holds:

  • holds({ before, limit?, after? }) lists the holds reserved before a time, oldest first, a page at a time: request ID, model, held estimates, the time it was reserved and the scope keys it is charged to.
  • sweep({ before, resolve?, limit? }) closes them through the store's own settle and release. By default it settles each at its estimate, so the budget keeps the charge: the fail-closed choice for a call that may have run. resolve: 'release' frees them, for calls you know never reached the provider. A function decides per hold, and may return the actual amounts from your own records, or 'keep'. A hold its caller closed while the sweep ran is skipped. limit caps how many holds one call examines (default 1 000).

Choose before well past your longest call and its settlement retries. A sweep that closes a hold its caller is about to settle wins: the store keeps the sweep's amounts, and the ledger accepts the caller's settlement or release as already closed, without retrying it or failing the call. The ledger records the call's actual usage, so for that call it and the store disagree.

import { createSqliteBudgetStore } from '@deuz-sdk/core/ops/sqlite';

const store = createSqliteBudgetStore({ path: './budgets.sqlite' });

/** Run every few minutes: close the holds of calls whose process died. */
async function closeStaleHolds(
  usedBy: (requestId: string) => { tokens: number; usd: number } | undefined,
) {
  return store.sweep({
    before: Date.now() - 15 * 60_000,
    // Bill what the call really used when you know it, its estimate otherwise.
    resolve: (hold) => usedBy(hold.requestId) ?? 'settle',
  });
}

sweep resolves to { settled, released, skipped }. To recover a lifetime budget from a hold leaked by a crash, sweep it: with its real usage when your records have it, with 'release' when the call never ran.

Retention

prune({ before }) (2.3) removes what admission no longer needs:

  • It deletes the settled, released and denied request records created before before. A pruned request is forgotten: repeating its reservation admits it anew, and a late settlement of it is ignored, so prune only records older than any retry could be.
  • It folds history. Per scope and model, the buckets that closed a whole bucket length before both before and now, and that admission no longer counts, are summed into the newest of them. Usage totals and every admission decision stay the same; usage(key, { since }) counts a folded amount at the bucket it was folded into.
  • A hold that is still open keeps its record and its bucket, so sweep stale holds first.

It resolves to { requests, buckets }: the records deleted and the history rows folded away.

import { createInMemoryBudgetStore } from '@deuz-sdk/core/agent';

const store = createInMemoryBudgetStore();
const scope = { key: 'org:acme', limits: { tokens: 1_000_000 } };
await store.reserve({ requestId: 'call-1', modelId: 'm', tokens: 500, scopes: [scope] });
await store.settle('call-1', { tokens: 420, usd: 0 });

const result = await store.prune({ before: Date.now() + 1 });
console.log(result.requests); // 1: the settled record of call-1
console.log(await store.usage('org:acme')); // { tokens: 420, usd: 0 }: history is kept

이 페이지에서