mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-24 19:24:22 +01:00
integrate with models.dev
This commit is contained in:
@@ -50,7 +50,7 @@ export namespace Config {
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
provider: z.lazy(() => Provider.Info.array().optional()),
|
||||
provider: z.record(z.string(), z.record(z.string(), z.any())).optional(),
|
||||
tool: z
|
||||
.object({
|
||||
provider: z.record(z.string(), z.string().array()).optional(),
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { Provider } from "./provider"
|
||||
|
||||
export const PROVIDER_DATABASE: Provider.Info[] = [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-20250514",
|
||||
name: "Claude Sonnet 4",
|
||||
cost: {
|
||||
input: 3.0 / 1_000_000,
|
||||
output: 15.0 / 1_000_000,
|
||||
inputCached: 3.75 / 1_000_000,
|
||||
outputCached: 0.3 / 1_000_000,
|
||||
},
|
||||
contextWindow: 200_000,
|
||||
maxOutputTokens: 50_000,
|
||||
reasoning: true,
|
||||
attachment: true,
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-20250514",
|
||||
name: "Claude Opus 4",
|
||||
cost: {
|
||||
input: 15.0 / 1_000_000,
|
||||
output: 75.0 / 1_000_000,
|
||||
inputCached: 18.75 / 1_000_000,
|
||||
outputCached: 1.5 / 1_000_000,
|
||||
},
|
||||
contextWindow: 200_000,
|
||||
maxOutputTokens: 32_000,
|
||||
reasoning: true,
|
||||
attachment: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
models: [
|
||||
{
|
||||
id: "codex-mini-latest",
|
||||
name: "Codex Mini",
|
||||
cost: {
|
||||
input: 1.5 / 1_000_000,
|
||||
inputCached: 0.375 / 1_000_000,
|
||||
output: 6.0 / 1_000_000,
|
||||
outputCached: 0.0 / 1_000_000,
|
||||
},
|
||||
contextWindow: 200_000,
|
||||
maxOutputTokens: 100_000,
|
||||
attachment: true,
|
||||
reasoning: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
name: "Google",
|
||||
models: [
|
||||
{
|
||||
id: "gemini-2.5-pro-preview-03-25",
|
||||
name: "Gemini 2.5 Pro",
|
||||
cost: {
|
||||
input: 1.25 / 1_000_000,
|
||||
inputCached: 0 / 1_000_000,
|
||||
output: 10 / 1_000_000,
|
||||
outputCached: 0 / 1_000_000,
|
||||
},
|
||||
contextWindow: 1_000_000,
|
||||
maxOutputTokens: 50_000,
|
||||
attachment: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
29
packages/opencode/src/provider/models.ts
Normal file
29
packages/opencode/src/provider/models.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
|
||||
export namespace ModelsDev {
|
||||
const log = Log.create({ service: "models.dev" })
|
||||
|
||||
function filepath() {
|
||||
return path.join(Global.Path.data, "models.json")
|
||||
}
|
||||
|
||||
export async function get() {
|
||||
const file = Bun.file(filepath())
|
||||
if (await file.exists()) {
|
||||
refresh()
|
||||
return file.json()
|
||||
}
|
||||
await refresh()
|
||||
return get()
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
log.info("refreshing")
|
||||
const result = await fetch("https://models.dev/api.json")
|
||||
if (!result.ok)
|
||||
throw new Error(`Failed to fetch models.dev: ${result.statusText}`)
|
||||
await Bun.write(filepath(), result)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import z from "zod"
|
||||
import { App } from "../app/app"
|
||||
import { Config } from "../config/config"
|
||||
import { PROVIDER_DATABASE } from "./database"
|
||||
import { mapValues, sortBy } from "remeda"
|
||||
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
@@ -22,6 +22,7 @@ import type { Tool } from "../tool/tool"
|
||||
import { WriteTool } from "../tool/write"
|
||||
import { TodoReadTool, TodoWriteTool } from "../tool/todo"
|
||||
import { AuthAnthropic } from "../auth/anthropic"
|
||||
import { ModelsDev } from "./models"
|
||||
|
||||
export namespace Provider {
|
||||
const log = Log.create({ service: "provider" })
|
||||
@@ -30,16 +31,18 @@ export namespace Provider {
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
attachment: z.boolean(),
|
||||
reasoning: z.boolean().optional(),
|
||||
cost: z.object({
|
||||
input: z.number(),
|
||||
inputCached: z.number(),
|
||||
output: z.number(),
|
||||
outputCached: z.number(),
|
||||
}),
|
||||
contextWindow: z.number(),
|
||||
maxOutputTokens: z.number().optional(),
|
||||
attachment: z.boolean(),
|
||||
reasoning: z.boolean().optional(),
|
||||
limit: z.object({
|
||||
context: z.number(),
|
||||
output: z.number(),
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Provider.Model",
|
||||
@@ -50,23 +53,27 @@ export namespace Provider {
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
options: z.record(z.string(), z.any()).optional(),
|
||||
models: Model.array(),
|
||||
models: z.record(z.string(), Model),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Provider.Info",
|
||||
})
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
const AUTODETECT: Record<string, string[]> = {
|
||||
anthropic: ["ANTHROPIC_API_KEY"],
|
||||
openai: ["OPENAI_API_KEY"],
|
||||
google: ["GOOGLE_GENERATIVE_AI_API_KEY"], // TODO: support GEMINI_API_KEY?
|
||||
type Autodetector = (provider: Info) => Promise<Record<string, any> | false>
|
||||
|
||||
function env(...keys: string[]): Autodetector {
|
||||
return async () => {
|
||||
for (const key of keys) {
|
||||
if (process.env[key]) return {}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const AUTODETECT2: Record<
|
||||
const AUTODETECT: Record<
|
||||
string,
|
||||
() => Promise<Record<string, any> | false>
|
||||
(provider: Info) => Promise<Record<string, any> | false>
|
||||
> = {
|
||||
anthropic: async () => {
|
||||
const result = await AuthAnthropic.load()
|
||||
@@ -78,44 +85,53 @@ export namespace Provider {
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
}
|
||||
if (process.env["ANTHROPIC_API_KEY"]) return {}
|
||||
return false
|
||||
return env("ANTHROPIC_API_KEY")
|
||||
},
|
||||
google: env("GOOGLE_GENERATIVE_AI_API_KEY"),
|
||||
openai: env("OPENAI_API_KEY"),
|
||||
}
|
||||
|
||||
const state = App.state("provider", async () => {
|
||||
log.info("loading config")
|
||||
const config = await Config.get()
|
||||
log.info("loading providers")
|
||||
const providers = new Map<string, Info>()
|
||||
const database: Record<string, Provider.Info> = await ModelsDev.get()
|
||||
|
||||
const providers: {
|
||||
[providerID: string]: {
|
||||
info: Provider.Info
|
||||
options: Record<string, any>
|
||||
}
|
||||
} = {}
|
||||
const models = new Map<string, { info: Model; language: LanguageModel }>()
|
||||
const sdk = new Map<string, SDK>()
|
||||
|
||||
log.info("loading")
|
||||
|
||||
for (const [providerID, fn] of Object.entries(AUTODETECT2)) {
|
||||
const provider = PROVIDER_DATABASE.find((x) => x.id === providerID)
|
||||
for (const [providerID, fn] of Object.entries(AUTODETECT)) {
|
||||
const provider = database[providerID]
|
||||
if (!provider) continue
|
||||
const result = await fn()
|
||||
if (!result) continue
|
||||
providers.set(providerID, {
|
||||
...provider,
|
||||
options: {
|
||||
...provider.options,
|
||||
...result,
|
||||
},
|
||||
})
|
||||
const options = await fn(provider)
|
||||
if (!options) continue
|
||||
providers[providerID] = {
|
||||
info: provider,
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of PROVIDER_DATABASE) {
|
||||
if (!AUTODETECT[item.id].some((env) => process.env[env])) continue
|
||||
log.info("found", { providerID: item.id })
|
||||
providers.set(item.id, item)
|
||||
}
|
||||
|
||||
for (const item of config.provider ?? []) {
|
||||
log.info("found", { providerID: item.id })
|
||||
providers.set(item.id, item)
|
||||
for (const [providerID, options] of Object.entries(config.provider ?? {})) {
|
||||
const existing = providers[providerID]
|
||||
if (existing) {
|
||||
existing.options = {
|
||||
...existing.options,
|
||||
...options,
|
||||
}
|
||||
continue
|
||||
}
|
||||
providers[providerID] = {
|
||||
info: database[providerID],
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -126,7 +142,9 @@ export namespace Provider {
|
||||
})
|
||||
|
||||
export async function active() {
|
||||
return state().then((state) => state.providers)
|
||||
return state().then((state) =>
|
||||
mapValues(state.providers, (item) => item.info),
|
||||
)
|
||||
}
|
||||
|
||||
async function getSDK(providerID: string) {
|
||||
@@ -149,7 +167,7 @@ export namespace Provider {
|
||||
}
|
||||
const mod = await import(path.join(dir))
|
||||
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
|
||||
const loaded = fn(s.providers.get(providerID)?.options)
|
||||
const loaded = fn(s.providers[providerID]?.options)
|
||||
s.sdk.set(providerID, loaded)
|
||||
return loaded as SDK
|
||||
}
|
||||
@@ -164,9 +182,9 @@ export namespace Provider {
|
||||
modelID,
|
||||
})
|
||||
|
||||
const provider = s.providers.get(providerID)
|
||||
const provider = s.providers[providerID]
|
||||
if (!provider) throw new ModelNotFoundError(modelID)
|
||||
const info = provider.models.find((m) => m.id === modelID)
|
||||
const info = provider.info.models[modelID]
|
||||
if (!info) throw new ModelNotFoundError(modelID)
|
||||
|
||||
const sdk = await getSDK(providerID)
|
||||
@@ -189,10 +207,20 @@ export namespace Provider {
|
||||
}
|
||||
}
|
||||
|
||||
const priority = ["claude-sonnet-4", "gemini-2.5-pro-preview", "codex-mini"]
|
||||
export function sort(models: Model[]) {
|
||||
return sortBy(
|
||||
models,
|
||||
[(model) => priority.indexOf(model.id), "desc"],
|
||||
[(model) => (model.id.includes("latest") ? 0 : 1), "asc"],
|
||||
[(model) => model.id, "desc"],
|
||||
)
|
||||
}
|
||||
|
||||
export async function defaultModel() {
|
||||
const [provider] = await active().then((val) => val.values().toArray())
|
||||
const [provider] = await active().then((val) => Object.values(val))
|
||||
if (!provider) throw new Error("no providers found")
|
||||
const model = provider.models[0]
|
||||
const [model] = sort(Object.values(provider.models))
|
||||
if (!model) throw new Error("no models found")
|
||||
return {
|
||||
providerID: provider.id,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Message } from "../session/message"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { App } from "../app/app"
|
||||
import { Global } from "../global"
|
||||
import { mapValues } from "remeda"
|
||||
|
||||
export namespace Server {
|
||||
const log = Log.create({ service: "server" })
|
||||
@@ -379,7 +380,12 @@ export namespace Server {
|
||||
description: "List of providers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Provider.Info.array()),
|
||||
schema: resolver(
|
||||
z.object({
|
||||
providers: Provider.Info.array(),
|
||||
default: z.record(z.string(), z.string()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -387,7 +393,13 @@ export namespace Server {
|
||||
}),
|
||||
async (c) => {
|
||||
const providers = await Provider.active()
|
||||
return c.json(providers.values().toArray())
|
||||
return c.json({
|
||||
providers: Object.values(providers),
|
||||
defaults: mapValues(
|
||||
providers,
|
||||
(item) => Provider.sort(Object.values(item.models))[0].id,
|
||||
),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user