TL;DR
@github/copilot-sdk@1.0.8 (bundled @github/copilot@1.0.73) sends an assistant message containing a literal JSON "refusal": null field in the outbound messages[] array when using a BYOK OpenAI-compatible provider. OpenAI's own API accepts null here, so nobody notices. But Google Gemini's OpenAI-compatible endpoint (generativelanguage.googleapis.com/v1beta/openai) strictly rejects it with 400 Bad Request ("Value is not a string: null"), and this error body is again swallowed by the CLI's retry logic, surfacing only CAPIError: 400 400 400 Bad Request with no indication of the real cause.
This is the same failure class as #1129 (copilot_mcp_server_name leaking into tools[]), but a different field/message, and #1129's fix does not cover this case — confirmed still failing on the latest published versions (SDK 1.0.8, CLI 1.0.73) as of 2026-07-21.
Environment
@github/copilot-sdk: 1.0.8 (latest stable on npm at time of testing)
@github/copilot (bundled darwin-arm64 binary): 1.0.73 (latest stable)
- Node.js:
v24.16.0
- OS: macOS 15.7.7 (Darwin 24G720)
- Provider: BYOK,
type: "openai", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/"
- Model:
gemini-2.5-flash
- MCP server: local stdio MCP server (Atlassian Jira MCP), at least one tool call in the conversation
Reproduction
Minimal SDK-driven session with any BYOK OpenAI-compatible provider that does strict schema validation, once the conversation reaches a second turn after a tool call has been executed (i.e. once an assistant message with tool_calls needs to be replayed back to the model):
import { CopilotClient, approveAll } from "@github/copilot-sdk";
const client = new CopilotClient();
const session = await client.createSession({
onPermissionRequest: approveAll,
model: "gemini-2.5-flash",
provider: {
type: "openai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
apiKey: process.env.GEMINI_API_KEY!,
}
});
await session.sendAndWait({ prompt: "Please call a tool, then respond." });
Expected
A normal multi-turn chat: tool call executes, result is sent back, model produces a final answer.
Actual
The first request (before any tool call) succeeds. The second request (after the tool result is appended to history) fails:
CAPIError: 400 400 400 Bad Request
Root cause
Captured the outbound request body via debug logging (~/.copilot/logs/process-*.log, logLevel: 'debug'). The second request's messages[] array contains an assistant message shaped like:
Replaying the exact captured request body directly against Gemini via curl reproduces the failure:
{
"error": {
"code": 400,
"message": "Value is not a string: null",
"status": "INVALID_ARGUMENT"
}
}
Removing only the "refusal": null key from that same assistant message and re-sending the identical request returns 200 OK with a valid completion. So the non-standard null value for refusal is the sole cause.
Verified by diffing:
- Captured request as-is (with
"refusal": null) → 400
- Same request with
refusal key removed → 200
Related — second, distinct bug with gemini-3.1-pro-preview
Using model gemini-3.1-pro-preview instead fails on the same kind of second-turn request with a different Gemini-side error:
{
"error": {
"code": 400,
"message": "Function call is missing a thought_signature in functionCall parts. This is required for tools to work correctly...",
"status": "INVALID_ARGUMENT"
}
}
Gemini 3.x requires the thought_signature value (returned by Gemini in extra_content.google.thought_signature on the assistant's tool_calls[].function) to be echoed back verbatim on the next request. The CLI does appear to attempt round-tripping this value (it is present in the outbound extra_content.google.thought_signature of the replayed tool_calls entry), but Gemini still rejects it — suggests either a serialization mismatch (e.g. wrapping/escaping) or the signature isn't being preserved correctly end-to-end. Not fully root-caused to the same file/line level as the refusal: null issue above, but flagging it here since it's the same "strict-provider vs. Copilot's OpenAI-compat serializer" problem class, on the same code path.
Workaround
None found yet on the client side
Related issues
TL;DR
@github/copilot-sdk@1.0.8(bundled@github/copilot@1.0.73) sends an assistant message containing a literal JSON"refusal": nullfield in the outboundmessages[]array when using a BYOK OpenAI-compatible provider. OpenAI's own API acceptsnullhere, so nobody notices. But Google Gemini's OpenAI-compatible endpoint (generativelanguage.googleapis.com/v1beta/openai) strictly rejects it with400 Bad Request("Value is not a string: null"), and this error body is again swallowed by the CLI's retry logic, surfacing onlyCAPIError: 400 400 400 Bad Requestwith no indication of the real cause.This is the same failure class as #1129 (
copilot_mcp_server_nameleaking intotools[]), but a different field/message, and #1129's fix does not cover this case — confirmed still failing on the latest published versions (SDK 1.0.8, CLI 1.0.73) as of 2026-07-21.Environment
@github/copilot-sdk:1.0.8(latest stable on npm at time of testing)@github/copilot(bundled darwin-arm64 binary):1.0.73(latest stable)v24.16.0type: "openai",baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/"gemini-2.5-flashReproduction
Minimal SDK-driven session with any BYOK OpenAI-compatible provider that does strict schema validation, once the conversation reaches a second turn after a tool call has been executed (i.e. once an
assistantmessage withtool_callsneeds to be replayed back to the model):Expected
A normal multi-turn chat: tool call executes, result is sent back, model produces a final answer.
Actual
The first request (before any tool call) succeeds. The second request (after the tool result is appended to history) fails:
Root cause
Captured the outbound request body via debug logging (
~/.copilot/logs/process-*.log,logLevel: 'debug'). The second request'smessages[]array contains an assistant message shaped like:{ "role": "assistant", "content": "...", "refusal": null, // ← non-standard-for-Gemini null value "tool_calls": [ ... ] }Replaying the exact captured request body directly against Gemini via
curlreproduces the failure:{ "error": { "code": 400, "message": "Value is not a string: null", "status": "INVALID_ARGUMENT" } }Removing only the
"refusal": nullkey from that same assistant message and re-sending the identical request returns200 OKwith a valid completion. So the non-standardnullvalue forrefusalis the sole cause.Verified by diffing:
"refusal": null) →400refusalkey removed →200Related — second, distinct bug with
gemini-3.1-pro-previewUsing model
gemini-3.1-pro-previewinstead fails on the same kind of second-turn request with a different Gemini-side error:{ "error": { "code": 400, "message": "Function call is missing a thought_signature in functionCall parts. This is required for tools to work correctly...", "status": "INVALID_ARGUMENT" } }Gemini 3.x requires the
thought_signaturevalue (returned by Gemini inextra_content.google.thought_signatureon the assistant'stool_calls[].function) to be echoed back verbatim on the next request. The CLI does appear to attempt round-tripping this value (it is present in the outboundextra_content.google.thought_signatureof the replayedtool_callsentry), but Gemini still rejects it — suggests either a serialization mismatch (e.g. wrapping/escaping) or the signature isn't being preserved correctly end-to-end. Not fully root-caused to the same file/line level as therefusal: nullissue above, but flagging it here since it's the same "strict-provider vs. Copilot's OpenAI-compat serializer" problem class, on the same code path.Workaround
None found yet on the client side
Related issues
copilot_mcp_server_namefield leaks intotools[]in outbound chat-completion requests, breaking strict OpenAI-compatible providers (e.g. Gemini) #1129 — same failure class (unknown/non-conformant field breaking strict OpenAI-compatible providers), different field, confirmed fixed for that specific field but not for this one.