Skip to content

x-ai/grok-4.5: required array-of-objects tool param never emitted; reduced schema transposes arg keys; billed tokens on empty forced-tool_choice responses #176

Description

@philipdalen

Model: x-ai/grok-4.5 (upstream x-ai/grok-4.5-20260708), provider xAI, via POST /api/v1/chat/completions (streaming and non-streaming both affected). Observed 2026-07-29 on our production account; all generation IDs below are from that account. temperature: 0.2, reasoning: {effort: "low"} unless noted (high behaves the same). Control model on identical payloads: google/gemini-3.5-flash — always correct.

Filing here because Discord is hard to attach a full repro to — happy to move/cross-post this if there is a better channel.

Summary

For one of our production tools (a bookkeeping proposal tool whose schema has 14 top-level properties, a nested object property, and a required array-of-objects property allocationLines with 17 item fields and two enums), grok-4.5 tool calling is broken in three observable ways:

  1. The required allocationLines parameter is never emitted. Across 19+ calls (a production incident plus systematic repro runs) with the full schema, the model returns an otherwise-valid tool call that omits the required array — at low and high reasoning effort, with 1 tool or 19 tools in the request, regardless of prompting, even after a tool-result error message explicitly names the missing parameter. Billed completion tokens match the visible flat arguments (nothing hidden was generated in these cases).
  2. When the schema is reduced to only its required properties, the array appears — with corrupted keys. 5/5 runs emit allocation lines where an enum value lands on the wrong key and the required enum key is missing, e.g. "tredcoProjectId": "employee_clearing" / "tredcoProjectId": "business_expense" / "tredcoProjectId": "treatment" (tredcoProjectId is a string ID field; treatment is the required enum). Identical corruption at effort: high (1 600+ reasoning tokens), so it is not sampling noise.
  3. Intermittently, responses come back with no tool call and empty/near-empty content while hundreds of completion tokens are billed — including under forced tool_choice. With tool_choice: {"type":"function","function":{"name":...}} the API returned finish_reason: "stop" with an empty message and no tool_calls while billing 246–285 completion tokens (only ~24 of them reasoning) — 5/5 in one configuration with a ~2.8 KB system prompt. In other runs 363–924 completion tokens were billed for ~20–25 visible text tokens and no tool call. The visible text in those runs even narrates the allocation lines the tool call should have carried. With a one-line system prompt the same forced call sometimes succeeds, so this facet is intermittent/context-length-correlated.

The model demonstrably knows the parameter: asked to recite the tool schema (no tool call), it lists allocationLines as required plus all 17 nested field names correctly. Small toy schemas of the same shape — required array-of-objects, including one with the same 5-value enum in items, and one with 17 item fields — work perfectly. The failure is specific to the full production schema, which suggests the server-side function-calling pipeline (schema-guided decoding / argument validation) mis-handles it above some complexity threshold.

Generation IDs (our account, for server-side inspection)

Behavior Generation Evidence
Required array omitted (minimal repro, scenario A) gen-1785348939-S4oh46bpsVzuC01CbZJo 718 completion tokens (488 reasoning), args have 8 keys, no allocationLines
Required array omitted (production, 3rd retry after 2 explicit error feedbacks) gen-1785310378-gmBxA1Ro45RYI1PZ7joH 215 completion tokens ≈ visible flat args exactly
Same, earlier retries in production gen-1785310355-9roddAlFvRrR4Gox2iBt, gen-1785310371-qomZaoZeYqASdNrkR5Pu 218 / 198 completion tokens, flat args
Empty message + billed tokens under forced tool_choice gen-1785348877-O3JzpTcfno8QrvkXjct4 finish_reason:"stop", 284 completion tokens billed (23 reasoning), ~90 chars visible text, no tool call
Same, fully empty gen-1785348882-OR29DBOAT1VENSWH2Ybw finish_reason:"stop", 285 completion tokens billed (24 reasoning), empty content, no tool call
Key-transposed array on reduced schema (scenario C) gen-1785348958-LPF3VPE9Zddt3u9jYyVL line contains "tredcoProjectId": "employee_clearing", required treatment missing
Forced+dictated call passing with short system prompt (intermittency counterexample) gen-1785348953-584onbPh9VEwXWZq8o9u dictated array came through intact
Control: gemini-3.5-flash, same payload gen-1785348969-wy2trHyn6jOkfqf3zjYx correct 2-line allocationLines

