/** * Client half of the pi-native auth-gateway protocol. * * Dispatches a {@link streamSimple}-shaped request to an `gjc auth-gateway` * via `POST /v1/pi/stream`, reads the SSE event stream back, and pushes the * parsed events into a local {@link AssistantMessageEventStream} — the same * stream type every other provider client produces. Callers downstream of * `streamSimple` cannot tell whether the events came from a real provider * SDK or from a gateway hop; they consume `AssistantMessageEvent`s either * way. * * Activated when a {@link Model} has `transport: "pi-native"` set; the * dispatch hook lives in `streamSimple()` (see `../stream.ts`). Used by * containerized GJC deployments that route every LLM call through a * credential-holding sidecar so the container stays credential-free. */ import { readSseJson } from "@gajae-code/utils"; import type { Api, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream as AssistantMessageEventStreamType, Context, Model, SimpleStreamOptions, } from "../types"; import { AssistantMessageEventStream } from "../utils/event-stream"; /** * Fields that must not cross the wire — either non-serializable (functions, * `AbortSignal`, the provider-session `Map`) or server-controlled * (`apiKey`, which the gateway injects from its own credential store; the * client's `apiKey` is the gateway *bearer*, sent in the `Authorization` * header rather than the request body). */ const NON_WIRE_KEYS = new Set([ "signal", "apiKey", "fetch", "onPayload", "onResponse", "onSseEvent", "execHandlers", "cursorExecHandlers", "cursorOnToolResult", "providerSessionState", "fallbackAttempt", ]); function buildWireOptions(options: SimpleStreamOptions | undefined): Record { if (!options) return {}; const wire: Record = {}; for (const [k, v] of Object.entries(options)) { if (v === undefined) continue; if (NON_WIRE_KEYS.has(k as keyof SimpleStreamOptions)) continue; wire[k] = v; } return wire; } async function decodeGatewayError(response: Response): Promise { const status = response.status; let body: unknown; try { body = await response.json(); } catch { body = await response.text().catch(() => ""); } if (typeof body === "object" && body !== null && "error" in body) { const err = (body as { error: unknown }).error; if (typeof err === "object" && err !== null) { const message = (err as { message?: unknown }).message; const type = (err as { type?: unknown }).type; const code = (err as { code?: unknown }).code; const out = new Error(typeof message === "string" ? message : `auth-gateway ${status}`); const transportError = out as Error & { status?: number; type?: string; providerCode?: string; headers?: Headers; }; transportError.status = status; transportError.headers = response.headers; if (typeof type === "string") transportError.type = type; if (typeof code === "string") transportError.providerCode = code; else if (typeof type === "string") transportError.providerCode = type; return out; } } const text = typeof body === "string" ? body : JSON.stringify(body); const err = new Error(`auth-gateway ${status}: ${text || response.statusText}`); const transportError = err as Error & { status?: number; headers?: Headers }; transportError.status = status; transportError.headers = response.headers; return err; } /** * Resolve the `/v1/pi/stream` endpoint URL from the model's `baseUrl`. * Trims a trailing slash so concatenation can't double-slash; throws when * the baseUrl is missing (transport=pi-native without a gateway target is * a configuration error, not a runtime recoverable one). */ function resolveStreamUrl(model: Model): string { if (!model.baseUrl) { throw new Error( `pi-native transport requires \`baseUrl\` on model ${model.id} (set it on the provider config in models.yml)`, ); } return `${model.baseUrl.replace(/\/+$/, "")}/v1/pi/stream`; } function buildHeaders(model: Model, apiKey: string | undefined): Record { const headers: Record = { "Content-Type": "application/json", Accept: "text/event-stream", ...(model.headers ?? {}), }; if (apiKey && !headers.Authorization) { headers.Authorization = `Bearer ${apiKey}`; } return headers; } /** * Stream a turn through an `gjc auth-gateway` over the pi-native protocol. * * The returned {@link AssistantMessageEventStream} receives each parsed * `AssistantMessageEvent` verbatim from the gateway; the terminal `done` / * `error` event resolves `.result()` automatically via the base class's * completion check. Non-streaming consumers just call `.result()` and pay * for SSE framing they don't use — that overhead is dominated by provider * latency, so we always stream rather than maintaining a parallel * non-streaming path. */ export function streamPiNative( model: Model, context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStreamType { const stream = new AssistantMessageEventStream(); void (async () => { const signal = options?.signal; // Abort propagation: cancel the response body when the caller's signal // fires. Mirror `streamProxy`'s shape — explicit listener + finally // cleanup — so we don't leak listeners on the long-running case. let response: Response | null = null; const onAbort = (): void => { const body = response?.body; if (body) body.cancel("Request aborted by caller").catch(() => {}); }; if (signal) { if (signal.aborted) { stream.fail(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason ?? "aborted"))); return; } signal.addEventListener("abort", onAbort, { once: true }); } try { const url = resolveStreamUrl(model as Model); const fetchImpl = options?.fetch ?? globalThis.fetch; const headers = buildHeaders(model as Model, options?.apiKey); const body = JSON.stringify({ modelId: model.id, context, options: buildWireOptions(options), stream: true, }); response = await fetchImpl(url, { method: "POST", headers, body, signal }); if (!response.ok) { stream.fail(await decodeGatewayError(response)); return; } if (!response.body) { stream.fail(new Error("auth-gateway returned empty body")); return; } let sawTerminal = false; for await (const event of readSseJson( response.body as ReadableStream, signal, )) { if (event.type === "done" || event.type === "error") { sawTerminal = true; } stream.push(event); // `stream.push` resolves `.result()` on `done`/`error`; subsequent // pushes are silently dropped by the base class. We still iterate // to drain any trailing bytes from the wire so the underlying TCP // stream closes cleanly. } if (!sawTerminal) { const aborted = signal?.aborted === true; if (aborted) { const partial = makeSyntheticAssistant(model as Model); partial.stopReason = "aborted"; partial.errorMessage = "stream closed without terminal event"; stream.push({ type: "error", reason: "aborted", error: partial }); } else { const error = Object.assign(new Error("pi-native SSE stream closed without terminal event"), { status: 502, headers: response.headers, }); stream.fail(error); } } stream.end(); } catch (err) { stream.fail(err); } finally { if (signal) signal.removeEventListener("abort", onAbort); } })(); return stream; } function makeSyntheticAssistant(model: Model): AssistantMessage { return { role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", timestamp: Date.now(), }; }