#!/usr/bin/env node import { execFileSync } from 'node:child_process' function fail(message) { console.error(`api_release_smoke=FAIL ${message}`) process.exit(1) } function assert(condition, message) { if (!condition) fail(message) } const baseURL = (process.env.RELEASE_SMOKE_BASE_URL || '').replace(/\/+$/, '') const webURL = (process.env.RELEASE_SMOKE_WEB_URL || '').replace(/\/+$/, '') const composeProject = process.env.RELEASE_SMOKE_COMPOSE_PROJECT || '' const composeFile = process.env.RELEASE_SMOKE_COMPOSE_FILE || 'docker-compose.yml' const nonce = process.env.RELEASE_SMOKE_NONCE || Date.now().toString(36) const databaseUser = process.env.RELEASE_SMOKE_DATABASE_USER || 'easyai' const databaseName = process.env.RELEASE_SMOKE_DATABASE_NAME || 'easyai_ai_gateway' if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(baseURL)) fail('invalid RELEASE_SMOKE_BASE_URL') if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(webURL)) fail('invalid RELEASE_SMOKE_WEB_URL') if (!/^[a-z0-9][a-z0-9_-]{2,62}$/.test(composeProject)) fail('invalid RELEASE_SMOKE_COMPOSE_PROJECT') if (!/^[a-z_][a-z0-9_]{0,62}$/.test(databaseUser)) fail('invalid RELEASE_SMOKE_DATABASE_USER') if (!/^[a-z_][a-z0-9_]{0,62}$/.test(databaseName)) fail('invalid RELEASE_SMOKE_DATABASE_NAME') async function request(path, { method = 'GET', token = '', body } = {}) { const response = await fetch(`${baseURL}${path}`, { method, signal: AbortSignal.timeout(15_000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }) const text = await response.text() let payload try { payload = text ? JSON.parse(text) : null } catch { fail(`${method} ${path} returned non-JSON HTTP ${response.status}`) } if (!response.ok) fail(`${method} ${path} returned HTTP ${response.status}`) return { response, payload } } async function waitForAPI() { let lastError = 'API did not respond' for (let attempt = 0; attempt < 60; attempt += 1) { try { const response = await fetch(`${baseURL}/api/v1/readyz`, { signal: AbortSignal.timeout(2_000), }) if (response.ok) return lastError = `readiness returned HTTP ${response.status}` } catch (error) { lastError = error.message } await new Promise((resolve) => setTimeout(resolve, 1_000)) } fail(`API readiness timed out: ${lastError}`) } await waitForAPI() const health = await request('/api/v1/healthz') assert(health.payload?.ok === true && health.payload?.service === 'easyai-ai-gateway', 'health payload mismatch') const ready = await request('/api/v1/readyz') assert(ready.payload?.ok === true, 'readiness payload mismatch') const openapi = await request('/api/v1/openapi.json') for (const path of [ '/api/v1/healthz', '/api/v1/readyz', '/api/v1/models', '/api/v1/chat/completions', '/api/v1/responses', '/api/v1/images/generations', ]) { assert(openapi.payload?.paths?.[path], `OpenAPI is missing ${path}`) } const username = `release_smoke_${nonce}`.slice(0, 60) const password = `Release-Smoke-${nonce}-9x!` const registration = await request('/api/v1/auth/register', { method: 'POST', body: { username, email: `${username}@example.invalid`, password }, }) assert(typeof registration.payload?.accessToken === 'string', 'registration did not return an access token') const login = await request('/api/v1/auth/login', { method: 'POST', body: { account: username, password }, }) const jwt = login.payload?.accessToken assert(typeof jwt === 'string' && jwt.length > 20, 'login did not return an access token') try { execFileSync('docker', [ 'compose', '-p', composeProject, '-f', composeFile, 'exec', '-T', 'postgres', 'psql', '-v', 'ON_ERROR_STOP=1', '-U', databaseUser, '-d', databaseName, '-c', `UPDATE gateway_wallet_accounts SET balance = 100000 WHERE gateway_user_id = (SELECT id FROM gateway_users WHERE username = '${username.replaceAll("'", "''")}')`, ], { stdio: ['ignore', 'ignore', 'pipe'] }) } catch { fail('could not fund the ephemeral smoke wallet') } const apiKeyResult = await request('/api/v1/api-keys', { method: 'POST', token: jwt, body: { name: 'release smoke key' }, }) const apiKey = apiKeyResult.payload?.secret assert(typeof apiKey === 'string' && apiKey.startsWith('sk-gw-'), 'API key creation failed') const models = await request('/api/v1/models', { token: apiKey }) assert(Array.isArray(models.payload?.items) || Array.isArray(models.payload?.data), 'models response has no items') const simulation = { runMode: 'simulation', simulation: true, simulationDurationMs: 5 } const chat = await request('/api/v1/chat/completions', { method: 'POST', token: apiKey, body: { model: 'openai:gpt-4o-mini', messages: [{ role: 'user', content: 'release smoke' }], ...simulation }, }) assert(/^chatcmpl-/.test(chat.payload?.id || ''), 'chat simulation response mismatch') const responses = await request('/api/v1/responses', { method: 'POST', token: apiKey, body: { model: 'openai:gpt-4o-mini', input: 'release smoke', ...simulation }, }) assert(/^resp/.test(responses.payload?.id || ''), 'responses simulation response mismatch') const images = await request('/api/v1/images/generations', { method: 'POST', token: apiKey, body: { model: 'openai:gpt-image-1', prompt: 'release smoke', ...simulation }, }) assert(Array.isArray(images.payload?.data) && images.payload.data.length > 0, 'image simulation response mismatch') const webHealth = await fetch(`${webURL}/api/v1/healthz`, { signal: AbortSignal.timeout(15_000) }) assert(webHealth.ok, `web API reverse proxy returned HTTP ${webHealth.status}`) const webApp = await fetch(`${webURL}/`, { signal: AbortSignal.timeout(15_000) }) assert(webApp.ok && (await webApp.text()).includes('EasyAI AI Gateway'), 'web application smoke failed') console.log('api_release_smoke=PASS health=ok readiness=ok openapi=ok auth=ok chat=ok responses=ok images=ok web=ok')