diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index a95706a3dbe5..7df17a998e4e 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -236,6 +236,28 @@ describe("query keys", () => { expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"]) }) + test("asks for the connected providers only unless the full catalog is requested", async () => { + const calls: unknown[] = [] + const legacy = { + provider: { + list: async (input: unknown) => { + calls.push(input) + return { data: { all: [], connected: [], default: {} } } + }, + }, + } as unknown as Parameters[3] + const api = {} as CatalogApi + const protocol = Promise.resolve("v1" as const) as Parameters[4] + + // The default matters: every consumer shares this query key, and one that mounts before the + // bootstrap wrote its result fetches on its own. Defaulting to the full catalog made that lone + // consumer pull the whole thing and undid the saving. + await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api, legacy, protocol)) + await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api, legacy, protocol, false)) + + expect(calls).toEqual([{ connected: true }, undefined]) + }) + test("loads the current provider and model catalog", async () => { const calls: unknown[] = [] const api = { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 0f3e47381649..58e853bccf5c 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -140,6 +140,39 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) => ), }) +const CATALOG_WARM_DELAY = 3000 + +/** + * Fills the provider query from the connected-only response first and pulls the full + * models.dev catalog afterwards. + * + * Every consumer shares the `[scope, directory, "providers"]` key, so the second step writes to + * it directly through `setQueryData`. Going through `fetchQuery` there would rewrite the stored + * query options, and a `staleTime` override in that call leaves the entry permanently stale, so + * every consumer refetches the full catalog again. + */ +function loadProvidersProgressively(input: { + queryClient: QueryClient + scope: ServerScope + directory: string | null + sdk: CatalogApi + legacy: OpencodeClient + protocol?: Promise + warmCatalog?: boolean +}) { + const query = loadProvidersQuery(input.scope, input.directory, input.sdk, input.legacy, input.protocol) + return input.queryClient.fetchQuery(query).then(() => { + // Only the global entry warms the catalog: it is identical for every directory, so warming + // per directory would pull the same payload once per project. + if (!input.warmCatalog) return + setTimeout(() => { + void fetchProviders(input.directory, input.sdk, input.legacy, input.protocol, false) + .then((full) => input.queryClient.setQueryData(query.queryKey, full)) + .catch(() => undefined) + }, CATALOG_WARM_DELAY) + }) +} + export async function bootstrapGlobal(input: { serverSDK: OpencodeClient serverAPI: CatalogApi & { readonly project: ProjectApi } @@ -154,9 +187,15 @@ export async function bootstrapGlobal(input: { const slow = [ () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)), () => - input.queryClient.fetchQuery( - loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol), - ), + loadProvidersProgressively({ + queryClient: input.queryClient, + scope: input.scope, + directory: null, + sdk: input.serverAPI, + legacy: input.serverSDK, + protocol: input.protocol, + warmCatalog: true, + }), () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)), () => input.queryClient @@ -218,29 +257,48 @@ function warmSessions(input: { ).then(() => undefined) } +const fetchProviders = ( + directory: string | null, + sdk: CatalogApi, + legacy?: OpencodeClient, + protocol?: Promise, + connectedOnly?: boolean, +) => + retry(async () => { + if ((await protocol) === "v1" && legacy) { + const result = await legacy.provider.list(connectedOnly ? { connected: true } : undefined) + return normalizeProviderList(result.data!) + } + const location = directory ? { location: { directory } } : undefined + const [providers, models, defaultModel] = await Promise.all([ + sdk.provider.list(location), + sdk.model.list(location), + sdk.model.default(location), + ]) + return normalizeProviderList(providers.data, models.data, defaultModel.data) + }) + +/** + * Defaults to the connected providers alone. The full models.dev catalog is what the model + * picker needs, and it arrives through `loadProvidersProgressively`, which writes it into this + * same cache entry a few seconds after startup. + * + * The default matters: every consumer shares this query key, and one that mounts before the + * bootstrap has written its result fetches on its own. With the full catalog as the default + * that lone consumer pulls all of it and undoes the saving. + */ export const loadProvidersQuery = ( scope: ServerScope, directory: string | null, sdk: CatalogApi, legacy?: OpencodeClient, protocol?: Promise, + connectedOnly = true, ) => queryOptions({ queryKey: [scope, directory, "providers"], - queryFn: () => - retry(async () => { - if ((await protocol) === "v1" && legacy) { - const result = await legacy.provider.list() - return normalizeProviderList(result.data!) - } - const location = directory ? { location: { directory } } : undefined - const [providers, models, defaultModel] = await Promise.all([ - sdk.provider.list(location), - sdk.model.list(location), - sdk.model.default(location), - ]) - return normalizeProviderList(providers.data, models.data, defaultModel.data) - }), + staleTime: Number.POSITIVE_INFINITY, + queryFn: () => fetchProviders(directory, sdk, legacy, protocol, connectedOnly), }) type AgentListApi = { @@ -525,16 +583,21 @@ export async function bootstrapDirectory(input: { loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), )), () => - input.queryClient - .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol)) - .catch((err) => { - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), - }) - }), + loadProvidersProgressively({ + queryClient: input.queryClient, + scope: input.scope, + directory: input.directory, + sdk: input.api, + legacy: input.sdk, + protocol: input.protocol, + }).catch((err) => { + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(err, input.translate), + }) + }), ].filter(Boolean) as (() => Promise)[] await waitForPaint() diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts index 3a9ae0c6d36c..bb70fdc9e6a9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts @@ -5,12 +5,26 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" +import { QueryBoolean } from "./query" import { described } from "./metadata" import { ProviderV2 } from "@opencode-ai/core/provider" const root = "/provider" +// The full catalog is the whole models.dev snapshot and only the model picker needs it. A +// client that just renders the currently selected model can ask for the connected providers +// alone and pull the rest separately. Defaults to the full list, so existing clients are +// unaffected. +const ProviderListQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + connected: Schema.optional(QueryBoolean), +}) + const ProviderAuthErrorName = Schema.Union([ Schema.Literal("BadRequest"), Schema.Literal("ProviderAuthOauthMissing"), @@ -36,7 +50,7 @@ export const ProviderApi = HttpApi.make("provider") HttpApiGroup.make("provider") .add( HttpApiEndpoint.get("list", root, { - query: WorkspaceRoutingQuery, + query: ProviderListQuery, success: described(Provider.ListResult, "List of providers"), }).annotateMerge( OpenApi.annotations({ 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..32a846760ec9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -39,7 +39,22 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const svc = yield* ProviderAuth.Service const authStore = yield* Auth.Service - const list = Effect.fn("ProviderHttpApi.list")(function* () { + const list = Effect.fn("ProviderHttpApi.list")(function* (ctx: { query: { connected?: boolean } }) { + // `connected=true` answers from the connected providers alone and never touches the + // models.dev catalog or the config filter. The caller asked for what is usable right + // now and fetches the catalog separately. + if (ctx.query.connected) { + const providers = yield* provider.list() + return { + // Every entry here is connected by construction, so `connected` is the full key set. + // The full response additionally reports catalog providers that only have stored + // credentials; those need the catalog and appear once the caller fetches it. + all: Object.values(providers).map(Provider.toPublicInfo), + default: Provider.defaultModelIDs(providers), + connected: Object.keys(providers), + } + } + const config = yield* cfg.get() const all = yield* ModelsDev.Service.use((s) => s.get()) const disabled = new Set(config.disabled_providers ?? []) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 2a7266c5118c..0aff712f029c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -57,6 +57,7 @@ type OpenApiResponse = { // server still decodes string query params at runtime. const QueryParameterSchemas: Record = { "GET /experimental/session start": { type: "number" }, + "GET /provider connected": QueryBooleanOpenApi, "GET /experimental/session roots": QueryBooleanOpenApi, "GET /experimental/session archived": QueryBooleanOpenApi, "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 625ca1896b46..8c4eaeedbfb6 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -38,6 +38,15 @@ function hasProviderWithFetch(input: unknown, key: "all" | "providers") { return "providers" in input && providerListHasFetch(input.providers) } +function providerIDs(input: unknown) { + return providerList(input, "all").flatMap((provider) => (isRecord(provider) ? [String(provider.id)] : [])) +} + +function connectedIDs(input: unknown) { + if (!isRecord(input) || !Array.isArray(input.connected)) return [] + return input.connected.map(String) +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } @@ -378,6 +387,34 @@ describe("provider HttpApi", () => { { ...projectOptions, init: writeFunctionOptionsPlugin }, ) + it.instance( + "serves only connected providers when asked, and the full catalog otherwise", + Effect.gen(function* () { + const directory = (yield* TestInstance).directory + const headers = { "x-opencode-directory": directory } + + const connectedResponse = yield* request("/provider?connected=true", { headers }) + const fullResponse = yield* request("/provider", { headers }) + expect(connectedResponse.status).toBe(200) + expect(fullResponse.status).toBe(200) + + const connectedBody = yield* connectedResponse.json + const fullBody = yield* fullResponse.json + + // The catalog holds every provider models.dev knows about; the connected view holds only + // what is usable right now. That difference is the whole point of the parameter. + expect(providerIDs(connectedBody).length).toBeGreaterThan(0) + expect(providerIDs(fullBody).length).toBeGreaterThan(providerIDs(connectedBody).length) + + // The connected view lists exactly the providers it returns. + expect(providerIDs(connectedBody).sort()).toEqual(connectedIDs(connectedBody).sort()) + + // Whatever the connected view returns must also be in the catalog — same shape, fewer rows. + for (const id of providerIDs(connectedBody)) expect(providerIDs(fullBody)).toContain(id) + }), + projectOptions, + ) + it.instance( "keeps provider.models hook input mutations out of provider state", Effect.gen(function* () { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index a2bcd4252c6d..403164f9b729 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3302,6 +3302,7 @@ export class Provider extends HeyApiClient { parameters?: { directory?: string workspace?: string + connected?: boolean | "true" | "false" }, options?: Options, ) { @@ -3312,6 +3313,7 @@ export class Provider extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "connected" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f06c20cc413e..2b78b873d4df 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -9307,6 +9307,7 @@ export type ProviderListData = { query?: { directory?: string workspace?: string + connected?: boolean | "true" | "false" } url: "/provider" } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index e66d14050558..cca4892b1243 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -4988,6 +4988,22 @@ "type": "string" }, "required": false + }, + { + "name": "connected", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["true", "false"] + } + ] + }, + "required": false } ], "responses": {