#@ybouhjira/hyperkit-llm-pipeline
Multi-LLM orchestration framework built with Effect-TS. Define typed pipeline steps, chain models together, and get full observability over every call — prompt, response, token usage, cost, and duration.
Part of the HyperKit monorepo. Used by @ybouhjira/hyperkit-ai-renderer to drive data-to-UI rendering pipelines.
#Installation
npm install @ybouhjira/hyperkit-llm-pipelinePeer dependency:
npm install effect@^3.0.0#Quick Start
import { Effect } from 'effect';
import { Schema as S } from 'effect';
import { defineStep, Pipeline, CliProvider } from '@ybouhjira/hyperkit-llm-pipeline';
// 1. Define the output schema for type-safe validation
const SummarySchema = S.Struct({ summary: S.String, bullets: S.Array(S.String) });
// 2. Define a step
const summarizeStep = defineStep({
name: 'summarize',
model: 'haiku',
systemPrompt: 'You are a concise summarizer. Return valid JSON only.',
outputSchema: SummarySchema,
buildPrompt: (text: string) => `Summarize this text:\n\n${text}`,
parseResponse: (response: string) => JSON.parse(response),
});
// 3. Build and run the pipeline
const pipeline = Pipeline.from('summarize-pipeline', summarizeStep);
const program = Pipeline.run(pipeline, 'Your long article text here...').pipe(
Effect.provide(CliProvider)
);
const { output, trace } = await Effect.runPromise(program);
console.log(output.summary);
console.log(`Cost: $${trace.totalCostUsd.toFixed(6)}`);#Core Concepts
#LlmProvider — the service interface
All LLM access goes through the LlmProvider Effect service. Steps declare a dependency on it; providers satisfy it at runtime via Effect.provide.
interface LlmRequest {
readonly model: string;
readonly systemPrompt: string;
readonly userPrompt: string;
}
interface LlmResponse {
readonly text: string;
readonly tokens: { readonly input: number; readonly output: number };
readonly costUsd: number;
}
class LlmProvider extends Context.Tag('LlmProvider')<
LlmProvider,
{ readonly call: (request: LlmRequest) => Effect.Effect<LlmResponse, LlmError> }
>() {}#Steps
A step is the smallest unit of work: it receives typed input, builds a prompt, calls the LLM, parses the response, and validates the output against a schema.
interface StepConfig<In, Out> {
name: string;
model: string;
systemPrompt: string;
outputSchema: Schema<Out>;
buildPrompt: (input: In) => string;
parseResponse: (response: string) => unknown;
retry?: {
maxAttempts?: number; // default: 1 (no retry)
feedbackOnError?: boolean; // append the parse/validation error to the next attempt's prompt
};
}#Pipelines
A pipeline is an ordered sequence of steps where the output of step N becomes the input of step N+1. The type system enforces this chain at compile time.
// Single step
const p = Pipeline.from('my-pipeline', step);
// Chained steps
const p2 = Pipeline.pipe(p, nextStep);
// Run
const { output, trace } = await Effect.runPromise(
Pipeline.run(p2, initialInput).pipe(Effect.provide(CliProvider))
);#API Reference
#defineStep<In, Out>(config: StepConfig<In, Out>): Step<In, Out>
Creates a pipeline step. The generic types In and Out are inferred from buildPrompt and outputSchema.
#Pipeline.from(name: string, step: Step<In, Out>): Pipeline<In, Out>
Creates a new pipeline from a single step.
#Pipeline.pipe(pipeline: Pipeline<In, Mid>, step: Step<Mid, Out>): Pipeline<In, Out>
Appends a step to a pipeline. The output type of the pipeline must match the input type of the step.
#Pipeline.run(pipeline, input): Effect<{ output, trace }, LlmError, LlmProvider>
Executes the pipeline. Returns an Effect that requires LlmProvider in its context. Provide it with Effect.provide(CliProvider) or a custom layer.
#LlmProvider
Effect Context.Tag representing the LLM service. Implement it with any Layer<LlmProvider>.
#LlmError
Tagged error class (_tag: 'LlmError') thrown when a provider call fails or all retry attempts are exhausted. Carries the model field.
#Providers
#CliProvider
The production provider. Routes calls to the Claude CLI or Gemini CLI based on the model name.
import { CliProvider } from '@ybouhjira/hyperkit-llm-pipeline';
const program = Pipeline.run(pipeline, input).pipe(Effect.provide(CliProvider));Model routing:
model === 'gemini'→gemini -p <prompt>(free, $0 cost)- anything else →
claude --model <model> --print -p <prompt>
Supported Claude models and estimated pricing (per 1M tokens):
| Model | Input | Output |
|---|---|---|
opus |
$15 | $75 |
sonnet |
$3 | $15 |
haiku |
$0.25 | $1.25 |
gemini |
$0 | $0 |
Unknown models fall back to haiku pricing.
Important: CliProvider uses synchronous execFileSync internally. It is designed for batch pipeline steps, not for interactive chat or streaming use cases.
Prerequisites: claude CLI and/or gemini CLI must be installed and authenticated in the system PATH.
#MockProvider
Factory for deterministic test providers. Accepts either a response map or a callback function.
import { MockProvider } from '@ybouhjira/hyperkit-llm-pipeline';
// Map responses by model name
const provider = MockProvider({
haiku: '{"summary": "short text", "bullets": ["point one"]}',
sonnet: '{"result": "ok"}',
});
// Function form — inspect the full request
const provider = MockProvider((req: LlmRequest) => {
if (req.model === 'haiku') return '{"value": 42}';
return '{"answer": "default"}';
});The function form receives the complete LlmRequest object (model, systemPrompt, userPrompt), making it suitable for testing prompt construction logic.
#Custom Providers
Implement Layer<LlmProvider> to integrate any LLM backend:
import { Layer, Effect } from 'effect';
import { LlmProvider, LlmError } from '@ybouhjira/hyperkit-llm-pipeline';
const MyProvider = Layer.succeed(LlmProvider, {
call: (request) =>
Effect.tryPromise({
try: async () => {
const response = await myApiClient.complete(request);
return {
text: response.content,
tokens: { input: response.usage.input, output: response.usage.output },
costUsd: 0,
};
},
catch: (err) => new LlmError(String(err), request.model),
}),
});#Pipeline Composition Example
A two-step pipeline: extract structured data with a cheap model, then enrich it with a more capable one.
import { Effect } from 'effect';
import { Schema as S } from 'effect';
import { defineStep, Pipeline, CliProvider } from '@ybouhjira/hyperkit-llm-pipeline';
const ExtractedSchema = S.Struct({ topics: S.Array(S.String), sentiment: S.String });
const EnrichedSchema = S.Struct({
topics: S.Array(S.String),
sentiment: S.String,
insight: S.String,
});
const extractStep = defineStep({
name: 'extract',
model: 'haiku',
systemPrompt: 'Extract topics and sentiment as JSON. Return only JSON, no markdown.',
outputSchema: ExtractedSchema,
buildPrompt: (text: string) => `Analyze:\n\n${text}`,
parseResponse: JSON.parse,
retry: { maxAttempts: 3, feedbackOnError: true },
});
const enrichStep = defineStep({
name: 'enrich',
model: 'sonnet',
systemPrompt: 'Add a one-sentence insight. Return only JSON, no markdown.',
outputSchema: EnrichedSchema,
buildPrompt: (extracted) => `Add insight to:\n\n${JSON.stringify(extracted)}`,
parseResponse: JSON.parse,
});
const pipeline = Pipeline.pipe(Pipeline.from('analyze', extractStep), enrichStep);
const { output, trace } = await Effect.runPromise(
Pipeline.run(pipeline, articleText).pipe(Effect.provide(CliProvider))
);
console.log(output.insight);
console.log(`Steps: ${trace.steps.length}, Total: $${trace.totalCostUsd.toFixed(6)}`);#Retry and Error Recovery
Configure retry on any step to handle transient parse errors or schema mismatches:
defineStep({
// ...
retry: {
maxAttempts: 3, // Try up to 3 times before failing with LlmError
feedbackOnError: true, // Append the error message to the next attempt's prompt
},
});When feedbackOnError is enabled, the second attempt's prompt becomes:
<original prompt>
Previous attempt failed with: Parse error: Unexpected token ...
Please fix the output.This gives the LLM the chance to self-correct without requiring a separate validation step.
#Observability and Tracing
Every Pipeline.run call returns a PipelineTrace alongside the output.
#StepTrace
Each step in trace.steps contains:
| Field | Type | Description |
|---|---|---|
id |
string |
Unique UUID for this execution |
stepName |
string |
Name from StepConfig |
model |
string |
Model used |
input |
unknown |
Input received by the step |
prompt |
string |
Final prompt sent to the LLM |
rawResponse |
string |
Raw text returned by the LLM |
output |
unknown |
Parsed and validated output |
startedAt |
number |
Unix timestamp (ms) |
completedAt |
number |
Unix timestamp (ms) |
durationMs |
number |
Wall-clock execution time |
tokens.input |
number |
Estimated input tokens |
tokens.output |
number |
Estimated output tokens |
costUsd |
number |
Estimated cost in USD |
attempts |
number |
Number of attempts made (1 = first try succeeded) |
error |
string? |
Error message if the step failed |
#PipelineTrace
Aggregated totals for the entire pipeline run:
| Field | Type | Description |
|---|---|---|
pipelineName |
string |
Pipeline name |
steps |
ReadonlyArray<StepTrace> |
Per-step traces |
startedAt |
number |
Unix timestamp (ms) |
completedAt |
number |
Unix timestamp (ms) |
totalDurationMs |
number |
Total wall-clock time |
totalCostUsd |
number |
Sum of all step costs |
totalTokens |
{ input, output } |
Sum of all step token usage |
success |
boolean |
true if all steps completed without error |
error |
string? |
Error message from the last failing step |
#Testing
Use MockProvider to write fast, deterministic tests without any LLM calls:
import { Effect } from 'effect';
import { defineStep, Pipeline, MockProvider } from '@ybouhjira/hyperkit-llm-pipeline';
it('extracts the answer field', async () => {
const step = defineStep({/* ... */});
const program = Pipeline.run(Pipeline.from('test', step), 'input').pipe(
Effect.provide(MockProvider({ haiku: '{"answer": "42"}' }))
);
const { output, trace } = await Effect.runPromise(program);
expect(output.answer).toBe('42');
expect(trace.success).toBe(true);
});#Roadmap
- Streaming support —
call()currently returns a complete response. Astream()method is planned for interactive use cases. - OpenAI provider —
Layer<LlmProvider>wrapping the OpenAI API. - Ollama provider — Local model support via the Ollama REST API.
- Cost estimation API — Pre-flight cost estimates based on prompt length and model pricing before making calls.
- DAG pipelines — Parallel step execution for steps that do not depend on each other (the
parentStepIdfield onStepTraceis reserved for this).
#License
ISC