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
text/typescript
/**
* # 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);
}