@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.
Files changed (80) hide show
  1. package/dist/litellm/budget-errors.d.ts +36 -0
  2. package/dist/litellm/budget-errors.d.ts.map +1 -0
  3. package/dist/litellm/budget-form.d.ts +50 -0
  4. package/dist/litellm/budget-form.d.ts.map +1 -0
  5. package/dist/litellm/budget-operations.d.ts +20 -0
  6. package/dist/litellm/budget-operations.d.ts.map +1 -0
  7. package/dist/litellm/budget.d.ts +61 -0
  8. package/dist/litellm/budget.d.ts.map +1 -0
  9. package/dist/litellm/fake-litellm.d.ts +5 -0
  10. package/dist/litellm/fake-litellm.d.ts.map +1 -1
  11. package/dist/litellm/index.d.ts +2 -0
  12. package/dist/litellm/index.d.ts.map +1 -1
  13. package/dist/litellm/index.js +125 -2
  14. package/dist/litellm/index.js.map +9 -5
  15. package/dist/litellm/operations.d.ts +22 -0
  16. package/dist/litellm/operations.d.ts.map +1 -1
  17. package/dist/litellm/providers.d.ts +1 -1
  18. package/dist/litellm/providers.d.ts.map +1 -1
  19. package/dist/openbao/auth-role-form.d.ts +9 -0
  20. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  21. package/dist/openbao/auth-role-wire.d.ts.map +1 -1
  22. package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
  23. package/dist/openbao/index.js +9 -6
  24. package/dist/openbao/index.js.map +4 -4
  25. package/dist/postgres/connection.d.ts +20 -2
  26. package/dist/postgres/connection.d.ts.map +1 -1
  27. package/dist/postgres/database-sql.d.ts +1 -1
  28. package/dist/postgres/database-sql.d.ts.map +1 -1
  29. package/dist/postgres/database.d.ts +1 -1
  30. package/dist/postgres/database.d.ts.map +1 -1
  31. package/dist/postgres/index.d.ts +5 -3
  32. package/dist/postgres/index.d.ts.map +1 -1
  33. package/dist/postgres/index.js +124 -38
  34. package/dist/postgres/index.js.map +8 -7
  35. package/dist/postgres/providers.d.ts +4 -2
  36. package/dist/postgres/providers.d.ts.map +1 -1
  37. package/dist/postgres/psql-executor.d.ts +21 -0
  38. package/dist/postgres/psql-executor.d.ts.map +1 -0
  39. package/dist/proxmox/ceph-flag-types.d.ts +57 -0
  40. package/dist/proxmox/ceph-flag-types.d.ts.map +1 -0
  41. package/dist/proxmox/ceph-flag-wire.d.ts +77 -0
  42. package/dist/proxmox/ceph-flag-wire.d.ts.map +1 -0
  43. package/dist/proxmox/ceph-flag.d.ts +15 -50
  44. package/dist/proxmox/ceph-flag.d.ts.map +1 -1
  45. package/dist/proxmox/ceph-osd-write.d.ts +10 -13
  46. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  47. package/dist/proxmox/index.js +772 -740
  48. package/dist/proxmox/index.js.map +8 -7
  49. package/docs/litellm-budget.md +55 -0
  50. package/docs/litellm.md +7 -6
  51. package/docs/postgres.md +22 -0
  52. package/docs/proxmox-sdk-migration.md +45 -0
  53. package/package.json +3 -3
  54. package/src/litellm/budget-errors.ts +38 -0
  55. package/src/litellm/budget-form.ts +94 -0
  56. package/src/litellm/budget-operations.ts +51 -0
  57. package/src/litellm/budget.test.ts +105 -0
  58. package/src/litellm/budget.ts +127 -0
  59. package/src/litellm/fake-litellm.ts +50 -1
  60. package/src/litellm/index.ts +13 -0
  61. package/src/litellm/operations.ts +1 -1
  62. package/src/litellm/providers.ts +4 -1
  63. package/src/openbao/auth-role-form.test.ts +37 -0
  64. package/src/openbao/auth-role-form.ts +12 -1
  65. package/src/openbao/auth-role-wire.test.ts +1 -0
  66. package/src/openbao/auth-role-wire.ts +13 -1
  67. package/src/openbao/distilled-retry.test.ts +1 -0
  68. package/src/openbao/fake-engines-roles.ts +7 -1
  69. package/src/postgres/connection.ts +37 -3
  70. package/src/postgres/database-sql.ts +4 -3
  71. package/src/postgres/database.ts +3 -2
  72. package/src/postgres/index.ts +10 -3
  73. package/src/postgres/providers.ts +15 -2
  74. package/src/postgres/psql-executor.test.ts +106 -0
  75. package/src/postgres/psql-executor.ts +130 -0
  76. package/src/proxmox/ceph-flag-types.ts +70 -0
  77. package/src/proxmox/ceph-flag-wire.test.ts +72 -0
  78. package/src/proxmox/ceph-flag-wire.ts +123 -0
  79. package/src/proxmox/ceph-flag.ts +77 -130
  80. 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
- | Models, virtual keys, teams, credentials, access groups | DB-backed, API-managed, but no kit resource yet |
23
- | `router_settings`, `litellm_settings`, `general_settings.master_key`/`database_url`/`store_model_in_db` | config-file only — read-only through the API |
24
- | A `config.yaml` pass-through entry (`is_from_config: true`) | Read-only through this resource — see Refusals |
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.42.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.2.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.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)));