Reproduction

Two files, no dependencies (OPENROUTER_API_KEY=... node standalone_repro.mjs). Scenario A (required param omitted) and C (transposed keys) reproduce deterministically for us; B is intermittent as described.

standalone_repro.mjs
// Repro for: x-ai/grok-4.5 via OpenRouter never emits a required nested-array
// tool parameter; forced tool_choice returns an empty message with billed
// completion tokens; a reduced schema emits the array with transposed keys.
// Usage: OPENROUTER_API_KEY=... node standalone_repro.mjs
import { readFileSync } from "node:fs"

const TOOL = JSON.parse(readFileSync(new URL("./alloc-tool.json", import.meta.url), "utf8"))
const apiKey = process.env.OPENROUTER_API_KEY
if (!apiKey) throw new Error("OPENROUTER_API_KEY missing")

const SYSTEM = "You are an accounting assistant for a small Norwegian business. Reply in Norwegian (nb-NO). Today is 2026-07-29."
const TASK = [
  "Bokfør denne kvitteringen som privat utlegg.",
  "[Kvittering: bensinstasjon 29.07.2026 — Diesel 1 336,88 kr, Spylervæske 82,90 kr, totalt 1 419,78 kr, alle linjer 25 % MVA, betalt privat.]",
].join("\n")
const DICTATED = JSON.stringify([
  { description: "Diesel", accountNumber: "6250", netAmountMinor: 106950, vatAmountMinor: 26738, vatRate: 25, treatment: "business_expense" },
  { description: "Spylervæske", accountNumber: "6390", netAmountMinor: 6632, vatAmountMinor: 1658, vatRate: 25, treatment: "business_expense" },
])
const DICTATE_TASK = [
  "Kall accounting_ledger_proposeAllocatedTransaction nå (privat utlegg, totalt 1 419,78 kr, dato 2026-07-29).",
  "Bruk NØYAKTIG denne verdien for parameteren allocationLines, kopier den ordrett inn i verktøykallet:",
  DICTATED,
].join("\n")

function requiredOnlyVariant(tool) {
  const clone = JSON.parse(JSON.stringify(tool))
  const params = clone.function.parameters
  params.properties = Object.fromEntries(
    Object.entries(params.properties).filter(([key]) => params.required.includes(key)),
  )
  return clone
}

async function complete({ model, userMessage, tool, force }) {
  const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
    method: "POST",
    headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      model,
      messages: [
        { role: "system", content: SYSTEM },
        { role: "user", content: userMessage },
      ],
      tools: [tool],
      tool_choice: force
        ? { type: "function", function: { name: tool.function.name } }
        : "auto",
      reasoning: { effort: "low" },
      stream: false,
      temperature: 0.2,
    }),
  })
  if (!res.ok) throw new Error(`OpenRouter ${res.status}: ${(await res.text()).slice(0, 300)}`)
  const body = await res.json()
  const message = body.choices?.[0]?.message
  const call = message?.tool_calls?.[0]
  const args = call ? JSON.parse(call.function.arguments) : null
  return {
    generationId: body.id,
    provider: body.provider,
    finishReason: body.choices?.[0]?.finish_reason,
    completionTokens: body.usage?.completion_tokens,
    reasoningTokens: body.usage?.completion_tokens_details?.reasoning_tokens,
    visibleContentChars: (message?.content ?? "").length,
    toolCall: call ? { argKeys: Object.keys(args), allocationLines: args.allocationLines ?? "ABSENT" } : "NO_TOOL_CALL",
  }
}

