From 8f7425fc3c92e225a486fdc47aee620e252c5c7a Mon Sep 17 00:00:00 2001 From: "michael.zhang" Date: Wed, 28 Jan 2026 18:45:13 +0800 Subject: [PATCH 1/2] added volcano engine model provider --- src/agents/model-auth.ts | 1 + src/agents/models-config.providers.ts | 17 ++ src/cli/program/register.onboard.ts | 4 +- src/commands/auth-choice-options.ts | 10 +- src/commands/auth-choice.apply.ts | 3 + src/commands/auth-choice.apply.volcengine.ts | 245 +++++++++++++++++++ src/commands/onboard-types.ts | 2 + src/wizard/onboarding.ts | 2 +- 8 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 src/commands/auth-choice.apply.volcengine.ts diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 96e4e4ae6..1d033232a 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -285,6 +285,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { venice: "VENICE_API_KEY", mistral: "MISTRAL_API_KEY", opencode: "OPENCODE_API_KEY", + volcengine: "VOLCENGINE_API_KEY", }; const envVar = envMap[normalized]; if (!envVar) return null; diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index a176dac8a..c89544cfb 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -359,6 +359,16 @@ async function buildOllamaProvider(): Promise { }; } +const ARK_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"; + +function buildVolcengineProvider(): ProviderConfig { + return { + baseUrl: ARK_BASE_URL, + api: "openai-completions", + models: [], + }; +} + export async function resolveImplicitProviders(params: { agentDir: string; }): Promise { @@ -402,6 +412,13 @@ export async function resolveImplicitProviders(params: { providers.venice = { ...(await buildVeniceProvider()), apiKey: veniceKey }; } + const volcengineKey = + resolveEnvApiKeyVarName("volcengine") ?? + resolveApiKeyFromProfiles({ provider: "volcengine", store: authStore }); + if (volcengineKey) { + providers.volcengine = { ...buildVolcengineProvider(), apiKey: volcengineKey }; + } + const qwenProfiles = listProfilesForProvider(authStore, "qwen-portal"); if (qwenProfiles.length > 0) { providers["qwen-portal"] = { diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 8f31635f0..2414643f4 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|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", + "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|apiKey|minimax-api|minimax-api-lightning|opencode-zen|volcengine-api-key|skip", ) .option( "--token-provider ", @@ -76,6 +76,7 @@ export function registerOnboardCommand(program: Command) { .option("--synthetic-api-key ", "Synthetic API key") .option("--venice-api-key ", "Venice API key") .option("--opencode-zen-api-key ", "OpenCode Zen API key") + .option("--volcengine-api-key ", "Volcano Engine API key") .option("--gateway-port ", "Gateway port") .option("--gateway-bind ", "Gateway bind: loopback|tailnet|lan|auto|custom") .option("--gateway-auth ", "Gateway auth: token|password") @@ -126,6 +127,7 @@ export function registerOnboardCommand(program: Command) { syntheticApiKey: opts.syntheticApiKey as string | undefined, veniceApiKey: opts.veniceApiKey as string | undefined, opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined, + volcengineApiKey: opts.volcengineApiKey as string | undefined, gatewayPort: typeof gatewayPort === "number" && Number.isFinite(gatewayPort) ? gatewayPort diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 6b49ff17b..7b6e5545b 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -20,7 +20,8 @@ export type AuthChoiceGroupId = | "minimax" | "synthetic" | "venice" - | "qwen"; + | "qwen" + | "volcengine"; export type AuthChoiceGroup = { value: AuthChoiceGroupId; @@ -113,6 +114,12 @@ const AUTH_CHOICE_GROUP_DEFS: { hint: "API key", choices: ["opencode-zen"], }, + { + value: "volcengine", + label: "Volcano Engine", + hint: "ARK API key", + choices: ["volcengine-api-key"], + }, ]; export function buildAuthChoiceOptions(params: { @@ -164,6 +171,7 @@ export function buildAuthChoiceOptions(params: { hint: "Uses the bundled Gemini CLI auth plugin", }); options.push({ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" }); + options.push({ value: "volcengine-api-key", label: "Volcano Engine (ARK) API key" }); options.push({ value: "qwen-portal", label: "Qwen OAuth" }); options.push({ value: "copilot-proxy", diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index f139b509f..f2d3384bf 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -11,6 +11,7 @@ import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js"; import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js"; import { applyAuthChoiceQwenPortal } from "./auth-choice.apply.qwen-portal.js"; +import { applyAuthChoiceVolcengine } from "./auth-choice.apply.volcengine.js"; import type { AuthChoice } from "./onboard-types.js"; export type ApplyAuthChoiceParams = { @@ -24,6 +25,7 @@ export type ApplyAuthChoiceParams = { opts?: { tokenProvider?: string; token?: string; + volcengineApiKey?: string; }; }; @@ -46,6 +48,7 @@ export async function applyAuthChoice( applyAuthChoiceGoogleGeminiCli, applyAuthChoiceCopilotProxy, applyAuthChoiceQwenPortal, + applyAuthChoiceVolcengine, ]; for (const handler of handlers) { diff --git a/src/commands/auth-choice.apply.volcengine.ts b/src/commands/auth-choice.apply.volcengine.ts new file mode 100644 index 000000000..c5ec1be72 --- /dev/null +++ b/src/commands/auth-choice.apply.volcengine.ts @@ -0,0 +1,245 @@ +import { resolveEnvApiKey } from "../agents/model-auth.js"; +import { upsertSharedEnvVar } from "../infra/env-file.js"; +import { + formatApiKeyPreview, + normalizeApiKeyInput, + validateApiKeyInput, +} from "./auth-choice.api-key.js"; +import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; +import { applyAuthProfileConfig } from "./onboard-auth.js"; + +const ARK_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"; + +type ArkModel = { + id: string; + owned_by: string; +}; + +type ArkModelsResponse = { + data: ArkModel[]; +}; + +export async function applyAuthChoiceVolcengine( + params: ApplyAuthChoiceParams, +): Promise { + const authChoice = params.authChoice; + + if (authChoice !== "volcengine-api-key") { + return null; + } + + // 1. Get API Key + let apiKey = resolveEnvApiKey("volcengine")?.apiKey; + if (process.env.VOLCENGINE_API_KEY) { + apiKey = process.env.VOLCENGINE_API_KEY; + } + + if (params.opts?.tokenProvider === "volcengine" && params.opts?.token) { + apiKey = params.opts.token; + } + + if (params.opts?.volcengineApiKey) { + apiKey = params.opts.volcengineApiKey; + } + + if (apiKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing VOLCENGINE_API_KEY (${formatApiKeyPreview(apiKey)})?`, + initialValue: true, + }); + if (!useExisting) { + apiKey = undefined; + } + } + + if (!apiKey) { + const input = await params.prompter.text({ + message: "Enter Volcano Engine (ARK) API key", + validate: validateApiKeyInput, + }); + if (typeof input === "symbol") return null; // Aborted + apiKey = normalizeApiKeyInput(String(input)); + } + + // Save API Key + const result = upsertSharedEnvVar({ + key: "VOLCENGINE_API_KEY", + value: apiKey, + }); + if (!process.env.VOLCENGINE_API_KEY) { + process.env.VOLCENGINE_API_KEY = apiKey; + } + + await params.prompter.note( + `Saved VOLCENGINE_API_KEY to ${result.path}`, + "Volcano Engine API key", + ); + + // 2. Models (Used for config generation later) + const models: ArkModel[] = []; + + // 3. Select Model + let modelId: string | null = null; + let selectionMessage = "Select a model (Auto-verified)"; + + // Helper to verify model access + const verifyModelAccess = async (id: string): Promise => { + const verifySpin = params.prompter.progress(`Verifying access to ${id} (10s timeout)...`); + try { + const res = await fetch(`${ARK_BASE_URL}/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: id, + messages: [{ role: "user", content: "hi" }], + max_tokens: 1, + stream: false, + }), + signal: AbortSignal.timeout(10000), + }); + + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + const errMsg = errData?.error?.message || res.statusText; + throw new Error(errMsg); + } + verifySpin.stop(`Access verified: ${id}`); + return true; + } catch (err: any) { + verifySpin.stop("Access denied or potential timeout"); + await params.prompter.note( + `Model "${id}" verification failed:\n${err.message}\n\nTip: You may need to create an Endpoint in ARK console or enable Pay-as-you-go.`, + "Validation Error", + ); + return false; + } + }; + + while (!modelId) { + const PREDEFINED_MODELS = [ + "glm-4-7-251222", + "doubao-seed-1-8-251228", + "deepseek-v3-2-251201", + "kimi-k2-thinking-251104", + ]; + + const choices = [ + // 1. Predefined Models + ...PREDEFINED_MODELS.map((id) => ({ + value: id, + label: id, + hint: "Predefined", + })), + // 2. Manual Entry (Always available as fallback) + { + value: "__manual__", + label: "Enter Manually (e.g. Endpoint ID ep-2025...)", + hint: "Use this if your Endpoint is not listed", + }, + ]; + + const selection = await params.prompter.select({ + message: selectionMessage, + options: choices, + }); + + if (typeof selection === "symbol") return null; + + let candidateId: string; + if (selection === "__manual__") { + const input = await params.prompter.text({ + message: "Enter Endpoint ID (e.g. ep-20250604...)", + validate: (val) => (val.length > 0 ? undefined : "Endpoint ID is required"), + }); + if (typeof input === "symbol") return null; + candidateId = String(input); + } else { + candidateId = String(selection); + } + + // Verify validity + const isValid = await verifyModelAccess(candidateId); + if (isValid) { + modelId = candidateId; + } else { + selectionMessage = + "Access Denied - Please ensure you have activated this model/endpoint in ARK Console"; + } + } + + // 4. Update Config + let nextConfig = applyAuthProfileConfig(params.config, { + profileId: "volcengine:default", + provider: "volcengine", // We might need to map this provider if 'volcengine' isn't valid in core + mode: "api_key", + }); + + // Configure default model + // Configure default model (Always set as primary when explicitly configuring via this wizard) + if (params.agentId) { + nextConfig = { + ...nextConfig, + agents: { + ...nextConfig.agents, + defaults: { + ...nextConfig.agents?.defaults, + model: { + ...nextConfig.agents?.defaults?.model, + primary: `volcengine/${modelId}`, // referencing the provider/model + }, + }, + }, + }; + } else { + // Always set default model for the workspace globally + nextConfig = { + ...nextConfig, + agents: { + ...nextConfig.agents, + defaults: { + ...nextConfig.agents?.defaults, + model: { + ...nextConfig.agents?.defaults?.model, + primary: `volcengine/${modelId}`, + }, + }, + }, + }; + } + + // Configure Provider + const existingProviderOrder = nextConfig.models?.providers; + // We need to cast this because TypeScript might complain if we add new keys to providers if it's strictly typed + const providers = { ...(existingProviderOrder || {}) } as any; + + providers.volcengine = { + baseUrl: ARK_BASE_URL, + api: "openai-completions", + apiKey: apiKey, + models: [ + { + id: modelId, + name: modelId, + }, + ...models + .filter((m) => m.id !== modelId) + .map((m) => ({ + id: m.id, + name: m.id, + })), + ], + }; + + nextConfig = { + ...nextConfig, + models: { + ...nextConfig.models, + providers, + }, + }; + + return { config: nextConfig, agentModelOverride: modelId }; +} diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index aa1d9afe0..b08492706 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -31,6 +31,7 @@ export type AuthChoice = | "github-copilot" | "copilot-proxy" | "qwen-portal" + | "volcengine-api-key" | "skip"; export type GatewayAuthChoice = "token" | "password"; export type ResetScope = "config" | "config+creds+sessions" | "full"; @@ -71,6 +72,7 @@ export type OnboardOptions = { syntheticApiKey?: string; veniceApiKey?: string; opencodeZenApiKey?: string; + volcengineApiKey?: string; gatewayPort?: number; gatewayBind?: GatewayBind; gatewayAuth?: GatewayAuthChoice; diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 75543ca19..50e1b238c 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -375,7 +375,7 @@ export async function runOnboardingWizard( }); nextConfig = authResult.config; - if (authChoiceFromPrompt) { + if (authChoiceFromPrompt && !authResult.agentModelOverride) { const modelSelection = await promptDefaultModel({ config: nextConfig, prompter, From cc54c47940d3aea2a4eb842356e130fc7c8f45ad Mon Sep 17 00:00:00 2001 From: "michael.zhang" Date: Wed, 28 Jan 2026 19:14:12 +0800 Subject: [PATCH 2/2] fix: lint errors in auth-choice.apply.volcengine.ts --- src/commands/auth-choice.apply.volcengine.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/commands/auth-choice.apply.volcengine.ts b/src/commands/auth-choice.apply.volcengine.ts index c5ec1be72..3c99e93f2 100644 --- a/src/commands/auth-choice.apply.volcengine.ts +++ b/src/commands/auth-choice.apply.volcengine.ts @@ -15,10 +15,6 @@ type ArkModel = { owned_by: string; }; -type ArkModelsResponse = { - data: ArkModel[]; -}; - export async function applyAuthChoiceVolcengine( params: ApplyAuthChoiceParams, ): Promise { @@ -213,7 +209,7 @@ export async function applyAuthChoiceVolcengine( // Configure Provider const existingProviderOrder = nextConfig.models?.providers; // We need to cast this because TypeScript might complain if we add new keys to providers if it's strictly typed - const providers = { ...(existingProviderOrder || {}) } as any; + const providers = { ...existingProviderOrder } as any; providers.volcengine = { baseUrl: ARK_BASE_URL,