Skip to content

Commit 4308dd7

Browse files
authored
fix(httpapi): expose v2 catalog errors (anomalyco#28498)
1 parent 7690481 commit 4308dd7

8 files changed

Lines changed: 82 additions & 14 deletions

File tree

‎packages/opencode/src/cli/cmd/run/stream.transport.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ type Trace = {
6262
write(type: string, data?: unknown): void
6363
}
6464

65+
const StreamClosed = undefined as never
66+
6567
type StreamInput = {
6668
sdk: OpencodeClient
6769
directory?: string
@@ -418,12 +420,12 @@ function createLayer(input: StreamInput) {
418420
),
419421
(events) =>
420422
Effect.sync(() => {
421-
void events.stream.return(undefined).catch(() => {})
423+
void events.stream.return(StreamClosed).catch(() => {})
422424
}),
423425
),
424426
)
425427
closeStream = () => {
426-
void events.stream.return(undefined).catch(() => {})
428+
void events.stream.return(StreamClosed).catch(() => {})
427429
}
428430
input.trace?.write("recv.subscribe", {
429431
sessionID: input.sessionID,

‎packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ModelV2 } from "@opencode-ai/core/model"
22
import { Schema } from "effect"
33
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
4+
import { ServiceUnavailableError } from "../../errors"
45
import { V2Authorization } from "../../middleware/authorization"
56
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
67

@@ -9,6 +10,7 @@ export const ModelGroup = HttpApiGroup.make("v2.model")
910
HttpApiEndpoint.get("models", "/api/model", {
1011
query: LocationQuery,
1112
success: Schema.Array(ModelV2.Info),
13+
error: ServiceUnavailableError,
1214
})
1315
.annotateMerge(locationQueryOpenApi)
1416
.annotateMerge(

‎packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ProviderV2 } from "@opencode-ai/core/provider"
22
import { Schema } from "effect"
33
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
4-
import { ApiNotFoundError } from "../../errors"
4+
import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors"
55
import { V2Authorization } from "../../middleware/authorization"
66
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
77

@@ -10,6 +10,7 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
1010
HttpApiEndpoint.get("providers", "/api/provider", {
1111
query: LocationQuery,
1212
success: Schema.Array(ProviderV2.Info),
13+
error: ServiceUnavailableError,
1314
})
1415
.annotateMerge(locationQueryOpenApi)
1516
.annotateMerge(
@@ -25,7 +26,7 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
2526
params: { providerID: ProviderV2.ID },
2627
query: LocationQuery,
2728
success: ProviderV2.Info,
28-
error: ApiNotFoundError,
29+
error: [ProviderNotFoundError, ServiceUnavailableError],
2930
})
3031
.annotateMerge(locationQueryOpenApi)
3132
.annotateMerge(

‎packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { PluginBoot } from "@opencode-ai/core/plugin/boot"
33
import { Effect } from "effect"
44
import { HttpApiBuilder } from "effect/unstable/httpapi"
55
import { InstanceHttpApi } from "../../api"
6+
import { ServiceUnavailableError } from "../../errors"
7+
8+
const catalogUnavailable = new ServiceUnavailableError({
9+
message: "Model catalog is unavailable",
10+
service: "catalog",
11+
})
612

713
export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (handlers) =>
814
Effect.gen(function* () {
@@ -11,7 +17,7 @@ export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (
1117
Effect.fn(function* () {
1218
const catalog = yield* Catalog.Service
1319
const pluginBoot = yield* PluginBoot.Service
14-
yield* pluginBoot.wait()
20+
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
1521
return yield* catalog.model.available()
1622
}),
1723
)

‎packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import { PluginBoot } from "@opencode-ai/core/plugin/boot"
33
import { Effect } from "effect"
44
import { HttpApiBuilder } from "effect/unstable/httpapi"
55
import { InstanceHttpApi } from "../../api"
6-
import { notFound } from "../../errors"
6+
import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors"
7+
8+
const catalogUnavailable = new ServiceUnavailableError({
9+
message: "Provider catalog is unavailable",
10+
service: "catalog",
11+
})
712

813
export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provider", (handlers) =>
914
Effect.gen(function* () {
@@ -13,7 +18,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provid
1318
Effect.fn(function* () {
1419
const catalog = yield* Catalog.Service
1520
const pluginBoot = yield* PluginBoot.Service
16-
yield* pluginBoot.wait()
21+
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
1722
return yield* catalog.provider.available()
1823
}),
1924
)
@@ -22,10 +27,21 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provid
2227
Effect.fn(function* (ctx) {
2328
const catalog = yield* Catalog.Service
2429
const pluginBoot = yield* PluginBoot.Service
25-
yield* pluginBoot.wait()
30+
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
2631
return yield* catalog.provider
2732
.get(ctx.params.providerID)
28-
.pipe(Effect.catchTag("CatalogV2.ProviderNotFound", () => Effect.fail(notFound("Provider not found"))))
33+
.pipe(
34+
Effect.catchTag(
35+
"CatalogV2.ProviderNotFound",
36+
(error) =>
37+
Effect.fail(
38+
new ProviderNotFoundError({
39+
providerID: error.providerID,
40+
message: `Provider not found: ${error.providerID}`,
41+
}),
42+
),
43+
),
44+
)
2945
}),
3046
)
3147
}),

‎packages/opencode/test/cli/run/stream.transport.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,14 @@ function assistant(id: string) {
9898
} satisfies SdkEvent
9999
}
100100

