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