Skip to content

Commit f7e2849

Browse files
committed
fixes and improvements
1 parent f48d9f2 commit f7e2849

File tree

6 files changed

+516
-318
lines changed

6 files changed

+516
-318
lines changed

src/lib/anthropic-compat.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,20 +98,21 @@ export function fromClaudeMessages(
9898
for (const block of content) {
9999
switch (block.type) {
100100
case 'text':
101-
textBlocks.push(block as models.ClaudeTextBlockParam);
101+
textBlocks.push(block);
102102
break;
103103
case 'image':
104-
imageBlocks.push(block as models.ClaudeImageBlockParam);
104+
imageBlocks.push(block);
105105
break;
106106
case 'tool_use':
107-
toolUseBlocks.push(block as models.ClaudeToolUseBlockParam);
107+
toolUseBlocks.push(block);
108108
break;
109109
case 'tool_result':
110-
toolResultBlocks.push(block as models.ClaudeToolResultBlockParam);
110+
toolResultBlocks.push(block);
111111
break;
112112
default: {
113+
// Exhaustiveness check - TypeScript will error if we don't handle all block types
113114
const exhaustiveCheck: never = block;
114-
throw new Error(`Unhandled content block type: ${exhaustiveCheck}`);
115+
throw new Error(`Unhandled content block type: ${JSON.stringify(exhaustiveCheck)}`);
115116
}
116117
}
117118
}

src/lib/async-params.ts

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,26 @@ import type * as models from '../models/index.js';
22
import type { StopWhen, Tool, TurnContext } from './tool-types.js';
33

44
/**
5-
* Type-safe Object.fromEntries that preserves key-value type relationships
5+
* Type guard to check if a value is a parameter function
6+
* Parameter functions take TurnContext and return a value or promise
67
*/
7-
const typeSafeObjectFromEntries = <
8-
const T extends ReadonlyArray<readonly [PropertyKey, unknown]>
9-
>(
10-
entries: T
11-
): { [K in T[number] as K[0]]: K[1] } => {
12-
return Object.fromEntries(entries) as { [K in T[number] as K[0]]: K[1] };
13-
};
8+
function isParameterFunction(
9+
value: unknown
10+
): value is (context: TurnContext) => unknown | Promise<unknown> {
11+
return typeof value === 'function';
12+
}
13+
14+
/**
15+
* Build a resolved request object from entries
16+
* This validates the structure matches the expected ResolvedCallModelInput shape
17+
*/
18+
function buildResolvedRequest(
19+
entries: ReadonlyArray<readonly [string, unknown]>
20+
): ResolvedCallModelInput {
21+
const obj = Object.fromEntries(entries);
22+
23+
return obj satisfies ResolvedCallModelInput;
24+
}
1425

1526
/**
1627
* A field can be either a value of type T or a function that computes T
@@ -73,13 +84,10 @@ export async function resolveAsyncFunctions(
7384
continue;
7485
}
7586

76-
if (typeof value === 'function') {
87+
if (isParameterFunction(value)) {
7788
try {
7889
// Execute the function with context and store the result
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>;
82-
const result = await Promise.resolve(fn(context));
90+
const result = await Promise.resolve(value(context));
8391
resolvedEntries.push([key, result] as const);
8492
} catch (error) {
8593
// Wrap errors with context about which field failed
@@ -94,11 +102,7 @@ export async function resolveAsyncFunctions(
94102
}
95103
}
96104

97-
// Use type-safe fromEntries - the result type is inferred from the entries
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
101-
return typeSafeObjectFromEntries(resolvedEntries) as ResolvedCallModelInput;
105+
return buildResolvedRequest(resolvedEntries);
102106
}
103107

104108
/**

src/lib/model-result.ts

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import type {
1313
} from './tool-types.js';
1414

1515
import { betaResponsesSend } from '../funcs/betaResponsesSend.js';
16-
import { hasAsyncFunctions, resolveAsyncFunctions } from './async-params.js';
16+
import {
17+
hasAsyncFunctions,
18+
resolveAsyncFunctions,
19+
type ResolvedCallModelInput,
20+
} from './async-params.js';
1721
import { ReusableReadableStream } from './reusable-stream.js';
1822
import {
1923
buildResponsesMessageStream,
@@ -83,9 +87,8 @@ function hasTypeProperty(item: unknown): item is {
8387
}
8488

8589
export interface GetResponseOptions {
86-
// Request can be a mix of sync and async fields
87-
// The actual type will be narrowed during async function resolution
88-
request: models.OpenResponsesRequest | CallModelInput | Record<string, unknown>;
90+
// Request can have async functions that will be resolved before sending to API
91+
request: CallModelInput;
8992
client: OpenRouterCore;
9093
options?: RequestOptions;
9194
tools?: Tool[];
@@ -122,6 +125,8 @@ export class ModelResult {
122125
toolCalls: ParsedToolCall[];
123126
response: models.OpenResponsesNonStreamingResponse;
124127
}> = [];
128+
// Track resolved request after async function resolution
129+
private resolvedRequest: models.OpenResponsesRequest | null = null;
125130

126131
constructor(options: GetResponseOptions) {
127132
this.options = options;
@@ -160,20 +165,30 @@ export class ModelResult {
160165
};
161166

162167
// Resolve any async functions first
168+
let baseRequest: ResolvedCallModelInput;
163169
if (hasAsyncFunctions(this.options.request)) {
164-
const resolved = await resolveAsyncFunctions(
165-
this.options.request as CallModelInput,
170+
baseRequest = await resolveAsyncFunctions(
171+
this.options.request,
166172
initialContext,
167173
);
168-
this.options.request = resolved as models.OpenResponsesRequest;
174+
} else {
175+
// Already resolved, extract non-function fields
176+
// Since request is CallModelInput, we need to filter out tools/stopWhen
177+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
178+
const { tools, stopWhen, ...rest } = this.options.request;
179+
// Use satisfies to validate type compatibility while maintaining the target type
180+
baseRequest = rest as ResolvedCallModelInput satisfies ResolvedCallModelInput;
169181
}
170182

171-
// Force stream mode
172-
const request = {
173-
...(this.options.request as models.OpenResponsesRequest),
183+
// Store resolved request with stream mode
184+
this.resolvedRequest = {
185+
...baseRequest,
174186
stream: true as const,
175187
};
176188

189+
// Force stream mode for initial request
190+
const request = this.resolvedRequest;
191+
177192
// Create the stream promise
178193
this.streamPromise = betaResponsesSend(
179194
this.options.client,
@@ -183,7 +198,10 @@ export class ModelResult {
183198
if (!result.ok) {
184199
throw result.error;
185200
}
186-
return result.value;
201+
// When stream: true, the API returns EventStream
202+
// TypeScript can't narrow the union type based on runtime parameter values,
203+
// so we assert the type here based on our knowledge that stream=true
204+
return result.value as EventStream<models.OpenResponsesStreamEvent>;
187205
});
188206

189207
// Wait for the stream and create the reusable stream
@@ -277,10 +295,14 @@ export class ModelResult {
277295
// Resolve async functions for this turn
278296
if (hasAsyncFunctions(this.options.request)) {
279297
const resolved = await resolveAsyncFunctions(
280-
this.options.request as CallModelInput,
298+
this.options.request,
281299
turnContext,
282300
);
283-
this.options.request = resolved as models.OpenResponsesRequest;
301+
// Update resolved request with new values
302+
this.resolvedRequest = {
303+
...resolved,
304+
stream: false, // Tool execution turns don't need streaming
305+
};
284306
}
285307

286308
// Execute all tool calls
@@ -314,16 +336,20 @@ export class ModelResult {
314336

315337
// Execute nextTurnParams functions for tools that were called
316338
if (this.options.tools && currentToolCalls.length > 0) {
339+
if (!this.resolvedRequest) {
340+
throw new Error('Request not initialized');
341+
}
342+
317343
const computedParams = await executeNextTurnParamsFunctions(
318344
currentToolCalls,
319345
this.options.tools,
320-
this.options.request as models.OpenResponsesRequest
346+
this.resolvedRequest
321347
);
322348

323-
// Apply computed parameters to the request for next turn
349+
// Apply computed parameters to the resolved request for next turn
324350
if (Object.keys(computedParams).length > 0) {
325-
this.options.request = applyNextTurnParamsToRequest(
326-
this.options.request as models.OpenResponsesRequest,
351+
this.resolvedRequest = applyNextTurnParamsToRequest(
352+
this.resolvedRequest,
327353
computedParams
328354
);
329355
}
@@ -341,8 +367,12 @@ export class ModelResult {
341367
];
342368

343369
// Make new request with tool results
370+
if (!this.resolvedRequest) {
371+
throw new Error('Request not initialized');
372+
}
373+
344374
const newRequest: models.OpenResponsesRequest = {
345-
...(this.options.request as models.OpenResponsesRequest),
375+
...this.resolvedRequest,
346376
input: newInput,
347377
stream: false,
348378
};

0 commit comments

Comments
 (0)