mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 00:52:18 +03:00
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:
committed by
Markus Hartung
parent
d732cf615d
commit
76ac1c8b7e
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
44
src/lib/providers/volcPlanAutoSyncBackfill.ts
Normal file
44
src/lib/providers/volcPlanAutoSyncBackfill.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
413
src/lib/providers/volcenginePlanModelDiscovery.ts
Normal file
413
src/lib/providers/volcenginePlanModelDiscovery.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user