Files
AgapHost/ai/pipecat/test_pipeline.py
alvis 9094d71e2f ai: migrate LLM backbone from Kimi CLI to Codex CLI
Retires the Moonshot/Kimi subscription in favour of the already-paid ChatGPT
plan. Both CLI wrappers now run `codex exec`; the kimi-agent container is gone.

adolf-llm + hindsight-llm:
- runKimi -> runCodex (`codex exec --json --skip-git-repo-check`), resume via
  `codex exec resume <thread_id>`.
- MCP moves from a per-session .mcp.json (a workaround for Kimi having no
  --mcp-config-file flag) to a $CODEX_HOME/config.toml generated once at
  startup from shared-mcp.json. Field translation is load-bearing:
  bearerTokenEnvVar -> bearer_token_env_var, enabledTools -> enabled_tools.
- approval_policy="never" + sandbox_mode required, or unattended turns block
  on an approval prompt nobody can answer.

kimi-agent removed. It was the ONLY large-tier deployment behind LiteLLM, so
deleting it outright would have silently degraded every large-tier request to
the local 4B model via the existing fallbacks. tier-large, the auto_router
complex-reasoning route and their fallbacks now point at the codex-backed
adolf-llm wrapper (model_name: codex-agent).

Three environment blockers fixed along the way:
- OpenAI geo-blocks this host (403 unsupported_country_region_territory).
  Both containers now egress via the host xray proxy, with NO_PROXY keeping
  MCP and *.alogins.net traffic off the tunnel.
- node:22-slim ships no system CA store; the Rust codex binary validates TLS
  against it, so every HTTPS call failed with a generic transport error while
  Node's own fetch worked. ca-certificates added to both images.
- `codex exec resume` rejects -C/--cd (plain `codex exec` accepts it), which
  broke follow-up turns while first turns succeeded.

Known regression: Kimi's managed-usage API has no Codex equivalent, so the
/usage route returns 501 and there is no quota probe for the codex model.
The two quota plugins degrade quietly to no output.

Also: stop tracking cognee.env (live LLM + JWT secrets) and gitignore it.
The secrets remain in earlier history and should be rotated.

Verified live: plain turn, SSE streaming, session resume, MCP tool call,
bearer-token MCP call, and completions through both LiteLLM routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Y5QPagv4iun1ghpwM96Ff
2026-08-01 06:13:27 +00:00

123 lines
4.5 KiB
Python

"""
End-to-end pipeline test:
1. Call /connect to get a room + token
2. Join the LiveKit room as a Python client
3. Publish TTS audio (pre-generated) as microphone input
4. Capture bot's audio response and save to file
"""
import asyncio
import wave
import struct
import httpx
import numpy as np
from livekit import rtc
PIPECAT_URL = "http://localhost:8882"
TTS_URL = "http://host.docker.internal:8881"
OUTPUT_FILE = "/tmp/bot_response.wav"
SAMPLE_RATE = 48000
NUM_CHANNELS = 1
async def generate_tts_pcm(text: str) -> bytes:
"""Get WAV audio from Silero TTS, return raw PCM int16 bytes."""
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(f"{TTS_URL}/v1/audio/speech", json={
"input": text, "voice": "onyx", "response_format": "wav"
})
r.raise_for_status()
# Skip WAV header (44 bytes) to get raw PCM
return r.content[44:]
async def main():
# Step 1 — create room
print("[test] Creating room...")
async with httpx.AsyncClient() as c:
r = await c.post(f"{PIPECAT_URL}/connect")
r.raise_for_status()
creds = r.json()
print(f"[test] Room: {creds['room']} URL: {creds['url']}")
# Step 2 — generate test audio
test_phrase = "Привет! Как тебя зовут?"
print(f"[test] Generating TTS for: {test_phrase!r}")
pcm_bytes = await generate_tts_pcm(test_phrase)
print(f"[test] TTS PCM: {len(pcm_bytes)} bytes (~{len(pcm_bytes)//(SAMPLE_RATE*2):.1f}s)")
# Step 3 — join room
room = rtc.Room()
received_frames: list[bytes] = []
@room.on("track_subscribed")
def on_track(track, pub, participant):
if track.kind == rtc.TrackKind.KIND_AUDIO and participant.identity == "pipecat-bot":
print(f"[test] Subscribed to bot audio track")
audio_stream = rtc.AudioStream(track, sample_rate=SAMPLE_RATE, num_channels=NUM_CHANNELS)
asyncio.ensure_future(_collect_audio(audio_stream, received_frames))
ws_url = creds["url"].replace("https://", "wss://").replace("http://", "ws://")
# Connect internally via host.docker.internal
internal_url = "ws://host.docker.internal:7880"
print(f"[test] Connecting to LiveKit at {internal_url}...")
await room.connect(internal_url, creds["token"])
print(f"[test] Connected. Waiting for bot to join...")
# Wait for bot participant
for _ in range(20):
if any(p.identity == "pipecat-bot" for p in room.remote_participants.values()):
break
await asyncio.sleep(0.5)
print(f"[test] Participants: {[p.identity for p in room.remote_participants.values()]}")
# Step 4 — publish audio as microphone
print("[test] Publishing audio track...")
source = rtc.AudioSource(SAMPLE_RATE, NUM_CHANNELS)
local_track = rtc.LocalAudioTrack.create_audio_track("microphone", source)
opts = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE)
await room.local_participant.publish_track(local_track, opts)
# Send PCM in 20ms chunks
chunk_samples = SAMPLE_RATE * 20 // 1000 # 960 samples per chunk
chunk_bytes = chunk_samples * 2 # int16
print(f"[test] Sending {len(pcm_bytes) // chunk_bytes} audio chunks...")
for i in range(0, len(pcm_bytes), chunk_bytes):
chunk = pcm_bytes[i:i + chunk_bytes]
if len(chunk) < chunk_bytes:
chunk = chunk + b'\x00' * (chunk_bytes - len(chunk))
samples = np.frombuffer(chunk, dtype=np.int16)
frame = rtc.AudioFrame(
data=samples.tobytes(),
sample_rate=SAMPLE_RATE,
num_channels=NUM_CHANNELS,
samples_per_channel=chunk_samples,
)
await source.capture_frame(frame)
await asyncio.sleep(0.02)
print("[test] Audio sent. Waiting for bot response (up to 30s)...")
await asyncio.sleep(30)
await room.disconnect()
# Step 5 — save response
if received_frames:
total = b"".join(received_frames)
print(f"[test] Received {len(total)} bytes of bot audio ({len(total)//(SAMPLE_RATE*2):.1f}s)")
with wave.open(OUTPUT_FILE, "wb") as wf:
wf.setnchannels(NUM_CHANNELS)
wf.setsampwidth(2)
wf.setframerate(SAMPLE_RATE)
wf.writeframes(total)
print(f"[test] Saved to {OUTPUT_FILE}")
else:
print("[test] No audio received from bot!")
async def _collect_audio(stream: rtc.AudioStream, buf: list):
async for event in stream:
buf.append(bytes(event.frame.data))
asyncio.run(main())