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
23 changes: 23 additions & 0 deletions .changeset/fix-server-tool-type-gaps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@openrouter/agent': minor
---

`serverTool()` results now assign to bare `ServerTool` and to mixed `Array<ClientTool | ServerTool>` arrays without a cast. Before this change, `ServerTool<'openrouter:datetime'>` was not assignable to `ServerTool` (= `ServerTool<ServerToolType>`), because `config: Extract<ServerToolConfig, { type: T }>` made the interface invariant over `T`.

```ts
import { serverTool, tool, type ClientTool, type ServerTool } from '@openrouter/agent';

const datetime: ServerTool = serverTool({ type: 'openrouter:datetime' }); // no cast
const tools: Array<ClientTool | ServerTool> = [saveNote, datetime];
```

**Breaking (type-level only, no runtime change):** `ServerTool` is now a conditional type alias rather than an interface.

- Bare `ServerTool` (no type argument) is now exactly `ServerToolBase`. It drops the required literal `id` (`` `server:${ServerToolType}` ``), so `id` becomes optional `string`. It also stops being the generic interface whose `config` follows `T`, so code that relied on `ServerTool`'s generic shape (for example `S extends ServerTool<infer K>`) must infer from `config.type` instead.
- Declaration merging (`interface ServerTool { ... }` augmentation) no longer works, because type aliases can't be merged.

Migration:

- To keep a narrowed `config` or a required literal `id`, pass the type argument: `ServerTool<'openrouter:web_search'>`, or use `ReturnType<typeof serverTool<'openrouter:web_search'>>`. `ServerTool<T>` and `ServerTool<T, TId>` work as before.
- If you augmented `ServerTool` with declaration merging, augment `ServerToolBase` instead (it is still an interface).
- Replace `S extends ServerTool<infer K>` with `S extends { readonly config: { readonly type: infer K } }`.
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
60 changes: 32 additions & 28 deletions packages/agent/src/lib/tool-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,15 +863,13 @@ 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';
Expand All @@ -883,40 +881,46 @@ export interface ServerToolBase {
* Optional here for source compatibility with legacy hand-constructed
* `ServerToolBase` values that predate this field. `ServerTool<T, TId>`
* (the type returned by {@link serverTool}) still requires it as a
* literal `TId` via interface narrowing below.
* literal `TId` via the intersection below.
*/
readonly id?: string;
}

/**
* 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`).
* @template TId Stable tool-set ID (defaults to `server:${T}`).
*/
export interface ServerTool<
T extends ServerToolType = ServerToolType,
TId extends string = `server:${T}`,
> extends ServerToolBase {
readonly config: Extract<
ServerToolConfig,
{
type: T;
}
>;
readonly id: TId;
}
export type ServerTool<T extends ServerToolType = never, TId extends string = `server:${T}`> = [
T,
] extends [
never,
]
? ServerToolBase
: ServerToolBase & {
readonly config: Extract<
ServerToolConfig,
{
type: T;
}
>;
readonly id: TId;
};

/**
* 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
91 changes: 91 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,91 @@
/**
* Regression coverage for a consumer-facing type gap reported against
* v0.4.0 that previously required `as any`: mixing `tool()` +
* `serverTool()` results in a single array typed as
* `Array<ClientTool | ServerTool>` (or annotating a `serverTool()` result
* as bare `ServerTool`) must typecheck without a cast. `serverTool<T>()`
* returns the narrow `ServerTool<T>` (a `ServerToolBase` intersection),
* which must flow into the bare `ServerTool`, which collapses to
* `ServerToolBase` via its `never` default.
*
* Also pins that `fromChatMessages()` output (typed precisely since #93)
* stays assignable to `callModel`'s `input` without a cast.
*/

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';

// --- 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;

// The mixed array is accepted by `callModel`'s `tools` parameter.
expectTypeOf(_mixed).toExtend<NonNullable<CallModelInput['tools']>>();

// --- fromChatMessages() output is assignable to input ----------------------

type _InputField = CallModelInput['input'];
const _converted = fromChatMessages([
{
role: 'user',
content: 'hi',
},
]);
const _asInput: _InputField = _converted;
void _asInput;
1 change: 1 addition & 0 deletions packages/agent/tsconfig.typecheck.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"compilerOptions": { "noEmit": true, "rootDir": "." },
"include": [
"src/**/*.ts",
"tests/unit/consumer-type-ergonomics.test-d.ts",
"tests/unit/context-schema-inference.test-d.ts",
"tests/unit/message-compat-types.test-d.ts",
"tests/unit/filter-tools-by-ids.test-d.ts",
Expand Down
Loading