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