Przeglądaj źródła

fix(server): keep provider lists JSON-safe (#26550)

Kit Langton 4 miesięcy temu
rodzic
commit
27fa297a42

+ 11 - 1
packages/opencode/src/provider/provider.ts

@@ -935,6 +935,16 @@ export const ConfigProvidersResult = Schema.Struct({
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 export type ConfigProvidersResult = Types.DeepMutable<Schema.Schema.Type<typeof ConfigProvidersResult>>
 
+export function toPublicInfo(provider: Info): Info {
+  return JSON.parse(
+    JSON.stringify(provider, (_, value) => {
+      if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined
+      if (typeof value === "bigint") return value.toString()
+      return value
+    }),
+  )
+}
+
 export function defaultModelIDs<T extends { models: Record<string, { id: string }> }>(providers: Record<string, T>) {
   return mapValues(providers, (item) => sort(Object.values(item.models))[0].id)
 }
@@ -1299,7 +1309,7 @@ const layer: Layer.Layer<
           const options = yield* Effect.promise(() =>
             plugin.auth!.loader!(
               () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any,
-              database[plugin.auth!.provider],
+              toPublicInfo(database[plugin.auth!.provider]),
             ),
           )
           const opts = options ?? {}

+ 1 - 1
packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts

@@ -24,7 +24,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h
     const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
       const providers = yield* providerSvc.list()
       return {
-        providers: Object.values(providers),
+        providers: Object.values(providers).map(Provider.toPublicInfo),
         default: Provider.defaultModelIDs(providers),
       }
     })

+ 1 - 1
packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts

@@ -30,7 +30,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
         connected,
       )
       return {
-        all: Object.values(providers),
+        all: Object.values(providers).map(Provider.toPublicInfo),
         default: Provider.defaultModelIDs(providers),
         connected: Object.keys(connected),
       }

+ 0 - 26
packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts

@@ -6,39 +6,13 @@ import { NamedError } from "@opencode-ai/core/util/error"
 import * as Log from "@opencode-ai/core/util/log"
 import { Cause, Effect } from "effect"
 import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
-import { HttpApiError } from "effect/unstable/httpapi"
-import { HttpApiSchemaError } from "effect/unstable/httpapi/HttpApiError"
 
 const log = Log.create({ service: "server" })
 
-function badRequestResponse() {
-  return HttpServerResponse.jsonUnsafe(
-    {
-      data: {},
-      errors: [],
-      success: false,
-    },
-    { status: 400 },
-  )
-}
-
-function normalizeEmptyBadRequest(response: HttpServerResponse.HttpServerResponse) {
-  if (response.status !== 400 || response.body._tag !== "Empty") return response
-  return badRequestResponse()
-}
-
 // Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s.
 export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) =>
   effect.pipe(
-    Effect.catch((error) => {
-      if (error instanceof HttpApiError.BadRequest) return Effect.succeed(badRequestResponse())
-      return Effect.fail(error)
-    }),
-    Effect.map(normalizeEmptyBadRequest),
     Effect.catchCause((cause) => {
-      const schemaError = cause.reasons.filter(Cause.isDieReason).find((reason) => HttpApiSchemaError.is(reason.defect))
-      if (schemaError) return Effect.succeed(badRequestResponse())
-
       const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => {
         if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false
         if (HttpServerError.isHttpServerError(reason.defect)) return false

+ 88 - 0
packages/opencode/test/server/httpapi-provider.test.ts

@@ -21,6 +21,22 @@ function app() {
   return Server.Default().app
 }
 
+function providerListHasFetch(list: unknown) {
+  if (!Array.isArray(list)) return false
+  return list.some((item: unknown) => {
+    if (typeof item !== "object" || item === null || !("id" in item) || !("options" in item)) return false
+    if (item.id !== "google") return false
+    if (typeof item.options !== "object" || item.options === null) return false
+    return "fetch" in item.options
+  })
+}
+
+function hasProviderWithFetch(input: unknown, key: "all" | "providers") {
+  if (typeof input !== "object" || input === null) return false
+  if (key === "all") return "all" in input && providerListHasFetch(input.all)
+  return "providers" in input && providerListHasFetch(input.providers)
+}
+
 function requestAuthorize(input: {
   app: ReturnType<typeof app>
   providerID: string
@@ -76,6 +92,39 @@ function writeProviderAuthPlugin(dir: string) {
   })
 }
 
+function writeFunctionOptionsPlugin(dir: string) {
+  return Effect.gen(function* () {
+    const fs = yield* FileSystem.FileSystem
+    const path = yield* Path.Path
+
+    yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true })
+    yield* fs.writeFileString(
+      path.join(dir, ".opencode", "plugin", "provider-function-options.ts"),
+      [
+        "export default {",
+        '  id: "test.provider-function-options",',
+        "  server: async () => ({",
+        "    auth: {",
+        '      provider: "google",',
+        "      loader: async (_getAuth, provider) => {",
+        "        for (const model of Object.values(provider.models ?? {})) {",
+        "          model.cost = { input: 0, output: 0 }",
+        "        }",
+        "        return {",
+        '        apiKey: "",',
+        "        fetch: async (input, init) => fetch(input, init),",
+        "        }",
+        "      },",
+        "      methods: [{ type: 'api', label: 'API key' }],",
+        "    },",
+        "  }),",
+        "}",
+        "",
+      ].join("\n"),
+    )
+  })
+}
+
 function withProviderProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E, R>) {
   return Effect.gen(function* () {
     const fs = yield* FileSystem.FileSystem
@@ -136,4 +185,43 @@ describe("provider HttpApi", () => {
       }),
     ),
   )
+
+  it.live("serves provider lists when auth loaders add runtime fetch options", () =>
+    Effect.gen(function* () {
+      const fs = yield* FileSystem.FileSystem
+      const path = yield* Path.Path
+      const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" })
+      const previous = process.env.OPENCODE_AUTH_CONTENT
+
+      yield* fs.writeFileString(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }),
+      )
+      yield* writeFunctionOptionsPlugin(dir)
+      yield* Effect.sync(() => {
+        process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({
+          google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 },
+        })
+      })
+      yield* Effect.addFinalizer(() =>
+        Effect.sync(() => {
+          if (previous === undefined) delete process.env.OPENCODE_AUTH_CONTENT
+          if (previous !== undefined) process.env.OPENCODE_AUTH_CONTENT = previous
+        }),
+      )
+      const headers = { "x-opencode-directory": dir }
+      const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers })))
+      const configResponse = yield* Effect.promise(() =>
+        Promise.resolve(app().request("/config/providers", { headers })),
+      )
+
+      expect(providerResponse.status).toBe(200)
+      expect(configResponse.status).toBe(200)
+
+      const providerBody = yield* Effect.promise(() => providerResponse.json())
+      const configBody = yield* Effect.promise(() => configResponse.json())
+      expect(hasProviderWithFetch(providerBody, "all")).toBe(false)
+      expect(hasProviderWithFetch(configBody, "providers")).toBe(false)
+    }),
+  )
 })