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
31 changes: 31 additions & 0 deletions .changeset/ai-sdk-v7-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@context-chef/ai-sdk-middleware": major
---

Support AI SDK v7 (provider spec V4).

The middleware now targets `ai@>=7` / `@ai-sdk/provider@>=4`: it implements the V4
language-model middleware spec (`specificationVersion: 'v4'`) and all public types
move from `LanguageModelV3*` to `LanguageModelV4*`. AI SDK v7's `wrapLanguageModel`
rejects a v3-spec middleware, so this is a breaking change that requires AI SDK v7.

**Migration**

- On AI SDK v7 (`ai@7`): upgrade to `@context-chef/ai-sdk-middleware@2`.
- Still on AI SDK v6 (`ai@6`): stay on `@context-chef/ai-sdk-middleware@1` — the 1.x
line continues to support the v3 spec. No code change is forced on you.

**Removed** (deprecated APIs that were slated for removal in the next major):

- `planCompaction`, `compactHistory`, and the `CompactionPlan` type (the provider-prompt
altitude variants) — use `planCompactionModelMessages` / `compactModelMessages` /
`CompactionPlanModelMessages` at the `ModelMessage` altitude instead.
- `onBudgetExceeded` on `ContextChefOptions` — use `onBeforeCompress` instead.

Runtime behavior is unchanged. The only V4 nuance: provider-level `FilePart.data`
became a tagged union (`SharedV4FileData`); the prompt adapter handles it
transparently and the binary/URL payload still round-trips losslessly.

On v7, durable in-loop compaction via `compactModelMessages` inside a
`ToolLoopAgent` `prepareStep` now persists across steps (AI SDK v7 carries
`prepareStep`-returned messages forward into later steps — v6 did not).
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -940,7 +940,7 @@ Install only what you need:
# Core library (OpenAI / Anthropic / Gemini direct SDK usage)
npx skills add MyPrototypeWhat/context-chef --skill context-chef-core

# AI SDK middleware (Vercel AI SDK v6+)
# AI SDK middleware (Vercel AI SDK v7+)
npx skills add MyPrototypeWhat/context-chef --skill context-chef-middleware

