feat: rebrand Metapi to BoosAPI, add ComfyUI agent and user management
- Rename project from Metapi to BoosAPI across all UI and server strings - Add ComfyUI conversational agent page (/comfyui-agent) - Add user management system (register, login, API keys, admin) - Update Dockerfile and database schema - Add ComfyUI workflow nodes support - Update shared contracts and platform configurations Signed-off-by: Boos4721 <boos4721@icloud.com>
This commit is contained in:
+7
-13
@@ -1,21 +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 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;
|
||||
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;
|
||||
document: boolean;
|
||||
image: boolean;
|
||||
audio: boolean;
|
||||
}): string;
|
||||
|
||||
@@ -1,94 +1,134 @@
|
||||
export const CONVERSATION_DOCUMENT_ACCEPT_PARTS = ['.pdf', '.txt', '.md', '.markdown', '.json'];
|
||||
const GENERIC_MIME_TYPES = new Set([
|
||||
'application/octet-stream',
|
||||
'binary/octet-stream',
|
||||
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',
|
||||
]);
|
||||
const DOCUMENT_MIME_TYPES = new Set([
|
||||
'application/json',
|
||||
'application/pdf',
|
||||
'text/markdown',
|
||||
'text/plain',
|
||||
]);
|
||||
const DOCUMENT_EXTENSIONS = ['.json', '.md', '.markdown', '.pdf', '.txt'];
|
||||
const IMAGE_EXTENSIONS = ['.avif', '.bmp', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp'];
|
||||
const AUDIO_EXTENSIONS = ['.aac', '.flac', '.m4a', '.mp3', '.ogg', '.wav', '.weba'];
|
||||
|
||||
function normalizeValue(value) {
|
||||
return (value || '').trim().toLowerCase();
|
||||
function stripMimeParameters(mimeType) {
|
||||
return mimeType.split(';')[0].trim();
|
||||
}
|
||||
|
||||
function normalizeMimeType(value) {
|
||||
const normalized = normalizeValue(value);
|
||||
const [essence] = normalized.split(';');
|
||||
return (essence || '').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 normalized = normalizeValue(filename);
|
||||
if (normalized.endsWith('.pdf')) return 'application/pdf';
|
||||
if (normalized.endsWith('.txt')) return 'text/plain';
|
||||
if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown';
|
||||
if (normalized.endsWith('.json')) return 'application/json';
|
||||
if (normalized.endsWith('.png')) return 'image/png';
|
||||
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (normalized.endsWith('.gif')) return 'image/gif';
|
||||
if (normalized.endsWith('.webp')) return 'image/webp';
|
||||
if (normalized.endsWith('.svg')) return 'image/svg+xml';
|
||||
if (normalized.endsWith('.avif')) return 'image/avif';
|
||||
if (normalized.endsWith('.bmp')) return 'image/bmp';
|
||||
if (normalized.endsWith('.wav')) return 'audio/wav';
|
||||
if (normalized.endsWith('.mp3')) return 'audio/mpeg';
|
||||
if (normalized.endsWith('.m4a')) return 'audio/mp4';
|
||||
if (normalized.endsWith('.ogg')) return 'audio/ogg';
|
||||
if (normalized.endsWith('.aac')) return 'audio/aac';
|
||||
if (normalized.endsWith('.flac')) return 'audio/flac';
|
||||
if (normalized.endsWith('.weba')) return 'audio/webm';
|
||||
return 'application/octet-stream';
|
||||
const ext = getExtension(filename);
|
||||
return EXTENSION_MIME_MAP[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function resolveConversationFileMimeType(mimeType, filename) {
|
||||
const normalizedMimeType = normalizeMimeType(mimeType);
|
||||
if (normalizedMimeType && !GENERIC_MIME_TYPES.has(normalizedMimeType)) {
|
||||
return normalizedMimeType;
|
||||
}
|
||||
return inferConversationFileMimeType(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) {
|
||||
const normalized = normalizeMimeType(mimeType);
|
||||
if (normalized.startsWith('image/')) return 'image';
|
||||
if (normalized.startsWith('audio/')) return 'audio';
|
||||
return 'document';
|
||||
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 mimeType = normalizeMimeType(file?.mimeType);
|
||||
if (mimeType) {
|
||||
if (mimeType.startsWith('image/')) return 'image';
|
||||
if (mimeType.startsWith('audio/')) return 'audio';
|
||||
if (DOCUMENT_MIME_TYPES.has(mimeType)) return 'document';
|
||||
if (!GENERIC_MIME_TYPES.has(mimeType)) return 'unknown';
|
||||
}
|
||||
|
||||
const filename = normalizeValue(file?.filename);
|
||||
if (!filename) return 'unknown';
|
||||
if (DOCUMENT_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'document';
|
||||
if (IMAGE_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'image';
|
||||
if (AUDIO_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'audio';
|
||||
return 'unknown';
|
||||
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 normalized = normalizeMimeType(mimeType);
|
||||
return DOCUMENT_MIME_TYPES.has(normalized)
|
||||
|| normalized.startsWith('image/')
|
||||
|| normalized.startsWith('audio/');
|
||||
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(',');
|
||||
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(',');
|
||||
}
|
||||
|
||||
@@ -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(',');
|
||||
}
|
||||
@@ -1,94 +1,65 @@
|
||||
export const PLATFORM_ALIASES = Object.assign(Object.create(null), {
|
||||
anyrouter: 'anyrouter',
|
||||
'wong-gongyi': 'new-api',
|
||||
'vo-api': 'new-api',
|
||||
'super-api': 'new-api',
|
||||
'rix-api': 'new-api',
|
||||
'neo-api': 'new-api',
|
||||
newapi: 'new-api',
|
||||
'new api': 'new-api',
|
||||
'new-api': 'new-api',
|
||||
oneapi: 'one-api',
|
||||
'one api': 'one-api',
|
||||
'one-api': 'one-api',
|
||||
onehub: 'one-hub',
|
||||
'one-hub': 'one-hub',
|
||||
donehub: 'done-hub',
|
||||
'done-hub': 'done-hub',
|
||||
veloera: 'veloera',
|
||||
sub2api: 'sub2api',
|
||||
openai: 'openai',
|
||||
codex: 'codex',
|
||||
'chatgpt-codex': 'codex',
|
||||
'chatgpt codex': 'codex',
|
||||
anthropic: 'claude',
|
||||
claude: 'claude',
|
||||
gemini: 'gemini',
|
||||
'gemini-cli': 'gemini-cli',
|
||||
antigravity: 'antigravity',
|
||||
'anti-gravity': 'antigravity',
|
||||
google: 'gemini',
|
||||
cliproxyapi: 'cliproxyapi',
|
||||
cpa: 'cliproxyapi',
|
||||
'cli-proxy-api': 'cliproxyapi',
|
||||
});
|
||||
|
||||
function getPlatformAlias(raw) {
|
||||
return Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, raw)
|
||||
? PLATFORM_ALIASES[raw]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeUrlCandidate(url) {
|
||||
return typeof url === 'string' ? url.trim() : '';
|
||||
}
|
||||
|
||||
function parseUrlCandidate(url) {
|
||||
const normalized = normalizeUrlCandidate(url);
|
||||
if (!normalized) return null;
|
||||
|
||||
const candidates = normalized.includes('://')
|
||||
? [normalized]
|
||||
: [`https://${normalized}`];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return new URL(candidate);
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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) {
|
||||
const raw = typeof platform === 'string' ? platform.trim().toLowerCase() : '';
|
||||
if (!raw) return '';
|
||||
return getPlatformAlias(raw) ?? raw;
|
||||
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 normalized = normalizeUrlCandidate(url).toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
const parsed = parseUrlCandidate(normalized);
|
||||
const host = parsed?.hostname?.trim().toLowerCase() || '';
|
||||
const port = parsed?.port?.trim() || '';
|
||||
const path = parsed?.pathname?.trim().toLowerCase() || '';
|
||||
|
||||
if (host === 'api.openai.com') return 'openai';
|
||||
if (host === 'chatgpt.com' && path.startsWith('/backend-api/codex')) return 'codex';
|
||||
if (host === 'api.anthropic.com' || (host === 'anthropic.com' && path.startsWith('/v1'))) return 'claude';
|
||||
if (
|
||||
host === 'generativelanguage.googleapis.com'
|
||||
|| host === 'gemini.google.com'
|
||||
|| ((host === 'googleapis.com' || host.endsWith('.googleapis.com')) && path.startsWith('/v1beta/openai'))
|
||||
) {
|
||||
return 'gemini';
|
||||
}
|
||||
if (host === 'cloudcode-pa.googleapis.com') return 'gemini-cli';
|
||||
if ((host === '127.0.0.1' || host === 'localhost') && port === '8317') return 'cliproxyapi';
|
||||
if (host.includes('anyrouter')) return 'anyrouter';
|
||||
if (host.includes('donehub') || host.includes('done-hub')) return 'done-hub';
|
||||
if (host.includes('onehub') || host.includes('one-hub')) return 'one-hub';
|
||||
if (host.includes('veloera')) return 'veloera';
|
||||
if (host.includes('sub2api')) return 'sub2api';
|
||||
|
||||
return 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+6
-8
@@ -1,12 +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;
|
||||
clientKind: string | null;
|
||||
sessionId: string | null;
|
||||
downstreamPath: string | null;
|
||||
upstreamPath: string | null;
|
||||
usageSource: ProxyLogUsageSource;
|
||||
messageText: string;
|
||||
};
|
||||
|
||||
export declare function parseProxyLogMetadata(rawMessage: string): ParsedProxyLogMetadata;
|
||||
|
||||
+40
-18
@@ -1,20 +1,42 @@
|
||||
const METADATA_PREFIX_REGEX = /^\[(\w+):([^\]]*)\]\s*/i;
|
||||
export function parseProxyLogMetadata(rawMessage) {
|
||||
const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i);
|
||||
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
|
||||
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
|
||||
const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i);
|
||||
const usageMatch = rawMessage.match(/\[usage:([^\]]+)\]/i);
|
||||
const messageText = rawMessage.replace(
|
||||
/^\s*(?:\[(?:client|session|downstream|upstream|usage):[^\]]+\]\s*)+/i,
|
||||
'',
|
||||
).trim();
|
||||
|
||||
return {
|
||||
clientKind: clientMatch?.[1]?.trim() || null,
|
||||
sessionId: sessionMatch?.[1]?.trim() || null,
|
||||
downstreamPath: downstreamMatch?.[1]?.trim() || null,
|
||||
upstreamPath: upstreamMatch?.[1]?.trim() || null,
|
||||
usageSource: usageMatch?.[1]?.trim() || null,
|
||||
messageText,
|
||||
};
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+13
-29
@@ -1,30 +1,14 @@
|
||||
export type SiteInitializationPresetId =
|
||||
| 'codingplan-openai'
|
||||
| 'codingplan-claude'
|
||||
| 'zhipu-coding-plan-openai'
|
||||
| 'zhipu-coding-plan-claude'
|
||||
| 'deepseek-openai'
|
||||
| 'deepseek-claude'
|
||||
| 'moonshot-openai'
|
||||
| 'moonshot-claude'
|
||||
| 'minimax-openai'
|
||||
| 'minimax-claude'
|
||||
| 'modelscope-openai'
|
||||
| 'modelscope-claude'
|
||||
| 'doubao-coding-openai';
|
||||
export type SiteInitializationPreset = {
|
||||
id: SiteInitializationPresetId;
|
||||
label: string;
|
||||
providerLabel: string;
|
||||
description: string;
|
||||
platform: string;
|
||||
defaultUrl?: string;
|
||||
initialSegment: 'session' | 'apikey';
|
||||
recommendedSkipModelFetch: boolean;
|
||||
recommendedModels: string[];
|
||||
docsUrl?: string;
|
||||
};
|
||||
|
||||
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 getSiteInitializationPreset(id: string | null | undefined): SiteInitializationPreset | null;
|
||||
export declare function detectSiteInitializationPreset(url: string, platform?: string | null): SiteInitializationPreset | null;
|
||||
export declare function detectSiteInitializationPreset(url: string, knownPlatform?: string): {
|
||||
id: string;
|
||||
platform: 'openai' | 'claude';
|
||||
} | null;
|
||||
|
||||
@@ -1,316 +1,156 @@
|
||||
import { analyzePrimarySiteUrl } from './sitePrimaryUrl.js';
|
||||
|
||||
function normalizeUrlCandidate(url) {
|
||||
return typeof url === 'string' ? url.trim() : '';
|
||||
}
|
||||
|
||||
function parseUrlCandidate(url) {
|
||||
const normalized = normalizeUrlCandidate(url);
|
||||
if (!normalized) return null;
|
||||
|
||||
const candidates = normalized.includes('://')
|
||||
? [normalized]
|
||||
: [`https://${normalized}`];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return new URL(candidate);
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePathname(pathname) {
|
||||
let normalized = typeof pathname === 'string' ? pathname.trim() : '';
|
||||
if (!normalized.startsWith('/')) normalized = `/${normalized}`;
|
||||
while (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function matchesHostAndPaths(url, hostname, paths) {
|
||||
const parsed = parseUrlCandidate(url);
|
||||
if (!parsed) return false;
|
||||
return parsed.hostname === hostname && paths.includes(normalizePathname(parsed.pathname));
|
||||
}
|
||||
|
||||
const CODINGPLAN_RECOMMENDED_MODELS = Object.freeze([
|
||||
'qwen3-coder-plus',
|
||||
'qwen3-coder-next',
|
||||
'qwen3.5-plus',
|
||||
'glm-5',
|
||||
]);
|
||||
|
||||
const ZHIPU_CODING_PLAN_RECOMMENDED_MODELS = Object.freeze([
|
||||
'glm-4.7',
|
||||
'glm-4.6',
|
||||
'glm-4.5',
|
||||
'glm-4.5-air',
|
||||
]);
|
||||
|
||||
const DEEPSEEK_RECOMMENDED_MODELS = Object.freeze([
|
||||
'deepseek-chat',
|
||||
'deepseek-reasoner',
|
||||
]);
|
||||
|
||||
const MOONSHOT_RECOMMENDED_MODELS = Object.freeze([
|
||||
'kimi-k2.5',
|
||||
'kimi-k2',
|
||||
'kimi-k2-thinking',
|
||||
]);
|
||||
|
||||
const MINIMAX_RECOMMENDED_MODELS = Object.freeze([
|
||||
'MiniMax-M2.7',
|
||||
'MiniMax-M2.5',
|
||||
'MiniMax-M2.1',
|
||||
]);
|
||||
|
||||
const MODELSCOPE_RECOMMENDED_MODELS = Object.freeze([
|
||||
'Qwen/Qwen3-32B',
|
||||
'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
'deepseek-ai/DeepSeek-V3.2',
|
||||
]);
|
||||
|
||||
const DOUBAO_CODING_RECOMMENDED_MODELS = Object.freeze([
|
||||
'ark-code-latest',
|
||||
'doubao-seed-2.0-code',
|
||||
'doubao-seed-2.0-pro',
|
||||
]);
|
||||
|
||||
const SITE_INITIALIZATION_PRESETS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'codingplan-openai',
|
||||
label: '阿里云 CodingPlan / OpenAI',
|
||||
providerLabel: '阿里云 CodingPlan',
|
||||
description: '适合阿里云 CodingPlan 的 OpenAI 兼容入口,建议先添加 API Key,再补入推荐模型完成初始化。',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: CODINGPLAN_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://help.aliyun.com/zh/model-studio/coding-plan-faq',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'coding.dashscope.aliyuncs.com', ['/v1']);
|
||||
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'],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'codingplan-claude',
|
||||
label: '阿里云 CodingPlan / Claude',
|
||||
providerLabel: '阿里云 CodingPlan',
|
||||
description: '适合阿里云 CodingPlan 的 Claude 兼容入口,建议先添加 API Key,再补入推荐模型完成初始化。',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: CODINGPLAN_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://help.aliyun.com/zh/model-studio/coding-plan-faq',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'coding.dashscope.aliyuncs.com', ['/apps/anthropic']);
|
||||
{
|
||||
id: 'codingplan-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['qwen3-coder-next', 'glm-5'],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'zhipu-coding-plan-openai',
|
||||
label: '智谱 Coding Plan / OpenAI',
|
||||
providerLabel: '智谱 Coding Plan',
|
||||
description: '适合智谱 Coding Plan 的 OpenAI 兼容入口,建议先添加 API Key,再补入常用 GLM 编程模型。',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ZHIPU_CODING_PLAN_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://docs.bigmodel.cn/cn/coding-plan/faq',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'open.bigmodel.cn', ['/api/coding/paas/v4']);
|
||||
{
|
||||
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'],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'zhipu-coding-plan-claude',
|
||||
label: '智谱 Coding Plan / Claude',
|
||||
providerLabel: '智谱 Coding Plan',
|
||||
description: '适合智谱 Coding Plan 的 Claude 兼容入口。由于该地址也可作为通用兼容入口,这里默认只提供手动预设,不按 URL 强制自动识别。',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://open.bigmodel.cn/api/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ZHIPU_CODING_PLAN_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://docs.bigmodel.cn/cn/coding-plan/faq',
|
||||
matches() {
|
||||
return false;
|
||||
{
|
||||
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'],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'deepseek-openai',
|
||||
label: 'DeepSeek / OpenAI',
|
||||
providerLabel: 'DeepSeek',
|
||||
description: '适合 DeepSeek 官方 OpenAI 兼容入口,建议直接添加 API Key,并优先补入官方常用编程模型。',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.deepseek.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: DEEPSEEK_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://api-docs.deepseek.com/',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api.deepseek.com', ['/', '/v1']);
|
||||
{
|
||||
id: 'deepseek-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.deepseek.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'deepseek-claude',
|
||||
label: 'DeepSeek / Claude',
|
||||
providerLabel: 'DeepSeek',
|
||||
description: '适合 DeepSeek 官方 Anthropic 兼容入口,便于 Claude Code 一类工具直接接入。',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.deepseek.com/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: DEEPSEEK_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://api-docs.deepseek.com/guides/anthropic_api',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api.deepseek.com', ['/anthropic']);
|
||||
{
|
||||
id: 'deepseek-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.deepseek.com/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'moonshot-openai',
|
||||
label: 'Moonshot(Kimi) / OpenAI',
|
||||
providerLabel: 'Moonshot / Kimi',
|
||||
description: '适合 Moonshot 官方 OpenAI 兼容入口,推荐优先使用 Kimi 系列编程与 Agent 模型。',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.moonshot.cn/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: MOONSHOT_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://platform.moonshot.cn/',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api.moonshot.cn', ['/', '/v1']);
|
||||
{
|
||||
id: 'moonshot-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.moonshot.cn/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['kimi-k2.5', 'kimi-k2', 'kimi-k2-thinking'],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'moonshot-claude',
|
||||
label: 'Moonshot(Kimi) / Claude',
|
||||
providerLabel: 'Moonshot / Kimi',
|
||||
description: '适合 Moonshot 官方 Anthropic 兼容入口,便于 Claude Code 与同类工具接入 Kimi。',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.moonshot.cn/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: MOONSHOT_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://platform.moonshot.cn/blog/posts/kimi-k2-0905',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api.moonshot.cn', ['/anthropic']);
|
||||
{
|
||||
id: 'moonshot-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.moonshot.cn/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'minimax-openai',
|
||||
label: 'MiniMax / OpenAI',
|
||||
providerLabel: 'MiniMax',
|
||||
description: '适合 MiniMax 官方 OpenAI 兼容入口,建议直接添加 API Key 后补入常用 M2 编程模型。',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.minimaxi.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: MINIMAX_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://platform.minimaxi.com/docs/api-reference/api-overview',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api.minimaxi.com', ['/', '/v1']);
|
||||
{
|
||||
id: 'minimax-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.minimaxi.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'minimax-claude',
|
||||
label: 'MiniMax / Claude',
|
||||
providerLabel: 'MiniMax',
|
||||
description: '适合 MiniMax 官方 Anthropic 兼容入口,适配 Claude Code 等编程工具场景。',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.minimaxi.com/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: MINIMAX_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://platform.minimaxi.com/docs/api-reference/text-anthropic-api',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api.minimaxi.com', ['/anthropic']);
|
||||
{
|
||||
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'],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'modelscope-openai',
|
||||
label: 'ModelScope / OpenAI',
|
||||
providerLabel: 'ModelScope',
|
||||
description: '适合 ModelScope API-Inference 的 OpenAI 兼容入口,适合直接接入常用开源编程模型。',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api-inference.modelscope.cn/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: MODELSCOPE_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://www.modelscope.cn/docs/model-service/API-Inference/intro',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api-inference.modelscope.cn', ['/v1']);
|
||||
{
|
||||
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',
|
||||
],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'modelscope-claude',
|
||||
label: 'ModelScope / Claude',
|
||||
providerLabel: 'ModelScope',
|
||||
description: '适合 ModelScope API-Inference 的 Claude 兼容入口,便于接入 Claude Code 一类工具。',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api-inference.modelscope.cn',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: MODELSCOPE_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://www.modelscope.cn/docs/model-service/API-Inference/intro',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api-inference.modelscope.cn', ['/']);
|
||||
{
|
||||
id: 'modelscope-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api-inference.modelscope.cn',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'doubao-coding-openai',
|
||||
label: '豆包 Coding Plan / OpenAI',
|
||||
providerLabel: '豆包 Coding Plan',
|
||||
description: '适合火山方舟 Coding Plan 的 OpenAI 兼容入口,推荐优先使用 ark-code 与豆包编程模型。',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: DOUBAO_CODING_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://www.volcengine.com/docs/82379/2205646?lang=zh',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'ark.cn-beijing.volces.com', ['/api/coding/v3']);
|
||||
{
|
||||
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'],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
function clonePreset(preset) {
|
||||
if (!preset) return null;
|
||||
return {
|
||||
...preset,
|
||||
recommendedModels: [...preset.recommendedModels],
|
||||
};
|
||||
}
|
||||
|
||||
export function listSiteInitializationPresets() {
|
||||
return SITE_INITIALIZATION_PRESETS.map((preset) => clonePreset(preset));
|
||||
}
|
||||
|
||||
];
|
||||
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) {
|
||||
const normalizedId = typeof id === 'string' ? id.trim() : '';
|
||||
if (!normalizedId) return null;
|
||||
return clonePreset(SITE_INITIALIZATION_PRESETS.find((preset) => preset.id === normalizedId) || null);
|
||||
return presets.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
export function detectSiteInitializationPreset(url, platform) {
|
||||
const normalizedPlatform = typeof platform === 'string' ? platform.trim().toLowerCase() : '';
|
||||
for (const preset of SITE_INITIALIZATION_PRESETS) {
|
||||
if (normalizedPlatform && preset.platform !== normalizedPlatform) continue;
|
||||
if (preset.matches(url)) return clonePreset(preset);
|
||||
}
|
||||
|
||||
if (!normalizedPlatform) return null;
|
||||
|
||||
const analyzed = analyzePrimarySiteUrl(url);
|
||||
for (const preset of SITE_INITIALIZATION_PRESETS) {
|
||||
if (preset.platform !== normalizedPlatform) continue;
|
||||
if (!preset.defaultUrl) continue;
|
||||
const presetAnalyzed = analyzePrimarySiteUrl(preset.defaultUrl);
|
||||
if (!presetAnalyzed.persistedUrl) continue;
|
||||
if (presetAnalyzed.persistedUrl === analyzed.persistedUrl) return clonePreset(preset);
|
||||
}
|
||||
|
||||
return null;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+7
-15
@@ -1,15 +1,7 @@
|
||||
export type PrimarySiteUrlAction =
|
||||
| 'unchanged'
|
||||
| 'auto_strip_known_api_suffix'
|
||||
| 'preserve_api_path'
|
||||
| 'preserve_semantic_path'
|
||||
| 'preserve_unknown_path';
|
||||
|
||||
export type PrimarySiteUrlAnalysis = {
|
||||
canonicalUrl: string;
|
||||
persistedUrl: string;
|
||||
matchedPath: string;
|
||||
action: PrimarySiteUrlAction;
|
||||
};
|
||||
|
||||
export declare function analyzePrimarySiteUrl(url: string | null | undefined): PrimarySiteUrlAnalysis;
|
||||
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;
|
||||
|
||||
+100
-101
@@ -1,107 +1,106 @@
|
||||
function normalizePathname(pathname) {
|
||||
let normalized = typeof pathname === 'string' ? pathname.trim() : '';
|
||||
if (!normalized || normalized === '/') return '/';
|
||||
if (!normalized.startsWith('/')) normalized = `/${normalized}`;
|
||||
while (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
/**
|
||||
* 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 parseUrlCandidate(url) {
|
||||
const trimmed = typeof url === 'string' ? url.trim() : '';
|
||||
if (!trimmed) return null;
|
||||
|
||||
const candidates = trimmed.includes('://')
|
||||
? [trimmed]
|
||||
: [`https://${trimmed}`];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
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 {
|
||||
return new URL(candidate);
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const AUTO_STRIP_PRIMARY_SITE_PATHS = new Set([
|
||||
'/v1',
|
||||
'/v1beta',
|
||||
'/v1/models',
|
||||
'/v1/chat/completions',
|
||||
'/v1/responses',
|
||||
'/v1/messages',
|
||||
'/v1beta/models',
|
||||
]);
|
||||
|
||||
const SEMANTIC_PRIMARY_SITE_PATHS = new Set([
|
||||
'/backend-api/codex',
|
||||
'/anthropic',
|
||||
'/apps/anthropic',
|
||||
'/api/anthropic',
|
||||
'/api/coding/paas/v4',
|
||||
'/v1beta/openai',
|
||||
]);
|
||||
|
||||
export function analyzePrimarySiteUrl(url) {
|
||||
const parsed = parseUrlCandidate(url);
|
||||
if (!parsed) {
|
||||
const trimmed = typeof url === 'string' ? url.trim().replace(/\/+$/, '') : '';
|
||||
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: trimmed,
|
||||
persistedUrl: trimmed,
|
||||
matchedPath: '',
|
||||
action: 'unchanged',
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
action: 'preserve_unknown_path',
|
||||
matchedPath: pathname,
|
||||
};
|
||||
}
|
||||
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
const matchedPath = normalizePathname(parsed.pathname);
|
||||
const canonicalUrl = matchedPath === '/'
|
||||
? parsed.origin
|
||||
: `${parsed.origin}${matchedPath}`;
|
||||
|
||||
if (matchedPath === '/') {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
matchedPath,
|
||||
action: 'unchanged',
|
||||
};
|
||||
}
|
||||
|
||||
if (SEMANTIC_PRIMARY_SITE_PATHS.has(matchedPath)) {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
matchedPath,
|
||||
action: 'preserve_semantic_path',
|
||||
};
|
||||
}
|
||||
|
||||
if (AUTO_STRIP_PRIMARY_SITE_PATHS.has(matchedPath)) {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: parsed.origin,
|
||||
matchedPath,
|
||||
action: 'auto_strip_known_api_suffix',
|
||||
};
|
||||
}
|
||||
|
||||
if (matchedPath.startsWith('/api')) {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
matchedPath,
|
||||
action: 'preserve_api_path',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
matchedPath,
|
||||
action: 'preserve_unknown_path',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export const ROUTE_DECISION_REFRESH_TASK_TYPE = 'route-decision.refresh';
|
||||
|
||||
export function normalizeTokenRouteMode(routeMode) {
|
||||
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
|
||||
if (routeMode === 'explicit_group')
|
||||
return 'explicit_group';
|
||||
return 'pattern';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -1,516 +1,59 @@
|
||||
let nextNodeId = 1;
|
||||
|
||||
function createNode(type, fields = {}) {
|
||||
return {
|
||||
id: nextNodeId++,
|
||||
type,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
function isDigitCharacter(ch) {
|
||||
if (!ch) return false;
|
||||
const code = ch.charCodeAt(0);
|
||||
return code >= 48 && code <= 57;
|
||||
}
|
||||
|
||||
function readRegexQuantifierLength(pattern, startIndex) {
|
||||
const ch = pattern[startIndex];
|
||||
if (ch === '*' || ch === '+' || ch === '?') return 1;
|
||||
if (ch !== '{') return 0;
|
||||
|
||||
let index = startIndex + 1;
|
||||
let sawDigit = false;
|
||||
while (index < pattern.length && isDigitCharacter(pattern[index])) {
|
||||
sawDigit = true;
|
||||
index += 1;
|
||||
}
|
||||
if (!sawDigit) return 0;
|
||||
if (pattern[index] === ',') {
|
||||
index += 1;
|
||||
while (index < pattern.length && isDigitCharacter(pattern[index])) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
if (pattern[index] !== '}') return 0;
|
||||
return index - startIndex + 1;
|
||||
}
|
||||
|
||||
function isAllowedSafeRegexCharacter(ch) {
|
||||
if (!ch) return false;
|
||||
const code = ch.charCodeAt(0);
|
||||
const isLowerAlpha = code >= 97 && code <= 122;
|
||||
const isUpperAlpha = code >= 65 && code <= 90;
|
||||
const isDigit = code >= 48 && code <= 57;
|
||||
if (isLowerAlpha || isUpperAlpha || isDigit) return true;
|
||||
return ' .^$|()[]{}+*?\\:_/-'.includes(ch);
|
||||
}
|
||||
|
||||
function hasUnsafeRegexBackreference(body) {
|
||||
for (let index = 0; index < body.length; index += 1) {
|
||||
if (body[index] !== '\\') continue;
|
||||
const prev = index > 0 ? body[index - 1] : '';
|
||||
const next = body[index + 1];
|
||||
if (prev !== '\\' && isDigitCharacter(next) && next !== '0') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSafeRegexPatternBody(body) {
|
||||
if (!body || body.length > 256) return false;
|
||||
for (const ch of body) {
|
||||
if (!isAllowedSafeRegexCharacter(ch)) return false;
|
||||
}
|
||||
if (
|
||||
body.includes('(?=')
|
||||
|| body.includes('(?!')
|
||||
|| body.includes('(?<=')
|
||||
|| body.includes('(?<!')
|
||||
|| body.includes('(?<')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (hasUnsafeRegexBackreference(body)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const groupStack = [];
|
||||
let escaped = false;
|
||||
let inCharClass = false;
|
||||
for (let index = 0; index < body.length; index += 1) {
|
||||
const ch = body[index];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
const next = body[index + 1];
|
||||
if (!next) return false;
|
||||
if (/[a-z]/i.test(next) && next !== 'd') {
|
||||
return false;
|
||||
}
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (inCharClass) {
|
||||
if (ch === ']') inCharClass = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '[') {
|
||||
inCharClass = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(') {
|
||||
if (body[index + 1] === '?') {
|
||||
return false;
|
||||
}
|
||||
groupStack.push({ hasInnerQuantifier: false, hasAlternation: false });
|
||||
continue;
|
||||
}
|
||||
if (ch === '|') {
|
||||
if (groupStack.length > 0) {
|
||||
groupStack[groupStack.length - 1].hasAlternation = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === ')') {
|
||||
const group = groupStack.pop();
|
||||
if (!group) return false;
|
||||
const quantifierLength = readRegexQuantifierLength(body, index + 1);
|
||||
if (quantifierLength > 0 && (group.hasInnerQuantifier || group.hasAlternation)) {
|
||||
return false;
|
||||
}
|
||||
const parent = groupStack[groupStack.length - 1];
|
||||
if (parent && (group.hasInnerQuantifier || quantifierLength > 0)) {
|
||||
parent.hasInnerQuantifier = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const quantifierLength = readRegexQuantifierLength(body, index);
|
||||
if (quantifierLength > 0) {
|
||||
if (groupStack.length > 0) {
|
||||
groupStack[groupStack.length - 1].hasInnerQuantifier = true;
|
||||
}
|
||||
index += quantifierLength - 1;
|
||||
}
|
||||
}
|
||||
return !escaped && !inCharClass && groupStack.length === 0;
|
||||
}
|
||||
|
||||
function matchesGlobPattern(model, pattern) {
|
||||
let modelIndex = 0;
|
||||
let patternIndex = 0;
|
||||
let starIndex = -1;
|
||||
let matchIndex = 0;
|
||||
|
||||
while (modelIndex < model.length) {
|
||||
const patternChar = pattern[patternIndex];
|
||||
const modelChar = model[modelIndex];
|
||||
if (patternChar === '*' ) {
|
||||
starIndex = patternIndex;
|
||||
matchIndex = modelIndex;
|
||||
patternIndex += 1;
|
||||
continue;
|
||||
}
|
||||
if (patternChar === '?' || patternChar === modelChar) {
|
||||
patternIndex += 1;
|
||||
modelIndex += 1;
|
||||
continue;
|
||||
}
|
||||
if (starIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
patternIndex = starIndex + 1;
|
||||
matchIndex += 1;
|
||||
modelIndex = matchIndex;
|
||||
}
|
||||
|
||||
while (pattern[patternIndex] === '*') {
|
||||
patternIndex += 1;
|
||||
}
|
||||
|
||||
return patternIndex === pattern.length;
|
||||
}
|
||||
|
||||
function toArraySet(values) {
|
||||
const unique = [];
|
||||
for (const value of values) {
|
||||
if (!unique.includes(value)) unique.push(value);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function parseCharClassChar(body, state) {
|
||||
const ch = body[state.index];
|
||||
if (ch === '\\') {
|
||||
state.index += 1;
|
||||
const escaped = body[state.index];
|
||||
if (!escaped) throw new Error('invalid escape');
|
||||
state.index += 1;
|
||||
if (escaped === 'd') return { kind: 'digit' };
|
||||
return { kind: 'char', value: escaped };
|
||||
}
|
||||
if (!ch) throw new Error('invalid char class');
|
||||
state.index += 1;
|
||||
return { kind: 'char', value: ch };
|
||||
}
|
||||
|
||||
function parseCharClass(body, state) {
|
||||
state.index += 1;
|
||||
let negated = false;
|
||||
if (body[state.index] === '^') {
|
||||
negated = true;
|
||||
state.index += 1;
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
while (state.index < body.length && body[state.index] !== ']') {
|
||||
const start = parseCharClassChar(body, state);
|
||||
if (body[state.index] === '-' && body[state.index + 1] && body[state.index + 1] !== ']') {
|
||||
state.index += 1;
|
||||
const end = parseCharClassChar(body, state);
|
||||
entries.push({ kind: 'range', start, end });
|
||||
continue;
|
||||
}
|
||||
entries.push(start);
|
||||
}
|
||||
|
||||
if (body[state.index] !== ']' || entries.length === 0) {
|
||||
throw new Error('invalid character class');
|
||||
}
|
||||
state.index += 1;
|
||||
return createNode('charclass', { negated, entries });
|
||||
}
|
||||
|
||||
function parseAtom(body, state) {
|
||||
const ch = body[state.index];
|
||||
if (!ch) throw new Error('unexpected end');
|
||||
|
||||
if (ch === '(') {
|
||||
state.index += 1;
|
||||
const group = parseExpression(body, state, ')');
|
||||
if (body[state.index] !== ')') {
|
||||
throw new Error('missing )');
|
||||
}
|
||||
state.index += 1;
|
||||
return group;
|
||||
}
|
||||
if (ch === '[') {
|
||||
return parseCharClass(body, state);
|
||||
}
|
||||
if (ch === '.') {
|
||||
state.index += 1;
|
||||
return createNode('any');
|
||||
}
|
||||
if (ch === '\\') {
|
||||
state.index += 1;
|
||||
const escaped = body[state.index];
|
||||
if (!escaped) throw new Error('invalid escape');
|
||||
state.index += 1;
|
||||
if (escaped === 'd') {
|
||||
return createNode('digit');
|
||||
}
|
||||
return createNode('literal', { value: escaped });
|
||||
}
|
||||
|
||||
state.index += 1;
|
||||
return createNode('literal', { value: ch });
|
||||
}
|
||||
|
||||
function parseQuantifier(body, state) {
|
||||
const ch = body[state.index];
|
||||
if (ch === '*') {
|
||||
state.index += 1;
|
||||
return { min: 0, max: Infinity };
|
||||
}
|
||||
if (ch === '+') {
|
||||
state.index += 1;
|
||||
return { min: 1, max: Infinity };
|
||||
}
|
||||
if (ch === '?') {
|
||||
state.index += 1;
|
||||
return { min: 0, max: 1 };
|
||||
}
|
||||
if (ch !== '{') return null;
|
||||
|
||||
let index = state.index + 1;
|
||||
let minText = '';
|
||||
while (index < body.length && isDigitCharacter(body[index])) {
|
||||
minText += body[index];
|
||||
index += 1;
|
||||
}
|
||||
if (!minText) return null;
|
||||
|
||||
let max = Number.parseInt(minText, 10);
|
||||
if (body[index] === ',') {
|
||||
index += 1;
|
||||
let maxText = '';
|
||||
while (index < body.length && isDigitCharacter(body[index])) {
|
||||
maxText += body[index];
|
||||
index += 1;
|
||||
}
|
||||
max = maxText ? Number.parseInt(maxText, 10) : Infinity;
|
||||
}
|
||||
if (body[index] !== '}') return null;
|
||||
state.index = index + 1;
|
||||
return {
|
||||
min: Number.parseInt(minText, 10),
|
||||
max,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTerm(body, state) {
|
||||
const atom = parseAtom(body, state);
|
||||
const quantifier = parseQuantifier(body, state);
|
||||
if (!quantifier) return atom;
|
||||
return createNode('repeat', {
|
||||
atom,
|
||||
min: quantifier.min,
|
||||
max: quantifier.max,
|
||||
});
|
||||
}
|
||||
|
||||
function parseSequence(body, state, stopChar) {
|
||||
const terms = [];
|
||||
while (state.index < body.length) {
|
||||
const ch = body[state.index];
|
||||
if (ch === '|' || ch === stopChar) break;
|
||||
terms.push(parseTerm(body, state));
|
||||
}
|
||||
if (terms.length === 0) {
|
||||
return createNode('empty');
|
||||
}
|
||||
if (terms.length === 1) return terms[0];
|
||||
return createNode('sequence', { terms });
|
||||
}
|
||||
|
||||
function parseExpression(body, state, stopChar = '') {
|
||||
const branches = [parseSequence(body, state, stopChar)];
|
||||
while (state.index < body.length && body[state.index] === '|') {
|
||||
state.index += 1;
|
||||
branches.push(parseSequence(body, state, stopChar));
|
||||
}
|
||||
if (branches.length === 1) return branches[0];
|
||||
return createNode('alternation', { branches });
|
||||
}
|
||||
|
||||
function charMatchesCharClassEntry(entry, ch) {
|
||||
if (entry.kind === 'digit') return isDigitCharacter(ch);
|
||||
if (entry.kind === 'char') return ch === entry.value;
|
||||
if (entry.kind === 'range') {
|
||||
if (entry.start.kind !== 'char' || entry.end.kind !== 'char') {
|
||||
return false;
|
||||
}
|
||||
const code = ch.charCodeAt(0);
|
||||
return code >= entry.start.value.charCodeAt(0) && code <= entry.end.value.charCodeAt(0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchNode(node, value, position, cache) {
|
||||
const cacheKey = `${node.id}:${position}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cache.has(cacheKey)) return cached;
|
||||
|
||||
let result = [];
|
||||
switch (node.type) {
|
||||
case 'empty':
|
||||
result = [position];
|
||||
break;
|
||||
case 'literal':
|
||||
result = value.startsWith(node.value, position) ? [position + node.value.length] : [];
|
||||
break;
|
||||
case 'any':
|
||||
result = position < value.length ? [position + 1] : [];
|
||||
break;
|
||||
case 'digit':
|
||||
result = position < value.length && isDigitCharacter(value[position]) ? [position + 1] : [];
|
||||
break;
|
||||
case 'charclass': {
|
||||
if (position < value.length) {
|
||||
const matched = node.entries.some((entry) => charMatchesCharClassEntry(entry, value[position]));
|
||||
if ((matched && !node.negated) || (!matched && node.negated)) {
|
||||
result = [position + 1];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'sequence': {
|
||||
let positions = [position];
|
||||
for (const term of node.terms) {
|
||||
const nextPositions = [];
|
||||
for (const current of positions) {
|
||||
nextPositions.push(...matchNode(term, value, current, cache));
|
||||
}
|
||||
positions = toArraySet(nextPositions);
|
||||
if (positions.length === 0) break;
|
||||
}
|
||||
result = positions;
|
||||
break;
|
||||
}
|
||||
case 'alternation': {
|
||||
const positions = [];
|
||||
for (const branch of node.branches) {
|
||||
positions.push(...matchNode(branch, value, position, cache));
|
||||
}
|
||||
result = toArraySet(positions);
|
||||
break;
|
||||
}
|
||||
case 'repeat': {
|
||||
let results = node.min === 0 ? [position] : [];
|
||||
let frontier = [position];
|
||||
const maxRepeat = Number.isFinite(node.max) ? node.max : (value.length - position + 1);
|
||||
for (let count = 1; count <= maxRepeat; count += 1) {
|
||||
const nextPositions = [];
|
||||
for (const current of frontier) {
|
||||
const ends = matchNode(node.atom, value, current, cache);
|
||||
for (const end of ends) {
|
||||
if (end !== current) nextPositions.push(end);
|
||||
}
|
||||
}
|
||||
frontier = toArraySet(nextPositions);
|
||||
if (frontier.length === 0) break;
|
||||
if (count >= node.min) {
|
||||
results = toArraySet([...results, ...frontier]);
|
||||
}
|
||||
}
|
||||
result = results;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
result = [];
|
||||
break;
|
||||
}
|
||||
|
||||
cache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function compileSafeRegexPattern(body) {
|
||||
nextNodeId = 1;
|
||||
const anchoredStart = body.startsWith('^');
|
||||
const anchoredEnd = body.endsWith('$') && body[body.length - 2] !== '\\';
|
||||
const normalizedBody = body
|
||||
.slice(anchoredStart ? 1 : 0, anchoredEnd ? -1 : body.length)
|
||||
.trim();
|
||||
const state = { index: 0 };
|
||||
const root = parseExpression(normalizedBody, state);
|
||||
if (state.index !== normalizedBody.length) {
|
||||
throw new Error('unsupported regex syntax');
|
||||
}
|
||||
|
||||
return {
|
||||
test(value) {
|
||||
const starts = anchoredStart
|
||||
? [0]
|
||||
: Array.from({ length: value.length + 1 }, (_, index) => index);
|
||||
for (const start of starts) {
|
||||
const ends = matchNode(root, value, start, new Map());
|
||||
if (!anchoredEnd && ends.length > 0) return true;
|
||||
if (anchoredEnd && ends.includes(value.length)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const matchCache = new Map();
|
||||
const MATCH_CACHE_LIMIT = 4000;
|
||||
|
||||
const REGEX_PREFIX = /^re:/i;
|
||||
export function isTokenRouteRegexPattern(pattern) {
|
||||
return pattern.trim().toLowerCase().startsWith('re:');
|
||||
return REGEX_PREFIX.test(pattern.trim());
|
||||
}
|
||||
|
||||
export function isExactTokenRouteModelPattern(pattern) {
|
||||
const normalized = pattern.trim();
|
||||
if (!normalized) return false;
|
||||
if (isTokenRouteRegexPattern(normalized)) return false;
|
||||
return !/[\*\?]/.test(normalized);
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed)
|
||||
return false;
|
||||
if (REGEX_PREFIX.test(trimmed))
|
||||
return false;
|
||||
return !/[?*]/.test(trimmed);
|
||||
}
|
||||
|
||||
export function parseTokenRouteRegexPattern(pattern) {
|
||||
if (!isTokenRouteRegexPattern(pattern)) {
|
||||
return { regex: null, error: null };
|
||||
}
|
||||
const body = pattern.trim().slice(3).trim();
|
||||
if (!body) {
|
||||
return { regex: null, error: 're: 后缺少正则表达式' };
|
||||
}
|
||||
if (!isSafeRegexPatternBody(body)) {
|
||||
return { regex: null, error: '出于安全原因不支持该正则表达式' };
|
||||
}
|
||||
try {
|
||||
return {
|
||||
regex: compileSafeRegexPattern(body),
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return { regex: null, error: error?.message || '无效正则' };
|
||||
}
|
||||
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 normalized = (pattern || '').trim();
|
||||
if (!normalized) return false;
|
||||
if (normalized === model) return true;
|
||||
|
||||
const cacheKey = `${model}\0${normalized}`;
|
||||
const cached = matchCache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
let result;
|
||||
if (isTokenRouteRegexPattern(normalized)) {
|
||||
const parsed = parseTokenRouteRegexPattern(normalized);
|
||||
result = !!parsed.regex && parsed.regex.test(model);
|
||||
} else {
|
||||
result = matchesGlobPattern(model, normalized);
|
||||
}
|
||||
|
||||
if (matchCache.size >= MATCH_CACHE_LIMIT) {
|
||||
matchCache.clear();
|
||||
}
|
||||
matchCache.set(cacheKey, result);
|
||||
return result;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user