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