airi/apps/server/scripts/e2e-llm-router.ts
RainbowBird 199f57acfa
refactor(server): drop seed-router-config / seed-streaming-tts scripts
The two seed scripts are fully superseded by the new admin endpoint
`POST /api/admin/config/router` — same encryption, same configKV
writes, same `configkv:invalidate` publish, plus auth + audit + body
limits. Keeping both code paths created a drift risk on the AAD label
and the merge semantics.

Doc + test fallout:
- `e2e-llm-router.ts` now points readers to the admin endpoint for
  the prerequisite seed step.
- `docs/ai-context/verifications/llm-router.md` and
  `streaming-tts.md` get curl-based seed instructions; the 2026-05-15
  llm-router evidence stays intact with a note that the script it
  used has since been removed.
- The U9 follow-up entry in `llm-router.md` flips from "not shipped"
  to "partially shipped" — ETag + HMAC publish are still deferred,
  so the `config_write` / `config_invalid_hmac` Grafana panels stay
  parked.
- Self-edit on the admin route + `app.ts` docstrings to drop the
  earlier "scripts stay as break-glass" wording.
2026-05-18 02:18:28 +08:00

132 lines
4.3 KiB
TypeScript

#!/usr/bin/env tsx
/**
* End-to-end test for U1-U7: hits a real OpenRouter API via the router
* service to prove envelope decrypt + config load + key rotation + upstream
* fetch all work together.
*
* Use when:
* - Verifying the gateway end-to-end after a fresh seed, without going
* through the HTTP auth chain.
*
* Expects:
* - `.env.local` provides REDIS_URL, LLM_ROUTER_MASTER_KEY.
* - `LLM_ROUTER_CONFIG` already seeded via
* `POST /api/admin/config/router` (see
* `docs/ai-context/verifications/llm-router.md` for the curl invocation).
*
* Returns: exit 0 with the assistant response printed; exit 1 on failure.
*/
import { env, exit } from 'node:process'
import Redis from 'ioredis'
import { parseEnv } from '../src/libs/env'
import { createConfigKVService } from '../src/services/config-kv'
import { createLlmRouterService } from '../src/services/llm-router'
import { createEnvelopeCrypto } from '../src/utils/envelope-crypto'
async function main() {
const parsedEnv = parseEnv(env)
if (!parsedEnv.LLM_ROUTER_MASTER_KEY) {
console.error('error: LLM_ROUTER_MASTER_KEY env var is required')
exit(1)
}
const redis = new Redis(parsedEnv.REDIS_URL)
const configKV = createConfigKVService(redis)
const envelope = createEnvelopeCrypto({
masterKey: parsedEnv.LLM_ROUTER_MASTER_KEY,
previousMasterKey: parsedEnv.LLM_ROUTER_MASTER_KEY_PREVIOUS,
})
// Debug wrapper: log every upstream request + response so we can see what
// the router is actually sending when E2E fails. Remove after E2E passes.
const debugFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
console.log(` fetch → POST ${url}`)
if (init?.headers) {
const hdrs = init.headers as Record<string, string>
const auth = hdrs.authorization || hdrs.Authorization
// NOTICE:
// Never log credential substrings: a 30-char prefix of an OpenRouter
// key (`sk-or-v1-bb1a38505a7309...`) is enough to identify the account.
// Print presence only. Source: codex review 2026-05-15 #10.
console.log(` auth = ${auth ? '<set>' : '<none>'}`)
}
if (init?.body) {
console.log(` body = ${String(init.body).slice(0, 200)}`)
}
const res = await fetch(input as any, init as any)
if (!res.ok) {
const clone = res.clone()
const text = await clone.text().catch(() => '<unreadable>')
console.log(`${res.status} body: ${text.slice(0, 300)}`)
}
return res
}
const router = createLlmRouterService({
configKV,
envelopeCrypto: envelope,
gatewayMetrics: null,
fetchImpl: debugFetch,
})
console.log('→ calling router.route() with model=chat-default')
const start = Date.now()
let response: Response
try {
response = await router.route({
modelName: 'chat-default',
body: {
messages: [
{ role: 'user', content: 'Say "hello world" in exactly 3 words, no period.' },
],
max_tokens: 20,
},
headers: {},
})
}
catch (err) {
console.error('router.route threw:', err)
await redis.quit()
exit(1)
}
const elapsed = Date.now() - start
console.log(`← status ${response.status} (${elapsed}ms)`)
if (!response.ok) {
const text = await response.text()
console.error('upstream non-2xx body:', text.slice(0, 500))
await redis.quit()
exit(1)
}
const payload = await response.json() as {
choices?: Array<{ message?: { content?: string } }>
usage?: { prompt_tokens?: number, completion_tokens?: number }
model?: string
}
const content = payload.choices?.[0]?.message?.content
console.log()
console.log('Assistant response:')
console.log(` model: ${payload.model ?? '<unknown>'}`)
console.log(` text: ${JSON.stringify(content)}`)
console.log(` tokens: prompt=${payload.usage?.prompt_tokens ?? '?'} completion=${payload.usage?.completion_tokens ?? '?'}`)
if (!content) {
console.error('error: response.choices[0].message.content was empty')
await redis.quit()
exit(1)
}
console.log()
console.log('E2E PASS — router service successfully called OpenRouter and returned a usable response.')
await redis.quit()
}
main().catch((err) => {
console.error('e2e failed:', err)
exit(1)
})