Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
28 changes: 28 additions & 0 deletions .changeset/fix-server-tool-type-gaps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@openrouter/agent": patch
---

Fix two type gaps that forced consumers to use `as any` when wiring up
`callModel` with server tools and chat-format inputs. Both fixes are
purely additive at the public-type level — `ServerTool` and
`ServerTool<T>` continue to work exactly as before; no consumer code
needs to change.

- **Mixed `Array<ClientTool | ServerTool>` now accepts narrow
`serverTool()` results without a cast.** Previously `ServerTool<T>`
defined `config: Extract<ServerToolConfig, { type: T }>`, which made
it invariant over `T` — so `ServerTool<'openrouter:datetime'>` was
not assignable to the bare `ServerTool` (= `ServerTool<ServerToolType>`).
`ServerTool` is now a conditional generic with a `never` default that
collapses to a non-generic structural base (`ServerToolBase`, also
newly exported), and narrow variants are represented as an
intersection with that base — so any `serverTool(...)` result flows
into `Array<ClientTool | ServerTool>` or `Tool[]` directly.
`ServerTool<'openrouter:datetime'>` (narrowing `config` at a call
site) still compiles as before.

- **`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.
1 change: 1 addition & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export type {
ResponseStreamEvent,
ResponseStreamEvent as EnhancedResponseStreamEvent,
ServerTool,
ServerToolBase,
ServerToolConfig,
ServerToolResultItem,
ServerToolType,
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
23 changes: 15 additions & 8 deletions packages/agent/src/lib/stream-transformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
isURLCitationAnnotation,
isWebSearchCallOutputItem,
} from './stream-type-guards.js';
import type { ClientTool, ParsedToolCall, ServerTool, Tool } from './tool-types.js';
import type { ClientTool, ParsedToolCall, ServerToolBase, Tool } from './tool-types.js';

/**
* Extract text deltas from responses stream events
Expand Down Expand Up @@ -261,21 +261,28 @@ type KnownServerToolOutputs = {
* map via KnownServerToolOutputs; anything else falls back to the
* provider-side server-tool output union (`OpenRouterServerToolOutput`)
* so the SDK's forward-compat variants flow through automatically.
*
* Inference reads `config.type` directly rather than `ServerTool<infer K>`
* so it works whether the source is the narrow intersection form or the
* wide `ServerToolBase` base — both expose the same `config.type` field.
*/
type InferServerToolOutput<S> =
S extends ServerTool<infer K>
? K extends keyof KnownServerToolOutputs
? KnownServerToolOutputs[K]
: OpenRouterServerToolOutput
: never;
type InferServerToolOutput<S> = S extends {
readonly config: {
readonly type: infer K;
};
}
? K extends keyof KnownServerToolOutputs
? KnownServerToolOutputs[K]
: OpenRouterServerToolOutput
: never;

/**
* Union of output item shapes produced by the server tools present in
* `TTools`. For the default unconstrained `readonly Tool[]`, this widens
* 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
53 changes: 30 additions & 23 deletions packages/agent/src/lib/tool-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,45 +343,52 @@ export type ServerToolConfig = Exclude<
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`
* without needing to reason about variance through `Extract<..., {type: T}>`.
* Structural base type for every server tool. Non-generic so that `Tool`'s
* union member doesn't carry a type parameter — this sidesteps the variance
* issue where `ServerTool<'openrouter:datetime'>` would fail to assign into
* `ServerTool<ServerToolType>` 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` (bare, via its `never` default) collapses to this type, so
* `Array<ClientTool | ServerTool>` accepts any narrow variant.
*/
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. Without a type argument, it is the structural base
* (equivalent to `ServerToolBase`) — use it as-is in mixed arrays like
* `Array<ClientTool | ServerTool>`. With a `type` literal argument, it
* narrows `config` to that specific SDK variant — returned by
* `serverTool<T>()` and useful when the concrete config shape matters.
*
* The `[T] extends [never]` default branch keeps bare `ServerTool` equal to
* `ServerToolBase`, so specific narrow variants assign into the bare form
* via the intersection being a subtype of `ServerToolBase`.
*
* @template T The specific server-tool type literal (narrows `config`).
*/
export interface ServerTool<T extends ServerToolType = ServerToolType> extends ServerToolBase {
readonly config: Extract<
ServerToolConfig,
{
type: T;
}
>;
}
export type ServerTool<T extends ServerToolType = never> = [
T,
] extends [
never,
]
? ServerToolBase
: ServerToolBase & {
readonly config: Extract<
ServerToolConfig,
{
type: T;
}
>;
};

/**
* Union of every tool kind accepted by `callModel({ tools: [...] })`:
* client function/generator/manual tools, or OpenRouter server tools.
* The server branch is the structural base; specific `ServerTool<T>`
* values flow in via interface extension.
* values flow in via the intersection being a subtype of `ServerToolBase`.
*/
export type Tool = ClientTool | ServerToolBase;

Expand Down
98 changes: 98 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,98 @@
/**
* 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. `serverTool<T>()` returns the narrow
* `ServerTool<T>` (a `ServerToolBase` intersection), which must flow
* into the bare `ServerTool` — bare `ServerTool` collapses to
* `ServerToolBase` via its `never` default.
*
* 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, Tool } from '../../src/lib/tool-types.js';

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

// Specific narrow factory return types must flow to the bare `ServerTool`
// form (which collapses to `ServerToolBase`) via intersection subtyping.
expectTypeOf<ServerTool<'openrouter:datetime'>>().toExtend<ServerTool>();
expectTypeOf<ServerTool<'openrouter:datetime'>>().toExtend<ServerToolBase>();
expectTypeOf<ServerTool<'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;
Loading