Summary
assessMergeCandidates describes its response items with z.record(z.string(), z.unknown()). Against OpenAI's Structured Outputs this is rejected outright, so every merge check fails with a 400 and no merge suggestion is ever created.
The intent of the loose item schema is documented in the code, and it is a reasonable one — but the JSON Schema it produces is not accepted by the provider.
Environment
- Self-hosted, single workspace,
v0.13.2-1017-g9d222efbf
- OpenAI directly:
OPENAI_BASE_URL=https://api.openai.com/v1
AI_CHAT_MODEL=gpt-4.1-mini, AI_EMBEDDING_MODEL=text-embedding-3-small
- zod
4.4.3, @tanstack/ai 0.52.0
Steps to reproduce
- Configure AI against
https://api.openai.com/v1 with a chat model and an embedding model.
- Submit two near-duplicate posts to the same board.
- Wait for the merge sweep (it runs at service start).
Actual
The candidate is found by embedding similarity, then the LLM verification fails:
{"component":"merge-check","candidate_count":1,"post_id":"…","msg":"found merge candidates"}
❌ [tanstack-ai:errors] ❌ openai-compatible.chatStream fatal
{"component":"embeddings","post_id":"…","err":{"type":"Error","message":
"400 Invalid schema for response_format 'structured_output': In context=('properties', 'results', 'type', '0', 'items'), 'propertyNames' is not permitted.",
"stack":"… at runAgenticStructuredOutput … at async assessMergeCandidates (merge-check.service … )","code":"400"},
"msg":"merge check failed"}
checkPostForMergeCandidates therefore never stamps mergeCheckedAt, so the sweep retries the same posts on every pass and duplicate detection is permanently unavailable.
Cause
merge-assessment.service.ts:
const MergeAssessmentResponseSchema = z.object({
results: z.array(z.record(z.string(), z.unknown())).catch([]),
})
zod 4 renders z.record(z.string(), z.unknown()) with a propertyNames keyword. OpenAI's Structured Outputs schema validator does not permit propertyNames, so the request is rejected before the model ever runs. Providers with a laxer schema check accept the same request, which is presumably why this has not surfaced before.
Suggested fix
Give the items the strict shape the system prompt already demands:
const MergeAssessmentItemSchema = z.strictObject({
candidatePostId: z.string(),
isDuplicate: z.boolean(),
confidence: z.number(),
reasoning: z.string(),
})
const MergeAssessmentResponseSchema = z.object({
results: z.array(MergeAssessmentItemSchema).catch([]),
})
The tolerance the comment argues for is moot under Structured Outputs: the provider guarantees the shape, so there is no malformed item left to skip. results keeps .catch([]), so a wrong-shaped top level still degrades to "no assessments" instead of throwing. The typeof guards in the filter loop below can stay as they are — they simply stop being load-bearing.
I have been running exactly this change for a day; duplicate detection then works end to end (candidate found by embedding, confirmed by the model with a confidence of 0.95, suggestion created). Happy to open a PR if you would like it in that form.
Note
This is not model-specific. It is the schema that is rejected, so the request never reaches a model — switching chat models does not help.
Summary
assessMergeCandidatesdescribes its response items withz.record(z.string(), z.unknown()). Against OpenAI's Structured Outputs this is rejected outright, so every merge check fails with a 400 and no merge suggestion is ever created.The intent of the loose item schema is documented in the code, and it is a reasonable one — but the JSON Schema it produces is not accepted by the provider.
Environment
v0.13.2-1017-g9d222efbfOPENAI_BASE_URL=https://api.openai.com/v1AI_CHAT_MODEL=gpt-4.1-mini,AI_EMBEDDING_MODEL=text-embedding-3-small4.4.3,@tanstack/ai0.52.0Steps to reproduce
https://api.openai.com/v1with a chat model and an embedding model.Actual
The candidate is found by embedding similarity, then the LLM verification fails:
checkPostForMergeCandidatestherefore never stampsmergeCheckedAt, so the sweep retries the same posts on every pass and duplicate detection is permanently unavailable.Cause
merge-assessment.service.ts:zod 4 renders
z.record(z.string(), z.unknown())with apropertyNameskeyword. OpenAI's Structured Outputs schema validator does not permitpropertyNames, so the request is rejected before the model ever runs. Providers with a laxer schema check accept the same request, which is presumably why this has not surfaced before.Suggested fix
Give the items the strict shape the system prompt already demands:
The tolerance the comment argues for is moot under Structured Outputs: the provider guarantees the shape, so there is no malformed item left to skip.
resultskeeps.catch([]), so a wrong-shaped top level still degrades to "no assessments" instead of throwing. Thetypeofguards in the filter loop below can stay as they are — they simply stop being load-bearing.I have been running exactly this change for a day; duplicate detection then works end to end (candidate found by embedding, confirmed by the model with a confidence of 0.95, suggestion created). Happy to open a PR if you would like it in that form.
Note
This is not model-specific. It is the schema that is rejected, so the request never reaches a model — switching chat models does not help.