fix: address review feedback and code scanning alerts
This commit is contained in:
+5
@@ -1,6 +1,11 @@
|
||||
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'>;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const CONVERSATION_DOCUMENT_ACCEPT_PARTS = ['.pdf', '.txt', '.md', '.markdown', '.json'];
|
||||
const GENERIC_MIME_TYPES = new Set([
|
||||
'application/octet-stream',
|
||||
'binary/octet-stream',
|
||||
]);
|
||||
const DOCUMENT_MIME_TYPES = new Set([
|
||||
'application/json',
|
||||
'application/pdf',
|
||||
@@ -13,6 +17,37 @@ function normalizeValue(value) {
|
||||
return (value || '').trim().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';
|
||||
}
|
||||
|
||||
export function resolveConversationFileMimeType(mimeType, filename) {
|
||||
const normalizedMimeType = normalizeValue(mimeType);
|
||||
if (normalizedMimeType && !GENERIC_MIME_TYPES.has(normalizedMimeType)) {
|
||||
return normalizedMimeType;
|
||||
}
|
||||
return inferConversationFileMimeType(filename);
|
||||
}
|
||||
|
||||
export function classifyConversationFileMimeType(mimeType) {
|
||||
const normalized = normalizeValue(mimeType);
|
||||
if (normalized.startsWith('image/')) return 'image';
|
||||
@@ -26,6 +61,7 @@ export function detectConversationFileKind(file) {
|
||||
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);
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
buildConversationAcceptList,
|
||||
classifyConversationFileMimeType,
|
||||
detectConversationFileKind,
|
||||
inferConversationFileMimeType,
|
||||
isSupportedConversationFileMimeType,
|
||||
resolveConversationFileMimeType,
|
||||
} from './conversationFileTypes.js';
|
||||
|
||||
describe('conversationFileTypes', () => {
|
||||
@@ -31,4 +33,13 @@ describe('conversationFileTypes', () => {
|
||||
audio: false,
|
||||
})).toBe('.pdf,.txt,.md,.markdown,.json,image/*');
|
||||
});
|
||||
|
||||
it('falls back from generic mime types to filename-based inference consistently', () => {
|
||||
expect(inferConversationFileMimeType('photo.avif')).toBe('image/avif');
|
||||
expect(resolveConversationFileMimeType('application/octet-stream', 'paper.pdf')).toBe('application/pdf');
|
||||
expect(detectConversationFileKind({
|
||||
filename: 'paper.pdf',
|
||||
mimeType: 'application/octet-stream',
|
||||
})).toBe('document');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const PLATFORM_ALIASES = {
|
||||
export const PLATFORM_ALIASES = Object.assign(Object.create(null), {
|
||||
anyrouter: 'anyrouter',
|
||||
'wong-gongyi': 'new-api',
|
||||
'vo-api': 'new-api',
|
||||
@@ -31,35 +31,64 @@ export const PLATFORM_ALIASES = {
|
||||
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 function normalizePlatformAlias(platform) {
|
||||
const raw = typeof platform === 'string' ? platform.trim().toLowerCase() : '';
|
||||
if (!raw) return '';
|
||||
return PLATFORM_ALIASES[raw] ?? raw;
|
||||
return getPlatformAlias(raw) ?? raw;
|
||||
}
|
||||
|
||||
export function detectPlatformByUrlHint(url) {
|
||||
const normalized = (url || '').trim().toLowerCase();
|
||||
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 (normalized.includes('api.openai.com')) return 'openai';
|
||||
if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex';
|
||||
if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude';
|
||||
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 (
|
||||
normalized.includes('generativelanguage.googleapis.com')
|
||||
|| normalized.includes('googleapis.com/v1beta/openai')
|
||||
|| normalized.includes('gemini.google.com')
|
||||
host === 'generativelanguage.googleapis.com'
|
||||
|| host === 'gemini.google.com'
|
||||
|| ((host === 'googleapis.com' || host.endsWith('.googleapis.com')) && path.startsWith('/v1beta/openai'))
|
||||
) {
|
||||
return 'gemini';
|
||||
}
|
||||
if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli';
|
||||
if (normalized.includes('anyrouter')) return 'anyrouter';
|
||||
if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub';
|
||||
if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub';
|
||||
if (normalized.includes('veloera')) return 'veloera';
|
||||
if (normalized.includes('sub2api')) return 'sub2api';
|
||||
if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi';
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -20,5 +20,6 @@ describe('platformIdentity', () => {
|
||||
expect(detectPlatformByUrlHint('https://generativelanguage.googleapis.com/v1beta')).toBe('gemini');
|
||||
expect(detectPlatformByUrlHint('https://cloudcode-pa.googleapis.com')).toBe('gemini-cli');
|
||||
expect(detectPlatformByUrlHint('http://127.0.0.1:8317/v1/models')).toBe('cliproxyapi');
|
||||
expect(detectPlatformByUrlHint('https://evil.example.com/?next=https://api.openai.com/v1/models')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,5 +8,6 @@ describe('token route contract', () => {
|
||||
expect(normalizeTokenRouteMode('pattern')).toBe('pattern');
|
||||
expect(normalizeTokenRouteMode('anything-else')).toBe('pattern');
|
||||
expect(normalizeTokenRouteMode(null)).toBe('pattern');
|
||||
expect(normalizeTokenRouteMode(undefined)).toBe('pattern');
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+4
-1
@@ -1,7 +1,10 @@
|
||||
export type TokenRoutePatternMatcher = {
|
||||
test(value: string): boolean;
|
||||
};
|
||||
export declare function isTokenRouteRegexPattern(pattern: string): boolean;
|
||||
export declare function isExactTokenRouteModelPattern(pattern: string): boolean;
|
||||
export declare function parseTokenRouteRegexPattern(pattern: string): {
|
||||
regex: RegExp | null;
|
||||
regex: TokenRoutePatternMatcher | null;
|
||||
error: string | null;
|
||||
};
|
||||
export declare function matchesTokenRouteModelPattern(model: string, pattern: string): boolean;
|
||||
|
||||
+493
-169
@@ -1,190 +1,514 @@
|
||||
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 && /\d/.test(pattern[index])) {
|
||||
sawDigit = true;
|
||||
index += 1;
|
||||
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 (!sawDigit)
|
||||
return 0;
|
||||
if (pattern[index] === ',') {
|
||||
index += 1;
|
||||
while (index < pattern.length && /\d/.test(pattern[index])) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
if (pattern[index] !== '}')
|
||||
return 0;
|
||||
return index - startIndex + 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;
|
||||
if (!/^[a-z0-9\s.^$|()[\]{}+*?\\:_/-]+$/i.test(body))
|
||||
return false;
|
||||
if (body.includes('(?=') || body.includes('(?!') || body.includes('(?<=') || body.includes('(?<!') || body.includes('(?<')) {
|
||||
return false;
|
||||
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 (/(^|[^\\])\\[1-9]/.test(body)) {
|
||||
if (ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (inCharClass) {
|
||||
if (ch === ']') inCharClass = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '[') {
|
||||
inCharClass = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(') {
|
||||
if (body[index + 1] === '?') {
|
||||
if (body[index + 2] !== ':') return false;
|
||||
groupStack.push({ hasInnerQuantifier: false, hasAlternation: false });
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
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 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 === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (inCharClass) {
|
||||
if (ch === ']')
|
||||
inCharClass = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '[') {
|
||||
inCharClass = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(') {
|
||||
if (body[index + 1] === '?') {
|
||||
if (body[index + 2] !== ':')
|
||||
return false;
|
||||
groupStack.push({ hasInnerQuantifier: false, hasAlternation: false });
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
return !escaped && !inCharClass && groupStack.length === 0;
|
||||
}
|
||||
function globToRegexSource(glob) {
|
||||
let source = '';
|
||||
for (let i = 0; i < glob.length; i += 1) {
|
||||
const ch = glob[i];
|
||||
if (ch === '*') {
|
||||
source += '.*';
|
||||
continue;
|
||||
}
|
||||
if (ch === '?') {
|
||||
source += '.';
|
||||
continue;
|
||||
}
|
||||
if (ch === '[') {
|
||||
const closeIndex = glob.indexOf(']', i + 1);
|
||||
if (closeIndex > i + 1) {
|
||||
source += glob.slice(i, closeIndex + 1);
|
||||
i = closeIndex;
|
||||
continue;
|
||||
}
|
||||
source += '\\[';
|
||||
continue;
|
||||
}
|
||||
source += ch.replace(/[\\^$+?.()|{}]/g, '\\$&');
|
||||
|
||||
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;
|
||||
}
|
||||
return source;
|
||||
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;
|
||||
}
|
||||
const compiledGlobCache = new Map();
|
||||
|
||||
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;
|
||||
function matchesGlobPattern(model, pattern) {
|
||||
let re = compiledGlobCache.get(pattern);
|
||||
if (re === undefined) {
|
||||
try {
|
||||
re = new RegExp(`^${globToRegexSource(pattern)}$`);
|
||||
}
|
||||
catch {
|
||||
re = null;
|
||||
}
|
||||
compiledGlobCache.set(pattern, re);
|
||||
}
|
||||
return re ? re.test(model) : false;
|
||||
}
|
||||
|
||||
export function isTokenRouteRegexPattern(pattern) {
|
||||
return pattern.trim().toLowerCase().startsWith('re:');
|
||||
return pattern.trim().toLowerCase().startsWith('re:');
|
||||
}
|
||||
|
||||
export function isExactTokenRouteModelPattern(pattern) {
|
||||
const normalized = pattern.trim();
|
||||
if (!normalized)
|
||||
return false;
|
||||
if (isTokenRouteRegexPattern(normalized))
|
||||
return false;
|
||||
return !/[\*\?]/.test(normalized);
|
||||
const normalized = pattern.trim();
|
||||
if (!normalized) return false;
|
||||
if (isTokenRouteRegexPattern(normalized)) return false;
|
||||
return !/[\*\?]/.test(normalized);
|
||||
}
|
||||
|
||||
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: new RegExp(body), error: null };
|
||||
}
|
||||
catch (error) {
|
||||
return { regex: null, error: error?.message || '无效正则' };
|
||||
}
|
||||
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 || '无效正则' };
|
||||
}
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,13 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('token route pattern helpers', () => {
|
||||
it('treats bracket-prefixed literal model names as exact patterns', async () => {
|
||||
const { isExactTokenRouteModelPattern } = await import('./tokenRoutePatterns.js');
|
||||
const {
|
||||
isExactTokenRouteModelPattern,
|
||||
matchesTokenRouteModelPattern,
|
||||
} = await import('./tokenRoutePatterns.js');
|
||||
expect(isExactTokenRouteModelPattern('[NV]deepseek-v3.1-terminus')).toBe(true);
|
||||
expect(matchesTokenRouteModelPattern('[NV]deepseek-v3.1-terminus', '[NV]deepseek-v3.1-terminus')).toBe(true);
|
||||
expect(matchesTokenRouteModelPattern('Ndeepseek-v3.1-terminus', '[NV]deepseek-v3.1-terminus')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unsafe nested-quantifier regex patterns', async () => {
|
||||
@@ -12,8 +17,8 @@ describe('token route pattern helpers', () => {
|
||||
parseTokenRouteRegexPattern,
|
||||
} = await import('./tokenRoutePatterns.js');
|
||||
|
||||
expect(parseTokenRouteRegexPattern('re:^(a+)+$').regex).toBeNull();
|
||||
expect(matchesTokenRouteModelPattern('aaaa', 're:^(a+)+$')).toBe(false);
|
||||
expect(parseTokenRouteRegexPattern('re:(?=claude)').regex).toBeNull();
|
||||
expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 're:(?=claude)')).toBe(false);
|
||||
});
|
||||
|
||||
it('supports exact, glob, and safe regex route matches', async () => {
|
||||
@@ -22,5 +27,6 @@ describe('token route pattern helpers', () => {
|
||||
expect(matchesTokenRouteModelPattern('gpt-4o-mini', 'gpt-4o-mini')).toBe(true);
|
||||
expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 'claude-*')).toBe(true);
|
||||
expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 're:^claude-(opus|sonnet)-4-6$')).toBe(true);
|
||||
expect(matchesTokenRouteModelPattern('gpt-4o-mini-2025', 're:^gpt-4o-mini-\\d+$')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user