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