Skip to content

[Bug] Merge assessment always fails with 400 against OpenAI: z.record renders propertyNames, which Structured Outputs reject #505

Description

@bemany

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

  1. Configure AI against https://api.openai.com/v1 with a chat model and an embedding model.
  2. Submit two near-duplicate posts to the same board.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions