@homeflare/alchemy 0.23.0 → 0.25.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 (177) hide show
  1. package/README.md +25 -1
  2. package/dist/litellm/client.d.ts +76 -0
  3. package/dist/litellm/client.d.ts.map +1 -0
  4. package/dist/litellm/credentials.d.ts +48 -0
  5. package/dist/litellm/credentials.d.ts.map +1 -0
  6. package/dist/litellm/fake-litellm.d.ts +36 -0
  7. package/dist/litellm/fake-litellm.d.ts.map +1 -0
  8. package/dist/litellm/fake-stack.d.ts +30 -0
  9. package/dist/litellm/fake-stack.d.ts.map +1 -0
  10. package/dist/litellm/generated/pass-through.d.ts +71 -0
  11. package/dist/litellm/generated/pass-through.d.ts.map +1 -0
  12. package/dist/litellm/index.d.ts +13 -0
  13. package/dist/litellm/index.d.ts.map +1 -0
  14. package/dist/litellm/index.js +270 -0
  15. package/dist/litellm/index.js.map +14 -0
  16. package/dist/litellm/pass-through-endpoint.d.ts +94 -0
  17. package/dist/litellm/pass-through-endpoint.d.ts.map +1 -0
  18. package/dist/litellm/pass-through-form.d.ts +40 -0
  19. package/dist/litellm/pass-through-form.d.ts.map +1 -0
  20. package/dist/litellm/providers.d.ts +16 -0
  21. package/dist/litellm/providers.d.ts.map +1 -0
  22. package/dist/paperless/client.d.ts +37 -0
  23. package/dist/paperless/client.d.ts.map +1 -0
  24. package/dist/paperless/constraint-guard.d.ts +19 -0
  25. package/dist/paperless/constraint-guard.d.ts.map +1 -0
  26. package/dist/paperless/constraints.d.ts +50 -0
  27. package/dist/paperless/constraints.d.ts.map +1 -0
  28. package/dist/paperless/credentials.d.ts +40 -0
  29. package/dist/paperless/credentials.d.ts.map +1 -0
  30. package/dist/paperless/custom-field.d.ts +75 -0
  31. package/dist/paperless/custom-field.d.ts.map +1 -0
  32. package/dist/paperless/document-type.d.ts +30 -0
  33. package/dist/paperless/document-type.d.ts.map +1 -0
  34. package/dist/paperless/errors.d.ts +49 -0
  35. package/dist/paperless/errors.d.ts.map +1 -0
  36. package/dist/paperless/fake-paperless.d.ts +39 -0
  37. package/dist/paperless/fake-paperless.d.ts.map +1 -0
  38. package/dist/paperless/generated/constraints/index.d.ts +13 -0
  39. package/dist/paperless/generated/constraints/index.d.ts.map +1 -0
  40. package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts +21 -0
  41. package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts.map +1 -0
  42. package/dist/paperless/generated/constraints/paperless-document_types.d.ts +21 -0
  43. package/dist/paperless/generated/constraints/paperless-document_types.d.ts.map +1 -0
  44. package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts +21 -0
  45. package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts.map +1 -0
  46. package/dist/paperless/generated/constraints/paperless-tags.d.ts +21 -0
  47. package/dist/paperless/generated/constraints/paperless-tags.d.ts.map +1 -0
  48. package/dist/paperless/generated/types/custom-fields.d.ts +23 -0
  49. package/dist/paperless/generated/types/custom-fields.d.ts.map +1 -0
  50. package/dist/paperless/generated/types/document-types.d.ts +39 -0
  51. package/dist/paperless/generated/types/document-types.d.ts.map +1 -0
  52. package/dist/paperless/generated/types/index.d.ts +10 -0
  53. package/dist/paperless/generated/types/index.d.ts.map +1 -0
  54. package/dist/paperless/generated/types/storage-paths.d.ts +31 -0
  55. package/dist/paperless/generated/types/storage-paths.d.ts.map +1 -0
  56. package/dist/paperless/generated/types/tags.d.ts +37 -0
  57. package/dist/paperless/generated/types/tags.d.ts.map +1 -0
  58. package/dist/paperless/index.d.ts +19 -0
  59. package/dist/paperless/index.d.ts.map +1 -0
  60. package/dist/paperless/index.js +524 -0
  61. package/dist/paperless/index.js.map +26 -0
  62. package/dist/paperless/matching-locate.d.ts +33 -0
  63. package/dist/paperless/matching-locate.d.ts.map +1 -0
  64. package/dist/paperless/matching-types.d.ts +60 -0
  65. package/dist/paperless/matching-types.d.ts.map +1 -0
  66. package/dist/paperless/matching.d.ts +61 -0
  67. package/dist/paperless/matching.d.ts.map +1 -0
  68. package/dist/paperless/providers.d.ts +15 -0
  69. package/dist/paperless/providers.d.ts.map +1 -0
  70. package/dist/paperless/storage-path.d.ts +32 -0
  71. package/dist/paperless/storage-path.d.ts.map +1 -0
  72. package/dist/paperless/tag.d.ts +47 -0
  73. package/dist/paperless/tag.d.ts.map +1 -0
  74. package/dist/postgres/connection.d.ts +50 -0
  75. package/dist/postgres/connection.d.ts.map +1 -0
  76. package/dist/postgres/database-attrs.d.ts +79 -0
  77. package/dist/postgres/database-attrs.d.ts.map +1 -0
  78. package/dist/postgres/database-sql.d.ts +73 -0
  79. package/dist/postgres/database-sql.d.ts.map +1 -0
  80. package/dist/postgres/database.d.ts +63 -0
  81. package/dist/postgres/database.d.ts.map +1 -0
  82. package/dist/postgres/errors.d.ts +86 -0
  83. package/dist/postgres/errors.d.ts.map +1 -0
  84. package/dist/postgres/fake-sql.d.ts +22 -0
  85. package/dist/postgres/fake-sql.d.ts.map +1 -0
  86. package/dist/postgres/index.d.ts +16 -0
  87. package/dist/postgres/index.d.ts.map +1 -0
  88. package/dist/postgres/index.js +238 -0
  89. package/dist/postgres/index.js.map +15 -0
  90. package/dist/postgres/providers.d.ts +14 -0
  91. package/dist/postgres/providers.d.ts.map +1 -0
  92. package/dist/proxmox/index.js.map +2 -2
  93. package/dist/proxmox/zfs-pool.d.ts +10 -2
  94. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  95. package/dist/release/archive.d.ts +6 -2
  96. package/dist/release/archive.d.ts.map +1 -1
  97. package/dist/release/binary-form.d.ts +14 -9
  98. package/dist/release/binary-form.d.ts.map +1 -1
  99. package/dist/release/binary-pins.d.ts +17 -0
  100. package/dist/release/binary-pins.d.ts.map +1 -0
  101. package/dist/release/catalog.d.ts.map +1 -1
  102. package/dist/release/index.js +90 -40
  103. package/dist/release/index.js.map +8 -7
  104. package/dist/release/tar.d.ts +9 -2
  105. package/dist/release/tar.d.ts.map +1 -1
  106. package/docs/litellm.md +110 -0
  107. package/docs/paperless.md +115 -0
  108. package/docs/peers.md +8 -1
  109. package/docs/postgres.md +125 -0
  110. package/docs/release-binary-catalogs.md +40 -4
  111. package/docs/release-binary-refusals.md +22 -8
  112. package/docs/release-binary-upstream.md +33 -15
  113. package/package.json +14 -1
  114. package/src/litellm/client.ts +195 -0
  115. package/src/litellm/credentials.ts +73 -0
  116. package/src/litellm/fake-litellm.ts +114 -0
  117. package/src/litellm/fake-stack.ts +100 -0
  118. package/src/litellm/generated/pass-through.ts +73 -0
  119. package/src/litellm/index.ts +42 -0
  120. package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +66 -0
  121. package/src/litellm/pass-through-endpoint-replace.test.ts +84 -0
  122. package/src/litellm/pass-through-endpoint.test.ts +208 -0
  123. package/src/litellm/pass-through-endpoint.ts +258 -0
  124. package/src/litellm/pass-through-form.test.ts +128 -0
  125. package/src/litellm/pass-through-form.ts +136 -0
  126. package/src/litellm/providers.ts +19 -0
  127. package/src/paperless/client.ts +88 -0
  128. package/src/paperless/constraint-guard.ts +50 -0
  129. package/src/paperless/constraints.test.ts +74 -0
  130. package/src/paperless/constraints.ts +111 -0
  131. package/src/paperless/credentials.ts +69 -0
  132. package/src/paperless/custom-field.test.ts +74 -0
  133. package/src/paperless/custom-field.ts +95 -0
  134. package/src/paperless/document-type.ts +74 -0
  135. package/src/paperless/errors.ts +62 -0
  136. package/src/paperless/fake-paperless.ts +156 -0
  137. package/src/paperless/generated/constraints/index.ts +23 -0
  138. package/src/paperless/generated/constraints/paperless-custom_fields.ts +29 -0
  139. package/src/paperless/generated/constraints/paperless-document_types.ts +32 -0
  140. package/src/paperless/generated/constraints/paperless-storage_paths.ts +34 -0
  141. package/src/paperless/generated/constraints/paperless-tags.ts +34 -0
  142. package/src/paperless/generated/types/custom-fields.ts +23 -0
  143. package/src/paperless/generated/types/document-types.ts +30 -0
  144. package/src/paperless/generated/types/index.ts +9 -0
  145. package/src/paperless/generated/types/storage-paths.ts +31 -0
  146. package/src/paperless/generated/types/tags.ts +37 -0
  147. package/src/paperless/index.ts +56 -0
  148. package/src/paperless/matching-identity.test.ts +194 -0
  149. package/src/paperless/matching-locate.ts +104 -0
  150. package/src/paperless/matching-types.ts +61 -0
  151. package/src/paperless/matching.test.ts +128 -0
  152. package/src/paperless/matching.ts +239 -0
  153. package/src/paperless/providers.ts +27 -0
  154. package/src/paperless/storage-path.ts +78 -0
  155. package/src/paperless/tag.ts +91 -0
  156. package/src/postgres/connection.ts +62 -0
  157. package/src/postgres/database-attrs.ts +87 -0
  158. package/src/postgres/database-bigint-serialization.test.ts +140 -0
  159. package/src/postgres/database-lifecycle.test.ts +113 -0
  160. package/src/postgres/database-provenance.test.ts +118 -0
  161. package/src/postgres/database-refusals.test.ts +135 -0
  162. package/src/postgres/database-sql.ts +174 -0
  163. package/src/postgres/database.ts +193 -0
  164. package/src/postgres/errors.ts +131 -0
  165. package/src/postgres/fake-sql.ts +123 -0
  166. package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +26 -0
  167. package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +67 -0
  168. package/src/postgres/index.ts +30 -0
  169. package/src/postgres/providers.ts +16 -0
  170. package/src/proxmox/zfs-pool.ts +10 -2
  171. package/src/release/archive.ts +10 -4
  172. package/src/release/binary-form.ts +24 -76
  173. package/src/release/binary-pins.ts +95 -0
  174. package/src/release/binary-root.test.ts +122 -0
  175. package/src/release/catalog.ts +10 -2
  176. package/src/release/tar-root.test.ts +114 -0
  177. package/src/release/tar.ts +78 -12
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `LiteLLM.PassThroughEndpoint`'s `replace` (clearing `timeout`/`methods`/`guardrails`), through
3
+ * Alchemy's own `Plan.make`/`apply` (fake-stack.ts) — not the handlers called directly.
4
+ *
5
+ * ⛔ BUG: before this file's fix, `diff` answered `{ action: 'replace' }` with no `deleteFirst`.
6
+ * Per `Apply.ts`, that is CREATE-FIRST: the engine mints a fresh `instanceId` and calls
7
+ * `reconcile` for the new generation with `output: undefined, olds: undefined`, deleting the old
8
+ * generation only afterwards, by a SEPARATE `provider.delete()` call under the OLD instanceId.
9
+ * Since `objectId` is derived from `instanceId` (`physicalIdOf`/`createPhysicalName`), the new
10
+ * generation's id differs from the still-live old row on the same `path`. `reconcile`'s own
11
+ * `locate()` then finds that old row as a `conflict` on `path` (not `mine`, since the id differs)
12
+ * and refuses with `LitellmUnaddressableRowError` — misidentifying its own prior generation as a
13
+ * foreign, unaddressable row. Every real deploy that clears one of the three nullable fields
14
+ * failed outright. `pass-through-endpoint.test.ts`'s own 'clearing a nullable field' test never
15
+ * caught this: it calls `h.reconcile` directly with the SAME instanceId and the OLD output, a
16
+ * call shape the real engine never produces for a `replace`.
17
+ */
18
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
19
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
20
+ import * as Effect from 'effect/Effect';
21
+ import { type FakeStack, fakeStack, writesOf } from './fake-stack.ts';
22
+ import { type FakeLitellm, startFakeLitellm } from './fake-litellm.ts';
23
+ import { LiteLLMPassThroughEndpoint } from './pass-through-endpoint.ts';
24
+ import type { PassThroughEndpointProps } from './pass-through-form.ts';
25
+
26
+ const MASTER_KEY = 'sk-test-master';
27
+
28
+ let fake: FakeLitellm;
29
+ let stack: FakeStack;
30
+ beforeEach(() => {
31
+ fake = startFakeLitellm({ masterKey: MASTER_KEY });
32
+ stack = fakeStack({ apiKey: MASTER_KEY, baseUrl: fake.url });
33
+ });
34
+ afterEach(() => fake.stop());
35
+
36
+ /** ★ `RemovalPolicy.destroy()`, deliberately: under the (non-default) `retain`, `deleteFirst`'s own
37
+ * old-generation delete is skipped by the engine (Apply.ts's `deleteOldGenerations`), so a path
38
+ * that cannot hold two rows stays conflicted either way — a documented engine limitation
39
+ * (openbao/REPLACE.md), not this provider's bug, and out of scope for this fix. */
40
+ const declare = (props: PassThroughEndpointProps) =>
41
+ Effect.asVoid(LiteLLMPassThroughEndpoint('Endpoint', props).pipe(RemovalPolicy.destroy()));
42
+
43
+ describe('clearing a nullable field, through the engine', () => {
44
+ test('plans replace and leaves exactly one live row on the path, never two, never zero', async () => {
45
+ const withTimeout: PassThroughEndpointProps = {
46
+ path: '/bria',
47
+ target: 'https://api.bria.ai',
48
+ timeout: 30,
49
+ };
50
+ const cleared: PassThroughEndpointProps = { path: '/bria', target: 'https://api.bria.ai' };
51
+
52
+ expect(await stack.deploy(declare(withTimeout))).toEqual({ Endpoint: 'create' });
53
+ const oldId = fake.rows().find((r) => r.path === '/bria')?.id;
54
+ expect(oldId).toBeDefined();
55
+
56
+ const planned = await stack.deploy(declare(cleared));
57
+
58
+ expect(planned).toEqual({ Endpoint: 'replace' });
59
+ const onPath = fake.rows().filter((r) => r.path === '/bria');
60
+ expect(onPath).toHaveLength(1);
61
+ expect(onPath[0]?.timeout).toBeUndefined();
62
+ // ★ deleteFirst: the old generation's id is gone; the surviving row is a NEW generation, not
63
+ // the old row merely left with a stale `timeout` because the write silently no-op'd.
64
+ expect(onPath[0]?.id).not.toBe(oldId);
65
+ });
66
+
67
+ test('deletes the old generation before creating the new one on the same path', async () => {
68
+ const withTimeout: PassThroughEndpointProps = {
69
+ path: '/bria',
70
+ target: 'https://api.bria.ai',
71
+ timeout: 30,
72
+ };
73
+ const cleared: PassThroughEndpointProps = { path: '/bria', target: 'https://api.bria.ai' };
74
+ await stack.deploy(declare(withTimeout));
75
+ const before = fake.requests().length;
76
+
77
+ await stack.deploy(declare(cleared));
78
+
79
+ const writes = writesOf(fake.requests().slice(before));
80
+ // create-first would be [POST, DELETE]; a `path` that cannot hold two rows needs delete-first.
81
+ expect(writes[0]?.startsWith('DELETE')).toBe(true);
82
+ expect(writes.some((w) => w.startsWith('POST /config/pass_through_endpoint'))).toBe(true);
83
+ });
84
+ });
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `LiteLLM.PassThroughEndpoint`'s lifecycle handlers, called directly against `fake-litellm.ts` —
3
+ * the loopback-fake pattern this kit uses in place of `alchemy/Test/*` (H12, no live writes).
4
+ */
5
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
6
+ import { Unowned } from 'alchemy/AdoptPolicy';
7
+ import { Stack } from 'alchemy/Stack';
8
+ import { Stage } from 'alchemy/Stage';
9
+ import * as Effect from 'effect/Effect';
10
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
11
+ import { litellmCredentialsLayerFor } from './credentials.ts';
12
+ import { type FakeLitellm, startFakeLitellm } from './fake-litellm.ts';
13
+ import { createPassThroughEndpoint } from './client.ts';
14
+ import {
15
+ LitellmConfigPathConflictError,
16
+ LitellmLiteralSecretHeaderError,
17
+ type PassThroughEndpointProps,
18
+ passThroughHandlers as h,
19
+ } from './pass-through-endpoint.ts';
20
+
21
+ const MASTER_KEY = 'sk-test-master';
22
+ const INSTANCE_ID = 'a'.repeat(32);
23
+ const FAKE_STACK = { actions: {}, bindings: {}, name: 's', resources: {}, stage: 'test' } as never;
24
+
25
+ let fake: FakeLitellm;
26
+ beforeEach(() => {
27
+ fake = startFakeLitellm({ masterKey: MASTER_KEY });
28
+ });
29
+ afterEach(() => fake.stop());
30
+
31
+ const run = <A, E>(effect: Effect.Effect<A, E, never>): Promise<A> =>
32
+ Effect.runPromise(effect as Effect.Effect<A, E, never>);
33
+
34
+ const withServices = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
35
+ effect.pipe(
36
+ Effect.provide(FetchHttpClient.layer),
37
+ Effect.provide(litellmCredentialsLayerFor({ apiKey: MASTER_KEY, baseUrl: fake.url })),
38
+ Effect.provideService(Stage, 'test'),
39
+ Effect.provideService(Stack, FAKE_STACK),
40
+ ) as Effect.Effect<A, E, never>;
41
+
42
+ const PROPS: PassThroughEndpointProps = { path: '/bria', target: 'https://api.bria.ai' };
43
+ const reconcileNew = (news: PassThroughEndpointProps) =>
44
+ run(
45
+ withServices(h.reconcile({ id: 'Endpoint', instanceId: INSTANCE_ID, news, output: undefined })),
46
+ );
47
+
48
+ describe('create then plan again', () => {
49
+ test('a second read/diff against the same declaration is noop, with no write', async () => {
50
+ const attrs = await reconcileNew(PROPS);
51
+ const before = fake.requests().length;
52
+ const read = await run(
53
+ withServices(h.read({ id: 'Endpoint', instanceId: INSTANCE_ID, olds: PROPS, output: attrs })),
54
+ );
55
+ const diff = await run(withServices(h.diff({ news: PROPS, output: read })));
56
+ expect(diff).toEqual({ action: 'noop' });
57
+ expect(
58
+ fake
59
+ .requests()
60
+ .slice(before)
61
+ .some((r) => r.method !== 'GET'),
62
+ ).toBe(false);
63
+ });
64
+ });
65
+
66
+ describe('a header edit', () => {
67
+ test('is one POST /{id} carrying the whole headers dict', async () => {
68
+ const attrs = await reconcileNew(PROPS);
69
+ const before = fake.requests().length;
70
+ const news = { ...PROPS, headers: { 'X-Client': 'v2' } };
71
+ await run(
72
+ withServices(h.reconcile({ id: 'Endpoint', instanceId: INSTANCE_ID, news, output: attrs })),
73
+ );
74
+ const writes = fake
75
+ .requests()
76
+ .slice(before)
77
+ .filter((r) => r.method !== 'GET');
78
+ expect(writes).toEqual([{ method: 'POST', path: `/config/pass_through_endpoint/${attrs.id}` }]);
79
+ expect(fake.rows().find((r) => r.id === attrs.id)?.headers).toEqual({ 'X-Client': 'v2' });
80
+ });
81
+ });
82
+
83
+ describe('clearing a nullable field', () => {
84
+ test('is a replace: delete before create, never two rows on one path', async () => {
85
+ const attrs = await reconcileNew({ ...PROPS, timeout: 30 });
86
+ const oldId = attrs.id;
87
+ const before = fake.requests().length;
88
+ const after = await run(
89
+ withServices(
90
+ h.reconcile({ id: 'Endpoint', instanceId: INSTANCE_ID, news: PROPS, output: attrs }),
91
+ ),
92
+ );
93
+ const writes = fake
94
+ .requests()
95
+ .slice(before)
96
+ .filter((r) => r.method !== 'GET')
97
+ .map((r) => r.method);
98
+ expect(writes).toEqual(['DELETE', 'POST']);
99
+ expect(fake.rows().filter((r) => r.path === '/bria')).toHaveLength(1);
100
+ expect(after.id).toBe(oldId); // same deterministic id, recreated
101
+ expect(after.timeout).toBeUndefined();
102
+ });
103
+ });
104
+
105
+ describe('delete', () => {
106
+ test('an already-gone id succeeds with no DELETE call', async () => {
107
+ const attrs = await reconcileNew(PROPS);
108
+ await run(
109
+ withServices(
110
+ h.delete({ id: 'Endpoint', instanceId: INSTANCE_ID, olds: PROPS, output: attrs }),
111
+ ),
112
+ );
113
+ const before = fake.requests().length;
114
+ await run(
115
+ withServices(
116
+ h.delete({ id: 'Endpoint', instanceId: INSTANCE_ID, olds: PROPS, output: attrs }),
117
+ ),
118
+ );
119
+ expect(
120
+ fake
121
+ .requests()
122
+ .slice(before)
123
+ .some((r) => r.method === 'DELETE'),
124
+ ).toBe(false);
125
+ });
126
+ });
127
+
128
+ describe('two endpoints created in one deploy', () => {
129
+ /** ⛔ Fails without client.ts's per-base-URL semaphore — the fake's race window (RACE_WINDOW_MS) proves it. */
130
+ test('both survive the shared general_settings field', async () => {
131
+ const a: PassThroughEndpointProps = { path: '/a', target: 'https://a.example.com' };
132
+ const b: PassThroughEndpointProps = { path: '/b', target: 'https://b.example.com' };
133
+ await Effect.runPromise(
134
+ Effect.all(
135
+ [
136
+ withServices(createPassThroughEndpoint({ ...a, id: 'ep-a' })),
137
+ withServices(createPassThroughEndpoint({ ...b, id: 'ep-b' })),
138
+ ],
139
+ { concurrency: 'unbounded' },
140
+ ),
141
+ );
142
+ expect(
143
+ fake
144
+ .rows()
145
+ .map((r) => r.path)
146
+ .sort(),
147
+ ).toEqual(['/a', '/b']);
148
+ });
149
+ });
150
+
151
+ describe('a path held by an is_from_config row', () => {
152
+ test('is refused at read, before any write', async () => {
153
+ fake.stop();
154
+ fake = startFakeLitellm({
155
+ masterKey: MASTER_KEY,
156
+ seed: [{ ...PROPS, id: 'cfg-1', is_from_config: true }],
157
+ });
158
+ const result = run(
159
+ withServices(
160
+ h.read({ id: 'Endpoint', instanceId: INSTANCE_ID, olds: PROPS, output: undefined }),
161
+ ),
162
+ );
163
+ await expect(result).rejects.toThrow(LitellmConfigPathConflictError as never);
164
+ expect(fake.requests().every((r) => r.method === 'GET')).toBe(true);
165
+ });
166
+ });
167
+
168
+ describe('a foreign DB row on the same path', () => {
169
+ test('is Unowned, and read works with output undefined', async () => {
170
+ fake.stop();
171
+ fake = startFakeLitellm({
172
+ masterKey: MASTER_KEY,
173
+ seed: [{ ...PROPS, id: 'foreign-1', is_from_config: false }],
174
+ });
175
+ const result = await run(
176
+ withServices(
177
+ h.read({ id: 'Endpoint', instanceId: INSTANCE_ID, olds: PROPS, output: undefined }),
178
+ ),
179
+ );
180
+ expect(Unowned.is(result)).toBe(true);
181
+ expect(result?.id).toBe('foreign-1');
182
+ });
183
+ });
184
+
185
+ describe('a literal secret header', () => {
186
+ test('is refused at plan (diff), before any write', async () => {
187
+ const news = { ...PROPS, headers: { Authorization: 'Bearer sk-live-literal' } };
188
+ const result = run(withServices(h.diff({ news, output: undefined })));
189
+ await expect(result).rejects.toThrow(LitellmLiteralSecretHeaderError as never);
190
+ });
191
+
192
+ test('an os.environ/ reference is accepted', async () => {
193
+ const news = { ...PROPS, headers: { Authorization: 'os.environ/UPSTREAM_KEY' } };
194
+ const diff = await run(withServices(h.diff({ news, output: undefined })));
195
+ expect(diff).toBeUndefined(); // output undefined -> the engine's default create
196
+ });
197
+ });
198
+
199
+ describe('read', () => {
200
+ test('with output undefined and nothing live, answers undefined (create)', async () => {
201
+ const result = await run(
202
+ withServices(
203
+ h.read({ id: 'Endpoint', instanceId: INSTANCE_ID, olds: PROPS, output: undefined }),
204
+ ),
205
+ );
206
+ expect(result).toBeUndefined();
207
+ });
208
+ });
@@ -0,0 +1,258 @@
1
+ /**
2
+ * `LiteLLM.PassThroughEndpoint` — one row in LiteLLM's `general_settings.pass_through_endpoints`.
3
+ *
4
+ * ★ ADOPT-FRIENDLY BY id, NOT BY PATH. `output?.id` is used as the object's live id once the
5
+ * engine has one — from our own prior create (the deterministic physical name) or from an
6
+ * adopted foreign row — and `read` computes a FRESH physical name only for a resource the
7
+ * engine has never resolved before. This is S7/S9's "output is a cache of stable identifiers,
8
+ * not proof the object exists" and S13's "an adopted object keeps its live name as an explicit
9
+ * prop", applied to an id LiteLLM lets a client choose (`_types.py:2192` at v1.100.0: create
10
+ * takes a caller-supplied `id`, else `uuid4()`).
11
+ *
12
+ * ⛔ A PATH HELD BY AN `is_from_config` ROW IS REFUSED, NOT ADOPTED. A DB row on the same path
13
+ * overrides it at runtime (`pass_through_endpoints.py` merges DB after config, at the tag) — so
14
+ * creating one would silently take over a route the operator declared in `config.yaml`, the
15
+ * exact "DB row silently overrides the file" the task names. `is_from_config` rows are also
16
+ * never updated or deleted by this provider, for the same reason.
17
+ * ⛔ A DB ROW WITH NO `id` (path-only, `_types.py`'s "backwards compatibility" case) CANNOT BE
18
+ * ADDRESSED by the id-keyed update/delete calls this provider uses, so it is refused rather than
19
+ * silently adopted into an id-based lifecycle it cannot actually manage — see docs/litellm.md.
20
+ * ⛔ NO CREDENTIAL IS A PROP (S25). `LITELLM_PROXY_URL`/`LITELLM_PROXY_API_KEY` are read at call
21
+ * time by credentials.ts.
22
+ */
23
+ import { Resource } from 'alchemy';
24
+ import { Unowned } from 'alchemy/AdoptPolicy';
25
+ import { isResolved } from 'alchemy/Diff';
26
+ import type { Input } from 'alchemy/Input';
27
+ import { createPhysicalName } from 'alchemy/PhysicalName';
28
+ import * as Provider from 'alchemy/Provider';
29
+ import type { Stack } from 'alchemy/Stack';
30
+ import type { Stage } from 'alchemy/Stage';
31
+ import * as Data from 'effect/Data';
32
+ import * as Effect from 'effect/Effect';
33
+ import * as Predicate from 'effect/Predicate';
34
+ import {
35
+ type LitellmError,
36
+ type LitellmRequirements,
37
+ createPassThroughEndpoint,
38
+ deletePassThroughEndpoint,
39
+ listPassThroughEndpoints,
40
+ updatePassThroughEndpoint,
41
+ } from './client.ts';
42
+ import type { PassThroughGenericEndpoint } from './generated/pass-through.ts';
43
+ import {
44
+ type PassThroughEndpointAttributes,
45
+ type PassThroughEndpointProps,
46
+ createBody,
47
+ literalSecretHeaders,
48
+ matches,
49
+ needsReplace,
50
+ updateBody,
51
+ } from './pass-through-form.ts';
52
+
53
+ export type { PassThroughEndpointAttributes, PassThroughEndpointProps };
54
+
55
+ export class LitellmConfigPathConflictError extends Data.TaggedError(
56
+ 'LitellmConfigPathConflictError',
57
+ )<{
58
+ readonly path: string;
59
+ }> {}
60
+ export class LitellmUnaddressableRowError extends Data.TaggedError('LitellmUnaddressableRowError')<{
61
+ readonly path: string;
62
+ }> {}
63
+ export class LitellmLiteralSecretHeaderError extends Data.TaggedError(
64
+ 'LitellmLiteralSecretHeaderError',
65
+ )<{
66
+ readonly headers: readonly string[];
67
+ }> {}
68
+
69
+ export type PassThroughEndpointError =
70
+ | LitellmError
71
+ | LitellmConfigPathConflictError
72
+ | LitellmUnaddressableRowError
73
+ | LitellmLiteralSecretHeaderError;
74
+
75
+ export interface LiteLLMPassThroughEndpoint extends Resource<
76
+ 'LiteLLM.PassThroughEndpoint',
77
+ PassThroughEndpointProps,
78
+ PassThroughEndpointAttributes,
79
+ never,
80
+ LitellmRequirements | Stack | Stage
81
+ > {}
82
+
83
+ export const LiteLLMPassThroughEndpoint = Resource<LiteLLMPassThroughEndpoint>(
84
+ 'LiteLLM.PassThroughEndpoint',
85
+ );
86
+
87
+ export const isLiteLLMPassThroughEndpoint = (value: unknown): value is LiteLLMPassThroughEndpoint =>
88
+ Predicate.hasProperty(value, 'Type') && value.Type === 'LiteLLM.PassThroughEndpoint';
89
+
90
+ const toAttributes = (
91
+ row: PassThroughGenericEndpoint,
92
+ fallbackId: string,
93
+ ): PassThroughEndpointAttributes => ({
94
+ ...row,
95
+ id: row.id ?? fallbackId,
96
+ });
97
+
98
+ const physicalIdOf = (logicalId: string, instanceId: string) =>
99
+ createPhysicalName({ id: logicalId, instanceId, lowercase: true, maxLength: 64 });
100
+
101
+ /**
102
+ * Find this resource's live row (by id) and, when absent, the row that already holds its `path`
103
+ * (a config-file row to refuse, or a DB row LiteLLM did not give an id — also refused, see the
104
+ * file header — or a genuine foreign DB row to hand back `Unowned`).
105
+ */
106
+ const locate = (
107
+ rows: readonly PassThroughGenericEndpoint[],
108
+ objectId: string,
109
+ path: string,
110
+ ): {
111
+ readonly mine: PassThroughGenericEndpoint | undefined;
112
+ readonly conflict: PassThroughGenericEndpoint | undefined;
113
+ } => {
114
+ const mine = rows.find((row) => row.id === objectId);
115
+ const conflict = mine === undefined ? rows.find((row) => row.path === path) : undefined;
116
+ return { conflict, mine };
117
+ };
118
+
119
+ /** The typed refusal for a `path` conflict that cannot ever be adopted (config row, or no id). */
120
+ const conflictRefusal = (conflict: PassThroughGenericEndpoint, path: string) =>
121
+ conflict.is_from_config === true
122
+ ? new LitellmConfigPathConflictError({ path })
123
+ : new LitellmUnaddressableRowError({ path });
124
+
125
+ /** `read`'s answer for a path conflict: `Unowned` when it is a real, addressable DB row — a typed refusal otherwise. */
126
+ const readConflict = (conflict: PassThroughGenericEndpoint, path: string) =>
127
+ conflict.is_from_config !== true && conflict.id !== undefined && conflict.id !== null
128
+ ? Effect.succeed(Unowned(toAttributes(conflict, conflict.id)))
129
+ : Effect.fail(conflictRefusal(conflict, path));
130
+
131
+ const refuseLiteralSecrets = (headers: Readonly<Record<string, unknown>> | undefined) => {
132
+ const bad = literalSecretHeaders(headers);
133
+ return bad.length === 0
134
+ ? Effect.void
135
+ : Effect.fail(new LitellmLiteralSecretHeaderError({ headers: bad }));
136
+ };
137
+
138
+ /** ⛔ TEST-ONLY EXPORT. Not on index.ts's barrel — a lifecycle test calls these directly against the fake. */
139
+ export const passThroughHandlers = {
140
+ read: ({
141
+ id,
142
+ instanceId,
143
+ olds,
144
+ output,
145
+ }: {
146
+ id: string;
147
+ instanceId: string;
148
+ olds: PassThroughEndpointProps;
149
+ output: PassThroughEndpointAttributes | undefined;
150
+ }) =>
151
+ Effect.gen(function* () {
152
+ const objectId = output?.id ?? (yield* physicalIdOf(id, instanceId));
153
+ const rows = yield* listPassThroughEndpoints();
154
+ const { conflict, mine } = locate(rows, objectId, olds.path);
155
+ if (mine !== undefined) return toAttributes(mine, objectId);
156
+ if (conflict === undefined) return undefined;
157
+ return yield* readConflict(conflict, olds.path);
158
+ }),
159
+
160
+ diff: ({
161
+ news,
162
+ output,
163
+ }: {
164
+ news: Input<PassThroughEndpointProps>;
165
+ output: PassThroughEndpointAttributes | undefined;
166
+ }) =>
167
+ Effect.gen(function* () {
168
+ if (!isResolved(news)) return undefined;
169
+ yield* refuseLiteralSecrets(news.headers);
170
+ if (output === undefined) return undefined;
171
+ // ⛔ deleteFirst: TRUE — a create-first replace mints a new instanceId/id before the old row
172
+ // on `path` is gone, so the new generation's `reconcile` would refuse it as a foreign
173
+ // conflict (Apply.ts). Same fix this kit's other unique-identity providers use.
174
+ if (needsReplace(output, news)) return { action: 'replace', deleteFirst: true } as const;
175
+ return matches(output, news)
176
+ ? ({ action: 'noop' } as const)
177
+ : ({ action: 'update' } as const);
178
+ }),
179
+
180
+ reconcile: ({
181
+ id,
182
+ instanceId,
183
+ news,
184
+ output,
185
+ }: {
186
+ id: string;
187
+ instanceId: string;
188
+ news: PassThroughEndpointProps;
189
+ output: PassThroughEndpointAttributes | undefined;
190
+ }) =>
191
+ Effect.gen(function* () {
192
+ yield* refuseLiteralSecrets(news.headers);
193
+ const objectId = output?.id ?? (yield* physicalIdOf(id, instanceId));
194
+ const rows = yield* listPassThroughEndpoints();
195
+ const { conflict, mine } = locate(rows, objectId, news.path);
196
+
197
+ if (mine === undefined) {
198
+ // ⚠️ A CONFLICT HERE — reconcile, not read — means ownership shifted between plan and
199
+ // apply (a config row or a foreign DB row landed on this path after `read` cleared it).
200
+ // `read` gates a genuine adopt by returning `Unowned`; reconcile itself never adopts,
201
+ // so ANY conflict here fails rather than risking a second row on one path.
202
+ if (conflict !== undefined) return yield* Effect.fail(conflictRefusal(conflict, news.path));
203
+ yield* createPassThroughEndpoint(createBody(news, objectId));
204
+ } else if (mine.is_from_config === true) {
205
+ // ⛔ NEVER UPDATE OR DELETE AN `is_from_config` ROW, even one this resource's own
206
+ // deterministic id happens to match (a config.yaml author could in principle choose the
207
+ // same id `createPhysicalName` would). Refuse rather than silently editing the file's row.
208
+ return yield* Effect.fail(new LitellmConfigPathConflictError({ path: news.path }));
209
+ } else if (needsReplace(toAttributes(mine, objectId), news)) {
210
+ yield* deletePassThroughEndpoint(objectId);
211
+ yield* createPassThroughEndpoint(createBody(news, objectId));
212
+ } else if (!matches(toAttributes(mine, objectId), news)) {
213
+ const body = updateBody(news);
214
+ if (Object.keys(body).length > 0) yield* updatePassThroughEndpoint(objectId, body);
215
+ }
216
+
217
+ const after = (yield* listPassThroughEndpoints()).find((row) => row.id === objectId);
218
+ if (after === undefined) {
219
+ return yield* Effect.die(
220
+ new Error(
221
+ `LiteLLM.PassThroughEndpoint ${news.path}: the write returned no error but id ` +
222
+ `${objectId} is still absent from GET /config/pass_through_endpoint — read back ` +
223
+ 'rather than trusting the call that just returned.',
224
+ ),
225
+ );
226
+ }
227
+ return toAttributes(after, objectId);
228
+ }),
229
+
230
+ delete: ({
231
+ id,
232
+ instanceId,
233
+ olds,
234
+ output,
235
+ }: {
236
+ id: string;
237
+ instanceId: string;
238
+ olds: PassThroughEndpointProps;
239
+ output: PassThroughEndpointAttributes;
240
+ }) =>
241
+ Effect.gen(function* () {
242
+ const objectId = output.id ?? (yield* physicalIdOf(id, instanceId));
243
+ const rows = yield* listPassThroughEndpoints();
244
+ const live = rows.find((row) => row.id === objectId);
245
+ // ⛔ NEVER DELETE AN `is_from_config` ROW, and a DB row this resource never created (a path
246
+ // match with a different id) is not this resource's to remove either.
247
+ if (live === undefined || live.is_from_config === true || live.path !== olds.path) return;
248
+ yield* deletePassThroughEndpoint(objectId);
249
+ }),
250
+
251
+ list: () => Effect.succeed([]),
252
+ };
253
+
254
+ export const LiteLLMPassThroughEndpointProvider = () =>
255
+ Provider.effect(
256
+ LiteLLMPassThroughEndpoint,
257
+ Effect.succeed(LiteLLMPassThroughEndpoint.Provider.of(passThroughHandlers)),
258
+ );
@@ -0,0 +1,128 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ createBody,
4
+ literalSecretHeaders,
5
+ matches,
6
+ needsReplace,
7
+ updateBody,
8
+ } from './pass-through-form.ts';
9
+
10
+ const PROPS = { path: '/bria', target: 'https://api.bria.ai' };
11
+
12
+ describe('createBody', () => {
13
+ test('settles the vendor defaults and never sends undefined keys', () => {
14
+ const body = createBody(PROPS, 'ep-1');
15
+ expect(body).toEqual({
16
+ auth: true,
17
+ cost_per_request: 0,
18
+ default_query_params: {},
19
+ headers: {},
20
+ id: 'ep-1',
21
+ include_subpath: false,
22
+ path: '/bria',
23
+ target: 'https://api.bria.ai',
24
+ });
25
+ expect('timeout' in body).toBe(false);
26
+ expect('methods' in body).toBe(false);
27
+ expect('guardrails' in body).toBe(false);
28
+ });
29
+
30
+ test('carries a declared nullable field through', () => {
31
+ const body = createBody({ ...PROPS, timeout: 30, methods: ['GET'] }, 'ep-1');
32
+ expect(body.timeout).toBe(30);
33
+ expect(body.methods).toEqual(['GET']);
34
+ });
35
+ });
36
+
37
+ describe('updateBody', () => {
38
+ /** ⛔ `exclude_none` can never clear a field through this call — needsReplace exists for that. */
39
+ test('omits an unset nullable field rather than sending it as null', () => {
40
+ const body = updateBody(PROPS);
41
+ expect('timeout' in body).toBe(false);
42
+ expect('guardrails' in body).toBe(false);
43
+ });
44
+
45
+ test('carries a declared value through', () => {
46
+ expect(updateBody({ ...PROPS, timeout: 5 }).timeout).toBe(5);
47
+ });
48
+ });
49
+
50
+ describe('needsReplace', () => {
51
+ const live = (over: Partial<Parameters<typeof needsReplace>[0]>) => ({
52
+ auth: true,
53
+ cost_per_request: 0,
54
+ default_query_params: {},
55
+ headers: {},
56
+ id: 'ep-1',
57
+ include_subpath: false,
58
+ path: '/bria',
59
+ target: 'https://api.bria.ai',
60
+ ...over,
61
+ });
62
+
63
+ test('timeout going from set to unset forces a replace', () => {
64
+ expect(needsReplace(live({ timeout: 30 }), PROPS)).toBe(true);
65
+ });
66
+
67
+ test('timeout staying unset, or staying set, is not a replace', () => {
68
+ expect(needsReplace(live({}), PROPS)).toBe(false);
69
+ expect(needsReplace(live({ timeout: 30 }), { ...PROPS, timeout: 60 })).toBe(false);
70
+ });
71
+
72
+ test('methods and guardrails going from set to unset also force a replace', () => {
73
+ expect(needsReplace(live({ methods: ['GET'] }), PROPS)).toBe(true);
74
+ expect(needsReplace(live({ guardrails: { pii: null } }), PROPS)).toBe(true);
75
+ });
76
+ });
77
+
78
+ describe('matches', () => {
79
+ const live = {
80
+ auth: true,
81
+ cost_per_request: 0,
82
+ default_query_params: {},
83
+ headers: {},
84
+ id: 'ep-1',
85
+ include_subpath: false,
86
+ path: '/bria',
87
+ target: 'https://api.bria.ai',
88
+ };
89
+
90
+ test('a settled default matches a declaration that omits the field', () => {
91
+ expect(matches(live, PROPS)).toBe(true);
92
+ });
93
+
94
+ test('a changed target does not match', () => {
95
+ expect(matches(live, { ...PROPS, target: 'https://elsewhere.example.com' })).toBe(false);
96
+ });
97
+
98
+ test('header order does not matter, header content does', () => {
99
+ expect(
100
+ matches({ ...live, headers: { a: '1', b: '2' } }, { ...PROPS, headers: { b: '2', a: '1' } }),
101
+ ).toBe(true);
102
+ expect(matches({ ...live, headers: { a: '1' } }, { ...PROPS, headers: { a: '2' } })).toBe(
103
+ false,
104
+ );
105
+ });
106
+ });
107
+
108
+ describe('literalSecretHeaders', () => {
109
+ test('an Authorization header without os.environ/ is refused', () => {
110
+ expect(literalSecretHeaders({ Authorization: 'Bearer sk-live-abc123' })).toEqual([
111
+ 'Authorization',
112
+ ]);
113
+ });
114
+
115
+ test('the os.environ/ reference form is accepted', () => {
116
+ expect(literalSecretHeaders({ Authorization: 'os.environ/UPSTREAM_KEY' })).toEqual([]);
117
+ });
118
+
119
+ test('is case-insensitive on the header name and checks every secret-shaped name', () => {
120
+ expect(
121
+ literalSecretHeaders({ 'X-API-Key': 'literal', 'cf-aig-authorization': 'also-literal' }),
122
+ ).toEqual(['X-API-Key', 'cf-aig-authorization']);
123
+ });
124
+
125
+ test('an ordinary forwarded header is never flagged', () => {
126
+ expect(literalSecretHeaders({ 'X-Client-Version': '1.2.3' })).toEqual([]);
127
+ });
128
+ });