From 1d757467728df72132f3b836b8ab8dc5c456581c Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Mon, 5 Oct 2026 16:15:06 +0000 Subject: [PATCH 1/4] feat(tools): configure deferred loading on typed functions --- README.md | 15 ++ docs/helpers/agent-sessions.md | 11 ++ .../helpers/beta/agents/AgentFunctionTool.kt | 9 + .../models/responses/ResponseCreateParams.kt | 18 ++ .../StructuredResponseCreateParams.kt | 9 + .../beta/agents/AgentFunctionToolTest.kt | 21 ++ .../StructuredResponseCreateParamsTest.kt | 7 + .../services/DeferredFunctionToolsTest.kt | 182 ++++++++++++++++++ .../beta/agents/AgentSessionStreamTest.kt | 4 +- 9 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 openai-java-core/src/test/kotlin/com/openai/services/DeferredFunctionToolsTest.kt diff --git a/README.md b/README.md index ae554c375..8c7488c1a 100644 --- a/README.md +++ b/README.md @@ -1036,6 +1036,21 @@ For a full example of the usage of _Function Calling_ with the Responses API usi define and parse function parameters, see [`ResponsesFunctionCallingExample`](openai-java-example/src/main/java/com/openai/example/ResponsesFunctionCallingExample.java). +Configure a class-derived Responses tool with an optional builder callback, including when using +structured responses or streaming: + +```java +var params = ResponseCreateParams.builder() + .model(MODEL) + .input("Look up item A123.") + .addTool(ToolSearchTool.builder().build()) + .addTool(LookupItem.class, tool -> tool.deferLoading(true)) + .build(); +``` + +The callback runs after the usual schema generation and local validation. Function arguments still +parse with `call.arguments(LookupItem.class)`; the API validates tool-search configuration. + ### Local function JSON schema validation Like for _Structured Outputs_, you can perform local validation to check that the JSON schema diff --git a/docs/helpers/agent-sessions.md b/docs/helpers/agent-sessions.md index 6f8664225..2f81dee86 100644 --- a/docs/helpers/agent-sessions.md +++ b/docs/helpers/agent-sessions.md @@ -111,6 +111,17 @@ amounts, as the existing event decoder represents JSON fractions as doubles. See [`BetaAgentToolsExample`](../../openai-java-example/src/main/java/com/openai/example/BetaAgentToolsExample.java) for a read-only catalog lookup. +For deferred discovery, configure an immutable copy of a typed binding and include a tool-search +tool in the agent definition: + +```java +var lookup = AgentFunctionTool.of(LookupOrder.class, args -> orderService.lookup(args.orderId)) + .withDeferLoading(true); +``` + +Submit `lookup.definition()` and register `lookup.handler()` as usual. `ofAsync(...)` bindings +support the same option; the original binding and handler behavior remain unchanged. + ### Observing local tool errors Use `onToolError` to send local failures to your logger or monitoring system: diff --git a/openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt b/openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt index ecc8d63f6..2bafed81f 100644 --- a/openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt +++ b/openai-java-core/src/main/kotlin/com/openai/helpers/beta/agents/AgentFunctionTool.kt @@ -32,6 +32,15 @@ private constructor( /** Register with `toolHandler`, or `asyncToolHandler` for a binding created by [ofAsync]. */ fun handler(): Function, R> = handler + /** Returns a new binding with deferred loading configured; the typed handler is unchanged. */ + fun withDeferLoading(value: Boolean): AgentFunctionTool = + AgentFunctionTool( + AgentToolParam.ofFunction( + definition.asFunction().toBuilder().deferLoading(value).build() + ), + handler, + ) + companion object { /** * The callback returns the same string, map, content list, or null as a raw tool handler. diff --git a/openai-java-core/src/main/kotlin/com/openai/models/responses/ResponseCreateParams.kt b/openai-java-core/src/main/kotlin/com/openai/models/responses/ResponseCreateParams.kt index 2bbf12922..6b2d8f687 100644 --- a/openai-java-core/src/main/kotlin/com/openai/models/responses/ResponseCreateParams.kt +++ b/openai-java-core/src/main/kotlin/com/openai/models/responses/ResponseCreateParams.kt @@ -37,6 +37,7 @@ import com.openai.models.ResponsesModel import java.util.Collections import java.util.Objects import java.util.Optional +import java.util.function.Consumer import kotlin.jvm.optionals.getOrNull /** @@ -1487,6 +1488,23 @@ private constructor( body.addTool(responseFunctionToolFromClass(functionParametersType, localValidation)) } + /** + * Derives a function schema from [functionParametersType], then customizes its definition. + * Use this to set options such as `deferLoading` without rebuilding the generated schema. + * Schema generation and [localValidation] behave as in the class-only overload. + */ + @JvmOverloads + fun addTool( + functionParametersType: Class<*>, + localValidation: JsonSchemaLocalValidation = JsonSchemaLocalValidation.YES, + customize: Consumer, + ) = apply { + val tool = + responseFunctionToolFromClass(functionParametersType, localValidation).toBuilder() + customize.accept(tool) + body.addTool(tool.build()) + } + /** Alias for calling [addTool] with `Tool.ofFileSearch(fileSearch)`. */ fun addTool(fileSearch: FileSearchTool) = apply { body.addTool(fileSearch) } diff --git a/openai-java-core/src/main/kotlin/com/openai/models/responses/StructuredResponseCreateParams.kt b/openai-java-core/src/main/kotlin/com/openai/models/responses/StructuredResponseCreateParams.kt index 0b3152a31..eaf6fe2d9 100644 --- a/openai-java-core/src/main/kotlin/com/openai/models/responses/StructuredResponseCreateParams.kt +++ b/openai-java-core/src/main/kotlin/com/openai/models/responses/StructuredResponseCreateParams.kt @@ -12,6 +12,7 @@ import com.openai.models.Reasoning import com.openai.models.ResponsesModel import java.util.Objects import java.util.Optional +import java.util.function.Consumer /** * A wrapper for [ResponseCreateParams] that provides a type-safe [Builder] that can record the @@ -515,6 +516,14 @@ class StructuredResponseCreateParams( localValidation: JsonSchemaLocalValidation = JsonSchemaLocalValidation.YES, ) = apply { paramsBuilder.addTool(functionParametersType, localValidation) } + /** @see ResponseCreateParams.Builder.addTool */ + @JvmOverloads + fun addTool( + functionParametersType: Class<*>, + localValidation: JsonSchemaLocalValidation = JsonSchemaLocalValidation.YES, + customize: Consumer, + ) = apply { paramsBuilder.addTool(functionParametersType, localValidation, customize) } + /** @see ResponseCreateParams.Builder.addTool */ fun addTool(fileSearch: FileSearchTool) = apply { paramsBuilder.addTool(fileSearch) } diff --git a/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt b/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt index c6a6b8bb0..b2a47ae8e 100644 --- a/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/helpers/beta/agents/AgentFunctionToolTest.kt @@ -87,6 +87,27 @@ internal class AgentFunctionToolTest { ) } + @Test + fun deferredBindingsPreserveDefinitionsAndHandlers() { + val tool = AgentFunctionTool.of(LookupItem::class.java) { it.itemId } + val async = + AgentFunctionTool.ofAsync(LookupItem::class.java) { + CompletableFuture.completedFuture(it.itemId) + } + for (original in listOf(tool, async)) { + assertThat(original.definition().asFunction().deferLoading()).contains(false) + for (value in listOf(true, false)) { + val configured = original.withDeferLoading(value) + assertThat(configured.handler()).isSameAs(original.handler()) + assertThat(configured.definition().asFunction().deferLoading()).contains(value) + assertThat(configured.definition().asFunction().parameters()) + .isEqualTo(original.definition().asFunction().parameters()) + assertThat(configured.name()).isEqualTo(original.name()) + } + assertThat(original.definition().asFunction().deferLoading()).contains(false) + } + } + @Test fun supportsUnannotatedClassesAndAsyncStages() { val pending = CompletableFuture() diff --git a/openai-java-core/src/test/kotlin/com/openai/models/responses/StructuredResponseCreateParamsTest.kt b/openai-java-core/src/test/kotlin/com/openai/models/responses/StructuredResponseCreateParamsTest.kt index 690ef8a7e..e68217b0d 100644 --- a/openai-java-core/src/test/kotlin/com/openai/models/responses/StructuredResponseCreateParamsTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/models/responses/StructuredResponseCreateParamsTest.kt @@ -31,6 +31,7 @@ import com.openai.models.ChatModel import com.openai.models.Reasoning import com.openai.models.ResponsesModel import java.util.Optional +import java.util.function.Consumer import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource @@ -238,6 +239,12 @@ internal class StructuredResponseCreateParamsTest { DelegationWriteTestCase("addTool", NAMESPACE_TOOL), DelegationWriteTestCase("addTool", TOOL_SEARCH_TOOL), DelegationWriteTestCase("addTool", CLASS, VALIDATION), + DelegationWriteTestCase( + "addTool", + CLASS, + VALIDATION, + Consumer {}, + ), DelegationWriteTestCase("addTool", FILE_SEARCH_TOOL), DelegationWriteTestCase("addFileSearchTool", LIST), DelegationWriteTestCase("addTool", COMPUTER_TOOL), diff --git a/openai-java-core/src/test/kotlin/com/openai/services/DeferredFunctionToolsTest.kt b/openai-java-core/src/test/kotlin/com/openai/services/DeferredFunctionToolsTest.kt new file mode 100644 index 000000000..59b4728a0 --- /dev/null +++ b/openai-java-core/src/test/kotlin/com/openai/services/DeferredFunctionToolsTest.kt @@ -0,0 +1,182 @@ +package com.openai.services + +import com.fasterxml.jackson.annotation.JsonClassDescription +import com.fasterxml.jackson.databind.JsonNode +import com.openai.client.OpenAIClientImpl +import com.openai.core.ClientOptions +import com.openai.core.JsonSchemaLocalValidation +import com.openai.core.RequestOptions +import com.openai.core.http.* +import com.openai.core.jsonMapper +import com.openai.helpers.ResponseAccumulator +import com.openai.helpers.beta.agents.AgentFunctionTool +import com.openai.models.beta.agents.sessions.SessionCreateParams +import com.openai.models.responses.* +import com.openai.services.blocking.ResponseServiceImpl +import java.io.ByteArrayOutputStream +import java.util.concurrent.CompletableFuture +import java.util.function.Consumer +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +internal class DeferredFunctionToolsTest { + @JsonClassDescription("Look up a catalog item.") class LookupItem(val itemId: String) + + class Answer(val summary: String) + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun typedToolsKeepDeferredLoadingAndArgumentParsing(streaming: Boolean) { + for (structured in listOf(false, true)) { + for (deferred in listOf(null, false, true)) { + val transport = Transport() + val options = + ClientOptions.builder().apiKey("test-key").httpClient(transport).build() + try { + val service = ResponseServiceImpl(options) + val builder = + ResponseCreateParams.builder() + .model("test-model") + .input("Look up A123.") + .addTool(ToolSearchTool.builder().build()) + val customize = Consumer { it.deferLoading(deferred!!) } + val accumulator = ResponseAccumulator.create() + val response = + if (structured) { + val typed = builder.text(Answer::class.java) + if (deferred == null) typed.addTool(LookupItem::class.java) + else + typed.addTool( + LookupItem::class.java, + JsonSchemaLocalValidation.YES, + customize, + ) + if (streaming) { + service.createStreaming(typed.build()).use { + it.stream().forEach(accumulator::accumulate) + } + accumulator.response() + } else service.create(typed.build()).rawResponse + } else { + if (deferred == null) builder.addTool(LookupItem::class.java) + else builder.addTool(LookupItem::class.java, customize = customize) + if (streaming) { + service.createStreaming(builder.build()).use { + it.stream().forEach(accumulator::accumulate) + } + accumulator.response() + } else service.create(builder.build()) + } + assertThat( + response + .output() + .single() + .asFunctionCall() + .arguments(LookupItem::class.java) + .itemId + ) + .isEqualTo("A123") + val function = transport.body.path("tools")[1] + assertThat(function.path("name").asText()).isEqualTo("LookupItem") + assertThat(function.path("description").asText()) + .isEqualTo("Look up a catalog item.") + assertThat(function.path("strict").asBoolean()).isTrue() + assertThat(function.path("parameters").path("required").map { it.asText() }) + .containsExactly("itemId") + if (deferred == null) assertThat(function.has("defer_loading")).isFalse() + else { + assertThat(function.has("defer_loading")).isTrue() + assertThat(function.path("defer_loading").booleanValue()) + .isEqualTo(deferred) + } + assertThat(transport.body.path("stream").asBoolean()).isEqualTo(streaming) + } finally { + options.close() + } + } + } + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun agentRequestsPreserveDeferredDefinitions(asyncHandler: Boolean) { + val original = + if (asyncHandler) + AgentFunctionTool.ofAsync(LookupItem::class.java) { + CompletableFuture.completedFuture(it.itemId) + } + else AgentFunctionTool.of(LookupItem::class.java) { it.itemId } + for (deferred in listOf(null, false, true)) { + val tool = deferred?.let { original.withDeferLoading(it) } ?: original + val transport = Transport() + val client = + OpenAIClientImpl( + ClientOptions.builder().apiKey("test-key").httpClient(transport).build() + ) + try { + client + .beta() + .agents() + .sessions() + .create( + SessionCreateParams.builder() + .agent( + SessionCreateParams.Agent.builder() + .model("test-model") + .addTool(tool.definition()) + .build() + ) + .environmentNone() + .input("Look up A123.") + .build() + ) + val definition = transport.body.path("agent").path("tools")[0] + assertThat(definition.has("defer_loading")).isTrue() + assertThat(definition.path("defer_loading").booleanValue()) + .isEqualTo(deferred ?: false) + } finally { + client.close() + } + } + } + + private class Transport : HttpClient { + lateinit var body: JsonNode + + override fun execute(request: HttpRequest, requestOptions: RequestOptions): HttpResponse { + val bytes = ByteArrayOutputStream() + request.body!!.writeTo(bytes) + body = jsonMapper().readTree(bytes.toByteArray()) + val response = + if (body.has("agent")) """{"id":"session_test","status":"idle"}""" + else + """{"id":"resp_test","output":[{"type":"function_call","name":"LookupItem","call_id":"call_test","arguments":"{\"itemId\":\"A123\"}"}]}""" + val streaming = body.path("stream").asBoolean() + return object : HttpResponse { + override fun statusCode() = 200 + + override fun headers() = + Headers.builder() + .put( + "Content-Type", + if (streaming) "text/event-stream" else "application/json", + ) + .build() + + override fun body() = + (if (streaming) + "data: {\"type\":\"response.completed\",\"response\":$response}\n\n" + else response) + .byteInputStream() + + override fun close() {} + } + } + + override fun executeAsync(request: HttpRequest, requestOptions: RequestOptions) = + CompletableFuture.completedFuture(execute(request, requestOptions)) + + override fun close() {} + } +} diff --git a/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt b/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt index 846ff97ff..45baeba3a 100644 --- a/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/services/beta/agents/AgentSessionStreamTest.kt @@ -259,10 +259,10 @@ internal class AgentSessionStreamTest { AgentFunctionTool.ofAsync(LookupItem::class.java) { CompletableFuture.completedFuture(action(it)) } - p.asyncToolHandler(tool.name(), tool.handler()) + p.asyncToolHandler(tool.name(), tool.withDeferLoading(true).handler()) } else { val tool = AgentFunctionTool.of(LookupItem::class.java, action) - p.toolHandler(tool.name(), tool.handler()) + p.toolHandler(tool.name(), tool.withDeferLoading(true).handler()) } val t = Transport( From e274f19895fce8395d2575fe927edd199b42aea4 Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Mon, 5 Oct 2026 16:16:51 +0000 Subject: [PATCH 2/4] test(tools): verify customization delegation directly --- .../StructuredResponseCreateParamsTest.kt | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/openai-java-core/src/test/kotlin/com/openai/models/responses/StructuredResponseCreateParamsTest.kt b/openai-java-core/src/test/kotlin/com/openai/models/responses/StructuredResponseCreateParamsTest.kt index e68217b0d..038feac20 100644 --- a/openai-java-core/src/test/kotlin/com/openai/models/responses/StructuredResponseCreateParamsTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/models/responses/StructuredResponseCreateParamsTest.kt @@ -239,12 +239,6 @@ internal class StructuredResponseCreateParamsTest { DelegationWriteTestCase("addTool", NAMESPACE_TOOL), DelegationWriteTestCase("addTool", TOOL_SEARCH_TOOL), DelegationWriteTestCase("addTool", CLASS, VALIDATION), - DelegationWriteTestCase( - "addTool", - CLASS, - VALIDATION, - Consumer {}, - ), DelegationWriteTestCase("addTool", FILE_SEARCH_TOOL), DelegationWriteTestCase("addFileSearchTool", LIST), DelegationWriteTestCase("addTool", COMPUTER_TOOL), @@ -357,7 +351,8 @@ internal class StructuredResponseCreateParamsTest { checkAllDelegatorWriteFunctionsAreTested( builderDelegator::class, builderDelegationTestCases(), - exceptionalTestedFns = listOf("text", "text"), // Two overloads. Two custom tests below. + // Custom tests cover text conversion and the three-argument tool customizer. + exceptionalTestedFns = listOf("text", "text", "addTool"), nonDelegatingFns = setOf("build", "wrap", "inject"), ) } @@ -368,6 +363,14 @@ internal class StructuredResponseCreateParamsTest { checkOneDelegationWrite(builderDelegator, mockBuilderDelegate, testCase) } + @Test + fun `delegation of tool customization`() { + val customize = Consumer { it.deferLoading(true) } + builderDelegator.addTool(CLASS, VALIDATION, customize) + verify(mockBuilderDelegate).addTool(CLASS, VALIDATION, customize) + verifyNoMoreInteractions(mockBuilderDelegate) + } + @Test fun `delegation of text with class`() { // Special unit test case as the delegator method signature does not match that of the From 0ba769513343603cf0a0a42014e3c69dcbb5a853 Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Mon, 5 Oct 2026 16:36:21 +0000 Subject: [PATCH 3/4] test: use public client for deferred tool requests --- .../kotlin/com/openai/services/DeferredFunctionToolsTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openai-java-core/src/test/kotlin/com/openai/services/DeferredFunctionToolsTest.kt b/openai-java-core/src/test/kotlin/com/openai/services/DeferredFunctionToolsTest.kt index 59b4728a0..aabdb1101 100644 --- a/openai-java-core/src/test/kotlin/com/openai/services/DeferredFunctionToolsTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/services/DeferredFunctionToolsTest.kt @@ -12,7 +12,6 @@ import com.openai.helpers.ResponseAccumulator import com.openai.helpers.beta.agents.AgentFunctionTool import com.openai.models.beta.agents.sessions.SessionCreateParams import com.openai.models.responses.* -import com.openai.services.blocking.ResponseServiceImpl import java.io.ByteArrayOutputStream import java.util.concurrent.CompletableFuture import java.util.function.Consumer @@ -34,7 +33,7 @@ internal class DeferredFunctionToolsTest { val options = ClientOptions.builder().apiKey("test-key").httpClient(transport).build() try { - val service = ResponseServiceImpl(options) + val service = OpenAIClientImpl(options).responses() val builder = ResponseCreateParams.builder() .model("test-model") From 65a5cda206859e904eabacfeca7c5752bc06f4b6 Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Mon, 5 Oct 2026 16:40:03 +0000 Subject: [PATCH 4/4] test: register deferred tool builder overloads --- .../src/apiCompatibility/structured-output-public-api.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openai-java-core/src/apiCompatibility/structured-output-public-api.txt b/openai-java-core/src/apiCompatibility/structured-output-public-api.txt index 0c0f3d7f0..2b3747b58 100644 --- a/openai-java-core/src/apiCompatibility/structured-output-public-api.txt +++ b/openai-java-core/src/apiCompatibility/structured-output-public-api.txt @@ -343,6 +343,8 @@ com.openai.models.responses.StructuredResponseCreateParams$Builder#addTool (Lcom com.openai.models.responses.StructuredResponseCreateParams$Builder#addTool (Lcom/openai/models/responses/WebSearchTool;)Lcom/openai/models/responses/StructuredResponseCreateParams$Builder; com.openai.models.responses.StructuredResponseCreateParams$Builder#addTool (Ljava/lang/Class;)Lcom/openai/models/responses/StructuredResponseCreateParams$Builder; com.openai.models.responses.StructuredResponseCreateParams$Builder#addTool (Ljava/lang/Class;Lcom/openai/core/JsonSchemaLocalValidation;)Lcom/openai/models/responses/StructuredResponseCreateParams$Builder; +com.openai.models.responses.StructuredResponseCreateParams$Builder#addTool (Ljava/lang/Class;Lcom/openai/core/JsonSchemaLocalValidation;Ljava/util/function/Consumer;)Lcom/openai/models/responses/StructuredResponseCreateParams$Builder; +com.openai.models.responses.StructuredResponseCreateParams$Builder#addTool (Ljava/lang/Class;Ljava/util/function/Consumer;)Lcom/openai/models/responses/StructuredResponseCreateParams$Builder; com.openai.models.responses.StructuredResponseCreateParams$Builder#addToolLocalShell ()Lcom/openai/models/responses/StructuredResponseCreateParams$Builder; com.openai.models.responses.StructuredResponseCreateParams$Builder#addToolProgrammaticToolCalling ()Lcom/openai/models/responses/StructuredResponseCreateParams$Builder; com.openai.models.responses.StructuredResponseCreateParams$Builder#additionalBodyProperties (Ljava/util/Map;)Lcom/openai/models/responses/StructuredResponseCreateParams$Builder;