Przeglądaj źródła

fix(provider): isolate plugin model mutations (#26561)

Co-authored-by: Developer <temp@example.com>
Kit Langton 4 miesięcy temu
rodzic
commit
5e49029e70

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

@@ -1162,7 +1162,7 @@ const layer: Layer.Layer<
           const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
 
           provider.models = yield* Effect.promise(async () => {
-            const next = await models(provider, { auth: pluginAuth })
+            const next = await models(toPublicInfo(provider), { auth: pluginAuth })
             return Object.fromEntries(
               Object.entries(next).map(([id, model]) => [
                 id,

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

@@ -37,6 +37,38 @@ function hasProviderWithFetch(input: unknown, key: "all" | "providers") {
   return "providers" in input && providerListHasFetch(input.providers)
 }
 
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value)
+}
+
+function providerList(input: unknown, key: "all" | "providers") {
+  if (!isRecord(input)) return []
+  if (!Array.isArray(input[key])) return []
+  return input[key]
+}
+
+function providerByID(input: unknown, key: "all" | "providers", id: string) {
+  return providerList(input, key).find((provider) => isRecord(provider) && provider.id === id)
+}
+
+function hasNonZeroModelCost(input: unknown, key: "all" | "providers", id: string) {
+  const provider = providerByID(input, key, id)
+  if (!isRecord(provider) || !isRecord(provider.models)) return false
+  return Object.values(provider.models).some((model) => {
+    if (!isRecord(model) || !isRecord(model.cost) || !isRecord(model.cost.cache)) return false
+    return [model.cost.input, model.cost.output, model.cost.cache.read, model.cost.cache.write].some(
+      (cost) => typeof cost === "number" && cost > 0,
+    )
+  })
+}
+
+function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id: string) {
+  const provider = providerByID(input, key, id)
+  if (!isRecord(provider)) return false
+  if (provider.name === "mutated-provider") return true
+  return isRecord(provider.options) && provider.options.mutatedByPlugin === true
+}
+
 function requestAuthorize(input: {
   app: ReturnType<typeof app>
   providerID: string
@@ -125,6 +157,40 @@ function writeFunctionOptionsPlugin(dir: string) {
   })
 }
 
+function writeProviderModelsMutationPlugin(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-models-mutation.ts"),
+      [
+        "export default {",
+        '  id: "test.provider-models-mutation",',
+        "  server: async () => ({",
+        "    provider: {",
+        '      id: "google",',
+        "      models: async (provider) => {",
+        "        const models = Object.fromEntries(",
+        "          Object.entries(provider.models ?? {}).map(([id, model]) => [id, { ...model }]),",
+        "        )",
+        '        provider.name = "mutated-provider"',
+        "        provider.options = { ...provider.options, mutatedByPlugin: true }",
+        "        for (const model of Object.values(provider.models ?? {})) {",
+        "          model.cost = { input: 0, output: 0 }",
+        "        }",
+        "        return models",
+        "      },",
+        "    },",
+        "  }),",
+        "}",
+        "",
+      ].join("\n"),
+    )
+  })
+}
+
 function withProviderProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E, R>) {
   return Effect.gen(function* () {
     const fs = yield* FileSystem.FileSystem
@@ -222,6 +288,37 @@ describe("provider HttpApi", () => {
       const configBody = yield* Effect.promise(() => configResponse.json())
       expect(hasProviderWithFetch(providerBody, "all")).toBe(false)
       expect(hasProviderWithFetch(configBody, "providers")).toBe(false)
+      expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
+      expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true)
+    }),
+  )
+
+  it.live("keeps provider.models hook input mutations out of provider state", () =>
+    Effect.gen(function* () {
+      const fs = yield* FileSystem.FileSystem
+      const path = yield* Path.Path
+      const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" })
+
+      yield* fs.writeFileString(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }),
+      )
+      yield* writeProviderModelsMutationPlugin(dir)
+
+      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(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false)
+      expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false)
+      expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
     }),
   )
 })