Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/src/components/settings-v2/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const PROVIDER_NOTES = [
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
{ match: (id: string) => id === "edenai", key: "dialog.provider.edenai.note" },
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
] as const

Expand Down
1 change: 1 addition & 0 deletions packages/app/src/hooks/use-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const popularProviders = [
"openai",
"google",
"openrouter",
"edenai",
"vercel",
]
const popularProviderSet = new Set(popularProviders)
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export const dict = {
"dialog.provider.openai.note": "GPT models for fast, capable general AI tasks",
"dialog.provider.google.note": "Gemini models for fast, structured responses",
"dialog.provider.openrouter.note": "Access all supported models from one provider",
"dialog.provider.edenai.note": "Route to 500+ models with smart routing and fallbacks",
"dialog.provider.vercel.note": "Unified access to AI models with smart routing",

"dialog.model.select.title": "Select model",
Expand Down
96 changes: 96 additions & 0 deletions packages/core/src/models-dev-builtin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { Model, Provider } from "./models-dev"

// Built-in providers that opencode ships as first-class citizens even though
// they are not (yet) published on models.dev. Each entry is shaped exactly like
// a models.dev provider record and is merged into `ModelsDev.get()`, so it flows
// into both the v1 (packages/opencode) and v2 (packages/core catalog) provider
// systems, `/connect` auth registration, and the model picker with no per-system
// wiring. A real models.dev entry, if one ever lands, wins over the built-in.

// Eden AI (https://www.edenai.co) is an OpenAI-compatible aggregator gateway:
// one API key routes to 500+ models with smart routing and fallbacks.
// API reference: https://www.edenai.co/docs/v3/llms/chat-completions
//
// Costs and limits below are indicative list prices for the underlying models;
// Eden AI reports exact per-request cost at runtime.
const EDENAI_BASE_URL = "https://api.edenai.run/v3"

function edenaiModel(id: string, name: string, overrides: Partial<Model> = {}): Model {
return {
id,
name,
release_date: "2025-01-01",
attachment: true,
reasoning: true,
temperature: true,
tool_call: true,
limit: { context: 200_000, output: 32_000 },
modalities: { input: ["text", "image"], output: ["text"] },
...overrides,
}
}

const edenai: Provider = {
id: "edenai",
name: "Eden AI",
npm: "@ai-sdk/openai-compatible",
api: EDENAI_BASE_URL,
env: ["EDENAI_API_KEY"],
models: {
// Smart Router — Eden AI picks the best model per request (quality, latency,
// cost, live provider health). Recommended default; needs no model config.
"@edenai": edenaiModel("@edenai", "Eden AI (Smart Router)", {
family: "edenai",
limit: { context: 200_000, output: 32_000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
}),
"anthropic/claude-sonnet-4-5": edenaiModel("anthropic/claude-sonnet-4-5", "Claude Sonnet 4.5", {
family: "claude",
release_date: "2025-09-29",
limit: { context: 200_000, output: 64_000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
cost: { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 },
}),
"anthropic/claude-opus-4-7": edenaiModel("anthropic/claude-opus-4-7", "Claude Opus 4.7", {
family: "claude",
release_date: "2025-11-24",
limit: { context: 200_000, output: 32_000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
cost: { input: 15, output: 75, cache_read: 1.5, cache_write: 18.75 },
}),
"anthropic/claude-haiku-4-5": edenaiModel("anthropic/claude-haiku-4-5", "Claude Haiku 4.5", {
family: "claude",
release_date: "2025-10-15",
limit: { context: 200_000, output: 64_000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
cost: { input: 1, output: 5, cache_read: 0.1, cache_write: 1.25 },
}),
"openai/gpt-5": edenaiModel("openai/gpt-5", "GPT-5", {
family: "gpt",
release_date: "2025-08-07",
temperature: false,
limit: { context: 400_000, output: 128_000 },
modalities: { input: ["text", "image"], output: ["text"] },
cost: { input: 1.25, output: 10 },
}),
"google/gemini-2.5-pro": edenaiModel("google/gemini-2.5-pro", "Gemini 2.5 Pro", {
family: "gemini",
release_date: "2025-06-17",
limit: { context: 1_048_576, output: 65_536 },
modalities: { input: ["text", "image", "pdf", "audio", "video"], output: ["text"] },
cost: { input: 1.25, output: 10 },
}),
},
}

export const BuiltinProviders: Record<string, Provider> = {
edenai,
}

// Merge the built-in providers into a models.dev catalog. Kept out of
// `ModelsDev.get()` (which stays a faithful mirror of models.dev) and applied
// wherever the provider catalog is assembled. A real models.dev entry, when
// present, always wins over the built-in of the same id.
export function withBuiltinProviders(data: Record<string, Provider>): Record<string, Provider> {
return { ...BuiltinProviders, ...data }
}
5 changes: 3 additions & 2 deletions packages/core/src/plugin/models-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect"
import { EventV2 } from "../event"
import { ModelsDev } from "../models-dev"
import { withBuiltinProviders } from "../models-dev-builtin"
import { ProviderV2 } from "../provider"

function released(date: string) {
Expand Down Expand Up @@ -123,7 +124,7 @@ export const ModelsDevPlugin = define({
const events = yield* EventV2.Service
yield* ctx.integration.transform(
Effect.fn(function* (integrations) {
const data = yield* modelsDev.get()
const data = withBuiltinProviders(yield* modelsDev.get())
for (const item of Object.values(data)) {
if (item.env.length === 0) continue
const integrationID = item.id
Expand All @@ -141,7 +142,7 @@ export const ModelsDevPlugin = define({
)
yield* ctx.catalog.transform(
Effect.fn(function* (catalog) {
const data = yield* modelsDev.get()
const data = withBuiltinProviders(yield* modelsDev.get())
for (const item of Object.values(data)) {
const providerID = ProviderV2.ID.make(item.id)
catalog.provider.update(providerID, (provider) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/plugin/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai"
import { CoherePlugin } from "./provider/cohere"
import { DeepInfraPlugin } from "./provider/deepinfra"
import { DynamicProviderPlugin } from "./provider/dynamic"
import { EdenAiPlugin } from "./provider/edenai"
import { GatewayPlugin } from "./provider/gateway"
import { GithubCopilotPlugin } from "./provider/github-copilot"
import { GitLabPlugin } from "./provider/gitlab"
Expand Down Expand Up @@ -44,6 +45,7 @@ export const ProviderPlugins: PluginInternal.Plugin<PluginInternal.Requirements
CloudflareWorkersAIPlugin,
CoherePlugin,
DeepInfraPlugin,
EdenAiPlugin,
GatewayPlugin,
GithubCopilotPlugin,
GitLabPlugin,
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/plugin/provider/edenai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Effect } from "effect"
import { define } from "../internal"

export const EdenAiPlugin = define({
id: "edenai",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.edenai.run/v3") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
})
}
}),
)
}),
})
14 changes: 14 additions & 0 deletions packages/core/test/plugin/models-dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,20 @@ describe("ModelsDevPlugin", () => {
],
connections: [],
}),
// Eden AI ships as a built-in provider (see models-dev-builtin.ts), so it
// is always merged into the catalog and registers its env-based auth method.
new Integration.Info({
id: Integration.ID.make("edenai"),
name: "Eden AI",
methods: [
{ type: "key" },
{
type: "env",
names: ["EDENAI_API_KEY"],
},
],
connections: [],
}),
])
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
(previous) =>
Expand Down
81 changes: 81 additions & 0 deletions packages/core/test/plugin/provider-edenai.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { EdenAiPlugin } from "@opencode-ai/core/plugin/provider/edenai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"

const it = testEffect(PluginTestLayer)

const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
yield* EdenAiPlugin.effect(host)
})

function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}

const edenaiApi = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://api.edenai.run/v3",
} as const

describe("EdenAiPlugin", () => {
it.effect("is registered so opencode identity headers can be applied", () =>
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("edenai"))),
)

it.effect("applies the opencode identity headers to edenai", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("edenai"), (provider) => {
provider.api = { ...edenaiApi }
})
})
yield* addPlugin()
const result = required(yield* catalog.provider.get(ProviderV2.ID.make("edenai")))
expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
}),
)

