Skip to content

Commit 7c72251

Browse files
authored
feat: add async function support and nextTurnParams for dynamic parameter control. Also dynamic run stoppage (#113)
2 parents 11145ec + 880135c commit 7c72251

27 files changed

+2169
-828
lines changed

.zed/settings.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"tab_size": 2,
3+
"project_name": "@openrouter/sdk",
4+
"formatter": {
5+
"language_server": {
6+
"name": "eslint"
7+
}
8+
},
9+
"language_servers": ["!biome", "..."]
10+
}

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
CLAUDE.md

CLAUDE.md

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Overview
6+
7+
This is the OpenRouter TypeScript SDK - a type-safe toolkit for building AI applications with access to 300+ language models. The SDK is **generated using Speakeasy** from an OpenAPI specification, with custom hand-written features for tool orchestration, async parameter resolution, and streaming.
8+
9+
**IMPORTANT**: Most code in this repository is auto-generated by Speakeasy. Do not manually edit generated files - changes will be overwritten. See the "Code Generation" section below for how to make changes.
10+
11+
## Common Commands
12+
13+
### Building
14+
```bash
15+
pnpm run build
16+
```
17+
Compiles TypeScript to `esm/` directory using `tsc`.
18+
19+
### Linting
20+
```bash
21+
pnpm run lint
22+
```
23+
**Note**: This project uses **ESLint** (not Biome). Configuration is in `eslint.config.mjs`.
24+
25+
### Testing
26+
```bash
27+
# Run all tests
28+
npx vitest
29+
30+
# Run specific test file
31+
npx vitest tests/e2e/call-model.test.ts
32+
33+
# Run tests in watch mode
34+
npx vitest --watch
35+
```
36+
37+
Tests require an OpenRouter API key:
38+
1. Copy `.env.example` to `.env`
39+
2. Add your API key: `OPENROUTER_API_KEY=your_key_here`
40+
41+
Test organization:
42+
- `tests/e2e/` - End-to-end integration tests
43+
- `tests/unit/` - Unit tests
44+
- `tests/funcs/` - Function-specific tests
45+
46+
### Publishing
47+
```bash
48+
pnpm run prepublishOnly
49+
```
50+
This runs the build automatically before publishing.
51+
52+
## Code Generation with Speakeasy
53+
54+
The SDK is generated from `.speakeasy/in.openapi.yaml` using [Speakeasy](https://www.speakeasy.com/docs).
55+
56+
### Generated vs Hand-Written Code
57+
58+
**Generated Files** (DO NOT EDIT - will be overwritten):
59+
- `src/models/` - Type definitions from OpenAPI schemas
60+
- `src/funcs/*Send.ts`, `src/funcs/*Get.ts`, etc. - Most API operation functions
61+
- `src/sdk/` - SDK service classes
62+
- `src/hooks/registration.ts` - Hook registration
63+
64+
**Hand-Written Files** (safe to edit):
65+
- `src/lib/` - All library utilities and helpers
66+
- `src/funcs/call-model.ts` - High-level model calling abstraction
67+
- `src/index.ts` - Main exports
68+
- `src/hooks/hooks.ts` and `src/hooks/types.ts` - Custom hooks
69+
70+
### Regenerating the SDK
71+
72+
To regenerate after updating the OpenAPI spec:
73+
```bash
74+
speakeasy run
75+
```
76+
77+
This reads configuration from `.speakeasy/gen.yaml` and workflow from `.speakeasy/workflow.yaml`.
78+
79+
### Making Changes to Generated Code
80+
81+
1. **For type/schema changes**: Update `.speakeasy/in.openapi.yaml` and regenerate
82+
2. **For overlays**: Edit files in `.speakeasy/overlays/` to apply transformations
83+
3. **For generation config**: Edit `.speakeasy/gen.yaml`
84+
4. **Always commit both** the OpenAPI spec changes AND the regenerated code
85+
86+
## Architecture
87+
88+
### Core Abstractions
89+
90+
**callModel** (`src/funcs/call-model.ts`)
91+
- High-level function for making model requests with tools
92+
- Returns a `ModelResult` wrapper with multiple consumption patterns
93+
- Supports async parameter resolution and automatic tool execution
94+
- Example consumption: `.getText()`, `.getTextStream()`, `.getToolStream()`, etc.
95+
96+
**ModelResult** (`src/lib/model-result.ts`)
97+
- Wraps streaming responses with multiple consumption patterns
98+
- Handles automatic tool execution and turn orchestration
99+
- Uses `ReusableReadableStream` to enable multiple parallel consumers
100+
101+
**Tool System** (`src/lib/tool.ts`, `src/lib/tool-types.ts`, `src/lib/tool-executor.ts`)
102+
- `tool()` helper creates type-safe tools with Zod schemas
103+
- Three tool types:
104+
- **Regular tools** (`execute: function`) - auto-executed, return final result
105+
- **Generator tools** (`execute: async generator`) - stream preliminary results
106+
- **Manual tools** (`execute: false`) - return tool calls without execution
107+
- Tool orchestrator (`src/lib/tool-orchestrator.ts`) manages multi-turn conversations
108+
109+
**Async Parameter Resolution** (`src/lib/async-params.ts`)
110+
- Any parameter in `CallModelInput` can be a function: `(ctx: TurnContext) => value`
111+
- Functions resolved before each turn, allowing dynamic parameter adjustment
112+
- Supports both sync and async functions
113+
- Example: `model: (ctx) => ctx.numberOfTurns > 3 ? 'gpt-4' : 'gpt-3.5-turbo'`
114+
115+
**Next Turn Params** (`src/lib/next-turn-params.ts`)
116+
- Tools can define `nextTurnParams` to modify request parameters after execution
117+
- Functions receive tool input and can return parameter updates
118+
- Applied after tool execution, before next API request
119+
- Example: Increase temperature after seeing tool results
120+
121+
**Stop Conditions** (`src/lib/stop-conditions.ts`)
122+
- Control when tool execution loops terminate
123+
- Built-in helpers: `stepCountIs()`, `hasToolCall()`, `maxTokensUsed()`, `maxCost()`, `finishReasonIs()`
124+
- Custom conditions receive full step history
125+
- Default: `stepCountIs(5)` if not specified
126+
127+
## Message Format Compatibility
128+
129+
The SDK supports multiple message formats:
130+
131+
- **OpenRouter format** (native)
132+
- **Claude format** via `fromClaudeMessages()` / `toClaudeMessage()` (`src/lib/anthropic-compat.ts`)
133+
- **OpenAI Chat format** via `fromChatMessages()` / `toChatMessage()` (`src/lib/chat-compat.ts`)
134+
135+
These converters handle content types, tool calls, and format-specific features.
136+
137+
## Streaming Architecture
138+
139+
**ReusableReadableStream** (`src/lib/reusable-stream.ts`)
140+
141+
- Caches stream events to enable multiple independent consumers
142+
- Critical for allowing parallel consumption patterns (text + tools + reasoning)
143+
- Handles both SSE and standard ReadableStream
144+
145+
**Stream Transformers** (`src/lib/stream-transformers.ts`)
146+
147+
- Extract specific data from response streams
148+
- `extractTextDeltas()`, `extractReasoningDeltas()`, `extractToolDeltas()`
149+
- Build higher-level streams for different consumption patterns
150+
- Handle both streaming and non-streaming responses uniformly
151+
152+
## Development Workflow
153+
154+
### When Adding New Features
155+
156+
1. **If it's an API change**: Update `.speakeasy/in.openapi.yaml` in the monorepo (see `/Users/mattapperson/Development/CLAUDE.md` for monorepo workflow)
157+
2. **If it's SDK functionality**: Add to `src/lib/` or extend existing hand-written files
158+
3. **Add tests** to appropriate directory (`tests/e2e/`, `tests/unit/`)
159+
4. **Update examples** if user-facing (in `examples/`)
160+
161+
### When Fixing Bugs
162+
163+
1. **In generated code**: Fix the OpenAPI spec or Speakeasy generation config, then regenerate
164+
2. **In hand-written code**: Fix directly in `src/lib/` or other hand-written files
165+
3. **Add regression test** to prevent reoccurrence
166+
167+
### Running Examples
168+
169+
```bash
170+
cd examples
171+
# Set your API key in .env first
172+
node --loader ts-node/esm call-model.example.ts
173+
```
174+
175+
Examples demonstrate:
176+
- `call-model.example.ts` - Basic usage
177+
- `call-model-typed-tool-calling.example.ts` - Type-safe tools
178+
- `anthropic-multimodal-tools.example.ts` - Multimodal inputs with tools
179+
- `anthropic-reasoning.example.ts` - Extended thinking/reasoning
180+
- `chat-reasoning.example.ts` - Reasoning with chat format
181+
- `tools-example.ts` - Comprehensive tool usage
182+
183+
## TypeScript Configuration
184+
185+
- **Target**: ES2020, module: Node16
186+
- **Strict mode**: Enabled with strictest settings from tsconfig/bases
187+
- **Output**: `esm/` directory with declaration files
188+
- **Module format**: ESM only (no CommonJS)
189+
190+
Key compiler options:
191+
- `exactOptionalPropertyTypes: true` - Strict optional handling
192+
- `noUncheckedIndexedAccess: true` - Array access safety
193+
- `isolatedModules: true` - Required for module transforms
194+
195+
## Testing Strategy
196+
197+
Tests use Vitest with:
198+
- 30s timeout for API calls
199+
- Environment variables from `.env`
200+
- Type checking enabled for test files
201+
202+
E2E tests (`tests/e2e/`) make real API calls and test:
203+
- Basic chat completions
204+
- Tool execution flows
205+
- Streaming responses
206+
- Multi-turn conversations
207+
- Different message formats
208+
209+
## Package Structure
210+
211+
This is an ES Module (ESM) package with multiple exports:
212+
- `@openrouter/sdk` - Main SDK
213+
- `@openrouter/sdk/types` - Type definitions
214+
- `@openrouter/sdk/models` - Model types
215+
- `@openrouter/sdk/models/operations` - Operation types
216+
- `@openrouter/sdk/models/errors` - Error types
217+
218+
The package uses conditional exports in `package.json` to map source files to build outputs.

examples/callModel-typed-tool-calling.example.ts renamed to examples/call-model-typed-tool-calling.example.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ async function main() {
9898
model: "openai/gpt-4o-mini",
9999
input: "What's the weather like in Paris?",
100100
tools: [weatherTool] as const,
101-
maxToolRounds: 0, // Don't auto-execute, just get the tool calls
101+
stopWhen: ({ steps }) => steps.length >= 0, // Stop immediately - don't auto-execute, just get the tool calls
102102
});
103103

