Skip to content

Commit 6bceb6a

Browse files
committed
fix(provider): preserve config precedence after model hooks
1 parent f9ba23a commit 6bceb6a

2 files changed

Lines changed: 193 additions & 85 deletions

File tree

packages/opencode/src/provider/provider.ts

Lines changed: 118 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1276,99 +1276,103 @@ export const layer = Layer.effect(
12761276
}
12771277

12781278
// extend database from config
1279-
for (const [providerID, provider] of configProviders) {
1280-
const existing = database[providerID]
1281-
const parsed: Info = {
1282-
id: ProviderV2.ID.make(providerID),
1283-
name: provider.name ?? existing?.name ?? providerID,
1284-
env: provider.env ?? existing?.env ?? [],
1285-
options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
1286-
source: "config",
1287-
models: existing?.models ?? {},
1288-
}
1279+
function applyConfig(target: Record<string, Info>) {
1280+
for (const [providerID, provider] of configProviders) {
1281+
const existing = target[providerID]
1282+
const parsed: Info = {
1283+
id: ProviderV2.ID.make(providerID),
1284+
name: provider.name ?? existing?.name ?? providerID,
1285+
env: provider.env ?? existing?.env ?? [],
1286+
options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
1287+
source: "config",
1288+
models: existing?.models ?? {},
1289+
}
12891290

1290-
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
1291-
const existingModel = parsed.models[model.id ?? modelID]
1292-
const apiID = model.id ?? existingModel?.api.id ?? modelID
1293-
const apiNpm =
1294-
model.provider?.npm ??
1295-
provider.npm ??
1296-
existingModel?.api.npm ??
1297-
modelsDev[providerID]?.npm ??
1298-
"@ai-sdk/openai-compatible"
1299-
const name = iife(() => {
1300-
if (model.name) return model.name
1301-
if (model.id && model.id !== modelID) return modelID
1302-
return existingModel?.name ?? modelID
1303-
})
1304-
const parsedModel: Model = {
1305-
id: ProviderV2.ModelID.make(modelID),
1306-
api: {
1307-
id: apiID,
1308-
npm: apiNpm,
1309-
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "",
1310-
},
1311-
status: model.status ?? existingModel?.status ?? "active",
1312-
name,
1313-
providerID: ProviderV2.ID.make(providerID),
1314-
capabilities: {
1315-
temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
1316-
reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
1317-
attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false,
1318-
toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
1319-
input: {
1320-
text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
1321-
audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
1322-
image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
1323-
video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false,
1324-
pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
1291+
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
1292+
const existingModel = parsed.models[model.id ?? modelID]
1293+
const apiID = model.id ?? existingModel?.api.id ?? modelID
1294+
const apiNpm =
1295+
model.provider?.npm ??
1296+
provider.npm ??
1297+
existingModel?.api.npm ??
1298+
modelsDev[providerID]?.npm ??
1299+
"@ai-sdk/openai-compatible"
1300+
const name = iife(() => {
1301+
if (model.name) return model.name
1302+
if (model.id && model.id !== modelID) return modelID
1303+
return existingModel?.name ?? modelID
1304+
})
1305+
const parsedModel: Model = {
1306+
id: ProviderV2.ModelID.make(modelID),
1307+
api: {
1308+
id: apiID,
1309+
npm: apiNpm,
1310+
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "",
1311+
},
1312+
status: model.status ?? existingModel?.status ?? "active",
1313+
name,
1314+
providerID: ProviderV2.ID.make(providerID),
1315+
capabilities: {
1316+
temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
1317+
reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
1318+
attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false,
1319+
toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
1320+
input: {
1321+
text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
1322+
audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
1323+
image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
1324+
video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false,
1325+
pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
1326+
},
1327+
output: {
1328+
text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
1329+
audio:
1330+
model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false,
1331+
image:
1332+
model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false,
1333+
video:
1334+
model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
1335+
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
1336+
},
1337+
interleaved:
1338+
model.interleaved ??
1339+
existingModel?.capabilities.interleaved ??
1340+
(!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.includes("deepseek")
1341+
? { field: "reasoning_content" }
1342+
: false),
13251343
},
1326-
output: {
1327-
text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
1328-
audio:
1329-
model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false,
1330-
image:
1331-
model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false,
1332-
video:
1333-
model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
1334-
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
1344+
cost: {
1345+
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
1346+
output: model?.cost?.output ?? existingModel?.cost?.output ?? 0,
1347+
cache: {
1348+
read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0,
1349+
write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0,
1350+
},
13351351
},
1336-
interleaved:
1337-
model.interleaved ??
1338-
existingModel?.capabilities.interleaved ??
1339-
(!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.includes("deepseek")
1340-
? { field: "reasoning_content" }
1341-
: false),
1342-
},
1343-
cost: {
1344-
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
1345-
output: model?.cost?.output ?? existingModel?.cost?.output ?? 0,
1346-
cache: {
1347-
read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0,
1348-
write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0,
1352+
options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}),
1353+
limit: {
1354+
context: model.limit?.context ?? existingModel?.limit?.context ?? 0,
1355+
input: model.limit?.input ?? existingModel?.limit?.input,
1356+
output: model.limit?.output ?? existingModel?.limit?.output ?? 0,
13491357
},
1350-
},
1351-
options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}),
1352-
limit: {
1353-
context: model.limit?.context ?? existingModel?.limit?.context ?? 0,
1354-
input: model.limit?.input ?? existingModel?.limit?.input,
1355-
output: model.limit?.output ?? existingModel?.limit?.output ?? 0,
1356-
},
1357-
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
1358-
family: model.family ?? existingModel?.family ?? "",
1359-
release_date: model.release_date ?? existingModel?.release_date ?? "",
1360-
variants: {},
1358+
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
1359+
family: model.family ?? existingModel?.family ?? "",
1360+
release_date: model.release_date ?? existingModel?.release_date ?? "",
1361+
variants: {},
1362+
}
1363+
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
1364+
parsedModel.variants = mapValues(
1365+
pickBy(merged, (v) => !v.disabled),
1366+
(v) => omit(v, ["disabled"]),
1367+
)
1368+
parsed.models[modelID] = parsedModel
13611369
}
1362-
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
1363-
parsedModel.variants = mapValues(
1364-
pickBy(merged, (v) => !v.disabled),
1365-
(v) => omit(v, ["disabled"]),
1366-
)
1367-
parsed.models[modelID] = parsedModel
1370+
target[providerID] = parsed
13681371
}
1369-
database[providerID] = parsed
13701372
}
13711373

