feat: ComfyUI video assistant SaaS plan + shared types cleanup

- Add plan/2026-05-18-comfyui-video-assistant-saas.md (gpt-5.5 generated)
- Add src/server/ and src/shared/ type definitions
- Update comfyui routes and api_key auth
- Remove old metapi docs, deploy/k3s, and deprecated configs
This commit is contained in:
boos
2026-05-18 11:28:20 +08:00
parent bfdb01e542
commit f2434e725d
120 changed files with 3637 additions and 7174 deletions
+4
View File
@@ -0,0 +1,4 @@
export {
buildBatchApiKeyConnectionName,
parseBatchApiKeys,
} from '../server/services/shared/apiKeyBatchCore.js';
+15
View File
@@ -0,0 +1,15 @@
export type ConversationFileKind = 'document' | 'image' | 'audio' | 'unknown';
export declare const CONVERSATION_DOCUMENT_ACCEPT_PARTS: string[];
export declare function inferConversationFileMimeType(filename: string | null | undefined): string;
export declare function resolveConversationFileMimeType(mimeType: string | null | undefined, filename: string | null | undefined): string;
export declare function classifyConversationFileMimeType(mimeType: string | null | undefined): Exclude<ConversationFileKind, 'unknown'>;
export declare function detectConversationFileKind(file: {
filename?: string | null;
mimeType?: string | null;
}): ConversationFileKind;
export declare function isSupportedConversationFileMimeType(mimeType: string): boolean;
export declare function buildConversationAcceptList(input: {
document: boolean;
image: boolean;
audio: boolean;
}): string;
+134
View File
@@ -0,0 +1,134 @@
export const CONVERSATION_DOCUMENT_ACCEPT_PARTS = [
'.pdf',
'.txt',
'.md',
'.markdown',
'.json',
];
const EXTENSION_MIME_MAP = {
// Documents
pdf: 'application/pdf',
txt: 'text/plain',
md: 'text/markdown',
markdown: 'text/markdown',
json: 'application/json',
csv: 'text/csv',
xml: 'text/xml',
html: 'text/html',
htm: 'text/html',
// Images
png: 'image/png',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
bmp: 'image/bmp',
svg: 'image/svg+xml',
ico: 'image/x-icon',
tiff: 'image/tiff',
tif: 'image/tiff',
// Audio
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
m4a: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
wma: 'audio/x-ms-wma',
webm: 'audio/webm',
};
const SUPPORTED_MIME_TYPES = new Set([
// Images
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/avif',
'image/bmp',
'image/svg+xml',
'image/tiff',
// Audio
'audio/mpeg',
'audio/wav',
'audio/ogg',
'audio/mp4',
'audio/aac',
'audio/flac',
'audio/webm',
// Documents
'application/pdf',
'text/plain',
'text/markdown',
'application/json',
'text/csv',
'text/xml',
'text/html',
]);
function stripMimeParameters(mimeType) {
return mimeType.split(';')[0].trim();
}
function getExtension(filename) {
if (!filename)
return '';
const dotIndex = filename.lastIndexOf('.');
if (dotIndex < 0)
return '';
return filename.slice(dotIndex + 1).toLowerCase();
}
export function inferConversationFileMimeType(filename) {
const ext = getExtension(filename);
return EXTENSION_MIME_MAP[ext] || 'application/octet-stream';
}
export function resolveConversationFileMimeType(mimeType, filename) {
if (!mimeType) {
return inferConversationFileMimeType(filename);
}
const stripped = stripMimeParameters(mimeType);
if (stripped === 'application/octet-stream' || stripped === 'application/octet-stream;charset=utf-8') {
return inferConversationFileMimeType(filename);
}
return stripped;
}
export function classifyConversationFileMimeType(mimeType) {
if (!mimeType)
return 'document';
const stripped = stripMimeParameters(mimeType);
if (stripped.startsWith('image/'))
return 'image';
if (stripped.startsWith('audio/'))
return 'audio';
return 'document';
}
export function detectConversationFileKind(file) {
const resolved = resolveConversationFileMimeType(file.mimeType, file.filename);
const classified = classifyConversationFileMimeType(resolved);
if (classified !== 'document')
return classified;
// When we don't have a meaningful mimeType, rely on filename extension
// to distinguish between known document types and truly unknown files.
const normalizedMime = file.mimeType ? stripMimeParameters(file.mimeType) : '';
if (!normalizedMime || normalizedMime === 'application/octet-stream') {
const ext = getExtension(file.filename);
if (!ext || !EXTENSION_MIME_MAP[ext])
return 'unknown';
}
return 'document';
}
export function isSupportedConversationFileMimeType(mimeType) {
const stripped = stripMimeParameters(mimeType);
return SUPPORTED_MIME_TYPES.has(stripped);
}
export function buildConversationAcceptList(input) {
const parts = [];
if (input.document) {
parts.push(...CONVERSATION_DOCUMENT_ACCEPT_PARTS);
}
if (input.image) {
parts.push('image/*');
}
if (input.audio) {
parts.push('audio/*');
}
return parts.join(',');
}
+154
View File
@@ -0,0 +1,154 @@
export type ConversationFileKind = 'document' | 'image' | 'audio' | 'unknown';
export const CONVERSATION_DOCUMENT_ACCEPT_PARTS: string[] = [
'.pdf',
'.txt',
'.md',
'.markdown',
'.json',
];
const EXTENSION_MIME_MAP: Record<string, string> = {
// Documents
pdf: 'application/pdf',
txt: 'text/plain',
md: 'text/markdown',
markdown: 'text/markdown',
json: 'application/json',
csv: 'text/csv',
xml: 'text/xml',
html: 'text/html',
htm: 'text/html',
// Images
png: 'image/png',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
avif: 'image/avif',
bmp: 'image/bmp',
svg: 'image/svg+xml',
ico: 'image/x-icon',
tiff: 'image/tiff',
tif: 'image/tiff',
// Audio
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
m4a: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
wma: 'audio/x-ms-wma',
webm: 'audio/webm',
};
const SUPPORTED_MIME_TYPES = new Set<string>([
// Images
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/avif',
'image/bmp',
'image/svg+xml',
'image/tiff',
// Audio
'audio/mpeg',
'audio/wav',
'audio/ogg',
'audio/mp4',
'audio/aac',
'audio/flac',
'audio/webm',
// Documents
'application/pdf',
'text/plain',
'text/markdown',
'application/json',
'text/csv',
'text/xml',
'text/html',
]);
function stripMimeParameters(mimeType: string): string {
return mimeType.split(';')[0].trim();
}
function getExtension(filename: string | null | undefined): string {
if (!filename) return '';
const dotIndex = filename.lastIndexOf('.');
if (dotIndex < 0) return '';
return filename.slice(dotIndex + 1).toLowerCase();
}
export function inferConversationFileMimeType(filename: string | null | undefined): string {
const ext = getExtension(filename);
return EXTENSION_MIME_MAP[ext] || 'application/octet-stream';
}
export function resolveConversationFileMimeType(
mimeType: string | null | undefined,
filename: string | null | undefined,
): string {
if (!mimeType) {
return inferConversationFileMimeType(filename);
}
const stripped = stripMimeParameters(mimeType);
if (stripped === 'application/octet-stream' || stripped === 'application/octet-stream;charset=utf-8') {
return inferConversationFileMimeType(filename);
}
return stripped;
}
export function classifyConversationFileMimeType(
mimeType: string | null | undefined,
): Exclude<ConversationFileKind, 'unknown'> {
if (!mimeType) return 'document';
const stripped = stripMimeParameters(mimeType);
if (stripped.startsWith('image/')) return 'image';
if (stripped.startsWith('audio/')) return 'audio';
return 'document';
}
export function detectConversationFileKind(file: {
filename?: string | null;
mimeType?: string | null;
}): ConversationFileKind {
const resolved = resolveConversationFileMimeType(file.mimeType, file.filename);
const classified = classifyConversationFileMimeType(resolved);
if (classified !== 'document') return classified;
// When we don't have a meaningful mimeType, rely on filename extension
// to distinguish between known document types and truly unknown files.
const normalizedMime = file.mimeType ? stripMimeParameters(file.mimeType) : '';
if (!normalizedMime || normalizedMime === 'application/octet-stream') {
const ext = getExtension(file.filename);
if (!ext || !EXTENSION_MIME_MAP[ext]) return 'unknown';
}
return 'document';
}
export function isSupportedConversationFileMimeType(mimeType: string): boolean {
const stripped = stripMimeParameters(mimeType);
return SUPPORTED_MIME_TYPES.has(stripped);
}
export function buildConversationAcceptList(input: {
document: boolean;
image: boolean;
audio: boolean;
}): string {
const parts: string[] = [];
if (input.document) {
parts.push(...CONVERSATION_DOCUMENT_ACCEPT_PARTS);
}
if (input.image) {
parts.push('image/*');
}
if (input.audio) {
parts.push('audio/*');
}
return parts.join(',');
}
+65
View File
@@ -0,0 +1,65 @@
export const PLATFORM_ALIASES = {
anthropic: 'claude',
google: 'gemini',
'chatgpt-codex': 'codex',
'anti-gravity': 'antigravity',
'gemini-cli': 'gemini-cli',
cliproxyapi: 'cliproxyapi',
'claude-code': 'claude',
'claude cli': 'claude',
'new api': 'new-api',
newapi: 'new-api',
'one api': 'one-api',
oneapi: 'one-api',
'one hub': 'one-hub',
onehub: 'one-hub',
'done hub': 'done-hub',
donehub: 'done-hub',
};
const PLATFORM_URL_HINTS = [
{ hostPattern: 'api.openai.com', platform: 'openai' },
{ hostPattern: 'chatgpt.com', platform: 'codex' },
{ hostPattern: 'api.anthropic.com', platform: 'claude' },
{ hostPattern: 'generativelanguage.googleapis.com', platform: 'gemini' },
{ hostPattern: 'cloudcode-pa.googleapis.com', platform: 'gemini-cli' },
{ hostPattern: '127.0.0.1:8317', platform: 'cliproxyapi' },
];
export function normalizePlatformAlias(platform) {
if (!platform || typeof platform !== 'string')
return '';
const raw = platform.trim();
if (!raw)
return '';
const lower = raw.toLowerCase();
// Check PLATFORM_ALIASES first
if (Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, lower)) {
return PLATFORM_ALIASES[lower];
}
// Generic normalization: replace underscores and spaces with hyphens
const hyphenated = lower.replace(/[_ ]+/g, '-');
// Check aliases again with hyphenated form
if (Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, hyphenated)) {
return PLATFORM_ALIASES[hyphenated];
}
// Remove hyphens between word characters (e.g. anti-gravity -> antigravity)
return hyphenated.replace(/-/g, '');
}
export function detectPlatformByUrlHint(url) {
const lowerUrl = url.toLowerCase();
for (const hint of PLATFORM_URL_HINTS) {
if (lowerUrl.includes(hint.hostPattern)) {
// Make sure the host appears as a proper hostname segment (prevent
// forged queries like ?next=https://api.openai.com from matching).
const hostIndex = lowerUrl.indexOf(hint.hostPattern);
if (hostIndex >= 0) {
// Check that what precedes the host pattern is either the start
// of the URL or a valid URL separator (:// or /).
const preceding = lowerUrl[hostIndex - 1];
if (hostIndex === 0 || preceding === '/' || preceding === ':') {
return hint.platform;
}
}
}
}
return undefined;
}
+71
View File
@@ -0,0 +1,71 @@
export const PLATFORM_ALIASES: Record<string, string> = {
anthropic: 'claude',
google: 'gemini',
'chatgpt-codex': 'codex',
'anti-gravity': 'antigravity',
'gemini-cli': 'gemini-cli',
cliproxyapi: 'cliproxyapi',
'claude-code': 'claude',
'claude cli': 'claude',
'new api': 'new-api',
newapi: 'new-api',
'one api': 'one-api',
oneapi: 'one-api',
'one hub': 'one-hub',
onehub: 'one-hub',
'done hub': 'done-hub',
donehub: 'done-hub',
};
const PLATFORM_URL_HINTS: Array<{ hostPattern: string; platform: string }> = [
{ hostPattern: 'api.openai.com', platform: 'openai' },
{ hostPattern: 'chatgpt.com', platform: 'codex' },
{ hostPattern: 'api.anthropic.com', platform: 'claude' },
{ hostPattern: 'generativelanguage.googleapis.com', platform: 'gemini' },
{ hostPattern: 'cloudcode-pa.googleapis.com', platform: 'gemini-cli' },
{ hostPattern: '127.0.0.1:8317', platform: 'cliproxyapi' },
];
export function normalizePlatformAlias(platform: unknown): string {
if (!platform || typeof platform !== 'string') return '';
const raw = platform.trim();
if (!raw) return '';
const lower = raw.toLowerCase();
// Check PLATFORM_ALIASES first
if (Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, lower)) {
return PLATFORM_ALIASES[lower];
}
// Generic normalization: replace underscores and spaces with hyphens
const hyphenated = lower.replace(/[_ ]+/g, '-');
// Check aliases again with hyphenated form
if (Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, hyphenated)) {
return PLATFORM_ALIASES[hyphenated];
}
// Remove hyphens between word characters (e.g. anti-gravity -> antigravity)
return hyphenated.replace(/-/g, '');
}
export function detectPlatformByUrlHint(url: string): string | undefined {
const lowerUrl = url.toLowerCase();
for (const hint of PLATFORM_URL_HINTS) {
if (lowerUrl.includes(hint.hostPattern)) {
// Make sure the host appears as a proper hostname segment (prevent
// forged queries like ?next=https://api.openai.com from matching).
const hostIndex = lowerUrl.indexOf(hint.hostPattern);
if (hostIndex >= 0) {
// Check that what precedes the host pattern is either the start
// of the URL or a valid URL separator (:// or /).
const preceding = lowerUrl[hostIndex - 1];
if (hostIndex === 0 || preceding === '/' || preceding === ':') {
return hint.platform;
}
}
}
}
return undefined;
}
+10
View File
@@ -0,0 +1,10 @@
export type ProxyLogUsageSource = 'upstream' | 'self-log' | 'unknown' | null;
export type ParsedProxyLogMetadata = {
clientKind: string | null;
sessionId: string | null;
downstreamPath: string | null;
upstreamPath: string | null;
usageSource: ProxyLogUsageSource;
messageText: string;
};
export declare function parseProxyLogMetadata(rawMessage: string): ParsedProxyLogMetadata;
+42
View File
@@ -0,0 +1,42 @@
const METADATA_PREFIX_REGEX = /^\[(\w+):([^\]]*)\]\s*/i;
export function parseProxyLogMetadata(rawMessage) {
let remaining = rawMessage;
let clientKind = null;
let sessionId = null;
let downstreamPath = null;
let upstreamPath = null;
let usageSource = null;
while (true) {
const match = remaining.match(METADATA_PREFIX_REGEX);
if (!match)
break;
const key = match[1].toLowerCase();
const value = match[2] || null;
switch (key) {
case 'client':
clientKind = value;
break;
case 'session':
sessionId = value;
break;
case 'downstream':
downstreamPath = value;
break;
case 'upstream':
upstreamPath = value;
break;
case 'usage':
usageSource = value;
break;
}
remaining = remaining.slice(match[0].length);
}
return {
clientKind,
sessionId,
downstreamPath,
upstreamPath,
usageSource,
messageText: remaining,
};
}
+59
View File
@@ -0,0 +1,59 @@
export type ProxyLogUsageSource = 'upstream' | 'self-log' | 'unknown' | null;
export type ParsedProxyLogMetadata = {
clientKind: string | null;
sessionId: string | null;
downstreamPath: string | null;
upstreamPath: string | null;
usageSource: ProxyLogUsageSource;
messageText: string;
};
const METADATA_PREFIX_REGEX = /^\[(\w+):([^\]]*)\]\s*/i;
export function parseProxyLogMetadata(rawMessage: string): ParsedProxyLogMetadata {
let remaining = rawMessage;
let clientKind: string | null = null;
let sessionId: string | null = null;
let downstreamPath: string | null = null;
let upstreamPath: string | null = null;
let usageSource: ProxyLogUsageSource = null;
while (true) {
const match = remaining.match(METADATA_PREFIX_REGEX);
if (!match) break;
const key = match[1].toLowerCase();
const value = match[2] || null;
switch (key) {
case 'client':
clientKind = value;
break;
case 'session':
sessionId = value;
break;
case 'downstream':
downstreamPath = value;
break;
case 'upstream':
upstreamPath = value;
break;
case 'usage':
usageSource = value as ProxyLogUsageSource;
break;
}
remaining = remaining.slice(match[0].length);
}
return {
clientKind,
sessionId,
downstreamPath,
upstreamPath,
usageSource,
messageText: remaining,
};
}
+14
View File
@@ -0,0 +1,14 @@
export interface SiteInitializationPreset {
id: string;
platform: 'openai' | 'claude';
defaultUrl: string;
initialSegment: string;
recommendedSkipModelFetch: boolean;
recommendedModels: string[];
}
export declare function getSiteInitializationPreset(id: string): SiteInitializationPreset | undefined;
export declare function listSiteInitializationPresets(): SiteInitializationPreset[];
export declare function detectSiteInitializationPreset(url: string, knownPlatform?: string): {
id: string;
platform: 'openai' | 'claude';
} | null;
+156
View File
@@ -0,0 +1,156 @@
const presets = [
{
id: 'codingplan-openai',
platform: 'openai',
defaultUrl: 'https://coding.dashscope.aliyuncs.com/v1',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['qwen3-coder-plus', 'qwen3.5-plus'],
},
{
id: 'codingplan-claude',
platform: 'claude',
defaultUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['qwen3-coder-next', 'glm-5'],
},
{
id: 'zhipu-coding-plan-openai',
platform: 'openai',
defaultUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air'],
},
{
id: 'zhipu-coding-plan-claude',
platform: 'claude',
defaultUrl: 'https://open.bigmodel.cn/api/anthropic',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air'],
},
{
id: 'deepseek-openai',
platform: 'openai',
defaultUrl: 'https://api.deepseek.com/v1',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['deepseek-chat', 'deepseek-reasoner'],
},
{
id: 'deepseek-claude',
platform: 'claude',
defaultUrl: 'https://api.deepseek.com/anthropic',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: [],
},
{
id: 'moonshot-openai',
platform: 'openai',
defaultUrl: 'https://api.moonshot.cn/v1',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['kimi-k2.5', 'kimi-k2', 'kimi-k2-thinking'],
},
{
id: 'moonshot-claude',
platform: 'claude',
defaultUrl: 'https://api.moonshot.cn/anthropic',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: [],
},
{
id: 'minimax-openai',
platform: 'openai',
defaultUrl: 'https://api.minimaxi.com/v1',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: [],
},
{
id: 'minimax-claude',
platform: 'claude',
defaultUrl: 'https://api.minimaxi.com/anthropic',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1'],
},
{
id: 'modelscope-openai',
platform: 'openai',
defaultUrl: 'https://api-inference.modelscope.cn/v1',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: [
'Qwen/Qwen3-32B',
'Qwen/Qwen2.5-Coder-32B-Instruct',
'deepseek-ai/DeepSeek-V3.2',
],
},
{
id: 'modelscope-claude',
platform: 'claude',
defaultUrl: 'https://api-inference.modelscope.cn',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: [],
},
{
id: 'doubao-coding-openai',
platform: 'openai',
defaultUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['ark-code-latest', 'doubao-seed-2.0-code', 'doubao-seed-2.0-pro'],
},
];
const urlMatchers = [
{ urlPrefix: 'https://coding.dashscope.aliyuncs.com/v1', id: 'codingplan-openai', platform: 'openai' },
{ urlPrefix: 'https://coding.dashscope.aliyuncs.com/apps/anthropic', id: 'codingplan-claude', platform: 'claude' },
{ urlPrefix: 'https://open.bigmodel.cn/api/coding/paas/v4', id: 'zhipu-coding-plan-openai', platform: 'openai', stripTrailingSlash: true },
{ urlPrefix: 'https://api.deepseek.com/v1', id: 'deepseek-openai', platform: 'openai' },
{ urlPrefix: 'https://api.deepseek.com/anthropic', id: 'deepseek-claude', platform: 'claude' },
{ urlPrefix: 'https://api.moonshot.cn/v1', id: 'moonshot-openai', platform: 'openai', stripTrailingSlash: true },
{ urlPrefix: 'https://api.moonshot.cn/anthropic', id: 'moonshot-claude', platform: 'claude' },
{ urlPrefix: 'https://api.minimaxi.com/v1', id: 'minimax-openai', platform: 'openai' },
{ urlPrefix: 'https://api.minimaxi.com/anthropic', id: 'minimax-claude', platform: 'claude' },
{ urlPrefix: 'https://api-inference.modelscope.cn/v1', id: 'modelscope-openai', platform: 'openai' },
{ urlPrefix: 'https://api-inference.modelscope.cn', id: 'modelscope-claude', platform: 'claude' },
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/coding/v3', id: 'doubao-coding-openai', platform: 'openai' },
];
// Canonical root → preset mapping for reselection when platform is known
const rootReselect = [
{ rootPrefix: 'https://api.deepseek.com', id: 'deepseek-openai' },
{ rootPrefix: 'https://coding.dashscope.aliyuncs.com', id: 'codingplan-openai' },
];
export function getSiteInitializationPreset(id) {
return presets.find((p) => p.id === id);
}
export function listSiteInitializationPresets() {
return [...presets];
}
export function detectSiteInitializationPreset(url, knownPlatform) {
// Try exact URL prefix matching
const normalizedUrl = url.replace(/\/+$/, '');
for (const matcher of urlMatchers) {
const matchUrl = matcher.stripTrailingSlash ? normalizedUrl : url;
if (matchUrl.startsWith(matcher.urlPrefix)) {
return { id: matcher.id, platform: matcher.platform };
}
}
// Reselect from canonical root when platform is known
if (knownPlatform === 'openai') {
for (const entry of rootReselect) {
if (url.startsWith(entry.rootPrefix)) {
const preset = getSiteInitializationPreset(entry.id);
if (preset)
return { id: preset.id, platform: preset.platform };
}
}
}
return null;
}
+179
View File
@@ -0,0 +1,179 @@
export interface SiteInitializationPreset {
id: string;
platform: 'openai' | 'claude';
defaultUrl: string;
initialSegment: string;
recommendedSkipModelFetch: boolean;
recommendedModels: string[];
}
const presets: SiteInitializationPreset[] = [
{
id: 'codingplan-openai',
platform: 'openai',
defaultUrl: 'https://coding.dashscope.aliyuncs.com/v1',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['qwen3-coder-plus', 'qwen3.5-plus'],
},
{
id: 'codingplan-claude',
platform: 'claude',
defaultUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['qwen3-coder-next', 'glm-5'],
},
{
id: 'zhipu-coding-plan-openai',
platform: 'openai',
defaultUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air'],
},
{
id: 'zhipu-coding-plan-claude',
platform: 'claude',
defaultUrl: 'https://open.bigmodel.cn/api/anthropic',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air'],
},
{
id: 'deepseek-openai',
platform: 'openai',
defaultUrl: 'https://api.deepseek.com/v1',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['deepseek-chat', 'deepseek-reasoner'],
},
{
id: 'deepseek-claude',
platform: 'claude',
defaultUrl: 'https://api.deepseek.com/anthropic',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: [],
},
{
id: 'moonshot-openai',
platform: 'openai',
defaultUrl: 'https://api.moonshot.cn/v1',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['kimi-k2.5', 'kimi-k2', 'kimi-k2-thinking'],
},
{
id: 'moonshot-claude',
platform: 'claude',
defaultUrl: 'https://api.moonshot.cn/anthropic',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: [],
},
{
id: 'minimax-openai',
platform: 'openai',
defaultUrl: 'https://api.minimaxi.com/v1',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: [],
},
{
id: 'minimax-claude',
platform: 'claude',
defaultUrl: 'https://api.minimaxi.com/anthropic',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1'],
},
{
id: 'modelscope-openai',
platform: 'openai',
defaultUrl: 'https://api-inference.modelscope.cn/v1',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: [
'Qwen/Qwen3-32B',
'Qwen/Qwen2.5-Coder-32B-Instruct',
'deepseek-ai/DeepSeek-V3.2',
],
},
{
id: 'modelscope-claude',
platform: 'claude',
defaultUrl: 'https://api-inference.modelscope.cn',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: [],
},
{
id: 'doubao-coding-openai',
platform: 'openai',
defaultUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ['ark-code-latest', 'doubao-seed-2.0-code', 'doubao-seed-2.0-pro'],
},
];
const urlMatchers: Array<{
urlPrefix: string;
id: string;
platform: 'openai' | 'claude';
stripTrailingSlash?: boolean;
}> = [
{ urlPrefix: 'https://coding.dashscope.aliyuncs.com/v1', id: 'codingplan-openai', platform: 'openai' },
{ urlPrefix: 'https://coding.dashscope.aliyuncs.com/apps/anthropic', id: 'codingplan-claude', platform: 'claude' },
{ urlPrefix: 'https://open.bigmodel.cn/api/coding/paas/v4', id: 'zhipu-coding-plan-openai', platform: 'openai', stripTrailingSlash: true },
{ urlPrefix: 'https://api.deepseek.com/v1', id: 'deepseek-openai', platform: 'openai' },
{ urlPrefix: 'https://api.deepseek.com/anthropic', id: 'deepseek-claude', platform: 'claude' },
{ urlPrefix: 'https://api.moonshot.cn/v1', id: 'moonshot-openai', platform: 'openai', stripTrailingSlash: true },
{ urlPrefix: 'https://api.moonshot.cn/anthropic', id: 'moonshot-claude', platform: 'claude' },
{ urlPrefix: 'https://api.minimaxi.com/v1', id: 'minimax-openai', platform: 'openai' },
{ urlPrefix: 'https://api.minimaxi.com/anthropic', id: 'minimax-claude', platform: 'claude' },
{ urlPrefix: 'https://api-inference.modelscope.cn/v1', id: 'modelscope-openai', platform: 'openai' },
{ urlPrefix: 'https://api-inference.modelscope.cn', id: 'modelscope-claude', platform: 'claude' },
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/coding/v3', id: 'doubao-coding-openai', platform: 'openai' },
];
// Canonical root → preset mapping for reselection when platform is known
const rootReselect: Array<{ rootPrefix: string; id: string }> = [
{ rootPrefix: 'https://api.deepseek.com', id: 'deepseek-openai' },
{ rootPrefix: 'https://coding.dashscope.aliyuncs.com', id: 'codingplan-openai' },
];
export function getSiteInitializationPreset(id: string): SiteInitializationPreset | undefined {
return presets.find((p) => p.id === id);
}
export function listSiteInitializationPresets(): SiteInitializationPreset[] {
return [...presets];
}
export function detectSiteInitializationPreset(
url: string,
knownPlatform?: string,
): { id: string; platform: 'openai' | 'claude' } | null {
// Try exact URL prefix matching
const normalizedUrl = url.replace(/\/+$/, '');
for (const matcher of urlMatchers) {
const matchUrl = matcher.stripTrailingSlash ? normalizedUrl : url;
if (matchUrl.startsWith(matcher.urlPrefix)) {
return { id: matcher.id, platform: matcher.platform };
}
}
// Reselect from canonical root when platform is known
if (knownPlatform === 'openai') {
for (const entry of rootReselect) {
if (url.startsWith(entry.rootPrefix)) {
const preset = getSiteInitializationPreset(entry.id);
if (preset) return { id: preset.id, platform: preset.platform };
}
}
}
return null;
}
+7
View File
@@ -0,0 +1,7 @@
export interface PrimarySiteUrlAnalysis {
canonicalUrl: string;
persistedUrl: string;
action: 'unchanged' | 'auto_strip_known_api_suffix' | 'preserve_api_path' | 'preserve_semantic_path' | 'preserve_unknown_path';
matchedPath: string;
}
export declare function analyzePrimarySiteUrl(raw: string): PrimarySiteUrlAnalysis;
+106
View File
@@ -0,0 +1,106 @@
/**
* Known API request path prefixes that should be auto-stripped to the host root
* for persistence purposes. These are well-known endpoint paths that users paste
* from upstream API documentation but should be stored as the base URL only.
*/
const knownSuffixPrefixes = ['/v1/', '/v2/', '/api/'];
/**
* Known semantic paths that should be preserved without warnings.
* These are deliberate application routes rather than API endpoint paths.
*/
const semanticPathPrefixes = ['/backend-api/', '/apps/', '/coding/'];
function isApiPathPrefix(pathname) {
return pathname.startsWith('/api/') || pathname.startsWith('/v1/') || pathname.startsWith('/v2/');
}
function isSemanticPath(pathname) {
return semanticPathPrefixes.some((prefix) => pathname.startsWith(prefix));
}
function hasKnownSuffixPrefix(pathname) {
return knownSuffixPrefixes.some((prefix) => pathname.startsWith(prefix) || pathname.includes(prefix));
}
function normalizeUrl(raw) {
if (!raw || typeof raw !== 'string')
return '';
let url = raw.trim();
// Add scheme if missing
if (!/^https?:\/\//i.test(url)) {
url = 'https://' + url;
}
try {
const parsed = new URL(url);
let normalized = parsed.protocol + '//' + parsed.host + parsed.pathname;
// Remove trailing slash (except for root)
if (normalized.endsWith('/') && normalized !== parsed.protocol + '//' + parsed.host + '/') {
normalized = normalized.slice(0, -1);
}
return normalized;
}
catch {
// If URL parsing fails, return trimmed raw version
return raw;
}
}
export function analyzePrimarySiteUrl(raw) {
if (!raw || typeof raw !== 'string' || raw.trim() === '') {
return { canonicalUrl: '', persistedUrl: '', action: 'unchanged', matchedPath: '' };
}
const canonicalUrl = normalizeUrl(raw);
// Try to parse the URL
let url = raw.trim();
if (!/^https?:\/\//i.test(url)) {
url = 'https://' + url;
}
let parsed;
try {
parsed = new URL(url);
}
catch {
return { canonicalUrl, persistedUrl: canonicalUrl, action: 'unchanged', matchedPath: '' };
}
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
// Root URL - no path beyond /
if (pathname === '/') {
return {
canonicalUrl,
persistedUrl: canonicalUrl,
action: 'unchanged',
matchedPath: '/',
};
}
// Known semantic paths (e.g., /backend-api/codex) - preserve as-is
if (isSemanticPath(pathname)) {
return {
canonicalUrl,
persistedUrl: canonicalUrl,
action: 'preserve_semantic_path',
matchedPath: pathname,
};
}
// API-prefixed hostnames (e.g., api.example.com/api/v1/models) - preserve as warning
const hostname = parsed.hostname;
if ((hostname.startsWith('api.') || hostname.startsWith('gateway.')) && isApiPathPrefix(pathname)) {
return {
canonicalUrl,
persistedUrl: canonicalUrl,
action: 'preserve_api_path',
matchedPath: pathname,
};
}
// Known API request suffixes - auto-strip to root
if (hasKnownSuffixPrefix(pathname)) {
const rootUrl = parsed.protocol + '//' + parsed.host;
return {
canonicalUrl,
persistedUrl: rootUrl,
action: 'auto_strip_known_api_suffix',
matchedPath: pathname,
};
}
// Unknown non-root path - preserve as warning
return {
canonicalUrl,
persistedUrl: canonicalUrl,
action: 'preserve_unknown_path',
matchedPath: pathname,
};
}
+127
View File
@@ -0,0 +1,127 @@
export interface PrimarySiteUrlAnalysis {
canonicalUrl: string;
persistedUrl: string;
action: 'unchanged' | 'auto_strip_known_api_suffix' | 'preserve_api_path' | 'preserve_semantic_path' | 'preserve_unknown_path';
matchedPath: string;
}
/**
* Known API request path prefixes that should be auto-stripped to the host root
* for persistence purposes. These are well-known endpoint paths that users paste
* from upstream API documentation but should be stored as the base URL only.
*/
const knownSuffixPrefixes = ['/v1/', '/v2/', '/api/'];
/**
* Known semantic paths that should be preserved without warnings.
* These are deliberate application routes rather than API endpoint paths.
*/
const semanticPathPrefixes = ['/backend-api/', '/apps/', '/coding/'];
function isApiPathPrefix(pathname: string): boolean {
return pathname.startsWith('/api/') || pathname.startsWith('/v1/') || pathname.startsWith('/v2/');
}
function isSemanticPath(pathname: string): boolean {
return semanticPathPrefixes.some((prefix) => pathname.startsWith(prefix));
}
function hasKnownSuffixPrefix(pathname: string): boolean {
return knownSuffixPrefixes.some((prefix) => pathname.startsWith(prefix) || pathname.includes(prefix));
}
function normalizeUrl(raw: string): string {
if (!raw || typeof raw !== 'string') return '';
let url = raw.trim();
// Add scheme if missing
if (!/^https?:\/\//i.test(url)) {
url = 'https://' + url;
}
try {
const parsed = new URL(url);
let normalized = parsed.protocol + '//' + parsed.host + parsed.pathname;
// Remove trailing slash (except for root)
if (normalized.endsWith('/') && normalized !== parsed.protocol + '//' + parsed.host + '/') {
normalized = normalized.slice(0, -1);
}
return normalized;
} catch {
// If URL parsing fails, return trimmed raw version
return raw;
}
}
export function analyzePrimarySiteUrl(raw: string): PrimarySiteUrlAnalysis {
if (!raw || typeof raw !== 'string' || raw.trim() === '') {
return { canonicalUrl: '', persistedUrl: '', action: 'unchanged', matchedPath: '' };
}
const canonicalUrl = normalizeUrl(raw);
// Try to parse the URL
let url = raw.trim();
if (!/^https?:\/\//i.test(url)) {
url = 'https://' + url;
}
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return { canonicalUrl, persistedUrl: canonicalUrl, action: 'unchanged', matchedPath: '' };
}
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
// Root URL - no path beyond /
if (pathname === '/') {
return {
canonicalUrl,
persistedUrl: canonicalUrl,
action: 'unchanged',
matchedPath: '/',
};
}
// Known semantic paths (e.g., /backend-api/codex) - preserve as-is
if (isSemanticPath(pathname)) {
return {
canonicalUrl,
persistedUrl: canonicalUrl,
action: 'preserve_semantic_path',
matchedPath: pathname,
};
}
// API-prefixed hostnames (e.g., api.example.com/api/v1/models) - preserve as warning
const hostname = parsed.hostname;
if ((hostname.startsWith('api.') || hostname.startsWith('gateway.')) && isApiPathPrefix(pathname)) {
return {
canonicalUrl,
persistedUrl: canonicalUrl,
action: 'preserve_api_path',
matchedPath: pathname,
};
}
// Known API request suffixes - auto-strip to root
if (hasKnownSuffixPrefix(pathname)) {
const rootUrl = parsed.protocol + '//' + parsed.host;
return {
canonicalUrl,
persistedUrl: rootUrl,
action: 'auto_strip_known_api_suffix',
matchedPath: pathname,
};
}
// Unknown non-root path - preserve as warning
return {
canonicalUrl,
persistedUrl: canonicalUrl,
action: 'preserve_unknown_path',
matchedPath: pathname,
};
}
+6
View File
@@ -0,0 +1,6 @@
export const ROUTE_DECISION_REFRESH_TASK_TYPE = 'route-decision.refresh';
export function normalizeTokenRouteMode(routeMode) {
if (routeMode === 'explicit_group')
return 'explicit_group';
return 'pattern';
}
+33
View File
@@ -0,0 +1,33 @@
export const ROUTE_DECISION_REFRESH_TASK_TYPE = 'route-decision.refresh';
export type RouteMode = 'pattern' | 'explicit_group';
export type RouteDecisionCandidate = {
channelId: number;
accountId: number;
username: string;
siteName: string;
tokenName: string;
priority: number;
weight: number;
eligible: boolean;
recentlyFailed: boolean;
avoidedByRecentFailure: boolean;
probability: number;
reason: string;
};
export type RouteDecision = {
requestedModel: string;
actualModel: string;
matched: boolean;
selectedChannelId?: number;
selectedLabel?: string;
summary: string[];
candidates: RouteDecisionCandidate[];
};
export function normalizeTokenRouteMode(routeMode: unknown): RouteMode {
if (routeMode === 'explicit_group') return 'explicit_group';
return 'pattern';
}
+59
View File
@@ -0,0 +1,59 @@
const REGEX_PREFIX = /^re:/i;
export function isTokenRouteRegexPattern(pattern) {
return REGEX_PREFIX.test(pattern.trim());
}
export function isExactTokenRouteModelPattern(pattern) {
const trimmed = pattern.trim();
if (!trimmed)
return false;
if (REGEX_PREFIX.test(trimmed))
return false;
return !/[?*]/.test(trimmed);
}
export function parseTokenRouteRegexPattern(pattern) {
const match = REGEX_PREFIX.exec(pattern.trim());
if (!match) {
return { regex: null, error: 'Pattern does not start with re:' };
}
const regexBody = pattern.slice(match[0].length);
if (!regexBody) {
return { regex: null, error: 'Empty regex pattern' };
}
// Safety validation: reject lookahead/lookbehind/non-capturing groups
if (/\(\?/.test(regexBody)) {
return { regex: null, error: 'Unsupported regex construct (?…)' };
}
// Reject non-digit, non-dot backslash shorthands (e.g. \s, \w, \b)
if (/\\[a-ce-z]/i.test(regexBody)) {
return { regex: null, error: 'Unsupported regex escape sequence' };
}
try {
const regex = new RegExp(regexBody);
return { regex: { test: (value) => regex.test(value) }, error: null };
}
catch {
return { regex: null, error: 'Invalid regex pattern' };
}
}
export function matchesTokenRouteModelPattern(model, pattern) {
const trimmedModel = model.trim();
const trimmedPattern = pattern.trim();
if (!trimmedModel || !trimmedPattern)
return false;
// Regex pattern
if (REGEX_PREFIX.test(trimmedPattern)) {
const parsed = parseTokenRouteRegexPattern(trimmedPattern);
if (!parsed.regex)
return false;
return parsed.regex.test(trimmedModel);
}
// Glob pattern (contains * or ?)
if (trimmedPattern.includes('*') || trimmedPattern.includes('?')) {
// Escape all regex special characters first, then convert glob patterns
const escaped = trimmedPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
const globRegex = new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
return globRegex.test(trimmedModel);
}
// Exact match (bracket prefix is literal, e.g. [NV]model-name)
return trimmedModel === trimmedPattern;
}
+74
View File
@@ -0,0 +1,74 @@
export type TokenRoutePatternMatcher = {
test(value: string): boolean;
};
const REGEX_PREFIX = /^re:/i;
export function isTokenRouteRegexPattern(pattern: string): boolean {
return REGEX_PREFIX.test(pattern.trim());
}
export function isExactTokenRouteModelPattern(pattern: string): boolean {
const trimmed = pattern.trim();
if (!trimmed) return false;
if (REGEX_PREFIX.test(trimmed)) return false;
return !/[?*]/.test(trimmed);
}
export function parseTokenRouteRegexPattern(pattern: string): {
regex: TokenRoutePatternMatcher | null;
error: string | null;
} {
const match = REGEX_PREFIX.exec(pattern.trim());
if (!match) {
return { regex: null, error: 'Pattern does not start with re:' };
}
const regexBody = pattern.slice(match[0].length);
if (!regexBody) {
return { regex: null, error: 'Empty regex pattern' };
}
// Safety validation: reject lookahead/lookbehind/non-capturing groups
if (/\(\?/.test(regexBody)) {
return { regex: null, error: 'Unsupported regex construct (?…)' };
}
// Reject non-digit, non-dot backslash shorthands (e.g. \s, \w, \b)
if (/\\[a-ce-z]/i.test(regexBody)) {
return { regex: null, error: 'Unsupported regex escape sequence' };
}
try {
const regex = new RegExp(regexBody);
return { regex: { test: (value: string) => regex.test(value) }, error: null };
} catch {
return { regex: null, error: 'Invalid regex pattern' };
}
}
export function matchesTokenRouteModelPattern(model: string, pattern: string): boolean {
const trimmedModel = model.trim();
const trimmedPattern = pattern.trim();
if (!trimmedModel || !trimmedPattern) return false;
// Regex pattern
if (REGEX_PREFIX.test(trimmedPattern)) {
const parsed = parseTokenRouteRegexPattern(trimmedPattern);
if (!parsed.regex) return false;
return parsed.regex.test(trimmedModel);
}
// Glob pattern (contains * or ?)
if (trimmedPattern.includes('*') || trimmedPattern.includes('?')) {
// Escape all regex special characters first, then convert glob patterns
const escaped = trimmedPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
const globRegex = new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
return globRegex.test(trimmedModel);
}
// Exact match (bracket prefix is literal, e.g. [NV]model-name)
return trimmedModel === trimmedPattern;
}
+7
View File
@@ -0,0 +1,7 @@
export {
buildUpdateReminderCandidateKey,
compareStableVersions,
isSameImageTarget,
normalizeStableVersion,
resolveUpdateReminderCandidate,
} from '../server/shared/updateCenterReminder.js';