Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/app/src/context/global-sync/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof loadProvidersQuery>[3]
const api = {} as CatalogApi
const protocol = Promise.resolve("v1" as const) as Parameters<typeof loadProvidersQuery>[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 = {
Expand Down
117 changes: 90 additions & 27 deletions packages/app/src/context/global-sync/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerProtocol>
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 }
Expand All @@ -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
Expand Down Expand Up @@ -218,29 +257,48 @@ function warmSessions(input: {
).then(() => undefined)
}

const fetchProviders = (
directory: string | null,
sdk: CatalogApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
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<ServerProtocol>,
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 = {
Expand Down Expand Up @@ -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<any>)[]

await waitForPaint()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ type OpenApiResponse = {
// server still decodes string query params at runtime.
const QueryParameterSchemas: Record<string, OpenApiSchema> = {
"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 },
Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/test/server/httpapi-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
Expand Down Expand Up @@ -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* () {
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3302,6 +3302,7 @@ export class Provider extends HeyApiClient {
parameters?: {
directory?: string
workspace?: string
connected?: boolean | "true" | "false"
},
options?: Options<never, ThrowOnError>,
) {
Expand All @@ -3312,6 +3313,7 @@ export class Provider extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "connected" },
],
},
],
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9307,6 +9307,7 @@ export type ProviderListData = {
query?: {
directory?: string
workspace?: string
connected?: boolean | "true" | "false"
}
url: "/provider"
}
Expand Down
16 changes: 16 additions & 0 deletions packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4988,6 +4988,22 @@
"type": "string"
},
"required": false
},
{
"name": "connected",
"in": "query",
"schema": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string",
"enum": ["true", "false"]
}
]
},
"required": false
}
],
"responses": {
Expand Down
Loading