include remaining workspace changes
This commit is contained in:
@@ -4,6 +4,7 @@ import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { PgDialect } from 'drizzle-orm/pg-core';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
@@ -39,6 +40,19 @@ describe('downstream api keys routes', () => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('builds postgres trend buckets by casting text timestamps before date_trunc', async () => {
|
||||
const routesModule = await import('./downstreamApiKeys.js') as Record<string, any>;
|
||||
|
||||
expect(typeof routesModule.buildBucketTsExpressionForDialect).toBe('function');
|
||||
|
||||
const expression = routesModule.buildBucketTsExpressionForDialect('postgres', schema.proxyLogs.createdAt, 3600);
|
||||
const rendered = new PgDialect().sqlToQuery(expression).sql;
|
||||
|
||||
expect(rendered).toContain('date_trunc');
|
||||
expect(rendered).toContain('cast');
|
||||
expect(rendered).toContain('timestamp');
|
||||
});
|
||||
|
||||
it('creates, updates, resets and deletes downstream api keys', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'portal-site',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { and, eq, inArray, sql, type SQL } from 'drizzle-orm';
|
||||
import { and, eq, inArray, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
|
||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, runtimeDbDialect, schema } from '../../db/index.js';
|
||||
import {
|
||||
getDownstreamApiKeyById,
|
||||
@@ -95,18 +95,27 @@ function resolveBucketSeconds(range: DownstreamKeyRange): number {
|
||||
return range === 'all' ? 86400 : 3600;
|
||||
}
|
||||
|
||||
function resolveBucketTsExpression(bucketSeconds: number) {
|
||||
if (runtimeDbDialect === 'mysql') {
|
||||
return sql<number>`floor(unix_timestamp(${schema.proxyLogs.createdAt}) / ${bucketSeconds}) * ${bucketSeconds}`;
|
||||
export function buildBucketTsExpressionForDialect(
|
||||
dialect: 'sqlite' | 'mysql' | 'postgres',
|
||||
createdAtColumn: SQLWrapper,
|
||||
bucketSeconds: number,
|
||||
) {
|
||||
if (dialect === 'mysql') {
|
||||
return sql<number>`floor(unix_timestamp(${createdAtColumn}) / ${bucketSeconds}) * ${bucketSeconds}`;
|
||||
}
|
||||
if (runtimeDbDialect === 'postgres') {
|
||||
if (dialect === 'postgres') {
|
||||
const createdAtTimestamp = sql`cast(${createdAtColumn} as timestamp)`;
|
||||
if (bucketSeconds === 86400) {
|
||||
return sql<number>`extract(epoch from date_trunc('day', ${schema.proxyLogs.createdAt}))::bigint`;
|
||||
return sql<number>`extract(epoch from date_trunc('day', ${createdAtTimestamp}))::bigint`;
|
||||
}
|
||||
return sql<number>`extract(epoch from date_trunc('hour', ${schema.proxyLogs.createdAt}))::bigint`;
|
||||
return sql<number>`extract(epoch from date_trunc('hour', ${createdAtTimestamp}))::bigint`;
|
||||
}
|
||||
// sqlite
|
||||
return sql<number>`cast(cast(strftime('%s', ${schema.proxyLogs.createdAt}) as integer) / ${bucketSeconds} as integer) * ${bucketSeconds}`;
|
||||
return sql<number>`cast(cast(strftime('%s', ${createdAtColumn}) as integer) / ${bucketSeconds} as integer) * ${bucketSeconds}`;
|
||||
}
|
||||
|
||||
function resolveBucketTsExpression(bucketSeconds: number) {
|
||||
return buildBucketTsExpressionForDialect(runtimeDbDialect, schema.proxyLogs.createdAt, bucketSeconds);
|
||||
}
|
||||
|
||||
async function validatePolicyReferences(input: {
|
||||
|
||||
@@ -52,6 +52,7 @@ vi.mock('../../db/index.js', () => ({
|
||||
|
||||
function parseSsePayloads(body: string): Array<Record<string, unknown>> {
|
||||
return body
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n\n')
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean)
|
||||
@@ -520,21 +521,32 @@ describe('gemini native proxy routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps non-sse json-array streaming payloads on the wire as chunk responses', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify([
|
||||
it('keeps non-sse json-array streaming payloads on the wire as raw chunk responses', async () => {
|
||||
const upstreamPayload = [
|
||||
{
|
||||
promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' },
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'first' }] },
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: 'tool-1', name: 'lookup_weather', args: { city: 'Shanghai' } },
|
||||
thoughtSignature: 'sig-tool-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
groundingMetadata: { source: 'web' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
serverContent: { modelTurn: { parts: [{ text: 'tool result received' }] } },
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'second', thoughtSignature: 'sig-1' }] },
|
||||
citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
@@ -545,7 +557,9 @@ describe('gemini native proxy routes', () => {
|
||||
thoughtsTokenCount: 3,
|
||||
},
|
||||
},
|
||||
]), {
|
||||
];
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify(upstreamPayload), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
@@ -567,45 +581,7 @@ describe('gemini native proxy routes', () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual([
|
||||
{
|
||||
responseId: '',
|
||||
modelVersion: '',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'first' }],
|
||||
},
|
||||
groundingMetadata: { source: 'web' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
responseId: '',
|
||||
modelVersion: '',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'second', thoughtSignature: 'sig-1' }],
|
||||
},
|
||||
citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] },
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 11,
|
||||
candidatesTokenCount: 6,
|
||||
totalTokenCount: 17,
|
||||
cachedContentTokenCount: 2,
|
||||
thoughtsTokenCount: 3,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(response.json()).toEqual(upstreamPayload);
|
||||
});
|
||||
|
||||
it('derives gemini-3 thinkingLevel from OpenAI-style reasoning inputs in the runtime request path', async () => {
|
||||
@@ -665,12 +641,13 @@ describe('gemini native proxy routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('streams SSE payloads as normalized chunk events instead of cumulative aggregate snapshots', async () => {
|
||||
it('streams SSE payloads as raw upstream events without reserializing tool-calling chunks', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"responseId":"resp-sse","modelVersion":"gemini-2.5-flash","candidates":[{"content":{"role":"model","parts":[{"text":"first"}]},"groundingMetadata":{"source":"web"}}]}\r\n\r\n'));
|
||||
controller.enqueue(encoder.encode('data: {"candidates":[{"content":{"role":"model","parts":[{"text":"second","thoughtSignature":"sig-1"}]},"citationMetadata":{"citations":[{"startIndex":0,"endIndex":5}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":6,"totalTokenCount":17,"cachedContentTokenCount":2,"thoughtsTokenCount":3}}\r\n\r\n'));
|
||||
controller.enqueue(encoder.encode('data: {"promptFeedback":{"blockReason":"BLOCK_REASON_UNSPECIFIED"},"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"id":"tool-1","name":"lookup_weather","args":{"city":"Shanghai"}},"thoughtSignature":"sig-tool-1"}]},"groundingMetadata":{"source":"web"}}]}\r\n\r\n'));
|
||||
controller.enqueue(encoder.encode('data: {"serverContent":{"modelTurn":{"parts":[{"text":"tool result received"}]}},"candidates":[{"content":{"role":"model","parts":[{"text":"second","thoughtSignature":"sig-1"}]},"citationMetadata":{"citations":[{"startIndex":0,"endIndex":5}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":6,"totalTokenCount":17,"cachedContentTokenCount":2,"thoughtsTokenCount":3}}\r\n\r\n'));
|
||||
controller.enqueue(encoder.encode('data: [DONE]\r\n\r\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
@@ -702,25 +679,30 @@ describe('gemini native proxy routes', () => {
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({
|
||||
responseId: 'resp-sse',
|
||||
modelVersion: 'gemini-2.5-flash',
|
||||
promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' },
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'first' }],
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: 'tool-1', name: 'lookup_weather', args: { city: 'Shanghai' } },
|
||||
thoughtSignature: 'sig-tool-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
groundingMetadata: { source: 'web' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(events[0]).not.toMatchObject({
|
||||
candidates: [
|
||||
{
|
||||
citationMetadata: expect.anything(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(events[0]).not.toHaveProperty('responseId');
|
||||
expect(events[0]).not.toHaveProperty('modelVersion');
|
||||
expect(events[0].candidates?.[0]).not.toHaveProperty('finishReason');
|
||||
expect(events[1]).toMatchObject({
|
||||
serverContent: {
|
||||
modelTurn: {
|
||||
parts: [{ text: 'tool result received' }],
|
||||
},
|
||||
},
|
||||
usageMetadata: {
|
||||
promptTokenCount: 11,
|
||||
candidatesTokenCount: 6,
|
||||
@@ -738,14 +720,8 @@ describe('gemini native proxy routes', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(events[1]).not.toMatchObject({
|
||||
candidates: [
|
||||
{
|
||||
groundingMetadata: expect.anything(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(response.body).not.toContain('\r\n\r\n');
|
||||
expect(response.body).toContain('\r\n\r\n');
|
||||
expect(response.body).toContain('data: [DONE]\r\n\r\n');
|
||||
});
|
||||
|
||||
it('falls back to the next channel when first Gemini channel returns 400 before any bytes are written', async () => {
|
||||
|
||||
Reference in New Issue
Block a user