@homeflare/alchemy 0.41.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 (115) 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/acl.d.ts.map +1 -1
  40. package/dist/proxmox/api-token.d.ts.map +1 -1
  41. package/dist/proxmox/ceph-flag-types.d.ts +57 -0
  42. package/dist/proxmox/ceph-flag-types.d.ts.map +1 -0
  43. package/dist/proxmox/ceph-flag-wire.d.ts +77 -0
  44. package/dist/proxmox/ceph-flag-wire.d.ts.map +1 -0
  45. package/dist/proxmox/ceph-flag.d.ts +15 -50
  46. package/dist/proxmox/ceph-flag.d.ts.map +1 -1
  47. package/dist/proxmox/ceph-osd-write.d.ts +10 -13
  48. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
  49. package/dist/proxmox/credential-errors.d.ts +18 -4
  50. package/dist/proxmox/credential-errors.d.ts.map +1 -1
  51. package/dist/proxmox/credentials.d.ts +35 -1
  52. package/dist/proxmox/credentials.d.ts.map +1 -1
  53. package/dist/proxmox/group.d.ts.map +1 -1
  54. package/dist/proxmox/index.js +805 -757
  55. package/dist/proxmox/index.js.map +21 -19
  56. package/dist/proxmox/lease-cache.d.ts +67 -5
  57. package/dist/proxmox/lease-cache.d.ts.map +1 -1
  58. package/dist/proxmox/mint.d.ts +4 -2
  59. package/dist/proxmox/mint.d.ts.map +1 -1
  60. package/dist/proxmox/node-network.d.ts.map +1 -1
  61. package/dist/proxmox/role.d.ts.map +1 -1
  62. package/dist/proxmox/storage.d.ts.map +1 -1
  63. package/dist/proxmox/unreadable-read.d.ts +8 -1
  64. package/dist/proxmox/unreadable-read.d.ts.map +1 -1
  65. package/dist/proxmox/user.d.ts.map +1 -1
  66. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  67. package/docs/credentials.md +14 -1
  68. package/docs/litellm-budget.md +55 -0
  69. package/docs/litellm.md +7 -6
  70. package/docs/postgres.md +22 -0
  71. package/docs/proxmox-sdk-migration.md +45 -0
  72. package/package.json +3 -3
  73. package/src/litellm/budget-errors.ts +38 -0
  74. package/src/litellm/budget-form.ts +94 -0
  75. package/src/litellm/budget-operations.ts +51 -0
  76. package/src/litellm/budget.test.ts +105 -0
  77. package/src/litellm/budget.ts +127 -0
  78. package/src/litellm/fake-litellm.ts +50 -1
  79. package/src/litellm/index.ts +13 -0
  80. package/src/litellm/operations.ts +1 -1
  81. package/src/litellm/providers.ts +4 -1
  82. package/src/openbao/auth-role-form.test.ts +37 -0
  83. package/src/openbao/auth-role-form.ts +12 -1
  84. package/src/openbao/auth-role-wire.test.ts +1 -0
  85. package/src/openbao/auth-role-wire.ts +13 -1
  86. package/src/openbao/distilled-retry.test.ts +1 -0
  87. package/src/openbao/fake-engines-roles.ts +7 -1
  88. package/src/postgres/connection.ts +37 -3
  89. package/src/postgres/database-sql.ts +4 -3
  90. package/src/postgres/database.ts +3 -2
  91. package/src/postgres/index.ts +10 -3
  92. package/src/postgres/providers.ts +15 -2
  93. package/src/postgres/psql-executor.test.ts +106 -0
  94. package/src/postgres/psql-executor.ts +130 -0
  95. package/src/proxmox/acl.ts +7 -1
  96. package/src/proxmox/api-token.ts +7 -1
  97. package/src/proxmox/ceph-flag-types.ts +70 -0
  98. package/src/proxmox/ceph-flag-wire.test.ts +72 -0
  99. package/src/proxmox/ceph-flag-wire.ts +123 -0
  100. package/src/proxmox/ceph-flag.ts +77 -130
  101. package/src/proxmox/ceph-osd-write.ts +10 -13
  102. package/src/proxmox/credential-errors.ts +19 -5
  103. package/src/proxmox/credentials.test.ts +73 -1
  104. package/src/proxmox/credentials.ts +72 -1
  105. package/src/proxmox/group.ts +7 -1
  106. package/src/proxmox/lease-cache-mint.test.ts +70 -0
  107. package/src/proxmox/lease-cache.test.ts +83 -7
  108. package/src/proxmox/lease-cache.ts +74 -10
  109. package/src/proxmox/mint.ts +12 -5
  110. package/src/proxmox/node-network.ts +7 -1
  111. package/src/proxmox/role.ts +7 -1
  112. package/src/proxmox/storage.ts +7 -1
  113. package/src/proxmox/unreadable-read.ts +16 -4
  114. package/src/proxmox/user.ts +7 -1
  115. package/src/proxmox/zfs-pool.ts +7 -1
