fix(api): treat padded tool_choice none as omit on mock tools - #639
fix(api): treat padded tool_choice none as omit on mock tools#639cursor[bot] wants to merge 30 commits into
Conversation
…closed otherwise Chat history: message-level audio and legacy function_call are null/empty omit no-ops; non-empty fail closed with named errors (including tools passthrough). Tip substrate from #577 assistant refusal/annotations honesty. Local full unit: 940 passed.
…ed otherwise OpenAI fine-tune style message weight is not applied on this gateway. Accept null/0/1 as honest no-ops; reject other types and values with invalid_message_weight. Tip substrate from #578. Local full unit: 943 passed.
…ion role Reject unsupported message keys with named unknown_message_fields (not silent strip or tools-passthrough smuggle). Reject legacy function role with invalid_message_role migration to tool. Tip substrate from #579. Local full unit: 947 passed.
OpenAI partial-assistant prefix flag is not applied on this gateway. null/false are honest no-ops; true and non-booleans fail closed with invalid_message_prefix. Tip substrate from #580. Local full unit: 950 passed.
…therwise Named invalid_max_tool_calls on /v1/chat/completions instead of opaque unknown_fields. Aligns with Responses max_tool_calls honesty; gateway has no multi-step tool loop.
…losed otherwise Legacy /v1/completions treated max_tool_calls as unknown_fields. Accept the key for named invalid_max_tool_calls (null/empty/whitespace omit-equivalent), matching chat/Responses honesty so SDKs get a clear migration path.
SDK clients often send include_usage/include_obfuscation as JSON null. Drop null flag values before validation so null (and null+false mixes) match omit / all-false no-ops on chat, Completions, and Responses. True flags remain fail-closed with invalid_stream_options.
…age honesty Null include_usage/include_obfuscation stay omit-equivalent, but unknown stream_options keys no longer become no-ops just because their value is null. Weight, prefix, refusal, annotations, developer role, empty user/system content, and participant name now use the same named errors on the tools passthrough path as on orchestration. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Hoist stream, required model, stream_options, and temperature/top_p range checks before proxy_completion so a tools or response_format body cannot return a billed JSON completion when the SDK asked for SSE, or silently pick a pool model when model is omitted. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Hoist stream, required model, stream_options, and temperature/top_p range checks before proxy_completion so a tools or response_format body cannot return a billed JSON completion when the SDK asked for SSE, or silently pick a pool model when model is omitted. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Hoist attribution and routing validation before proxy_completion so a tools or response_format body cannot bill a sync completion with an unknown spend dimension or a batch/latency_tolerant hint. Tools passthrough has no batch job plane. Buyer next action: send known sync attribution; omit routing.channel=batch and latency_tolerant=true on tool-calling requests. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Call the orchestration message, max_tokens, attribution, and routing validators before proxy_completion so a tools or response_format body cannot bill a completion with no prompt, crash on a non-object entry, or silently drop unknown spend/routing keys. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Match _validate_messages: tools + user content 123 must 400 invalid_message instead of billing a sync completion. Assistant/tool JSON null stays omit-equivalent. Buyer next action: send user/system content as a non-empty string or a content-parts array. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Match the tools-path honesty contract to the invalid_message behavior so SDK clients send a string or content-parts array. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Passthrough has no batch job plane. Reject routing.channel=batch and latency_tolerant=true before proxy_completion so a tool-calling body cannot bill a silent sync completion. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Hoist the remaining chat request knobs before proxy_completion so an OpenAI SDK tool-calling body cannot bill a sync completion for seed, stop, n>1, logprobs, logit_bias, out-of-range token/penalty values, unsupported reasoning_effort, or a non-default service_tier. Buyer next action: omit those fields on tool-calling requests; they are not applied on this gateway. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Call _validate_messages before proxy_completion so tools + [] / omitted / null / non-list / non-object messages raise invalid_message instead of billing a completion with no prompt. Buyer next action: always send a non-empty messages array of objects on tool-calling requests. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Hoist the orchestration request-knob validators before proxy_completion so a tools or response_format body cannot bill a completion that silently drops seed, stop, n>1, logprobs, logit_bias, or out-of-range penalties. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
stream_chat defaulted to 0.2 even after the HTTP path wrote default_temperature from the request. A streamed invoice summary at temperature=0.8 no longer silently falls back to 0.2. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
… proxy SDK optional defaults serialize omitted fields as JSON null. Accepting those keys without popping them is not omit-equivalent: proxy_completion forwards the body and several providers reject a null JSON Schema object. Pop the keys in place, keep non-null wrong types on invalid_tools, and assert omit-real via mock echo on chat and Responses. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
OpenAI SDK tool callers send stream=true. Returning 400 was honest but left every streaming tool client failing. Pipe a single pool agent as chat.completion.chunk frames so concatenated mock content matches the non-stream JSON body, and pipe live provider text so tool_calls deltas survive. Keep include_usage, empty messages, and unsupported knobs fail-closed. Buyer next action: send stream=true on tool-calling requests; omit stream_options.include_usage. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Keep tools/response_format SSE proxy from this branch and take #601's null tool.function omit-pop, request-knob HTTP cases, and streamed route temperature. Conflict resolution keeps stream=true as SSE, not 400. CHANGELOG records the combined unreleased slice. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Unknown assistant tool_calls entry/function fields, non-boolean include_orchestration_trace, and unknown mode used to bill a JSON or SSE completion on the tools proxy. They now return the same named 400s as the orchestration path before the first byte. Buyer next action: send only id/type/function/optional index on assistant tool_calls; send a boolean or omit include_orchestration_trace; send auto/route/conduct or omit mode. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
mock:// lookup_balance now returns assistant tool_calls and streams delta.tool_calls with finish_reason=tool_calls so offline SDK clients match the live _stream_raw path. Invoice identifiers bind from the user text (default INV-9). Cite Toolformer, ReAct, and OpenAI function calling (APA 7th) in docs/papers and the honesty test. Buyer next action: send stream=true on tool-calling requests and include the invoice id in the user message. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
stream_chat recorded temperature but omitted the request-scoped top_p, presence_penalty, and frequency_penalty that chat() already forwards. A streamed invoice summary at top_p=0.1 no longer drops those knobs. Buyer next action: send the nucleus and penalty values you want on stream=true route requests; streaming matches the JSON path. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Streamed top_p / penalties now match chat(). Record the ICLR 2020 nucleus-sampling paper (APA 7th) so the buyer can see why those knobs must not silently drop on stream=true. Buyer next action: send the nucleus and penalty values you want on streamed route requests. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
The HTTP validator already accepted " none " / tab-newline none as none but discarded the stripped token. Mock selection required an exact "none", so a padded none still emitted tool_calls after a 200. Strip in the selector and write the token back so live providers see none. JSON and SSE honesty tests cover exact and padded none. Buyer next action: send tool_choice=none to skip tools; incidental padding stays omit-equivalent. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
Buyers type "invoice 4419" more often than "INV-4419". The mock binder only matched an INV- prefix, so a realistic prompt still defaulted to INV-9. Accept invoice / invoice id / invoice # plus digits. Buyer next action: put the invoice number in the user text; prefixed and bare forms both bind. Co-authored-by: Seongho Bae <seonghobae@users.noreply.github.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
There was a problem hiding this comment.
Stale comment
Review (#639 unique slice vs #620
baf2e84)Padded
tool_choicenonestripping + validator writeback, bare-invoiceinvoice 4419→INV-4419, and the Holtzman cite next to the nucleus paragraph are sound. HTTP JSON+SSE covers exact and paddednone. Localtests/test_passthrough_sse_tools_http_honesty.pyprintedokon this head.No correctness blocker in this slice. Do not self-approve. Do not merge #620 / #606 / #617 / #609 / #610 / #613 / #615 in parallel with this stream-path tip. Independent non-author APPROVE + Full unit/Semgrep still required before merge.
Buyer next action
Send
tool_choice=nonewhen you want no tool call; incidental padding is still omit-equivalent. Put the invoice number in the user text (INV-4419orinvoice 4419). After the firsttool_callshop, POST the tool output asrole=toolwith the sametool_call_id— that second hop is the remaining buyer gap and is landed on the successor tip, not here.Follow-up (not this PR)
- Empty/whitespace
tool_choicewithtoolsis accepted as omit, then the mock still emitslookup_balanceunless the empty token is written back asnone.- Bare-invoice bind is in-process only; add one
_post_rawJSON+SSE pair forinvoice 4419.- Second-hop
role=toolcontinuation (LangChain/OpenAI SDK default) must synthesizecontent/stopfrom the observed values. That slice is the next stream-path landing vehicle — do not fold it back onto this tip after it opens.Checks on this synchronize were still queued at review time. Treat CI wait as non-blocking; re-run only if a job fails with a real finding.
Sent by Cursor Automation: Fix Issues
There was a problem hiding this comment.
Review (#639 unique slice vs #620 baf2e84)
Padded tool_choice=none is content/stop on mock JSON and SSE. _validate_chat_tool_choice writes none / auto / required back. Bare invoice 4419 binds INV-4419 in proxy_completion. That unique work is implemented correctly and does not weaken the security gate.
Remaining buyer-visible holes on this head:
- Write-back is untested. HTTP never asserts
echo.tool_choice == "none". Deleting the write-back still passes. - Bare invoice bind is
proxy_completiononly./v1/chat/completionsand SSE are unlocked. - Mock tool loop. A
role=toolobservation still emits anotherlookup_balance. Official SDK / LangChain clients spin.
Do not merge #639 while a successor holds the stream-path tip. Prefer #660 (d074598, synthesize bound tool results + HTTP invoice + empty tool_choice write-back). #674 (594cb62) also stops the loop and locks echo write-back, but answers [agent] chat-mock instead of the observation amount — keep it behind #660. Do not merge #620 / #606 / #617 in parallel.
Buyer next action: send tool_choice=none to skip tools; put the invoice number in the user text; after the first tool_calls hop post role=tool with the same tool_call_id and read the answer on #660. Independent non-author review is still required. This automation cannot merge.
Local checks on the #674 successor printed ok for SSE tools honesty, paper/API/self-check/conventions, tool_call_id, and passthrough.
Sent by Cursor Automation: Fix Issues
| streamed, finish_reason = _reconstruct_tool_calls(sse) | ||
| assert finish_reason == "stop", choice | ||
| assert streamed == [] | ||
| assert _reconstruct_content(sse) == reference["message"]["content"] |
There was a problem hiding this comment.
This HTTP loop asserts finish_reason=stop and no tool_calls, but never echo.tool_choice == "none". _selected_function_name strips on its own, so deleting the validator write-back at server.py:2969 still stays green while HTTPS would forward " none ".
Lock it here: assert json.loads(json_raw)["echo"]["tool_choice"] == "none".
#674 also writes the stripped token in _passthrough_upstream so proxy_completion echo matches. Prefer #660 for the stream-path tip (this slice plus bound tool-result synthesis). Do not merge this head in parallel.
| assert result["choices"][0]["finish_reason"] == "tool_calls" | ||
| assert message["tool_calls"][0]["function"]["name"] == "lookup_balance" | ||
| assert json.loads(message["tool_calls"][0]["function"]["arguments"]) == { | ||
| "invoice_id": "INV-4419" |
There was a problem hiding this comment.
Bare invoice 4419 → INV-4419 is only asserted through proxy_completion on this head. A buyer hitting /v1/chat/completions or SSE is the path that matters.
#660 and #674 both add test_http_chat_tools_stream_binds_bare_invoice_number. Prefer #660: after the first tool_calls hop it synthesizes the observation (balance_usd=128.50) instead of looping lookup_balance or returning [agent] chat-mock. Do not duplicate that test here.


Successor to #620. Same SSE mock
tool_calls+ streamed route sampling tip, plus the padded-nonehonesty hole.Unique vs #620 (
baf2e84)_selected_function_nametreats whitespace-paddedtool_choice(" none ","\tnone\n") asnoneso mocklookup_balancestayscontent/stop._validate_chat_tool_choicewrites the stripped token back so live providers seenone, not the padded SDK string.none(tests/test_passthrough_sse_tools_http_honesty.py).docs/rest_api_design.md.Buyer next action
Send
tool_choice=nonewhen you want no tool call. Incidental padding is still omit-equivalent. Include the invoice id in the user text on tool-calling streams. Do not merge #620 in parallel — this tip already contains that stack plus the padded-none fix.Do not merge in parallel
Prefer this over #620 / #606 / #617 / #609 / #610 / #613 / #615 for the stream-path tip. Keep KV/token #621, message-name #623, verify #618, and Responses stream #628 on their own tips. Independent non-author APPROVE + Full unit/Semgrep required.
Local checks printed
ok: SSE tools honesty (including the new padded-none cases), padded tool_choice strip, chat tool_choice functions, paper/API/self-check/conventions.