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
5 changes: 5 additions & 0 deletions .changeset/server-tools-models-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@openrouter/agent': patch
---

Populate primary model from models fallback array when model is omitted (#121)
12 changes: 12 additions & 0 deletions packages/agent/src/inner-loop/call-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
13 changes: 12 additions & 1 deletion packages/agent/src/lib/async-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,18 @@ export async function resolveAsyncFunctions<TTools extends readonly Tool[] = rea
}
}

return buildResolvedRequest(resolvedEntries);
const resolvedObj = buildResolvedRequest(resolvedEntries);

if (
resolvedObj['model'] === undefined &&
Array.isArray(resolvedObj['models']) &&
resolvedObj['models'].length > 0 &&
typeof resolvedObj['models'][0] === 'string'
) {
(resolvedObj as Record<string, unknown>)['model'] = resolvedObj['models'][0];
}

return resolvedObj;
}

/**
Expand Down
8 changes: 8 additions & 0 deletions packages/agent/src/lib/model-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
74 changes: 73 additions & 1 deletion packages/agent/src/lib/next-turn-params.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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',
]);
});
});
22 changes: 19 additions & 3 deletions packages/agent/src/lib/next-turn-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
197 changes: 197 additions & 0 deletions packages/agent/tests/unit/server-tool-models-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});