Assistant message "refusal": null in outbound chat-completion request breaks strict OpenAI-compatible providers (e.g. Gemini)
- Ngôn ngữ chính
- Java
- Star
- 10.5k
- Fork
- 1.5k
- Merge trung bình
- 1 ngày 11 giờ
- Pull request đã merge (30 ngày)
- 128
Mô tả
## 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.
Hướng dẫn đóng góp
Hướng nghiên cứu
Bắt đầu với việc tuần tự hóa thông báo gửi đi của Copilot SDK/CLI và tái hiện vấn đề bằng session TypeScript tối thiểu đối với endpoint tương thích với Gemini. Kiểm tra request đã được ghi lại trong ~/.copilot/logs/process-*.log, sau đó xác minh rằng assistant message không còn chứa null refusal field và request thứ hai hoàn tất thành công sau một tool call.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Đánh giá
- Công nghệ
- node.js, typescript
- Lĩnh vực
- api
- Loại issue
- Lỗi
- Độ khó
- 3/5
- Thời gian dự kiến
- 1-2 ngày
- Mức độ hoạt động
- Ít trao đổi
- Độ rõ ràng
- Khá rõ ràng
- Mức phù hợp với người mới
- 55/100