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