it.effect("merges opencode identity headers with existing headers", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.make("edenai"), (provider) => {
provider.api = { ...edenaiApi }
provider.request.headers.Existing = "value"
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("edenai"))).request.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
}),
)

it.effect("only touches the edenai openai-compatible endpoint", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
// A different openai-compatible provider must be left untouched.
catalog.provider.update(ProviderV2.ID.make("zenmux"), (provider) => {
provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" }
})
})
yield* addPlugin()
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({})
}),
)
})
94 changes: 94 additions & 0 deletions packages/llm/src/providers/edenai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Effect, Schema } from "effect"
import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat"
import { isRecord } from "../protocols/shared"

export const profile = OpenAICompatibleProfiles.profiles.edenai
export const id = ProviderID.make(profile.provider)
const ADAPTER = "edenai"

export interface EdenAIOptions {
readonly [key: string]: unknown
// Ordered list of `provider/model` fallbacks tried if the primary model fails.
readonly fallbacks?: ReadonlyArray<string>
// Restrict the `@edenai` smart router to a subset of candidate models.
readonly routerCandidates?: ReadonlyArray<string>
}

export type EdenAIProviderOptionsInput = ProviderOptions & {
readonly edenai?: EdenAIOptions
}

export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: EdenAIProviderOptionsInput
}

const EdenAIBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
Schema.Record(Schema.String, Schema.Any),
])
export type EdenAIBody = Schema.Schema.Type<typeof EdenAIBody>

export const protocol = Protocol.make({
id: "edenai-chat",
body: {
schema: EdenAIBody,
from: (request) =>
OpenAIChat.protocol.body.from(request).pipe(
Effect.map(
(body) =>
({
...body,
...bodyOptions(request.providerOptions?.edenai),
}) as EdenAIBody,
),
),
},
stream: OpenAIChat.protocol.stream,
})

const bodyOptions = (input: unknown) => {
const edenai = isRecord(input) ? input : {}
return {
...(Array.isArray(edenai.fallbacks) ? { fallbacks: edenai.fallbacks } : {}),
...(Array.isArray(edenai.routerCandidates) ? { router_candidates: edenai.routerCandidates } : {}),
}
}

export const route = Route.make({
id: ADAPTER,
provider: profile.provider,
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
framing: Framing.sse,
})

export const routes = [route]

const configuredRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return route.with({
...rest,
endpoint: { baseURL: baseURL ?? profile.baseURL },
auth: AuthOptions.bearer(input, "EDENAI_API_KEY"),
})
}

export const configure = (input: ModelOptions = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}

export const provider = configure()
export const model = provider.model
1 change: 1 addition & 0 deletions packages/llm/src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * as AmazonBedrock from "./amazon-bedrock"
export * as Azure from "./azure"
export * as Cloudflare from "./cloudflare"
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
export * as EdenAI from "./edenai"
export * as GitHubCopilot from "./github-copilot"
export * as Google from "./google"
export * as OpenAI from "./openai"
Expand Down
1 change: 1 addition & 0 deletions packages/llm/src/providers/openai-compatible-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const profiles = {
cerebras: { provider: "cerebras", baseURL: "https://api.cerebras.ai/v1" },
deepinfra: { provider: "deepinfra", baseURL: "https://api.deepinfra.com/v1/openai" },
deepseek: { provider: "deepseek", baseURL: "https://api.deepseek.com/v1" },
edenai: { provider: "edenai", baseURL: "https://api.edenai.run/v3" },
fireworks: { provider: "fireworks", baseURL: "https://api.fireworks.ai/inference/v1" },
groq: { provider: "groq", baseURL: "https://api.groq.com/openai/v1" },
openrouter: { provider: "openrouter", baseURL: "https://openrouter.ai/api/v1" },
Expand Down
Loading
Loading