mirror of
https://github.com/moeru-ai/airi.git
synced 2026-05-18 06:31:37 +00:00
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.
132 lines
4.3 KiB
TypeScript
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)
|
|
})
|