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