Compare commits

...

13 Commits

Author SHA1 Message Date
Dax Raad
3bedf95e12 sync 2025-12-03 21:07:18 -05:00
Dax Raad
c999c3d9e5 dumb 2025-12-03 21:04:58 -05:00
opencode-agent[bot]
425ec87b7f Fixed target field usage in model API IDs
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2025-12-04 00:54:25 +00:00
Dax Raad
42aadadf7a regen sdk 2025-12-03 19:31:02 -05:00
Dax Raad
9c898cd958 core: fix provider options being overwritten when configured from multiple sources 2025-12-03 19:15:44 -05:00
Dax Raad
19c3b25bea sync 2025-12-03 19:03:14 -05:00
Dax Raad
bbbffbf928 sync 2025-12-03 19:00:22 -05:00
Dax Raad
c57216f22d sync 2025-12-03 18:43:36 -05:00
Dax Raad
c3327fc0e4 sync 2025-12-03 18:33:02 -05:00
Dax Raad
2dbb029472 sync 2025-12-03 18:33:02 -05:00
Dax Raad
8b1c55f9fa sync 2025-12-03 18:33:02 -05:00
Dax Raad
a844eb2429 core: convert Model type to Zod schema for better type safety and validation 2025-12-03 18:33:02 -05:00
Dax Raad
48cf07d32a core: refactor model ID system to use target field for provider calls
Changed model identification from using model.id to model.target when calling providers, allowing users to specify alternate model IDs while maintaining internal references. This enables more flexible provider configurations and better model mapping.
2025-12-03 18:33:02 -05:00
20 changed files with 892 additions and 720 deletions

View File

@@ -456,9 +456,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0" />
<div class="flex gap-x-3 items-baseline flex-[1_0_0]">
<span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
<Show when={i.release_date}>
<Show when={false}>
<span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0">
{DateTime.fromFormat(i.release_date, "yyyy-MM-dd").toFormat("LLL yyyy")}
{DateTime.fromFormat("unknown", "yyyy-MM-dd").toFormat("LLL yyyy")}
</span>
</Show>
</div>

View File

@@ -224,6 +224,7 @@ export namespace Agent {
export async function generate(input: { description: string }) {
const defaultModel = await Provider.defaultModel()
const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
const language = await Provider.getLanguage(model)
const system = SystemPrompt.header(defaultModel.providerID)
system.push(PROMPT_GENERATE)
const existing = await list()
@@ -241,7 +242,7 @@ export namespace Agent {
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
},
],
model: model.language,
model: language,
schema: z.object({
identifier: z.string(),
whenToUse: z.string(),

View File

@@ -38,7 +38,7 @@ export const ModelsCommand = cmd({
function printModels(providerID: string, verbose?: boolean) {
const provider = providers[providerID]
const sortedModels = Object.entries(provider.info.models).sort(([a], [b]) => a.localeCompare(b))
const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sortedModels) {
process.stdout.write(`${providerID}/${modelID}`)
process.stdout.write(EOL)

View File

@@ -470,6 +470,42 @@ export namespace Config {
})
export type Layout = z.infer<typeof Layout>
export const Provider = ModelsDev.Provider.partial()
.extend({
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
models: z.record(z.string(), ModelsDev.Model.partial()).optional(),
options: z
.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"),
setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"),
timeout: z
.union([
z
.number()
.int()
.positive()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
z.literal(false).describe("Disable timeout for this provider entirely."),
])
.optional()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
})
.catchall(z.any())
.optional(),
})
.strict()
.meta({
ref: "ProviderConfig",
})
export type Provider = z.infer<typeof Provider>
export const Info = z
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
@@ -536,43 +572,7 @@ export namespace Config {
.optional()
.describe("Agent configuration, see https://opencode.ai/docs/agent"),
provider: z
.record(
z.string(),
ModelsDev.Provider.partial()
.extend({
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
models: z.record(z.string(), ModelsDev.Model.partial()).optional(),
options: z
.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"),
setCacheKey: z
.boolean()
.optional()
.describe("Enable promptCacheKey for this provider (default false)"),
timeout: z
.union([
z
.number()
.int()
.positive()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
z.literal(false).describe("Disable timeout for this provider entirely."),
])
.optional()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
})
.catchall(z.any())
.optional(),
})
.strict(),
)
.record(z.string(), Provider)
.optional()
.describe("Custom provider configurations and model overrides"),
mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),

View File

@@ -9,16 +9,16 @@ export namespace ModelsDev {
const log = Log.create({ service: "models.dev" })
const filepath = path.join(Global.Path.cache, "models.json")
export const Model = z
.object({
id: z.string(),
name: z.string(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
tool_call: z.boolean(),
cost: z.object({
export const Model = z.object({
id: z.string(),
name: z.string(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
tool_call: z.boolean(),
cost: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
@@ -31,40 +31,34 @@ export namespace ModelsDev {
cache_write: z.number().optional(),
})
.optional(),
}),
limit: z.object({
context: z.number(),
output: z.number(),
}),
modalities: z
.object({
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
})
.optional(),
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
provider: z.object({ npm: z.string() }).optional(),
})
.meta({
ref: "Model",
})
})
.optional(),
limit: z.object({
context: z.number(),
output: z.number(),
}),
modalities: z
.object({
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
})
.optional(),
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
provider: z.object({ npm: z.string() }).optional(),
})
export type Model = z.infer<typeof Model>
export const Provider = z
.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()),
id: z.string(),
npm: z.string().optional(),
models: z.record(z.string(), Model),
})
.meta({
ref: "Provider",
})
export const Provider = z.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()),
id: z.string(),
npm: z.string().optional(),
models: z.record(z.string(), Model),
})
export type Provider = z.infer<typeof Provider>

View File

@@ -1,8 +1,8 @@
import z from "zod"
import fuzzysort from "fuzzysort"
import { Config } from "../config/config"
import { mergeDeep, sortBy } from "remeda"
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
import { mapValues, mergeDeep, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { Plugin } from "../plugin"
@@ -23,7 +23,7 @@ import { createVertex } from "@ai-sdk/google-vertex"
import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"
import { createOpenAI } from "@ai-sdk/openai"
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/openai-compatible/src"
export namespace Provider {
@@ -43,14 +43,13 @@ export namespace Provider {
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
}
type CustomLoader = (provider: ModelsDev.Provider) => Promise<{
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
type CustomLoader = (provider: Info) => Promise<{
autoload: boolean
getModel?: (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
getModel?: CustomModelLoader
options?: Record<string, any>
}>
type Source = "env" | "config" | "custom" | "api"
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
async anthropic() {
return {
@@ -280,7 +279,7 @@ export namespace Provider {
project,
location,
},
async getModel(sdk: any, modelID: string) {
async getModel(sdk, modelID) {
const id = String(modelID).trim()
return sdk.languageModel(id)
},
@@ -299,10 +298,155 @@ export namespace Provider {
},
}
export const Model = z
.object({
id: z.string(),
providerID: z.string(),
api: z.object({
id: z.string(),
url: z.string(),
npm: z.string(),
}),
name: z.string(),
capabilities: z.object({
temperature: z.boolean(),
reasoning: z.boolean(),
attachment: z.boolean(),
toolcall: z.boolean(),
input: z.object({
text: z.boolean(),
audio: z.boolean(),
image: z.boolean(),
video: z.boolean(),
pdf: z.boolean(),
}),
output: z.object({
text: z.boolean(),
audio: z.boolean(),
image: z.boolean(),
video: z.boolean(),
pdf: z.boolean(),
}),
}),
cost: z.object({
input: z.number(),
output: z.number(),
cache: z.object({
read: z.number(),
write: z.number(),
}),
experimentalOver200K: z
.object({
input: z.number(),
output: z.number(),
cache: z.object({
read: z.number(),
write: z.number(),
}),
})
.optional(),
}),
limit: z.object({
context: z.number(),
output: z.number(),
}),
status: z.enum(["alpha", "beta", "deprecated", "active"]),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()),
})
.meta({
ref: "Model",
})
export type Model = z.infer<typeof Model>
export const Info = z
.object({
id: z.string(),
name: z.string(),
source: z.enum(["env", "config", "custom", "api"]),
env: z.string().array(),
key: z.string().optional(),
options: z.record(z.string(), z.any()),
models: z.record(z.string(), Model),
})
.meta({
ref: "Provider",
})
export type Info = z.infer<typeof Info>
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
return {
id: model.id,
providerID: provider.id,
name: model.name,
api: {
id: model.id,
url: provider.api!,
npm: model.provider?.npm ?? provider.npm ?? provider.id,
},
status: model.status ?? "active",
headers: model.headers ?? {},
options: model.options ?? {},
cost: {
input: model.cost?.input ?? 0,
output: model.cost?.output ?? 0,
cache: {
read: model.cost?.cache_read ?? 0,
write: model.cost?.cache_write ?? 0,
},
experimentalOver200K: model.cost?.context_over_200k
? {
cache: {
read: model.cost.context_over_200k.cache_read ?? 0,
write: model.cost.context_over_200k.cache_write ?? 0,
},
input: model.cost.context_over_200k.input,
output: model.cost.context_over_200k.output,
}
: undefined,
},
limit: {
context: model.limit.context,
output: model.limit.output,
},
capabilities: {
temperature: model.temperature,
reasoning: model.reasoning,
attachment: model.attachment,
toolcall: model.tool_call,
input: {
text: model.modalities?.input?.includes("text") ?? false,
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") ?? false,
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,
},
},
}
}
export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
return {
id: provider.id,
source: "custom",
name: provider.name,
env: provider.env ?? [],
options: {},
models: mapValues(provider.models, (model) => fromModelsDevModel(provider, model)),
}
}
const state = Instance.state(async () => {
using _ = log.time("state")
const config = await Config.get()
const database = await ModelsDev.get()
const database = mapValues(await ModelsDev.get(), fromModelsDevProvider)
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null
@@ -313,54 +457,15 @@ export namespace Provider {
return true
}
const providers: {
[providerID: string]: {
source: Source
info: ModelsDev.Provider
getModel?: (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
options: Record<string, any>
}
const providers: { [providerID: string]: Info } = {}
const languages = new Map<string, LanguageModelV2>()
const modelLoaders: {
[providerID: string]: CustomModelLoader
} = {}
const models = new Map<
string,
{
providerID: string
modelID: string
info: ModelsDev.Model
language: LanguageModel
npm?: string
}
>()
const sdk = new Map<number, SDK>()
// Maps `${provider}/${key}` to the providers actual model ID for custom aliases.
const realIdByKey = new Map<string, string>()
log.info("init")
function mergeProvider(
id: string,
options: Record<string, any>,
source: Source,
getModel?: (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>,
) {
const provider = providers[id]
if (!provider) {
const info = database[id]
if (!info) return
if (info.api && !options["baseURL"]) options["baseURL"] = info.api
providers[id] = {
source,
info,
options,
getModel,
}
return
}
provider.options = mergeDeep(provider.options, options)
provider.source = source
provider.getModel = getModel ?? provider.getModel
}
const configProviders = Object.entries(config.provider ?? {})
// Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot
@@ -370,19 +475,31 @@ export namespace Provider {
...githubCopilot,
id: "github-copilot-enterprise",
name: "GitHub Copilot Enterprise",
// Enterprise uses a different API endpoint - will be set dynamically based on auth
api: undefined,
}
}
function mergeProvider(providerID: string, provider: Partial<Info>) {
const existing = providers[providerID]
if (existing) {
// @ts-expect-error
providers[providerID] = mergeDeep(existing, provider)
return
}
const match = database[providerID]
if (!match) return
// @ts-expect-error
providers[providerID] = mergeDeep(match, provider)
}
// extend database from config
for (const [providerID, provider] of configProviders) {
const existing = database[providerID]
const parsed: ModelsDev.Provider = {
const parsed: Info = {
id: providerID,
npm: provider.npm ?? existing?.npm,
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
api: provider.api ?? existing?.api,
options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
source: "config",
models: existing?.models ?? {},
}
@@ -393,51 +510,53 @@ export namespace Provider {
if (model.id && model.id !== modelID) return modelID
return existing?.name ?? modelID
})
const parsedModel: ModelsDev.Model = {
const parsedModel: Model = {
id: modelID,
name,
release_date: model.release_date ?? existing?.release_date,
attachment: model.attachment ?? existing?.attachment ?? false,
reasoning: model.reasoning ?? existing?.reasoning ?? false,
temperature: model.temperature ?? existing?.temperature ?? false,
tool_call: model.tool_call ?? existing?.tool_call ?? true,
cost:
!model.cost && !existing?.cost
? {
input: 0,
output: 0,
cache_read: 0,
cache_write: 0,
}
: {
cache_read: 0,
cache_write: 0,
...existing?.cost,
...model.cost,
},
options: {
...existing?.options,
...model.options,
api: {
id: model.id ?? existing?.api.id ?? modelID,
npm: model.provider?.npm ?? provider.npm ?? existing?.api.npm ?? providerID,
url: provider?.api ?? existing?.api.url,
},
limit: model.limit ??
existing?.limit ?? {
context: 0,
output: 0,
status: model.status ?? existing?.status ?? "active",
name,
providerID,
capabilities: {
temperature: model.temperature ?? existing?.capabilities.temperature ?? false,
reasoning: model.reasoning ?? existing?.capabilities.reasoning ?? false,
attachment: model.attachment ?? existing?.capabilities.attachment ?? false,
toolcall: model.tool_call ?? existing?.capabilities.toolcall ?? true,
input: {
text: model.modalities?.input?.includes("text") ?? existing?.capabilities.input.text ?? true,
audio: model.modalities?.input?.includes("audio") ?? existing?.capabilities.input.audio ?? false,
image: model.modalities?.input?.includes("image") ?? existing?.capabilities.input.image ?? false,
video: model.modalities?.input?.includes("video") ?? existing?.capabilities.input.video ?? false,
pdf: model.modalities?.input?.includes("pdf") ?? existing?.capabilities.input.pdf ?? false,
},
modalities: model.modalities ??
existing?.modalities ?? {
input: ["text"],
output: ["text"],
output: {
text: model.modalities?.output?.includes("text") ?? existing?.capabilities.output.text ?? true,
audio: model.modalities?.output?.includes("audio") ?? existing?.capabilities.output.audio ?? false,
image: model.modalities?.output?.includes("image") ?? existing?.capabilities.output.image ?? false,
video: model.modalities?.output?.includes("video") ?? existing?.capabilities.output.video ?? false,
pdf: model.modalities?.output?.includes("pdf") ?? existing?.capabilities.output.pdf ?? false,
},
headers: model.headers,
provider: model.provider ?? existing?.provider,
}
if (model.id && model.id !== modelID) {
realIdByKey.set(`${providerID}/${modelID}`, model.id)
},
cost: {
input: model?.cost?.input ?? existing?.cost?.input ?? 0,
output: model?.cost?.output ?? existing?.cost?.output ?? 0,
cache: {
read: model?.cost?.cache_read ?? existing?.cost?.cache.read ?? 0,
write: model?.cost?.cache_write ?? existing?.cost?.cache.write ?? 0,
},
},
options: mergeDeep(existing?.options ?? {}, model.options ?? {}),
limit: {
context: model.limit?.context ?? existing?.limit?.context ?? 0,
output: model.limit?.output ?? existing?.limit?.output ?? 0,
},
headers: mergeDeep(existing?.headers ?? {}, model.headers ?? {}),
}
parsed.models[modelID] = parsedModel
}
database[providerID] = parsed
}
@@ -447,19 +566,20 @@ export namespace Provider {
if (disabled.has(providerID)) continue
const apiKey = provider.env.map((item) => env[item]).find(Boolean)
if (!apiKey) continue
mergeProvider(
providerID,
// only include apiKey if there's only one potential option
provider.env.length === 1 ? { apiKey } : {},
"env",
)
mergeProvider(providerID, {
source: "env",
key: provider.env.length === 1 ? apiKey : undefined,
})
}
// load apikeys
for (const [providerID, provider] of Object.entries(await Auth.all())) {
if (disabled.has(providerID)) continue
if (provider.type === "api") {
mergeProvider(providerID, { apiKey: provider.key }, "api")
mergeProvider(providerID, {
source: "api",
key: provider.key,
})
}
}
@@ -485,7 +605,10 @@ export namespace Provider {
// Load for the main provider if auth exists
if (auth) {
const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
mergeProvider(plugin.auth.provider, options ?? {}, "custom")
mergeProvider(plugin.auth.provider, {
source: "custom",
options: options,
})
}
// If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists
@@ -498,7 +621,10 @@ export namespace Provider {
() => Auth.get(enterpriseProviderID) as any,
database[enterpriseProviderID],
)
mergeProvider(enterpriseProviderID, enterpriseOptions ?? {}, "custom")
mergeProvider(enterpriseProviderID, {
source: "custom",
options: enterpriseOptions,
})
}
}
}
@@ -508,13 +634,21 @@ export namespace Provider {
if (disabled.has(providerID)) continue
const result = await fn(database[providerID])
if (result && (result.autoload || providers[providerID])) {
mergeProvider(providerID, result.options ?? {}, "custom", result.getModel)
if (result.getModel) modelLoaders[providerID] = result.getModel
mergeProvider(providerID, {
source: "custom",
options: result.options,
})
}
}
// load config
for (const [providerID, provider] of configProviders) {
mergeProvider(providerID, provider.options ?? {}, "config")
const partial: Partial<Info> = { source: "config" }
if (provider.env) partial.env = provider.env
if (provider.name) partial.name = provider.name
if (provider.options) partial.options = provider.options
mergeProvider(providerID, partial)
}
for (const [providerID, provider] of Object.entries(providers)) {
@@ -524,49 +658,43 @@ export namespace Provider {
}
if (providerID === "github-copilot" || providerID === "github-copilot-enterprise") {
provider.info.npm = "@ai-sdk/github-copilot"
provider.models = mapValues(provider.models, (model) => ({
...model,
api: {
...model.api,
npm: "@ai-sdk/github-copilot",
},
}))
}
const configProvider = config.provider?.[providerID]
const filteredModels = Object.fromEntries(
Object.entries(provider.info.models)
// Filter out blacklisted models
.filter(
([modelID]) =>
modelID !== "gpt-5-chat-latest" && !(providerID === "openrouter" && modelID === "openai/gpt-5-chat"),
)
// Filter out experimental models
.filter(
([, model]) =>
((!model.experimental && model.status !== "alpha") || Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) &&
model.status !== "deprecated",
)
// Filter by provider's whitelist/blacklist from config
.filter(([modelID]) => {
if (!configProvider) return true
return (
(!configProvider.blacklist || !configProvider.blacklist.includes(modelID)) &&
(!configProvider.whitelist || configProvider.whitelist.includes(modelID))
)
}),
)
for (const [modelID, model] of Object.entries(provider.models)) {
model.api.id = model.api.id ?? model.id ?? modelID
if (modelID === "gpt-5-chat-latest" || (providerID === "openrouter" && modelID === "openai/gpt-5-chat"))
delete provider.models[modelID]
if ((model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) || model.status === "deprecated")
delete provider.models[modelID]
if (
(configProvider?.blacklist && configProvider.blacklist.includes(modelID)) ||
(configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
)
delete provider.models[modelID]
}
provider.info.models = filteredModels
if (Object.keys(provider.info.models).length === 0) {
if (Object.keys(provider.models).length === 0) {
delete providers[providerID]
continue
}
log.info("found", { providerID, npm: provider.info.npm })
log.info("found", { providerID })
}
return {
models,
models: languages,
providers,
sdk,
realIdByKey,
modelLoaders,
}
})
@@ -574,19 +702,28 @@ export namespace Provider {
return state().then((state) => state.providers)
}
async function getSDK(provider: ModelsDev.Provider, model: ModelsDev.Model) {
return (async () => {
async function getSDK(model: Model) {
try {
using _ = log.time("getSDK", {
providerID: provider.id,
providerID: model.providerID,
})
const s = await state()
const pkg = model.provider?.npm ?? provider.npm ?? provider.id
const options = { ...s.providers[provider.id]?.options }
if (pkg.includes("@ai-sdk/openai-compatible") && options["includeUsage"] === undefined) {
const provider = s.providers[model.providerID]
const options = { ...provider.options }
if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
options["includeUsage"] = true
}
const key = Bun.hash.xxHash32(JSON.stringify({ pkg, options }))
if (!options["baseURL"]) options["baseURL"] = model.api.url
if (!options["apiKey"]) options["apiKey"] = provider.key
if (model.headers)
options["headers"] = {
...options["headers"],
...model.headers,
}
const key = Bun.hash.xxHash32(JSON.stringify({ npm: model.api.npm, options }))
const existing = s.sdk.get(key)
if (existing) return existing
@@ -615,12 +752,13 @@ export namespace Provider {
}
// Special case: google-vertex-anthropic uses a subpath import
const bundledKey = provider.id === "google-vertex-anthropic" ? "@ai-sdk/google-vertex/anthropic" : pkg
const bundledKey =
model.providerID === "google-vertex-anthropic" ? "@ai-sdk/google-vertex/anthropic" : model.api.npm
const bundledFn = BUNDLED_PROVIDERS[bundledKey]
if (bundledFn) {
log.info("using bundled provider", { providerID: provider.id, pkg: bundledKey })
log.info("using bundled provider", { providerID: model.providerID, pkg: bundledKey })
const loaded = bundledFn({
name: provider.id,
name: model.providerID,
...options,
})
s.sdk.set(key, loaded)
@@ -628,25 +766,25 @@ export namespace Provider {
}
let installedPath: string
if (!pkg.startsWith("file://")) {
installedPath = await BunProc.install(pkg, "latest")
if (!model.api.npm.startsWith("file://")) {
installedPath = await BunProc.install(model.api.npm, "latest")
} else {
log.info("loading local provider", { pkg })
installedPath = pkg
log.info("loading local provider", { pkg: model.api.npm })
installedPath = model.api.npm
}
const mod = await import(installedPath)
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn({
name: provider.id,
name: model.providerID,
...options,
})
s.sdk.set(key, loaded)
return loaded as SDK
})().catch((e) => {
throw new InitError({ providerID: provider.id }, { cause: e })
})
} catch (e) {
throw new InitError({ providerID: model.providerID }, { cause: e })
}
}
export async function getProvider(providerID: string) {
@@ -654,15 +792,7 @@ export namespace Provider {
}
export async function getModel(providerID: string, modelID: string) {
const key = `${providerID}/${modelID}`
const s = await state()
if (s.models.has(key)) return s.models.get(key)!
log.info("getModel", {
providerID,
modelID,
})
const provider = s.providers[providerID]
if (!provider) {
const availableProviders = Object.keys(s.providers)
@@ -671,43 +801,36 @@ export namespace Provider {
throw new ModelNotFoundError({ providerID, modelID, suggestions })
}
const info = provider.info.models[modelID]
const info = provider.models[modelID]
if (!info) {
const availableModels = Object.keys(provider.info.models)
const availableModels = Object.keys(provider.models)
const matches = fuzzysort.go(modelID, availableModels, { limit: 3, threshold: -10000 })
const suggestions = matches.map((m) => m.target)
throw new ModelNotFoundError({ providerID, modelID, suggestions })
}
return info
}
const sdk = await getSDK(provider.info, info)
export async function getLanguage(model: Model) {
const s = await state()
const key = `${model.providerID}/${model.id}`
if (s.models.has(key)) return s.models.get(key)!
const provider = s.providers[model.providerID]
const sdk = await getSDK(model)
try {
const keyReal = `${providerID}/${modelID}`
const realID = s.realIdByKey.get(keyReal) ?? info.id
const language = provider.getModel
? await provider.getModel(sdk, realID, provider.options)
: sdk.languageModel(realID)
log.info("found", { providerID, modelID })
s.models.set(key, {
providerID,
modelID,
info,
language,
npm: info.provider?.npm ?? provider.info.npm,
})
return {
modelID,
providerID,
info,
language,
npm: info.provider?.npm ?? provider.info.npm,
}
const language = s.modelLoaders[model.providerID]
? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options)
: sdk.languageModel(model.api.id)
s.models.set(key, language)
return language
} catch (e) {
if (e instanceof NoSuchModelError)
throw new ModelNotFoundError(
{
modelID: modelID,
providerID,
modelID: model.id,
providerID: model.providerID,
},
{ cause: e },
)
@@ -720,7 +843,7 @@ export namespace Provider {
const provider = s.providers[providerID]
if (!provider) return undefined
for (const item of query) {
for (const modelID of Object.keys(provider.info.models)) {
for (const modelID of Object.keys(provider.models)) {
if (modelID.includes(item))
return {
providerID,
@@ -756,7 +879,7 @@ export namespace Provider {
priority = ["gpt-5-nano"]
}
for (const item of priority) {
for (const model of Object.keys(provider.info.models)) {
for (const model of Object.keys(provider.models)) {
if (model.includes(item)) return getModel(providerID, model)
}
}
@@ -764,7 +887,7 @@ export namespace Provider {
// Check if opencode provider is available before using it
const opencodeProvider = await state().then((state) => state.providers["opencode"])
if (opencodeProvider && opencodeProvider.info.models["gpt-5-nano"]) {
if (opencodeProvider && opencodeProvider.models["gpt-5-nano"]) {
return getModel("opencode", "gpt-5-nano")
}
@@ -772,7 +895,7 @@ export namespace Provider {
}
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
export function sort(models: ModelsDev.Model[]) {
export function sort(models: Model[]) {
return sortBy(
models,
[(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"],
@@ -787,12 +910,12 @@ export namespace Provider {
const provider = await list()
.then((val) => Object.values(val))
.then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id)))
.then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)))
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.info.models))
const [model] = sort(Object.values(provider.models))
if (!model) throw new Error("no models found")
return {
providerID: provider.info.id,
providerID: provider.id,
modelID: model.id,
}
}

View File

@@ -1,10 +1,11 @@
import type { APICallError, ModelMessage } from "ai"
import { unique } from "remeda"
import type { JSONSchema } from "zod/v4/core"
import type { Provider } from "./provider"
export namespace ProviderTransform {
function normalizeMessages(msgs: ModelMessage[], providerID: string, modelID: string): ModelMessage[] {
if (modelID.includes("claude")) {
function normalizeMessages(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
if (model.api.id.includes("claude")) {
return msgs.map((msg) => {
if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) {
msg.content = msg.content.map((part) => {
@@ -20,7 +21,7 @@ export namespace ProviderTransform {
return msg
})
}
if (providerID === "mistral" || modelID.toLowerCase().includes("mistral")) {
if (model.providerID === "mistral" || model.api.id.toLowerCase().includes("mistral")) {
const result: ModelMessage[] = []
for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i]
@@ -107,67 +108,68 @@ export namespace ProviderTransform {
return msgs
}
export function message(msgs: ModelMessage[], providerID: string, modelID: string) {
msgs = normalizeMessages(msgs, providerID, modelID)
if (providerID === "anthropic" || modelID.includes("anthropic") || modelID.includes("claude")) {
msgs = applyCaching(msgs, providerID)
export function message(msgs: ModelMessage[], model: Provider.Model) {
msgs = normalizeMessages(msgs, model)
if (model.providerID === "anthropic" || model.api.id.includes("anthropic") || model.api.id.includes("claude")) {
msgs = applyCaching(msgs, model.providerID)
}
return msgs
}
export function temperature(_providerID: string, modelID: string) {
if (modelID.toLowerCase().includes("qwen")) return 0.55
if (modelID.toLowerCase().includes("claude")) return undefined
if (modelID.toLowerCase().includes("gemini-3-pro")) return 1.0
export function temperature(model: Provider.Model) {
if (model.api.id.toLowerCase().includes("qwen")) return 0.55
if (model.api.id.toLowerCase().includes("claude")) return undefined
if (model.api.id.toLowerCase().includes("gemini-3-pro")) return 1.0
return 0
}
export function topP(_providerID: string, modelID: string) {
if (modelID.toLowerCase().includes("qwen")) return 1
export function topP(model: Provider.Model) {
if (model.api.id.toLowerCase().includes("qwen")) return 1
return undefined
}
export function options(
providerID: string,
modelID: string,
npm: string,
model: Provider.Model,
sessionID: string,
providerOptions?: Record<string, any>,
): Record<string, any> {
const result: Record<string, any> = {}
// switch to providerID later, for now use this
if (npm === "@openrouter/ai-sdk-provider") {
if (model.api.npm === "@openrouter/ai-sdk-provider") {
result["usage"] = {
include: true,
}
}
if (providerID === "openai" || providerOptions?.setCacheKey) {
if (model.providerID === "openai" || providerOptions?.setCacheKey) {
result["promptCacheKey"] = sessionID
}
if (providerID === "google" || (providerID.startsWith("opencode") && modelID.includes("gemini-3"))) {
if (
model.providerID === "google" ||
(model.providerID.startsWith("opencode") && model.api.id.includes("gemini-3"))
) {
result["thinkingConfig"] = {
includeThoughts: true,
}
}
if (modelID.includes("gpt-5") && !modelID.includes("gpt-5-chat")) {
if (modelID.includes("codex")) {
if (model.providerID.includes("gpt-5") && !model.api.id.includes("gpt-5-chat")) {
if (model.providerID.includes("codex")) {
result["store"] = false
}
if (!modelID.includes("codex") && !modelID.includes("gpt-5-pro")) {
if (!model.api.id.includes("codex") && !model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium"
}
if (modelID.endsWith("gpt-5.1") && providerID !== "azure") {
if (model.api.id.endsWith("gpt-5.1") && model.providerID !== "azure") {
result["textVerbosity"] = "low"
}
if (providerID.startsWith("opencode")) {
if (model.providerID.startsWith("opencode")) {
result["promptCacheKey"] = sessionID
result["include"] = ["reasoning.encrypted_content"]
result["reasoningSummary"] = "auto"
@@ -176,17 +178,17 @@ export namespace ProviderTransform {
return result
}
export function smallOptions(input: { providerID: string; modelID: string }) {
export function smallOptions(model: Provider.Model) {
const options: Record<string, any> = {}
if (input.providerID === "openai" || input.modelID.includes("gpt-5")) {
if (input.modelID.includes("5.1")) {
if (model.providerID === "openai" || model.api.id.includes("gpt-5")) {
if (model.api.id.includes("5.1")) {
options["reasoningEffort"] = "low"
} else {
options["reasoningEffort"] = "minimal"
}
}
if (input.providerID === "google") {
if (model.providerID === "google") {
options["thinkingConfig"] = {
thinkingBudget: 0,
}
@@ -254,7 +256,7 @@ export namespace ProviderTransform {
return standardLimit
}
export function schema(providerID: string, modelID: string, schema: JSONSchema.BaseSchema) {
export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema) {
/*
if (["openai", "azure"].includes(providerID)) {
if (schema.type === "object" && schema.properties) {
@@ -274,7 +276,7 @@ export namespace ProviderTransform {
*/
// Convert integer enums to string enums for Google/Gemini
if (providerID === "google" || modelID.includes("gemini")) {
if (model.providerID === "google" || model.api.id.includes("gemini")) {
const sanitizeGemini = (obj: any): any => {
if (obj === null || typeof obj !== "object") {
return obj

View File

@@ -8,7 +8,7 @@ import { proxy } from "hono/proxy"
import { Session } from "../session"
import z from "zod"
import { Provider } from "../provider/provider"
import { mapValues } from "remeda"
import { mapValues, pipe } from "remeda"
import { NamedError } from "@opencode-ai/util/error"
import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../file/ripgrep"
@@ -296,8 +296,8 @@ export namespace Server {
}),
),
async (c) => {
const { provider, model } = c.req.valid("query")
const tools = await ToolRegistry.tools(provider, model)
const { provider } = c.req.valid("query")
const tools = await ToolRegistry.tools(provider)
return c.json(
tools.map((t) => ({
id: t.id,
@@ -1025,7 +1025,7 @@ export namespace Server {
async (c) => {
c.status(204)
c.header("Content-Type", "application/json")
return stream(c, async (stream) => {
return stream(c, async () => {
const sessionID = c.req.valid("param").id
const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID })
@@ -1231,7 +1231,7 @@ export namespace Server {
"application/json": {
schema: resolver(
z.object({
providers: ModelsDev.Provider.array(),
providers: Provider.Info.array(),
default: z.record(z.string(), z.string()),
}),
),
@@ -1242,7 +1242,7 @@ export namespace Server {
}),
async (c) => {
using _ = log.time("providers")
const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info))
const providers = await Provider.list().then((x) => mapValues(x, (item) => item))
return c.json({
providers: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
@@ -1272,7 +1272,10 @@ export namespace Server {
},
}),
async (c) => {
const providers = await ModelsDev.get()
const providers = pipe(
await ModelsDev.get(),
mapValues((x) => Provider.fromModelsDevProvider(x)),
)
const connected = await Provider.list().then((x) => Object.keys(x))
return c.json({
all: Object.values(providers),

View File

@@ -1,4 +1,4 @@
import { streamText, wrapLanguageModel, type ModelMessage } from "ai"
import { wrapLanguageModel, type ModelMessage } from "ai"
import { Session } from "."
import { Identifier } from "../id/id"
import { Instance } from "../project/instance"
@@ -7,7 +7,6 @@ import { MessageV2 } from "./message-v2"
import { SystemPrompt } from "./system"
import { Bus } from "../bus"
import z from "zod"
import type { ModelsDev } from "../provider/models"
import { SessionPrompt } from "./prompt"
import { Flag } from "../flag/flag"
import { Token } from "../util/token"
@@ -29,7 +28,7 @@ export namespace SessionCompaction {
),
}
export function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: ModelsDev.Model }) {
export function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) return false
const context = input.model.limit.context
if (context === 0) return false
@@ -98,6 +97,7 @@ export namespace SessionCompaction {
auto: boolean
}) {
const model = await Provider.getModel(input.model.providerID, input.model.modelID)
const language = await Provider.getLanguage(model)
const system = [...SystemPrompt.compaction(model.providerID)]
const msg = (await Session.updateMessage({
id: Identifier.ascending("message"),
@@ -126,79 +126,72 @@ export namespace SessionCompaction {
const processor = SessionProcessor.create({
assistantMessage: msg,
sessionID: input.sessionID,
providerID: input.model.providerID,
model: model.info,
model: model,
abort: input.abort,
})
const result = await processor.process(() =>
streamText({
onError(error) {
log.error("stream error", {
error,
})
},
// set to 0, we handle loop
maxRetries: 0,
providerOptions: ProviderTransform.providerOptions(
model.npm,
model.providerID,
pipe(
{},
mergeDeep(ProviderTransform.options(model.providerID, model.modelID, model.npm ?? "", input.sessionID)),
mergeDeep(model.info.options),
),
const result = await processor.process({
onError(error) {
log.error("stream error", {
error,
})
},
// set to 0, we handle loop
maxRetries: 0,
providerOptions: ProviderTransform.providerOptions(
model.api.npm,
model.providerID,
pipe({}, mergeDeep(ProviderTransform.options(model, input.sessionID)), mergeDeep(model.options)),
),
headers: model.headers,
abortSignal: input.abort,
tools: model.capabilities.toolcall ? {} : undefined,
messages: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
headers: model.info.headers,
abortSignal: input.abort,
tools: model.info.tool_call ? {} : undefined,
messages: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...MessageV2.toModelMessage(
input.messages.filter((m) => {
if (m.info.role !== "assistant" || m.info.error === undefined) {
return true
}
if (
MessageV2.AbortedError.isInstance(m.info.error) &&
m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
) {
return true
}
...MessageV2.toModelMessage(
input.messages.filter((m) => {
if (m.info.role !== "assistant" || m.info.error === undefined) {
return true
}
if (
MessageV2.AbortedError.isInstance(m.info.error) &&
m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
) {
return true
}
return false
}),
),
{
role: "user",
content: [
{
type: "text",
text: "Summarize our conversation above. This summary will be the only context available when the conversation continues, so preserve critical information including: what was accomplished, current work in progress, files involved, next steps, and any key user requests or constraints. Be concise but detailed enough that work can continue seamlessly.",
},
],
},
],
model: wrapLanguageModel({
model: model.language,
middleware: [
return false
}),
),
{
role: "user",
content: [
{
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.modelID)
}
return args.params
},
type: "text",
text: "Summarize our conversation above. This summary will be the only context available when the conversation continues, so preserve critical information including: what was accomplished, current work in progress, files involved, next steps, and any key user requests or constraints. Be concise but detailed enough that work can continue seamlessly.",
},
],
}),
},
],
model: wrapLanguageModel({
model: language,
middleware: [
{
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.modelID)
}
return args.params
},
},
],
}),
)
})
if (result === "continue" && input.auto) {
const continueMsg = await Session.updateMessage({
id: Identifier.ascending("message"),

View File

@@ -6,8 +6,7 @@ import { Config } from "../config/config"
import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"
import type { ModelsDev } from "../provider/models"
import { Share } from "../share/share"
import { Storage } from "../storage/storage"
import { Log } from "../util/log"
import { MessageV2 } from "./message-v2"
@@ -16,7 +15,8 @@ import { SessionPrompt } from "./prompt"
import { fn } from "@/util/fn"
import { Command } from "../command"
import { Snapshot } from "@/snapshot"
import { ShareNext } from "@/share/share-next"
import type { Provider } from "@/provider/provider"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -223,6 +223,7 @@ export namespace Session {
}
if (cfg.enterprise?.url) {
const { ShareNext } = await import("@/share/share-next")
const share = await ShareNext.create(id)
await update(id, (draft) => {
draft.share = {
@@ -233,6 +234,7 @@ export namespace Session {
const session = await get(id)
if (session.share) return session.share
const { Share } = await import("../share/share")
const share = await Share.create(id)
await update(id, (draft) => {
draft.share = {
@@ -253,6 +255,7 @@ export namespace Session {
export const unshare = fn(Identifier.schema("session"), async (id) => {
const cfg = await Config.get()
if (cfg.enterprise?.url) {
const { ShareNext } = await import("@/share/share-next")
await ShareNext.remove(id)
await update(id, (draft) => {
draft.share = undefined
@@ -264,6 +267,7 @@ export namespace Session {
await update(id, (draft) => {
draft.share = undefined
})
const { Share } = await import("../share/share")
await Share.remove(id, share.secret)
})
@@ -389,7 +393,7 @@ export namespace Session {
export const getUsage = fn(
z.object({
model: z.custom<ModelsDev.Model>(),
model: z.custom<Provider.Model>(),
usage: z.custom<LanguageModelUsage>(),
metadata: z.custom<ProviderMetadata>().optional(),
}),
@@ -420,16 +424,16 @@ export namespace Session {
}
const costInfo =
input.model.cost?.context_over_200k && tokens.input + tokens.cache.read > 200_000
? input.model.cost.context_over_200k
input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000
? input.model.cost.experimentalOver200K
: input.model.cost
return {
cost: safe(
new Decimal(0)
.add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000))
.add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.read).mul(costInfo?.cache_read ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.write).mul(costInfo?.cache_write ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.read).mul(costInfo?.cache.read ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.write).mul(costInfo?.cache.write ?? 0).div(1_000_000))
// TODO: update models.dev to have better pricing model, for now:
// charge reasoning tokens at the same rate as output tokens
.add(new Decimal(tokens.reasoning).mul(costInfo?.output ?? 0).div(1_000_000))

View File

@@ -1,6 +1,5 @@
import type { ModelsDev } from "@/provider/models"
import { MessageV2 } from "./message-v2"
import { type StreamTextResult, type Tool as AITool, APICallError } from "ai"
import { streamText } from "ai"
import { Log } from "@/util/log"
import { Identifier } from "@/id/id"
import { Session } from "."
@@ -11,6 +10,7 @@ import { SessionSummary } from "./summary"
import { Bus } from "@/bus"
import { SessionRetry } from "./retry"
import { SessionStatus } from "./status"
import type { Provider } from "@/provider/provider"
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
@@ -19,11 +19,19 @@ export namespace SessionProcessor {
export type Info = Awaited<ReturnType<typeof create>>
export type Result = Awaited<ReturnType<Info["process"]>>
export type StreamInput = Parameters<typeof streamText>[0]
export type TBD = {
model: {
modelID: string
providerID: string
}
}
export function create(input: {
assistantMessage: MessageV2.Assistant
sessionID: string
providerID: string
model: ModelsDev.Model
model: Provider.Model
abort: AbortSignal
}) {
const toolcalls: Record<string, MessageV2.ToolPart> = {}
@@ -38,13 +46,13 @@ export namespace SessionProcessor {
partFromToolCall(toolCallID: string) {
return toolcalls[toolCallID]
},
async process(fn: () => StreamTextResult<Record<string, AITool>, never>) {
async process(streamInput: StreamInput) {
log.info("process")
while (true) {
try {
let currentText: MessageV2.TextPart | undefined
let reasoningMap: Record<string, MessageV2.ReasoningPart> = {}
const stream = fn()
const stream = streamText(streamInput)
for await (const value of stream.fullStream) {
input.abort.throwIfAborted()
@@ -328,11 +336,12 @@ export namespace SessionProcessor {
continue
}
}
} catch (e) {
} catch (e: any) {
log.error("process", {
error: e,
stack: JSON.stringify(e.stack),
})
const error = MessageV2.fromError(e, { providerID: input.providerID })
const error = MessageV2.fromError(e, { providerID: input.sessionID })
const retry = SessionRetry.retryable(error)
if (retry !== undefined) {
attempt++

View File

@@ -11,7 +11,6 @@ import { Agent } from "../agent/agent"
import { Provider } from "../provider/provider"
import {
generateText,
streamText,
type ModelMessage,
type Tool as AITool,
tool,
@@ -288,6 +287,7 @@ export namespace SessionPrompt {
})
const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID)
const language = await Provider.getLanguage(model)
const task = tasks.pop()
// pending subtask
@@ -311,7 +311,7 @@ export namespace SessionPrompt {
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: model.modelID,
modelID: model.id,
providerID: model.providerID,
time: {
created: Date.now(),
@@ -408,7 +408,7 @@ export namespace SessionPrompt {
agent: lastUser.agent,
model: {
providerID: model.providerID,
modelID: model.modelID,
modelID: model.id,
},
sessionID,
auto: task.auto,
@@ -421,7 +421,7 @@ export namespace SessionPrompt {
if (
lastFinished &&
lastFinished.summary !== true &&
SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model: model.info })
SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model })
) {
await SessionCompaction.create({
sessionID,
@@ -455,7 +455,7 @@ export namespace SessionPrompt {
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: model.modelID,
modelID: model.id,
providerID: model.providerID,
time: {
created: Date.now(),
@@ -463,20 +463,18 @@ export namespace SessionPrompt {
sessionID,
})) as MessageV2.Assistant,
sessionID: sessionID,
model: model.info,
providerID: model.providerID,
model,
abort,
})
const system = await resolveSystemPrompt({
providerID: model.providerID,
modelID: model.info.id,
model,
agent,
system: lastUser.system,
})
const tools = await resolveTools({
agent,
sessionID,
model: lastUser.model,
model,
tools: lastUser.tools,
processor,
})
@@ -486,21 +484,19 @@ export namespace SessionPrompt {
{
sessionID: sessionID,
agent: lastUser.agent,
model: model.info,
model: model,
provider,
message: lastUser,
},
{
temperature: model.info.temperature
? (agent.temperature ?? ProviderTransform.temperature(model.providerID, model.modelID))
temperature: model.capabilities.temperature
? (agent.temperature ?? ProviderTransform.temperature(model))
: undefined,
topP: agent.topP ?? ProviderTransform.topP(model.providerID, model.modelID),
topP: agent.topP ?? ProviderTransform.topP(model),
options: pipe(
{},
mergeDeep(
ProviderTransform.options(model.providerID, model.modelID, model.npm ?? "", sessionID, provider?.options),
),
mergeDeep(model.info.options),
mergeDeep(ProviderTransform.options(model, sessionID, provider?.options)),
mergeDeep(model.options),
mergeDeep(agent.options),
),
},
@@ -513,113 +509,111 @@ export namespace SessionPrompt {
})
}
const result = await processor.process(() =>
streamText({
onError(error) {
log.error("stream error", {
error,
const result = await processor.process({
onError(error) {
log.error("stream error", {
error,
})
},
async experimental_repairToolCall(input) {
const lower = input.toolCall.toolName.toLowerCase()
if (lower !== input.toolCall.toolName && tools[lower]) {
log.info("repairing tool call", {
tool: input.toolCall.toolName,
repaired: lower,
})
},
async experimental_repairToolCall(input) {
const lower = input.toolCall.toolName.toLowerCase()
if (lower !== input.toolCall.toolName && tools[lower]) {
log.info("repairing tool call", {
tool: input.toolCall.toolName,
repaired: lower,
})
return {
...input.toolCall,
toolName: lower,
}
}
return {
...input.toolCall,
input: JSON.stringify({
tool: input.toolCall.toolName,
error: input.error.message,
}),
toolName: "invalid",
toolName: lower,
}
},
headers: {
...(model.providerID.startsWith("opencode")
? {
"x-opencode-project": Instance.project.id,
"x-opencode-session": sessionID,
"x-opencode-request": lastUser.id,
}
: undefined),
...model.info.headers,
},
// set to 0, we handle loop
maxRetries: 0,
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
maxOutputTokens: ProviderTransform.maxOutputTokens(
model.providerID,
params.options,
model.info.limit.output,
OUTPUT_TOKEN_MAX,
}
return {
...input.toolCall,
input: JSON.stringify({
tool: input.toolCall.toolName,
error: input.error.message,
}),
toolName: "invalid",
}
},
headers: {
...(model.providerID.startsWith("opencode")
? {
"x-opencode-project": Instance.project.id,
"x-opencode-session": sessionID,
"x-opencode-request": lastUser.id,
}
: undefined),
...model.headers,
},
// set to 0, we handle loop
maxRetries: 0,
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
maxOutputTokens: ProviderTransform.maxOutputTokens(
model.api.npm,
params.options,
model.limit.output,
OUTPUT_TOKEN_MAX,
),
abortSignal: abort,
providerOptions: ProviderTransform.providerOptions(model.api.npm, model.providerID, params.options),
stopWhen: stepCountIs(1),
temperature: params.temperature,
topP: params.topP,
messages: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
abortSignal: abort,
providerOptions: ProviderTransform.providerOptions(model.npm, model.providerID, params.options),
stopWhen: stepCountIs(1),
temperature: params.temperature,
topP: params.topP,
messages: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...MessageV2.toModelMessage(
msgs.filter((m) => {
if (m.info.role !== "assistant" || m.info.error === undefined) {
return true
}
if (
MessageV2.AbortedError.isInstance(m.info.error) &&
m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
) {
return true
}
...MessageV2.toModelMessage(
msgs.filter((m) => {
if (m.info.role !== "assistant" || m.info.error === undefined) {
return true
}
if (
MessageV2.AbortedError.isInstance(m.info.error) &&
m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
) {
return true
}
return false
}),
),
],
tools: model.info.tool_call === false ? undefined : tools,
model: wrapLanguageModel({
model: model.language,
middleware: [
{
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, model.providerID, model.modelID)
}
// Transform tool schemas for provider compatibility
if (args.params.tools && Array.isArray(args.params.tools)) {
args.params.tools = args.params.tools.map((tool: any) => {
// Tools at middleware level have inputSchema, not parameters
if (tool.inputSchema && typeof tool.inputSchema === "object") {
// Transform the inputSchema for provider compatibility
return {
...tool,
inputSchema: ProviderTransform.schema(model.providerID, model.modelID, tool.inputSchema),
}
return false
}),
),
],
tools: model.capabilities.toolcall === false ? undefined : tools,
model: wrapLanguageModel({
model: language,
middleware: [
{
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error - prompt types are compatible at runtime
args.params.prompt = ProviderTransform.message(args.params.prompt, model)
}
// Transform tool schemas for provider compatibility
if (args.params.tools && Array.isArray(args.params.tools)) {
args.params.tools = args.params.tools.map((tool: any) => {
// Tools at middleware level have inputSchema, not parameters
if (tool.inputSchema && typeof tool.inputSchema === "object") {
// Transform the inputSchema for provider compatibility
return {
...tool,
inputSchema: ProviderTransform.schema(model, tool.inputSchema),
}
// If no inputSchema, return tool unchanged
return tool
})
}
return args.params
},
}
// If no inputSchema, return tool unchanged
return tool
})
}
return args.params
},
],
}),
},
],
}),
)
})
if (result === "stop") break
continue
}
@@ -642,18 +636,13 @@ export namespace SessionPrompt {
return Provider.defaultModel()
}
async function resolveSystemPrompt(input: {
system?: string
agent: Agent.Info
providerID: string
modelID: string
}) {
let system = SystemPrompt.header(input.providerID)
async function resolveSystemPrompt(input: { system?: string; agent: Agent.Info; model: Provider.Model }) {
let system = SystemPrompt.header(input.model.providerID)
system.push(
...(() => {
if (input.system) return [input.system]
if (input.agent.prompt) return [input.agent.prompt]
return SystemPrompt.provider(input.modelID)
return SystemPrompt.provider(input.model)
})(),
)
system.push(...(await SystemPrompt.environment()))
@@ -666,10 +655,7 @@ export namespace SessionPrompt {
async function resolveTools(input: {
agent: Agent.Info
model: {
providerID: string
modelID: string
}
model: Provider.Model
sessionID: string
tools?: Record<string, boolean>
processor: SessionProcessor.Info
@@ -677,16 +663,12 @@ export namespace SessionPrompt {
const tools: Record<string, AITool> = {}
const enabledTools = pipe(
input.agent.tools,
mergeDeep(await ToolRegistry.enabled(input.model.providerID, input.model.modelID, input.agent)),
mergeDeep(await ToolRegistry.enabled(input.agent)),
mergeDeep(input.tools ?? {}),
)
for (const item of await ToolRegistry.tools(input.model.providerID, input.model.modelID)) {
for (const item of await ToolRegistry.tools(input.model.providerID)) {
if (Wildcard.all(item.id, enabledTools) === false) continue
const schema = ProviderTransform.schema(
input.model.providerID,
input.model.modelID,
z.toJSONSchema(item.parameters),
)
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
id: item.id as any,
description: item.description,
@@ -1437,25 +1419,18 @@ export namespace SessionPrompt {
if (!isFirst) return
const small =
(await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID))
const language = await Provider.getLanguage(small)
const provider = await Provider.getProvider(small.providerID)
const options = pipe(
{},
mergeDeep(
ProviderTransform.options(
small.providerID,
small.modelID,
small.npm ?? "",
input.session.id,
provider?.options,
),
),
mergeDeep(ProviderTransform.smallOptions({ providerID: small.providerID, modelID: small.modelID })),
mergeDeep(small.info.options),
mergeDeep(ProviderTransform.options(small, input.session.id, provider?.options)),
mergeDeep(ProviderTransform.smallOptions(small)),
mergeDeep(small.options),
)
await generateText({
// use higher # for reasoning models since reasoning tokens eat up a lot of the budget
maxOutputTokens: small.info.reasoning ? 3000 : 20,
providerOptions: ProviderTransform.providerOptions(small.npm, small.providerID, options),
maxOutputTokens: small.capabilities.reasoning ? 3000 : 20,
providerOptions: ProviderTransform.providerOptions(small.api.npm, small.providerID, options),
messages: [
...SystemPrompt.title(small.providerID).map(
(x): ModelMessage => ({
@@ -1486,8 +1461,8 @@ export namespace SessionPrompt {
},
]),
],
headers: small.info.headers,
model: small.language,
headers: small.headers,
model: language,
})
.then((result) => {
if (result.text)
@@ -1504,7 +1479,7 @@ export namespace SessionPrompt {
})
})
.catch((error) => {
log.error("failed to generate title", { error, model: small.info.id })
log.error("failed to generate title", { error, model: small.id })
})
}
}

View File

@@ -76,19 +76,20 @@ export namespace SessionSummary {
const small =
(await Provider.getSmallModel(assistantMsg.providerID)) ??
(await Provider.getModel(assistantMsg.providerID, assistantMsg.modelID))
const language = await Provider.getLanguage(small)
const options = pipe(
{},
mergeDeep(ProviderTransform.options(small.providerID, small.modelID, small.npm ?? "", assistantMsg.sessionID)),
mergeDeep(ProviderTransform.smallOptions({ providerID: small.providerID, modelID: small.modelID })),
mergeDeep(small.info.options),
mergeDeep(ProviderTransform.options(small, assistantMsg.sessionID)),
mergeDeep(ProviderTransform.smallOptions(small)),
mergeDeep(small.options),
)
const textPart = msgWithParts.parts.find((p) => p.type === "text" && !p.synthetic) as MessageV2.TextPart
if (textPart && !userMsg.summary?.title) {
const result = await generateText({
maxOutputTokens: small.info.reasoning ? 1500 : 20,
providerOptions: ProviderTransform.providerOptions(small.npm, small.providerID, options),
maxOutputTokens: small.capabilities.reasoning ? 1500 : 20,
providerOptions: ProviderTransform.providerOptions(small.api.npm, small.providerID, options),
messages: [
...SystemPrompt.title(small.providerID).map(
(x): ModelMessage => ({
@@ -106,8 +107,8 @@ export namespace SessionSummary {
`,
},
],
headers: small.info.headers,
model: small.language,
headers: small.headers,
model: language,
})
log.info("title", { title: result.text })
userMsg.summary.title = result.text
@@ -132,9 +133,9 @@ export namespace SessionSummary {
}
}
const result = await generateText({
model: small.language,
model: language,
maxOutputTokens: 100,
providerOptions: ProviderTransform.providerOptions(small.npm, small.providerID, options),
providerOptions: ProviderTransform.providerOptions(small.api.npm, small.providerID, options),
messages: [
...SystemPrompt.summarize(small.providerID).map(
(x): ModelMessage => ({
@@ -148,7 +149,7 @@ export namespace SessionSummary {
content: `Summarize the above conversation according to your system prompts.`,
},
],
headers: small.info.headers,
headers: small.headers,
}).catch(() => {})
if (result) summary = result.text
}

View File

@@ -17,6 +17,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_SUMMARIZE from "./prompt/summarize.txt"
import PROMPT_TITLE from "./prompt/title.txt"
import PROMPT_CODEX from "./prompt/codex.txt"
import type { Provider } from "@/provider/provider"
export namespace SystemPrompt {
export function header(providerID: string) {
@@ -24,12 +25,13 @@ export namespace SystemPrompt {
return []
}
export function provider(modelID: string) {
if (modelID.includes("gpt-5")) return [PROMPT_CODEX]
if (modelID.includes("gpt-") || modelID.includes("o1") || modelID.includes("o3")) return [PROMPT_BEAST]
if (modelID.includes("gemini-")) return [PROMPT_GEMINI]
if (modelID.includes("claude")) return [PROMPT_ANTHROPIC]
if (modelID.includes("polaris-alpha")) return [PROMPT_POLARIS]
export function provider(model: Provider.Model) {
if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX]
if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))
return [PROMPT_BEAST]
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
if (model.api.id.includes("polaris-alpha")) return [PROMPT_POLARIS]
return [PROMPT_ANTHROPIC_WITHOUT_TODO]
}

View File

@@ -1,7 +1,6 @@
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { ulid } from "ulid"
import type { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { Session } from "@/session"
import { MessageV2 } from "@/session/message-v2"
@@ -36,7 +35,7 @@ export namespace ShareNext {
type: "model",
data: [
await Provider.getModel(evt.properties.info.model.providerID, evt.properties.info.model.modelID).then(
(m) => m.info,
(m) => m,
),
],
},
@@ -105,7 +104,7 @@ export namespace ShareNext {
}
| {
type: "model"
data: ModelsDev.Model[]
data: SDK.Model[]
}
const queue = new Map<string, { timeout: NodeJS.Timeout; data: Map<string, Data> }>()
@@ -171,7 +170,7 @@ export namespace ShareNext {
messages
.filter((m) => m.info.role === "user")
.map((m) => (m.info as SDK.UserMessage).model)
.map((m) => Provider.getModel(m.providerID, m.modelID).then((m) => m.info)),
.map((m) => Provider.getModel(m.providerID, m.modelID).then((m) => m)),
)
await sync(sessionID, [
{

View File

@@ -37,7 +37,7 @@ export const BatchTool = Tool.define("batch", async () => {
const discardedCalls = params.tool_calls.slice(10)
const { ToolRegistry } = await import("./registry")
const availableTools = await ToolRegistry.tools("", "")
const availableTools = await ToolRegistry.tools("")
const toolMap = new Map(availableTools.map((t) => [t.id, t]))
const executeCall = async (call: (typeof toolCalls)[0]) => {

View File

@@ -101,7 +101,7 @@ export const ReadTool = Tool.define("read", {
const modelID = ctx.extra["modelID"] as string
const model = await Provider.getModel(providerID, modelID).catch(() => undefined)
if (!model) return false
return model.info.modalities?.input?.includes("image") ?? false
return model.capabilities.input.image
})()
if (isImage) {
if (!supportsImages) {

View File

@@ -108,7 +108,7 @@ export namespace ToolRegistry {
return all().then((x) => x.map((t) => t.id))
}
export async function tools(providerID: string, _modelID: string) {
export async function tools(providerID: string) {
const tools = await all()
const result = await Promise.all(
tools
@@ -124,11 +124,7 @@ export namespace ToolRegistry {
return result
}
export async function enabled(
_providerID: string,
_modelID: string,
agent: Agent.Info,
): Promise<Record<string, boolean>> {
export async function enabled(agent: Agent.Info): Promise<Record<string, boolean>> {
const result: Record<string, boolean> = {}
if (agent.permission.edit === "deny") {

View File

@@ -132,7 +132,7 @@ test("model whitelist filters models for provider", async () => {
fn: async () => {
const providers = await Provider.list()
expect(providers["anthropic"]).toBeDefined()
const models = Object.keys(providers["anthropic"].info.models)
const models = Object.keys(providers["anthropic"].models)
expect(models).toContain("claude-sonnet-4-20250514")
expect(models.length).toBe(1)
},
@@ -163,7 +163,7 @@ test("model blacklist excludes specific models", async () => {
fn: async () => {
const providers = await Provider.list()
expect(providers["anthropic"]).toBeDefined()
const models = Object.keys(providers["anthropic"].info.models)
const models = Object.keys(providers["anthropic"].models)
expect(models).not.toContain("claude-sonnet-4-20250514")
},
})
@@ -198,8 +198,8 @@ test("custom model alias via config", async () => {
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")
expect(providers["anthropic"].models["my-alias"]).toBeDefined()
expect(providers["anthropic"].models["my-alias"].name).toBe("My Custom Alias")
},
})
})
@@ -241,8 +241,8 @@ test("custom provider with npm package", async () => {
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()
expect(providers["custom-provider"].name).toBe("Custom Provider")
expect(providers["custom-provider"].models["custom-model"]).toBeDefined()
},
})
})
@@ -299,8 +299,9 @@ test("getModel returns model for valid provider/model", 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()
expect(model.id).toBe("claude-sonnet-4-20250514")
const language = await Provider.getLanguage(model)
expect(language).toBeDefined()
},
})
})
@@ -478,11 +479,11 @@ test("model cost defaults to zero when not specified", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
const model = providers["test-provider"].info.models["test-model"]
const model = providers["test-provider"].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)
expect(model.cost.cache.read).toBe(0)
expect(model.cost.cache.write).toBe(0)
},
})
})
@@ -516,7 +517,7 @@ test("model options are merged from existing model", async () => {
},
fn: async () => {
const providers = await Provider.list()
const model = providers["anthropic"].info.models["claude-sonnet-4-20250514"]
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.options.customOption).toBe("custom-value")
},
})
@@ -623,17 +624,17 @@ test("getModel uses realIdByKey for aliased models", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers["anthropic"].info.models["my-sonnet"]).toBeDefined()
expect(providers["anthropic"].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")
expect(model.id).toBe("my-sonnet")
expect(model.name).toBe("My Sonnet Alias")
},
})
})
test("provider api field sets default baseURL", async () => {
test("provider api field sets model api.url", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
@@ -666,7 +667,8 @@ test("provider api field sets default baseURL", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
expect(providers["custom-api"].options.baseURL).toBe("https://api.example.com/v1")
// api field is stored on model.api.url, used by getSDK to set baseURL
expect(providers["custom-api"].models["model-1"].api.url).toBe("https://api.example.com/v1")
},
})
})
@@ -737,10 +739,10 @@ test("model inherits properties from existing database model", async () => {
},
fn: async () => {
const providers = await Provider.list()
const model = providers["anthropic"].info.models["claude-sonnet-4-20250514"]
const model = providers["anthropic"].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.capabilities.toolcall).toBe(true)
expect(model.capabilities.attachment).toBe(true)
expect(model.limit.context).toBeGreaterThan(0)
},
})
@@ -820,7 +822,7 @@ test("whitelist and blacklist can be combined", async () => {
fn: async () => {
const providers = await Provider.list()
expect(providers["anthropic"]).toBeDefined()
const models = Object.keys(providers["anthropic"].info.models)
const models = Object.keys(providers["anthropic"].models)
expect(models).toContain("claude-sonnet-4-20250514")
expect(models).not.toContain("claude-opus-4-20250514")
expect(models.length).toBe(1)
@@ -858,11 +860,9 @@ test("model modalities default correctly", async () => {
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"],
})
const model = providers["test-provider"].models["test-model"]
expect(model.capabilities.input.text).toBe(true)
expect(model.capabilities.output.text).toBe(true)
},
})
})
@@ -903,11 +903,11 @@ test("model with custom cost values", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
const model = providers["test-provider"].info.models["test-model"]
const model = providers["test-provider"].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)
expect(model.cost.cache.read).toBe(2.5)
expect(model.cost.cache.write).toBe(7.5)
},
})
})
@@ -931,7 +931,7 @@ test("getSmallModel returns appropriate small model", async () => {
fn: async () => {
const model = await Provider.getSmallModel("anthropic")
expect(model).toBeDefined()
expect(model?.modelID).toContain("haiku")
expect(model?.id).toContain("haiku")
},
})
})
@@ -957,7 +957,7 @@ test("getSmallModel respects config small_model override", async () => {
const model = await Provider.getSmallModel("anthropic")
expect(model).toBeDefined()
expect(model?.providerID).toBe("anthropic")
expect(model?.modelID).toBe("claude-sonnet-4-20250514")
expect(model?.id).toBe("claude-sonnet-4-20250514")
},
})
})
@@ -1046,7 +1046,7 @@ test("provider with custom npm package", async () => {
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"].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible")
expect(providers["local-llm"].options.baseURL).toBe("http://localhost:11434/v1")
},
})
@@ -1082,7 +1082,7 @@ test("model alias name defaults to alias key when id differs", async () => {
},
fn: async () => {
const providers = await Provider.list()
expect(providers["anthropic"].info.models["sonnet"].name).toBe("sonnet")
expect(providers["anthropic"].models["sonnet"].name).toBe("sonnet")
},
})
})
@@ -1123,8 +1123,8 @@ test("provider with multiple env var options only includes apiKey when single en
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()
// When multiple env options exist, key should NOT be auto-set
expect(providers["multi-env"].key).toBeUndefined()
},
})
})
@@ -1165,8 +1165,8 @@ test("provider with single env var includes apiKey automatically", async () => {
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")
// Single env option should auto-set key
expect(providers["single-env"].key).toBe("my-api-key")
},
})
})
@@ -1201,7 +1201,7 @@ test("model cost overrides existing cost values", async () => {
},
fn: async () => {
const providers = await Provider.list()
const model = providers["anthropic"].info.models["claude-sonnet-4-20250514"]
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.cost.input).toBe(999)
expect(model.cost.output).toBe(888)
},
@@ -1249,11 +1249,11 @@ test("completely new provider not in database can be configured", async () => {
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")
expect(providers["brand-new-provider"].name).toBe("Brand New")
const model = providers["brand-new-provider"].models["new-model"]
expect(model.capabilities.reasoning).toBe(true)
expect(model.capabilities.attachment).toBe(true)
expect(model.capabilities.input.image).toBe(true)
},
})
})
@@ -1322,7 +1322,7 @@ test("model with tool_call false", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
expect(providers["no-tools"].info.models["basic-model"].tool_call).toBe(false)
expect(providers["no-tools"].models["basic-model"].capabilities.toolcall).toBe(false)
},
})
})
@@ -1357,7 +1357,7 @@ test("model defaults tool_call to true when not specified", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
expect(providers["default-tools"].info.models["model"].tool_call).toBe(true)
expect(providers["default-tools"].models["model"].capabilities.toolcall).toBe(true)
},
})
})
@@ -1396,7 +1396,7 @@ test("model headers are preserved", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
const model = providers["headers-provider"].info.models["model"]
const model = providers["headers-provider"].models["model"]
expect(model.headers).toEqual({
"X-Custom-Header": "custom-value",
Authorization: "Bearer special-token",
@@ -1465,8 +1465,8 @@ test("getModel returns consistent results", 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)
expect(model1.id).toEqual(model2.id)
expect(model1).toEqual(model2)
},
})
})
@@ -1501,7 +1501,7 @@ test("provider name defaults to id when not in database", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
expect(providers["my-custom-id"].info.name).toBe("my-custom-id")
expect(providers["my-custom-id"].name).toBe("my-custom-id")
},
})
})
@@ -1601,7 +1601,7 @@ test("getProvider returns provider info", async () => {
fn: async () => {
const provider = await Provider.getProvider("anthropic")
expect(provider).toBeDefined()
expect(provider?.info.id).toBe("anthropic")
expect(provider?.id).toBe("anthropic")
},
})
})
@@ -1684,7 +1684,7 @@ test("model limit defaults to zero when not specified", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
const model = providers["no-limit"].info.models["model"]
const model = providers["no-limit"].models["model"]
expect(model.limit.context).toBe(0)
expect(model.limit.output).toBe(0)
},

View File

@@ -942,6 +942,75 @@ export type AgentConfig = {
| undefined
}
export type ProviderConfig = {
api?: string
name?: string
env?: Array<string>
id?: string
npm?: string
models?: {
[key: string]: {
id?: string
name?: string
release_date?: string
attachment?: boolean
reasoning?: boolean
temperature?: boolean
tool_call?: boolean
cost?: {
input: number
output: number
cache_read?: number
cache_write?: number
context_over_200k?: {
input: number
output: number
cache_read?: number
cache_write?: number
}
}
limit?: {
context: number
output: number
}
modalities?: {
input: Array<"text" | "audio" | "image" | "video" | "pdf">
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
options?: {
[key: string]: unknown
}
headers?: {
[key: string]: string
}
provider?: {
npm: string
}
}
}
whitelist?: Array<string>
blacklist?: Array<string>
options?: {
apiKey?: string
baseURL?: string
/**
* GitHub Enterprise URL for copilot authentication
*/
enterpriseUrl?: string
/**
* Enable promptCacheKey for this provider (default false)
*/
setCacheKey?: boolean
/**
* Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.
*/
timeout?: number | false
[key: string]: unknown | string | boolean | (number | false) | undefined
}
}
export type McpLocalConfig = {
/**
* Type of MCP server connection
@@ -1100,74 +1169,7 @@ export type Config = {
* Custom provider configurations and model overrides
*/
provider?: {
[key: string]: {
api?: string
name?: string
env?: Array<string>
id?: string
npm?: string
models?: {
[key: string]: {
id?: string
name?: string
release_date?: string
attachment?: boolean
reasoning?: boolean
temperature?: boolean
tool_call?: boolean
cost?: {
input: number
output: number
cache_read?: number
cache_write?: number
context_over_200k?: {
input: number
output: number
cache_read?: number
cache_write?: number
}
}
limit?: {
context: number
output: number
}
modalities?: {
input: Array<"text" | "audio" | "image" | "video" | "pdf">
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
options?: {
[key: string]: unknown
}
headers?: {
[key: string]: string
}
provider?: {
npm: string
}
}
}
whitelist?: Array<string>
blacklist?: Array<string>
options?: {
apiKey?: string
baseURL?: string
/**
* GitHub Enterprise URL for copilot authentication
*/
enterpriseUrl?: string
/**
* Enable promptCacheKey for this provider (default false)
*/
setCacheKey?: boolean
/**
* Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.
*/
timeout?: number | false
[key: string]: unknown | string | boolean | (number | false) | undefined
}
}
[key: string]: ProviderConfig
}
/**
* MCP (Model Context Protocol) server configurations
@@ -1354,51 +1356,71 @@ export type Command = {
export type Model = {
id: string
providerID: string
api: {
id: string
url: string
npm: string
}
name: string
release_date: string
attachment: boolean
reasoning: boolean
temperature: boolean
tool_call: boolean
capabilities: {
temperature: boolean
reasoning: boolean
attachment: boolean
toolcall: boolean
input: {
text: boolean
audio: boolean
image: boolean
video: boolean
pdf: boolean
}
output: {
text: boolean
audio: boolean
image: boolean
video: boolean
pdf: boolean
}
}
cost: {
input: number
output: number
cache_read?: number
cache_write?: number
context_over_200k?: {
cache: {
read: number
write: number
}
experimentalOver200K?: {
input: number
output: number
cache_read?: number
cache_write?: number
cache: {
read: number
write: number
}
}
}
limit: {
context: number
output: number
}
modalities?: {
input: Array<"text" | "audio" | "image" | "video" | "pdf">
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
status: "alpha" | "beta" | "deprecated" | "active"
options: {
[key: string]: unknown
}
headers?: {
headers: {
[key: string]: string
}
provider?: {
npm: string
}
}
export type Provider = {
api?: string
name: string
env: Array<string>
id: string
npm?: string
name: string
source: "env" | "config" | "custom" | "api"
env: Array<string>
key?: string
options: {
[key: string]: unknown
}
models: {
[key: string]: Model
}
@@ -2665,7 +2687,55 @@ export type ProviderListResponses = {
* List of providers
*/
200: {
all: Array<Provider>
all: Array<{
api?: string
name: string
env: Array<string>
id: string
npm?: string
models: {
[key: string]: {
id: string
name: string
release_date: string
attachment: boolean
reasoning: boolean
temperature: boolean
tool_call: boolean
cost?: {
input: number
output: number
cache_read?: number
cache_write?: number
context_over_200k?: {
input: number
output: number
cache_read?: number
cache_write?: number
}
}
limit: {
context: number
output: number
}
modalities?: {
input: Array<"text" | "audio" | "image" | "video" | "pdf">
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
options: {
[key: string]: unknown
}
headers?: {
[key: string]: string
}
provider?: {
npm: string
}
}
}
}>
default: {
[key: string]: string
}