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
+19 -2
View File
@@ -21,6 +21,7 @@ pub enum DownstreamAuthResult {
pub async fn authorize_downstream_token(
token: &str,
pool: &SqlitePool,
global_proxy_token: Option<&str>,
) -> Result<DownstreamAuthResult, String> {
// Try managed keys first
if token.starts_with("sk-") {
@@ -36,11 +37,27 @@ pub async fn authorize_downstream_token(
return Ok(DownstreamAuthResult::Managed { key: k });
}
// Key not found in table
if global_proxy_token
.map(|global| !global.is_empty() && token == global)
.unwrap_or(false)
{
return Ok(DownstreamAuthResult::Global {
token: token.to_string(),
});
}
return Err("Invalid API key".to_string());
}
// Not an sk- key, can't be a downstream key
if global_proxy_token
.map(|global| !global.is_empty() && token == global)
.unwrap_or(false)
{
return Ok(DownstreamAuthResult::Global {
token: token.to_string(),
});
}
Err("Invalid API key format".to_string())
}
+7
View File
@@ -83,6 +83,8 @@ pub fn routes() -> Router<AppState> {
.route("/api/comfyui/queue", get(proxy_get_queue))
.route("/api/comfyui/view", get(proxy_get_view))
// Static proxy (public — assets/JS/CSS)
.route("/api/comfyui/proxy", get(proxy_static_root))
.route("/api/comfyui/proxy/", get(proxy_static_root))
.route("/api/comfyui/proxy/{*path}", get(proxy_static))
// HTML page (public)
.route("/comfyui", get(comfyui_page))
@@ -146,6 +148,11 @@ async fn proxy_get_view(
// ── Static proxy (public — assets/JS/CSS for iframe) ─────────────────
/// GET /api/comfyui/proxy (root) — redirect to proxied root
async fn proxy_static_root() -> AppResult<Response> {
proxy_static(Path("/".to_string())).await
}
/// GET /api/comfyui/proxy/{path} — proxy everything (HTML, assets)
async fn proxy_static(Path(path): Path<String>) -> AppResult<Response> {
info!("GET /api/comfyui/proxy/{}", path);
@@ -0,0 +1,22 @@
export function parseBatchApiKeys(input) {
if (Array.isArray(input)) {
return Array.from(new Set(input
.map((item) => String(item || '').trim())
.filter((item) => item.length > 0)));
}
const raw = String(input || '').trim();
if (!raw)
return [];
return Array.from(new Set(raw
.split(/[\r\n,,;;\s]+/)
.map((item) => item.trim())
.filter((item) => item.length > 0)));
}
export function buildBatchApiKeyConnectionName(baseName, index, total) {
const normalized = String(baseName || '').trim();
if (!normalized)
return '';
if (total <= 1)
return normalized;
return `${normalized} #${index + 1}`;
}
@@ -0,0 +1,26 @@
export function parseBatchApiKeys(input: unknown): string[] {
if (Array.isArray(input)) {
return Array.from(new Set(
input
.map((item) => String(item || '').trim())
.filter((item) => item.length > 0),
));
}
const raw = String(input || '').trim();
if (!raw) return [];
return Array.from(new Set(
raw
.split(/[\r\n,,;;\s]+/)
.map((item) => item.trim())
.filter((item) => item.length > 0),
));
}
export function buildBatchApiKeyConnectionName(baseName: string | null | undefined, index: number, total: number): string {
const normalized = String(baseName || '').trim();
if (!normalized) return '';
if (total <= 1) return normalized;
return `${normalized} #${index + 1}`;
}
+779
View File
@@ -0,0 +1,779 @@
const BRAND_DEFINITIONS = [
{
name: 'OpenAI',
icon: 'openai',
color: 'linear-gradient(135deg, #10a37f, #1a7f5a)',
rules: [
{ keyword: 'gpt', mode: 'startsWith' },
{ keyword: 'chatgpt', mode: 'startsWith' },
{ keyword: 'dall-e', mode: 'startsWith' },
{ keyword: 'whisper', mode: 'startsWith' },
{ keyword: 'text-embedding', mode: 'startsWith' },
{ keyword: 'text-moderation', mode: 'startsWith' },
{ keyword: 'davinci', mode: 'startsWith' },
{ keyword: 'babbage', mode: 'startsWith' },
{ keyword: 'codex-mini', mode: 'startsWith' },
{ keyword: 'o1', mode: 'startsWith' },
{ keyword: 'o3', mode: 'startsWith' },
{ keyword: 'o4', mode: 'startsWith' },
{ keyword: 'tts', mode: 'startsWith' },
],
},
{
name: 'Anthropic',
icon: 'claude-color',
color: 'linear-gradient(135deg, #d4a574, #c4956a)',
rules: [
{ keyword: 'claude', mode: 'includes' },
],
},
{
name: 'Google',
icon: 'gemini-color',
color: 'linear-gradient(135deg, #4285f4, #34a853)',
rules: [
{ keyword: 'gemini', mode: 'includes' },
{ keyword: 'gemma', mode: 'includes' },
{ keyword: 'google/', mode: 'includes' },
{ keyword: 'palm', mode: 'includes' },
{ keyword: 'paligemma', mode: 'includes' },
{ keyword: 'shieldgemma', mode: 'includes' },
{ keyword: 'recurrentgemma', mode: 'includes' },
{ keyword: 'deplot', mode: 'includes' },
{ keyword: 'codegemma', mode: 'includes' },
{ keyword: 'imagen', mode: 'includes' },
{ keyword: 'learnlm', mode: 'includes' },
{ keyword: 'aqa', mode: 'includes' },
{ keyword: 'veo', mode: 'startsWith' },
{ keyword: 'google/', mode: 'startsWith' },
],
},
{
name: 'DeepSeek',
icon: 'deepseek-color',
color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)',
rules: [
{ keyword: 'deepseek', mode: 'includes' },
{ keyword: 'ds-chat', mode: 'segment' },
],
},
{
name: '通义千问',
icon: 'qwen-color',
color: 'linear-gradient(135deg, #615cf7, #9b8afb)',
rules: [
{ keyword: 'qwen', mode: 'includes' },
{ keyword: 'qwq', mode: 'includes' },
{ keyword: 'tongyi', mode: 'includes' },
],
},
{
name: '智谱 AI',
icon: 'zhipu-color',
color: 'linear-gradient(135deg, #3b6cf5, #6366f1)',
rules: [
{ keyword: 'glm', mode: 'includes' },
{ keyword: 'chatglm', mode: 'includes' },
{ keyword: 'codegeex', mode: 'includes' },
{ keyword: 'cogview', mode: 'includes' },
{ keyword: 'cogvideo', mode: 'includes' },
],
},
{
name: 'Meta',
icon: 'meta-color',
color: 'linear-gradient(135deg, #0668E1, #1877f2)',
rules: [
{ keyword: 'llama', mode: 'includes' },
{ keyword: 'code-llama', mode: 'includes' },
{ keyword: 'codellama', mode: 'includes' },
],
},
{
name: 'Mistral',
icon: 'mistral-color',
color: 'linear-gradient(135deg, #f7d046, #f2a900)',
rules: [
{ keyword: 'mistral', mode: 'includes' },
{ keyword: 'mixtral', mode: 'includes' },
{ keyword: 'codestral', mode: 'includes' },
{ keyword: 'pixtral', mode: 'includes' },
{ keyword: 'ministral', mode: 'includes' },
{ keyword: 'voxtral', mode: 'includes' },
{ keyword: 'magistral', mode: 'includes' },
],
},
{
name: 'Moonshot',
icon: 'moonshot',
color: 'linear-gradient(135deg, #000000, #333333)',
rules: [
{ keyword: 'moonshot', mode: 'includes' },
{ keyword: 'kimi', mode: 'includes' },
],
},
{
name: '零一万物',
icon: 'yi-color',
color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)',
rules: [
{ keyword: 'yi-', mode: 'startsWith' },
{ keyword: 'yi', mode: 'boundary' },
],
},
{
name: '文心一言',
icon: 'wenxin-color',
color: 'linear-gradient(135deg, #2932e1, #4468f2)',
rules: [
{ keyword: 'ernie', mode: 'includes' },
{ keyword: 'eb-', mode: 'includes' },
],
},
{
name: '讯飞星火',
icon: 'spark-color',
color: 'linear-gradient(135deg, #0070f3, #00d4ff)',
rules: [
{ keyword: 'spark', mode: 'includes' },
{ keyword: 'generalv', mode: 'includes' },
],
},
{
name: '腾讯混元',
icon: 'hunyuan-color',
color: 'linear-gradient(135deg, #00b7ff, #0052d9)',
rules: [
{ keyword: 'hunyuan', mode: 'includes' },
{ keyword: 'tencent-hunyuan', mode: 'includes' },
],
},
{
name: '豆包',
icon: 'doubao-color',
color: 'linear-gradient(135deg, #3b5bdb, #7048e8)',
rules: [
{ keyword: 'doubao', mode: 'includes' },
],
},
{
name: 'MiniMax',
icon: 'minimax-color',
color: 'linear-gradient(135deg, #6366f1, #818cf8)',
rules: [
{ keyword: 'minimax', mode: 'includes' },
{ keyword: 'abab', mode: 'includes' },
{ keyword: 'mini2.1', mode: 'segment' },
],
},
{
name: 'Cohere',
icon: 'cohere-color',
color: 'linear-gradient(135deg, #39594d, #5ba77f)',
rules: [
{ keyword: 'command', mode: 'includes' },
{ keyword: 'c4ai-', mode: 'includes' },
{ keyword: 'aya', mode: 'includes' },
{ keyword: 'embed-', mode: 'startsWith' },
],
},
{
name: 'Microsoft',
icon: 'microsoft-color',
color: 'linear-gradient(135deg, #00bcf2, #0078d4)',
rules: [
{ keyword: 'microsoft/', mode: 'includes' },
{ keyword: 'phi-', mode: 'includes' },
{ keyword: 'kosmos', mode: 'includes' },
{ keyword: 'phi4', mode: 'segment' },
],
},
{
name: 'xAI',
icon: 'xai',
color: 'linear-gradient(135deg, #111, #444)',
rules: [
{ keyword: 'grok', mode: 'includes' },
],
},
{
name: '阶跃星辰',
icon: 'stepfun-color',
color: 'linear-gradient(135deg, #0066ff, #3399ff)',
rules: [
{ keyword: 'stepfun', mode: 'includes' },
{ keyword: 'step-', mode: 'startsWith' },
{ keyword: 'step3', mode: 'startsWith' },
],
},
{
name: '百川智能',
icon: 'baichuan-color',
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
rules: [
{ keyword: 'baichuan', mode: 'includes' },
],
},
{
name: 'AI21 Labs',
icon: 'ai21-brand-color',
color: 'linear-gradient(135deg, #7c3aed, #a855f7)',
rules: [
{ keyword: 'ai21', mode: 'includes' },
{ keyword: 'jamba', mode: 'startsWith' },
{ keyword: 'jamba', mode: 'includes' },
],
},
{
name: 'AI2',
icon: 'ai2-color',
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
rules: [
{ keyword: 'allenai', mode: 'includes' },
{ keyword: 'olmo', mode: 'includes' },
],
},
{
name: 'Amazon Nova',
icon: 'nova',
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
rules: [
{ keyword: 'amazon/nova', mode: 'startsWith' },
{ keyword: 'amazon.nova', mode: 'includes' },
{ keyword: 'us.amazon.nova', mode: 'includes' },
{ keyword: 'nova-', mode: 'startsWith' },
{ keyword: 'nova-lite', mode: 'startsWith' },
{ keyword: 'nova-pro', mode: 'startsWith' },
{ keyword: 'nova-micro', mode: 'startsWith' },
{ keyword: 'nova-canvas', mode: 'startsWith' },
{ keyword: 'nova-reel', mode: 'startsWith' },
],
},
{
name: 'Stability',
icon: 'stability-color',
color: 'linear-gradient(135deg, #8b5cf6, #a855f7)',
rules: [
{ keyword: 'flux', mode: 'includes' },
{ keyword: 'stablediffusion', mode: 'includes' },
{ keyword: 'stable-diffusion', mode: 'includes' },
{ keyword: 'sdxl', mode: 'includes' },
{ keyword: 'sd3', mode: 'startsWith' },
],
},
{
name: 'NVIDIA',
icon: 'nvidia-color',
color: 'linear-gradient(135deg, #76b900, #4a8c0b)',
rules: [
{ keyword: 'nvidia/', mode: 'includes' },
{ keyword: 'nvclip', mode: 'includes' },
{ keyword: 'nemotron', mode: 'includes' },
{ keyword: 'nemoretriever', mode: 'includes' },
{ keyword: 'neva', mode: 'includes' },
{ keyword: 'riva-translate', mode: 'includes' },
{ keyword: 'cosmos', mode: 'includes' },
{ keyword: 'nv-', mode: 'startsWith' },
],
},
{
name: 'IBM',
icon: 'ibm',
color: 'linear-gradient(135deg, #0f62fe, #4589ff)',
rules: [
{ keyword: 'ibm/', mode: 'includes' },
{ keyword: 'granite', mode: 'includes' },
],
},
{
name: 'BAAI',
icon: 'baai',
color: 'linear-gradient(135deg, #111827, #374151)',
rules: [
{ keyword: 'baai/', mode: 'includes' },
{ keyword: 'bge-', mode: 'includes' },
],
},
{
name: 'ByteDance',
icon: 'bytedance-color',
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
rules: [
{ keyword: 'bytedance', mode: 'includes' },
{ keyword: 'seed-oss', mode: 'includes' },
{ keyword: 'kolors', mode: 'includes' },
{ keyword: 'kwai', mode: 'includes' },
{ keyword: 'kwaipilot', mode: 'includes' },
{ keyword: 'wan-', mode: 'startsWith' },
{ keyword: 'kat-', mode: 'startsWith' },
],
},
{
name: 'InternLM',
icon: 'internlm-color',
color: 'linear-gradient(135deg, #1b3882, #4063c5)',
rules: [
{ keyword: 'internlm', mode: 'includes' },
],
},
{
name: 'Midjourney',
icon: 'midjourney',
color: 'linear-gradient(135deg, #4c6ef5, #748ffc)',
rules: [
{ keyword: 'midjourney', mode: 'includes' },
{ keyword: 'mj_', mode: 'startsWith' },
],
},
{
name: 'DeepL',
icon: 'deepl-color',
color: 'linear-gradient(135deg, #0f2b46, #21476f)',
rules: [
{ keyword: 'deepl-', mode: 'startsWith' },
{ keyword: 'deepl/', mode: 'includes' },
],
},
{
name: 'Jina AI',
icon: 'jina',
color: 'linear-gradient(135deg, #111827, #4b5563)',
rules: [
{ keyword: 'jina', mode: 'includes' },
],
},
{
name: 'Relace',
icon: 'relace',
color: 'linear-gradient(135deg, #7c3aed, #6366f1)',
rules: [
{ keyword: 'relace', mode: 'includes' },
],
},
{
name: 'Arcee',
icon: 'arcee-color',
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
rules: [
{ keyword: 'arcee-ai', mode: 'includes' },
{ keyword: 'arcee', mode: 'includes' },
],
},
{
name: 'AionLabs',
icon: 'aionlabs-color',
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
rules: [
{ keyword: 'aion-labs', mode: 'includes' },
{ keyword: 'aionlabs', mode: 'includes' },
],
},
{
name: 'DeepCogito',
icon: 'deepcogito-color',
color: 'linear-gradient(135deg, #2563eb, #7c3aed)',
rules: [
{ keyword: 'deepcogito', mode: 'includes' },
],
},
{
name: 'Essential AI',
icon: 'essentialai-color',
color: 'linear-gradient(135deg, #0f172a, #334155)',
rules: [
{ keyword: 'essentialai', mode: 'includes' },
],
},
{
name: 'Inception',
icon: 'inception',
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
rules: [
{ keyword: 'inception', mode: 'includes' },
],
},
{
name: 'Inflection',
icon: 'inflection',
color: 'linear-gradient(135deg, #1d4ed8, #2563eb)',
rules: [
{ keyword: 'inflection', mode: 'includes' },
],
},
{
name: 'Liquid AI',
icon: 'liquid',
color: 'linear-gradient(135deg, #0f172a, #475569)',
rules: [
{ keyword: 'liquid', mode: 'includes' },
{ keyword: 'lfm-', mode: 'startsWith' },
],
},
{
name: 'LongCat',
icon: 'longcat-color',
color: 'linear-gradient(135deg, #f97316, #fb7185)',
rules: [
{ keyword: 'longcat', mode: 'includes' },
],
},
{
name: 'Morph',
icon: 'morph-color',
color: 'linear-gradient(135deg, #4f46e5, #8b5cf6)',
rules: [
{ keyword: 'morph/', mode: 'includes' },
{ keyword: 'morph-', mode: 'startsWith' },
],
},
{
name: 'Nous Research',
icon: 'nousresearch',
color: 'linear-gradient(135deg, #111827, #4b5563)',
rules: [
{ keyword: 'nousresearch', mode: 'includes' },
],
},
{
name: 'Upstage',
icon: 'upstage-color',
color: 'linear-gradient(135deg, #2563eb, #14b8a6)',
rules: [
{ keyword: 'upstage', mode: 'includes' },
],
},
{
name: 'Xiaomi MiMo',
icon: 'xiaomimimo',
color: 'linear-gradient(135deg, #f97316, #fb923c)',
rules: [
{ keyword: 'xiaomi/mimo', mode: 'includes' },
{ keyword: 'xiaomimimo', mode: 'includes' },
{ keyword: 'mimo-v', mode: 'startsWith' },
],
},
{
name: 'Z.ai',
icon: 'zai',
color: 'linear-gradient(135deg, #0f172a, #2563eb)',
rules: [
{ keyword: '2zai', mode: 'startsWith' },
{ keyword: 'z-ai', mode: 'startsWith' },
],
},
{
name: 'SenseNova',
icon: 'sensenova-brand-color',
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
rules: [
{ keyword: 'sensenova', mode: 'includes' },
],
},
{
name: 'Perplexity',
icon: 'perplexity-color',
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
rules: [
{ keyword: 'perplexity', mode: 'includes' },
{ keyword: 'pplx-', mode: 'startsWith' },
],
},
{
name: 'OpenRouter',
icon: 'openrouter',
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
rules: [
{ keyword: 'openrouter', mode: 'includes' },
{ keyword: 'openrouter-', mode: 'startsWith' },
],
},
{
name: 'Groq',
icon: 'groq',
color: 'linear-gradient(135deg, #111827, #374151)',
rules: [
{ keyword: 'groq', mode: 'includes' },
],
},
{
name: 'Fireworks',
icon: 'fireworks-color',
color: 'linear-gradient(135deg, #fb7185, #f97316)',
rules: [
{ keyword: 'fireworks-ai', mode: 'includes' },
{ keyword: 'fireworks', mode: 'includes' },
],
},
{
name: 'DeepInfra',
icon: 'deepinfra-color',
color: 'linear-gradient(135deg, #4f46e5, #7c3aed)',
rules: [
{ keyword: 'deepinfra', mode: 'includes' },
],
},
{
name: 'Together AI',
icon: 'together-brand-color',
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
rules: [
{ keyword: 'together.ai', mode: 'includes' },
{ keyword: 'together', mode: 'includes' },
],
},
{
name: 'Replicate',
icon: 'replicate-brand',
color: 'linear-gradient(135deg, #111827, #6366f1)',
rules: [
{ keyword: 'replicate', mode: 'includes' },
],
},
{
name: 'SambaNova',
icon: 'sambanova-color',
color: 'linear-gradient(135deg, #2563eb, #06b6d4)',
rules: [
{ keyword: 'sambanova', mode: 'includes' },
],
},
{
name: 'Cerebras',
icon: 'cerebras-brand-color',
color: 'linear-gradient(135deg, #0f766e, #65a30d)',
rules: [
{ keyword: 'cerebras', mode: 'includes' },
],
},
{
name: 'Ollama',
icon: 'ollama',
color: 'linear-gradient(135deg, #1f2937, #4b5563)',
rules: [
{ keyword: 'ollama', mode: 'includes' },
],
},
{
name: 'ModelScope',
icon: 'modelscope-color',
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
rules: [
{ keyword: 'modelscope', mode: 'includes' },
],
},
{
name: 'SiliconCloud',
icon: 'siliconcloud-color',
color: 'linear-gradient(135deg, #0ea5e9, #22d3ee)',
rules: [
{ keyword: 'siliconcloud', mode: 'includes' },
{ keyword: 'siliconflow', mode: 'includes' },
],
},
{
name: 'Azure AI',
icon: 'azureai-color',
color: 'linear-gradient(135deg, #0284c7, #2563eb)',
rules: [
{ keyword: 'azureai', mode: 'includes' },
{ keyword: 'azure-openai', mode: 'includes' },
{ keyword: 'azure/openai', mode: 'includes' },
],
},
{
name: 'AWS Bedrock',
icon: 'bedrock-color',
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
rules: [
{ keyword: 'bedrock', mode: 'includes' },
],
},
{
name: 'Vertex AI',
icon: 'vertexai-color',
color: 'linear-gradient(135deg, #2563eb, #6366f1)',
rules: [
{ keyword: 'vertexai', mode: 'includes' },
],
},
{
name: 'Google Cloud',
icon: 'googlecloud-brand-color',
color: 'linear-gradient(135deg, #4285f4, #34a853)',
rules: [
{ keyword: 'googlecloud', mode: 'includes' },
{ keyword: 'google-cloud', mode: 'includes' },
],
},
{
name: '百度智能云',
icon: 'baiducloud-color',
color: 'linear-gradient(135deg, #2563eb, #38bdf8)',
rules: [
{ keyword: 'baiducloud', mode: 'includes' },
{ keyword: 'qianfan', mode: 'includes' },
],
},
{
name: '百炼',
icon: 'bailian-color',
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
rules: [
{ keyword: 'bailian', mode: 'includes' },
{ keyword: 'dashscope', mode: 'includes' },
],
},
{
name: '阿里云',
icon: 'alibabacloud-color',
color: 'linear-gradient(135deg, #f97316, #fb923c)',
rules: [
{ keyword: 'alibabacloud', mode: 'includes' },
],
},
{
name: '火山引擎',
icon: 'volcengine-color',
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
rules: [
{ keyword: 'volcengine', mode: 'includes' },
],
},
{
name: '七牛云',
icon: 'qiniu-color',
color: 'linear-gradient(135deg, #06b6d4, #0891b2)',
rules: [
{ keyword: 'qiniu', mode: 'includes' },
],
},
];
function normalizeInput(value) {
return String(value || '').trim().toLowerCase();
}
export function stripCommonWrappers(value) {
return value
.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '')
.replace(/^re:\s*/g, '')
.replace(/^\^+/, '')
.replace(/\$+$/, '')
.trim();
}
export function collectBrandCandidates(modelName) {
const queue = [];
const seen = new Set();
const push = (value) => {
const normalized = normalizeInput(value);
if (!normalized || seen.has(normalized))
return;
seen.add(normalized);
queue.push(normalized);
};
push(modelName);
for (let index = 0; index < queue.length; index += 1) {
const candidate = queue[index];
const cleaned = stripCommonWrappers(candidate);
push(cleaned);
if (cleaned.includes('/')) {
for (const part of cleaned.split('/'))
push(part);
}
if (cleaned.includes(':')) {
for (const part of cleaned.split(':'))
push(part);
}
if (cleaned.includes(',')) {
for (const part of cleaned.split(','))
push(part);
}
}
return queue;
}
function buildMatchContext(modelName) {
const candidates = collectBrandCandidates(modelName);
const raw = candidates[0] || normalizeInput(modelName);
const cleaned = stripCommonWrappers(raw);
const segments = Array.from(new Set(candidates
.flatMap((candidate) => candidate.split(/[/:,\s]+/g))
.map((segment) => segment.trim())
.filter(Boolean)));
return {
raw,
cleaned,
segments,
candidates,
};
}
function matchesRule(context, rule) {
switch (rule.mode) {
case 'includes':
return context.raw.includes(rule.keyword)
|| context.cleaned.includes(rule.keyword)
|| context.candidates.some((candidate) => candidate.includes(rule.keyword));
case 'startsWith':
return context.raw.startsWith(rule.keyword)
|| context.cleaned.startsWith(rule.keyword)
|| context.segments.some((segment) => segment.startsWith(rule.keyword))
|| context.candidates.some((candidate) => candidate.startsWith(rule.keyword));
case 'segment':
return context.segments.includes(rule.keyword);
case 'boundary': {
const pattern = new RegExp(`(^|[/:_\\-\\s])${escapeRegExp(rule.keyword)}(?=$|[/:_\\-\\s])`);
return pattern.test(context.raw)
|| pattern.test(context.cleaned)
|| context.candidates.some((candidate) => pattern.test(candidate));
}
default:
return false;
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({
brand,
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`),
}));
export function getAllBrands() {
return BRAND_DEFINITIONS.map(({ name, icon, color }) => ({ name, icon, color }));
}
export function getAllBrandNames() {
return BRAND_DEFINITIONS.map((brand) => brand.name);
}
function toBrandInfo(brand) {
return {
name: brand.name,
icon: brand.icon,
color: brand.color,
};
}
export function getMatchingBrands(modelName) {
const context = buildMatchContext(modelName);
const matches = [];
const seen = new Set();
const add = (brand) => {
if (seen.has(brand.name))
return;
seen.add(brand.name);
matches.push(toBrandInfo(brand));
};
for (const definition of BRAND_DEFINITIONS) {
if (definition.rules.some((rule) => matchesRule(context, rule))) {
add(definition);
}
}
for (const candidate of context.candidates) {
for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) {
if (rule.boundaryRegex.test(candidate)) {
add(rule.brand);
}
}
}
return matches;
}
export function getMatchingBrandNames(modelName) {
return getMatchingBrands(modelName).map((brand) => brand.name);
}
export function getBrand(modelName) {
return getMatchingBrands(modelName)[0] || null;
}
+822
View File
@@ -0,0 +1,822 @@
export interface BrandMatchContext {
raw: string;
cleaned: string;
segments: string[];
candidates: string[];
}
export interface BrandInfo {
name: string;
icon: string;
color: string;
}
export type BrandMatchMode = 'includes' | 'startsWith' | 'segment' | 'boundary';
type BrandRule = {
keyword: string;
mode: BrandMatchMode;
};
type BrandDefinition = BrandInfo & {
rules: BrandRule[];
};
const BRAND_DEFINITIONS: BrandDefinition[] = [
{
name: 'OpenAI',
icon: 'openai',
color: 'linear-gradient(135deg, #10a37f, #1a7f5a)',
rules: [
{ keyword: 'gpt', mode: 'startsWith' },
{ keyword: 'chatgpt', mode: 'startsWith' },
{ keyword: 'dall-e', mode: 'startsWith' },
{ keyword: 'whisper', mode: 'startsWith' },
{ keyword: 'text-embedding', mode: 'startsWith' },
{ keyword: 'text-moderation', mode: 'startsWith' },
{ keyword: 'davinci', mode: 'startsWith' },
{ keyword: 'babbage', mode: 'startsWith' },
{ keyword: 'codex-mini', mode: 'startsWith' },
{ keyword: 'o1', mode: 'startsWith' },
{ keyword: 'o3', mode: 'startsWith' },
{ keyword: 'o4', mode: 'startsWith' },
{ keyword: 'tts', mode: 'startsWith' },
],
},
{
name: 'Anthropic',
icon: 'claude-color',
color: 'linear-gradient(135deg, #d4a574, #c4956a)',
rules: [
{ keyword: 'claude', mode: 'includes' },
],
},
{
name: 'Google',
icon: 'gemini-color',
color: 'linear-gradient(135deg, #4285f4, #34a853)',
rules: [
{ keyword: 'gemini', mode: 'includes' },
{ keyword: 'gemma', mode: 'includes' },
{ keyword: 'google/', mode: 'includes' },
{ keyword: 'palm', mode: 'includes' },
{ keyword: 'paligemma', mode: 'includes' },
{ keyword: 'shieldgemma', mode: 'includes' },
{ keyword: 'recurrentgemma', mode: 'includes' },
{ keyword: 'deplot', mode: 'includes' },
{ keyword: 'codegemma', mode: 'includes' },
{ keyword: 'imagen', mode: 'includes' },
{ keyword: 'learnlm', mode: 'includes' },
{ keyword: 'aqa', mode: 'includes' },
{ keyword: 'veo', mode: 'startsWith' },
{ keyword: 'google/', mode: 'startsWith' },
],
},
{
name: 'DeepSeek',
icon: 'deepseek-color',
color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)',
rules: [
{ keyword: 'deepseek', mode: 'includes' },
{ keyword: 'ds-chat', mode: 'segment' },
],
},
{
name: '通义千问',
icon: 'qwen-color',
color: 'linear-gradient(135deg, #615cf7, #9b8afb)',
rules: [
{ keyword: 'qwen', mode: 'includes' },
{ keyword: 'qwq', mode: 'includes' },
{ keyword: 'tongyi', mode: 'includes' },
],
},
{
name: '智谱 AI',
icon: 'zhipu-color',
color: 'linear-gradient(135deg, #3b6cf5, #6366f1)',
rules: [
{ keyword: 'glm', mode: 'includes' },
{ keyword: 'chatglm', mode: 'includes' },
{ keyword: 'codegeex', mode: 'includes' },
{ keyword: 'cogview', mode: 'includes' },
{ keyword: 'cogvideo', mode: 'includes' },
],
},
{
name: 'Meta',
icon: 'meta-color',
color: 'linear-gradient(135deg, #0668E1, #1877f2)',
rules: [
{ keyword: 'llama', mode: 'includes' },
{ keyword: 'code-llama', mode: 'includes' },
{ keyword: 'codellama', mode: 'includes' },
],
},
{
name: 'Mistral',
icon: 'mistral-color',
color: 'linear-gradient(135deg, #f7d046, #f2a900)',
rules: [
{ keyword: 'mistral', mode: 'includes' },
{ keyword: 'mixtral', mode: 'includes' },
{ keyword: 'codestral', mode: 'includes' },
{ keyword: 'pixtral', mode: 'includes' },
{ keyword: 'ministral', mode: 'includes' },
{ keyword: 'voxtral', mode: 'includes' },
{ keyword: 'magistral', mode: 'includes' },
],
},
{
name: 'Moonshot',
icon: 'moonshot',
color: 'linear-gradient(135deg, #000000, #333333)',
rules: [
{ keyword: 'moonshot', mode: 'includes' },
{ keyword: 'kimi', mode: 'includes' },
],
},
{
name: '零一万物',
icon: 'yi-color',
color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)',
rules: [
{ keyword: 'yi-', mode: 'startsWith' },
{ keyword: 'yi', mode: 'boundary' },
],
},
{
name: '文心一言',
icon: 'wenxin-color',
color: 'linear-gradient(135deg, #2932e1, #4468f2)',
rules: [
{ keyword: 'ernie', mode: 'includes' },
{ keyword: 'eb-', mode: 'includes' },
],
},
{
name: '讯飞星火',
icon: 'spark-color',
color: 'linear-gradient(135deg, #0070f3, #00d4ff)',
rules: [
{ keyword: 'spark', mode: 'includes' },
{ keyword: 'generalv', mode: 'includes' },
],
},
{
name: '腾讯混元',
icon: 'hunyuan-color',
color: 'linear-gradient(135deg, #00b7ff, #0052d9)',
rules: [
{ keyword: 'hunyuan', mode: 'includes' },
{ keyword: 'tencent-hunyuan', mode: 'includes' },
],
},
{
name: '豆包',
icon: 'doubao-color',
color: 'linear-gradient(135deg, #3b5bdb, #7048e8)',
rules: [
{ keyword: 'doubao', mode: 'includes' },
],
},
{
name: 'MiniMax',
icon: 'minimax-color',
color: 'linear-gradient(135deg, #6366f1, #818cf8)',
rules: [
{ keyword: 'minimax', mode: 'includes' },
{ keyword: 'abab', mode: 'includes' },
{ keyword: 'mini2.1', mode: 'segment' },
],
},
{
name: 'Cohere',
icon: 'cohere-color',
color: 'linear-gradient(135deg, #39594d, #5ba77f)',
rules: [
{ keyword: 'command', mode: 'includes' },
{ keyword: 'c4ai-', mode: 'includes' },
{ keyword: 'aya', mode: 'includes' },
{ keyword: 'embed-', mode: 'startsWith' },
],
},
{
name: 'Microsoft',
icon: 'microsoft-color',
color: 'linear-gradient(135deg, #00bcf2, #0078d4)',
rules: [
{ keyword: 'microsoft/', mode: 'includes' },
{ keyword: 'phi-', mode: 'includes' },
{ keyword: 'kosmos', mode: 'includes' },
{ keyword: 'phi4', mode: 'segment' },
],
},
{
name: 'xAI',
icon: 'xai',
color: 'linear-gradient(135deg, #111, #444)',
rules: [
{ keyword: 'grok', mode: 'includes' },
],
},
{
name: '阶跃星辰',
icon: 'stepfun-color',
color: 'linear-gradient(135deg, #0066ff, #3399ff)',
rules: [
{ keyword: 'stepfun', mode: 'includes' },
{ keyword: 'step-', mode: 'startsWith' },
{ keyword: 'step3', mode: 'startsWith' },
],
},
{
name: '百川智能',
icon: 'baichuan-color',
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
rules: [
{ keyword: 'baichuan', mode: 'includes' },
],
},
{
name: 'AI21 Labs',
icon: 'ai21-brand-color',
color: 'linear-gradient(135deg, #7c3aed, #a855f7)',
rules: [
{ keyword: 'ai21', mode: 'includes' },
{ keyword: 'jamba', mode: 'startsWith' },
{ keyword: 'jamba', mode: 'includes' },
],
},
{
name: 'AI2',
icon: 'ai2-color',
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
rules: [
{ keyword: 'allenai', mode: 'includes' },
{ keyword: 'olmo', mode: 'includes' },
],
},
{
name: 'Amazon Nova',
icon: 'nova',
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
rules: [
{ keyword: 'amazon/nova', mode: 'startsWith' },
{ keyword: 'amazon.nova', mode: 'includes' },
{ keyword: 'us.amazon.nova', mode: 'includes' },
{ keyword: 'nova-', mode: 'startsWith' },
{ keyword: 'nova-lite', mode: 'startsWith' },
{ keyword: 'nova-pro', mode: 'startsWith' },
{ keyword: 'nova-micro', mode: 'startsWith' },
{ keyword: 'nova-canvas', mode: 'startsWith' },
{ keyword: 'nova-reel', mode: 'startsWith' },
],
},
{
name: 'Stability',
icon: 'stability-color',
color: 'linear-gradient(135deg, #8b5cf6, #a855f7)',
rules: [
{ keyword: 'flux', mode: 'includes' },
{ keyword: 'stablediffusion', mode: 'includes' },
{ keyword: 'stable-diffusion', mode: 'includes' },
{ keyword: 'sdxl', mode: 'includes' },
{ keyword: 'sd3', mode: 'startsWith' },
],
},
{
name: 'NVIDIA',
icon: 'nvidia-color',
color: 'linear-gradient(135deg, #76b900, #4a8c0b)',
rules: [
{ keyword: 'nvidia/', mode: 'includes' },
{ keyword: 'nvclip', mode: 'includes' },
{ keyword: 'nemotron', mode: 'includes' },
{ keyword: 'nemoretriever', mode: 'includes' },
{ keyword: 'neva', mode: 'includes' },
{ keyword: 'riva-translate', mode: 'includes' },
{ keyword: 'cosmos', mode: 'includes' },
{ keyword: 'nv-', mode: 'startsWith' },
],
},
{
name: 'IBM',
icon: 'ibm',
color: 'linear-gradient(135deg, #0f62fe, #4589ff)',
rules: [
{ keyword: 'ibm/', mode: 'includes' },
{ keyword: 'granite', mode: 'includes' },
],
},
{
name: 'BAAI',
icon: 'baai',
color: 'linear-gradient(135deg, #111827, #374151)',
rules: [
{ keyword: 'baai/', mode: 'includes' },
{ keyword: 'bge-', mode: 'includes' },
],
},
{
name: 'ByteDance',
icon: 'bytedance-color',
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
rules: [
{ keyword: 'bytedance', mode: 'includes' },
{ keyword: 'seed-oss', mode: 'includes' },
{ keyword: 'kolors', mode: 'includes' },
{ keyword: 'kwai', mode: 'includes' },
{ keyword: 'kwaipilot', mode: 'includes' },
{ keyword: 'wan-', mode: 'startsWith' },
{ keyword: 'kat-', mode: 'startsWith' },
],
},
{
name: 'InternLM',
icon: 'internlm-color',
color: 'linear-gradient(135deg, #1b3882, #4063c5)',
rules: [
{ keyword: 'internlm', mode: 'includes' },
],
},
{
name: 'Midjourney',
icon: 'midjourney',
color: 'linear-gradient(135deg, #4c6ef5, #748ffc)',
rules: [
{ keyword: 'midjourney', mode: 'includes' },
{ keyword: 'mj_', mode: 'startsWith' },
],
},
{
name: 'DeepL',
icon: 'deepl-color',
color: 'linear-gradient(135deg, #0f2b46, #21476f)',
rules: [
{ keyword: 'deepl-', mode: 'startsWith' },
{ keyword: 'deepl/', mode: 'includes' },
],
},
{
name: 'Jina AI',
icon: 'jina',
color: 'linear-gradient(135deg, #111827, #4b5563)',
rules: [
{ keyword: 'jina', mode: 'includes' },
],
},
{
name: 'Relace',
icon: 'relace',
color: 'linear-gradient(135deg, #7c3aed, #6366f1)',
rules: [
{ keyword: 'relace', mode: 'includes' },
],
},
{
name: 'Arcee',
icon: 'arcee-color',
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
rules: [
{ keyword: 'arcee-ai', mode: 'includes' },
{ keyword: 'arcee', mode: 'includes' },
],
},
{
name: 'AionLabs',
icon: 'aionlabs-color',
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
rules: [
{ keyword: 'aion-labs', mode: 'includes' },
{ keyword: 'aionlabs', mode: 'includes' },
],
},
{
name: 'DeepCogito',
icon: 'deepcogito-color',
color: 'linear-gradient(135deg, #2563eb, #7c3aed)',
rules: [
{ keyword: 'deepcogito', mode: 'includes' },
],
},
{
name: 'Essential AI',
icon: 'essentialai-color',
color: 'linear-gradient(135deg, #0f172a, #334155)',
rules: [
{ keyword: 'essentialai', mode: 'includes' },
],
},
{
name: 'Inception',
icon: 'inception',
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
rules: [
{ keyword: 'inception', mode: 'includes' },
],
},
{
name: 'Inflection',
icon: 'inflection',
color: 'linear-gradient(135deg, #1d4ed8, #2563eb)',
rules: [
{ keyword: 'inflection', mode: 'includes' },
],
},
{
name: 'Liquid AI',
icon: 'liquid',
color: 'linear-gradient(135deg, #0f172a, #475569)',
rules: [
{ keyword: 'liquid', mode: 'includes' },
{ keyword: 'lfm-', mode: 'startsWith' },
],
},
{
name: 'LongCat',
icon: 'longcat-color',
color: 'linear-gradient(135deg, #f97316, #fb7185)',
rules: [
{ keyword: 'longcat', mode: 'includes' },
],
},
{
name: 'Morph',
icon: 'morph-color',
color: 'linear-gradient(135deg, #4f46e5, #8b5cf6)',
rules: [
{ keyword: 'morph/', mode: 'includes' },
{ keyword: 'morph-', mode: 'startsWith' },
],
},
{
name: 'Nous Research',
icon: 'nousresearch',
color: 'linear-gradient(135deg, #111827, #4b5563)',
rules: [
{ keyword: 'nousresearch', mode: 'includes' },
],
},
{
name: 'Upstage',
icon: 'upstage-color',
color: 'linear-gradient(135deg, #2563eb, #14b8a6)',
rules: [
{ keyword: 'upstage', mode: 'includes' },
],
},
{
name: 'Xiaomi MiMo',
icon: 'xiaomimimo',
color: 'linear-gradient(135deg, #f97316, #fb923c)',
rules: [
{ keyword: 'xiaomi/mimo', mode: 'includes' },
{ keyword: 'xiaomimimo', mode: 'includes' },
{ keyword: 'mimo-v', mode: 'startsWith' },
],
},
{
name: 'Z.ai',
icon: 'zai',
color: 'linear-gradient(135deg, #0f172a, #2563eb)',
rules: [
{ keyword: '2zai', mode: 'startsWith' },
{ keyword: 'z-ai', mode: 'startsWith' },
],
},
{
name: 'SenseNova',
icon: 'sensenova-brand-color',
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
rules: [
{ keyword: 'sensenova', mode: 'includes' },
],
},
{
name: 'Perplexity',
icon: 'perplexity-color',
color: 'linear-gradient(135deg, #0f766e, #14b8a6)',
rules: [
{ keyword: 'perplexity', mode: 'includes' },
{ keyword: 'pplx-', mode: 'startsWith' },
],
},
{
name: 'OpenRouter',
icon: 'openrouter',
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
rules: [
{ keyword: 'openrouter', mode: 'includes' },
{ keyword: 'openrouter-', mode: 'startsWith' },
],
},
{
name: 'Groq',
icon: 'groq',
color: 'linear-gradient(135deg, #111827, #374151)',
rules: [
{ keyword: 'groq', mode: 'includes' },
],
},
{
name: 'Fireworks',
icon: 'fireworks-color',
color: 'linear-gradient(135deg, #fb7185, #f97316)',
rules: [
{ keyword: 'fireworks-ai', mode: 'includes' },
{ keyword: 'fireworks', mode: 'includes' },
],
},
{
name: 'DeepInfra',
icon: 'deepinfra-color',
color: 'linear-gradient(135deg, #4f46e5, #7c3aed)',
rules: [
{ keyword: 'deepinfra', mode: 'includes' },
],
},
{
name: 'Together AI',
icon: 'together-brand-color',
color: 'linear-gradient(135deg, #7c3aed, #ec4899)',
rules: [
{ keyword: 'together.ai', mode: 'includes' },
{ keyword: 'together', mode: 'includes' },
],
},
{
name: 'Replicate',
icon: 'replicate-brand',
color: 'linear-gradient(135deg, #111827, #6366f1)',
rules: [
{ keyword: 'replicate', mode: 'includes' },
],
},
{
name: 'SambaNova',
icon: 'sambanova-color',
color: 'linear-gradient(135deg, #2563eb, #06b6d4)',
rules: [
{ keyword: 'sambanova', mode: 'includes' },
],
},
{
name: 'Cerebras',
icon: 'cerebras-brand-color',
color: 'linear-gradient(135deg, #0f766e, #65a30d)',
rules: [
{ keyword: 'cerebras', mode: 'includes' },
],
},
{
name: 'Ollama',
icon: 'ollama',
color: 'linear-gradient(135deg, #1f2937, #4b5563)',
rules: [
{ keyword: 'ollama', mode: 'includes' },
],
},
{
name: 'ModelScope',
icon: 'modelscope-color',
color: 'linear-gradient(135deg, #2563eb, #60a5fa)',
rules: [
{ keyword: 'modelscope', mode: 'includes' },
],
},
{
name: 'SiliconCloud',
icon: 'siliconcloud-color',
color: 'linear-gradient(135deg, #0ea5e9, #22d3ee)',
rules: [
{ keyword: 'siliconcloud', mode: 'includes' },
{ keyword: 'siliconflow', mode: 'includes' },
],
},
{
name: 'Azure AI',
icon: 'azureai-color',
color: 'linear-gradient(135deg, #0284c7, #2563eb)',
rules: [
{ keyword: 'azureai', mode: 'includes' },
{ keyword: 'azure-openai', mode: 'includes' },
{ keyword: 'azure/openai', mode: 'includes' },
],
},
{
name: 'AWS Bedrock',
icon: 'bedrock-color',
color: 'linear-gradient(135deg, #f59e0b, #f97316)',
rules: [
{ keyword: 'bedrock', mode: 'includes' },
],
},
{
name: 'Vertex AI',
icon: 'vertexai-color',
color: 'linear-gradient(135deg, #2563eb, #6366f1)',
rules: [
{ keyword: 'vertexai', mode: 'includes' },
],
},
{
name: 'Google Cloud',
icon: 'googlecloud-brand-color',
color: 'linear-gradient(135deg, #4285f4, #34a853)',
rules: [
{ keyword: 'googlecloud', mode: 'includes' },
{ keyword: 'google-cloud', mode: 'includes' },
],
},
{
name: '百度智能云',
icon: 'baiducloud-color',
color: 'linear-gradient(135deg, #2563eb, #38bdf8)',
rules: [
{ keyword: 'baiducloud', mode: 'includes' },
{ keyword: 'qianfan', mode: 'includes' },
],
},
{
name: '百炼',
icon: 'bailian-color',
color: 'linear-gradient(135deg, #7c3aed, #2563eb)',
rules: [
{ keyword: 'bailian', mode: 'includes' },
{ keyword: 'dashscope', mode: 'includes' },
],
},
{
name: '阿里云',
icon: 'alibabacloud-color',
color: 'linear-gradient(135deg, #f97316, #fb923c)',
rules: [
{ keyword: 'alibabacloud', mode: 'includes' },
],
},
{
name: '火山引擎',
icon: 'volcengine-color',
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
rules: [
{ keyword: 'volcengine', mode: 'includes' },
],
},
{
name: '七牛云',
icon: 'qiniu-color',
color: 'linear-gradient(135deg, #06b6d4, #0891b2)',
rules: [
{ keyword: 'qiniu', mode: 'includes' },
],
},
];
function normalizeInput(value: string): string {
return String(value || '').trim().toLowerCase();
}
export function stripCommonWrappers(value: string): string {
return value
.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '')
.replace(/^re:\s*/g, '')
.replace(/^\^+/, '')
.replace(/\$+$/, '')
.trim();
}
export function collectBrandCandidates(modelName: string): string[] {
const queue: string[] = [];
const seen = new Set<string>();
const push = (value: string) => {
const normalized = normalizeInput(value);
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
queue.push(normalized);
};
push(modelName);
for (let index = 0; index < queue.length; index += 1) {
const candidate = queue[index]!;
const cleaned = stripCommonWrappers(candidate);
push(cleaned);
if (cleaned.includes('/')) {
for (const part of cleaned.split('/')) push(part);
}
if (cleaned.includes(':')) {
for (const part of cleaned.split(':')) push(part);
}
if (cleaned.includes(',')) {
for (const part of cleaned.split(',')) push(part);
}
}
return queue;
}
function buildMatchContext(modelName: string): BrandMatchContext {
const candidates = collectBrandCandidates(modelName);
const raw = candidates[0] || normalizeInput(modelName);
const cleaned = stripCommonWrappers(raw);
const segments = Array.from(new Set(
candidates
.flatMap((candidate) => candidate.split(/[/:,\s]+/g))
.map((segment) => segment.trim())
.filter(Boolean),
));
return {
raw,
cleaned,
segments,
candidates,
};
}
function matchesRule(context: BrandMatchContext, rule: BrandRule): boolean {
switch (rule.mode) {
case 'includes':
return context.raw.includes(rule.keyword)
|| context.cleaned.includes(rule.keyword)
|| context.candidates.some((candidate) => candidate.includes(rule.keyword));
case 'startsWith':
return context.raw.startsWith(rule.keyword)
|| context.cleaned.startsWith(rule.keyword)
|| context.segments.some((segment) => segment.startsWith(rule.keyword))
|| context.candidates.some((candidate) => candidate.startsWith(rule.keyword));
case 'segment':
return context.segments.includes(rule.keyword);
case 'boundary': {
const pattern = new RegExp(`(^|[/:_\\-\\s])${escapeRegExp(rule.keyword)}(?=$|[/:_\\-\\s])`);
return pattern.test(context.raw)
|| pattern.test(context.cleaned)
|| context.candidates.some((candidate) => pattern.test(candidate));
}
default:
return false;
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({
brand,
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`),
}));
export function getAllBrands(): BrandInfo[] {
return BRAND_DEFINITIONS.map(({ name, icon, color }) => ({ name, icon, color }));
}
export function getAllBrandNames(): string[] {
return BRAND_DEFINITIONS.map((brand) => brand.name);
}
function toBrandInfo(brand: BrandDefinition): BrandInfo {
return {
name: brand.name,
icon: brand.icon,
color: brand.color,
};
}
export function getMatchingBrands(modelName: string): BrandInfo[] {
const context = buildMatchContext(modelName);
const matches: BrandInfo[] = [];
const seen = new Set<string>();
const add = (brand: BrandDefinition) => {
if (seen.has(brand.name)) return;
seen.add(brand.name);
matches.push(toBrandInfo(brand));
};
for (const definition of BRAND_DEFINITIONS) {
if (definition.rules.some((rule) => matchesRule(context, rule))) {
add(definition);
}
}
for (const candidate of context.candidates) {
for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) {
if (rule.boundaryRegex.test(candidate)) {
add(rule.brand);
}
}
}
return matches;
}
export function getMatchingBrandNames(modelName: string): string[] {
return getMatchingBrands(modelName).map((brand) => brand.name);
}
export function getBrand(modelName: string): BrandInfo | null {
return getMatchingBrands(modelName)[0] || null;
}
+118
View File
@@ -0,0 +1,118 @@
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
function normalizeString(value) {
return String(value || '').trim();
}
function normalizeDigest(value) {
const digest = normalizeString(value);
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
}
function normalizeStableTag(value) {
return normalizeStableVersion(value);
}
function hasSameImageTag(left, right) {
const rawLeft = normalizeString(left);
const rawRight = normalizeString(right);
if (!rawLeft || !rawRight)
return false;
const stableLeft = normalizeStableTag(left);
const stableRight = normalizeStableTag(right);
if (stableLeft && stableRight) {
return stableLeft === stableRight;
}
return rawLeft === rawRight;
}
export function normalizeStableVersion(value) {
const raw = normalizeString(value);
if (!raw)
return '';
const match = raw.match(STABLE_SEMVER_PATTERN);
if (!match)
return '';
return [
Number.parseInt(match[1], 10),
Number.parseInt(match[2], 10),
Number.parseInt(match[3], 10),
].join('.');
}
export function compareStableVersions(left, right) {
const normalizedLeft = normalizeStableVersion(left);
const normalizedRight = normalizeStableVersion(right);
if (!normalizedLeft || !normalizedRight)
return null;
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
for (let index = 0; index < 3; index += 1) {
if (leftParts[index] === rightParts[index])
continue;
return leftParts[index] < rightParts[index] ? -1 : 1;
}
return 0;
}
export function isSameImageTarget(current, target) {
const currentDigest = normalizeDigest(current?.imageDigest);
const targetDigest = normalizeDigest(target.digest);
if (currentDigest && targetDigest) {
return currentDigest === targetDigest;
}
return hasSameImageTag(current?.imageTag, target.tag);
}
export function buildUpdateReminderCandidateKey(source, candidate) {
const tagName = normalizeString(candidate.tagName);
const digest = normalizeDigest(candidate.digest);
if (!tagName)
return '';
if (source === 'docker-hub-tag' && digest) {
return `${source}:${tagName}@${digest}`;
}
return `${source}:${tagName}`;
}
export function resolveUpdateReminderCandidate(input) {
const githubCandidateVersion = normalizeString(input.githubRelease?.normalizedVersion);
const githubTag = normalizeString(input.githubRelease?.tagName || githubCandidateVersion);
if (githubTag) {
const githubTargetVersion = githubCandidateVersion || githubTag;
const versionCompare = compareStableVersions(input.currentVersion, githubTargetVersion);
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, githubTargetVersion);
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
return {
source: 'github-release',
kind: 'new-version',
candidateKey: buildUpdateReminderCandidateKey('github-release', { tagName: githubTag, digest: null }),
displayVersion: normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion || githubTag),
tagName: githubTag,
digest: null,
};
}
}
const dockerCandidateVersion = normalizeString(input.dockerHubTag?.normalizedVersion);
const dockerTag = normalizeString(input.dockerHubTag?.tagName || dockerCandidateVersion);
const dockerDigest = normalizeDigest(input.dockerHubTag?.digest);
if (!dockerTag)
return null;
if (isSameImageTarget(input.helper, { tag: dockerTag, digest: dockerDigest })) {
return null;
}
const dockerVersionCompare = compareStableVersions(input.currentVersion, dockerCandidateVersion || dockerTag);
if (dockerVersionCompare === -1) {
return {
source: 'docker-hub-tag',
kind: 'new-version',
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest || null }),
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
tagName: dockerTag,
digest: dockerDigest || null,
};
}
const helperDigest = normalizeDigest(input.helper?.imageDigest);
if (dockerDigest && helperDigest && hasSameImageTag(input.helper?.imageTag, dockerTag) && helperDigest !== dockerDigest) {
return {
source: 'docker-hub-tag',
kind: 'new-digest',
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest }),
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
tagName: dockerTag,
digest: dockerDigest,
};
}
return null;
}
+161
View File
@@ -0,0 +1,161 @@
export type UpdateVersionCandidateLike = {
normalizedVersion?: string | null;
displayVersion?: string | null;
tagName?: string | null;
digest?: string | null;
};
export type UpdateHelperRuntimeLike = {
imageTag?: string | null;
imageDigest?: string | null;
};
export type UpdateReminderCandidate = {
source: 'github-release' | 'docker-hub-tag';
kind: 'new-version' | 'new-digest';
candidateKey: string;
displayVersion: string;
tagName: string;
digest: string | null;
};
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
function normalizeString(value?: string | null): string {
return String(value || '').trim();
}
function normalizeDigest(value?: string | null): string {
const digest = normalizeString(value);
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
}
function normalizeStableTag(value?: string | null): string {
return normalizeStableVersion(value);
}
function hasSameImageTag(left?: string | null, right?: string | null): boolean {
const rawLeft = normalizeString(left);
const rawRight = normalizeString(right);
if (!rawLeft || !rawRight) return false;
const stableLeft = normalizeStableTag(left);
const stableRight = normalizeStableTag(right);
if (stableLeft && stableRight) {
return stableLeft === stableRight;
}
return rawLeft === rawRight;
}
export function normalizeStableVersion(value?: string | null): string {
const raw = normalizeString(value);
if (!raw) return '';
const match = raw.match(STABLE_SEMVER_PATTERN);
if (!match) return '';
return [
Number.parseInt(match[1], 10),
Number.parseInt(match[2], 10),
Number.parseInt(match[3], 10),
].join('.');
}
export function compareStableVersions(left?: string | null, right?: string | null): number | null {
const normalizedLeft = normalizeStableVersion(left);
const normalizedRight = normalizeStableVersion(right);
if (!normalizedLeft || !normalizedRight) return null;
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
for (let index = 0; index < 3; index += 1) {
if (leftParts[index] === rightParts[index]) continue;
return leftParts[index] < rightParts[index] ? -1 : 1;
}
return 0;
}
export function isSameImageTarget(
current: UpdateHelperRuntimeLike | null | undefined,
target: { tag?: string | null; digest?: string | null },
): boolean {
const currentDigest = normalizeDigest(current?.imageDigest);
const targetDigest = normalizeDigest(target.digest);
if (currentDigest && targetDigest) {
return currentDigest === targetDigest;
}
return hasSameImageTag(current?.imageTag, target.tag);
}
export function buildUpdateReminderCandidateKey(
source: 'github-release' | 'docker-hub-tag',
candidate: { tagName?: string | null; digest?: string | null },
): string {
const tagName = normalizeString(candidate.tagName);
const digest = normalizeDigest(candidate.digest);
if (!tagName) return '';
if (source === 'docker-hub-tag' && digest) {
return `${source}:${tagName}@${digest}`;
}
return `${source}:${tagName}`;
}
export function resolveUpdateReminderCandidate(input: {
currentVersion?: string | null;
helper: UpdateHelperRuntimeLike | null | undefined;
githubRelease: UpdateVersionCandidateLike | null | undefined;
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
}): UpdateReminderCandidate | null {
const githubCandidateVersion = normalizeString(input.githubRelease?.normalizedVersion);
const githubTag = normalizeString(input.githubRelease?.tagName || githubCandidateVersion);
if (githubTag) {
const githubTargetVersion = githubCandidateVersion || githubTag;
const versionCompare = compareStableVersions(input.currentVersion, githubTargetVersion);
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, githubTargetVersion);
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
return {
source: 'github-release',
kind: 'new-version',
candidateKey: buildUpdateReminderCandidateKey('github-release', { tagName: githubTag, digest: null }),
displayVersion: normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion || githubTag),
tagName: githubTag,
digest: null,
};
}
}
const dockerCandidateVersion = normalizeString(input.dockerHubTag?.normalizedVersion);
const dockerTag = normalizeString(input.dockerHubTag?.tagName || dockerCandidateVersion);
const dockerDigest = normalizeDigest(input.dockerHubTag?.digest);
if (!dockerTag) return null;
if (isSameImageTarget(input.helper, { tag: dockerTag, digest: dockerDigest })) {
return null;
}
const dockerVersionCompare = compareStableVersions(input.currentVersion, dockerCandidateVersion || dockerTag);
if (dockerVersionCompare === -1) {
return {
source: 'docker-hub-tag',
kind: 'new-version',
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest || null }),
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
tagName: dockerTag,
digest: dockerDigest || null,
};
}
const helperDigest = normalizeDigest(input.helper?.imageDigest);
if (dockerDigest && helperDigest && hasSameImageTag(input.helper?.imageTag, dockerTag) && helperDigest !== dockerDigest) {
return {
source: 'docker-hub-tag',
kind: 'new-digest',
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest }),
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
tagName: dockerTag,
digest: dockerDigest,
};
}
return null;
}
+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';