From 4308dd75fb303e2f3dee12d8a75f863c4ac884ad Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 21 May 2026 00:53:35 +0530 Subject: [PATCH] fix(httpapi): expose v2 catalog errors (#28498) --- .../src/cli/cmd/run/stream.transport.ts | 6 +++-- .../instance/httpapi/groups/v2/model.ts | 2 ++ .../instance/httpapi/groups/v2/provider.ts | 5 ++-- .../instance/httpapi/handlers/v2/model.ts | 8 ++++++- .../instance/httpapi/handlers/v2/provider.ts | 24 +++++++++++++++---- .../test/cli/run/stream.transport.test.ts | 10 +++++--- .../test/server/httpapi-provider.test.ts | 20 ++++++++++++++++ .../server/httpapi-public-openapi.test.ts | 21 ++++++++++++++-- 8 files changed, 82 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index 2977d03f69..41a083c702 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -62,6 +62,8 @@ type Trace = { write(type: string, data?: unknown): void } +const StreamClosed = undefined as never + type StreamInput = { sdk: OpencodeClient directory?: string @@ -418,12 +420,12 @@ function createLayer(input: StreamInput) { ), (events) => Effect.sync(() => { - void events.stream.return(undefined).catch(() => {}) + void events.stream.return(StreamClosed).catch(() => {}) }), ), ) closeStream = () => { - void events.stream.return(undefined).catch(() => {}) + void events.stream.return(StreamClosed).catch(() => {}) } input.trace?.write("recv.subscribe", { sessionID: input.sessionID, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts index b2586387d5..2f52ff23d4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts @@ -1,6 +1,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { ServiceUnavailableError } from "../../errors" import { V2Authorization } from "../../middleware/authorization" import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location" @@ -9,6 +10,7 @@ export const ModelGroup = HttpApiGroup.make("v2.model") HttpApiEndpoint.get("models", "/api/model", { query: LocationQuery, success: Schema.Array(ModelV2.Info), + error: ServiceUnavailableError, }) .annotateMerge(locationQueryOpenApi) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts index deebcdc9f4..2038ddfedd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts @@ -1,7 +1,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { ApiNotFoundError } from "../../errors" +import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors" import { V2Authorization } from "../../middleware/authorization" import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location" @@ -10,6 +10,7 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider") HttpApiEndpoint.get("providers", "/api/provider", { query: LocationQuery, success: Schema.Array(ProviderV2.Info), + error: ServiceUnavailableError, }) .annotateMerge(locationQueryOpenApi) .annotateMerge( @@ -25,7 +26,7 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider") params: { providerID: ProviderV2.ID }, query: LocationQuery, success: ProviderV2.Info, - error: ApiNotFoundError, + error: [ProviderNotFoundError, ServiceUnavailableError], }) .annotateMerge(locationQueryOpenApi) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts index 9ba4c654a9..4a748ef9b7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts @@ -3,6 +3,12 @@ import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" +import { ServiceUnavailableError } from "../../errors" + +const catalogUnavailable = new ServiceUnavailableError({ + message: "Model catalog is unavailable", + service: "catalog", +}) export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (handlers) => Effect.gen(function* () { @@ -11,7 +17,7 @@ export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", ( Effect.fn(function* () { const catalog = yield* Catalog.Service const pluginBoot = yield* PluginBoot.Service - yield* pluginBoot.wait() + yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) return yield* catalog.model.available() }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts index a77cb159c8..e520a937f6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts @@ -3,7 +3,12 @@ import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" -import { notFound } from "../../errors" +import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors" + +const catalogUnavailable = new ServiceUnavailableError({ + message: "Provider catalog is unavailable", + service: "catalog", +}) export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provider", (handlers) => Effect.gen(function* () { @@ -13,7 +18,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provid Effect.fn(function* () { const catalog = yield* Catalog.Service const pluginBoot = yield* PluginBoot.Service - yield* pluginBoot.wait() + yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) return yield* catalog.provider.available() }), ) @@ -22,10 +27,21 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provid Effect.fn(function* (ctx) { const catalog = yield* Catalog.Service const pluginBoot = yield* PluginBoot.Service - yield* pluginBoot.wait() + yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) return yield* catalog.provider .get(ctx.params.providerID) - .pipe(Effect.catchTag("CatalogV2.ProviderNotFound", () => Effect.fail(notFound("Provider not found")))) + .pipe( + Effect.catchTag( + "CatalogV2.ProviderNotFound", + (error) => + Effect.fail( + new ProviderNotFoundError({ + providerID: error.providerID, + message: `Provider not found: ${error.providerID}`, + }), + ), + ), + ) }), ) }), diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index bc40bdf908..d1b145db24 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -98,12 +98,14 @@ function assistant(id: string) { } satisfies SdkEvent } -function feed() { +const StreamClosed = undefined as never + +function feed(returnValue: R = StreamClosed) { const list: T[] = [] let done = false let wake: (() => void) | undefined - const wrapped = (async function* () { + const wrapped = (async function* (): AsyncGenerator { while (!done || list.length > 0) { if (list.length === 0) { await new Promise((resolve) => { @@ -119,6 +121,7 @@ function feed() { yield next } + return returnValue as R })() return { @@ -166,10 +169,11 @@ function globalSse(stream: GlobalEventStream) { } function wrapGlobalStream(stream: EventStream): GlobalEventStream { - return (async function* () { + return (async function* (): GlobalEventStream { for await (const event of stream) { yield globalEvent(event) } + return StreamClosed })() } diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index d11ecc85ec..25181f3b2d 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -274,6 +274,26 @@ function setEnvScoped(key: string, value: string) { } describe("provider HttpApi", () => { + it.instance.skip( + "returns public v2 provider not found errors", + Effect.gen(function* () { + const instance = yield* TestInstance + const response = yield* Effect.promise(() => + Promise.resolve( + app().request("/api/provider/missing", { headers: { "x-opencode-directory": instance.directory } }), + ), + ) + + expect(response.status).toBe(404) + expect(yield* Effect.promise(() => response.json())).toEqual({ + _tag: "ProviderNotFoundError", + providerID: "missing", + message: "Provider not found: missing", + }) + }), + projectOptions, + ) + it.instance( "serves OAuth authorize response shapes", Effect.gen(function* () { diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index 9a437029f1..dea89be20e 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -61,9 +61,9 @@ describe("PublicApi OpenAPI v2 errors", () => { return ref ? [`${route.method.toUpperCase()} ${route.path} ${status} ${componentName(ref)}`] : [] }), ) - .filter((entry) => entry.includes("BadRequestError") || entry.includes("NotFoundError")) + .filter((entry) => entry.endsWith(" BadRequestError") || entry.endsWith(" NotFoundError")) - expect(refs).toEqual(["GET /api/provider/{providerID} 404 NotFoundError"]) + expect(refs).toEqual([]) }) test("new /api endpoint errors cannot use built-in components without an explicit allowlist", () => { @@ -82,4 +82,21 @@ describe("PublicApi OpenAPI v2 errors", () => { expect(builtInEndpointErrors).toEqual(allowedV2BuiltInEndpointErrors) }) + + test("documents v2 provider and model catalog errors", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + + expect(componentName(responseRef(spec.paths["/api/provider"]?.get?.responses?.["503"]) ?? "")).toBe( + "ServiceUnavailableError", + ) + expect(componentName(responseRef(spec.paths["/api/model"]?.get?.responses?.["503"]) ?? "")).toBe( + "ServiceUnavailableError", + ) + expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["404"]) ?? "")).toBe( + "ProviderNotFoundError", + ) + expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["503"]) ?? "")).toBe( + "ServiceUnavailableError", + ) + }) })