@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,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four LiteLLM proxy calls a `LiteLLM.PassThroughEndpoint` needs, and nothing else reaches
|
|
3
|
+
* LiteLLM's `/config/pass_through_endpoint` routes.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ WRAPPED IN A PER-BASE-URL SEMAPHORE. LiteLLM stores every pass-through endpoint in ONE
|
|
6
|
+
* `general_settings.pass_through_endpoints` list (pass_through_endpoints.py at tag v1.100.0),
|
|
7
|
+
* so create/update/delete are each a read-modify-write of the WHOLE list through
|
|
8
|
+
* `update_config_general_settings`. Two of this package's own calls racing each other — two
|
|
9
|
+
* endpoints created in the same deploy — would each read the list, add their own row and write
|
|
10
|
+
* it back, and the second write would silently drop the first row. One permit per proxy
|
|
11
|
+
* serialises this package's own writes; it cannot serialise against the LiteLLM UI or a second
|
|
12
|
+
* deploy process, which is why `reconcile` reads back after writing rather than trusting the
|
|
13
|
+
* call that just returned (docs/litellm.md).
|
|
14
|
+
* ★ `Authorization: Bearer <key>` — LiteLLM's own client uses it (litellm/proxy/client/cli, and
|
|
15
|
+
* every `/config/*` route decorator at the tag takes `user_api_key_dict = Depends(user_api_key_auth)`,
|
|
16
|
+
* which accepts a bearer token). Every mutation additionally requires the caller be
|
|
17
|
+
* PROXY_ADMIN (`update_config_general_settings`, proxy_server.py:16411 at the tag) — an
|
|
18
|
+
* ordinary virtual key gets `LitellmUnauthorizedError`, not a clearer 403 body naming the role.
|
|
19
|
+
* ⛔ NO MESSAGE SNIFFING (S21). Every status this file maps becomes a typed tag; nothing here or
|
|
20
|
+
* above it matches an error body's text. Deleting an id LiteLLM does not have answers 400
|
|
21
|
+
* (measured against the tag's `pass_through_endpoint_delete` — the id is looked up in the list
|
|
22
|
+
* and a miss raises `HTTPException(400)`, not 404) — but so does a genuinely-live row when the
|
|
23
|
+
* caller isn't PROXY_ADMIN, or the DB is disconnected (`update_config_general_settings`,
|
|
24
|
+
* proxy_server.py:16389-16416 at the tag): the SAME status, for a delete that did NOT happen.
|
|
25
|
+
* `deletePassThroughEndpoint` tells them apart by re-listing (a GET, which needs no PROXY_ADMIN —
|
|
26
|
+
* proxy_server.py:3245-3251) rather than by the 400's body text — see its own comment.
|
|
27
|
+
*/
|
|
28
|
+
import * as Data from 'effect/Data';
|
|
29
|
+
import * as Effect from 'effect/Effect';
|
|
30
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
31
|
+
import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
|
|
32
|
+
import * as Semaphore from 'effect/Semaphore';
|
|
33
|
+
import { LitellmCredentials, type LitellmCredentialsError } from './credentials.ts';
|
|
34
|
+
import type {
|
|
35
|
+
PassThroughEndpointResponse,
|
|
36
|
+
PassThroughGenericEndpoint,
|
|
37
|
+
} from './generated/pass-through.ts';
|
|
38
|
+
|
|
39
|
+
export type LitellmRequirements = HttpClient.HttpClient | LitellmCredentials;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The `litellm:<METHOD> <path>` operations this package writes to and reads from.
|
|
43
|
+
*
|
|
44
|
+
* ★ `codegen/litellm.ts` scans this FILE's text for these exact literals to pick which of
|
|
45
|
+
* LiteLLM 1.100.0's 696 paths to generate types from (codegen/litellm.ts header). Keep each one
|
|
46
|
+
* a literal string on one line; the generator matches text, it does not evaluate the object.
|
|
47
|
+
*/
|
|
48
|
+
export const LITELLM_OPERATIONS = {
|
|
49
|
+
create: 'litellm:POST /config/pass_through_endpoint',
|
|
50
|
+
delete: 'litellm:DELETE /config/pass_through_endpoint',
|
|
51
|
+
list: 'litellm:GET /config/pass_through_endpoint',
|
|
52
|
+
update: 'litellm:POST /config/pass_through_endpoint/{endpoint_id}',
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
export class LitellmUnauthorizedError extends Data.TaggedError('LitellmUnauthorizedError')<{
|
|
56
|
+
readonly status: number;
|
|
57
|
+
readonly method: string;
|
|
58
|
+
readonly path: string;
|
|
59
|
+
}> {}
|
|
60
|
+
export class LitellmBadRequestError extends Data.TaggedError('LitellmBadRequestError')<{
|
|
61
|
+
readonly method: string;
|
|
62
|
+
readonly path: string;
|
|
63
|
+
readonly detail: string;
|
|
64
|
+
}> {}
|
|
65
|
+
export class LitellmHttpError extends Data.TaggedError('LitellmHttpError')<{
|
|
66
|
+
readonly status: number;
|
|
67
|
+
readonly method: string;
|
|
68
|
+
readonly path: string;
|
|
69
|
+
readonly detail: string;
|
|
70
|
+
}> {}
|
|
71
|
+
export class LitellmTransportError extends Data.TaggedError('LitellmTransportError')<{
|
|
72
|
+
readonly method: string;
|
|
73
|
+
readonly path: string;
|
|
74
|
+
readonly cause: string;
|
|
75
|
+
}> {}
|
|
76
|
+
|
|
77
|
+
export type LitellmError =
|
|
78
|
+
| LitellmUnauthorizedError
|
|
79
|
+
| LitellmBadRequestError
|
|
80
|
+
| LitellmHttpError
|
|
81
|
+
| LitellmTransportError
|
|
82
|
+
| LitellmCredentialsError;
|
|
83
|
+
|
|
84
|
+
const semaphores = new Map<string, Semaphore.Semaphore>();
|
|
85
|
+
|
|
86
|
+
/** One semaphore per base URL, created on first use. Never `Hash.structure` (S18) — a plain Map key. */
|
|
87
|
+
const semaphoreFor = (baseUrl: string): Effect.Effect<Semaphore.Semaphore> =>
|
|
88
|
+
Effect.sync(() => {
|
|
89
|
+
const existing = semaphores.get(baseUrl);
|
|
90
|
+
if (existing !== undefined) return existing;
|
|
91
|
+
const created = Semaphore.makeUnsafe(1);
|
|
92
|
+
semaphores.set(baseUrl, created);
|
|
93
|
+
return created;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const call = <T>(method: 'GET' | 'POST' | 'DELETE', path: string, body?: unknown) =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
const creds = yield* yield* LitellmCredentials;
|
|
99
|
+
const client = yield* HttpClient.HttpClient;
|
|
100
|
+
const url = `${creds.baseUrl}${path}`;
|
|
101
|
+
const request = HttpClientRequest.make(method)(url).pipe(
|
|
102
|
+
HttpClientRequest.setHeaders({
|
|
103
|
+
Accept: 'application/json',
|
|
104
|
+
Authorization: `Bearer ${creds.apiKey}`,
|
|
105
|
+
}),
|
|
106
|
+
body === undefined
|
|
107
|
+
? (self) => self
|
|
108
|
+
: HttpClientRequest.bodyText(JSON.stringify(body), 'application/json'),
|
|
109
|
+
);
|
|
110
|
+
const response = yield* client
|
|
111
|
+
.execute(request)
|
|
112
|
+
.pipe(
|
|
113
|
+
Effect.mapError(
|
|
114
|
+
(cause) => new LitellmTransportError({ cause: String(cause), method, path }),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
const text = yield* response.text.pipe(
|
|
118
|
+
Effect.mapError((cause) => new LitellmTransportError({ cause: String(cause), method, path })),
|
|
119
|
+
);
|
|
120
|
+
if (response.status === 401 || response.status === 403) {
|
|
121
|
+
return yield* Effect.fail(
|
|
122
|
+
new LitellmUnauthorizedError({ method, path, status: response.status }),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (response.status === 400) {
|
|
126
|
+
return yield* Effect.fail(
|
|
127
|
+
new LitellmBadRequestError({ detail: text.slice(0, 300), method, path }),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (response.status < 200 || response.status >= 300) {
|
|
131
|
+
return yield* Effect.fail(
|
|
132
|
+
new LitellmHttpError({ detail: text.slice(0, 300), method, path, status: response.status }),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return (text === '' ? undefined : JSON.parse(text)) as T;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/** GET /config/pass_through_endpoint — every row, config-file and DB alike. Not under the semaphore: reads don't race the field. */
|
|
139
|
+
export const listPassThroughEndpoints = (): Effect.Effect<
|
|
140
|
+
readonly PassThroughGenericEndpoint[],
|
|
141
|
+
LitellmError,
|
|
142
|
+
LitellmRequirements
|
|
143
|
+
> =>
|
|
144
|
+
call<PassThroughEndpointResponse>('GET', '/config/pass_through_endpoint').pipe(
|
|
145
|
+
Effect.map((body) => body.endpoints),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const mutate = <T>(io: Effect.Effect<T, LitellmError, LitellmRequirements>) =>
|
|
149
|
+
Effect.gen(function* () {
|
|
150
|
+
const creds = yield* yield* LitellmCredentials;
|
|
151
|
+
const semaphore = yield* semaphoreFor(creds.baseUrl);
|
|
152
|
+
return yield* semaphore.withPermits(1)(io);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/** POST /config/pass_through_endpoint. `body` carries `id` — see pass-through-endpoint.ts. */
|
|
156
|
+
export const createPassThroughEndpoint = (
|
|
157
|
+
body: PassThroughGenericEndpoint,
|
|
158
|
+
): Effect.Effect<void, LitellmError, LitellmRequirements> =>
|
|
159
|
+
mutate(call('POST', '/config/pass_through_endpoint', body));
|
|
160
|
+
|
|
161
|
+
/** POST /config/pass_through_endpoint/{endpoint_id}. Merges with `exclude_none` — see pass-through-form.ts. */
|
|
162
|
+
export const updatePassThroughEndpoint = (
|
|
163
|
+
endpointId: string,
|
|
164
|
+
body: Partial<PassThroughGenericEndpoint>,
|
|
165
|
+
): Effect.Effect<void, LitellmError, LitellmRequirements> =>
|
|
166
|
+
mutate(call('POST', `/config/pass_through_endpoint/${encodeURIComponent(endpointId)}`, body));
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* DELETE /config/pass_through_endpoint?endpoint_id=… — idempotent BY THIS FUNCTION, not by the
|
|
170
|
+
* vendor. ⚠️ Measured at v1.100.0: deleting an id the list does not have raises `HTTPException(400)`
|
|
171
|
+
* from `pass_through_endpoint_delete` — but the SAME route also 400s `not_allowed_access` for a
|
|
172
|
+
* non-PROXY_ADMIN caller or a disconnected DB (`update_config_general_settings`,
|
|
173
|
+
* proxy_server.py:16389-16416), on a row that never got deleted. A 400 alone cannot tell the two
|
|
174
|
+
* apart, so THIS FUNCTION DOESN'T GUESS FROM THE STATUS ALONE: on 400 it re-lists (a GET — no
|
|
175
|
+
* PROXY_ADMIN required, proxy_server.py:3245-3251) and only treats the delete as already-done if
|
|
176
|
+
* `endpointId` is genuinely absent; otherwise the delete failed and this re-fails with the ORIGINAL
|
|
177
|
+
* error, not a message match (S21 — a status-and-a-real-read decision, never body text).
|
|
178
|
+
*/
|
|
179
|
+
export const deletePassThroughEndpoint = (
|
|
180
|
+
endpointId: string,
|
|
181
|
+
): Effect.Effect<void, LitellmError, LitellmRequirements> =>
|
|
182
|
+
mutate(
|
|
183
|
+
call(
|
|
184
|
+
'DELETE',
|
|
185
|
+
`/config/pass_through_endpoint?endpoint_id=${encodeURIComponent(endpointId)}`,
|
|
186
|
+
).pipe(
|
|
187
|
+
Effect.catchTag('LitellmBadRequestError', (original) =>
|
|
188
|
+
Effect.gen(function* () {
|
|
189
|
+
const rows = yield* listPassThroughEndpoints();
|
|
190
|
+
if (rows.some((row) => row.id === endpointId)) return yield* Effect.fail(original);
|
|
191
|
+
}),
|
|
192
|
+
),
|
|
193
|
+
Effect.asVoid,
|
|
194
|
+
),
|
|
195
|
+
);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lazy Auth service every LiteLLM call reads from, and nothing else reads the environment.
|
|
3
|
+
*
|
|
4
|
+
* ★ LITELLM'S OWN VARIABLE NAMES, NOT A HOUSE CHOICE. `LITELLM_PROXY_URL` and
|
|
5
|
+
* `LITELLM_PROXY_API_KEY` are what LiteLLM's own CLI client reads
|
|
6
|
+
* (litellm/proxy/client/cli/main.py:70,78 at tag v1.100.0) — using the vendor's own names means
|
|
7
|
+
* a consumer who already has a `.env` for the `litellm` CLI needs nothing new for this package.
|
|
8
|
+
* ⛔ NO DEFAULT URL. A published package that fell back to a loopback address would let a
|
|
9
|
+
* consumer who forgot the variable watch every call fail against a proxy that is not theirs,
|
|
10
|
+
* instead of being told which variable is missing (S24/S25 — the house rule, not a vendor one).
|
|
11
|
+
* ⛔ THE KEY IS NEVER A PROP. Alchemy persists attributes unencrypted (S25); the master key that
|
|
12
|
+
* `update_config_general_settings` requires (PROXY_ADMIN only, proxy_server.py:16411 at the
|
|
13
|
+
* tag) is read fresh from the environment on every call, never stored in state.
|
|
14
|
+
* ★ LAZY: the service's VALUE is an `Effect` that resolves the credential, not the credential
|
|
15
|
+
* itself — so a layer built once still reads the environment inside each operation
|
|
16
|
+
* (`yield* yield* LitellmCredentials`), the idiom `alchemy/Auth`'s custom-auth-provider guide
|
|
17
|
+
* describes for a credential a layer must not capture at build time.
|
|
18
|
+
*/
|
|
19
|
+
import * as Context from 'effect/Context';
|
|
20
|
+
import * as Data from 'effect/Data';
|
|
21
|
+
import * as Effect from 'effect/Effect';
|
|
22
|
+
import * as Layer from 'effect/Layer';
|
|
23
|
+
|
|
24
|
+
export const LITELLM_PROXY_URL_ENV = 'LITELLM_PROXY_URL';
|
|
25
|
+
export const LITELLM_PROXY_API_KEY_ENV = 'LITELLM_PROXY_API_KEY';
|
|
26
|
+
|
|
27
|
+
export interface LitellmCreds {
|
|
28
|
+
/** Trailing slash stripped. No default — see the header. */
|
|
29
|
+
readonly baseUrl: string;
|
|
30
|
+
/** LiteLLM's master (or a PROXY_ADMIN) key. Never logged, never an attribute. */
|
|
31
|
+
readonly apiKey: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class LitellmCredentialsError extends Data.TaggedError('LitellmCredentialsError')<{
|
|
35
|
+
readonly message: string;
|
|
36
|
+
}> {}
|
|
37
|
+
|
|
38
|
+
const missing = (name: string): LitellmCredentialsError =>
|
|
39
|
+
new LitellmCredentialsError({
|
|
40
|
+
message:
|
|
41
|
+
`${name} is not set. Export it — the same variable LiteLLM's own \`litellm\` CLI ` +
|
|
42
|
+
'reads — before running a stack that declares a LiteLLM.PassThroughEndpoint.',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Read at call time. Exported so a test can call it directly against `process.env`. */
|
|
46
|
+
export const resolveCreds: Effect.Effect<LitellmCreds, LitellmCredentialsError> = Effect.gen(
|
|
47
|
+
function* () {
|
|
48
|
+
const rawUrl = process.env[LITELLM_PROXY_URL_ENV];
|
|
49
|
+
if (rawUrl === undefined || rawUrl.trim() === '')
|
|
50
|
+
return yield* Effect.fail(missing(LITELLM_PROXY_URL_ENV));
|
|
51
|
+
const rawKey = process.env[LITELLM_PROXY_API_KEY_ENV];
|
|
52
|
+
if (rawKey === undefined || rawKey.trim() === '') {
|
|
53
|
+
return yield* Effect.fail(missing(LITELLM_PROXY_API_KEY_ENV));
|
|
54
|
+
}
|
|
55
|
+
return { baseUrl: rawUrl.trim().replace(/\/$/, ''), apiKey: rawKey.trim() };
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
/** The Effect service a LiteLLM operation reads: `yield* yield* LitellmCredentials`. */
|
|
60
|
+
export class LitellmCredentials extends Context.Service<
|
|
61
|
+
LitellmCredentials,
|
|
62
|
+
Effect.Effect<LitellmCreds, LitellmCredentialsError>
|
|
63
|
+
>()('homeflare/litellm/Credentials') {}
|
|
64
|
+
|
|
65
|
+
/** The default layer: resolves from the environment, fresh, on every `yield*`. */
|
|
66
|
+
export const litellmCredentialsLayer: Layer.Layer<LitellmCredentials> = Layer.succeed(
|
|
67
|
+
LitellmCredentials,
|
|
68
|
+
resolveCreds,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/** For a test: a fixed credential, never touching the environment. */
|
|
72
|
+
export const litellmCredentialsLayerFor = (creds: LitellmCreds): Layer.Layer<LitellmCredentials> =>
|
|
73
|
+
Layer.succeed(LitellmCredentials, Effect.succeed(creds));
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A loopback `Bun.serve` fake of LiteLLM's `/config/pass_through_endpoint` routes — TEST ONLY,
|
|
3
|
+
* never imported by `index.ts` (the netbox/proxmox house convention for test-only helpers).
|
|
4
|
+
*
|
|
5
|
+
* ★ MODELS THE WHOLE-FIELD READ-MODIFY-WRITE ON PURPOSE. `general_settings.pass_through_endpoints`
|
|
6
|
+
* is ONE field (client.ts's header), so every mutation here reads the CURRENT row list, waits
|
|
7
|
+
* (so two concurrent requests can interleave), then writes back what it captured — not what is
|
|
8
|
+
* there by the time it writes. Two `create` calls that race lose one row, exactly like the
|
|
9
|
+
* vendor field would without the client's per-base-URL semaphore serialising them.
|
|
10
|
+
* ⚠️ WHAT THIS FAKE DOES NOT MODEL: PROXY_ADMIN vs. a lesser role for most routes (any correct
|
|
11
|
+
* bearer token is accepted); and update-of-a-missing-id, which is not measured at the tag — this
|
|
12
|
+
* fake 400s it, the same status a missing delete gets, as a documented TEST SIMPLIFICATION, not a
|
|
13
|
+
* vendor fact (docs/litellm.md says so too). `forbidDelete` is the one exception: it models the
|
|
14
|
+
* real asymmetry client.ts's header describes — `DELETE` routes through
|
|
15
|
+
* `update_config_general_settings`, which 400s `not_allowed_access` for a non-PROXY_ADMIN caller
|
|
16
|
+
* (or a disconnected DB) on a row that is genuinely still live, the SAME status a missing-id
|
|
17
|
+
* delete gets (proxy_server.py:16389-16416 at v1.100.0).
|
|
18
|
+
*/
|
|
19
|
+
import type { PassThroughGenericEndpoint } from './generated/pass-through.ts';
|
|
20
|
+
|
|
21
|
+
export interface FakeRequest {
|
|
22
|
+
readonly method: string;
|
|
23
|
+
readonly path: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FakeLitellm {
|
|
27
|
+
readonly url: string;
|
|
28
|
+
readonly rows: () => readonly PassThroughGenericEndpoint[];
|
|
29
|
+
readonly requests: () => readonly FakeRequest[];
|
|
30
|
+
readonly stop: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const RACE_WINDOW_MS = 15;
|
|
34
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
35
|
+
|
|
36
|
+
export const startFakeLitellm = (options?: {
|
|
37
|
+
readonly masterKey?: string;
|
|
38
|
+
readonly seed?: readonly PassThroughGenericEndpoint[];
|
|
39
|
+
/** Every `DELETE` 400s `not_allowed_access`, whether or not the row exists — see the file header. */
|
|
40
|
+
readonly forbidDelete?: boolean;
|
|
41
|
+
}): FakeLitellm => {
|
|
42
|
+
const masterKey = options?.masterKey ?? 'sk-test-master';
|
|
43
|
+
const forbidDelete = options?.forbidDelete ?? false;
|
|
44
|
+
let rows: PassThroughGenericEndpoint[] = [...(options?.seed ?? [])];
|
|
45
|
+
const requests: FakeRequest[] = [];
|
|
46
|
+
|
|
47
|
+
const json = (status: number, body: unknown) =>
|
|
48
|
+
new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' }, status });
|
|
49
|
+
|
|
50
|
+
const server = Bun.serve({
|
|
51
|
+
port: 0,
|
|
52
|
+
fetch: async (req) => {
|
|
53
|
+
const url = new URL(req.url);
|
|
54
|
+
requests.push({ method: req.method, path: url.pathname });
|
|
55
|
+
const auth = req.headers.get('authorization');
|
|
56
|
+
if (auth !== `Bearer ${masterKey}`) return json(401, { detail: 'invalid api key' });
|
|
57
|
+
if (
|
|
58
|
+
url.pathname !== '/config/pass_through_endpoint' &&
|
|
59
|
+
!url.pathname.startsWith('/config/pass_through_endpoint/')
|
|
60
|
+
) {
|
|
61
|
+
return json(404, { detail: 'not found' });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (req.method === 'GET') {
|
|
65
|
+
const endpointId = url.searchParams.get('endpoint_id');
|
|
66
|
+
const filtered = endpointId === null ? rows : rows.filter((row) => row.id === endpointId);
|
|
67
|
+
return json(200, { endpoints: filtered });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (req.method === 'POST' && url.pathname === '/config/pass_through_endpoint') {
|
|
71
|
+
const body = (await req.json()) as PassThroughGenericEndpoint;
|
|
72
|
+
const before = rows; // ★ captured before the window — the race the semaphore prevents
|
|
73
|
+
await sleep(RACE_WINDOW_MS);
|
|
74
|
+
rows = [...before, { ...body, is_from_config: false }];
|
|
75
|
+
return json(200, {});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (req.method === 'POST' && url.pathname.startsWith('/config/pass_through_endpoint/')) {
|
|
79
|
+
const id = decodeURIComponent(url.pathname.slice('/config/pass_through_endpoint/'.length));
|
|
80
|
+
const patch = (await req.json()) as Partial<PassThroughGenericEndpoint>;
|
|
81
|
+
const before = rows;
|
|
82
|
+
await sleep(RACE_WINDOW_MS);
|
|
83
|
+
const index = before.findIndex((row) => row.id === id);
|
|
84
|
+
if (index === -1) return json(400, { detail: `No pass-through endpoint with id=${id}` });
|
|
85
|
+
// ⚠️ `exclude_none`-shaped: only keys the patch actually carries are merged, matching
|
|
86
|
+
// client.ts's `updateBody` never sending an unset/null field (pass-through-form.ts).
|
|
87
|
+
const merged = { ...before[index], ...patch };
|
|
88
|
+
rows = before.map((row, i) => (i === index ? (merged as PassThroughGenericEndpoint) : row));
|
|
89
|
+
return json(200, {});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (req.method === 'DELETE' && url.pathname === '/config/pass_through_endpoint') {
|
|
93
|
+
const id = url.searchParams.get('endpoint_id');
|
|
94
|
+
if (forbidDelete) return json(400, { error: 'not_allowed_access' });
|
|
95
|
+
const before = rows;
|
|
96
|
+
await sleep(RACE_WINDOW_MS);
|
|
97
|
+
if (!before.some((row) => row.id === id)) {
|
|
98
|
+
return json(400, { detail: `No pass-through endpoint with id=${String(id)}` });
|
|
99
|
+
}
|
|
100
|
+
rows = before.filter((row) => row.id !== id);
|
|
101
|
+
return json(200, { endpoints: rows });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return json(405, { detail: `unhandled ${req.method} ${url.pathname}` });
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
requests: () => [...requests],
|
|
110
|
+
rows: () => [...rows],
|
|
111
|
+
stop: () => server.stop(true),
|
|
112
|
+
url: server.url.toString().replace(/\/$/, ''),
|
|
113
|
+
};
|
|
114
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alchemy's own Plan and Apply, run in-process over an in-memory state store, so a test can deploy a
|
|
3
|
+
* `LiteLLM.PassThroughEndpoint` declaration, change it, deploy again, and see what the ENGINE did
|
|
4
|
+
* with the provider's answers: which action it planned, and which calls reached the fake proxy.
|
|
5
|
+
*
|
|
6
|
+
* ★ WHY THE ENGINE AND NOT THE HANDLERS ALONE (openbao/fake-stack.ts's own reasoning, copied here
|
|
7
|
+
* because a `diff` answer proves nothing by itself). `Apply.ts` mints a FRESH `instanceId` for a
|
|
8
|
+
* create-first `replace` and calls `reconcile` with `output: undefined, olds: undefined` — a call
|
|
9
|
+
* shape `pass-through-endpoint.test.ts`'s handler-only tests never produce (see the file's own
|
|
10
|
+
* test that reuses the OLD instanceId and output, which is why it passed while the engine path
|
|
11
|
+
* was broken). This harness drives the real `Plan.make`/`apply` instead of restating the claim.
|
|
12
|
+
* ★ NOT alchemy/Test/Bun — same reasoning as openbao/fake-stack.ts: no CLI profile/credentials
|
|
13
|
+
* files, no file logger. Just the provider, an in-memory state store, and the stack's services.
|
|
14
|
+
*/
|
|
15
|
+
import * as Alchemy from 'alchemy';
|
|
16
|
+
import { AdoptPolicy } from 'alchemy/AdoptPolicy';
|
|
17
|
+
import { provideFreshArtifactStore } from 'alchemy/Artifacts';
|
|
18
|
+
import * as Effect from 'effect/Effect';
|
|
19
|
+
import * as Layer from 'effect/Layer';
|
|
20
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
21
|
+
import { type LitellmCreds, litellmCredentialsLayerFor } from './credentials.ts';
|
|
22
|
+
import { LiteLLMPassThroughEndpointProvider } from './pass-through-endpoint.ts';
|
|
23
|
+
|
|
24
|
+
/** The planned action for each resource, deletions included, keyed by FQN. */
|
|
25
|
+
export type Planned = Readonly<Record<string, string>>;
|
|
26
|
+
|
|
27
|
+
/** A stack body: resource declarations, as an `alchemy.run.ts` would write them. */
|
|
28
|
+
export type StackBody = Effect.Effect<unknown, unknown, unknown>;
|
|
29
|
+
|
|
30
|
+
/** What the CLI would set for one deploy: `adopt` is `--adopt` (the AdoptPolicy service). */
|
|
31
|
+
export type DeployOptions = { readonly adopt?: boolean };
|
|
32
|
+
|
|
33
|
+
export interface FakeStack {
|
|
34
|
+
/** Plan, then apply, `body`. Resolves to the plan; rejects with the apply's failure. */
|
|
35
|
+
readonly deploy: (body: StackBody, options?: DeployOptions) => Promise<Planned>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Node = { readonly action: string };
|
|
39
|
+
type PlanView = {
|
|
40
|
+
readonly resources: Readonly<Record<string, Node>>;
|
|
41
|
+
readonly deletions: Readonly<Record<string, Node | undefined>>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const actionsOf = (plan: PlanView): Planned => {
|
|
45
|
+
const out: Record<string, string> = {};
|
|
46
|
+
for (const [fqn, node] of Object.entries(plan.resources)) out[fqn] = node.action;
|
|
47
|
+
for (const [fqn, node] of Object.entries(plan.deletions)) {
|
|
48
|
+
if (node !== undefined) out[fqn] = node.action;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* ⚠️ CREDENTIALS/HTTPCLIENT ARE PROVIDED AROUND `deploy`, NOT BAKED INTO THE PROVIDER LAYER.
|
|
55
|
+
* `Provider.effect`'s own construction effect is `Effect.succeed(...)` (pass-through-endpoint.ts)
|
|
56
|
+
* — it never actually reads `LitellmCredentials`/`HttpClient`; only the HANDLERS it returns do,
|
|
57
|
+
* and those run later, whenever `Apply.ts` calls `provider.reconcile`/`read`/`delete`. Effect
|
|
58
|
+
* resolves a `yield*`'s service from whatever ambient context wraps the CALL SITE, so the layers
|
|
59
|
+
* those handlers need must wrap the whole plan+apply pipeline below, exactly where openbao's
|
|
60
|
+
* fake-stack.ts wraps `BaoEnv` around its own `deploy`.
|
|
61
|
+
*/
|
|
62
|
+
export const fakeStack = (creds: LitellmCreds, name = 'PassThroughStack'): FakeStack => {
|
|
63
|
+
const state = Alchemy.inMemoryState();
|
|
64
|
+
const providers = LiteLLMPassThroughEndpointProvider();
|
|
65
|
+
const stack = Alchemy.Stack as unknown as (
|
|
66
|
+
stackName: string,
|
|
67
|
+
options: { providers: typeof providers; state: typeof state },
|
|
68
|
+
body: StackBody,
|
|
69
|
+
) => Effect.Effect<{ readonly services: never }, unknown, never>;
|
|
70
|
+
const plan = Alchemy.Plan.make as unknown as (
|
|
71
|
+
compiled: unknown,
|
|
72
|
+
) => Effect.Effect<PlanView, unknown, never>;
|
|
73
|
+
const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
|
|
74
|
+
|
|
75
|
+
const deploy = (body: StackBody, options: DeployOptions = {}) =>
|
|
76
|
+
Effect.gen(function* () {
|
|
77
|
+
const compiled = yield* stack(name, { providers, state }, body);
|
|
78
|
+
return yield* Effect.gen(function* () {
|
|
79
|
+
const planned = yield* plan(compiled);
|
|
80
|
+
yield* apply(planned);
|
|
81
|
+
return actionsOf(planned);
|
|
82
|
+
}).pipe(provideFreshArtifactStore, Effect.provide(Layer.succeedContext(compiled.services)));
|
|
83
|
+
}).pipe(
|
|
84
|
+
options.adopt === undefined ? (e) => e : Effect.provideService(AdoptPolicy, options.adopt),
|
|
85
|
+
Effect.provideService(Alchemy.Stage, 'test'),
|
|
86
|
+
Effect.provide(litellmCredentialsLayerFor(creds)),
|
|
87
|
+
Effect.provide(FetchHttpClient.layer),
|
|
88
|
+
Effect.scoped,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
deploy: (body, options) => Effect.runPromise(deploy(body, options) as Effect.Effect<Planned>),
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Every call that changed something, as `METHOD path`, in order. */
|
|
97
|
+
export const writesOf = (
|
|
98
|
+
requests: readonly { readonly method: string; readonly path: string }[],
|
|
99
|
+
): string[] =>
|
|
100
|
+
requests.filter((each) => each.method !== 'GET').map((each) => `${each.method} ${each.path}`);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated from LiteLLM 1.100.0's OpenAPI document -- DO NOT EDIT BY HAND.
|
|
3
|
+
*
|
|
4
|
+
* Run: bun codegen/litellm.ts (`--check` compares without writing)
|
|
5
|
+
* Manifest entry: `litellm-openapi` -- LiteLLM Proxy (app.openapi()) 1.100.0
|
|
6
|
+
* sha256 1b3e4d23da6aea92..., read from codegen/manifest.json's own note on how this document
|
|
7
|
+
* was produced (the vendor's own generator, cross-checked byte-identically — not a live read).
|
|
8
|
+
*
|
|
9
|
+
* Covers the 4 pass-through operations this package writes to:
|
|
10
|
+
* litellm:DELETE /config/pass_through_endpoint
|
|
11
|
+
* litellm:GET /config/pass_through_endpoint
|
|
12
|
+
* litellm:POST /config/pass_through_endpoint
|
|
13
|
+
* litellm:POST /config/pass_through_endpoint/{endpoint_id}
|
|
14
|
+
*
|
|
15
|
+
* Does NOT cover the other 692 paths LiteLLM 1.100.0 publishes — this generator is
|
|
16
|
+
* consumer-driven (codegen/litellm.ts), not exhaustive. See codegen/README.md.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface PassThroughEndpointResponse {
|
|
20
|
+
readonly endpoints: readonly PassThroughGenericEndpoint[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PassThroughGenericEndpoint {
|
|
24
|
+
/**
|
|
25
|
+
* Whether authentication is required for the pass-through endpoint. Defaults to True so a pass-through silently created without an explicit value still requires a valid LiteLLM API key — set to False only if the endpoint is meant to be a public forwarder (e.g. an unauthenticated webhook target).
|
|
26
|
+
* @default true
|
|
27
|
+
*/
|
|
28
|
+
readonly auth?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The USD cost per request to the target endpoint. This is used to calculate the cost of the request to the target endpoint.
|
|
31
|
+
* @default 0
|
|
32
|
+
*/
|
|
33
|
+
readonly cost_per_request?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Key-value pairs of default query parameters to be sent with every request to this endpoint. These can be overridden by client-provided query parameters. For example: {'key': 'default_value', 'api_version': '2023-01'}
|
|
36
|
+
* @default {}
|
|
37
|
+
*/
|
|
38
|
+
readonly default_query_params?: Record<string, unknown>;
|
|
39
|
+
/** Guardrails configuration for this passthrough endpoint. Dict keys are guardrail names, values are optional settings for field targeting. When set, all org/team/key level guardrails will also execute. Defaults to None (no guardrails execute). */
|
|
40
|
+
readonly guardrails?: Record<string, PassThroughGuardrailSettings | null> | null;
|
|
41
|
+
/**
|
|
42
|
+
* Key-value pairs of headers to be forwarded with the request. You can set any key value pair here and it will be forwarded to your target endpoint
|
|
43
|
+
* @default {}
|
|
44
|
+
*/
|
|
45
|
+
readonly headers?: Record<string, unknown>;
|
|
46
|
+
/** Optional unique identifier for the pass-through endpoint. If not provided, endpoints will be identified by path for backwards compatibility. */
|
|
47
|
+
readonly id?: string | null;
|
|
48
|
+
/**
|
|
49
|
+
* If True, requests to subpaths of the path will be forwarded to the target endpoint. For example, if the path is /bria and include_subpath is True, requests to /bria/v1/text-to-image/base/2.3 will be forwarded to the target endpoint.
|
|
50
|
+
* @default false
|
|
51
|
+
*/
|
|
52
|
+
readonly include_subpath?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* True if this endpoint is defined in the config file, False if from DB. Config-defined endpoints cannot be edited via the UI.
|
|
55
|
+
* @default false
|
|
56
|
+
*/
|
|
57
|
+
readonly is_from_config?: boolean;
|
|
58
|
+
/** List of HTTP methods this endpoint handles (e.g., ['GET', 'POST']). If None or empty, all methods (GET, POST, PUT, DELETE, PATCH) are supported for backward compatibility. This allows the same path to have different targets for different HTTP methods. */
|
|
59
|
+
readonly methods?: readonly string[] | null;
|
|
60
|
+
/** The route to be added to the LiteLLM Proxy Server. */
|
|
61
|
+
readonly path: string;
|
|
62
|
+
/** The URL to which requests for this path should be forwarded. */
|
|
63
|
+
readonly target: string;
|
|
64
|
+
/** Upstream request timeout in seconds for this pass-through endpoint. If unset, uses general_settings.pass_through_request_timeout (default 600). */
|
|
65
|
+
readonly timeout?: number | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PassThroughGuardrailSettings {
|
|
69
|
+
/** JSONPath expressions for input field targeting (pre_call). Examples: 'query', 'documents[*].text', 'messages[*].content'. If not specified, guardrail runs on entire request payload. */
|
|
70
|
+
readonly request_fields?: readonly string[] | null;
|
|
71
|
+
/** JSONPath expressions for output field targeting (post_call). Examples: 'results[*].text', 'output'. If not specified, guardrail runs on entire response payload. */
|
|
72
|
+
readonly response_fields?: readonly string[] | null;
|
|
73
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiteLLM providers for Alchemy.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ THIS BARREL IS THE PUBLIC API, deliberately smaller than the directory — `fake-litellm.ts`
|
|
5
|
+
* and the internals `pass-through-form.ts`/`client.ts` need but a consumer should not depend on
|
|
6
|
+
* directly are not re-exported here (netbox/index.ts's own rule).
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
LITELLM_PROXY_API_KEY_ENV,
|
|
10
|
+
LITELLM_PROXY_URL_ENV,
|
|
11
|
+
LitellmCredentials,
|
|
12
|
+
type LitellmCreds,
|
|
13
|
+
LitellmCredentialsError,
|
|
14
|
+
litellmCredentialsLayer,
|
|
15
|
+
litellmCredentialsLayerFor,
|
|
16
|
+
} from './credentials.ts';
|
|
17
|
+
export {
|
|
18
|
+
LITELLM_OPERATIONS,
|
|
19
|
+
LitellmBadRequestError,
|
|
20
|
+
type LitellmError,
|
|
21
|
+
LitellmHttpError,
|
|
22
|
+
type LitellmRequirements,
|
|
23
|
+
LitellmTransportError,
|
|
24
|
+
LitellmUnauthorizedError,
|
|
25
|
+
} from './client.ts';
|
|
26
|
+
export type {
|
|
27
|
+
PassThroughEndpointResponse,
|
|
28
|
+
PassThroughGenericEndpoint,
|
|
29
|
+
PassThroughGuardrailSettings,
|
|
30
|
+
} from './generated/pass-through.ts';
|
|
31
|
+
export {
|
|
32
|
+
LiteLLMPassThroughEndpoint,
|
|
33
|
+
LiteLLMPassThroughEndpointProvider,
|
|
34
|
+
LitellmConfigPathConflictError,
|
|
35
|
+
LitellmLiteralSecretHeaderError,
|
|
36
|
+
LitellmUnaddressableRowError,
|
|
37
|
+
isLiteLLMPassThroughEndpoint,
|
|
38
|
+
type PassThroughEndpointAttributes,
|
|
39
|
+
type PassThroughEndpointError,
|
|
40
|
+
type PassThroughEndpointProps,
|
|
41
|
+
} from './pass-through-endpoint.ts';
|
|
42
|
+
export { litellmProviders } from './providers.ts';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `deletePassThroughEndpoint` treats ANY HTTP 400 as "already deleted" — but the vendor's real
|
|
3
|
+
* `DELETE /config/pass_through_endpoint` route also 400s `not_allowed_access` for a non-PROXY_ADMIN
|
|
4
|
+
* caller (or a disconnected DB), on a row that is genuinely still live: `update_config_general_settings`
|
|
5
|
+
* (proxy_server.py:16389-16416 at v1.100.0), the SAME status a missing-id delete gets. Before this
|
|
6
|
+
* file's fix, `client.ts` swallowed that too, silently reporting success while the row stayed live.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ NO MESSAGE SNIFFING (S21): the fix cannot tell the two 400 reasons apart by body text either —
|
|
9
|
+
* `fake-litellm.ts`'s `forbidDelete` deliberately sends the SAME status the vendor does. It tells
|
|
10
|
+
* them apart the way `reconcile` already does elsewhere in this file (client.ts's own header:
|
|
11
|
+
* "read back after writing rather than trusting the call that just returned"): re-list (a GET,
|
|
12
|
+
* which needs no PROXY_ADMIN — pass_through_endpoints.py:3245-3251) and treat the 400 as
|
|
13
|
+
* "already gone" only if the row is actually absent.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
18
|
+
import {
|
|
19
|
+
type LitellmRequirements,
|
|
20
|
+
deletePassThroughEndpoint,
|
|
21
|
+
listPassThroughEndpoints,
|
|
22
|
+
} from './client.ts';
|
|
23
|
+
import { litellmCredentialsLayerFor } from './credentials.ts';
|
|
24
|
+
import { startFakeLitellm } from './fake-litellm.ts';
|
|
25
|
+
|
|
26
|
+
const MASTER_KEY = 'sk-test-master';
|
|
27
|
+
const PROPS = {
|
|
28
|
+
auth: true,
|
|
29
|
+
id: 'live-1',
|
|
30
|
+
is_from_config: false,
|
|
31
|
+
path: '/bria',
|
|
32
|
+
target: 'https://api.bria.ai',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const run = <A, E>(baseUrl: string, effect: Effect.Effect<A, E, LitellmRequirements>): Promise<A> =>
|
|
36
|
+
Effect.runPromise(
|
|
37
|
+
effect.pipe(
|
|
38
|
+
Effect.provide(FetchHttpClient.layer),
|
|
39
|
+
Effect.provide(litellmCredentialsLayerFor({ apiKey: MASTER_KEY, baseUrl })),
|
|
40
|
+
) as Effect.Effect<A, E, never>,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
describe('a delete forbidden for a reason other than "already gone"', () => {
|
|
44
|
+
test('is not swallowed: it fails, and the row is still live', async () => {
|
|
45
|
+
const fake = startFakeLitellm({ forbidDelete: true, masterKey: MASTER_KEY, seed: [PROPS] });
|
|
46
|
+
try {
|
|
47
|
+
const result = run(fake.url, deletePassThroughEndpoint('live-1'));
|
|
48
|
+
await expect(result).rejects.toThrow();
|
|
49
|
+
const rows = await run(fake.url, listPassThroughEndpoints());
|
|
50
|
+
expect(rows.some((r) => r.id === 'live-1')).toBe(true);
|
|
51
|
+
} finally {
|
|
52
|
+
fake.stop();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a genuinely-gone id still succeeds with no error (idempotent delete preserved)', async () => {
|
|
57
|
+
const fake = startFakeLitellm({ forbidDelete: true, masterKey: MASTER_KEY });
|
|
58
|
+
try {
|
|
59
|
+
await expect(
|
|
60
|
+
run(fake.url, deletePassThroughEndpoint('never-existed')),
|
|
61
|
+
).resolves.toBeUndefined();
|
|
62
|
+
} finally {
|
|
63
|
+
fake.stop();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|