# TanStack AI middleware (TanStack AI v0.10+)
Expand Down
53 changes: 4 additions & 49 deletions packages/ai-sdk-middleware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[![npm downloads](https://img.shields.io/npm/dm/@context-chef/ai-sdk-middleware.svg)](https://www.npmjs.com/package/@context-chef/ai-sdk-middleware)
[![License](https://img.shields.io/npm/l/@context-chef/ai-sdk-middleware.svg)](https://github.com/MyPrototypeWhat/context-chef/blob/main/LICENSE)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue.svg)](https://www.typescriptlang.org/)
[![AI SDK](https://img.shields.io/badge/AI%20SDK-v6-black.svg)](https://ai-sdk.dev)
[![AI SDK](https://img.shields.io/badge/AI%20SDK-v7-black.svg)](https://ai-sdk.dev)

[Vercel AI SDK](https://ai-sdk.dev) middleware powered by [context-chef](https://github.com/MyPrototypeWhat/context-chef). Transparent history compression, tool result truncation, and token budget management — zero code changes required.

Expand All @@ -16,6 +16,9 @@
npm install @context-chef/ai-sdk-middleware ai
```

> **AI SDK version.** `2.x` targets **AI SDK v7** (`ai@>=7`). Still on AI SDK v6?
> Use `@context-chef/ai-sdk-middleware@1` — the `1.x` line supports `ai@6`.

## Quick Start

```typescript
Expand Down Expand Up @@ -309,54 +312,6 @@ if (toSummarize.length > 0) {

ModelMessage-altitude sibling of [`summarizeMessages`](#summarizemessagesprompt-model-options): summarize a `ModelMessage[]` slice into a single summary string via the same pipeline (role-flattening + core `summarizeHistory`). System messages are dropped. An empty slice returns `''` without a model call; it throws if the model call fails. Use it with `planCompactionModelMessages` when you want to drive summarization yourself instead of the one-shot `compactModelMessages`.

### `compactHistory(prompt, model, options)`

> **Deprecated.** `compactHistory` / `planCompaction` take and return
> `LanguageModelV3Prompt` — the provider-protocol altitude, which nobody
> persists. Use [`compactModelMessages`](#compactmodelmessagesmessages-model-options)
> / `planCompactionModelMessages` instead. Still exported and working; removed in the next major.

The V3-prompt variant of `compactModelMessages`. It splits the history on turn boundaries, summarizes the old slice, and returns a new prompt ready to persist — `[...system, <summary>, ...recent turns]`:

```typescript
import { compactHistory } from '@context-chef/ai-sdk-middleware';

// Between model calls, when you own `messages`:
messages = await compactHistory(messages, summarizerModel, {
keepRecentTurns: 4, // keep the last 4 atomic turns verbatim
toolResultStubThreshold: 5000,
});
// Persist the result — history actually shrinks and stays shrunk.
```

- The cut lands only on **turn boundaries** (an assistant + its tool results stay together), so it never orphans a tool result or splits a multi-block assistant message.
- System messages are preserved verbatim and never summarized.
- Returns the prompt **unchanged** when there is nothing old enough to compact (no more turns than `keepRecentTurns`) or the summarizer yields no text — safe to call unconditionally. Throws only if the model call throws.
- Accepts the same `SummarizeMessagesOptions` (`customCompressionInstructions`, `toolResultStubThreshold`) as `summarizeMessages`.

### `planCompaction(prompt, options)`

> **Deprecated.** Use [`planCompactionModelMessages`](#plancompactionmodelmessagesmessages-options).
> This V3-prompt variant is the provider-protocol altitude — a type you never
> persist. Still exported and working; removed in the next major.

The synchronous split behind `compactHistory`, for when you want the boundary without summarizing (persist your own marker, or use a different summarizer). Returns `{ system, toSummarize, toKeep }` (all `LanguageModelV3Prompt`), cut on turn boundaries:

```typescript
import { planCompaction, summarizeMessages } from '@context-chef/ai-sdk-middleware';
import { Prompts } from '@context-chef/core';

const { system, toSummarize, toKeep } = planCompaction(messages, { keepRecentTurns: 4 });
if (toSummarize.length > 0) {
const summary = await summarizeMessages(toSummarize, model);
messages = [
...system,
{ role: 'user', content: [{ type: 'text', text: Prompts.getCompactSummaryWrapper(summary) }] },
...toKeep,
];
}
```

## How It Works

```
Expand Down
8 changes: 4 additions & 4 deletions packages/ai-sdk-middleware/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@
"@context-chef/core": "workspace:*"
},
"peerDependencies": {
"@ai-sdk/provider": ">=3",
"ai": ">=6"
"@ai-sdk/provider": ">=4",
"ai": ">=7"
},
"devDependencies": {
"@ai-sdk/provider": "^3.0.8",
"@ai-sdk/provider": "^4.0.0",
"@types/node": "^25.3.0",
"ai": "^6.0.140",
"ai": "^7.0.0",
"tsdown": "^0.20.3",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
Expand Down
53 changes: 34 additions & 19 deletions packages/ai-sdk-middleware/src/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type {
LanguageModelV3Message,
LanguageModelV3Prompt,
LanguageModelV3ToolResultOutput,
LanguageModelV3ToolResultPart,
SharedV3ProviderOptions,
LanguageModelV4Message,
LanguageModelV4Prompt,
LanguageModelV4ToolResultOutput,
LanguageModelV4ToolResultPart,
SharedV4FileData,
SharedV4ProviderOptions,
} from '@ai-sdk/provider';
import {
type Attachment,
Expand All @@ -12,10 +13,24 @@ import {
type ToolCall,
} from '@context-chef/core';

/**
* Extracts the Attachment presence/metadata signal from a V4 file part's `data`.
*
* V4 restructured `FilePart.data` from a bare value into a tagged union
* (`SharedV4FileData`). Only inline string data (`{ type: 'data', data: string }`)
* yields a recorded signal — bytes, URLs, and provider references become `''`,
* exactly as the pre-v4 adapter only recorded already-string `data`. The real
* payload always round-trips losslessly through `_userContent`/`_assistantContent`;
* this value is only a presence signal Janitor reads via `attachments?.length`.
*/
function fileDataSignal(data: SharedV4FileData): string {
return data.type === 'data' && typeof data.data === 'string' ? data.data : '';
}

/** Content types for each AI SDK message role */
type UserContent = Extract<LanguageModelV3Message, { role: 'user' }>['content'];
type AssistantContent = Extract<LanguageModelV3Message, { role: 'assistant' }>['content'];
type ToolContent = Extract<LanguageModelV3Message, { role: 'tool' }>['content'];
type UserContent = Extract<LanguageModelV4Message, { role: 'user' }>['content'];
type AssistantContent = Extract<LanguageModelV4Message, { role: 'assistant' }>['content'];
type ToolContent = Extract<LanguageModelV4Message, { role: 'tool' }>['content'];

/**
* Extended IR message with typed pass-through fields for lossless AI SDK round-trip.
Expand All @@ -26,12 +41,12 @@ export interface AISDKMessage extends Message {
_assistantContent?: AssistantContent;
_toolContent?: ToolContent;
_originalText?: string;
_providerOptions?: SharedV3ProviderOptions;
_providerOptions?: SharedV4ProviderOptions;
_toolName?: string;
}

/**
* Converts an AI SDK V3 prompt to context-chef IR messages.
* Converts an AI SDK V4 prompt to context-chef IR messages.
*
* Original AI SDK content is stored in per-role fields for lossless round-trip.
* `_originalText` caches the extracted text so `toAISDK` can detect Janitor modifications.
Expand All @@ -42,7 +57,7 @@ export interface AISDKMessage extends Message {
* non-system message is a user message. This is a system boundary — IR
* downstream is trusted to satisfy invariants.
*/
export function fromAISDK(prompt: LanguageModelV3Prompt): AISDKMessage[] {
export function fromAISDK(prompt: LanguageModelV4Prompt): AISDKMessage[] {
const messages: AISDKMessage[] = [];

for (const msg of prompt) {
Expand Down Expand Up @@ -73,7 +88,7 @@ export function fromAISDK(prompt: LanguageModelV3Prompt): AISDKMessage[] {
// so we never invent a fake encoding for non-string inputs.
attachments.push({
mediaType: part.mediaType,
data: typeof part.data === 'string' ? part.data : '',
data: fileDataSignal(part.data),
...(part.filename ? { filename: part.filename } : {}),
});
}
Expand Down Expand Up @@ -127,7 +142,7 @@ export function fromAISDK(prompt: LanguageModelV3Prompt): AISDKMessage[] {
// _assistantContent carries the actual payload through round-trip.
attachments.push({
mediaType: part.mediaType,
data: typeof part.data === 'string' ? part.data : '',
data: fileDataSignal(part.data),
...(part.filename ? { filename: part.filename } : {}),
});
}
Expand Down Expand Up @@ -189,14 +204,14 @@ function asAISDK(msg: Message): AISDKMessage {
}

/**
* Converts context-chef IR messages back to AI SDK V3 prompt format.
* Converts context-chef IR messages back to AI SDK V4 prompt format.
*
* Uses per-role original content when unmodified (detected via `_originalText`).
* Falls back to constructing from IR fields when content was modified by Janitor
* (e.g. compact() cleared tool results) or for new messages (e.g. compression summaries).
*/
export function toAISDK(messages: Message[]): LanguageModelV3Prompt {
const prompt: LanguageModelV3Prompt = [];
export function toAISDK(messages: Message[]): LanguageModelV4Prompt {
const prompt: LanguageModelV4Prompt = [];

let i = 0;
while (i < messages.length) {
Expand Down Expand Up @@ -240,11 +255,11 @@ export function toAISDK(messages: Message[]): LanguageModelV3Prompt {
}

if (msg.role === 'tool') {
const toolResults: LanguageModelV3ToolResultPart[] = [];
const toolResults: LanguageModelV4ToolResultPart[] = [];
// Re-attach the message-level providerOptions captured on the first IR
// message of the original tool turn (see fromAISDK). Take the first
// non-undefined across the coalesced group.
let providerOptions: SharedV3ProviderOptions | undefined;
let providerOptions: SharedV4ProviderOptions | undefined;
while (i < messages.length && messages[i].role === 'tool') {
const toolMsg = asAISDK(messages[i]);
const toolModified =
Expand Down Expand Up @@ -289,7 +304,7 @@ export function toAISDK(messages: Message[]): LanguageModelV3Prompt {
return prompt;
}

export function stringifyToolOutput(output: LanguageModelV3ToolResultOutput): string {
export function stringifyToolOutput(output: LanguageModelV4ToolResultOutput): string {
switch (output.type) {
case 'text':
case 'error-text':
Expand Down
93 changes: 1 addition & 92 deletions packages/ai-sdk-middleware/src/compaction.ts
Original file line number Diff line number Diff line change
@@ -1,106 +1,15 @@
import type { LanguageModelV3, LanguageModelV3Prompt } from '@ai-sdk/provider';
import {
compactHistory as coreCompactHistory,
planCompaction as corePlanCompaction,
type PlanCompactionOptions,
} from '@context-chef/core';
import type { LanguageModel, ModelMessage } from 'ai';

import { fromAISDK, toAISDK } from './adapter';
import { createCompressionAdapter, type SummarizeMessagesOptions } from './middleware';
import { fromModelMessages, toModelMessages } from './modelMessageAdapter';

export type { PlanCompactionOptions } from '@context-chef/core';

export interface CompactionPlan {
/** System messages, preserved verbatim — standing instructions are never summarized. */
system: LanguageModelV3Prompt;
/**
* The old conversation slice to summarize (system excluded). Feed this to
* `summarizeMessages`. Empty when there is nothing old enough to compact.
*/
toSummarize: LanguageModelV3Prompt;
/** The recent conversation turns to keep verbatim. */
toKeep: LanguageModelV3Prompt;
}

/**
* @deprecated Use {@link planCompactionModelMessages}. This V3-prompt variant is
* the provider-protocol altitude — a type you never persist. Removed in the next
* major.
*
* Splits an AI SDK prompt into `{ system, toSummarize, toKeep }` on **turn
* boundaries**, for durable (caller-owned) compaction.
*
* Unlike the in-flight middleware `compress` — which only rewrites the outgoing
* request and is discarded each call — this is a pure, synchronous split you run
* against your *own* message store. Summarize `toSummarize`, then persist
* `[...system, <summary>, ...toKeep]` back to your store so the history actually
* shrinks. See {@link compactHistory} for the one-shot version.
*
* The AI-SDK-typed wrapper around core's provider-agnostic `planCompaction`:
* converts the prompt to IR via {@link fromAISDK}, splits on turn boundaries
* (assistant + its tool results stay together), and converts each slice back via
* {@link toAISDK}.
*/
export function planCompaction(
prompt: LanguageModelV3Prompt,
options: PlanCompactionOptions,
): CompactionPlan {
const plan = corePlanCompaction(fromAISDK(prompt), options);
return {
system: toAISDK(plan.system),
toSummarize: toAISDK(plan.toSummarize),
toKeep: toAISDK(plan.toKeep),
};
}

/**
* @deprecated Use {@link compactModelMessages}. `LanguageModelV3Prompt` is the
* provider-protocol altitude (ephemeral, never persisted); durable compaction
* belongs at the ModelMessage altitude. Removed in the next major.
*
* One-shot durable compaction: plan a turn-safe split, summarize the old slice,
* and return a new prompt ready to persist — `[...system, <summary>, ...toKeep]`.
*
* This is the recommended way to keep a long conversation lean when you own the
* message store (a long agent loop, or a chat past the budget). Run it between
* model calls and replace your stored messages with the result; the summary is
* a real `user` message wrapped with the "continued conversation" framing.
*
* Returns the prompt **unchanged** (same reference) when there is nothing old
* enough to compact (no more turns than `keepRecentTurns`) or when the summarizer
* yields no text — so it is safe to call unconditionally, and callers can skip
* persistence on a no-op via `result === prompt`. Throws only if the model call
* throws.
*
* The AI-SDK-typed wrapper around core's `compactHistory`: it binds `model` into
* a compression callback via {@link createCompressionAdapter} (core never calls a
* model directly). Do NOT also configure middleware `compress` (with a `model`)
* on the same path — that compresses twice. Use this OR in-flight `compress`,
* not both.
*
* @example
* ```ts
* // In your loop / between turns, when you own `messages`:
* messages = await compactHistory(messages, summarizerModel, {
* keepRecentTurns: 4,
* toolResultStubThreshold: 5000,
* });
* ```
*/
export async function compactHistory(
prompt: LanguageModelV3Prompt,
model: LanguageModelV3,
options: PlanCompactionOptions & SummarizeMessagesOptions,
): Promise<LanguageModelV3Prompt> {
const ir = fromAISDK(prompt);
const result = await coreCompactHistory(ir, createCompressionAdapter(model), options);
// core returns the input IR reference on a no-op — preserve the original
// prompt reference so callers can skip persistence via `result === prompt`.
return result === ir ? prompt : toAISDK(result);
}

export interface CompactionPlanModelMessages {
/** System messages, preserved verbatim — standing instructions are never summarized. */
system: ModelMessage[];
Expand Down Expand Up @@ -143,7 +52,7 @@ export function planCompactionModelMessages(
* loop, or inside a `ToolLoopAgent` `prepareStep` (`return { messages: await
* compactModelMessages(messages, model, opts) }`).
*
* `model` is `ai`'s `LanguageModel` (string id | V3 | V2) — exactly what
* `model` is `ai`'s `LanguageModel` (string id | V4) — exactly what
* `prepareStep`/`generateText` give you. Reuses core's `compactHistory` +
* `createCompressionAdapter` (tool-role flattening); no model is called directly.
*
Expand Down
Loading
Loading