1374+
applyConfig(database)
1375+
13721376
// load env
13731377
const envs = yield* env.all()
13741378
for (const [id, provider] of Object.entries(database)) {
@@ -1461,6 +1465,35 @@ export const layer = Layer.effect(
14611465
})
14621466
}
14631467

1468+
for (const hook of plugins) {
1469+
const p = hook.provider
1470+
const models = p?.models
1471+
if (!p || !models) continue
1472+
1473+
const providerID = ProviderV2.ID.make(p.id)
1474+
if (disabled.has(providerID)) continue
1475+
1476+
const provider = providers[providerID]
1477+
if (!provider) continue
1478+
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
1479+
1480+
provider.models = yield* Effect.promise(async () => {
1481+
const next = await models(provider, { auth: pluginAuth })
1482+
return Object.fromEntries(
1483+
Object.entries(next).map(([id, model]) => [
1484+
id,
1485+
{
1486+
...model,
1487+
id: ProviderV2.ModelID.make(id),
1488+
providerID,
1489+
},
1490+
]),
1491+
)
1492+
})
1493+
}
1494+
1495+
applyConfig(providers)
1496+
14641497
for (const [id, provider] of Object.entries(providers)) {
14651498
const providerID = ProviderV2.ID.make(id)
14661499
if (!isProviderAllowed(providerID)) {

packages/opencode/test/provider/provider.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1710,6 +1710,81 @@ it.effect("plugin config providers persist after instance dispose", () =>
17101710
}).pipe(provideMultiInstance),
17111711
)
17121712

1713+
it.effect("provider hook runs for config-only providers and preserves config model overrides", () =>
1714+
Effect.gen(function* () {
1715+
const dir = yield* tmpdirScoped()
1716+
const configDir = path.join(dir, ".opencode")
1717+
const root = path.join(configDir, "plugin")
1718+
yield* Effect.promise(() => mkdir(root, { recursive: true }))
1719+
yield* Effect.promise(() => markPluginDependenciesReady(configDir))
1720+
yield* Effect.promise(() => markPluginDependenciesReady(Global.Path.config))
1721+
yield* Effect.promise(() =>
1722+
Bun.write(
1723+
path.join(root, "demo-provider-hook.ts"),
1724+
[
1725+
"export default {",
1726+
' id: "demo.provider-hook",',
1727+
" server: async () => ({",
1728+
" provider: {",
1729+
' id: "demo",',
1730+
" async models(provider) {",
1731+
" return {",
1732+
" ...provider.models,",
1733+
" chat: {",
1734+
' ...provider.models.chat,',
1735+
' name: "Hook Chat",',
1736+
" },",
1737+
" live: {",
1738+
' ...provider.models.chat,',
1739+
' api: { ...provider.models.chat.api, id: "live" },',
1740+
' name: "Hook Live",',
1741+
" },",
1742+
" }",
1743+
" },",
1744+
" },",
1745+
" }),",
1746+
"}",
1747+
"",
1748+
].join("\n"),
1749+
),
1750+
)
1751+
yield* Effect.promise(() =>
1752+
Bun.write(
1753+
path.join(dir, "opencode.json"),
1754+
JSON.stringify({
1755+
$schema: "https://opencode.ai/config.json",
1756+
provider: {
1757+
demo: {
1758+
name: "Demo Provider",
1759+
npm: "@ai-sdk/openai-compatible",
1760+
api: "https://example.com/v1",
1761+
models: {
1762+
chat: {
1763+
name: "Config Chat",
1764+
tool_call: true,
1765+
limit: { context: 128000, output: 4096 },
1766+
},
1767+
},
1768+
},
1769+
},
1770+
}),
1771+
),
1772+
)
1773+
1774+
const loadAndList = Effect.gen(function* () {
1775+
const plugin = yield* Plugin.Service
1776+
const provider = yield* Provider.Service
1777+
yield* plugin.init()
1778+
return yield* provider.list()
1779+
}).pipe(provideInstanceEffect(dir))
1780+
1781+
const providers = yield* loadAndList
1782+
expect(providers[ProviderV2.ID.make("demo")]).toBeDefined()
1783+
expect(providers[ProviderV2.ID.make("demo")].models[ProviderV2.ModelID.make("live")]).toBeDefined()
1784+
expect(providers[ProviderV2.ID.make("demo")].models[ProviderV2.ModelID.make("chat")].name).toBe("Config Chat")
1785+
}).pipe(provideMultiInstance),
1786+
)
1787+
17131788
it.instance(
17141789
"plugin config enabled and disabled providers are honored",
17151790
Effect.gen(function* () {

0 commit comments

Comments
 (0)