fix(httpapi): expose v2 catalog errors (#28498)
This commit is contained in:
@@ -62,6 +62,8 @@ type Trace = {
|
|||||||
write(type: string, data?: unknown): void
|
write(type: string, data?: unknown): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const StreamClosed = undefined as never
|
||||||
|
|
||||||
type StreamInput = {
|
type StreamInput = {
|
||||||
sdk: OpencodeClient
|
sdk: OpencodeClient
|
||||||
directory?: string
|
directory?: string
|
||||||
@@ -418,12 +420,12 @@ function createLayer(input: StreamInput) {
|
|||||||
),
|
),
|
||||||
(events) =>
|
(events) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
void events.stream.return(undefined).catch(() => {})
|
void events.stream.return(StreamClosed).catch(() => {})
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
closeStream = () => {
|
closeStream = () => {
|
||||||
void events.stream.return(undefined).catch(() => {})
|
void events.stream.return(StreamClosed).catch(() => {})
|
||||||
}
|
}
|
||||||
input.trace?.write("recv.subscribe", {
|
input.trace?.write("recv.subscribe", {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
import { ServiceUnavailableError } from "../../errors"
|
||||||
import { V2Authorization } from "../../middleware/authorization"
|
import { V2Authorization } from "../../middleware/authorization"
|
||||||
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
|
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export const ModelGroup = HttpApiGroup.make("v2.model")
|
|||||||
HttpApiEndpoint.get("models", "/api/model", {
|
HttpApiEndpoint.get("models", "/api/model", {
|
||||||
query: LocationQuery,
|
query: LocationQuery,
|
||||||
success: Schema.Array(ModelV2.Info),
|
success: Schema.Array(ModelV2.Info),
|
||||||
|
error: ServiceUnavailableError,
|
||||||
})
|
})
|
||||||
.annotateMerge(locationQueryOpenApi)
|
.annotateMerge(locationQueryOpenApi)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { ApiNotFoundError } from "../../errors"
|
import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors"
|
||||||
import { V2Authorization } from "../../middleware/authorization"
|
import { V2Authorization } from "../../middleware/authorization"
|
||||||
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
|
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
|
|||||||
HttpApiEndpoint.get("providers", "/api/provider", {
|
HttpApiEndpoint.get("providers", "/api/provider", {
|
||||||
query: LocationQuery,
|
query: LocationQuery,
|
||||||
success: Schema.Array(ProviderV2.Info),
|
success: Schema.Array(ProviderV2.Info),
|
||||||
|
error: ServiceUnavailableError,
|
||||||
})
|
})
|
||||||
.annotateMerge(locationQueryOpenApi)
|
.annotateMerge(locationQueryOpenApi)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
@@ -25,7 +26,7 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
|
|||||||
params: { providerID: ProviderV2.ID },
|
params: { providerID: ProviderV2.ID },
|
||||||
query: LocationQuery,
|
query: LocationQuery,
|
||||||
success: ProviderV2.Info,
|
success: ProviderV2.Info,
|
||||||
error: ApiNotFoundError,
|
error: [ProviderNotFoundError, ServiceUnavailableError],
|
||||||
})
|
})
|
||||||
.annotateMerge(locationQueryOpenApi)
|
.annotateMerge(locationQueryOpenApi)
|
||||||
.annotateMerge(
|
.annotateMerge(
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { InstanceHttpApi } from "../../api"
|
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) =>
|
export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -11,7 +17,7 @@ export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (
|
|||||||
Effect.fn(function* () {
|
Effect.fn(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const pluginBoot = yield* PluginBoot.Service
|
const pluginBoot = yield* PluginBoot.Service
|
||||||
yield* pluginBoot.wait()
|
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
|
||||||
return yield* catalog.model.available()
|
return yield* catalog.model.available()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { InstanceHttpApi } from "../../api"
|
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) =>
|
export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provider", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -13,7 +18,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provid
|
|||||||
Effect.fn(function* () {
|
Effect.fn(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const pluginBoot = yield* PluginBoot.Service
|
const pluginBoot = yield* PluginBoot.Service
|
||||||
yield* pluginBoot.wait()
|
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
|
||||||
return yield* catalog.provider.available()
|
return yield* catalog.provider.available()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -22,10 +27,21 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provid
|
|||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const pluginBoot = yield* PluginBoot.Service
|
const pluginBoot = yield* PluginBoot.Service
|
||||||
yield* pluginBoot.wait()
|
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
|
||||||
return yield* catalog.provider
|
return yield* catalog.provider
|
||||||
.get(ctx.params.providerID)
|
.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}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -98,12 +98,14 @@ function assistant(id: string) {
|
|||||||
} satisfies SdkEvent
|
} satisfies SdkEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
function feed<T>() {
|
const StreamClosed = undefined as never
|
||||||
|
|
||||||
|
function feed<T, R = never>(returnValue: R = StreamClosed) {
|
||||||
const list: T[] = []
|
const list: T[] = []
|
||||||
let done = false
|
let done = false
|
||||||
let wake: (() => void) | undefined
|
let wake: (() => void) | undefined
|
||||||
|
|
||||||
const wrapped = (async function* () {
|
const wrapped = (async function* (): AsyncGenerator<T, R, unknown> {
|
||||||
while (!done || list.length > 0) {
|
while (!done || list.length > 0) {
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
@@ -119,6 +121,7 @@ function feed<T>() {
|
|||||||
|
|
||||||
yield next
|
yield next
|
||||||
}
|
}
|
||||||
|
return returnValue as R
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -166,10 +169,11 @@ function globalSse(stream: GlobalEventStream) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wrapGlobalStream(stream: EventStream): GlobalEventStream {
|
function wrapGlobalStream(stream: EventStream): GlobalEventStream {
|
||||||
return (async function* () {
|
return (async function* (): GlobalEventStream {
|
||||||
for await (const event of stream) {
|
for await (const event of stream) {
|
||||||
yield globalEvent(event)
|
yield globalEvent(event)
|
||||||
}
|
}
|
||||||
|
return StreamClosed
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,26 @@ function setEnvScoped(key: string, value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("provider HttpApi", () => {
|
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(
|
it.instance(
|
||||||
"serves OAuth authorize response shapes",
|
"serves OAuth authorize response shapes",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
|||||||
return ref ? [`${route.method.toUpperCase()} ${route.path} ${status} ${componentName(ref)}`] : []
|
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", () => {
|
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)
|
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",
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user