fix(llm): JSON body + 403/404 status mapping for OpenAI device code

This commit is contained in:
matevip 2026-05-05 13:06:11 +08:00
parent e9a11f8db4
commit bf7bc73f5f

View File

@ -13,8 +13,6 @@ import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
import vip.mate.exception.MateClawException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ConcurrentHashMap;
/**
@ -83,12 +81,15 @@ public class OpenAIDeviceCodeService {
/** Step 1: request a user_code and device_auth_id. */
public DeviceCodeStartResult start() {
String body = "client_id=" + enc(CLIENT_ID);
// OpenAI's deviceauth endpoints want a JSON body they reject
// application/x-www-form-urlencoded with HTTP 400. Confirmed against
// the upstream Codex CLI device code implementation.
String body = "{\"client_id\":\"" + CLIENT_ID + "\"}";
JsonNode resp;
try {
String raw = restClient.post()
.uri(DEVICE_USERCODE_URL)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.header(HttpHeaders.USER_AGENT, userAgent)
.body(body)
.retrieve()
@ -147,14 +148,15 @@ public class OpenAIDeviceCodeService {
}
sessions.put(deviceAuthId, session.withLastPollAt(now));
String body = "device_auth_id=" + enc(deviceAuthId)
+ "&user_code=" + enc(session.userCode());
// JSON body, same as /usercode see comment in start().
String body = "{\"device_auth_id\":\"" + jsonEscape(deviceAuthId)
+ "\",\"user_code\":\"" + jsonEscape(session.userCode()) + "\"}";
String responseBody;
try {
responseBody = restClient.post()
.uri(DEVICE_TOKEN_URL)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.header(HttpHeaders.USER_AGENT, userAgent)
.body(body)
.retrieve()
@ -218,6 +220,12 @@ public class OpenAIDeviceCodeService {
}
private DeviceCodePollResult classifyError(String deviceAuthId, int status, String body) {
// OpenAI's deviceauth poll signals "user has not finished yet" via HTTP
// 403/404 (unlike the OAuth 2.0 RFC 8628 spec, which uses 400 + an
// {error: authorization_pending} body). Treat both shapes as PENDING.
if (status == 403 || status == 404) {
return DeviceCodePollResult.pending();
}
String error = extractErrorCode(body);
if ("authorization_pending".equals(error) || "slow_down".equals(error)) {
return DeviceCodePollResult.pending();
@ -232,6 +240,26 @@ public class OpenAIDeviceCodeService {
return DeviceCodePollResult.pending();
}
/** Minimal JSON string escape — only client-controlled fields ever flow through here. */
private static String jsonEscape(String value) {
if (value == null) return "";
StringBuilder sb = new StringBuilder(value.length() + 8);
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
else sb.append(c);
}
}
return sb.toString();
}
private String extractErrorCode(String body) {
if (body == null || body.isBlank()) return null;
try {
@ -248,10 +276,6 @@ public class OpenAIDeviceCodeService {
return null;
}
private static String enc(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
int activeSessionCount() {
return sessions.size();
}