diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index 6f0e5f608cde..362a0a70ebdd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -3,6 +3,7 @@ import { Config } from "@/config/config" import { ModelsDev } from "@opencode-ai/core/models-dev" import { Provider } from "@/provider/provider" import { Auth } from "@/auth" +import { InstanceState } from "@/effect/instance-state" import { mapValues } from "remeda" import { Effect, Schema } from "effect" @@ -38,27 +39,37 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const provider = yield* Provider.Service const svc = yield* ProviderAuth.Service const authStore = yield* Auth.Service + const state = yield* InstanceState.make(() => + Effect.sync(() => new WeakMap()), + ) const list = Effect.fn("ProviderHttpApi.list")(function* () { const config = yield* cfg.get() const all = yield* ModelsDev.Service.use((s) => s.get()) + const connected = yield* provider.list() + const credentials = yield* authStore.all().pipe(Effect.orDie) + const authorization = Object.keys(credentials).sort().join("\0") + // Config changes dispose the instance; a catalog refresh replaces `all`. + const cache = yield* InstanceState.get(state) + const cached = cache.get(all) + if (cached?.authorization === authorization) return cached.response const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record = {} for (const [key, value] of Object.entries(all)) { if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value } - const connected = yield* provider.list() - const credentials = yield* authStore.all().pipe(Effect.orDie) const providers = Object.assign( mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)), connected, ) - return { + const response = HttpServerResponse.jsonUnsafe({ all: Object.values(providers).map(Provider.toPublicInfo), default: Provider.defaultModelIDs(providers), connected: Object.keys(providers).filter((id) => id in connected || credentials[id]), - } + }) + cache.set(all, { authorization, response }) + return response }) const auth = Effect.fn("ProviderHttpApi.auth")(function* () { @@ -108,7 +119,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" }) return handlers - .handle("list", list) + .handleRaw("list", list) .handle("auth", auth) .handleRaw("authorize", authorizeRaw) .handle("callback", callback) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index 585c59cb4317..8f1c11625539 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -4,6 +4,8 @@ import path from "node:path" import { pathToFileURL } from "node:url" import { Flag } from "@opencode-ai/core/flag/flag" import { Server } from "../../src/server/server" +import { Provider } from "../../src/provider/provider" +import { Schema } from "effect" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { withTimeout } from "../../src/util/timeout" import { resetDatabase } from "../fixture/db" @@ -18,6 +20,39 @@ const original = { const auth = { username: "opencode", password: "listen-secret" } const testPty = process.platform === "win32" ? test.skip : test +test("provider catalog revalidates after configuration changes", async () => { + await using dir = await tmpdir({ config: { snapshot: false, formatter: false, lsp: false } }) + const listener = await startListener() + const headers = { + authorization: authorization(), + "x-opencode-directory": dir.path, + "content-type": "application/json", + } + const url = new URL("/provider", listener.url) + try { + const first = await fetch(url, { headers }) + expect(first.status).toBe(200) + const body = await first.text() + const catalog = Schema.decodeUnknownSync(Provider.ListResult)(JSON.parse(body)) + expect(catalog.all.length).toBeGreaterThan(0) + const repeated = await fetch(url, { headers }) + expect(repeated.status).toBe(200) + expect(await repeated.text()).toBe(body) + const disabled = catalog.all[0].id + await Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ disabled_providers: [disabled] })) + const changed = await fetch(new URL("/instance/dispose", listener.url), { method: "POST", headers }) + expect(changed.status).toBe(200) + await changed.text() + const refreshed = await fetch(url, { headers }) + expect(refreshed.status).toBe(200) + const next = Schema.decodeUnknownSync(Provider.ListResult)(await refreshed.json()) + expect(next.all.length).toBe(catalog.all.length - 1) + expect(next.all.some((provider) => provider.id === disabled)).toBe(false) + } finally { + await stop(listener, "provider catalog listener did not stop") + } +}, 30000) + afterEach(async () => { Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME