forked from boos/BoosAPI
Merge branch 'main' of https://github.com/cita-777/metapi
This commit is contained in:
@@ -38,4 +38,79 @@ describe('runtime schema bootstrap', () => {
|
|||||||
|
|
||||||
expect(executedSql.slice(0, expectedBootstrapSql.length)).toEqual(expectedBootstrapSql);
|
expect(executedSql.slice(0, expectedBootstrapSql.length)).toEqual(expectedBootstrapSql);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores duplicate mysql index errors when replaying bootstrap statements', async () => {
|
||||||
|
const executedSql: string[] = [];
|
||||||
|
const targetSql = 'CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191))';
|
||||||
|
|
||||||
|
await ensureRuntimeDatabaseSchema({
|
||||||
|
...createStubClient('mysql', executedSql),
|
||||||
|
execute: async (sqlText: string) => {
|
||||||
|
executedSql.push(sqlText);
|
||||||
|
if (sqlText === targetSql) {
|
||||||
|
const error = new Error("Duplicate key name 'model_availability_account_model_unique'") as Error & { code?: string };
|
||||||
|
error.code = 'ER_DUP_KEYNAME';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(executedSql).toContain(targetSql);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replays the mysql bootstrap against an already-initialized schema', async () => {
|
||||||
|
const executedSql: string[] = [];
|
||||||
|
const createdStatements = new Set<string>();
|
||||||
|
|
||||||
|
const mysqlClient = createStubClient('mysql', executedSql);
|
||||||
|
mysqlClient.execute = async (sqlText: string) => {
|
||||||
|
executedSql.push(sqlText);
|
||||||
|
const normalized = sqlText.trim().toLowerCase();
|
||||||
|
const createsSchemaObject = normalized.startsWith('create table if not exists')
|
||||||
|
|| normalized.startsWith('create index')
|
||||||
|
|| normalized.startsWith('create unique index');
|
||||||
|
|
||||||
|
if (createsSchemaObject) {
|
||||||
|
if (createdStatements.has(sqlText)) {
|
||||||
|
const error = new Error(
|
||||||
|
normalized.startsWith('create table')
|
||||||
|
? 'Table already exists'
|
||||||
|
: 'Duplicate key name during bootstrap replay',
|
||||||
|
) as Error & { code?: string };
|
||||||
|
error.code = normalized.startsWith('create table') ? 'ER_TABLE_EXISTS_ERROR' : 'ER_DUP_KEYNAME';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
createdStatements.add(sqlText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
await ensureRuntimeDatabaseSchema(mysqlClient);
|
||||||
|
await ensureRuntimeDatabaseSchema(mysqlClient);
|
||||||
|
|
||||||
|
expect(executedSql.length).toBeGreaterThan(createdStatements.size);
|
||||||
|
expect(createdStatements.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores postgres relation-already-exists errors when replaying bootstrap statements', async () => {
|
||||||
|
const executedSql: string[] = [];
|
||||||
|
const targetSql = 'CREATE UNIQUE INDEX "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name")';
|
||||||
|
|
||||||
|
await ensureRuntimeDatabaseSchema({
|
||||||
|
...createStubClient('postgres', executedSql),
|
||||||
|
execute: async (sqlText: string) => {
|
||||||
|
executedSql.push(sqlText);
|
||||||
|
if (sqlText === targetSql) {
|
||||||
|
const error = new Error('relation "model_availability_account_model_unique" already exists') as Error & { code?: string };
|
||||||
|
error.code = '42P07';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(executedSql).toContain(targetSql);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,38 @@ export interface RuntimeSchemaConnectionInput {
|
|||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||||
|
if (typeof error === 'object' && error && 'message' in error) {
|
||||||
|
return String((error as { message?: unknown }).message || '');
|
||||||
|
}
|
||||||
|
return String(error || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExistingSchemaObjectError(error: unknown): boolean {
|
||||||
|
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||||
|
const code = typeof error === 'object' && error && 'code' in error
|
||||||
|
? String((error as { code?: unknown }).code || '')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return code === 'ER_DUP_KEYNAME'
|
||||||
|
|| code === 'ER_TABLE_EXISTS_ERROR'
|
||||||
|
|| code === '42P07'
|
||||||
|
|| code === '42710'
|
||||||
|
|| lowered.includes('already exists')
|
||||||
|
|| lowered.includes('duplicate key name')
|
||||||
|
|| lowered.includes('relation') && lowered.includes('already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeBootstrapStatement(client: RuntimeSchemaClient, sqlText: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await client.execute(sqlText);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isExistingSchemaObjectError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function validateIdentifier(identifier: string): string {
|
function validateIdentifier(identifier: string): string {
|
||||||
if (!/^[a-z_][a-z0-9_]*$/i.test(identifier)) {
|
if (!/^[a-z_][a-z0-9_]*$/i.test(identifier)) {
|
||||||
throw new Error(`Invalid SQL identifier: ${identifier}`);
|
throw new Error(`Invalid SQL identifier: ${identifier}`);
|
||||||
@@ -212,7 +244,7 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
|
|||||||
: readGeneratedBootstrapStatements(client.dialect);
|
: readGeneratedBootstrapStatements(client.dialect);
|
||||||
|
|
||||||
for (const sqlText of statements) {
|
for (const sqlText of statements) {
|
||||||
await client.execute(sqlText);
|
await executeBootstrapStatement(client, sqlText);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureLegacySchemaCompatibility(createLegacySchemaInspector(client));
|
await ensureLegacySchemaCompatibility(createLegacySchemaInspector(client));
|
||||||
|
|||||||
@@ -614,6 +614,105 @@ describe('buildUpstreamEndpointRequest', () => {
|
|||||||
expect(request.headers['Content-Type']).toBe('application/json');
|
expect(request.headers['Content-Type']).toBe('application/json');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('strips unsupported openai parameters like frequency_penalty for Gemini models on chat endpoints', () => {
|
||||||
|
const request = buildUpstreamEndpointRequest({
|
||||||
|
endpoint: 'chat',
|
||||||
|
modelName: 'gemini-1.5-pro',
|
||||||
|
stream: false,
|
||||||
|
tokenValue: 'sk-test',
|
||||||
|
sitePlatform: 'gemini',
|
||||||
|
openaiBody: {
|
||||||
|
model: 'gemini-1.5-pro',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
frequency_penalty: 0.5,
|
||||||
|
presence_penalty: 0.2,
|
||||||
|
logit_bias: { '100': 1 },
|
||||||
|
logprobs: true,
|
||||||
|
top_logprobs: 2,
|
||||||
|
store: true,
|
||||||
|
temperature: 0.8,
|
||||||
|
top_p: 1.0,
|
||||||
|
},
|
||||||
|
downstreamFormat: 'openai',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.path).toBe('/v1beta/openai/chat/completions');
|
||||||
|
expect(request.body.frequency_penalty).toBeUndefined();
|
||||||
|
expect(request.body.presence_penalty).toBeUndefined();
|
||||||
|
expect(request.body.logit_bias).toBeUndefined();
|
||||||
|
expect(request.body.logprobs).toBeUndefined();
|
||||||
|
expect(request.body.top_logprobs).toBeUndefined();
|
||||||
|
expect(request.body.store).toBeUndefined();
|
||||||
|
expect(request.body.temperature).toBe(0.8);
|
||||||
|
expect(request.body.top_p).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips unsupported openai parameters like frequency_penalty for Gemini models on responses endpoints', () => {
|
||||||
|
const request = buildUpstreamEndpointRequest({
|
||||||
|
endpoint: 'responses',
|
||||||
|
modelName: 'gemini-1.5-pro',
|
||||||
|
stream: false,
|
||||||
|
tokenValue: 'sk-test',
|
||||||
|
sitePlatform: 'gemini',
|
||||||
|
openaiBody: {
|
||||||
|
model: 'gemini-1.5-pro',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
frequency_penalty: 0.5,
|
||||||
|
presence_penalty: 0.2,
|
||||||
|
logit_bias: { '100': 1 },
|
||||||
|
logprobs: true,
|
||||||
|
top_logprobs: 2,
|
||||||
|
store: true,
|
||||||
|
temperature: 0.8,
|
||||||
|
top_p: 1.0,
|
||||||
|
},
|
||||||
|
downstreamFormat: 'openai',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.path).toBe('/v1beta/openai/responses');
|
||||||
|
expect(request.body.frequency_penalty).toBeUndefined();
|
||||||
|
expect(request.body.presence_penalty).toBeUndefined();
|
||||||
|
expect(request.body.logit_bias).toBeUndefined();
|
||||||
|
expect(request.body.logprobs).toBeUndefined();
|
||||||
|
expect(request.body.top_logprobs).toBeUndefined();
|
||||||
|
expect(request.body.store).toBeUndefined();
|
||||||
|
expect(request.body.temperature).toBe(0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips unsupported openai parameters like frequency_penalty for Gemini models from downstream responses bodies', () => {
|
||||||
|
const request = buildUpstreamEndpointRequest({
|
||||||
|
endpoint: 'responses',
|
||||||
|
modelName: 'gemini-1.5-pro',
|
||||||
|
stream: false,
|
||||||
|
tokenValue: 'sk-test',
|
||||||
|
sitePlatform: 'gemini',
|
||||||
|
openaiBody: {},
|
||||||
|
downstreamFormat: 'responses',
|
||||||
|
responsesOriginalBody: {
|
||||||
|
model: 'gemini-1.5-pro',
|
||||||
|
input: 'hello',
|
||||||
|
frequency_penalty: 0.5,
|
||||||
|
presence_penalty: 0.2,
|
||||||
|
logit_bias: { '100': 1 },
|
||||||
|
logprobs: true,
|
||||||
|
top_logprobs: 2,
|
||||||
|
store: true,
|
||||||
|
temperature: 0.8,
|
||||||
|
top_p: 1.0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.path).toBe('/v1beta/openai/responses');
|
||||||
|
expect(request.body.frequency_penalty).toBeUndefined();
|
||||||
|
expect(request.body.presence_penalty).toBeUndefined();
|
||||||
|
expect(request.body.logit_bias).toBeUndefined();
|
||||||
|
expect(request.body.logprobs).toBeUndefined();
|
||||||
|
expect(request.body.top_logprobs).toBeUndefined();
|
||||||
|
expect(request.body.store).toBeUndefined();
|
||||||
|
expect(request.body.temperature).toBe(0.8);
|
||||||
|
expect(request.body.top_p).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves structured responses content blocks instead of flattening them', () => {
|
it('preserves structured responses content blocks instead of flattening them', () => {
|
||||||
const request = buildUpstreamEndpointRequest({
|
const request = buildUpstreamEndpointRequest({
|
||||||
endpoint: 'responses',
|
endpoint: 'responses',
|
||||||
|
|||||||
@@ -437,6 +437,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
} {
|
} {
|
||||||
const sitePlatform = normalizePlatformName(input.sitePlatform);
|
const sitePlatform = normalizePlatformName(input.sitePlatform);
|
||||||
const isClaudeUpstream = sitePlatform === 'claude';
|
const isClaudeUpstream = sitePlatform === 'claude';
|
||||||
|
const isGeminiUpstream = sitePlatform === 'gemini';
|
||||||
|
|
||||||
const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => {
|
const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => {
|
||||||
const normalizedSiteUrl = asTrimmedString(input.siteUrl).toLowerCase();
|
const normalizedSiteUrl = asTrimmedString(input.siteUrl).toLowerCase();
|
||||||
@@ -452,7 +453,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveEndpointPath = (endpoint: UpstreamEndpoint): string => {
|
const resolveEndpointPath = (endpoint: UpstreamEndpoint): string => {
|
||||||
if (sitePlatform === 'gemini') {
|
if (isGeminiUpstream) {
|
||||||
return resolveGeminiEndpointPath(endpoint);
|
return resolveGeminiEndpointPath(endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,6 +481,25 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
commonHeaders.Authorization = `Bearer ${input.tokenValue}`;
|
commonHeaders.Authorization = `Bearer ${input.tokenValue}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stripGeminiUnsupportedFields = (body: Record<string, unknown>) => {
|
||||||
|
const next = { ...body };
|
||||||
|
if (isGeminiUpstream) {
|
||||||
|
for (const key of [
|
||||||
|
'frequency_penalty',
|
||||||
|
'presence_penalty',
|
||||||
|
'logit_bias',
|
||||||
|
'logprobs',
|
||||||
|
'top_logprobs',
|
||||||
|
'store',
|
||||||
|
]) {
|
||||||
|
delete next[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openaiBody = stripGeminiUnsupportedFields(input.openaiBody);
|
||||||
|
|
||||||
if (input.endpoint === 'messages') {
|
if (input.endpoint === 'messages') {
|
||||||
const claudeHeaders = input.downstreamFormat === 'claude'
|
const claudeHeaders = input.downstreamFormat === 'claude'
|
||||||
? extractClaudePassthroughHeaders(input.downstreamHeaders)
|
? extractClaudePassthroughHeaders(input.downstreamHeaders)
|
||||||
@@ -514,7 +534,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
const sanitizedBody = nativeClaudeBody
|
const sanitizedBody = nativeClaudeBody
|
||||||
?? normalizedClaudeBody
|
?? normalizedClaudeBody
|
||||||
?? sanitizeAnthropicMessagesBody(
|
?? sanitizeAnthropicMessagesBody(
|
||||||
convertOpenAiBodyToAnthropicMessagesBody(input.openaiBody, input.modelName, input.stream),
|
convertOpenAiBodyToAnthropicMessagesBody(openaiBody, input.modelName, input.stream),
|
||||||
);
|
);
|
||||||
|
|
||||||
const headers = ensureStreamAcceptHeader({
|
const headers = ensureStreamAcceptHeader({
|
||||||
@@ -538,11 +558,11 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
const rawBody = (
|
const rawBody = (
|
||||||
input.downstreamFormat === 'responses' && input.responsesOriginalBody
|
input.downstreamFormat === 'responses' && input.responsesOriginalBody
|
||||||
? {
|
? {
|
||||||
...input.responsesOriginalBody,
|
...stripGeminiUnsupportedFields(input.responsesOriginalBody),
|
||||||
model: input.modelName,
|
model: input.modelName,
|
||||||
stream: input.stream,
|
stream: input.stream,
|
||||||
}
|
}
|
||||||
: convertOpenAiBodyToResponsesBodyViaTransformer(input.openaiBody, input.modelName, input.stream)
|
: convertOpenAiBodyToResponsesBodyViaTransformer(openaiBody, input.modelName, input.stream)
|
||||||
);
|
);
|
||||||
const body = sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream);
|
const body = sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream);
|
||||||
|
|
||||||
@@ -563,7 +583,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
path: resolveEndpointPath('chat'),
|
path: resolveEndpointPath('chat'),
|
||||||
headers,
|
headers,
|
||||||
body: {
|
body: {
|
||||||
...input.openaiBody,
|
...openaiBody,
|
||||||
model: input.modelName,
|
model: input.modelName,
|
||||||
stream: input.stream,
|
stream: input.stream,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user