Skip to content
Open
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ yarn add @openrouter/sdk
> npx skills add OpenRouterTeam/skills --skill openrouter-agent-migration
> ```

## Multi-turn conversation state

`callModelWithState` carries conversation context across turns: each turn
loads prior history keyed by a caller-supplied `conversationId`, appends the
new input and response output, and persists it back — no manual history
management. State lives in a pluggable `ConversationStateStore` (in-memory
and file backends included), with optional TTL expiry, `expire()` sweeps, and
`clear()` for explicit deletion. See [docs/multi-turn-state.md](docs/multi-turn-state.md)
for keying, configuration, cleanup, and recovery semantics.

<!-- Start Requirements [requirements] -->
## Requirements

Expand Down
168 changes: 168 additions & 0 deletions docs/multi-turn-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# Multi-Turn Conversation State

The SDK can carry conversation context across turns so the model sees prior
user and assistant messages without the caller re-sending full history on
every request. This document describes how that state is keyed, configured,
expired, and cleared.

> Implementation: `src/lib/conversation-state-store.ts` (store + backends),
> `src/funcs/call-model-with-state.ts` (turn-pipeline entry point).
> Integration tests: `tests/unit/multi-turn-integration.test.ts`.

## Quick start

```typescript
import { OpenRouter } from '@openrouter/sdk';
import {
ConversationStateStore,
InMemoryConversationStateBackend,
} from '@openrouter/sdk/lib/conversation-state-store.js';

const client = new OpenRouter();

// One store per application (or per tenant); reuse it for every turn.
const store = new ConversationStateStore(new InMemoryConversationStateBackend(), {
ttlMs: 30 * 60 * 1000, // optional: expire idle conversations after 30 min
});

// Turn 1
const first = client.callModelWithState({
model: 'openai/gpt-5',
input: 'My name is Ada.',
conversationId: 'user-123',
stateStore: store,
});
await first.getText();

