Conversation
…ted response A response the provider stopped at max_output_tokens can still carry a function_call item whose arguments are whatever prefix fit in the budget. The loop parsed that fragment, failed, fed the parse error back to the model, and issued another request against the same exhausted budget, so every truncated turn cost one wasted round trip. extractToolCallsFromResponse and responseHasToolCalls now yield no tool calls when status is incomplete with reason max_output_tokens. The loop finalizes on the truncated turn and the caller sees incompleteDetails.
…ore the cut-off Review feedback: a parallel turn can complete one function_call and be cut off at max_output_tokens during the next. Rejecting the whole response discarded the completed call. Filter per item instead: on a truncated response a function_call is executable iff its status is completed (arguments must parse when the provider omits status). The dropped item is also excluded from state and from the next request's input, so no call is echoed without an output.
…eted before the cut-off" This reverts commit b212546.
A truncated turn's function_call items are one unfinished batch, so none of them execute - including the ones completed before the cut-off. Running the complete prefix would hand the model a result set with a silent hole and spend side effects on a turn that truncates the same way on the same budget. Adds the mixed-batch case (three completed calls plus one cut call) asserting zero executions, one request, and the full output returned to the caller.
There was a problem hiding this comment.
Approve posted as COMMENT — the maintainer app lacks
pull_requests:writeon OpenRouterTeam, so the APPROVE event is rejected by GitHub. The assessment below is an approval; a human reviewer with write access can merge.
Perry's Review
Verdict: ✅ LGTM
The fix is at the right level: isTruncatedAtMaxOutputTokens gates both responseHasToolCalls and extractToolCallsFromResponse, so all 11 call sites in model-result.ts (the main loop gate at ~L6311, mid-loop re-extraction at ~L6336/6405, and consumer APIs getToolCalls/getText/etc.) are covered uniformly — no per-call-site patching needed.
Loop decision verified
At model-result.ts, the gate if (!hasToolCalls) { validateFinalResponse; finalResponse = currentResponse; markStateComplete; return } fires when responseHasToolCalls returns false. validateFinalResponse only throws on empty/missing output, and a truncated turn's output carries the cut-off function_call (non-empty), so it finalizes cleanly — no throw, no re-request, no execution. The tests confirm: one betaResponsesSend call, zero executions, status: 'incomplete' surfaced.
Premise checks out
Pre-fix extractToolCallsFromResponse caught the JSON.parse failure but still pushed the call with raw fragment args (arguments: item.arguments as unknown), so the loop executed the whole batch (completed-before-cutoff calls ran with real side effects), the fragment call failed as a tool error, and the loop re-requested on the same exhausted budget — exactly the wasted generation + misclassified shell error the PR describes. The response-level "execute nothing" choice avoids both. Matches Vercel AI SDK's isToolExecutionAllowedFinishReason (refuses on length) and OpenAI's hosted-shell behaviour.
Tests
Both scenarios (single cut-off call; mixed batch with completed + cut-off) assert zero executions, one request, and incomplete status. The mixed-batch test also asserts the full output (including the cut-off item) is returned to the caller. All fail on main. CI is green.
One non-blocking question (posted inline)
getToolCallsStream() (via buildToolCallStream) doesn't go through the new guard — it parses SSE output_item.done events directly, so it can still yield the cut-off call with raw fragment args, while getToolCalls() returns []. This isn't hit by the DEV-1188 path (which uses getResponse()), and buildToolCallStream yields incrementally before the terminal response.incomplete event arrives so it can't cheaply know the truncation status at yield-time. Worth a decision: document the divergence, or buffer until the terminal event.
Risk assessment
Risk: 🟢 Low
Estimated impact: Low — worst case if wrong is a recoverable no-op turn the caller re-requests after raising the budget.
| Dimension | Severity | Risk | Reasoning |
|---|---|---|---|
| Implementation risk | 🟩 | Low | Small, well-placed change at the shared-helper level; 11 call sites covered uniformly; tests fail on main and pass with the change. |
| Premise risk | 🟩 | Low | Diagnosis supported by production telemetry (3,628 parse errors / 7 days); design decision matches the Vercel AI SDK reference and was deliberated in the sibling PR. |
| Estimated impact | 🟩 | Low | The change makes the loop more conservative (executes fewer calls, stops a wasted re-request); worst case if wrong is a recoverable no-op turn the caller re-requests after raising the budget. |
| Risk Factor | Severity | Risk | Reasoning |
|---|---|---|---|
| Reversibility | 🟩 | Low | Removing the guard restores prior behaviour; no persisted state is mutated. |
| Detectability | 🟩 | Low | The truncated turn surfaces incomplete_details.reason to the caller immediately. |
| Blast radius | 🟩 | Low | Only affects turns truncated at max_output_tokens carrying function_call items. |
| Data integrity | None | Low | No persisted state is touched by the change. |
| Financial exposure | 🟩 | Low | The change reduces billed generations by eliminating the wasted retry. |
| Security and privacy exposure | None | Low | Nothing sensitive is reachable from the change. |
| Propagation | 🟩 | Low | Downstream consumers (getToolCalls) see [] consistently; the streaming surface has a latent divergence (noted above). |
| Availability | None | Low | The change cannot affect whether anything serves. |
| Recovery cost | 🟩 | Low | A missed execution is recovered by the caller re-requesting with a higher budget. |
| Time to correct | 🟩 | Low | A revert or guard broadening is a one-line change. |
| * carries a cut-off `function_call` item. | ||
| */ | ||
| export function responseHasToolCalls(response: models.OpenResponsesResult): boolean { | ||
| if (isTruncatedAtMaxOutputTokens(response)) { |
There was a problem hiding this comment.
Question: this guard covers the loop's execution decision (responseHasToolCalls → finalize) and extractToolCallsFromResponse, but the streaming surface getToolCallsStream() → buildToolCallStream parses SSE output_item.done events directly and bypasses both. On a truncated turn it can still yield the cut-off call with raw fragment args (arguments: '{"commands":'), while getToolCalls() returns []. That divergence is latent (the DEV-1188 path uses getResponse(), not the streaming tool API) and buildToolCallStream yields incrementally before the terminal response.incomplete event, so it can't cheaply know the truncation status at yield-time. Is the divergence intentional (streaming reflects "what the model emitted," non-streaming reflects "what the loop executes"), or should the streaming surface buffer until the terminal event and suppress/flag truncated calls too? If intentional, a one-line note on responseHasToolCalls/extractToolCallsFromResponse documenting the scope would close the gap.
There was a problem hiding this comment.
AGENT-WRITTEN: Intentional. getToolCallsStream() is a consumer view of what the model emitted, yielded per output_item.done before the terminal event exists; the two guarded functions are the execution decision on the completed response. Buffering the stream until the terminal event would defeat its purpose. Added the scope note to the responseHasToolCalls docblock (mirrored in the openrouter-web patch).
Problem
When the provider stops a turn at
max_output_tokens, the response carriesstatus: incomplete,incomplete_details.reason: max_output_tokens, and whateverfunction_callitems the model had emitted - the last one holding an argument prefix. The loop never looked at the response status:responseHasToolCallssaw afunction_callitem,extractToolCallsFromResponsetried toJSON.parsethe fragment, the parse failure was fed back to the model as a tool error, and the loop requested again on the same exhausted budget. Each truncated turn cost one extra billed generation and produced aFailed to parse tool call argumentswarning that was misclassified as a tool failure.Fix
A response truncated at
max_output_tokensyields no tool calls.responseHasToolCallsreturnsfalseandextractToolCallsFromResponsereturns[], so the loop executes nothing, makes no follow-up request, and finalizes on the truncated response. The caller receives the whole turn - every item, including the cut-offfunction_call- plusincomplete_details, and resumes once the budget is raised.This is response-level on purpose. The
function_callitems on a truncated turn are one unfinished batch: the model asked for the set together without seeing any result. Executing the complete prefix would hand the model a result set with a silent hole (the cut call is neither answered nor flagged) and spend side effects on a turn that truncates the same way on the same budget. It matches the Vercel AI SDK, whose loop refuses tool execution when the finish reason islength(isToolExecutionAllowedFinishReason, changelog 7.0.70), and OpenAI's hosted-shell behaviour, which executes nothing on amax_output_tokensturn.Tests
tests/unit/max-output-tokens-truncation.test.tsdrives the realcallModelloop against a mockedbetaResponsesSend:incompleteBoth fail on
main(the loop retries up to the step cap) and pass with the change.Context: OpenRouterTeam/openrouter-web#43149 carries this as a patch on
@openrouter/agent@0.10.0until the next release.