104104
// Tool calls are now typed based on the tool definitions!
@@ -117,7 +117,7 @@ async function main() {
117117
model: "openai/gpt-4o-mini",
118118
input: "What's the weather in Tokyo?",
119119
tools: [weatherTool] as const,
120-
maxToolRounds: 0,
120+
stopWhen: ({ steps }) => steps.length >= 0, // Stop immediately
121121
});
122122

123123
// Stream tool calls with typed arguments

examples/tools-example.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,20 @@
66
* 1. Validated using Zod schemas
77
* 2. Executed when the model calls them
88
* 3. Results sent back to the model
9-
* 4. Process repeats until no more tool calls (up to maxToolRounds)
9+
* 4. Process repeats until stopWhen condition is met (default: stepCountIs(5))
1010
*
1111
* The API is simple: just call callModel() with tools, and await the result.
1212
* Tools are executed transparently before getMessage() or getText() returns!
1313
*
14-
* maxToolRounds can be:
15-
* - A number: Maximum number of tool execution rounds (default: 5)
16-
* - A function: (context: TurnContext) => boolean
17-
* - Return true to allow another turn
18-
* - Return false to stop execution
19-
* - Context includes: numberOfTurns, messageHistory, model/models
14+
* stopWhen can be:
15+
* - A single condition: stepCountIs(3), hasToolCall('finalize'), maxCost(0.50)
16+
* - An array of conditions: [stepCountIs(10), maxCost(1.00)] (OR logic - stops if ANY is true)
17+
* - A custom function: ({ steps }) => steps.length >= 5 || steps.some(s => s.finishReason === 'length')
2018
*/
2119

