Skip to content

Commit af4f852

Browse files
committed
type cleanup and cruft removal
1 parent 6369ea3 commit af4f852

13 files changed

+112
-123
lines changed

examples/callModel-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

src/funcs/call-model.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ export function callModel<TOOLS extends readonly Tool[] = readonly Tool[]>(
144144
client,
145145
request: finalRequest,
146146
options: options ?? {},
147+
// Cast to Tool[] because ModelResult expects mutable array internally
148+
// The readonly constraint is maintained at the callModel interface level
147149
tools: (tools ?? []) as Tool[],
148150
...(stopWhen !== undefined && {
149151
stopWhen,

src/lib/async-params.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ export type CallModelInput<TOOLS extends readonly Tool[] = readonly Tool[]> = {
3737
*/
3838
export type ResolvedCallModelInput = Omit<models.OpenResponsesRequest, 'stream' | 'tools'> & {
3939
tools?: never;
40-
maxToolRounds?: never;
4140
};
4241

4342
/**
@@ -77,8 +76,9 @@ export async function resolveAsyncFunctions(
7776
if (typeof value === 'function') {
7877
try {
7978
// Execute the function with context and store the result
80-
// Type guard ensures value is a function
81-
const fn = value as (context: TurnContext) => unknown;
79+
// We've already filtered out stopWhen at line 73, so this is a parameter function
80+
// that accepts TurnContext (not a StopCondition which needs steps)
81+
const fn = value as (context: TurnContext) => unknown | Promise<unknown>;
8282
const result = await Promise.resolve(fn(context));
8383
resolvedEntries.push([key, result] as const);
8484
} catch (error) {
@@ -95,8 +95,9 @@ export async function resolveAsyncFunctions(
9595
}
9696

9797
// Use type-safe fromEntries - the result type is inferred from the entries
98-
// We still need the final cast to ResolvedCallModelInput because TypeScript can't prove
99-
// that the dynamic keys match the static type, but this is safer than before
98+
// TypeScript can't prove that dynamic keys match the static type at compile time,
99+
// but we know all keys come from the input object (minus stopWhen/tools)
100+
// and all values are properly resolved through the function above
100101
return typeSafeObjectFromEntries(resolvedEntries) as ResolvedCallModelInput;
101102
}
102103

src/lib/model-result.ts

Lines changed: 6 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import type { RequestOptions } from './sdks.js';
66
import type {
77
ChatStreamEvent,
88
EnhancedResponseStreamEvent,
9-
MaxToolRounds,
109
ParsedToolCall,
1110
Tool,
1211
ToolStreamEvent,
@@ -90,7 +89,6 @@ export interface GetResponseOptions {
9089
client: OpenRouterCore;
9190
options?: RequestOptions;
9291
tools?: Tool[];
93-
maxToolRounds?: MaxToolRounds;
9492
}
9593

9694
/**
@@ -159,7 +157,7 @@ export class ModelResult {
159157
// Build initial turn context (turn 0 for initial request)
160158
const initialContext: TurnContext = {
161159
numberOfTurns: 0,
162-
messageHistory: [],
160+
input: [],
163161
model: undefined,
164162
models: undefined,
165163
};
@@ -248,9 +246,6 @@ export class ModelResult {
248246
return;
249247
}
250248

251-
// Get maxToolRounds configuration
252-
const maxToolRounds = this.options.maxToolRounds ?? 5;
253-
254249
let currentResponse = initialResponse;
255250
let currentRound = 0;
256251
let currentInput: models.OpenResponsesInput =
@@ -272,30 +267,6 @@ export class ModelResult {
272267
break;
273268
}
274269

275-
// Check if we should continue based on maxToolRounds
276-
if (typeof maxToolRounds === 'number') {
277-
if (currentRound >= maxToolRounds) {
278-
break;
279-
}
280-
} else if (typeof maxToolRounds === 'function') {
281-
// Function signature: (context: TurnContext) => boolean
282-
const resolvedRequest = this.options.request as models.OpenResponsesRequest;
283-
const turnContext: TurnContext = {
284-
numberOfTurns: currentRound + 1,
285-
messageHistory: currentInput,
286-
...(resolvedRequest.model && {
287-
model: resolvedRequest.model,
288-
}),
289-
...(resolvedRequest.models && {
290-
models: resolvedRequest.models,
291-
}),
292-
};
293-
const shouldContinue = maxToolRounds(turnContext);
294-
if (!shouldContinue) {
295-
break;
296-
}
297-
}
298-
299270
// Store execution round info
300271
this.allToolExecutionRounds.push({
301272
round: currentRound,
@@ -307,7 +278,7 @@ export class ModelResult {
307278
const resolvedRequest = this.options.request as models.OpenResponsesRequest;
308279
const turnContext: TurnContext = {
309280
numberOfTurns: currentRound + 1, // 1-indexed
310-
messageHistory: currentInput,
281+
input: currentInput,
311282
...(resolvedRequest.model && {
312283
model: resolvedRequest.model,
313284
}),
@@ -348,8 +319,8 @@ export class ModelResult {
348319
callId: toolCall.id,
349320
output: result.error
350321
? JSON.stringify({
351-
error: result.error.message,
352-
})
322+
error: result.error.message,
323+
})
353324
: JSON.stringify(result.result),
354325
});
355326
}
@@ -377,8 +348,8 @@ export class ModelResult {
377348
...(Array.isArray(currentResponse.output)
378349
? currentResponse.output
379350
: [
380-
currentResponse.output,
381-
]),
351+
currentResponse.output,
352+
]),
382353
...toolResults,
383354
];
384355

src/lib/next-turn-params.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function buildNextTurnParamsContext(
2525
temperature: request.temperature ?? null,
2626
maxOutputTokens: request.maxOutputTokens ?? null,
2727
topP: request.topP ?? null,
28-
topK: request.topK ?? 0,
28+
topK: request.topK,
2929
instructions: request.instructions ?? null,
3030
};
3131
}
@@ -66,13 +66,16 @@ export async function executeNextTurnParamsFunctions(
6666

6767
// Validate that call.arguments is a record using type guard
6868
if (!isRecord(call.arguments)) {
69+
const typeStr = Array.isArray(call.arguments)
70+
? 'array'
71+
: typeof call.arguments;
6972
throw new Error(
70-
`Tool call arguments for ${tool.function.name} must be an object, got ${typeof call.arguments}`
73+
`Tool call arguments for ${tool.function.name} must be an object, got ${typeStr}`
7174
);
7275
}
7376

7477
// Process each parameter key with proper typing
75-
await processNextTurnParamsForCall(nextParams, call.arguments, workingContext, result);
78+
await processNextTurnParamsForCall(nextParams, call.arguments, workingContext, result, tool.function.name);
7679
}
7780
}
7881

@@ -86,7 +89,8 @@ async function processNextTurnParamsForCall(
8689
nextParams: Record<string, unknown>,
8790
params: Record<string, unknown>,
8891
workingContext: NextTurnParamsContext,
89-
result: Partial<NextTurnParamsContext>
92+
result: Partial<NextTurnParamsContext>,
93+
toolName: string
9094
): Promise<void> {
9195
// Type-safe processing for each known parameter key
9296
// We iterate through keys and use runtime checks instead of casts
@@ -99,15 +103,20 @@ async function processNextTurnParamsForCall(
99103

100104
// Validate that paramKey is actually a key of NextTurnParamsContext
101105
if (!isValidNextTurnParamKey(paramKey)) {
102-
// Skip invalid keys silently - they're not part of the API
106+
console.warn(
107+
`Invalid nextTurnParams key "${paramKey}" in tool "${toolName}". ` +
108+
`Valid keys: input, model, models, temperature, maxOutputTokens, topP, topK, instructions`
109+
);
103110
continue;
104111
}
105112

106113
// Execute the function and await the result
107114
const newValue = await Promise.resolve(fn(params, workingContext));
108115

109-
// Update the result using type-safe assignment
116+
// Update both result and workingContext to enable composition
117+
// Later tools will see modifications made by earlier tools
110118
setNextTurnParam(result, paramKey, newValue);
119+
setNextTurnParam(workingContext, paramKey, newValue);
111120
}
112121
}
113122

@@ -130,7 +139,8 @@ function isValidNextTurnParamKey(key: string): key is keyof NextTurnParamsContex
130139

131140
/**
132141
* Type-safe setter for NextTurnParamsContext
133-
* Ensures the value type matches the key type
142+
* This wrapper is needed because TypeScript doesn't properly narrow the type
143+
* after the type guard, even though we've validated the key
134144
*/
135145
function setNextTurnParam<K extends keyof NextTurnParamsContext>(
136146
target: Partial<NextTurnParamsContext>,

src/lib/reusable-stream.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,26 +83,27 @@ export class ReusableReadableStream<T> {
8383
throw self.sourceError;
8484
}
8585

86-
// Wait for more data - but check conditions after setting up the promise
87-
// to avoid race condition where source completes between check and wait
86+
// Set up the waiting promise FIRST to avoid race condition
87+
// where source completes after the check but before promise is set
8888
const waitPromise = new Promise<void>((resolve, reject) => {
8989
consumer.waitingPromise = {
9090
resolve,
9191
reject,
9292
};
93-
});
9493

95-
// Double-check conditions after setting up promise to handle race
96-
if (self.sourceComplete || self.sourceError || consumer.position < self.buffer.length) {
97-
// Resolve immediately if conditions changed
98-
if (consumer.waitingPromise) {
99-
consumer.waitingPromise.resolve();
100-
consumer.waitingPromise = null;
94+
// Immediately check if we should resolve after setting up the promise
95+
// This handles the case where data arrived or source completed
96+
// between our initial checks and promise creation
97+
if (self.sourceComplete || self.sourceError || consumer.position < self.buffer.length) {
98+
resolve();
10199
}
102-
}
100+
});
103101

104102
await waitPromise;
105103

104+
// Clear the promise reference after it resolves
105+
consumer.waitingPromise = null;
106+
106107
// Recursively try again after waking up
107108
return this.next();
108109
},

src/lib/stop-conditions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export async function isStopConditionMet<TOOLS extends readonly Tool[]>(options:
7979
* stopWhen: maxTokensUsed(10000) // Stop when total tokens exceed 10,000
8080
* ```
8181
*/
82-
export function maxTokensUsed(maxTokens: number): StopCondition<any> {
82+
export function maxTokensUsed(maxTokens: number): StopCondition {
8383
return ({ steps }: { readonly steps: ReadonlyArray<StepResult> }) => {
8484
const totalTokens = steps.reduce(
8585
(sum: number, step: StepResult) => sum + (step.usage?.totalTokens ?? 0),

src/lib/stream-transformers.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,12 @@ export function extractToolCallsFromResponse(
364364
name: functionCallItem.name,
365365
arguments: parsedArguments,
366366
});
367-
} catch (_error) {
367+
} catch (error) {
368+
console.warn(
369+
`Failed to parse tool call arguments for ${functionCallItem.name}:`,
370+
error instanceof Error ? error.message : String(error),
371+
`\nArguments: ${functionCallItem.arguments.substring(0, 100)}${functionCallItem.arguments.length > 100 ? '...' : ''}`
372+
);
368373
// Include the tool call with unparsed arguments
369374
toolCalls.push({
370375
id: functionCallItem.callId,
@@ -439,7 +444,12 @@ export async function* buildToolCallStream(
439444
name: doneEvent.name,
440445
arguments: parsedArguments,
441446
};
442-
} catch (_error) {
447+
} catch (error) {
448+
console.warn(
449+
`Failed to parse tool call arguments for ${doneEvent.name}:`,
450+
error instanceof Error ? error.message : String(error),
451+
`\nArguments: ${doneEvent.arguments.substring(0, 100)}${doneEvent.arguments.length > 100 ? '...' : ''}`
452+
);
443453
// Yield with unparsed arguments if parsing fails
444454
yield {
445455
id: toolCall.id,
@@ -557,10 +567,11 @@ function mapAnnotationsToCitations(
557567
default: {
558568
// Exhaustiveness check - TypeScript will error if we don't handle all annotation types
559569
const exhaustiveCheck: never = annotation;
570+
// Cast to unknown for runtime debugging if type system bypassed
560571
// This should never execute - throw with JSON of the unhandled value
561572
throw new Error(
562573
`Unhandled annotation type. This indicates a new annotation type was added. ` +
563-
`Annotation: ${JSON.stringify(exhaustiveCheck)}`
574+
`Annotation: ${JSON.stringify(exhaustiveCheck as unknown)}`
564575
);
565576
}
566577
}
@@ -683,12 +694,12 @@ export function convertToClaudeMessage(
683694
try {
684695
parsedInput = JSON.parse(fnCall.arguments);
685696
} catch (error) {
697+
console.warn(
698+
`Failed to parse tool call arguments for ${fnCall.name}:`,
699+
error instanceof Error ? error.message : String(error),
700+
`\nArguments: ${fnCall.arguments.substring(0, 100)}${fnCall.arguments.length > 100 ? '...' : ''}`
701+
);
686702
// Preserve raw arguments if JSON parsing fails
687-
// Log warning in development/debug environments
688-
if (typeof process !== 'undefined' && process.env?.['NODE_ENV'] === 'development') {
689-
// biome-ignore lint/suspicious/noConsole: needed for debugging in development
690-
console.warn(`Failed to parse tool call arguments for ${fnCall.name}:`, error);
691-
}
692703
parsedInput = {
693704
_raw_arguments: fnCall.arguments,
694705
};

0 commit comments

Comments
 (0)