console.log("A) grok-4.5, auto tool_choice, task prompt — expect allocationLines ABSENT despite being required:")
console.log(JSON.stringify(await complete({ model: "x-ai/grok-4.5", userMessage: TASK, tool: TOOL, force: false }), null, 2))

console.log("\nB) grok-4.5, FORCED tool_choice + exact allocationLines JSON dictated — INTERMITTENT: sometimes passes, sometimes returns no tool call / empty message with ~250-900 billed completion tokens (see generation ids in report):")
console.log(JSON.stringify(await complete({ model: "x-ai/grok-4.5", userMessage: DICTATE_TASK, tool: TOOL, force: true }), null, 2))

console.log("\nC) grok-4.5, schema reduced to required-only properties, forced — allocationLines appears but with transposed keys (enum values on wrong keys):")
console.log(JSON.stringify(await complete({ model: "x-ai/grok-4.5", userMessage: TASK, tool: requiredOnlyVariant(TOOL), force: true }), null, 2))

console.log("\nD) control: google/gemini-3.5-flash, same request as A — expect correct allocationLines array:")
console.log(JSON.stringify(await complete({ model: "google/gemini-3.5-flash", userMessage: TASK, tool: TOOL, force: false }), null, 2))
alloc-tool.json — the failing tool schema (verbatim from production)
{
  "type": "function",
  "function": {
    "name": "accounting_ledger_proposeAllocatedTransaction",
    "description": "Prepare a day-to-day accounting transaction proposal with required structured allocationLines for human review before booking. Use this for multi-line receipts or mixed account/VAT/private/payback treatment so the review card and approval use the line-level split instead of a single flat account. Opening balances must use the accounting onboarding opening-balance tools.",
    "parameters": {
      "type": "object",
      "properties": {
        "description": {
          "type": "string",
          "description": "Human-readable transaction description."
        },
        "amountNok": {
          "type": "number",
          "description": "Gross amount in NOK, not minor currency units."
        },
        "type": {
          "type": "string",
          "description": "Whether this is income or an expense.",
          "enum": [
            "income",
            "expense"
          ]
        },
        "date": {
          "type": "string",
          "description": "Transaction date in YYYY-MM-DD format. Defaults to today."
        },
        "paymentMethod": {
          "type": "string",
          "description": "Optional payment method.",
          "enum": [
            "bank_transfer",
            "company_card",
            "private_card",
            "cash",
            "other"
          ]
        },
        "tredcoProjectId": {
          "type": "string",
          "description": "Optional canonical web project ID for allocation."
        },
        "clearProject": {
          "type": "boolean",
          "description": "Set true to record without a project, overriding the chat's conversation-default project. Do not combine with tredcoProjectId."
        },
        "allocationLines": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "lineId": {
                "type": "string",
                "description": "Stable source/review line ID."
              },
              "sourceLineIndex": {
                "type": "number",
                "description": "Zero-based source receipt/invoice line index when known."
              },
              "description": {
                "type": "string",
                "description": "Source-line description shown to the reviewer."
              },
              "accountNumber": {
                "type": "string",
                "description": "Four-digit NS 4102 account for this line."
              },
              "accountName": {
                "type": "string",
                "description": "Display name for accountNumber."
              },
              "grossAmountMinor": {
                "type": "number",
                "description": "Gross line amount in øre, including VAT. If a receipt line column is before VAT, put that amount in netAmountMinor and provide vatAmountMinor instead."
              },
              "grossAmountNok": {
                "type": "number",
                "description": "Gross line amount in NOK, including VAT; use only when minor units are not available."
              },
              "netAmountMinor": {
                "type": "number",
                "description": "Net/pre-VAT line amount in øre when VAT is split or the receipt lists line amounts before VAT."
              },
              "vatAmountMinor": {
                "type": "number",
                "description": "VAT amount in øre for this line; private/excluded lines may include VAT here for review even when it should not be posted to input VAT."
              },
              "vatRate": {
                "type": "number",
                "description": "VAT percentage for this line."
              },
              "debitAmountMinor": {
                "type": "number",
                "description": "Explicit debit amount in øre for special allocation lines."
              },
              "creditAmountMinor": {
                "type": "number",
                "description": "Explicit credit amount in øre for payback/refund/private offset lines."
              },
              "treatment": {
                "type": "string",
                "description": "Required review treatment for the line.",
                "enum": [
                  "business_expense",
                  "employee_clearing",
                  "deposit_or_payback",
                  "private_exclusion",
                  "supplier_invoice_expense"
                ]
              },
              "status": {
                "type": "string",
                "description": "Whether this line is ready or needs clarification before approval.",
                "enum": [
                  "ready",
                  "needs_clarification"
                ]
              },
              "confidence": {
                "type": "number",
                "description": "Assistant confidence on a 0-100 scale."
              },
              "rationale": {
                "type": "string",
                "description": "Line-level accounting rationale shown in the review card."
              },
              "tredcoProjectId": {
                "type": "string",
                "description": "Canonical web project ID for this line when a receipt is split across jobs; omit to use the proposal-level project."
              }
            },
            "required": [
              "accountNumber",
              "treatment"
            ],
            "additionalProperties": false
          },
          "description": "Required structured source/review lines for line-level booking. Structured source/review lines for multi-account booking. Use whenever the receipt or pending transaction has multiple source lines, mixed accounts, mixed VAT rates, employee clearing, deposit/payback, or private/excluded parts; do not put those splits only in rationale. Line amounts must balance to the gross payment total; Norwegian receipts often list source lines before VAT, so use netAmountMinor plus vatAmountMinor when the receipt has a separate MVA summary. Each line can carry account, VAT, treatment, status, confidence, and explicit debit/credit amounts for special cases."
        },
        "rationale": {
          "type": "string",
          "description": "Plain-language note, in the user's language, on why the split is booked this way and the one assumption the user should confirm."
        },
        "hasReceipt": {
          "type": "boolean",
          "description": "Whether the proposal is based on an attached receipt."
        },
        "receiptFileId": {
          "type": "string",
          "description": "Legacy file ID for the attached receipt. Prefer receiptFileRef."
        },
        "receiptFileRef": {
          "type": "object",
          "properties": {
            "ownerService": {
              "type": "string",
              "description": "Service that originally owns the uploaded file metadata.",
              "enum": [
                "web",
                "accounting",
                "agent"
              ]
            },
            "fileId": {
              "type": "string",
              "description": "Stable file ID."
            },
            "tenantId": {
              "type": "string",
              "description": "Tenant that owns the file."
            },
            "name": {
              "type": "string",
              "description": "Original filename."
            },
            "contentType": {
              "type": "string",
              "description": "MIME content type."
            },
            "size": {
              "type": "number",
              "description": "File size in bytes."
            },
            "purpose": {
              "type": "string",
              "description": "File purpose.",
              "enum": [
                "chat_attachment",
                "receipt",
                "supplier_invoice",
                "quote_source",
                "generated_output",
                "scratch"
              ]
            }
          },
          "required": [
            "ownerService",
            "fileId"
          ],
          "additionalProperties": false,
          "description": "Owner-tagged file reference for the attached receipt."
        },
        "requestReceipt": {
          "type": "boolean",
          "description": "Show an upload affordance for the missing receipt on the proposal card. Set when the booking claims a VAT deduction or otherwise needs evidence and none is attached. Do not set for bank fees, interest, or entries that need no documentation."
        }
      },
      "required": [
        "description",
        "amountNok",
        "type",
        "allocationLines"
      ],
      "additionalProperties": false
    }
  }
}
Full output of a verification run (2026-07-29)
A) grok-4.5, auto tool_choice, task prompt — expect allocationLines ABSENT despite being required:
{
  "generationId": "gen-1785348939-S4oh46bpsVzuC01CbZJo",
  "provider": "xAI",
  "finishReason": "tool_calls",
  "completionTokens": 718,
  "reasoningTokens": 488,
  "visibleContentChars": 78,
  "toolCall": {
    "argKeys": [
      "description",
      "amountNok",
      "type",
      "date",
      "paymentMethod",
      "hasReceipt",
      "requestReceipt",
      "rationale"
    ],
    "allocationLines": "ABSENT"
  }
}

