@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.
- package/README.md +25 -1
- package/dist/litellm/client.d.ts +76 -0
- package/dist/litellm/client.d.ts.map +1 -0
- package/dist/litellm/credentials.d.ts +48 -0
- package/dist/litellm/credentials.d.ts.map +1 -0
- package/dist/litellm/fake-litellm.d.ts +36 -0
- package/dist/litellm/fake-litellm.d.ts.map +1 -0
- package/dist/litellm/fake-stack.d.ts +30 -0
- package/dist/litellm/fake-stack.d.ts.map +1 -0
- package/dist/litellm/generated/pass-through.d.ts +71 -0
- package/dist/litellm/generated/pass-through.d.ts.map +1 -0
- package/dist/litellm/index.d.ts +13 -0
- package/dist/litellm/index.d.ts.map +1 -0
- package/dist/litellm/index.js +270 -0
- package/dist/litellm/index.js.map +14 -0
- package/dist/litellm/pass-through-endpoint.d.ts +94 -0
- package/dist/litellm/pass-through-endpoint.d.ts.map +1 -0
- package/dist/litellm/pass-through-form.d.ts +40 -0
- package/dist/litellm/pass-through-form.d.ts.map +1 -0
- package/dist/litellm/providers.d.ts +16 -0
- package/dist/litellm/providers.d.ts.map +1 -0
- package/dist/paperless/client.d.ts +37 -0
- package/dist/paperless/client.d.ts.map +1 -0
- package/dist/paperless/constraint-guard.d.ts +19 -0
- package/dist/paperless/constraint-guard.d.ts.map +1 -0
- package/dist/paperless/constraints.d.ts +50 -0
- package/dist/paperless/constraints.d.ts.map +1 -0
- package/dist/paperless/credentials.d.ts +40 -0
- package/dist/paperless/credentials.d.ts.map +1 -0
- package/dist/paperless/custom-field.d.ts +75 -0
- package/dist/paperless/custom-field.d.ts.map +1 -0
- package/dist/paperless/document-type.d.ts +30 -0
- package/dist/paperless/document-type.d.ts.map +1 -0
- package/dist/paperless/errors.d.ts +49 -0
- package/dist/paperless/errors.d.ts.map +1 -0
- package/dist/paperless/fake-paperless.d.ts +39 -0
- package/dist/paperless/fake-paperless.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/index.d.ts +13 -0
- package/dist/paperless/generated/constraints/index.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-document_types.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-document_types.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-tags.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-tags.d.ts.map +1 -0
- package/dist/paperless/generated/types/custom-fields.d.ts +23 -0
- package/dist/paperless/generated/types/custom-fields.d.ts.map +1 -0
- package/dist/paperless/generated/types/document-types.d.ts +39 -0
- package/dist/paperless/generated/types/document-types.d.ts.map +1 -0
- package/dist/paperless/generated/types/index.d.ts +10 -0
- package/dist/paperless/generated/types/index.d.ts.map +1 -0
- package/dist/paperless/generated/types/storage-paths.d.ts +31 -0
- package/dist/paperless/generated/types/storage-paths.d.ts.map +1 -0
- package/dist/paperless/generated/types/tags.d.ts +37 -0
- package/dist/paperless/generated/types/tags.d.ts.map +1 -0
- package/dist/paperless/index.d.ts +19 -0
- package/dist/paperless/index.d.ts.map +1 -0
- package/dist/paperless/index.js +524 -0
- package/dist/paperless/index.js.map +26 -0
- package/dist/paperless/matching-locate.d.ts +33 -0
- package/dist/paperless/matching-locate.d.ts.map +1 -0
- package/dist/paperless/matching-types.d.ts +60 -0
- package/dist/paperless/matching-types.d.ts.map +1 -0
- package/dist/paperless/matching.d.ts +61 -0
- package/dist/paperless/matching.d.ts.map +1 -0
- package/dist/paperless/providers.d.ts +15 -0
- package/dist/paperless/providers.d.ts.map +1 -0
- package/dist/paperless/storage-path.d.ts +32 -0
- package/dist/paperless/storage-path.d.ts.map +1 -0
- package/dist/paperless/tag.d.ts +47 -0
- package/dist/paperless/tag.d.ts.map +1 -0
- package/dist/postgres/connection.d.ts +50 -0
- package/dist/postgres/connection.d.ts.map +1 -0
- package/dist/postgres/database-attrs.d.ts +79 -0
- package/dist/postgres/database-attrs.d.ts.map +1 -0
- package/dist/postgres/database-sql.d.ts +73 -0
- package/dist/postgres/database-sql.d.ts.map +1 -0
- package/dist/postgres/database.d.ts +63 -0
- package/dist/postgres/database.d.ts.map +1 -0
- package/dist/postgres/errors.d.ts +86 -0
- package/dist/postgres/errors.d.ts.map +1 -0
- package/dist/postgres/fake-sql.d.ts +22 -0
- package/dist/postgres/fake-sql.d.ts.map +1 -0
- package/dist/postgres/index.d.ts +16 -0
- package/dist/postgres/index.d.ts.map +1 -0
- package/dist/postgres/index.js +238 -0
- package/dist/postgres/index.js.map +15 -0
- package/dist/postgres/providers.d.ts +14 -0
- package/dist/postgres/providers.d.ts.map +1 -0
- package/dist/proxmox/index.js.map +2 -2
- package/dist/proxmox/zfs-pool.d.ts +10 -2
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/dist/release/archive.d.ts +6 -2
- package/dist/release/archive.d.ts.map +1 -1
- package/dist/release/binary-form.d.ts +14 -9
- package/dist/release/binary-form.d.ts.map +1 -1
- package/dist/release/binary-pins.d.ts +17 -0
- package/dist/release/binary-pins.d.ts.map +1 -0
- package/dist/release/catalog.d.ts.map +1 -1
- package/dist/release/index.js +90 -40
- package/dist/release/index.js.map +8 -7
- package/dist/release/tar.d.ts +9 -2
- package/dist/release/tar.d.ts.map +1 -1
- package/docs/litellm.md +110 -0
- package/docs/paperless.md +115 -0
- package/docs/peers.md +8 -1
- package/docs/postgres.md +125 -0
- package/docs/release-binary-catalogs.md +40 -4
- package/docs/release-binary-refusals.md +22 -8
- package/docs/release-binary-upstream.md +33 -15
- package/package.json +14 -1
- package/src/litellm/client.ts +195 -0
- package/src/litellm/credentials.ts +73 -0
- package/src/litellm/fake-litellm.ts +114 -0
- package/src/litellm/fake-stack.ts +100 -0
- package/src/litellm/generated/pass-through.ts +73 -0
- package/src/litellm/index.ts +42 -0
- package/src/litellm/pass-through-endpoint-delete-swallow.test.ts +66 -0
- package/src/litellm/pass-through-endpoint-replace.test.ts +84 -0
- package/src/litellm/pass-through-endpoint.test.ts +208 -0
- package/src/litellm/pass-through-endpoint.ts +258 -0
- package/src/litellm/pass-through-form.test.ts +128 -0
- package/src/litellm/pass-through-form.ts +136 -0
- package/src/litellm/providers.ts +19 -0
- package/src/paperless/client.ts +88 -0
- package/src/paperless/constraint-guard.ts +50 -0
- package/src/paperless/constraints.test.ts +74 -0
- package/src/paperless/constraints.ts +111 -0
- package/src/paperless/credentials.ts +69 -0
- package/src/paperless/custom-field.test.ts +74 -0
- package/src/paperless/custom-field.ts +95 -0
- package/src/paperless/document-type.ts +74 -0
- package/src/paperless/errors.ts +62 -0
- package/src/paperless/fake-paperless.ts +156 -0
- package/src/paperless/generated/constraints/index.ts +23 -0
- package/src/paperless/generated/constraints/paperless-custom_fields.ts +29 -0
- package/src/paperless/generated/constraints/paperless-document_types.ts +32 -0
- package/src/paperless/generated/constraints/paperless-storage_paths.ts +34 -0
- package/src/paperless/generated/constraints/paperless-tags.ts +34 -0
- package/src/paperless/generated/types/custom-fields.ts +23 -0
- package/src/paperless/generated/types/document-types.ts +30 -0
- package/src/paperless/generated/types/index.ts +9 -0
- package/src/paperless/generated/types/storage-paths.ts +31 -0
- package/src/paperless/generated/types/tags.ts +37 -0
- package/src/paperless/index.ts +56 -0
- package/src/paperless/matching-identity.test.ts +194 -0
- package/src/paperless/matching-locate.ts +104 -0
- package/src/paperless/matching-types.ts +61 -0
- package/src/paperless/matching.test.ts +128 -0
- package/src/paperless/matching.ts +239 -0
- package/src/paperless/providers.ts +27 -0
- package/src/paperless/storage-path.ts +78 -0
- package/src/paperless/tag.ts +91 -0
- package/src/postgres/connection.ts +62 -0
- package/src/postgres/database-attrs.ts +87 -0
- package/src/postgres/database-bigint-serialization.test.ts +140 -0
- package/src/postgres/database-lifecycle.test.ts +113 -0
- package/src/postgres/database-provenance.test.ts +118 -0
- package/src/postgres/database-refusals.test.ts +135 -0
- package/src/postgres/database-sql.ts +174 -0
- package/src/postgres/database.ts +193 -0
- package/src/postgres/errors.ts +131 -0
- package/src/postgres/fake-sql.ts +123 -0
- package/src/postgres/fixtures/pg-REL_18_6-create-database-synopsis.txt +26 -0
- package/src/postgres/fixtures/pg-REL_18_6-pg_database.h.txt +67 -0
- package/src/postgres/index.ts +30 -0
- package/src/postgres/providers.ts +16 -0
- package/src/proxmox/zfs-pool.ts +10 -2
- package/src/release/archive.ts +10 -4
- package/src/release/binary-form.ts +24 -76
- package/src/release/binary-pins.ts +95 -0
- package/src/release/binary-root.test.ts +122 -0
- package/src/release/catalog.ts +10 -2
- package/src/release/tar-root.test.ts +114 -0
- 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
|
+
});
|