diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 6abf6d7387..7d75906360 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -21,6 +21,11 @@ import { import { autoSyncCodexProfilesFromLiveCatalog } from "@/lib/cli-helper/codexProfileAutoSync"; import { autoSyncClaudeProfilesFromLiveCatalog } from "@/lib/cli-helper/claudeProfileAutoSync"; import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability"; +import { + fetchVolcPlanModels, + providerToVolcPlanKind, +} from "@/lib/providers/volcenginePlanModelDiscovery"; +import { replaceSyncedAvailableModelsForConnection } from "@/lib/db/models"; import { GET as getProviderModels } from "../models/route"; import { isDegradedDiscovery } from "./degradedLocalCatalog"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; @@ -423,6 +428,84 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: logProvider = toNonEmptyString(connection.provider) || "unknown"; channelLabel = getModelSyncChannelLabel(connection); + + // Volcano Ark plan providers: discover models live from the console API + // (cookie+csrf captured at bind time). The chat API has no /models + // endpoint, so the default discovery path below cannot serve them. + const volcPlanKind = providerToVolcPlanKind(logProvider); + if (volcPlanKind) { + const psd = + connection.providerSpecificData && typeof connection.providerSpecificData === "object" + ? (connection.providerSpecificData as JsonRecord) + : {}; + const cookie = toNonEmptyString(psd.volcConsoleCookie) || ""; + const csrf = toNonEmptyString(psd.volcCsrfToken) || ""; + const duration = Date.now() - start; + let discovered; + try { + discovered = await fetchVolcPlanModels(volcPlanKind, cookie, csrf); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + await saveCallLog({ + method: "POST", + path: `/api/providers/${id}/sync-models`, + status: 401, + model: "model-sync", + provider: logProvider, + sourceFormat: "-", + connectionId: id, + duration, + error: message, + requestType: "model-sync", + ...(channelLabel ? { responseBody: { channel: channelLabel } } : {}), + }).catch(() => undefined); + return NextResponse.json( + { error: sanitizeErrorMessage(message) || "Volcano plan discovery failed" }, + { status: 401 } + ); + } + const previous = await getSyncedAvailableModelsForConnection(logProvider, id); + const synced = await replaceSyncedAvailableModelsForConnection(logProvider, id, discovered); + const prevIds = new Set(previous.map((m) => String(m.id))); + const added = synced.filter((m) => !prevIds.has(String(m.id))).length; + const removed = previous.filter( + (m) => !synced.some((n) => String(n.id) === String(m.id)) + ).length; + await saveCallLog({ + method: "GET", + path: `/api/providers/${id}/models`, + status: 200, + model: "model-sync", + provider: logProvider, + sourceFormat: "console-discovery", + connectionId: id, + duration: Date.now() - start, + requestType: "model-sync", + responseBody: { + source: "volcengine-plan-console-discovery", + plan: volcPlanKind, + syncedModels: synced.length, + added, + removed, + provider: logProvider, + channel: channelLabel, + mode, + }, + }).catch(() => undefined); + return NextResponse.json({ + ok: true, + provider: logProvider, + connectionId: id, + source: "volcengine-plan-console-discovery", + plan: volcPlanKind, + mode, + syncedModels: synced.length, + availableModelsCount: synced.length, + modelChanges: { added, removed, total: added + removed }, + models: synced, + }); + } + if (providerUsesCuratedModelsOnly(logProvider)) { const [removedSyncedLists, removedImportedModelIds] = await Promise.all([ deleteSyncedAvailableModelsForProvider(logProvider), diff --git a/src/lib/initCloudSync.ts b/src/lib/initCloudSync.ts index 3464ea0d29..1d62f425e5 100644 --- a/src/lib/initCloudSync.ts +++ b/src/lib/initCloudSync.ts @@ -4,6 +4,7 @@ import { isAutomatedTestProcess } from "@/shared/utils/testProcess"; import { getJobRegistry } from "@/lib/jobRegistry"; import { registerBudgetResetJob } from "@/lib/jobs/budgetResetJob"; import { registerTokenHealthCheck } from "@/lib/jobs/tokenHealthCheckJob"; +import { backfillVolcPlanAutoSync } from "@/lib/providers/volcPlanAutoSyncBackfill"; // Initialize runtime background sync services once per server process. let initialized = false; @@ -31,6 +32,7 @@ export async function ensureCloudSyncInitialized() { if (!initialized) { try { await initializeCloudSync(); + await backfillVolcPlanAutoSync(); startModelSyncScheduler(); // startAll() runs each interval job's first tick synchronously, so it has to diff --git a/src/lib/providers/modelListingCapability.ts b/src/lib/providers/modelListingCapability.ts index 64f94078ea..db0e439824 100644 --- a/src/lib/providers/modelListingCapability.ts +++ b/src/lib/providers/modelListingCapability.ts @@ -10,14 +10,13 @@ /** Service kinds that, on their own, mean the provider lists no models. */ const TOOL_ONLY_SERVICE_KINDS = new Set(["webSearch", "webFetch"]); -/** Providers whose registry catalog is the complete, intentional model list. */ -const CURATED_MODEL_ONLY_PROVIDERS = new Set([ - "chatgpt-web", - "kimi-web", - "zai-web", - "volcengine-agent-plan", - "volcengine-coding-plan", -]); +/** Providers whose registry catalog is the complete, intentional model list. + * + * Volcano Ark plan providers (`volcengine-agent-plan` / `volcengine-coding-plan`) + * are intentionally NOT curated: their model list is discovered live from the + * console API (see volcenginePlanModelDiscovery.ts) and merged into the synced + * catalog, so the static registry only acts as a capability-seed fallback. */ +const CURATED_MODEL_ONLY_PROVIDERS = new Set(["chatgpt-web", "kimi-web", "zai-web"]); export function providerUsesCuratedModelsOnly(providerId: string): boolean { return CURATED_MODEL_ONLY_PROVIDERS.has(providerId.trim().toLowerCase()); diff --git a/src/lib/providers/volcPlanAutoSyncBackfill.ts b/src/lib/providers/volcPlanAutoSyncBackfill.ts new file mode 100644 index 0000000000..014ed0a68a --- /dev/null +++ b/src/lib/providers/volcPlanAutoSyncBackfill.ts @@ -0,0 +1,44 @@ +/** + * One-time, idempotent backfill: ensure Volcano Ark plan connections carry + * `autoSync:true` so the 24h modelSyncScheduler picks them up. + * + * Plan connections created before volcenginePlanBinding set `autoSync` do not + * have the flag, so the scheduler (which only syncs connections whose + * providerSpecificData.autoSync === true) silently skipped them. This runs + * once per boot, patches any missing flag in place, and exits. It is safe to + * re-run — updateProviderConnection merges the patch. + */ + +import { getProviderConnections, updateProviderConnection } from "@/lib/db/providers"; + +const VOLC_PLAN_PROVIDERS = new Set(["volcengine-agent-plan", "volcengine-coding-plan"]); + +let backfilled = false; + +export async function backfillVolcPlanAutoSync(): Promise { + if (backfilled) return; + backfilled = true; + try { + const connections = await getProviderConnections(); + for (const conn of connections) { + const provider = typeof conn.provider === "string" ? conn.provider : ""; + if (!VOLC_PLAN_PROVIDERS.has(provider)) continue; + const psd = + conn.providerSpecificData && typeof conn.providerSpecificData === "object" + ? (conn.providerSpecificData as Record) + : {}; + if (psd.autoSync === true) continue; + const merged = { ...psd, autoSync: true }; + if (typeof conn.id !== "string" || !conn.id) continue; + await updateProviderConnection(conn.id, { + providerSpecificData: merged, + }); + } + } catch (error) { + backfilled = false; // allow retry on next boot if this boot failed + console.warn( + "[VolcPlanAutoSync] backfill failed — will retry next boot:", + (error as Error).message + ); + } +} diff --git a/src/lib/providers/volcenginePlanBinding.ts b/src/lib/providers/volcenginePlanBinding.ts index 050bd0cf01..ae989d5eae 100644 --- a/src/lib/providers/volcenginePlanBinding.ts +++ b/src/lib/providers/volcenginePlanBinding.ts @@ -6,7 +6,7 @@ import { type JsonRecord = Record; -const CONSOLE_TOP_BASE = "https://console.volcengine.com/api/top/ark/cn-beijing/2024-01-01"; +export const CONSOLE_TOP_BASE = "https://console.volcengine.com/api/top/ark/cn-beijing/2024-01-01"; const CODING_PLAN_PROVIDER = "volcengine-coding-plan"; const AGENT_PLAN_PROVIDER = "volcengine-agent-plan"; @@ -15,6 +15,8 @@ const PLAN_CONFIG = { provider: CODING_PLAN_PROVIDER, name: "Volcano Ark Coding Plan", usageAction: "GetCodingPlanUsage", + listModelAction: "ListArkCodeLatestModel", + listModelPayload: {}, referer: "https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan", listApiKeysPayload: { ProjectName: "default" }, }, @@ -22,6 +24,8 @@ const PLAN_CONFIG = { provider: AGENT_PLAN_PROVIDER, name: "Volcano Ark Agent Plan", usageAction: "GetAgentPlanAFPUsage", + listModelAction: "GetAgentPlanModelMappingMeta", + listModelPayload: { Edition: "agent_plan_personal" }, referer: "https://console.volcengine.com/ark/region:cn-beijing/subscription/agent-plan", listApiKeysPayload: { ProjectName: "default", @@ -32,18 +36,18 @@ const PLAN_CONFIG = { type PlanKind = keyof typeof PLAN_CONFIG; -interface ConsoleApiResult { +export interface ConsoleApiResult { ok: boolean; status: number; json: JsonRecord; error: string | null; } -function stringField(value: unknown): string { +export function stringField(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } -function record(value: unknown): JsonRecord { +export function record(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } @@ -67,7 +71,7 @@ function extractCsrf(credentials: JsonRecord, cookieHeader: string): string { return cookieHeader.match(/(?:^|;\s*)csrfToken=([^;]+)/)?.[1]?.trim() || ""; } -async function callConsoleApi( +export async function callConsoleApi( action: string, payload: JsonRecord, cookieHeader: string, @@ -104,7 +108,7 @@ async function callConsoleApi( }; } -async function detectPlan( +export async function detectPlan( kind: PlanKind, cookieHeader: string, csrfToken: string @@ -184,6 +188,8 @@ async function upsertConnection( volcApiKeyId: apiKeyId, volcPlanKind: kind, volcLastUsage: usage, + // Enable 24h model auto-sync (modelSyncScheduler picks up autoSync:true). + autoSync: true, }; const existing = (await getProviderConnections({ provider: cfg.provider })).find( diff --git a/src/lib/providers/volcenginePlanModelDiscovery.ts b/src/lib/providers/volcenginePlanModelDiscovery.ts new file mode 100644 index 0000000000..417d9cbaad --- /dev/null +++ b/src/lib/providers/volcenginePlanModelDiscovery.ts @@ -0,0 +1,413 @@ +/** + * Volcano Ark Plan — live model discovery via console APIs. + * + * Both Plan subscriptions have NO usable `/models` endpoint on the chat API + * (`/api/plan/v3` returns 404; coding `/api/coding/v3/models` is unreliable). + * The authoritative model catalog is instead exposed by the console's + * top-level Ark actions, authenticated by the same console cookie + csrf + * token already captured during plan binding (see volcenginePlanBinding.ts). + * + * - Agent Plan: `GetAgentPlanModelMappingMeta` → Result.Data[] + * filter: PlatformAllowStatus===true && Type==="llm" + * id : RespModelID (already version-suffixed, matches chat endpoint) + * - Coding Plan: `ListArkCodeLatestModel` → Result.Data[] + * id : ModelId (version-suffixed) + * + * The console API returns only id/name/version/description — NOT capabilities + * (contextLength, toolCalling, vision, reasoning). We enrich each discovered + * model from a static family→capability map keyed by the OutputName/ModelName + * prefix, falling back to conservative defaults so new families stay usable + * without a code change. + * + * Output shape matches SyncedAvailableModelInput so the sync-models route can + * persist it via replaceSyncedAvailableModelsForConnection. + */ + +import type { SyncedAvailableModelInput } from "@/lib/db/models/synced"; + +type JsonRecord = Record; + +export type VolcPlanKind = "agent" | "coding"; + +export interface DiscoveredVolcModel { + id: string; + name: string; + description?: string; + enabledThinking?: boolean; +} + +const CONSOLE_TOP_BASE = "https://console.volcengine.com/api/top/ark/cn-beijing/2024-01-01"; +const AGENT_PLAN_REFERER = + "https://console.volcengine.com/ark/region:cn-beijing/subscription/agent-plan"; +const CODING_PLAN_REFERER = + "https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan"; + +function stringField(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} +function record(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +interface ConsoleApiResult { + ok: boolean; + status: number; + json: JsonRecord; + error: string | null; +} + +/** + * Hit the Volcano console API directly via undici, BYPASSING OmniRoute's + * global fetch patch (open-sse/utils/proxyFetch.ts) which is built for LLM + * provider traffic and reroutes/rewrites requests to console.volcengine.com. + * Dynamic import so the build cannot extern/strip the dependency. + */ +async function callConsoleApiDirect( + action: string, + payload: JsonRecord, + cookieHeader: string, + csrfToken: string, + referer: string +): Promise { + const { fetch: pristineFetch } = await import("undici"); + const response = await pristineFetch(`${CONSOLE_TOP_BASE}/${action}?`, { + method: "POST", + headers: { + accept: "application/json, text/plain, */*", + "content-type": "application/json", + cookie: cookieHeader, + origin: "https://console.volcengine.com", + referer, + "x-csrf-token": csrfToken, + }, + body: JSON.stringify(payload), + }); + const text = await response.text(); + let json: JsonRecord = {}; + try { + json = record(JSON.parse(text)); + } catch { + // Non-JSON console failures are reported through `error` below. + } + const meta = record(json.ResponseMetadata); + const err = record(meta.Error); + const message = stringField(err.Message); + return { + ok: response.ok && !message, + status: response.status, + json, + error: message || (response.ok ? null : text.slice(0, 200)), + }; +} + +async function detectPlan( + kind: VolcPlanKind, + cookieHeader: string, + csrfToken: string +): Promise<{ available: boolean; error: string | null }> { + const action = kind === "agent" ? "GetAgentPlanAFPUsage" : "GetCodingPlanUsage"; + const referer = kind === "agent" ? AGENT_PLAN_REFERER : CODING_PLAN_REFERER; + const result = await callConsoleApiDirect(action, {}, cookieHeader, csrfToken, referer); + if (!result.ok) { + return { available: false, error: result.error }; + } + return { available: true, error: null }; +} + +const PLAN_DISCOVERY_CONFIG: Record< + VolcPlanKind, + { + action: string; + /** Base payload; coding plan needs AccountId injected per-request. */ + payload: JsonRecord; + referer: string; + /** Whether the listing API requires the console AccountId in the body. */ + requiresAccountId: boolean; + } +> = { + agent: { + action: "GetAgentPlanModelMappingMeta", + payload: { Edition: "agent_plan_personal" }, + referer: AGENT_PLAN_REFERER, + requiresAccountId: false, + }, + coding: { + action: "ListArkCodeLatestModel", + payload: {}, + referer: CODING_PLAN_REFERER, + requiresAccountId: true, + }, +}; + +/** + * Extract the numeric `AccountID` from the console cookie jar. The Coding Plan + * listing API requires `{AccountId: }` in the body (string is rejected + * with InvalidParameter). The AccountID is always present in an authenticated + * console cookie, so this avoids a separate binding field / DB migration. + */ +function extractAccountId(cookieHeader: string): number | null { + const raw = cookieHeader.match(/(?:^|;\s*)AccountID=([^;]+)/i)?.[1]?.trim(); + if (!raw) return null; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : null; +} + +/** + * Family→capability enrichment. The console API does not return context + * window / tool / vision / reasoning flags, so we seed them from the model + * family. Keyed by the canonical model name (RespModelName / OutputName / + * ModelName) lowercased; a `*`-prefixed entry matches by prefix. + * + * Values mirror the curated static registry (volcengine/{agent,coding}-plan) + * so behavior is unchanged for known models; unknown families fall back to + * `enrichWithDefaults`. + */ +const FAMILY_CAPABILITY_MAP: Array<{ + match: string; + contextLength: number; + toolCalling: boolean; + supportsVision: boolean; + supportsReasoning: boolean; +}> = [ + // Doubao Seed 2.x turbo / mini — 256K, multimodal + { + match: "doubao-seed-2-1-turbo", + contextLength: 262144, + toolCalling: true, + supportsVision: true, + supportsReasoning: true, + }, + { + match: "doubao-seed-2-0-mini", + contextLength: 262144, + toolCalling: true, + supportsVision: true, + supportsReasoning: true, + }, + // Doubao Seed 2.0 lite — 256K, multimodal + { + match: "doubao-seed-2-0-lite", + contextLength: 262144, + toolCalling: true, + supportsVision: true, + supportsReasoning: true, + }, + // Doubao Seed Evolving — 1M + { + match: "doubao-seed-evolving", + contextLength: 1048576, + toolCalling: true, + supportsVision: true, + supportsReasoning: true, + }, + // DeepSeek V4 family — 1M, text-only reasoning + { + match: "deepseek-v4", + contextLength: 1048576, + toolCalling: true, + supportsVision: false, + supportsReasoning: true, + }, + // GLM 5.x — 1M + { + match: "glm-5", + contextLength: 1048576, + toolCalling: true, + supportsVision: false, + supportsReasoning: true, + }, + // Kimi K2.7 code — 1M, multimodal + { + match: "kimi-k2.7-code", + contextLength: 1048576, + toolCalling: true, + supportsVision: true, + supportsReasoning: true, + }, + { + match: "kimi-k2-7-code", + contextLength: 1048576, + toolCalling: true, + supportsVision: true, + supportsReasoning: true, + }, + // Kimi K2.6 — 1M + { + match: "kimi-k2.6", + contextLength: 1048576, + toolCalling: true, + supportsVision: false, + supportsReasoning: true, + }, + // MiniMax M3 / M2.7 — 1M + { + match: "minimax-m3", + contextLength: 1048576, + toolCalling: true, + supportsVision: false, + supportsReasoning: true, + }, + { + match: "minimax-m2.7", + contextLength: 1048576, + toolCalling: true, + supportsVision: false, + supportsReasoning: true, + }, +]; + +const DEFAULT_CAPABILITY = { + contextLength: 131072, + toolCalling: true, + supportsVision: false, + supportsReasoning: true, +}; + +function matchFamily(name: string) { + const lower = name.trim().toLowerCase(); + if (!lower) return null; + // Prefer exact match, then prefix match. + for (const entry of FAMILY_CAPABILITY_MAP) { + if (entry.match === lower) return entry; + } + for (const entry of FAMILY_CAPABILITY_MAP) { + if (lower.startsWith(entry.match)) return entry; + } + return null; +} + +function enrichModel(model: DiscoveredVolcModel): SyncedAvailableModelInput { + const family = matchFamily(model.name) ?? matchFamily(model.id) ?? DEFAULT_CAPABILITY; + return { + id: model.id, + name: model.name || model.id, + source: "imported", + apiFormat: "chat-completions", + supportedEndpoints: ["chat"], + inputTokenLimit: family.contextLength, + supportsTools: family.toolCalling, + supportsVision: family.supportsVision, + supportsThinking: model.enabledThinking ?? family.supportsReasoning, + ...(model.description ? { description: model.description } : {}), + }; +} + +/** + * Parse Agent Plan `GetAgentPlanModelMappingMeta` Result.Data[]. + * Only entries with PlatformAllowStatus===true and Type==="llm" are usable + * for chat; others (disabled, embedding, audio, video, auto-routing) are + * skipped. + */ +function parseAgentPlanModels(json: JsonRecord): DiscoveredVolcModel[] { + const data = record(json.Result).Data; + const arr = Array.isArray(data) ? data : []; + const out: DiscoveredVolcModel[] = []; + for (const raw of arr) { + const item = record(raw); + const allowed = item.PlatformAllowStatus === true; + const type = stringField(item.Type); + const id = stringField(item.RespModelID); + if (!allowed || !id) continue; + if (type && type !== "llm") continue; + const name = stringField(item.RespModelName) || id; + out.push({ + id, + name, + ...(stringField(item.RespModelVersion) + ? { description: `v${stringField(item.RespModelVersion)}` } + : {}), + }); + } + return out; +} + +/** + * Parse Coding Plan `ListArkCodeLatestModel` Result.Data[]. + * The API exposes every catalog entry regardless of subscription state; we + * keep ALL of them (the chat endpoint accepts them, Enabled only reflects + * console visibility) but surface Enabled through the description. + */ +function parseCodingPlanModels(json: JsonRecord): DiscoveredVolcModel[] { + const data = record(json.Result).Data; + const arr = Array.isArray(data) ? data : []; + const out: DiscoveredVolcModel[] = []; + for (const raw of arr) { + const item = record(raw); + const id = stringField(item.ModelId); + if (!id) continue; + const name = stringField(item.OutputName) || stringField(item.ModelName) || id; + const enabledThinking = item.EnabledThinking === true || item.EnabledThinking === "true"; + const desc = stringField(item.Description); + out.push({ + id, + name, + ...(desc ? { description: desc } : {}), + ...(enabledThinking ? { enabledThinking: true } : {}), + }); + } + return out; +} + +/** + * Fetch the live model list for a Volcano Ark plan subscription using the + * console cookie + csrf token stored on the connection's providerSpecificData. + * + * Verifies the plan subscription is still active (detectPlan) before listing, + * so an expired/disabled plan returns a clear error instead of a stale/empty + * catalog that would erase the user's synced models. + */ +export async function fetchVolcPlanModels( + kind: VolcPlanKind, + cookieHeader: string, + csrfToken: string +): Promise { + if (!cookieHeader || !csrfToken) { + throw new Error("Volcano console cookie or csrfToken is missing — re-bind the plan"); + } + + // Validate the subscription/credentials are still live. + const detected = await detectPlan(kind, cookieHeader, csrfToken); + if (!detected.available) { + throw new Error( + `Volcano ${kind} plan unavailable${detected.error ? `: ${detected.error}` : ""} — re-bind the plan` + ); + } + + const cfg = PLAN_DISCOVERY_CONFIG[kind]; + const payload: JsonRecord = { ...cfg.payload }; + if (cfg.requiresAccountId) { + const accountId = extractAccountId(cookieHeader); + if (accountId === null) { + throw new Error( + `Volcano ${kind} plan discovery requires AccountId, but none found in console cookie — re-bind the plan` + ); + } + payload.AccountId = accountId; + } + const result = await callConsoleApiDirect( + cfg.action, + payload, + cookieHeader, + csrfToken, + cfg.referer + ); + if (!result.ok) { + throw new Error( + `Volcano ${kind} plan model discovery (${cfg.action}) failed${result.error ? `: ${result.error}` : ""}` + ); + } + + const discovered = + kind === "agent" ? parseAgentPlanModels(result.json) : parseCodingPlanModels(result.json); + if (discovered.length === 0) { + throw new Error(`Volcano ${kind} plan returned no usable models`); + } + return discovered.map(enrichModel); +} + +export function providerToVolcPlanKind(providerId: string): VolcPlanKind | null { + const id = providerId.trim().toLowerCase(); + if (id === "volcengine-agent-plan") return "agent"; + if (id === "volcengine-coding-plan") return "coding"; + return null; +}