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
87 changes: 87 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,77 @@ export namespace Provider {
}
}

/**
* Convert a config provider to database provider format
* Used for plugin providers registered via config() hook
*/
export function fromConfigProvider(provider: z.infer<typeof Config.Provider>, providerID: ProviderID): Info {
return {
id: providerID,
source: "config",
name: provider.name ?? providerID,
env: provider.env ?? [],
options: provider.options ?? {},
models: mapValues(provider.models ?? {}, (model, modelID) => {
const parsedModel: Model = {
id: ModelID.make(model.id ?? modelID),
providerID,
api: {
id: model.id ?? modelID,
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
url: model.provider?.api ?? provider.api ?? "",
},
name: model.name ?? modelID,
status: model.status ?? "active",
capabilities: {
temperature: model.temperature ?? false,
reasoning: model.reasoning ?? false,
attachment: model.attachment ?? false,
toolcall: model.tool_call ?? true,
input: {
text: model.modalities?.input?.includes("text") ?? true,
audio: model.modalities?.input?.includes("audio") ?? false,
image: model.modalities?.input?.includes("image") ?? false,
video: model.modalities?.input?.includes("video") ?? false,
pdf: model.modalities?.input?.includes("pdf") ?? false,
},
output: {
text: model.modalities?.output?.includes("text") ?? true,
audio: model.modalities?.output?.includes("audio") ?? false,
image: model.modalities?.output?.includes("image") ?? false,
video: model.modalities?.output?.includes("video") ?? false,
pdf: model.modalities?.output?.includes("pdf") ?? false,
},
interleaved: model.interleaved ?? false,
},
cost: {
input: model?.cost?.input ?? 0,
output: model?.cost?.output ?? 0,
cache: {
read: model?.cost?.cache_read ?? 0,
write: model?.cost?.cache_write ?? 0,
},
},
limit: {
context: model.limit?.context ?? 0,
output: model.limit?.output ?? 0,
},
options: model.options ?? {},
headers: model.headers ?? {},
family: model.family ?? "",
release_date: model.release_date ?? "",
variants: {},
}
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
parsedModel.variants = mapValues(
pickBy(merged, (v) => !v.disabled),
(v) => omit(v, ["disabled"]),
)
return parsedModel
}),
}
}

const layer: Layer.Layer<Service, never, Config.Service | Auth.Service> = Layer.effect(
Service,
Effect.gen(function* () {
Expand Down Expand Up @@ -1102,6 +1173,22 @@ export namespace Provider {
database[providerID] = parsed
}

// Check for plugin providers that were registered via config() hook
// This ensures plugin providers exist in database even if not in opencode.json
const loadedPlugins = yield* Effect.promise(() => Plugin.list())
for (const plugin of loadedPlugins) {
if (!plugin.auth) continue
const providerID = ProviderID.make(plugin.auth.provider)

// Check if plugin registered provider via config() hook
const configProvider = cfg.provider?.[providerID]
if (configProvider && !database[providerID]) {
// Add plugin provider to database so mergeProvider can find it
database[providerID] = fromConfigProvider(configProvider, providerID)
log.debug("Added plugin provider to database", { providerID })
}
}

// load env
const env = Env.all()
for (const [id, provider] of Object.entries(database)) {
Expand Down
153 changes: 153 additions & 0 deletions packages/opencode/test/provider/plugin-reload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { test, expect } from "bun:test"
import path from "path"

import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { ProviderID } from "../../src/provider/schema"

test("plugin provider registered via config persists after instance dispose/reload", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"plugin-provider": {
npm: "@ai-sdk/openai-compatible",
name: "Plugin Provider",
options: {
baseURL: "https://api.plugin.com/v1",
headers: {
"X-Custom-Header": "test-value",
},
},
models: {
"plugin-model-1": {
name: "Plugin Model 1",
id: "plugin-model-1",
reasoning: true,
tool_call: true,
limit: {
context: 128000,
output: 16384,
},
cost: {
input: 0.001,
output: 0.002,
},
modalities: {
input: ["text", "image"],
output: ["text"],
},
},
"plugin-model-2": {
name: "Plugin Model 2",
id: "plugin-model-2",
reasoning: false,
tool_call: true,
limit: {
context: 64000,
output: 8192,
},
cost: {
input: 0.0005,
output: 0.001,
},
modalities: {
input: ["text"],
output: ["text"],
},
},
},
},
},
}),
)
},
})

// First instance: provider should be loaded
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
const providerID = ProviderID.make("plugin-provider")
const provider = providers[providerID]

// Verify provider exists
expect(provider).toBeDefined()
expect(provider?.id).toBe(providerID)
expect(provider?.name).toBe("Plugin Provider")
expect(provider?.source).toBe("config")
expect(provider?.options.baseURL).toBe("https://api.plugin.com/v1")
expect(provider?.options.headers?.["X-Custom-Header"]).toBe("test-value")

// Verify models
expect(Object.keys(provider!.models)).toHaveLength(2)
expect(provider!.models["plugin-model-1"]).toBeDefined()
expect(provider!.models["plugin-model-2"]).toBeDefined()

// Verify model 1 details
const model1 = provider!.models["plugin-model-1"]
expect(model1.name).toBe("Plugin Model 1")
expect(model1.api.id).toBe("plugin-model-1")
expect(model1.api.npm).toBe("@ai-sdk/openai-compatible")
expect(model1.capabilities.reasoning).toBe(true)
expect(model1.capabilities.toolcall).toBe(true)
expect(model1.limit.context).toBe(128000)
expect(model1.limit.output).toBe(16384)
expect(model1.cost.input).toBe(0.001)
expect(model1.cost.output).toBe(0.002)
expect(model1.capabilities.input.text).toBe(true)
expect(model1.capabilities.input.image).toBe(true)
expect(model1.capabilities.output.text).toBe(true)

// Verify model 2 details
const model2 = provider!.models["plugin-model-2"]
expect(model2.name).toBe("Plugin Model 2")
expect(model2.capabilities.reasoning).toBe(false)
expect(model2.limit.context).toBe(64000)
},
})

// Second instance: provider should persist after dispose/reload
// This is the critical test for issue #20026 fix
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
const providerID = ProviderID.make("plugin-provider")
const provider = providers[providerID]

// Verify provider persisted
expect(provider).toBeDefined()
expect(provider?.id).toBe(providerID)
expect(provider?.name).toBe("Plugin Provider")
expect(provider?.source).toBe("config")

// Verify options persisted
expect(provider?.options.baseURL).toBe("https://api.plugin.com/v1")
expect(provider?.options.headers?.["X-Custom-Header"]).toBe("test-value")

// Verify models persisted
expect(Object.keys(provider!.models)).toHaveLength(2)
expect(provider!.models["plugin-model-1"]).toBeDefined()
expect(provider!.models["plugin-model-2"]).toBeDefined()

// Verify data integrity after reload
const model1 = provider!.models["plugin-model-1"]
expect(model1.name).toBe("Plugin Model 1")
expect(model1.api.id).toBe("plugin-model-1")
expect(model1.api.npm).toBe("@ai-sdk/openai-compatible")
expect(model1.capabilities.reasoning).toBe(true)
expect(model1.limit.context).toBe(128000)

const model2 = provider!.models["plugin-model-2"]
expect(model2.name).toBe("Plugin Model 2")
expect(model2.capabilities.reasoning).toBe(false)
expect(model2.limit.context).toBe(64000)
},
})
})
Loading