agentleFS
kodar-anderskodar-anders/combined-ai/CLAUDE.md

combined-ai

CLAUDE.md0 starsChanged 2 months agoMITView on GitHub

A plain TypeScript library that combines several AI providers into one package — pick one provider for a prompt, or combine many via a strategy. src/index.ts is the main public entry point; re-export the public API there (provider classes stay internal). A second combined-ai/test subpath entry (src/testing/index.ts) exports the network-free MockProvider (+ re-exports ProviderError so instanceof holds across the separately-bundled entry) — kept off the main entry so it never lands in a production bundle. Three providers: Anthropic, OpenAI, Google.…

  • Reads credentials
  • In this repositoryOne other file it gives its agents.

The file

We split this CLAUDE.md into the rules it gives an agent

6 rules. About 0 are common, 3 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Don't over-engineer: no abstractions for hypothetical needs, no unrelated refactors while fixing, prefer 2–3x inline duplication over a …

    line 5
  2. Very specific:

    Edit loop (run as you go, not at "done"): after editing a file, run yarn eslint <path> (not the full yarn lint), yarn typecheck, and …

    line 7
  3. Distinctive:

    Keep CLAUDE.md updated and compact — it's instructions, not docs.

    line 8
  4. Distinctive:

    Don't run integration tests unless asked — they make live, paid API calls.

    line 10
  5. Very specific:

    All talk to their HTTP API directly over fetch — no SDK dependency.

    line 49
  6. Very specific:

    noUncheckedIndexedAccess gotcha: don't zip parallel arrays by index — carry paired data in one structure (roster / running answer).

    line 84

≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
CLAUDE.md

CLAUDE.md

Agent instructions (read first)

  • Don't over-engineer: no abstractions for hypothetical needs, no unrelated refactors while fixing, prefer 2–3x inline duplication over a premature helper.
  • Never add Co-Authored-By: Claude or any AI-attribution trailer to commits.
  • Edit loop (run as you go, not at "done"): after editing a file, run yarn eslint <path> (not the full yarn lint), yarn typecheck, and prettier on it. If the file is covered by Jest tests, run them (yarn test path/to/file.test.ts) before declaring done.
  • Keep CLAUDE.md updated and compact — it's instructions, not docs. Record only what an agent can't infer from code (commands, conventions, gotchas, decisions); point to files rather than restate their types. When you add a line, delete or compress a stale one.
  • Keep README.md/CHANGELOG.md updated. Unshipped changes go under ## [Unreleased] (Keep a Changelog format). Add a dated version heading only at release time: rename ## [Unreleased] → ## [<version>] - <date> and put a fresh empty ## [Unreleased] above it. Categorize (### Added/### Changed/### Fixed) against the last published version. Keep the changelog short and clean.
  • Don't run integration tests unless asked — they make live, paid API calls.
  • Per-developer instructions live in a gitignored CLAUDE.local.md (loads after this file). Keep this file to project-wide facts.

What this is

A plain TypeScript library that combines several AI providers into one package — pick one provider for a prompt, or combine many via a strategy. src/index.ts is the main public entry point; re-export the public API there (provider classes stay internal). A second combined-ai/test subpath entry (src/testing/index.ts) exports the network-free MockProvider (+ re-exports ProviderError so instanceof holds across the separately-bundled entry) — kept off the main entry so it never lands in a production bundle. Three providers: Anthropic, OpenAI, Google. The "google" provider speaks the Gemini API — registry key/name is "google" (the company, like anthropic/openai); internal toGemini* helpers and the gemini-3.8-flash default keep the Gemini name (the API it talks to).

Core contracts — src/types.ts

