diff --git a/.changeset/fix-server-tool-type-gaps.md b/.changeset/fix-server-tool-type-gaps.md new file mode 100644 index 0000000..717fd3c --- /dev/null +++ b/.changeset/fix-server-tool-type-gaps.md @@ -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` continue to work exactly as before; no consumer code +needs to change. + +- **Mixed `Array` now accepts narrow + `serverTool()` results without a cast.** Previously `ServerTool` + defined `config: Extract`, which made + it invariant over `T` — so `ServerTool<'openrouter:datetime'>` was + not assignable to the bare `ServerTool` (= `ServerTool`). + `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` 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. diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 0bd90cf..f0057e0 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -151,6 +151,7 @@ export type { ResponseStreamEvent, ResponseStreamEvent as EnhancedResponseStreamEvent, ServerTool, + ServerToolBase, ServerToolConfig, ServerToolResultItem, ServerToolType, diff --git a/packages/agent/src/lib/async-params.ts b/packages/agent/src/lib/async-params.ts index 8856346..8662fe5 100644 --- a/packages/agent/src/lib/async-params.ts +++ b/packages/agent/src/lib/async-params.ts @@ -57,7 +57,18 @@ type BaseCallModelInput< models.ResponsesRequest[K] >; } & { - input: FieldOrAsyncFunction | 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 | FieldOrAsyncFunction; tools?: TTools; stopWhen?: StopWhen; /** Typed context data passed to tools via contextSchema. Includes optional `shared` key. */ diff --git a/packages/agent/src/lib/stream-transformers.ts b/packages/agent/src/lib/stream-transformers.ts index b35b56b..6b6d300 100644 --- a/packages/agent/src/lib/stream-transformers.ts +++ b/packages/agent/src/lib/stream-transformers.ts @@ -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 @@ -261,13 +261,20 @@ 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` + * 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 extends ServerTool - ? K extends keyof KnownServerToolOutputs - ? KnownServerToolOutputs[K] - : OpenRouterServerToolOutput - : never; +type InferServerToolOutput = 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 @@ -275,7 +282,7 @@ type InferServerToolOutput = * to every mapped output plus the generic fallback. Unused otherwise. */ type InferServerToolOutputsUnion = InferServerToolOutput< - Extract + Extract >; /** diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index f4794a8..7a4475a 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -343,15 +343,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` through `Extract<..., {type: T}>`. * - * `Tool` uses `ServerToolBase` as its union member (rather than a generic - * `ServerTool` parameterized on a union) so specific `ServerTool` values - * assign into `Tool[]` directly. + * `ServerTool` (bare, via its `never` default) collapses to this type, so + * `Array` accepts any narrow variant. */ export interface ServerToolBase { readonly _brand: 'server-tool'; @@ -359,29 +357,38 @@ export interface ServerToolBase { } /** - * 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` 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`. With a `type` literal argument, it + * narrows `config` to that specific SDK variant — returned by + * `serverTool()` 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 extends ServerToolBase { - readonly config: Extract< - ServerToolConfig, - { - type: T; - } - >; -} +export type ServerTool = [ + 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` - * values flow in via interface extension. + * values flow in via the intersection being a subtype of `ServerToolBase`. */ export type Tool = ClientTool | ServerToolBase; diff --git a/packages/agent/tests/unit/consumer-type-ergonomics.test-d.ts b/packages/agent/tests/unit/consumer-type-ergonomics.test-d.ts new file mode 100644 index 0000000..d6c9e80 --- /dev/null +++ b/packages/agent/tests/unit/consumer-type-ergonomics.test-d.ts @@ -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` must assign to `callModel`'s `tools` + * parameter without a cast. `serverTool()` returns the narrow + * `ServerTool` (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>().toExtend(); +expectTypeOf>().toExtend(); +expectTypeOf>().toExtend(); + +// 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 accepts a mix without cast. +const _mixed: Array = [ + 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().toExtend<_InputField>(); + +// And the concrete return of `fromChatMessages()` must be assignable. +const _converted = fromChatMessages([ + { + role: 'user', + content: 'hi', + }, +]); +const _asInput: _InputField = _converted; +void _asInput;