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