diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/provider/ChatGPTOAuthImageProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/image/provider/ChatGPTOAuthImageProvider.java new file mode 100644 index 00000000..befbdb53 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/image/provider/ChatGPTOAuthImageProvider.java @@ -0,0 +1,357 @@ +package vip.mate.tool.image.provider; + +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import vip.mate.exception.MateClawException; +import vip.mate.llm.oauth.OpenAIOAuthService; +import vip.mate.system.model.SystemSettingsDTO; +import vip.mate.tool.image.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Image generation provider that drives {@code gpt-image-2} through the + * ChatGPT subscription OAuth path — i.e., the user's ChatGPT Plus / Pro + * quota instead of an OpenAI API key. + * + *
Mechanism: the Codex Responses API (running at + * {@code https://chatgpt.com/backend-api/codex/responses}) hosts a tool of + * type {@code image_generation}. We invoke a small chat-host model + * (default {@code gpt-5.4}) and force {@code tool_choice} to the + * image_generation tool, so the chat model never produces text — it just + * triggers the image tool, which runs {@code gpt-image-2} server-side and + * streams the result back as base64 PNG. + * + *
Required headers (Cloudflare in front of the codex backend rejects + * other clients): + *
Response is server-sent events; we parse partial-image and final + * {@code response.output_item.done} frames and return the latest base64 + * payload as a {@code data:image/png;base64,...} URL — same shape the + * existing {@link OpenAiImageProvider} uses for its {@code gpt-image-2} + * branch, so the frontend renderer can stay unchanged. + * + *
Caveat: the upstream Codex CLI app is gated behind
+ * an undocumented model allow-list. The {@code gpt-5.4 + image_generation}
+ * pairing works today but may flip to 403 if OpenAI tightens the list;
+ * users can fall back to the API-key {@link OpenAiImageProvider}.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ChatGPTOAuthImageProvider implements ImageGenerationProvider {
+
+ private final OpenAIOAuthService oauthService;
+ private final ObjectMapper objectMapper;
+
+ private static final String CODEX_RESPONSES_URL =
+ "https://chatgpt.com/backend-api/codex/responses";
+
+ /** Real image API model. Three quality tiers map onto this single model. */
+ private static final String IMAGE_API_MODEL = "gpt-image-2";
+
+ private static final List Package-private for unit-testability.
+ */
+ String extractFinalImageFromSseBody(String body) {
+ if (body == null || body.isBlank()) return null;
+
+ String finalResult = null;
+ String latestPartial = null;
+
+ // SSE frames are separated by blank lines. Inside each frame, lines
+ // beginning with "data:" carry JSON (possibly multi-line if the
+ // server splits a payload, though OpenAI doesn't).
+ String[] frames = body.split("\\r?\\n\\r?\\n");
+ for (String frame : frames) {
+ if (frame.isBlank()) continue;
+ StringBuilder dataBuf = new StringBuilder();
+ for (String line : frame.split("\\r?\\n")) {
+ String stripped = line.trim();
+ if (stripped.startsWith("data:")) {
+ if (dataBuf.length() > 0) dataBuf.append('\n');
+ dataBuf.append(stripped.substring(5).trim());
+ }
+ }
+ if (dataBuf.length() == 0) continue;
+ String json = dataBuf.toString();
+ if ("[DONE]".equals(json)) continue;
+
+ try {
+ JsonNode node = objectMapper.readTree(json);
+ String type = node.path("type").asText("");
+ if ("response.image_generation_call.partial_image".equals(type)) {
+ String partial = node.path("partial_image_b64").asText(null);
+ if (partial != null && !partial.isBlank()) latestPartial = partial;
+ } else if ("response.output_item.done".equals(type)) {
+ JsonNode item = node.path("item");
+ if ("image_generation_call".equals(item.path("type").asText(""))) {
+ String result = item.path("result").asText(null);
+ if (result != null && !result.isBlank()) finalResult = result;
+ }
+ } else if ("response.completed".equals(type)) {
+ // Some replies put the final image only in response.completed.output[]
+ JsonNode output = node.path("response").path("output");
+ if (output.isArray()) {
+ for (JsonNode it : output) {
+ if ("image_generation_call".equals(it.path("type").asText(""))) {
+ String result = it.path("result").asText(null);
+ if (result != null && !result.isBlank()) finalResult = result;
+ }
+ }
+ }
+ }
+ } catch (Exception parseErr) {
+ // Malformed frame — keep going. Real frames are JSON; the
+ // occasional comment/heartbeat frame harmlessly falls here.
+ log.debug("[ChatGPT OAuth Image] skipping unparseable SSE data: {}", parseErr.getMessage());
+ }
+ }
+ return finalResult != null ? finalResult : latestPartial;
+ }
+
+ // ==================== helpers =========================================
+
+ /**
+ * Resolve the quality tier for this request. Uses the requested model
+ * when it's a virtual {@code gpt-image-2-{tier}} id, else
+ * {@link #defaultQuality}.
+ */
+ String qualityForRequest(ImageGenerationRequest request) {
+ String requested = request.getModel();
+ // Guard the contains() call: List.of(...) throws NPE on a null arg,
+ // and the request model is routinely unset.
+ if (requested != null && GPT_IMAGE_2_TIERS.contains(requested)) {
+ return switch (requested) {
+ case "gpt-image-2-low" -> "low";
+ case "gpt-image-2-high" -> "high";
+ default -> "medium";
+ };
+ }
+ return normalizeQuality(defaultQuality);
+ }
+
+ private static String normalizeQuality(String q) {
+ if (q == null) return "medium";
+ return switch (q.toLowerCase()) {
+ case "low", "medium", "high" -> q.toLowerCase();
+ default -> "medium";
+ };
+ }
+
+ /**
+ * Pick a {@code gpt-image-2}-supported size from the requested {@code size}
+ * + {@code aspectRatio}. Mirrors the gpt-image-2 branch of
+ * {@link OpenAiImageProvider#normalizeSize}.
+ */
+ String normalizeSize(String size, String aspectRatio) {
+ if (size != null && !size.isBlank() && GPT_IMAGE_2_SIZES.contains(size)) {
+ return size;
+ }
+ if (aspectRatio != null) {
+ return switch (aspectRatio) {
+ case "9:16", "2:3", "3:4" -> "1024x1536";
+ case "16:9", "3:2", "4:3" -> "1536x1024";
+ default -> "1024x1024";
+ };
+ }
+ return "1024x1024";
+ }
+
+ private static String truncate(String s, int max) {
+ if (s == null) return "";
+ return s.length() <= max ? s : s.substring(0, max) + "...";
+ }
+}