forked from boos/BoosAPI
chore: initialize clean metapi repository
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getAccountsAddPanelStyle } from './accountsPanelStyle.js';
|
||||
|
||||
describe('getAccountsAddPanelStyle', () => {
|
||||
it('preserves spacing and raises stacking context so select dropdown is clickable', () => {
|
||||
const style = getAccountsAddPanelStyle();
|
||||
|
||||
expect(style.padding).toBe(20);
|
||||
expect(style.marginBottom).toBe(16);
|
||||
expect(style.position).toBe('relative');
|
||||
expect(style.zIndex).toBeGreaterThan(0);
|
||||
expect(style.overflow).toBe('visible');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export function getAccountsAddPanelStyle() {
|
||||
return {
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
position: 'relative' as const,
|
||||
zIndex: 10,
|
||||
overflow: 'visible' as const,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatCheckinLogTime,
|
||||
formatDateLocal,
|
||||
formatDateTimeMinuteLocal,
|
||||
} from './checkinLogTime.js';
|
||||
|
||||
describe('formatCheckinLogTime', () => {
|
||||
it('formats sqlite UTC timestamp as local display time', () => {
|
||||
const formatted = formatCheckinLogTime('2026-02-25 03:51:58', 'en-US', 'UTC');
|
||||
expect(formatted).toBe('02/25/2026, 03:51:58');
|
||||
});
|
||||
|
||||
it('keeps original value for invalid timestamp', () => {
|
||||
expect(formatCheckinLogTime('invalid-time', 'en-US', 'UTC')).toBe('invalid-time');
|
||||
});
|
||||
|
||||
it('formats local date with minute precision helpers', () => {
|
||||
expect(formatDateLocal('2026-02-25 03:51:58', 'en-US', 'UTC')).toBe('02/25/2026');
|
||||
expect(formatDateTimeMinuteLocal('2026-02-25 03:51:58', 'en-US', 'UTC')).toBe('02/25/2026, 03:51');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
function parseServerUtcDate(value: string | null | undefined): { date: Date | null; raw: string } {
|
||||
if (!value) return { date: null, raw: '' };
|
||||
|
||||
const raw = String(value).trim();
|
||||
if (!raw) return { date: null, raw };
|
||||
|
||||
let normalized = raw;
|
||||
if (!normalized.includes('T') && normalized.includes(' ')) {
|
||||
normalized = normalized.replace(' ', 'T');
|
||||
}
|
||||
|
||||
const hasTimeZone = /[zZ]$|[+-]\d{2}:\d{2}$/.test(normalized);
|
||||
if (!hasTimeZone) {
|
||||
normalized = `${normalized}Z`;
|
||||
}
|
||||
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return { date: null, raw };
|
||||
}
|
||||
|
||||
return { date, raw };
|
||||
}
|
||||
|
||||
function formatWithParts(
|
||||
value: string | null | undefined,
|
||||
options: Intl.DateTimeFormatOptions,
|
||||
locale = 'zh-CN',
|
||||
timeZone?: string,
|
||||
): string {
|
||||
const { date, raw } = parseServerUtcDate(value);
|
||||
if (!date) return raw || '-';
|
||||
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
...options,
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatDateTimeLocal(
|
||||
value: string | null | undefined,
|
||||
locale = 'zh-CN',
|
||||
timeZone?: string,
|
||||
): string {
|
||||
return formatWithParts(value, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}, locale, timeZone);
|
||||
}
|
||||
|
||||
export function formatDateTimeMinuteLocal(
|
||||
value: string | null | undefined,
|
||||
locale = 'zh-CN',
|
||||
timeZone?: string,
|
||||
): string {
|
||||
return formatWithParts(value, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}, locale, timeZone);
|
||||
}
|
||||
|
||||
export function formatDateLocal(
|
||||
value: string | null | undefined,
|
||||
locale = 'zh-CN',
|
||||
timeZone?: string,
|
||||
): string {
|
||||
return formatWithParts(value, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}, locale, timeZone);
|
||||
}
|
||||
|
||||
export function formatCheckinLogTime(
|
||||
value: string | null | undefined,
|
||||
locale?: string,
|
||||
timeZone?: string,
|
||||
): string {
|
||||
return formatDateTimeLocal(value, locale, timeZone);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEBUG_TABS,
|
||||
DEFAULT_INPUTS,
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
MESSAGE_STATUS,
|
||||
buildApiPayload,
|
||||
collectModelTesterModelNames,
|
||||
countConversationTurns,
|
||||
filterModelTesterModelNames,
|
||||
parseCustomRequestBody,
|
||||
parseModelTesterSession,
|
||||
serializeModelTesterSession,
|
||||
syncMessagesToCustomRequestBody,
|
||||
toApiMessages,
|
||||
type ModelTesterSessionState,
|
||||
} from './modelTesterSession.js';
|
||||
|
||||
describe('modelTesterSession', () => {
|
||||
it('counts only user messages as turns', () => {
|
||||
const turns = countConversationTurns([
|
||||
{ id: '1', role: 'user', content: 'hello', createAt: 1 },
|
||||
{ id: '2', role: 'assistant', content: 'hi', createAt: 2 },
|
||||
{ id: '3', role: 'system', content: 'meta', createAt: 3 },
|
||||
{ id: '4', role: 'user', content: 'again', createAt: 4 },
|
||||
]);
|
||||
expect(turns).toBe(2);
|
||||
});
|
||||
|
||||
it('serializes and parses full playground session state', () => {
|
||||
const state: ModelTesterSessionState = {
|
||||
input: 'draft',
|
||||
inputs: {
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4o-mini',
|
||||
temperature: 0.6,
|
||||
top_p: 0.9,
|
||||
max_tokens: 2048,
|
||||
frequency_penalty: 0.1,
|
||||
presence_penalty: 0.2,
|
||||
seed: 12,
|
||||
stream: true,
|
||||
},
|
||||
parameterEnabled: {
|
||||
...DEFAULT_PARAMETER_ENABLED,
|
||||
max_tokens: true,
|
||||
seed: true,
|
||||
},
|
||||
messages: [
|
||||
{ id: 'm1', role: 'user', content: 'hello', createAt: 1 },
|
||||
{ id: 'm2', role: 'assistant', content: 'hi', createAt: 2, status: MESSAGE_STATUS.COMPLETE },
|
||||
],
|
||||
pendingPayload: {
|
||||
model: 'gpt-4o-mini',
|
||||
stream: true,
|
||||
temperature: 0.6,
|
||||
top_p: 0.9,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
},
|
||||
pendingJobId: 'job-1',
|
||||
customRequestMode: true,
|
||||
customRequestBody: '{"model":"gpt-4o-mini","messages":[]}',
|
||||
showDebugPanel: true,
|
||||
activeDebugTab: DEBUG_TABS.REQUEST,
|
||||
};
|
||||
|
||||
const serialized = serializeModelTesterSession(state);
|
||||
const restored = parseModelTesterSession(serialized);
|
||||
|
||||
expect(restored).toEqual(state);
|
||||
});
|
||||
|
||||
it('supports parsing legacy session format', () => {
|
||||
const restored = parseModelTesterSession(JSON.stringify({
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.5,
|
||||
input: 'legacy',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
pendingPayload: null,
|
||||
}));
|
||||
|
||||
expect(restored?.inputs.model).toBe('gpt-4o');
|
||||
expect(restored?.inputs.temperature).toBe(0.5);
|
||||
expect(restored?.parameterEnabled).toEqual(DEFAULT_PARAMETER_ENABLED);
|
||||
});
|
||||
|
||||
it('returns null for malformed or missing session payload', () => {
|
||||
expect(parseModelTesterSession(null)).toBeNull();
|
||||
expect(parseModelTesterSession('not-json')).toBeNull();
|
||||
expect(parseModelTesterSession(JSON.stringify({ messages: [] }))).toBeNull();
|
||||
});
|
||||
|
||||
it('sanitizes invalid message entries and invalid pending payload', () => {
|
||||
const restored = parseModelTesterSession(JSON.stringify({
|
||||
inputs: { model: 'gpt-4o', temperature: 99 },
|
||||
parameterEnabled: {},
|
||||
input: 123,
|
||||
messages: [
|
||||
{ role: 'user', content: 'ok' },
|
||||
{ role: 'assistant', content: 123 },
|
||||
{ role: 'unknown', content: 'bad role' },
|
||||
{ content: 'missing role' },
|
||||
],
|
||||
pendingPayload: {
|
||||
model: 123,
|
||||
messages: [{ role: 'user', content: 'still here' }],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(restored?.inputs.model).toBe('gpt-4o');
|
||||
expect(restored?.input).toBe('');
|
||||
expect(restored?.pendingPayload).toBeNull();
|
||||
expect(restored?.messages).toHaveLength(1);
|
||||
expect(restored?.messages[0]).toMatchObject({ role: 'user', content: 'ok' });
|
||||
});
|
||||
|
||||
it('finalizes loading messages if no pending job remains', () => {
|
||||
const restored = parseModelTesterSession(JSON.stringify({
|
||||
inputs: { ...DEFAULT_INPUTS, model: 'gpt-4o' },
|
||||
parameterEnabled: DEFAULT_PARAMETER_ENABLED,
|
||||
input: '',
|
||||
messages: [{
|
||||
id: 'm-loading',
|
||||
role: 'assistant',
|
||||
content: '<think>reasoning...</think>final',
|
||||
createAt: 1,
|
||||
status: 'loading',
|
||||
}],
|
||||
pendingPayload: null,
|
||||
pendingJobId: null,
|
||||
}));
|
||||
|
||||
expect(restored?.messages[0].status).toBe(MESSAGE_STATUS.COMPLETE);
|
||||
expect(restored?.messages[0].content).toBe('final');
|
||||
});
|
||||
|
||||
it('drops loading assistant placeholders when building API payload messages', () => {
|
||||
const payloadMessages = toApiMessages([
|
||||
{ id: '1', role: 'user', content: 'hello', createAt: 1 },
|
||||
{ id: '2', role: 'assistant', content: '', createAt: 2, status: MESSAGE_STATUS.LOADING },
|
||||
{ id: '3', role: 'assistant', content: 'done', createAt: 3, status: MESSAGE_STATUS.COMPLETE },
|
||||
]);
|
||||
|
||||
expect(payloadMessages).toEqual([
|
||||
{ role: 'user', content: 'hello' },
|
||||
{ role: 'assistant', content: 'done' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds payload with parameter enable switches', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{ id: 'u1', role: 'user', content: 'hello', createAt: 1 }],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4o-mini',
|
||||
temperature: 0.5,
|
||||
top_p: 0.8,
|
||||
max_tokens: 200,
|
||||
frequency_penalty: 0.2,
|
||||
presence_penalty: 0.3,
|
||||
seed: 42,
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
temperature: true,
|
||||
top_p: false,
|
||||
max_tokens: true,
|
||||
frequency_penalty: true,
|
||||
presence_penalty: false,
|
||||
seed: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
targetFormat: 'openai',
|
||||
stream: true,
|
||||
temperature: 0.5,
|
||||
max_tokens: 200,
|
||||
frequency_penalty: 0.2,
|
||||
seed: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses and syncs custom request body', () => {
|
||||
const parsed = parseCustomRequestBody('{"model":"gpt-4o","messages":[{"role":"user","content":"x"}],"stream":true}');
|
||||
expect(parsed?.model).toBe('gpt-4o');
|
||||
expect(parsed?.stream).toBe(true);
|
||||
|
||||
const synced = syncMessagesToCustomRequestBody(
|
||||
'{"model":"legacy"}',
|
||||
[{ id: '1', role: 'user', content: 'new', createAt: 1 }],
|
||||
{ ...DEFAULT_INPUTS, model: 'gpt-4o' },
|
||||
);
|
||||
|
||||
const syncedJson = JSON.parse(synced);
|
||||
expect(syncedJson.model).toBe('legacy');
|
||||
expect(syncedJson.messages).toEqual([{ role: 'user', content: 'new' }]);
|
||||
});
|
||||
|
||||
it('merges marketplace models with exact enabled route models for tester options', () => {
|
||||
const modelNames = collectModelTesterModelNames(
|
||||
{
|
||||
models: [
|
||||
{ name: 'gpt-4o-mini' },
|
||||
{ name: 'bge-large-en-v1.5' },
|
||||
],
|
||||
},
|
||||
[
|
||||
{ modelPattern: 'BAAI/bge-large-en-v1.5', enabled: true },
|
||||
{ modelPattern: 'claude-*', enabled: true },
|
||||
{ modelPattern: 'gemini-2.5-pro', enabled: false },
|
||||
],
|
||||
);
|
||||
|
||||
expect(modelNames).toEqual([
|
||||
'gpt-4o-mini',
|
||||
'bge-large-en-v1.5',
|
||||
'BAAI/bge-large-en-v1.5',
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates repeated model names when merging model sources', () => {
|
||||
const modelNames = collectModelTesterModelNames(
|
||||
{
|
||||
models: [
|
||||
{ name: 'gpt-4o-mini' },
|
||||
{ name: 'gpt-4o-mini' },
|
||||
],
|
||||
},
|
||||
[
|
||||
{ modelPattern: 'gpt-4o-mini', enabled: true },
|
||||
],
|
||||
);
|
||||
|
||||
expect(modelNames).toEqual(['gpt-4o-mini']);
|
||||
});
|
||||
|
||||
it('filters models by keyword and keeps best matches first', () => {
|
||||
const filtered = filterModelTesterModelNames(
|
||||
[
|
||||
'BAAI/bge-large-en-v1.5',
|
||||
'text-embedding-3-large',
|
||||
'bge-m3',
|
||||
],
|
||||
'bge',
|
||||
);
|
||||
|
||||
expect(filtered).toEqual([
|
||||
'bge-m3',
|
||||
'BAAI/bge-large-en-v1.5',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns all models unchanged when keyword is empty', () => {
|
||||
const filtered = filterModelTesterModelNames(
|
||||
['gpt-4o', 'claude-3-5-haiku-20241022'],
|
||||
' ',
|
||||
);
|
||||
|
||||
expect(filtered).toEqual(['gpt-4o', 'claude-3-5-haiku-20241022']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,544 @@
|
||||
export const MESSAGE_STATUS = {
|
||||
LOADING: 'loading',
|
||||
INCOMPLETE: 'incomplete',
|
||||
COMPLETE: 'complete',
|
||||
ERROR: 'error',
|
||||
} as const;
|
||||
|
||||
export const DEBUG_TABS = {
|
||||
PREVIEW: 'preview',
|
||||
REQUEST: 'request',
|
||||
RESPONSE: 'response',
|
||||
} as const;
|
||||
|
||||
type MessageStatus = typeof MESSAGE_STATUS[keyof typeof MESSAGE_STATUS];
|
||||
export type DebugTab = typeof DEBUG_TABS[keyof typeof DEBUG_TABS];
|
||||
type ChatRole = 'user' | 'assistant' | 'system';
|
||||
export type TestTargetFormat = 'openai' | 'claude';
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
createAt: number;
|
||||
status?: MessageStatus;
|
||||
reasoningContent?: string | null;
|
||||
isReasoningExpanded?: boolean;
|
||||
isThinkingComplete?: boolean;
|
||||
hasAutoCollapsed?: boolean;
|
||||
};
|
||||
|
||||
type ApiChatMessage = {
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ModelTesterInputs = {
|
||||
model: string;
|
||||
targetFormat: TestTargetFormat;
|
||||
temperature: number;
|
||||
top_p: number;
|
||||
max_tokens: number;
|
||||
frequency_penalty: number;
|
||||
presence_penalty: number;
|
||||
seed: number | null;
|
||||
stream: boolean;
|
||||
};
|
||||
|
||||
export type ParameterEnabled = {
|
||||
temperature: boolean;
|
||||
top_p: boolean;
|
||||
max_tokens: boolean;
|
||||
frequency_penalty: boolean;
|
||||
presence_penalty: boolean;
|
||||
seed: boolean;
|
||||
};
|
||||
|
||||
export type TestChatPayload = {
|
||||
model: string;
|
||||
messages: ApiChatMessage[];
|
||||
targetFormat?: TestTargetFormat;
|
||||
stream?: boolean;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
max_tokens?: number;
|
||||
frequency_penalty?: number;
|
||||
presence_penalty?: number;
|
||||
seed?: number;
|
||||
};
|
||||
|
||||
export type ModelTesterSessionState = {
|
||||
input: string;
|
||||
inputs: ModelTesterInputs;
|
||||
parameterEnabled: ParameterEnabled;
|
||||
messages: ChatMessage[];
|
||||
pendingPayload: TestChatPayload | null;
|
||||
pendingJobId?: string | null;
|
||||
customRequestMode: boolean;
|
||||
customRequestBody: string;
|
||||
showDebugPanel: boolean;
|
||||
activeDebugTab: DebugTab;
|
||||
};
|
||||
|
||||
export const MODEL_TESTER_STORAGE_KEY = 'metapi:model-tester:session:v4';
|
||||
|
||||
export const DEFAULT_INPUTS: ModelTesterInputs = {
|
||||
model: '',
|
||||
targetFormat: 'openai',
|
||||
temperature: 0.7,
|
||||
top_p: 1,
|
||||
max_tokens: 4096,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
seed: null,
|
||||
stream: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_PARAMETER_ENABLED: ParameterEnabled = {
|
||||
temperature: true,
|
||||
top_p: true,
|
||||
max_tokens: false,
|
||||
frequency_penalty: true,
|
||||
presence_penalty: true,
|
||||
seed: false,
|
||||
};
|
||||
|
||||
const THINK_TAG_REGEX = /<think>([\s\S]*?)<\/think>/g;
|
||||
const VALID_ROLES: ReadonlySet<string> = new Set(['user', 'assistant', 'system']);
|
||||
const VALID_STATUS: ReadonlySet<string> = new Set(Object.values(MESSAGE_STATUS));
|
||||
const VALID_DEBUG_TABS: ReadonlySet<string> = new Set(Object.values(DEBUG_TABS));
|
||||
|
||||
let messageCounter = 0;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const toFiniteNumber = (value: unknown, fallback: number): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
const toBoolean = (value: unknown, fallback: boolean): boolean =>
|
||||
typeof value === 'boolean' ? value : fallback;
|
||||
|
||||
const toNullableFiniteNumber = (value: unknown): number | null => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
return null;
|
||||
};
|
||||
|
||||
const isExactModelPattern = (modelPattern: string): boolean => !/[\*\?\[]/.test(modelPattern);
|
||||
|
||||
export const collectModelTesterModelNames = (
|
||||
marketplace: { models?: Array<{ name?: unknown }>; } | null | undefined,
|
||||
routes: Array<{ modelPattern?: unknown; enabled?: unknown; }> | null | undefined,
|
||||
): string[] => {
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const appendModel = (rawName: unknown) => {
|
||||
if (typeof rawName !== 'string') return;
|
||||
const name = rawName.trim();
|
||||
if (!name || seen.has(name)) return;
|
||||
seen.add(name);
|
||||
result.push(name);
|
||||
};
|
||||
|
||||
for (const item of marketplace?.models || []) {
|
||||
appendModel(item?.name);
|
||||
}
|
||||
|
||||
for (const route of routes || []) {
|
||||
if (!route || route.enabled === false) continue;
|
||||
if (typeof route.modelPattern !== 'string') continue;
|
||||
const modelPattern = route.modelPattern.trim();
|
||||
if (!modelPattern || !isExactModelPattern(modelPattern)) continue;
|
||||
appendModel(modelPattern);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const filterModelTesterModelNames = (models: string[], query: string): string[] => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) return [...models];
|
||||
|
||||
return models
|
||||
.map((name, index) => {
|
||||
const matchIndex = name.toLowerCase().indexOf(keyword);
|
||||
if (matchIndex === -1) return null;
|
||||
return { name, matchIndex, index };
|
||||
})
|
||||
.filter((item): item is { name: string; matchIndex: number; index: number } => item !== null)
|
||||
.sort((a, b) => {
|
||||
if (a.matchIndex !== b.matchIndex) return a.matchIndex - b.matchIndex;
|
||||
if (a.name.length !== b.name.length) return a.name.length - b.name.length;
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map((item) => item.name);
|
||||
};
|
||||
|
||||
const createMessageId = (): string => {
|
||||
messageCounter += 1;
|
||||
return `msg-${Date.now()}-${messageCounter}`;
|
||||
};
|
||||
|
||||
export const createMessage = (role: ChatRole, content: string, extra: Partial<ChatMessage> = {}): ChatMessage => ({
|
||||
id: createMessageId(),
|
||||
role,
|
||||
content,
|
||||
createAt: Date.now(),
|
||||
...extra,
|
||||
});
|
||||
|
||||
export const createLoadingAssistantMessage = (): ChatMessage =>
|
||||
createMessage('assistant', '', {
|
||||
status: MESSAGE_STATUS.LOADING,
|
||||
reasoningContent: '',
|
||||
isReasoningExpanded: true,
|
||||
isThinkingComplete: false,
|
||||
hasAutoCollapsed: false,
|
||||
});
|
||||
|
||||
const parseMessage = (value: unknown, index: number): ChatMessage | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
if (typeof value.role !== 'string' || !VALID_ROLES.has(value.role)) return null;
|
||||
if (typeof value.content !== 'string') return null;
|
||||
|
||||
const parsed: ChatMessage = {
|
||||
id: typeof value.id === 'string' && value.id.trim().length > 0
|
||||
? value.id
|
||||
: `legacy-${index}-${Date.now()}`,
|
||||
role: value.role as ChatRole,
|
||||
content: value.content,
|
||||
createAt: typeof value.createAt === 'number' && Number.isFinite(value.createAt)
|
||||
? value.createAt
|
||||
: Date.now(),
|
||||
};
|
||||
|
||||
if (typeof value.status === 'string' && VALID_STATUS.has(value.status)) {
|
||||
parsed.status = value.status as MessageStatus;
|
||||
}
|
||||
if (typeof value.reasoningContent === 'string') {
|
||||
parsed.reasoningContent = value.reasoningContent;
|
||||
} else if (value.reasoningContent === null) {
|
||||
parsed.reasoningContent = null;
|
||||
}
|
||||
if (typeof value.isReasoningExpanded === 'boolean') {
|
||||
parsed.isReasoningExpanded = value.isReasoningExpanded;
|
||||
}
|
||||
if (typeof value.isThinkingComplete === 'boolean') {
|
||||
parsed.isThinkingComplete = value.isThinkingComplete;
|
||||
}
|
||||
if (typeof value.hasAutoCollapsed === 'boolean') {
|
||||
parsed.hasAutoCollapsed = value.hasAutoCollapsed;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const sanitizeMessages = (value: unknown): ChatMessage[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item, index) => parseMessage(item, index))
|
||||
.filter((item): item is ChatMessage => item !== null);
|
||||
};
|
||||
|
||||
const parseInputs = (value: unknown, fallbackModel = ''): ModelTesterInputs => {
|
||||
if (!isRecord(value)) {
|
||||
return {
|
||||
...DEFAULT_INPUTS,
|
||||
model: fallbackModel,
|
||||
};
|
||||
}
|
||||
|
||||
const model = typeof value.model === 'string' && value.model.trim().length > 0
|
||||
? value.model
|
||||
: fallbackModel;
|
||||
|
||||
return {
|
||||
model,
|
||||
targetFormat: value.targetFormat === 'claude' ? 'claude' : DEFAULT_INPUTS.targetFormat,
|
||||
temperature: toFiniteNumber(value.temperature, DEFAULT_INPUTS.temperature),
|
||||
top_p: toFiniteNumber(value.top_p, DEFAULT_INPUTS.top_p),
|
||||
max_tokens: toFiniteNumber(value.max_tokens, DEFAULT_INPUTS.max_tokens),
|
||||
frequency_penalty: toFiniteNumber(value.frequency_penalty, DEFAULT_INPUTS.frequency_penalty),
|
||||
presence_penalty: toFiniteNumber(value.presence_penalty, DEFAULT_INPUTS.presence_penalty),
|
||||
seed: toNullableFiniteNumber(value.seed),
|
||||
stream: toBoolean(value.stream, DEFAULT_INPUTS.stream),
|
||||
};
|
||||
};
|
||||
|
||||
const parseParameterEnabled = (value: unknown): ParameterEnabled => {
|
||||
if (!isRecord(value)) {
|
||||
return { ...DEFAULT_PARAMETER_ENABLED };
|
||||
}
|
||||
|
||||
return {
|
||||
temperature: toBoolean(value.temperature, DEFAULT_PARAMETER_ENABLED.temperature),
|
||||
top_p: toBoolean(value.top_p, DEFAULT_PARAMETER_ENABLED.top_p),
|
||||
max_tokens: toBoolean(value.max_tokens, DEFAULT_PARAMETER_ENABLED.max_tokens),
|
||||
frequency_penalty: toBoolean(value.frequency_penalty, DEFAULT_PARAMETER_ENABLED.frequency_penalty),
|
||||
presence_penalty: toBoolean(value.presence_penalty, DEFAULT_PARAMETER_ENABLED.presence_penalty),
|
||||
seed: toBoolean(value.seed, DEFAULT_PARAMETER_ENABLED.seed),
|
||||
};
|
||||
};
|
||||
|
||||
const parsePendingPayload = (value: unknown): TestChatPayload | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
if (typeof value.model !== 'string' || value.model.trim().length === 0) return null;
|
||||
|
||||
const payloadMessages = sanitizeMessages(value.messages).map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
}));
|
||||
|
||||
if (payloadMessages.length === 0) return null;
|
||||
|
||||
const payload: TestChatPayload = {
|
||||
model: value.model,
|
||||
messages: payloadMessages,
|
||||
};
|
||||
|
||||
if (value.targetFormat === 'claude' || value.targetFormat === 'openai') {
|
||||
payload.targetFormat = value.targetFormat;
|
||||
}
|
||||
if (typeof value.stream === 'boolean') payload.stream = value.stream;
|
||||
if (typeof value.temperature === 'number' && Number.isFinite(value.temperature)) payload.temperature = value.temperature;
|
||||
if (typeof value.top_p === 'number' && Number.isFinite(value.top_p)) payload.top_p = value.top_p;
|
||||
if (typeof value.max_tokens === 'number' && Number.isFinite(value.max_tokens)) payload.max_tokens = value.max_tokens;
|
||||
if (typeof value.frequency_penalty === 'number' && Number.isFinite(value.frequency_penalty)) payload.frequency_penalty = value.frequency_penalty;
|
||||
if (typeof value.presence_penalty === 'number' && Number.isFinite(value.presence_penalty)) payload.presence_penalty = value.presence_penalty;
|
||||
if (typeof value.seed === 'number' && Number.isFinite(value.seed)) payload.seed = value.seed;
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const serializeModelTesterSession = (state: ModelTesterSessionState): string =>
|
||||
JSON.stringify(state);
|
||||
|
||||
export const processThinkTags = (content: string, reasoningContent = ''): { content: string; reasoningContent: string } => {
|
||||
if (!content || !content.includes('<think>')) {
|
||||
return { content, reasoningContent };
|
||||
}
|
||||
|
||||
const thoughts: string[] = [];
|
||||
const replyParts: string[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
THINK_TAG_REGEX.lastIndex = 0;
|
||||
while ((match = THINK_TAG_REGEX.exec(content)) !== null) {
|
||||
replyParts.push(content.substring(lastIndex, match.index));
|
||||
thoughts.push(match[1]);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
replyParts.push(content.substring(lastIndex));
|
||||
|
||||
const processedContent = replyParts.join('').replace(/<\/?think>/g, '').trim();
|
||||
const thoughtsCombined = thoughts.join('\n\n---\n\n');
|
||||
|
||||
return {
|
||||
content: processedContent,
|
||||
reasoningContent: reasoningContent && thoughtsCombined
|
||||
? `${reasoningContent}\n\n---\n\n${thoughtsCombined}`
|
||||
: (reasoningContent || thoughtsCombined),
|
||||
};
|
||||
};
|
||||
|
||||
const processIncompleteThinkTags = (content: string, reasoningContent = ''): { content: string; reasoningContent: string } => {
|
||||
if (!content) return { content: '', reasoningContent };
|
||||
|
||||
const lastOpenThinkIndex = content.lastIndexOf('<think>');
|
||||
if (lastOpenThinkIndex === -1) {
|
||||
return processThinkTags(content, reasoningContent);
|
||||
}
|
||||
|
||||
const fragmentAfterLastOpen = content.substring(lastOpenThinkIndex);
|
||||
if (!fragmentAfterLastOpen.includes('</think>')) {
|
||||
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
|
||||
const cleanContent = content.substring(0, lastOpenThinkIndex);
|
||||
const mergedReasoning = unclosedThought
|
||||
? (reasoningContent ? `${reasoningContent}\n\n---\n\n${unclosedThought}` : unclosedThought)
|
||||
: reasoningContent;
|
||||
return processThinkTags(cleanContent, mergedReasoning);
|
||||
}
|
||||
|
||||
return processThinkTags(content, reasoningContent);
|
||||
};
|
||||
|
||||
export const finalizeIncompleteMessage = (message: ChatMessage): ChatMessage => {
|
||||
if (message.status !== MESSAGE_STATUS.LOADING && message.status !== MESSAGE_STATUS.INCOMPLETE) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const processed = processIncompleteThinkTags(message.content || '', message.reasoningContent || '');
|
||||
return {
|
||||
...message,
|
||||
content: processed.content || message.content,
|
||||
reasoningContent: processed.reasoningContent || null,
|
||||
status: MESSAGE_STATUS.COMPLETE,
|
||||
isThinkingComplete: true,
|
||||
};
|
||||
};
|
||||
|
||||
const buildFallbackInputsFromLegacy = (value: Record<string, unknown>): ModelTesterInputs => {
|
||||
const legacyModel = typeof value.model === 'string' ? value.model : '';
|
||||
const inputs = parseInputs(value.inputs, legacyModel);
|
||||
|
||||
if (!value.inputs) {
|
||||
inputs.targetFormat = value.targetFormat === 'claude' ? 'claude' : inputs.targetFormat;
|
||||
inputs.temperature = toFiniteNumber(value.temperature, inputs.temperature);
|
||||
}
|
||||
|
||||
return inputs;
|
||||
};
|
||||
|
||||
export const parseModelTesterSession = (raw: string | null): ModelTesterSessionState | null => {
|
||||
if (typeof raw !== 'string' || raw.trim().length === 0) return null;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isRecord(parsed)) return null;
|
||||
|
||||
const inputs = buildFallbackInputsFromLegacy(parsed);
|
||||
if (!inputs.model) return null;
|
||||
|
||||
const state: ModelTesterSessionState = {
|
||||
input: typeof parsed.input === 'string' ? parsed.input : '',
|
||||
inputs,
|
||||
parameterEnabled: parseParameterEnabled(parsed.parameterEnabled),
|
||||
messages: sanitizeMessages(parsed.messages),
|
||||
pendingPayload: parsePendingPayload(parsed.pendingPayload),
|
||||
customRequestMode: toBoolean(parsed.customRequestMode, false),
|
||||
customRequestBody: typeof parsed.customRequestBody === 'string' ? parsed.customRequestBody : '',
|
||||
showDebugPanel: toBoolean(parsed.showDebugPanel, false),
|
||||
activeDebugTab: typeof parsed.activeDebugTab === 'string' && VALID_DEBUG_TABS.has(parsed.activeDebugTab)
|
||||
? parsed.activeDebugTab as DebugTab
|
||||
: DEBUG_TABS.PREVIEW,
|
||||
};
|
||||
|
||||
if (typeof parsed.pendingJobId === 'string' && parsed.pendingJobId.trim().length > 0) {
|
||||
state.pendingJobId = parsed.pendingJobId;
|
||||
} else if (parsed.pendingJobId === null) {
|
||||
state.pendingJobId = null;
|
||||
}
|
||||
|
||||
if (!state.pendingJobId && state.messages.length > 0) {
|
||||
state.messages = state.messages.map((message) => finalizeIncompleteMessage(message));
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export const toApiMessages = (messages: ChatMessage[]): ApiChatMessage[] =>
|
||||
messages
|
||||
.filter((message) => {
|
||||
if (message.role !== 'assistant') return true;
|
||||
return message.status !== MESSAGE_STATUS.LOADING && message.status !== MESSAGE_STATUS.INCOMPLETE;
|
||||
})
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
}));
|
||||
|
||||
export const buildApiPayload = (
|
||||
messages: ChatMessage[],
|
||||
inputs: ModelTesterInputs,
|
||||
parameterEnabled: ParameterEnabled,
|
||||
): TestChatPayload => {
|
||||
const payload: TestChatPayload = {
|
||||
model: inputs.model,
|
||||
messages: toApiMessages(messages),
|
||||
targetFormat: inputs.targetFormat,
|
||||
stream: inputs.stream,
|
||||
};
|
||||
|
||||
const mapping: Array<{ key: keyof ParameterEnabled; field: keyof ModelTesterInputs }> = [
|
||||
{ key: 'temperature', field: 'temperature' },
|
||||
{ key: 'top_p', field: 'top_p' },
|
||||
{ key: 'max_tokens', field: 'max_tokens' },
|
||||
{ key: 'frequency_penalty', field: 'frequency_penalty' },
|
||||
{ key: 'presence_penalty', field: 'presence_penalty' },
|
||||
{ key: 'seed', field: 'seed' },
|
||||
];
|
||||
|
||||
for (const item of mapping) {
|
||||
const enabled = parameterEnabled[item.key];
|
||||
if (!enabled) continue;
|
||||
|
||||
const value = inputs[item.field];
|
||||
if (item.field === 'seed') {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
payload.seed = value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
(payload as any)[item.field] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const parseCustomRequestBody = (raw: string): TestChatPayload | null => {
|
||||
if (!raw.trim()) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsePendingPayload(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const syncMessagesToCustomRequestBody = (
|
||||
currentBody: string,
|
||||
messages: ChatMessage[],
|
||||
inputs: ModelTesterInputs,
|
||||
): string => {
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = JSON.parse(currentBody || '{}');
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
payload.model = typeof payload.model === 'string' && payload.model.trim().length > 0
|
||||
? payload.model
|
||||
: inputs.model;
|
||||
payload.targetFormat = payload.targetFormat === 'claude' ? 'claude' : inputs.targetFormat;
|
||||
payload.stream = payload.stream !== undefined ? payload.stream : inputs.stream;
|
||||
payload.messages = toApiMessages(messages);
|
||||
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
export const syncCustomRequestBodyToMessages = (raw: string): ChatMessage[] | null => {
|
||||
const parsed = parseCustomRequestBody(raw);
|
||||
if (!parsed?.messages || parsed.messages.length === 0) return null;
|
||||
|
||||
return parsed.messages.map((item, index) => createMessage(item.role, item.content, {
|
||||
id: `custom-${index}-${Date.now()}`,
|
||||
}));
|
||||
};
|
||||
|
||||
export const findLastLoadingAssistantIndex = (messages: ChatMessage[]): number => {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (
|
||||
message.role === 'assistant' &&
|
||||
(message.status === MESSAGE_STATUS.LOADING || message.status === MESSAGE_STATUS.INCOMPLETE)
|
||||
) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
export const countConversationTurns = (messages: ChatMessage[]): number =>
|
||||
messages.reduce((turns, message) => turns + (message.role === 'user' ? 1 : 0), 0);
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
mergeMarketplaceMetadata,
|
||||
shouldHydrateMarketplaceMetadata,
|
||||
type MarketplaceModelMetadataShape,
|
||||
} from './modelsMarketplaceMetadata.js';
|
||||
|
||||
interface TestModel extends MarketplaceModelMetadataShape {
|
||||
name: string;
|
||||
accountCount: number;
|
||||
}
|
||||
|
||||
function createModel(overrides: Partial<TestModel>): TestModel {
|
||||
return {
|
||||
name: 'gpt-4o',
|
||||
accountCount: 1,
|
||||
description: null,
|
||||
tags: [],
|
||||
supportedEndpointTypes: [],
|
||||
pricingSources: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('modelsMarketplaceMetadata', () => {
|
||||
it('identifies when metadata hydration is needed', () => {
|
||||
const models = [
|
||||
createModel({ name: 'gpt-4o', description: 'base model' }),
|
||||
createModel({ name: 'claude-sonnet-4' }),
|
||||
];
|
||||
|
||||
expect(shouldHydrateMarketplaceMetadata(models)).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies when metadata is already complete', () => {
|
||||
const models = [
|
||||
createModel({
|
||||
name: 'gpt-4o',
|
||||
description: 'base model',
|
||||
tags: ['chat'],
|
||||
supportedEndpointTypes: ['chat'],
|
||||
pricingSources: [{ source: 'site-a' }],
|
||||
}),
|
||||
createModel({
|
||||
name: 'claude-sonnet-4',
|
||||
description: 'anthropic model',
|
||||
tags: ['reasoning'],
|
||||
supportedEndpointTypes: ['chat'],
|
||||
pricingSources: [{ source: 'site-b' }],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(shouldHydrateMarketplaceMetadata(models)).toBe(false);
|
||||
});
|
||||
|
||||
it('merges metadata into current models without overriding non-metadata fields', () => {
|
||||
const base = [
|
||||
createModel({ name: 'GPT-4O', accountCount: 3 }),
|
||||
createModel({ name: 'claude-sonnet-4', accountCount: 5 }),
|
||||
];
|
||||
const detail = [
|
||||
createModel({
|
||||
name: 'gpt-4o',
|
||||
accountCount: 99,
|
||||
description: 'OpenAI flagship model',
|
||||
tags: ['openai'],
|
||||
supportedEndpointTypes: ['chat', 'responses'],
|
||||
pricingSources: [{ source: 'site-a' }],
|
||||
}),
|
||||
];
|
||||
|
||||
const merged = mergeMarketplaceMetadata(base, detail);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{
|
||||
...base[0],
|
||||
description: 'OpenAI flagship model',
|
||||
tags: ['openai'],
|
||||
supportedEndpointTypes: ['chat', 'responses'],
|
||||
pricingSources: [{ source: 'site-a' }],
|
||||
},
|
||||
base[1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface MarketplaceModelMetadataShape {
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
supportedEndpointTypes: string[];
|
||||
pricingSources: unknown[];
|
||||
}
|
||||
|
||||
interface MarketplaceModelLike extends MarketplaceModelMetadataShape {
|
||||
name: string;
|
||||
}
|
||||
|
||||
function normalizeModelName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function hasMarketplaceMetadata(model: MarketplaceModelMetadataShape): boolean {
|
||||
if (typeof model.description === 'string' && model.description.trim().length > 0) return true;
|
||||
if (Array.isArray(model.tags) && model.tags.length > 0) return true;
|
||||
if (Array.isArray(model.supportedEndpointTypes) && model.supportedEndpointTypes.length > 0) return true;
|
||||
if (Array.isArray(model.pricingSources) && model.pricingSources.length > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldHydrateMarketplaceMetadata<T extends MarketplaceModelMetadataShape>(models: T[]): boolean {
|
||||
return models.some((model) => !hasMarketplaceMetadata(model));
|
||||
}
|
||||
|
||||
export function mergeMarketplaceMetadata<T extends MarketplaceModelLike>(baseModels: T[], detailedModels: T[]): T[] {
|
||||
const detailedByName = new Map<string, T>();
|
||||
for (const model of detailedModels) {
|
||||
detailedByName.set(normalizeModelName(model.name), model);
|
||||
}
|
||||
|
||||
return baseModels.map((model) => {
|
||||
const detail = detailedByName.get(normalizeModelName(model.name));
|
||||
if (!detail) return model;
|
||||
|
||||
return {
|
||||
...model,
|
||||
description: detail.description ?? null,
|
||||
tags: Array.isArray(detail.tags) ? detail.tags : [],
|
||||
supportedEndpointTypes: Array.isArray(detail.supportedEndpointTypes) ? detail.supportedEndpointTypes : [],
|
||||
pricingSources: Array.isArray(detail.pricingSources) ? detail.pricingSources : [],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ROUTING_PROFILE_PRESETS,
|
||||
applyRoutingProfilePreset,
|
||||
resolveRoutingProfilePreset,
|
||||
} from './routingProfiles.js';
|
||||
|
||||
describe('routingProfiles', () => {
|
||||
it('applies stable preset weights', () => {
|
||||
const stable = applyRoutingProfilePreset('stable');
|
||||
expect(stable).toEqual(ROUTING_PROFILE_PRESETS.stable);
|
||||
});
|
||||
|
||||
it('resolves known profile by exact weights', () => {
|
||||
const preset = resolveRoutingProfilePreset(ROUTING_PROFILE_PRESETS.cost);
|
||||
expect(preset).toBe('cost');
|
||||
});
|
||||
|
||||
it('returns custom when weights do not match presets', () => {
|
||||
const preset = resolveRoutingProfilePreset({
|
||||
baseWeightFactor: 0.61,
|
||||
valueScoreFactor: 0.39,
|
||||
costWeight: 0.22,
|
||||
balanceWeight: 0.41,
|
||||
usageWeight: 0.37,
|
||||
});
|
||||
expect(preset).toBe('custom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
export type RoutingWeights = {
|
||||
baseWeightFactor: number;
|
||||
valueScoreFactor: number;
|
||||
costWeight: number;
|
||||
balanceWeight: number;
|
||||
usageWeight: number;
|
||||
};
|
||||
|
||||
type RoutingProfilePresetId = 'balanced' | 'stable' | 'cost' | 'custom';
|
||||
|
||||
export const ROUTING_PROFILE_PRESETS: Record<Exclude<RoutingProfilePresetId, 'custom'>, RoutingWeights> = {
|
||||
balanced: {
|
||||
baseWeightFactor: 0.5,
|
||||
valueScoreFactor: 0.5,
|
||||
costWeight: 0.4,
|
||||
balanceWeight: 0.3,
|
||||
usageWeight: 0.3,
|
||||
},
|
||||
stable: {
|
||||
baseWeightFactor: 0.7,
|
||||
valueScoreFactor: 0.3,
|
||||
costWeight: 0.2,
|
||||
balanceWeight: 0.6,
|
||||
usageWeight: 0.2,
|
||||
},
|
||||
cost: {
|
||||
baseWeightFactor: 0.35,
|
||||
valueScoreFactor: 0.65,
|
||||
costWeight: 0.75,
|
||||
balanceWeight: 0.15,
|
||||
usageWeight: 0.1,
|
||||
},
|
||||
};
|
||||
|
||||
const EPSILON = 1e-6;
|
||||
|
||||
export function applyRoutingProfilePreset(presetId: Exclude<RoutingProfilePresetId, 'custom'>): RoutingWeights {
|
||||
return {
|
||||
...ROUTING_PROFILE_PRESETS[presetId],
|
||||
};
|
||||
}
|
||||
|
||||
function weightsAlmostEqual(a: RoutingWeights, b: RoutingWeights): boolean {
|
||||
return (
|
||||
Math.abs(a.baseWeightFactor - b.baseWeightFactor) <= EPSILON
|
||||
&& Math.abs(a.valueScoreFactor - b.valueScoreFactor) <= EPSILON
|
||||
&& Math.abs(a.costWeight - b.costWeight) <= EPSILON
|
||||
&& Math.abs(a.balanceWeight - b.balanceWeight) <= EPSILON
|
||||
&& Math.abs(a.usageWeight - b.usageWeight) <= EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveRoutingProfilePreset(weights: RoutingWeights): RoutingProfilePresetId {
|
||||
if (weightsAlmostEqual(weights, ROUTING_PROFILE_PRESETS.balanced)) return 'balanced';
|
||||
if (weightsAlmostEqual(weights, ROUTING_PROFILE_PRESETS.stable)) return 'stable';
|
||||
if (weightsAlmostEqual(weights, ROUTING_PROFILE_PRESETS.cost)) return 'cost';
|
||||
return 'custom';
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildSiteSaveAction } from './sitesEditor.js';
|
||||
|
||||
describe('buildSiteSaveAction', () => {
|
||||
it('returns add action in add mode', () => {
|
||||
const action = buildSiteSaveAction(
|
||||
{ mode: 'add' },
|
||||
{ name: 'site-a', url: 'https://a.example.com/', platform: 'new-api', apiKey: 'sk-1' },
|
||||
);
|
||||
|
||||
expect(action).toEqual({
|
||||
kind: 'add',
|
||||
payload: {
|
||||
name: 'site-a',
|
||||
url: 'https://a.example.com/',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns update action in edit mode with site id', () => {
|
||||
const action = buildSiteSaveAction(
|
||||
{ mode: 'edit', editingSiteId: 12 },
|
||||
{ name: 'site-b', url: 'https://b.example.com', platform: 'one-api', apiKey: '' },
|
||||
);
|
||||
|
||||
expect(action).toEqual({
|
||||
kind: 'update',
|
||||
id: 12,
|
||||
payload: {
|
||||
name: 'site-b',
|
||||
url: 'https://b.example.com',
|
||||
platform: 'one-api',
|
||||
apiKey: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when edit mode has no site id', () => {
|
||||
expect(() =>
|
||||
buildSiteSaveAction(
|
||||
{ mode: 'edit' },
|
||||
{ name: 'site-c', url: 'https://c.example.com', platform: '', apiKey: '' },
|
||||
),
|
||||
).toThrow('editingSiteId is required in edit mode');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
export type SiteForm = {
|
||||
name: string;
|
||||
url: string;
|
||||
platform: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
export type SiteEditorState =
|
||||
| { mode: 'add' }
|
||||
| { mode: 'edit'; editingSiteId: number };
|
||||
|
||||
type SiteSaveAction =
|
||||
| { kind: 'add'; payload: SiteForm }
|
||||
| { kind: 'update'; id: number; payload: SiteForm };
|
||||
|
||||
export function emptySiteForm(): SiteForm {
|
||||
return { name: '', url: '', platform: '', apiKey: '' };
|
||||
}
|
||||
|
||||
export function siteFormFromSite(site: Partial<SiteForm>): SiteForm {
|
||||
return {
|
||||
name: site.name ?? '',
|
||||
url: site.url ?? '',
|
||||
platform: site.platform ?? '',
|
||||
apiKey: site.apiKey ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSiteSaveAction(editor: SiteEditorState, form: SiteForm): SiteSaveAction {
|
||||
if (editor.mode === 'edit') {
|
||||
if (!Number.isFinite(editor.editingSiteId)) {
|
||||
throw new Error('editingSiteId is required in edit mode');
|
||||
}
|
||||
return { kind: 'update', id: editor.editingSiteId, payload: form };
|
||||
}
|
||||
return { kind: 'add', payload: form };
|
||||
}
|
||||
Reference in New Issue
Block a user