From 66448041e51b50121f04588ef35ce304fdbcc1ab Mon Sep 17 00:00:00 2001 From: Varun S G Date: Sat, 5 Sep 2026 01:13:10 +0530 Subject: [PATCH] fix(agent): populate primary model from models fallback array (#121) --- .changeset/server-tools-models-fallback.md | 5 + packages/agent/src/inner-loop/call-model.ts | 12 ++ packages/agent/src/lib/async-params.ts | 13 +- packages/agent/src/lib/model-result.ts | 8 + .../agent/src/lib/next-turn-params.test.ts | 74 ++++++- packages/agent/src/lib/next-turn-params.ts | 22 +- .../unit/server-tool-models-fallback.test.ts | 197 ++++++++++++++++++ 7 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 .changeset/server-tools-models-fallback.md create mode 100644 packages/agent/tests/unit/server-tool-models-fallback.test.ts 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/inner-loop/call-model.ts b/packages/agent/src/inner-loop/call-model.ts index 73d2d8ef..93fa9542 100644 --- a/packages/agent/src/inner-loop/call-model.ts +++ b/packages/agent/src/inner-loop/call-model.ts @@ -158,6 +158,18 @@ export function callModel< }; stripToolSetSnapshotMetadata(finalRequest); + // If model is not set but models is provided, default model to models[0]. + // OpenRouter server tools and backend handlers require the primary model + // to be populated on the request. + if ( + finalRequest['model'] === undefined && + Array.isArray(finalRequest['models']) && + finalRequest['models'].length > 0 && + typeof finalRequest['models'][0] === 'string' + ) { + finalRequest['model'] = finalRequest['models'][0]; + } + if (apiTools !== undefined) { finalRequest['tools'] = apiTools; } diff --git a/packages/agent/src/lib/async-params.ts b/packages/agent/src/lib/async-params.ts index f2f72e71..44e7f736 100644 --- a/packages/agent/src/lib/async-params.ts +++ b/packages/agent/src/lib/async-params.ts @@ -391,7 +391,18 @@ export async function resolveAsyncFunctions 0 && + typeof resolvedObj['models'][0] === 'string' + ) { + (resolvedObj as Record)['model'] = resolvedObj['models'][0]; + } + + return resolvedObj; } /** diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index bbc4f047..535a8fa9 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -5344,6 +5344,14 @@ export class ModelResult< ...rest, }; stripToolSetSnapshotMetadata(resolved); + if ( + resolved['model'] === undefined && + Array.isArray(resolved['models']) && + resolved['models'].length > 0 && + typeof resolved['models'][0] === 'string' + ) { + resolved['model'] = resolved['models'][0]; + } 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..a22550f6 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,75 @@ 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', + ]); + }); +}); + +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..2fdc66d1 100644 --- a/packages/agent/src/lib/next-turn-params.ts +++ b/packages/agent/src/lib/next-turn-params.ts @@ -22,8 +22,14 @@ export function buildNextTurnParamsContext( return { input: request.input ?? [], toolChoice: request.toolChoice, - model: request.model ?? '', - models: request.models ?? [], + model: request.model ?? request.models?.[0] ?? '', + models: + request.models ?? + (request.model + ? [ + request.model, + ] + : []), temperature: request.temperature ?? null, maxOutputTokens: request.maxOutputTokens ?? null, topP: request.topP ?? null, @@ -181,8 +187,18 @@ export function applyNextTurnParamsToRequest( for (const [key, value] of Object.entries(computedParams)) { sanitized[key] = value === null ? undefined : value; } - return { + const nextRequest: models.ResponsesRequest = { ...request, ...sanitized, }; + if ( + (nextRequest.model === undefined || + ('models' in computedParams && !('model' in computedParams))) && + Array.isArray(nextRequest.models) && + nextRequest.models.length > 0 && + typeof nextRequest.models[0] === 'string' + ) { + nextRequest.model = nextRequest.models[0]; + } + 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', + ]); + }); +});