Ver Fonte

fix(httpapi): expose v2 catalog errors (#28498)

Shoubhit Dash há 3 meses atrás
pai
commit
4308dd75fb

+ 4 - 2
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,

+ 2 - 0
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(

+ 3 - 2
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(

+ 7 - 1
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()
       }),
     )

+ 20 - 4
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}`,
+                    }),
+                  ),
+              ),
+            )
         }),
       )
   }),

+ 7 - 3
packages/opencode/test/cli/run/stream.transport.test.ts

@@ -98,12 +98,14 @@ function assistant(id: string) {
   } satisfies SdkEvent
 }
 
-function feed<T>() {
+const StreamClosed = undefined as never
+
+function feed<T, R = never>(returnValue: R = StreamClosed) {
   const list: T[] = []
   let done = false
   let wake: (() => void) | undefined
 
-  const wrapped = (async function* () {
+  const wrapped = (async function* (): AsyncGenerator<T, R, unknown> {
     while (!done || list.length > 0) {
       if (list.length === 0) {
         await new Promise<void>((resolve) => {
@@ -119,6 +121,7 @@ function feed<T>() {
 
       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
   })()
 }
 

+ 20 - 0
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* () {

+ 19 - 2
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",
+    )
+  })
 })