diff --git a/.changeset/server-tools-models-fallback.md b/.changeset/server-tools-models-fallback.md new file mode 100644 index 00000000..e1cdf83c --- /dev/null +++ b/.changeset/server-tools-models-fallback.md @@ -0,0 +1,5 @@ +--- +'@openrouter/agent': patch +--- + +Populate primary model from models fallback array when model is omitted (#121) diff --git a/packages/agent/src/lib/async-params.ts b/packages/agent/src/lib/async-params.ts index f2f72e71..84d9f99f 100644 --- a/packages/agent/src/lib/async-params.ts +++ b/packages/agent/src/lib/async-params.ts @@ -406,3 +406,33 @@ export function hasAsyncFunctions(input: unknown): boolean { } return Object.values(input).some((value) => typeof value === 'function'); } + +/** + * Populate the top-level `model` from the `models` fallback list (#121). + * + * OpenRouter server tools (e.g. `openrouter:datetime`) require a primary + * `model` on the request; a caller that only supplies `models` would + * otherwise send none. The primary is `models[0]`, matching the router's + * own fallback order. + * + * Mutates `request` in place. By default an existing `model` is kept; pass + * `overwrite: true` to replace it (used when `models` changed and `model` + * did not, so the two can't drift apart). + */ +export function fillPrimaryModelFromModels( + request: { + model?: unknown; + models?: unknown; + }, + options: { + overwrite?: boolean; + } = {}, +): void { + if (request.model !== undefined && !options.overwrite) { + return; + } + const primary = Array.isArray(request.models) ? request.models[0] : undefined; + if (typeof primary === 'string') { + request.model = primary; + } +} diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index bbc4f047..364d186d 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -7,6 +7,7 @@ import * as z4 from 'zod/v4'; import type { $ZodObject, $ZodShape } from 'zod/v4/core'; import type { CallModelInput, ResolvedCallModelInput } from './async-params.js'; import { + fillPrimaryModelFromModels, hasAsyncFunctions, resolveAsyncFunctions, stripToolSetSnapshotMetadata, @@ -5312,6 +5313,7 @@ export class ModelResult< private async resolveRequestForContext(context: TurnContext): Promise { if (hasAsyncFunctions(this.options.request)) { const resolved = await resolveAsyncFunctions(this.options.request, context); + fillPrimaryModelFromModels(resolved); return this.applyResolvedForcedToolChoicePolicy(resolved); } // Already resolved, extract non-function fields. @@ -5344,6 +5346,7 @@ export class ModelResult< ...rest, }; stripToolSetSnapshotMetadata(resolved); + fillPrimaryModelFromModels(resolved); return this.applyResolvedForcedToolChoicePolicy(resolved as ResolvedCallModelInput); } diff --git a/packages/agent/src/lib/next-turn-params.test.ts b/packages/agent/src/lib/next-turn-params.test.ts index 80a57b04..c89fcdb8 100644 --- a/packages/agent/src/lib/next-turn-params.test.ts +++ b/packages/agent/src/lib/next-turn-params.test.ts @@ -1,7 +1,7 @@ import type * as models from '@openrouter/sdk/models'; import { describe, expect, it } from 'vitest'; -import { applyNextTurnParamsToRequest } from './next-turn-params.js'; +import { applyNextTurnParamsToRequest, buildNextTurnParamsContext } from './next-turn-params.js'; /** * Creates a minimal ResponsesRequest for testing applyNextTurnParamsToRequest. @@ -198,3 +198,85 @@ describe('applyNextTurnParamsToRequest with allowed_tools', () => { ); }); }); + +describe('buildNextTurnParamsContext with models fallback', () => { + it('defaults model to models[0] when model is undefined', () => { + const context = buildNextTurnParamsContext({ + models: [ + 'mistralai/mistral-small-2603', + 'mistralai/mistral-large-2512', + ], + input: 'hello', + }); + + expect(context.model).toBe('mistralai/mistral-small-2603'); + expect(context.models).toEqual([ + 'mistralai/mistral-small-2603', + 'mistralai/mistral-large-2512', + ]); + }); + + it('preserves explicit model when provided', () => { + const context = buildNextTurnParamsContext({ + model: 'anthropic/claude-3', + models: [ + 'mistralai/mistral-large-2512', + ], + input: 'hello', + }); + + expect(context.model).toBe('anthropic/claude-3'); + expect(context.models).toEqual([ + 'mistralai/mistral-large-2512', + ]); + }); + + it('does not synthesize models from model when models was never set', () => { + const context = buildNextTurnParamsContext({ + model: 'anthropic/claude-3', + input: 'hello', + }); + + expect(context.model).toBe('anthropic/claude-3'); + expect(context.models).toEqual([]); + }); +}); + +describe('applyNextTurnParamsToRequest with models fallback', () => { + it('updates model to models[0] when models is updated without model', () => { + const request = createBaseRequest({ + model: 'openai/gpt-4', + }); + + const result = applyNextTurnParamsToRequest(request, { + models: [ + 'mistralai/mistral-small-2603', + 'mistralai/mistral-large-2512', + ], + }); + + expect(result.model).toBe('mistralai/mistral-small-2603'); + expect(result.models).toEqual([ + 'mistralai/mistral-small-2603', + 'mistralai/mistral-large-2512', + ]); + }); + + it('preserves explicit model when both model and models are updated', () => { + const request = createBaseRequest({ + model: 'openai/gpt-4', + }); + + const result = applyNextTurnParamsToRequest(request, { + model: 'anthropic/claude-3', + models: [ + 'mistralai/mistral-large-2512', + ], + }); + + expect(result.model).toBe('anthropic/claude-3'); + expect(result.models).toEqual([ + 'mistralai/mistral-large-2512', + ]); + }); +}); diff --git a/packages/agent/src/lib/next-turn-params.ts b/packages/agent/src/lib/next-turn-params.ts index 7ba0807b..a883f047 100644 --- a/packages/agent/src/lib/next-turn-params.ts +++ b/packages/agent/src/lib/next-turn-params.ts @@ -1,4 +1,5 @@ import type * as models from '@openrouter/sdk/models'; +import { fillPrimaryModelFromModels } from './async-params.js'; import type { NextTurnParamsContext, ParsedToolCall, Tool } from './tool-types.js'; import { isClientTool } from './tool-types.js'; @@ -22,7 +23,7 @@ export function buildNextTurnParamsContext( return { input: request.input ?? [], toolChoice: request.toolChoice, - model: request.model ?? '', + model: request.model ?? request.models?.[0] ?? '', models: request.models ?? [], temperature: request.temperature ?? null, maxOutputTokens: request.maxOutputTokens ?? null, @@ -167,6 +168,12 @@ function setNextTurnParam( * Apply computed nextTurnParams to the current request * Returns a new request object with updated parameters * + * `models` takes precedence over `model`: if `computedParams` changes + * `models` but not `model`, the request's `model` is reset to the new + * `models[0]`, even when the request already had an explicit `model`. + * This keeps the primary model in sync with the fallback list. To pin a + * different primary, set `model` in the same nextTurnParams update. + * * @param request - The current request * @param computedParams - Computed parameter values from nextTurnParams functions * @returns New request with updated parameters @@ -181,8 +188,12 @@ export function applyNextTurnParamsToRequest( for (const [key, value] of Object.entries(computedParams)) { sanitized[key] = value === null ? undefined : value; } - return { + const nextRequest: models.ResponsesRequest = { ...request, ...sanitized, }; + fillPrimaryModelFromModels(nextRequest, { + overwrite: 'models' in computedParams && !('model' in computedParams), + }); + return nextRequest; } diff --git a/packages/agent/tests/unit/server-tool-models-fallback.test.ts b/packages/agent/tests/unit/server-tool-models-fallback.test.ts new file mode 100644 index 00000000..8acd7907 --- /dev/null +++ b/packages/agent/tests/unit/server-tool-models-fallback.test.ts @@ -0,0 +1,197 @@ +import type { OpenRouterCore } from '@openrouter/sdk/core'; +import type * as models from '@openrouter/sdk/models'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { callModel } from '../../src/inner-loop/call-model.js'; +import { serverTool } from '../../src/lib/tool.js'; + +const mockBetaResponsesSend = vi.hoisted(() => vi.fn()); + +vi.mock('@openrouter/sdk/funcs/betaResponsesSend', () => ({ + betaResponsesSend: mockBetaResponsesSend, +})); + +function makeResponse( + id: string, + model: string, + output: models.OpenResponsesResult['output'], +): models.OpenResponsesResult { + return { + id, + object: 'response', + createdAt: 0, + model, + status: 'completed', + completedAt: 0, + output, + error: null, + incompleteDetails: null, + temperature: null, + topP: null, + presencePenalty: null, + frequencyPenalty: null, + metadata: null, + instructions: null, + tools: [], + toolChoice: 'auto', + parallelToolCalls: false, + } as models.OpenResponsesResult; +} + +const client = { + _options: {}, +} as OpenRouterCore; + +describe('Issue #121: server tools with model fallback via models', () => { + beforeEach(() => { + mockBetaResponsesSend.mockReset(); + }); + + it('populates model from models[0] when model is omitted (#121)', async () => { + mockBetaResponsesSend.mockResolvedValueOnce({ + ok: true, + value: makeResponse('resp_1', 'mistralai/mistral-small-2603', [ + { + id: 'item_1', + type: 'openrouter:datetime', + status: 'completed', + additionalProperties: { + datetime: '2026-09-05T00:00:00Z', + }, + } as models.OutputServerToolItem, + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'Today is Saturday.', + annotations: [], + }, + ], + }, + ]), + }); + + const result = callModel(client, { + models: [ + 'mistralai/mistral-small-2603', + 'mistralai/mistral-large-2512', + ], + input: 'What day is it?', + tools: [ + serverTool({ + type: 'openrouter:datetime', + }), + ], + }); + + const text = await result.getText(); + expect(text).toBe('Today is Saturday.'); + + // Verify betaResponsesSend was called with model set to models[0] + expect(mockBetaResponsesSend).toHaveBeenCalledTimes(1); + const sentRequest = mockBetaResponsesSend.mock.calls[0][1].responsesRequest; + expect(sentRequest.model).toBe('mistralai/mistral-small-2603'); + expect(sentRequest.models).toEqual([ + 'mistralai/mistral-small-2603', + 'mistralai/mistral-large-2512', + ]); + expect(sentRequest.tools).toEqual([ + { + type: 'openrouter:datetime', + }, + ]); + }); + + it('preserves explicit model when provided alongside models', async () => { + mockBetaResponsesSend.mockResolvedValueOnce({ + ok: true, + value: makeResponse('resp_2', 'primary-model', [ + { + id: 'msg_2', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'Hello', + annotations: [], + }, + ], + }, + ]), + }); + + const result = callModel(client, { + model: 'primary-model', + models: [ + 'fallback-model-1', + 'fallback-model-2', + ], + input: 'Hello', + tools: [ + serverTool({ + type: 'openrouter:datetime', + }), + ], + }); + + await result.getText(); + + expect(mockBetaResponsesSend).toHaveBeenCalledTimes(1); + const sentRequest = mockBetaResponsesSend.mock.calls[0][1].responsesRequest; + expect(sentRequest.model).toBe('primary-model'); + expect(sentRequest.models).toEqual([ + 'fallback-model-1', + 'fallback-model-2', + ]); + }); + + it('populates model from models[0] when models is an async function', async () => { + mockBetaResponsesSend.mockResolvedValueOnce({ + ok: true, + value: makeResponse('resp_3', 'dynamic-model-1', [ + { + id: 'msg_3', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'Dynamic model response', + annotations: [], + }, + ], + }, + ]), + }); + + const result = callModel(client, { + models: async () => [ + 'dynamic-model-1', + 'dynamic-model-2', + ], + input: 'Test', + tools: [ + serverTool({ + type: 'openrouter:datetime', + }), + ], + }); + + const text = await result.getText(); + expect(text).toBe('Dynamic model response'); + + expect(mockBetaResponsesSend).toHaveBeenCalledTimes(1); + const sentRequest = mockBetaResponsesSend.mock.calls[0][1].responsesRequest; + expect(sentRequest.model).toBe('dynamic-model-1'); + expect(sentRequest.models).toEqual([ + 'dynamic-model-1', + 'dynamic-model-2', + ]); + }); +});