001/*
002 * Copyright (c) 2026 Singular
003 * SPDX-License-Identifier: MIT
004 */
005
006package ai.singlr.openai;
007
008import ai.singlr.core.common.HttpClientFactory;
009import ai.singlr.core.model.CloseableIterator;
010import ai.singlr.core.model.FinishReason;
011import ai.singlr.core.model.Message;
012import ai.singlr.core.model.Model;
013import ai.singlr.core.model.ModelConfig;
014import ai.singlr.core.model.Response;
015import ai.singlr.core.model.StreamEvent;
016import ai.singlr.core.model.ThinkingLevel;
017import ai.singlr.core.model.ToolCall;
018import ai.singlr.core.model.ToolChoice;
019import ai.singlr.core.schema.OutputSchema;
020import ai.singlr.core.schema.StructuredContentParser;
021import ai.singlr.core.tool.Tool;
022import ai.singlr.openai.api.ApiStreamEvent;
023import ai.singlr.openai.api.ContentPart;
024import ai.singlr.openai.api.InputItem;
025import ai.singlr.openai.api.ResponsesRequest;
026import ai.singlr.openai.api.TextFormatConfig;
027import ai.singlr.openai.api.ToolDefinition;
028import java.io.BufferedReader;
029import java.io.IOException;
030import java.io.InputStream;
031import java.io.InputStreamReader;
032import java.net.URI;
033import java.net.http.HttpClient;
034import java.net.http.HttpRequest;
035import java.net.http.HttpResponse;
036import java.nio.charset.StandardCharsets;
037import java.time.Duration;
038import java.util.ArrayList;
039import java.util.Base64;
040import java.util.HashMap;
041import java.util.List;
042import java.util.Map;
043import java.util.concurrent.Callable;
044import java.util.concurrent.ExecutionException;
045import java.util.concurrent.ExecutorService;
046import java.util.concurrent.Executors;
047import java.util.concurrent.Future;
048import java.util.concurrent.TimeUnit;
049import java.util.concurrent.TimeoutException;
050import tools.jackson.databind.DeserializationFeature;
051import tools.jackson.databind.ObjectMapper;
052import tools.jackson.databind.json.JsonMapper;
053
054/**
055 * OpenAI model implementation using the Responses API.
056 *
057 * <p>All requests use SSE streaming internally for robust timeout handling. Synchronous {@link
058 * #chat} methods stream under the hood and accumulate the response, avoiding HTTP read timeouts on
059 * long-running generations. A per-line idle timeout detects stalled streams and throws a retryable
060 * {@link OpenAIException}.
061 */
062public class OpenAIModel implements Model {
063
064  private static final String PROVIDER_NAME = "openai";
065  static final String DEFAULT_BASE_URL = "https://api.openai.com/v1/responses";
066
067  static final String REASONING_KEY = "openai.reasoning";
068
069  private final OpenAIModelId modelId;
070  private final ModelConfig config;
071  private final HttpClient httpClient;
072  private final ObjectMapper objectMapper;
073
074  OpenAIModel(OpenAIModelId modelId, ModelConfig config) {
075    if (modelId == null) {
076      throw new IllegalArgumentException("modelId is required");
077    }
078    if (config == null) {
079      throw new IllegalArgumentException("config is required");
080    }
081    var hasCustomEndpoint = config.baseUrl() != null && !config.baseUrl().isBlank();
082    if (!hasCustomEndpoint && (config.apiKey() == null || config.apiKey().isBlank())) {
083      throw new IllegalArgumentException(
084          "config with valid apiKey is required (or set baseUrl + auth header)");
085    }
086    this.modelId = modelId;
087    this.config = config;
088    this.httpClient = HttpClientFactory.create(config);
089    this.objectMapper =
090        JsonMapper.builder().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build();
091  }
092
093  @Override
094  public String id() {
095    return modelId.id();
096  }
097
098  @Override
099  public String provider() {
100    return PROVIDER_NAME;
101  }
102
103  @Override
104  public int contextWindow() {
105    return modelId.contextWindow();
106  }
107
108  @Override
109  public int maxOutputTokens() {
110    return modelId.maxOutputTokens();
111  }
112
113  @Override
114  public void close() {
115    httpClient.shutdown();
116    try {
117      if (!httpClient.awaitTermination(Duration.ofSeconds(5))) {
118        httpClient.shutdownNow();
119      }
120    } catch (InterruptedException e) {
121      httpClient.shutdownNow();
122      Thread.currentThread().interrupt();
123    }
124  }
125
126  @Override
127  public Response<Void> chat(List<Message> messages, List<Tool> tools) {
128    var request = buildRequest(messages, tools, null);
129    return streamAndDrain(request);
130  }
131
132  @Override
133  public <T> Response<T> chat(
134      List<Message> messages, List<Tool> tools, OutputSchema<T> outputSchema) {
135    var request = buildRequest(messages, tools, outputSchema.schema().toMap());
136    var response = streamAndDrain(request);
137    var parsed = parseStructuredContent(response.content(), outputSchema);
138
139    return Response.<T>newBuilder(outputSchema.type())
140        .withContent(response.content())
141        .withParsed(parsed)
142        .withToolCalls(response.toolCalls())
143        .withFinishReason(response.finishReason())
144        .withUsage(response.usage())
145        .withThinking(response.thinking())
146        .withCitations(response.citations())
147        .withMetadata(response.metadata())
148        .build();
149  }
150
151  @Override
152  public CloseableIterator<StreamEvent> chatStream(List<Message> messages, List<Tool> tools) {
153    var request = buildRequest(messages, tools, null);
154    try {
155      return openStream(request);
156    } catch (OpenAIException e) {
157      return CloseableIterator.of(
158          List.of((StreamEvent) new StreamEvent.Error(e.getMessage(), e)).iterator());
159    } catch (IOException e) {
160      return CloseableIterator.of(
161          List.of((StreamEvent) new StreamEvent.Error("Failed to connect", e)).iterator());
162    } catch (InterruptedException e) {
163      Thread.currentThread().interrupt();
164      return CloseableIterator.of(
165          List.of((StreamEvent) new StreamEvent.Error("Request interrupted", e)).iterator());
166    }
167  }
168
169  <T> T parseStructuredContent(String content, OutputSchema<T> schema) {
170    return StructuredContentParser.parse(content, schema, jsonAdapter, OpenAIException::new);
171  }
172
173  @SuppressWarnings({"unchecked", "rawtypes"})
174  private final StructuredContentParser.JsonAdapter jsonAdapter =
175      new StructuredContentParser.JsonAdapter() {
176        @Override
177        public Map<String, Object> toMap(String json) throws Exception {
178          return objectMapper.readValue(json, Map.class);
179        }
180
181        @Override
182        public <T> T fromMap(Map<String, Object> map, Class<T> type) {
183          return objectMapper.convertValue(map, type);
184        }
185      };
186
187  private StreamingIterator openStream(ResponsesRequest request)
188      throws IOException, InterruptedException {
189    var jsonBody = serializeRequest(request);
190    var httpRequest = buildHttpRequest(jsonBody);
191    var httpResponse = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofInputStream());
192    if (httpResponse.statusCode() != 200) {
193      try (var body = httpResponse.body()) {
194        var errorBody = readBoundedErrorBody(body);
195        throw new OpenAIException(
196            "API error (status " + httpResponse.statusCode() + "): " + errorBody,
197            httpResponse.statusCode());
198      }
199    }
200    return new StreamingIterator(httpResponse, objectMapper, config.streamIdleTimeout());
201  }
202
203  /** Max bytes of an HTTP error body read into the exception message. */
204  static final int MAX_ERROR_BODY_BYTES = 64 * 1024;
205
206  /**
207   * Read the error body up to {@link #MAX_ERROR_BODY_BYTES}, appending a truncation marker if the
208   * server pushed more. Misconfigured proxies and gateways can return multi-megabyte HTML error
209   * pages; {@code readAllBytes()} would buffer the lot before the caller sees anything.
210   */
211  static String readBoundedErrorBody(InputStream body) throws IOException {
212    var capped = body.readNBytes(MAX_ERROR_BODY_BYTES);
213    var truncated = body.read() != -1;
214    var text = new String(capped, StandardCharsets.UTF_8);
215    return truncated
216        ? text + "\n[truncated: error body exceeded " + MAX_ERROR_BODY_BYTES + " bytes]"
217        : text;
218  }
219
220  private Response<Void> streamAndDrain(ResponsesRequest request) {
221    try (var iterator = openStream(request)) {
222      return drainToResponse(iterator);
223    } catch (OpenAIException e) {
224      throw e;
225    } catch (IOException e) {
226      throw new OpenAIException("Failed to communicate with OpenAI API", e);
227    } catch (InterruptedException e) {
228      Thread.currentThread().interrupt();
229      throw new OpenAIException("Request interrupted", e);
230    }
231  }
232
233  @SuppressWarnings("unchecked")
234  static Response<Void> drainToResponse(StreamingIterator iterator) {
235    while (iterator.hasNext()) {
236      var event = iterator.next();
237      if (event instanceof StreamEvent.Done(var response)) {
238        return (Response<Void>) response;
239      }
240      if (event instanceof StreamEvent.Error(String message, Exception cause)) {
241        if (cause instanceof OpenAIException oe) {
242          throw oe;
243        }
244        throw new OpenAIException(message, cause);
245      }
246    }
247    throw new OpenAIException("Stream ended without completion event");
248  }
249
250  ResponsesRequest buildRequest(
251      List<Message> messages, List<Tool> tools, Map<String, Object> outputSchema) {
252    var inputItems = new ArrayList<InputItem>();
253    String instructions = null;
254
255    for (var message : messages) {
256      switch (message.role()) {
257        case SYSTEM -> instructions = appendSystemText(instructions, message.content());
258        case USER -> inputItems.add(convertUserMessage(message));
259        case ASSISTANT -> inputItems.addAll(convertAssistantMessage(message));
260        case TOOL ->
261            inputItems.add(InputItem.functionCallOutput(message.toolCallId(), message.content()));
262      }
263    }
264
265    List<ToolDefinition> toolDefs = null;
266    if (tools != null && !tools.isEmpty()) {
267      toolDefs =
268          tools.stream()
269              .map(
270                  t ->
271                      ToolDefinition.function(
272                          t.name(), t.description(), t.parametersAsJsonSchema()))
273              .toList();
274    }
275
276    var toolChoiceValue = buildToolChoice(tools);
277    var reasoningConfig = buildReasoningConfig();
278
279    Double temperature = config.temperature();
280    if (reasoningConfig != null) {
281      temperature = null;
282    }
283
284    var builder =
285        ResponsesRequest.newBuilder()
286            .withModel(modelId.id())
287            .withInput(inputItems)
288            .withInstructions(instructions)
289            .withStream(true)
290            .withTools(toolDefs)
291            .withToolChoice(toolChoiceValue)
292            .withTemperature(temperature)
293            .withTopP(config.topP())
294            .withMaxOutputTokens(
295                config.maxOutputTokens() != null
296                    ? config.maxOutputTokens()
297                    : modelId.maxOutputTokens())
298            .withStop(config.stopSequences())
299            .withReasoning(reasoningConfig);
300
301    if (outputSchema != null) {
302      var hasOpenMap = hasOpenMapShape(outputSchema);
303      var schema = hasOpenMap ? outputSchema : addAdditionalPropertiesFalse(outputSchema);
304      var textFormat = TextFormatConfig.jsonSchema("output", schema, !hasOpenMap);
305      builder.withText(new ResponsesRequest.TextConfig(textFormat));
306    }
307
308    return builder.build();
309  }
310
311  private static String appendSystemText(String existing, String additional) {
312    if (existing == null) {
313      return additional;
314    }
315    return existing + "\n\n" + additional;
316  }
317
318  /**
319   * Returns {@code true} when the schema contains any open-keyed object — an {@code object} type
320   * whose {@code additionalProperties} is a value-schema (i.e., a {@code Map<String, X>} shape)
321   * rather than {@code false}.
322   *
323   * <p>OpenAI's strict mode rejects schemas with open-keyed objects: strict mode requires every
324   * {@code object} to set {@code additionalProperties: false} and list every property in {@code
325   * required}. Open Maps violate both. Detecting this lets {@link #buildRequest} fall back to
326   * non-strict json_schema mode, which preserves structured output without the strict-mode
327   * validator.
328   */
329  @SuppressWarnings("unchecked")
330  static boolean hasOpenMapShape(Map<String, Object> schema) {
331    if (schema == null) {
332      return false;
333    }
334    if ("object".equals(schema.get("type"))
335        && schema.get("additionalProperties") instanceof Map<?, ?>) {
336      return true;
337    }
338    if (schema.get("properties") instanceof Map<?, ?> props) {
339      for (var entry : ((Map<String, Object>) props).entrySet()) {
340        if (entry.getValue() instanceof Map<?, ?> nested
341            && hasOpenMapShape((Map<String, Object>) nested)) {
342          return true;
343        }
344      }
345    }
346    if (schema.get("items") instanceof Map<?, ?> items
347        && hasOpenMapShape((Map<String, Object>) items)) {
348      return true;
349    }
350    if (schema.get("additionalProperties") instanceof Map<?, ?> ap
351        && hasOpenMapShape((Map<String, Object>) ap)) {
352      return true;
353    }
354    return false;
355  }
356
357  @SuppressWarnings("unchecked")
358  static Map<String, Object> addAdditionalPropertiesFalse(Map<String, Object> schema) {
359    var result = new HashMap<>(schema);
360    if ("object".equals(result.get("type"))) {
361      var existing = result.get("additionalProperties");
362      if (existing instanceof Map<?, ?> existingSchema) {
363        // Map value schema — recurse into it instead of overwriting
364        result.put(
365            "additionalProperties",
366            addAdditionalPropertiesFalse((Map<String, Object>) existingSchema));
367      } else {
368        result.put("additionalProperties", false);
369      }
370      if (result.get("properties") instanceof Map<?, ?> props) {
371        var newProps = new HashMap<String, Object>();
372        for (var entry : ((Map<String, Object>) props).entrySet()) {
373          if (entry.getValue() instanceof Map<?, ?> nested) {
374            newProps.put(
375                entry.getKey(), addAdditionalPropertiesFalse((Map<String, Object>) nested));
376          } else {
377            newProps.put(entry.getKey(), entry.getValue());
378          }
379        }
380        result.put("properties", newProps);
381      }
382    }
383    if ("array".equals(result.get("type")) && result.get("items") instanceof Map<?, ?> items) {
384      result.put("items", addAdditionalPropertiesFalse((Map<String, Object>) items));
385    }
386    return result;
387  }
388
389  /**
390   * Convert a Helios USER {@link Message} into a Responses-API input item. Plain text is emitted as
391   * the bare-string overload (the Responses API accepts that form). When the message carries inline
392   * files, the wire shape becomes a content-part array so the provider receives the image/file
393   * blocks alongside the text.
394   *
395   * @param message the user message; non-null
396   * @return the input item
397   */
398  static InputItem convertUserMessage(Message message) {
399    var text = message.content() != null ? message.content() : "";
400    if (!message.hasInlineFiles()) {
401      return InputItem.userMessage(text);
402    }
403    var parts = new ArrayList<ContentPart>(message.inlineFiles().size() + 1);
404    for (var file : message.inlineFiles()) {
405      var data = Base64.getEncoder().encodeToString(file.data());
406      var media = file.mimeType();
407      if (media != null && media.startsWith("image/")) {
408        parts.add(ContentPart.inputImage(media, data));
409      } else {
410        parts.add(ContentPart.inputFile(media, data, null));
411      }
412    }
413    if (!text.isEmpty()) {
414      parts.add(ContentPart.inputText(text));
415    }
416    return InputItem.userMessage(parts);
417  }
418
419  List<InputItem> convertAssistantMessage(Message message) {
420    var items = new ArrayList<InputItem>();
421
422    if (message.content() != null && !message.content().isEmpty()) {
423      items.add(InputItem.assistantMessage(message.content()));
424    }
425
426    if (message.hasToolCalls()) {
427      for (var tc : message.toolCalls()) {
428        var argsJson = serializeArguments(tc.arguments());
429        items.add(InputItem.functionCall(tc.id(), tc.name(), argsJson));
430      }
431    }
432
433    if (items.isEmpty()) {
434      items.add(InputItem.assistantMessage(""));
435    }
436
437    return items;
438  }
439
440  private String serializeArguments(Map<String, Object> arguments) {
441    if (arguments == null || arguments.isEmpty()) {
442      return "{}";
443    }
444    try {
445      return objectMapper.writeValueAsString(arguments);
446    } catch (Exception e) {
447      throw new OpenAIException("Failed to serialize tool call arguments", e);
448    }
449  }
450
451  private Object buildToolChoice(List<Tool> tools) {
452    if (config.toolChoice() == null) {
453      return null;
454    }
455
456    return switch (config.toolChoice()) {
457      case ToolChoice.Auto a -> "auto";
458      case ToolChoice.Any a -> "required";
459      case ToolChoice.None n -> "none";
460      case ToolChoice.Required r -> {
461        var name = r.allowedTools().iterator().next();
462        yield Map.of("type", "function", "name", name);
463      }
464    };
465  }
466
467  private ResponsesRequest.ReasoningConfig buildReasoningConfig() {
468    if (config.thinkingLevel() == null || config.thinkingLevel() == ThinkingLevel.NONE) {
469      return null;
470    }
471
472    var effort =
473        switch (config.thinkingLevel()) {
474          case NONE -> null;
475          case MINIMAL, LOW -> "low";
476          case MEDIUM -> "medium";
477          case HIGH -> "high";
478        };
479
480    return ResponsesRequest.ReasoningConfig.of(effort);
481  }
482
483  String serializeRequest(ResponsesRequest request) {
484    try {
485      return objectMapper.writeValueAsString(request);
486    } catch (Exception e) {
487      throw new OpenAIException("Failed to serialize request", e);
488    }
489  }
490
491  HttpRequest buildHttpRequest(String jsonBody) {
492    var defaults = new java.util.LinkedHashMap<String, String>();
493    defaults.put("Content-Type", "application/json");
494    if (config.apiKey() != null && !config.apiKey().isBlank()) {
495      defaults.put("Authorization", "Bearer " + config.apiKey());
496    }
497    var builder =
498        HttpRequest.newBuilder()
499            .uri(URI.create(config.effectiveBaseUrl(DEFAULT_BASE_URL)))
500            .POST(HttpRequest.BodyPublishers.ofString(jsonBody));
501    for (var entry : config.effectiveHeaders(defaults).entrySet()) {
502      builder.header(entry.getKey(), entry.getValue());
503    }
504    // Null-guard matches Anthropic/Gemini parity — HttpRequest.Builder.timeout(null) NPEs and
505    // ModelConfig.Builder.withResponseTimeout(null) is currently legal.
506    if (config.responseTimeout() != null) {
507      builder.timeout(config.responseTimeout());
508    }
509    return builder.build();
510  }
511
512  static FinishReason mapStatus(String status) {
513    if (status == null) {
514      return FinishReason.STOP;
515    }
516    return switch (status) {
517      case "completed" -> FinishReason.STOP;
518      case "incomplete" -> FinishReason.LENGTH;
519      case "failed" -> FinishReason.ERROR;
520      default -> FinishReason.STOP;
521    };
522  }
523
524  static class StreamingIterator implements CloseableIterator<StreamEvent> {
525    private final InputStream rawStream;
526    private final BufferedReader reader;
527    private final ObjectMapper objectMapper;
528    private final Duration streamIdleTimeout;
529    private final ExecutorService readExecutor;
530    private final StringBuilder contentBuilder = new StringBuilder();
531    private final List<ToolCall> toolCalls = new ArrayList<>();
532    private final Map<String, ToolCallAccumulator> toolCallAccumulators = new HashMap<>();
533    private final StringBuilder reasoningBuilder = new StringBuilder();
534    private StreamEvent nextEvent = null;
535    private boolean done = false;
536    private int inputTokens = 0;
537    private int outputTokens = 0;
538    private String responseStatus = null;
539
540    StreamingIterator(
541        HttpResponse<InputStream> response, ObjectMapper objectMapper, Duration streamIdleTimeout) {
542      this.rawStream = response.body();
543      this.reader =
544          new BufferedReader(new InputStreamReader(this.rawStream, StandardCharsets.UTF_8));
545      this.objectMapper = objectMapper;
546      this.streamIdleTimeout = streamIdleTimeout;
547      this.readExecutor = Executors.newVirtualThreadPerTaskExecutor();
548    }
549
550    @Override
551    public boolean hasNext() {
552      if (done) {
553        return false;
554      }
555      if (nextEvent != null) {
556        return true;
557      }
558      nextEvent = readNextEvent();
559      return nextEvent != null;
560    }
561
562    @Override
563    public StreamEvent next() {
564      if (nextEvent == null) {
565        nextEvent = readNextEvent();
566      }
567      var event = nextEvent;
568      nextEvent = null;
569      return event;
570    }
571
572    private String readLineWithTimeout() throws IOException {
573      Future<String> future = readExecutor.submit((Callable<String>) () -> reader.readLine());
574      try {
575        return future.get(streamIdleTimeout.toMillis(), TimeUnit.MILLISECONDS);
576      } catch (TimeoutException e) {
577        future.cancel(true);
578        throw new OpenAIException(
579            "Stream idle timeout: no data received for " + streamIdleTimeout.toSeconds() + "s");
580      } catch (ExecutionException e) {
581        if (e.getCause() instanceof IOException ioe) {
582          throw ioe;
583        }
584        throw new IOException("Stream read failed", e.getCause());
585      } catch (InterruptedException e) {
586        future.cancel(true);
587        Thread.currentThread().interrupt();
588        throw new IOException("Stream read interrupted", e);
589      }
590    }
591
592    private StreamEvent readNextEvent() {
593      try {
594        String line;
595        while ((line = readLineWithTimeout()) != null) {
596          if (line.startsWith("data: ")) {
597            var json = line.substring(6).trim();
598            if (json.isEmpty() || json.equals("[DONE]")) {
599              continue;
600            }
601            var event = parseStreamEvent(json);
602            if (event != null) {
603              return event;
604            }
605          }
606        }
607        done = true;
608        close();
609        return buildDoneEvent();
610      } catch (OpenAIException e) {
611        done = true;
612        close();
613        return new StreamEvent.Error(e.getMessage(), e);
614      } catch (IOException e) {
615        done = true;
616        close();
617        return new StreamEvent.Error("Stream read error", e);
618      }
619    }
620
621    @SuppressWarnings("unchecked")
622    private StreamEvent parseStreamEvent(String json) {
623      try {
624        var event = objectMapper.readValue(json, ApiStreamEvent.class);
625
626        if (event.hasTypeResponseOutputTextDelta()) {
627          if (event.delta() != null) {
628            contentBuilder.append(event.delta());
629            return new StreamEvent.TextDelta(event.delta());
630          }
631          return null;
632        }
633
634        if (event.hasTypeResponseOutputItemAdded()) {
635          if (event.item() != null && event.item().hasTypeFunctionCall()) {
636            toolCallAccumulators.put(
637                event.item().id(),
638                new ToolCallAccumulator(
639                    event.item().callId(), event.item().name(), new StringBuilder()));
640            return new StreamEvent.ToolCallStart(event.item().callId(), event.item().name());
641          }
642          return null;
643        }
644
645        if (event.hasTypeFunctionCallArgumentsDelta()) {
646          if (event.delta() != null && event.itemId() != null) {
647            var accumulator = toolCallAccumulators.get(event.itemId());
648            if (accumulator != null) {
649              accumulator.jsonBuilder().append(event.delta());
650            }
651          }
652          return null;
653        }
654
655        if (event.hasTypeFunctionCallArgumentsDone()) {
656          if (event.itemId() != null) {
657            var accumulator = toolCallAccumulators.remove(event.itemId());
658            if (accumulator != null) {
659              var jsonStr = accumulator.jsonBuilder().toString();
660              Map<String, Object> arguments = Map.of();
661              if (!jsonStr.isEmpty()) {
662                try {
663                  arguments = objectMapper.readValue(jsonStr, Map.class);
664                } catch (Exception e) {
665                  arguments = Map.of("_raw", jsonStr);
666                }
667              }
668              var tc =
669                  ToolCall.newBuilder()
670                      .withId(accumulator.callId())
671                      .withName(accumulator.name())
672                      .withArguments(arguments)
673                      .build();
674              toolCalls.add(tc);
675              return new StreamEvent.ToolCallComplete(tc);
676            }
677          }
678          return null;
679        }
680
681        if (event.hasTypeResponseCompleted()) {
682          if (event.response() != null) {
683            responseStatus = event.response().status();
684            if (event.response().usage() != null) {
685              var usage = event.response().usage();
686              if (usage.inputTokens() != null) {
687                inputTokens = usage.inputTokens();
688              }
689              if (usage.outputTokens() != null) {
690                outputTokens = usage.outputTokens();
691              }
692            }
693          }
694          done = true;
695          close();
696          return buildDoneEvent();
697        }
698
699        if (event.hasTypeResponseFailed()) {
700          done = true;
701          close();
702          return new StreamEvent.Error("API response failed: " + json, null);
703        }
704
705        if (event.hasTypeError()) {
706          return new StreamEvent.Error("API stream error: " + json, null);
707        }
708
709        if (event.hasTypeReasoningSummaryTextDelta()) {
710          if (event.text() != null) {
711            reasoningBuilder.append(event.text());
712            return new StreamEvent.ThinkingDelta(event.text());
713          }
714          return null;
715        }
716
717        // Reasoning summary block closing — emit terminal aggregation so consumers can stop
718        // accumulating deltas and capture the full reasoning text. OpenAI's Responses API does
719        // not surface a signature for reasoning summaries, so the second arg is null.
720        if (event.hasTypeReasoningSummaryTextDone() && !reasoningBuilder.isEmpty()) {
721          return new StreamEvent.ThinkingComplete(reasoningBuilder.toString(), null);
722        }
723
724        return null;
725      } catch (Exception e) {
726        return new StreamEvent.Error("Failed to parse stream event", e);
727      }
728    }
729
730    private StreamEvent buildDoneEvent() {
731      var content = contentBuilder.toString();
732      var calls = toolCalls.isEmpty() ? List.<ToolCall>of() : List.copyOf(toolCalls);
733
734      var finishReason = mapStatus(responseStatus);
735      if (!calls.isEmpty() && finishReason != FinishReason.TOOL_CALLS) {
736        finishReason = FinishReason.TOOL_CALLS;
737      }
738
739      Response.Usage usage = null;
740      if (inputTokens > 0 || outputTokens > 0) {
741        usage = Response.Usage.of(inputTokens, outputTokens);
742      }
743
744      String thinking = reasoningBuilder.isEmpty() ? null : reasoningBuilder.toString();
745
746      var metadata = new HashMap<String, String>();
747      if (thinking != null) {
748        metadata.put(REASONING_KEY, thinking);
749      }
750
751      var response =
752          Response.newBuilder()
753              .withContent(content)
754              .withToolCalls(calls)
755              .withFinishReason(finishReason)
756              .withUsage(usage)
757              .withThinking(thinking)
758              .withMetadata(metadata.isEmpty() ? Map.of() : Map.copyOf(metadata))
759              .build();
760
761      return new StreamEvent.Done(response);
762    }
763
764    @Override
765    public void close() {
766      done = true;
767      readExecutor.shutdownNow();
768      try {
769        rawStream.close();
770      } catch (IOException ignored) {
771      }
772      try {
773        reader.close();
774      } catch (IOException ignored) {
775      }
776    }
777
778    private record ToolCallAccumulator(String callId, String name, StringBuilder jsonBuilder) {}
779  }
780}