// Turn 2 — the model sees turn 1's context and answers "Ada".
const second = client.callModelWithState({
model: 'openai/gpt-5',
input: 'What is my name?',
conversationId: 'user-123',
stateStore: store,
});
console.log(await second.getText());
```

The functional entry point is also available as
`callModelWithState(client, { ... })` from `src/funcs/call-model-with-state.ts`.

## How state is keyed

Every conversation is identified by a caller-supplied `conversationId` string:

- The id is **the only lookup key**. Each turn loads the state document under
that id, appends the new input and the response output to the stored
message history, and persists the result back under the same id before the
response is returned.
- Choose ids that are stable for the lifetime of the conversation — e.g. a
session id, `user-<id>`, or a UUID minted when the chat starts.
- **Parallel conversations are fully isolated**: two ids never share state,
even when their turns interleave or run concurrently on the same store.
- The id guard: if the turn pipeline ever produces a state document whose id
differs from the accessor's bound id (e.g. the first turn creates a
placeholder state internally), the store rebinds it to your
`conversationId` on save. State can never strand under an id you don't own.

Single-turn callers don't need any of this: `callModel` without a
`conversationId` behaves exactly as before and touches no store.

## The state document

```typescript
interface ConversationState {
id: string; // your conversationId
messages: BaseInputsUnion[]; // user inputs + assistant outputs, in turn order
status: 'in_progress' | 'awaiting_approval' | 'interrupted' | 'complete';
createdAt: number; // ms epoch
updatedAt: number; // ms epoch; drives TTL expiry
previousResponseId?: string; // last Responses API response id
// ... pending tool results and metadata
}
```

Documents are JSON-serializable and are validated on every read. A document
that fails validation raises `CorruptedStateError` (see "Missing or corrupted
state" below).

## Configuration

```typescript
new ConversationStateStore(backend, {
ttlMs?: number, // idle expiry window; omit or 0 to disable
now?: () => number, // clock override (testing)
});
```

| Option | Default | Effect |
|----------|--------------|--------|
| `ttlMs` | `0` (off) | `get` returns `null` for — and `expire()` deletes — conversations whose `updatedAt` is older than this. |
| `now` | `Date.now` | Clock used for expiry checks. Inject a fake clock in tests. |

There are **no environment variables or global config flags** for multi-turn
state. Configuration is explicit per store instance — nothing changes unless
the caller constructs a store and passes it to `callModelWithState`.

## Backends

Two zero-dependency backends ship with the SDK:

- **`InMemoryConversationStateBackend`** — process-local `Map`. Right for
tests and ephemeral single-process use. Documents are deep-copied on
read/write so callers can't mutate stored state by aliasing. Implements
`list()`, so store-wide `expire()` works.
- **`FileConversationStateBackend`** — JSON-file-per-conversation under a
directory (Node.js only). Ids are sanitized to prevent path traversal.
Useful for local durable execution without Redis.

Any other backend (Redis, Postgres, …) can be implemented against the
`ConversationStateBackend` interface:

```typescript
interface ConversationStateBackend {
load(id: string): Promise<ConversationState | null>;
save(id: string, state: ConversationState): Promise<void>;
delete(id: string): Promise<void>;
list?(): Promise<string[]>; // enables store-wide expire()
}
```

## Expiry and cleanup

- **TTL expiry (read-through):** with `ttlMs` set, `store.get(id)` returns
`null` for idle-expired documents. When a turn then arrives for that id the
pipeline starts a **fresh conversation under the same id** — the model does
not see the expired history.
- **Store-wide sweep:** `store.expire()` removes every stale conversation
(requires the backend to implement `list()`) and returns the removed ids.
`store.expire(['id1', 'id2'])` sweeps only the given ids. Run this on a
timer if you want storage reclaimed rather than just hidden.
- **Explicit deletion:** `store.clear(id)` deletes one conversation
immediately, regardless of TTL. The next turn for that id starts fresh.
Clearing one conversation never affects siblings.

## Missing or corrupted state

| Situation | Behavior |
|-----------|----------|
| No state for `conversationId` (first turn, after `clear`, after TTL expiry) | Fresh state document created under the same id; turn proceeds normally. |
| Stored document is invalid JSON or fails schema validation | `CorruptedStateError` is thrown on the next read — the SDK fails loudly instead of silently continuing with a mangled history. |
| Backend write fails | The error propagates from the store; no partial in-memory fallback. |

To recover a corrupted conversation, call `store.clear(id)` and let the next
turn recreate it (or restore the document from a backup and re-`put` it).

## Testing recipes

The integration tests in `tests/unit/multi-turn-integration.test.ts` show the
patterns, all without a live API key:

- **3+ turn context:** queue mocked `betaResponsesSend` responses and assert
that turn N's API request input contains every earlier turn's user input and
assistant output, in order.
- **Parallel isolation:** interleave turns across two `conversationId`s on one
store and assert neither request input mentions the other conversation.
- **Expiry:** construct the store with a short `ttlMs` (or a fake `now`
clock), sleep past the TTL, and assert the next turn starts fresh.
- **Corruption:** poison the backend (or the JSON file for
`FileConversationStateBackend`) and assert `CorruptedStateError`.
115 changes: 115 additions & 0 deletions src/funcs/call-model-with-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { $ZodObject, $ZodShape, infer as zodInfer } from 'zod/v4/core';
import type { OpenRouterCore } from '../core.js';
import type { CallModelInput } from '../lib/async-params.js';
import type {
ConversationStateStore,
} from '../lib/conversation-state-store.js';
import type { RequestOptions } from '../lib/sdks.js';
import type { Tool } from '../lib/tool-types.js';

import { callModel } from './call-model.js';
import {
createStateAccessor,
} from '../lib/conversation-state-store.js';
import type { ModelResult } from '../lib/model-result.js';

/**
* Input for {@link callModelWithState}: everything `callModel` accepts, plus
* the conversation identity and the store that owns its state document.
*
* The `state` field of the base input is intentionally omitted — the accessor
* is derived from `conversationId` + `stateStore`, and supplying both would be
* contradictory.
*/
export type CallModelWithStateInput<
TTools extends readonly Tool[] = readonly Tool[],
TShared extends Record<string, unknown> = Record<string, never>,
> = Omit<CallModelInput<TTools, TShared>, 'state'> & {
/**
* Conversation/session id the turn belongs to. On the first turn (or after
* TTL expiry) the store creates a fresh state document under this id; on
* continuation turns the prior message history is loaded from it and the
* updated state — new input, response output, and tool-call outputs — is
* persisted back under the same id before the response is returned.
*/
conversationId: string;
/**
* The store that owns this conversation's state. Callers choose the backend
* (in-memory, file, Redis, …) once and pass it for every turn.
*/
stateStore: ConversationStateStore<TTools>;
};

/**
* Multi-turn variant of `callModel` wired into the conversation state store
* (DEV-127 / docs/multi-turn-state.md).
*
* Each incoming turn:
* 1. loads the prior state by `conversationId` (missing or TTL-expired state
* falls back to a freshly created state under the same id),
* 2. appends the new turn — caller input is appended to the stored message
* history, response output and tool-call outputs are appended as they
* complete, and
* 3. persists the updated state before the response is consumed.
*
* Callers that do not supply a conversation id should use `callModel`
* directly — single-turn behavior there is unchanged.
*
* @example
* ```typescript
* const store = new ConversationStateStore(new InMemoryConversationStateBackend());
* const first = client.callModelWithState({
* model: 'gpt-4',
* input: 'My name is Ada.',
* conversationId: 'user-123',
* stateStore: store,
* });
* await first.getText();
*
* const second = client.callModelWithState({
* model: 'gpt-4',
* input: 'What is my name?',
* conversationId: 'user-123',
* stateStore: store,
* });
* // The model sees the first turn's context and answers "Ada".
* ```
*/
export function callModelWithState<
TTools extends readonly Tool[],
TSharedSchema extends $ZodObject<$ZodShape> | undefined = undefined,
TShared extends Record<string, unknown> = TSharedSchema extends $ZodObject<$ZodShape> ? zodInfer<TSharedSchema> : Record<string, never>,
>(
client: OpenRouterCore,
request: CallModelWithStateInput<TTools, TShared> & { sharedContextSchema?: TSharedSchema },
options?: RequestOptions,
): ModelResult<TTools, TShared> {
const { conversationId, stateStore, ...rest } = request;

if (typeof conversationId !== 'string' || conversationId.length === 0) {
throw new TypeError(
'callModelWithState requires a non-empty "conversationId" string. ' +
'Use callModel for single-turn requests without conversation state.',
);
}
if (!stateStore) {
throw new TypeError(
'callModelWithState requires a "stateStore" (ConversationStateStore) ' +
'that owns the conversation\'s state document.',
);
}

const state = createStateAccessor<TTools>(stateStore, conversationId);

// The rest of the pipeline (ModelResult) already implements load/create/
// resume, message-history merging, and per-turn persistence against the
// StateAccessor contract — the integration is supplying that accessor.
return callModel<TTools, TSharedSchema, TShared>(
client,
{
...rest,
state,
} as CallModelInput<TTools, TShared> & { sharedContextSchema?: TSharedSchema },
options,
);
}
Loading