diff --git a/docs/docs.json b/docs/docs.json index a676004f6..0ddc09433 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -117,6 +117,14 @@ "source": "/opencode/", "destination": "/providers/opencode" }, + { + "source": "/edenai", + "destination": "/providers/edenai" + }, + { + "source": "/edenai/", + "destination": "/providers/edenai" + }, { "source": "/mattermost", "destination": "/channels/mattermost" @@ -1029,6 +1037,7 @@ "providers/minimax", "providers/vercel-ai-gateway", "providers/openrouter", + "providers/edenai", "providers/synthetic", "providers/opencode", "providers/glm", diff --git a/docs/providers/edenai.md b/docs/providers/edenai.md new file mode 100644 index 000000000..a33509cb6 --- /dev/null +++ b/docs/providers/edenai.md @@ -0,0 +1,74 @@ +--- +summary: "Use Eden AI's unified API to access many models in Clawdbot" +read_when: + - You want a European multi-provider API gateway + - You want a single API key for many LLMs +--- +# Eden AI + +Eden AI provides a **unified API** that routes requests to many models behind a single +endpoint and API key. It is OpenAI-compatible and based in Europe. + +## CLI setup + +```bash +clawdbot onboard --auth-choice edenai-api-key +``` + +Or non-interactive: + +```bash +clawdbot onboard --auth-choice apiKey --token-provider edenai --token "$EDENAI_API_KEY" +``` + +## Config snippet + +```json5 +{ + env: { EDENAI_API_KEY: "..." }, + agents: { + defaults: { + model: { primary: "edenai/anthropic/claude-sonnet-4-5" } + } + } +} +``` + +## Supported providers + +Eden AI supports: Anthropic, OpenAI, Mistral, Google, and more. + +## Example models + +- `edenai/anthropic/claude-sonnet-4-5` - Claude Sonnet 4.5 +- `edenai/openai/gpt-4o` - GPT-4o +- `edenai/openai/gpt-4o-mini` - GPT-4o Mini (cheaper) +- `edenai/mistral/mistral-large-latest` - Mistral Large +- `edenai/mistral/mistral-small-latest` - Mistral Small + +## Expert models + +Beyond LLMs, Eden AI provides access to specialized **expert models** optimized for specific tasks: + +- **Video analysis** - person tracking, object tracking, text detection, explicit content detection +- **OCR** - document parsing, invoice extraction, ID recognition +- **Image generation** - text-to-image across multiple providers +- **Video generation** - text-to-video and image-to-video +- **Speech-to-text** - transcription and dictation +- **Text-to-speech** - voice synthesis +- **Content moderation** - explicit content detection, AI-generated content detection + +Integration of these expert models into Clawdbot skills is coming soon. + +## Notes + +- Model refs are `edenai//` (e.g., `edenai/openai/gpt-4o`). +- For more model/provider options, see [/concepts/model-providers](/concepts/model-providers). +- Eden AI uses Bearer token authentication. + +## Links + +- [Eden AI website](https://www.edenai.co/) +- [Eden AI documentation](https://docs.edenai.co/) +- [Supported models](https://app.edenai.run/models) +- [Get your API key](https://app.edenai.run/admin/account/settings) diff --git a/docs/providers/index.md b/docs/providers/index.md index b2793ee22..8574c0003 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -37,6 +37,7 @@ See [Venice AI](/providers/venice). - [Anthropic (API + Claude Code CLI)](/providers/anthropic) - [Qwen (OAuth)](/providers/qwen) - [OpenRouter](/providers/openrouter) +- [Eden AI](/providers/edenai) - [Vercel AI Gateway](/providers/vercel-ai-gateway) - [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot) - [OpenCode Zen](/providers/opencode) diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 1445b53f7..a73f1e172 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -277,6 +277,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { cerebras: "CEREBRAS_API_KEY", xai: "XAI_API_KEY", openrouter: "OPENROUTER_API_KEY", + edenai: "EDENAI_API_KEY", "vercel-ai-gateway": "AI_GATEWAY_API_KEY", moonshot: "MOONSHOT_API_KEY", "kimi-code": "KIMICODE_API_KEY", diff --git a/src/agents/model-scan.test.ts b/src/agents/model-scan.test.ts index d69445324..5381dbe79 100644 --- a/src/agents/model-scan.test.ts +++ b/src/agents/model-scan.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { scanOpenRouterModels } from "./model-scan.js"; +import { scanEdenAiModels, scanOpenRouterModels } from "./model-scan.js"; function createFetchFixture(payload: unknown): typeof fetch { return async () => @@ -86,3 +86,149 @@ describe("scanOpenRouterModels", () => { } }); }); + +describe("scanEdenAiModels", () => { + it("lists models from {object, data} response format", async () => { + const fetchImpl = createFetchFixture({ + object: "list", + data: [ + { + id: "anthropic/claude-3-haiku", + model_name: "Claude 3 Haiku", + owned_by: "anthropic", + context_length: 200000, + created: 1700000000, + capabilities: { + supports_function_calling: true, + supports_vision: false, + supports_tool_choice: true, + input_modalities: ["text"], + output_modalities: ["text"], + }, + pricing: { input_cost_per_token: 0.00025, output_cost_per_token: 0.00125 }, + }, + ], + }); + + const results = await scanEdenAiModels({ + fetchImpl, + apiKey: "test-key", + probe: false, + }); + + expect(results).toHaveLength(1); + expect(results[0]?.id).toBe("anthropic/claude-3-haiku"); + expect(results[0]?.modelRef).toBe("edenai/anthropic/claude-3-haiku"); + expect(results[0]?.provider).toBe("edenai"); + expect(results[0]?.name).toBe("Claude 3 Haiku"); + expect(results[0]?.supportsToolsMeta).toBe(true); + }); + + it("requires an API key", async () => { + const fetchImpl = createFetchFixture({ object: "list", data: [] }); + const previousKey = process.env.EDENAI_API_KEY; + try { + delete process.env.EDENAI_API_KEY; + await expect(scanEdenAiModels({ fetchImpl, probe: false, apiKey: "" })).rejects.toThrow( + /Missing Eden AI API key/, + ); + } finally { + if (previousKey === undefined) { + delete process.env.EDENAI_API_KEY; + } else { + process.env.EDENAI_API_KEY = previousKey; + } + } + }); + + it("filters by provider", async () => { + const fetchImpl = createFetchFixture({ + object: "list", + data: [ + { + id: "anthropic/claude-3", + model_name: "Claude", + owned_by: "anthropic", + context_length: 200000, + created: 1700000000, + capabilities: { + supports_function_calling: true, + supports_vision: false, + supports_tool_choice: true, + input_modalities: ["text"], + output_modalities: ["text"], + }, + pricing: { input_cost_per_token: 0.001, output_cost_per_token: 0.002 }, + }, + { + id: "openai/gpt-4", + model_name: "GPT-4", + owned_by: "openai", + context_length: 128000, + created: 1700000000, + capabilities: { + supports_function_calling: true, + supports_vision: true, + supports_tool_choice: true, + input_modalities: ["text", "image"], + output_modalities: ["text"], + }, + pricing: { input_cost_per_token: 0.003, output_cost_per_token: 0.006 }, + }, + ], + }); + + const results = await scanEdenAiModels({ + fetchImpl, + apiKey: "test-key", + probe: false, + providerFilter: "anthropic", + }); + + expect(results).toHaveLength(1); + expect(results[0]?.id).toBe("anthropic/claude-3"); + }); + + it("detects free models", async () => { + const fetchImpl = createFetchFixture({ + object: "list", + data: [ + { + id: "free/model", + model_name: "Free Model", + owned_by: "free", + context_length: 4096, + created: 1700000000, + capabilities: { + supports_function_calling: false, + supports_vision: false, + supports_tool_choice: false, + input_modalities: ["text"], + output_modalities: ["text"], + }, + pricing: { input_cost_per_token: 0, output_cost_per_token: 0 }, + }, + { + id: "paid/model", + model_name: "Paid Model", + owned_by: "paid", + context_length: 8192, + created: 1700000000, + capabilities: { + supports_function_calling: true, + supports_vision: false, + supports_tool_choice: true, + input_modalities: ["text"], + output_modalities: ["text"], + }, + pricing: { input_cost_per_token: 0.001, output_cost_per_token: 0.002 }, + }, + ], + }); + + const results = await scanEdenAiModels({ fetchImpl, apiKey: "test-key", probe: false }); + + expect(results.find((r) => r.id === "free/model")?.isFree).toBe(true); + expect(results.find((r) => r.id === "paid/model")?.isFree).toBe(false); + }); +}); diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts index ba4775372..f77beefc7 100644 --- a/src/agents/model-scan.ts +++ b/src/agents/model-scan.ts @@ -10,6 +10,7 @@ import { import { Type } from "@sinclair/typebox"; const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; +const EDENAI_MODELS_URL = "https://api.edenai.run/v3/llm/models"; const DEFAULT_TIMEOUT_MS = 12_000; const DEFAULT_CONCURRENCY = 3; @@ -463,5 +464,220 @@ export async function scanOpenRouterModels( ); } -export { OPENROUTER_MODELS_URL }; -export type { OpenRouterModelMeta, OpenRouterModelPricing }; +// Eden AI model scanning + +type EdenAiModelMeta = { + id: string; + model_name: string; + owned_by: string; + context_length: number | null; + created: number; + capabilities: { + supports_function_calling: boolean; + supports_vision: boolean; + supports_tool_choice: boolean; + input_modalities: string[]; + output_modalities: string[]; + }; + pricing: { + input_cost_per_token: number; + output_cost_per_token: number; + }; +}; + +export type EdenAiScanOptions = { + apiKey?: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; + concurrency?: number; + minParamB?: number; + maxAgeDays?: number; + providerFilter?: string; + probe?: boolean; + onProgress?: (update: { phase: "catalog" | "probe"; completed: number; total: number }) => void; +}; + +function parseEdenAiModality(meta: EdenAiModelMeta): Array<"text" | "image"> { + const modalities = meta.capabilities?.input_modalities ?? []; + const hasImage = modalities.some((m) => m.toLowerCase() === "image"); + return hasImage ? ["text", "image"] : ["text"]; +} + +async function fetchEdenAiModels( + fetchImpl: typeof fetch, + apiKey: string, +): Promise { + const res = await fetchImpl(EDENAI_MODELS_URL, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${apiKey}`, + }, + }); + if (!res.ok) { + throw new Error(`Eden AI /llm/models failed: HTTP ${res.status}`); + } + const payload = (await res.json()) as unknown; + // Eden AI returns {object: "list", data: [...]} format + const entries = Array.isArray(payload) + ? payload + : payload && + typeof payload === "object" && + "data" in payload && + Array.isArray((payload as { data: unknown }).data) + ? (payload as { data: unknown[] }).data + : []; + + return entries + .map((entry) => { + if (!entry || typeof entry !== "object") return null; + const obj = entry as Record; + const id = typeof obj.id === "string" ? obj.id.trim() : ""; + if (!id) return null; + const model_name = + typeof obj.model_name === "string" && obj.model_name.trim() ? obj.model_name.trim() : id; + const owned_by = typeof obj.owned_by === "string" ? obj.owned_by.trim() : ""; + + const context_length = + typeof obj.context_length === "number" && Number.isFinite(obj.context_length) + ? obj.context_length + : null; + + const created = + typeof obj.created === "number" && Number.isFinite(obj.created) ? obj.created : 0; + + const caps = (obj.capabilities ?? {}) as Record; + const capabilities = { + supports_function_calling: caps.supports_function_calling === true, + supports_vision: caps.supports_vision === true, + supports_tool_choice: caps.supports_tool_choice === true, + input_modalities: Array.isArray(caps.input_modalities) + ? caps.input_modalities.filter((m): m is string => typeof m === "string") + : [], + output_modalities: Array.isArray(caps.output_modalities) + ? caps.output_modalities.filter((m): m is string => typeof m === "string") + : [], + }; + + const pricingRaw = (obj.pricing ?? {}) as Record; + const pricing = { + input_cost_per_token: + typeof pricingRaw.input_cost_per_token === "number" ? pricingRaw.input_cost_per_token : 0, + output_cost_per_token: + typeof pricingRaw.output_cost_per_token === "number" + ? pricingRaw.output_cost_per_token + : 0, + }; + + return { + id, + model_name, + owned_by, + context_length, + created, + capabilities, + pricing, + } satisfies EdenAiModelMeta; + }) + .filter((entry): entry is EdenAiModelMeta => Boolean(entry)); +} + +function isFreeEdenAiModel(entry: EdenAiModelMeta): boolean { + return entry.pricing.input_cost_per_token === 0 && entry.pricing.output_cost_per_token === 0; +} + +export async function scanEdenAiModels( + options: EdenAiScanOptions = {}, +): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const probe = options.probe ?? true; + const apiKey = options.apiKey?.trim() || getEnvApiKey("edenai") || ""; + if (!apiKey) { + throw new Error("Missing Eden AI API key. Set EDENAI_API_KEY to run models scan."); + } + + // timeoutMs reserved for future probing support + const concurrency = Math.max(1, Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY)); + const minParamB = Math.max(0, Math.floor(options.minParamB ?? 0)); + const maxAgeDays = Math.max(0, Math.floor(options.maxAgeDays ?? 0)); + const providerFilter = options.providerFilter?.trim().toLowerCase() ?? ""; + + const catalog = await fetchEdenAiModels(fetchImpl, apiKey); + const now = Date.now(); + + const filtered = catalog.filter((entry) => { + if (providerFilter) { + const prefix = entry.id.split("/")[0]?.toLowerCase() ?? ""; + if (prefix !== providerFilter) return false; + } + if (minParamB > 0) { + const params = inferParamBFromIdOrName(`${entry.id} ${entry.model_name}`); + if (!params || params < minParamB) return false; + } + if (maxAgeDays > 0 && entry.created > 0) { + const createdMs = entry.created * 1000; + const ageMs = now - createdMs; + const ageDays = ageMs / (24 * 60 * 60 * 1000); + if (ageDays > maxAgeDays) return false; + } + return true; + }); + + options.onProgress?.({ + phase: "probe", + completed: 0, + total: filtered.length, + }); + + return mapWithConcurrency( + filtered, + concurrency, + async (entry) => { + const isFree = isFreeEdenAiModel(entry); + const inferredParamB = inferParamBFromIdOrName(`${entry.id} ${entry.model_name}`); + const modalities = parseEdenAiModality(entry); + const modalityString = modalities.includes("image") ? "text+image" : "text"; + + const baseResult = { + id: entry.id, + name: entry.model_name, + provider: "edenai", + modelRef: `edenai/${entry.id}`, + contextLength: entry.context_length, + maxCompletionTokens: null, + supportedParametersCount: 0, + supportsToolsMeta: entry.capabilities.supports_function_calling, + modality: modalityString, + inferredParamB, + createdAtMs: entry.created > 0 ? entry.created * 1000 : null, + pricing: { + prompt: entry.pricing.input_cost_per_token, + completion: entry.pricing.output_cost_per_token, + request: 0, + image: 0, + webSearch: 0, + internalReasoning: 0, + }, + isFree, + }; + + // Eden AI probing is not yet supported - their API structure differs from OpenAI + // Use --no-probe for catalog listing, or implement Eden AI-specific probing later + return { + ...baseResult, + tool: { ok: false, latencyMs: null, skipped: !probe }, + image: { ok: false, latencyMs: null, skipped: !probe }, + } satisfies ModelScanResult; + }, + { + onProgress: (completed, total) => + options.onProgress?.({ + phase: "probe", + completed, + total, + }), + }, + ); +} + +export { OPENROUTER_MODELS_URL, EDENAI_MODELS_URL }; +export type { OpenRouterModelMeta, OpenRouterModelPricing, EdenAiModelMeta }; diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index e58c23078..b5f89fd44 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -246,7 +246,8 @@ export function registerModelsCli(program: Command) { models .command("scan") - .description("Scan OpenRouter free models for tools + images") + .description("Scan model registries for tools + images") + .option("--source ", "Model registry (openrouter, edenai)", "openrouter") .option("--min-params ", "Minimum parameter size (billions)") .option("--max-age-days ", "Skip models older than N days") .option("--provider ", "Filter by provider prefix") diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 3f81a5ee8..ffff63f5f 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) { .option("--mode ", "Wizard mode: local|remote") .option( "--auth-choice ", - "Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|xiaomi-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", + "Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|edenai-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|codex-cli|gemini-api-key|zai-api-key|xiaomi-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", ) .option( "--token-provider ", @@ -67,6 +67,7 @@ export function registerOnboardCommand(program: Command) { .option("--anthropic-api-key ", "Anthropic API key") .option("--openai-api-key ", "OpenAI API key") .option("--openrouter-api-key ", "OpenRouter API key") + .option("--edenai-api-key ", "Eden AI API key") .option("--ai-gateway-api-key ", "Vercel AI Gateway API key") .option("--moonshot-api-key ", "Moonshot API key") .option("--kimi-code-api-key ", "Kimi Code API key") @@ -118,6 +119,7 @@ export function registerOnboardCommand(program: Command) { anthropicApiKey: opts.anthropicApiKey as string | undefined, openaiApiKey: opts.openaiApiKey as string | undefined, openrouterApiKey: opts.openrouterApiKey as string | undefined, + edenaiApiKey: opts.edenaiApiKey as string | undefined, aiGatewayApiKey: opts.aiGatewayApiKey as string | undefined, moonshotApiKey: opts.moonshotApiKey as string | undefined, kimiCodeApiKey: opts.kimiCodeApiKey as string | undefined, diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 5acddf4e3..5dc2c69eb 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -13,6 +13,7 @@ export type AuthChoiceGroupId = | "google" | "copilot" | "openrouter" + | "edenai" | "ai-gateway" | "moonshot" | "zai" @@ -90,6 +91,12 @@ const AUTH_CHOICE_GROUP_DEFS: { hint: "API key", choices: ["openrouter-api-key"], }, + { + value: "edenai", + label: "Eden AI", + hint: "API Key", + choices: ["edenai-api-key"], + }, { value: "ai-gateway", label: "Vercel AI Gateway", @@ -142,6 +149,11 @@ export function buildAuthChoiceOptions(params: { options.push({ value: "chutes", label: "Chutes (OAuth)" }); options.push({ value: "openai-api-key", label: "OpenAI API key" }); options.push({ value: "openrouter-api-key", label: "OpenRouter API key" }); + options.push({ + value: "edenai-api-key", + label: "Eden AI API key", + hint: "European multi-provider gateway", + }); options.push({ value: "ai-gateway-api-key", label: "Vercel AI Gateway API key", diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index fa4fc77e7..a2bbf7280 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -13,6 +13,8 @@ import { } from "./google-gemini-model-default.js"; import { applyAuthProfileConfig, + applyEdenaiConfig, + applyEdenaiProviderConfig, applyKimiCodeConfig, applyKimiCodeProviderConfig, applyMoonshotConfig, @@ -30,6 +32,7 @@ import { applyXiaomiConfig, applyXiaomiProviderConfig, applyZaiConfig, + EDENAI_DEFAULT_MODEL_REF, KIMI_CODE_MODEL_REF, MOONSHOT_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF, @@ -37,6 +40,7 @@ import { VENICE_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, XIAOMI_DEFAULT_MODEL_REF, + setEdenaiApiKey, setGeminiApiKey, setKimiCodeApiKey, setMoonshotApiKey, @@ -73,6 +77,8 @@ export async function applyAuthChoiceApiProviders( ) { if (params.opts.tokenProvider === "openrouter") { authChoice = "openrouter-api-key"; + } else if (params.opts.tokenProvider === "edenai") { + authChoice = "edenai-api-key"; } else if (params.opts.tokenProvider === "vercel-ai-gateway") { authChoice = "ai-gateway-api-key"; } else if (params.opts.tokenProvider === "moonshot") { @@ -172,6 +178,94 @@ export async function applyAuthChoiceApiProviders( return { config: nextConfig, agentModelOverride }; } + if (authChoice === "edenai-api-key") { + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profileOrder = resolveAuthProfileOrder({ + cfg: nextConfig, + store, + provider: "edenai", + }); + const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId])); + const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined; + let profileId = "edenai:default"; + let mode: "api_key" | "oauth" | "token" = "api_key"; + let hasCredential = false; + + if (existingProfileId && existingCred?.type) { + profileId = existingProfileId; + mode = + existingCred.type === "oauth" + ? "oauth" + : existingCred.type === "token" + ? "token" + : "api_key"; + hasCredential = true; + } + + if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "edenai") { + await setEdenaiApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir); + hasCredential = true; + } + + if (!hasCredential) { + await params.prompter.note( + [ + "Eden AI provides unified access to multiple LLM providers.", + "Get your API key at: https://app.edenai.run/admin/account/settings", + ].join("\n"), + "Eden AI", + ); + } + + if (!hasCredential) { + const envKey = resolveEnvApiKey("edenai"); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing EDENAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await setEdenaiApiKey(envKey.apiKey, params.agentDir); + hasCredential = true; + } + } + } + + if (!hasCredential) { + const key = await params.prompter.text({ + message: "Enter Eden AI API key", + validate: validateApiKeyInput, + }); + await setEdenaiApiKey(normalizeApiKeyInput(String(key)), params.agentDir); + hasCredential = true; + } + + if (hasCredential) { + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: "edenai", + mode, + }); + } + { + const applied = await applyDefaultModelChoice({ + config: nextConfig, + setDefaultModel: params.setDefaultModel, + defaultModel: EDENAI_DEFAULT_MODEL_REF, + applyDefaultConfig: applyEdenaiConfig, + applyProviderConfig: applyEdenaiProviderConfig, + noteDefault: EDENAI_DEFAULT_MODEL_REF, + noteAgentModel, + prompter: params.prompter, + }); + nextConfig = applied.config; + agentModelOverride = applied.agentModelOverride ?? agentModelOverride; + } + return { config: nextConfig, agentModelOverride }; + } + if (authChoice === "ai-gateway-api-key") { let hasCredential = false; diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index a4d831c92..4cc9f928f 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -11,6 +11,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { chutes: "chutes", "openai-api-key": "openai", "openrouter-api-key": "openrouter", + "edenai-api-key": "edenai", "ai-gateway-api-key": "vercel-ai-gateway", "moonshot-api-key": "moonshot", "kimi-code-api-key": "kimi-code", diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index ce1d05489..8dcdff089 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -131,6 +131,7 @@ export async function modelsStatusCommand( "cerebras", "xai", "openrouter", + "edenai", "zai", "mistral", "synthetic", diff --git a/src/commands/models/scan.ts b/src/commands/models/scan.ts index abde4877c..e71503171 100644 --- a/src/commands/models/scan.ts +++ b/src/commands/models/scan.ts @@ -1,6 +1,10 @@ import { cancel, multiselect as clackMultiselect, isCancel } from "@clack/prompts"; import { resolveApiKeyForProvider } from "../../agents/model-auth.js"; -import { type ModelScanResult, scanOpenRouterModels } from "../../agents/model-scan.js"; +import { + type ModelScanResult, + scanEdenAiModels, + scanOpenRouterModels, +} from "../../agents/model-scan.js"; import { withProgressTotals } from "../../cli/progress.js"; import { loadConfig } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; @@ -120,6 +124,8 @@ function printScanTable(results: ModelScanResult[], runtime: RuntimeEnv) { } } +export type ModelScanSource = "openrouter" | "edenai"; + export async function modelsScanCommand( opts: { minParams?: string; @@ -134,6 +140,7 @@ export async function modelsScanCommand( setImage?: boolean; json?: boolean; probe?: boolean; + source?: ModelScanSource; }, runtime: RuntimeEnv, ) { @@ -160,11 +167,15 @@ export async function modelsScanCommand( const cfg = loadConfig(); const probe = opts.probe ?? true; + const source: ModelScanSource = opts.source ?? "openrouter"; + const providerForKey = source === "edenai" ? "edenai" : "openrouter"; + const sourceLabel = source === "edenai" ? "Eden AI" : "OpenRouter"; + let storedKey: string | undefined; - if (probe) { + if (probe || source === "edenai") { try { const resolved = await resolveApiKeyForProvider({ - provider: "openrouter", + provider: providerForKey, cfg, }); storedKey = resolved.apiKey; @@ -172,14 +183,15 @@ export async function modelsScanCommand( storedKey = undefined; } } + const results = await withProgressTotals( { - label: "Scanning OpenRouter models...", + label: `Scanning ${sourceLabel} models...`, indeterminate: false, enabled: opts.json !== true, }, - async (update) => - await scanOpenRouterModels({ + async (progressUpdate) => { + const scanOpts = { apiKey: storedKey ?? undefined, minParamB: minParams, maxAgeDays, @@ -187,22 +199,35 @@ export async function modelsScanCommand( timeoutMs: timeout, concurrency, probe, - onProgress: ({ phase, completed, total }) => { + onProgress: ({ + phase, + completed, + total, + }: { + phase: string; + completed: number; + total: number; + }) => { if (phase !== "probe") return; const labelBase = probe ? "Probing models" : "Scanning models"; - update({ + progressUpdate({ completed, total, label: `${labelBase} (${completed}/${total})`, }); }, - }), + }; + if (source === "edenai") { + return await scanEdenAiModels(scanOpts); + } + return await scanOpenRouterModels(scanOpts); + }, ); if (!probe) { if (!opts.json) { runtime.log( - `Found ${results.length} OpenRouter free models (metadata only; pass --probe to test tools/images).`, + `Found ${results.length} ${sourceLabel} models (metadata only; pass --probe to test tools/images).`, ); printScanTable(sortScanResults(results), runtime); } else { @@ -213,7 +238,7 @@ export async function modelsScanCommand( const toolOk = results.filter((entry) => entry.tool.ok); if (toolOk.length === 0) { - throw new Error("No tool-capable OpenRouter free models found."); + throw new Error(`No tool-capable ${sourceLabel} models found.`); } const sorted = sortScanResults(results); diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index c94eeb51b..c4fca7b75 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -13,6 +13,7 @@ import { } from "../agents/venice-models.js"; import type { OpenClawConfig } from "../config/config.js"; import { + EDENAI_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, XIAOMI_DEFAULT_MODEL_REF, @@ -484,6 +485,78 @@ export function applyVeniceConfig(cfg: OpenClawConfig): OpenClawConfig { }; } +export const EDENAI_BASE_URL = "https://api.edenai.run/v3/llm"; + +/** + * Apply Eden AI provider configuration without changing the default model. + * Registers Eden AI models and sets up the provider, but preserves existing model selection. + */ +export function applyEdenaiProviderConfig(cfg: ClawdbotConfig): ClawdbotConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[EDENAI_DEFAULT_MODEL_REF] = { + ...models[EDENAI_DEFAULT_MODEL_REF], + alias: models[EDENAI_DEFAULT_MODEL_REF]?.alias ?? "Eden AI", + }; + + const providers = { ...cfg.models?.providers }; + const existingProvider = providers.edenai; + const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; + const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< + string, + unknown + > as { apiKey?: string }; + const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; + const normalizedApiKey = resolvedApiKey?.trim(); + providers.edenai = { + ...existingProviderRest, + baseUrl: EDENAI_BASE_URL, + api: "openai-completions", + ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + models: existingModels, + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + models: { + mode: cfg.models?.mode ?? "merge", + providers, + }, + }; +} + +/** + * Apply Eden AI provider configuration AND set Eden AI as the default model. + * Use this when Eden AI is the primary provider choice during onboarding. + */ +export function applyEdenaiConfig(cfg: ClawdbotConfig): ClawdbotConfig { + const next = applyEdenaiProviderConfig(cfg); + const existingModel = next.agents?.defaults?.model; + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: { + ...(existingModel && "fallbacks" in (existingModel as Record) + ? { + fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks, + } + : undefined), + primary: EDENAI_DEFAULT_MODEL_REF, + }, + }, + }, + }; +} + export function applyAuthProfileConfig( cfg: OpenClawConfig, params: { diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index fbf6dbfb9..53e88f6a8 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -177,3 +177,17 @@ export async function setOpencodeZenApiKey(key: string, agentDir?: string) { agentDir: resolveAuthAgentDir(agentDir), }); } + +export const EDENAI_DEFAULT_MODEL_REF = "edenai/anthropic/claude-sonnet-4-5"; + +export async function setEdenaiApiKey(key: string, agentDir?: string) { + upsertAuthProfile({ + profileId: "edenai:default", + credential: { + type: "api_key", + provider: "edenai", + key, + }, + agentDir: resolveAuthAgentDir(agentDir), + }); +} diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 612b24865..e2c17e91b 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -5,6 +5,8 @@ export { export { VENICE_DEFAULT_MODEL_ID, VENICE_DEFAULT_MODEL_REF } from "../agents/venice-models.js"; export { applyAuthProfileConfig, + applyEdenaiConfig, + applyEdenaiProviderConfig, applyKimiCodeConfig, applyKimiCodeProviderConfig, applyMoonshotConfig, @@ -20,6 +22,7 @@ export { applyXiaomiConfig, applyXiaomiProviderConfig, applyZaiConfig, + EDENAI_BASE_URL, } from "./onboard-auth.config-core.js"; export { applyMinimaxApiConfig, @@ -35,8 +38,10 @@ export { applyOpencodeZenProviderConfig, } from "./onboard-auth.config-opencode.js"; export { + EDENAI_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF, setAnthropicApiKey, + setEdenaiApiKey, setGeminiApiKey, setKimiCodeApiKey, setMinimaxApiKey, diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 8719a1f1a..d205f77d1 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -8,6 +8,7 @@ import { buildTokenProfileId, validateAnthropicSetupToken } from "../../auth-tok import { applyGoogleGeminiModelDefault } from "../../google-gemini-model-default.js"; import { applyAuthProfileConfig, + applyEdenaiConfig, applyKimiCodeConfig, applyMinimaxApiConfig, applyMinimaxConfig, @@ -20,6 +21,7 @@ import { applyXiaomiConfig, applyZaiConfig, setAnthropicApiKey, + setEdenaiApiKey, setGeminiApiKey, setKimiCodeApiKey, setMinimaxApiKey, @@ -235,6 +237,25 @@ export async function applyNonInteractiveAuthChoice(params: { return applyOpenrouterConfig(nextConfig); } + if (authChoice === "edenai-api-key") { + const resolved = await resolveNonInteractiveApiKey({ + provider: "edenai", + cfg: baseConfig, + flagValue: opts.edenaiApiKey, + flagName: "--edenai-api-key", + envVar: "EDENAI_API_KEY", + runtime, + }); + if (!resolved) return null; + if (resolved.source !== "profile") await setEdenaiApiKey(resolved.key); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "edenai:default", + provider: "edenai", + mode: "api_key", + }); + return applyEdenaiConfig(nextConfig); + } + if (authChoice === "ai-gateway-api-key") { const resolved = await resolveNonInteractiveApiKey({ provider: "vercel-ai-gateway", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index f4154bc6d..37261e4a6 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -12,6 +12,7 @@ export type AuthChoice = | "openai-codex" | "openai-api-key" | "openrouter-api-key" + | "edenai-api-key" | "ai-gateway-api-key" | "moonshot-api-key" | "kimi-code-api-key" @@ -63,6 +64,7 @@ export type OnboardOptions = { anthropicApiKey?: string; openaiApiKey?: string; openrouterApiKey?: string; + edenaiApiKey?: string; aiGatewayApiKey?: string; moonshotApiKey?: string; kimiCodeApiKey?: string;