/** * OpenRouter provider wiring for the agent-render session (Phase 1, step 1). * * The Reactor render atom (architecture.md §1) is, at run time, one bounded * `@openai/agents` session. This module is the *provider* half of that adapter: * it points the SDK at OpenRouter's OpenAI-compatible Chat Completions surface so * `google/gemini-3.5-flash` can be driven cheaply and reproducibly, and exposes a * single flag-gated `smokeRun()` that proves the wiring end to end. * * Discipline (standing constraints + research/agents-sdk/05 §2.4): * - SCOPED `OpenAIProvider` — no `setDefaultOpenAIClient` / `setOpenAIAPI` * global mutation. `useResponses: false` forces the Chat Completions model * per-provider (the Responses API 404s on OpenRouter). * - `@openai/agents` + `zod` stay dev/optional. NOTHING here runs at import * time except cheap pure helpers; the SDK is only touched inside the * factory/smoke functions, and any live call is gated behind the presence of * `OPENROUTER_API_KEY` (see `hasOpenRouterKey`). * - Tracing is disabled before any live run (the exporter would otherwise POST * to api.openai.com — an out-of-band network side-effect). */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { Agent, OpenAIProvider, Runner, setTracingDisabled, type ModelProvider, } from "@openai/agents"; import { redactSecrets, redactError } from "../../redact"; import { unquote } from "../string-util"; // --------------------------------------------------------------------------- // Constants — the decided OpenRouter / gemini wiring (research §0, §2.4, §4.1) // --------------------------------------------------------------------------- /** OpenRouter's OpenAI-compatible base. Chat Completions only; no `/responses`. */ export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; /** The render model. A bare string resolved by the scoped provider's getModel. */ export const DEFAULT_RENDER_MODEL = "google/gemini-3.5-flash"; /** * Greedy decoding — determinism knob #1 (research §4.1). No longer applied * implicitly: an unset temperature is OMITTED from requests (OpenAI reasoning * models reject explicit values), so pass this explicitly where greedy decoding * is wanted. Kept exported for consumers that reference the canonical value. */ export const DEFAULT_TEMPERATURE = 0; /** The provider label that rides into the receipt `Cost` (research §3, §4.2). */ export const OPENROUTER_PROVIDER_LABEL = "openrouter"; /** * Default location of the `.env` fallback for `OPENROUTER_API_KEY`. The primary * source is always `process.env`; this is only a convenience fallback, resolved * PORTABLY — the project's `.env` (`/.env`), overridable via * `REACTOR_ENV_PATH`. Never a baked-in author path. */ export const DEFAULT_ENV_PATH = process.env["REACTOR_ENV_PATH"] ?? join(process.cwd(), ".env"); const OPENROUTER_API_KEY = "OPENROUTER_API_KEY"; /** Env var that forces the live gate closed (hermetic offline). */ const REACTOR_OFFLINE = "REACTOR_OFFLINE"; // --------------------------------------------------------------------------- // Secret redaction — never let key material reach a log / thrown error / CI // --------------------------------------------------------------------------- // The pure scrubbers live in the KEYLESS top-level module (src/redact.ts) so the // reconciler core can reach them without crossing into this model-bearing // adapter. Re-exported here for the adapter's existing call sites. export { redactSecrets, redactError }; // --------------------------------------------------------------------------- // Env: read OPENROUTER_API_KEY without a dotenv dependency // --------------------------------------------------------------------------- /** * True when `REACTOR_OFFLINE` is set to a truthy value. Because * {@link readOpenRouterKey} falls back to reading the repo `.env` file, plain * `env -u OPENROUTER_API_KEY` does NOT disable the key-gated live tests — the * file fallback still resolves the key. Setting `REACTOR_OFFLINE=1` short-circuits * BOTH the process env and the `.env` fallback so the offline gate is hermetic * (`pnpm test:offline`), and a runaway live test can never hang it. */ export function isOfflineForced(): boolean { const v = process.env[REACTOR_OFFLINE]; return ( typeof v === "string" && v.length > 0 && v !== "0" && v.toLowerCase() !== "false" ); } /** * Resolve the OpenRouter API key. Returns `undefined` (never throws) when absent * or when `REACTOR_OFFLINE` is set, so callers can gate live behaviour on its * presence. Prefers `process.env`, then falls back to a minimal parse of the * `.env` file at `envPath`. */ export function readOpenRouterKey( envPath: string = DEFAULT_ENV_PATH, ): string | undefined { if (isOfflineForced()) { return undefined; } const fromProcess = process.env[OPENROUTER_API_KEY]; if (typeof fromProcess === "string" && fromProcess.length > 0) { return fromProcess; } const fromFile = readEnvValue(envPath, OPENROUTER_API_KEY); if (typeof fromFile === "string" && fromFile.length > 0) { return fromFile; } return undefined; } /** True when an OpenRouter key is available — the live-test / smoke gate. */ export function hasOpenRouterKey(envPath: string = DEFAULT_ENV_PATH): boolean { return readOpenRouterKey(envPath) !== undefined; } function readEnvValue(envPath: string, key: string): string | undefined { let contents: string; try { contents = readFileSync(envPath, "utf8"); } catch { return undefined; } for (const rawLine of contents.split(/\r?\n/)) { const line = rawLine.trim(); if (line.length === 0 || line.startsWith("#")) { continue; } const eq = line.indexOf("="); if (eq <= 0) { continue; } const name = line.slice(0, eq).trim(); if (name !== key) { continue; } return unquote(line.slice(eq + 1).trim()); } return undefined; } // --------------------------------------------------------------------------- // Scoped provider — no global mutation // --------------------------------------------------------------------------- export interface OpenRouterProviderConfig { /** OpenRouter API key. Defaults to {@link readOpenRouterKey}. */ readonly apiKey?: string; /** Base URL override. Defaults to {@link OPENROUTER_BASE_URL}. */ readonly baseURL?: string; /** Path to the `.env` fallback. Defaults to {@link DEFAULT_ENV_PATH}. */ readonly envPath?: string; } /** * Build a SCOPED `OpenAIProvider` pointed at OpenRouter on the Chat Completions * path. The provider lazily constructs its own OpenAI client from * `apiKey`/`baseURL` (openaiProvider.ts `#getClient`), so we never import the * `openai` package directly and never mutate the SDK's process-global default * client. `useResponses: false` selects `OpenAIChatCompletionsModel`. * * Throws only if no key can be resolved — callers that want the soft gate should * check {@link hasOpenRouterKey} first. */ export function createOpenRouterProvider( config: OpenRouterProviderConfig = {}, ): ModelProvider { const apiKey = config.apiKey ?? readOpenRouterKey(config.envPath); if (apiKey === undefined) { throw new Error( `OpenRouter provider requires ${OPENROUTER_API_KEY}; none found in process.env or ${config.envPath ?? DEFAULT_ENV_PATH}`, ); } return new OpenAIProvider({ apiKey, baseURL: config.baseURL ?? OPENROUTER_BASE_URL, // Chat Completions, not Responses — per-provider, no global setOpenAIAPI. useResponses: false, }); } // --------------------------------------------------------------------------- // Flag-gated smoke run — proves the wiring against live gemini // --------------------------------------------------------------------------- export interface SmokeRunConfig extends OpenRouterProviderConfig { /** Model id. Defaults to {@link DEFAULT_RENDER_MODEL}. */ readonly model?: string; /** Decoding temperature. Unset → omitted from the request (provider default). */ readonly temperature?: number; /** Best-effort reproducibility seed, passed through `providerData.seed`. */ readonly seed?: number; /** Prompt to send. Defaults to a trivial ping. */ readonly input?: string; /** Pre-built provider (skips {@link createOpenRouterProvider}). */ readonly provider?: ModelProvider; } export interface SmokeRunResult { readonly text: string; readonly inputTokens: number; readonly outputTokens: number; readonly totalTokens: number; readonly model: string; } /** * Run one bounded session against live OpenRouter gemini and return the text + * usage. Disables tracing first (otherwise the exporter POSTs to api.openai.com). * Uses a scoped `Runner({ modelProvider })` so nothing process-global is touched. * * GATING: callers MUST check {@link hasOpenRouterKey} (or pass an `apiKey`); this * function performs a real network call. It is invoked only from the live, * env-gated smoke test — never at import or in the offline build/test path. */ export async function smokeRun( config: SmokeRunConfig = {}, ): Promise { setTracingDisabled(true); const model = config.model ?? DEFAULT_RENDER_MODEL; // Unset temperature is omitted, not defaulted: the probe must work against // reasoning models (which reject explicit values), e.g. via `doctor --live` // with a configured reasoning render model. const temperature = config.temperature; const provider = config.provider ?? createOpenRouterProvider(config); const agent = new Agent({ name: "reactor-render-smoke", instructions: "You are a connectivity probe for the Reactor render gateway. " + "Answer in the fewest words possible.", model, modelSettings: { ...(temperature !== undefined ? { temperature } : {}), ...(config.seed !== undefined ? { providerData: { seed: config.seed } } : {}), }, }); const runner = new Runner({ modelProvider: provider }); const input = config.input ?? "Reply with the single word: ok"; let result; try { result = await runner.run(agent, input); } catch (error) { // A provider 403 ("key limit exceeded") echoes a key fingerprint; never let // it propagate into a test failure / CI log unredacted. throw redactError(error); } const usage = result.state.usage; return { text: typeof result.finalOutput === "string" ? result.finalOutput : String(result.finalOutput ?? ""), inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, totalTokens: usage.totalTokens, model, }; }