The provider-agnostic contract: Message, CompletionRequest, CompletionResult, Provider (complete() → text, stream() → AsyncIterable<string> deltas, optional embed?() → EmbeddingResult). Points an agent can't infer:

  • Embeddings: embed?() is optional on Provider (Anthropic has no first-party endpoint → omits it; OpenAI + Google implement it). Reach it via ProviderRegistry.embed/embedMany (the #selectEmbedder helper throws if unsupported). EmbeddingResult.usage reuses Usage with outputTokens:0, totalTokens===inputTokens so embedding calls price/aggregate through the existing cost machinery; embedding models are in MODELS (input-only, outputPerMTok:0 — full exact keys, the digit-suffix resolver won't reach -small/-large from a base). Google's embed endpoint reports no usage → usage omitted. cosineSimilarity lives in src/embeddings.ts (pure; always cosine, never raw dot-product).
  • synthesizeText (src/synthesize.ts) — standalone single-provider merge of text candidates (e.g. ensemble's votes[key].candidates, each converted via String() since a field's value is unknown); not a combine strategy; returns a plain CompletionResult so costOf works unmodified. Shares its A, B, C…/#<n> candidate-lettering scheme (letterLabel, src/label.ts) with consensus's anonymized draft/critique rendering.
  • Message.content is string | ContentPart[] (string = one text part). ContentPart union: text/image/file/tool_use/tool_result. MediaSource = base64 (carries mediaType) | url (optional mediaType). Intended compile seam: adding a ContentPart member breaks every provider to*Part mapper and textOf in combine/shared.ts until handled. Provider support varies — mappers throw when unsupported. Optional cacheControl?: CacheControl on text/image/file parts (and system?: string | SystemPrompt for the system prompt) is the Anthropic prompt-cache marker — see Cost & pricing → request-side caching.
  • complete()-only result fields, all optional + set only when present (so toEqual({text,model}) tests still pass): finishReason (normalized stop|length|content_filter|tool_use|other) + rawFinishReason; refusal (forces content_filter); usage ({inputTokens,outputTokens,totalTokens}); parsed; toolCalls. New code building a CompletionResult should keep these.
  • temperature?: number — forwarded as-is (no range normalization; the provider validates), omitted entirely when unset so existing calls are byte-identical. Load-bearing gotcha: Anthropic removed the parameter on Opus 4.7+/Sonnet 5/Fable 5, including the claude-opus-5-5 default — setting it there is a 400. Documented in prose, deliberately not model-sniffed (that's roadmap #13). assertValidTemperature (transport.ts) rejects non-finite only (NaN would reach the wire as null); called per provider #buildBody and in the registry #prepare. In combine it's request-wide and per-participant (ParticipantSpec.temperature → ParticipantOverrides, wins via ?? so 0 survives); per-participant is the recommended form on a mixed roster, since a request-wide value hits models that reject it and under consensus those 400s count against minParticipants and can fail the run. Overrides replace, never clear (no way to exempt a participant from an inherited value) — leave the request-wide one unset instead. Applies to every call a participant makes (critique/synth/sanitize included): per participant, not per phase.
  • signal?: AbortSignal forwards to every provider fetch; combine threads one signal through all participant calls. retry?: RetryOptions + timeoutMs?: number (also on EmbeddingRequest) are per-request transport overrides (see Errors & transport). Semantic split: signal bounds the whole run (one signal cancels a combine / a fallback chain), timeoutMs bounds each individual provider call (in combine/fallback that's per participant/entry, so worst-case wall-clock is calls × timeoutMs, not a run-wide deadline).
  • Structured output: responseFormat?: {type:"json_schema", schema, name?} — raw JSON Schema (no Zod; locked choice). Affects complete() and stream(); complete() also returns parsed (= JSON.parse(text), undefined on invalid/truncated). Cross-provider schema rules (driven by OpenAI strict + Gemini's limited keywords): every object needs additionalProperties:false + all props required with a single non-null type; avoid optional/nullable, recursive, $ref, numeric/length constraints (toGeminiSchema doesn't translate null-unions).
  • Tool calling (single-provider only): tools? + toolChoice? (auto|any|none|{name}); replay results with tool_use/tool_result parts. combine rejects tools/toolChoice. Replay rules: OpenAI throws without tool_use.id/tool_result.toolUseId; Gemini throws without tool_result.name; Anthropic hoists tool_result blocks first.

Errors & transport

  • src/errors.ts — error vocabulary. ProviderError has toJSON() → {name, message, provider, kind, status?, code?, type?}, so plain JSON.stringify round-trips it. Two non-obvious bits: name/provider/kind/status/code/type/body were already own-enumerable (only message/cause weren't), so toJSON adds message and removes body — body is dropped as a duplicate (apiError embeds it verbatim in message), not for privacy. A non-ProviderError failure still stringifies to {}. ProviderError (exported), kind: "api" (non-2xx → status set, via apiError) | "transport" (fetch rejected / signal aborted → cause set, via transportError — the one place the transport-error shape lives; transport.ts, sse.ts (body-read aborts), readJsonBody, apiError (its error-body text() read), and the test MockProvider all throw through it). apiErrorFromBody handles a 2xx body carrying {error} (so it throws instead of returning text:""). Imports ProviderName from ./registry type-only (no runtime cycle — keep it that way). Mid-stream SSE error events stay plain Error (only the reader.read() abort is wrapped). Also aggregateError(message, causes) (→ AggregateError or plain Error); combine's noResultError adapts it. No fetch/transport code here.
  • src/transport.ts — requestWithRetry(provider, input, init, retry?): bounded exponential backoff on 429/503/529, honors Retry-After (capped 60s), aborts early on signal. Transport rejections aren't retried. Config via per-provider retry?: RetryOptions ({maxRetries?, baseDelayMs?}, defaults 2/500ms, 0 disables). Per-complete() call, so combine benefits transparently. Per-request overrides: each provider calls requestControls(request, this.#retry) once per complete()/stream()/embed() → {signal, retry}. Also exports assertValidTemperature (finite-only; a plain Error with an eslint-disable for unicorn/prefer-type-error — it's an invalid value, and it keeps the class consistent with assertValidTimeoutMs). mergeRetry field-merges request.retry over the construction-time retry (spread, so explicit maxRetries:0 wins); withTimeout folds request.timeoutMs into the signal via AbortSignal.any([signal, AbortSignal.timeout(ms)]) — a whole-call deadline (all retries + backoff + stream body read), aborting with a TimeoutError (distinct from a caller abort). assertValidTimeoutMs (exported; also called up-front in the registry #prepare) rejects non-positive / non-finite / > 2_147_483_647 (the setTimeout overflow footgun). RetryOptions is defined in types.ts (keeps that leaf a leaf) and re-exported from transport.ts for back-compat — don't move it back. readJsonBody(provider, response) wraps an abort/network failure during the .json() body read as a transport error (a SyntaxError from a malformed body stays raw); the 3 complete() sites use it since requestWithRetry only wraps up to the response headers.

Cost & pricing — src/models.ts + src/cost.ts

Data/math split (mirrors errors/transport). Both re-exported from index.ts.

  • src/models.ts — internal MODELS table (USD per MTok, kept small on purpose: the most common models per provider, not an exhaustive catalog — a wrong price is worse than undefined), findModel(model, options?), listModels(), PRICING_VERIFIED_ON. Resolution: exact → dated-snapshot prefix — a key k matches k-<DIGIT>… only (the char after k- must be 0-9), so date/version snapshots resolve (gpt-4.1-2025-…→gpt-4.1, longest key wins) but differently-priced siblings with a word suffix return undefined, never a wrong price (gpt-4.1-nano, gemini-2.5-flash-lite; the digit rule is load-bearing — don't loosen to bare startsWith(k+"-")). Namespaced gateway ids (openai/gpt-4.1) correctly miss. options.models merges over built-ins (override wins); no merge alloc when no overrides. ModelInfo.id is the resolved key; returned pricing is a copy (clonePricing, incl. nested highTier) so callers can't corrupt the singleton. Tiered pricing via optional ModelPricing.highTier ({aboveInputTokens, …}) — Gemini 2.5 Pro doubles above 200k prompt tokens, Claude Haiku 5.5 is 5× above 100k; recent OpenAI rows bill 2× input / 1.5× output above 272K (tier cached rate only where the page states it). Cache pricing via optional cachedInputPerMTok (read, tier-aware via highTier.cachedInputPerMTok) + cacheWriteInputPerMTok (write, base-only — so Haiku 5.5's >100k writes under-bill, accepted); Anthropic carried (read 0.1×, 0.05× on Opus/Sonnet 5.5, 0.025× on Fable 5.1; write 1.25× = 5-min TTL, so 1-hour writes under-bill), Gemini carried (read 0.1×, 2.5 Pro tiered) and OpenAI carried per-row as published (0.05–0.1× on 5.x/6.x, 0.25–0.5× on gpt-4.x/o-series; the -pro rows list none → fall back to full input rate). Prices are best-effort/hand-maintained — a wrong price is worse than undefined; keep the table small and update PRICING_VERIFIED_ON when touching prices. To refresh the table + per-provider defaults against the providers' live docs, run the /update-models skill (.claude/skills/update-models/).
  • src/cost.ts — costOf(result, options?) / costOfUsage(usage, model, options?) → CostBreakdown (USD) or undefined (never throws): unknown model, no usage, or inputTokens <= 0 (empty {}/malformed usage — a real completion always has prompt tokens; declining beats a confident $0 or input mis-billed at the output rate). Picks highTier rates when inputTokens > aboveInputTokens. Cache split: inputCost = uncached×inputRate + cachedInputTokens×(cachedRate ?? inputRate) + cacheCreationInputTokens×(writeRate ?? inputRate), where uncached = max(0, inputTokens − cached − write) (clamped so gateway over-reports can't go negative); folded into inputCost (no separate CostBreakdown field). cachedRate is read off the selected tier (so Gemini 2.5 Pro's cached rate doubles >200k); writeRate off base. Gemini thinking tokens: bill outputTokens + max(0, totalTokens−input−output) at the output rate (0 residual for Anthropic/OpenAI). Never price off totalTokens (cross-provider semantics differ). Float dollars — (tokens*perMTok)/1e6.
  • Prompt-cache accounting (the gotcha): Usage.inputTokens is the total billable prompt (a superset incl. cache reads/writes); cachedInputTokens/cacheCreationInputTokens are subsets of it (set only when reported). Providers differ — Anthropic reports cache tokens in buckets outside input_tokens (so extractUsage sums them into the superset; defensively returns inputTokens:0 if base input_tokens is missing, so cost declines rather than underbills), while OpenAI (prompt_tokens_details.cached_tokens) / Gemini (cachedContentTokenCount) report cached as a subset already inside their prompt count (clamped to inputTokens). Reporting is complete()-only — stream() reports no usage. OpenAI + Gemini 2.5 cache automatically; mark Anthropic breakpoints via CacheControl (below).
  • Request-side caching (Anthropic): CacheControl = {ttl?: "1h"} on a ContentPart (cacheControl on text/image/file) or the system prompt's object form system: {text, cacheControl} (SystemPrompt). anthropic.ts emits cache_control on those blocks; prepareCacheControl enforces the ≤4 breakpoint limit (throws a clear error, not a raw 400) and toggles the extended-cache-ttl-2025-04-11 beta header only when a ttl:"1h" breakpoint is present (computed once, passed to #headers — don't re-scan). OpenAI/Gemini ignore the marker (read system .text only). Under combine, placement decides: completionFor forwards system as-is (object form included), so ensemble/broadcast honor a system marker; the shaped strategies flatten via composeSystem (no block boundary survives) and the registry's #rejectSystemCacheControl throws for consensus/pipeline/panel. A content-part marker is honored only in phases that send request.messages unchanged (fan-out, consensus drafts, panel answers, pipeline stage 1) — later phases re-render via renderConversation/textOf, which drops non-text parts. The 4-breakpoint throw happens inside complete(), so under combine it surfaces as failed outcomes, not a thrown error.
  • combineCost/budget shipped in src/combine/cost.ts — cache savings flow through both automatically (per-call ledger priced via costOfUsage). See Combine section.
  • Deferred: capability flags. Streaming usage, Gemini explicit caching, and verified OpenAI cache-read rates still pending.

Providers — src/providers/

All talk to their HTTP API directly over fetch — no SDK dependency. Each maps Message.content per-wire, normalizes finishReason/usage, supports structured output + tools. Class stays internal; index.ts re-exports only the options type. Gotchas:

  • anthropic.ts — Messages API, default claude-opus-5-5 (rejects a forced toolChoice any/{name} with a 400). Body kept minimal — the Opus 4.x/5 line rejects top_p/top_k/thinking, and temperature too (which is why temperature is sent only when the caller sets it; see Core contracts). refusal collected from type:"refusal" blocks; hoists tool_result blocks first. Prompt caching: emits cache_control on marked text/image/document blocks + object-form system; prepareCacheControl caps breakpoints at 4 + gates the 1h beta header (see Cost & pricing).
  • openai.ts — Chat Completions, default gpt-6.1-sol (tiers are Astra/Sol/Terra/Luna, no mini/nano). No tool calling via this library on the default: gpt-6.1-sol/-terra need the Responses API; gpt-6-sol/-luna need reasoning_effort:"none", which we don't send — documented, not model-sniffed. system folded into a leading system message; cap sent as max_completion_tokens. OpenAIProviderOptions.headers? spread last (override defaults) — for gateways/OpenAI-compatible. Constructor's 2nd arg name (internal, registry-only) attributes errors to a custom alias. URL file source throws (Chat Completions has none); tool-call arguments is a JSON string (parsed on the way out).
  • google.ts — Gemini API (GoogleProviderOptions), default gemini-3.8-flash (GA 2026-09-02), embed default gemini-embedding-2 (the 2.5 generation has no announced shutdown date as of 2026-09-24, but access is limited to prior users since 2026-09-18). Model+action in the URL path; assistant→model; system→systemInstruction; cap→maxOutputTokens. Thinking-token gotcha: Gemini 2.5/3.x thinking tokens count against maxOutputTokens, so a tiny cap returns empty/truncated text — give it a generous cap. fileData.fileUri accepts only a Files API/gs:// URI, not a public web URL. toGeminiSchema UPPERCASEs type and drops additionalProperties (Gemini 400s on it though OpenAI strict requires it) for structured output + tool params; finishReason overridden to tool_use only on a clean stop; no refusal field.
  • Shared helpers: extract.ts (extractModel, exported isRecord), structured.ts (parseStructured), sse.ts (sseJson(body, provider) — shared SSE reader: yields parsed data: JSON objects, ends on [DONE], always releases the reader (guarded finally so a cancel rejection can't mask the outcome); wraps a reader.read() abort/network failure as a transport error via provider; each stream() for-awaits it and handles its own events/errors). toArray stays duplicated per-provider (trivial). errors.ts/transport.ts keep their own isRecord (lower layer).
  • Adding a provider: new src/providers/<name>.ts implementing Provider; add an optional field to ProviderRegistryConfig + a construction branch in the registry; re-export its options type from index.ts.

Registry — src/registry.ts

ProviderRegistry is the single point of access; provider classes aren't exported. Constructor takes ProviderRegistryConfig ({anthropic?, openai?, google?, custom?}) and builds only what's present. Never reads env — keys come from config.

  • select(name) → Provider or throws (lists configured names); has(name), names() (built-ins first in fixed order, then custom in definition order).
  • custom?: Record<string, CustomProviderConfig> — {kind:"openai-compatible", apiKey, baseUrl, model, headers?, retry?} (OpenRouter/Together/Groq/Ollama/local; baseUrl excludes the request path) | {kind:"provider", provider} (BYO). A custom name colliding with a built-in throws.
  • ProviderName = BuiltInProviderName | (string & Record<never,never>) (autocomplete trick; Record<never,never> satisfies no-empty-object-type) — custom names accepted everywhere, built-ins still autocomplete.
  • Combine API: five per-strategy methods (consensus/pipeline/ensemble/broadcast/panel, each returns its concrete …Result) + generic combine(request) dispatching on request.strategy ?? "consensus". combine<S extends StrategyName> infers S from the strategy?: S discriminant → concrete ResultFor<S> for a literal strategy, full union otherwise. (Don't type the param as StrategyRequest<S> — TS can't infer through indexed-access on object literals.)
  • Fallback (src/fallback.ts): fallback(specs, options?) → a composable Provider (not a wire provider — it wraps resolved providers, like combine wraps a roster; imports ProviderName type-only, registry imports the factory at runtime). Tries entries in order, catching ProviderError. FallbackSpec = ProviderName | {provider, model?, maxTokens?} (overrides beat the per-call request, entry→request→default — mirrors completionFor). Abort (request.signal.aborted, since abort & network are both kind:"transport") propagates without advancing; non-ProviderError propagates (a bug); all-fail throws aggregateError (≥1 cause even for 1 entry — push cause before the last-entry check). stream() falls back only before the first delta (commit after first next(); try/finally { it.return?.() } closes the inner stream on early break). Full request forwards unchanged (unlike combine — tools/responseFormat/cacheControl pass through). shouldFallback/onFallback both take one FallbackEvent {provider, index, error} (permissive default; onFallback fires N-1× on all-fail). No embed. No per-entry retry (that's #7's axis).
  • Validation: shared #prepare normalizes each ParticipantSpec (throws on a falsy model/maxTokens override), enforces ≥1 participant + unique ids + non-empty messages, rejects tools/toolChoice, resolves the roster. Per-strategy: consensus/pipeline/broadcast/panel reject responseFormat (ensemble-only); consensus validates minParticipants/synthesizer, panel validates synthesizer (shared #validateSynthesizer); ensemble requires responseFormat with an object root.

Combine — src/combine/

Make multiple providers cooperate on one prompt via a selectable strategy. One file per strategy. Entry is ProviderRegistry.combine; each orchestrator is internal (the registry imports it). No token streaming in combine yet. Settlement events (draft/critique/answer/review/stage/response) are {type} & ParticipantOutcome (so status:"ok" ⇒ result, "failed" ⇒ error); emit them by spreading the outcome first (emit({...outcome, type:"draft"})) so a later ParticipantOutcome field can't shadow the tag. phase/budget/embedding carry no outcome; synthesis/sanitize emit nothing, and embedding fires only on failure and carries no usage (events still aren't a cost ledger).

  • index.ts — public barrel: combine types + the one runtime value STRATEGY_NAMES (source of truth combine() validates). CombineResult discriminated on strategy (BroadcastResult has no text). ParticipantSpec = ProviderName | {provider, model?, maxTokens?, label?, instruction?} with a unique id (label, defaults to provider or <provider>-<model>); instruction = per-participant role/persona (panel-only, ignored elsewhere); synthesizer is an id. Results carry usage?: CombineUsage (total/byParticipant keyed by id + a per-call calls: CallUsage[] ledger, each tagged with model for individual pricing). Imports ProviderName type-only (keep the combine↔registry cycle erasable).
  • cost.ts — combineCost(result, options?) → CombineCost {totalCost, byParticipant, unpriced} (USD), pricing each usage.calls entry individually (never the summed byParticipant — summing breaks tiered rates / thinking residual); skips unpriceable calls, undefined if none priceable. unpriced counts the skipped ledger entries (required field — always computable) so a cheap run is distinguishable from a half-priced one; counts an unknown model or usage costOfUsage declines (inputTokens <= 0), and can never equal calls.length (all-unpriceable → undefined). unpriced: 0 is not an all-clear — callUsages drops usage-less calls before the ledger, so combineCost can't see or count them (Gemini embed reports no usage; gateways often omit it). Accumulate byParticipant in a Map + Object.fromEntries, never record[id] = …: a __proto__ participant label otherwise makes a fully-priced run return undefined. Imports costOfUsage from ../cost (one-way combine→cost; cost.ts stays single-result). combineCost/CombineCost/CallUsage re-exported from root index.ts.
  • Budget (CombineOptions.budget?: {usd} & CostOptions) — soft floor on optional work, not a hard cap. makeBudget→BudgetTracker (shared) tracks running cost via costOfUsage; strategies call budget.gate(skipped, extra?) (emits the skip event + returns whether to skip — keeps the event shape in one place, no-op tracker returns false so no options?.budget re-check needed). consensus skips critiques/sanitize, pipeline stops refining/skips sanitize. exceeded() is spent > 0 && spent >= usd (the spent > 0 makes an unpriceable roster — or a usd:0 budget — inert, not falsely tripped). Required phases (consensus drafts+synth, pipeline first stage) always run so a run never ends empty. Unpriceable call → contributes 0 + one-shot {type:"budget", underEnforced:true} event. ensemble/broadcast accept budget but it's inert (single parallel burst can't be gated → no budget.add, no event; price with combineCost). Gating is coarse — checked only between Promise.all phases. budget is a new CombineEvent variant (skip events carry skipped, pipeline-refine skip also id/index).
  • shared.ts — helpers reused by ≥2 strategies (completionFor, composeSystem, runOutcome, respondAll, noResultError, sanitizeAnswer (returns model too), makeEmitter, aggregateUsage, callUsages, makeBudget/BudgetTracker, runEmbedding, RosterEntry, …). completionFor/sanitizeAnswer take per-participant overrides that beat the request-wide model/maxTokens; strategies pass the entry itself. completionFor also forwards retry/timeoutMs (per participant call, unlike the run-wide signal). A helper lands here only once a 2nd strategy reuses it (2–3x rule).
  • consensus — draft (parallel) → critique (each survivor critiques all drafts, anonymized unless attribution:"attributed") → synthesize (by synthesizer id) + a sanitizing pass. Framing invariants (preserve — rationale in doc-comments): SYNTH_FRAMING adjudicates on correctness over popularity and forbids alluding to drafts/critiques/labels; sanitizeAnswer strips leftover process narration (one extra complete() call, multi-provider only); CONCISE_DIRECTIVE strips ceremony but keeps reasoning/caveats. Survivor policy: empty drafts dropped; 0 → throw; single-provider → plain completion; < minParticipants (default 2) → throw; failed critiques non-fatal; failed synthesizer falls back to next survivor.
  • pipeline — sequential refinement; participant order = conveyor order. First stage drafts, later stages refine the running answer; a stage advances only if ok + non-empty; final = last advancing stage. Throws only if none advanced. Sanitize runs only when a refiner actually changed the text. No minParticipants/synthesizer/attribution.
  • ensemble — multi-model vote on structured output (the differentiator). Every participant answers under the same responseFormat in parallel; only ok responses with a plain-object parsed count. Mechanical merge (no LLM): field-wise majority vote over the first-seen key union (values grouped by deep-equality stableKey, largest group wins, strict > so a tie goes to the first-seen group — not the first to reach the count, which is what the pre-0.7 loop did) so merged values are real. agreement denominator = all valid responses (omitted fields score low), exposed as validResponseCount. votes is the home for per-field detail (candidates with voter ids + winner, plus absent) — extend EnsembleFieldVote rather than adding another parallel Record<string,…> to the result; absent comes from a set difference against the voters, never a second Object.hasOwn scan (which can disagree with the vote on a hand-built BYO object). excluded: EnsembleExclusion[] (reason: "failed" | "unparsed", the latter covering no-JSON/truncated and array/scalar roots) is built in the same loop as valid — not a second scan — so responses.length - excluded.length === validResponseCount holds by construction. The all-excluded throw has no result to carry it, so describeExclusions puts the id: reason pairs in the error message — that path also has no causes for noResultError when every response was ok-but-unparseable, so the message is the only diagnostic. collectStringFields filters blank strings (.trim() === ""): the whole batch is one embed() call and OpenAI 400s on "", so one blank used to kill semanticAgreement for every field. Object-root only; nested merge is shallow. Build the per-field records with Object.fromEntries, never record[key] = … — JSON.parse output can carry an own __proto__ key, and index assignment invokes the prototype setter (drops the field, pollutes the record). (stableKey uses .sort() not toSorted — ES2022 lib lacks it.)
  • broadcast — fan-out, no combine: every participant answers the verbatim prompt in parallel (no framing/sanitize), returns all responses, no text. Throws only if zero succeed (empty ok still counts). Rejects responseFormat.
  • panel — role-based experts → integrate (mixture-of-agents). Each participant answers in character via its instruction (composed [system]+[instruction]+[framing] by a panel-local roleSystem), then synthesizer (default first) integrates → answers+reviews+text. Framing invariant (preserve): PANEL_SYNTH_FRAMING integrates complementary perspectives (vs consensus adjudicating); the synth/sanitize passes get no role instruction (neutral integrator). Optional crossExamine review round (each survivor reviews all answers, non-fatal, budget-gated "reviews"). Always attributed; no minParticipants/attribution. Survivors: empty dropped; 0 → throw; 1 → return that answer sanitized (the only degrade guard — no minParticipants backstop); failed/empty synth falls back to next survivor. synthesizerOrder copied from consensus (not shared until a 3rd user).
  • Embedding signals (CombineOptions.embedding = {provider, model?}, resolved in the registry #resolveEmbedder→#selectEmbedder, passed to the orchestrator as a trailing embedder? arg). All informational (never change a returned/merged value), usage folds into the ledger under a distinct embedding:<provider> id (so it can't collide with a participant id). A failure is reported, not swallowed: all four strategies go through runEmbedding (shared.ts), which returns {value?, embeddingError?} and emits a {type:"embedding", error} event — keeping declined (fewer than 2 answers, or a mismatched vector count → {}, reported as nothing) distinct from failed. embeddingError is spelled identically on all four results on purpose, even though the signals themselves are named per strategy. Gotcha: consensus/panel start the call before critique/review and await it at result-build time, so the event's position is nondeterministic (assert membership) and on their all-synthesizers-failed throw the event is the only signal: broadcast result.semantic (agreement/outlier/clusters); consensus result.draftAgreement (same SemanticComparison over surviving drafts, computed concurrently with critique/synth); panel result.perspectiveAgreement (same, over surviving answers — but low agreement is expected/healthy for a panel); ensemble result.semanticAgreement (per-field mean-pairwise-cosine over string fields only, one batched embed call — exact-match vote still picks merged). pipeline unaffected (no parallel answers).
  • embedding.ts — combine's embedding module (like cost.ts), used by broadcast/consensus/panel/ensemble: compareAnswers (whole-answer → SemanticComparison), fieldSemanticAgreement (per-field string scores), ResolvedEmbedder. agreement=mean pairwise cosine, outlier=centroid distance (≥3), clusters=seed-based (threshold const, deterministic). Principle: embeddings in combine rank/inform, never decide a value.
  • Test coupling (gotcha): consensus/pipeline/panel tests classify the phase by literal markers in the framing constants — "Rewrite the following" (sanitize), "lead assistant" (consensus synth), "produce only a critique" (critique), "revise the current answer" (pipeline refine), "member of an expert panel" (panel answer), "produce only a review" (panel review), "integrate the complementary perspectives" (panel synth). Keep each constant free of the others' markers.
  • noUncheckedIndexedAccess gotcha: don't zip parallel arrays by index — carry paired data in one structure (roster / running answer).
  • Adding a strategy: new src/combine/<strategy>.ts exporting its orchestrator; add to STRATEGY_NAMES/StrategyName, a <Strategy>Request, a …Result member, and a StrategyRequest entry in index.ts; add a public method + a combine() case in the registry. Promote a helper to shared.ts only on actual reuse.

Tests

  • Colocated __tests__/ per source folder: src/providers/__tests__/, src/combine/__tests__/, src/testing/__tests__/ (MockProvider), src/__tests__/ (registry, transport). Import via ../<file> / ../../<file>.
  • Each provider has <provider>.test.ts (mocked) + <provider>.integration.test.ts (live, opt-in — keep the suffix so the test:integration filter matches).
  • Combine unit tests use network-free fake Providers (no fetch mock); registry-level combine() validation lives in src/__tests__/registry.test.ts.

Commands

  • yarn build — tsup bundles both entries (src/index.ts + src/testing/index.ts, common base src/ → dist/testing/*) to dual ESM+CJS with declarations + sourcemaps. CLI multi-entry on purpose — no tsup.config.ts (a root config .ts is outside tsconfig include and trips typed-lint's projectService, failing yarn lint/CI publish).
  • yarn typecheck — tsc --noEmit (type-check only; tsup owns the build).
  • yarn test — Jest. Single: yarn test <pattern> or -t "<name>". yarn test:watch.
  • yarn test:integration — live, paid tests. Double-gated per provider: needs RUN_LIVE_TESTS=1 (the script sets it) + that provider's key (ANTHROPIC_API_KEY/OPENAI_API_KEY/GEMINI_API_KEY), else describe.skip. Cheap models (claude-haiku-4-5/gpt-4.1-mini/gemini-2.5-flash), small maxTokens (Gemini bigger for thinking tokens). Append a filename pattern to narrow (replaces the default). Env loads from a gitignored .env via jest.setup.cjs/dotenv (copy .env.example). Only tests read env — the library never does.
  • yarn lint / yarn lint:fix, yarn format / yarn format:check.

Toolchain & conventions

  • Build = tsup, type-check = tsc (noEmit: true). Tests run through @swc/jest (no type-checking — that's yarn typecheck); test files import globals from @jest/globals. Dual-format packaging via package.json exports; prepack builds. Node globals typed via @types/node + "types":["node"] (makes fetch/Response/ReadableStream resolve under the ES2022 lib).
  • Yarn 4 (Berry) PnP — no node_modules; always yarn, never npm. PnP gotcha: can only import deps declared directly in package.json (why @jest/globals is an explicit devDep). @jest/globals version lags jest — match what Jest pulls.
  • Lint (eslint.config.mjs, flat, type-aware + unicorn + promise; Jest rules in tests only): type over interface; inline import type + consistent-type-exports; exported API needs explicit types/return types; no-console: error (off in tests); _-prefix unused args; single-line case omits braces; Prettier owns formatting.
  • Publishing: push a v* tag → .github/workflows/publish.yml verifies tag==version, runs checks, publishes to npm via OIDC trusted publishing (no token), and creates a GitHub release from the matching CHANGELOG.md section.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.