Skip to content

Commit 4b78a19

Browse files
committed
feat: add async function support for CallModelInput parameters
Adds support for making any CallModelInput field a dynamic async function that computes values based on conversation context (TurnContext). Key features: - All API parameter fields can be functions (excluding tools/maxToolRounds) - Functions receive TurnContext with numberOfTurns, messageHistory, model, models - Resolved before EVERY turn (initial request + each tool execution round) - Execution order: Async functions → Tool execution → nextTurnParams → API - Fully type-safe with TypeScript support - Backward compatible (accepts both static values and functions) Changes: - Created src/lib/async-params.ts with type definitions and resolution logic - Updated callModel() to accept AsyncCallModelInput type - Added async resolution in ModelResult.initStream() and multi-turn loop - Exported new types and helper functions - Added comprehensive JSDoc documentation with examples Example usage: ```typescript const result = callModel(client, { temperature: (ctx) => Math.min(ctx.numberOfTurns * 0.2, 1.0), model: (ctx) => ctx.numberOfTurns > 3 ? 'gpt-4' : 'gpt-3.5-turbo', input: [{ type: 'text', text: 'Hello' }], }); ```
1 parent 99941fc commit 4b78a19

File tree

4 files changed

+353
-181
lines changed

4 files changed

+353
-181
lines changed

src/funcs/call-model.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { OpenRouterCore } from '../core.js';
2+
import type { AsyncCallModelInput } from '../lib/async-params.js';
23
import type { RequestOptions } from '../lib/sdks.js';
34
import type { MaxToolRounds, Tool } from '../lib/tool-types.js';
45
import type * as models from '../models/index.js';
@@ -14,6 +15,9 @@ export type CallModelInput = Omit<models.OpenResponsesRequest, 'stream' | 'tools
1415
maxToolRounds?: MaxToolRounds;
1516
};
1617