@@ -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)));
@@ -40,6 +40,8 @@ export interface FakeLitellm {
40
40
  readonly fetch: typeof globalThis.fetch;
41
41
  readonly rows: () => readonly PassThroughGenericEndpoint[];
42
42
  readonly requests: () => readonly FakeRequest[];
43
+ /** `/budget/*` rows, wire-shaped (`tpm_limit`/`rpm_limit` as decimal strings, like the real table). */
44
+ readonly budgets: () => readonly Record<string, unknown>[];
43
45
  }
44
46
 
45
47
  const RACE_WINDOW_MS = 15;
@@ -53,11 +55,52 @@ export const startFakeLitellm = (options?: {
53
55
  readonly seed?: readonly PassThroughGenericEndpoint[];
54
56
  /** Every `DELETE` 400s `not_allowed_access`, whether or not the row exists — see the file header. */
55
57
  readonly forbidDelete?: boolean;
58
+ readonly budgetSeed?: readonly Record<string, unknown>[];
59
+ /** `/budget/update` drops `null`s instead of writing them (an `exclude_none` merge). */
60
+ readonly budgetExcludeNone?: boolean;
56
61
  }): FakeLitellm => {
57
62
  const masterKey = options?.masterKey ?? 'sk-test-master';
58
63
  const forbidDelete = options?.forbidDelete ?? false;
59
64
  let rows: PassThroughGenericEndpoint[] = [...(options?.seed ?? [])];
60
65
  const requests: FakeRequest[] = [];
66
+ let budgets: Record<string, unknown>[] = [...(options?.budgetSeed ?? [])];
67
+
68
+ /**
69
+ * ⚠️ Shapes from the generated 1.100.0 schema (`budget_management.ts`), not a live 1.103.0 read.
70
+ * Create of an existing id is 400 (a unique-key failure); update/delete of a missing id is 400
71
+ * (unmeasured, the SDK patch for these routes declares `BadRequest`).
72
+ */
73
+ const budgetRoute = async (request: Request, url: URL) => {
74
+ if (request.method === 'GET' && url.pathname === '/budget/list') return json(200, budgets);
75
+ const body = (await request.json()) as Record<string, unknown>;
76
+ const id = String(body['budget_id'] ?? body['id']);
77
+ const at = budgets.findIndex((row) => row['budget_id'] === id);
78
+ const wire = (fields: Record<string, unknown>) =>
79
+ Object.fromEntries(
80
+ Object.entries(fields).map(([k, v]) => [
81
+ k,
82
+ k.endsWith('_limit') && v !== null ? String(v) : v,
83
+ ]),
84
+ );
85
+ if (url.pathname === '/budget/new') {
86
+ if (at !== -1) return json(400, { detail: `budget ${id} exists` });
87
+ budgets = [...budgets, { created_at: 'now', updated_at: 'now', ...wire(body) }];
88
+ return json(200, {});
89
+ }
90
+ if (at === -1) return json(400, { detail: `budget ${id} not found` });
91
+ if (url.pathname === '/budget/update') {
92
+ const patch = options?.budgetExcludeNone
93
+ ? Object.fromEntries(Object.entries(body).filter(([, v]) => v !== null))
94
+ : body;
95
+ budgets = budgets.map((row, i) => (i === at ? { ...row, ...wire(patch) } : row));
96
+ return json(200, {});
97
+ }
98
+ if (url.pathname === '/budget/delete') {
99
+ budgets = budgets.filter((_, i) => i !== at);
100
+ return json(200, {});
101
+ }
102
+ return json(404, { detail: 'not found' });
103
+ };
61
104
 
62
105
  const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
63
106
  const request =
@@ -66,6 +109,7 @@ export const startFakeLitellm = (options?: {
66
109
  requests.push({ method: request.method, path: `${url.pathname}${url.search}` });
67
110
  const auth = request.headers.get('authorization');
68
111
  if (auth !== `Bearer ${masterKey}`) return json(401, { detail: 'invalid api key' });
112
+ if (url.pathname.startsWith('/budget/')) return budgetRoute(request, url);
69
113
  if (
70
114
  url.pathname !== '/config/pass_through_endpoint' &&
71
115
  !url.pathname.startsWith('/config/pass_through_endpoint/')
@@ -117,5 +161,10 @@ export const startFakeLitellm = (options?: {
117
161
  return json(405, { detail: `unhandled ${request.method} ${url.pathname}` });
118
162
  }) as typeof globalThis.fetch;
119
163
 
120
- return { fetch, requests: () => [...requests], rows: () => [...rows] };
164
+ return {
165
+ budgets: () => [...budgets],
166
+ fetch,
167
+ requests: () => [...requests],
168
+ rows: () => [...rows],
169
+ };
121
170
  };
@@ -25,4 +25,17 @@ export {
25
25
  type PassThroughEndpointError,
26
26
  type PassThroughEndpointProps,
27
27
  } from './pass-through-endpoint.ts';
28
+ export {
29
+ LiteLLMBudget,
30
+ LiteLLMBudgetProvider,
31
+ isLiteLLMBudget,
32
+ type BudgetAttributes,
33
+ type BudgetError,
34
+ type BudgetProps,
35
+ } from './budget.ts';
36
+ export {
37
+ LitellmBudgetFieldNotClearedError,
38
+ LitellmBudgetHardCapUnjustifiedError,
39
+ LitellmBudgetUnreadableError,
40
+ } from './budget-errors.ts';
28
41
  export { litellmProviders } from './providers.ts';
@@ -77,7 +77,7 @@ const semaphoreFor = (baseUrl: string): Effect.Effect<Semaphore.Semaphore> =>
77
77
  * `Fetch` service still wins inside that client, which is how the fake proxy stays the test
78
78
  * transport.
79
79
  */
80
- const throughFetch = <A, E, R>(io: Effect.Effect<A, E, R>) =>
80
+ export const throughFetch = <A, E, R>(io: Effect.Effect<A, E, R>) =>
81
81
  io.pipe(Effect.provide(FetchHttpClient.layer));
82
82
 
83
83
  const mutate = <A, E>(
@@ -20,7 +20,10 @@
20
20
  */
21
21
  import { type Credentials, CredentialsFromEnv } from '@distilled.cloud/litellm/Credentials';
22
22
  import * as Layer from 'effect/Layer';
23
+ import { LiteLLMBudgetProvider } from './budget.ts';
23
24
  import { LiteLLMPassThroughEndpointProvider } from './pass-through-endpoint.ts';
24
25
 
25
26
  export const litellmProviders = (creds: Layer.Layer<Credentials> = CredentialsFromEnv) =>
26
- LiteLLMPassThroughEndpointProvider().pipe(Layer.provideMerge(creds));
27
+ Layer.mergeAll(LiteLLMPassThroughEndpointProvider(), LiteLLMBudgetProvider()).pipe(
28
+ Layer.provideMerge(creds),
29
+ );
@@ -43,3 +43,40 @@ describe('auth role secret_id TTL', () => {
43
43
  expect(matches(attributesOf(PROPS, { ...live, secret_id_ttl: 0 }), PROPS)).toBe(false);
44
44
  });
45
45
  });
46
+
47
+ describe('auth role token_period', () => {
48
+ const LIVE = {
49
+ bind_secret_id: true,
50
+ secret_id_num_uses: 0,
51
+ secret_id_ttl: 7776000,
52
+ token_max_ttl: 3600,
53
+ token_period: 0,
54
+ token_policies: ['default', 'host-cert'],
55
+ token_ttl: 900,
56
+ };
57
+
58
+ it('omits token_period from the SDK write when the prop is unset', async () => {
59
+ expect((await Effect.runPromise(authRoleRequest(PROPS))).token_period).toBeUndefined();
60
+ });
61
+
62
+ it('writes 24h as the equivalent SDK seconds, never legacy period', async () => {
63
+ const props = { ...PROPS, tokenPeriod: '24h' };
64
+ const request = await Effect.runPromise(authRoleRequest(props));
65
+ expect(request.token_period).toBe(86400);
66
+ expect(request).not.toHaveProperty('period');
67
+ });
68
+
69
+ it('reads a live token_period back and matches an equivalent declaration', () => {
70
+ const props = { ...PROPS, tokenPeriod: '24h' };
71
+ const attributes = attributesOf(props, { ...LIVE, token_period: 86400 });
72
+ expect(attributes.tokenPeriod).toBe('1d');
73
+ expect(matches(attributes, props)).toBe(true);
74
+ expect(matches(attributesOf(props, { ...LIVE, token_period: 3600 }), props)).toBe(false);
75
+ });
76
+
77
+ it('leaves an unmanaged live token_period out of drift when the prop is unset', () => {
78
+ const attributes = attributesOf(PROPS, { ...LIVE, token_period: 86400 });
79
+ expect(attributes.tokenPeriod).toBe('1d');
80
+ expect(matches(attributes, PROPS)).toBe(true);
81
+ });
82
+ });
@@ -16,6 +16,14 @@ export interface BaoAuthRoleProps {
16
16
  bindSecretId?: boolean;
17
17
  /** `0` means unlimited uses — the shape reconcile.sh writes for headless roles. */
18
18
  secretIdNumUses?: number;
19
+ /**
20
+ * Token period, e.g. `24h` — set only for a periodic (forever-renewable) role, where
21
+ * `tokenTtl`/`tokenMaxTtl` are conventionally `'0'`. Omitted means this declaration does not
22
+ * manage the field: a live role's `token_period` (however it got set) is left untouched, the
23
+ * same way an omitted `bindSecretId` is. See house `openbao/approles.ts` for the role this
24
+ * unblocks — a periodic agent token that was inexpressible with these props.
25
+ */
26
+ tokenPeriod?: string;
19
27
  }
20
28
 
21
29
  export interface BaoAuthRoleAttributes {
@@ -26,6 +34,7 @@ export interface BaoAuthRoleAttributes {
26
34
  secretIdTtl: string;
27
35
  bindSecretId: boolean;
28
36
  secretIdNumUses: number;
37
+ tokenPeriod: string;
29
38
  /** SHA-256 of the managed fields — safe to persist; see policy.ts. */
30
39
  digest: string;
31
40
  }
@@ -64,6 +73,7 @@ export const attributesOf = (
64
73
  secretIdTtl: ttlText(ttlSeconds(live['secret_id_ttl']), '0'),
65
74
  bindSecretId: bool(live['bind_secret_id'], true),
66
75
  secretIdNumUses: int(live['secret_id_num_uses'], 0),
76
+ tokenPeriod: ttlText(ttlSeconds(live['token_period']), '0'),
67
77
  };
68
78
  return { ...attrs, digest: sha256(JSON.stringify(attrs)) };
69
79
  };
@@ -88,6 +98,7 @@ export const matches = (attributes: BaoAuthRoleAttributes, props: BaoAuthRolePro
88
98
  sameTtl(props.tokenMaxTtl, attributes.tokenMaxTtl) &&
89
99
  sameTtl(props.secretIdTtl, attributes.secretIdTtl) &&
90
100
  (props.bindSecretId === undefined || attributes.bindSecretId === props.bindSecretId) &&
91
- (props.secretIdNumUses === undefined || attributes.secretIdNumUses === props.secretIdNumUses)
101
+ (props.secretIdNumUses === undefined || attributes.secretIdNumUses === props.secretIdNumUses) &&
102
+ (props.tokenPeriod === undefined || sameTtl(props.tokenPeriod, attributes.tokenPeriod))
92
103
  );
93
104
  };
@@ -16,6 +16,7 @@ const DATA = {
16
16
  secret_id_num_uses: 0,
17
17
  secret_id_ttl: 7776000,
18
18
  token_max_ttl: 3600,
19
+ token_period: 0,
19
20
  token_policies: ['host-cert'],
20
21
  token_ttl: 900,
21
22
  };
@@ -35,6 +35,7 @@ const completeRole = (live: AppRoleReadRoleResponse): boolean =>
35
35
  seconds(live.secret_id_ttl) &&
36
36
  seconds(live.token_ttl) &&
37
37
  seconds(live.token_max_ttl) &&
38
+ seconds(live.token_period) &&
38
39
  Array.isArray(live.token_policies) &&
39
40
  live.token_policies.every((policy) => typeof policy === 'string');
40
41
 
@@ -56,7 +57,14 @@ export const authRoleRequest = (props: BaoAuthRoleProps) =>
56
57
  const tokenTtl = parseDuration(props.tokenTtl);
57
58
  const tokenMaxTtl = parseDuration(props.tokenMaxTtl);
58
59
  const secretIdTtl = parseDuration(props.secretIdTtl);
59
- if (!seconds(tokenTtl) || !seconds(tokenMaxTtl) || !seconds(secretIdTtl)) {
60
+ const tokenPeriod =
61
+ props.tokenPeriod === undefined ? undefined : parseDuration(props.tokenPeriod);
62
+ if (
63
+ !seconds(tokenTtl) ||
64
+ !seconds(tokenMaxTtl) ||
65
+ !seconds(secretIdTtl) ||
66
+ (props.tokenPeriod !== undefined && !seconds(tokenPeriod))
67
+ ) {
60
68
  return yield* Effect.fail(
61
69
  new BaoError(0, 'POST', readPath(props.name), ['invalid role TTL duration']),
62
70
  );
@@ -64,6 +72,9 @@ export const authRoleRequest = (props: BaoAuthRoleProps) =>
64
72
  // ★ The former CLI-style body used strings for every k=v. The SDK schema uses seconds,
65
73
  // booleans and arrays; omitted knobs stay omitted on update.
66
74
  // pathRoleCreateUpdate preserves absent bind_secret_id/secret_id_num_uses on existing roles.
75
+ // ⛔ token_period, not period — the vendor doc on both fields says period is legacy and loses
76
+ // to token_period when both are set (approle.ts AppRoleWriteRoleRequest); this Resource only
77
+ // ever emits the modern field.
67
78
  const request: AppRoleWriteRoleRequest = {
68
79
  ...identity(props.name),
69
80
  token_policies: [...props.tokenPolicies],
@@ -72,6 +83,7 @@ export const authRoleRequest = (props: BaoAuthRoleProps) =>
72
83
  secret_id_ttl: secretIdTtl,
73
84
  ...(props.bindSecretId === undefined ? {} : { bind_secret_id: props.bindSecretId }),
74
85
  ...(props.secretIdNumUses === undefined ? {} : { secret_id_num_uses: props.secretIdNumUses }),
86
+ ...(tokenPeriod === undefined ? {} : { token_period: tokenPeriod }),
75
87
  };
76
88
  return request;
77
89
  });
@@ -25,6 +25,7 @@ const ROLE = {
25
25
  secret_id_num_uses: 0,
26
26
  secret_id_ttl: 0,
27
27
  token_max_ttl: 3600,
28
+ token_period: 0,
28
29
  token_policies: ['default'],
29
30
  token_ttl: 900,
30
31
  };
@@ -57,7 +57,13 @@ export const wireRoles = (): Store =>
57
57
  store<Stored>(
58
58
  (path, body, before) =>
59
59
  path.startsWith('auth/approle/role/')
60
- ? { bind_secret_id: true, secret_id_num_uses: 0, ...before, ...typed(body) }
60
+ ? {
61
+ bind_secret_id: true,
62
+ secret_id_num_uses: 0,
63
+ token_period: 0,
64
+ ...before,
65
+ ...typed(body),
66
+ }
61
67
  : typed(body),
62
68
  (path) => keyOf(path),
63
69
  );
@@ -18,6 +18,8 @@ import * as Context from 'effect/Context';
18
18
  import * as Effect from 'effect/Effect';
19
19
  import * as Layer from 'effect/Layer';
20
20
  import type { SqlError } from 'effect/unstable/sql/SqlError';
21
+ import type { PgExecutor } from './database-sql.ts';
22
+ import { type PsqlRunner, makePsqlExecutor } from './psql-executor.ts';
21
23
 
22
24
  /** What a stack passes to reach one cluster. `host` may be a socket directory (one beginning
23
25
  * with `/` expands to `${host}/.s.PGSQL.${port}` — measured in
@@ -31,11 +33,27 @@ export interface PostgresConnectionConfig {
31
33
  readonly ssl?: boolean;
32
34
  }
33
35
 
36
+ /** The runner transport (`psql-executor.ts`): SQL goes through `run` instead of a socket, for a
37
+ * host whose Postgres is reachable only by a command (CT100: ssh + `podman exec`). `template`
38
+ * is the `CREATE DATABASE` template — `template0` by default, because CT100's `template1` is
39
+ * ParadeDB's and would copy postgis/pg_ivm/paradedb objects into every new database. */
40
+ export interface PostgresRunnerConfig {
41
+ readonly run: PsqlRunner;
42
+ readonly database: string;
43
+ readonly username: string;
44
+ readonly template?: string;
45
+ }
46
+
47
+ /** What `withPg`'s callback learns about the transport beyond the client. */
48
+ export interface PgContext {
49
+ readonly template?: string;
50
+ }
51
+
34
52
  /** The lazy connection service. Its value is an `Effect` of the config, per S24 — see the file
35
53
  * header for why. */
36
54
  export class PostgresConnection extends Context.Service<
37
55
  PostgresConnection,
38
- Effect.Effect<PostgresConnectionConfig>
56
+ Effect.Effect<PostgresConnectionConfig | PostgresRunnerConfig>
39
57
  >()('Postgres.Connection') {}
40
58
 
41
59
  /** A stack's one-line way to provide a cluster: `Layer.provide(postgresConnection({ … }))` on
@@ -44,6 +62,14 @@ export const postgresConnection = (
44
62
  config: PostgresConnectionConfig,
45
63
  ): Layer.Layer<PostgresConnection> => Layer.succeed(PostgresConnection, Effect.succeed(config));
46
64
 
65
+ /** A stack's one-line way to provide a cluster reached through a command runner. */
66
+ export const postgresRunnerConnection = (
67
+ config: PostgresRunnerConfig,
68
+ ): Layer.Layer<PostgresConnection> => Layer.succeed(PostgresConnection, Effect.succeed(config));
69
+
70
+ const isRunner = (c: PostgresConnectionConfig | PostgresRunnerConfig): c is PostgresRunnerConfig =>
71
+ 'run' in c;
72
+
47
73
  /**
48
74
  * Run one operation against a freshly built, freshly closed connection pool.
49
75
  *
@@ -53,10 +79,18 @@ export const postgresConnection = (
53
79
  * closes its own.
54
80
  */
55
81
  export const withPg = <A, E>(
56
- build: (pg: PgClient.PgClient) => Effect.Effect<A, E>,
82
+ build: (pg: PgClient.PgClient | PgExecutor, context: PgContext) => Effect.Effect<A, E>,
57
83
  ): Effect.Effect<A, E | SqlError, PostgresConnection> =>
58
84
  Effect.gen(function* () {
59
85
  const resolveConfig = yield* PostgresConnection;
60
86
  const config = yield* resolveConfig;
61
- return yield* Effect.provide(Effect.flatMap(PgClient.PgClient, build), PgClient.layer(config));
87
+ if (isRunner(config)) {
88
+ return yield* build(makePsqlExecutor(config.run, config), {
89
+ template: config.template ?? 'template0',
90
+ });
91
+ }
92
+ return yield* Effect.provide(
93
+ Effect.flatMap(PgClient.PgClient, (pg) => build(pg, {})),
94
+ PgClient.layer(config),
95
+ );
62
96
  });
@@ -47,8 +47,9 @@ export const quoteIdent = (value: string): string => `"${value.replace(/"/g, '""
47
47
  * value that is locale or encoding text, never for an identifier (see the file header). */
48
48
  export const quoteStringLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
49
49
 
50
- const withOptions = (props: PostgresDatabaseProps): string => {
50
+ const withOptions = (props: PostgresDatabaseProps, template?: string): string => {
51
51
  const parts: string[] = [`OWNER ${quoteIdent(props.owner)}`];
52
+ if (template !== undefined) parts.push(`TEMPLATE ${quoteIdent(template)}`);
52
53
  if (props.encoding !== undefined) parts.push(`ENCODING ${quoteStringLiteral(props.encoding)}`);
53
54
  if (props.localeProvider !== undefined) {
54
55
  parts.push(`LOCALE_PROVIDER ${quoteStringLiteral(props.localeProvider)}`);
@@ -74,8 +75,8 @@ const withOptions = (props: PostgresDatabaseProps): string => {
74
75
  * `Math.trunc`ed and stringified, and a boolean is one of exactly two literal words this file
75
76
  * writes, so no declared value reaches the statement through string interpolation unescaped.
76
77
  */
77
- export const buildCreateDatabaseSql = (props: PostgresDatabaseProps): string =>
78
- `CREATE DATABASE ${quoteIdent(props.name)} WITH ${withOptions(props)}`;
78
+ export const buildCreateDatabaseSql = (props: PostgresDatabaseProps, template?: string): string =>
79
+ `CREATE DATABASE ${quoteIdent(props.name)} WITH ${withOptions(props, template)}`;
79
80
 
80
81
  /** `42P04` (`duplicate_database`) is a race: something else created the name between our read
81
82
  * and our create. Measured at `internal/sqlError.ts@effect/sql-pg 4.0.0-rc.115`: every SQLSTATE
@@ -80,6 +80,7 @@ const liveDrift = (props: PostgresDatabaseProps, live: PostgresDatabaseAttribute
80
80
  export const reconcileWithClient = (
81
81
  pg: PgExecutor,
82
82
  props: PostgresDatabaseProps,
83
+ template?: string,
83
84
  ): Effect.Effect<
84
85
  PostgresDatabaseAttributes,
85
86
  PostgresDatabaseOwnerMissing | PostgresDatabaseDrift | PostgresDatabaseCreateVanished | SqlError
@@ -93,7 +94,7 @@ export const reconcileWithClient = (
93
94
  new PostgresDatabaseOwnerMissing({ database: props.name, owner: props.owner }),
94
95
  );
95
96
  }
96
- yield* pg.unsafe(buildCreateDatabaseSql(props)).pipe(
97
+ yield* pg.unsafe(buildCreateDatabaseSql(props, template)).pipe(
97
98
  Effect.asVoid,
98
99
  Effect.catchTag('SqlError', (error) =>
99
100
  isDuplicateDatabaseRace(error) ? Effect.succeed(undefined) : Effect.fail(error),
@@ -183,7 +184,7 @@ export const postgresDatabaseHandlers = PostgresDatabase.Provider.of({
183
184
  new PostgresDatabaseNameRefused({ name: news.name, ...nameRefusal }),
184
185
  );
185
186
  }
186
- return yield* withPg((pg) => reconcileWithClient(pg, news));
187
+ return yield* withPg((pg, ctx) => reconcileWithClient(pg, news, ctx.template));
187
188
  }),
188
189
 
189
190
  delete: ({ olds }) => Effect.fail(new PostgresDatabaseDropRefused({ database: olds.name })),
@@ -5,8 +5,15 @@
5
5
  * ⛔ THIS BARREL IS THE PUBLIC API, deliberately smaller than the directory: `fake-sql.ts` is a
6
6
  * test double, not something a consuming stack should import.
7
7
  */
8
- export type { PostgresConnectionConfig } from './connection.ts';
9
- export { PostgresConnection, postgresConnection, withPg } from './connection.ts';
8
+ export type { PostgresConnectionConfig, PostgresRunnerConfig } from './connection.ts';
9
+ export {
10
+ PostgresConnection,
11
+ postgresConnection,
12
+ postgresRunnerConnection,
13
+ withPg,
14
+ } from './connection.ts';
15
+ export type { PsqlResult, PsqlRunner } from './psql-executor.ts';
16
+ export { inlineParams, makePsqlExecutor } from './psql-executor.ts';
10
17
  export type { PostgresDatabaseAttributes, PostgresDatabaseProps } from './database-attrs.ts';
11
18
  export { POSTGRES_NAME_MAX_BYTES, nameByteRefusal, utf8ByteLength } from './database-attrs.ts';
12
19
  export {
@@ -27,4 +34,4 @@ export {
27
34
  PostgresDatabaseRenameRefused,
28
35
  type PostgresDatabaseError,
29
36
  } from './errors.ts';
30
- export { postgresProviders } from './providers.ts';
37
+ export { postgresProviders, postgresRunnerProviders } from './providers.ts';