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