Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.0] - 2026-05-14

This release is the "give it to a paying customer" hardening pass: the
library now survives the real-world failure modes that would have bitten
a production user on 0.4.

### Added
- **Multi-modal content preservation.** New `passthrough` ContentBlock
type carries provider-specific blocks (images, files, audio,
documents) verbatim through optimization. The OpenAI, Anthropic, and
Vercel AI SDK adapters now wrap unrecognized blocks in `passthrough`
on the way in and unwrap them on the way back to the SDK. Token
counting respects `passthrough.estimatedTokens` (defaults to 500;
per-block defaults to 850 for OpenAI images, 1500 for Anthropic
images, 1000 for AI SDK image/file parts).
- **Configurable summarizer error recovery.** `summarizer.onError`
accepts `'fall-back'` (default), `'throw'`, or a custom handler. When
the LLM call fails, the optimizer falls back to `sliding-window` for
that call instead of throwing the user's request.
- **LRU-capped embedding cache.** `createEmbeddingScorer` accepts
`maxCacheSize` (default 1000, set to 0 to disable). Cache evicts
least-recently-used entries when the cap is exceeded, fixing an
unbounded-memory issue in long-running processes.
- **`meta.fellBackTo`** is now set whenever the requested strategy
couldn't run cleanly and fell back to another. Summarizer reports
`'sliding-window'` when there's no compressible material or when
`llmCall` errors and `onError` is `'fall-back'`. Hybrid reports the
final fallback when its three-phase pipeline still leaves the result
over budget.
- **Status: pre-1.0 section** in README documenting breaking-change
policy and browser bundle-size caveat.
- **Deploy instructions** for the playground (Vercel, Netlify,
GitHub Pages).

### Changed
- 21 → 101 tests across the 0.2 → 0.5 arc.

## [0.4.0] - 2026-05-14

### Added
Expand Down Expand Up @@ -103,6 +140,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Tool-pair preservation across boundary trims.
- ESM + CJS builds, TypeScript types, Node 18+.

[0.5.0]: https://github.com/EvanPaules/ctx-opt/releases/tag/v0.5.0
[0.4.0]: https://github.com/EvanPaules/ctx-opt/releases/tag/v0.4.0
[0.2.0]: https://github.com/EvanPaules/ctx-opt/releases/tag/v0.2.0
[0.1.0]: https://github.com/EvanPaules/ctx-opt/releases/tag/v0.1.0
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ summarizer-with-real-LLM, and LangChain.js integrations.
| `hybrid` | Slow | Best | Yes (both) | Production: relevance-filter first, then summarize the rest if still over budget. |

All strategies preserve the system prompt by default and never split a tool-use /
tool-result pair across the boundary.
tool-result pair across the boundary. Multi-modal content (images, files, audio)
is preserved verbatim through optimization via the `passthrough` content-block
type; the messages reach the LLM unmodified even if ctx-opt trims around them.

### Benchmarks

Expand Down Expand Up @@ -156,12 +158,17 @@ interface OptimizerConfig {
llmCall: SummarizerLLMFn; // your LLM call — see "Plugging in your LLM"
maxSummaryTokens?: number; // default: 400
triggerThreshold?: number; // 0..1, default: 0.85
recentWindow?: number; // per-strategy override
onError?: 'fall-back' | 'throw' | ((err: unknown) => void); // default: 'fall-back'
};

relevance?: {
scorer: RelevanceScorerFn; // your scorer — returns one score per message
minScore?: number; // default: 0.2
recentWindow?: number; // per-strategy override
};

pricing?: Record<string, { inputUsdPerMillion: number }>; // override built-in pricing table
}
```

Expand Down Expand Up @@ -310,6 +317,7 @@ Every call to `optimize()` returns a `meta` describing what happened:
| `withinBudget` | `true` if `outputTokens <= maxTokens`. |
| `inputCostUsd` | Dollar cost of the optimized input. Undefined if model pricing is unknown. |
| `savedUsd` | Dollars saved on input cost vs the unoptimized array. Undefined if model pricing is unknown. |
| `fellBackTo` | Set when the requested strategy couldn't run cleanly and fell back (e.g. summarizer's llmCall threw → falls back to `sliding-window`). |

## Token counting accuracy

Expand All @@ -336,6 +344,28 @@ const tokens = await countMessageTokensWithAnthropic(
);
```

## Status: pre-1.0

