feat(volcengine): live model discovery for Ark plan providers

Replace the static curated model lists for volcengine-agent-plan and
volcengine-coding-plan with live discovery from the console APIs
(GetAgentPlanModelMappingMeta / ListArkCodeLatestModel), authenticated by
the console cookie+csrf already captured at plan binding time.

- Add volcenginePlanModelDiscovery.ts: fetch + parse + capability enrichment
  (family->contextLength/vision/reasoning map, conservative default fallback).
  Console calls go through a dynamic undici import to bypass OmniRoute's
  global fetch patch (built for LLM provider traffic, reroutes console hits).
  Coding plan's ListArkCodeLatestModel needs {AccountId:<number>} extracted
  from the console cookie; agent plan's GetAgentPlanModelMappingMeta filters
  PlatformAllowStatus===true && Type==='llm'.
- Remove both plan ids from CURATED_MODEL_ONLY_PROVIDERS so synced models
  merge into /v1/models and the dashboard Sync Models button works.
- sync-models route: short-circuit to console discovery for plan providers
  (the chat API has no /models endpoint); persist via
  replaceSyncedAvailableModelsForConnection.
- volcenginePlanBinding: set autoSync:true on new plan connections so the
  24h modelSyncScheduler refreshes them automatically.
- volcPlanAutoSyncBackfill: idempotent boot-time backfill so pre-existing
  plan connections also enter the scheduler.

Verified end-to-end on local OmniRoute build against live Volcano console:
agent plan synced 7 LLMs, coding plan synced 11 models, /v1/models exposes
all of them (incl. new glm-5-3-260801 / deepseek-v4-flash-260801).
This commit is contained in:
yangsiyuan.rengar
2026-08-17 16:52:06 +08:00
committed by Markus Hartung
parent d732cf615d
commit 76ac1c8b7e
6 changed files with 561 additions and 14 deletions

View File

@@ -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),

View File

@@ -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

View File

@@ -10,14 +10,13 @@
/** Service kinds that, on their own, mean the provider lists no models. */
const TOOL_ONLY_SERVICE_KINDS = new Set<string>(["webSearch", "webFetch"]);
/** Providers whose registry catalog is the complete, intentional model list. */
const CURATED_MODEL_ONLY_PROVIDERS = new Set<string>([
"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<string>(["chatgpt-web", "kimi-web", "zai-web"]);
export function providerUsesCuratedModelsOnly(providerId: string): boolean {
return CURATED_MODEL_ONLY_PROVIDERS.has(providerId.trim().toLowerCase());

View File

@@ -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<void> {
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<string, unknown>)
: {};
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
);
}
}

View File

@@ -6,7 +6,7 @@ import {
type JsonRecord = Record<string, unknown>;
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(

View File

@@ -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<string, unknown>;
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<ConsoleApiResult> {
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: <number>}` 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<SyncedAvailableModelInput[]> {
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;
}