core: add provider test coverage for upcoming refactor
Add comprehensive test suite for Provider module to ensure safe refactoring of provider internals. Tests cover: - Provider loading from env vars and config - Provider filtering (disabled_providers, enabled_providers) - Model whitelist/blacklist - Model aliasing and custom providers - getModel, getProvider, closest, defaultModel functions Also adds Env module for instance-scoped environment variable access, enabling isolated test environments without global state pollution.
This commit is contained in:
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
export namespace Env {
|
||||
const state = Instance.state(() => {
|
||||
return { ...process.env } as Record<string, string | undefined>
|
||||
})
|
||||
|
||||
export function get(key: string) {
|
||||
const env = state()
|
||||
return env[key]
|
||||
}
|
||||
|
||||
export function all() {
|
||||
return state()
|
||||
}
|
||||
|
||||
export function set(key: string, value: string) {
|
||||
const env = state()
|
||||
env[key] = value
|
||||
}
|
||||
|
||||
export function remove(key: string) {
|
||||
const env = state()
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Plugin } from "../plugin"
|
||||
import { ModelsDev } from "./models"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Auth } from "../auth"
|
||||
import { Env } from "../env"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { iife } from "@/util/iife"
|
||||
@@ -64,7 +65,8 @@ export namespace Provider {
|
||||
},
|
||||
async opencode(input) {
|
||||
const hasKey = await (async () => {
|
||||
if (input.env.some((item) => process.env[item])) return true
|
||||
const env = Env.all()
|
||||
if (input.env.some((item) => env[item])) return true
|
||||
if (await Auth.get(input.id)) return true
|
||||
return false
|
||||
})()
|
||||
@@ -128,7 +130,7 @@ export namespace Provider {
|
||||
}
|
||||
},
|
||||
"azure-cognitive-services": async () => {
|
||||
const resourceName = process.env["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME"]
|
||||
const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
@@ -144,10 +146,15 @@ export namespace Provider {
|
||||
}
|
||||
},
|
||||
"amazon-bedrock": async () => {
|
||||
if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"] && !process.env["AWS_BEARER_TOKEN_BEDROCK"])
|
||||
return { autoload: false }
|
||||
const [awsProfile, awsAccessKeyId, awsBearerToken, awsRegion] = await Promise.all([
|
||||
Env.get("AWS_PROFILE"),
|
||||
Env.get("AWS_ACCESS_KEY_ID"),
|
||||
Env.get("AWS_BEARER_TOKEN_BEDROCK"),
|
||||
Env.get("AWS_REGION"),
|
||||
])
|
||||
if (!awsProfile && !awsAccessKeyId && !awsBearerToken) return { autoload: false }
|
||||
|
||||
const region = process.env["AWS_REGION"] ?? "us-east-1"
|
||||
const region = awsRegion ?? "us-east-1"
|
||||
|
||||
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
|
||||
return {
|
||||
@@ -246,8 +253,8 @@ export namespace Provider {
|
||||
}
|
||||
},
|
||||
"google-vertex": async () => {
|
||||
const project = process.env["GOOGLE_CLOUD_PROJECT"] ?? process.env["GCP_PROJECT"] ?? process.env["GCLOUD_PROJECT"]
|
||||
const location = process.env["GOOGLE_CLOUD_LOCATION"] ?? process.env["VERTEX_LOCATION"] ?? "us-east5"
|
||||
const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
|
||||
const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-east5"
|
||||
const autoload = Boolean(project)
|
||||
if (!autoload) return { autoload: false }
|
||||
return {
|
||||
@@ -263,8 +270,8 @@ export namespace Provider {
|
||||
}
|
||||
},
|
||||
"google-vertex-anthropic": async () => {
|
||||
const project = process.env["GOOGLE_CLOUD_PROJECT"] ?? process.env["GCP_PROJECT"] ?? process.env["GCLOUD_PROJECT"]
|
||||
const location = process.env["GOOGLE_CLOUD_LOCATION"] ?? process.env["VERTEX_LOCATION"] ?? "global"
|
||||
const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
|
||||
const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "global"
|
||||
const autoload = Boolean(project)
|
||||
if (!autoload) return { autoload: false }
|
||||
return {
|
||||
@@ -435,9 +442,10 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
// load env
|
||||
const env = Env.all()
|
||||
for (const [providerID, provider] of Object.entries(database)) {
|
||||
if (disabled.has(providerID)) continue
|
||||
const apiKey = provider.env.map((item) => process.env[item]).at(0)
|
||||
const apiKey = provider.env.map((item) => env[item]).find(Boolean)
|
||||
if (!apiKey) continue
|
||||
mergeProvider(
|
||||
providerID,
|
||||
|
||||
@@ -1,4 +1,35 @@
|
||||
import { Log } from "../src/util/log"
|
||||
// IMPORTANT: Set env vars BEFORE any imports from src/ directory
|
||||
// xdg-basedir reads env vars at import time, so we must set these first
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
const testDataDir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid)
|
||||
process.env["XDG_DATA_HOME"] = testDataDir
|
||||
process.env["XDG_CACHE_HOME"] = path.join(testDataDir, "cache")
|
||||
process.env["XDG_CONFIG_HOME"] = path.join(testDataDir, "config")
|
||||
process.env["XDG_STATE_HOME"] = path.join(testDataDir, "state")
|
||||
|
||||
// Clear provider env vars to ensure clean test state
|
||||
delete process.env["ANTHROPIC_API_KEY"]
|
||||
delete process.env["OPENAI_API_KEY"]
|
||||
delete process.env["GOOGLE_API_KEY"]
|
||||
delete process.env["GOOGLE_GENERATIVE_AI_API_KEY"]
|
||||
delete process.env["AZURE_OPENAI_API_KEY"]
|
||||
delete process.env["AWS_ACCESS_KEY_ID"]
|
||||
delete process.env["AWS_PROFILE"]
|
||||
delete process.env["OPENROUTER_API_KEY"]
|
||||
delete process.env["GROQ_API_KEY"]
|
||||
delete process.env["MISTRAL_API_KEY"]
|
||||
delete process.env["PERPLEXITY_API_KEY"]
|
||||
delete process.env["TOGETHER_API_KEY"]
|
||||
delete process.env["XAI_API_KEY"]
|
||||
delete process.env["DEEPSEEK_API_KEY"]
|
||||
delete process.env["FIREWORKS_API_KEY"]
|
||||
delete process.env["CEREBRAS_API_KEY"]
|
||||
delete process.env["SAMBANOVA_API_KEY"]
|
||||
|
||||
// Now safe to import from src/
|
||||
const { Log } = await import("../src/util/log")
|
||||
|
||||
Log.init({
|
||||
print: false,
|
||||
|
||||
@@ -0,0 +1,1729 @@
|
||||
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 { Env } from "../../src/env"
|
||||
|
||||
test("provider loaded from env variable", 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",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["anthropic"]).toBeDefined()
|
||||
// Note: source becomes "custom" because CUSTOM_LOADERS run after env loading
|
||||
// and anthropic has a custom loader that merges additional options
|
||||
expect(providers["anthropic"].source).toBe("custom")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("provider loaded from config with apiKey option", 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: {
|
||||
anthropic: {
|
||||
options: {
|
||||
apiKey: "config-api-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["anthropic"]).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("disabled_providers excludes provider", 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",
|
||||
disabled_providers: ["anthropic"],
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["anthropic"]).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("enabled_providers restricts to only listed providers", 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",
|
||||
enabled_providers: ["anthropic"],
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
Env.set("OPENAI_API_KEY", "test-openai-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["anthropic"]).toBeDefined()
|
||||
expect(providers["openai"]).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model whitelist filters models for provider", 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: {
|
||||
anthropic: {
|
||||
whitelist: ["claude-sonnet-4-20250514"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["anthropic"]).toBeDefined()
|
||||
const models = Object.keys(providers["anthropic"].info.models)
|
||||
expect(models).toContain("claude-sonnet-4-20250514")
|
||||
expect(models.length).toBe(1)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model blacklist excludes specific models", 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: {
|
||||
anthropic: {
|
||||
blacklist: ["claude-sonnet-4-20250514"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["anthropic"]).toBeDefined()
|
||||
const models = Object.keys(providers["anthropic"].info.models)
|
||||
expect(models).not.toContain("claude-sonnet-4-20250514")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("custom model alias via config", 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: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"my-alias": {
|
||||
id: "claude-sonnet-4-20250514",
|
||||
name: "My Custom Alias",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["anthropic"]).toBeDefined()
|
||||
expect(providers["anthropic"].info.models["my-alias"]).toBeDefined()
|
||||
expect(providers["anthropic"].info.models["my-alias"].name).toBe("My Custom Alias")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("custom provider with npm package", 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: {
|
||||
"custom-provider": {
|
||||
name: "Custom Provider",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
api: "https://api.custom.com/v1",
|
||||
env: ["CUSTOM_API_KEY"],
|
||||
models: {
|
||||
"custom-model": {
|
||||
name: "Custom Model",
|
||||
tool_call: true,
|
||||
limit: {
|
||||
context: 128000,
|
||||
output: 4096,
|
||||
},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "custom-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["custom-provider"]).toBeDefined()
|
||||
expect(providers["custom-provider"].info.name).toBe("Custom Provider")
|
||||
expect(providers["custom-provider"].info.models["custom-model"]).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("env variable takes precedence, config merges options", 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: {
|
||||
anthropic: {
|
||||
options: {
|
||||
timeout: 60000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "env-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["anthropic"]).toBeDefined()
|
||||
// Config options should be merged
|
||||
expect(providers["anthropic"].options.timeout).toBe(60000)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("getModel returns model for valid provider/model", 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",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const model = await Provider.getModel("anthropic", "claude-sonnet-4-20250514")
|
||||
expect(model).toBeDefined()
|
||||
expect(model.providerID).toBe("anthropic")
|
||||
expect(model.modelID).toBe("claude-sonnet-4-20250514")
|
||||
expect(model.language).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("getModel throws ModelNotFoundError for invalid model", 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",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
expect(Provider.getModel("anthropic", "nonexistent-model")).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("getModel throws ModelNotFoundError for invalid provider", 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",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
expect(Provider.getModel("nonexistent-provider", "some-model")).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("parseModel correctly parses provider/model string", () => {
|
||||
const result = Provider.parseModel("anthropic/claude-sonnet-4")
|
||||
expect(result.providerID).toBe("anthropic")
|
||||
expect(result.modelID).toBe("claude-sonnet-4")
|
||||
})
|
||||
|
||||
test("parseModel handles model IDs with slashes", () => {
|
||||
const result = Provider.parseModel("openrouter/anthropic/claude-3-opus")
|
||||
expect(result.providerID).toBe("openrouter")
|
||||
expect(result.modelID).toBe("anthropic/claude-3-opus")
|
||||
})
|
||||
|
||||
test("defaultModel returns first available model when no config set", 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",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const model = await Provider.defaultModel()
|
||||
expect(model.providerID).toBeDefined()
|
||||
expect(model.modelID).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("defaultModel respects config model setting", 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",
|
||||
model: "anthropic/claude-sonnet-4-20250514",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const model = await Provider.defaultModel()
|
||||
expect(model.providerID).toBe("anthropic")
|
||||
expect(model.modelID).toBe("claude-sonnet-4-20250514")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("provider with baseURL from config", 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: {
|
||||
"custom-openai": {
|
||||
name: "Custom OpenAI",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
"gpt-4": {
|
||||
name: "GPT-4",
|
||||
tool_call: true,
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://custom.openai.com/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["custom-openai"]).toBeDefined()
|
||||
expect(providers["custom-openai"].options.baseURL).toBe("https://custom.openai.com/v1")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model cost defaults to zero when not specified", 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: {
|
||||
"test-provider": {
|
||||
name: "Test Provider",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
"test-model": {
|
||||
name: "Test Model",
|
||||
tool_call: true,
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const model = providers["test-provider"].info.models["test-model"]
|
||||
expect(model.cost.input).toBe(0)
|
||||
expect(model.cost.output).toBe(0)
|
||||
expect(model.cost.cache_read).toBe(0)
|
||||
expect(model.cost.cache_write).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model options are merged from existing model", 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: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
options: {
|
||||
customOption: "custom-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const model = providers["anthropic"].info.models["claude-sonnet-4-20250514"]
|
||||
expect(model.options.customOption).toBe("custom-value")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("provider removed when all models filtered out", 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: {
|
||||
anthropic: {
|
||||
whitelist: ["nonexistent-model"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["anthropic"]).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("closest finds model by partial match", 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",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const result = await Provider.closest("anthropic", ["sonnet-4"])
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.providerID).toBe("anthropic")
|
||||
expect(result?.modelID).toContain("sonnet-4")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("closest returns undefined for nonexistent provider", 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",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await Provider.closest("nonexistent", ["model"])
|
||||
expect(result).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("getModel uses realIdByKey for aliased models", 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: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"my-sonnet": {
|
||||
id: "claude-sonnet-4-20250514",
|
||||
name: "My Sonnet Alias",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["anthropic"].info.models["my-sonnet"]).toBeDefined()
|
||||
|
||||
const model = await Provider.getModel("anthropic", "my-sonnet")
|
||||
expect(model).toBeDefined()
|
||||
expect(model.modelID).toBe("my-sonnet")
|
||||
expect(model.info.name).toBe("My Sonnet Alias")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("provider api field sets default baseURL", 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: {
|
||||
"custom-api": {
|
||||
name: "Custom API",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
api: "https://api.example.com/v1",
|
||||
env: [],
|
||||
models: {
|
||||
"model-1": {
|
||||
name: "Model 1",
|
||||
tool_call: true,
|
||||
limit: { context: 8000, output: 2000 },
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["custom-api"].options.baseURL).toBe("https://api.example.com/v1")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("explicit baseURL overrides api field", 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: {
|
||||
"custom-api": {
|
||||
name: "Custom API",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
api: "https://api.example.com/v1",
|
||||
env: [],
|
||||
models: {
|
||||
"model-1": {
|
||||
name: "Model 1",
|
||||
tool_call: true,
|
||||
limit: { context: 8000, output: 2000 },
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://custom.override.com/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["custom-api"].options.baseURL).toBe("https://custom.override.com/v1")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model inherits properties from existing database model", 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: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
name: "Custom Name for Sonnet",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const model = providers["anthropic"].info.models["claude-sonnet-4-20250514"]
|
||||
expect(model.name).toBe("Custom Name for Sonnet")
|
||||
expect(model.tool_call).toBe(true)
|
||||
expect(model.attachment).toBe(true)
|
||||
expect(model.limit.context).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("disabled_providers prevents loading even with env var", 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",
|
||||
disabled_providers: ["openai"],
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("OPENAI_API_KEY", "test-openai-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["openai"]).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("enabled_providers with empty array allows no providers", 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",
|
||||
enabled_providers: [],
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
Env.set("OPENAI_API_KEY", "test-openai-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(Object.keys(providers).length).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("whitelist and blacklist can be combined", 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: {
|
||||
anthropic: {
|
||||
whitelist: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"],
|
||||
blacklist: ["claude-opus-4-20250514"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["anthropic"]).toBeDefined()
|
||||
const models = Object.keys(providers["anthropic"].info.models)
|
||||
expect(models).toContain("claude-sonnet-4-20250514")
|
||||
expect(models).not.toContain("claude-opus-4-20250514")
|
||||
expect(models.length).toBe(1)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model modalities default correctly", 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: {
|
||||
"test-provider": {
|
||||
name: "Test",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
"test-model": {
|
||||
name: "Test Model",
|
||||
tool_call: true,
|
||||
limit: { context: 8000, output: 2000 },
|
||||
},
|
||||
},
|
||||
options: { apiKey: "test" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const model = providers["test-provider"].info.models["test-model"]
|
||||
expect(model.modalities).toEqual({
|
||||
input: ["text"],
|
||||
output: ["text"],
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model with custom cost values", 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: {
|
||||
"test-provider": {
|
||||
name: "Test",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
"test-model": {
|
||||
name: "Test Model",
|
||||
tool_call: true,
|
||||
limit: { context: 8000, output: 2000 },
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 15,
|
||||
cache_read: 2.5,
|
||||
cache_write: 7.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
options: { apiKey: "test" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const model = providers["test-provider"].info.models["test-model"]
|
||||
expect(model.cost.input).toBe(5)
|
||||
expect(model.cost.output).toBe(15)
|
||||
expect(model.cost.cache_read).toBe(2.5)
|
||||
expect(model.cost.cache_write).toBe(7.5)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("getSmallModel returns appropriate small model", 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",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const model = await Provider.getSmallModel("anthropic")
|
||||
expect(model).toBeDefined()
|
||||
expect(model?.modelID).toContain("haiku")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("getSmallModel respects config small_model override", 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",
|
||||
small_model: "anthropic/claude-sonnet-4-20250514",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const model = await Provider.getSmallModel("anthropic")
|
||||
expect(model).toBeDefined()
|
||||
expect(model?.providerID).toBe("anthropic")
|
||||
expect(model?.modelID).toBe("claude-sonnet-4-20250514")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("provider.sort prioritizes preferred models", () => {
|
||||
const models = [
|
||||
{ id: "random-model", name: "Random" },
|
||||
{ id: "claude-sonnet-4-latest", name: "Claude Sonnet 4" },
|
||||
{ id: "gpt-5-turbo", name: "GPT-5 Turbo" },
|
||||
{ id: "other-model", name: "Other" },
|
||||
] as any[]
|
||||
|
||||
const sorted = Provider.sort(models)
|
||||
expect(sorted[0].id).toContain("sonnet-4")
|
||||
expect(sorted[0].id).toContain("latest")
|
||||
expect(sorted[sorted.length - 1].id).not.toContain("gpt-5")
|
||||
expect(sorted[sorted.length - 1].id).not.toContain("sonnet-4")
|
||||
})
|
||||
|
||||
test("multiple providers can be configured simultaneously", 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: {
|
||||
anthropic: {
|
||||
options: { timeout: 30000 },
|
||||
},
|
||||
openai: {
|
||||
options: { timeout: 60000 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-anthropic-key")
|
||||
Env.set("OPENAI_API_KEY", "test-openai-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["anthropic"]).toBeDefined()
|
||||
expect(providers["openai"]).toBeDefined()
|
||||
expect(providers["anthropic"].options.timeout).toBe(30000)
|
||||
expect(providers["openai"].options.timeout).toBe(60000)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("provider with custom npm package", 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: {
|
||||
"local-llm": {
|
||||
name: "Local LLM",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
"llama-3": {
|
||||
name: "Llama 3",
|
||||
tool_call: true,
|
||||
limit: { context: 8192, output: 2048 },
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "not-needed",
|
||||
baseURL: "http://localhost:11434/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["local-llm"]).toBeDefined()
|
||||
expect(providers["local-llm"].info.npm).toBe("@ai-sdk/openai-compatible")
|
||||
expect(providers["local-llm"].options.baseURL).toBe("http://localhost:11434/v1")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases for model configuration
|
||||
|
||||
test("model alias name defaults to alias key when id differs", 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: {
|
||||
anthropic: {
|
||||
models: {
|
||||
sonnet: {
|
||||
id: "claude-sonnet-4-20250514",
|
||||
// no name specified - should default to "sonnet" (the key)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["anthropic"].info.models["sonnet"].name).toBe("sonnet")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("provider with multiple env var options only includes apiKey when single env", 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: {
|
||||
"multi-env": {
|
||||
name: "Multi Env Provider",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: ["MULTI_ENV_KEY_1", "MULTI_ENV_KEY_2"],
|
||||
models: {
|
||||
"model-1": {
|
||||
name: "Model 1",
|
||||
tool_call: true,
|
||||
limit: { context: 8000, output: 2000 },
|
||||
},
|
||||
},
|
||||
options: {
|
||||
baseURL: "https://api.example.com/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("MULTI_ENV_KEY_1", "test-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["multi-env"]).toBeDefined()
|
||||
// When multiple env options exist, apiKey should NOT be auto-set
|
||||
expect(providers["multi-env"].options.apiKey).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("provider with single env var includes apiKey automatically", 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: {
|
||||
"single-env": {
|
||||
name: "Single Env Provider",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: ["SINGLE_ENV_KEY"],
|
||||
models: {
|
||||
"model-1": {
|
||||
name: "Model 1",
|
||||
tool_call: true,
|
||||
limit: { context: 8000, output: 2000 },
|
||||
},
|
||||
},
|
||||
options: {
|
||||
baseURL: "https://api.example.com/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("SINGLE_ENV_KEY", "my-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["single-env"]).toBeDefined()
|
||||
// Single env option should auto-set apiKey
|
||||
expect(providers["single-env"].options.apiKey).toBe("my-api-key")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model cost overrides existing cost values", 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: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
cost: {
|
||||
input: 999,
|
||||
output: 888,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const model = providers["anthropic"].info.models["claude-sonnet-4-20250514"]
|
||||
expect(model.cost.input).toBe(999)
|
||||
expect(model.cost.output).toBe(888)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("completely new provider not in database can be configured", 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: {
|
||||
"brand-new-provider": {
|
||||
name: "Brand New",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
api: "https://new-api.com/v1",
|
||||
models: {
|
||||
"new-model": {
|
||||
name: "New Model",
|
||||
tool_call: true,
|
||||
reasoning: true,
|
||||
attachment: true,
|
||||
temperature: true,
|
||||
limit: { context: 32000, output: 8000 },
|
||||
modalities: {
|
||||
input: ["text", "image"],
|
||||
output: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "new-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["brand-new-provider"]).toBeDefined()
|
||||
expect(providers["brand-new-provider"].info.name).toBe("Brand New")
|
||||
const model = providers["brand-new-provider"].info.models["new-model"]
|
||||
expect(model.reasoning).toBe(true)
|
||||
expect(model.attachment).toBe(true)
|
||||
expect(model.modalities?.input).toContain("image")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("disabled_providers and enabled_providers interaction", 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",
|
||||
// enabled_providers takes precedence - only these are considered
|
||||
enabled_providers: ["anthropic", "openai"],
|
||||
// Then disabled_providers filters from the enabled set
|
||||
disabled_providers: ["openai"],
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-anthropic")
|
||||
Env.set("OPENAI_API_KEY", "test-openai")
|
||||
Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
// anthropic: in enabled, not in disabled = allowed
|
||||
expect(providers["anthropic"]).toBeDefined()
|
||||
// openai: in enabled, but also in disabled = NOT allowed
|
||||
expect(providers["openai"]).toBeUndefined()
|
||||
// google: not in enabled = NOT allowed (even though not disabled)
|
||||
expect(providers["google"]).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model with tool_call false", 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: {
|
||||
"no-tools": {
|
||||
name: "No Tools Provider",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
"basic-model": {
|
||||
name: "Basic Model",
|
||||
tool_call: false,
|
||||
limit: { context: 4000, output: 1000 },
|
||||
},
|
||||
},
|
||||
options: { apiKey: "test" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["no-tools"].info.models["basic-model"].tool_call).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model defaults tool_call to true when not specified", 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: {
|
||||
"default-tools": {
|
||||
name: "Default Tools Provider",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
model: {
|
||||
name: "Model",
|
||||
// tool_call not specified
|
||||
limit: { context: 4000, output: 1000 },
|
||||
},
|
||||
},
|
||||
options: { apiKey: "test" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["default-tools"].info.models["model"].tool_call).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model headers are preserved", 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: {
|
||||
"headers-provider": {
|
||||
name: "Headers Provider",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
model: {
|
||||
name: "Model",
|
||||
tool_call: true,
|
||||
limit: { context: 4000, output: 1000 },
|
||||
headers: {
|
||||
"X-Custom-Header": "custom-value",
|
||||
Authorization: "Bearer special-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
options: { apiKey: "test" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const model = providers["headers-provider"].info.models["model"]
|
||||
expect(model.headers).toEqual({
|
||||
"X-Custom-Header": "custom-value",
|
||||
Authorization: "Bearer special-token",
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("provider env fallback - second env var used if first missing", 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: {
|
||||
"fallback-env": {
|
||||
name: "Fallback Env Provider",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: ["PRIMARY_KEY", "FALLBACK_KEY"],
|
||||
models: {
|
||||
model: {
|
||||
name: "Model",
|
||||
tool_call: true,
|
||||
limit: { context: 4000, output: 1000 },
|
||||
},
|
||||
},
|
||||
options: { baseURL: "https://api.example.com" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
// Only set fallback, not primary
|
||||
Env.set("FALLBACK_KEY", "fallback-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
// Provider should load because fallback env var is set
|
||||
expect(providers["fallback-env"]).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("getModel returns consistent results", 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",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const model1 = await Provider.getModel("anthropic", "claude-sonnet-4-20250514")
|
||||
const model2 = await Provider.getModel("anthropic", "claude-sonnet-4-20250514")
|
||||
expect(model1.providerID).toEqual(model2.providerID)
|
||||
expect(model1.modelID).toEqual(model2.modelID)
|
||||
expect(model1.info).toEqual(model2.info)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("provider name defaults to id when not in database", 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: {
|
||||
"my-custom-id": {
|
||||
// no name specified
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
model: {
|
||||
name: "Model",
|
||||
tool_call: true,
|
||||
limit: { context: 4000, output: 1000 },
|
||||
},
|
||||
},
|
||||
options: { apiKey: "test" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["my-custom-id"].info.name).toBe("my-custom-id")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("ModelNotFoundError includes suggestions for typos", 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",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
try {
|
||||
await Provider.getModel("anthropic", "claude-sonet-4") // typo: sonet instead of sonnet
|
||||
expect(true).toBe(false) // Should not reach here
|
||||
} catch (e: any) {
|
||||
expect(e.data.suggestions).toBeDefined()
|
||||
expect(e.data.suggestions.length).toBeGreaterThan(0)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("ModelNotFoundError for provider includes suggestions", 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",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
try {
|
||||
await Provider.getModel("antropic", "claude-sonnet-4") // typo: antropic
|
||||
expect(true).toBe(false) // Should not reach here
|
||||
} catch (e: any) {
|
||||
expect(e.data.suggestions).toBeDefined()
|
||||
expect(e.data.suggestions).toContain("anthropic")
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("getProvider returns undefined for nonexistent provider", 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",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const provider = await Provider.getProvider("nonexistent")
|
||||
expect(provider).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("getProvider returns provider info", 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",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const provider = await Provider.getProvider("anthropic")
|
||||
expect(provider).toBeDefined()
|
||||
expect(provider?.info.id).toBe("anthropic")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("closest returns undefined when no partial match found", 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",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const result = await Provider.closest("anthropic", ["nonexistent-xyz-model"])
|
||||
expect(result).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("closest checks multiple query terms in order", 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",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
// First term won't match, second will
|
||||
const result = await Provider.closest("anthropic", ["nonexistent", "haiku"])
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.modelID).toContain("haiku")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model limit defaults to zero when not specified", 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: {
|
||||
"no-limit": {
|
||||
name: "No Limit Provider",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
model: {
|
||||
name: "Model",
|
||||
tool_call: true,
|
||||
// no limit specified
|
||||
},
|
||||
},
|
||||
options: { apiKey: "test" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const model = providers["no-limit"].info.models["model"]
|
||||
expect(model.limit.context).toBe(0)
|
||||
expect(model.limit.output).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("provider options are deeply merged", 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: {
|
||||
anthropic: {
|
||||
options: {
|
||||
headers: {
|
||||
"X-Custom": "custom-value",
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
// Custom options should be merged
|
||||
expect(providers["anthropic"].options.timeout).toBe(30000)
|
||||
expect(providers["anthropic"].options.headers["X-Custom"]).toBe("custom-value")
|
||||
// anthropic custom loader adds its own headers, they should coexist
|
||||
expect(providers["anthropic"].options.headers["anthropic-beta"]).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user