mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbd58974a8 | |||
| 7e4d2150fe | |||
| fe58321f36 | |||
| ff77016c8b | |||
| 84c4fe971a |
@@ -9,6 +9,11 @@
|
|||||||
"opencode": {
|
"opencode": {
|
||||||
"options": {},
|
"options": {},
|
||||||
},
|
},
|
||||||
|
"azure": {
|
||||||
|
"options": {
|
||||||
|
"resourceName": "alice-mi7mfgew-eastus2",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"context7": {
|
"context7": {
|
||||||
|
|||||||
@@ -60,7 +60,12 @@ export namespace ModelsDev {
|
|||||||
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
||||||
options: z.record(z.string(), z.any()),
|
options: z.record(z.string(), z.any()),
|
||||||
headers: z.record(z.string(), z.string()).optional(),
|
headers: z.record(z.string(), z.string()).optional(),
|
||||||
provider: z.object({ npm: z.string() }).optional(),
|
provider: z
|
||||||
|
.object({
|
||||||
|
npm: z.string().optional(),
|
||||||
|
api: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
|
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
|
||||||
})
|
})
|
||||||
export type Model = z.infer<typeof Model>
|
export type Model = z.infer<typeof Model>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export namespace Provider {
|
|||||||
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
|
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
|
type CustomModelLoader = (sdk: any, model: Model, options?: Record<string, any>) => Promise<any>
|
||||||
type CustomLoader = (provider: Info) => Promise<{
|
type CustomLoader = (provider: Info) => Promise<{
|
||||||
autoload: boolean
|
autoload: boolean
|
||||||
getModel?: CustomModelLoader
|
getModel?: CustomModelLoader
|
||||||
@@ -110,8 +110,8 @@ export namespace Provider {
|
|||||||
openai: async () => {
|
openai: async () => {
|
||||||
return {
|
return {
|
||||||
autoload: false,
|
autoload: false,
|
||||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
async getModel(sdk: any, model: Model, _options?: Record<string, any>) {
|
||||||
return sdk.responses(modelID)
|
return sdk.responses(model.api.id)
|
||||||
},
|
},
|
||||||
options: {},
|
options: {},
|
||||||
}
|
}
|
||||||
@@ -119,11 +119,11 @@ export namespace Provider {
|
|||||||
"github-copilot": async () => {
|
"github-copilot": async () => {
|
||||||
return {
|
return {
|
||||||
autoload: false,
|
autoload: false,
|
||||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
async getModel(sdk: any, model: Model, _options?: Record<string, any>) {
|
||||||
if (modelID.includes("codex")) {
|
if (model.api.id.includes("codex")) {
|
||||||
return sdk.responses(modelID)
|
return sdk.responses(model.api.id)
|
||||||
}
|
}
|
||||||
return sdk.chat(modelID)
|
return sdk.chat(model.api.id)
|
||||||
},
|
},
|
||||||
options: {},
|
options: {},
|
||||||
}
|
}
|
||||||
@@ -131,42 +131,44 @@ export namespace Provider {
|
|||||||
"github-copilot-enterprise": async () => {
|
"github-copilot-enterprise": async () => {
|
||||||
return {
|
return {
|
||||||
autoload: false,
|
autoload: false,
|
||||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
async getModel(sdk: any, model: Model, _options?: Record<string, any>) {
|
||||||
if (modelID.includes("codex")) {
|
if (model.api.id.includes("codex")) {
|
||||||
return sdk.responses(modelID)
|
return sdk.responses(model.api.id)
|
||||||
}
|
}
|
||||||
return sdk.chat(modelID)
|
return sdk.chat(model.api.id)
|
||||||
},
|
},
|
||||||
options: {},
|
options: {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// TODO: handle the openai and anthropic deployments
|
||||||
azure: async () => {
|
azure: async () => {
|
||||||
return {
|
return {
|
||||||
autoload: false,
|
autoload: false,
|
||||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
async getModel(sdk: any, model: Model, options?: Record<string, any>) {
|
||||||
if (options?.["useCompletionUrls"]) {
|
if (model && model.api.npm !== "@ai-sdk/azure") {
|
||||||
return sdk.chat(modelID)
|
return sdk.languageModel(model.api.id)
|
||||||
} else {
|
|
||||||
return sdk.responses(modelID)
|
|
||||||
}
|
}
|
||||||
|
if (options?.["useCompletionUrls"]) {
|
||||||
|
return sdk.chat(model.api.id)
|
||||||
|
}
|
||||||
|
return sdk.responses(model.api.id)
|
||||||
},
|
},
|
||||||
options: {},
|
options: {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"azure-cognitive-services": async () => {
|
"azure-cognitive-services": async () => {
|
||||||
const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
|
|
||||||
return {
|
return {
|
||||||
autoload: false,
|
autoload: false,
|
||||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
async getModel(sdk: any, model: Model, options?: Record<string, any>) {
|
||||||
if (options?.["useCompletionUrls"]) {
|
if (model && model.api.npm !== "@ai-sdk/azure") {
|
||||||
return sdk.chat(modelID)
|
return sdk.languageModel(model.api.id)
|
||||||
} else {
|
|
||||||
return sdk.responses(modelID)
|
|
||||||
}
|
}
|
||||||
|
if (options?.["useCompletionUrls"]) {
|
||||||
|
return sdk.chat(model.api.id)
|
||||||
|
}
|
||||||
|
return sdk.responses(model.api.id)
|
||||||
},
|
},
|
||||||
options: {
|
options: {},
|
||||||
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"amazon-bedrock": async () => {
|
"amazon-bedrock": async () => {
|
||||||
@@ -225,7 +227,8 @@ export namespace Provider {
|
|||||||
return {
|
return {
|
||||||
autoload: true,
|
autoload: true,
|
||||||
options: providerOptions,
|
options: providerOptions,
|
||||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
async getModel(sdk: any, model: Model, options?: Record<string, any>) {
|
||||||
|
let modelID = model.api.id
|
||||||
// Skip region prefixing if model already has a cross-region inference profile prefix
|
// Skip region prefixing if model already has a cross-region inference profile prefix
|
||||||
if (modelID.startsWith("global.") || modelID.startsWith("jp.")) {
|
if (modelID.startsWith("global.") || modelID.startsWith("jp.")) {
|
||||||
return sdk.languageModel(modelID)
|
return sdk.languageModel(modelID)
|
||||||
@@ -343,8 +346,8 @@ export namespace Provider {
|
|||||||
project,
|
project,
|
||||||
location,
|
location,
|
||||||
},
|
},
|
||||||
async getModel(sdk: any, modelID: string) {
|
async getModel(sdk: any, model: Model) {
|
||||||
const id = String(modelID).trim()
|
const id = String(model.api.id).trim()
|
||||||
return sdk.languageModel(id)
|
return sdk.languageModel(id)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -360,8 +363,8 @@ export namespace Provider {
|
|||||||
project,
|
project,
|
||||||
location,
|
location,
|
||||||
},
|
},
|
||||||
async getModel(sdk: any, modelID) {
|
async getModel(sdk: any, model: Model) {
|
||||||
const id = String(modelID).trim()
|
const id = String(model.api.id).trim()
|
||||||
return sdk.languageModel(id)
|
return sdk.languageModel(id)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -383,8 +386,8 @@ export namespace Provider {
|
|||||||
return {
|
return {
|
||||||
autoload: !!envServiceKey,
|
autoload: !!envServiceKey,
|
||||||
options: envServiceKey ? { deploymentId, resourceGroup } : {},
|
options: envServiceKey ? { deploymentId, resourceGroup } : {},
|
||||||
async getModel(sdk: any, modelID: string) {
|
async getModel(sdk: any, model: Model) {
|
||||||
return sdk(modelID)
|
return sdk(model.api.id)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -423,8 +426,8 @@ export namespace Provider {
|
|||||||
...(providerConfig?.options?.featureFlags || {}),
|
...(providerConfig?.options?.featureFlags || {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
|
async getModel(sdk: ReturnType<typeof createGitLab>, model: Model) {
|
||||||
return sdk.agenticChat(modelID, {
|
return sdk.agenticChat(model.api.id, {
|
||||||
featureFlags: {
|
featureFlags: {
|
||||||
duo_agent_platform_agentic_chat: true,
|
duo_agent_platform_agentic_chat: true,
|
||||||
duo_agent_platform: true,
|
duo_agent_platform: true,
|
||||||
@@ -451,8 +454,8 @@ export namespace Provider {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
autoload: true,
|
autoload: true,
|
||||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
async getModel(sdk: any, model: Model, _options?: Record<string, any>) {
|
||||||
return sdk.languageModel(modelID)
|
return sdk.languageModel(model.api.id)
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway}/compat`,
|
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway}/compat`,
|
||||||
@@ -594,7 +597,7 @@ export namespace Provider {
|
|||||||
family: model.family,
|
family: model.family,
|
||||||
api: {
|
api: {
|
||||||
id: model.id,
|
id: model.id,
|
||||||
url: provider.api!,
|
url: model.provider?.api ?? provider.api!,
|
||||||
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
|
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
|
||||||
},
|
},
|
||||||
status: model.status ?? "active",
|
status: model.status ?? "active",
|
||||||
@@ -957,6 +960,21 @@ export namespace Provider {
|
|||||||
return state().then((state) => state.providers)
|
return state().then((state) => state.providers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveModelBaseURL(model: Model, options: Record<string, any>): string {
|
||||||
|
const template = model.api?.url ?? ""
|
||||||
|
if (!template) return ""
|
||||||
|
const matches = [...template.matchAll(/{{([^}]+)}}/g)]
|
||||||
|
if (matches.length === 0) return template
|
||||||
|
return matches.reduce((url, match) => {
|
||||||
|
const keys = match[1].split("|").map((item) => item.trim())
|
||||||
|
const resolved = keys
|
||||||
|
.map((key) => Env.get(key) ?? options[key])
|
||||||
|
.find((value) => value !== undefined && value !== null && value !== "")
|
||||||
|
if (resolved === undefined || resolved === null || resolved === "") return url
|
||||||
|
return url.replaceAll(match[0], String(resolved))
|
||||||
|
}, template)
|
||||||
|
}
|
||||||
|
|
||||||
async function getSDK(model: Model) {
|
async function getSDK(model: Model) {
|
||||||
try {
|
try {
|
||||||
using _ = log.time("getSDK", {
|
using _ = log.time("getSDK", {
|
||||||
@@ -970,7 +988,8 @@ export namespace Provider {
|
|||||||
options["includeUsage"] = true
|
options["includeUsage"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options["baseURL"]) options["baseURL"] = model.api.url
|
const resolvedBaseURL = resolveModelBaseURL(model, options)
|
||||||
|
if (!options["baseURL"] && resolvedBaseURL) options["baseURL"] = resolvedBaseURL
|
||||||
if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
|
if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
|
||||||
if (model.headers)
|
if (model.headers)
|
||||||
options["headers"] = {
|
options["headers"] = {
|
||||||
@@ -1093,9 +1112,8 @@ export namespace Provider {
|
|||||||
const sdk = await getSDK(model)
|
const sdk = await getSDK(model)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const language = s.modelLoaders[model.providerID]
|
const loader = s.modelLoaders[model.providerID]
|
||||||
? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options)
|
const language = loader ? await loader(sdk, model, provider.options) : sdk.languageModel(model.api.id)
|
||||||
: sdk.languageModel(model.api.id)
|
|
||||||
s.models.set(key, language)
|
s.models.set(key, language)
|
||||||
return language
|
return language
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -381,6 +381,160 @@ test("parseModel handles model IDs with slashes", () => {
|
|||||||
expect(result.modelID).toBe("anthropic/claude-3-opus")
|
expect(result.modelID).toBe("anthropic/claude-3-opus")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("resolveModelBaseURL returns empty when url missing", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const model = { api: { url: "" } } as Provider.Model
|
||||||
|
expect(Provider.resolveModelBaseURL(model, {})).toBe("")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolveModelBaseURL returns template when no placeholders", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const model = { api: { url: "https://api.example.com/v1" } } as Provider.Model
|
||||||
|
expect(Provider.resolveModelBaseURL(model, {})).toBe("https://api.example.com/v1")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolveModelBaseURL replaces placeholders from env", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
init: async () => {
|
||||||
|
Env.set("CUSTOM_HOST", "env.example.com")
|
||||||
|
},
|
||||||
|
fn: async () => {
|
||||||
|
const model = { api: { url: "https://{{CUSTOM_HOST}}/v1" } } as Provider.Model
|
||||||
|
expect(Provider.resolveModelBaseURL(model, {})).toBe("https://env.example.com/v1")
|
||||||
|
Env.remove("CUSTOM_HOST")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolveModelBaseURL falls back to options when env missing", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const model = { api: { url: "https://{{HOST}}/v1" } } as Provider.Model
|
||||||
|
expect(Provider.resolveModelBaseURL(model, { HOST: "options.example.com" })).toBe(
|
||||||
|
"https://options.example.com/v1",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolveModelBaseURL chooses first non-empty key", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const model = { api: { url: "https://{{PRIMARY|SECONDARY}}/v1" } } as Provider.Model
|
||||||
|
const options = {
|
||||||
|
PRIMARY: "",
|
||||||
|
SECONDARY: "fallback.example.com",
|
||||||
|
}
|
||||||
|
expect(Provider.resolveModelBaseURL(model, options)).toBe("https://fallback.example.com/v1")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolveModelBaseURL keeps unresolved placeholders", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const model = { api: { url: "https://{{MISSING}}/v1" } } as Provider.Model
|
||||||
|
expect(Provider.resolveModelBaseURL(model, {})).toBe("https://{{MISSING}}/v1")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolveModelBaseURL replaces multiple placeholders", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const model = { api: { url: "https://{{HOST}}/{{VERSION}}" } } as Provider.Model
|
||||||
|
const options = {
|
||||||
|
HOST: "multi.example.com",
|
||||||
|
VERSION: "v1",
|
||||||
|
}
|
||||||
|
expect(Provider.resolveModelBaseURL(model, options)).toBe("https://multi.example.com/v1")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("defaultModel returns first available model when no config set", async () => {
|
test("defaultModel returns first available model when no config set", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user