`ctx-opt` is at `0.x`. The core API surface (`ContextOptimizer`,
strategies, meta shape, SDK adapters) is settling but **breaking changes
are still on the table** until 1.0. Each minor version (`0.4 -> 0.5`)
may contain breaking changes; patch versions (`0.5.0 -> 0.5.1`) will
not. The CHANGELOG calls out anything breaking explicitly.

Pin to a minor version in production:

```json
"dependencies": { "ctx-opt": "~0.5.0" }
```

### Browser bundle

The core works in the browser, but the underlying `js-tiktoken`
encoding tables add **~2 MB** to your bundle. That's fine for an
internal tool or a server-rendered app but not for a tightly
performance-budgeted client. For client-side use, consider running
optimization on the server and streaming the result down.

## Changelog

See [CHANGELOG.md](./CHANGELOG.md) for the release history.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ctx-opt",
"version": "0.4.0",
"version": "0.5.0",
"description": "Intelligent context window optimization middleware for LLM applications",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
26 changes: 23 additions & 3 deletions playground/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,34 @@ Opens on http://localhost:5173. The playground imports ctx-opt directly
from `../src/` via a Vite alias, so changes to the library are reflected
immediately.

## Deploy
## Deploy to Vercel

```bash
# One-time setup if you don't have vercel CLI:
npm install -g vercel

# From the playground/ directory:
npm run build
# dist/ is a static SPA. Deploy with vercel, netlify, gh-pages, anything.
vercel deploy --prod ./dist
vercel deploy --prod dist
```

Vercel will prompt for project linking the first time, then return a
public URL. Put it in the main README's playground link.

## Deploy to Netlify

```bash
npm install -g netlify-cli
npm run build
netlify deploy --prod --dir dist
```

## Deploy via GitHub Pages

`npm run build` produces a fully-static `dist/` directory. Push that to
a `gh-pages` branch with your tool of choice and serve it from
`https://<user>.github.io/<repo>/playground`.

## What it shows

- All four strategies running on the same input.
Expand Down
27 changes: 25 additions & 2 deletions src/adapters/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,18 @@ function aiSdkContentToCtx(content: unknown): Message['content'] {
const text =
typeof p.result === 'string' ? p.result : JSON.stringify(p.result ?? '');
blocks.push({ type: 'tool_result', tool_use_id: p.toolCallId, content: text });
} else if (p.type === 'image') {
blocks.push({ type: 'passthrough', raw: part, estimatedTokens: 1000, kind: 'image' });
} else if (p.type === 'file') {
blocks.push({ type: 'passthrough', raw: part, estimatedTokens: 1000, kind: 'file' });
} else if (p.type) {
blocks.push({ type: 'passthrough', raw: part, kind: p.type });
}
}
return blocks.length > 0 ? blocks : '';
}

