Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/openrouter-json-object-structured-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/ai-openrouter': patch
---

Honor `modelOptions.responseFormat: { type: 'json_object' }` during structured
output generation while preserving strict `json_schema` as the default.
110 changes: 70 additions & 40 deletions packages/ai-openrouter/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,28 +204,32 @@ export class OpenRouterTextAdapter<
}

/**
* Generate structured output via OpenRouter's `responseFormat: { type:
* 'json_schema', jsonSchema: ... }` (camelCase). Uses stream: false to get
* the complete response in one call.
*
* The outputSchema is already JSON Schema (converted in the ai layer).
* We apply OpenAI-strict transformations for cross-provider compatibility.
* Generate structured output via OpenRouter's `responseFormat`. Uses
* `stream: false`. Default is strict `json_schema` from `outputSchema`.
* Callers can opt into JSON mode with
* `modelOptions.responseFormat: { type: 'json_object' }`.
*/
async structuredOutput(
options: StructuredOutputOptions<ResolveProviderOptions<TModel>>,
): Promise<StructuredOutputResult<unknown>> {
const { chatOptions, outputSchema } = options
const chatRequest = this.mapOptionsToRequest(chatOptions)

const jsonSchema = this.makeStructuredOutputCompatible(
const responseFormat = this.resolveStructuredResponseFormat(
chatRequest.responseFormat,
outputSchema,
outputSchema.required,
)

try {
// Strip streamOptions which is only valid for streaming calls
const { streamOptions: _streamOptions, ...cleanParams } = chatRequest
// Strip streamOptions which is only valid for streaming calls. Also
// remove the caller's responseFormat before adding the resolved
// structured-output format below.
const {
streamOptions: _streamOptions,
responseFormat: _responseFormat,
...cleanParams
} = chatRequest
void _streamOptions
void _responseFormat
chatOptions.logger.request(
`activity=structuredOutput provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`,
{ provider: this.name, model: this.model },
Expand All @@ -236,14 +240,7 @@ export class OpenRouterTextAdapter<
chatRequest: {
...cleanParams,
stream: false,
responseFormat: {
type: 'json_schema',
jsonSchema: {
name: 'structured_output',
schema: jsonSchema,
strict: true,
},
},
responseFormat,
},
},
{
Expand Down Expand Up @@ -304,8 +301,8 @@ export class OpenRouterTextAdapter<

/**
* Streamed structured output: a single OpenRouter chat call with
* `responseFormat: { type: 'json_schema', jsonSchema: {...} }` and
* `stream: true`. Emits AG-UI lifecycle events plus a terminal
* `stream: true` and the format from {@link resolveStructuredResponseFormat}.
* Emits AG-UI lifecycle events plus a terminal
* `CUSTOM { name: 'structured-output.complete' }` carrying the parsed
* object and raw JSON text.
*
Expand All @@ -322,10 +319,9 @@ export class OpenRouterTextAdapter<
): AsyncIterable<StreamChunk> {
const { chatOptions, outputSchema } = options
const chatRequest = this.mapOptionsToRequest(chatOptions)

const jsonSchema = this.makeStructuredOutputCompatible(
const responseFormat = this.resolveStructuredResponseFormat(
chatRequest.responseFormat,
outputSchema,
outputSchema.required,
)

const aguiState = {
Expand Down Expand Up @@ -375,14 +371,20 @@ export class OpenRouterTextAdapter<
}.bind(this)

try {
// Strip streamOptions/tools from the base request. Structured output
// sends `responseFormat: json_schema` and doesn't carry tools — keeping
// them can confuse strict-mode validation upstream. (`stream` is
// already absent — `mapOptionsToRequest` returns `Omit<ChatRequest,
// 'stream'>`; we set it explicitly below.)
const { streamOptions: _so, tools: _t, ...cleanParams } = chatRequest
// Strip streamOptions/tools/responseFormat from the base request before
// adding the resolved structured-output format. Structured output
// doesn't carry tools — keeping them can confuse strict-mode validation
// upstream. (`stream` is already absent — `mapOptionsToRequest` returns
// `Omit<ChatRequest, 'stream'>`; we set it explicitly below.)
const {
streamOptions: _so,
tools: _t,
responseFormat: _responseFormat,
...cleanParams
} = chatRequest
void _so
void _t
void _responseFormat

chatOptions.logger.request(
`activity=structuredOutputStream provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`,
Expand All @@ -396,14 +398,7 @@ export class OpenRouterTextAdapter<
...cleanParams,
stream: true,
streamOptions: { includeUsage: true },
responseFormat: {
type: 'json_schema',
jsonSchema: {
name: 'structured_output',
schema: jsonSchema,
strict: true,
},
},
responseFormat,
},
},
{
Expand Down Expand Up @@ -626,6 +621,36 @@ export class OpenRouterTextAdapter<
}
}

/**
* Resolve the provider request format for a schema-bearing call.
*
* Explicit `modelOptions.responseFormat: { type: 'json_object' }` is
* forwarded. Every other value is replaced with strict `json_schema`
* generated from `outputSchema`.
*/
protected resolveStructuredResponseFormat(
requested: ChatRequest['responseFormat'],
outputSchema: JSONSchema,
): NonNullable<ChatRequest['responseFormat']> {
if (requested?.type === 'json_object') {
return { type: 'json_object' }
}

const jsonSchema = this.makeStructuredOutputCompatible(
outputSchema,
outputSchema.required,
)

return {
type: 'json_schema',
jsonSchema: {
name: 'structured_output',
schema: jsonSchema,
strict: true,
},
}
}

/**
* Applies provider-specific transformations for structured output compatibility.
*/
Expand Down Expand Up @@ -1191,11 +1216,16 @@ export class OpenRouterTextAdapter<
? convertToolsToProviderFormat(options.tools)
: undefined

// Attach json_schema only when outputSchema is set and every routed model
// is in the combined-capable set.
// Attach json_schema only when outputSchema is set, every routed model
// is in the combined set, and the caller did not opt into JSON mode.
const combinedOutputSchema: JSONSchema | undefined = options.outputSchema
const requestedResponseFormat =
options.modelOptions != null && 'responseFormat' in options.modelOptions
? options.modelOptions.responseFormat
: undefined
const combinedSchema =
combinedOutputSchema &&
requestedResponseFormat?.type !== 'json_object' &&
this.supportsCombinedToolsAndSchema(options.modelOptions)
? this.makeStructuredOutputCompatible(
combinedOutputSchema,
Expand Down
78 changes: 78 additions & 0 deletions packages/ai-openrouter/tests/openrouter-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1341,6 +1341,45 @@ describe('OpenRouter structured output', () => {
expect(result.usage).toBeUndefined()
})

it('honors json_object for non-streaming structured output', async () => {
setupMockSdkClient([], {
choices: [
{
message: {
content: '{"name":"Alice","age":30}',
},
},
],
})
const adapter = createAdapter()

const result = await adapter.structuredOutput({
chatOptions: {
model: 'openai/gpt-4o-mini',
messages: [{ role: 'user', content: 'Give me a person as json' }],
logger: testLogger,
modelOptions: {
responseFormat: { type: 'json_object' },
},
},
outputSchema: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name', 'age'],
},
})

expect(result.data).toEqual({ name: 'Alice', age: 30 })
const [rawParams] = mockSend.mock.calls[0]!
expect(rawParams.chatRequest.responseFormat).toEqual({
type: 'json_object',
})
expect(rawParams.chatRequest.stream).toBe(false)
})

it('makes schema OpenAI-strict compatible before sending', async () => {
// Regression: upstream providers (OpenAI) reject json_schema requests with
// strict: true unless every object sets additionalProperties: false and
Expand Down Expand Up @@ -1505,6 +1544,45 @@ describe('OpenRouter structured output', () => {
expect(sentSchema.properties.nickname.type).toEqual(['string', 'null'])
})

it('honors json_object through core chat() structured streaming', async () => {
setupMockSdkClient([
{
id: 'c-json-object',
model: 'openai/gpt-4o-mini',
choices: [
{
delta: { content: '{"name":"Alice","age":30}' },
finishReason: 'stop',
},
],
},
])
const adapter = createAdapter()

const result = await chat({
adapter,
messages: [{ role: 'user', content: 'Give me a person as json' }],
modelOptions: {
responseFormat: { type: 'json_object' },
},
outputSchema: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name', 'age'],
},
})

expect(result).toEqual({ name: 'Alice', age: 30 })
const [rawParams] = mockSend.mock.calls[0]!
expect(rawParams.chatRequest.responseFormat).toEqual({
type: 'json_object',
})
expect(rawParams.chatRequest.stream).toBe(true)
})

it('parses JSON response content correctly', async () => {
const nonStreamResponse = {
choices: [
Expand Down
12 changes: 12 additions & 0 deletions testing/e2e/fixtures/openrouter-json-object/basic.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"fixtures": [
{
"match": {
"userMessage": "[json-object-wire] return a person as json"
},
"response": {
"content": "{\"name\":\"Alice\",\"age\":30}"
}
}
]
}
22 changes: 22 additions & 0 deletions testing/e2e/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { Route as ApiOtelUsageRouteImport } from './routes/api.otel-usage'
import { Route as ApiOtelTranscriptionRouteImport } from './routes/api.otel-transcription'
import { Route as ApiOtelMediaRouteImport } from './routes/api.otel-media'
import { Route as ApiOpenrouterWebToolsWireRouteImport } from './routes/api.openrouter-web-tools-wire'
import { Route as ApiOpenrouterJsonObjectWireRouteImport } from './routes/api.openrouter-json-object-wire'
import { Route as ApiOpenrouterCostRouteImport } from './routes/api.openrouter-cost'
import { Route as ApiOpenaiUsageDetailsRouteImport } from './routes/api.openai-usage-details'
import { Route as ApiOpenaiShellSkillsWireRouteImport } from './routes/api.openai-shell-skills-wire'
Expand Down Expand Up @@ -254,6 +255,12 @@ const ApiOpenrouterWebToolsWireRoute =
path: '/api/openrouter-web-tools-wire',
getParentRoute: () => rootRouteImport,
} as any)
const ApiOpenrouterJsonObjectWireRoute =
ApiOpenrouterJsonObjectWireRouteImport.update({
id: '/api/openrouter-json-object-wire',
path: '/api/openrouter-json-object-wire',
getParentRoute: () => rootRouteImport,
} as any)
const ApiOpenrouterCostRoute = ApiOpenrouterCostRouteImport.update({
id: '/api/openrouter-cost',
path: '/api/openrouter-cost',
Expand Down Expand Up @@ -516,6 +523,7 @@ export interface FileRoutesByFullPath {
'/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute
'/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute
'/api/openrouter-cost': typeof ApiOpenrouterCostRoute
'/api/openrouter-json-object-wire': typeof ApiOpenrouterJsonObjectWireRoute
'/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute
'/api/otel-media': typeof ApiOtelMediaRoute
'/api/otel-transcription': typeof ApiOtelTranscriptionRoute
Expand Down Expand Up @@ -591,6 +599,7 @@ export interface FileRoutesByTo {
'/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute
'/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute
'/api/openrouter-cost': typeof ApiOpenrouterCostRoute
'/api/openrouter-json-object-wire': typeof ApiOpenrouterJsonObjectWireRoute
'/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute
'/api/otel-media': typeof ApiOtelMediaRoute
'/api/otel-transcription': typeof ApiOtelTranscriptionRoute
Expand Down Expand Up @@ -667,6 +676,7 @@ export interface FileRoutesById {
'/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute
'/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute
'/api/openrouter-cost': typeof ApiOpenrouterCostRoute
'/api/openrouter-json-object-wire': typeof ApiOpenrouterJsonObjectWireRoute
'/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute
'/api/otel-media': typeof ApiOtelMediaRoute
'/api/otel-transcription': typeof ApiOtelTranscriptionRoute
Expand Down Expand Up @@ -744,6 +754,7 @@ export interface FileRouteTypes {
| '/api/openai-shell-skills-wire'
| '/api/openai-usage-details'
| '/api/openrouter-cost'
| '/api/openrouter-json-object-wire'
| '/api/openrouter-web-tools-wire'
| '/api/otel-media'
| '/api/otel-transcription'
Expand Down Expand Up @@ -819,6 +830,7 @@ export interface FileRouteTypes {
| '/api/openai-shell-skills-wire'
| '/api/openai-usage-details'
| '/api/openrouter-cost'
| '/api/openrouter-json-object-wire'
| '/api/openrouter-web-tools-wire'
| '/api/otel-media'
| '/api/otel-transcription'
Expand Down Expand Up @@ -894,6 +906,7 @@ export interface FileRouteTypes {
| '/api/openai-shell-skills-wire'
| '/api/openai-usage-details'
| '/api/openrouter-cost'
| '/api/openrouter-json-object-wire'
| '/api/openrouter-web-tools-wire'
| '/api/otel-media'
| '/api/otel-transcription'
Expand Down Expand Up @@ -970,6 +983,7 @@ export interface RootRouteChildren {
ApiOpenaiShellSkillsWireRoute: typeof ApiOpenaiShellSkillsWireRoute
ApiOpenaiUsageDetailsRoute: typeof ApiOpenaiUsageDetailsRoute
ApiOpenrouterCostRoute: typeof ApiOpenrouterCostRoute
ApiOpenrouterJsonObjectWireRoute: typeof ApiOpenrouterJsonObjectWireRoute
ApiOpenrouterWebToolsWireRoute: typeof ApiOpenrouterWebToolsWireRoute
ApiOtelMediaRoute: typeof ApiOtelMediaRoute
ApiOtelTranscriptionRoute: typeof ApiOtelTranscriptionRoute
Expand Down Expand Up @@ -1220,6 +1234,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiOpenrouterWebToolsWireRouteImport
parentRoute: typeof rootRouteImport
}
'/api/openrouter-json-object-wire': {
id: '/api/openrouter-json-object-wire'
path: '/api/openrouter-json-object-wire'
fullPath: '/api/openrouter-json-object-wire'
preLoaderRoute: typeof ApiOpenrouterJsonObjectWireRouteImport
parentRoute: typeof rootRouteImport
}
'/api/openrouter-cost': {
id: '/api/openrouter-cost'
path: '/api/openrouter-cost'
Expand Down Expand Up @@ -1615,6 +1636,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiOpenaiShellSkillsWireRoute: ApiOpenaiShellSkillsWireRoute,
ApiOpenaiUsageDetailsRoute: ApiOpenaiUsageDetailsRoute,
ApiOpenrouterCostRoute: ApiOpenrouterCostRoute,
ApiOpenrouterJsonObjectWireRoute: ApiOpenrouterJsonObjectWireRoute,
ApiOpenrouterWebToolsWireRoute: ApiOpenrouterWebToolsWireRoute,
ApiOtelMediaRoute: ApiOtelMediaRoute,
ApiOtelTranscriptionRoute: ApiOtelTranscriptionRoute,
Expand Down
Loading