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