18+
// Re-export AsyncCallModelInput for convenience
19+
export type { AsyncCallModelInput } from '../lib/async-params.js';
20+
1721
/**
1822
* Get a response with multiple consumption patterns
1923
*
@@ -36,10 +40,61 @@ export type CallModelInput = Omit<models.OpenResponsesRequest, 'stream' | 'tools
3640
* For message format conversion, use the helper functions:
3741
* - `fromChatMessages()` / `toChatMessage()` for OpenAI chat format
3842
* - `fromClaudeMessages()` / `toClaudeMessage()` for Anthropic Claude format
43+
*
44+
* **Async Function Support:**
45+
*
46+
* Any field in CallModelInput can be a function that computes the value dynamically
47+
* based on the conversation context. Functions are resolved before EVERY turn, allowing
48+
* parameters to adapt as the conversation progresses.
49+
*
50+
* @example
51+
* ```typescript
52+
* // Increase temperature over turns
53+
* const result = callModel(client, {
54+
* model: 'gpt-4',
55+
* temperature: (ctx) => Math.min(ctx.numberOfTurns * 0.2, 1.0),
56+
* input: [{ type: 'text', text: 'Hello' }],
57+
* });
58+
* ```
59+
*
60+
* @example
61+
* ```typescript
62+
* // Switch models based on conversation length
63+
* const result = callModel(client, {
64+
* model: (ctx) => ctx.numberOfTurns > 3 ? 'gpt-4' : 'gpt-3.5-turbo',
65+
* input: [{ type: 'text', text: 'Complex question' }],
66+
* });
67+
* ```
68+
*
69+
* @example
70+
* ```typescript
71+
* // Use async functions to fetch dynamic values
72+
* const result = callModel(client, {
73+
* model: 'gpt-4',
74+
* instructions: async (ctx) => {
75+
* const userPrefs = await fetchUserPreferences();
76+
* return `You are a helpful assistant. User preferences: ${userPrefs}`;
77+
* },
78+
* input: [{ type: 'text', text: 'Help me' }],
79+
* });
80+
* ```
81+
*
82+
* Async functions receive `TurnContext` with:
83+
* - `numberOfTurns`: Current turn number (0-indexed, 0 = initial request)
84+
* - `messageHistory`: Current conversation messages
85+
* - `model`: Current model selection (if set)
86+
* - `models`: Current models array (if set)
87+
*
88+
* **Execution Order:**
89+
* Functions are resolved at the START of each turn in this order:
90+
* 1. Async functions (parallel resolution)
91+
* 2. Tool execution (if tools called by model)
92+
* 3. nextTurnParams functions (if defined on tools)
93+
* 4. API request with resolved values
3994
*/
4095
export function callModel(
4196
client: OpenRouterCore,
42-
request: CallModelInput,
97+
request: CallModelInput | AsyncCallModelInput,
4398
options?: RequestOptions,
4499
): ModelResult {
45100
const { tools, maxToolRounds, ...apiRequest } = request;
@@ -48,12 +103,13 @@ export function callModel(
48103
const apiTools = tools ? convertToolsToAPIFormat(tools) : undefined;
49104

50105
// Build the request with converted tools
51-
const finalRequest: models.OpenResponsesRequest = {
106+
// Note: async functions are resolved later in ModelResult.executeToolsIfNeeded()
107+
const finalRequest: models.OpenResponsesRequest | AsyncCallModelInput = {
52108
...apiRequest,
53109
...(apiTools !== undefined && {
54110
tools: apiTools,
55111
}),
56-
};
112+
} as any;
57113

58114
return new ModelResult({
59115
client,

src/index.ts

Lines changed: 76 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -2,89 +2,96 @@
22
* Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
33
*/
44

5-
export * from "./lib/config.js";
6-
export * as files from "./lib/files.js";
7-
export { HTTPClient } from "./lib/http.js";
8-
export type { Fetcher, HTTPClientOptions } from "./lib/http.js";
9-
export * from "./sdk/sdk.js";
10-
11-
// Message format compatibility helpers
12-
export { fromClaudeMessages, toClaudeMessage } from "./lib/anthropic-compat.js";
13-
export { fromChatMessages, toChatMessage } from "./lib/chat-compat.js";
14-
export { extractUnsupportedContent, hasUnsupportedContent, getUnsupportedContentSummary } from "./lib/stream-transformers.js";
15-
5+
// Async params support
6+
export type {
7+
AsyncCallModelInput,
8+
FieldOrAsyncFunction,
9+
ResolvedAsyncCallModelInput,
10+
} from './lib/async-params.js';
11+
export type { Fetcher, HTTPClientOptions } from './lib/http.js';
12+
// Tool types
13+
export type {
14+
ChatStreamEvent,
15+
EnhancedResponseStreamEvent,
16+
InferToolEvent,
17+
InferToolEventsUnion,
18+
InferToolInput,
19+
InferToolOutput,
20+
ManualTool,
21+
NextTurnParamsContext,
22+
NextTurnParamsFunctions,
23+
Tool,
24+
ToolCallInfo,
25+
ToolPreliminaryResultEvent,
26+
ToolStreamEvent,
27+
ToolWithExecute,
28+
ToolWithGenerator,
29+
TurnContext,
30+
TypedToolCall,
31+
TypedToolCallUnion,
32+
} from './lib/tool-types.js';
33+
export type { BuildTurnContextOptions } from './lib/turn-context.js';
1634
// Claude message types
1735
export type {
18-
ClaudeMessage,
19-
ClaudeMessageParam,
36+
ClaudeBase64ImageSource,
37+
ClaudeCacheControl,
38+
ClaudeCitationCharLocation,
39+
ClaudeCitationContentBlockLocation,
40+
ClaudeCitationPageLocation,
41+
ClaudeCitationSearchResultLocation,
42+
ClaudeCitationWebSearchResultLocation,
2043
ClaudeContentBlock,
2144
ClaudeContentBlockParam,
22-
ClaudeTextBlock,
23-
ClaudeThinkingBlock,
45+
ClaudeImageBlockParam,
46+
ClaudeMessage,
47+
ClaudeMessageParam,
2448
ClaudeRedactedThinkingBlock,
25-
ClaudeToolUseBlock,
2649
ClaudeServerToolUseBlock,
27-
ClaudeTextBlockParam,
28-
ClaudeImageBlockParam,
29-
ClaudeToolUseBlockParam,
30-
ClaudeToolResultBlockParam,
3150
ClaudeStopReason,
32-
ClaudeUsage,
33-
ClaudeCacheControl,
51+
ClaudeTextBlock,
52+
ClaudeTextBlockParam,
3453
ClaudeTextCitation,
35-
ClaudeCitationCharLocation,
36-
ClaudeCitationPageLocation,
37-
ClaudeCitationContentBlockLocation,
38-
ClaudeCitationWebSearchResultLocation,
39-
ClaudeCitationSearchResultLocation,
40-
ClaudeBase64ImageSource,
54+
ClaudeThinkingBlock,
55+
ClaudeToolResultBlockParam,
56+
ClaudeToolUseBlock,
57+
ClaudeToolUseBlockParam,
4158
ClaudeURLImageSource,
42-
} from "./models/claude-message.js";
59+
ClaudeUsage,
60+
} from './models/claude-message.js';
4361

62+
// Message format compatibility helpers
63+
export { fromClaudeMessages, toClaudeMessage } from './lib/anthropic-compat.js';
64+
export {
65+
hasAsyncFunctions,
66+
resolveAsyncFunctions,
67+
} from './lib/async-params.js';
68+
export { fromChatMessages, toChatMessage } from './lib/chat-compat.js';
69+
// Claude constants and type guards
70+
export { ClaudeContentBlockType, NonClaudeMessageRole } from './lib/claude-constants.js';
71+
export { isClaudeStyleMessages } from './lib/claude-type-guards.js';
72+
export * from './lib/config.js';
73+
export * as files from './lib/files.js';
74+
export { HTTPClient } from './lib/http.js';
75+
// Next turn params helpers
76+
export {
77+
applyNextTurnParamsToRequest,
78+
buildNextTurnParamsContext,
79+
executeNextTurnParamsFunctions,
80+
} from './lib/next-turn-params.js';
81+
export {
82+
extractUnsupportedContent,
83+
getUnsupportedContentSummary,
84+
hasUnsupportedContent,
85+
} from './lib/stream-transformers.js';
4486
// Tool creation helpers
45-
export { tool } from "./lib/tool.js";
46-
47-
// Tool types
48-
export type {
49-
Tool,
50-
ToolWithExecute,
51-
ToolWithGenerator,
52-
ManualTool,
53-
TurnContext,
54-
InferToolInput,
55-
InferToolOutput,
56-
InferToolEvent,
57-
InferToolEventsUnion,
58-
TypedToolCall,
59-
TypedToolCallUnion,
60-
ToolStreamEvent,
61-
ChatStreamEvent,
62-
EnhancedResponseStreamEvent,
63-
ToolPreliminaryResultEvent,
64-
NextTurnParamsContext,
65-
NextTurnParamsFunctions,
66-
ToolCallInfo,
67-
} from "./lib/tool-types.js";
68-
87+
export { tool } from './lib/tool.js';
6988
export {
70-
ToolType,
7189
hasExecuteFunction,
7290
isGeneratorTool,
7391
isRegularExecuteTool,
7492
isToolPreliminaryResultEvent,
75-
} from "./lib/tool-types.js";
76-
93+
ToolType,
94+
} from './lib/tool-types.js';
7795
// Turn context helpers
78-
export { buildTurnContext, normalizeInputToArray } from "./lib/turn-context.js";
79-
export type { BuildTurnContextOptions } from "./lib/turn-context.js";
80-
81-
// Next turn params helpers
82-
export {
83-
buildNextTurnParamsContext,
84-
executeNextTurnParamsFunctions,
85-
applyNextTurnParamsToRequest,
86-
} from "./lib/next-turn-params.js";
87-
88-
// Claude constants and type guards
89-
export { ClaudeContentBlockType, NonClaudeMessageRole } from "./lib/claude-constants.js";
90-
export { isClaudeStyleMessages } from "./lib/claude-type-guards.js";
96+
export { buildTurnContext, normalizeInputToArray } from './lib/turn-context.js';
97+
export * from './sdk/sdk.js';

src/lib/async-params.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { CallModelInput } from '../funcs/call-model.js';
2+
import type { TurnContext } from './tool-types.js';
3+
4+
/**
5+
* A field can be either a value of type T or a function that computes T
6+
*/
7+
export type FieldOrAsyncFunction<T> = T | ((context: TurnContext) => T | Promise<T>);
8+
9+
/**
10+
* CallModelInput with async function support for API parameter fields
11+
* Excludes tools and maxToolRounds which should not be dynamic
12+
*/
13+
export type AsyncCallModelInput = {
14+
[K in keyof Omit<CallModelInput, 'tools' | 'maxToolRounds'>]: FieldOrAsyncFunction<
15+
CallModelInput[K]
16+
>;
17+
} & {
18+
tools?: CallModelInput['tools'];
19+
maxToolRounds?: CallModelInput['maxToolRounds'];
20+
};
21+
22+
/**
23+
* Resolved AsyncCallModelInput (all functions evaluated to values)
24+
* This strips out the function types, leaving only the resolved value types
25+
*/
26+
export type ResolvedAsyncCallModelInput = Omit<CallModelInput, 'tools' | 'maxToolRounds'> & {
27+
tools?: never;
28+
maxToolRounds?: never;
29+
};
30+
31+
/**
32+
* Resolve all async functions in CallModelInput to their values
33+
*
34+
* @param input - Input with possible functions
35+
* @param context - Turn context for function execution
36+
* @returns Resolved input with all values (no functions)
37+
*
38+
* @example
39+
* ```typescript
40+
* const resolved = await resolveAsyncFunctions(
41+
* {
42+
* model: 'gpt-4',
43+
* temperature: (ctx) => ctx.numberOfTurns * 0.1,
44+
* input: 'Hello',
45+
* },
46+
* { numberOfTurns: 2, messageHistory: [] }
47+
* );
48+
* // resolved.temperature === 0.2
49+
* ```
50+
*/
51+
export async function resolveAsyncFunctions(
52+
input: AsyncCallModelInput,
53+
context: TurnContext,
54+
): Promise<ResolvedAsyncCallModelInput> {
55+
const resolved: Record<string, unknown> = {};
56+
57+
// Iterate over all keys in the input
58+
for (const [key, value] of Object.entries(input)) {
59+
if (typeof value === 'function') {
60+
try {
61+
// Execute the function with context
62+
resolved[key] = await Promise.resolve(value(context));
63+
} catch (error) {
64+
// Wrap errors with context about which field failed
65+
throw new Error(
66+
`Failed to resolve async function for field "${key}": ${
67+
error instanceof Error ? error.message : String(error)
68+
}`,
69+
);
70+
}
71+
} else {
72+
// Not a function, use as-is
73+
resolved[key] = value;
74+
}
75+
}
76+
77+
return resolved as ResolvedAsyncCallModelInput;
78+
}
79+
80+
/**
81+
* Check if input has any async functions that need resolution
82+
*
83+
* @param input - Input to check
84+
* @returns True if any field is a function
85+
*/
86+
export function hasAsyncFunctions(input: any): boolean {
87+
if (!input || typeof input !== 'object') {
88+
return false;
89+
}
90+
return Object.values(input).some((value) => typeof value === 'function');
91+
}

0 commit comments

Comments
 (0)