@homeflare/alchemy 0.42.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/litellm/budget-errors.d.ts +36 -0
- package/dist/litellm/budget-errors.d.ts.map +1 -0
- package/dist/litellm/budget-form.d.ts +50 -0
- package/dist/litellm/budget-form.d.ts.map +1 -0
- package/dist/litellm/budget-operations.d.ts +20 -0
- package/dist/litellm/budget-operations.d.ts.map +1 -0
- package/dist/litellm/budget.d.ts +61 -0
- package/dist/litellm/budget.d.ts.map +1 -0
- package/dist/litellm/fake-litellm.d.ts +5 -0
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/litellm/index.d.ts +2 -0
- package/dist/litellm/index.d.ts.map +1 -1
- package/dist/litellm/index.js +125 -2
- package/dist/litellm/index.js.map +9 -5
- package/dist/litellm/operations.d.ts +22 -0
- package/dist/litellm/operations.d.ts.map +1 -1
- package/dist/litellm/providers.d.ts +1 -1
- package/dist/litellm/providers.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +9 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role-wire.d.ts.map +1 -1
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
- package/dist/openbao/index.js +9 -6
- package/dist/openbao/index.js.map +4 -4
- package/dist/postgres/connection.d.ts +20 -2
- package/dist/postgres/connection.d.ts.map +1 -1
- package/dist/postgres/database-sql.d.ts +1 -1
- package/dist/postgres/database-sql.d.ts.map +1 -1
- package/dist/postgres/database.d.ts +1 -1
- package/dist/postgres/database.d.ts.map +1 -1
- package/dist/postgres/index.d.ts +5 -3
- package/dist/postgres/index.d.ts.map +1 -1
- package/dist/postgres/index.js +124 -38
- package/dist/postgres/index.js.map +8 -7
- package/dist/postgres/providers.d.ts +4 -2
- package/dist/postgres/providers.d.ts.map +1 -1
- package/dist/postgres/psql-executor.d.ts +21 -0
- package/dist/postgres/psql-executor.d.ts.map +1 -0
- package/dist/proxmox/ceph-flag-types.d.ts +57 -0
- package/dist/proxmox/ceph-flag-types.d.ts.map +1 -0
- package/dist/proxmox/ceph-flag-wire.d.ts +77 -0
- package/dist/proxmox/ceph-flag-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-flag.d.ts +15 -50
- package/dist/proxmox/ceph-flag.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-write.d.ts +10 -13
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
- package/dist/proxmox/index.js +772 -740
- package/dist/proxmox/index.js.map +8 -7
- package/docs/litellm-budget.md +55 -0
- package/docs/litellm.md +7 -6
- package/docs/postgres.md +22 -0
- package/docs/proxmox-sdk-migration.md +45 -0
- package/package.json +3 -3
- package/src/litellm/budget-errors.ts +38 -0
- package/src/litellm/budget-form.ts +94 -0
- package/src/litellm/budget-operations.ts +51 -0
- package/src/litellm/budget.test.ts +105 -0
- package/src/litellm/budget.ts +127 -0
- package/src/litellm/fake-litellm.ts +50 -1
- package/src/litellm/index.ts +13 -0
- package/src/litellm/operations.ts +1 -1
- package/src/litellm/providers.ts +4 -1
- package/src/openbao/auth-role-form.test.ts +37 -0
- package/src/openbao/auth-role-form.ts +12 -1
- package/src/openbao/auth-role-wire.test.ts +1 -0
- package/src/openbao/auth-role-wire.ts +13 -1
- package/src/openbao/distilled-retry.test.ts +1 -0
- package/src/openbao/fake-engines-roles.ts +7 -1
- package/src/postgres/connection.ts +37 -3
- package/src/postgres/database-sql.ts +4 -3
- package/src/postgres/database.ts +3 -2
- package/src/postgres/index.ts +10 -3
- package/src/postgres/providers.ts +15 -2
- package/src/postgres/psql-executor.test.ts +106 -0
- package/src/postgres/psql-executor.ts +130 -0
- package/src/proxmox/ceph-flag-types.ts +70 -0
- package/src/proxmox/ceph-flag-wire.test.ts +72 -0
- package/src/proxmox/ceph-flag-wire.ts +123 -0
- package/src/proxmox/ceph-flag.ts +77 -130
- package/src/proxmox/ceph-osd-write.ts +10 -13
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# LiteLLM — `LiteLLM.Budget`
|
|
2
|
+
|
|
3
|
+
One row of LiteLLM's `LiteLLM_BudgetTable` (`/budget/*`): the "tier" that virtual keys and teams
|
|
4
|
+
bind to (`hf-tier-agent`, `hf-tier-interactive`, …). Typed calls come from
|
|
5
|
+
`@distilled.cloud/litellm/budget_management`; credentials and refusals are the family's, see
|
|
6
|
+
[litellm.md](./litellm.md).
|
|
7
|
+
|
|
8
|
+
## Monitoring, not limits
|
|
9
|
+
|
|
10
|
+
`max_budget` is a **hard cap**: LiteLLM's own field doc says "Requests will fail if this budget
|
|
11
|
+
(in USD) is exceeded". On 2026-09-28 an undeclared $60/day cap on `hf-tier-interactive` (made
|
|
12
|
+
through the API on 09-06, in no repo) 429'd every call of the claude2 fleet. So:
|
|
13
|
+
|
|
14
|
+
- `softBudget` is the prop to reach for: it "will NOT fail if this is exceeded" and fires the alert.
|
|
15
|
+
- `maxBudget` is **never defaulted**, and the plan is refused
|
|
16
|
+
(`LitellmBudgetHardCapUnjustifiedError`) unless `maxBudgetReason` is set. The reason lives in the
|
|
17
|
+
declaration, so a hard cap is a reviewed line of source, not an API side effect.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
export class Interactive extends LiteLLMBudget('Interactive', {
|
|
21
|
+
budgetId: 'hf-tier-interactive', // adopts the live row (needs --adopt)
|
|
22
|
+
budgetDuration: '1d',
|
|
23
|
+
softBudget: 60,
|
|
24
|
+
rpmLimit: 60,
|
|
25
|
+
tpmLimit: 6_000_000,
|
|
26
|
+
}) {}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Declaring an adopted hard tier **without** `maxBudget` clears the live cap: the update sends an
|
|
30
|
+
explicit `null` for every limit the declaration omits.
|
|
31
|
+
|
|
32
|
+
## Behaviour
|
|
33
|
+
|
|
34
|
+
| Concern | Rule |
|
|
35
|
+
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
36
|
+
| Identity | `budgetId` (else a deterministic physical name). A different declared id is a `replace`. |
|
|
37
|
+
| Adopt | A live row with the id and no state is `Unowned`; needs `--adopt`. |
|
|
38
|
+
| Removal | `defaultRemovalPolicy: 'retain'`. Opt in with `RemovalPolicy.destroy()`. Delete is idempotent: a `BadRequest` is swallowed only if a re-list shows it gone. |
|
|
39
|
+
| Read | `/budget/list` (whole table). `rpm_limit`/`tpm_limit` come back as decimal strings (`BigInt?`), converted to numbers. |
|
|
40
|
+
| Write check | Reconcile reads back; a limit the row still carries that the declaration dropped fails with `LitellmBudgetFieldNotClearedError`. |
|
|
41
|
+
|
|
42
|
+
## Version: distilled copy is 1.100.0, the proxy is 1.103.0
|
|
43
|
+
|
|
44
|
+
The SDK is generated at v1.100.0. For the budget routes the request bodies used here
|
|
45
|
+
(`budget_id`, `soft_budget`, `max_budget`, `budget_duration`, `rpm_limit`, `tpm_limit`) are all in
|
|
46
|
+
that schema. **Not measured against 1.103.0** (no live proxy read was made, and the source for it
|
|
47
|
+
is not cloned): whether `/budget/update` accepts an explicit `null` to clear a limit, and what
|
|
48
|
+
happens on create of an existing id. The fake models 400 for both. The read-back check above
|
|
49
|
+
exists so an `exclude_none` merge fails loudly instead of leaving a hard cap in place.
|
|
50
|
+
|
|
51
|
+
## Not modelled
|
|
52
|
+
|
|
53
|
+
`max_parallel_requests`, `model_max_budget` and `budget_reset_at`: the row read back
|
|
54
|
+
(`BudgetListItem`) does not carry them, so a declaration could never be diffed. Add them once a
|
|
55
|
+
read that returns them is measured. Keys and teams that reference a tier are separate resources.
|
package/docs/litellm.md
CHANGED
|
@@ -16,12 +16,13 @@ against the tag's committed `schema.d.ts`; NOT a live read, because the referenc
|
|
|
16
16
|
Measured at 1.100.0 (`schema.prisma`, `pass_through_endpoints.py`, and the mini's own
|
|
17
17
|
`settings.yaml` `store_model_in_db: true`):
|
|
18
18
|
|
|
19
|
-
| Family | Managed by this resource?
|
|
20
|
-
| ------------------------------------------------------------------------------------------------------- |
|
|
21
|
-
| Pass-through endpoints | Yes — this package
|
|
22
|
-
|
|
|
23
|
-
|
|
|
24
|
-
|
|
|
19
|
+
| Family | Managed by this resource? |
|
|
20
|
+
| ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
|
21
|
+
| Pass-through endpoints | Yes — this package |
|
|
22
|
+
| Budgets (`/budget/*`) | Yes — `LiteLLM.Budget`, [litellm-budget.md](./litellm-budget.md) |
|
|
23
|
+
| Models, virtual keys, teams, credentials, access groups | DB-backed, API-managed, but no kit resource yet |
|
|
24
|
+
| `router_settings`, `litellm_settings`, `general_settings.master_key`/`database_url`/`store_model_in_db` | config-file only — read-only through the API |
|
|
25
|
+
| A `config.yaml` pass-through entry (`is_from_config: true`) | Read-only through this resource — see Refusals |
|
|
25
26
|
|
|
26
27
|
## Credentials
|
|
27
28
|
|
package/docs/postgres.md
CHANGED
|
@@ -123,3 +123,25 @@ mini's 19 already-live databases needs `adopt(true)` in the consuming stack.
|
|
|
123
123
|
|
|
124
124
|
Whether `ALTER DATABASE … REFRESH COLLATION VERSION` should ever be offered as an explicit,
|
|
125
125
|
separate operation (not part of `reconcile`) is open — nothing in this family calls it.
|
|
126
|
+
|
|
127
|
+
## Runner transport (loopback-only clusters)
|
|
128
|
+
|
|
129
|
+
A cluster reachable only by a command — CT100's rootful podman container `postgres`, loopback
|
|
130
|
+
listener, local socket, user `postgres`, no password — cannot take a wire connection from the
|
|
131
|
+
mini. `postgresRunnerProviders({ run, database, username, template? })` (or
|
|
132
|
+
`postgresRunnerConnection` on its own) runs the same `Postgres.Database` reconcile through a
|
|
133
|
+
caller-supplied `PsqlRunner`, which prepends the transport, e.g. `ssh <host> sudo -n podman
|
|
134
|
+
exec -i postgres <argv>` with `stdin` piped through. The kit holds no host, container or sudo
|
|
135
|
+
rule; the consuming stack's runner does.
|
|
136
|
+
|
|
137
|
+
- Same props, same create-and-assert, same adopt/retain and drift semantics — only the
|
|
138
|
+
`PgExecutor` differs (`psql-executor.ts`).
|
|
139
|
+
- `psql` has no bind parameters, so string params are inlined as `quoteStringLiteral`
|
|
140
|
+
literals; a non-string param is refused. Rows come back through `json_agg`, so `oid` and
|
|
141
|
+
`datconnlimit` are numbers as on the socket path.
|
|
142
|
+
- `CREATE DATABASE … TEMPLATE "template0"` is the runner default (`template` overrides):
|
|
143
|
+
CT100's `template1` is ParadeDB's and would copy postgis/pg_ivm/paradedb objects. The socket
|
|
144
|
+
path adds no `TEMPLATE` clause, unchanged.
|
|
145
|
+
- Not covered: there is no `Postgres.Role` resource in the kit yet, so the owner role must
|
|
146
|
+
exist (`PostgresDatabaseOwnerMissing` otherwise). NetBox's 11 lost triggers on `pg_restore`
|
|
147
|
+
are a restore concern, out of this resource's scope.
|
|
@@ -44,6 +44,51 @@ SdnVnet, SdnSubnet, HaResource, HaRule and CephFlag.
|
|
|
44
44
|
Their previous measured-none records
|
|
45
45
|
are historical inventories, not a current census or permission to skip migration.
|
|
46
46
|
|
|
47
|
+
**Measured 2026-09-28, by parser** (Bun's `Transpiler.scanImports` — a real import-graph
|
|
48
|
+
read, not a text match — walked one hop through local imports so a family that only
|
|
49
|
+
reaches the hand client via the shared `resource.ts` factory is still attributed
|
|
50
|
+
correctly; run against `packages/alchemy/src/proxmox` and `src/openbao`):
|
|
51
|
+
|
|
52
|
+
| Package | distilled-only | hand-client-only | mixed | no transport import (pure logic/form/test) |
|
|
53
|
+
| --------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
|
|
54
|
+
| `alchemy/src/proxmox` (165 files) | 71 | 9 — `resource.ts` (the shared `pveHandlers` factory itself), `sdn-zone.ts`, `sdn-vnet.ts`, `sdn-subnet.ts`, `sdn-apply.ts`, `sdn-apply-read.ts`, `ha-resource.ts`, `ha-rule.ts`, `ceph-flag.ts` | 1 — `index.ts` (barrel, re-exports both) | 84 |
|
|
55
|
+
| `alchemy/src/openbao` (80 files) | 2 | 36 (every `Bao*` family but Policy) | 5 — `distilled.ts`, `policy-wire.ts`, `policy-identity.ts`, `auth-role-wire.ts`, `auth-role-identity.ts` | 37 |
|
|
56
|
+
|
|
57
|
+
The proxmox count confirms the doc text above with one addition: nine, not eight,
|
|
58
|
+
files are hand-client-only. Eight back the seven named families (SdnApply's family
|
|
59
|
+
spans two files, `sdn-apply.ts` and `sdn-apply-read.ts`); the ninth is `resource.ts`
|
|
60
|
+
— the shared `pveHandlers`/`pveOperations` factory itself, which imports
|
|
61
|
+
`./client.ts` and no `@distilled.cloud/*` module, so
|
|
62
|
+
the one-hop rule that correctly attributes the seven named families' hand-client use
|
|
63
|
+
attributes this file's own import the same way. It does not mean every distilled-only
|
|
64
|
+
family still calls through it: 17 of the 71 import `resource.ts`, and all 17 are
|
|
65
|
+
type-only (`PveRequirements`/`WithTarget`, or `WithPbsTarget` for three PBS files —
|
|
66
|
+
types are erased at build time, invisible to `scanImports`, so the parser correctly
|
|
67
|
+
leaves them out of this table). `ceph-osd.ts` is one of those 17, not an exception;
|
|
68
|
+
it is the only one of the 71 that even mentions `pveHandlers` at all, in a comment
|
|
69
|
+
explaining why it deliberately does not use the factory — read in full, it calls
|
|
70
|
+
neither factory function. No other PVE/PBS family has regressed to hand-client
|
|
71
|
+
since the 28-family migration
|
|
72
|
+
landed.
|
|
73
|
+
|
|
74
|
+
**OpenBao is far less migrated than this repo's own comments could suggest** — only
|
|
75
|
+
`BaoPolicy`'s wire/identity pair and the `distilled.ts` wrapper use distilled transport
|
|
76
|
+
at all today; every other family (`BaoMount`, `BaoAuthMethod`, `BaoAuthRole`'s form
|
|
77
|
+
paths, `BaoPkiRole`, `BaoSshRole`, `BaoCloudflareRole`, `BaoProxmoxRole`, `BaoJwtRole`,
|
|
78
|
+
`BaoJwtAuthConfig`, `BaoMfaTotpMethod`, `BaoMfaLoginEnforcement`, `BaoPlugin`,
|
|
79
|
+
rename/remount/role-reconcile helpers) is still on the hand `bao-http.ts` client.
|
|
80
|
+
`mount-wire.ts` specifically is blocked on more than volume: the distilled SDK's
|
|
81
|
+
`mounts.ts` operations all declare `errors: []` (no typed absence error), unlike the
|
|
82
|
+
sibling `policies.ts`'s `PolicyNotFound` — a clean migration needs that SDK gap patched
|
|
83
|
+
first, in the distilled clone, per [the interim route](./distilled-interim.md)'s
|
|
84
|
+
build-SDK-first step. Two `codex/`-prefixed worktrees already exist in the distilled
|
|
85
|
+
clone (`openbao-mount-map`, `openbao-policy-runtime`) with unmerged work toward exactly
|
|
86
|
+
this: `openbao-mount-map`'s six commits generate the OpenBao SDK from a disposable local
|
|
87
|
+
2.6.2 server and add a typed mount-inventory response shape (`protocol-mount-tables.ts`,
|
|
88
|
+
provenance in `patches/mount-tables.md`), but its `mounts.ts` operations still declare
|
|
89
|
+
`errors: []` — the inventory-shape fix and the typed-absence-error fix are two separate
|
|
90
|
+
gaps, and only the first is done there.
|
|
91
|
+
|
|
47
92
|
- NetworkApply reads `listNodeNetwork`. A string `changes` sibling is preserved by
|
|
48
93
|
the protocol and decoded by that operation's union. The diff is counted and does
|
|
49
94
|
not enter state or logs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -128,12 +128,12 @@
|
|
|
128
128
|
"dependencies": {
|
|
129
129
|
"@distilled.cloud/caddy": "npm:@homeflare/distilled-caddy@0.2.2",
|
|
130
130
|
"@distilled.cloud/grafana": "npm:@homeflare/distilled-grafana@0.2.0",
|
|
131
|
-
"@distilled.cloud/litellm": "npm:@homeflare/distilled-litellm@0.
|
|
131
|
+
"@distilled.cloud/litellm": "npm:@homeflare/distilled-litellm@0.3.0",
|
|
132
132
|
"@distilled.cloud/netbox": "npm:@homeflare/distilled-netbox@0.3.0",
|
|
133
133
|
"@distilled.cloud/openbao": "npm:@homeflare/distilled-openbao@0.2.2",
|
|
134
134
|
"@distilled.cloud/opnsense": "npm:@homeflare/distilled-opnsense@0.3.0",
|
|
135
135
|
"@distilled.cloud/paperless-ngx": "npm:@homeflare/distilled-paperless-ngx@0.3.0",
|
|
136
|
-
"@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.3.
|
|
136
|
+
"@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.3.4",
|
|
137
137
|
"@distilled.cloud/proxmox-backup": "npm:@homeflare/distilled-proxmox-backup@0.3.1",
|
|
138
138
|
"@distilled.cloud/unifi-network": "npm:@homeflare/distilled-unifi-network@0.4.0"
|
|
139
139
|
},
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed refusals `LiteLLM.Budget` raises on top of `@distilled.cloud/litellm`'s own SDK errors.
|
|
3
|
+
*/
|
|
4
|
+
import type * as budgets from '@distilled.cloud/litellm/budget_management';
|
|
5
|
+
import * as Data from 'effect/Data';
|
|
6
|
+
|
|
7
|
+
/** A `maxBudget` (a HARD cap: LiteLLM answers 429 once it is spent) declared with no reason. */
|
|
8
|
+
export class LitellmBudgetHardCapUnjustifiedError extends Data.TaggedError(
|
|
9
|
+
'LitellmBudgetHardCapUnjustifiedError',
|
|
10
|
+
)<{
|
|
11
|
+
readonly budgetId: string | undefined;
|
|
12
|
+
}> {}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The write returned no error but the row still carries a limit the declaration does not.
|
|
16
|
+
* `/budget/update` may merge with `exclude_none` (the way `/config/pass_through_endpoint` does),
|
|
17
|
+
* in which case a limit can never be cleared by an update. Refused, never claimed as done.
|
|
18
|
+
*/
|
|
19
|
+
export class LitellmBudgetFieldNotClearedError extends Data.TaggedError(
|
|
20
|
+
'LitellmBudgetFieldNotClearedError',
|
|
21
|
+
)<{
|
|
22
|
+
readonly budgetId: string;
|
|
23
|
+
readonly fields: readonly string[];
|
|
24
|
+
}> {}
|
|
25
|
+
|
|
26
|
+
/** `/budget/info` answered something that is not a list of budget rows. */
|
|
27
|
+
export class LitellmBudgetUnreadableError extends Data.TaggedError('LitellmBudgetUnreadableError')<{
|
|
28
|
+
readonly budgetId: string;
|
|
29
|
+
}> {}
|
|
30
|
+
|
|
31
|
+
export type BudgetError =
|
|
32
|
+
| budgets.PostInfoBudgetBudgetInfoError
|
|
33
|
+
| budgets.PostNewBudgetBudgetNewError
|
|
34
|
+
| budgets.UpdateBudgetBudgetUpdatePostError
|
|
35
|
+
| budgets.DeleteBudgetBudgetDeletePostError
|
|
36
|
+
| LitellmBudgetHardCapUnjustifiedError
|
|
37
|
+
| LitellmBudgetFieldNotClearedError
|
|
38
|
+
| LitellmBudgetUnreadableError;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Props, wire bodies and comparison for `LiteLLM.Budget`, testable without a server.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ NO SILENT `max_budget`. LiteLLM's own field doc: "Requests will fail if this budget (in USD)
|
|
5
|
+
* is exceeded" — a hard cap. On 2026-09-28 an undeclared $60/day `max_budget` on
|
|
6
|
+
* `hf-tier-interactive` 429'd the whole claude2 fleet. So `maxBudget` is never defaulted and is
|
|
7
|
+
* refused unless `maxBudgetReason` says why; `softBudget` is the monitoring field (it "will NOT
|
|
8
|
+
* fail if this is exceeded", it fires the alert).
|
|
9
|
+
* ⚠️ NOT MODELLED: `max_parallel_requests`, `model_max_budget`, `budget_reset_at`. The row read
|
|
10
|
+
* back (`BudgetListItem`, generated at LiteLLM 1.100.0) has none of them, so a declaration of
|
|
11
|
+
* them could never be diffed. Add them when a read that returns them is measured.
|
|
12
|
+
*/
|
|
13
|
+
import type * as budgets from '@distilled.cloud/litellm/budget_management';
|
|
14
|
+
|
|
15
|
+
export interface BudgetProps {
|
|
16
|
+
/** Live budget id. Set it to adopt an existing row (`hf-tier-agent`); default is a deterministic physical name. */
|
|
17
|
+
readonly budgetId?: string;
|
|
18
|
+
/** Alert threshold in USD. Requests keep flowing when exceeded. The monitoring field. */
|
|
19
|
+
readonly softBudget?: number;
|
|
20
|
+
/** HARD cap in USD: requests fail (429) once spent. Refused unless `maxBudgetReason` is set. */
|
|
21
|
+
readonly maxBudget?: number;
|
|
22
|
+
/** Why a hard cap is wanted. Required with `maxBudget`; recorded in the declaration only. */
|
|
23
|
+
readonly maxBudgetReason?: string;
|
|
24
|
+
/** Reset period, e.g. `'1d'`, `'28d'`. */
|
|
25
|
+
readonly budgetDuration?: string;
|
|
26
|
+
/** Requests per minute. */
|
|
27
|
+
readonly rpmLimit?: number;
|
|
28
|
+
/** Tokens per minute. */
|
|
29
|
+
readonly tpmLimit?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BudgetAttributes {
|
|
33
|
+
readonly budgetId: string;
|
|
34
|
+
readonly softBudget: number | null;
|
|
35
|
+
readonly maxBudget: number | null;
|
|
36
|
+
readonly budgetDuration: string | null;
|
|
37
|
+
readonly rpmLimit: number | null;
|
|
38
|
+
readonly tpmLimit: number | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Declared field → wire field. The single list every comparison and body walks. */
|
|
42
|
+
const FIELDS = [
|
|
43
|
+
['softBudget', 'soft_budget'],
|
|
44
|
+
['maxBudget', 'max_budget'],
|
|
45
|
+
['budgetDuration', 'budget_duration'],
|
|
46
|
+
['rpmLimit', 'rpm_limit'],
|
|
47
|
+
['tpmLimit', 'tpm_limit'],
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
export const isHardCapUnjustified = (props: BudgetProps): boolean =>
|
|
51
|
+
props.maxBudget !== undefined && (props.maxBudgetReason ?? '').trim() === '';
|
|
52
|
+
|
|
53
|
+
/** Create body: only what is declared. */
|
|
54
|
+
export const createBody = (
|
|
55
|
+
props: BudgetProps,
|
|
56
|
+
budgetId: string,
|
|
57
|
+
): budgets.PostNewBudgetBudgetNewRequest => {
|
|
58
|
+
const body: Record<string, unknown> = { budget_id: budgetId };
|
|
59
|
+
for (const [prop, wire] of FIELDS) if (props[prop] !== undefined) body[wire] = props[prop];
|
|
60
|
+
return body as budgets.PostNewBudgetBudgetNewRequest;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Update body: declared fields, plus an explicit `null` for a live limit the declaration dropped. */
|
|
64
|
+
export const updateBody = (
|
|
65
|
+
props: BudgetProps,
|
|
66
|
+
live: BudgetAttributes,
|
|
67
|
+
): budgets.UpdateBudgetBudgetUpdatePostRequest => {
|
|
68
|
+
const body: Record<string, unknown> = { budget_id: live.budgetId };
|
|
69
|
+
for (const [prop, wire] of FIELDS) {
|
|
70
|
+
if (props[prop] !== undefined) body[wire] = props[prop];
|
|
71
|
+
else if (live[prop] !== null) body[wire] = null;
|
|
72
|
+
}
|
|
73
|
+
return body as budgets.UpdateBudgetBudgetUpdatePostRequest;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Wire names of the fields where live and declared disagree. */
|
|
77
|
+
export const differing = (live: BudgetAttributes, props: BudgetProps): readonly string[] =>
|
|
78
|
+
FIELDS.filter(([prop]) => live[prop] !== (props[prop] ?? null)).map(([, wire]) => wire);
|
|
79
|
+
|
|
80
|
+
const numberOrNull = (value: unknown): number | null =>
|
|
81
|
+
value === null || value === undefined || value === '' ? null : Number(value);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One row of `/budget/info`. `tpm_limit`/`rpm_limit` are `BigInt?` in LiteLLM's schema and come
|
|
85
|
+
* back as decimal strings (the generated `BudgetListItem` says so), hence `Number()`.
|
|
86
|
+
*/
|
|
87
|
+
export const toAttributes = (row: Record<string, unknown>): BudgetAttributes => ({
|
|
88
|
+
budgetDuration: typeof row['budget_duration'] === 'string' ? row['budget_duration'] : null,
|
|
89
|
+
budgetId: String(row['budget_id']),
|
|
90
|
+
maxBudget: numberOrNull(row['max_budget']),
|
|
91
|
+
rpmLimit: numberOrNull(row['rpm_limit']),
|
|
92
|
+
softBudget: numberOrNull(row['soft_budget']),
|
|
93
|
+
tpmLimit: numberOrNull(row['tpm_limit']),
|
|
94
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four LiteLLM `/budget/*` calls `LiteLLM.Budget` uses, through the SDK's typed operations.
|
|
3
|
+
*
|
|
4
|
+
* ★ READS GO THROUGH `/budget/list` (the whole table, a bare array), not `/budget/info`. Both
|
|
5
|
+
* answer `body: unknown` in the generated schema, and what `/budget/info` does for an id that is
|
|
6
|
+
* not there is unmeasured; a list answers absence unambiguously.
|
|
7
|
+
* ⚠️ Unlike pass-through endpoints, each budget is its own DB row, so no semaphore.
|
|
8
|
+
* ⚠️ DELETE is idempotent BY THIS FUNCTION: a `BadRequest` re-lists and is swallowed only when the
|
|
9
|
+
* id is genuinely absent, never on message text (pass-through `operations.ts`' rule).
|
|
10
|
+
*/
|
|
11
|
+
import * as budgets from '@distilled.cloud/litellm/budget_management';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import { type LitellmOpContext, throughFetch } from './operations.ts';
|
|
14
|
+
import { type BudgetAttributes, toAttributes } from './budget-form.ts';
|
|
15
|
+
import { LitellmBudgetUnreadableError } from './budget-errors.ts';
|
|
16
|
+
|
|
17
|
+
export const listBudgets = (): Effect.Effect<
|
|
18
|
+
readonly BudgetAttributes[],
|
|
19
|
+
budgets.ListBudgetBudgetListGetError | LitellmBudgetUnreadableError,
|
|
20
|
+
LitellmOpContext
|
|
21
|
+
> =>
|
|
22
|
+
throughFetch(budgets.listBudgetBudgetListGet({})).pipe(
|
|
23
|
+
// ⚠️ MEASURED with the fake: the SDK types this `{ body: unknown }` but hands a bare JSON array
|
|
24
|
+
// back as the array itself, so accept both shapes and refuse anything else.
|
|
25
|
+
Effect.flatMap((response) => {
|
|
26
|
+
const rows: unknown = Array.isArray(response)
|
|
27
|
+
? response
|
|
28
|
+
: (response as { body?: unknown }).body;
|
|
29
|
+
return Array.isArray(rows)
|
|
30
|
+
? Effect.succeed(rows.map((row) => toAttributes(row as Record<string, unknown>)))
|
|
31
|
+
: Effect.fail(new LitellmBudgetUnreadableError({ budgetId: '*' }));
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export const createBudget = (body: budgets.PostNewBudgetBudgetNewRequest) =>
|
|
36
|
+
throughFetch(budgets.postNewBudgetBudgetNew(body)).pipe(Effect.asVoid);
|
|
37
|
+
|
|
38
|
+
export const updateBudget = (body: budgets.UpdateBudgetBudgetUpdatePostRequest) =>
|
|
39
|
+
throughFetch(budgets.updateBudgetBudgetUpdatePost(body)).pipe(Effect.asVoid);
|
|
40
|
+
|
|
41
|
+
export const deleteBudget = (id: string) =>
|
|
42
|
+
throughFetch(budgets.deleteBudgetBudgetDeletePost({ id })).pipe(
|
|
43
|
+
Effect.asVoid,
|
|
44
|
+
Effect.catchTag('BadRequest', (original) =>
|
|
45
|
+
listBudgets().pipe(
|
|
46
|
+
Effect.flatMap((rows) =>
|
|
47
|
+
rows.some((row) => row.budgetId === id) ? Effect.fail(original) : Effect.void,
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `LiteLLM.Budget` through Alchemy's real Plan and Apply over the fake proxy.
|
|
3
|
+
*/
|
|
4
|
+
import { expect, test } from 'bun:test';
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import { FAKE_BASE, startFakeLitellm } from './fake-litellm.ts';
|
|
7
|
+
import { fakeStack, writesOf } from './fake-stack.ts';
|
|
8
|
+
import { type BudgetProps, LiteLLMBudget } from './budget.ts';
|
|
9
|
+
import { differing, isHardCapUnjustified, toAttributes } from './budget-form.ts';
|
|
10
|
+
|
|
11
|
+
const KEY = 'sk-test-master';
|
|
12
|
+
const stack = (fake: ReturnType<typeof startFakeLitellm>) =>
|
|
13
|
+
fakeStack({ apiKey: KEY, baseUrl: FAKE_BASE }, fake.fetch);
|
|
14
|
+
const declare = (props: BudgetProps, name = 'Tier') =>
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
yield* LiteLLMBudget(name, props);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const tier = {
|
|
20
|
+
budget_id: 'hf-tier-agent',
|
|
21
|
+
budget_duration: '1d',
|
|
22
|
+
created_at: 'x',
|
|
23
|
+
max_budget: 2,
|
|
24
|
+
rpm_limit: '60',
|
|
25
|
+
soft_budget: null,
|
|
26
|
+
tpm_limit: null,
|
|
27
|
+
updated_at: 'x',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
test('creates a soft-only budget and sends no max_budget', async () => {
|
|
31
|
+
const fake = startFakeLitellm({ masterKey: KEY });
|
|
32
|
+
const planned = await stack(fake).deploy(
|
|
33
|
+
declare({ budgetId: 'hf-tier-x', budgetDuration: '1d', softBudget: 50 }),
|
|
34
|
+
);
|
|
35
|
+
expect(planned).toEqual({ Tier: 'create' });
|
|
36
|
+
expect(fake.budgets()[0]).toMatchObject({ budget_id: 'hf-tier-x', soft_budget: 50 });
|
|
37
|
+
expect(fake.budgets()[0]).not.toHaveProperty('max_budget');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('a second deploy of the same declaration writes nothing', async () => {
|
|
41
|
+
const fake = startFakeLitellm({ masterKey: KEY });
|
|
42
|
+
const props = { budgetId: 'hf-tier-x', softBudget: 50, rpmLimit: 60 };
|
|
43
|
+
const same = stack(fake);
|
|
44
|
+
await same.deploy(declare(props));
|
|
45
|
+
const before = fake.requests().length;
|
|
46
|
+
expect(await same.deploy(declare(props))).toEqual({ Tier: 'noop' });
|
|
47
|
+
expect(writesOf(fake.requests().slice(before))).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a hard cap without a reason is refused before any write', async () => {
|
|
51
|
+
const fake = startFakeLitellm({ masterKey: KEY });
|
|
52
|
+
await expect(
|
|
53
|
+
stack(fake).deploy(declare({ budgetId: 'hf-tier-x', maxBudget: 60 })),
|
|
54
|
+
).rejects.toThrow();
|
|
55
|
+
expect(writesOf(fake.requests())).toEqual([]);
|
|
56
|
+
expect(isHardCapUnjustified({ maxBudget: 1, maxBudgetReason: ' ' })).toBe(true);
|
|
57
|
+
expect(isHardCapUnjustified({ maxBudget: 1, maxBudgetReason: 'abuse guard' })).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a hard cap with a reason is written', async () => {
|
|
61
|
+
const fake = startFakeLitellm({ masterKey: KEY });
|
|
62
|
+
await stack(fake).deploy(
|
|
63
|
+
declare({ budgetId: 'hf-tier-x', maxBudget: 5, maxBudgetReason: 'public voice endpoint' }),
|
|
64
|
+
);
|
|
65
|
+
expect(fake.budgets()[0]).toMatchObject({ max_budget: 5 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a live row is Unowned: refused without --adopt, taken with it', async () => {
|
|
69
|
+
const fake = startFakeLitellm({ masterKey: KEY, budgetSeed: [tier] });
|
|
70
|
+
const props = {
|
|
71
|
+
budgetId: 'hf-tier-agent',
|
|
72
|
+
budgetDuration: '1d',
|
|
73
|
+
maxBudget: 2,
|
|
74
|
+
maxBudgetReason: 'measured live',
|
|
75
|
+
};
|
|
76
|
+
const engine = stack(fake);
|
|
77
|
+
await expect(engine.deploy(declare(props))).rejects.toThrow();
|
|
78
|
+
expect(writesOf(fake.requests())).toEqual([]);
|
|
79
|
+
const planned = await engine.deploy(declare({ ...props, rpmLimit: 60 }), { adopt: true });
|
|
80
|
+
expect(Object.values(planned)).toEqual(['adopted']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('adopting a hard tier and declaring it soft clears max_budget', async () => {
|
|
84
|
+
const fake = startFakeLitellm({ masterKey: KEY, budgetSeed: [tier] });
|
|
85
|
+
await stack(fake).deploy(
|
|
86
|
+
declare({ budgetId: 'hf-tier-agent', budgetDuration: '1d', rpmLimit: 60, softBudget: 2 }),
|
|
87
|
+
{ adopt: true },
|
|
88
|
+
);
|
|
89
|
+
expect(fake.budgets()[0]).toMatchObject({ max_budget: null, soft_budget: 2 });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a proxy that cannot clear a limit fails the apply instead of claiming success', async () => {
|
|
93
|
+
const fake = startFakeLitellm({ masterKey: KEY, budgetSeed: [tier], budgetExcludeNone: true });
|
|
94
|
+
await expect(
|
|
95
|
+
stack(fake).deploy(declare({ budgetId: 'hf-tier-agent', budgetDuration: '1d', rpmLimit: 60 }), {
|
|
96
|
+
adopt: true,
|
|
97
|
+
}),
|
|
98
|
+
).rejects.toThrow();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('form helpers: string limits become numbers, and differences are named', () => {
|
|
102
|
+
const live = toAttributes(tier);
|
|
103
|
+
expect(live.rpmLimit).toBe(60);
|
|
104
|
+
expect(differing(live, { budgetDuration: '1d', rpmLimit: 60 })).toEqual(['max_budget']);
|
|
105
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `LiteLLM.Budget` — one row of LiteLLM's `LiteLLM_BudgetTable` (`/budget/*`), the "tier" keys and
|
|
3
|
+
* teams bind to (`hf-tier-*`).
|
|
4
|
+
*
|
|
5
|
+
* ★ MONITORING, NOT LIMITS (Tim, 2026-09-28): `softBudget` is the prop; `maxBudget` is a hard cap
|
|
6
|
+
* that 429s every caller and is refused without `maxBudgetReason` (budget-form.ts).
|
|
7
|
+
* ★ ADOPT BY id. `budgetId` names the live row; without it a deterministic physical name is used.
|
|
8
|
+
* A foreign row with that id is `Unowned`, so it needs `--adopt`.
|
|
9
|
+
* ★ `defaultRemovalPolicy: 'retain'` — deleting a budget row that keys still reference either
|
|
10
|
+
* fails or detaches them, and a detached key loses its rate limits. Opt in with
|
|
11
|
+
* `.pipe(RemovalPolicy.destroy())`.
|
|
12
|
+
* ⛔ CLEARING A LIMIT: sent as an explicit `null`. If this LiteLLM merges with `exclude_none` the
|
|
13
|
+
* row keeps the old value; reconcile reads back and FAILS (`LitellmBudgetFieldNotClearedError`)
|
|
14
|
+
* rather than report a cleared cap. Unmeasured against a live proxy at 1.103.0.
|
|
15
|
+
* ⛔ NO CREDENTIAL IS A PROP; `litellmProviders`' layer supplies `Credentials`.
|
|
16
|
+
*/
|
|
17
|
+
import { Resource } from 'alchemy';
|
|
18
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
19
|
+
import { isResolved } from 'alchemy/Diff';
|
|
20
|
+
import type { Input } from 'alchemy/Input';
|
|
21
|
+
import { createPhysicalName } from 'alchemy/PhysicalName';
|
|
22
|
+
import * as Provider from 'alchemy/Provider';
|
|
23
|
+
import * as Effect from 'effect/Effect';
|
|
24
|
+
import * as Predicate from 'effect/Predicate';
|
|
25
|
+
import {
|
|
26
|
+
LitellmBudgetFieldNotClearedError,
|
|
27
|
+
LitellmBudgetHardCapUnjustifiedError,
|
|
28
|
+
} from './budget-errors.ts';
|
|
29
|
+
import {
|
|
30
|
+
type BudgetAttributes,
|
|
31
|
+
type BudgetProps,
|
|
32
|
+
createBody,
|
|
33
|
+
differing,
|
|
34
|
+
isHardCapUnjustified,
|
|
35
|
+
updateBody,
|
|
36
|
+
} from './budget-form.ts';
|
|
37
|
+
import { createBudget, deleteBudget, listBudgets, updateBudget } from './budget-operations.ts';
|
|
38
|
+
|
|
39
|
+
export type { BudgetAttributes, BudgetProps };
|
|
40
|
+
export type { BudgetError } from './budget-errors.ts';
|
|
41
|
+
|
|
42
|
+
export interface LiteLLMBudget extends Resource<'LiteLLM.Budget', BudgetProps, BudgetAttributes> {}
|
|
43
|
+
|
|
44
|
+
export const LiteLLMBudget = Resource<LiteLLMBudget>('LiteLLM.Budget', {
|
|
45
|
+
defaultRemovalPolicy: 'retain',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const isLiteLLMBudget = (value: unknown): value is LiteLLMBudget =>
|
|
49
|
+
Predicate.hasProperty(value, 'Type') && value.Type === 'LiteLLM.Budget';
|
|
50
|
+
|
|
51
|
+
const refuseUnjustifiedCap = (props: BudgetProps) =>
|
|
52
|
+
isHardCapUnjustified(props)
|
|
53
|
+
? Effect.fail(new LitellmBudgetHardCapUnjustifiedError({ budgetId: props.budgetId }))
|
|
54
|
+
: Effect.void;
|
|
55
|
+
|
|
56
|
+
const idOf = (
|
|
57
|
+
id: string,
|
|
58
|
+
instanceId: string,
|
|
59
|
+
props: BudgetProps,
|
|
60
|
+
output: BudgetAttributes | undefined,
|
|
61
|
+
) =>
|
|
62
|
+
output?.budgetId !== undefined
|
|
63
|
+
? Effect.succeed(output.budgetId)
|
|
64
|
+
: props.budgetId !== undefined
|
|
65
|
+
? Effect.succeed(props.budgetId)
|
|
66
|
+
: createPhysicalName({ id, instanceId, lowercase: true, maxLength: 64 });
|
|
67
|
+
|
|
68
|
+
type Args<P> = {
|
|
69
|
+
id: string;
|
|
70
|
+
instanceId: string;
|
|
71
|
+
output: BudgetAttributes | undefined;
|
|
72
|
+
} & P;
|
|
73
|
+
|
|
74
|
+
/** ⛔ TEST-ONLY EXPORT. Not on index.ts's barrel. */
|
|
75
|
+
export const budgetHandlers = {
|
|
76
|
+
read: ({ id, instanceId, olds, output }: Args<{ olds: BudgetProps }>) =>
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
const budgetId = yield* idOf(id, instanceId, olds, output);
|
|
79
|
+
const live = (yield* listBudgets()).find((row) => row.budgetId === budgetId);
|
|
80
|
+
if (live === undefined) return undefined;
|
|
81
|
+
return output === undefined ? Unowned(live) : live;
|
|
82
|
+
}),
|
|
83
|
+
|
|
84
|
+
diff: ({ news, output }: { news: Input<BudgetProps>; output: BudgetAttributes | undefined }) =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
if (!isResolved(news)) return undefined;
|
|
87
|
+
yield* refuseUnjustifiedCap(news);
|
|
88
|
+
if (output === undefined) return undefined;
|
|
89
|
+
// The id is identity: a different declared id is a different row.
|
|
90
|
+
if (news.budgetId !== undefined && news.budgetId !== output.budgetId) {
|
|
91
|
+
return { action: 'replace', deleteFirst: false } as const;
|
|
92
|
+
}
|
|
93
|
+
return differing(output, news).length === 0
|
|
94
|
+
? ({ action: 'noop' } as const)
|
|
95
|
+
: ({ action: 'update' } as const);
|
|
96
|
+
}),
|
|
97
|
+
|
|
98
|
+
reconcile: ({ id, instanceId, news, output }: Args<{ news: BudgetProps }>) =>
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
yield* refuseUnjustifiedCap(news);
|
|
101
|
+
const budgetId = yield* idOf(id, instanceId, news, output);
|
|
102
|
+
const before = (yield* listBudgets()).find((row) => row.budgetId === budgetId);
|
|
103
|
+
if (before === undefined) yield* createBudget(createBody(news, budgetId));
|
|
104
|
+
else if (differing(before, news).length > 0) yield* updateBudget(updateBody(news, before));
|
|
105
|
+
|
|
106
|
+
const after = (yield* listBudgets()).find((row) => row.budgetId === budgetId);
|
|
107
|
+
if (after === undefined) {
|
|
108
|
+
return yield* Effect.die(
|
|
109
|
+
new Error(`LiteLLM.Budget ${budgetId}: write returned no error but the row is absent.`),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const left = differing(after, news);
|
|
113
|
+
if (left.length > 0) {
|
|
114
|
+
return yield* Effect.fail(
|
|
115
|
+
new LitellmBudgetFieldNotClearedError({ budgetId, fields: left }),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return after;
|
|
119
|
+
}),
|
|
120
|
+
|
|
121
|
+
delete: ({ output }: { output: BudgetAttributes }) => deleteBudget(output.budgetId),
|
|
122
|
+
|
|
123
|
+
list: () => Effect.succeed([]),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const LiteLLMBudgetProvider = () =>
|
|
127
|
+
Provider.effect(LiteLLMBudget, Effect.succeed(LiteLLMBudget.Provider.of(budgetHandlers)));
|