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