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