Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/helpers/agent-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ private constructor(
/** Register with `toolHandler`, or `asyncToolHandler` for a binding created by [ofAsync]. */
fun handler(): Function<Map<String, Any?>, R> = handler

/** Returns a new binding with deferred loading configured; the typed handler is unchanged. */
fun withDeferLoading(value: Boolean): AgentFunctionTool<R> =
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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<FunctionTool.Builder>,
) = 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) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -515,6 +516,14 @@ class StructuredResponseCreateParams<T : Any>(
localValidation: JsonSchemaLocalValidation = JsonSchemaLocalValidation.YES,
) = apply { paramsBuilder.addTool(functionParametersType, localValidation) }

/** @see ResponseCreateParams.Builder.addTool */
@JvmOverloads
fun addTool(
functionParametersType: Class<*>,
localValidation: JsonSchemaLocalValidation = JsonSchemaLocalValidation.YES,
customize: Consumer<FunctionTool.Builder>,
) = apply { paramsBuilder.addTool(functionParametersType, localValidation, customize) }

/** @see ResponseCreateParams.Builder.addTool */
fun addTool(fileSearch: FileSearchTool) = apply { paramsBuilder.addTool(fileSearch) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -350,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"),
)
}
Expand All @@ -361,6 +363,14 @@ internal class StructuredResponseCreateParamsTest {
checkOneDelegationWrite(builderDelegator, mockBuilderDelegate, testCase)
}

@Test
fun `delegation of tool customization`() {
val customize = Consumer<FunctionTool.Builder> { 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
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 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 = OpenAIClientImpl(options).responses()
val builder =
ResponseCreateParams.builder()
.model("test-model")
.input("Look up A123.")
.addTool(ToolSearchTool.builder().build())
val customize = Consumer<FunctionTool.Builder> { 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() {}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading