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