UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

155 lines (148 loc) 9.53 kB
/** * # session/harness/harness-client — the ONE {@link LlmClient} factory (routes a {@link HarnessProvider} to its lane) * * The harness has two KINDS of serving lane and they cannot share a constructor: * * - **SDK lanes** (`bedrock` / `anthropic` / `google` / `gateway` / `local`) — HTTP, built by * `aiSdkLlmClient` in `./ai-sdk-client.ts` (the ONE module that imports the provider packages). * - **The SUBPROCESS lane** (`codex-cli`) — the ZERO-CASH ChatGPT subscription, built by `codexCliClient` * in `./codex-cli-client.ts`. It shells out to `codex exec` because no OpenAI-compatible HTTP endpoint * accepts a ChatGPT-subscription OAuth token. * * Callers should not care. This module is the single seam they call — give it a provider + model id, get an * `LlmClient`. It exists so that adding the subscription lane did not require every runner * (`run-grid`, `eval-watcher`, the fan fleets) to learn a second construction path and risk silently falling * back to a CASH provider when the FREE one was available. * * ## The `subagent` lane is NOT here — and that is not an omission * A Claude Code subagent is ONE-SHOT: it is spawned, it does work, it returns text. It cannot be driven * turn-by-turn inside the harness's multi-turn loop, so there is no `subagent` {@link LlmClient} to build and * no `subagent` member of {@link HarnessProvider}. Claude-via-subagent is an **AUTHORING** lane — it produces * a plan DOC which is frozen into a fixture (`scripts/bench/author-cells.ts`). MULTI-TURN WATCHING still * requires a real API endpoint (bedrock / gateway / local). That is the shape of the thing, not a gap. */ import { aiSdkLlmClient, type AiSdkClientOptions, type HarnessProvider } from "./ai-sdk-client.ts"; import { codexCliClient, type CodexCliClientOptions } from "./codex-cli-client.ts"; import type { LlmClient } from "../agent.ts"; /** Options for {@link harnessLlmClient} — the SDK options, plus the subprocess lane's extras (ignored off * the `codex-cli` lane, so every existing call site can pass exactly what it passes today). */ export interface HarnessClientOptions extends AiSdkClientOptions { /** `codex-cli` ONLY: reasoning effort for the turn (default `low` — see {@link codexCliClient}). */ readonly reasoningEffort?: CodexCliClientOptions["reasoningEffort"]; /** `codex-cli` ONLY: per-call wall-clock ceiling (a subprocess turn is slow; default 180s). */ readonly codexTimeoutMs?: number; /** `codex-cli` ONLY test seam: a stand-in for the real `codex exec` subprocess. */ readonly codexExec?: CodexCliClientOptions["exec"]; } /** Does this lane serve at ZERO MARGINAL CASH? `local` is free hardware; `codex-cli` is a subscription * already paid for. Mirrors `scripts/bench/pricing.ts`'s `FREE_CASH_LANES` at the provider level, so a runner * can log the cost posture of the lane it just built without importing the bench pricing module. */ export function isFreeProvider(provider: HarnessProvider): boolean { return provider === "local" || provider === "codex-cli"; } /** Whether a provider's OWN credential is available, and — when absent — the human name of what is missing. * Every lane authenticates from a DIFFERENT source, so "is the Bedrock key present?" is the wrong question * for any lane but Bedrock: a codex-cli entrant (ChatGPT OAuth in `$CODEX_HOME/auth.json`) or a local * entrant (no credential) must run whether or not an AWS key exists. A runner asks THIS, per model, so it * skips a lane only when ITS OWN credential is missing — and can say which one. */ export interface CredentialStatus { readonly ready: boolean; /** The env var / file a present credential would come from — for logging either way. */ readonly credential: string; /** Set only when `ready === false`: the human-facing reason the lane can't serve. */ readonly missing?: string; } /** * Resolve whether `provider`'s own credential is present in the environment. * * - `bedrock` → `AWS_BEARER_TOKEN_BEDROCK` * - `gateway` → `AI_GATEWAY_API_KEY` * - `google` → `GOOGLE_API_KEY` / `GEMINI_API_KEY` / `GOOGLE_GENERATIVE_AI_API_KEY` (first present) * - `anthropic` → `ANTHROPIC_API_KEY` * - `codex-cli` → the ChatGPT-subscription OAuth in `$CODEX_HOME/auth.json` (NOT an env key) * - `local` → no credential (a local server authenticates itself) */ export function providerCredentialStatus(provider: HarnessProvider, env: NodeJS.ProcessEnv = process.env): CredentialStatus { const present = (name: string): boolean => typeof env[name] === "string" && env[name]!.trim().length > 0; switch (provider) { case "bedrock": return present("AWS_BEARER_TOKEN_BEDROCK") ? { ready: true, credential: "AWS_BEARER_TOKEN_BEDROCK" } : { ready: false, credential: "AWS_BEARER_TOKEN_BEDROCK", missing: "AWS_BEARER_TOKEN_BEDROCK absent (Bedrock credits lane)" }; case "gateway": return present("AI_GATEWAY_API_KEY") ? { ready: true, credential: "AI_GATEWAY_API_KEY" } : { ready: false, credential: "AI_GATEWAY_API_KEY", missing: "AI_GATEWAY_API_KEY absent (Vercel AI Gateway CASH lane)" }; case "google": { const names = ["GOOGLE_API_KEY", "GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"]; return names.some(present) ? { ready: true, credential: names.find(present)! } : { ready: false, credential: names.join(" / "), missing: `${names.join(" / ")} all absent (Vertex/Gemini lane)` }; } case "anthropic": return present("ANTHROPIC_API_KEY") ? { ready: true, credential: "ANTHROPIC_API_KEY" } : { ready: false, credential: "ANTHROPIC_API_KEY", missing: "ANTHROPIC_API_KEY absent (direct Anthropic CASH lane)" }; case "codex-cli": { // The subscription auth lives in a FILE, not an env var. Presence is checked by the client at // construction (it resolves the binary and the CLI reads $CODEX_HOME/auth.json); here we only report // that this lane needs no ENV credential, so a runner never skips it for a missing AWS/gateway key. const home = env.CODEX_HOME ?? `${env.HOME ?? "~"}/.codex`; return { ready: true, credential: `${home}/auth.json (ChatGPT OAuth)` }; } case "local": return { ready: true, credential: env.LOCAL_LLM_BASE_URL ?? "LOCAL_LLM_BASE_URL (default localhost)" }; case "openai": return present("OPENAI_API_KEY") ? { ready: true, credential: "OPENAI_API_KEY" } : { ready: false, credential: "OPENAI_API_KEY", missing: "OPENAI_API_KEY absent (direct OpenAI CASH lane)" }; case "azure": // Azure needs BOTH the endpoint and the key; report the first missing one so a runner can act on it. return present("AZURE_OPENAI_ENDPOINT") && present("AZURE_OPENAI_API_KEY") ? { ready: true, credential: "AZURE_OPENAI_API_KEY" } : { ready: false, credential: "AZURE_OPENAI_ENDPOINT + AZURE_OPENAI_API_KEY", missing: `${present("AZURE_OPENAI_ENDPOINT") ? "" : "AZURE_OPENAI_ENDPOINT "}${present("AZURE_OPENAI_API_KEY") ? "" : "AZURE_OPENAI_API_KEY "}absent (Azure OpenAI CREDITS lane)`.trim(), }; case "fireworks": return present("FIREWORKS_API_KEY") ? { ready: true, credential: "FIREWORKS_API_KEY" } : { ready: false, credential: "FIREWORKS_API_KEY", missing: "FIREWORKS_API_KEY absent (Fireworks hosted-open CASH lane)" }; case "vertex-maas": { // Auth is an ADC BEARER (gcloud application-default), not an env key — like codex-cli, the // credential lives outside the env, and a dead ADC fails loudly at call time (AdcAuthError names // the reauth command). What IS env-gated is the GCP PROJECT id: the endpoint URL embeds it, so // without one the lane cannot even construct. Report that as the missing piece. const projectVars = ["GOOGLE_VERTEX_PROJECT", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT"]; const projectVar = projectVars.find(present); return projectVar !== undefined ? { ready: true, credential: `gcloud ADC bearer + ${projectVar} (Vertex MaaS CREDITS lane)` } : { ready: false, credential: `gcloud ADC bearer + ${projectVars.join(" / ")}`, missing: `${projectVars.join(" / ")} all absent (Vertex Model Garden MaaS CREDITS lane — the endpoint URL embeds the project id)`, }; } } } /** * Build the {@link LlmClient} for `provider` — the SINGLE construction seam every runner should use. * * Routes `codex-cli` to the subscription subprocess lane and everything else to the AI SDK. Never falls back * between lanes: a `codex-cli` request that cannot be served FAILS (the CLI is missing / the model id is * empty) rather than quietly becoming a CASH gateway call — the cost regression this lane exists to prevent. */ export function harnessLlmClient(opts: HarnessClientOptions): LlmClient { if (opts.provider === "codex-cli") { return codexCliClient({ modelId: opts.modelId, ...(opts.reasoningEffort !== undefined ? { reasoningEffort: opts.reasoningEffort } : {}), ...(opts.codexTimeoutMs !== undefined ? { timeoutMs: opts.codexTimeoutMs } : {}), ...(opts.secrets !== undefined ? { secrets: opts.secrets } : {}), ...(opts.captureWire !== undefined ? { captureWire: opts.captureWire } : {}), ...(opts.codexExec !== undefined ? { exec: opts.codexExec } : {}), }); } return aiSdkLlmClient(opts); }