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