refactor: purge Node.js, Rust is the one true backend
- Moved boosapi-rs/* to repo root - Deleted all Node.js source (src/server/, src/web/, scripts/) - Deleted node_modules, package.json, tsconfig, vite.config - Updated .gitignore and AGENTS.md for Rust-only stack - Fixed static file path for frontend dist/web/ - Dockerfile stays the same (already Rust-only) - Kept dist/web/ for Rust static serving
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
buildBatchApiKeyConnectionName,
|
||||
parseBatchApiKeys,
|
||||
} from '../server/services/shared/apiKeyBatchCore.js';
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
export type ConversationFileKind = 'document' | 'image' | 'audio' | 'unknown';
|
||||
export declare const CONVERSATION_DOCUMENT_ACCEPT_PARTS: string[];
|
||||
export declare function inferConversationFileMimeType(filename: string | null | undefined): string;
|
||||
export declare function resolveConversationFileMimeType(mimeType: string | null | undefined, filename: string | null | undefined): string;
|
||||
export declare function classifyConversationFileMimeType(mimeType: string | null | undefined): Exclude<ConversationFileKind, 'unknown'>;
|
||||
export declare function detectConversationFileKind(file: {
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
}): ConversationFileKind;
|
||||
export declare function isSupportedConversationFileMimeType(mimeType: string): boolean;
|
||||
export declare function buildConversationAcceptList(input: {
|
||||
document: boolean;
|
||||
image: boolean;
|
||||
audio: boolean;
|
||||
}): string;
|
||||
@@ -1,134 +0,0 @@
|
||||
export const CONVERSATION_DOCUMENT_ACCEPT_PARTS = [
|
||||
'.pdf',
|
||||
'.txt',
|
||||
'.md',
|
||||
'.markdown',
|
||||
'.json',
|
||||
];
|
||||
const EXTENSION_MIME_MAP = {
|
||||
// Documents
|
||||
pdf: 'application/pdf',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
markdown: 'text/markdown',
|
||||
json: 'application/json',
|
||||
csv: 'text/csv',
|
||||
xml: 'text/xml',
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
// Images
|
||||
png: 'image/png',
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
avif: 'image/avif',
|
||||
bmp: 'image/bmp',
|
||||
svg: 'image/svg+xml',
|
||||
ico: 'image/x-icon',
|
||||
tiff: 'image/tiff',
|
||||
tif: 'image/tiff',
|
||||
// Audio
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
m4a: 'audio/mp4',
|
||||
aac: 'audio/aac',
|
||||
flac: 'audio/flac',
|
||||
wma: 'audio/x-ms-wma',
|
||||
webm: 'audio/webm',
|
||||
};
|
||||
const SUPPORTED_MIME_TYPES = new Set([
|
||||
// Images
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
'image/bmp',
|
||||
'image/svg+xml',
|
||||
'image/tiff',
|
||||
// Audio
|
||||
'audio/mpeg',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
'audio/mp4',
|
||||
'audio/aac',
|
||||
'audio/flac',
|
||||
'audio/webm',
|
||||
// Documents
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
'application/json',
|
||||
'text/csv',
|
||||
'text/xml',
|
||||
'text/html',
|
||||
]);
|
||||
function stripMimeParameters(mimeType) {
|
||||
return mimeType.split(';')[0].trim();
|
||||
}
|
||||
function getExtension(filename) {
|
||||
if (!filename)
|
||||
return '';
|
||||
const dotIndex = filename.lastIndexOf('.');
|
||||
if (dotIndex < 0)
|
||||
return '';
|
||||
return filename.slice(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
export function inferConversationFileMimeType(filename) {
|
||||
const ext = getExtension(filename);
|
||||
return EXTENSION_MIME_MAP[ext] || 'application/octet-stream';
|
||||
}
|
||||
export function resolveConversationFileMimeType(mimeType, filename) {
|
||||
if (!mimeType) {
|
||||
return inferConversationFileMimeType(filename);
|
||||
}
|
||||
const stripped = stripMimeParameters(mimeType);
|
||||
if (stripped === 'application/octet-stream' || stripped === 'application/octet-stream;charset=utf-8') {
|
||||
return inferConversationFileMimeType(filename);
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
export function classifyConversationFileMimeType(mimeType) {
|
||||
if (!mimeType)
|
||||
return 'document';
|
||||
const stripped = stripMimeParameters(mimeType);
|
||||
if (stripped.startsWith('image/'))
|
||||
return 'image';
|
||||
if (stripped.startsWith('audio/'))
|
||||
return 'audio';
|
||||
return 'document';
|
||||
}
|
||||
export function detectConversationFileKind(file) {
|
||||
const resolved = resolveConversationFileMimeType(file.mimeType, file.filename);
|
||||
const classified = classifyConversationFileMimeType(resolved);
|
||||
if (classified !== 'document')
|
||||
return classified;
|
||||
// When we don't have a meaningful mimeType, rely on filename extension
|
||||
// to distinguish between known document types and truly unknown files.
|
||||
const normalizedMime = file.mimeType ? stripMimeParameters(file.mimeType) : '';
|
||||
if (!normalizedMime || normalizedMime === 'application/octet-stream') {
|
||||
const ext = getExtension(file.filename);
|
||||
if (!ext || !EXTENSION_MIME_MAP[ext])
|
||||
return 'unknown';
|
||||
}
|
||||
return 'document';
|
||||
}
|
||||
export function isSupportedConversationFileMimeType(mimeType) {
|
||||
const stripped = stripMimeParameters(mimeType);
|
||||
return SUPPORTED_MIME_TYPES.has(stripped);
|
||||
}
|
||||
export function buildConversationAcceptList(input) {
|
||||
const parts = [];
|
||||
if (input.document) {
|
||||
parts.push(...CONVERSATION_DOCUMENT_ACCEPT_PARTS);
|
||||
}
|
||||
if (input.image) {
|
||||
parts.push('image/*');
|
||||
}
|
||||
if (input.audio) {
|
||||
parts.push('audio/*');
|
||||
}
|
||||
return parts.join(',');
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildConversationAcceptList,
|
||||
classifyConversationFileMimeType,
|
||||
detectConversationFileKind,
|
||||
inferConversationFileMimeType,
|
||||
isSupportedConversationFileMimeType,
|
||||
resolveConversationFileMimeType,
|
||||
} from './conversationFileTypes.js';
|
||||
|
||||
describe('conversationFileTypes', () => {
|
||||
it('classifies conversation file kinds from mime type and filename', () => {
|
||||
expect(classifyConversationFileMimeType('image/png')).toBe('image');
|
||||
expect(classifyConversationFileMimeType('audio/mpeg')).toBe('audio');
|
||||
expect(classifyConversationFileMimeType('application/pdf')).toBe('document');
|
||||
|
||||
expect(detectConversationFileKind({ filename: 'paper.pdf', mimeType: null })).toBe('document');
|
||||
expect(detectConversationFileKind({ filename: 'photo.webp', mimeType: null })).toBe('image');
|
||||
expect(detectConversationFileKind({ filename: 'voice.mp3', mimeType: null })).toBe('audio');
|
||||
expect(detectConversationFileKind({ filename: 'unknown.bin', mimeType: null })).toBe('unknown');
|
||||
});
|
||||
|
||||
it('keeps supported mime and accept list rules in one place', () => {
|
||||
expect(isSupportedConversationFileMimeType('application/pdf')).toBe(true);
|
||||
expect(isSupportedConversationFileMimeType('image/jpeg')).toBe(true);
|
||||
expect(isSupportedConversationFileMimeType('audio/wav')).toBe(true);
|
||||
expect(isSupportedConversationFileMimeType('application/octet-stream')).toBe(false);
|
||||
|
||||
expect(buildConversationAcceptList({
|
||||
document: true,
|
||||
image: true,
|
||||
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');
|
||||
});
|
||||
|
||||
it('strips MIME parameters before matching support and kind rules', () => {
|
||||
expect(resolveConversationFileMimeType('text/plain; charset=utf-8', 'notes.txt')).toBe('text/plain');
|
||||
expect(classifyConversationFileMimeType('text/plain; charset=utf-8')).toBe('document');
|
||||
expect(detectConversationFileKind({
|
||||
filename: 'notes.txt',
|
||||
mimeType: 'text/plain; charset=utf-8',
|
||||
})).toBe('document');
|
||||
expect(isSupportedConversationFileMimeType('text/plain; charset=utf-8')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
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(',');
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Shortcut for `serde_json::from_value` with a descriptive error type.
|
||||
pub fn from_value<T: DeserializeOwned>(value: Value) -> Result<T, serde_json::Error> {
|
||||
serde_json::from_value(value)
|
||||
}
|
||||
|
||||
/// Shortcut for `serde_json::to_value` with a descriptive error type.
|
||||
pub fn to_value<T: Serialize>(value: &T) -> Result<Value, serde_json::Error> {
|
||||
serde_json::to_value(value)
|
||||
}
|
||||
|
||||
/// Pretty-print a JSON value as a string.
|
||||
pub fn pretty(value: &Value) -> String {
|
||||
serde_json::to_string_pretty(value).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Compact JSON string from a serializable value.
|
||||
pub fn to_string<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(value)
|
||||
}
|
||||
|
||||
/// Parse a JSON string into a value.
|
||||
pub fn from_str<T: DeserializeOwned>(s: &str) -> Result<T, serde_json::Error> {
|
||||
serde_json::from_str(s)
|
||||
}
|
||||
|
||||
/// Check whether a JSON value is null or absent.
|
||||
pub fn is_null(value: &Value) -> bool {
|
||||
value.is_null()
|
||||
}
|
||||
|
||||
/// Get a string field from a JSON object, returning None if missing or wrong type.
|
||||
pub fn get_string(value: &Value, key: &str) -> Option<String> {
|
||||
value.get(key).and_then(|v| v.as_str()).map(String::from)
|
||||
}
|
||||
|
||||
/// Get an i64 field from a JSON object.
|
||||
pub fn get_i64(value: &Value, key: &str) -> Option<i64> {
|
||||
value.get(key).and_then(|v| v.as_i64())
|
||||
}
|
||||
|
||||
/// Get a f64 field from a JSON object.
|
||||
pub fn get_f64(value: &Value, key: &str) -> Option<f64> {
|
||||
value.get(key).and_then(|v| v.as_f64())
|
||||
}
|
||||
|
||||
/// Get a bool field from a JSON object.
|
||||
pub fn get_bool(value: &Value, key: &str) -> Option<bool> {
|
||||
value.get(key).and_then(|v| v.as_bool())
|
||||
}
|
||||
|
||||
/// Get a nested value by a dotted path (e.g. "a.b.c").
|
||||
pub fn get_nested<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
|
||||
let mut current = value;
|
||||
for segment in path.split('.') {
|
||||
current = current.get(segment)?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_get_string() {
|
||||
let v = json!({"name": "hello"});
|
||||
assert_eq!(get_string(&v, "name"), Some("hello".into()));
|
||||
assert_eq!(get_string(&v, "missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_nested() {
|
||||
let v = json!({"a": {"b": {"c": 42}}});
|
||||
assert_eq!(get_nested(&v, "a.b.c"), Some(&json!(42)));
|
||||
assert_eq!(get_nested(&v, "a.b.missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip() {
|
||||
let val = json!({"x": 1, "y": "z"});
|
||||
let s = to_string(&val).unwrap();
|
||||
let back: Value = from_str(&s).unwrap();
|
||||
assert_eq!(val, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Shared utilities
|
||||
|
||||
pub mod json;
|
||||
pub mod pagination;
|
||||
pub mod sse;
|
||||
pub mod time;
|
||||
|
||||
pub use json::*;
|
||||
pub use pagination::*;
|
||||
pub use sse::*;
|
||||
pub use time::*;
|
||||
@@ -0,0 +1,71 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Query parameters for paginated list endpoints.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PaginationParams {
|
||||
pub page: Option<i64>,
|
||||
pub page_size: Option<i64>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
}
|
||||
|
||||
impl PaginationParams {
|
||||
/// Returns the page number (1-indexed), defaulting to 1.
|
||||
pub fn page(&self) -> i64 {
|
||||
self.page.filter(|&p| p > 0).unwrap_or(1)
|
||||
}
|
||||
|
||||
/// Returns the page size, defaulting to 20, clamped to [1, 100].
|
||||
pub fn page_size(&self) -> i64 {
|
||||
self.page_size
|
||||
.filter(|&s| s > 0)
|
||||
.map(|s| s.min(100))
|
||||
.unwrap_or(20)
|
||||
}
|
||||
|
||||
/// Returns the offset for SQL LIMIT/OFFSET queries.
|
||||
pub fn offset(&self) -> i64 {
|
||||
(self.page() - 1) * self.page_size()
|
||||
}
|
||||
|
||||
/// Returns the sort column, defaulting to "id".
|
||||
pub fn sort_by(&self) -> &str {
|
||||
self.sort_by.as_deref().unwrap_or("id")
|
||||
}
|
||||
|
||||
/// Returns the sort direction, defaulting to "asc".
|
||||
pub fn sort_order(&self) -> &str {
|
||||
self.sort_order.as_deref().unwrap_or("asc")
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard paginated response wrapper.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PaginatedResponse<T: Serialize> {
|
||||
pub data: Vec<T>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub page_size: i64,
|
||||
pub total_pages: i64,
|
||||
}
|
||||
|
||||
impl<T: Serialize> PaginatedResponse<T> {
|
||||
/// Build a paginated response from data, total count, and query params.
|
||||
pub fn new(data: Vec<T>, total: i64, params: &PaginationParams) -> Self {
|
||||
let page = params.page();
|
||||
let page_size = params.page_size();
|
||||
let total_pages = if total == 0 {
|
||||
0
|
||||
} else {
|
||||
(total as f64 / page_size as f64).ceil() as i64
|
||||
};
|
||||
|
||||
Self {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
export declare const PLATFORM_ALIASES: Record<string, string>;
|
||||
export declare function normalizePlatformAlias(platform: unknown): string;
|
||||
export declare function detectPlatformByUrlHint(url: string): string | undefined;
|
||||
@@ -1,65 +0,0 @@
|
||||
export const PLATFORM_ALIASES = {
|
||||
anthropic: 'claude',
|
||||
google: 'gemini',
|
||||
'chatgpt-codex': 'codex',
|
||||
'anti-gravity': 'antigravity',
|
||||
'gemini-cli': 'gemini-cli',
|
||||
cliproxyapi: 'cliproxyapi',
|
||||
'claude-code': 'claude',
|
||||
'claude cli': 'claude',
|
||||
'new api': 'new-api',
|
||||
newapi: 'new-api',
|
||||
'one api': 'one-api',
|
||||
oneapi: 'one-api',
|
||||
'one hub': 'one-hub',
|
||||
onehub: 'one-hub',
|
||||
'done hub': 'done-hub',
|
||||
donehub: 'done-hub',
|
||||
};
|
||||
const PLATFORM_URL_HINTS = [
|
||||
{ hostPattern: 'api.openai.com', platform: 'openai' },
|
||||
{ hostPattern: 'chatgpt.com', platform: 'codex' },
|
||||
{ hostPattern: 'api.anthropic.com', platform: 'claude' },
|
||||
{ hostPattern: 'generativelanguage.googleapis.com', platform: 'gemini' },
|
||||
{ hostPattern: 'cloudcode-pa.googleapis.com', platform: 'gemini-cli' },
|
||||
{ hostPattern: '127.0.0.1:8317', platform: 'cliproxyapi' },
|
||||
];
|
||||
export function normalizePlatformAlias(platform) {
|
||||
if (!platform || typeof platform !== 'string')
|
||||
return '';
|
||||
const raw = platform.trim();
|
||||
if (!raw)
|
||||
return '';
|
||||
const lower = raw.toLowerCase();
|
||||
// Check PLATFORM_ALIASES first
|
||||
if (Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, lower)) {
|
||||
return PLATFORM_ALIASES[lower];
|
||||
}
|
||||
// Generic normalization: replace underscores and spaces with hyphens
|
||||
const hyphenated = lower.replace(/[_ ]+/g, '-');
|
||||
// Check aliases again with hyphenated form
|
||||
if (Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, hyphenated)) {
|
||||
return PLATFORM_ALIASES[hyphenated];
|
||||
}
|
||||
// Remove hyphens between word characters (e.g. anti-gravity -> antigravity)
|
||||
return hyphenated.replace(/-/g, '');
|
||||
}
|
||||
export function detectPlatformByUrlHint(url) {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
for (const hint of PLATFORM_URL_HINTS) {
|
||||
if (lowerUrl.includes(hint.hostPattern)) {
|
||||
// Make sure the host appears as a proper hostname segment (prevent
|
||||
// forged queries like ?next=https://api.openai.com from matching).
|
||||
const hostIndex = lowerUrl.indexOf(hint.hostPattern);
|
||||
if (hostIndex >= 0) {
|
||||
// Check that what precedes the host pattern is either the start
|
||||
// of the URL or a valid URL separator (:// or /).
|
||||
const preceding = lowerUrl[hostIndex - 1];
|
||||
if (hostIndex === 0 || preceding === '/' || preceding === ':') {
|
||||
return hint.platform;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
detectPlatformByUrlHint,
|
||||
normalizePlatformAlias,
|
||||
} from './platformIdentity.js';
|
||||
|
||||
describe('platformIdentity', () => {
|
||||
it('normalizes shared platform aliases', () => {
|
||||
expect(normalizePlatformAlias('chatgpt-codex')).toBe('codex');
|
||||
expect(normalizePlatformAlias('anti-gravity')).toBe('antigravity');
|
||||
expect(normalizePlatformAlias('one api')).toBe('one-api');
|
||||
expect(normalizePlatformAlias('')).toBe('');
|
||||
});
|
||||
|
||||
it('detects platform by well-known url hints', () => {
|
||||
expect(detectPlatformByUrlHint('https://api.openai.com/v1/models')).toBe('openai');
|
||||
expect(detectPlatformByUrlHint('https://chatgpt.com/backend-api/codex')).toBe('codex');
|
||||
expect(detectPlatformByUrlHint('https://api.anthropic.com/v1/messages')).toBe('claude');
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
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
-10
@@ -1,10 +0,0 @@
|
||||
export type ProxyLogUsageSource = 'upstream' | 'self-log' | 'unknown' | null;
|
||||
export type ParsedProxyLogMetadata = {
|
||||
clientKind: string | null;
|
||||
sessionId: string | null;
|
||||
downstreamPath: string | null;
|
||||
upstreamPath: string | null;
|
||||
usageSource: ProxyLogUsageSource;
|
||||
messageText: string;
|
||||
};
|
||||
export declare function parseProxyLogMetadata(rawMessage: string): ParsedProxyLogMetadata;
|
||||
@@ -1,42 +0,0 @@
|
||||
const METADATA_PREFIX_REGEX = /^\[(\w+):([^\]]*)\]\s*/i;
|
||||
export function parseProxyLogMetadata(rawMessage) {
|
||||
let remaining = rawMessage;
|
||||
let clientKind = null;
|
||||
let sessionId = null;
|
||||
let downstreamPath = null;
|
||||
let upstreamPath = null;
|
||||
let usageSource = null;
|
||||
while (true) {
|
||||
const match = remaining.match(METADATA_PREFIX_REGEX);
|
||||
if (!match)
|
||||
break;
|
||||
const key = match[1].toLowerCase();
|
||||
const value = match[2] || null;
|
||||
switch (key) {
|
||||
case 'client':
|
||||
clientKind = value;
|
||||
break;
|
||||
case 'session':
|
||||
sessionId = value;
|
||||
break;
|
||||
case 'downstream':
|
||||
downstreamPath = value;
|
||||
break;
|
||||
case 'upstream':
|
||||
upstreamPath = value;
|
||||
break;
|
||||
case 'usage':
|
||||
usageSource = value;
|
||||
break;
|
||||
}
|
||||
remaining = remaining.slice(match[0].length);
|
||||
}
|
||||
return {
|
||||
clientKind,
|
||||
sessionId,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
usageSource,
|
||||
messageText: remaining,
|
||||
};
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseProxyLogMetadata } from './proxyLogMeta.js';
|
||||
|
||||
describe('proxyLogMeta', () => {
|
||||
it('parses all supported proxy log prefixes', () => {
|
||||
expect(parseProxyLogMetadata(
|
||||
'[client:codex] [session:turn-1] [downstream:/v1/responses] [upstream:/responses] [usage:self-log] boom',
|
||||
)).toEqual({
|
||||
clientKind: 'codex',
|
||||
sessionId: 'turn-1',
|
||||
downstreamPath: '/v1/responses',
|
||||
upstreamPath: '/responses',
|
||||
usageSource: 'self-log',
|
||||
messageText: 'boom',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps plain message text when no metadata prefixes exist', () => {
|
||||
expect(parseProxyLogMetadata('network timeout')).toEqual({
|
||||
clientKind: null,
|
||||
sessionId: null,
|
||||
downstreamPath: null,
|
||||
upstreamPath: null,
|
||||
usageSource: null,
|
||||
messageText: 'network timeout',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles mixed-case, partial, reordered, and empty metadata safely', () => {
|
||||
expect(parseProxyLogMetadata('[CLIENT:codex] [SESSION:turn-1] boom')).toEqual({
|
||||
clientKind: 'codex',
|
||||
sessionId: 'turn-1',
|
||||
downstreamPath: null,
|
||||
upstreamPath: null,
|
||||
usageSource: null,
|
||||
messageText: 'boom',
|
||||
});
|
||||
|
||||
expect(parseProxyLogMetadata('[client:codex] boom')).toEqual({
|
||||
clientKind: 'codex',
|
||||
sessionId: null,
|
||||
downstreamPath: null,
|
||||
upstreamPath: null,
|
||||
usageSource: null,
|
||||
messageText: 'boom',
|
||||
});
|
||||
|
||||
expect(parseProxyLogMetadata('[upstream:/x] [client:codex] [session:1] msg')).toEqual({
|
||||
clientKind: 'codex',
|
||||
sessionId: '1',
|
||||
downstreamPath: null,
|
||||
upstreamPath: '/x',
|
||||
usageSource: null,
|
||||
messageText: 'msg',
|
||||
});
|
||||
|
||||
expect(parseProxyLogMetadata('')).toEqual({
|
||||
clientKind: null,
|
||||
sessionId: null,
|
||||
downstreamPath: null,
|
||||
upstreamPath: null,
|
||||
usageSource: null,
|
||||
messageText: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
export type ProxyLogUsageSource = 'upstream' | 'self-log' | 'unknown' | null;
|
||||
|
||||
export type ParsedProxyLogMetadata = {
|
||||
clientKind: string | null;
|
||||
sessionId: string | null;
|
||||
downstreamPath: string | null;
|
||||
upstreamPath: string | null;
|
||||
usageSource: ProxyLogUsageSource;
|
||||
messageText: string;
|
||||
};
|
||||
|
||||
const METADATA_PREFIX_REGEX = /^\[(\w+):([^\]]*)\]\s*/i;
|
||||
|
||||
export function parseProxyLogMetadata(rawMessage: string): ParsedProxyLogMetadata {
|
||||
let remaining = rawMessage;
|
||||
|
||||
let clientKind: string | null = null;
|
||||
let sessionId: string | null = null;
|
||||
let downstreamPath: string | null = null;
|
||||
let upstreamPath: string | null = null;
|
||||
let usageSource: ProxyLogUsageSource = null;
|
||||
|
||||
while (true) {
|
||||
const match = remaining.match(METADATA_PREFIX_REGEX);
|
||||
if (!match) break;
|
||||
|
||||
const key = match[1].toLowerCase();
|
||||
const value = match[2] || null;
|
||||
|
||||
switch (key) {
|
||||
case 'client':
|
||||
clientKind = value;
|
||||
break;
|
||||
case 'session':
|
||||
sessionId = value;
|
||||
break;
|
||||
case 'downstream':
|
||||
downstreamPath = value;
|
||||
break;
|
||||
case 'upstream':
|
||||
upstreamPath = value;
|
||||
break;
|
||||
case 'usage':
|
||||
usageSource = value as ProxyLogUsageSource;
|
||||
break;
|
||||
}
|
||||
|
||||
remaining = remaining.slice(match[0].length);
|
||||
}
|
||||
|
||||
return {
|
||||
clientKind,
|
||||
sessionId,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
usageSource,
|
||||
messageText: remaining,
|
||||
};
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
export interface SiteInitializationPreset {
|
||||
id: string;
|
||||
platform: 'openai' | 'claude';
|
||||
defaultUrl: string;
|
||||
initialSegment: string;
|
||||
recommendedSkipModelFetch: boolean;
|
||||
recommendedModels: string[];
|
||||
}
|
||||
export declare function getSiteInitializationPreset(id: string): SiteInitializationPreset | undefined;
|
||||
export declare function listSiteInitializationPresets(): SiteInitializationPreset[];
|
||||
export declare function detectSiteInitializationPreset(url: string, knownPlatform?: string): {
|
||||
id: string;
|
||||
platform: 'openai' | 'claude';
|
||||
} | null;
|
||||
@@ -1,156 +0,0 @@
|
||||
const presets = [
|
||||
{
|
||||
id: 'codingplan-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['qwen3-coder-plus', 'qwen3.5-plus'],
|
||||
},
|
||||
{
|
||||
id: 'codingplan-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['qwen3-coder-next', 'glm-5'],
|
||||
},
|
||||
{
|
||||
id: 'zhipu-coding-plan-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air'],
|
||||
},
|
||||
{
|
||||
id: 'zhipu-coding-plan-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://open.bigmodel.cn/api/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air'],
|
||||
},
|
||||
{
|
||||
id: 'deepseek-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.deepseek.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
{
|
||||
id: 'deepseek-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.deepseek.com/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
{
|
||||
id: 'moonshot-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.moonshot.cn/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['kimi-k2.5', 'kimi-k2', 'kimi-k2-thinking'],
|
||||
},
|
||||
{
|
||||
id: 'moonshot-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.moonshot.cn/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
{
|
||||
id: 'minimax-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.minimaxi.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
{
|
||||
id: 'minimax-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.minimaxi.com/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1'],
|
||||
},
|
||||
{
|
||||
id: 'modelscope-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api-inference.modelscope.cn/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [
|
||||
'Qwen/Qwen3-32B',
|
||||
'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
'deepseek-ai/DeepSeek-V3.2',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'modelscope-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api-inference.modelscope.cn',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
{
|
||||
id: 'doubao-coding-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['ark-code-latest', 'doubao-seed-2.0-code', 'doubao-seed-2.0-pro'],
|
||||
},
|
||||
];
|
||||
const urlMatchers = [
|
||||
{ urlPrefix: 'https://coding.dashscope.aliyuncs.com/v1', id: 'codingplan-openai', platform: 'openai' },
|
||||
{ urlPrefix: 'https://coding.dashscope.aliyuncs.com/apps/anthropic', id: 'codingplan-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://open.bigmodel.cn/api/coding/paas/v4', id: 'zhipu-coding-plan-openai', platform: 'openai', stripTrailingSlash: true },
|
||||
{ urlPrefix: 'https://api.deepseek.com/v1', id: 'deepseek-openai', platform: 'openai' },
|
||||
{ urlPrefix: 'https://api.deepseek.com/anthropic', id: 'deepseek-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://api.moonshot.cn/v1', id: 'moonshot-openai', platform: 'openai', stripTrailingSlash: true },
|
||||
{ urlPrefix: 'https://api.moonshot.cn/anthropic', id: 'moonshot-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://api.minimaxi.com/v1', id: 'minimax-openai', platform: 'openai' },
|
||||
{ urlPrefix: 'https://api.minimaxi.com/anthropic', id: 'minimax-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://api-inference.modelscope.cn/v1', id: 'modelscope-openai', platform: 'openai' },
|
||||
{ urlPrefix: 'https://api-inference.modelscope.cn', id: 'modelscope-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/coding/v3', id: 'doubao-coding-openai', platform: 'openai' },
|
||||
];
|
||||
// Canonical root → preset mapping for reselection when platform is known
|
||||
const rootReselect = [
|
||||
{ rootPrefix: 'https://api.deepseek.com', id: 'deepseek-openai' },
|
||||
{ rootPrefix: 'https://coding.dashscope.aliyuncs.com', id: 'codingplan-openai' },
|
||||
];
|
||||
export function getSiteInitializationPreset(id) {
|
||||
return presets.find((p) => p.id === id);
|
||||
}
|
||||
export function listSiteInitializationPresets() {
|
||||
return [...presets];
|
||||
}
|
||||
export function detectSiteInitializationPreset(url, knownPlatform) {
|
||||
// Try exact URL prefix matching
|
||||
const normalizedUrl = url.replace(/\/+$/, '');
|
||||
for (const matcher of urlMatchers) {
|
||||
const matchUrl = matcher.stripTrailingSlash ? normalizedUrl : url;
|
||||
if (matchUrl.startsWith(matcher.urlPrefix)) {
|
||||
return { id: matcher.id, platform: matcher.platform };
|
||||
}
|
||||
}
|
||||
// Reselect from canonical root when platform is known
|
||||
if (knownPlatform === 'openai') {
|
||||
for (const entry of rootReselect) {
|
||||
if (url.startsWith(entry.rootPrefix)) {
|
||||
const preset = getSiteInitializationPreset(entry.id);
|
||||
if (preset)
|
||||
return { id: preset.id, platform: preset.platform };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
detectSiteInitializationPreset,
|
||||
getSiteInitializationPreset,
|
||||
listSiteInitializationPresets,
|
||||
} from './siteInitializationPresets.js';
|
||||
|
||||
describe('siteInitializationPresets', () => {
|
||||
it('exposes vendor presets with recommended API-key-first initialization', () => {
|
||||
const presetIds = listSiteInitializationPresets().map((preset) => preset.id);
|
||||
expect(presetIds).toEqual(expect.arrayContaining([
|
||||
'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',
|
||||
]));
|
||||
|
||||
const openaiPreset = getSiteInitializationPreset('codingplan-openai');
|
||||
expect(openaiPreset).toMatchObject({
|
||||
id: 'codingplan-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
});
|
||||
expect(openaiPreset?.recommendedModels).toEqual(expect.arrayContaining(['qwen3-coder-plus', 'qwen3.5-plus']));
|
||||
|
||||
const claudePreset = getSiteInitializationPreset('codingplan-claude');
|
||||
expect(claudePreset).toMatchObject({
|
||||
id: 'codingplan-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
});
|
||||
expect(claudePreset?.recommendedModels).toEqual(expect.arrayContaining(['qwen3-coder-next', 'glm-5']));
|
||||
|
||||
const zhipuOpenAiPreset = getSiteInitializationPreset('zhipu-coding-plan-openai');
|
||||
expect(zhipuOpenAiPreset).toMatchObject({
|
||||
id: 'zhipu-coding-plan-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
});
|
||||
expect(zhipuOpenAiPreset?.recommendedModels).toEqual(['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air']);
|
||||
|
||||
const zhipuClaudePreset = getSiteInitializationPreset('zhipu-coding-plan-claude');
|
||||
expect(zhipuClaudePreset).toMatchObject({
|
||||
id: 'zhipu-coding-plan-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://open.bigmodel.cn/api/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
});
|
||||
expect(zhipuClaudePreset?.recommendedModels).toEqual(['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air']);
|
||||
|
||||
const deepseekPreset = getSiteInitializationPreset('deepseek-openai');
|
||||
expect(deepseekPreset).toMatchObject({
|
||||
id: 'deepseek-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.deepseek.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
});
|
||||
expect(deepseekPreset?.recommendedModels).toEqual(['deepseek-chat', 'deepseek-reasoner']);
|
||||
|
||||
const moonshotPreset = getSiteInitializationPreset('moonshot-openai');
|
||||
expect(moonshotPreset).toMatchObject({
|
||||
id: 'moonshot-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.moonshot.cn/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
});
|
||||
expect(moonshotPreset?.recommendedModels).toEqual(['kimi-k2.5', 'kimi-k2', 'kimi-k2-thinking']);
|
||||
|
||||
const minimaxPreset = getSiteInitializationPreset('minimax-claude');
|
||||
expect(minimaxPreset).toMatchObject({
|
||||
id: 'minimax-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.minimaxi.com/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
});
|
||||
expect(minimaxPreset?.recommendedModels).toEqual(['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1']);
|
||||
|
||||
const modelscopePreset = getSiteInitializationPreset('modelscope-openai');
|
||||
expect(modelscopePreset).toMatchObject({
|
||||
id: 'modelscope-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api-inference.modelscope.cn/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
});
|
||||
expect(modelscopePreset?.recommendedModels).toEqual([
|
||||
'Qwen/Qwen3-32B',
|
||||
'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
'deepseek-ai/DeepSeek-V3.2',
|
||||
]);
|
||||
|
||||
const doubaoPreset = getSiteInitializationPreset('doubao-coding-openai');
|
||||
expect(doubaoPreset).toMatchObject({
|
||||
id: 'doubao-coding-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
});
|
||||
expect(doubaoPreset?.recommendedModels).toEqual([
|
||||
'ark-code-latest',
|
||||
'doubao-seed-2.0-code',
|
||||
'doubao-seed-2.0-pro',
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects Aliyun CodingPlan endpoints by URL', () => {
|
||||
expect(detectSiteInitializationPreset('https://coding.dashscope.aliyuncs.com/v1')).toMatchObject({
|
||||
id: 'codingplan-openai',
|
||||
platform: 'openai',
|
||||
});
|
||||
expect(detectSiteInitializationPreset('https://coding.dashscope.aliyuncs.com/apps/anthropic')).toMatchObject({
|
||||
id: 'codingplan-claude',
|
||||
platform: 'claude',
|
||||
});
|
||||
expect(detectSiteInitializationPreset('https://api.openai.com/v1')).toBeNull();
|
||||
});
|
||||
|
||||
it('detects Zhipu Coding Plan OpenAI endpoints by URL but keeps Claude-compatible entry manual-only', () => {
|
||||
expect(detectSiteInitializationPreset('https://open.bigmodel.cn/api/coding/paas/v4')).toMatchObject({
|
||||
id: 'zhipu-coding-plan-openai',
|
||||
platform: 'openai',
|
||||
});
|
||||
expect(detectSiteInitializationPreset('https://open.bigmodel.cn/api/coding/paas/v4/')).toMatchObject({
|
||||
id: 'zhipu-coding-plan-openai',
|
||||
platform: 'openai',
|
||||
});
|
||||
expect(detectSiteInitializationPreset('https://open.bigmodel.cn/api/anthropic')).toBeNull();
|
||||
});
|
||||
|
||||
it('detects vendor-specific OpenAI and Claude endpoints by URL', () => {
|
||||
expect(detectSiteInitializationPreset('https://api.deepseek.com/v1')).toMatchObject({
|
||||
id: 'deepseek-openai',
|
||||
platform: 'openai',
|
||||
});
|
||||
expect(detectSiteInitializationPreset('https://api.deepseek.com/anthropic')).toMatchObject({
|
||||
id: 'deepseek-claude',
|
||||
platform: 'claude',
|
||||
});
|
||||
|
||||
expect(detectSiteInitializationPreset('https://api.moonshot.cn/v1/')).toMatchObject({
|
||||
id: 'moonshot-openai',
|
||||
platform: 'openai',
|
||||
});
|
||||
expect(detectSiteInitializationPreset('https://api.moonshot.cn/anthropic')).toMatchObject({
|
||||
id: 'moonshot-claude',
|
||||
platform: 'claude',
|
||||
});
|
||||
|
||||
expect(detectSiteInitializationPreset('https://api.minimaxi.com/v1')).toMatchObject({
|
||||
id: 'minimax-openai',
|
||||
platform: 'openai',
|
||||
});
|
||||
expect(detectSiteInitializationPreset('https://api.minimaxi.com/anthropic')).toMatchObject({
|
||||
id: 'minimax-claude',
|
||||
platform: 'claude',
|
||||
});
|
||||
|
||||
expect(detectSiteInitializationPreset('https://api-inference.modelscope.cn/v1')).toMatchObject({
|
||||
id: 'modelscope-openai',
|
||||
platform: 'openai',
|
||||
});
|
||||
expect(detectSiteInitializationPreset('https://api-inference.modelscope.cn')).toMatchObject({
|
||||
id: 'modelscope-claude',
|
||||
platform: 'claude',
|
||||
});
|
||||
|
||||
expect(detectSiteInitializationPreset('https://ark.cn-beijing.volces.com/api/coding/v3')).toMatchObject({
|
||||
id: 'doubao-coding-openai',
|
||||
platform: 'openai',
|
||||
});
|
||||
});
|
||||
|
||||
it('reselects openai presets from canonicalized root urls when platform is already known', () => {
|
||||
expect(detectSiteInitializationPreset('https://api.deepseek.com', 'openai')).toMatchObject({
|
||||
id: 'deepseek-openai',
|
||||
platform: 'openai',
|
||||
});
|
||||
expect(detectSiteInitializationPreset('https://coding.dashscope.aliyuncs.com', 'openai')).toMatchObject({
|
||||
id: 'codingplan-openai',
|
||||
platform: 'openai',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
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
@@ -1,7 +0,0 @@
|
||||
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;
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Known API request path prefixes that should be auto-stripped to the host root
|
||||
* for persistence purposes. These are well-known endpoint paths that users paste
|
||||
* from upstream API documentation but should be stored as the base URL only.
|
||||
*/
|
||||
const knownSuffixPrefixes = ['/v1/', '/v2/', '/api/'];
|
||||
/**
|
||||
* Known semantic paths that should be preserved without warnings.
|
||||
* These are deliberate application routes rather than API endpoint paths.
|
||||
*/
|
||||
const semanticPathPrefixes = ['/backend-api/', '/apps/', '/coding/'];
|
||||
function isApiPathPrefix(pathname) {
|
||||
return pathname.startsWith('/api/') || pathname.startsWith('/v1/') || pathname.startsWith('/v2/');
|
||||
}
|
||||
function isSemanticPath(pathname) {
|
||||
return semanticPathPrefixes.some((prefix) => pathname.startsWith(prefix));
|
||||
}
|
||||
function hasKnownSuffixPrefix(pathname) {
|
||||
return knownSuffixPrefixes.some((prefix) => pathname.startsWith(prefix) || pathname.includes(prefix));
|
||||
}
|
||||
function normalizeUrl(raw) {
|
||||
if (!raw || typeof raw !== 'string')
|
||||
return '';
|
||||
let url = raw.trim();
|
||||
// Add scheme if missing
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
let normalized = parsed.protocol + '//' + parsed.host + parsed.pathname;
|
||||
// Remove trailing slash (except for root)
|
||||
if (normalized.endsWith('/') && normalized !== parsed.protocol + '//' + parsed.host + '/') {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
catch {
|
||||
// If URL parsing fails, return trimmed raw version
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
export function analyzePrimarySiteUrl(raw) {
|
||||
if (!raw || typeof raw !== 'string' || raw.trim() === '') {
|
||||
return { canonicalUrl: '', persistedUrl: '', action: 'unchanged', matchedPath: '' };
|
||||
}
|
||||
const canonicalUrl = normalizeUrl(raw);
|
||||
// Try to parse the URL
|
||||
let url = raw.trim();
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
}
|
||||
catch {
|
||||
return { canonicalUrl, persistedUrl: canonicalUrl, action: 'unchanged', matchedPath: '' };
|
||||
}
|
||||
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
|
||||
// Root URL - no path beyond /
|
||||
if (pathname === '/') {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
action: 'unchanged',
|
||||
matchedPath: '/',
|
||||
};
|
||||
}
|
||||
// Known semantic paths (e.g., /backend-api/codex) - preserve as-is
|
||||
if (isSemanticPath(pathname)) {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
action: 'preserve_semantic_path',
|
||||
matchedPath: pathname,
|
||||
};
|
||||
}
|
||||
// API-prefixed hostnames (e.g., api.example.com/api/v1/models) - preserve as warning
|
||||
const hostname = parsed.hostname;
|
||||
if ((hostname.startsWith('api.') || hostname.startsWith('gateway.')) && isApiPathPrefix(pathname)) {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
action: 'preserve_api_path',
|
||||
matchedPath: pathname,
|
||||
};
|
||||
}
|
||||
// Known API request suffixes - auto-strip to root
|
||||
if (hasKnownSuffixPrefix(pathname)) {
|
||||
const rootUrl = parsed.protocol + '//' + parsed.host;
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: rootUrl,
|
||||
action: 'auto_strip_known_api_suffix',
|
||||
matchedPath: pathname,
|
||||
};
|
||||
}
|
||||
// Unknown non-root path - preserve as warning
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
action: 'preserve_unknown_path',
|
||||
matchedPath: pathname,
|
||||
};
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { analyzePrimarySiteUrl } from './sitePrimaryUrl.js';
|
||||
|
||||
describe('sitePrimaryUrl', () => {
|
||||
it('returns unchanged for empty, nullish, and invalid inputs', () => {
|
||||
expect(analyzePrimarySiteUrl('')).toMatchObject({
|
||||
canonicalUrl: '',
|
||||
persistedUrl: '',
|
||||
action: 'unchanged',
|
||||
matchedPath: '',
|
||||
});
|
||||
expect(analyzePrimarySiteUrl(null as unknown as string)).toMatchObject({
|
||||
canonicalUrl: '',
|
||||
persistedUrl: '',
|
||||
action: 'unchanged',
|
||||
matchedPath: '',
|
||||
});
|
||||
expect(analyzePrimarySiteUrl(undefined as unknown as string)).toMatchObject({
|
||||
canonicalUrl: '',
|
||||
persistedUrl: '',
|
||||
action: 'unchanged',
|
||||
matchedPath: '',
|
||||
});
|
||||
expect(analyzePrimarySiteUrl(' not a valid url/// ')).toMatchObject({
|
||||
canonicalUrl: 'not a valid url',
|
||||
persistedUrl: 'not a valid url',
|
||||
action: 'unchanged',
|
||||
matchedPath: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns unchanged for root-only urls', () => {
|
||||
expect(analyzePrimarySiteUrl('https://example.com/')).toMatchObject({
|
||||
canonicalUrl: 'https://example.com',
|
||||
persistedUrl: 'https://example.com',
|
||||
action: 'unchanged',
|
||||
matchedPath: '/',
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-strips known non-api request suffixes to the host root', () => {
|
||||
expect(analyzePrimarySiteUrl('https://api.openai.com/v1/messages?trace=1#frag')).toMatchObject({
|
||||
canonicalUrl: 'https://api.openai.com/v1/messages',
|
||||
persistedUrl: 'https://api.openai.com',
|
||||
action: 'auto_strip_known_api_suffix',
|
||||
matchedPath: '/v1/messages',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves api-prefixed paths and marks them as warnings', () => {
|
||||
expect(analyzePrimarySiteUrl('api.example.com/api/v1/models')).toMatchObject({
|
||||
canonicalUrl: 'https://api.example.com/api/v1/models',
|
||||
persistedUrl: 'https://api.example.com/api/v1/models',
|
||||
action: 'preserve_api_path',
|
||||
matchedPath: '/api/v1/models',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves known semantic paths without warnings', () => {
|
||||
expect(analyzePrimarySiteUrl('https://chatgpt.com/backend-api/codex/')).toMatchObject({
|
||||
canonicalUrl: 'https://chatgpt.com/backend-api/codex',
|
||||
persistedUrl: 'https://chatgpt.com/backend-api/codex',
|
||||
action: 'preserve_semantic_path',
|
||||
matchedPath: '/backend-api/codex',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves unknown non-api paths and marks them as warnings', () => {
|
||||
expect(analyzePrimarySiteUrl('https://gateway.example.com/custom-base')).toMatchObject({
|
||||
canonicalUrl: 'https://gateway.example.com/custom-base',
|
||||
persistedUrl: 'https://gateway.example.com/custom-base',
|
||||
action: 'preserve_unknown_path',
|
||||
matchedPath: '/custom-base',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use axum::response::sse::Event;
|
||||
use serde::Serialize;
|
||||
use std::convert::Infallible;
|
||||
|
||||
/// Create an SSE event from a serializable data type.
|
||||
pub fn sse_event<T: Serialize>(event_type: &str, data: &T) -> Result<Event, Infallible> {
|
||||
Ok(Event::default()
|
||||
.event(event_type)
|
||||
.data(serde_json::to_string(data).unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// Angles for character prompts (front, back, left, right).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Angles {
|
||||
pub front: String,
|
||||
pub back: String,
|
||||
pub left: String,
|
||||
pub right: String,
|
||||
}
|
||||
|
||||
/// SSE event data discriminated by the `type` field.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum SseData {
|
||||
Text {
|
||||
content: String,
|
||||
},
|
||||
CharacterPrompts {
|
||||
character: String,
|
||||
angles: Angles,
|
||||
},
|
||||
Image {
|
||||
url: String,
|
||||
character: String,
|
||||
angle: String,
|
||||
},
|
||||
Scene {
|
||||
id: u32,
|
||||
description: String,
|
||||
prompt: String,
|
||||
},
|
||||
Tts {
|
||||
character: String,
|
||||
text: String,
|
||||
url: String,
|
||||
},
|
||||
ComfyuiWorkflow {
|
||||
json: serde_json::Value,
|
||||
},
|
||||
Done,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Returns the current UTC time as a `DateTime<Utc>`.
|
||||
pub fn now() -> DateTime<Utc> {
|
||||
Utc::now()
|
||||
}
|
||||
|
||||
/// Returns the current Unix timestamp in seconds.
|
||||
pub fn timestamp() -> i64 {
|
||||
Utc::now().timestamp()
|
||||
}
|
||||
|
||||
/// Returns the current Unix timestamp in milliseconds.
|
||||
pub fn timestamp_ms() -> i64 {
|
||||
Utc::now().timestamp_millis()
|
||||
}
|
||||
|
||||
/// Format a `DateTime<Utc>` as an RFC 3339 / ISO 8601 string.
|
||||
pub fn format_rfc3339(dt: &DateTime<Utc>) -> String {
|
||||
dt.to_rfc3339()
|
||||
}
|
||||
|
||||
/// Format a `DateTime<Utc>` as a human-friendly string.
|
||||
pub fn format_human(dt: &DateTime<Utc>) -> String {
|
||||
dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()
|
||||
}
|
||||
|
||||
/// Parse an RFC 3339 / ISO 8601 string into a `DateTime<Utc>`.
|
||||
pub fn parse_rfc3339(s: &str) -> Result<DateTime<Utc>, chrono::ParseError> {
|
||||
s.parse::<DateTime<Utc>>()
|
||||
}
|
||||
|
||||
/// Convert a Unix timestamp (seconds) to `DateTime<Utc>`.
|
||||
pub fn from_timestamp(secs: i64) -> Option<DateTime<Utc>> {
|
||||
DateTime::from_timestamp(secs, 0)
|
||||
}
|
||||
|
||||
/// Convert a `SystemTime` to `DateTime<Utc>`.
|
||||
pub fn from_system_time(t: SystemTime) -> Option<DateTime<Utc>> {
|
||||
match t.duration_since(UNIX_EPOCH) {
|
||||
Ok(d) => DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `NaiveDateTime` (assumed UTC) to `DateTime<Utc>`.
|
||||
pub fn from_naive_utc(dt: NaiveDateTime) -> DateTime<Utc> {
|
||||
DateTime::from_naive_utc_and_offset(dt, Utc)
|
||||
}
|
||||
|
||||
/// Compute the duration between two timestamps.
|
||||
pub fn duration_secs(start: DateTime<Utc>, end: DateTime<Utc>) -> f64 {
|
||||
(end - start).num_milliseconds() as f64 / 1000.0
|
||||
}
|
||||
|
||||
/// Check if a timestamp is older than the given duration.
|
||||
pub fn is_older_than(dt: DateTime<Utc>, duration: Duration) -> bool {
|
||||
let cutoff = Utc::now() - chrono::Duration::from_std(duration).unwrap_or_default();
|
||||
dt < cutoff
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_now_and_timestamp() {
|
||||
let ts = timestamp();
|
||||
assert!(ts > 1_700_000_000); // sanity check for 2024+
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_roundtrip() {
|
||||
let dt = now();
|
||||
let s = format_rfc3339(&dt);
|
||||
let parsed = parse_rfc3339(&s).unwrap();
|
||||
assert_eq!(dt.timestamp(), parsed.timestamp());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_timestamp() {
|
||||
let dt = from_timestamp(0).unwrap();
|
||||
assert_eq!(dt.timestamp(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_older_than() {
|
||||
let old = Utc::now() - chrono::Duration::hours(2);
|
||||
assert!(is_older_than(old, Duration::from_secs(3600)));
|
||||
}
|
||||
}
|
||||
Vendored
-26
@@ -1,26 +0,0 @@
|
||||
export declare 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 declare function normalizeTokenRouteMode(routeMode: unknown): RouteMode;
|
||||
@@ -1,6 +0,0 @@
|
||||
export const ROUTE_DECISION_REFRESH_TASK_TYPE = 'route-decision.refresh';
|
||||
export function normalizeTokenRouteMode(routeMode) {
|
||||
if (routeMode === 'explicit_group')
|
||||
return 'explicit_group';
|
||||
return 'pattern';
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('token route contract', () => {
|
||||
it('exports the route decision refresh task type', async () => {
|
||||
const { ROUTE_DECISION_REFRESH_TASK_TYPE } = await import('./tokenRouteContract.js');
|
||||
|
||||
expect(ROUTE_DECISION_REFRESH_TASK_TYPE).toBe('route-decision.refresh');
|
||||
});
|
||||
|
||||
it('normalizes unknown route modes to pattern', async () => {
|
||||
const { normalizeTokenRouteMode } = await import('./tokenRouteContract.js');
|
||||
|
||||
expect(normalizeTokenRouteMode('explicit_group')).toBe('explicit_group');
|
||||
expect(normalizeTokenRouteMode('pattern')).toBe('pattern');
|
||||
expect(normalizeTokenRouteMode('anything-else')).toBe('pattern');
|
||||
expect(normalizeTokenRouteMode(null)).toBe('pattern');
|
||||
expect(normalizeTokenRouteMode(undefined)).toBe('pattern');
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
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';
|
||||
}
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
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: TokenRoutePatternMatcher | null;
|
||||
error: string | null;
|
||||
};
|
||||
export declare function matchesTokenRouteModelPattern(model: string, pattern: string): boolean;
|
||||
@@ -1,59 +0,0 @@
|
||||
const REGEX_PREFIX = /^re:/i;
|
||||
export function isTokenRouteRegexPattern(pattern) {
|
||||
return REGEX_PREFIX.test(pattern.trim());
|
||||
}
|
||||
export function isExactTokenRouteModelPattern(pattern) {
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed)
|
||||
return false;
|
||||
if (REGEX_PREFIX.test(trimmed))
|
||||
return false;
|
||||
return !/[?*]/.test(trimmed);
|
||||
}
|
||||
export function parseTokenRouteRegexPattern(pattern) {
|
||||
const match = REGEX_PREFIX.exec(pattern.trim());
|
||||
if (!match) {
|
||||
return { regex: null, error: 'Pattern does not start with re:' };
|
||||
}
|
||||
const regexBody = pattern.slice(match[0].length);
|
||||
if (!regexBody) {
|
||||
return { regex: null, error: 'Empty regex pattern' };
|
||||
}
|
||||
// Safety validation: reject lookahead/lookbehind/non-capturing groups
|
||||
if (/\(\?/.test(regexBody)) {
|
||||
return { regex: null, error: 'Unsupported regex construct (?…)' };
|
||||
}
|
||||
// Reject non-digit, non-dot backslash shorthands (e.g. \s, \w, \b)
|
||||
if (/\\[a-ce-z]/i.test(regexBody)) {
|
||||
return { regex: null, error: 'Unsupported regex escape sequence' };
|
||||
}
|
||||
try {
|
||||
const regex = new RegExp(regexBody);
|
||||
return { regex: { test: (value) => regex.test(value) }, error: null };
|
||||
}
|
||||
catch {
|
||||
return { regex: null, error: 'Invalid regex pattern' };
|
||||
}
|
||||
}
|
||||
export function matchesTokenRouteModelPattern(model, pattern) {
|
||||
const trimmedModel = model.trim();
|
||||
const trimmedPattern = pattern.trim();
|
||||
if (!trimmedModel || !trimmedPattern)
|
||||
return false;
|
||||
// Regex pattern
|
||||
if (REGEX_PREFIX.test(trimmedPattern)) {
|
||||
const parsed = parseTokenRouteRegexPattern(trimmedPattern);
|
||||
if (!parsed.regex)
|
||||
return false;
|
||||
return parsed.regex.test(trimmedModel);
|
||||
}
|
||||
// Glob pattern (contains * or ?)
|
||||
if (trimmedPattern.includes('*') || trimmedPattern.includes('?')) {
|
||||
// Escape all regex special characters first, then convert glob patterns
|
||||
const escaped = trimmedPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||
const globRegex = new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
|
||||
return globRegex.test(trimmedModel);
|
||||
}
|
||||
// Exact match (bracket prefix is literal, e.g. [NV]model-name)
|
||||
return trimmedModel === trimmedPattern;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('token route pattern helpers', () => {
|
||||
it('treats bracket-prefixed literal model names as exact patterns', async () => {
|
||||
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 () => {
|
||||
const {
|
||||
matchesTokenRouteModelPattern,
|
||||
parseTokenRouteRegexPattern,
|
||||
} = await import('./tokenRoutePatterns.js');
|
||||
|
||||
expect(parseTokenRouteRegexPattern('re:(?=claude)').regex).toBeNull();
|
||||
expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 're:(?=claude)')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects regex syntax that the lightweight parser does not implement', async () => {
|
||||
const {
|
||||
matchesTokenRouteModelPattern,
|
||||
parseTokenRouteRegexPattern,
|
||||
} = await import('./tokenRoutePatterns.js');
|
||||
|
||||
expect(parseTokenRouteRegexPattern('re:^(?:gpt|claude)-5$').regex).toBeNull();
|
||||
expect(matchesTokenRouteModelPattern('gpt-5', 're:^(?:gpt|claude)-5$')).toBe(false);
|
||||
expect(parseTokenRouteRegexPattern('re:^gpt-\\s+$').regex).toBeNull();
|
||||
expect(matchesTokenRouteModelPattern('gpt- ', 're:^gpt-\\s+$')).toBe(false);
|
||||
});
|
||||
|
||||
it('supports exact, glob, and safe regex route matches', async () => {
|
||||
const { matchesTokenRouteModelPattern } = await import('./tokenRoutePatterns.js');
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildUpdateReminderCandidateKey,
|
||||
resolveUpdateReminderCandidate,
|
||||
} from './updateCenterReminder.js';
|
||||
|
||||
describe('updateCenterReminder shared logic', () => {
|
||||
it('prefers a newer GitHub release when the current runtime is behind', () => {
|
||||
expect(resolveUpdateReminderCandidate({
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: '1.2.3',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
displayVersion: '1.3.0',
|
||||
tagName: 'v1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: 'latest',
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
},
|
||||
})).toEqual(expect.objectContaining({
|
||||
source: 'github-release',
|
||||
kind: 'new-version',
|
||||
candidateKey: 'github-release:v1.3.0',
|
||||
displayVersion: '1.3.0',
|
||||
}));
|
||||
});
|
||||
|
||||
it('treats a same-version Docker target with a different digest as a reminder-worthy new digest', () => {
|
||||
expect(resolveUpdateReminderCandidate({
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: '1.2.3',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
githubRelease: null,
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.2.3',
|
||||
displayVersion: '1.2.3 @ sha256:bbbbbbbbbbbb',
|
||||
tagName: '1.2.3',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
})).toEqual(expect.objectContaining({
|
||||
source: 'docker-hub-tag',
|
||||
kind: 'new-digest',
|
||||
candidateKey: 'docker-hub-tag:1.2.3@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
}));
|
||||
});
|
||||
|
||||
it('treats semver tags with and without a v prefix as the same digest-tracked image target', () => {
|
||||
expect(resolveUpdateReminderCandidate({
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: 'v1.2.3',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
githubRelease: null,
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.2.3',
|
||||
displayVersion: '1.2.3 @ sha256:bbbbbbbbbbbb',
|
||||
tagName: '1.2.3',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
})).toEqual(expect.objectContaining({
|
||||
source: 'docker-hub-tag',
|
||||
kind: 'new-digest',
|
||||
candidateKey: 'docker-hub-tag:1.2.3@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
}));
|
||||
});
|
||||
|
||||
it('treats alias tags like latest as digest-tracked targets when the digest changes', () => {
|
||||
expect(resolveUpdateReminderCandidate({
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: 'latest',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
githubRelease: null,
|
||||
dockerHubTag: {
|
||||
normalizedVersion: 'latest',
|
||||
displayVersion: 'latest @ sha256:bbbbbbbbbbbb',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
})).toEqual(expect.objectContaining({
|
||||
source: 'docker-hub-tag',
|
||||
kind: 'new-digest',
|
||||
candidateKey: 'docker-hub-tag:latest@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not return an older GitHub candidate when the helper is already ahead of it', () => {
|
||||
expect(resolveUpdateReminderCandidate({
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: '1.4.0',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
displayVersion: '1.3.0',
|
||||
tagName: 'v1.3.0',
|
||||
},
|
||||
dockerHubTag: null,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the discovered targets do not differ meaningfully from the current runtime', () => {
|
||||
expect(resolveUpdateReminderCandidate({
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: '1.2.3',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.2.3',
|
||||
displayVersion: '1.2.3',
|
||||
tagName: 'v1.2.3',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.2.3',
|
||||
displayVersion: '1.2.3 @ sha256:aaaaaaaaaaaa',
|
||||
tagName: '1.2.3',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('builds stable candidate keys for both source types', () => {
|
||||
expect(buildUpdateReminderCandidateKey('github-release', {
|
||||
tagName: 'v1.3.0',
|
||||
digest: null,
|
||||
})).toBe('github-release:v1.3.0');
|
||||
|
||||
expect(buildUpdateReminderCandidateKey('docker-hub-tag', {
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
})).toBe('docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
export type {
|
||||
UpdateHelperRuntimeLike,
|
||||
UpdateReminderCandidate,
|
||||
UpdateVersionCandidateLike,
|
||||
} from '../server/shared/updateCenterReminder.js';
|
||||
export {
|
||||
buildUpdateReminderCandidateKey,
|
||||
compareStableVersions,
|
||||
isSameImageTarget,
|
||||
normalizeStableVersion,
|
||||
resolveUpdateReminderCandidate,
|
||||
} from '../server/shared/updateCenterReminder.js';
|
||||
Reference in New Issue
Block a user