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

Co-authored-by: Developer <temp@example.com>
This commit is contained in:
Kit Langton
2026-05-09 15:47:07 -04:00
committed by GitHub
parent 19abadaf27
commit 5e49029e70
2 changed files with 98 additions and 1 deletions

View File

@@ -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,

View File

@@ -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)
}),
)
})