function ctxBlocksToAiSdkContent(role: Message['role'], blocks: ContentBlock[]): unknown {
function ctxBlocksToAiSdkContent(_role: Message['role'], blocks: ContentBlock[]): unknown {
return blocks.map((b) => {
if (b.type === 'text') return { type: 'text', text: b.text };
if (b.type === 'tool_use') {
Expand All @@ -73,6 +79,7 @@ function ctxBlocksToAiSdkContent(role: Message['role'], blocks: ContentBlock[]):
result: b.content,
};
}
if (b.type === 'passthrough') return b.raw;
return { type: 'text', text: '' };
});
}
Expand Down Expand Up @@ -111,9 +118,17 @@ function openAIContentToCtx(content: unknown): Message['content'] {
if (!Array.isArray(content)) return '';
const blocks: ContentBlock[] = [];
for (const part of content) {
const p = part as { type?: string; text?: string };
const p = part as { type?: string; text?: string; image_url?: { detail?: string } };
if (p.type === 'text' && typeof p.text === 'string') {
blocks.push({ type: 'text', text: p.text });
} else if (p.type === 'image_url') {
// OpenAI image cost is roughly 85 (low detail) or up to ~1500 (high detail).
const detail = p.image_url?.detail;
const estimatedTokens = detail === 'low' ? 85 : 850;
blocks.push({ type: 'passthrough', raw: part, estimatedTokens, kind: 'image' });
} else if (p.type) {
// Unknown but typed block — preserve verbatim for round-trip.
blocks.push({ type: 'passthrough', raw: part, kind: p.type });
}
}
return blocks.length > 0 ? blocks : '';
Expand All @@ -124,6 +139,7 @@ function ctxBlocksToOpenAIContent(blocks: ContentBlock[]): unknown {
if (b.type === 'text') return { type: 'text', text: b.text };
if (b.type === 'tool_use') return { type: 'text', text: `[tool_use:${b.name}] ${JSON.stringify(b.input)}` };
if (b.type === 'tool_result') return { type: 'text', text: `[tool_result] ${b.content}` };
if (b.type === 'passthrough') return b.raw;
return { type: 'text', text: '' };
});
}
Expand Down Expand Up @@ -208,6 +224,12 @@ function anthropicContentToCtx(content: unknown): Message['content'] {
} else if (p.type === 'tool_result' && typeof p.tool_use_id === 'string') {
const text = typeof p.content === 'string' ? p.content : flattenAnthropicResultContent(p.content);
blocks.push({ type: 'tool_result', tool_use_id: p.tool_use_id, content: text });
} else if (p.type === 'image') {
blocks.push({ type: 'passthrough', raw: part, estimatedTokens: 1500, kind: 'image' });
} else if (p.type === 'document') {
blocks.push({ type: 'passthrough', raw: part, estimatedTokens: 1500, kind: 'document' });
} else if (p.type) {
blocks.push({ type: 'passthrough', raw: part, kind: p.type });
}
}
return blocks.length > 0 ? blocks : '';
Expand All @@ -218,6 +240,7 @@ function ctxBlocksToAnthropicContent(blocks: ContentBlock[]): unknown {
if (b.type === 'text') return { type: 'text', text: b.text };
if (b.type === 'tool_use') return { type: 'tool_use', id: b.id, name: b.name, input: b.input };
if (b.type === 'tool_result') return { type: 'tool_result', tool_use_id: b.tool_use_id, content: b.content };
if (b.type === 'passthrough') return b.raw;
return { type: 'text', text: '' };
});
}
Expand Down
24 changes: 21 additions & 3 deletions src/optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ export class ContextOptimizer {
};
}

let result: { messages: Message[]; messagesDropped: number; messagesSummarized: number };
let result: {
messages: Message[];
messagesDropped: number;
messagesSummarized: number;
fellBackTo?: StrategyName;
};

switch (strategy) {
case 'sliding-window': {
Expand Down Expand Up @@ -76,6 +81,7 @@ export class ContextOptimizer {
strategyUsed: strategy,
messagesDropped: result.messagesDropped,
messagesSummarized: result.messagesSummarized,
fellBackTo: result.fellBackTo,
}),
};
}
Expand All @@ -95,10 +101,16 @@ export class ContextOptimizer {
private async runHybrid(
messages: Message[],
task: string | undefined
): Promise<{ messages: Message[]; messagesDropped: number; messagesSummarized: number }> {
): Promise<{
messages: Message[];
messagesDropped: number;
messagesSummarized: number;
fellBackTo?: StrategyName;
}> {
const model = this.config.model;
let messagesDropped = 0;
let messagesSummarized = 0;
let fellBackTo: StrategyName | undefined;
let current = messages;

if (this.config.relevance) {
Expand All @@ -114,16 +126,18 @@ export class ContextOptimizer {
const s = await applySummarizer(current, this.config);
messagesDropped += Math.max(0, current.length - s.messages.length - (s.messagesSummarized > 0 ? 1 : 0));
messagesSummarized += s.messagesSummarized;
if (s.fellBackTo) fellBackTo = s.fellBackTo;
current = s.messages;
}

if (countMessageTokens(current, model) > this.config.maxTokens) {
const fb = applySlidingWindow(current, this.config);
messagesDropped += fb.messagesDropped;
current = fb.messages;
fellBackTo = 'sliding-window';
}

return { messages: current, messagesDropped, messagesSummarized };
return { messages: current, messagesDropped, messagesSummarized, ...(fellBackTo ? { fellBackTo } : {}) };
}

private buildMeta(args: {
Expand All @@ -132,6 +146,7 @@ export class ContextOptimizer {
strategyUsed: StrategyName;
messagesDropped: number;
messagesSummarized: number;
fellBackTo?: StrategyName;
}): OptimizeMeta {
const saved = Math.max(0, args.inputTokens - args.outputTokens);
const pricing = resolvePricing(this.config.model, this.config.pricing);
Expand All @@ -149,6 +164,9 @@ export class ContextOptimizer {
meta.inputCostUsd = tokensToUsd(args.outputTokens, pricing);
meta.savedUsd = tokensToUsd(saved, pricing);
}
if (args.fellBackTo) {
meta.fellBackTo = args.fellBackTo;
}
return meta;
}
}
Loading
Loading