#!/usr/bin/env node import { mkdir, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { execFileSync } from 'node:child_process'; const baseURL = (process.env.GATEWAY_BASE_URL || 'http://127.0.0.1:8088').replace(/\/+$/, ''); const authentication = resolveGatewayAPIKey(); const modelCases = [ { model: process.env.GATEWAY_E2E_DOUBAO_MODEL || 'Doubao Seed 2.0 Pro', platform: '火山', protocol: 'openai_responses', converted: false, }, { model: process.env.GATEWAY_E2E_QWEN_MODEL || 'Qwen3.7-Plus', platform: '百炼', protocol: 'openai_responses', converted: false, }, { model: process.env.GATEWAY_E2E_DEEPSEEK_MODEL || 'deepseek-openai:deepseek-v4-pro', platform: 'DeepSeek 官网', protocol: 'openai_chat_completions', converted: true, }, ]; function assert(condition, message) { if (!condition) throw new Error(message); } function resolveGatewayAPIKey() { if (process.env.GATEWAY_E2E_API_KEY) { return { value: process.env.GATEWAY_E2E_API_KEY, source: 'environment', keyId: null }; } const container = process.env.GATEWAY_E2E_DB_CONTAINER || 'postgres'; const database = process.env.GATEWAY_E2E_DB_NAME || 'easyai_ai_gateway'; const user = process.env.GATEWAY_E2E_DB_USER || 'easyai'; const query = `SELECT key.id::text || E'\\t' || key.key_secret FROM gateway_api_keys key JOIN gateway_wallet_accounts wallet ON wallet.gateway_user_id = key.gateway_user_id AND wallet.status = 'active' WHERE key.deleted_at IS NULL AND key.status = 'active' AND key.key_secret IS NOT NULL AND key.key_secret <> '' AND key.scopes ? 'chat' AND (key.expires_at IS NULL OR key.expires_at > now()) AND (wallet.balance - wallet.frozen_balance) > 0 ORDER BY (wallet.balance - wallet.frozen_balance) DESC, key.created_at DESC LIMIT 1`; let output = ''; try { output = execFileSync('docker', ['exec', container, 'psql', '-U', user, '-d', database, '-At', '-c', query], { encoding: 'utf8' }).trim(); } catch { throw new Error('Unable to load an active chat-scoped Gateway API Key from the database; set GATEWAY_E2E_API_KEY explicitly'); } const separator = output.indexOf('\t'); if (separator <= 0 || separator === output.length - 1) { throw new Error('The database does not contain an active chat-scoped Gateway API Key with a stored secret'); } return { keyId: output.slice(0, separator), value: output.slice(separator + 1), source: 'database' }; } async function requestJSON(token, path, body) { const response = await fetch(`${baseURL}${path}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const text = await response.text(); if (!response.ok) throw new Error(`POST ${path} failed ${response.status}: ${text}`); return { body: JSON.parse(text), taskId: response.headers.get('x-gateway-task-id') }; } async function requestStream(token, path, body) { const response = await fetch(`${baseURL}${path}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ ...body, stream: true }), }); const text = await response.text(); if (!response.ok) throw new Error(`POST ${path} failed ${response.status}: ${text}`); const events = parseSSE(text); const errorEvent = events.find((event) => event.event === 'error'); if (errorEvent) throw new Error(`Responses stream failed: ${JSON.stringify(errorEvent.data)}`); const completed = events.findLast((event) => event.event === 'response.completed'); assert(completed?.data?.response, `stream is missing response.completed: ${text}`); validateEventOrder(events); return { events, body: completed.data.response, taskId: response.headers.get('x-gateway-task-id') }; } function parseSSE(text) { return text.split(/\r?\n\r?\n/).flatMap((block) => { if (!block.trim()) return []; let event = 'message'; const data = []; for (const line of block.split(/\r?\n/)) { if (line.startsWith('event:')) event = line.slice(6).trim(); if (line.startsWith('data:')) data.push(line.slice(5).trim()); } if (!data.length) return []; return [{ event, data: JSON.parse(data.join('\n')) }]; }); } function validateEventOrder(events) { const names = events.map((event) => event.event); const created = names.indexOf('response.created'); const inProgress = names.indexOf('response.in_progress'); const completed = names.lastIndexOf('response.completed'); assert(created >= 0, `missing response.created: ${names.join(', ')}`); assert(inProgress > created, `response.in_progress is out of order: ${names.join(', ')}`); assert(completed > inProgress, `response.completed is out of order: ${names.join(', ')}`); assert(completed === names.length - 1, `response.completed must be the final event: ${names.join(', ')}`); assert(!names.includes('[DONE]'), 'Responses stream must not contain a Chat [DONE] event'); } function responseText(response) { if (typeof response.output_text === 'string') return response.output_text; return (response.output || []) .flatMap((item) => item.type === 'message' ? item.content || [] : []) .filter((item) => item.type === 'output_text') .map((item) => item.text || '') .join(''); } function functionCall(response) { return (response.output || []).find((item) => item.type === 'function_call'); } async function getTask(token, taskId) { assert(taskId, 'response is missing X-Gateway-Task-Id'); const response = await fetch(`${baseURL}/api/v1/tasks/${taskId}`, { headers: { Authorization: `Bearer ${token}` }, }); const text = await response.text(); if (!response.ok) throw new Error(`GET task failed ${response.status}: ${text}`); return JSON.parse(text); } function auditSummary(task, expected) { const metrics = task.metrics || {}; assert(String(metrics.platformName || '').includes(expected.platform), `${expected.model} used unexpected platform: ${metrics.platformName}`); assert(metrics.upstreamProtocol === expected.protocol, `${expected.model} used ${metrics.upstreamProtocol}, expected ${expected.protocol}`); assert(Boolean(metrics.responseConverted) === expected.converted, `${expected.model} responseConverted mismatch`); assert(metrics.upstreamEndpoint === (expected.converted ? '/chat/completions' : '/responses'), `${expected.model} upstream endpoint mismatch`); if (expected.converted) { assert(metrics.publicResponseId === `resp_${String(task.id).replaceAll('-', '')}`, `${expected.model} Chat fallback id is not derived from the task id`); } else { assert(metrics.publicResponseId === metrics.upstreamResponseId, `${expected.model} native Responses id was not preserved`); } return { taskId: task.id, publicResponseId: metrics.publicResponseId, upstreamResponseId: metrics.upstreamResponseId, platform: metrics.platformName, platformModelId: metrics.platformModelId, protocol: metrics.upstreamProtocol, upstreamEndpoint: metrics.upstreamEndpoint, responseConverted: metrics.responseConverted, parentResponseId: metrics.parentResponseId || null, chainDepth: metrics.responseChainDepth, usage: task.usage, finalChargeAmount: task.finalChargeAmount, billingSummary: task.billingSummary, billings: task.billings, }; } const token = authentication.value; const results = []; for (const expected of modelCases) { const nonce = `E2E-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`; const first = await requestJSON(token, '/v1/responses', { model: expected.model, input: `请记住校验码 ${nonce}。只回复“已记住”。`, store: true, }); assert(/^resp_[A-Za-z0-9_-]+$/.test(first.body.id), `${expected.model} returned invalid response id`); const second = await requestJSON(token, '/v1/responses', { model: expected.model, input: '我刚才让你记住的校验码是什么?只回复校验码。', previous_response_id: first.body.id, store: true, }); assert(second.body.previous_response_id === first.body.id, `${expected.model} response did not preserve previous_response_id`); assert(responseText(second.body).includes(nonce), `${expected.model} did not preserve ordinary conversation continuity`); const toolOutput = `TOOL-${nonce}`; const normalizedModel = expected.model.toLowerCase(); const requiresNonThinkingToolMode = normalizedModel.startsWith('qwen3.7-') || normalizedModel.includes('deepseek-v4-pro'); const toolReasoning = requiresNonThinkingToolMode ? { reasoning: { effort: 'none' } } : {}; const forcedToolChoice = 'required'; const toolFirst = await requestStream(token, '/v1/responses', { model: expected.model, input: '调用 lookup_verification_code 获取校验结果。', tools: [{ type: 'function', name: 'lookup_verification_code', description: '返回测试校验结果', parameters: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'], additionalProperties: false }, strict: true, }], tool_choice: forcedToolChoice, parallel_tool_calls: true, ...toolReasoning, store: true, }); const call = functionCall(toolFirst.body); assert(call?.call_id, `${expected.model} tool response is missing call_id`); assert(toolFirst.events.some((event) => event.event === 'response.function_call_arguments.delta'), `${expected.model} stream is missing function arguments delta`); const toolSecond = await requestStream(token, '/v1/responses', { model: expected.model, previous_response_id: toolFirst.body.id, input: [{ type: 'function_call_output', call_id: call.call_id, output: JSON.stringify({ verification: toolOutput }) }], ...toolReasoning, store: true, }); assert(responseText(toolSecond.body).includes(toolOutput), `${expected.model} did not consume function_call_output`); const auditedTasks = await Promise.all([ getTask(token, first.taskId), getTask(token, second.taskId), getTask(token, toolFirst.taskId), getTask(token, toolSecond.taskId), ]); const ordinaryFirstAudit = auditSummary(auditedTasks[0], expected); const ordinarySecondAudit = auditSummary(auditedTasks[1], expected); const toolFirstAudit = auditSummary(auditedTasks[2], expected); const toolSecondAudit = auditSummary(auditedTasks[3], expected); assert(ordinarySecondAudit.parentResponseId === first.body.id, `${expected.model} ordinary parent response id mismatch`); assert(ordinarySecondAudit.chainDepth === 1, `${expected.model} ordinary chain depth mismatch`); assert(toolSecondAudit.parentResponseId === toolFirst.body.id, `${expected.model} tool parent response id mismatch`); assert(toolSecondAudit.chainDepth === 1, `${expected.model} tool chain depth mismatch`); results.push({ model: expected.model, ordinaryConversation: { passed: true, first: ordinaryFirstAudit, second: ordinarySecondAudit, }, toolCalling: { passed: true, callId: call.call_id, first: toolFirstAudit, second: toolSecondAudit, firstEventTypes: toolFirst.events.map((event) => event.event), secondEventTypes: toolSecond.events.map((event) => event.event), }, }); } const report = { ok: true, generatedAt: new Date().toISOString(), baseURL, authentication: { type: 'gateway_api_key', source: authentication.source, keyId: authentication.keyId }, results, }; const outputPath = process.env.GATEWAY_E2E_OUTPUT; if (outputPath) { await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); } console.log(JSON.stringify(report, null, 2));