@homeflare/alchemy 0.52.0 → 0.53.1
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/dist/grafana/folder-permission.d.ts +76 -0
- package/dist/grafana/folder-permission.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +3 -1
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +133 -46
- package/dist/grafana/index.js.map +7 -5
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/grafana/provisioned.d.ts +1 -1
- package/dist/grafana/provisioned.d.ts.map +1 -1
- package/dist/grafana/team.d.ts +64 -0
- package/dist/grafana/team.d.ts.map +1 -0
- package/dist/linux/container-env-quote.d.ts +2 -0
- package/dist/linux/container-env-quote.d.ts.map +1 -1
- package/dist/linux/index.js +5 -1
- package/dist/linux/index.js.map +3 -3
- package/dist/litellm/fake-marketplace-litellm.d.ts +33 -0
- package/dist/litellm/fake-marketplace-litellm.d.ts.map +1 -0
- package/dist/litellm/index.d.ts +1 -0
- package/dist/litellm/index.d.ts.map +1 -1
- package/dist/litellm/index.js +530 -329
- package/dist/litellm/index.js.map +7 -4
- package/dist/litellm/marketplace-form.d.ts +21 -0
- package/dist/litellm/marketplace-form.d.ts.map +1 -0
- package/dist/litellm/marketplace-operations.d.ts +27 -0
- package/dist/litellm/marketplace-operations.d.ts.map +1 -0
- package/dist/litellm/marketplace-plugin.d.ts +57 -0
- package/dist/litellm/marketplace-plugin.d.ts.map +1 -0
- package/dist/litellm/marketplace-types.d.ts +55 -0
- package/dist/litellm/marketplace-types.d.ts.map +1 -0
- package/dist/litellm/providers.d.ts +1 -1
- package/dist/litellm/providers.d.ts.map +1 -1
- package/docs/litellm-marketplace.md +59 -0
- package/docs/litellm.md +1 -0
- package/package.json +1 -1
- package/src/grafana/folder-permission.test.ts +107 -0
- package/src/grafana/folder-permission.ts +128 -0
- package/src/grafana/index.ts +10 -1
- package/src/grafana/providers.ts +4 -0
- package/src/grafana/provisioned.ts +1 -1
- package/src/grafana/team.test.ts +108 -0
- package/src/grafana/team.ts +105 -0
- package/src/linux/container-env-quote.test.ts +54 -2
- package/src/linux/container-env-quote.ts +15 -0
- package/src/linux/container-fixture.test.ts +3 -3
- package/src/litellm/fake-marketplace-litellm.test.ts +51 -0
- package/src/litellm/fake-marketplace-litellm.ts +134 -0
- package/src/litellm/index.ts +8 -0
- package/src/litellm/marketplace-form.ts +160 -0
- package/src/litellm/marketplace-operations.ts +76 -0
- package/src/litellm/marketplace-plugin-replace.test.ts +68 -0
- package/src/litellm/marketplace-plugin.test.ts +181 -0
- package/src/litellm/marketplace-plugin.ts +156 -0
- package/src/litellm/marketplace-types.ts +53 -0
- package/src/litellm/providers.ts +2 -0
- package/src/litellm/registry-exports.test.ts +6 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.Team` against a fake Grafana through the real `@distilled.cloud/grafana` protocol:
|
|
3
|
+
* exact-name observe (adopt), create body, update only on drift, provisioned refusal, idempotent delete.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import type { GrafanaOpContext } from '@distilled.cloud/grafana/Protocol';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
9
|
+
import { grafanaOperations } from './resource.ts';
|
|
10
|
+
import { spec } from './team.ts';
|
|
11
|
+
|
|
12
|
+
const team = (over: Record<string, unknown> = {}) => ({
|
|
13
|
+
id: 7,
|
|
14
|
+
uid: 'aex7',
|
|
15
|
+
name: 'village-grafana',
|
|
16
|
+
email: '',
|
|
17
|
+
isProvisioned: false,
|
|
18
|
+
memberCount: 0,
|
|
19
|
+
orgId: 1,
|
|
20
|
+
...over,
|
|
21
|
+
});
|
|
22
|
+
const search = (teams: unknown[]) =>
|
|
23
|
+
Response.json({ teams, totalCount: teams.length, page: 1, perPage: 100 });
|
|
24
|
+
const run = <A, E>(
|
|
25
|
+
fake: ReturnType<typeof fakeGrafana>,
|
|
26
|
+
effect: Effect.Effect<A, E, GrafanaOpContext>,
|
|
27
|
+
): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))));
|
|
28
|
+
const props = { name: 'village-grafana' };
|
|
29
|
+
|
|
30
|
+
describe('Grafana.Team', () => {
|
|
31
|
+
test('fetchLive keeps only an exact name match, GET without a body', async () => {
|
|
32
|
+
const fake = fakeGrafana((m, url) =>
|
|
33
|
+
m === 'GET' && url.pathname === '/api/teams/search'
|
|
34
|
+
? search([team({ name: 'village-grafana-old', id: 9 }), team()])
|
|
35
|
+
: fakeFailure(404, 'x'),
|
|
36
|
+
);
|
|
37
|
+
const live = await run(fake, spec.fetchLive(props));
|
|
38
|
+
expect(live?.id).toBe(7);
|
|
39
|
+
expect(fake.bodies[0]).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('absent team folds to undefined; 403 propagates', async () => {
|
|
43
|
+
expect(
|
|
44
|
+
await run(
|
|
45
|
+
fakeGrafana(() => search([])),
|
|
46
|
+
spec.fetchLive(props),
|
|
47
|
+
),
|
|
48
|
+
).toBeUndefined();
|
|
49
|
+
const failure = await run(
|
|
50
|
+
fakeGrafana(() => fakeFailure(403, 'forbidden')),
|
|
51
|
+
Effect.flip(spec.fetchLive(props)),
|
|
52
|
+
);
|
|
53
|
+
expect((failure as { _tag: string })._tag).toBe('Forbidden');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('reconcile adopts a matching live team with no write', async () => {
|
|
57
|
+
const fake = fakeGrafana((m) =>
|
|
58
|
+
m === 'GET' ? search([team()]) : fakeFailure(500, 'no write expected'),
|
|
59
|
+
);
|
|
60
|
+
const out = await run(fake, grafanaOperations(spec).reconcile(props));
|
|
61
|
+
expect(out).toMatchObject({ id: 7, name: 'village-grafana' });
|
|
62
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('reconcile creates when absent, sending only declared fields', async () => {
|
|
66
|
+
let created = false;
|
|
67
|
+
const fake = fakeGrafana((m) => {
|
|
68
|
+
if (m === 'POST') {
|
|
69
|
+
created = true;
|
|
70
|
+
return Response.json({ teamId: 7, uid: 'aex7', message: 'Team created' });
|
|
71
|
+
}
|
|
72
|
+
return search(created ? [team()] : []);
|
|
73
|
+
});
|
|
74
|
+
await run(fake, grafanaOperations(spec).reconcile(props));
|
|
75
|
+
expect(fake.bodies.filter((b) => b !== undefined)).toEqual([{ name: 'village-grafana' }]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('email drift updates by team id', async () => {
|
|
79
|
+
let updated = false;
|
|
80
|
+
const fake = fakeGrafana((m) => {
|
|
81
|
+
if (m === 'PUT') {
|
|
82
|
+
updated = true;
|
|
83
|
+
return Response.json({ message: 'Team updated' });
|
|
84
|
+
}
|
|
85
|
+
return search([team({ email: updated ? 'ops@example.com' : '' })]);
|
|
86
|
+
});
|
|
87
|
+
await run(fake, grafanaOperations(spec).reconcile({ ...props, email: 'ops@example.com' }));
|
|
88
|
+
expect(fake.seen.some((s) => s.method === 'PUT' && s.path === '/api/teams/7')).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('a provisioned team refuses update and delete without writing', async () => {
|
|
92
|
+
const fake = fakeGrafana(() => search([team({ isProvisioned: true, email: 'a@b' })]));
|
|
93
|
+
const failure = await run(
|
|
94
|
+
fake,
|
|
95
|
+
Effect.flip(grafanaOperations(spec).reconcile({ ...props, email: 'ops@example.com' })),
|
|
96
|
+
);
|
|
97
|
+
expect((failure as { _tag: string })._tag).toBe('GrafanaProvisionedObjectError');
|
|
98
|
+
const del = await run(fake, Effect.flip(grafanaOperations(spec).destroy(props)));
|
|
99
|
+
expect((del as { _tag: string })._tag).toBe('GrafanaProvisionedObjectError');
|
|
100
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('delete is idempotent when the team is already gone', async () => {
|
|
104
|
+
const fake = fakeGrafana(() => search([]));
|
|
105
|
+
await run(fake, grafanaOperations(spec).destroy(props));
|
|
106
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.Team` — a Grafana team, keyed by NAME.
|
|
3
|
+
*
|
|
4
|
+
* Walked against `@distilled.cloud/grafana` 0.2.1 (`@homeflare/distilled-grafana`): `searchTeams`,
|
|
5
|
+
* `createTeam`, `updateTeam`, `deleteTeam`. Upstream Alchemy ships no Grafana family (S1); distilled
|
|
6
|
+
* ships the SDK, so every call goes through it (S23).
|
|
7
|
+
*
|
|
8
|
+
* ★ NAME IS THE KEY, NOT `uid`. `CreateTeamRequest` takes only `name`/`email` — there is no way to
|
|
9
|
+
* choose a team uid, and Grafana enforces unique team names per org. `fetchLive` therefore runs
|
|
10
|
+
* `GET /teams/search?name=<name>` (an exact-name filter) and keeps only an exact match; adoption of a
|
|
11
|
+
* team created by hand or by a script is a plain observe (S9/S10).
|
|
12
|
+
* ★ `email` is optional and compared only when declared ("undeclared means don't care", the kit's
|
|
13
|
+
* convention for optional props).
|
|
14
|
+
* ⛔ A provisioned team (`isProvisioned`, e.g. synced from SCIM/LDAP) is refused for update and delete.
|
|
15
|
+
*/
|
|
16
|
+
import { Resource } from 'alchemy';
|
|
17
|
+
import * as Provider from 'alchemy/Provider';
|
|
18
|
+
import * as grafana from '@distilled.cloud/grafana';
|
|
19
|
+
import * as Effect from 'effect/Effect';
|
|
20
|
+
import { type GrafanaProvisionedObjectError, refuseIfProvisioned } from './provisioned.ts';
|
|
21
|
+
import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
|
|
22
|
+
|
|
23
|
+
export interface TeamProps {
|
|
24
|
+
/** Team name — unique per org, and this resource's identity. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** Optional team email; compared only when declared. */
|
|
27
|
+
email?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TeamAttributes {
|
|
31
|
+
id: number;
|
|
32
|
+
uid: string;
|
|
33
|
+
name: string;
|
|
34
|
+
email: string;
|
|
35
|
+
isProvisioned: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GrafanaTeam extends Resource<'Grafana.Team', TeamProps, TeamAttributes, never> {}
|
|
39
|
+
|
|
40
|
+
export const GrafanaTeam = Resource<GrafanaTeam>('Grafana.Team');
|
|
41
|
+
|
|
42
|
+
/** Exact-name lookup. `/teams/search?name=` is already exact in Grafana; the filter keeps that true. */
|
|
43
|
+
export const findTeamByName = (name: string) =>
|
|
44
|
+
grafana
|
|
45
|
+
.searchTeams({ name, perpage: 100 })
|
|
46
|
+
.pipe(Effect.map((result) => (result.teams ?? []).find((team) => team.name === name)));
|
|
47
|
+
|
|
48
|
+
export const spec: GrafanaSpec<
|
|
49
|
+
TeamProps,
|
|
50
|
+
grafana.TeamDTO,
|
|
51
|
+
TeamAttributes,
|
|
52
|
+
| grafana.SearchTeamsError
|
|
53
|
+
| grafana.CreateTeamError
|
|
54
|
+
| grafana.UpdateTeamError
|
|
55
|
+
| grafana.DeleteTeamError
|
|
56
|
+
| GrafanaProvisionedObjectError
|
|
57
|
+
> = {
|
|
58
|
+
attributes: (live) => ({
|
|
59
|
+
email: live.email ?? '',
|
|
60
|
+
id: live.id,
|
|
61
|
+
isProvisioned: live.isProvisioned,
|
|
62
|
+
name: live.name,
|
|
63
|
+
uid: live.uid,
|
|
64
|
+
}),
|
|
65
|
+
create: (props) =>
|
|
66
|
+
grafana.createTeam({
|
|
67
|
+
name: props.name,
|
|
68
|
+
...(props.email === undefined ? {} : { email: props.email }),
|
|
69
|
+
}),
|
|
70
|
+
destroy: (props, live) =>
|
|
71
|
+
Effect.gen(function* () {
|
|
72
|
+
if (live.isProvisioned) {
|
|
73
|
+
return yield* refuseIfProvisioned(
|
|
74
|
+
'Grafana.Team',
|
|
75
|
+
props.name,
|
|
76
|
+
'an external team sync (isProvisioned)',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
yield* grafana.deleteTeam({ team_id: String(live.id) });
|
|
80
|
+
}),
|
|
81
|
+
fetchLive: (props) => findTeamByName(props.name),
|
|
82
|
+
matches: (attributes, props) =>
|
|
83
|
+
attributes.name === props.name &&
|
|
84
|
+
(props.email === undefined || attributes.email === props.email),
|
|
85
|
+
update: (props, live) =>
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
if (live.isProvisioned) {
|
|
88
|
+
return yield* refuseIfProvisioned(
|
|
89
|
+
'Grafana.Team',
|
|
90
|
+
props.name,
|
|
91
|
+
'an external team sync (isProvisioned)',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return yield* grafana.updateTeam({
|
|
95
|
+
team_id: String(live.id),
|
|
96
|
+
name: props.name,
|
|
97
|
+
...(props.email === undefined ? {} : { email: props.email }),
|
|
98
|
+
});
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const handlers = grafanaHandlers(spec);
|
|
103
|
+
|
|
104
|
+
export const GrafanaTeamProvider = () =>
|
|
105
|
+
Provider.effect(GrafanaTeam, Effect.succeed(GrafanaTeam.Provider.of(handlers)));
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
QuadletEnvironmentValueError,
|
|
16
16
|
environmentLines,
|
|
17
17
|
environmentValueProblems,
|
|
18
|
+
needsQuoting,
|
|
18
19
|
quoteEnvironmentValue,
|
|
19
20
|
} from './container-env-quote.ts';
|
|
20
21
|
import { containerProblems, renderContainerFile } from './container-form.ts';
|
|
@@ -62,13 +63,64 @@ describe('the rendered text', () => {
|
|
|
62
63
|
|
|
63
64
|
test('keys are sorted, so the render is stable for an unordered plain object', () => {
|
|
64
65
|
expect(environmentLines({ B: '2', A: '1' })).toEqual([
|
|
65
|
-
['Environment', '
|
|
66
|
-
['Environment', '
|
|
66
|
+
['Environment', 'A=1'],
|
|
67
|
+
['Environment', 'B=2'],
|
|
67
68
|
]);
|
|
68
69
|
expect(environmentLines({})).toEqual([]);
|
|
69
70
|
});
|
|
70
71
|
});
|
|
71
72
|
|
|
73
|
+
describe('quoted only when needed (0.53.1)', () => {
|
|
74
|
+
test('plain values render bare, byte-identical to the 0.51/0.52 renderer', () => {
|
|
75
|
+
expect(
|
|
76
|
+
environmentLines({
|
|
77
|
+
CADDY_CONFIG: '/etc/caddy/Caddyfile',
|
|
78
|
+
EMPTY: '',
|
|
79
|
+
URL: 'https://grafana.example/login?a=1&b=2#frag',
|
|
80
|
+
MIXED: 'a-b_c.d:e/f,g;h@i+j=k(l)[m]{n}*~!|<>?`',
|
|
81
|
+
}).map(([key, value]) => `${key}=${value}`),
|
|
82
|
+
).toEqual([
|
|
83
|
+
'Environment=CADDY_CONFIG=/etc/caddy/Caddyfile',
|
|
84
|
+
'Environment=EMPTY=',
|
|
85
|
+
'Environment=MIXED=a-b_c.d:e/f,g;h@i+j=k(l)[m]{n}*~!|<>?`',
|
|
86
|
+
'Environment=URL=https://grafana.example/login?a=1&b=2#frag',
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test.each([
|
|
91
|
+
['space', 'a b'],
|
|
92
|
+
['tab', 'a\tb'],
|
|
93
|
+
['double quote', 'say "hi"'],
|
|
94
|
+
['single quote', "it's"],
|
|
95
|
+
['percent', '50%off'],
|
|
96
|
+
['dollar', 'pa$word'],
|
|
97
|
+
['backslash', 'C:\\path'],
|
|
98
|
+
])('a value with a %s is quoted', (_name, value) => {
|
|
99
|
+
expect(needsQuoting(`K=${value}`)).toBe(true);
|
|
100
|
+
const rendered = quoteEnvironmentValue('K', value);
|
|
101
|
+
expect(rendered.startsWith('"K=')).toBe(true);
|
|
102
|
+
expect(rendered.endsWith('"')).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a single quote alone triggers the full quoted form', () => {
|
|
106
|
+
expect(quoteEnvironmentValue('K', "it's")).toBe(`"K=it's"`);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a special character in the KEY also triggers quoting', () => {
|
|
110
|
+
expect(quoteEnvironmentValue('A%B', '1')).toBe('"A%%B=1"');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('plain values never need quoting', () => {
|
|
114
|
+
expect(needsQuoting('A=1')).toBe(false);
|
|
115
|
+
expect(needsQuoting('K=')).toBe(false);
|
|
116
|
+
expect(quoteEnvironmentValue('A', '1')).toBe('A=1');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('newlines are still refused before the bare/quoted decision', () => {
|
|
120
|
+
expect(() => quoteEnvironmentValue('K', 'plain\nplain')).toThrow(QuadletEnvironmentValueError);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
72
124
|
describe('newlines are refused, not encoded', () => {
|
|
73
125
|
test('a newline in a value is a typed error naming the key, never the value', () => {
|
|
74
126
|
expect(() => quoteEnvironmentValue('WEBHOOK_URL', 'https://x\nSECRET-TAIL')).toThrow(
|
|
@@ -65,6 +65,17 @@ export class QuadletEnvironmentValueError extends Data.TaggedError('QuadletEnvir
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The characters that make a bare `KEY=value` unsafe: whitespace (quadlet's splitter ends the
|
|
70
|
+
* assignment there), `"` and `'` (start/end a quoted region), `\\` (C-escape, which can fail the
|
|
71
|
+
* whole line), `%` (systemd specifier) and `$` (systemd variable expansion). Anything else passes
|
|
72
|
+
* quadlet, podman's re-emission and systemd's ExecStart parsing unchanged.
|
|
73
|
+
*/
|
|
74
|
+
const SPECIAL = /[\s"'\\%$]/u;
|
|
75
|
+
|
|
76
|
+
/** True when `assignment` must be wrapped and escaped to survive the three parsers. */
|
|
77
|
+
export const needsQuoting = (assignment: string): boolean => SPECIAL.test(assignment);
|
|
78
|
+
|
|
68
79
|
/**
|
|
69
80
|
* `Environment="KEY=value"` — the whole assignment double-quoted, with `\`, `"`, `%` and `$`
|
|
70
81
|
* escaped (in that order, over the joined `KEY=value`), so the wrapping quotes this function adds
|
|
@@ -76,6 +87,10 @@ export class QuadletEnvironmentValueError extends Data.TaggedError('QuadletEnvir
|
|
|
76
87
|
*/
|
|
77
88
|
export const quoteEnvironmentValue = (key: string, value: string): string => {
|
|
78
89
|
if (key.includes('\n') || value.includes('\n')) throw new QuadletEnvironmentValueError({ key });
|
|
90
|
+
// ★ QUOTE ONLY WHEN NEEDED (0.53.1): a `KEY=value` with none of the characters any of the three
|
|
91
|
+
// parsers treats specially passes all of them verbatim, so it renders bare — byte-identical to
|
|
92
|
+
// the 0.51/0.52 renderer, so units holding only plain values do not change (and do not restart).
|
|
93
|
+
if (!needsQuoting(`${key}=${value}`)) return `${key}=${value}`;
|
|
79
94
|
// ⛔ THE `$` REPLACEMENT MUST BE A FUNCTION — measured 2026-10-12: `replaceAll`'s replacement
|
|
80
95
|
// STRING runs its own `$`-pattern mini-language (same fold as systemd: `$$` in it IS one
|
|
81
96
|
// literal `$`, and `$&` is the match), so a plain `'$$'` replacement silently no-ops and the
|
|
@@ -66,9 +66,9 @@ test('renders every directive CT100’s live caddy.container carries', () => {
|
|
|
66
66
|
'ContainerName=caddy',
|
|
67
67
|
'Network=host',
|
|
68
68
|
'EnvironmentFile=/etc/caddy/cf-token.env',
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
'Environment=
|
|
69
|
+
// ★ Bare again since 0.53.1: the value holds nothing any parser treats specially, so it renders
|
|
70
|
+
// exactly as the live file (and 0.51/0.52) has it — container-env-quote.ts quotes only when needed.
|
|
71
|
+
'Environment=CADDY_CONFIG=/etc/caddy/Caddyfile',
|
|
72
72
|
'Volume=/etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z',
|
|
73
73
|
'Volume=/etc/caddy/rendered:/etc/caddy/rendered:ro,Z',
|
|
74
74
|
'Volume=/etc/caddy/cf-token.env:/run/cf/token.env:ro',
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fake-level assertions for `fake-marketplace-litellm.ts` against the distilled 1.103.0 route docs.
|
|
3
|
+
*/
|
|
4
|
+
import { expect, test } from 'bun:test';
|
|
5
|
+
import { credentials } from '@distilled.cloud/litellm/Credentials';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import * as FetchHttpClient from 'effect/http/FetchHttpClient';
|
|
8
|
+
import * as Layer from 'effect/Layer';
|
|
9
|
+
import { registerPlugin, updatePlugin } from './marketplace-operations.ts';
|
|
10
|
+
import { FAKE_BASE } from './fake-litellm.ts';
|
|
11
|
+
import { pluginRow, startFakeMarketplaceLitellm } from './fake-marketplace-litellm.ts';
|
|
12
|
+
import { FAKE_KEY } from './fake-registry-base.ts';
|
|
13
|
+
|
|
14
|
+
const run = <A, E>(fetchFn: typeof globalThis.fetch, effect: Effect.Effect<A, E, unknown>) =>
|
|
15
|
+
Effect.runPromise(
|
|
16
|
+
effect.pipe(
|
|
17
|
+
Effect.provide(FetchHttpClient.layer),
|
|
18
|
+
Effect.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)),
|
|
19
|
+
Effect.provide(credentials({ apiKey: FAKE_KEY, baseUrl: FAKE_BASE })),
|
|
20
|
+
) as Effect.Effect<A, E, never>,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
test('register is create-only: a second POST of the same name returns 409 and changes nothing', async () => {
|
|
24
|
+
const fake = startFakeMarketplaceLitellm();
|
|
25
|
+
await run(fake.fetch, registerPlugin({ name: 'fake-workflows', source: { source: 'github' } }));
|
|
26
|
+
const before = fake.plugins()[0];
|
|
27
|
+
await expect(
|
|
28
|
+
run(fake.fetch, registerPlugin({ name: 'fake-workflows', source: { source: 'github' } })),
|
|
29
|
+
).rejects.toThrow();
|
|
30
|
+
expect(fake.plugins()).toHaveLength(1);
|
|
31
|
+
expect(fake.plugins()[0]).toEqual(before);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('PUT is a full replace: an omitted description is reset to null', async () => {
|
|
35
|
+
const fake = startFakeMarketplaceLitellm({
|
|
36
|
+
seed: [
|
|
37
|
+
pluginRow({
|
|
38
|
+
description: 'FAKE workflows',
|
|
39
|
+
name: 'fake-workflows',
|
|
40
|
+
source: { source: 'github', repo: 'FAKE-org/FAKE-repo' },
|
|
41
|
+
}),
|
|
42
|
+
],
|
|
43
|
+
});
|
|
44
|
+
await run(
|
|
45
|
+
fake.fetch,
|
|
46
|
+
updatePlugin('fake-workflows', {
|
|
47
|
+
source: { source: 'github', repo: 'FAKE-org/FAKE-repo' },
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
expect(fake.plugins()[0]).toMatchObject({ description: null, name: 'fake-workflows' });
|
|
51
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake of LiteLLM's `/claude-code/*` routes for tests — TEST ONLY. Skeleton and the rules every
|
|
3
|
+
* fake follows: fake-registry-base.ts.
|
|
4
|
+
*
|
|
5
|
+
* ★ MEASURED AGAINST THE GENERATED 1.103.0 ROUTE DOCS:
|
|
6
|
+
* - `POST /claude-code/plugins` is create-only and never overwrites; if `name` already exists it
|
|
7
|
+
* returns 409 Conflict (`claude_code_marketplace.ts:546`).
|
|
8
|
+
* - `PUT /claude-code/plugins/{name}` is a full replace, not a merge: any optional field left out
|
|
9
|
+
* is reset to its default (`claude_code_marketplace.ts:563`).
|
|
10
|
+
* - `PluginListItem` optional fields: author, category, description, domain, homepage, keywords,
|
|
11
|
+
* namespace, version (`claude_code_marketplace.ts:211-226`).
|
|
12
|
+
* - Enable/disable flip `enabled`; `GET /claude-code/marketplace.json` lists only enabled plugins.
|
|
13
|
+
* - Writes need an admin key (`forbidWrites` models the 403).
|
|
14
|
+
* - `registerDisabled` models a proxy whose register leaves the plugin disabled, which the resource
|
|
15
|
+
* must not trust. `ignoreSource` models a PUT that drops the source field.
|
|
16
|
+
* ⚠️ EVERY OTHER ANSWER IS UNMEASURED; shapes come from the generated 1.103.0 schema.
|
|
17
|
+
*/
|
|
18
|
+
import { FAKE_KEY, type FakeRecord, json, recordingFetch } from './fake-registry-base.ts';
|
|
19
|
+
import type { Row } from './registry-support.ts';
|
|
20
|
+
|
|
21
|
+
export interface FakeMarketplaceLitellm extends FakeRecord {
|
|
22
|
+
readonly plugins: () => readonly Row[];
|
|
23
|
+
/** The names `GET /claude-code/marketplace.json` lists right now. */
|
|
24
|
+
readonly listed: () => Promise<readonly string[]>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FakeMarketplaceOptions {
|
|
28
|
+
readonly seed?: readonly Row[];
|
|
29
|
+
readonly forbidWrites?: boolean;
|
|
30
|
+
readonly registerDisabled?: boolean;
|
|
31
|
+
readonly ignoreSource?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const pluginRow = (fields: Row): Row => ({
|
|
35
|
+
created_at: '2026-10-12T00:00:00Z',
|
|
36
|
+
description: null,
|
|
37
|
+
enabled: true,
|
|
38
|
+
id: `FAKE-plugin-${String(fields['name'])}`,
|
|
39
|
+
source: { repo: 'FAKE-org/FAKE-repo', source: 'github' },
|
|
40
|
+
updated_at: '2026-10-12T00:00:00Z',
|
|
41
|
+
version: null,
|
|
42
|
+
...fields,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const OPTIONAL_PUT_FIELDS = [
|
|
46
|
+
'author',
|
|
47
|
+
'category',
|
|
48
|
+
'description',
|
|
49
|
+
'domain',
|
|
50
|
+
'homepage',
|
|
51
|
+
'keywords',
|
|
52
|
+
'namespace',
|
|
53
|
+
'version',
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export const startFakeMarketplaceLitellm = (
|
|
57
|
+
options: FakeMarketplaceOptions = {},
|
|
58
|
+
): FakeMarketplaceLitellm => {
|
|
59
|
+
let rows: Row[] = (options.seed ?? []).map((row) => ({ ...row }));
|
|
60
|
+
|
|
61
|
+
const record = recordingFetch(FAKE_KEY, ({ body, request, url }) => {
|
|
62
|
+
const byName = /^\/claude-code\/plugins\/([^/]+)(\/enable|\/disable)?$/.exec(url.pathname);
|
|
63
|
+
if (url.pathname === '/claude-code/plugins' && request.method === 'GET') {
|
|
64
|
+
return json(200, { count: rows.length, plugins: rows });
|
|
65
|
+
}
|
|
66
|
+
if (url.pathname === '/claude-code/marketplace.json' && request.method === 'GET') {
|
|
67
|
+
return json(200, {
|
|
68
|
+
name: 'FAKE-litellm',
|
|
69
|
+
plugins: rows
|
|
70
|
+
.filter((row) => row['enabled'] === true)
|
|
71
|
+
.map((row) => ({ name: row['name'] })),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (request.method !== 'GET' && options.forbidWrites === true) {
|
|
75
|
+
return json(403, { detail: { error: 'not_allowed_access' } });
|
|
76
|
+
}
|
|
77
|
+
if (url.pathname === '/claude-code/plugins' && request.method === 'POST') {
|
|
78
|
+
const name = body['name'];
|
|
79
|
+
if (rows.some((row) => row['name'] === name)) {
|
|
80
|
+
return json(409, { detail: `Plugin '${name}' already exists` });
|
|
81
|
+
}
|
|
82
|
+
const next = pluginRow({
|
|
83
|
+
...body,
|
|
84
|
+
enabled: options.registerDisabled !== true,
|
|
85
|
+
});
|
|
86
|
+
rows = [...rows, next];
|
|
87
|
+
return json(200, { action: 'created', plugin: next, status: 'success' });
|
|
88
|
+
}
|
|
89
|
+
if (byName !== null) {
|
|
90
|
+
const name = decodeURIComponent(byName[1] ?? '');
|
|
91
|
+
const at = rows.findIndex((row) => row['name'] === name);
|
|
92
|
+
if (at === -1) return json(404, { detail: `Plugin '${name}' not found` });
|
|
93
|
+
const row = rows[at];
|
|
94
|
+
if (row === undefined) return json(404, { detail: `Plugin '${name}' not found` });
|
|
95
|
+
if (request.method === 'PUT') {
|
|
96
|
+
const reset = Object.fromEntries(
|
|
97
|
+
OPTIONAL_PUT_FIELDS.map((field) => [field, body[field] ?? null]),
|
|
98
|
+
);
|
|
99
|
+
const source = options.ignoreSource === true ? row['source'] : body['source'];
|
|
100
|
+
const next = pluginRow({
|
|
101
|
+
...row,
|
|
102
|
+
...reset,
|
|
103
|
+
source,
|
|
104
|
+
});
|
|
105
|
+
rows = rows.map((each, i) => (i === at ? next : each));
|
|
106
|
+
return json(200, { action: 'updated', plugin: next, status: 'success' });
|
|
107
|
+
}
|
|
108
|
+
if (request.method === 'POST' && byName[2] !== undefined) {
|
|
109
|
+
rows = rows.map((each, i) =>
|
|
110
|
+
i === at ? { ...each, enabled: byName[2] === '/enable' } : each,
|
|
111
|
+
);
|
|
112
|
+
return json(200, { status: 'success' });
|
|
113
|
+
}
|
|
114
|
+
if (request.method === 'DELETE') {
|
|
115
|
+
rows = rows.filter((_, i) => i !== at);
|
|
116
|
+
return json(200, { status: 'success' });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return json(404, { detail: 'not found' });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
...record,
|
|
124
|
+
listed: async () => {
|
|
125
|
+
const response = await record.fetch('http://fake.local/claude-code/marketplace.json', {
|
|
126
|
+
headers: { authorization: `Bearer ${FAKE_KEY}` },
|
|
127
|
+
method: 'GET',
|
|
128
|
+
});
|
|
129
|
+
const payload = (await response.json()) as { plugins?: readonly { name?: unknown }[] };
|
|
130
|
+
return (payload.plugins ?? []).map((each) => String(each.name));
|
|
131
|
+
},
|
|
132
|
+
plugins: () => rows.map((row) => ({ ...row })),
|
|
133
|
+
};
|
|
134
|
+
};
|
package/src/litellm/index.ts
CHANGED
|
@@ -79,6 +79,14 @@ export {
|
|
|
79
79
|
type AccessGroupError,
|
|
80
80
|
type AccessGroupProps,
|
|
81
81
|
} from './access-group.ts';
|
|
82
|
+
export {
|
|
83
|
+
LiteLLMClaudeCodeMarketplacePlugin,
|
|
84
|
+
LiteLLMClaudeCodeMarketplacePluginProvider,
|
|
85
|
+
isLiteLLMClaudeCodeMarketplacePlugin,
|
|
86
|
+
type MarketplacePluginAttributes,
|
|
87
|
+
type MarketplacePluginError,
|
|
88
|
+
type MarketplacePluginProps,
|
|
89
|
+
} from './marketplace-plugin.ts';
|
|
82
90
|
export {
|
|
83
91
|
LiteLLMPolicyAttachment,
|
|
84
92
|
LiteLLMPolicyAttachmentProvider,
|