Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/fix-server-tool-type-gaps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@openrouter/agent": patch
Comment thread
mattapperson marked this conversation as resolved.
---

Fix two type gaps that forced consumers to use `as any` when wiring up
`callModel` with server tools and chat-format inputs:

- **`ServerTool` is now a structural-base alias**, not a generic. The
factory returns a narrow `ServerToolNarrow<T>` that extends
`ServerToolBase` via interface extension, so a specific
`ServerToolNarrow<'openrouter:datetime'>` flows into the public
`ServerTool` alias without a cast. Mixed arrays like
`Array<ClientTool | ServerTool>` or `Tool[]` now accept any mix of
`tool()` and `serverTool()` results directly. New public types:
`ServerToolBase` (structural base) and `ServerToolNarrow<T>` (narrow
form when the exact `config` shape matters). The old `ServerTool<T>`
generic is replaced by `ServerToolNarrow<T>`; code that only used
`ServerTool` without a type argument is unaffected.

- **`callModel`'s `request.input` now accepts `InputsUnion`** (the SDK's
wider message shape returned by `fromChatMessages()`), alongside the
existing `Item[]` and plain `string` forms. The docstring on
`fromChatMessages()` already claims its output "can be passed directly
to `callModel()`"; the types now match.
2 changes: 2 additions & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ export type {
ResponseStreamEvent,
ResponseStreamEvent as EnhancedResponseStreamEvent,
ServerTool,
ServerToolBase,
ServerToolConfig,
ServerToolNarrow,
ServerToolResultItem,
ServerToolType,
StateAccessor,
Expand Down
13 changes: 12 additions & 1 deletion packages/agent/src/lib/async-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,18 @@ type BaseCallModelInput<
models.ResponsesRequest[K]
>;
} & {
input: FieldOrAsyncFunction<Item[]> | string;
/**
* The input for the model turn. Accepts either:
* - A plain `string` prompt.
* - An array of typed `Item[]` (the narrow, local item union).
* - The SDK's `InputsUnion` shape (a `string` or broader item array)
* — this is what converters like `fromChatMessages()` return, so
* those results assign directly without a cast.
*
* When a function is provided, it is resolved once per call with the
* current turn context before the request is sent.
*/
input: FieldOrAsyncFunction<Item[]> | FieldOrAsyncFunction<models.InputsUnion>;
tools?: TTools;
stopWhen?: StopWhen<TTools>;
/** Typed context data passed to tools via contextSchema. Includes optional `shared` key. */
Expand Down
12 changes: 9 additions & 3 deletions packages/agent/src/lib/stream-transformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ import {
isURLCitationAnnotation,
isWebSearchCallOutputItem,
} from './stream-type-guards.js';
import type { ClientTool, ParsedToolCall, ServerTool, Tool } from './tool-types.js';
import type {
ClientTool,
ParsedToolCall,
ServerToolBase,
ServerToolNarrow,
Tool,
} from './tool-types.js';

/**
* Extract text deltas from responses stream events
Expand Down Expand Up @@ -263,7 +269,7 @@ type KnownServerToolOutputs = {
* so the SDK's forward-compat variants flow through automatically.
*/
type InferServerToolOutput<S> =
S extends ServerTool<infer K>
S extends ServerToolNarrow<infer K>
? K extends keyof KnownServerToolOutputs
? KnownServerToolOutputs[K]
: OpenRouterServerToolOutput
Expand All @@ -275,7 +281,7 @@ type InferServerToolOutput<S> =
* to every mapped output plus the generic fallback. Unused otherwise.
*/
type InferServerToolOutputsUnion<TTools extends readonly Tool[]> = InferServerToolOutput<
Extract<TTools[number], ServerTool>
Extract<TTools[number], ServerToolBase>
>;

/**
Expand Down
37 changes: 27 additions & 10 deletions packages/agent/src/lib/tool-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,29 +346,34 @@ export type ServerToolType = ServerToolConfig['type'];
* Structural base type for every server tool. Interface extension (not a
* distributive conditional) is used so the narrow-T subtype assigns cleanly
* into the wide-T supertype via nominal inheritance — TypeScript treats
* `ServerTool<'web_search_2025_08_26'>` as a subtype of `ServerToolBase`
* `ServerToolNarrow<'web_search_2025_08_26'>` as a subtype of `ServerToolBase`
* without needing to reason about variance through `Extract<..., {type: T}>`.
*
* `Tool` uses `ServerToolBase` as its union member (rather than a generic
* `ServerTool` parameterized on a union) so specific `ServerTool<T>` values
* assign into `Tool[]` directly.
* `ServerTool` parameterized on a union) so specific `ServerToolNarrow<T>`
* values assign into `Tool[]` directly.
*/
export interface ServerToolBase {
readonly _brand: 'server-tool';
readonly config: ServerToolConfig;
}

/**
* A server-executed tool. OpenRouter runs the tool and returns an output
* item in the response — no execute function lives on the client. When
* the type parameter `T` is a specific literal, `config` narrows to the
* SDK shape for that tool. Because this interface `extends ServerToolBase`,
* any `ServerTool<T>` value is nominally assignable to `ServerToolBase`
* (and hence to `Tool`) regardless of `T`.
* A server-executed tool narrowed to a single `type` literal `T`. Because
* `config: Extract<ServerToolConfig, {type: T}>` makes `T` appear in both
* positions of the filter, the narrow form is not naturally assignable to
* a `ServerToolNarrow<ServerToolType>` bare union — so we keep the generic
* form separate from the public `ServerTool` alias.
*
* Consumers should use the `ServerTool` alias (which is `ServerToolBase`)
* when typing mixed arrays like `Array<ClientTool | ServerTool>`, and use
* `ServerToolNarrow<T>` (or simply `ReturnType<typeof serverTool<T>>`)
* only when the specific config shape matters.
*
* @template T The specific server-tool type literal (narrows `config`).
*/
export interface ServerTool<T extends ServerToolType = ServerToolType> extends ServerToolBase {
export interface ServerToolNarrow<T extends ServerToolType = ServerToolType>
extends ServerToolBase {
readonly config: Extract<
ServerToolConfig,
{
Expand All @@ -377,6 +382,18 @@ export interface ServerTool<T extends ServerToolType = ServerToolType> extends S
>;
}

/**
* Public alias for a server-executed tool that accepts any `type`.
* Structurally identical to `ServerToolBase`; kept as a distinct alias so
* that `Array<ClientTool | ServerTool>` reads naturally at call sites and
* every `ServerToolNarrow<T>` instance assigns into it via extension.
*
* For factory-call-site typing where the exact `T` matters — e.g. narrowing
* `config` to a specific SDK shape — use `ServerToolNarrow<T>` or rely on
* `ReturnType<typeof serverTool<T>>`.
*/
export type ServerTool = ServerToolBase;

/**
* Union of every tool kind accepted by `callModel({ tools: [...] })`:
* client function/generator/manual tools, or OpenRouter server tools.
Expand Down
4 changes: 2 additions & 2 deletions packages/agent/src/lib/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { $ZodObject, $ZodShape, $ZodType, infer as zodInfer } from 'zod/v4/
import type {
ManualTool,
NextTurnParamsFunctions,
ServerTool,
ServerToolConfig,
ServerToolNarrow,
ServerToolType,
ToModelOutputFunction,
Tool,
Expand Down Expand Up @@ -370,7 +370,7 @@ export function serverTool<T extends ServerToolType>(
type: T;
}
>,
): ServerTool<T> {
): ServerToolNarrow<T> {
return {
_brand: 'server-tool',
config,
Expand Down
103 changes: 103 additions & 0 deletions packages/agent/tests/unit/consumer-type-ergonomics.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Regression coverage for two consumer-facing type gaps reported against
* v0.4.0 that previously required `as any`:
*
* 1. Mixing `tool()` + `serverTool()` results in a single array typed as
* `Array<ClientTool | ServerTool>` must assign to `callModel`'s `tools`
* parameter without a cast. The factory return type is narrow
* (`ServerToolNarrow<T>`) and must flow through interface extension up
* to the `ServerToolBase`-based `ServerTool` alias.
*
* 2. `fromChatMessages()` returns the SDK's `InputsUnion`, which must be
* directly assignable to `callModel`'s `request.input` without a cast.
* Previously the input was typed as `Item[] | string`, which is a
* narrower union that `InputsUnion` does not extend.
*/

import type * as models from '@openrouter/sdk/models';
import { expectTypeOf } from 'vitest';
import { z } from 'zod/v4';
import type { CallModelInput } from '../../src/lib/async-params.js';
import { fromChatMessages } from '../../src/lib/chat-compat.js';
import { serverTool, tool } from '../../src/lib/tool.js';
import type {
ClientTool,
ServerTool,
ServerToolBase,
ServerToolNarrow,
Tool,
} from '../../src/lib/tool-types.js';

// --- Issue 1: mixed arrays assign without `as any` --------------------------

// Specific narrow factory return types must flow to the public `ServerTool`
// alias via interface extension.
expectTypeOf<ServerToolNarrow<'openrouter:datetime'>>().toExtend<ServerTool>();
expectTypeOf<ServerToolNarrow<'openrouter:datetime'>>().toExtend<ServerToolBase>();
expectTypeOf<ServerToolNarrow<'openrouter:datetime'>>().toExtend<Tool>();

// ServerTool (bare, no generic) is the structural base — it should accept
// any narrow variant assigned to it.
const _dt: ServerTool = serverTool({
type: 'openrouter:datetime',
});
const _ws: ServerTool = serverTool({
type: 'openrouter:web_search',
});
void _dt;
void _ws;

// Array<ClientTool | ServerTool> accepts a mix without cast.
const _mixed: Array<ClientTool | ServerTool> = [
tool({
name: 'save_note',
inputSchema: z.object({
title: z.string(),
}),
execute: async () => ({
ok: true,
}),
}),
serverTool({
type: 'openrouter:datetime',
}),
serverTool({
type: 'openrouter:web_search',
}),
];
void _mixed;

// Tool[] accepts the same mix.
const _asTool: Tool[] = [
tool({
name: 'save_note',
inputSchema: z.object({
title: z.string(),
}),
execute: async () => ({
ok: true,
}),
}),
serverTool({
type: 'openrouter:datetime',
}),
];
void _asTool;

// --- Issue 2: fromChatMessages() output is assignable to input -------------

// A `CallModelInput`'s `input` field accepts `InputsUnion` directly. We use
// `Extract` instead of `toExtend` because `input` is a field-or-fn union; we
// just need the plain data variant to accept `InputsUnion`.
type _InputField = CallModelInput['input'];
expectTypeOf<models.InputsUnion>().toExtend<_InputField>();

// And the concrete return of `fromChatMessages()` must be assignable.
const _converted = fromChatMessages([
{
role: 'user',
content: 'hi',
},
]);
const _asInput: _InputField = _converted;
void _asInput;
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { expectTypeOf } from 'vitest';
import { z } from 'zod/v4';
import type { StreamableOutputItem } from '../../src/lib/stream-transformers.js';
import { serverTool, tool } from '../../src/lib/tool.js';
import type { ServerTool, ServerToolType, Tool } from '../../src/lib/tool-types.js';
import type { ServerToolNarrow, ServerToolType, Tool } from '../../src/lib/tool-types.js';

// --- Default (unconstrained TTools): widest possible union ------------------

Expand All @@ -37,7 +37,7 @@ expectTypeOf<models.OutputWebSearchCallItem>().toExtend<AnyTools>();

type DatetimeOnly = StreamableOutputItem<
readonly [
ServerTool<'openrouter:datetime'>,
ServerToolNarrow<'openrouter:datetime'>,
]
>;
expectTypeOf<models.OutputMessage>().toExtend<DatetimeOnly>();
Expand Down Expand Up @@ -81,7 +81,7 @@ expectTypeOf<models.OutputImageGenerationCallItem>().not.toExtend<Mixed>();

type FutureToolOnly = StreamableOutputItem<
readonly [
ServerTool<ServerToolType>,
ServerToolNarrow<ServerToolType>,
]
>;
// The widest `ServerToolType` includes every known literal; the inferred
Expand Down
Loading