B) grok-4.5, FORCED tool_choice + exact allocationLines JSON dictated — expect NO_TOOL_CALL / empty message with ~250 billed completion tokens:
{
  "generationId": "gen-1785348953-584onbPh9VEwXWZq8o9u",
  "provider": "xAI",
  "finishReason": "tool_calls",
  "completionTokens": 281,
  "reasoningTokens": 117,
  "visibleContentChars": 57,
  "toolCall": {
    "argKeys": [
      "description",
      "amountNok",
      "type",
      "allocationLines",
      "date"
    ],
    "allocationLines": [
      {
        "accountNumber": "6250",
        "treatment": "business_expense",
        "description": "Diesel",
        "netAmountMinor": 106950,
        "vatAmountMinor": 26738,
        "vatRate": 25
      },
      {
        "accountNumber": "6390",
        "treatment": "business_expense",
        "description": "Spylervæske",
        "netAmountMinor": 6632,
        "vatAmountMinor": 1658,
        "vatRate": 25
      }
    ]
  }
}

C) grok-4.5, schema reduced to required-only properties, forced — allocationLines appears but with transposed keys (enum values on wrong keys):
{
  "generationId": "gen-1785348958-LPF3VPE9Zddt3u9jYyVL",
  "provider": "xAI",
  "finishReason": "tool_calls",
  "completionTokens": 580,
  "reasoningTokens": 288,
  "visibleContentChars": 82,
  "toolCall": {
    "argKeys": [
      "description",
      "amountNok",
      "type",
      "allocationLines"
    ],
    "allocationLines": [
      {
        "accountNumber": "7020",
        "accountName": "Drivstoff",
        "description": "Diesel",
        "grossAmountNok": 1336.88,
        "vatRate": 25,
        "tredcoProjectId": "employee_clearing"
      }
    ]
  }
}

