Skip to content
Source
Sign inSign up
Open History to see the last commit

TextGenerators.test.ts

235 lines8.5 KB

1import { it } from "@effect/vitest";
2import {
3 type ManagedPolicySummary,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 type TextGeneratorConfig,
7} from "@t3tools/contracts";
8import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Schema from "effect/Schema";
12import * as FetchHttpClient from "effect/http/FetchHttpClient";
13import { expect, vi } from "vite-plus/test";
14​
15import * as ServerSettings from "../serverSettings.ts";
16import { SECRET_REDACTED } from "../settingsSecrets.ts";
17import * as TextGenerators from "./TextGenerators.ts";
18​
19const ID = ProviderInstanceId.make("router");
20​
21const layer = (generator: Partial<TextGeneratorConfig>, policy?: ManagedPolicySummary) =>
22 Layer.effect(TextGenerators.TextGenerators, TextGenerators.make).pipe(
23 Layer.provideMerge(
24 ServerSettings.layerTest(
25 {
26 textGenerators: {
27 [ID]: { kind: "openaiCompatible", baseUrl: "https://api.test/v1", ...generator },
28 },
29 },
30 policy,
31 ),
32 ),
33 Layer.provide(FetchHttpClient.layer),
34 Layer.provide(Layer.mock(SecretResolver.SecretResolver)({})),
35 );
36​
37const request = {
38 operation: "generateThreadTitle",
39 cwd: "/unused",
40 prompt: "Name this thread.",
41 outputSchema: Schema.Struct({ title: Schema.String }),
42 modelSelection: { instanceId: ID, model: "some/model" },
43} as const;
44​
45it.effect.each([
46 {
47 kind: "openaiCompatible",
48 path: "/v1/chat/completions",
49 auth: ["authorization", "Bearer secret"],
50 reply: { choices: [{ finish_reason: "stop", message: { content: '{"title":"Fix login"}' } }] },
51 },
52 {
53 kind: "anthropic",
54 path: "/v1/messages",
55 auth: ["x-api-key", "secret"],
56 reply: { stop_reason: "end_turn", content: [{ type: "text", text: '{"title":"Fix login"}' }] },
57 },
58] as const)("$kind generates structured text with its API's request shape", (api) =>
59 Effect.gen(function* () {
60 const upstream = vi.fn<typeof fetch>(async (url, init) => {
61 expect(new URL(String(url)).pathname).toBe(api.path);
62 expect(init?.redirect).toBe("error");
63 expect(new Headers(init?.headers).get(api.auth[0])).toBe(api.auth[1]);
64 const body = JSON.parse(String(init?.body));
65 expect(body.model).toBe("some/model");
66 expect(body.messages).toEqual([{ role: "user", content: "Name this thread." }]);
67 return Response.json(api.reply);
68 });
69 const generators = yield* TextGenerators.TextGenerators.pipe(
70 Effect.provide(layer({ kind: api.kind, apiKey: "secret" })),
71 );
72 const run = yield* generators.runner(ID);
73 expect(run).toBeDefined();
74 const result = yield* run!(request).pipe(
75 Effect.provideService(FetchHttpClient.Fetch, upstream),
76 );
77 expect(result).toEqual({ title: "Fix login" });
78 expect(yield* generators.runner(ProviderInstanceId.make("codex"))).toBeUndefined();
79​
80 upstream.mockResolvedValueOnce(
81 Response.json({ error: { message: "Incorrect API key provided" } }, { status: 401 }),
82 );
83 const failure = yield* Effect.flip(
84 run!(request).pipe(Effect.provideService(FetchHttpClient.Fetch, upstream)),
85 );
86 expect(failure.detail).toMatch(/rejected the API key: Incorrect API key provided$/);
87 }),
88);
89​
90it.effect("a turned-off text generator fails instead of calling its API", () =>
91 Effect.gen(function* () {
92 const upstream = vi.fn<typeof fetch>();
93 const generators = yield* TextGenerators.TextGenerators.pipe(
94 Effect.provide(layer({ enabled: false, label: "OpenRouter" })),
95 );
96 const run = yield* generators.runner(ID);
97 const failure = yield* Effect.flip(
98 run!(request).pipe(Effect.provideService(FetchHttpClient.Fetch, upstream)),
99 );
100 expect(failure.detail).toBe("OpenRouter is off. Turn it on in Settings → Providers.");
101 expect(upstream).not.toHaveBeenCalled();
102 }),
103);
104​
105it.effect("lists models once per config, following Anthropic pagination, until refreshed", () =>
106 Effect.gen(function* () {
107 const upstream = vi.fn<typeof fetch>(async (url) => {
108 const parsed = new URL(String(url));
109 expect(parsed.pathname).toBe("/v1/models");
110 return parsed.searchParams.get("after_id") === "claude-b"
111 ? Response.json({ data: [{ id: "claude-a" }], has_more: false })
112 : Response.json({
113 data: [{ id: "claude-b", display_name: "Claude B" }],
114 has_more: true,
115 last_id: "claude-b",
116 });
117 });
118 const program = Effect.gen(function* () {
119 const generators = yield* TextGenerators.TextGenerators;
120 const settings = yield* ServerSettings.ServerSettingsService;
121 const list = (refresh?: boolean) =>
122 generators
123 .listModels({ id: ID, ...(refresh ? { refresh } : {}) })
124 .pipe(Effect.provideService(FetchHttpClient.Fetch, upstream));
125​
126 expect((yield* list()).models).toEqual([
127 { id: "claude-a", name: "claude-a" },
128 { id: "claude-b", name: "Claude B" },
129 ]);
130 yield* list();
131 expect(upstream).toHaveBeenCalledTimes(2);
132​
133 yield* list(true);
134 expect(upstream).toHaveBeenCalledTimes(4);
135​
136 yield* settings.updateSettings({
137 textGenerators: {
138 [ID]: {
139 kind: "anthropic",
140 baseUrl: "https://api.test/v1",
141 apiKey: "rotated",
142 enabled: true,
143 },
144 },
145 });
146 yield* list();
147 expect(upstream).toHaveBeenCalledTimes(6);
148 });
149 yield* program.pipe(Effect.provide(layer({ kind: "anthropic", apiKey: "secret" })));
150 }),
151);
152​
153it.effect("checks unsaved settings, keeping the saved key behind a redaction marker", () =>
154 Effect.gen(function* () {
155 const seen: Array<{ url: string; auth: string | null }> = [];
156 const upstream = vi.fn<typeof fetch>(async (url, init) => {
157 seen.push({ url: String(url), auth: new Headers(init?.headers).get("authorization") });
158 return String(url).endsWith("/chat/completions")
159 ? Response.json({
160 model: JSON.parse(String(init?.body)).model,
161 choices: [{ finish_reason: "stop", message: { content: "OK" } }],
162 })
163 : Response.json({ data: [{ id: "model-a" }] });
164 });
165 const generators = yield* TextGenerators.TextGenerators.pipe(
166 Effect.provide(layer({ apiKey: "saved-key" })),
167 );
168 const list = (id: string, config: Partial<TextGeneratorConfig>) =>
169 generators
170 .listModels({
171 id: ProviderInstanceId.make(id),
172 config: {
173 kind: "openaiCompatible",
174 baseUrl: "https://new.test/v1",
175 apiKey: "",
176 enabled: true,
177 ...config,
178 },
179 })
180 .pipe(Effect.provideService(FetchHttpClient.Fetch, upstream));
181​
182 expect((yield* list("draft", { apiKey: "typed-key" })).models).toEqual([
183 { id: "model-a", name: "model-a" },
184 ]);
185 yield* list(ID, { apiKey: SECRET_REDACTED });
186 expect(seen).toEqual([
187 { url: "https://new.test/v1/models", auth: "Bearer typed-key" },
188 { url: "https://new.test/v1/models", auth: "Bearer saved-key" },
189 ]);
190​
191 // Listing alone proves nothing about the key on some APIs; a test asks the model itself.
192 const tested = yield* generators
193 .listModels({ id: ID, test: true, model: "chosen/model" })
194 .pipe(Effect.provideService(FetchHttpClient.Fetch, upstream));
195 expect(tested.testedModel).toBe("chosen/model");
196 expect(seen.at(-1)).toEqual({
197 url: "https://api.test/v1/chat/completions",
198 auth: "Bearer saved-key",
199 });
200​
201 // An allow-list names agent drivers only, so no unsaved config may reach an API either.
202 const managed = yield* TextGenerators.TextGenerators.pipe(
203 Effect.provide(
204 layer(
205 { apiKey: "saved-key" },
206 {
207 organization: "Example Corp",
208 lockedSettings: [],
209 limits: {
210 providers: {
211 allowedDrivers: [ProviderDriverKind.make("codex")],
212 lockedInstanceFields: {},
213 },
214 },
215 },
216 ),
217 ),
218 );
219 const requestsBefore = seen.length;
220 const refused = yield* managed
221 .listModels({
222 id: ProviderInstanceId.make("draft"),
223 config: {
224 kind: "openaiCompatible",
225 baseUrl: "https://new.test/v1",
226 apiKey: "k",
227 enabled: true,
228 },
229 test: true,
230 })
231 .pipe(Effect.provideService(FetchHttpClient.Fetch, upstream), Effect.flip);
232 expect(refused.detail).toContain("policy");
233 expect(seen).toHaveLength(requestsBefore);
234 }),
235);