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.
- update-modelsSkill
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
- Distinctive: line 5
Don't over-engineer: no abstractions for hypothetical needs, no unrelated refactors while fixing, prefer 2–3x inline duplication over a …
- Very specific: line 7
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 …
- Distinctive: line 8
Keep CLAUDE.md updated and compact — it's instructions, not docs.
- Distinctive: line 10
Don't run integration tests unless asked — they make live, paid API calls.
- Very specific: line 49
All talk to their HTTP API directly over fetch — no SDK dependency.
- Very specific: line 84
noUncheckedIndexedAccessgotcha: don't zip parallel arrays by index — carry paired data in one structure (roster / running answer).
≈ “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.
Files
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: Claudeor 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 fullyarn 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.mdupdated 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.mdupdated. 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 onProvider(Anthropic has no first-party endpoint → omits it; OpenAI + Google implement it). Reach it viaProviderRegistry.embed/embedMany(the#selectEmbedderhelper throws if unsupported).EmbeddingResult.usagereusesUsagewithoutputTokens:0,totalTokens===inputTokensso embedding calls price/aggregate through the existing cost machinery; embedding models are inMODELS(input-only,outputPerMTok:0— full exact keys, the digit-suffix resolver won't reach-small/-largefrom a base). Google's embed endpoint reports no usage →usageomitted.cosineSimilaritylives insrc/embeddings.ts(pure; always cosine, never raw dot-product). synthesizeText(src/synthesize.ts) — standalone single-provider merge of text candidates (e.g. ensemble'svotes[key].candidates, each converted viaString()since a field'svalueisunknown); not a combine strategy; returns a plainCompletionResultsocostOfworks unmodified. Shares itsA, B, C…/#<n>candidate-lettering scheme (letterLabel,src/label.ts) with consensus's anonymized draft/critique rendering.Message.contentisstring | ContentPart[](string = one text part).ContentPartunion: text/image/file/tool_use/tool_result.MediaSource= base64 (carries mediaType) | url (optional mediaType). Intended compile seam: adding aContentPartmember breaks every providerto*Partmapper andtextOfincombine/shared.tsuntil handled. Provider support varies — mappers throw when unsupported. OptionalcacheControl?: CacheControlon text/image/file parts (andsystem?: string | SystemPromptfor 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 (sotoEqual({text,model})tests still pass):finishReason(normalizedstop|length|content_filter|tool_use|other) +rawFinishReason;refusal(forcescontent_filter);usage({inputTokens,outputTokens,totalTokens});parsed;toolCalls. New code building aCompletionResultshould 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 theclaude-opus-5-5default — setting it there is a 400. Documented in prose, deliberately not model-sniffed (that's roadmap #13).assertValidTemperature(transport.ts) rejects non-finite only (NaNwould reach the wire asnull); called per provider#buildBodyand in the registry#prepare. In combine it's request-wide and per-participant (ParticipantSpec.temperature→ParticipantOverrides, wins via??so0survives); 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 againstminParticipantsand 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?: AbortSignalforwards to every providerfetch; combine threads one signal through all participant calls.retry?: RetryOptions+timeoutMs?: number(also onEmbeddingRequest) are per-request transport overrides (see Errors & transport). Semantic split:signalbounds the whole run (one signal cancels a combine / a fallback chain),timeoutMsbounds each individual provider call (in combine/fallback that's per participant/entry, so worst-case wall-clock iscalls × timeoutMs, not a run-wide deadline).- Structured output:
responseFormat?: {type:"json_schema", schema, name?}— raw JSON Schema (no Zod; locked choice). Affectscomplete()andstream();complete()also returnsparsed(=JSON.parse(text), undefined on invalid/truncated). Cross-provider schema rules (driven by OpenAI strict + Gemini's limited keywords): every object needsadditionalProperties:false+ all propsrequiredwith a single non-null type; avoid optional/nullable, recursive,$ref, numeric/length constraints (toGeminiSchemadoesn't translate null-unions). - Tool calling (single-provider only):
tools?+toolChoice?(auto|any|none|{name}); replay results withtool_use/tool_resultparts.combinerejects tools/toolChoice. Replay rules: OpenAI throws withouttool_use.id/tool_result.toolUseId; Gemini throws withouttool_result.name; Anthropic hoiststool_resultblocks first.
Errors & transport
src/errors.ts— error vocabulary.ProviderErrorhastoJSON()→{name, message, provider, kind, status?, code?, type?}, so plainJSON.stringifyround-trips it. Two non-obvious bits:name/provider/kind/status/code/type/bodywere already own-enumerable (onlymessage/causeweren't), sotoJSONaddsmessageand removesbody—bodyis dropped as a duplicate (apiErrorembeds it verbatim inmessage), not for privacy. A non-ProviderErrorfailure still stringifies to{}.ProviderError(exported),kind:"api"(non-2xx →statusset, viaapiError) |"transport"(fetchrejected / signal aborted →causeset, viatransportError— the one place the transport-error shape lives;transport.ts,sse.ts(body-read aborts),readJsonBody,apiError(its error-bodytext()read), and the testMockProviderall throw through it).apiErrorFromBodyhandles a 2xx body carrying{error}(so it throws instead of returningtext:""). ImportsProviderNamefrom./registrytype-only (no runtime cycle — keep it that way). Mid-stream SSE error events stay plainError(only thereader.read()abort is wrapped). AlsoaggregateError(message, causes)(→AggregateErroror plainError); combine'snoResultErroradapts it. No fetch/transport code here.src/transport.ts—requestWithRetry(provider, input, init, retry?): bounded exponential backoff on 429/503/529, honorsRetry-After(capped 60s), aborts early onsignal. Transport rejections aren't retried. Config via per-providerretry?: RetryOptions({maxRetries?, baseDelayMs?}, defaults 2/500ms,0disables). Per-complete()call, so combine benefits transparently. Per-request overrides: each provider callsrequestControls(request, this.#retry)once percomplete()/stream()/embed()→{signal, retry}. Also exportsassertValidTemperature(finite-only; a plainErrorwith aneslint-disableforunicorn/prefer-type-error— it's an invalid value, and it keeps the class consistent withassertValidTimeoutMs).mergeRetryfield-mergesrequest.retryover the construction-time retry (spread, so explicitmaxRetries:0wins);withTimeoutfoldsrequest.timeoutMsinto the signal viaAbortSignal.any([signal, AbortSignal.timeout(ms)])— a whole-call deadline (all retries + backoff + stream body read), aborting with aTimeoutError(distinct from a caller abort).assertValidTimeoutMs(exported; also called up-front in the registry#prepare) rejects non-positive / non-finite /> 2_147_483_647(thesetTimeoutoverflow footgun).RetryOptionsis defined intypes.ts(keeps that leaf a leaf) and re-exported fromtransport.tsfor back-compat — don't move it back.readJsonBody(provider, response)wraps an abort/network failure during the.json()body read as a transport error (aSyntaxErrorfrom a malformed body stays raw); the 3complete()sites use it sincerequestWithRetryonly 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— internalMODELStable (USD per MTok, kept small on purpose: the most common models per provider, not an exhaustive catalog — a wrong price is worse thanundefined),findModel(model, options?),listModels(),PRICING_VERIFIED_ON. Resolution: exact → dated-snapshot prefix — a keykmatchesk-<DIGIT>…only (the char afterk-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 returnundefined, never a wrong price (gpt-4.1-nano,gemini-2.5-flash-lite; the digit rule is load-bearing — don't loosen to barestartsWith(k+"-")). Namespaced gateway ids (openai/gpt-4.1) correctly miss.options.modelsmerges over built-ins (override wins); no merge alloc when no overrides.ModelInfo.idis the resolved key; returnedpricingis a copy (clonePricing, incl. nestedhighTier) so callers can't corrupt the singleton. Tiered pricing via optionalModelPricing.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 optionalcachedInputPerMTok(read, tier-aware viahighTier.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-prorows list none → fall back to full input rate). Prices are best-effort/hand-maintained — a wrong price is worse thanundefined; keep the table small and updatePRICING_VERIFIED_ONwhen touching prices. To refresh the table + per-provider defaults against the providers' live docs, run the/update-modelsskill (.claude/skills/update-models/).src/cost.ts—costOf(result, options?)/costOfUsage(usage, model, options?)→CostBreakdown(USD) orundefined(never throws): unknown model, no usage, orinputTokens <= 0(empty{}/malformed usage — a real completion always has prompt tokens; declining beats a confident$0or input mis-billed at the output rate). PickshighTierrates wheninputTokens > aboveInputTokens. Cache split:inputCost = uncached×inputRate + cachedInputTokens×(cachedRate ?? inputRate) + cacheCreationInputTokens×(writeRate ?? inputRate), whereuncached = max(0, inputTokens − cached − write)(clamped so gateway over-reports can't go negative); folded intoinputCost(no separateCostBreakdownfield).cachedRateis read off the selected tier (so Gemini 2.5 Pro's cached rate doubles >200k);writeRateoff base. Gemini thinking tokens: billoutputTokens + max(0, totalTokens−input−output)at the output rate (0 residual for Anthropic/OpenAI). Never price offtotalTokens(cross-provider semantics differ). Float dollars —(tokens*perMTok)/1e6.- Prompt-cache accounting (the gotcha):
Usage.inputTokensis the total billable prompt (a superset incl. cache reads/writes);cachedInputTokens/cacheCreationInputTokensare subsets of it (set only when reported). Providers differ — Anthropic reports cache tokens in buckets outsideinput_tokens(soextractUsagesums them into the superset; defensively returnsinputTokens:0if baseinput_tokensis 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 toinputTokens). Reporting iscomplete()-only —stream()reports no usage. OpenAI + Gemini 2.5 cache automatically; mark Anthropic breakpoints viaCacheControl(below). - Request-side caching (Anthropic):
CacheControl = {ttl?: "1h"}on aContentPart(cacheControlon text/image/file) or the system prompt's object formsystem: {text, cacheControl}(SystemPrompt).anthropic.tsemitscache_controlon those blocks;prepareCacheControlenforces the ≤4 breakpoint limit (throws a clear error, not a raw 400) and toggles theextended-cache-ttl-2025-04-11beta header only when attl:"1h"breakpoint is present (computed once, passed to#headers— don't re-scan). OpenAI/Gemini ignore the marker (read system.textonly). Undercombine, placement decides:completionForforwardssystemas-is (object form included), so ensemble/broadcast honor asystemmarker; the shaped strategies flatten viacomposeSystem(no block boundary survives) and the registry's#rejectSystemCacheControlthrows for consensus/pipeline/panel. A content-part marker is honored only in phases that sendrequest.messagesunchanged (fan-out, consensus drafts, panel answers, pipeline stage 1) — later phases re-render viarenderConversation/textOf, which drops non-text parts. The 4-breakpoint throw happens insidecomplete(), so under combine it surfaces as failed outcomes, not a thrown error. combineCost/budget shipped insrc/combine/cost.ts— cache savings flow through both automatically (per-call ledger priced viacostOfUsage). 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, defaultclaude-opus-5-5(rejects a forcedtoolChoiceany/{name}with a 400). Body kept minimal — the Opus 4.x/5 line rejectstop_p/top_k/thinking, andtemperaturetoo (which is whytemperatureis sent only when the caller sets it; see Core contracts).refusalcollected fromtype:"refusal"blocks; hoiststool_resultblocks first. Prompt caching: emitscache_controlon marked text/image/document blocks + object-formsystem;prepareCacheControlcaps breakpoints at 4 + gates the 1h beta header (see Cost & pricing).openai.ts— Chat Completions, defaultgpt-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 needreasoning_effort:"none", which we don't send — documented, not model-sniffed.systemfolded into a leading system message; cap sent asmax_completion_tokens.OpenAIProviderOptions.headers?spread last (override defaults) — for gateways/OpenAI-compatible. Constructor's 2nd argname(internal, registry-only) attributes errors to a custom alias. URL file source throws (Chat Completions has none); tool-callargumentsis a JSON string (parsed on the way out).google.ts— Gemini API (GoogleProviderOptions), defaultgemini-3.8-flash(GA 2026-09-02), embed defaultgemini-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 againstmaxOutputTokens, so a tiny cap returns empty/truncated text — give it a generous cap.fileData.fileUriaccepts only a Files API/gs://URI, not a public web URL.toGeminiSchemaUPPERCASEstypeand dropsadditionalProperties(Gemini 400s on it though OpenAI strict requires it) for structured output + tool params; finishReason overridden totool_useonly on a clean stop; norefusalfield.- Shared helpers:
extract.ts(extractModel, exportedisRecord),structured.ts(parseStructured),sse.ts(sseJson(body, provider)— shared SSE reader: yields parseddata:JSON objects, ends on[DONE], always releases the reader (guardedfinallyso a cancel rejection can't mask the outcome); wraps areader.read()abort/network failure as a transport error viaprovider; eachstream()for-awaits it and handles its own events/errors).toArraystays duplicated per-provider (trivial).errors.ts/transport.tskeep their ownisRecord(lower layer). - Adding a provider: new
src/providers/<name>.tsimplementingProvider; add an optional field toProviderRegistryConfig+ a construction branch in the registry; re-export its options type fromindex.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)→Provideror 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;baseUrlexcludes 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>satisfiesno-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) + genericcombine(request)dispatching onrequest.strategy ?? "consensus".combine<S extends StrategyName>infersSfrom thestrategy?: Sdiscriminant → concreteResultFor<S>for a literal strategy, full union otherwise. (Don't type the param asStrategyRequest<S>— TS can't infer through indexed-access on object literals.) - Fallback (
src/fallback.ts):fallback(specs, options?)→ a composableProvider(not a wire provider — it wraps resolved providers, like combine wraps a roster; importsProviderNametype-only, registry imports the factory at runtime). Tries entries in order, catchingProviderError.FallbackSpec = ProviderName | {provider, model?, maxTokens?}(overrides beat the per-call request, entry→request→default — mirrorscompletionFor). Abort (request.signal.aborted, since abort & network are bothkind:"transport") propagates without advancing; non-ProviderErrorpropagates (a bug); all-fail throwsaggregateError(≥1 cause even for 1 entry — push cause before the last-entry check).stream()falls back only before the first delta (commit after firstnext();try/finally { it.return?.() }closes the inner stream on earlybreak). Full request forwards unchanged (unlike combine — tools/responseFormat/cacheControl pass through).shouldFallback/onFallbackboth take oneFallbackEvent {provider, index, error}(permissive default;onFallbackfires N-1× on all-fail). Noembed. No per-entryretry(that's #7's axis). - Validation: shared
#preparenormalizes eachParticipantSpec(throws on a falsymodel/maxTokensoverride), enforces ≥1 participant + unique ids + non-empty messages, rejects tools/toolChoice, resolves the roster. Per-strategy: consensus/pipeline/broadcast/panel rejectresponseFormat(ensemble-only); consensus validatesminParticipants/synthesizer, panel validatessynthesizer(shared#validateSynthesizer); ensemble requiresresponseFormatwith 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 valueSTRATEGY_NAMES(source of truthcombine()validates).CombineResultdiscriminated onstrategy(BroadcastResulthas notext).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);synthesizeris an id. Results carryusage?: CombineUsage(total/byParticipantkeyed by id + a per-callcalls: CallUsage[]ledger, each tagged withmodelfor individual pricing). ImportsProviderNametype-only (keep the combine↔registry cycle erasable).cost.ts—combineCost(result, options?)→CombineCost {totalCost, byParticipant, unpriced}(USD), pricing eachusage.callsentry individually (never the summedbyParticipant— summing breaks tiered rates / thinking residual); skips unpriceable calls,undefinedif none priceable.unpricedcounts the skipped ledger entries (required field — always computable) so a cheap run is distinguishable from a half-priced one; counts an unknown model or usagecostOfUsagedeclines (inputTokens <= 0), and can never equalcalls.length(all-unpriceable →undefined).unpriced: 0is not an all-clear —callUsagesdrops usage-less calls before the ledger, socombineCostcan't see or count them (Gemini embed reports no usage; gateways often omit it). AccumulatebyParticipantin a Map +Object.fromEntries, neverrecord[id] = …: a__proto__participant label otherwise makes a fully-priced run returnundefined. ImportscostOfUsagefrom../cost(one-way combine→cost;cost.tsstays single-result).combineCost/CombineCost/CallUsagere-exported from rootindex.ts.- Budget (
CombineOptions.budget?: {usd} & CostOptions) — soft floor on optional work, not a hard cap.makeBudget→BudgetTracker(shared) tracks running cost viacostOfUsage; strategies callbudget.gate(skipped, extra?)(emits the skip event + returns whether to skip — keeps the event shape in one place, no-op tracker returns false so nooptions?.budgetre-check needed). consensus skips critiques/sanitize, pipeline stops refining/skips sanitize.exceeded()isspent > 0 && spent >= usd(thespent > 0makes an unpriceable roster — or ausd:0budget — 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 → nobudget.add, no event; price withcombineCost). Gating is coarse — checked only betweenPromise.allphases.budgetis a newCombineEventvariant (skip events carryskipped, pipeline-refine skip alsoid/index). shared.ts— helpers reused by ≥2 strategies (completionFor,composeSystem,runOutcome,respondAll,noResultError,sanitizeAnswer(returnsmodeltoo),makeEmitter,aggregateUsage,callUsages,makeBudget/BudgetTracker,runEmbedding,RosterEntry, …).completionFor/sanitizeAnswertake per-participant overrides that beat the request-widemodel/maxTokens; strategies pass the entry itself.completionForalso forwardsretry/timeoutMs(per participant call, unlike the run-widesignal). 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 (bysynthesizerid) + a sanitizing pass. Framing invariants (preserve — rationale in doc-comments):SYNTH_FRAMINGadjudicates on correctness over popularity and forbids alluding to drafts/critiques/labels;sanitizeAnswerstrips leftover process narration (one extracomplete()call, multi-provider only);CONCISE_DIRECTIVEstrips 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. NominParticipants/synthesizer/attribution. - ensemble — multi-model vote on structured output (the differentiator). Every participant answers under the same
responseFormatin parallel; onlyokresponses with a plain-objectparsedcount. Mechanical merge (no LLM): field-wise majority vote over the first-seen key union (values grouped by deep-equalitystableKey, 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.agreementdenominator = all valid responses (omitted fields score low), exposed asvalidResponseCount.votesis the home for per-field detail (candidates with voter ids +winner, plusabsent) — extendEnsembleFieldVoterather than adding another parallelRecord<string,…>to the result;absentcomes from a set difference against the voters, never a secondObject.hasOwnscan (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 asvalid— not a second scan — soresponses.length - excluded.length === validResponseCountholds by construction. The all-excluded throw has no result to carry it, sodescribeExclusionsputs theid: reasonpairs in the error message — that path also has no causes fornoResultErrorwhen every response wasok-but-unparseable, so the message is the only diagnostic.collectStringFieldsfilters blank strings (.trim() === ""): the whole batch is oneembed()call and OpenAI 400s on"", so one blank used to killsemanticAgreementfor every field. Object-root only; nested merge is shallow. Build the per-field records withObject.fromEntries, neverrecord[key] = …—JSON.parseoutput can carry an own__proto__key, and index assignment invokes the prototype setter (drops the field, pollutes the record). (stableKeyuses.sort()nottoSorted— 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 (emptyokstill counts). RejectsresponseFormat. - panel — role-based experts → integrate (mixture-of-agents). Each participant answers in character via its
instruction(composed[system]+[instruction]+[framing]by a panel-localroleSystem), thensynthesizer(default first) integrates →answers+reviews+text. Framing invariant (preserve):PANEL_SYNTH_FRAMINGintegrates complementary perspectives (vs consensus adjudicating); the synth/sanitize passes get no role instruction (neutral integrator). OptionalcrossExaminereview round (each survivor reviews all answers, non-fatal, budget-gated"reviews"). Always attributed; nominParticipants/attribution. Survivors: empty dropped; 0 → throw; 1 → return that answer sanitized (the only degrade guard — nominParticipantsbackstop); failed/empty synth falls back to next survivor.synthesizerOrdercopied 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 trailingembedder?arg). All informational (never change a returned/merged value), usage folds into the ledger under a distinctembedding:<provider>id (so it can't collide with a participant id). A failure is reported, not swallowed: all four strategies go throughrunEmbedding(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.embeddingErroris 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: broadcastresult.semantic(agreement/outlier/clusters); consensusresult.draftAgreement(sameSemanticComparisonover surviving drafts, computed concurrently with critique/synth); panelresult.perspectiveAgreement(same, over surviving answers — but low agreement is expected/healthy for a panel); ensembleresult.semanticAgreement(per-field mean-pairwise-cosine over string fields only, one batched embed call — exact-match vote still picksmerged). pipeline unaffected (no parallel answers). embedding.ts— combine's embedding module (likecost.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. noUncheckedIndexedAccessgotcha: don't zip parallel arrays by index — carry paired data in one structure (roster / running answer).- Adding a strategy: new
src/combine/<strategy>.tsexporting its orchestrator; add toSTRATEGY_NAMES/StrategyName, a<Strategy>Request, a…Resultmember, and aStrategyRequestentry inindex.ts; add a public method + acombine()casein the registry. Promote a helper toshared.tsonly 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 thetest:integrationfilter matches). - Combine unit tests use network-free fake
Providers (nofetchmock); registry-levelcombine()validation lives insrc/__tests__/registry.test.ts.
Commands
yarn build— tsup bundles both entries (src/index.ts+src/testing/index.ts, common basesrc/→dist/testing/*) to dual ESM+CJS with declarations + sourcemaps. CLI multi-entry on purpose — notsup.config.ts(a root config.tsis outside tsconfigincludeand trips typed-lint'sprojectService, failingyarn 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: needsRUN_LIVE_TESTS=1(the script sets it) + that provider's key (ANTHROPIC_API_KEY/OPENAI_API_KEY/GEMINI_API_KEY), elsedescribe.skip. Cheap models (claude-haiku-4-5/gpt-4.1-mini/gemini-2.5-flash), smallmaxTokens(Gemini bigger for thinking tokens). Append a filename pattern to narrow (replaces the default). Env loads from a gitignored.envviajest.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'syarn typecheck); test files import globals from@jest/globals. Dual-format packaging viapackage.jsonexports;prepackbuilds. Node globals typed via@types/node+"types":["node"](makesfetch/Response/ReadableStreamresolve under the ES2022 lib). - Yarn 4 (Berry) PnP — no
node_modules; alwaysyarn, nevernpm. PnP gotcha: can only import deps declared directly inpackage.json(why@jest/globalsis an explicit devDep).@jest/globalsversion lagsjest— match what Jest pulls. - Lint (
eslint.config.mjs, flat, type-aware + unicorn + promise; Jest rules in tests only):typeoverinterface; inlineimport type+consistent-type-exports; exported API needs explicit types/return types;no-console: error(off in tests);_-prefix unused args; single-linecaseomits braces; Prettier owns formatting. - Publishing: push a
v*tag →.github/workflows/publish.ymlverifies tag==version, runs checks, publishes to npm via OIDC trusted publishing (no token), and creates a GitHub release from the matchingCHANGELOG.mdsection.
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
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.

