Skip to content

fix(types): eliminate as-any gaps for serverTool mixing and fromChatMessages input#32

Open
mattapperson wants to merge 4 commits intomainfrom
fix-server-tool-type-gaps
Open

fix(types): eliminate as-any gaps for serverTool mixing and fromChatMessages input#32
mattapperson wants to merge 4 commits intomainfrom
fix-server-tool-type-gaps

Conversation

@mattapperson
Copy link
Copy Markdown
Collaborator

Summary

Fixes two type gaps reported against v0.4.0 (context: Slack thread) where consumers mixing tool() + serverTool() or using fromChatMessages() were forced to use as any.

Gap 1: Array<ClientTool | ServerTool> rejected narrow serverTool() results

ServerTool<T> defined config: Extract<ServerToolConfig, { type: T }>, which is invariant over T. So ServerTool<'openrouter:datetime'> did not assign to bare ServerTool (= ServerTool<ServerToolType>). Splitting the public surface:

  • ServerToolBase — structural base, used as the union member of Tool.
  • ServerToolNarrow<T> — narrow form, returned by serverTool<T>(). extends ServerToolBase via interface inheritance so narrow → base is nominal.
  • ServerTool — now a type alias for ServerToolBase. Mixed arrays like Array<ClientTool | ServerTool> and Tool[] accept any serverTool() result directly.

Code that only used ServerTool without a type argument is unaffected. Callers that referenced ServerTool<T> (rare — was only useful for narrowing config) should migrate to ServerToolNarrow<T> or simply ReturnType<typeof serverTool<T>>.

Gap 2: fromChatMessages() output not assignable to request.input

fromChatMessages() returns models.InputsUnion, but callModel's request.input was typed FieldOrAsyncFunction<Item[]> | string — a narrower union that excluded EasyInputMessage (with a wide role union). The fromChatMessages docstring literally claims its output "can be passed directly to callModel()"; now the types match. Widened input to also accept FieldOrAsyncFunction<InputsUnion>.

Before / after

// Before — both required `as any`
const tools: (ClientTool | ServerTool)[] = [clientTool, datetimeTool]; // ❌ variance
await callModel(client, { input: fromChatMessages(msgs), ... });       // ❌ narrower
// After — no casts
const tools: (ClientTool | ServerTool)[] = [clientTool, datetimeTool]; // ✅
await callModel(client, { input: fromChatMessages(msgs), ... });       // ✅

Test plan

  • pnpm --filter @openrouter/agent run typecheck — clean
  • pnpm --filter @openrouter/agent exec vitest run --project=unit — 246/246, 21 files (20 runtime + 3 type-only)
  • pnpm --filter @openrouter/agent exec biome check src tests — clean
  • New tests/unit/consumer-type-ergonomics.test-d.ts locks in both fixes at type-level
  • Migrated existing server-tool-stream-narrowing.test-d.ts fixtures from ServerTool<T> to ServerToolNarrow<T>
  • Built & tested against a local consumer (serverTool mix + fromChatMessages) — both scenarios compile without as any

…essages input

Two consumer-facing type gaps in v0.4.0 required `as any`:

1. Mixing `tool()` + `serverTool()` results in a single array typed as
   `Array<ClientTool | ServerTool>` failed because `ServerTool<T>`'s
   `config: Extract<..., {type: T}>` is invariant over `T`, so a
   `ServerTool<'openrouter:datetime'>` did not assign to the bare
   `ServerTool` (= `ServerTool<ServerToolType>`). Split into:
   - `ServerToolBase` — structural base (also kept as the union member
     of `Tool`).
   - `ServerToolNarrow<T>` — narrow form, returned by `serverTool<T>()`,
     extends `ServerToolBase` via interface inheritance.
   - `ServerTool` — now a type alias for `ServerToolBase`, so
     `Array<ClientTool | ServerTool>` accepts any narrow variant.

2. `fromChatMessages()` returns `InputsUnion`, but `callModel`'s
   `request.input` was typed `FieldOrAsyncFunction<Item[]> | string`,
   which is a narrower union. Widen `input` to also accept
   `FieldOrAsyncFunction<InputsUnion>` so the converter's output
   assigns directly without a cast — matching the docstring.

Adds a type-level regression test (`consumer-type-ergonomics.test-d.ts`)
covering both scenarios, and migrates the existing narrowing fixtures
to `ServerToolNarrow<T>`.
Copy link
Copy Markdown
Collaborator Author

@mattapperson mattapperson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One consideration on the changeset classification for a type-level break.

Comment thread .changeset/fix-server-tool-type-gaps.md
Restores ServerTool as a conditional generic with a `never` default so
bare `ServerTool` collapses to `ServerToolBase` — preserving backward
compatibility for callers that used `ServerTool<T>` while still allowing
mixed `Array<ClientTool | ServerTool>` to accept narrow variants via
intersection subtyping. Infers the stream output's type from
`config.type` directly so narrowing works through both the intersection
form and the bare base.
Reclassifies the changeset as `minor` and spells out the type-level
breaking change so downstream release notes surface the migration from
`ServerTool<T>` to `ServerToolNarrow<T>`. Also applies biome formatting
required by the pre-push lint hook.
Revert classification to `patch` and rewrite the body to explain that
both fixes are purely additive — `ServerTool` and `ServerTool<T>` still
compile exactly as before, so no consumer migration is required.
Copy link
Copy Markdown
Collaborator Author

@mattapperson mattapperson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed, no issues found

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant