Skip to content

Assistant message "refusal": null in outbound chat-completion request breaks strict OpenAI-compatible providers (e.g. Gemini) #2061

Description

@badsgahhl

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:

{
  "role": "assistant",
  "content": "...",
  "refusal": null,          // ← non-standard-for-Gemini null value
  "tool_calls": [ ... ]
}

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:

  1. Captured request as-is (with "refusal": null) → 400
  2. 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

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