1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Fiber from "effect/Fiber";
4import * as Layer from "effect/Layer";
5import * as TestClock from "effect/testing/TestClock";
6import { HttpClient, HttpClientResponse } from "effect/http";
7import { describe } from "vite-plus/test";
8
9import { loadOpenCode2Catalog } from "./openCode2Catalog.ts";
10import * as OpenCode2Client from "./v2/OpenCode2Client.ts";
11
12/**
13 * `GET /api/model` from an `opencode serve` 2.0.26 with credentials for both
14 * gateways, cut to the one model each of them lists. Step 5 Preview Free is
15 * the whole point: both providers list it, with the same name.
16 */
17const MODELS_BODY = `{"location":{"directory":"/project"},"data":[{"id":"step-5-preview-free","modelID":"step-5-preview-free","providerID":"opencode-go","name":"Step 5 Preview Free","compatibility":{"reasoningField":"reasoning_content"},"package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"sk-demo-not-real","baseURL":"http://127.0.0.1:48990/v1","provider":"opencode-go"},"capabilities":{"tools":true,"input":["text","image","video"],"output":["text"]},"variants":[{"id":"low","settings":{"reasoningEffort":"low"}},{"id":"medium","settings":{"reasoningEffort":"medium"}},{"id":"high","settings":{"reasoningEffort":"high"}}],"time":{"released":1789516800000},"cost":[{"input":0,"output":0,"cache":{"read":0,"write":0}}],"status":"active","enabled":true,"limit":{"context":1000000,"input":1000000,"output":65536}},{"id":"step-5-preview-free","modelID":"step-5-preview-free","providerID":"opencode","name":"Step 5 Preview Free","compatibility":{"reasoningField":"reasoning_content"},"package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"public","baseURL":"https://opencode.ai/zen/v1","provider":"opencode"},"capabilities":{"tools":true,"input":["text","image","video"],"output":["text"]},"variants":[{"id":"low","settings":{"reasoningEffort":"low"}},{"id":"medium","settings":{"reasoningEffort":"medium"}},{"id":"high","settings":{"reasoningEffort":"high"}}],"time":{"released":1789516800000},"cost":[{"input":0,"output":0,"cache":{"read":0,"write":0}}],"status":"active","enabled":true,"limit":{"context":1000000,"input":1000000,"output":65536}}]}`;
18
19/** `GET /api/provider` from the same server. */
20const PROVIDERS_BODY = `{"location":{"directory":"/project"},"data":[{"id":"opencode-go","integrationID":"opencode-go","name":"OpenCode Go","activation":"enabled","package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"sk-demo-not-real","baseURL":"http://127.0.0.1:48990/v1","provider":"opencode-go"}},{"id":"opencode","integrationID":"opencode","name":"OpenCode Zen","activation":"enabled","package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"public","baseURL":"https://opencode.ai/zen/v1"}}]}`;
21
22const layerServerReplying = (
23 bodies: Readonly<Record<string, string>>,
24 hanging: ReadonlyArray<string> = [],
25) =>
26 Layer.succeed(
27 HttpClient.HttpClient,
28 HttpClient.make((request) =>
29 Effect.suspend(() => {
30 const path = new URL(request.url).pathname;
31 if (hanging.includes(path)) {
32 return Effect.never;
33 }
34 const body = bodies[path];
35 return Effect.succeed(
36 HttpClientResponse.fromWeb(
37 request,
38 new Response(body ?? '{"_tag":"NotFoundError","message":"not recorded"}', {
39 status: body === undefined ? 404 : 200,
40 headers: { "content-type": "application/json" },
41 }),
42 ),
43 );
44 }),
45 ),
46 );
47
48/**
49 * Reads a catalog the way the driver does, through the client and a
50 * connection. `hanging` names paths the server accepts but never answers.
51 */
52const readCatalog = (
53 bodies: Readonly<Record<string, string>>,
54 hanging: ReadonlyArray<string> = [],
55) =>
56 Effect.gen(function* () {
57 const opencode = yield* OpenCode2Client.OpenCode2Client;
58 const { client } = yield* opencode.connect({
59 baseUrl: "http://127.0.0.1:4096",
60 password: "secret",
61 });
62 return yield* loadOpenCode2Catalog(client, "/project");
63 }).pipe(
64 Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServerReplying(bodies, hanging)))),
65 );
66
67describe("loadOpenCode2Catalog", () => {
68 it.effect("names the provider each listed model came from", () =>
69 Effect.gen(function* () {
70 const models = yield* readCatalog({
71 "/api/model": MODELS_BODY,
72 "/api/provider": PROVIDERS_BODY,
73 });
74
75 assert.deepStrictEqual(
76 models.map((model) => [`${model.providerID}/${model.id}`, model.providerName]),
77 [
78 ["opencode-go/step-5-preview-free", "OpenCode Go"],
79 ["opencode/step-5-preview-free", "OpenCode Zen"],
80 ],
81 );
82 }),
83 );
84
85 it.effect("keeps the variants a model advertised", () =>
86 Effect.gen(function* () {
87 const models = yield* readCatalog({
88 "/api/model": MODELS_BODY,
89 "/api/provider": PROVIDERS_BODY,
90 });
91
92 assert.deepStrictEqual(
93 models.map((model) => model.variants),
94 [
95 [{ id: "low" }, { id: "medium" }, { id: "high" }],
96 [{ id: "low" }, { id: "medium" }, { id: "high" }],
97 ],
98 );
99 }),
100 );
101
102 // A server that cannot name its providers still lists its models, and the
103 // status check labels those with the provider id instead.
104 it.effect("leaves a model unnamed when the provider list fails", () =>
105 Effect.gen(function* () {
106 const models = yield* readCatalog({ "/api/model": MODELS_BODY });
107
108 assert.deepStrictEqual(
109 models.map((model) => [`${model.providerID}/${model.id}`, model.providerName]),
110 [
111 ["opencode-go/step-5-preview-free", undefined],
112 ["opencode/step-5-preview-free", undefined],
113 ],
114 );
115 }),
116 );
117
118 // Names are decoration: a provider read that never answers must not hold the
119 // models back, because the models are what the snapshot needs.
120 it.effect("keeps reading models while a provider list stays pending", () =>
121 Effect.gen(function* () {
122 const reading = yield* readCatalog({ "/api/model": MODELS_BODY }, ["/api/provider"]).pipe(
123 Effect.forkChild,
124 );
125 yield* TestClock.adjust("5 seconds");
126 const models = yield* Fiber.join(reading);
127
128 assert.deepStrictEqual(
129 models.map((model) => [`${model.providerID}/${model.id}`, model.providerName]),
130 [
131 ["opencode-go/step-5-preview-free", undefined],
132 ["opencode/step-5-preview-free", undefined],
133 ],
134 );
135 }).pipe(Effect.provide(TestClock.layer())),
136 );
137
138 it.effect("fails the read when the model list fails", () =>
139 Effect.gen(function* () {
140 const error = yield* readCatalog({
141 "/api/model": '{"_tag":"InternalServerError","message":"catalog unavailable"}',
142 "/api/provider": PROVIDERS_BODY,
143 }).pipe(Effect.flip);
144
145 assert.strictEqual(error.operation, "model.list");
146 assert.match(error.detail, /could not list its models/i);
147 }),
148 );
149});