github / github/copilot-sdk

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

Ouverte
#2,061 0 commentaires 0 réactions 0 personnes assignées Voir sur GitHub
Langage dominant
Java
Étoiles
10.5k
Forks
1.5k
Merge moyen
1 j 11 h
PR mergées (30 j)
128

Description

## 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):

```ts
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:

```jsonc
{
"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:

```json
{
"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:

```json
{
"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

- #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.

Guide de contribution

Ouvrir le guide de contribution

Piste de recherche

Start with the Copilot SDK/CLI outbound message serialization and reproduce the issue using the minimal TypeScript session against the Gemini-compatible endpoint. Inspect the captured request in ~/.copilot/logs/process-*.log, then verify that the assistant message no longer contains a null refusal field and that the second request completes successfully after a tool call.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Évaluation

Stack technique
node.js, typescript
Domaine
api
Type d'issue
Bug
Difficulté
3/5
Temps estimé
1-2 jours
Activité
Calme
Clarté
Plutôt claire
Accessibilité débutants
55/100

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.