Skip to content

Commit c8ef55a

Browse files
committed
add stepWhen
1 parent 457f93b commit c8ef55a

File tree

6 files changed

+243
-15
lines changed

6 files changed

+243
-15
lines changed

src/funcs/call-model.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { OpenRouterCore } from '../core.js';
22
import type { CallModelInput } from '../lib/async-params.js';
33
import type { RequestOptions } from '../lib/sdks.js';
4+
import type { Tool } from '../lib/tool-types.js';
45

56
import { ModelResult } from '../lib/model-result.js';
67
import { convertToolsToAPIFormat } from '../lib/tool-executor.js';
@@ -82,16 +83,51 @@ export type { CallModelInput } from '../lib/async-params.js';
8283
* 2. Tool execution (if tools called by model)
8384
* 3. nextTurnParams functions (if defined on tools)
8485
* 4. API request with resolved values
86+
*
87+
* **Stop Conditions:**
88+
*
89+
* Control when tool execution stops using the `stopWhen` parameter:
90+
*
91+
* @example
92+
* ```typescript
93+
* // Stop after 3 steps
94+
* stopWhen: stepCountIs(3)
95+
*
96+
* // Stop when a specific tool is called
97+
* stopWhen: hasToolCall('finalizeResults')
98+
*
99+
* // Multiple conditions (OR logic - stops if ANY is true)
100+
* stopWhen: [
101+
* stepCountIs(10), // Safety: max 10 steps
102+
* maxCost(0.50), // Budget: max $0.50
103+
* hasToolCall('finalize') // Logic: stop when finalize called
104+
* ]
105+
*
106+
* // Custom condition with full step history
107+
* stopWhen: ({ steps }) => {
108+
* const totalCalls = steps.reduce((sum, s) => sum + s.toolCalls.length, 0);
109+
* return totalCalls >= 20; // Stop after 20 total tool calls
110+
* }
111+
* ```
112+
*
113+
* Available helper functions:
114+
* - `stepCountIs(n)` - Stop after n steps
115+
* - `hasToolCall(name)` - Stop when tool is called
116+
* - `maxTokensUsed(n)` - Stop when token usage exceeds n
117+
* - `maxCost(n)` - Stop when cost exceeds n dollars
118+
* - `finishReasonIs(reason)` - Stop on specific finish reason
119+
*
120+
* Default: `stepCountIs(5)` if not specified
85121
*/
86-
export function callModel(
122+
export function callModel<TOOLS extends readonly Tool[] = readonly Tool[]>(
87123
client: OpenRouterCore,
88-
request: CallModelInput,
124+
request: CallModelInput<TOOLS>,
89125
options?: RequestOptions,
90126
): ModelResult {
91-
const { tools, maxToolRounds, ...apiRequest } = request;
127+
const { tools, stopWhen, ...apiRequest } = request;
92128

93129
// Convert tools to API format and extract enhanced tools if present
94-
const apiTools = tools ? convertToolsToAPIFormat(tools) : undefined;
130+
const apiTools = tools ? convertToolsToAPIFormat(tools as unknown as Tool[]) : undefined;
95131

96132
// Build the request with converted tools
97133
// Note: async functions are resolved later in ModelResult.executeToolsIfNeeded()
@@ -108,9 +144,9 @@ export function callModel(
108144
client,
109145
request: finalRequest,
110146
options: options ?? {},
111-
tools: tools ?? [],
112-
...(maxToolRounds !== undefined && {
113-
maxToolRounds,
147+
tools: (tools ?? []) as Tool[],
148+
...(stopWhen !== undefined && {
149+
stopWhen,
114150
}),
115151
});
116152
}

src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export type {
2020
ManualTool,
2121
NextTurnParamsContext,
2222
NextTurnParamsFunctions,
23+
StepResult,
24+
StopCondition,
25+
StopWhen,
2326
Tool,
2427
ToolCallInfo,
2528
ToolPreliminaryResultEvent,
@@ -29,6 +32,7 @@ export type {
2932
TurnContext,
3033
TypedToolCall,
3134
TypedToolCallUnion,
35+
Warning,
3236
} from './lib/tool-types.js';
3337
export type { BuildTurnContextOptions } from './lib/turn-context.js';
3438
// Claude message types
@@ -78,6 +82,15 @@ export {
7882
buildNextTurnParamsContext,
7983
executeNextTurnParamsFunctions,
8084
} from './lib/next-turn-params.js';
85+
// Stop condition helpers
86+
export {
87+
finishReasonIs,
88+
hasToolCall,
89+
isStopConditionMet,
90+
maxCost,
91+
maxTokensUsed,
92+
stepCountIs,
93+
} from './lib/stop-conditions.js';
8194
export {
8295
extractUnsupportedContent,
8396
getUnsupportedContentSummary,

src/lib/async-params.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type * as models from '../models/index.js';
2-
import type { MaxToolRounds, Tool, TurnContext } from './tool-types.js';
2+
import type { StopWhen, Tool, TurnContext } from './tool-types.js';
33

44
/**
55
* A field can be either a value of type T or a function that computes T
@@ -9,13 +9,15 @@ export type FieldOrAsyncFunction<T> = T | ((context: TurnContext) => T | Promise
99
/**
1010
* Input type for callModel function
1111
* Each field can independently be a static value or a function that computes the value
12+
* Generic over TOOLS to enable proper type inference for stopWhen conditions
1213
*/
13-
export type CallModelInput = {
14-
[K in keyof Omit<models.OpenResponsesRequest, 'stream' | 'tools'>]?:
15-
FieldOrAsyncFunction<models.OpenResponsesRequest[K]>;
14+
export type CallModelInput<TOOLS extends readonly Tool[] = readonly Tool[]> = {
15+
[K in keyof Omit<models.OpenResponsesRequest, 'stream' | 'tools'>]?: FieldOrAsyncFunction<
16+
models.OpenResponsesRequest[K]
17+
>;
1618
} & {
17-
tools?: Tool[];
18-
maxToolRounds?: MaxToolRounds;
19+
tools?: TOOLS;
20+
stopWhen?: StopWhen<TOOLS>;
1921
};
2022

2123
/**
@@ -56,10 +58,17 @@ export async function resolveAsyncFunctions(
5658

5759
// Iterate over all keys in the input
5860
for (const [key, value] of Object.entries(input)) {
61+
// Skip stopWhen and tools - they're handled separately
62+
if (key === 'stopWhen' || key === 'tools') {
63+
continue;
64+
}
65+
5966
if (typeof value === 'function') {
6067
try {
6168
// Execute the function with context and store the result
62-
const result = await Promise.resolve(value(context));
69+
// Safe to cast because we've filtered out stopWhen and tools
70+
const fn = value as (context: TurnContext) => unknown;
71+
const result = await Promise.resolve(fn(context));
6372
resolvedEntries.push([key, result]);
6473
} catch (error) {
6574
// Wrap errors with context about which field failed

src/lib/next-turn-params.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export async function executeNextTurnParamsFunctions(
4949

5050
// Collect all nextTurnParams functions from tools (in tools array order)
5151
const result: Partial<NextTurnParamsContext> = {};
52-
let workingContext = { ...context };
52+
const workingContext = { ...context };
5353

5454
for (const tool of tools) {
5555
if (!tool.function.nextTurnParams) {

src/lib/stop-conditions.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import type { StepResult, StopCondition, Tool } from './tool-types.js';
2+
3+
/**
4+
* Stop condition that checks if step count equals or exceeds a specific number
5+
* @param stepCount - The number of steps to allow before stopping
6+
* @returns StopCondition that returns true when steps.length >= stepCount
7+
*
8+
* @example
9+
* ```typescript
10+
* stopWhen: stepCountIs(5) // Stop after 5 steps
11+
* ```
12+
*/
13+
export function stepCountIs(stepCount: number): StopCondition {
14+
return ({ steps }: { readonly steps: ReadonlyArray<StepResult> }) => steps.length >= stepCount;
15+
}
16+
17+
/**
18+
* Stop condition that checks if any step contains a tool call with the given name
19+
* @param toolName - The name of the tool to check for
20+
* @returns StopCondition that returns true if the tool was called in any step
21+
*
22+
* @example
23+
* ```typescript
24+
* stopWhen: hasToolCall('search') // Stop when search tool is called
25+
* ```
26+
*/
27+
export function hasToolCall(toolName: string): StopCondition {
28+
return ({ steps }: { readonly steps: ReadonlyArray<StepResult> }) => {
29+
return steps.some((step: StepResult) =>
30+
step.toolCalls.some((call: { name: string }) => call.name === toolName),
31+
);
32+
};
33+
}
34+
35+
/**
36+
* Evaluates an array of stop conditions
37+
* Returns true if ANY condition returns true (OR logic)
38+
* @param options - Object containing stopConditions and steps
39+
* @returns Promise<boolean> indicating if execution should stop
40+
*
41+
* @example
42+
* ```typescript
43+
* const shouldStop = await isStopConditionMet({
44+
* stopConditions: [stepCountIs(5), hasToolCall('search')],
45+
* steps: allSteps
46+
* });
47+
* ```
48+
*/
49+
export async function isStopConditionMet<TOOLS extends readonly Tool[]>(options: {
50+
readonly stopConditions: ReadonlyArray<StopCondition<TOOLS>>;
51+
readonly steps: ReadonlyArray<StepResult<TOOLS>>;
52+
}): Promise<boolean> {
53+
const { stopConditions, steps } = options;
54+
55+
// Evaluate all conditions in parallel
56+
const results = await Promise.all(
57+
stopConditions.map((condition: StopCondition<TOOLS>) =>
58+
Promise.resolve(
59+
condition({
60+
steps,
61+
}),
62+
),
63+
),
64+
);
65+
66+
// Return true if ANY condition is true (OR logic)
67+
return results.some((result: boolean | undefined) => result === true);
68+
}
69+
70+
/**
71+
* Stop when total token usage exceeds a threshold
72+
* OpenRouter-specific helper using usage data
73+
*
74+
* @param maxTokens - Maximum total tokens to allow
75+
* @returns StopCondition that returns true when token usage exceeds threshold
76+
*
77+
* @example
78+
* ```typescript
79+
* stopWhen: maxTokensUsed(10000) // Stop when total tokens exceed 10,000
80+
* ```
81+
*/
82+
export function maxTokensUsed(maxTokens: number): StopCondition<any> {
83+
return ({ steps }: { readonly steps: ReadonlyArray<StepResult> }) => {
84+
const totalTokens = steps.reduce(
85+
(sum: number, step: StepResult) => sum + (step.usage?.totalTokens ?? 0),
86+
0,
87+
);
88+
return totalTokens >= maxTokens;
89+
};
90+
}
91+
92+
/**
93+
* Stop when total cost exceeds a threshold
94+
* OpenRouter-specific helper using cost data
95+
*
96+
* @param maxCostInDollars - Maximum cost in dollars to allow
97+
* @returns StopCondition that returns true when cost exceeds threshold
98+
*
99+
* @example
100+
* ```typescript
101+
* stopWhen: maxCost(0.50) // Stop when total cost exceeds $0.50
102+
* ```
103+
*/
104+
export function maxCost(maxCostInDollars: number): StopCondition {
105+
return ({ steps }: { readonly steps: ReadonlyArray<StepResult> }) => {
106+
const totalCost = steps.reduce(
107+
(sum: number, step: StepResult) => sum + (step.usage?.cost ?? 0),
108+
0,
109+
);
110+
return totalCost >= maxCostInDollars;
111+
};
112+
}
113+
114+
/**
115+
* Stop when a specific finish reason is encountered
116+
*
117+
* @param reason - The finish reason to check for
118+
* @returns StopCondition that returns true when finish reason matches
119+
*
120+
* @example
121+
* ```typescript
122+
* stopWhen: finishReasonIs('length') // Stop when context length limit is hit
123+
* ```
124+
*/
125+
export function finishReasonIs(reason: string): StopCondition {
126+
return ({ steps }: { readonly steps: ReadonlyArray<StepResult> }) => {
127+
return steps.some((step: StepResult) => step.finishReason === reason);
128+
};
129+
}

src/lib/tool-types.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,47 @@ export interface ToolExecutionResult {
279279
*/
280280
export type MaxToolRounds = number | ((context: TurnContext) => boolean); // Return true to allow another turn, false to stop
281281

282+
/**
283+
* Warning from step execution
284+
*/
285+
export interface Warning {
286+
type: string;
287+
message: string;
288+
}
289+
290+
/**
291+
* Result of a single step in the tool execution loop
292+
* Compatible with Vercel AI SDK pattern
293+
*/
294+
export interface StepResult<_TOOLS extends readonly Tool[] = readonly Tool[]> {
295+
readonly stepType: 'initial' | 'continue';
296+
readonly text: string;
297+
readonly toolCalls: ParsedToolCall[];
298+
readonly toolResults: ToolExecutionResult[];
299+
readonly response: models.OpenResponsesNonStreamingResponse;
300+
readonly usage?: models.OpenResponsesUsage | undefined;
301+
readonly finishReason?: string | undefined;
302+
readonly warnings?: Warning[] | undefined;
303+
readonly experimental_providerMetadata?: Record<string, unknown> | undefined;
304+
}
305+
306+
/**
307+
* A condition function that determines whether to stop tool execution
308+
* Returns true to STOP execution, false to CONTINUE
309+
* (Matches Vercel AI SDK semantics)
310+
*/
311+
export type StopCondition<TOOLS extends readonly Tool[] = readonly Tool[]> = (options: {
312+
readonly steps: ReadonlyArray<StepResult<TOOLS>>;
313+
}) => boolean | Promise<boolean>;
314+
315+
/**
316+
* Stop condition configuration
317+
* Can be a single condition or array of conditions
318+
*/
319+
export type StopWhen<TOOLS extends readonly Tool[] = readonly Tool[]> =
320+
| StopCondition<TOOLS>
321+
| ReadonlyArray<StopCondition<TOOLS>>;
322+
282323
/**
283324
* Result of executeTools operation
284325
*/

0 commit comments

Comments
 (0)