// shre-router client. POST /v1/chat with stream:false. // Real model answer lives in .message.content; top-level .content can carry an // upstream-down apology — always prefer .message.content. export async function chat({ routerUrl, model, messages, agentId, sessionId, tenantId, timeoutMs }) { const res = await fetch(`${routerUrl.replace(/\/$/, '')}/v1/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, // tools:false + raw:true — reviews need pure model reasoning. raw:true is // the router's sanctioned _bypassToolRouting flag: without it, keyword // fast-paths (current-info briefing, store-resolver, retail dispatch) // hijack prompts whose DIFF CONTENT mentions github/sales/store-ish terms // and return non-review text. The domain preflight (agent_mismatch 400) // still applies per agent tier; the pipeline's attempt-2 fallback agent // covers that. body: JSON.stringify({ model, messages, agentId, sessionId, tenantId, stream: false, tools: false, raw: true }), signal: AbortSignal.timeout(timeoutMs) }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`router -> ${res.status}: ${text.slice(0, 300)}`); } const data = await res.json(); // Response shape varies by upstream: prefer .message.content, then // OpenAI-style choices, then top-level .content (which can carry an // upstream-down apology — hence last). const content = data?.message?.content ?? data?.choices?.[0]?.message?.content ?? data?.content; if (typeof content !== 'string' || !content.trim()) { throw new Error('router returned empty content'); } return { content, servedModel: data?._shre?.model ?? null }; }