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