2220
import * as dotenv from 'dotenv';
2321
import { z } from 'zod/v4';
24-
import { OpenRouter, ToolType } from '../src/index.js';
22+
import { OpenRouter, ToolType, stepCountIs } from '../src/index.js';
2523

2624
// Type declaration for ShadowRealm (TC39 Stage 3 proposal)
2725
// See: https://tc39.es/proposal-shadowrealm/
@@ -78,10 +76,8 @@ async function basicToolExample() {
7876
tools: [
7977
weatherTool,
8078
],
81-
// Example: limit to 3 turns using a function
82-
maxToolRounds: (context) => {
83-
return context.numberOfTurns < 3; // Allow up to 3 turns
84-
},
79+
// Example: limit to 3 steps
80+
stopWhen: stepCountIs(3),
8581
});
8682

8783
// Tools are automatically executed! Just get the final message

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,13 @@
6767
"scripts": {
6868
"lint": "eslint --cache --max-warnings=0 src",
6969
"build": "tsc",
70-
"prepublishOnly": "npm run build"
70+
"typecheck": "tsc --noEmit",
71+
"prepublishOnly": "npm run build",
72+
"test": "vitest --run",
73+
"test:watch": "vitest"
7174
},
7275
"peerDependencies": {
73-
76+
7477
},
7578
"devDependencies": {
7679
"@eslint/js": "^9.19.0",

0 commit comments

Comments
 (0)