Skip to content

Commit fde224e

Browse files
kinKingentombeckenham
authored andcommitted
fix(ai-openrouter): honor json_object structured output
1 parent 888e8b7 commit fde224e

7 files changed

Lines changed: 287 additions & 30 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@tanstack/ai-openrouter': patch
3+
---
4+
5+
Honor `modelOptions.responseFormat: { type: 'json_object' }` during structured
6+
output generation while preserving strict `json_schema` as the default.

packages/ai-openrouter/src/adapters/text.ts

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type {
2727
} from '@tanstack/ai/adapters'
2828
import type {
2929
ContentPart,
30+
JSONSchema,
3031
ModelMessage,
3132
StreamChunk,
3233
TextOptions,
@@ -214,16 +215,22 @@ export class OpenRouterTextAdapter<
214215
): Promise<StructuredOutputResult<unknown>> {
215216
const { chatOptions, outputSchema } = options
216217
const chatRequest = this.mapOptionsToRequest(chatOptions)
217-
218-
const jsonSchema = this.makeStructuredOutputCompatible(
218+
const responseFormat = this.resolveStructuredResponseFormat(
219+
chatRequest.responseFormat,
219220
outputSchema,
220-
outputSchema.required,
221221
)
222222

223223
try {
224-
// Strip streamOptions which is only valid for streaming calls
225-
const { streamOptions: _streamOptions, ...cleanParams } = chatRequest
224+
// Strip streamOptions which is only valid for streaming calls. Also
225+
// remove the caller's responseFormat before adding the resolved
226+
// structured-output format below.
227+
const {
228+
streamOptions: _streamOptions,
229+
responseFormat: _responseFormat,
230+
...cleanParams
231+
} = chatRequest
226232
void _streamOptions
233+
void _responseFormat
227234
chatOptions.logger.request(
228235
`activity=structuredOutput provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`,
229236
{ provider: this.name, model: this.model },
@@ -234,14 +241,7 @@ export class OpenRouterTextAdapter<
234241
chatRequest: {
235242
...cleanParams,
236243
stream: false,
237-
responseFormat: {
238-
type: 'json_schema',
239-
jsonSchema: {
240-
name: 'structured_output',
241-
schema: jsonSchema,
242-
strict: true,
243-
},
244-
},
244+
responseFormat,
245245
},
246246
},
247247
{
@@ -313,10 +313,9 @@ export class OpenRouterTextAdapter<
313313
): AsyncIterable<StreamChunk> {
314314
const { chatOptions, outputSchema } = options
315315
const chatRequest = this.mapOptionsToRequest(chatOptions)
316-
317-
const jsonSchema = this.makeStructuredOutputCompatible(
316+
const responseFormat = this.resolveStructuredResponseFormat(
317+
chatRequest.responseFormat,
318318
outputSchema,
319-
outputSchema.required,
320319
)
321320

322321
const timestamp = Date.now()
@@ -368,14 +367,20 @@ export class OpenRouterTextAdapter<
368367
}.bind(this)
369368

370369
try {
371-
// Strip streamOptions/tools from the base request. Structured output
372-
// sends `responseFormat: json_schema` and doesn't carry tools — keeping
373-
// them can confuse strict-mode validation upstream. (`stream` is
374-
// already absent — `mapOptionsToRequest` returns `Omit<ChatRequest,
375-
// 'stream'>`; we set it explicitly below.)
376-
const { streamOptions: _so, tools: _t, ...cleanParams } = chatRequest
370+
// Strip streamOptions/tools/responseFormat from the base request before
371+
// adding the resolved structured-output format. Structured output
372+
// doesn't carry tools — keeping them can confuse strict-mode validation
373+
// upstream. (`stream` is already absent — `mapOptionsToRequest` returns
374+
// `Omit<ChatRequest, 'stream'>`; we set it explicitly below.)
375+
const {
376+
streamOptions: _so,
377+
tools: _t,
378+
responseFormat: _responseFormat,
379+
...cleanParams
380+
} = chatRequest
377381
void _so
378382
void _t
383+
void _responseFormat
379384

380385
chatOptions.logger.request(
381386
`activity=structuredOutputStream provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`,
@@ -389,14 +394,7 @@ export class OpenRouterTextAdapter<
389394
...cleanParams,
390395
stream: true,
391396
streamOptions: { includeUsage: true },
392-
responseFormat: {
393-
type: 'json_schema',
394-
jsonSchema: {
395-
name: 'structured_output',
396-
schema: jsonSchema,
397-
strict: true,
398-
},
399-
},
397+
responseFormat,
400398
},
401399
},
402400
{
@@ -619,6 +617,38 @@ export class OpenRouterTextAdapter<
619617
}
620618
}
621619

620+
/**
621+
* Resolve the provider request format for a schema-bearing call.
622+
*
623+
* OpenRouter models that support JSON mode but not strict structured outputs
624+
* can opt into `json_object` through the existing provider-native
625+
* `modelOptions.responseFormat` surface. All other values keep the current
626+
* strict `json_schema` behavior so callers cannot accidentally replace the
627+
* schema that the activity layer validates against.
628+
*/
629+
protected resolveStructuredResponseFormat(
630+
requested: ChatRequest['responseFormat'],
631+
outputSchema: JSONSchema,
632+
): NonNullable<ChatRequest['responseFormat']> {
633+
if (requested?.type === 'json_object') {
634+
return requested
635+
}
636+
637+
const jsonSchema = this.makeStructuredOutputCompatible(
638+
outputSchema,
639+
outputSchema.required,
640+
)
641+
642+
return {
643+
type: 'json_schema',
644+
jsonSchema: {
645+
name: 'structured_output',
646+
schema: jsonSchema,
647+
strict: true,
648+
},
649+
}
650+
}
651+
622652
/**
623653
* Applies provider-specific transformations for structured output compatibility.
624654
*/

packages/ai-openrouter/tests/openrouter-adapter.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,6 +1272,45 @@ describe('OpenRouter structured output', () => {
12721272
expect(params.stream).toBe(false)
12731273
})
12741274

1275+
it('honors json_object for non-streaming structured output', async () => {
1276+
setupMockSdkClient([], {
1277+
choices: [
1278+
{
1279+
message: {
1280+
content: '{"name":"Alice","age":30}',
1281+
},
1282+
},
1283+
],
1284+
})
1285+
const adapter = createAdapter()
1286+
1287+
const result = await adapter.structuredOutput({
1288+
chatOptions: {
1289+
model: 'openai/gpt-4o-mini',
1290+
messages: [{ role: 'user', content: 'Give me a person as json' }],
1291+
logger: testLogger,
1292+
modelOptions: {
1293+
responseFormat: { type: 'json_object' },
1294+
},
1295+
},
1296+
outputSchema: {
1297+
type: 'object',
1298+
properties: {
1299+
name: { type: 'string' },
1300+
age: { type: 'number' },
1301+
},
1302+
required: ['name', 'age'],
1303+
},
1304+
})
1305+
1306+
expect(result.data).toEqual({ name: 'Alice', age: 30 })
1307+
const [rawParams] = mockSend.mock.calls[0]!
1308+
expect(rawParams.chatRequest.responseFormat).toEqual({
1309+
type: 'json_object',
1310+
})
1311+
expect(rawParams.chatRequest.stream).toBe(false)
1312+
})
1313+
12751314
it('makes schema OpenAI-strict compatible before sending', async () => {
12761315
// Regression: upstream providers (OpenAI) reject json_schema requests with
12771316
// strict: true unless every object sets additionalProperties: false and
@@ -1436,6 +1475,45 @@ describe('OpenRouter structured output', () => {
14361475
expect(sentSchema.properties.nickname.type).toEqual(['string', 'null'])
14371476
})
14381477

1478+
it('honors json_object through core chat() structured streaming', async () => {
1479+
setupMockSdkClient([
1480+
{
1481+
id: 'c-json-object',
1482+
model: 'openai/gpt-4o-mini',
1483+
choices: [
1484+
{
1485+
delta: { content: '{"name":"Alice","age":30}' },
1486+
finishReason: 'stop',
1487+
},
1488+
],
1489+
},
1490+
])
1491+
const adapter = createAdapter()
1492+
1493+
const result = await chat({
1494+
adapter,
1495+
messages: [{ role: 'user', content: 'Give me a person as json' }],
1496+
modelOptions: {
1497+
responseFormat: { type: 'json_object' },
1498+
},
1499+
outputSchema: {
1500+
type: 'object',
1501+
properties: {
1502+
name: { type: 'string' },
1503+
age: { type: 'number' },
1504+
},
1505+
required: ['name', 'age'],
1506+
},
1507+
})
1508+
1509+
expect(result).toEqual({ name: 'Alice', age: 30 })
1510+
const [rawParams] = mockSend.mock.calls[0]!
1511+
expect(rawParams.chatRequest.responseFormat).toEqual({
1512+
type: 'json_object',
1513+
})
1514+
expect(rawParams.chatRequest.stream).toBe(true)
1515+
})
1516+
14391517
it('parses JSON response content correctly', async () => {
14401518
const nonStreamResponse = {
14411519
choices: [
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": {
5+
"userMessage": "[json-object-wire] return a person as json"
6+
},
7+
"response": {
8+
"content": "{\"name\":\"Alice\",\"age\":30}"
9+
}
10+
}
11+
]
12+
}

testing/e2e/src/routeTree.gen.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { Route as ApiPersistenceDurabilityRouteImport } from './routes/api.persi
3939
import { Route as ApiOtelUsageRouteImport } from './routes/api.otel-usage'
4040
import { Route as ApiOtelMediaRouteImport } from './routes/api.otel-media'
4141
import { Route as ApiOpenrouterWebToolsWireRouteImport } from './routes/api.openrouter-web-tools-wire'
42+
import { Route as ApiOpenrouterJsonObjectWireRouteImport } from './routes/api.openrouter-json-object-wire'
4243
import { Route as ApiOpenrouterCostRouteImport } from './routes/api.openrouter-cost'
4344
import { Route as ApiOpenaiUsageDetailsRouteImport } from './routes/api.openai-usage-details'
4445
import { Route as ApiOpenaiShellSkillsWireRouteImport } from './routes/api.openai-shell-skills-wire'
@@ -230,6 +231,12 @@ const ApiOpenrouterWebToolsWireRoute =
230231
path: '/api/openrouter-web-tools-wire',
231232
getParentRoute: () => rootRouteImport,
232233
} as any)
234+
const ApiOpenrouterJsonObjectWireRoute =
235+
ApiOpenrouterJsonObjectWireRouteImport.update({
236+
id: '/api/openrouter-json-object-wire',
237+
path: '/api/openrouter-json-object-wire',
238+
getParentRoute: () => rootRouteImport,
239+
} as any)
233240
const ApiOpenrouterCostRoute = ApiOpenrouterCostRouteImport.update({
234241
id: '/api/openrouter-cost',
235242
path: '/api/openrouter-cost',
@@ -459,6 +466,7 @@ export interface FileRoutesByFullPath {
459466
'/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute
460467
'/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute
461468
'/api/openrouter-cost': typeof ApiOpenrouterCostRoute
469+
'/api/openrouter-json-object-wire': typeof ApiOpenrouterJsonObjectWireRoute
462470
'/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute
463471
'/api/otel-media': typeof ApiOtelMediaRoute
464472
'/api/otel-usage': typeof ApiOtelUsageRoute
@@ -526,6 +534,7 @@ export interface FileRoutesByTo {
526534
'/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute
527535
'/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute
528536
'/api/openrouter-cost': typeof ApiOpenrouterCostRoute
537+
'/api/openrouter-json-object-wire': typeof ApiOpenrouterJsonObjectWireRoute
529538
'/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute
530539
'/api/otel-media': typeof ApiOtelMediaRoute
531540
'/api/otel-usage': typeof ApiOtelUsageRoute
@@ -594,6 +603,7 @@ export interface FileRoutesById {
594603
'/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute
595604
'/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute
596605
'/api/openrouter-cost': typeof ApiOpenrouterCostRoute
606+
'/api/openrouter-json-object-wire': typeof ApiOpenrouterJsonObjectWireRoute
597607
'/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute
598608
'/api/otel-media': typeof ApiOtelMediaRoute
599609
'/api/otel-usage': typeof ApiOtelUsageRoute
@@ -663,6 +673,7 @@ export interface FileRouteTypes {
663673
| '/api/openai-shell-skills-wire'
664674
| '/api/openai-usage-details'
665675
| '/api/openrouter-cost'
676+
| '/api/openrouter-json-object-wire'
666677
| '/api/openrouter-web-tools-wire'
667678
| '/api/otel-media'
668679
| '/api/otel-usage'
@@ -730,6 +741,7 @@ export interface FileRouteTypes {
730741
| '/api/openai-shell-skills-wire'
731742
| '/api/openai-usage-details'
732743
| '/api/openrouter-cost'
744+
| '/api/openrouter-json-object-wire'
733745
| '/api/openrouter-web-tools-wire'
734746
| '/api/otel-media'
735747
| '/api/otel-usage'
@@ -797,6 +809,7 @@ export interface FileRouteTypes {
797809
| '/api/openai-shell-skills-wire'
798810
| '/api/openai-usage-details'
799811
| '/api/openrouter-cost'
812+
| '/api/openrouter-json-object-wire'
800813
| '/api/openrouter-web-tools-wire'
801814
| '/api/otel-media'
802815
| '/api/otel-usage'
@@ -865,6 +878,7 @@ export interface RootRouteChildren {
865878
ApiOpenaiShellSkillsWireRoute: typeof ApiOpenaiShellSkillsWireRoute
866879
ApiOpenaiUsageDetailsRoute: typeof ApiOpenaiUsageDetailsRoute
867880
ApiOpenrouterCostRoute: typeof ApiOpenrouterCostRoute
881+
ApiOpenrouterJsonObjectWireRoute: typeof ApiOpenrouterJsonObjectWireRoute
868882
ApiOpenrouterWebToolsWireRoute: typeof ApiOpenrouterWebToolsWireRoute
869883
ApiOtelMediaRoute: typeof ApiOtelMediaRoute
870884
ApiOtelUsageRoute: typeof ApiOtelUsageRoute
@@ -1092,6 +1106,13 @@ declare module '@tanstack/react-router' {
10921106
preLoaderRoute: typeof ApiOpenrouterWebToolsWireRouteImport
10931107
parentRoute: typeof rootRouteImport
10941108
}
1109+
'/api/openrouter-json-object-wire': {
1110+
id: '/api/openrouter-json-object-wire'
1111+
path: '/api/openrouter-json-object-wire'
1112+
fullPath: '/api/openrouter-json-object-wire'
1113+
preLoaderRoute: typeof ApiOpenrouterJsonObjectWireRouteImport
1114+
parentRoute: typeof rootRouteImport
1115+
}
10951116
'/api/openrouter-cost': {
10961117
id: '/api/openrouter-cost'
10971118
path: '/api/openrouter-cost'
@@ -1446,6 +1467,7 @@ const rootRouteChildren: RootRouteChildren = {
14461467
ApiOpenaiShellSkillsWireRoute: ApiOpenaiShellSkillsWireRoute,
14471468
ApiOpenaiUsageDetailsRoute: ApiOpenaiUsageDetailsRoute,
14481469
ApiOpenrouterCostRoute: ApiOpenrouterCostRoute,
1470+
ApiOpenrouterJsonObjectWireRoute: ApiOpenrouterJsonObjectWireRoute,
14491471
ApiOpenrouterWebToolsWireRoute: ApiOpenrouterWebToolsWireRoute,
14501472
ApiOtelMediaRoute: ApiOtelMediaRoute,
14511473
ApiOtelUsageRoute: ApiOtelUsageRoute,

0 commit comments

Comments
 (0)