101-
function feed<T>() {
101+
const StreamClosed = undefined as never
102+
103+
function feed<T, R = never>(returnValue: R = StreamClosed) {
102104
const list: T[] = []
103105
let done = false
104106
let wake: (() => void) | undefined
105107

106-
const wrapped = (async function* () {
108+
const wrapped = (async function* (): AsyncGenerator<T, R, unknown> {
107109
while (!done || list.length > 0) {
108110
if (list.length === 0) {
109111
await new Promise<void>((resolve) => {
@@ -119,6 +121,7 @@ function feed<T>() {
119121

120122
yield next
121123
}
124+
return returnValue as R
122125
})()
123126

124127
return {
@@ -166,10 +169,11 @@ function globalSse(stream: GlobalEventStream) {
166169
}
167170

168171
function wrapGlobalStream(stream: EventStream): GlobalEventStream {
169-
return (async function* () {
172+
return (async function* (): GlobalEventStream {
170173
for await (const event of stream) {
171174
yield globalEvent(event)
172175
}
176+
return StreamClosed
173177
})()
174178
}
175179

‎packages/opencode/test/server/httpapi-provider.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,26 @@ function setEnvScoped(key: string, value: string) {
274274
}
275275

276276
describe("provider HttpApi", () => {
277+
it.instance.skip(
278+
"returns public v2 provider not found errors",
279+
Effect.gen(function* () {
280+
const instance = yield* TestInstance
281+
const response = yield* Effect.promise(() =>
282+
Promise.resolve(
283+
app().request("/api/provider/missing", { headers: { "x-opencode-directory": instance.directory } }),
284+
),
285+
)
286+
287+
expect(response.status).toBe(404)
288+
expect(yield* Effect.promise(() => response.json())).toEqual({
289+
_tag: "ProviderNotFoundError",
290+
providerID: "missing",
291+
message: "Provider not found: missing",
292+
})
293+
}),
294+
projectOptions,
295+
)
296+
277297
it.instance(
278298
"serves OAuth authorize response shapes",
279299
Effect.gen(function* () {

‎packages/opencode/test/server/httpapi-public-openapi.test.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ describe("PublicApi OpenAPI v2 errors", () => {
6161
return ref ? [`${route.method.toUpperCase()} ${route.path} ${status} ${componentName(ref)}`] : []
6262
}),
6363
)
64-
.filter((entry) => entry.includes("BadRequestError") || entry.includes("NotFoundError"))
64+
.filter((entry) => entry.endsWith(" BadRequestError") || entry.endsWith(" NotFoundError"))
6565

66-
expect(refs).toEqual(["GET /api/provider/{providerID} 404 NotFoundError"])
66+
expect(refs).toEqual([])
6767
})
6868

6969
test("new /api endpoint errors cannot use built-in components without an explicit allowlist", () => {
@@ -82,4 +82,21 @@ describe("PublicApi OpenAPI v2 errors", () => {
8282

8383
expect(builtInEndpointErrors).toEqual(allowedV2BuiltInEndpointErrors)
8484
})
85+
86+
test("documents v2 provider and model catalog errors", () => {
87+
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
88+
89+
expect(componentName(responseRef(spec.paths["/api/provider"]?.get?.responses?.["503"]) ?? "")).toBe(
90+
"ServiceUnavailableError",
91+
)
92+
expect(componentName(responseRef(spec.paths["/api/model"]?.get?.responses?.["503"]) ?? "")).toBe(
93+
"ServiceUnavailableError",
94+
)
95+
expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["404"]) ?? "")).toBe(
96+
"ProviderNotFoundError",
97+
)
98+
expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["503"]) ?? "")).toBe(
99+
"ServiceUnavailableError",
100+
)
101+
})
85102
})

0 commit comments

Comments
 (0)