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