D) control: google/gemini-3.5-flash, same request as A — expect correct allocationLines array:
{
  "generationId": "gen-1785348969-wy2trHyn6jOkfqf3zjYx",
  "provider": "Google",
  "finishReason": "tool_calls",
  "completionTokens": 1209,
  "reasoningTokens": 863,
  "visibleContentChars": 0,
  "toolCall": {
    "argKeys": [
      "rationale",
      "paymentMethod",
      "type",
      "hasReceipt",
      "date",
      "allocationLines",
      "amountNok",
      "description"
    ],
    "allocationLines": [
      {
        "grossAmountMinor": 133688,
        "vatRate": 25,
        "accountNumber": "7000",
        "status": "ready",
        "confidence": 95,
        "vatAmountMinor": 26738,
        "accountName": "Drivstoff transportmidler 1",
        "rationale": "Diesel til bil benyttet i næring.",
        "netAmountMinor": 106950,
        "treatment": "business_expense"
      },
      {
        "accountNumber": "7040",
        "vatAmountMinor": 1658,
        "confidence": 90,
        "status": "ready",
        "treatment": "business_expense",
        "grossAmountMinor": 8290,
        "accountName": "Annet bilhold",
        "netAmountMinor": 6632,
        "vatRate": 25,
        "rationale": "Spylervæske til bil benyttet i næring."
      }
    ]
  }
}

Expected

  • A parameter listed in required is emitted (or the request fails loudly).
  • tool_choice: {"type":"function",...} never returns a text-only/empty message.
  • Billed completion tokens correspond to returned output.
  • Argument keys are not transposed (enum values must not land on unrelated keys).

Impact

In production this looped a customer-facing agent: the tool rejects the argument object missing its required parameter, the model retries (announcing, in text, the lines it never manages to emit), and the turn eventually died after repeated failures — while every retry was billed. The key-transposition facet is arguably worse: with a slimmer schema the call succeeds with silently wrong field assignments.

Happy to provide more generation IDs, run variants, or A/B anything else against our account.

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