github / github/copilot-sdk

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

オープン
#2,061 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る
主要言語
Java
スター
10.5k
フォーク
1.5k
平均マージ
1日 11時間
マージ済み PR(30日)
128

説明

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

コントリビューションガイド

コントリビューションガイドを開く

調査の方向性

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.

索引モデルが issue の本文から書いたものです。

評価

技術スタック
node.js, typescript
領域
api
issue の種類
バグ
難易度
3/5
見積もり時間
1〜2日
活発さ
静か
明瞭さ
おおむね明確
初心者へのやさしさ
55/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。