From b51a889d7d880586314aecba57b93e1c6ff5a13c Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Thu, 10 Sep 2026 14:41:56 +0800 Subject: [PATCH 1/2] feat(openai-official): add OpenAI Responses API module Add agentscope-extensions-model-openai-official module integrating OpenAI models via the official OpenAI Java SDK (com.openai:openai-java) against the Responses API. Includes request mapping, response parsing, streaming assembly, error translation, credential type, SPI registration, e2e test provider, bilingual docs, and distribution/BOM updates. Resolve documentation conflicts from Mintlify migration (#3081): - Replace deleted _toc.yml with docs.json entries - Adapt agent.md/index.md/overview.md to Mintlify syntax - Add Mintlify front matter to openai-official.md pages --- .../core/model/ModelContextWindows.java | 10 +- .../io/agentscope/core/model/ModelUtils.java | 3 + .../agentscope-all/pom.xml | 12 +- .../agentscope-bom/pom.xml | 10 +- .../pom.xml | 6 + .../agentscope/core/e2e/ProviderFactory.java | 5 + .../OpenAIOfficialResponsesProvider.java | 108 ++ .../pom.xml | 74 ++ .../openaiofficial/OpenAIErrorTranslator.java | 128 ++ .../OpenAIOfficialConstants.java | 69 ++ .../OpenAIOfficialModelException.java | 101 ++ .../OpenAIOfficialModelProvider.java | 111 ++ .../OpenAIResponsesChatModel.java | 383 ++++++ .../OpenAISdkClientFactory.java | 92 ++ .../model/openaiofficial/ResponsesHelper.java | 120 ++ .../ResponsesMultiAgentFormatter.java | 334 ++++++ .../ResponsesRequestMapper.java | 771 ++++++++++++ .../ResponsesResponseParser.java | 179 +++ .../ResponsesStreamingAssembler.java | 323 +++++ .../credential/OpenAIOfficialCredential.java | 127 ++ ...io.agentscope.core.model.spi.ModelProvider | 1 + .../model/openaiofficial/CrossTurnTest.java | 769 ++++++++++++ .../OpenAIErrorTranslatorTest.java | 260 ++++ .../OpenAIOfficialModelExceptionTest.java | 174 +++ .../OpenAIOfficialModelProviderTest.java | 175 +++ .../OpenAIResponsesChatModelTest.java | 692 +++++++++++ .../OpenAISdkClientFactoryTest.java | 67 ++ .../openaiofficial/ResponsesHelperTest.java | 97 ++ .../ResponsesMultiAgentFormatterTest.java | 676 +++++++++++ .../ResponsesRequestMapperTest.java | 1045 +++++++++++++++++ .../ResponsesResponseParserTest.java | 455 +++++++ .../ResponsesStreamingAssemblerTest.java | 568 +++++++++ .../model/openaiofficial/TestSdkFixtures.java | 582 +++++++++ .../OpenAIOfficialCredentialTest.java | 64 + .../agentscope-extensions-model/pom.xml | 2 +- docs/docs.json | 88 +- docs/v2/en/docs/building-blocks/agent.md | 3 +- docs/v2/en/docs/building-blocks/model.md | 8 +- docs/v2/en/integration/model/index.md | 1 + .../en/integration/model/openai-official.md | 67 ++ docs/v2/en/integration/overview.md | 1 + docs/v2/zh/docs/building-blocks/agent.md | 3 +- docs/v2/zh/docs/building-blocks/model.md | 8 +- docs/v2/zh/integration/model/index.md | 1 + .../zh/integration/model/openai-official.md | 67 ++ docs/v2/zh/integration/overview.md | 1 + 46 files changed, 8782 insertions(+), 59 deletions(-) create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/providers/OpenAIOfficialResponsesProvider.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/pom.xml create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIErrorTranslator.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialConstants.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelException.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelProvider.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModel.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAISdkClientFactory.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesHelper.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatter.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapper.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParser.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssembler.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/credential/OpenAIOfficialCredential.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/resources/META-INF/services/io.agentscope.core.model.spi.ModelProvider create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/CrossTurnTest.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIErrorTranslatorTest.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelExceptionTest.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelProviderTest.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModelTest.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAISdkClientFactoryTest.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesHelperTest.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatterTest.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapperTest.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParserTest.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssemblerTest.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/TestSdkFixtures.java create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/credential/OpenAIOfficialCredentialTest.java create mode 100644 docs/v2/en/integration/model/openai-official.md create mode 100644 docs/v2/zh/integration/model/openai-official.md diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/ModelContextWindows.java b/agentscope-core/src/main/java/io/agentscope/core/model/ModelContextWindows.java index 734fc23012..e5cf1d3f3f 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/model/ModelContextWindows.java +++ b/agentscope-core/src/main/java/io/agentscope/core/model/ModelContextWindows.java @@ -68,7 +68,14 @@ private ModelContextWindows() {} Map.entry("o3-mini", 200_000), Map.entry("o3", 200_000), Map.entry("o1-mini", 128_000), - Map.entry("o1", 200_000)); + Map.entry("o1", 200_000), + Map.entry("gpt-5.4-mini", 400_000), + Map.entry("gpt-5.4", 1_050_000), + Map.entry("gpt-5.5", 1_050_000), + Map.entry("gpt-5.6-luna", 1_050_000), + Map.entry("gpt-5.6-terra", 1_050_000), + Map.entry("gpt-5.6-sol", 1_050_000), + Map.entry("gpt-6-astra", 1_050_000)); public static final Map DEEPSEEK = Map.ofEntries( @@ -77,6 +84,7 @@ private ModelContextWindows() {} public static final Map GLM = Map.ofEntries( + Map.entry("glm-5.3", 1_000_000), Map.entry("glm-5.2", 1_000_000), Map.entry("glm-5.1", 200_000), Map.entry("glm-5-turbo", 200_000), diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java b/agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java index c8f0cf3cd8..27ba87b82d 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java +++ b/agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java @@ -16,6 +16,7 @@ package io.agentscope.core.model; import java.time.Duration; +import java.util.concurrent.TimeoutException; import java.util.function.Predicate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -88,6 +89,8 @@ public static Flux applyTimeoutAndRetry( Flux.error( new ModelException( "Model request timeout after " + timeout, + new TimeoutException( + "Model request timeout after " + timeout), modelName, provider))); LOG.debug("Applied timeout: {} for model: {}", timeout, modelName); diff --git a/agentscope-distribution/agentscope-all/pom.xml b/agentscope-distribution/agentscope-all/pom.xml index 6662505ef3..d126920195 100644 --- a/agentscope-distribution/agentscope-all/pom.xml +++ b/agentscope-distribution/agentscope-all/pom.xml @@ -108,12 +108,12 @@ true - - - - - - + + io.agentscope + agentscope-extensions-model-openai-official + compile + true + io.agentscope diff --git a/agentscope-distribution/agentscope-bom/pom.xml b/agentscope-distribution/agentscope-bom/pom.xml index 85f258e8b4..ba750e19ea 100644 --- a/agentscope-distribution/agentscope-bom/pom.xml +++ b/agentscope-distribution/agentscope-bom/pom.xml @@ -355,11 +355,11 @@ agentscope-extensions-model-openai ${project.version} - - - - - + + io.agentscope + agentscope-extensions-model-openai-official + ${project.version} + io.agentscope agentscope-extensions-model-gemini diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/pom.xml b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/pom.xml index 63a89075c8..a3cad563a0 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/pom.xml +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/pom.xml @@ -61,6 +61,12 @@ test + + io.agentscope + agentscope-extensions-model-openai-official + test + + io.agentscope agentscope-extensions-model-gemini diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/ProviderFactory.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/ProviderFactory.java index 2577d14075..90d6f71380 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/ProviderFactory.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/ProviderFactory.java @@ -24,6 +24,7 @@ import io.agentscope.core.e2e.providers.GeminiProvider; import io.agentscope.core.e2e.providers.ModelCapability; import io.agentscope.core.e2e.providers.ModelProvider; +import io.agentscope.core.e2e.providers.OpenAIOfficialResponsesProvider; import io.agentscope.core.e2e.providers.OpenRouterProvider; import java.util.ArrayList; import java.util.List; @@ -88,6 +89,10 @@ public class ProviderFactory { private static List getAllProviders() { List providers = new ArrayList<>(); + // OpenAI Official SDK providers (Responses API) + providers.add(new OpenAIOfficialResponsesProvider.Gpt54()); + providers.add(new OpenAIOfficialResponsesProvider.Gpt54MultiAgent()); + // DashScope Compatible providers providers.add(new DashScopeCompatibleProvider.QwenPlusOpenAI()); providers.add(new DashScopeCompatibleProvider.QwenPlusMultiAgentOpenAI()); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/providers/OpenAIOfficialResponsesProvider.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/providers/OpenAIOfficialResponsesProvider.java new file mode 100644 index 0000000000..c231151019 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-e2e-tests/src/test/java/io/agentscope/core/e2e/providers/OpenAIOfficialResponsesProvider.java @@ -0,0 +1,108 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.e2e.providers; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.extensions.model.openaiofficial.OpenAIResponsesChatModel; +import io.agentscope.extensions.model.openaiofficial.ResponsesMultiAgentFormatter; +import java.util.HashSet; +import java.util.Set; + +/** + * Provider for OpenAI models via the official OpenAI Java SDK (Responses API). + */ +@ModelCapabilities({ModelCapability.BASIC, ModelCapability.TOOL_CALLING}) +public class OpenAIOfficialResponsesProvider extends BaseModelProvider { + + private static final String API_KEY_ENV = "OPENAI_API_KEY"; + private static final String BASE_URL_ENV = "OPENAI_BASE_URL"; + + public OpenAIOfficialResponsesProvider(String modelName, boolean multiAgentFormatter) { + super(API_KEY_ENV, modelName, multiAgentFormatter); + } + + @Override + protected ReActAgent.Builder doCreateAgentBuilder(String name, Toolkit toolkit, String apiKey) { + String baseUrl = System.getenv(BASE_URL_ENV); + + OpenAIResponsesChatModel.Builder builder = + OpenAIResponsesChatModel.builder().apiKey(apiKey).modelName(getModelName()); + + if (baseUrl != null && !baseUrl.isEmpty()) { + builder.baseUrl(baseUrl); + } + + if (isMultiAgentFormatter()) { + builder.formatter(new ResponsesMultiAgentFormatter()); + } + + return ReActAgent.builder().name(name).model(builder.build()).toolkit(toolkit); + } + + @Override + public String getProviderName() { + return "OpenAI-Official"; + } + + @Override + public Set getCapabilities() { + Set caps = new HashSet<>(super.getCapabilities()); + if (isMultiAgentFormatter()) { + caps.add(ModelCapability.MULTI_AGENT_FORMATTER); + } + return caps; + } + + // ========================================================================== + // Provider Instances + // ========================================================================== + + /** GPT-5.4 via OpenAI Official SDK (Responses API). */ + @ModelCapabilities({ + ModelCapability.BASIC, + ModelCapability.TOOL_CALLING, + ModelCapability.THINKING + }) + public static class Gpt54 extends OpenAIOfficialResponsesProvider { + public Gpt54() { + super("gpt-5.4", false); + } + + @Override + public String getProviderName() { + return "OpenAI-Official"; + } + } + + /** GPT-5.4 with Multi-Agent Formatter. */ + @ModelCapabilities({ + ModelCapability.BASIC, + ModelCapability.TOOL_CALLING, + ModelCapability.THINKING, + ModelCapability.MULTI_AGENT_FORMATTER + }) + public static class Gpt54MultiAgent extends OpenAIOfficialResponsesProvider { + public Gpt54MultiAgent() { + super("gpt-5.4", true); + } + + @Override + public String getProviderName() { + return "OpenAI-Official (Multi-Agent)"; + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/pom.xml b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/pom.xml new file mode 100644 index 0000000000..bf75ec87ac --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/pom.xml @@ -0,0 +1,74 @@ + + + + + 4.0.0 + + io.agentscope + agentscope-extensions-model + ${revision} + ../pom.xml + + + agentscope-extensions-model-openai-official + AgentScope Java - Extensions - Model - OpenAI Official + OpenAI official SDK model provider extension for AgentScope Java + + + + io.agentscope + agentscope-core + + + + com.openai + openai-java + + + + io.projectreactor + reactor-core + + + + com.fasterxml.jackson.core + jackson-databind + + + + io.agentscope + agentscope-core + ${project.version} + test-jar + test + + + + org.junit.jupiter + junit-jupiter + test + + + + org.mockito + mockito-core + test + + + diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIErrorTranslator.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIErrorTranslator.java new file mode 100644 index 0000000000..d2f29d86c1 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIErrorTranslator.java @@ -0,0 +1,128 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import com.openai.errors.OpenAIInvalidDataException; +import com.openai.errors.OpenAIIoException; +import com.openai.errors.OpenAIRetryableException; +import com.openai.errors.OpenAIServiceException; +import java.util.concurrent.TimeoutException; + +/** + * Translates official OpenAI Java SDK exceptions into {@link OpenAIOfficialModelException}. + * + *

SDK exceptions are classified as follows: + *

    + *
  • Typed {@code OpenAIServiceException} subclasses (e.g. {@code BadRequestException}, + * {@code RateLimitException}, {@code InternalServerException}) → exception with + * HTTP status; retryability is determined by + * {@link OpenAIOfficialModelException#isRetryableHttpStatus()}. + *
  • 408/409 from {@code UnexpectedStatusCodeException} or {@code SseException} → + * exception with HTTP status. + *
  • Any other {@code OpenAIServiceException} → exception with HTTP status, + * non-retryable by default. + *
  • {@code OpenAIIoException} / {@code OpenAIRetryableException} → exception + * without HTTP status. + *
  • {@code TimeoutException} in cause chain → exception without HTTP status. + *
  • {@code OpenAIInvalidDataException} → exception without HTTP status, + * non-retryable. + *
+ * + *

Non-SDK exceptions (validation, refusal, unsupported input) are created at their + * respective call sites, not by this translator. + * + *

The original SDK exception is always preserved as the cause so that the module + * retryOn predicate can inspect headers and exception types in the cause chain. + */ +final class OpenAIErrorTranslator { + + private OpenAIErrorTranslator() {} + + /** + * Translates a throwable into an {@link OpenAIOfficialModelException}. + * + * @param throwable the exception to translate (typically an SDK exception) + * @param modelName the model name for context, or null if unknown + * @return a normalized exception with provider id {@code openai-official} + */ + static OpenAIOfficialModelException translate(Throwable throwable, String modelName) { + if (throwable instanceof OpenAIOfficialModelException alreadyTranslated) { + return alreadyTranslated; + } + + // OpenAIServiceException covers all HTTP-status-bearing SDK errors + if (throwable instanceof OpenAIServiceException serviceException) { + int statusCode = serviceException.statusCode(); + String safeMessage = safeMessage(throwable, statusCode); + return new OpenAIOfficialModelException(safeMessage, throwable, modelName, statusCode); + } + + // Retryable transport-level exceptions (no HTTP status) + if (throwable instanceof OpenAIIoException + || throwable instanceof OpenAIRetryableException) { + String safeMessage = safeMessage(throwable, null); + return new OpenAIOfficialModelException(safeMessage, throwable, modelName); + } + + // SDK response parsing/validation error (non-retryable, no HTTP status) + if (throwable instanceof OpenAIInvalidDataException) { + String safeMessage = safeMessage(throwable, null); + return new OpenAIOfficialModelException(safeMessage, throwable, modelName); + } + + // TimeoutException (direct or in cause chain) → retryable, no HTTP status + if (hasTimeoutInCauseChain(throwable)) { + String safeMessage = safeMessage(throwable, null); + return new OpenAIOfficialModelException(safeMessage, throwable, modelName); + } + + // Fallback: wrap any other throwable as non-retryable + String safeMessage = safeMessage(throwable, null); + return new OpenAIOfficialModelException(safeMessage, throwable, modelName); + } + + /** + * Null-safe message extraction. + * + *

If {@code throwable.getMessage()} is non-null, uses it. Otherwise falls back to + * {@code "OpenAI API error (HTTP {statusCode})"} when an HTTP status is known, or + * the exception class name when no status is available. + */ + private static String safeMessage(Throwable throwable, Integer statusCode) { + String message = throwable.getMessage(); + if (message != null && !message.isBlank()) { + return message; + } + if (statusCode != null) { + return "OpenAI API error (HTTP " + statusCode + ")"; + } + return "OpenAI API error: " + throwable.getClass().getSimpleName(); + } + + /** + * Checks whether a {@link TimeoutException} appears anywhere in the cause chain. + */ + private static boolean hasTimeoutInCauseChain(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof TimeoutException) { + return true; + } + current = current.getCause(); + } + return false; + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialConstants.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialConstants.java new file mode 100644 index 0000000000..fc4b6c8b0e --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialConstants.java @@ -0,0 +1,69 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import java.util.Set; + +/** + * Shared constants for the OpenAI official module. + * + *

Defines the provider id, the {@code additionalBodyParams} whitelist key set, and + * metadata namespace keys ({@code openai.*}). All internal components reference these + * constants so that metadata-key writes and history-replay reads use the exact same strings. + */ +final class OpenAIOfficialConstants { + + private OpenAIOfficialConstants() {} + + /** Provider id used in ModelException, SPI registration, and model id prefix. */ + static final String PROVIDER_ID = "openai-official"; + + // ── additionalBodyParams whitelist ─────────────────────────── + + /** + * Whitelist of keys allowed in {@code GenerateOptions.additionalBodyParams}. + * Non-whitelist keys trigger fail-fast during request mapping. + */ + static final Set ADDITIONAL_BODY_PARAMS_WHITELIST = + Set.of( + "max_tool_calls", + "prompt_cache_key", + "prompt_cache_options", + "service_tier", + "safety_identifier", + "reasoning.summary", + "reasoning.context", + "reasoning.mode"); + + // ── Metadata namespace keys ───────────────────────── + + // Response-level + static final String MD_RESPONSE_ID = "openai.response.id"; + static final String MD_RESPONSE_STATUS = "openai.response.status"; + static final String MD_RESPONSE_CREATED_AT = "openai.response.created_at"; + static final String MD_RESPONSE_COMPLETED_AT = "openai.response.completed_at"; + static final String MD_RESPONSE_SERVICE_TIER = "openai.response.service_tier"; + static final String MD_RESPONSE_INCOMPLETE_REASON = "openai.response.incomplete_reason"; + static final String MD_RESPONSE_ERROR = "openai.response.error"; + + // Usage-level + static final String MD_USAGE_REASONING_TOKENS = "openai.usage.reasoning_tokens"; + + // Reasoning-level (internal state, used for history replay) + static final String MD_REASONING_ENCRYPTED_CONTENT = "openai.reasoning.encrypted_content"; + static final String MD_REASONING_SUMMARY = "openai.reasoning.summary"; + static final String MD_REASONING_TEXT = "openai.reasoning.text"; +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelException.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelException.java new file mode 100644 index 0000000000..5bcd3fed28 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelException.java @@ -0,0 +1,101 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import io.agentscope.core.model.ModelException; +import io.agentscope.core.model.ModelHttpException; + +/** + * Exception type for the {@code openai-official} provider. + * + *

Extends {@link ModelException} and implements {@link ModelHttpException} so that + * HTTP-status-aware retry classification works through both the core + * {@code ExecutionConfig.RETRYABLE_ERRORS} safety net and the module's own retryOn predicate. + * + *

Overrides {@link #isRetryableHttpStatus()} to include 408 and 409 in addition to the + * core default (429 and 5xx). This aligns with the SDK {@code RetryingHttpClient.shouldRetry} + * status-code set {408, 409, 429, >=500}. + */ +public class OpenAIOfficialModelException extends ModelException implements ModelHttpException { + + private final Integer statusCode; + + /** + * Creates an exception with no HTTP status (for non-HTTP SDK failures such as + * {@code OpenAIIoException}, {@code OpenAIInvalidDataException}, or validation errors). + * + * @param message the error message + * @param cause the underlying SDK exception, or null + * @param modelName the model name, or null if unknown + */ + public OpenAIOfficialModelException(String message, Throwable cause, String modelName) { + super(message, cause, modelName, OpenAIOfficialConstants.PROVIDER_ID); + this.statusCode = null; + } + + /** + * Creates an exception with an HTTP status code (for {@code OpenAIServiceException} + * subclasses such as {@code BadRequestException}, {@code RateLimitException}, etc.). + * + * @param message the error message + * @param cause the underlying SDK exception + * @param modelName the model name, or null if unknown + * @param statusCode the HTTP status code from the SDK exception + */ + public OpenAIOfficialModelException( + String message, Throwable cause, String modelName, Integer statusCode) { + super(message, cause, modelName, OpenAIOfficialConstants.PROVIDER_ID); + this.statusCode = statusCode; + } + + /** + * Creates a validation/non-retryable exception with no cause and no HTTP status. + * + * @param message the validation message + */ + public OpenAIOfficialModelException(String message) { + super(message, null, null, OpenAIOfficialConstants.PROVIDER_ID); + this.statusCode = null; + } + + /** + * Creates a validation/non-retryable exception with no cause, no HTTP status, + * but with a known model name. + * + * @param message the validation message + * @param modelName the model name, or null if unknown + */ + public OpenAIOfficialModelException(String message, String modelName) { + super(message, null, modelName, OpenAIOfficialConstants.PROVIDER_ID); + this.statusCode = null; + } + + @Override + public Integer getStatusCode() { + return statusCode; + } + + @Override + public boolean isRetryableHttpStatus() { + if (statusCode == null) { + return false; + } + return statusCode == 408 + || statusCode == 409 + || statusCode == 429 + || (statusCode >= 500 && statusCode < 600); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelProvider.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelProvider.java new file mode 100644 index 0000000000..9ee44cbfb9 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelProvider.java @@ -0,0 +1,111 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import static io.agentscope.core.model.ModelProviderSupport.booleanOption; +import static io.agentscope.core.model.ModelProviderSupport.findAssignableComponent; +import static io.agentscope.core.model.ModelProviderSupport.firstNonBlank; +import static io.agentscope.core.model.ModelProviderSupport.intOption; + +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelCreationContext; +import io.agentscope.core.model.spi.ModelProvider; +import java.util.Map; +import java.util.regex.Pattern; + +public final class OpenAIOfficialModelProvider implements ModelProvider { + + private static final String PREFIX = "openai-official:"; + private static final Pattern MODEL_ID = Pattern.compile("openai-official:.+"); + + @Override + public String providerId() { + return OpenAIOfficialConstants.PROVIDER_ID; + } + + @Override + public boolean supports(String modelId) { + return modelId != null && MODEL_ID.matcher(modelId).matches(); + } + + @Override + public Model create(String modelId) { + return create(modelId, ModelCreationContext.empty()); + } + + @Override + public Model create(String modelId, ModelCreationContext context) { + if (!supports(modelId)) { + throw new IllegalArgumentException("Unsupported OpenAI model id: " + modelId); + } + + String apiKey = firstNonBlank(context.getApiKey(), System.getenv("OPENAI_API_KEY")); + if (apiKey == null) { + throw new IllegalStateException( + "Environment variable OPENAI_API_KEY is required to auto-create model: " + + modelId); + } + String modelName = modelId.substring(PREFIX.length()); + String baseUrl = firstNonBlank(context.getBaseUrl(), System.getenv("OPENAI_BASE_URL")); + boolean stream = context.getStream() != null ? context.getStream() : true; + + OpenAIResponsesChatModel.Builder builder = + OpenAIResponsesChatModel.builder() + .apiKey(apiKey) + .baseUrl(baseUrl) + .modelName(modelName) + .stream(stream); + + applyAdvancedOptions(builder, context); + return builder.build(); + } + + @SuppressWarnings("unchecked") + private static void applyAdvancedOptions( + OpenAIResponsesChatModel.Builder builder, ModelCreationContext context) { + GenerateOptions generateOptions = context.component(GenerateOptions.class); + if (generateOptions != null) { + builder.generateOptions(generateOptions); + } + + Boolean strictTools = booleanOption(context, "strictTools"); + if (strictTools != null) { + builder.strictTools(strictTools); + } + + Boolean strictJsonSchema = booleanOption(context, "strictJsonSchema"); + if (strictJsonSchema != null) { + builder.strictJsonSchema(strictJsonSchema); + } + + Integer contextWindowSize = intOption(context, "contextWindowSize"); + if (contextWindowSize != null) { + builder.contextWindowSize(contextWindowSize); + } + + ResponsesMultiAgentFormatter formatter = + findAssignableComponent(context, ResponsesMultiAgentFormatter.class); + if (formatter != null) { + builder.formatter(formatter); + } + + Object raw = context.option("additionalHeaders"); + if (raw instanceof Map map) { + builder.additionalHeaders((Map) map); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModel.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModel.java new file mode 100644 index 0000000000..a892569252 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModel.java @@ -0,0 +1,383 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import com.openai.client.OpenAIClient; +import com.openai.core.http.StreamResponse; +import com.openai.errors.OpenAIIoException; +import com.openai.errors.OpenAIRetryableException; +import com.openai.errors.OpenAIServiceException; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseInputItem; +import com.openai.models.responses.ResponseStreamEvent; +import io.agentscope.core.message.Msg; +import io.agentscope.core.model.ChatModelBase; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.ExecutionConfig; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ModelContextWindows; +import io.agentscope.core.model.ModelHttpException; +import io.agentscope.core.model.ModelProviderSupport; +import io.agentscope.core.model.ModelUtils; +import io.agentscope.core.model.ToolSchema; +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeoutException; +import java.util.function.Function; +import java.util.function.Predicate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.scheduler.Schedulers; + +public class OpenAIResponsesChatModel extends ChatModelBase { + + private static final Logger log = LoggerFactory.getLogger(OpenAIResponsesChatModel.class); + + private final OpenAIClient client; + private final GenerateOptions configuredOptions; + private final String apiKey; + private final String baseUrl; + private final Boolean strictTools; + private final Boolean strictJsonSchema; + private final ResponsesMultiAgentFormatter formatter; + + OpenAIResponsesChatModel( + OpenAIClient client, + GenerateOptions configuredOptions, + String apiKey, + String baseUrl, + Boolean strictTools, + Boolean strictJsonSchema, + ResponsesMultiAgentFormatter formatter) { + this.client = client; + this.configuredOptions = configuredOptions; + this.apiKey = apiKey; + this.baseUrl = baseUrl; + this.strictTools = strictTools; + this.strictJsonSchema = strictJsonSchema; + this.formatter = formatter; + } + + void applyNativeStructuredOutputDefaults() { + setNativeStructuredOutput(true); + setNativeStructuredOutputWithTools(true); + } + + @Override + public String getModelName() { + return configuredOptions != null ? configuredOptions.getModelName() : null; + } + + @Override + protected Flux doStream( + List messages, List tools, GenerateOptions options) { + return ModelUtils.applyTimeoutAndRetry( + doStream0(messages, tools, options), + options, + configuredOptions, + configuredOptions.getModelName(), + OpenAIOfficialConstants.PROVIDER_ID); + } + + private Flux doStream0( + List messages, List tools, GenerateOptions options) { + + GenerateOptions effectiveOptions = GenerateOptions.mergeOptions(options, configuredOptions); + + validateConnectionFields(effectiveOptions); + + boolean stream = effectiveOptions.getStream() != null ? effectiveOptions.getStream() : true; + + String modelName = effectiveOptions.getModelName(); + log.debug("OpenAI API call: model={}", modelName); + + Function, List> historyMapper = + formatter != null ? formatter::formatHistory : ResponsesRequestMapper::mapHistory; + + ResponseCreateParams params = + ResponsesRequestMapper.map( + messages, + tools, + effectiveOptions, + strictTools, + strictJsonSchema, + historyMapper); + + if (stream) { + return buildStreamingFlux(params, modelName); + } else { + return buildNonStreamingFlux(params, modelName); + } + } + + private Flux buildNonStreamingFlux( + ResponseCreateParams params, String modelName) { + return Flux.defer( + () -> { + Instant start = Instant.now(); + try { + Response response = client.responses().create(params); + return Flux.just( + ResponsesResponseParser.parse(response, modelName, start)); + } catch (RuntimeException e) { + return Flux.error(OpenAIErrorTranslator.translate(e, modelName)); + } + }) + .subscribeOn(Schedulers.boundedElastic()); + } + + private Flux buildStreamingFlux(ResponseCreateParams params, String modelName) { + return Flux.defer( + () -> { + Instant start = Instant.now(); + try { + StreamResponse streamResponse = + client.responses().createStreaming(params); + return ResponsesStreamingAssembler.assemble( + streamResponse, modelName, start); + } catch (RuntimeException e) { + return Flux.error(OpenAIErrorTranslator.translate(e, modelName)); + } + }) + .subscribeOn(Schedulers.boundedElastic()); + } + + private void validateConnectionFields(GenerateOptions effectiveOptions) { + String effectiveApiKey = ModelProviderSupport.trimToNull(effectiveOptions.getApiKey()); + if (effectiveApiKey != null && !Objects.equals(effectiveApiKey, apiKey)) { + throw new OpenAIOfficialModelException( + "apiKey override per request is not supported by the openai-official" + + " provider. The apiKey must match the one configured at model" + + " construction.", + effectiveOptions.getModelName()); + } + + String effectiveBaseUrl = ModelProviderSupport.trimToNull(effectiveOptions.getBaseUrl()); + if (effectiveBaseUrl != null && !Objects.equals(effectiveBaseUrl, baseUrl)) { + throw new OpenAIOfficialModelException( + "baseUrl override per request is not supported by the openai-official" + + " provider. The baseUrl must match the one configured at model" + + " construction.", + effectiveOptions.getModelName()); + } + } + + // Module retryOn predicate + + static Predicate moduleRetryOn() { + return OpenAIResponsesChatModel::isModuleRetryable; + } + + private static boolean isModuleRetryable(Throwable error) { + Boolean headerShouldRetry = checkXShouldRetry(error); + if (headerShouldRetry != null) { + return headerShouldRetry; + } + + Throwable current = error; + while (current != null) { + if (current instanceof ModelHttpException mhe) { + Integer statusCode = mhe.getStatusCode(); + if (statusCode != null) { + int code = statusCode; + if (code == 408 || code == 409 || code == 429 || (code >= 500 && code < 600)) { + return true; + } + } + } + + if (current instanceof OpenAIIoException + || current instanceof OpenAIRetryableException) { + return true; + } + + if (current instanceof IOException) { + return true; + } + + if (current instanceof TimeoutException) { + return true; + } + + current = current.getCause(); + } + return false; + } + + private static Boolean checkXShouldRetry(Throwable current) { + while (current != null) { + if (current instanceof OpenAIServiceException svc) { + List values = svc.headers().values("x-should-retry"); + if (!values.isEmpty()) { + String value = values.get(0); + if ("true".equalsIgnoreCase(value)) { + return true; + } + if ("false".equalsIgnoreCase(value)) { + return false; + } + } + } + current = current.getCause(); + } + return null; + } + + /** Package-private test seam for verifying Builder injection logic. */ + GenerateOptions getConfiguredOptions() { + return configuredOptions; + } + + // Builder + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String apiKey; + private String baseUrl; + private String modelName; + private boolean stream = true; + private GenerateOptions generateOptions; + private int contextWindowSize = -1; + private Boolean strictTools; + private Boolean strictJsonSchema; + private ResponsesMultiAgentFormatter formatter; + private Map additionalHeaders; + + public Builder apiKey(String apiKey) { + this.apiKey = apiKey; + return this; + } + + public Builder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + public Builder modelName(String modelName) { + this.modelName = modelName; + return this; + } + + public Builder stream(boolean stream) { + this.stream = stream; + return this; + } + + public Builder generateOptions(GenerateOptions generateOptions) { + this.generateOptions = generateOptions; + return this; + } + + public Builder contextWindowSize(int contextWindowSize) { + this.contextWindowSize = contextWindowSize; + return this; + } + + public Builder strictTools(Boolean strictTools) { + this.strictTools = strictTools; + return this; + } + + public Builder strictJsonSchema(Boolean strictJsonSchema) { + this.strictJsonSchema = strictJsonSchema; + return this; + } + + public Builder formatter(ResponsesMultiAgentFormatter formatter) { + this.formatter = formatter; + return this; + } + + public Builder additionalHeaders(Map headers) { + this.additionalHeaders = headers; + return this; + } + + public OpenAIResponsesChatModel build() { + Objects.requireNonNull(modelName, "modelName must be set"); + + GenerateOptions options = + GenerateOptions.builder() + .apiKey(apiKey) + .baseUrl(baseUrl) + .modelName(modelName) + .stream(stream) + .build(); + + GenerateOptions mergedOptions = GenerateOptions.mergeOptions(options, generateOptions); + + boolean userProvidedRetryOn = + mergedOptions.getExecutionConfig() != null + && mergedOptions.getExecutionConfig().getRetryOn() != null; + + GenerateOptions effectiveOptions = + ModelUtils.ensureDefaultExecutionConfig(mergedOptions); + + if (!userProvidedRetryOn) { + ExecutionConfig moduleRetryConfig = + ExecutionConfig.builder().retryOn(moduleRetryOn()).build(); + ExecutionConfig mergedExec = + ExecutionConfig.mergeConfigs( + moduleRetryConfig, effectiveOptions.getExecutionConfig()); + GenerateOptions execOverride = + GenerateOptions.builder().executionConfig(mergedExec).build(); + effectiveOptions = GenerateOptions.mergeOptions(execOverride, effectiveOptions); + } + + String resolvedApiKey = + ModelProviderSupport.firstNonBlank( + effectiveOptions.getApiKey(), System.getenv("OPENAI_API_KEY")); + String resolvedBaseUrl = + ModelProviderSupport.firstNonBlank( + effectiveOptions.getBaseUrl(), System.getenv("OPENAI_BASE_URL")); + OpenAIClient client = + OpenAISdkClientFactory.createClient( + resolvedApiKey, + resolvedBaseUrl, + additionalHeaders, + effectiveOptions.getExecutionConfig() != null + ? effectiveOptions.getExecutionConfig().getTimeout() + : null); + + OpenAIResponsesChatModel model = + new OpenAIResponsesChatModel( + client, + effectiveOptions, + resolvedApiKey, + resolvedBaseUrl, + strictTools, + strictJsonSchema, + formatter); + + model.setContextWindowSize( + contextWindowSize >= 0 + ? contextWindowSize + : ModelContextWindows.lookup(modelName, ModelContextWindows.OPENAI)); + + model.applyNativeStructuredOutputDefaults(); + + return model; + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAISdkClientFactory.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAISdkClientFactory.java new file mode 100644 index 0000000000..e70b26f964 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAISdkClientFactory.java @@ -0,0 +1,92 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import java.time.Duration; +import java.util.Map; + +/** + * Factory for constructing official OpenAI Java SDK clients. + * + *

This is the only entry point for production client creation. Tests bypass this factory + * and inject a fake/mock {@link OpenAIClient} via the package-private constructor on + * {@code OpenAIResponsesChatModel}. + * + *

Key behaviours: + *

    + *
  • {@code maxRetries = 0} — AgentScope owns retry.
  • + *
  • Builder-level additional headers are injected into {@code ClientOptions} and remain + * constant across all requests. Per-request headers are not supported.
  • + *
  • {@code apiKey} missing or blank → fail-fast with a non-retryable + * {@link OpenAIOfficialModelException}. The {@code OPENAI_API_KEY} fallback should + * be resolved by the caller (SPI/Builder) before invoking this factory.
  • + *
  • {@code baseUrl} null/blank → SDK defaults to {@code https://api.openai.com/v1} + * .
  • + *
+ */ +final class OpenAISdkClientFactory { + + private OpenAISdkClientFactory() {} + + /** + * Creates an {@link OpenAIClient} from the resolved configuration. + * + * @param apiKey the API key (must be non-blank; caller resolves + * {@code OPENAI_API_KEY} fallback before calling) + * @param baseUrl the base URL, or null/blank for SDK default + * @param additionalHeaders builder-level headers to inject into the client (may be null) + * @param timeout the request timeout, or null for SDK default + * @return a configured {@link OpenAIClient} with {@code maxRetries=0} + * @throws OpenAIOfficialModelException if apiKey is missing/blank or SDK construction fails + */ + static OpenAIClient createClient( + String apiKey, + String baseUrl, + Map additionalHeaders, + Duration timeout) { + if (apiKey == null || apiKey.isBlank()) { + throw new OpenAIOfficialModelException( + "apiKey is required for the openai-official provider. Set it via" + + " builder.apiKey(...) or the OPENAI_API_KEY environment variable.", + null, + null); + } + + try { + OpenAIOkHttpClient.Builder builder = + OpenAIOkHttpClient.builder().apiKey(apiKey).maxRetries(0); + + if (baseUrl != null && !baseUrl.isBlank()) { + builder.baseUrl(baseUrl); + } + + if (timeout != null) { + builder.timeout(timeout); + } + + if (additionalHeaders != null && !additionalHeaders.isEmpty()) { + additionalHeaders.forEach(builder::putHeader); + } + + return builder.build(); + } catch (RuntimeException e) { + throw new OpenAIOfficialModelException( + "Failed to construct OpenAI client: " + e.getMessage(), e, null); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesHelper.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesHelper.java new file mode 100644 index 0000000000..effc4c2884 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesHelper.java @@ -0,0 +1,120 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseError; +import com.openai.models.responses.ResponseStatus; +import com.openai.models.responses.ResponseUsage; +import io.agentscope.core.model.ChatUsage; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +final class ResponsesHelper { + + private ResponsesHelper() {} + + /** + * Extracts response-level metadata (id, status, timestamps, service tier, + * incomplete details, error) from a terminal {@link Response}. + * + * @param response the SDK Response object + * @return a mutable metadata map containing response-level keys + */ + static Map extractResponseMetadata(Response response) { + Map metadata = new HashMap<>(); + + metadata.put(OpenAIOfficialConstants.MD_RESPONSE_ID, response.id()); + + Optional statusOpt = response.status(); + if (statusOpt.isPresent()) { + metadata.put(OpenAIOfficialConstants.MD_RESPONSE_STATUS, statusOpt.get().asString()); + } + + metadata.put(OpenAIOfficialConstants.MD_RESPONSE_CREATED_AT, response.createdAt()); + + Optional completedAt = response.completedAt(); + if (completedAt.isPresent()) { + metadata.put(OpenAIOfficialConstants.MD_RESPONSE_COMPLETED_AT, completedAt.get()); + } + + Optional serviceTier = response.serviceTier(); + if (serviceTier.isPresent()) { + metadata.put( + OpenAIOfficialConstants.MD_RESPONSE_SERVICE_TIER, serviceTier.get().asString()); + } + + Optional incompleteDetails = response.incompleteDetails(); + if (incompleteDetails.isPresent()) { + Optional reason = incompleteDetails.get().reason(); + if (reason.isPresent()) { + metadata.put( + OpenAIOfficialConstants.MD_RESPONSE_INCOMPLETE_REASON, + reason.get().asString()); + } + } + + Optional error = response.error(); + if (error.isPresent()) { + Map errorMap = new HashMap<>(); + errorMap.put("message", error.get().message()); + try { + errorMap.put("code", error.get().code().asString()); + } catch (RuntimeException ignored) { + // code field may be missing + } + metadata.put(OpenAIOfficialConstants.MD_RESPONSE_ERROR, errorMap); + } + + return metadata; + } + + /** + * Extracts usage from a terminal {@link Response}. + * + * @param response the SDK Response object + * @param startTime the start time for wall-clock timing + * @param metadata the metadata map to write reasoning tokens into + * @return a {@link ChatUsage}, or {@code null} if the response has no usage + */ + static ChatUsage extractUsage( + Response response, Instant startTime, Map metadata) { + Optional usageOpt = response.usage(); + if (usageOpt.isEmpty()) { + return null; + } + + ResponseUsage respUsage = usageOpt.get(); + long inputTokens = respUsage.inputTokens(); + long outputTokens = respUsage.outputTokens(); + long cachedTokens = respUsage.inputTokensDetails().cachedTokens(); + long reasoningTokens = respUsage.outputTokensDetails().reasoningTokens(); + + double time = Duration.between(startTime, Instant.now()).toMillis() / 1000.0; + + metadata.put(OpenAIOfficialConstants.MD_USAGE_REASONING_TOKENS, (int) reasoningTokens); + + return ChatUsage.builder() + .inputTokens((int) inputTokens) + .outputTokens((int) outputTokens) + .cachedTokens((int) cachedTokens) + .time(time) + .build(); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatter.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatter.java new file mode 100644 index 0000000000..b0c5c9702e --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatter.java @@ -0,0 +1,334 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import com.openai.models.responses.EasyInputMessage; +import com.openai.models.responses.ResponseInputContent; +import com.openai.models.responses.ResponseInputImage; +import com.openai.models.responses.ResponseInputItem; +import com.openai.models.responses.ResponseInputText; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.DataBlock; +import io.agentscope.core.message.HintBlock; +import io.agentscope.core.message.ImageBlock; +import io.agentscope.core.message.MessageMetadataKeys; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Multi-agent formatter for the OpenAI Responses API. + * + *

Converts AgentScope Msg objects to Responses API ResponseInputItem objects + * with multi-agent support. + * + *

The formatter groups messages into: + *

    + *
  • SYSTEM - extracted individually as {@code EasyInputMessage} with SYSTEM role + *
  • TOOL_SEQUENCE - tool calls and results, passed through to + * {@link ResponsesRequestMapper} unchanged (including reasoning replay) + *
  • AGENT_CONVERSATION - merged into a single {@code EasyInputMessage} with + * USER role, wrapped in {@code } tags + *
  • BYPASS - messages with {@code BYPASS_MULTIAGENT_HISTORY_MERGE} metadata, + * passed through individually as user messages + *
+ */ +public class ResponsesMultiAgentFormatter { + + private static final Logger log = LoggerFactory.getLogger(ResponsesMultiAgentFormatter.class); + + private static final String DEFAULT_CONVERSATION_HISTORY_PROMPT = + "# Conversation History\n" + + "The content between tags contains your conversation" + + " history\n"; + + private static final String HISTORY_START_TAG = ""; + private static final String HISTORY_END_TAG = ""; + + private final String conversationHistoryPrompt; + + /** Create a formatter with the default conversation history prompt. */ + public ResponsesMultiAgentFormatter() { + this(DEFAULT_CONVERSATION_HISTORY_PROMPT); + } + + /** + * Create a formatter with a custom conversation history prompt. + * + * @param conversationHistoryPrompt the prompt to prepend before the first history block + */ + public ResponsesMultiAgentFormatter(String conversationHistoryPrompt) { + this.conversationHistoryPrompt = conversationHistoryPrompt; + } + + /** + * Formats AgentScope messages into Responses API input items with multi-agent merging. + * + *

System messages and tool sequences are passed through to {@link ResponsesRequestMapper} + * unchanged. Agent conversation messages are merged into single user messages with + * {@code } tags. + * + * @param messages the conversation history + * @return Responses API input items + */ + public List formatHistory(List messages) { + List result = new ArrayList<>(); + List groups = groupMessages(messages); + boolean isFirstAgentGroup = true; + + for (MessageGroup group : groups) { + switch (group.type) { + case SYSTEM -> + ResponsesRequestMapper.mapSystemMessage(group.messages.get(0), result); + case TOOL_SEQUENCE -> { + for (Msg msg : group.messages) { + if (msg.getRole() == MsgRole.ASSISTANT || msg.getRole() == MsgRole.TOOL) { + ResponsesRequestMapper.mapMessage(msg, result); + } + } + } + case AGENT_CONVERSATION -> { + result.addAll(mergeAgentConversation(group.messages, isFirstAgentGroup)); + isFirstAgentGroup = false; + } + case BYPASS -> ResponsesRequestMapper.mapMessage(group.messages.get(0), result); + } + } + + return result; + } + + // -- Grouping -------------------------------------------------- + + /** + * Types of message groups in multi-agent conversations. + */ + private enum GroupType { + SYSTEM, // System messages + TOOL_SEQUENCE, // Tool use and tool result messages + AGENT_CONVERSATION, // Regular agent conversation messages + BYPASS // Messages that bypass history merging + } + + private record MessageGroup(GroupType type, List messages) {} + + /** + * Groups messages into contiguous runs of the same type. SYSTEM and BYPASS messages + * always start a new group (they are never grouped with adjacent messages). + */ + private List groupMessages(List msgs) { + List groups = new ArrayList<>(); + List currentGroup = new ArrayList<>(); + GroupType currentType = null; + + for (Msg msg : msgs) { + GroupType msgType = determineGroupType(msg); + + if (currentType == null + || currentType != msgType + || msgType == GroupType.SYSTEM + || msgType == GroupType.BYPASS) { + if (!currentGroup.isEmpty()) { + groups.add(new MessageGroup(currentType, new ArrayList<>(currentGroup))); + } + currentGroup = new ArrayList<>(); + currentType = msgType; + } + currentGroup.add(msg); + } + + if (!currentGroup.isEmpty()) { + groups.add(new MessageGroup(currentType, currentGroup)); + } + + return groups; + } + + private GroupType determineGroupType(Msg msg) { + if (shouldBypassHistory(msg)) { + return GroupType.BYPASS; + } + + return switch (msg.getRole()) { + case SYSTEM -> GroupType.SYSTEM; + case TOOL -> GroupType.TOOL_SEQUENCE; + case USER, ASSISTANT -> { + if (msg.hasContentBlocks(ToolUseBlock.class) + || msg.hasContentBlocks(ToolResultBlock.class)) { + yield GroupType.TOOL_SEQUENCE; + } + yield GroupType.AGENT_CONVERSATION; + } + }; + } + + private static boolean shouldBypassHistory(Msg msg) { + if (msg.getMetadata() == null) { + return false; + } + Object bypassFlag = + msg.getMetadata().get(MessageMetadataKeys.BYPASS_MULTIAGENT_HISTORY_MERGE); + return Boolean.TRUE.equals(bypassFlag); + } + + // -- Conversation merging -------------------------------------- + + /** + * Merges a group of agent conversation messages into a single user message + * with {@code } tags. + * + *

Text and thinking blocks are accumulated in a text buffer. Image and data blocks + * flush the buffer and are added as separate {@link ResponseInputContent} parts to + * preserve their multimodal nature. + * + * @param messages the conversation messages to merge + * @param isFirstGroup whether this is the first agent conversation group (controls + * whether the history prompt is included) + * @return a single-element list containing the merged user message, or empty list + */ + private List mergeAgentConversation( + List messages, boolean isFirstGroup) { + + List parts = new ArrayList<>(); + StringBuilder textBuffer = new StringBuilder(); + + String prompt = isFirstGroup ? conversationHistoryPrompt : ""; + textBuffer.append(prompt).append(HISTORY_START_TAG).append("\n"); + + // Include agent name prefix only in multi-turn context + boolean includePrefix = messages.size() > 1; + + for (Msg msg : messages) { + processMessage(msg, textBuffer, parts, includePrefix); + } + + textBuffer.append(HISTORY_END_TAG).append("\n"); + + // Flush remaining text (always non-empty: at least the history tags are present) + parts.add( + ResponseInputContent.ofInputText( + ResponseInputText.builder().text(textBuffer.toString()).build())); + + return List.of( + ResponseInputItem.ofEasyInputMessage( + EasyInputMessage.builder() + .role(EasyInputMessage.Role.USER) + .contentOfResponseInputMessageContentList(parts) + .build())); + } + + /** + * Processes a single message, routing each content block to the text buffer + * or the parts list as appropriate. + */ + private void processMessage( + Msg msg, + StringBuilder textBuffer, + List parts, + boolean includePrefix) { + + String agentName = msg.getName(); + List blocks = msg.getContent(); + if (blocks == null) { + return; + } + + for (ContentBlock block : blocks) { + if (block instanceof TextBlock tb) { + if (includePrefix) { + appendNamePrefix(textBuffer, agentName); + } + textBuffer.append(tb.getText()).append("\n"); + } else if (block instanceof HintBlock hb) { + if (includePrefix) { + appendNamePrefix(textBuffer, agentName); + } + textBuffer.append(hb.getHint()).append("\n"); + } else if (block instanceof ImageBlock ib) { + flushText(textBuffer, parts); + try { + String imageUrl = ResponsesRequestMapper.resolveImageUrl(ib.getSource()); + parts.add( + ResponseInputContent.ofInputImage( + ResponseInputImage.builder() + .imageUrl(imageUrl) + .detail(ResponseInputImage.Detail.of("auto")) + .build())); + } catch (Exception e) { + log.warn( + "Failed to process ImageBlock in multi-agent conversation: {}", + e.getMessage()); + if (includePrefix) { + appendNamePrefix(textBuffer, agentName); + } + textBuffer.append("[Image - processing failed]\n"); + } + } else if (block instanceof DataBlock db) { + flushText(textBuffer, parts); + try { + String imageUrl = ResponsesRequestMapper.resolveDataBlockImageUrl(db); + parts.add( + ResponseInputContent.ofInputImage( + ResponseInputImage.builder() + .imageUrl(imageUrl) + .detail(ResponseInputImage.Detail.of("auto")) + .build())); + } catch (Exception e) { + log.warn( + "Failed to process DataBlock in multi-agent conversation: {}", + e.getMessage()); + if (includePrefix) { + appendNamePrefix(textBuffer, agentName); + } + textBuffer.append("[Data - processing failed]\n"); + } + } else if (block instanceof ThinkingBlock tb) { + if (includePrefix) { + appendNamePrefix(textBuffer, agentName); + } + String thinking = tb.getThinking(); + if (thinking != null && !thinking.isEmpty()) { + textBuffer.append("[Thinking]: ").append(thinking).append("\n"); + } + } + // Other block types (AudioBlock, VideoBlock, ToolUseBlock, + // ToolResultBlock) are silently skipped. ToolUseBlock and + // ToolResultBlock can never reach here + } + } + + private void flushText(StringBuilder textBuffer, List parts) { + if (textBuffer.length() > 0) { + parts.add( + ResponseInputContent.ofInputText( + ResponseInputText.builder().text(textBuffer.toString()).build())); + textBuffer.setLength(0); + } + } + + private void appendNamePrefix(StringBuilder buffer, String agentName) { + if (agentName != null && !agentName.isEmpty()) { + buffer.append(agentName).append(": "); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapper.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapper.java new file mode 100644 index 0000000000..52f95eced6 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapper.java @@ -0,0 +1,771 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import com.openai.core.JsonField; +import com.openai.core.JsonMissing; +import com.openai.core.JsonValue; +import com.openai.models.Reasoning; +import com.openai.models.ReasoningEffort; +import com.openai.models.ResponseFormatJsonObject; +import com.openai.models.ResponsesModel; +import com.openai.models.responses.EasyInputMessage; +import com.openai.models.responses.FunctionTool; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseFormatTextJsonSchemaConfig; +import com.openai.models.responses.ResponseFunctionCallOutputItem; +import com.openai.models.responses.ResponseFunctionToolCall; +import com.openai.models.responses.ResponseInputContent; +import com.openai.models.responses.ResponseInputImage; +import com.openai.models.responses.ResponseInputImageContent; +import com.openai.models.responses.ResponseInputItem; +import com.openai.models.responses.ResponseInputText; +import com.openai.models.responses.ResponseInputTextContent; +import com.openai.models.responses.ResponseReasoningItem; +import com.openai.models.responses.ResponseTextConfig; +import com.openai.models.responses.Tool; +import com.openai.models.responses.ToolChoiceFunction; +import com.openai.models.responses.ToolChoiceOptions; +import io.agentscope.core.formatter.JsonSchema; +import io.agentscope.core.formatter.ResponseFormat; +import io.agentscope.core.message.Base64Source; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.DataBlock; +import io.agentscope.core.message.ImageBlock; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.Source; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.message.URLSource; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolChoice; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.util.JsonUtils; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; + +/** + * Maps AgentScope inputs to OpenAI Responses API {@link ResponseCreateParams}. + * + *

Handles options mapping, message history mapping (including reasoning replay), + * tool definition mapping, and all fail-fast validation for unsupported fields. + */ +final class ResponsesRequestMapper { + + private ResponsesRequestMapper() {} + + /** + * Maps AgentScope messages, tools, and options to a {@link ResponseCreateParams}. + * + * @param messages the conversation history + * @param tools the tool definitions (may be null or empty) + * @param effectiveOptions the merged (per-call + configured) generation options + * @param strictTools the model-level strict tools setting (null = not set) + * @param strictJsonSchema the model-level strict JSON schema setting (null = not set) + * @param historyMapper function that converts messages to Responses API input items; + * use {@link #mapHistory(List)} for default 1:1 mapping, or pass a + * {@link ResponsesMultiAgentFormatter#formatHistory} method reference for + * multi-agent conversation merging + * @return a built {@link ResponseCreateParams} + */ + static ResponseCreateParams map( + List messages, + List tools, + GenerateOptions effectiveOptions, + Boolean strictTools, + Boolean strictJsonSchema, + Function, List> historyMapper) { + + validateRejectedFields(effectiveOptions); + + Map additionalBodyParams = effectiveOptions.getAdditionalBodyParams(); + if (additionalBodyParams != null && !additionalBodyParams.isEmpty()) { + validateWhitelist(additionalBodyParams); + } + + ResponseCreateParams.Builder builder = + ResponseCreateParams.builder() + .store(false) + .model(ResponsesModel.ofString(effectiveOptions.getModelName())); + + mapOptions(builder, effectiveOptions, strictJsonSchema); + + if (additionalBodyParams != null && !additionalBodyParams.isEmpty()) { + mapAdditionalBodyParams(builder, additionalBodyParams); + } + + builder.inputOfResponse(historyMapper.apply(messages)); + + if (tools != null && !tools.isEmpty()) { + builder.tools(mapTools(tools, strictTools)); + } + + if (effectiveOptions.getToolChoice() != null) { + mapToolChoice(builder, effectiveOptions.getToolChoice()); + } + + return builder.build(); + } + + // ── Options mapping ────────────────────────────────────────── + + private static void mapOptions( + ResponseCreateParams.Builder builder, + GenerateOptions options, + Boolean strictJsonSchema) { + + if (options.getTemperature() != null) { + builder.temperature(options.getTemperature()); + } + if (options.getTopP() != null) { + builder.topP(options.getTopP()); + } + + Integer maxTokens = options.getMaxCompletionTokens(); + if (maxTokens == null) { + maxTokens = options.getMaxTokens(); + } + if (maxTokens != null) { + builder.maxOutputTokens(maxTokens.longValue()); + } + + if (options.getParallelToolCalls() != null) { + builder.parallelToolCalls(options.getParallelToolCalls()); + } + + if (options.getReasoningEffort() != null + || hasWhitelistKey(options, "reasoning.summary") + || hasWhitelistKey(options, "reasoning.context") + || hasWhitelistKey(options, "reasoning.mode")) { + builder.reasoning(buildReasoning(options)); + } + + if (options.getResponseFormat() != null) { + ResponseFormat format = options.getResponseFormat(); + if (format.getType() != null) { + switch (format.getType()) { + case "json_object" -> + builder.text( + ResponseTextConfig.builder() + .format(ResponseFormatJsonObject.builder().build()) + .build()); + case "json_schema" -> { + JsonSchema schema = format.getJsonSchema(); + if (schema == null || schema.getName() == null) { + throw new OpenAIOfficialModelException( + "json_schema response format requires a non-null" + + " schema or schema name."); + } + ResponseFormatTextJsonSchemaConfig.Builder schemaBuilder = + ResponseFormatTextJsonSchemaConfig.builder() + .name(schema.getName()) + .schema(buildSchema(schema.getSchema())); + if (schema.getDescription() != null) { + schemaBuilder.description(schema.getDescription()); + } + if (strictJsonSchema != null) { + schemaBuilder.strict(strictJsonSchema); + } + builder.text( + ResponseTextConfig.builder().format(schemaBuilder.build()).build()); + } + case "text" -> { + // SDK default is plain text; no need to set text param + } + default -> + throw new OpenAIOfficialModelException( + "Unsupported response format type: " + format.getType()); + } + } + } + } + + private static boolean hasWhitelistKey(GenerateOptions options, String key) { + Map params = options.getAdditionalBodyParams(); + return params != null && params.get(key) != null; + } + + private static Reasoning buildReasoning(GenerateOptions options) { + Reasoning.Builder builder = Reasoning.builder(); + if (options.getReasoningEffort() != null) { + // validate() forces client-side enum checking; of() alone accepts any string + builder.effort(ReasoningEffort.of(options.getReasoningEffort()).validate()); + } + Map params = options.getAdditionalBodyParams(); + if (params != null) { + String summary = asString(params.get("reasoning.summary")); + if (summary != null) { + builder.summary(Reasoning.Summary.of(summary).validate()); + } + String context = asString(params.get("reasoning.context")); + if (context != null) { + builder.context(Reasoning.Context.of(context).validate()); + } + String mode = asString(params.get("reasoning.mode")); + if (mode != null) { + builder.mode(Reasoning.Mode.of(mode).validate()); + } + } + return builder.build(); + } + + // ── AdditionalBodyParams mapping ───────────────────────────── + + private static void validateWhitelist(Map params) { + for (String key : params.keySet()) { + if (!OpenAIOfficialConstants.ADDITIONAL_BODY_PARAMS_WHITELIST.contains(key)) { + throw new OpenAIOfficialModelException( + "additionalBodyParams key '" + + key + + "' is not in the whitelist for the openai-official" + + " provider."); + } + } + } + + private static void mapAdditionalBodyParams( + ResponseCreateParams.Builder builder, Map params) { + + String maxToolCalls = asString(params.get("max_tool_calls")); + if (maxToolCalls != null) { + try { + builder.maxToolCalls(Long.parseLong(maxToolCalls)); + } catch (NumberFormatException e) { + throw new OpenAIOfficialModelException( + "Invalid max_tool_calls value '" + + maxToolCalls + + "': expected a long integer."); + } + } + + String serviceTier = asString(params.get("service_tier")); + if (serviceTier != null) { + builder.serviceTier(ResponseCreateParams.ServiceTier.of(serviceTier).validate()); + } + + String promptCacheKey = asString(params.get("prompt_cache_key")); + if (promptCacheKey != null) { + builder.promptCacheKey(promptCacheKey); + } + + Object promptCacheOptionsRaw = params.get("prompt_cache_options"); + if (promptCacheOptionsRaw != null) { + if (!(promptCacheOptionsRaw instanceof Map)) { + throw new OpenAIOfficialModelException( + "prompt_cache_options must be a Map, got: " + + promptCacheOptionsRaw.getClass().getSimpleName()); + } + builder.promptCacheOptions(buildPromptCacheOptions((Map) promptCacheOptionsRaw)); + } + + String safetyIdentifier = asString(params.get("safety_identifier")); + if (safetyIdentifier != null) { + builder.safetyIdentifier(safetyIdentifier); + } + } + + private static ResponseCreateParams.PromptCacheOptions buildPromptCacheOptions(Map raw) { + ResponseCreateParams.PromptCacheOptions.Builder builder = + ResponseCreateParams.PromptCacheOptions.builder(); + String mode = asString(raw.get("mode")); + if (mode != null) { + builder.mode(ResponseCreateParams.PromptCacheOptions.Mode.of(mode).validate()); + } + String ttl = asString(raw.get("ttl")); + if (ttl != null) { + builder.ttl(ResponseCreateParams.PromptCacheOptions.Ttl.of(ttl).validate()); + } + return builder.build(); + } + + // ── Rejected fields validation ─────────────────────────────── + + private static void validateRejectedFields(GenerateOptions options) { + if (options.getEndpointPath() != null && !options.getEndpointPath().isBlank()) { + throw new OpenAIOfficialModelException( + "endpointPath is not supported by the openai-official provider."); + } + if (options.getFrequencyPenalty() != null) { + throw new OpenAIOfficialModelException( + "frequencyPenalty is not supported by the openai-official provider."); + } + if (options.getPresencePenalty() != null) { + throw new OpenAIOfficialModelException( + "presencePenalty is not supported by the openai-official provider."); + } + if (options.getTopK() != null) { + throw new OpenAIOfficialModelException( + "topK is not supported by the openai-official provider."); + } + if (options.getSeed() != null) { + throw new OpenAIOfficialModelException( + "seed is not supported by the openai-official provider."); + } + if (options.getCacheControl() != null) { + throw new OpenAIOfficialModelException( + "cacheControl is not supported by the openai-official provider."); + } + if (options.getThinkingBudget() != null) { + throw new OpenAIOfficialModelException( + "thinkingBudget is not supported by the openai-official provider."); + } + if (options.getAdditionalHeaders() != null && !options.getAdditionalHeaders().isEmpty()) { + throw new OpenAIOfficialModelException( + "per-request additionalHeaders are not supported by the" + + " openai-official provider. Use builder-level" + + " additionalHeaders instead."); + } + if (options.getAdditionalQueryParams() != null + && !options.getAdditionalQueryParams().isEmpty()) { + throw new OpenAIOfficialModelException( + "per-request additionalQueryParams are not supported by" + + " the openai-official provider."); + } + } + + // ── History mapping ────────────────────────────────────────── + + static List mapHistory(List messages) { + List items = new ArrayList<>(); + for (Msg msg : messages) { + mapMessage(msg, items); + } + return items; + } + + static void mapMessage(Msg msg, List items) { + switch (msg.getRole()) { + case SYSTEM -> mapSystemMessage(msg, items); + case USER -> mapUserMessage(msg, items); + case ASSISTANT -> mapAssistantMessage(msg, items); + case TOOL -> mapToolMessage(msg, items); + } + } + + static void mapSystemMessage(Msg msg, List items) { + // Msg.validateRoleContent only allows TextBlock for the SYSTEM role during construction + String text = collectText(msg); + items.add( + ResponseInputItem.ofEasyInputMessage( + EasyInputMessage.builder() + .role(EasyInputMessage.Role.SYSTEM) + .content(text != null ? text : "") + .build())); + } + + static void mapUserMessage(Msg msg, List items) { + List blocks = msg.getContent(); + if (blocks == null || blocks.isEmpty()) { + items.add( + ResponseInputItem.ofEasyInputMessage( + EasyInputMessage.builder() + .role(EasyInputMessage.Role.USER) + .content("") + .build())); + return; + } + + if (blocks.size() == 1 && blocks.get(0) instanceof TextBlock tb) { + items.add( + ResponseInputItem.ofEasyInputMessage( + EasyInputMessage.builder() + .role(EasyInputMessage.Role.USER) + .content(tb.getText()) + .build())); + return; + } + + List parts = new ArrayList<>(); + for (Object block : blocks) { + if (block instanceof TextBlock tb) { + parts.add( + ResponseInputContent.ofInputText( + ResponseInputText.builder().text(tb.getText()).build())); + } else if (block instanceof ImageBlock ib) { + String imageUrl = resolveImageUrl(ib.getSource()); + parts.add( + ResponseInputContent.ofInputImage( + ResponseInputImage.builder() + .imageUrl(imageUrl) + .detail(ResponseInputImage.Detail.of("auto")) + .build())); + } else if (block instanceof DataBlock db) { + String imageUrl = resolveDataBlockImageUrl(db); + parts.add( + ResponseInputContent.ofInputImage( + ResponseInputImage.builder() + .imageUrl(imageUrl) + .detail(ResponseInputImage.Detail.of("auto")) + .build())); + } else { + throw new OpenAIOfficialModelException( + "Unsupported content block in user message: " + + block.getClass().getSimpleName()); + } + } + items.add( + ResponseInputItem.ofEasyInputMessage( + EasyInputMessage.builder() + .role(EasyInputMessage.Role.USER) + .contentOfResponseInputMessageContentList(parts) + .build())); + } + + static void mapAssistantMessage(Msg msg, List items) { + Map metadata = msg.getMetadata(); + String encryptedContent = null; + if (metadata != null) { + Object ec = metadata.get(OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT); + if (ec instanceof String s) { + encryptedContent = s; + } + } + + if (encryptedContent != null) { + mapReasoningReplay(msg, encryptedContent, items); + } + + String text = collectText(msg); + if (text != null && !text.isEmpty()) { + items.add( + ResponseInputItem.ofEasyInputMessage( + EasyInputMessage.builder() + .role(EasyInputMessage.Role.ASSISTANT) + .content(text) + .build())); + } + + List blocks = msg.getContent(); + if (blocks != null) { + for (Object block : blocks) { + if (block instanceof ToolUseBlock tb) { + mapToolUseBlock(tb, items); + } else if (block instanceof ThinkingBlock) { + if (encryptedContent == null) { + throw new OpenAIOfficialModelException( + "Cannot map ThinkingBlock to a Responses reasoning" + + " input item without encrypted_content."); + } + } else if (!(block instanceof TextBlock)) { + throw new OpenAIOfficialModelException( + "Unsupported content block in assistant message: " + + block.getClass().getSimpleName()); + } + } + } + } + + private static void mapReasoningReplay( + Msg msg, String encryptedContent, List items) { + ResponseReasoningItem.Builder builder = + ResponseReasoningItem.builder() + .id((JsonField) JsonMissing.of()) + .encryptedContent(encryptedContent); + + ThinkingBlock thinkingBlock = msg.getFirstContentBlock(ThinkingBlock.class); + String summary = thinkingBlock != null ? thinkingBlock.getThinking() : null; + + // SDK requires the summary field; set empty list when no summary text + if (summary != null && !summary.isEmpty()) { + builder.summary(List.of(ResponseReasoningItem.Summary.builder().text(summary).build())); + } else { + builder.summary(List.of()); + } + items.add(ResponseInputItem.ofReasoning(builder.build())); + } + + private static void mapToolUseBlock(ToolUseBlock tb, List items) { + Objects.requireNonNull(tb.getId(), "ToolUseBlock.id must not be null for history replay"); + Objects.requireNonNull( + tb.getName(), "ToolUseBlock.name must not be null for history replay"); + + String arguments = JsonUtils.resolveToolCallArgsJson(tb); + + items.add( + ResponseInputItem.ofFunctionCall( + ResponseFunctionToolCall.builder() + .callId(tb.getId()) + .name(tb.getName()) + .arguments(arguments) + .build())); + } + + static void mapToolMessage(Msg msg, List items) { + List blocks = msg.getContent(); + if (blocks == null || blocks.isEmpty()) { + throw new OpenAIOfficialModelException( + "Tool message has no content blocks; at least one" + + " ToolResultBlock is required for history replay."); + } + for (Object block : blocks) { + if (block instanceof ToolResultBlock trb) { + mapToolResultBlock(trb, items); + } else { + throw new OpenAIOfficialModelException( + "Unsupported content block in tool message: " + + block.getClass().getSimpleName()); + } + } + } + + private static void mapToolResultBlock(ToolResultBlock trb, List items) { + Objects.requireNonNull( + trb.getId(), "ToolResultBlock.id must not be null for history replay"); + + List outputBlocks = trb.getOutput(); + + // When all output blocks are text, use the simpler string form; + // when any non-text block is present (image/data), use the list form + // to preserve mixed content order. + boolean hasNonText = false; + for (ContentBlock block : outputBlocks) { + if (!(block instanceof TextBlock)) { + hasNonText = true; + break; + } + } + + ResponseInputItem.FunctionCallOutput.Builder fcoBuilder = + ResponseInputItem.FunctionCallOutput.builder().callId(trb.getId()); + if (hasNonText) { + fcoBuilder.outputOfResponseFunctionCallOutputItemList( + mapToolResultOutputItems(outputBlocks)); + } else { + fcoBuilder.output( + ResponseInputItem.FunctionCallOutput.Output.ofString( + joinOutputText(outputBlocks))); + } + items.add(ResponseInputItem.ofFunctionCallOutput(fcoBuilder.build())); + } + + /** + * Maps tool result output blocks to a list of {@link ResponseFunctionCallOutputItem}, + * preserving the original block order for mixed text/image content. + */ + private static List mapToolResultOutputItems( + List blocks) { + List result = new ArrayList<>(); + for (ContentBlock block : blocks) { + if (block instanceof TextBlock tb) { + result.add( + ResponseFunctionCallOutputItem.ofInputText( + ResponseInputTextContent.builder().text(tb.getText()).build())); + } else if (block instanceof ImageBlock ib) { + result.add(buildImageOutputItem(resolveImageUrl(ib.getSource()))); + } else if (block instanceof DataBlock db) { + result.add(buildImageOutputItem(resolveDataBlockImageUrl(db))); + } else { + throw new OpenAIOfficialModelException( + "Unsupported output block in ToolResultBlock: " + + block.getClass().getSimpleName()); + } + } + return result; + } + + /** + * Builds a {@link ResponseFunctionCallOutputItem} for an image URL. + * + * @param imageUrl the resolved image URL or data URI + * @return an input_image variant of {@link ResponseFunctionCallOutputItem} + */ + private static ResponseFunctionCallOutputItem buildImageOutputItem(String imageUrl) { + return ResponseFunctionCallOutputItem.ofInputImage( + ResponseInputImageContent.builder() + .imageUrl(imageUrl) + .detail(ResponseInputImageContent.Detail.of("auto")) + .build()); + } + + /** + * Joins all {@link TextBlock} text in the given list with newline separators. + * + * @param blocks the output blocks (guaranteed non-null by {@link ToolResultBlock}) + * @return the joined text, or empty string if no text blocks are present + */ + private static String joinOutputText(List blocks) { + if (blocks.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (ContentBlock block : blocks) { + if (block instanceof TextBlock tb) { + if (sb.length() > 0) { + sb.append("\n"); + } + sb.append(tb.getText()); + } + } + return sb.toString(); + } + + // ── Tool definition mapping ────────────────────────────────── + + private static List mapTools(List tools, Boolean strictTools) { + List result = new ArrayList<>(); + for (ToolSchema schema : tools) { + result.add(Tool.ofFunction(mapFunctionTool(schema, strictTools))); + } + return result; + } + + private static FunctionTool mapFunctionTool(ToolSchema schema, Boolean strictTools) { + // Resolve strict to a boolean upfront (tool-level > builder-level > false), + boolean effectiveStrict = + schema.getStrict() != null ? schema.getStrict() : Boolean.TRUE.equals(strictTools); + + FunctionTool.Builder builder = + FunctionTool.builder() + .name(schema.getName()) + .description(schema.getDescription()) + .strict(effectiveStrict); + + Map parameters = schema.getParameters(); + if (parameters != null && !parameters.isEmpty()) { + builder.parameters(buildParameters(parameters)); + } else { + builder.parameters(buildParameters(emptyToolParameters(effectiveStrict))); + } + + if (schema.getOutputSchema() != null && !schema.getOutputSchema().isEmpty()) { + builder.outputSchema(buildOutputSchema(schema.getOutputSchema())); + } + + return builder.build(); + } + + private static FunctionTool.Parameters buildParameters(Map schema) { + FunctionTool.Parameters.Builder builder = FunctionTool.Parameters.builder(); + for (Map.Entry entry : schema.entrySet()) { + builder.putAdditionalProperty(entry.getKey(), JsonValue.from(entry.getValue())); + } + return builder.build(); + } + + private static FunctionTool.OutputSchema buildOutputSchema(Map schema) { + FunctionTool.OutputSchema.Builder builder = FunctionTool.OutputSchema.builder(); + for (Map.Entry entry : schema.entrySet()) { + builder.putAdditionalProperty(entry.getKey(), JsonValue.from(entry.getValue())); + } + return builder.build(); + } + + private static ResponseFormatTextJsonSchemaConfig.Schema buildSchema( + Map schema) { + ResponseFormatTextJsonSchemaConfig.Schema.Builder builder = + ResponseFormatTextJsonSchemaConfig.Schema.builder(); + for (Map.Entry entry : schema.entrySet()) { + builder.putAdditionalProperty(entry.getKey(), JsonValue.from(entry.getValue())); + } + return builder.build(); + } + + /** + * Builds an empty parameters schema for tools without explicit parameters. + * + *

When {@code strict} is true, includes {@code additionalProperties: false} and + * {@code required: []} per the OpenAI structured outputs spec. When false, omits + * {@code additionalProperties}. + */ + private static Map emptyToolParameters(boolean strict) { + Map schema = new HashMap<>(); + schema.put("type", "object"); + schema.put("properties", Map.of()); + schema.put("required", List.of()); + if (strict) { + schema.put("additionalProperties", false); + } + return schema; + } + + // ── ToolChoice mapping ─────────────────────────────────────── + + private static void mapToolChoice(ResponseCreateParams.Builder builder, ToolChoice choice) { + if (choice instanceof ToolChoice.Auto) { + builder.toolChoice(ToolChoiceOptions.AUTO); + } else if (choice instanceof ToolChoice.None) { + builder.toolChoice(ToolChoiceOptions.NONE); + } else if (choice instanceof ToolChoice.Required) { + builder.toolChoice(ToolChoiceOptions.REQUIRED); + } else if (choice instanceof ToolChoice.Specific specific) { + builder.toolChoice(ToolChoiceFunction.builder().name(specific.toolName()).build()); + } else { + throw new OpenAIOfficialModelException( + "Unsupported ToolChoice type: " + choice.getClass().getSimpleName()); + } + } + + // ── Helpers ────────────────────────────────────────────────── + + static String collectText(Msg msg) { + List blocks = msg.getContent(); + if (blocks == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (Object block : blocks) { + if (block instanceof TextBlock tb) { + if (sb.length() > 0) { + sb.append("\n"); + } + sb.append(tb.getText()); + } + } + return sb.length() > 0 ? sb.toString() : null; + } + + static String resolveImageUrl(Source source) { + if (source instanceof URLSource urlSource) { + return urlSource.getUrl(); + } + if (source instanceof Base64Source base64) { + return "data:" + base64.getMediaType() + ";base64," + base64.getData(); + } + throw new OpenAIOfficialModelException( + "Unsupported image source type: " + source.getClass().getSimpleName()); + } + + static String resolveDataBlockImageUrl(DataBlock db) { + Source source = db.getSource(); + if (source instanceof URLSource urlSource) { + return urlSource.getUrl(); + } + if (source instanceof Base64Source base64) { + String mediaType = base64.getMediaType(); + if (mediaType == null || !mediaType.startsWith("image/")) { + throw new OpenAIOfficialModelException( + "Non-image DataBlock is not supported by the" + + " openai-official provider."); + } + return "data:" + mediaType + ";base64," + base64.getData(); + } + throw new OpenAIOfficialModelException( + "Unsupported DataBlock source type: " + source.getClass().getSimpleName()); + } + + private static String asString(Object value) { + if (value == null) { + return null; + } + return value.toString(); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParser.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParser.java new file mode 100644 index 0000000000..383e695373 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParser.java @@ -0,0 +1,179 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseFunctionToolCall; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseReasoningItem; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.ChatUsage; +import io.agentscope.core.util.JsonUtils; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Parses a non-streaming Responses API {@link Response} into a single {@link ChatResponse}. + * + *

Traverses {@code response.output()} once, bucketing items by type (reasoning, message, + * function_call, unknown). Assembles content blocks in fixed order: ThinkingBlock + * -> TextBlock -> ToolUseBlock. Refusal content is checked first and short-circuits to a + * non-retryable exception. + */ +final class ResponsesResponseParser { + + private ResponsesResponseParser() {} + + private static final Logger log = LoggerFactory.getLogger(ResponsesResponseParser.class); + + static ChatResponse parse(Response response, String modelName, Instant startTime) { + // ── Extraction: traverse output once, bucket by type ── + StringBuilder summaryBuilder = new StringBuilder(); + String encryptedContent = null; + StringBuilder reasoningTextBuilder = new StringBuilder(); + StringBuilder textBuilder = new StringBuilder(); + List toolUseBlocks = new ArrayList<>(); + + List output = response.output(); + for (ResponseOutputItem item : output) { + if (item.isReasoning()) { + ResponseReasoningItem reasoning = item.asReasoning(); + extractReasoning(reasoning, summaryBuilder, reasoningTextBuilder); + if (encryptedContent == null) { + Optional ec = reasoning.encryptedContent(); + if (ec.isPresent() && !ec.get().isEmpty()) { + encryptedContent = ec.get(); + } + } + } else if (item.isMessage()) { + extractMessage(item.asMessage(), textBuilder, modelName); + } else if (item.isFunctionCall()) { + toolUseBlocks.add(extractFunctionCall(item.asFunctionCall())); + } + // Unknown output item types are silently ignored (forward compatibility) + } + + // ── Assembly: ThinkingBlock -> TextBlock -> ToolUseBlock ── + List contentBlocks = new ArrayList<>(); + + // ThinkingBlock (when summary text is present) + String summaryText = summaryBuilder.toString(); + if (!summaryText.isEmpty()) { + contentBlocks.add(ThinkingBlock.builder().thinking(summaryText).build()); + } + + // TextBlock + String text = textBuilder.toString(); + if (!text.isEmpty()) { + contentBlocks.add(TextBlock.builder().text(text).build()); + } + + // ToolUseBlocks + contentBlocks.addAll(toolUseBlocks); + + // ── Metadata + usage + finishReason ── + String responseId = response.id(); + Map metadata = ResponsesHelper.extractResponseMetadata(response); + String finishReason = (String) metadata.get(OpenAIOfficialConstants.MD_RESPONSE_STATUS); + ChatUsage usage = ResponsesHelper.extractUsage(response, startTime, metadata); + + // Reasoning metadata + if (encryptedContent != null) { + metadata.put(OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT, encryptedContent); + } + if (!summaryText.isEmpty()) { + metadata.put(OpenAIOfficialConstants.MD_REASONING_SUMMARY, summaryText); + } + String reasoningText = reasoningTextBuilder.toString(); + if (!reasoningText.isEmpty()) { + metadata.put(OpenAIOfficialConstants.MD_REASONING_TEXT, reasoningText); + } + + return ChatResponse.builder() + .id(responseId) + .content(contentBlocks) + .usage(usage) + .metadata(metadata) + .finishReason(finishReason) + .build(); + } + + private static void extractReasoning( + ResponseReasoningItem reasoning, + StringBuilder summaryBuilder, + StringBuilder reasoningTextBuilder) { + // Summary parts: concatenate all .text() for each summary item + for (ResponseReasoningItem.Summary summary : reasoning.summary()) { + summaryBuilder.append(summary.text()); + } + // Reasoning text content: concatenate all .text() for each content item + Optional> contentOpt = reasoning.content(); + if (contentOpt.isPresent()) { + for (ResponseReasoningItem.Content content : contentOpt.get()) { + reasoningTextBuilder.append(content.text()); + } + } + } + + private static void extractMessage( + ResponseOutputMessage message, StringBuilder textBuilder, String modelName) { + for (ResponseOutputMessage.Content content : message.content()) { + if (content.isOutputText()) { + textBuilder.append(content.asOutputText().text()); + } else if (content.isRefusal()) { + throw new OpenAIOfficialModelException( + "Model response was refused: " + content.asRefusal().refusal(), + null, + modelName); + } + // Unknown content part types are silently ignored + } + } + + private static ToolUseBlock extractFunctionCall(ResponseFunctionToolCall call) { + String callId = call.callId(); + String name = call.name(); + String arguments = call.arguments(); + + Map input; + try { + @SuppressWarnings("unchecked") + Map parsed = JsonUtils.getJsonCodec().fromJson(arguments, Map.class); + input = parsed != null ? parsed : new HashMap<>(); + } catch (RuntimeException e) { + log.warn( + "Failed to parse tool call arguments as JSON; preserving raw arguments:" + + " callId={}, name={}, error={}", + callId, + name, + e.getMessage()); + input = new HashMap<>(); + } + + return ToolUseBlock.builder().id(callId).name(name).input(input).content(arguments).build(); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssembler.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssembler.java new file mode 100644 index 0000000000..cd3861c60f --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssembler.java @@ -0,0 +1,323 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import com.openai.core.http.StreamResponse; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseError; +import com.openai.models.responses.ResponseErrorEvent; +import com.openai.models.responses.ResponseFailedEvent; +import com.openai.models.responses.ResponseFunctionCallArgumentsDeltaEvent; +import com.openai.models.responses.ResponseFunctionToolCall; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputItemAddedEvent; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseReasoningItem; +import com.openai.models.responses.ResponseReasoningSummaryTextDeltaEvent; +import com.openai.models.responses.ResponseReasoningTextDeltaEvent; +import com.openai.models.responses.ResponseStreamEvent; +import com.openai.models.responses.ResponseTextDeltaEvent; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.ChatUsage; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +/** + * Assembles a streaming Responses API event stream into a {@code Flux}. + * + *

Routes each {@link ResponseStreamEvent} to the appropriate handler: text deltas produce + * {@link TextBlock} fragments, reasoning summary deltas produce {@link + * ThinkingBlock} fragments, and function-call argument deltas produce fragment {@link + * ToolUseBlock}s with placeholder id/name when the output-item-added event was not received. + * + *

Terminal events (completed/incomplete) trigger re-extraction of refusal, reasoning + * metadata, and usage from the final {@link Response}. Refusal is checked first and + * short-circuits to a non-retryable exception. Failed/error events are translated to + * {@link OpenAIOfficialModelException}. + * + *

The SDK {@link StreamResponse} is closed on any terminal signal (complete, error, cancel) + * via {@code doFinally}. + */ +final class ResponsesStreamingAssembler { + + private ResponsesStreamingAssembler() {} + + private static final Logger log = LoggerFactory.getLogger(ResponsesStreamingAssembler.class); + + private static final String FRAGMENT_PLACEHOLDER = "__fragment__"; + + /** + * Converts a SDK streaming response into a {@code Flux}. + * + * @param streamResponse the SDK stream of {@link ResponseStreamEvent}s + * @param modelName the model name for error context + * @param startTime the start time for usage timing + * @return a flux of chat responses, one per emitted block + */ + static Flux assemble( + StreamResponse streamResponse, + String modelName, + Instant startTime) { + StreamingState state = new StreamingState(modelName, startTime); + + return Flux.fromStream(streamResponse.stream()) + .doFinally(signal -> closeQuietly(streamResponse)) + .handle( + (event, sink) -> { + try { + for (ChatResponse response : state.processEvent(event)) { + sink.next(response); + } + } catch (OpenAIOfficialModelException e) { + sink.error(e); + } catch (RuntimeException e) { + sink.error(OpenAIErrorTranslator.translate(e, modelName)); + } + }) + .onErrorMap(e -> OpenAIErrorTranslator.translate(e, modelName)); + } + + private static void closeQuietly(StreamResponse streamResponse) { + try { + streamResponse.close(); + } catch (Exception e) { + log.debug("Failed to close SDK stream", e); + } + } + + // ── Streaming state ────────────────────────────────────────────────── + + private static final class StreamingState { + private final String modelName; + private final Instant startTime; + private final Map itemRegistry = new HashMap<>(); + private final StringBuilder reasoningTextAccumulator = new StringBuilder(); + + StreamingState(String modelName, Instant startTime) { + this.modelName = modelName; + this.startTime = startTime; + } + + /** + * Routes a single stream event to the appropriate handler. + * + * @return a list of 0 or 1 {@link ChatResponse} blocks; terminal events produce a + * single block with usage and metadata + */ + List processEvent(ResponseStreamEvent event) { + List results = new ArrayList<>(); + + if (event.isOutputItemAdded()) { + handleOutputItemAdded(event.asOutputItemAdded()); + } else if (event.isOutputTextDelta()) { + ResponseTextDeltaEvent deltaEvent = event.asOutputTextDelta(); + if (!deltaEvent.delta().isEmpty()) { + results.add(handleTextDelta(deltaEvent)); + } + } else if (event.isReasoningSummaryTextDelta()) { + ResponseReasoningSummaryTextDeltaEvent deltaEvent = + event.asReasoningSummaryTextDelta(); + if (!deltaEvent.delta().isEmpty()) { + results.add(handleReasoningSummaryDelta(deltaEvent)); + } + } else if (event.isReasoningTextDelta()) { + handleReasoningTextDelta(event.asReasoningTextDelta()); + } else if (event.isFunctionCallArgumentsDelta()) { + ResponseFunctionCallArgumentsDeltaEvent deltaEvent = + event.asFunctionCallArgumentsDelta(); + if (!deltaEvent.delta().isEmpty()) { + results.add(handleFunctionCallArgumentsDelta(deltaEvent)); + } + } else if (event.isCompleted()) { + results.add(handleTerminal(event.asCompleted().response())); + } else if (event.isIncomplete()) { + results.add(handleTerminal(event.asIncomplete().response())); + } else if (event.isFailed()) { + throw handleFailed(event.asFailed()); + } else if (event.isError()) { + throw handleErrorEvent(event.asError()); + } + // All other events (text.done, reasoning_summary.done, reasoning_text.done, + // function_arguments.done, output_item.done, refusal.delta, refusal.done) + // produce no ChatResponse blocks. + + return results; + } + + // ── Event handlers ── + + private void handleOutputItemAdded(ResponseOutputItemAddedEvent event) { + ResponseOutputItem item = event.item(); + if (item.isFunctionCall()) { + ResponseFunctionToolCall call = item.asFunctionCall(); + Optional itemIdOpt = call.id(); + if (itemIdOpt.isPresent()) { + itemRegistry.put(itemIdOpt.get(), new ToolCallInfo(call.callId(), call.name())); + } else { + log.warn( + "Function call output item missing item ID; subsequent argument" + + " deltas will use placeholder, callId={}, name={}", + call.callId(), + call.name()); + } + } + } + + private ChatResponse handleTextDelta(ResponseTextDeltaEvent event) { + List content = new ArrayList<>(); + content.add(TextBlock.builder().text(event.delta()).build()); + return ChatResponse.builder().content(content).build(); + } + + private ChatResponse handleReasoningSummaryDelta( + ResponseReasoningSummaryTextDeltaEvent event) { + List content = new ArrayList<>(); + content.add(ThinkingBlock.builder().thinking(event.delta()).build()); + return ChatResponse.builder().content(content).build(); + } + + private void handleReasoningTextDelta(ResponseReasoningTextDeltaEvent event) { + String delta = event.delta(); + if (!delta.isEmpty()) { + reasoningTextAccumulator.append(delta); + } + } + + private ChatResponse handleFunctionCallArgumentsDelta( + ResponseFunctionCallArgumentsDeltaEvent event) { + ToolCallInfo info = itemRegistry.get(event.itemId()); + String callId = info != null ? info.callId() : ""; + String name = info != null ? info.name() : FRAGMENT_PLACEHOLDER; + List content = new ArrayList<>(); + content.add( + ToolUseBlock.builder() + .id(callId) + .name(name) + .input(new HashMap<>()) + .content(event.delta()) + .build()); + return ChatResponse.builder().content(content).build(); + } + + private ChatResponse handleTerminal(Response response) { + // Step 0: refusal gate + String refusal = extractRefusal(response); + if (!refusal.isEmpty()) { + throw new OpenAIOfficialModelException( + "Model response was refused: " + refusal, null, modelName); + } + + // Step 1: re-extraction from terminal response.output() + String encryptedContent = null; + StringBuilder summaryBuilder = new StringBuilder(); + for (ResponseOutputItem item : response.output()) { + if (item.isReasoning()) { + ResponseReasoningItem reasoning = item.asReasoning(); + for (ResponseReasoningItem.Summary summary : reasoning.summary()) { + summaryBuilder.append(summary.text()); + } + if (encryptedContent == null) { + Optional ec = reasoning.encryptedContent(); + if (ec.isPresent() && !ec.get().isEmpty()) { + encryptedContent = ec.get(); + } + } + } + } + String summaryText = summaryBuilder.toString(); + String reasoningText = reasoningTextAccumulator.toString(); + + // Build metadata + String responseId = response.id(); + Map metadata = ResponsesHelper.extractResponseMetadata(response); + String finishReason = (String) metadata.get(OpenAIOfficialConstants.MD_RESPONSE_STATUS); + ChatUsage usage = ResponsesHelper.extractUsage(response, startTime, metadata); + + // Reasoning metadata + if (encryptedContent != null) { + metadata.put( + OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT, encryptedContent); + } + if (!summaryText.isEmpty()) { + metadata.put(OpenAIOfficialConstants.MD_REASONING_SUMMARY, summaryText); + } + if (!reasoningText.isEmpty()) { + metadata.put(OpenAIOfficialConstants.MD_REASONING_TEXT, reasoningText); + } + + return ChatResponse.builder() + .id(responseId) + .content(new ArrayList<>()) + .usage(usage) + .metadata(metadata) + .finishReason(finishReason) + .build(); + } + + private String extractRefusal(Response response) { + StringBuilder refusalBuilder = new StringBuilder(); + for (ResponseOutputItem item : response.output()) { + if (item.isMessage()) { + for (ResponseOutputMessage.Content content : item.asMessage().content()) { + if (content.isRefusal()) { + refusalBuilder.append(content.asRefusal().refusal()); + } + } + } + } + return refusalBuilder.toString(); + } + + private OpenAIOfficialModelException handleFailed(ResponseFailedEvent event) { + Response response = event.response(); + Optional errorOpt = response.error(); + if (errorOpt.isEmpty()) { + return new OpenAIOfficialModelException( + "OpenAI API stream failed", null, modelName); + } + ResponseError error = errorOpt.get(); + String message = error.message(); + try { + message = message + " (code: " + error.code().asString() + ")"; + } catch (RuntimeException ignored) { + // code field may be missing — ResponseError.code() uses getRequired + } + return new OpenAIOfficialModelException(message, null, modelName); + } + + private OpenAIOfficialModelException handleErrorEvent(ResponseErrorEvent event) { + String message = event.message(); + Optional code = event.code(); + if (code.isPresent()) { + message = message + " (code: " + code.get() + ")"; + } + return new OpenAIOfficialModelException(message, null, modelName); + } + } + + private record ToolCallInfo(String callId, String name) {} +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/credential/OpenAIOfficialCredential.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/credential/OpenAIOfficialCredential.java new file mode 100644 index 0000000000..6a558dbf0f --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/credential/OpenAIOfficialCredential.java @@ -0,0 +1,127 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial.credential; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import io.agentscope.core.credential.CredentialBase; +import io.agentscope.core.model.ChatModelBase; +import io.agentscope.extensions.model.openaiofficial.OpenAIResponsesChatModel; +import java.util.Objects; + +/** Credential for the OpenAI official SDK provider. */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonPropertyOrder({"id", "type", "api_key", "organization", "base_url"}) +public final class OpenAIOfficialCredential extends CredentialBase { + + public static final String TYPE = "openai_official_credential"; + + private final String apiKey; + private final String organization; + private final String baseUrl; + + private OpenAIOfficialCredential( + String id, String apiKey, String organization, String baseUrl) { + super(id); + this.apiKey = Objects.requireNonNull(apiKey, "apiKey must not be null"); + this.organization = organization; + this.baseUrl = baseUrl; + } + + @JsonCreator + static OpenAIOfficialCredential fromJson( + @JsonProperty("id") String id, + @JsonProperty("api_key") String apiKey, + @JsonProperty("organization") String organization, + @JsonProperty("base_url") String baseUrl) { + return new OpenAIOfficialCredential(id, apiKey, organization, baseUrl); + } + + @JsonProperty("type") + public String getType() { + return TYPE; + } + + @JsonProperty("api_key") + public String getApiKey() { + return apiKey; + } + + @JsonProperty("organization") + public String getOrganization() { + return organization; + } + + @JsonProperty("base_url") + public String getBaseUrl() { + return baseUrl; + } + + @Override + public Class getChatModelClass() { + return OpenAIResponsesChatModel.class; + } + + @Override + public String toString() { + return "OpenAIOfficialCredential{id=" + + getId() + + ", organization=" + + organization + + ", baseUrl=" + + baseUrl + + ", apiKey=***}"; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String id; + private String apiKey; + private String organization; + private String baseUrl; + + private Builder() {} + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder apiKey(String apiKey) { + this.apiKey = apiKey; + return this; + } + + public Builder organization(String organization) { + this.organization = organization; + return this; + } + + public Builder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + public OpenAIOfficialCredential build() { + return new OpenAIOfficialCredential(id, apiKey, organization, baseUrl); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/resources/META-INF/services/io.agentscope.core.model.spi.ModelProvider b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/resources/META-INF/services/io.agentscope.core.model.spi.ModelProvider new file mode 100644 index 0000000000..a96450f21a --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/resources/META-INF/services/io.agentscope.core.model.spi.ModelProvider @@ -0,0 +1 @@ +io.agentscope.extensions.model.openaiofficial.OpenAIOfficialModelProvider diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/CrossTurnTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/CrossTurnTest.java new file mode 100644 index 0000000000..6175a19fb8 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/CrossTurnTest.java @@ -0,0 +1,769 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.openai.client.OpenAIClient; +import com.openai.models.responses.FunctionTool; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseInputItem; +import com.openai.models.responses.ResponseReasoningItem; +import com.openai.models.responses.ResponseStreamEvent; +import com.openai.services.blocking.ResponseService; +import io.agentscope.core.message.AssistantMessage; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolResultMessage; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.message.UserMessage; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.ExecutionConfig; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ModelUtils; +import io.agentscope.core.model.ToolSchema; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class CrossTurnTest { + + private static final String API_KEY = "test-key"; + private static final String MODEL_NAME = "gpt-4o"; + + private static TextBlock text(String t) { + return TextBlock.builder().text(t).build(); + } + + private static List simpleMessages() { + return List.of(UserMessage.builder().content(text("Hello")).build()); + } + + private static OpenAIClient mockClient() { + OpenAIClient client = mock(OpenAIClient.class); + ResponseService svc = mock(ResponseService.class); + when(client.responses()).thenReturn(svc); + return client; + } + + private static GenerateOptions withExecConfig(GenerateOptions options) { + GenerateOptions withDefaults = ModelUtils.ensureDefaultExecutionConfig(options); + ExecutionConfig moduleRetry = + ExecutionConfig.builder().retryOn(OpenAIResponsesChatModel.moduleRetryOn()).build(); + ExecutionConfig mergedExec = + ExecutionConfig.mergeConfigs(moduleRetry, withDefaults.getExecutionConfig()); + GenerateOptions execOverride = + GenerateOptions.builder().executionConfig(mergedExec).build(); + return GenerateOptions.mergeOptions(execOverride, withDefaults); + } + + private static OpenAIResponsesChatModel createModel( + OpenAIClient client, + GenerateOptions configured, + Boolean strictTools, + Boolean strictJsonSchema, + int contextWindowSize) { + OpenAIResponsesChatModel model = + new OpenAIResponsesChatModel( + client, configured, API_KEY, null, strictTools, strictJsonSchema, null); + model.applyNativeStructuredOutputDefaults(); + // contextWindowSize set via Builder in production; here we only verify constancy + return model; + } + + private static ResponseReasoningItem findReasoningItem(ResponseCreateParams params) { + List input = params.input().orElseThrow().asResponse(); + for (ResponseInputItem item : input) { + if (item.isReasoning()) { + return item.asReasoning(); + } + } + return null; + } + + @Nested + class OptionMergeTests { + + @Test + void perCallOverridesConfigured() { + OpenAIClient client = mockClient(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn( + TestSdkFixtures.textResponse("turn1"), + TestSdkFixtures.textResponse("turn2")); + + GenerateOptions configured = + withExecConfig( + GenerateOptions.builder().apiKey(API_KEY).modelName(MODEL_NAME).stream( + false) + .temperature(0.7) + .build()); + OpenAIResponsesChatModel model = createModel(client, configured, null, null, 0); + + model.stream(simpleMessages(), null, null).collectList().block(); + model.stream(simpleMessages(), null, GenerateOptions.builder().temperature(0.2).build()) + .collectList() + .block(); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(ResponseCreateParams.class); + verify(svc, times(2)).create(captor.capture()); + ResponseCreateParams turn1Params = captor.getAllValues().get(0); + ResponseCreateParams turn2Params = captor.getAllValues().get(1); + + assertEquals(0.7, turn1Params.temperature().orElseThrow()); + assertEquals(0.2, turn2Params.temperature().orElseThrow()); + } + + @Test + void additionalBodyParamsUnionMerge() { + OpenAIClient client = mockClient(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn( + TestSdkFixtures.textResponse("turn1"), + TestSdkFixtures.textResponse("turn2")); + + GenerateOptions configured = + withExecConfig( + GenerateOptions.builder().apiKey(API_KEY).modelName(MODEL_NAME).stream( + false) + .additionalBodyParam("service_tier", "flex") + .build()); + OpenAIResponsesChatModel model = createModel(client, configured, null, null, 0); + + model.stream( + simpleMessages(), + null, + GenerateOptions.builder() + .additionalBodyParam("prompt_cache_key", "k1") + .build()) + .collectList() + .block(); + model.stream( + simpleMessages(), + null, + GenerateOptions.builder() + .additionalBodyParam("service_tier", "priority") + .build()) + .collectList() + .block(); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(ResponseCreateParams.class); + verify(svc, times(2)).create(captor.capture()); + ResponseCreateParams turn1Params = captor.getAllValues().get(0); + ResponseCreateParams turn2Params = captor.getAllValues().get(1); + + assertTrue(turn1Params.serviceTier().isPresent()); + assertEquals("flex", turn1Params.serviceTier().get().asString()); + assertEquals("k1", turn1Params.promptCacheKey().orElseThrow()); + assertEquals("priority", turn2Params.serviceTier().get().asString()); + assertFalse(turn2Params.promptCacheKey().isPresent()); + } + + @Test + void additionalBodyParamsNonWhitelistFailsFast() { + OpenAIClient client = mockClient(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn(TestSdkFixtures.textResponse("ok")); + + GenerateOptions configured = + withExecConfig( + GenerateOptions.builder().apiKey(API_KEY).modelName(MODEL_NAME).stream( + false) + .build()); + OpenAIResponsesChatModel model = createModel(client, configured, null, null, 0); + + GenerateOptions perCall = + GenerateOptions.builder().additionalBodyParam("unknown_key", "val").build(); + + assertThrows( + OpenAIOfficialModelException.class, + () -> model.stream(simpleMessages(), null, perCall).collectList().block()); + } + + @Test + void connectionFieldFailFast() { + OpenAIClient client = mockClient(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn(TestSdkFixtures.textResponse("turn1")); + + GenerateOptions configured = + withExecConfig( + GenerateOptions.builder().apiKey(API_KEY).modelName(MODEL_NAME).stream( + false) + .build()); + OpenAIResponsesChatModel model = createModel(client, configured, null, null, 0); + + model.stream(simpleMessages(), null, GenerateOptions.builder().build()) + .collectList() + .block(); + assertThrows( + OpenAIOfficialModelException.class, + () -> + model.stream( + simpleMessages(), + null, + GenerateOptions.builder().apiKey("different").build()) + .collectList() + .block()); + assertThrows( + OpenAIOfficialModelException.class, + () -> + model.stream( + simpleMessages(), + null, + GenerateOptions.builder() + .baseUrl("https://other.example.com") + .build()) + .collectList() + .block()); + verify(svc, times(1)).create(any(ResponseCreateParams.class)); + } + + @Test + void builderOnlyConstantAcrossTurns() { + OpenAIClient client = mockClient(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn( + TestSdkFixtures.textResponse("turn1"), + TestSdkFixtures.textResponse("turn2")); + + GenerateOptions configured = + withExecConfig( + GenerateOptions.builder().apiKey(API_KEY).modelName(MODEL_NAME).stream( + false) + .build()); + OpenAIResponsesChatModel model = createModel(client, configured, true, true, 0); + + ToolSchema schema = + ToolSchema.builder() + .name("tool1") + .description("d") + .parameters(Map.of("type", "object", "properties", Map.of())) + .build(); + + model.stream(simpleMessages(), List.of(schema), null).collectList().block(); + model.stream(simpleMessages(), List.of(schema), GenerateOptions.builder().build()) + .collectList() + .block(); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(ResponseCreateParams.class); + verify(svc, times(2)).create(captor.capture()); + FunctionTool t1Tool = + captor.getAllValues().get(0).tools().orElseThrow().get(0).asFunction(); + FunctionTool t2Tool = + captor.getAllValues().get(1).tools().orElseThrow().get(0).asFunction(); + assertEquals(true, t1Tool.strict().orElseThrow()); + assertEquals(true, t2Tool.strict().orElseThrow()); + assertEquals(model.getContextWindowSize(), model.getContextWindowSize()); + } + + @Test + void moduleInternalFixedAcrossTurns() { + OpenAIClient client = mockClient(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn( + TestSdkFixtures.textResponse("turn1"), + TestSdkFixtures.textResponse("turn2")); + + GenerateOptions configured = + withExecConfig( + GenerateOptions.builder().apiKey(API_KEY).modelName(MODEL_NAME).stream( + false) + .build()); + OpenAIResponsesChatModel model = createModel(client, configured, null, null, 0); + + model.stream(simpleMessages(), null, null).collectList().block(); + model.stream(simpleMessages(), null, GenerateOptions.builder().build()) + .collectList() + .block(); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(ResponseCreateParams.class); + verify(svc, times(2)).create(captor.capture()); + assertFalse(captor.getAllValues().get(0).store().orElseThrow()); + assertFalse(captor.getAllValues().get(1).store().orElseThrow()); + } + } + + @Nested + class ReasoningCrossTurnTests { + + @Test + void reasoningEffortCrossTurnInvariant() { + OpenAIClient client = mockClient(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn( + TestSdkFixtures.completedResponse( + List.of( + TestSdkFixtures.reasoningItem( + "summary1", "enc123", null), + TestSdkFixtures.messageItem("I can help"))), + TestSdkFixtures.textResponse("turn2")); + + GenerateOptions configured = + withExecConfig( + GenerateOptions.builder().apiKey(API_KEY).modelName(MODEL_NAME).stream( + false) + .reasoningEffort("high") + .additionalBodyParam("reasoning.summary", "auto") + .build()); + OpenAIResponsesChatModel model = createModel(client, configured, null, null, 0); + + List turn1Results = + model.stream(simpleMessages(), null, null).collectList().block(); + assertNotNull(turn1Results); + assertTrue( + turn1Results.get(0).getContent().stream() + .anyMatch(b -> b instanceof ThinkingBlock)); + + List turn2Messages = + List.of( + UserMessage.builder().content(text("Hello")).build(), + AssistantMessage.builder() + .content(ThinkingBlock.builder().thinking("summary1").build()) + .content(TextBlock.builder().text("I can help").build()) + .metadata( + Map.of( + OpenAIOfficialConstants + .MD_REASONING_ENCRYPTED_CONTENT, + "enc123")) + .build(), + UserMessage.builder().content(text("Follow up")).build()); + + model.stream( + turn2Messages, + null, + GenerateOptions.builder().reasoningEffort("low").build()) + .collectList() + .block(); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(ResponseCreateParams.class); + verify(svc, times(2)).create(captor.capture()); + ResponseCreateParams turn2Params = captor.getAllValues().get(1); + + assertTrue(turn2Params.reasoning().isPresent()); + assertEquals("low", turn2Params.reasoning().get().effort().get().asString()); + assertTrue(turn2Params.reasoning().get().summary().isPresent()); + assertEquals("auto", turn2Params.reasoning().get().summary().get().asString()); + + ResponseReasoningItem replayItem = findReasoningItem(turn2Params); + assertNotNull(replayItem, "Expected reasoning input item in history replay"); + assertEquals("enc123", replayItem.encryptedContent().orElseThrow()); + // Summary may be empty if getFirstContentBlock returns null for multi-block messages + // Summary content not asserted here (verified in mapper tests) + } + + @Test + void reasoningSummaryOptinCrossTurn() { + OpenAIClient client = mockClient(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn( + TestSdkFixtures.completedResponse( + List.of( + TestSdkFixtures.reasoningItem( + "summary1", "enc123", null), + TestSdkFixtures.messageItem("I can help"))), + TestSdkFixtures.textResponse("turn2")); + + GenerateOptions configured = + withExecConfig( + GenerateOptions.builder().apiKey(API_KEY).modelName(MODEL_NAME).stream( + false) + .reasoningEffort("high") + .build()); + OpenAIResponsesChatModel model = createModel(client, configured, null, null, 0); + + GenerateOptions perCall1 = + GenerateOptions.builder() + .additionalBodyParam("reasoning.summary", "auto") + .build(); + List turn1Results = + model.stream(simpleMessages(), null, perCall1).collectList().block(); + assertNotNull(turn1Results); + assertTrue( + turn1Results.get(0).getContent().stream() + .anyMatch(b -> b instanceof ThinkingBlock)); + + List turn2Messages = + List.of( + UserMessage.builder().content(text("Hello")).build(), + AssistantMessage.builder() + .content(ThinkingBlock.builder().thinking("summary1").build()) + .content(TextBlock.builder().text("I can help").build()) + .metadata( + Map.of( + OpenAIOfficialConstants + .MD_REASONING_ENCRYPTED_CONTENT, + "enc123")) + .build(), + UserMessage.builder().content(text("Follow up")).build()); + + List turn2Results = + model.stream(turn2Messages, null, null).collectList().block(); + assertNotNull(turn2Results); + assertFalse( + turn2Results.get(0).getContent().stream() + .anyMatch(b -> b instanceof ThinkingBlock), + "Turn 2 without opt-in should not create ThinkingBlock"); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(ResponseCreateParams.class); + verify(svc, times(2)).create(captor.capture()); + ResponseCreateParams turn1Params = captor.getAllValues().get(0); + ResponseCreateParams turn2Params = captor.getAllValues().get(1); + + assertTrue(turn1Params.reasoning().isPresent()); + assertTrue(turn1Params.reasoning().get().summary().isPresent()); + assertTrue(turn2Params.reasoning().isPresent()); + assertFalse(turn2Params.reasoning().get().summary().isPresent()); + + ResponseReasoningItem replayItem = findReasoningItem(turn2Params); + assertNotNull(replayItem, "Encrypted reasoning replayed regardless of opt-in"); + assertEquals("enc123", replayItem.encryptedContent().orElseThrow()); + } + + @Test + void reasoningContextCrossTurn() { + OpenAIClient client = mockClient(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn( + TestSdkFixtures.completedResponse( + List.of( + TestSdkFixtures.reasoningItem( + "summary1", "enc123", null), + TestSdkFixtures.messageItem("response1"))), + TestSdkFixtures.textResponse("response2")); + + GenerateOptions configured = + withExecConfig( + GenerateOptions.builder().apiKey(API_KEY).modelName(MODEL_NAME).stream( + false) + .reasoningEffort("high") + .additionalBodyParam("reasoning.summary", "auto") + .build()); + OpenAIResponsesChatModel model = createModel(client, configured, null, null, 0); + + GenerateOptions perCall1 = + GenerateOptions.builder() + .additionalBodyParam("reasoning.context", "current_turn") + .build(); + model.stream(simpleMessages(), null, perCall1).collectList().block(); + + List turn2Messages = + List.of( + UserMessage.builder().content(text("Hello")).build(), + AssistantMessage.builder() + .content(ThinkingBlock.builder().thinking("summary1").build()) + .content(TextBlock.builder().text("response1").build()) + .metadata( + Map.of( + OpenAIOfficialConstants + .MD_REASONING_ENCRYPTED_CONTENT, + "enc123")) + .build(), + UserMessage.builder().content(text("Follow up")).build()); + + GenerateOptions perCall2 = + GenerateOptions.builder() + .additionalBodyParam("reasoning.context", "all_turns") + .build(); + model.stream(turn2Messages, null, perCall2).collectList().block(); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(ResponseCreateParams.class); + verify(svc, times(2)).create(captor.capture()); + ResponseCreateParams turn1Params = captor.getAllValues().get(0); + ResponseCreateParams turn2Params = captor.getAllValues().get(1); + + assertTrue(turn1Params.reasoning().get().context().isPresent()); + assertEquals("current_turn", turn1Params.reasoning().get().context().get().asString()); + assertTrue(turn2Params.reasoning().get().context().isPresent()); + assertEquals("all_turns", turn2Params.reasoning().get().context().get().asString()); + + ResponseReasoningItem replayItem = findReasoningItem(turn2Params); + assertNotNull(replayItem); + assertEquals("enc123", replayItem.encryptedContent().orElseThrow()); + } + + @Test + void reasoningModeCrossTurn() { + OpenAIClient client = mockClient(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn( + TestSdkFixtures.completedResponse( + List.of( + TestSdkFixtures.reasoningItem( + "summary1", "enc123", null), + TestSdkFixtures.messageItem("response1"))), + TestSdkFixtures.textResponse("response2")); + + GenerateOptions configured = + withExecConfig( + GenerateOptions.builder().apiKey(API_KEY).modelName(MODEL_NAME).stream( + false) + .reasoningEffort("high") + .additionalBodyParam("reasoning.summary", "auto") + .build()); + OpenAIResponsesChatModel model = createModel(client, configured, null, null, 0); + + GenerateOptions perCall1 = + GenerateOptions.builder().additionalBodyParam("reasoning.mode", "pro").build(); + model.stream(simpleMessages(), null, perCall1).collectList().block(); + + List turn2Messages = + List.of( + UserMessage.builder().content(text("Hello")).build(), + AssistantMessage.builder() + .content(ThinkingBlock.builder().thinking("summary1").build()) + .content(TextBlock.builder().text("response1").build()) + .metadata( + Map.of( + OpenAIOfficialConstants + .MD_REASONING_ENCRYPTED_CONTENT, + "enc123")) + .build(), + UserMessage.builder().content(text("Follow up")).build()); + + GenerateOptions perCall2 = + GenerateOptions.builder() + .additionalBodyParam("reasoning.mode", "standard") + .build(); + model.stream(turn2Messages, null, perCall2).collectList().block(); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(ResponseCreateParams.class); + verify(svc, times(2)).create(captor.capture()); + ResponseCreateParams turn1Params = captor.getAllValues().get(0); + ResponseCreateParams turn2Params = captor.getAllValues().get(1); + + assertTrue(turn1Params.reasoning().get().mode().isPresent()); + assertEquals("pro", turn1Params.reasoning().get().mode().get().asString()); + assertTrue(turn2Params.reasoning().get().mode().isPresent()); + assertEquals("standard", turn2Params.reasoning().get().mode().get().asString()); + + ResponseReasoningItem replayItem = findReasoningItem(turn2Params); + assertNotNull(replayItem); + assertEquals("enc123", replayItem.encryptedContent().orElseThrow()); + } + + @Test + void encryptedReasoningReplay() { + List messages = + List.of( + UserMessage.builder().content(text("Hello")).build(), + AssistantMessage.builder() + .content(ThinkingBlock.builder().thinking("my summary").build()) + .content(TextBlock.builder().text("my response").build()) + .metadata( + Map.of( + OpenAIOfficialConstants + .MD_REASONING_ENCRYPTED_CONTENT, + "enc_data")) + .build(), + UserMessage.builder().content(text("Follow up")).build()); + + GenerateOptions options = + withExecConfig( + GenerateOptions.builder().apiKey(API_KEY).modelName(MODEL_NAME).stream( + false) + .build()); + + ResponseCreateParams params = + ResponsesRequestMapper.map( + messages, + null, + options, + null, + null, + ResponsesRequestMapper::mapHistory); + + ResponseReasoningItem reasoning = findReasoningItem(params); + assertNotNull(reasoning, "Expected reasoning input item in history replay"); + assertEquals("enc_data", reasoning.encryptedContent().orElseThrow()); + // Summary may be empty in replay + // Summary content verified separately in mapper unit tests + + List input = params.input().orElseThrow().asResponse(); + int reasoningIndex = -1; + for (int i = 0; i < input.size(); i++) { + if (input.get(i).isReasoning()) { + reasoningIndex = i; + break; + } + } + assertTrue(reasoningIndex >= 0, "Reasoning item should exist in input"); + assertTrue(reasoningIndex > 0, "Reasoning item should not be the first item"); + assertTrue( + reasoningIndex < input.size() - 1, + "Reasoning item should not be the last item"); + } + } + + @Nested + class ToolSetCrossTurnTests { + + @Test + void toolSetChangeAcrossTurns() { + OpenAIClient client = mockClient(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn( + TestSdkFixtures.functionCallResponse( + "call_X", "tool_a", "{\"q\":\"test\"}"), + TestSdkFixtures.textResponse("done")); + + GenerateOptions configured = + withExecConfig( + GenerateOptions.builder().apiKey(API_KEY).modelName(MODEL_NAME).stream( + false) + .build()); + OpenAIResponsesChatModel model = createModel(client, configured, null, null, 0); + + ToolSchema toolA = + ToolSchema.builder() + .name("tool_a") + .description("Tool A") + .parameters(Map.of("type", "object", "properties", Map.of())) + .build(); + ToolSchema toolB = + ToolSchema.builder() + .name("tool_b") + .description("Tool B") + .parameters(Map.of("type", "object", "properties", Map.of())) + .build(); + + model.stream(simpleMessages(), List.of(toolA, toolB), null).collectList().block(); + + List turn2Messages = + List.of( + UserMessage.builder().content(text("Use tools")).build(), + AssistantMessage.builder() + .content( + ToolUseBlock.builder() + .id("call_X") + .name("tool_a") + .input(Map.of("q", "test")) + .content("{\"q\":\"test\"}") + .build()) + .build(), + ToolResultMessage.builder() + .content( + ToolResultBlock.builder() + .id("call_X") + .name("tool_a") + .output(text("result")) + .build()) + .build(), + UserMessage.builder().content(text("Continue")).build()); + + model.stream(turn2Messages, List.of(toolB), null).collectList().block(); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(ResponseCreateParams.class); + verify(svc, times(2)).create(captor.capture()); + ResponseCreateParams turn1Params = captor.getAllValues().get(0); + ResponseCreateParams turn2Params = captor.getAllValues().get(1); + + assertTrue(turn1Params.tools().isPresent()); + assertEquals(2, turn1Params.tools().get().size()); + assertTrue(turn2Params.tools().isPresent()); + assertEquals(1, turn2Params.tools().get().size()); + FunctionTool turn2Tool = turn2Params.tools().get().get(0).asFunction(); + assertEquals("tool_b", turn2Tool.name()); + + List input = turn2Params.input().orElseThrow().asResponse(); + boolean foundFunctionCall = false; + boolean foundFunctionCallOutput = false; + for (ResponseInputItem item : input) { + if (item.isFunctionCall()) foundFunctionCall = true; + if (item.isFunctionCallOutput()) foundFunctionCallOutput = true; + } + assertTrue(foundFunctionCall, "History should contain function_call input item"); + assertTrue(foundFunctionCallOutput, "History should contain function_call_output item"); + } + } + + @Nested + class StreamModeCrossTurnTests { + + @Test + void streamModeSwitchAcrossTurns() { + OpenAIClient client = mockClient(); + ResponseService svc = client.responses(); + + List streamingEvents = + List.of( + TestSdkFixtures.textDeltaEvent("Hello", "msg_1"), + TestSdkFixtures.completedEvent(TestSdkFixtures.textResponse("Hello"))); + when(svc.createStreaming(any(ResponseCreateParams.class))) + .thenReturn(TestSdkFixtures.streamOf(streamingEvents)); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn(TestSdkFixtures.textResponse("turn2")); + + GenerateOptions configured = + withExecConfig( + GenerateOptions.builder().apiKey(API_KEY).modelName(MODEL_NAME).stream( + true) + .build()); + OpenAIResponsesChatModel model = createModel(client, configured, null, null, 0); + + List turn1Results = + model.stream(simpleMessages(), null, null).collectList().block(); + assertNotNull(turn1Results); + assertFalse(turn1Results.isEmpty()); + + List turn2Results = + model.stream( + simpleMessages(), + null, + GenerateOptions.builder().stream(false).build()) + .collectList() + .block(); + assertNotNull(turn2Results); + assertEquals(1, turn2Results.size()); + + verify(svc, times(1)).createStreaming(any(ResponseCreateParams.class)); + verify(svc, times(1)).create(any(ResponseCreateParams.class)); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIErrorTranslatorTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIErrorTranslatorTest.java new file mode 100644 index 0000000000..c1d2ef29da --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIErrorTranslatorTest.java @@ -0,0 +1,260 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.openai.errors.BadRequestException; +import com.openai.errors.InternalServerException; +import com.openai.errors.NotFoundException; +import com.openai.errors.OpenAIInvalidDataException; +import com.openai.errors.OpenAIIoException; +import com.openai.errors.OpenAIRetryableException; +import com.openai.errors.PermissionDeniedException; +import com.openai.errors.RateLimitException; +import com.openai.errors.SseException; +import com.openai.errors.UnauthorizedException; +import com.openai.errors.UnexpectedStatusCodeException; +import com.openai.errors.UnprocessableEntityException; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link OpenAIErrorTranslator}, covering each SDK exception type. + * + *

Each test verifies: provider id, model name, cause preservation, status code + * extraction (where applicable), safe_message content, and retryable classification + * via {@link OpenAIOfficialModelException#isRetryableHttpStatus()}. + */ +class OpenAIErrorTranslatorTest { + + private static final String MODEL = TestSdkFixtures.MODEL_NAME; + + /** + * Verifies the common invariants for every translated exception. + */ + private static void assertCommon(OpenAIOfficialModelException ex, Throwable originalCause) { + assertEquals("openai-official", ex.getProvider(), "provider id"); + assertEquals(MODEL, ex.getModelName(), "model name"); + assertNotNull(ex.getMessage(), "safe_message must be non-null"); + assertFalse(ex.getMessage().isBlank(), "safe_message must be non-blank"); + String originalMessage = originalCause.getMessage(); + if (originalMessage != null && !originalMessage.isBlank()) { + assertEquals( + originalMessage, + ex.getMessage(), + "safe_message must match the original throwable's message"); + } + assertSame( + originalCause, ex.getCause(), "original SDK exception must be preserved as cause"); + } + + // ── HTTP 400 Bad Request ── + @Test + void errBadRequest() { + BadRequestException sdk = TestSdkFixtures.badRequest("bad request"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertEquals(400, ex.getStatusCode()); + assertFalse(ex.isRetryableHttpStatus()); + } + + // ── HTTP 401 Unauthorized ── + @Test + void errUnauthorized() { + UnauthorizedException sdk = TestSdkFixtures.unauthorized("unauthorized"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertEquals(401, ex.getStatusCode()); + assertFalse(ex.isRetryableHttpStatus()); + } + + // ── HTTP 403 Permission Denied ── + @Test + void errPermissionDenied() { + PermissionDeniedException sdk = TestSdkFixtures.permissionDenied("forbidden"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertEquals(403, ex.getStatusCode()); + assertFalse(ex.isRetryableHttpStatus()); + } + + // ── HTTP 404 Not Found ── + @Test + void errNotFound() { + NotFoundException sdk = TestSdkFixtures.notFound("not found"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertEquals(404, ex.getStatusCode()); + assertFalse(ex.isRetryableHttpStatus()); + } + + // ── HTTP 422 Unprocessable Entity ── + @Test + void errUnprocessableEntity() { + UnprocessableEntityException sdk = TestSdkFixtures.unprocessableEntity("unprocessable"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertEquals(422, ex.getStatusCode()); + assertFalse(ex.isRetryableHttpStatus()); + } + + // ── HTTP 429 Rate Limit ── + @Test + void errRateLimit() { + RateLimitException sdk = TestSdkFixtures.rateLimit("rate limited"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertEquals(429, ex.getStatusCode()); + assertTrue(ex.isRetryableHttpStatus()); + } + + // ── HTTP 408 from UnexpectedStatusCodeException ── + @Test + void errTimeoutConflict408() { + UnexpectedStatusCodeException sdk = TestSdkFixtures.unexpectedStatusCode(408, "timeout"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertEquals(408, ex.getStatusCode()); + assertTrue(ex.isRetryableHttpStatus()); + } + + // ── HTTP 409 from SseException ── + @Test + void errTimeoutConflict409Sse() { + SseException sdk = TestSdkFixtures.sseException(409, "conflict"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertEquals(409, ex.getStatusCode()); + assertTrue(ex.isRetryableHttpStatus()); + } + + // ── HTTP 5xx Internal Server Error ── + @Test + void errInternalServer500() { + InternalServerException sdk = TestSdkFixtures.internalServer(500, "server error"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertEquals(500, ex.getStatusCode()); + assertTrue(ex.isRetryableHttpStatus()); + } + + @Test + void errInternalServer503() { + InternalServerException sdk = TestSdkFixtures.internalServer(503, "service unavailable"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertEquals(503, ex.getStatusCode()); + assertTrue(ex.isRetryableHttpStatus()); + } + + // ── non-standard HTTP status, non-retryable ── + @Test + void errOtherService418() { + UnexpectedStatusCodeException sdk = TestSdkFixtures.unexpectedStatusCode(418, "teapot"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertEquals(418, ex.getStatusCode()); + assertFalse(ex.isRetryableHttpStatus()); + } + + // ── OpenAIIoException ── + @Test + void errIoRetryableOpenAIIo() { + OpenAIIoException sdk = TestSdkFixtures.ioException("connection error"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertNull(ex.getStatusCode()); + assertFalse(ex.isRetryableHttpStatus()); + } + + // ── OpenAIRetryableException ── + @Test + void errIoRetryableOpenAIRetryable() { + OpenAIRetryableException sdk = TestSdkFixtures.retryableException("transient error"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertNull(ex.getStatusCode()); + assertFalse(ex.isRetryableHttpStatus()); + } + + // ── direct TimeoutException ── + @Test + void errSdkTimeoutDirect() { + TimeoutException sdk = TestSdkFixtures.timeoutException("timed out after 30s"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertNull(ex.getStatusCode()); + assertFalse(ex.isRetryableHttpStatus()); + } + + // ── TimeoutException in cause chain ── + @Test + void errSdkTimeoutInCauseChain() { + TimeoutException timeout = TestSdkFixtures.timeoutException("timed out"); + RuntimeException wrapper = new RuntimeException("wrapper", timeout); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(wrapper, MODEL); + assertCommon(ex, wrapper); + assertNull(ex.getStatusCode()); + assertFalse(ex.isRetryableHttpStatus()); + } + + // ── OpenAIInvalidDataException ── + @Test + void errInvalidData() { + OpenAIInvalidDataException sdk = TestSdkFixtures.invalidDataException("parse error"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertNull(ex.getStatusCode()); + assertFalse(ex.isRetryableHttpStatus()); + } + + // ── already translated (pass-through) ── + @Test + void alreadyTranslatedIsPassedThrough() { + RuntimeException cause = new RuntimeException("sdk error"); + OpenAIOfficialModelException original = + new OpenAIOfficialModelException("already wrapped", cause, MODEL, 429); + OpenAIOfficialModelException result = OpenAIErrorTranslator.translate(original, MODEL); + assertSame(original, result); + } + + // ── generic fallback ── + @Test + void genericRuntimeExceptionIsWrapped() { + RuntimeException sdk = new RuntimeException("unknown error"); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertNull(ex.getStatusCode()); + assertFalse(ex.isRetryableHttpStatus()); + } + + // ── safe_message fallback for null message ── + @Test + void safeMessageFallbackForServiceExceptionWithNullMessage() { + // OpenAIIoException can be constructed with null message; safeMessage + // falls back to "OpenAI API error: ". + OpenAIIoException sdk = new OpenAIIoException(null); + OpenAIOfficialModelException ex = OpenAIErrorTranslator.translate(sdk, MODEL); + assertCommon(ex, sdk); + assertTrue(ex.getMessage().contains("OpenAI API error")); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelExceptionTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelExceptionTest.java new file mode 100644 index 0000000000..1a07509e58 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelExceptionTest.java @@ -0,0 +1,174 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.model.ModelException; +import io.agentscope.core.model.ModelHttpException; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link OpenAIOfficialModelException}. + * + *

Verifies provider id, HTTP status code handling, and the {@link #isRetryableHttpStatus()} + * override covering 408/409/429/5xx — aligned with SDK {@code RetryingHttpClient.shouldRetry}. + */ +class OpenAIOfficialModelExceptionTest { + + private static final String MODEL = "gpt-4o"; + + @Test + void extendsModelExceptionAndImplementsModelHttpException() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", new RuntimeException("cause"), MODEL); + assertInstanceOf(ModelException.class, ex); + assertInstanceOf(ModelHttpException.class, ex); + } + + @Test + void providerIdIsAlwaysOpenaiOfficial() { + OpenAIOfficialModelException withStatus = + new OpenAIOfficialModelException("msg", new RuntimeException("cause"), MODEL, 429); + OpenAIOfficialModelException withoutStatus = + new OpenAIOfficialModelException("msg", new RuntimeException("cause"), MODEL); + OpenAIOfficialModelException validationOnly = + new OpenAIOfficialModelException("validation error"); + + assertEquals("openai-official", withStatus.getProvider()); + assertEquals("openai-official", withoutStatus.getProvider()); + assertEquals("openai-official", validationOnly.getProvider()); + + // The 2-arg constructor should also set provider id and model name + OpenAIOfficialModelException withModelName = + new OpenAIOfficialModelException("validation error", MODEL); + assertEquals("openai-official", withModelName.getProvider()); + assertEquals(MODEL, withModelName.getModelName()); + } + + @Test + void statusCodeIsNullWhenNotProvided() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", new RuntimeException("cause"), MODEL); + assertNull(ex.getStatusCode()); + } + + @Test + void statusCodeIsPreservedWhenProvided() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", new RuntimeException("cause"), MODEL, 429); + assertEquals(429, ex.getStatusCode()); + } + + @Test + void modelNameIsPreserved() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", new RuntimeException("cause"), MODEL, 400); + assertEquals(MODEL, ex.getModelName()); + } + + @Test + void causeIsPreserved() { + RuntimeException sdkCause = new RuntimeException("sdk error"); + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", sdkCause, MODEL, 400); + assertSame(sdkCause, ex.getCause()); + } + + // ── isRetryableHttpStatus ── + + @Test + void retryableHttpStatusTrueFor408() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", new RuntimeException(), MODEL, 408); + assertTrue(ex.isRetryableHttpStatus()); + } + + @Test + void retryableHttpStatusTrueFor409() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", new RuntimeException(), MODEL, 409); + assertTrue(ex.isRetryableHttpStatus()); + } + + @Test + void retryableHttpStatusTrueFor429() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", new RuntimeException(), MODEL, 429); + assertTrue(ex.isRetryableHttpStatus()); + } + + @Test + void retryableHttpStatusTrueFor500() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", new RuntimeException(), MODEL, 500); + assertTrue(ex.isRetryableHttpStatus()); + } + + @Test + void retryableHttpStatusTrueFor503() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", new RuntimeException(), MODEL, 503); + assertTrue(ex.isRetryableHttpStatus()); + } + + @Test + void retryableHttpStatusFalseFor400() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", new RuntimeException(), MODEL, 400); + assertFalse(ex.isRetryableHttpStatus()); + } + + @Test + void retryableHttpStatusFalseFor401() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", new RuntimeException(), MODEL, 401); + assertFalse(ex.isRetryableHttpStatus()); + } + + @Test + void retryableHttpStatusFalseFor403() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", new RuntimeException(), MODEL, 403); + assertFalse(ex.isRetryableHttpStatus()); + } + + @Test + void retryableHttpStatusFalseFor404() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", new RuntimeException(), MODEL, 404); + assertFalse(ex.isRetryableHttpStatus()); + } + + @Test + void retryableHttpStatusFalseFor422() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", new RuntimeException(), MODEL, 422); + assertFalse(ex.isRetryableHttpStatus()); + } + + @Test + void retryableHttpStatusFalseWhenStatusCodeNull() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("msg", new RuntimeException(), MODEL); + assertFalse(ex.isRetryableHttpStatus()); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelProviderTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelProviderTest.java new file mode 100644 index 0000000000..910e59ae16 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialModelProviderTest.java @@ -0,0 +1,175 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelCreationContext; +import java.util.Map; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +class OpenAIOfficialModelProviderTest { + + private final OpenAIOfficialModelProvider provider = new OpenAIOfficialModelProvider(); + + @Test + void providerIdReturnsOpenAIOfficial() { + assertEquals("openai-official", provider.providerId()); + } + + @Nested + class Supports { + + @Test + void matchesOwnPrefix() { + assertTrue(provider.supports("openai-official:gpt-4o")); + assertTrue(provider.supports("openai-official:o3")); + } + + @Test + void rejectsOtherPrefixes() { + assertFalse(provider.supports("openai:gpt-4o")); + assertFalse(provider.supports("anthropic:claude-3")); + } + + @Test + void rejectsNull() { + assertFalse(provider.supports(null)); + } + } + + @Nested + class ContextResolution { + + @Test + @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") + void createFromContextWithEnvFallback() { + Model model = + provider.create( + "openai-official:gpt-4o", ModelCreationContext.builder().build()); + assertNotNull(model); + assertEquals("gpt-4o", model.getModelName()); + assertTrue(model.supportsNativeStructuredOutput()); + assertTrue(model.supportsNativeStructuredOutputWithTools()); + } + + @Test + void createFromContextWithApiKey() { + Model model = + provider.create( + "openai-official:gpt-4o", + ModelCreationContext.builder().apiKey("sk-test-key").build()); + assertNotNull(model); + assertEquals("gpt-4o", model.getModelName()); + } + + @Test + void createWithCustomBaseUrl() { + Model model = + provider.create( + "openai-official:gpt-4o", + ModelCreationContext.builder() + .apiKey("sk-test-key") + .baseUrl("https://custom.example.com") + .build()); + assertNotNull(model); + assertEquals("gpt-4o", model.getModelName()); + } + + @Test + void missingApiKeyThrows() { + String envKey = System.getenv("OPENAI_API_KEY"); + org.junit.jupiter.api.Assumptions.assumeTrue( + envKey == null || envKey.isBlank(), + "OPENAI_API_KEY must not be set for this test"); + + assertThrows( + IllegalStateException.class, () -> provider.create("openai-official:gpt-4o")); + } + + @Test + void unsupportedModelIdThrows() { + assertThrows(IllegalArgumentException.class, () -> provider.create("openai:gpt-4o")); + } + } + + @Nested + class StreamDefault { + + @Test + @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") + void streamDefaultsToTrueWhenNotSet() { + Model model = + provider.create( + "openai-official:gpt-4o", + ModelCreationContext.builder().apiKey("sk-test-key").build()); + assertNotNull(model); + assertTrue(model instanceof OpenAIResponsesChatModel); + } + } + + @Nested + class AdvancedOptions { + + @Test + void createWithGenerateOptionsComponent() { + GenerateOptions gopts = GenerateOptions.builder().temperature(0.5).build(); + Model model = + provider.create( + "openai-official:gpt-4o", + ModelCreationContext.builder() + .apiKey("sk-test-key") + .component(GenerateOptions.class, gopts) + .build()); + assertNotNull(model); + assertEquals("gpt-4o", model.getModelName()); + } + + @Test + void createWithContextWindowSizeOption() { + Model model = + provider.create( + "openai-official:gpt-4o", + ModelCreationContext.builder() + .apiKey("sk-test-key") + .option("contextWindowSize", 8192) + .build()); + assertNotNull(model); + assertEquals(8192, model.getContextWindowSize()); + } + + @Test + void createWithAdditionalHeadersOption() { + Model model = + provider.create( + "openai-official:gpt-4o", + ModelCreationContext.builder() + .apiKey("sk-test-key") + .option("additionalHeaders", Map.of("X-Custom", "value")) + .build()); + assertNotNull(model); + assertEquals("gpt-4o", model.getModelName()); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModelTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModelTest.java new file mode 100644 index 0000000000..9486d283a5 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModelTest.java @@ -0,0 +1,692 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.openai.client.OpenAIClient; +import com.openai.core.http.StreamResponse; +import com.openai.errors.OpenAIServiceException; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseStreamEvent; +import com.openai.services.blocking.ResponseService; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.UserMessage; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.ExecutionConfig; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ModelException; +import io.agentscope.core.model.ModelUtils; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Predicate; +import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import reactor.core.Disposable; + +class OpenAIResponsesChatModelTest { + + private static final String API_KEY = "test-key"; + private static final String MODEL_NAME = "gpt-4o"; + + private static TextBlock text(String t) { + return TextBlock.builder().text(t).build(); + } + + private static List simpleMessages() { + return List.of(UserMessage.builder().content(text("Hello")).build()); + } + + private static GenerateOptions configuredOptions(boolean stream) { + return configuredOptions(stream, API_KEY, null); + } + + private static GenerateOptions configuredOptions( + boolean stream, String apiKey, String baseUrl) { + GenerateOptions base = + GenerateOptions.builder() + .apiKey(apiKey) + .baseUrl(baseUrl) + .modelName(MODEL_NAME) + .stream(stream) + .build(); + base = ModelUtils.ensureDefaultExecutionConfig(base); + ExecutionConfig moduleRetry = + ExecutionConfig.builder().retryOn(OpenAIResponsesChatModel.moduleRetryOn()).build(); + ExecutionConfig mergedExec = + ExecutionConfig.mergeConfigs(moduleRetry, base.getExecutionConfig()); + GenerateOptions execOverride = + GenerateOptions.builder().executionConfig(mergedExec).build(); + return GenerateOptions.mergeOptions(execOverride, base); + } + + private static OpenAIResponsesChatModel createModel(OpenAIClient client, boolean stream) { + return createModel(client, stream, API_KEY, null); + } + + private static OpenAIResponsesChatModel createModel( + OpenAIClient client, boolean stream, String apiKey, String baseUrl) { + GenerateOptions options = configuredOptions(stream, apiKey, baseUrl); + OpenAIResponsesChatModel model = + new OpenAIResponsesChatModel(client, options, apiKey, baseUrl, null, null, null); + model.applyNativeStructuredOutputDefaults(); + return model; + } + + private static OpenAIClient mockClientWithResponseService() { + OpenAIClient client = mock(OpenAIClient.class); + ResponseService responseService = mock(ResponseService.class); + when(client.responses()).thenReturn(responseService); + return client; + } + + @Nested + class NonStreaming { + + @Test + void nonStreamingEndToEnd() { + OpenAIClient client = mockClientWithResponseService(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn(TestSdkFixtures.textResponse("Hello world")); + + OpenAIResponsesChatModel model = createModel(client, false); + List results = + model.stream(simpleMessages(), null, null).collectList().block(); + + assertNotNull(results); + assertEquals(1, results.size()); + boolean hasText = + results.get(0).getContent().stream() + .anyMatch( + b -> + b instanceof TextBlock tb + && tb.getText().contains("Hello")); + assertTrue(hasText); + } + + @Test + void connectionFieldMismatchApiKeyFailsFast() { + OpenAIClient client = mockClientWithResponseService(); + OpenAIResponsesChatModel model = createModel(client, false, API_KEY, null); + + GenerateOptions perCall = GenerateOptions.builder().apiKey("different-key").build(); + + assertThrows( + OpenAIOfficialModelException.class, + () -> model.stream(simpleMessages(), null, perCall).collectList().block()); + } + + @Test + void connectionFieldMismatchBaseUrlFailsFast() { + OpenAIClient client = mockClientWithResponseService(); + OpenAIResponsesChatModel model = createModel(client, false, API_KEY, null); + + GenerateOptions perCall = + GenerateOptions.builder().baseUrl("https://custom.example.com").build(); + + assertThrows( + OpenAIOfficialModelException.class, + () -> model.stream(simpleMessages(), null, perCall).collectList().block()); + } + + @Test + void perCallNullApiKeyDoesNotFailFast() { + OpenAIClient client = mockClientWithResponseService(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn(TestSdkFixtures.textResponse("Hello world")); + + OpenAIResponsesChatModel model = createModel(client, false, API_KEY, null); + + // per-call options with null apiKey — should fall back to configured, no fail-fast + GenerateOptions perCall = GenerateOptions.builder().build(); + + List results = + model.stream(simpleMessages(), null, perCall).collectList().block(); + + assertNotNull(results); + assertEquals(1, results.size()); + } + + @Test + void perCallNullBaseUrlDoesNotFailFast() { + OpenAIClient client = mockClientWithResponseService(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn(TestSdkFixtures.textResponse("Hello world")); + + OpenAIResponsesChatModel model = + createModel(client, false, API_KEY, "https://configured.example.com"); + + // per-call options with null baseUrl — should fall back to configured, no fail-fast + GenerateOptions perCall = GenerateOptions.builder().build(); + + List results = + model.stream(simpleMessages(), null, perCall).collectList().block(); + + assertNotNull(results); + assertEquals(1, results.size()); + } + + @Test + void perCallBlankApiKeyDoesNotFailFast() { + OpenAIClient client = mockClientWithResponseService(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn(TestSdkFixtures.textResponse("Hello world")); + + OpenAIResponsesChatModel model = createModel(client, false, API_KEY, null); + + // blank apiKey should be treated as "not set", not as an override + GenerateOptions perCall = GenerateOptions.builder().apiKey(" ").build(); + + List results = + model.stream(simpleMessages(), null, perCall).collectList().block(); + + assertNotNull(results); + assertEquals(1, results.size()); + } + + @Test + void perCallBlankBaseUrlDoesNotFailFast() { + OpenAIClient client = mockClientWithResponseService(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenReturn(TestSdkFixtures.textResponse("Hello world")); + + OpenAIResponsesChatModel model = + createModel(client, false, API_KEY, "https://configured.example.com"); + + // blank baseUrl should be treated as "not set", not as an override + GenerateOptions perCall = GenerateOptions.builder().baseUrl(" ").build(); + + List results = + model.stream(simpleMessages(), null, perCall).collectList().block(); + + assertNotNull(results); + assertEquals(1, results.size()); + } + } + + @Nested + class Streaming { + + @Test + void streamingEndToEnd() { + OpenAIClient client = mockClientWithResponseService(); + ResponseService svc = client.responses(); + List events = + List.of( + TestSdkFixtures.textDeltaEvent("Hello", "msg_1"), + TestSdkFixtures.completedEvent(TestSdkFixtures.textResponse("Hello"))); + when(svc.createStreaming(any(ResponseCreateParams.class))) + .thenReturn(TestSdkFixtures.streamOf(events)); + + OpenAIResponsesChatModel model = createModel(client, true); + List results = + model.stream(simpleMessages(), null, null).collectList().block(); + + assertNotNull(results); + assertFalse(results.isEmpty()); + boolean hasText = + results.stream() + .flatMap(r -> r.getContent().stream()) + .anyMatch( + b -> + b instanceof TextBlock tb + && tb.getText().contains("Hello")); + assertTrue(hasText); + } + } + + @Nested + class ErrorTranslation { + + @Test + void nonStreamingSdkErrorTranslated() { + OpenAIClient client = mockClientWithResponseService(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenThrow(TestSdkFixtures.badRequest("Invalid request")); + + OpenAIResponsesChatModel model = createModel(client, false); + + OpenAIOfficialModelException ex = + assertThrows( + OpenAIOfficialModelException.class, + () -> model.stream(simpleMessages(), null, null).collectList().block()); + assertEquals(400, ex.getStatusCode()); + assertEquals(OpenAIOfficialConstants.PROVIDER_ID, ex.getProvider()); + } + + @Test + void streamingSdkErrorTranslated() { + OpenAIClient client = mockClientWithResponseService(); + ResponseService svc = client.responses(); + when(svc.createStreaming(any(ResponseCreateParams.class))) + .thenThrow(TestSdkFixtures.badRequest("Invalid request")); + + OpenAIResponsesChatModel model = createModel(client, true); + + OpenAIOfficialModelException ex = + assertThrows( + OpenAIOfficialModelException.class, + () -> model.stream(simpleMessages(), null, null).collectList().block()); + assertEquals(400, ex.getStatusCode()); + } + } + + @Nested + class StreamDefault { + + @Test + void builderDefaultStreamIsTrue() { + OpenAIClient client = mockClientWithResponseService(); + ResponseService svc = client.responses(); + List events = + List.of(TestSdkFixtures.completedEvent(TestSdkFixtures.textResponse("ok"))); + when(svc.createStreaming(any(ResponseCreateParams.class))) + .thenReturn(TestSdkFixtures.streamOf(events)); + + GenerateOptions options = configuredOptions(true); + OpenAIResponsesChatModel model = + new OpenAIResponsesChatModel(client, options, API_KEY, null, null, null, null); + model.applyNativeStructuredOutputDefaults(); + + model.stream(simpleMessages(), null, null).collectList().block(); + + verify(svc).createStreaming(any(ResponseCreateParams.class)); + } + } + + @Nested + class RetryOnPredicate { + + @Test + void statusCode400NotRetryable() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException( + "err", TestSdkFixtures.badRequest("bad"), MODEL_NAME, 400); + assertFalse(OpenAIResponsesChatModel.moduleRetryOn().test(ex)); + } + + @Test + void statusCode429Retryable() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException( + "err", TestSdkFixtures.rateLimit("rate"), MODEL_NAME, 429); + assertTrue(OpenAIResponsesChatModel.moduleRetryOn().test(ex)); + } + + @Test + void statusCode500Retryable() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException( + "err", TestSdkFixtures.internalServer(500, "server"), MODEL_NAME, 500); + assertTrue(OpenAIResponsesChatModel.moduleRetryOn().test(ex)); + } + + @Test + void statusCode408Retryable() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException( + "err", + TestSdkFixtures.unexpectedStatusCode(408, "conflict"), + MODEL_NAME, + 408); + assertTrue(OpenAIResponsesChatModel.moduleRetryOn().test(ex)); + } + + @Test + void openAiIoExceptionRetryable() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException( + "err", TestSdkFixtures.ioException("io"), MODEL_NAME); + assertTrue(OpenAIResponsesChatModel.moduleRetryOn().test(ex)); + } + + @Test + void openAiRetryableExceptionRetryable() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException( + "err", TestSdkFixtures.retryableException("retryable"), MODEL_NAME); + assertTrue(OpenAIResponsesChatModel.moduleRetryOn().test(ex)); + } + + @Test + void timeoutExceptionRetryable() { + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException( + "err", TestSdkFixtures.timeoutException("timeout"), MODEL_NAME); + assertTrue(OpenAIResponsesChatModel.moduleRetryOn().test(ex)); + } + + @Test + void reactorTimeoutModelExceptionRetryable() { + ModelException ex = + new ModelException( + "Model request timeout", + new TimeoutException("Model request timeout after PT5M"), + MODEL_NAME, + "openai-official"); + assertTrue(OpenAIResponsesChatModel.moduleRetryOn().test(ex)); + } + + @Test + void xShouldRetryTrueForcesRetryable() { + OpenAIServiceException mockSvc = mock(OpenAIServiceException.class); + when(mockSvc.statusCode()).thenReturn(400); + when(mockSvc.headers()).thenReturn(TestSdkFixtures.headersWithShouldRetry("true")); + + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("err", mockSvc, MODEL_NAME, 400); + assertTrue(OpenAIResponsesChatModel.moduleRetryOn().test(ex)); + } + + @Test + void xShouldRetryFalseForcesNotRetryable() { + OpenAIServiceException mockSvc = mock(OpenAIServiceException.class); + when(mockSvc.statusCode()).thenReturn(500); + when(mockSvc.headers()).thenReturn(TestSdkFixtures.headersWithShouldRetry("false")); + + OpenAIOfficialModelException ex = + new OpenAIOfficialModelException("err", mockSvc, MODEL_NAME, 500); + assertFalse(OpenAIResponsesChatModel.moduleRetryOn().test(ex)); + } + + @Test + void plainModelExceptionNotRetryable() { + OpenAIOfficialModelException ex = new OpenAIOfficialModelException("validation error"); + assertFalse(OpenAIResponsesChatModel.moduleRetryOn().test(ex)); + } + } + + @Nested + class RetryOnInjection { + + @Test + void moduleRetryOnInjectedWhenUserDoesNotProvideCustom() { + OpenAIResponsesChatModel model = + OpenAIResponsesChatModel.builder() + .apiKey("test-key") + .modelName(MODEL_NAME) + .build(); + + GenerateOptions configured = model.getConfiguredOptions(); + assertNotNull(configured); + ExecutionConfig execConfig = configured.getExecutionConfig(); + assertNotNull(execConfig); + Predicate injectedRetryOn = execConfig.getRetryOn(); + assertNotNull(injectedRetryOn); + + // The injected retryOn should behave identically to moduleRetryOn() + Predicate expected = OpenAIResponsesChatModel.moduleRetryOn(); + + // Retryable: 429 + OpenAIOfficialModelException retryable = + new OpenAIOfficialModelException( + "err", TestSdkFixtures.rateLimit("rate"), MODEL_NAME, 429); + assertTrue(injectedRetryOn.test(retryable)); + assertEquals(expected.test(retryable), injectedRetryOn.test(retryable)); + + // Non-retryable: 400 + OpenAIOfficialModelException nonRetryable = + new OpenAIOfficialModelException( + "err", TestSdkFixtures.badRequest("bad"), MODEL_NAME, 400); + assertFalse(injectedRetryOn.test(nonRetryable)); + assertEquals(expected.test(nonRetryable), injectedRetryOn.test(nonRetryable)); + } + + @Test + void userProvidedRetryOnRespected() { + Predicate customRetryOn = e -> false; + + GenerateOptions userOptions = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder().retryOn(customRetryOn).build()) + .build(); + + OpenAIResponsesChatModel model = + OpenAIResponsesChatModel.builder() + .apiKey("test-key") + .modelName(MODEL_NAME) + .generateOptions(userOptions) + .build(); + + GenerateOptions configured = model.getConfiguredOptions(); + assertNotNull(configured); + ExecutionConfig execConfig = configured.getExecutionConfig(); + assertNotNull(execConfig); + + // The user's retryOn should be retained, not overridden by module retryOn + assertSame(customRetryOn, execConfig.getRetryOn()); + + // Behavioral verification: custom retryOn always returns false, + // even for errors that the module retryOn would classify as retryable + OpenAIOfficialModelException retryable = + new OpenAIOfficialModelException( + "err", TestSdkFixtures.rateLimit("rate"), MODEL_NAME, 429); + assertFalse(execConfig.getRetryOn().test(retryable)); + } + } + + // ── Module-level config ───────────────────────────────────────── + + @Nested + class ModuleLevelConfig { + + @Test + void nativeStructuredOutputAlwaysTrue() { + OpenAIClient client = mockClientWithResponseService(); + OpenAIResponsesChatModel model = createModel(client, false); + assertTrue(model.supportsNativeStructuredOutput()); + } + + @Test + void nativeStructuredOutputWithToolsAlwaysTrue() { + OpenAIClient client = mockClientWithResponseService(); + OpenAIResponsesChatModel model = createModel(client, false); + assertTrue(model.supportsNativeStructuredOutputWithTools()); + } + + @Test + void contextWindowSizeFromBuilderOverridesLookup() { + OpenAIResponsesChatModel model = + OpenAIResponsesChatModel.builder() + .apiKey(API_KEY) + .modelName(MODEL_NAME) + .contextWindowSize(12345) + .build(); + assertEquals(12345, model.getContextWindowSize()); + } + + @Test + void contextWindowSizeFallsBackToLookup() { + OpenAIResponsesChatModel model = + OpenAIResponsesChatModel.builder() + .apiKey(API_KEY) + .modelName(MODEL_NAME) + .build(); + int expected = + io.agentscope.core.model.ModelContextWindows.lookup( + MODEL_NAME, io.agentscope.core.model.ModelContextWindows.OPENAI); + assertEquals(expected, model.getContextWindowSize()); + } + } + + // ── Builder boundary ───────────────────────────────────────────── + + @Nested + class BuilderBoundary { + + @Test + void builderDoesNotExposeClientMethod() { + boolean found = false; + for (java.lang.reflect.Method m : + OpenAIResponsesChatModel.Builder.class.getDeclaredMethods()) { + if (m.getName().equals("client")) { + found = true; + break; + } + } + assertFalse(found, "Builder should not expose client() method"); + } + + @Test + void builderDoesNotExposeProxyMethod() { + boolean found = false; + for (java.lang.reflect.Method m : + OpenAIResponsesChatModel.Builder.class.getDeclaredMethods()) { + if (m.getName().equals("proxy")) { + found = true; + break; + } + } + assertFalse(found, "Builder should not expose proxy() method"); + } + + @Test + void builderExposesFormatterMethod() { + boolean found = false; + for (java.lang.reflect.Method m : + OpenAIResponsesChatModel.Builder.class.getDeclaredMethods()) { + if (m.getName().equals("formatter")) { + found = true; + break; + } + } + assertTrue(found, "Builder should expose formatter() method"); + } + + @Test + void builderDoesNotExposeEndpointPathMethod() { + boolean found = false; + for (java.lang.reflect.Method m : + OpenAIResponsesChatModel.Builder.class.getDeclaredMethods()) { + if (m.getName().equals("endpointPath")) { + found = true; + break; + } + } + assertFalse(found, "Builder should not expose endpointPath() method"); + } + + @Test + void builderDoesNotExposeHttpTransportMethod() { + boolean found = false; + for (java.lang.reflect.Method m : + OpenAIResponsesChatModel.Builder.class.getDeclaredMethods()) { + if (m.getName().equals("httpTransport")) { + found = true; + break; + } + } + assertFalse(found, "Builder should not expose httpTransport() method"); + } + } + + // ── Retry and cancel ──────────────────────────────────────────── + + @Nested + class RetryAndCancel { + + @Test + void streamCancelClosesSdkStream() throws Exception { + OpenAIClient client = mockClientWithResponseService(); + ResponseService svc = client.responses(); + + CountDownLatch eventLatch = new CountDownLatch(1); + CountDownLatch closeLatch = new CountDownLatch(1); + + StreamResponse streamResponse = + new StreamResponse() { + @Override + public Stream stream() { + return Stream.generate( + () -> { + eventLatch.countDown(); + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return TestSdkFixtures.textDeltaEvent("x", "msg_1"); + }); + } + + @Override + public void close() { + closeLatch.countDown(); + } + }; + when(svc.createStreaming(any(ResponseCreateParams.class))).thenReturn(streamResponse); + + OpenAIResponsesChatModel model = createModel(client, true); + Disposable disposable = model.stream(simpleMessages(), null, null).subscribe(); + assertTrue(eventLatch.await(5, TimeUnit.SECONDS), "Stream should emit events"); + disposable.dispose(); + assertTrue( + closeLatch.await(2, TimeUnit.SECONDS), + "StreamResponse should be closed on cancel"); + } + + @Test + void retryableErrorRetriedViaRetryWhen() { + OpenAIClient client = mockClientWithResponseService(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenThrow(TestSdkFixtures.rateLimit("rate limited")) + .thenReturn(TestSdkFixtures.textResponse("success")); + + OpenAIResponsesChatModel model = createModel(client, false); + List results = + model.stream(simpleMessages(), null, null).collectList().block(); + + assertNotNull(results); + verify(svc, times(2)).create(any(ResponseCreateParams.class)); + } + + @Test + void nonRetryableErrorNotRetried() { + OpenAIClient client = mockClientWithResponseService(); + ResponseService svc = client.responses(); + when(svc.create(any(ResponseCreateParams.class))) + .thenThrow(TestSdkFixtures.badRequest("bad request")); + + OpenAIResponsesChatModel model = createModel(client, false); + assertThrows( + OpenAIOfficialModelException.class, + () -> model.stream(simpleMessages(), null, null).collectList().block()); + verify(svc, times(1)).create(any(ResponseCreateParams.class)); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAISdkClientFactoryTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAISdkClientFactoryTest.java new file mode 100644 index 0000000000..557c82bda5 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAISdkClientFactoryTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.openai.client.OpenAIClient; +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link OpenAISdkClientFactory}. + * + *

Covers the three key branches: apiKey fail-fast, happy path, and RuntimeException + * wrapping. The factory is also exercised indirectly by {@link OpenAIOfficialModelProviderTest}. + */ +class OpenAISdkClientFactoryTest { + + @Test + void blankApiKeyThrowsWithDescriptiveMessage() { + OpenAIOfficialModelException ex = + assertThrows( + OpenAIOfficialModelException.class, + () -> OpenAISdkClientFactory.createClient(null, null, null, null)); + assertTrue(ex.getMessage().contains("apiKey is required")); + } + + @Test + void validParamsReturnsClient() { + OpenAIClient client = + OpenAISdkClientFactory.createClient( + "sk-test", + "https://custom.example.com", + Map.of("X-Request-Id", "abc"), + Duration.ofSeconds(30)); + assertTrue(client != null); + } + + @Test + void sdkRuntimeExceptionWrappedInModelException() { + // null header value triggers Kotlin null-check in putHeader(name, value) + java.util.HashMap headers = new java.util.HashMap<>(); + headers.put("X-Null", null); + + OpenAIOfficialModelException ex = + assertThrows( + OpenAIOfficialModelException.class, + () -> OpenAISdkClientFactory.createClient("sk-test", null, headers, null)); + assertTrue(ex.getMessage().contains("Failed to construct OpenAI client")); + assertTrue(ex.getCause() != null); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesHelperTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesHelperTest.java new file mode 100644 index 0000000000..744f8d6dff --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesHelperTest.java @@ -0,0 +1,97 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.openai.models.responses.Response; +import io.agentscope.core.model.ChatUsage; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Direct tests for {@link ResponsesHelper}. + * + *

Covers full metadata extraction, absence paths, usage mapping, and usage-absent + * return. Most fields are also exercised indirectly by {@link ResponsesResponseParserTest}. + */ +class ResponsesHelperTest { + + @Test + void fullMetadataResponseExtractsAllFields() { + Response response = TestSdkFixtures.fullMetadataResponse(); + Map md = ResponsesHelper.extractResponseMetadata(response); + + assertEquals("resp_test_123", md.get(OpenAIOfficialConstants.MD_RESPONSE_ID)); + assertEquals(1697000000.5, (Double) md.get(OpenAIOfficialConstants.MD_RESPONSE_CREATED_AT)); + assertEquals("incomplete", md.get(OpenAIOfficialConstants.MD_RESPONSE_STATUS)); + assertEquals( + 1697000001.5, (Double) md.get(OpenAIOfficialConstants.MD_RESPONSE_COMPLETED_AT)); + assertEquals("priority", md.get(OpenAIOfficialConstants.MD_RESPONSE_SERVICE_TIER)); + assertEquals( + "max_output_tokens", md.get(OpenAIOfficialConstants.MD_RESPONSE_INCOMPLETE_REASON)); + @SuppressWarnings("unchecked") + Map errorMap = + (Map) md.get(OpenAIOfficialConstants.MD_RESPONSE_ERROR); + assertEquals("Something went wrong", errorMap.get("message")); + assertEquals("server_error", errorMap.get("code")); + } + + @Test + void minimalResponseHasOnlyIdAndCreatedAt() { + Response response = + TestSdkFixtures.response(List.of(TestSdkFixtures.messageItem("hello")), null, null); + Map md = ResponsesHelper.extractResponseMetadata(response); + + assertEquals("resp_test_123", md.get(OpenAIOfficialConstants.MD_RESPONSE_ID)); + assertEquals(1697000000.5, (Double) md.get(OpenAIOfficialConstants.MD_RESPONSE_CREATED_AT)); + assertFalse(md.containsKey(OpenAIOfficialConstants.MD_RESPONSE_STATUS)); + assertFalse(md.containsKey(OpenAIOfficialConstants.MD_RESPONSE_COMPLETED_AT)); + assertFalse(md.containsKey(OpenAIOfficialConstants.MD_RESPONSE_SERVICE_TIER)); + assertFalse(md.containsKey(OpenAIOfficialConstants.MD_RESPONSE_INCOMPLETE_REASON)); + assertFalse(md.containsKey(OpenAIOfficialConstants.MD_RESPONSE_ERROR)); + } + + @Test + void usageMappedToChatUsageAndReasoningTokensWrittenToMetadata() { + Response response = TestSdkFixtures.usageResponse(100L, 50L, 20L, 15L); + Map metadata = new HashMap<>(); + ChatUsage usage = ResponsesHelper.extractUsage(response, Instant.now(), metadata); + + assertNotNull(usage); + assertEquals(100, usage.getInputTokens()); + assertEquals(50, usage.getOutputTokens()); + assertEquals(20, usage.getCachedTokens()); + assertEquals(150, usage.getTotalTokens()); + assertEquals(15, metadata.get(OpenAIOfficialConstants.MD_USAGE_REASONING_TOKENS)); + } + + @Test + void usageAbsentReturnsNullAndDoesNotWriteReasoningTokens() { + Response response = TestSdkFixtures.textResponse("hello"); + Map metadata = new HashMap<>(); + ChatUsage usage = ResponsesHelper.extractUsage(response, Instant.now(), metadata); + + assertNull(usage); + assertFalse(metadata.containsKey(OpenAIOfficialConstants.MD_USAGE_REASONING_TOKENS)); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatterTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatterTest.java new file mode 100644 index 0000000000..8edac293d4 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatterTest.java @@ -0,0 +1,676 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.openai.models.responses.EasyInputMessage; +import com.openai.models.responses.ResponseInputContent; +import com.openai.models.responses.ResponseInputItem; +import io.agentscope.core.message.Base64Source; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.DataBlock; +import io.agentscope.core.message.HintBlock; +import io.agentscope.core.message.ImageBlock; +import io.agentscope.core.message.MessageMetadataKeys; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.message.URLSource; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ResponsesMultiAgentFormatter}. + * + *

Verifies message grouping (SYSTEM, TOOL_SEQUENCE, AGENT_CONVERSATION, BYPASS), + * conversation merging with history tags, media handling, thinking/hint block + * inclusion, tool sequence passthrough with filtering, bypass dispatch, and custom prompt. + */ +@DisplayName("ResponsesMultiAgentFormatter Unit Tests") +class ResponsesMultiAgentFormatterTest { + + private ResponsesMultiAgentFormatter formatter; + + @BeforeEach + void setUp() { + formatter = new ResponsesMultiAgentFormatter(); + } + + private static TextBlock text(String t) { + return TextBlock.builder().text(t).build(); + } + + private static Msg user(String name, ContentBlock... blocks) { + return Msg.builder().role(MsgRole.USER).name(name).content(List.of(blocks)).build(); + } + + private static Msg user(ContentBlock... blocks) { + return Msg.builder().role(MsgRole.USER).content(List.of(blocks)).build(); + } + + private static Msg assistant(String name, ContentBlock... blocks) { + return Msg.builder().role(MsgRole.ASSISTANT).name(name).content(List.of(blocks)).build(); + } + + private static Msg assistant(ContentBlock... blocks) { + return Msg.builder().role(MsgRole.ASSISTANT).content(List.of(blocks)).build(); + } + + private static Msg system(String content) { + return Msg.builder().role(MsgRole.SYSTEM).content(text(content)).build(); + } + + private static Msg tool(ToolResultBlock trb) { + return Msg.builder().role(MsgRole.TOOL).content(trb).build(); + } + + private static Msg bypassUser(ContentBlock... blocks) { + return Msg.builder() + .role(MsgRole.USER) + .content(List.of(blocks)) + .metadata(Map.of(MessageMetadataKeys.BYPASS_MULTIAGENT_HISTORY_MERGE, true)) + .build(); + } + + private static Msg bypassAssistant(ContentBlock... blocks) { + return Msg.builder() + .role(MsgRole.ASSISTANT) + .content(List.of(blocks)) + .metadata(Map.of(MessageMetadataKeys.BYPASS_MULTIAGENT_HISTORY_MERGE, true)) + .build(); + } + + private static ToolUseBlock toolUse(String id, String name) { + return ToolUseBlock.builder().id(id).name(name).input(Map.of()).build(); + } + + private static ToolResultBlock toolResult(String id, String name, String result) { + return ToolResultBlock.builder().id(id).name(name).output(text(result)).build(); + } + + private static String extractAllText(List items) { + StringBuilder sb = new StringBuilder(); + for (ResponseInputItem item : items) { + sb.append(extractText(item)); + } + return sb.toString(); + } + + private static String extractText(ResponseInputItem item) { + if (!item.isEasyInputMessage()) { + return ""; + } + EasyInputMessage msg = item.asEasyInputMessage(); + EasyInputMessage.Content content = msg.content(); + if (content.isResponseInputMessageContentList()) { + StringBuilder sb = new StringBuilder(); + for (ResponseInputContent part : content.asResponseInputMessageContentList()) { + if (part.isInputText()) { + sb.append(part.asInputText().text()); + } + } + return sb.toString(); + } + return content.toString(); + } + + private static long countRole(List items, EasyInputMessage.Role role) { + return items.stream() + .filter(ResponseInputItem::isEasyInputMessage) + .map(ResponseInputItem::asEasyInputMessage) + .filter(m -> m.role() == role) + .count(); + } + + private static long countImages(List items) { + return items.stream() + .filter(ResponseInputItem::isEasyInputMessage) + .map(ResponseInputItem::asEasyInputMessage) + .filter(m -> m.content().isResponseInputMessageContentList()) + .flatMap(m -> m.content().asResponseInputMessageContentList().stream()) + .filter(ResponseInputContent::isInputImage) + .count(); + } + + @Nested + @DisplayName("System message handling") + class SystemMessages { + + @Test + @DisplayName("Single system message extracted as separate item") + void singleSystemMessage() { + List result = + formatter.formatHistory(List.of(system("You are helpful"))); + assertEquals(1, result.size()); + assertTrue(result.get(0).isEasyInputMessage()); + assertEquals(EasyInputMessage.Role.SYSTEM, result.get(0).asEasyInputMessage().role()); + assertTrue(extractText(result.get(0)).contains("You are helpful")); + } + + @Test + @DisplayName("Two consecutive system messages each start a new group") + void twoSystemMessagesSeparateGroups() { + List result = + formatter.formatHistory( + List.of(system("System prompt A"), system("System prompt B"))); + assertEquals(2, result.size()); + assertEquals(EasyInputMessage.Role.SYSTEM, result.get(0).asEasyInputMessage().role()); + assertEquals(EasyInputMessage.Role.SYSTEM, result.get(1).asEasyInputMessage().role()); + assertTrue(extractText(result.get(0)).contains("System prompt A")); + assertTrue(extractText(result.get(1)).contains("System prompt B")); + } + + @Test + @DisplayName("System message followed by conversation") + void systemThenConversation() { + List result = + formatter.formatHistory( + List.of( + system("You are a translator"), + user("Alice", text("Hello")), + assistant("Bob", text("Hi there")))); + assertEquals(2, result.size()); + assertEquals(EasyInputMessage.Role.SYSTEM, result.get(0).asEasyInputMessage().role()); + assertEquals(EasyInputMessage.Role.USER, result.get(1).asEasyInputMessage().role()); + String convText = extractText(result.get(1)); + assertTrue(convText.contains("")); + assertTrue(convText.contains("Hello")); + assertTrue(convText.contains("Hi there")); + } + } + + @Nested + @DisplayName("Agent conversation merging") + class AgentConversation { + + @Test + @DisplayName("Two-agent conversation merged into single user message") + void twoAgentConversationMerged() { + List result = + formatter.formatHistory( + List.of( + user("Alice", text("Hello, Bob")), + assistant("Bob", text("Hi Alice!")), + user("Alice", text("How are you?")))); + assertEquals(1, result.size()); + assertEquals(EasyInputMessage.Role.USER, result.get(0).asEasyInputMessage().role()); + String content = extractText(result.get(0)); + assertTrue(content.contains("# Conversation History")); + assertTrue(content.contains("")); + assertTrue(content.contains("")); + assertTrue(content.contains("Alice: Hello, Bob")); + assertTrue(content.contains("Bob: Hi Alice!")); + assertTrue(content.contains("Alice: How are you?")); + } + + @Test + @DisplayName("Single message in conversation: no name prefix") + void singleMessageNoPrefix() { + List result = + formatter.formatHistory(List.of(user("Alice", text("Hello")))); + assertEquals(1, result.size()); + String content = extractText(result.get(0)); + assertFalse(content.contains("Alice:")); + assertTrue(content.contains("Hello")); + assertTrue(content.contains("")); + } + + @Test + @DisplayName("Multiple agent conversation groups separated by tool sequence") + void multipleConversationGroups() { + List result = + formatter.formatHistory( + List.of( + user("Alice", text("What's the weather?")), + assistant("Bob", toolUse("call_1", "get_weather")), + tool(toolResult("call_1", "get_weather", "Sunny")), + assistant("Bob", text("It's sunny!")), + user("Alice", text("Great, thanks!")))); + assertNotNull(result); + assertTrue(result.size() >= 2); + String allText = extractAllText(result); + assertTrue(allText.contains("")); + assertTrue(allText.contains("# Conversation History")); + } + + @Test + @DisplayName("Second agent conversation group omits history prompt") + void secondGroupOmitsPrompt() { + List messages = new ArrayList<>(); + messages.add(user("Alice", text("Hello"))); + messages.add(assistant("Bob", text("Hi"))); + messages.add(assistant("Bob", toolUse("call_1", "search"))); + messages.add(tool(toolResult("call_1", "search", "result"))); + messages.add(assistant("Bob", text("Found it"))); + messages.add(user("Alice", text("Nice"))); + List result = formatter.formatHistory(messages); + String allText = extractAllText(result); + assertEquals(1, countOccurrences(allText, "# Conversation History")); + assertTrue(countOccurrences(allText, "") >= 2); + } + } + + @Nested + @DisplayName("Tool sequence passthrough") + class ToolSequence { + + @Test + @DisplayName("Assistant tool use passed through as function call") + void assistantToolUsePassedThrough() { + List result = + formatter.formatHistory( + List.of( + assistant( + "Bob", + text("Let me check"), + toolUse("call_1", "get_weather")))); + assertTrue(result.size() >= 1); + assertTrue(result.stream().anyMatch(ResponseInputItem::isFunctionCall)); + } + + @Test + @DisplayName("Tool result passed through as function call output") + void toolResultPassedThrough() { + List result = + formatter.formatHistory( + List.of(tool(toolResult("call_1", "get_weather", "Sunny, 25C")))); + assertTrue(result.size() >= 1); + assertTrue(result.stream().anyMatch(ResponseInputItem::isFunctionCallOutput)); + } + + @Test + @DisplayName("Full tool sequence: assistant call + tool result + assistant response") + void fullToolSequence() { + List result = + formatter.formatHistory( + List.of( + assistant("Bob", toolUse("call_1", "get_weather")), + tool(toolResult("call_1", "get_weather", "Sunny")), + assistant("Bob", text("The weather is sunny")))); + assertNotNull(result); + assertTrue(result.size() >= 2); + } + } + + @Nested + @DisplayName("Bypass message handling") + class Bypass { + + @Test + @DisplayName("Bypass USER message mapped via mapMessage") + void bypassUserMessage() { + List result = + formatter.formatHistory(List.of(bypassUser(text("Direct message")))); + assertEquals(1, result.size()); + assertTrue(result.get(0).isEasyInputMessage()); + assertEquals(EasyInputMessage.Role.USER, result.get(0).asEasyInputMessage().role()); + assertTrue(extractText(result.get(0)).contains("Direct message")); + } + + @Test + @DisplayName("Bypass ASSISTANT message dispatched correctly via mapMessage") + void bypassAssistantMessage() { + List result = + formatter.formatHistory(List.of(bypassAssistant(text("Assistant reply")))); + assertEquals(1, result.size()); + assertTrue(result.get(0).isEasyInputMessage()); + assertEquals( + EasyInputMessage.Role.ASSISTANT, result.get(0).asEasyInputMessage().role()); + assertTrue(extractText(result.get(0)).contains("Assistant reply")); + } + + @Test + @DisplayName("Bypass message between conversation groups is not merged") + void bypassBetweenConversations() { + List result = + formatter.formatHistory( + List.of( + user("Alice", text("Hello")), + bypassUser(text("Important context")), + assistant("Bob", text("Hi")))); + assertNotNull(result); + assertTrue(result.size() >= 2); + assertTrue(extractAllText(result).contains("Important context")); + } + } + + @Nested + @DisplayName("Media handling in merged conversation") + class MediaHandling { + + @Test + @DisplayName("URL image in conversation produces image part") + void urlImageInConversation() { + String imageUrl = "https://example.com/image.png"; + List result = + formatter.formatHistory( + List.of( + user( + "Alice", + text("Look at this"), + ImageBlock.builder() + .source( + URLSource.builder() + .url(imageUrl) + .build()) + .build()), + assistant("Bob", text("Nice image!")))); + assertEquals(1, result.size()); + assertEquals(1, countImages(result)); + String content = extractText(result.get(0)); + assertTrue(content.contains("Look at this")); + assertTrue(content.contains("Nice image!")); + } + + @Test + @DisplayName("DataBlock image URL produces image part") + void dataBlockImageUrl() { + String imageUrl = "https://example.com/data.png"; + List result = + formatter.formatHistory( + List.of( + user( + "Alice", + text("Check this data"), + DataBlock.builder() + .source( + URLSource.builder() + .url(imageUrl) + .build()) + .build()))); + assertEquals(1, result.size()); + assertEquals(1, countImages(result)); + } + + @Test + @DisplayName("Non-image DataBlock produces error text fallback") + void nonImageDataBlockFallback() { + List result = + formatter.formatHistory( + List.of( + user( + "Alice", + text("Before"), + DataBlock.builder() + .source( + Base64Source.builder() + .mediaType("audio/wav") + .data("dGVzdA==") + .build()) + .build(), + text("After")))); + assertEquals(1, result.size()); + String content = extractText(result.get(0)); + assertTrue(content.contains("Before")); + assertTrue(content.contains("After")); + assertTrue(content.contains("[Data - processing failed]")); + } + + @Test + @DisplayName("Multiple images flush text buffer correctly") + void multipleImagesFlushText() { + List result = + formatter.formatHistory( + List.of( + user( + "Alice", + text("Two images:"), + ImageBlock.builder() + .source( + URLSource.builder() + .url( + "https://example.com/img1.png") + .build()) + .build(), + text("and"), + ImageBlock.builder() + .source( + URLSource.builder() + .url( + "https://example.com/img2.png") + .build()) + .build()))); + assertEquals(1, result.size()); + assertEquals(2, countImages(result)); + String content = extractText(result.get(0)); + assertTrue(content.contains("Two images:")); + assertTrue(content.contains("and")); + } + } + + @Nested + @DisplayName("Thinking and hint blocks in merged conversation") + class ThinkingAndHint { + + @Test + @DisplayName("ThinkingBlock text included in merged history") + void thinkingBlockIncluded() { + List result = + formatter.formatHistory( + List.of( + user("Alice", text("What is 2+2?")), + assistant( + "Bob", + ThinkingBlock.builder() + .thinking("Let me calculate") + .build(), + text("The answer is 4")))); + assertEquals(1, result.size()); + String content = extractText(result.get(0)); + assertTrue(content.contains("[Thinking]: Let me calculate")); + assertTrue(content.contains("The answer is 4")); + } + + @Test + @DisplayName("Empty thinking block produces no output") + void emptyThinkingBlock() { + List result = + formatter.formatHistory( + List.of( + user("Alice", text("Hello")), + assistant( + "Bob", + ThinkingBlock.builder().thinking("").build(), + text("Hi")))); + assertEquals(1, result.size()); + String content = extractText(result.get(0)); + assertFalse(content.contains("[Thinking]")); + assertTrue(content.contains("Hi")); + } + + @Test + @DisplayName("HintBlock text included in merged history") + void hintBlockIncluded() { + List result = + formatter.formatHistory( + List.of( + user("Alice", text("Hello")), + assistant( + "Bob", + new HintBlock("hint1", "Greeting received"), + text("Hi there")))); + assertEquals(1, result.size()); + String content = extractText(result.get(0)); + assertTrue(content.contains("Greeting received")); + assertTrue(content.contains("Hi there")); + } + } + + @Nested + @DisplayName("Custom conversation history prompt") + class CustomPrompt { + + @Test + @DisplayName("Custom prompt used in first conversation group") + void customPromptUsed() { + ResponsesMultiAgentFormatter customFormatter = + new ResponsesMultiAgentFormatter("## Chat History\n"); + List result = + customFormatter.formatHistory( + List.of(user("Alice", text("Hello")), assistant("Bob", text("Hi")))); + assertEquals(1, result.size()); + String content = extractText(result.get(0)); + assertTrue(content.contains("## Chat History")); + assertFalse(content.contains("# Conversation History")); + } + + @Test + @DisplayName("Custom prompt appears only once across multiple groups") + void customPromptOncePerFormat() { + ResponsesMultiAgentFormatter customFormatter = + new ResponsesMultiAgentFormatter("[History]\n"); + List messages = new ArrayList<>(); + messages.add(user("Alice", text("Hello"))); + messages.add(assistant("Bob", text("Hi"))); + messages.add(assistant("Bob", toolUse("call_1", "search"))); + messages.add(tool(toolResult("call_1", "search", "found"))); + messages.add(assistant("Bob", text("Done"))); + messages.add(user("Alice", text("Thanks"))); + List result = customFormatter.formatHistory(messages); + assertEquals(1, countOccurrences(extractAllText(result), "[History]")); + } + } + + @Nested + @DisplayName("Mixed message scenarios") + class MixedScenarios { + + @Test + @DisplayName("System + conversation + tools + conversation") + void fullMixedScenario() { + List messages = new ArrayList<>(); + messages.add(system("You are a helpful assistant")); + messages.add(user("Alice", text("What's the weather?"))); + messages.add(assistant("Bob", toolUse("call_1", "get_weather"))); + messages.add(tool(toolResult("call_1", "get_weather", "Sunny"))); + messages.add(assistant("Bob", text("It's sunny today"))); + messages.add(user("Alice", text("Great! What about tomorrow?"))); + List result = formatter.formatHistory(messages); + assertNotNull(result); + assertTrue(result.size() >= 3); + assertTrue(result.get(0).isEasyInputMessage()); + assertEquals(EasyInputMessage.Role.SYSTEM, result.get(0).asEasyInputMessage().role()); + assertTrue(result.stream().anyMatch(ResponseInputItem::isFunctionCall)); + assertTrue(result.stream().anyMatch(ResponseInputItem::isFunctionCallOutput)); + assertEquals(2, countRole(result, EasyInputMessage.Role.USER)); + String allText = extractAllText(result); + assertTrue(allText.contains("What's the weather?")); + assertTrue(allText.contains("It's sunny today")); + assertTrue(allText.contains("Great! What about tomorrow?")); + } + + @Test + @DisplayName("Conversation before and after tool sequence") + void conversationBeforeAndAfterTools() { + List messages = new ArrayList<>(); + messages.add(user("Alice", text("Search for cats"))); + messages.add(assistant("Bob", toolUse("call_1", "search"))); + messages.add(tool(toolResult("call_1", "search", "Found cats"))); + messages.add(assistant("Bob", text("I found some cats!"))); + messages.add(user("Alice", text("Show me dogs too"))); + List result = formatter.formatHistory(messages); + assertNotNull(result); + assertTrue(result.size() >= 2); + assertTrue(result.stream().anyMatch(ResponseInputItem::isFunctionCall)); + assertTrue(result.stream().anyMatch(ResponseInputItem::isFunctionCallOutput)); + String allText = extractAllText(result); + assertTrue(allText.contains("Search for cats")); + assertTrue(allText.contains("I found some cats!")); + assertTrue(allText.contains("Show me dogs too")); + } + } + + @Nested + @DisplayName("Edge cases") + class EdgeCases { + + @Test + @DisplayName("Empty message list produces empty result") + void emptyMessageList() { + List result = formatter.formatHistory(List.of()); + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("Single assistant text message produces merged history") + void singleAssistantMessage() { + List result = + formatter.formatHistory(List.of(assistant("Bob", text("Hello world")))); + assertEquals(1, result.size()); + assertTrue(result.get(0).isEasyInputMessage()); + assertEquals(EasyInputMessage.Role.USER, result.get(0).asEasyInputMessage().role()); + String content = extractText(result.get(0)); + assertTrue(content.contains("Hello world")); + assertTrue(content.contains("")); + } + + @Test + @DisplayName("Messages without names use no prefix in multi-turn") + void messagesWithoutNames() { + List result = + formatter.formatHistory( + List.of( + Msg.builder() + .role(MsgRole.USER) + .content(text("Question")) + .build(), + Msg.builder() + .role(MsgRole.ASSISTANT) + .content(text("Answer")) + .build())); + assertEquals(1, result.size()); + String content = extractText(result.get(0)); + assertTrue(content.contains("Question")); + assertTrue(content.contains("Answer")); + } + + @Test + @DisplayName("Tool role messages always classified as TOOL_SEQUENCE") + void toolRoleAlwaysToolSequence() { + List result = + formatter.formatHistory( + List.of( + assistant("Bob", text("Let me search")), + tool(toolResult("call_1", "search", "result")), + tool(toolResult("call_2", "search", "result2")))); + assertEquals( + 2, result.stream().filter(ResponseInputItem::isFunctionCallOutput).count()); + } + } + + private static long countOccurrences(String haystack, String needle) { + long count = 0; + int idx = 0; + while ((idx = haystack.indexOf(needle, idx)) != -1) { + count++; + idx += needle.length(); + } + return count; + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapperTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapperTest.java new file mode 100644 index 0000000000..36da4db162 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapperTest.java @@ -0,0 +1,1045 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.openai.core.JsonValue; +import com.openai.models.responses.FunctionTool; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseFunctionCallOutputItem; +import com.openai.models.responses.ResponseInputItem; +import io.agentscope.core.formatter.JsonSchema; +import io.agentscope.core.formatter.ResponseFormat; +import io.agentscope.core.message.AssistantMessage; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.DataBlock; +import io.agentscope.core.message.ImageBlock; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.SystemMessage; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolResultMessage; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.message.URLSource; +import io.agentscope.core.message.UserMessage; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ToolChoice; +import io.agentscope.core.model.ToolSchema; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link ResponsesRequestMapper}, grouped by request-mapping sections. + */ +class ResponsesRequestMapperTest { + + private static final String MODEL = "gpt-4o"; + + private static GenerateOptions baseOptions() { + return GenerateOptions.builder().modelName(MODEL).stream(false).build(); + } + + private static TextBlock text(String t) { + return TextBlock.builder().text(t).build(); + } + + private static ResponseCreateParams mapWith( + GenerateOptions options, List tools, Boolean strictTools) { + return ResponsesRequestMapper.map( + List.of(SystemMessage.builder().content(text("system")).build()), + tools, + options, + strictTools, + null, + ResponsesRequestMapper::mapHistory); + } + + private static ResponseCreateParams mapHistory( + GenerateOptions options, List messages) { + @SuppressWarnings("unchecked") + List msgs = (List) messages; + return ResponsesRequestMapper.map( + msgs, null, options, null, null, ResponsesRequestMapper::mapHistory); + } + + // ── Options mapping ────────────────────────────────────────── + + @Nested + class OptionsMapping { + + @Test + void temperature() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .temperature(0.7) + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertEquals(0.7, params.temperature().orElseThrow()); + } + + @Test + void topP() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false).topP(0.9).build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertEquals(0.9, params.topP().orElseThrow()); + } + + @Test + void maxOutputTokensFromMaxTokens() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .maxTokens(4096) + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertEquals(4096L, params.maxOutputTokens().orElseThrow()); + } + + @Test + void maxOutputTokensPriorityMaxCompletionTokens() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .maxTokens(1000) + .maxCompletionTokens(2000) + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertEquals(2000L, params.maxOutputTokens().orElseThrow()); + } + + @Test + void parallelToolCalls() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .parallelToolCalls(false) + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertFalse(params.parallelToolCalls().orElseThrow()); + } + + @Test + void reasoningEffort() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .reasoningEffort("high") + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.reasoning().isPresent()); + assertTrue(params.reasoning().get().effort().isPresent()); + assertEquals("high", params.reasoning().get().effort().get().asString()); + } + + @Test + void reasoningSummaryOptIn() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("reasoning.summary", "auto") + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.reasoning().isPresent()); + assertTrue(params.reasoning().get().summary().isPresent()); + assertEquals("auto", params.reasoning().get().summary().get().asString()); + } + + @Test + void noReasoningWhenAllNull() { + ResponseCreateParams params = mapWith(baseOptions(), null, null); + assertFalse(params.reasoning().isPresent()); + } + + @Test + void storeAlwaysFalse() { + ResponseCreateParams params = mapWith(baseOptions(), null, null); + assertFalse(params.store().orElseThrow()); + } + + @Test + void modelNameSet() { + ResponseCreateParams params = mapWith(baseOptions(), null, null); + assertNotNull(params.model()); + } + + @Test + void responseFormatJsonObject() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .responseFormat(ResponseFormat.jsonObject()) + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.text().isPresent()); + } + + @Test + void responseFormatJsonSchema() { + JsonSchema schema = + JsonSchema.builder().name("Result").schema(Map.of("type", "object")).build(); + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .responseFormat(ResponseFormat.jsonSchema(schema)) + .build(); + ResponseCreateParams params = + ResponsesRequestMapper.map( + List.of(SystemMessage.builder().content(text("s")).build()), + null, + opts, + null, + true, + ResponsesRequestMapper::mapHistory); + assertTrue(params.text().isPresent()); + } + + @Test + void toolChoiceAuto() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .toolChoice(new ToolChoice.Auto()) + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.toolChoice().isPresent()); + } + + @Test + void toolChoiceSpecific() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .toolChoice(new ToolChoice.Specific("my_tool")) + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.toolChoice().isPresent()); + } + + @Test + void reasoningContext() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("reasoning.context", "current_turn") + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.reasoning().isPresent()); + assertTrue(params.reasoning().get().context().isPresent()); + assertEquals("current_turn", params.reasoning().get().context().get().asString()); + } + + @Test + void reasoningMode() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("reasoning.mode", "standard") + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.reasoning().isPresent()); + assertTrue(params.reasoning().get().mode().isPresent()); + assertEquals("standard", params.reasoning().get().mode().get().asString()); + } + + @Test + void responseFormatText() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .responseFormat(ResponseFormat.text()) + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertFalse(params.text().isPresent()); + } + + @Test + void responseFormatUnknownTypeFailsFast() { + ResponseFormat format = ResponseFormat.builder().type("xml").build(); + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .responseFormat(format) + .build(); + assertThrows(OpenAIOfficialModelException.class, () -> mapWith(opts, null, null)); + } + + @Test + void jsonSchemaNullNameFailsFast() { + JsonSchema schema = JsonSchema.builder().schema(Map.of("type", "object")).build(); + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .responseFormat(ResponseFormat.jsonSchema(schema)) + .build(); + assertThrows( + OpenAIOfficialModelException.class, + () -> + ResponsesRequestMapper.map( + List.of(SystemMessage.builder().content(text("s")).build()), + null, + opts, + null, + null, + ResponsesRequestMapper::mapHistory)); + } + + @Test + void toolChoiceNone() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .toolChoice(new ToolChoice.None()) + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.toolChoice().isPresent()); + } + + @Test + void toolChoiceRequired() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .toolChoice(new ToolChoice.Required()) + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.toolChoice().isPresent()); + } + } + + // ── AdditionalBodyParams whitelist ────────────────────────── + + @Nested + class AdditionalBodyParams { + + @Test + void maxToolCalls() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("max_tool_calls", "5") + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertEquals(5L, params.maxToolCalls().orElseThrow()); + } + + @Test + void serviceTier() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("service_tier", "flex") + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.serviceTier().isPresent()); + } + + @Test + void promptCacheKey() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("prompt_cache_key", "key1") + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertEquals("key1", params.promptCacheKey().orElseThrow()); + } + + @Test + void safetyIdentifier() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("safety_identifier", "sid-123") + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertEquals("sid-123", params.safetyIdentifier().orElseThrow()); + } + + @Test + void nonWhitelistKeyFailsFast() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("unknown_param", "value") + .build(); + OpenAIOfficialModelException ex = + assertThrows( + OpenAIOfficialModelException.class, () -> mapWith(opts, null, null)); + assertTrue(ex.getMessage().contains("unknown_param")); + } + + @Test + void promptCacheOptions() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam( + "prompt_cache_options", + Map.of("mode", "explicit", "ttl", "30m")) + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.promptCacheOptions().isPresent()); + } + + @Test + void maxToolCallsInvalidValueFailsFast() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("max_tool_calls", "abc") + .build(); + OpenAIOfficialModelException ex = + assertThrows( + OpenAIOfficialModelException.class, () -> mapWith(opts, null, null)); + assertTrue(ex.getMessage().contains("max_tool_calls")); + } + + @Test + void promptCacheOptionsNonMapFailsFast() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("prompt_cache_options", "not-a-map") + .build(); + assertThrows(OpenAIOfficialModelException.class, () -> mapWith(opts, null, null)); + } + } + + // ── Rejected fields ────────────────────────────────────────── + + @Nested + class RejectedFields { + + @Test + void frequencyPenalty() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .frequencyPenalty(0.5) + .build(); + assertThrows(OpenAIOfficialModelException.class, () -> mapWith(opts, null, null)); + } + + @Test + void presencePenalty() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .presencePenalty(0.3) + .build(); + assertThrows(OpenAIOfficialModelException.class, () -> mapWith(opts, null, null)); + } + + @Test + void topK() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false).topK(40).build(); + assertThrows(OpenAIOfficialModelException.class, () -> mapWith(opts, null, null)); + } + + @Test + void seed() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false).seed(42L).build(); + assertThrows(OpenAIOfficialModelException.class, () -> mapWith(opts, null, null)); + } + + @Test + void cacheControl() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .cacheControl(true) + .build(); + assertThrows(OpenAIOfficialModelException.class, () -> mapWith(opts, null, null)); + } + + @Test + void thinkingBudget() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .thinkingBudget(1000) + .build(); + assertThrows(OpenAIOfficialModelException.class, () -> mapWith(opts, null, null)); + } + + @Test + void additionalHeadersFailsFast() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalHeader("X-Custom", "val") + .build(); + assertThrows(OpenAIOfficialModelException.class, () -> mapWith(opts, null, null)); + } + + @Test + void endpointPathFailsFast() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .endpointPath("/custom/path") + .build(); + assertThrows(OpenAIOfficialModelException.class, () -> mapWith(opts, null, null)); + } + + @Test + void additionalQueryParamsFailsFast() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalQueryParam("param", "val") + .build(); + assertThrows(OpenAIOfficialModelException.class, () -> mapWith(opts, null, null)); + } + } + + // ── History mapping ────────────────────────────────────────── + + @Nested + class HistoryMapping { + + @Test + void systemTextMappedAsEasyInputMessage() { + List messages = + List.of(SystemMessage.builder().content(text("You are helpful.")).build()); + ResponseCreateParams params = mapHistory(baseOptions(), messages); + assertNotNull(params.input()); + } + + @Test + void userTextOnlyMappedAsStringContent() { + List messages = List.of(UserMessage.builder().content(text("Hello!")).build()); + ResponseCreateParams params = mapHistory(baseOptions(), messages); + assertNotNull(params.input()); + } + + @Test + void userMultimodalMappedAsContentList() { + List messages = + List.of( + UserMessage.builder() + .content(text("What is this?")) + .content( + ImageBlock.builder() + .source( + new URLSource( + "https://example.com/img.png")) + .build()) + .build()); + ResponseCreateParams params = mapHistory(baseOptions(), messages); + assertNotNull(params.input()); + } + + @Test + void assistantTextMappedAsAssistantMessage() { + List messages = + List.of(AssistantMessage.builder().content(text("I can help.")).build()); + ResponseCreateParams params = mapHistory(baseOptions(), messages); + assertNotNull(params.input()); + } + + @Test + void assistantToolUseMappedAsFunctionCall() { + List messages = + List.of( + AssistantMessage.builder() + .content( + ToolUseBlock.builder() + .id("call_1") + .name("search") + .input(Map.of("q", "test")) + .content("{\"q\":\"test\"}") + .build()) + .build()); + ResponseCreateParams params = mapHistory(baseOptions(), messages); + assertNotNull(params.input()); + } + + @Test + void toolResultMappedAsFunctionCallOutput() { + List messages = + List.of( + ToolResultMessage.builder() + .content( + ToolResultBlock.builder() + .id("call_1") + .name("search") + .output(text("result")) + .build()) + .build()); + ResponseCreateParams params = mapHistory(baseOptions(), messages); + assertNotNull(params.input()); + } + + @Test + void toolResultWithTextOnlyUsesStringForm() { + List messages = + List.of( + ToolResultMessage.builder() + .content( + ToolResultBlock.builder() + .id("call_1") + .name("search") + .output(text("result")) + .build()) + .build()); + ResponseCreateParams params = mapHistory(baseOptions(), messages); + List input = params.input().orElseThrow().asResponse(); + ResponseInputItem.FunctionCallOutput fco = input.get(0).asFunctionCallOutput(); + assertTrue(fco.output().isString()); + } + + @Test + void toolResultWithImageOutputMappedAsList() { + List messages = + List.of( + ToolResultMessage.builder() + .content( + ToolResultBlock.builder() + .id("call_1") + .name("screenshot") + .output( + ImageBlock.builder() + .source( + new URLSource( + "https://example.com/shot.png")) + .build()) + .build()) + .build()); + ResponseCreateParams params = mapHistory(baseOptions(), messages); + List input = params.input().orElseThrow().asResponse(); + ResponseInputItem.FunctionCallOutput fco = input.get(0).asFunctionCallOutput(); + assertTrue(fco.output().isResponseFunctionCallOutputItemList()); + List outputItems = + fco.output().asResponseFunctionCallOutputItemList(); + assertEquals(1, outputItems.size()); + assertTrue(outputItems.get(0).isInputImage()); + } + + @Test + void toolResultWithMixedTextAndImagePreservesOrder() { + List messages = + List.of( + ToolResultMessage.builder() + .content( + ToolResultBlock.builder() + .id("call_1") + .name("describe") + .output( + List.of( + text("before image"), + ImageBlock.builder() + .source( + new URLSource( + "https://example.com/img.png")) + .build(), + text("after image"))) + .build()) + .build()); + ResponseCreateParams params = mapHistory(baseOptions(), messages); + List input = params.input().orElseThrow().asResponse(); + ResponseInputItem.FunctionCallOutput fco = input.get(0).asFunctionCallOutput(); + assertTrue(fco.output().isResponseFunctionCallOutputItemList()); + List outputItems = + fco.output().asResponseFunctionCallOutputItemList(); + assertEquals(3, outputItems.size()); + assertTrue(outputItems.get(0).isInputText()); + assertTrue(outputItems.get(1).isInputImage()); + assertTrue(outputItems.get(2).isInputText()); + } + + @Test + void toolResultWithDataBlockImageMappedAsList() { + List messages = + List.of( + ToolResultMessage.builder() + .content( + ToolResultBlock.builder() + .id("call_1") + .name("capture") + .output( + DataBlock.builder() + .source( + new URLSource( + "https://example.com/data.png")) + .build()) + .build()) + .build()); + ResponseCreateParams params = mapHistory(baseOptions(), messages); + List input = params.input().orElseThrow().asResponse(); + ResponseInputItem.FunctionCallOutput fco = input.get(0).asFunctionCallOutput(); + assertTrue(fco.output().isResponseFunctionCallOutputItemList()); + List outputItems = + fco.output().asResponseFunctionCallOutputItemList(); + assertEquals(1, outputItems.size()); + assertTrue(outputItems.get(0).isInputImage()); + } + + @Test + void thinkingBlockWithoutEncryptedContentFailsFast() { + List messages = + List.of( + AssistantMessage.builder() + .content( + ThinkingBlock.builder() + .thinking("some reasoning") + .build()) + .build()); + assertThrows( + OpenAIOfficialModelException.class, () -> mapHistory(baseOptions(), messages)); + } + + @Test + void reasoningReplayWithEncryptedContent() { + List messages = + List.of( + AssistantMessage.builder() + .content( + ThinkingBlock.builder() + .thinking("some reasoning") + .build()) + .metadata( + Map.of( + OpenAIOfficialConstants + .MD_REASONING_ENCRYPTED_CONTENT, + "encrypted123")) + .build()); + ResponseCreateParams params = mapHistory(baseOptions(), messages); + assertNotNull(params.input()); + } + + @Test + void assistantMessageWithUnsupportedBlockFailsFast() { + List messages = + List.of( + AssistantMessage.builder() + .content( + ImageBlock.builder() + .source( + new URLSource( + "https://example.com/img.png")) + .build()) + .build()); + assertThrows( + OpenAIOfficialModelException.class, () -> mapHistory(baseOptions(), messages)); + } + + @Test + void emptyToolMessageFailsFast() { + List messages = List.of(ToolResultMessage.builder().build()); + assertThrows( + OpenAIOfficialModelException.class, () -> mapHistory(baseOptions(), messages)); + } + } + + // ── Tool definition mapping ───────────────────────────────── + + @Nested + class ToolsMapping { + + @Test + void basicToolDefinition() { + ToolSchema schema = + ToolSchema.builder() + .name("get_weather") + .description("Get weather") + .parameters(Map.of("type", "object", "properties", Map.of())) + .build(); + ResponseCreateParams params = mapWith(baseOptions(), List.of(schema), null); + assertTrue(params.tools().isPresent()); + assertEquals(1, params.tools().get().size()); + FunctionTool tool = params.tools().get().get(0).asFunction(); + assertEquals("get_weather", tool.name()); + assertTrue(tool.description().isPresent()); + assertEquals("Get weather", tool.description().orElseThrow()); + } + + @Test + void strictFromToolSchema() { + ToolSchema schema = + ToolSchema.builder().name("tool1").description("d").strict(true).build(); + ResponseCreateParams params = mapWith(baseOptions(), List.of(schema), null); + assertTrue(params.tools().isPresent()); + FunctionTool tool = params.tools().get().get(0).asFunction(); + assertTrue(tool.strict().isPresent()); + assertEquals(true, tool.strict().orElseThrow()); + } + + @Test + void strictFallbackToBuilder() { + ToolSchema schema = ToolSchema.builder().name("tool1").description("d").build(); + ResponseCreateParams params = mapWith(baseOptions(), List.of(schema), true); + assertTrue(params.tools().isPresent()); + FunctionTool tool = params.tools().get().get(0).asFunction(); + assertTrue(tool.strict().isPresent()); + assertEquals(true, tool.strict().orElseThrow()); + } + + @Test + void emptyToolsNotSet() { + ResponseCreateParams params = mapWith(baseOptions(), List.of(), null); + assertFalse(params.tools().isPresent()); + } + + @Test + void strictWithEmptyParamsSynthesizesMinimalSchema() { + ToolSchema schema = + ToolSchema.builder().name("tool1").description("d").strict(true).build(); + ResponseCreateParams params = mapWith(baseOptions(), List.of(schema), null); + assertTrue(params.tools().isPresent()); + } + + @Test + void outputSchemaMapped() { + ToolSchema schema = + ToolSchema.builder() + .name("tool1") + .description("d") + .outputSchema(Map.of("type", "object")) + .build(); + ResponseCreateParams params = mapWith(baseOptions(), List.of(schema), null); + assertTrue(params.tools().isPresent()); + } + + @Test + void strictDefaultsToFalseWhenBothNull() { + ToolSchema schema = ToolSchema.builder().name("tool1").description("d").build(); + ResponseCreateParams params = mapWith(baseOptions(), List.of(schema), null); + assertTrue(params.tools().isPresent()); + FunctionTool tool = params.tools().get().get(0).asFunction(); + assertTrue(tool.strict().isPresent()); + assertEquals(false, tool.strict().orElseThrow()); + } + + @Test + void emptyParamsNonStrictOmitsAdditionalProperties() { + ToolSchema schema = + ToolSchema.builder().name("tool1").description("d").strict(false).build(); + ResponseCreateParams params = mapWith(baseOptions(), List.of(schema), null); + FunctionTool tool = params.tools().get().get(0).asFunction(); + // Non-strict empty params should not impose additionalProperties:false + // (only strict=true synthesizes the minimal strict schema) + Map paramMap = + tool.parameters().orElseThrow()._additionalProperties(); + assertFalse(paramMap.containsKey("additionalProperties")); + } + } + + // ── Reasoning validation ────────────────────────────────────── + + @Nested + class ReasoningValidation { + + @Test + void reasoningEffortInvalidValueThrows() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .reasoningEffort("extreme") + .build(); + assertThrows(RuntimeException.class, () -> mapWith(opts, null, null)); + } + + @Test + void reasoningContextAllTurns() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("reasoning.context", "all_turns") + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.reasoning().isPresent()); + assertTrue(params.reasoning().get().context().isPresent()); + assertEquals("all_turns", params.reasoning().get().context().get().asString()); + } + + @Test + void reasoningContextInvalidValueThrows() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("reasoning.context", "every_turn") + .build(); + assertThrows(RuntimeException.class, () -> mapWith(opts, null, null)); + } + + @Test + void reasoningModePro() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("reasoning.mode", "pro") + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.reasoning().isPresent()); + assertTrue(params.reasoning().get().mode().isPresent()); + assertEquals("pro", params.reasoning().get().mode().get().asString()); + } + + @Test + void reasoningModeInvalidValueThrows() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("reasoning.mode", "ultra") + .build(); + assertThrows(RuntimeException.class, () -> mapWith(opts, null, null)); + } + + @Test + void reasoningSummaryOptInRequestSide() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("reasoning.summary", "auto") + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.reasoning().isPresent()); + assertTrue(params.reasoning().get().summary().isPresent()); + assertEquals("auto", params.reasoning().get().summary().get().asString()); + } + + @Test + void reasoningSummaryNoOptInRequestSide() { + ResponseCreateParams params = mapWith(baseOptions(), null, null); + assertFalse(params.reasoning().isPresent()); + } + + @Test + void enableThinkingNotMappedByRequestMapper() { + ResponseCreateParams params = mapWith(baseOptions(), null, null); + assertFalse(params.reasoning().isPresent()); + } + } + + // ── Structured output ───────────────────────────────────────── + + @Nested + class StructuredOutputTests { + + @Test + void responseFormatJsonSchemaStrictTrue() { + JsonSchema schema = + JsonSchema.builder().name("Result").schema(Map.of("type", "object")).build(); + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .responseFormat(ResponseFormat.jsonSchema(schema)) + .build(); + ResponseCreateParams params = + ResponsesRequestMapper.map( + List.of(SystemMessage.builder().content(text("s")).build()), + null, + opts, + null, + true, + ResponsesRequestMapper::mapHistory); + assertTrue(params.text().isPresent()); + assertTrue(params.text().orElseThrow().format().orElseThrow().isJsonSchema()); + assertEquals( + true, + params.text() + .orElseThrow() + .format() + .orElseThrow() + .asJsonSchema() + .strict() + .orElseThrow()); + } + + @Test + void responseFormatJsonSchemaStrictNullNotSet() { + JsonSchema schema = + JsonSchema.builder().name("Result").schema(Map.of("type", "object")).build(); + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .responseFormat(ResponseFormat.jsonSchema(schema)) + .build(); + ResponseCreateParams params = + ResponsesRequestMapper.map( + List.of(SystemMessage.builder().content(text("s")).build()), + null, + opts, + null, + null, + ResponsesRequestMapper::mapHistory); + assertTrue(params.text().isPresent()); + assertTrue(params.text().orElseThrow().format().orElseThrow().isJsonSchema()); + assertFalse( + params.text() + .orElseThrow() + .format() + .orElseThrow() + .asJsonSchema() + .strict() + .isPresent()); + } + + @Test + void strictJsonSchemaOrthogonalToToolStrict() { + ToolSchema toolSchema = + ToolSchema.builder().name("tool1").description("d").strict(false).build(); + JsonSchema jsonSchema = + JsonSchema.builder().name("Result").schema(Map.of("type", "object")).build(); + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .responseFormat(ResponseFormat.jsonSchema(jsonSchema)) + .build(); + ResponseCreateParams params = + ResponsesRequestMapper.map( + List.of(SystemMessage.builder().content(text("s")).build()), + List.of(toolSchema), + opts, + null, + true, + ResponsesRequestMapper::mapHistory); + FunctionTool tool = params.tools().orElseThrow().get(0).asFunction(); + assertEquals(false, tool.strict().orElseThrow()); + assertTrue( + params.text() + .orElseThrow() + .format() + .orElseThrow() + .asJsonSchema() + .strict() + .isPresent()); + } + } + + // ── Prompt cache options detail ─────────────────────────────── + + @Nested + class PromptCacheOptionsDetail { + + @Test + void promptCacheOptionsWithModeAndTtl() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam( + "prompt_cache_options", + Map.of("mode", "explicit", "ttl", "30m")) + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.promptCacheOptions().isPresent()); + assertEquals( + "explicit", + params.promptCacheOptions().orElseThrow().mode().orElseThrow().asString()); + assertEquals( + "30m", + params.promptCacheOptions().orElseThrow().ttl().orElseThrow().asString()); + } + + @Test + void promptCacheOptionsCoexistsWithPromptCacheKey() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("prompt_cache_key", "key1") + .additionalBodyParam("prompt_cache_options", Map.of("mode", "implicit")) + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertEquals("key1", params.promptCacheKey().orElseThrow()); + assertTrue(params.promptCacheOptions().isPresent()); + assertEquals( + "implicit", + params.promptCacheOptions().orElseThrow().mode().orElseThrow().asString()); + } + + @Test + void promptCacheOptionsWithOnlyMode() { + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .additionalBodyParam("prompt_cache_options", Map.of("mode", "explicit")) + .build(); + ResponseCreateParams params = mapWith(opts, null, null); + assertTrue(params.promptCacheOptions().isPresent()); + assertTrue(params.promptCacheOptions().orElseThrow().mode().isPresent()); + assertFalse(params.promptCacheOptions().orElseThrow().ttl().isPresent()); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParserTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParserTest.java new file mode 100644 index 0000000000..61be8b3052 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParserTest.java @@ -0,0 +1,455 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseStatus; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; +import io.agentscope.core.message.ToolCallState; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.ChatUsage; +import io.agentscope.core.model.ModelException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link ResponsesResponseParser}, covering non-streaming response assembly, + * reasoning extraction, text concatenation, tool-use parsing, refusal gating, + * usage mapping, finish-reason mapping, and metadata preservation. + */ +class ResponsesResponseParserTest { + + private static final String MODEL = TestSdkFixtures.MODEL_NAME; + + private static ChatResponse parse(Response response) { + return ResponsesResponseParser.parse(response, MODEL, Instant.now()); + } + + // ── Fixed-order assembly ────────────────────────────────────────────── + + @Test + void interleavedItemsAssembledInFixedOrder() { + Response response = TestSdkFixtures.interleavedResponse(); + ChatResponse result = parse(response); + + List blocks = result.getContent(); + // Expected order: ThinkingBlock -> TextBlock -> ToolUseBlock + assertEquals(3, blocks.size()); + assertInstanceOf(ThinkingBlock.class, blocks.get(0)); + assertInstanceOf(TextBlock.class, blocks.get(1)); + assertInstanceOf(ToolUseBlock.class, blocks.get(2)); + } + + @Test + void thinkingBlockFirstWhenSummaryPresent() { + Response response = TestSdkFixtures.textResponse("hello"); + // Add reasoning before the message by building a custom response + Response reasoningResponse = + TestSdkFixtures.completedResponse( + List.of( + TestSdkFixtures.reasoningItem("summary", null, null), + TestSdkFixtures.messageItem("hello"))); + ChatResponse result = parse(reasoningResponse); + List blocks = result.getContent(); + assertEquals(2, blocks.size()); + assertInstanceOf(ThinkingBlock.class, blocks.get(0)); + assertInstanceOf(TextBlock.class, blocks.get(1)); + } + + @Test + void textBlockSecondWhenNoReasoning() { + Response response = TestSdkFixtures.textResponse("hello"); + ChatResponse result = parse(response); + List blocks = result.getContent(); + assertEquals(1, blocks.size()); + assertInstanceOf(TextBlock.class, blocks.get(0)); + } + + @Test + void toolUseBlockLast() { + Response response = + TestSdkFixtures.completedResponse( + List.of( + TestSdkFixtures.functionCallItem("call_1", "get_weather", "{}"), + TestSdkFixtures.messageItem("result"))); + ChatResponse result = parse(response); + List blocks = result.getContent(); + assertEquals(2, blocks.size()); + assertInstanceOf(TextBlock.class, blocks.get(0)); + assertInstanceOf(ToolUseBlock.class, blocks.get(1)); + } + + // ── Tool use state ──────────────────────────────────────────────────── + + @Test + void toolUseBlockStateIsPending() { + Response response = TestSdkFixtures.functionCallResponse("call_1", "get_weather", "{}"); + ChatResponse result = parse(response); + ToolUseBlock block = (ToolUseBlock) result.getContent().get(0); + assertEquals(ToolCallState.PENDING, block.getState()); + } + + // ── Reasoning extraction ────────────────────────────────────────────── + + @Test + void reasoningSummaryCreatesThinkingBlock() { + Response response = TestSdkFixtures.reasoningResponse("thinking about it", "enc123", null); + ChatResponse result = parse(response); + List blocks = result.getContent(); + assertEquals(1, blocks.size()); + assertInstanceOf(ThinkingBlock.class, blocks.get(0)); + ThinkingBlock tb = (ThinkingBlock) blocks.get(0); + assertEquals("thinking about it", tb.getThinking()); + } + + @Test + void encryptedReasoningNotInThinkingBlock() { + Response response = + TestSdkFixtures.reasoningResponse("summary text", "encrypted_data", null); + ChatResponse result = parse(response); + ThinkingBlock tb = (ThinkingBlock) result.getContent().get(0); + // ThinkingBlock.getThinking() must contain only summary, not encrypted content + assertEquals("summary text", tb.getThinking()); + assertFalse(tb.getThinking().contains("encrypted_data")); + // Encrypted content is in metadata, not in ThinkingBlock + assertEquals( + "encrypted_data", + result.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT)); + } + + @Test + void reasoningTextNotConcatenatedWithSummary() { + Response response = + TestSdkFixtures.reasoningResponse("summary", "enc", "raw reasoning text"); + ChatResponse result = parse(response); + ThinkingBlock tb = (ThinkingBlock) result.getContent().get(0); + // ThinkingBlock contains only summary, not raw reasoning text + assertEquals("summary", tb.getThinking()); + assertFalse(tb.getThinking().contains("raw reasoning text")); + // Raw reasoning text is in metadata + assertEquals( + "raw reasoning text", + result.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_TEXT)); + } + + @Test + void reasoningSummaryWrittenToMetadata() { + Response response = TestSdkFixtures.reasoningResponse("my summary", "enc", null); + ChatResponse result = parse(response); + assertEquals( + "my summary", + result.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_SUMMARY)); + } + + @Test + void reasoningSummaryNotWrittenWhenEmpty() { + Response response = TestSdkFixtures.textResponse("hello"); + ChatResponse result = parse(response); + assertNull(result.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_SUMMARY)); + } + + // ── Text concatenation ─────────────────────────────────────────────── + + @Test + void multipleMessageItemsConcatenatedIntoSingleTextBlock() { + Response response = + TestSdkFixtures.completedResponse( + List.of( + TestSdkFixtures.messageItem("Hello"), + TestSdkFixtures.messageItem("World"))); + ChatResponse result = parse(response); + List blocks = result.getContent(); + assertEquals(1, blocks.size()); + TextBlock tb = (TextBlock) blocks.get(0); + assertEquals("HelloWorld", tb.getText()); + } + + @Test + void multipleOutputTextPartsConcatenated() { + Response response = + TestSdkFixtures.completedResponse( + List.of(TestSdkFixtures.messageItemMultiText("Hello", " ", "World"))); + ChatResponse result = parse(response); + TextBlock tb = (TextBlock) result.getContent().get(0); + assertEquals("Hello World", tb.getText()); + } + + @Test + void emptyTextDoesNotProduceTextBlock() { + Response response = TestSdkFixtures.textResponse(""); + ChatResponse result = parse(response); + assertTrue(result.getContent().isEmpty()); + } + + // ── Tool args parse failure ─────────────────────────────────────────── + + @Test + void invalidJsonArgumentsYieldEmptyInputAndRawContent() { + Response response = + TestSdkFixtures.functionCallResponse("call_1", "get_weather", "not valid json"); + ChatResponse result = parse(response); + ToolUseBlock block = (ToolUseBlock) result.getContent().get(0); + assertEquals("not valid json", block.getContent()); + assertTrue(block.getInput().isEmpty()); + } + + @Test + void validJsonArgumentsParsedIntoInput() { + Response response = + TestSdkFixtures.functionCallResponse( + "call_1", "get_weather", "{\"city\":\"SF\",\"unit\":\"c\"}"); + ChatResponse result = parse(response); + ToolUseBlock block = (ToolUseBlock) result.getContent().get(0); + assertEquals("SF", block.getInput().get("city")); + assertEquals("c", block.getInput().get("unit")); + assertEquals("{\"city\":\"SF\",\"unit\":\"c\"}", block.getContent()); + } + + // ── Refusal ─────────────────────────────────────────────────────────── + + @Test + void refusalThrowsModelException() { + Response response = TestSdkFixtures.refusalResponse("I cannot help with that"); + assertThrows(ModelException.class, () -> parse(response)); + } + + @Test + void refusalExceptionContainsRefusalText() { + Response response = TestSdkFixtures.refusalResponse("content policy violation"); + ModelException ex = assertThrows(ModelException.class, () -> parse(response)); + assertTrue(ex.getMessage().contains("content policy violation")); + } + + @Test + void refusalExceptionHasOpenaiOfficialProvider() { + Response response = TestSdkFixtures.refusalResponse("denied"); + ModelException ex = assertThrows(ModelException.class, () -> parse(response)); + assertEquals("openai-official", ex.getProvider()); + } + + // ── Usage mapping ───────────────────────────────────────────────────── + + @Test + void usageFieldsMappedToChatUsage() { + Response response = TestSdkFixtures.usageResponse(100L, 50L, 20L, 10L); + ChatResponse result = parse(response); + ChatUsage usage = result.getUsage(); + assertNotNull(usage); + assertEquals(100, usage.getInputTokens()); + assertEquals(50, usage.getOutputTokens()); + assertEquals(20, usage.getCachedTokens()); + assertEquals(150, usage.getTotalTokens()); + } + + @Test + void reasoningTokensInMetadata() { + Response response = TestSdkFixtures.usageResponse(100L, 50L, 0L, 15L); + ChatResponse result = parse(response); + assertEquals( + 15, result.getMetadata().get(OpenAIOfficialConstants.MD_USAGE_REASONING_TOKENS)); + } + + @Test + void usageAbsentYieldsNullChatUsage() { + Response response = TestSdkFixtures.textResponse("hello"); + ChatResponse result = parse(response); + assertNull(result.getUsage()); + } + + // ── Finish reason mapping ───────────────────────────────────────────── + + @Test + void statusCompletedTransmittedAsFinishReason() { + Response response = TestSdkFixtures.textResponse("hello"); + ChatResponse result = parse(response); + assertEquals("completed", result.getFinishReason()); + } + + @Test + void statusIncompleteTransmittedAsFinishReason() { + Response response = + TestSdkFixtures.response( + List.of(TestSdkFixtures.messageItem("partial")), + ResponseStatus.INCOMPLETE, + null); + ChatResponse result = parse(response); + assertEquals("incomplete", result.getFinishReason()); + } + + @Test + void statusFailedTransmittedAsFinishReason() { + Response response = + TestSdkFixtures.response( + List.of(TestSdkFixtures.messageItem("")), ResponseStatus.FAILED, null); + ChatResponse result = parse(response); + assertEquals("failed", result.getFinishReason()); + } + + @Test + void statusAbsentYieldsNullFinishReason() { + Response response = + TestSdkFixtures.response(List.of(TestSdkFixtures.messageItem("hello")), null, null); + ChatResponse result = parse(response); + assertNull(result.getFinishReason()); + } + + // ── Metadata preservation ──────────────────────────────────────────── + + @Test + void responseIdInMetadata() { + Response response = TestSdkFixtures.textResponse("hello"); + ChatResponse result = parse(response); + assertEquals( + "resp_test_123", result.getMetadata().get(OpenAIOfficialConstants.MD_RESPONSE_ID)); + } + + @Test + void responseStatusInMetadata() { + Response response = TestSdkFixtures.textResponse("hello"); + ChatResponse result = parse(response); + assertEquals( + "completed", result.getMetadata().get(OpenAIOfficialConstants.MD_RESPONSE_STATUS)); + } + + @Test + void createdAtInMetadata() { + Response response = TestSdkFixtures.textResponse("hello"); + ChatResponse result = parse(response); + Object createdAt = result.getMetadata().get(OpenAIOfficialConstants.MD_RESPONSE_CREATED_AT); + assertTrue( + createdAt instanceof Double, + "createdAt should be Double, got " + createdAt.getClass()); + assertEquals(1697000000.5, createdAt); + } + + @Test + void completedAtInMetadata() { + Response response = TestSdkFixtures.fullMetadataResponse(); + ChatResponse result = parse(response); + Object completedAt = + result.getMetadata().get(OpenAIOfficialConstants.MD_RESPONSE_COMPLETED_AT); + assertTrue( + completedAt instanceof Double, + "completedAt should be Double, got " + completedAt.getClass()); + assertEquals(1697000001.5, completedAt); + } + + @Test + void serviceTierInMetadata() { + Response response = TestSdkFixtures.fullMetadataResponse(); + ChatResponse result = parse(response); + assertEquals( + "priority", + result.getMetadata().get(OpenAIOfficialConstants.MD_RESPONSE_SERVICE_TIER)); + } + + @Test + void incompleteReasonInMetadata() { + Response response = TestSdkFixtures.fullMetadataResponse(); + ChatResponse result = parse(response); + assertEquals( + "max_output_tokens", + result.getMetadata().get(OpenAIOfficialConstants.MD_RESPONSE_INCOMPLETE_REASON)); + } + + @Test + void errorInMetadata() { + Response response = TestSdkFixtures.fullMetadataResponse(); + ChatResponse result = parse(response); + Object error = result.getMetadata().get(OpenAIOfficialConstants.MD_RESPONSE_ERROR); + assertTrue(error instanceof Map); + @SuppressWarnings("unchecked") + Map errorMap = (Map) error; + assertEquals("Something went wrong", errorMap.get("message")); + assertEquals("server_error", errorMap.get("code")); + } + + @Test + void metadataValuesAreJsonCompatible() { + Response response = TestSdkFixtures.usageResponse(100L, 50L, 20L, 10L); + ChatResponse result = parse(response); + for (Map.Entry entry : result.getMetadata().entrySet()) { + Object value = entry.getValue(); + assertTrue( + value instanceof String + || value instanceof Number + || value instanceof Map + || value instanceof List); + } + } + + // ── Unknown output item handling ───────────────────────────────────── + + @Test + void emptyOutputProducesNoContentBlocks() { + Response response = TestSdkFixtures.completedResponse(List.of()); + ChatResponse result = parse(response); + assertTrue(result.getContent().isEmpty()); + } + + @Test + void unknownOutputItemTypeSilentlyIgnored() { + Response response = + TestSdkFixtures.completedResponse( + List.of( + TestSdkFixtures.messageItem("hello"), + TestSdkFixtures.fileSearchItem())); + ChatResponse result = parse(response); + // Only the message item produces a TextBlock; file_search_call is ignored + assertEquals(1, result.getContent().size()); + assertInstanceOf(TextBlock.class, result.getContent().get(0)); + } + + @Test + void reasoningOnlyProducesThinkingBlock() { + Response response = + TestSdkFixtures.completedResponse( + List.of(TestSdkFixtures.reasoningItem("summary", "enc", null))); + ChatResponse result = parse(response); + assertEquals(1, result.getContent().size()); + assertInstanceOf(ThinkingBlock.class, result.getContent().get(0)); + } + + // ── Response id ─────────────────────────────────────────────────────── + + @Test + void responseIdTransmittedToChatResponseId() { + Response response = TestSdkFixtures.textResponse("hello"); + ChatResponse result = parse(response); + assertEquals("resp_test_123", result.getId()); + } + + private static void assertInstanceOf(Class expected, Object actual) { + assertTrue( + expected.isInstance(actual), + "Expected " + + expected.getSimpleName() + + " but got " + + (actual != null ? actual.getClass().getSimpleName() : "null")); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssemblerTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssemblerTest.java new file mode 100644 index 0000000000..05b04f3325 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssemblerTest.java @@ -0,0 +1,568 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.openai.core.http.StreamResponse; +import com.openai.models.responses.ResponseFunctionToolCall; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseStatus; +import com.openai.models.responses.ResponseStreamEvent; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; +import io.agentscope.core.message.ToolCallState; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.ChatUsage; +import io.agentscope.core.model.ModelException; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link ResponsesStreamingAssembler}, covering text deltas, reasoning + * summary/text separation, tool-call fragment assembly, terminal re-extraction, + * refusal gating, usage mapping, and error event handling. + */ +class ResponsesStreamingAssemblerTest { + + private static final String MODEL = TestSdkFixtures.MODEL_NAME; + + private static List assemble(List events) { + StreamResponse stream = TestSdkFixtures.streamOf(events); + return ResponsesStreamingAssembler.assemble(stream, MODEL, Instant.now()) + .collectList() + .block(); + } + + private static ChatResponse terminalBlock(List results) { + assertFalse(results.isEmpty(), "Expected at least one result"); + return results.get(results.size() - 1); + } + + // ── Text streaming ──────────────────────────────────────────────────── + + @Test + void textDeltaProducesTextBlock() { + List events = + List.of( + TestSdkFixtures.textDeltaEvent("Hello", "msg_001"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.completedResponse(List.of()))); + List results = assemble(events); + + // First block is a text delta + assertEquals(2, results.size()); + ChatResponse textBlock = results.get(0); + assertNull(textBlock.getUsage()); + assertEquals(1, textBlock.getContent().size()); + assertInstanceOf(TextBlock.class, textBlock.getContent().get(0)); + assertEquals("Hello", ((TextBlock) textBlock.getContent().get(0)).getText()); + } + + @Test + void textDoneDoesNotProduceExtraBlock() { + List events = + List.of( + TestSdkFixtures.textDeltaEvent("Hi", "msg_001"), + TestSdkFixtures.noopEvent(), // represents output_text.done + TestSdkFixtures.completedEvent( + TestSdkFixtures.completedResponse(List.of()))); + List results = assemble(events); + + // Only 1 text delta + 1 terminal = 2 blocks (text.done produced nothing) + assertEquals(2, results.size()); + assertInstanceOf(TextBlock.class, results.get(0).getContent().get(0)); + } + + @Test + void multipleTextDeltasProduceMultipleBlocks() { + List events = + List.of( + TestSdkFixtures.textDeltaEvent("Hello ", "msg_001"), + TestSdkFixtures.textDeltaEvent("World", "msg_001"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.completedResponse(List.of()))); + List results = assemble(events); + + assertEquals(3, results.size()); + assertEquals("Hello ", ((TextBlock) results.get(0).getContent().get(0)).getText()); + assertEquals("World", ((TextBlock) results.get(1).getContent().get(0)).getText()); + } + + // ── Block order and intermediate blocks ──────────────────────────────── + + @Test + void blocksEmittedInEventOrder() { + List events = + List.of( + TestSdkFixtures.textDeltaEvent("text", "msg_001"), + TestSdkFixtures.reasoningSummaryDeltaEvent("thinking", "rs_001"), + TestSdkFixtures.functionCallArgsDeltaEvent("{\"a\"", "fc_001"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.completedResponse(List.of()))); + List results = assemble(events); + + // Order: TextBlock, ThinkingBlock, ToolUseBlock, terminal + assertEquals(4, results.size()); + assertInstanceOf(TextBlock.class, results.get(0).getContent().get(0)); + assertInstanceOf(ThinkingBlock.class, results.get(1).getContent().get(0)); + assertInstanceOf(ToolUseBlock.class, results.get(2).getContent().get(0)); + + // Terminal block has empty content + assertTrue(terminalBlock(results).getContent().isEmpty()); + } + + @Test + void intermediateBlockUsageIsNull() { + List events = + List.of( + TestSdkFixtures.textDeltaEvent("Hi", "msg_001"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.usageResponse(100L, 50L, 0L, 0L))); + List results = assemble(events); + + assertEquals(2, results.size()); + assertNull(results.get(0).getUsage(), "Intermediate block usage must be null"); + assertNotNull(results.get(1).getUsage(), "Terminal block usage must be non-null"); + } + + // ── Reasoning summary ────────────────────────────────────────────────── + + @Test + void reasoningSummaryDeltaProducesThinkingBlock() { + List events = + List.of( + TestSdkFixtures.reasoningSummaryDeltaEvent("thinking hard", "rs_001"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.completedResponse(List.of()))); + List results = assemble(events); + + assertEquals(2, results.size()); + assertInstanceOf(ThinkingBlock.class, results.get(0).getContent().get(0)); + assertEquals( + "thinking hard", + ((ThinkingBlock) results.get(0).getContent().get(0)).getThinking()); + } + + // ── Reasoning text delta separation ──────────────────────────────────── + + @Test + void reasoningTextDeltaWrittenToTerminalMetadata() { + List events = + List.of( + TestSdkFixtures.reasoningTextDeltaEvent("raw reasoning", "rs_001"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.completedResponse(List.of()))); + List results = assemble(events); + + // reasoning_text.delta produces no intermediate block + assertEquals(1, results.size()); + assertEquals( + "raw reasoning", + terminalBlock(results) + .getMetadata() + .get(OpenAIOfficialConstants.MD_REASONING_TEXT)); + } + + @Test + void reasoningSummaryAndTextDeltaNotConcatenated() { + List events = + List.of( + TestSdkFixtures.reasoningSummaryDeltaEvent("summary", "rs_001"), + TestSdkFixtures.reasoningTextDeltaEvent("raw", "rs_001"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.completedResponse(List.of()))); + List results = assemble(events); + + // ThinkingBlock contains only "summary", not "raw" + ThinkingBlock tb = (ThinkingBlock) results.get(0).getContent().get(0); + assertEquals("summary", tb.getThinking()); + assertFalse(tb.getThinking().contains("raw")); + + // Terminal metadata has both + ChatResponse terminal = terminalBlock(results); + assertEquals("raw", terminal.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_TEXT)); + } + + @Test + void reasoningDoneEventsProduceNoBlocks() { + List events = + List.of( + TestSdkFixtures.reasoningSummaryDeltaEvent("summary", "rs_001"), + TestSdkFixtures.noopEvent(), // reasoning_summary_text.done + TestSdkFixtures.reasoningTextDeltaEvent("raw", "rs_001"), + TestSdkFixtures.noopEvent(), // reasoning_text.done + TestSdkFixtures.completedEvent( + TestSdkFixtures.completedResponse(List.of()))); + List results = assemble(events); + + // Only: 1 ThinkingBlock (summary delta) + 1 terminal = 2 + // done events produced no extra blocks + assertEquals(2, results.size()); + } + + // ── Tool call streaming ─────────────────────────────────────────────── + + @Test + void toolArgsDeltaProducesFragmentToolUseBlock() { + ResponseOutputItem item = + ResponseOutputItem.ofFunctionCall( + ResponseFunctionToolCall.builder() + .arguments("{}") + .callId("call_123") + .name("get_weather") + .id("fc_001") + .build()); + List events = + List.of( + TestSdkFixtures.outputItemAddedEvent(item), + TestSdkFixtures.functionCallArgsDeltaEvent("{\"city\"", "fc_001"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.completedResponse(List.of()))); + List results = assemble(events); + + assertEquals(2, results.size()); + ToolUseBlock block = (ToolUseBlock) results.get(0).getContent().get(0); + assertEquals("call_123", block.getId()); + assertEquals("get_weather", block.getName()); + assertEquals("{\"city\"", block.getContent()); + assertTrue(block.getInput().isEmpty()); + assertEquals(ToolCallState.PENDING, block.getState()); + } + + @Test + void toolArgsDoneDoesNotProduceCompleteBlock() { + ResponseOutputItem item = + ResponseOutputItem.ofFunctionCall( + ResponseFunctionToolCall.builder() + .arguments("{}") + .callId("call_123") + .name("get_weather") + .id("fc_001") + .build()); + List events = + List.of( + TestSdkFixtures.outputItemAddedEvent(item), + TestSdkFixtures.functionCallArgsDeltaEvent("{\"city\"", "fc_001"), + TestSdkFixtures.functionCallArgsDoneEvent( + "{\"city\":\"SF\"}", "fc_001", "get_weather"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.completedResponse(List.of()))); + List results = assemble(events); + + // delta + terminal = 2 blocks (done produced nothing) + assertEquals(2, results.size()); + assertInstanceOf(ToolUseBlock.class, results.get(0).getContent().get(0)); + } + + @Test + void deltaThenDoneNoDuplicateToolArgs() { + ResponseOutputItem item = + ResponseOutputItem.ofFunctionCall( + ResponseFunctionToolCall.builder() + .arguments("{}") + .callId("call_123") + .name("get_weather") + .id("fc_001") + .build()); + List events = + List.of( + TestSdkFixtures.outputItemAddedEvent(item), + TestSdkFixtures.functionCallArgsDeltaEvent("{\"city\"", "fc_001"), + TestSdkFixtures.functionCallArgsDoneEvent( + "{\"city\":\"SF\"}", "fc_001", "get_weather"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.completedResponse(List.of()))); + List results = assemble(events); + + ToolUseBlock block = (ToolUseBlock) results.get(0).getContent().get(0); + assertEquals("{\"city\"", block.getContent(), "Fragment content should be delta, not done"); + } + + @Test + void toolArgsDeltaNoItemAddedUsesPlaceholder() { + List events = + List.of( + TestSdkFixtures.functionCallArgsDeltaEvent("{\"a\"", "fc_unknown"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.completedResponse(List.of()))); + List results = assemble(events); + + assertEquals(2, results.size()); + ToolUseBlock block = (ToolUseBlock) results.get(0).getContent().get(0); + assertEquals("", block.getId(), "Fragment id should be empty when item not registered"); + assertEquals( + "__fragment__", + block.getName(), + "Fragment name should be placeholder when item not registered"); + } + + @Test + void multipleToolCallsRoutedByItemId() { + ResponseOutputItem item1 = + ResponseOutputItem.ofFunctionCall( + ResponseFunctionToolCall.builder() + .arguments("{}") + .callId("call_1") + .name("tool_a") + .id("fc_1") + .build()); + ResponseOutputItem item2 = + ResponseOutputItem.ofFunctionCall( + ResponseFunctionToolCall.builder() + .arguments("{}") + .callId("call_2") + .name("tool_b") + .id("fc_2") + .build()); + List events = + List.of( + TestSdkFixtures.outputItemAddedEvent(item1), + TestSdkFixtures.outputItemAddedEvent(item2), + TestSdkFixtures.functionCallArgsDeltaEvent("{\"a\"", "fc_1"), + TestSdkFixtures.functionCallArgsDeltaEvent("{\"b\"", "fc_2"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.completedResponse(List.of()))); + List results = assemble(events); + + assertEquals(3, results.size()); + ToolUseBlock block1 = (ToolUseBlock) results.get(0).getContent().get(0); + ToolUseBlock block2 = (ToolUseBlock) results.get(1).getContent().get(0); + assertEquals("call_1", block1.getId()); + assertEquals("tool_a", block1.getName()); + assertEquals("call_2", block2.getId()); + assertEquals("tool_b", block2.getName()); + } + + // ── Terminal re-extraction ──────────────────────────────────────────── + + @Test + void terminalReextractsEncryptedContentAndSummary() { + List events = + List.of( + TestSdkFixtures.reasoningSummaryDeltaEvent("my summary", "rs_001"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.reasoningResponse( + "my summary", "enc_data_123", null))); + List results = assemble(events); + + ChatResponse terminal = terminalBlock(results); + assertEquals( + "enc_data_123", + terminal.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT)); + assertEquals( + "my summary", + terminal.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_SUMMARY)); + } + + @Test + void encryptedContentNotInThinkingBlock() { + List events = + List.of( + TestSdkFixtures.reasoningSummaryDeltaEvent("summary", "rs_001"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.reasoningResponse("summary", "enc_data", null))); + List results = assemble(events); + + ThinkingBlock tb = (ThinkingBlock) results.get(0).getContent().get(0); + assertEquals("summary", tb.getThinking()); + assertFalse(tb.getThinking().contains("enc_data")); + } + + @Test + void terminalReextractsResponseMetadata() { + List events = + List.of(TestSdkFixtures.completedEvent(TestSdkFixtures.fullMetadataResponse())); + List results = assemble(events); + + ChatResponse terminal = terminalBlock(results); + assertEquals( + "resp_test_123", + terminal.getMetadata().get(OpenAIOfficialConstants.MD_RESPONSE_ID)); + assertEquals( + "incomplete", + terminal.getMetadata().get(OpenAIOfficialConstants.MD_RESPONSE_STATUS)); + assertEquals("incomplete", terminal.getFinishReason()); + assertEquals( + "max_output_tokens", + terminal.getMetadata().get(OpenAIOfficialConstants.MD_RESPONSE_INCOMPLETE_REASON)); + assertEquals( + "priority", + terminal.getMetadata().get(OpenAIOfficialConstants.MD_RESPONSE_SERVICE_TIER)); + } + + @Test + void terminalValueOverridesAccumulatedReasoning() { + // Delta accumulates "delta summary", but terminal response has "terminal summary" + List events = + List.of( + TestSdkFixtures.reasoningSummaryDeltaEvent("delta summary", "rs_001"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.reasoningResponse( + "terminal summary", "enc", null))); + List results = assemble(events); + + ChatResponse terminal = terminalBlock(results); + assertEquals( + "terminal summary", + terminal.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_SUMMARY), + "Terminal re-extracted value should override accumulated delta"); + } + + @Test + void cancelBeforeTerminalProducesNoTerminalBlock() { + // Empty event list (simulates cancel before any terminal event) + List events = List.of(); + List results = assemble(events); + + assertTrue(results.isEmpty(), "No terminal block when stream ends before terminal event"); + } + + // ── Refusal ─────────────────────────────────────────────────────────── + + @Test + void refusalInTerminalThrowsModelException() { + List events = + List.of( + TestSdkFixtures.textDeltaEvent("Some text", "msg_001"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.refusalResponse("content policy violation"))); + ModelException ex = assertThrows(ModelException.class, () -> assemble(events)); + assertTrue(ex.getMessage().contains("content policy violation")); + assertEquals("openai-official", ex.getProvider()); + } + + // ── Usage ───────────────────────────────────────────────────────────── + + @Test + void terminalBlockCarriesUsage() { + List events = + List.of( + TestSdkFixtures.textDeltaEvent("Hi", "msg_001"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.usageResponse(100L, 50L, 20L, 10L))); + List results = assemble(events); + + ChatResponse terminal = terminalBlock(results); + ChatUsage usage = terminal.getUsage(); + assertNotNull(usage); + assertEquals(100, usage.getInputTokens()); + assertEquals(50, usage.getOutputTokens()); + assertEquals(20, usage.getCachedTokens()); + assertEquals(150, usage.getTotalTokens()); + assertEquals( + 10, terminal.getMetadata().get(OpenAIOfficialConstants.MD_USAGE_REASONING_TOKENS)); + } + + @Test + void usageAbsentYieldsNullInTerminal() { + List events = + List.of(TestSdkFixtures.completedEvent(TestSdkFixtures.textResponse("hello"))); + List results = assemble(events); + + ChatResponse terminal = terminalBlock(results); + assertNull(terminal.getUsage()); + } + + // ── Failed and error events ─────────────────────────────────────────── + + @Test + void failedEventThrowsModelException() { + List events = + List.of(TestSdkFixtures.failedEvent(TestSdkFixtures.fullMetadataResponse())); + assertThrows(ModelException.class, () -> assemble(events)); + } + + @Test + void errorEventThrowsModelException() { + List events = + List.of(TestSdkFixtures.errorEvent("stream error occurred", "ERR_001")); + ModelException ex = assertThrows(ModelException.class, () -> assemble(events)); + assertTrue(ex.getMessage().contains("stream error occurred")); + assertTrue(ex.getMessage().contains("ERR_001")); + assertEquals("openai-official", ex.getProvider()); + } + + // ── Stream close on completion ───────────────────────────────────────── + + @Test + void streamClosedOnCompletion() { + List events = + List.of( + TestSdkFixtures.completedEvent( + TestSdkFixtures.completedResponse(List.of()))); + StreamResponse stream = TestSdkFixtures.streamOf(events); + ResponsesStreamingAssembler.assemble(stream, MODEL, Instant.now()).collectList().block(); + // If the stream wasn't closed, the test would hang on certain platforms + // because the underlying Stream resource leaks. No assertion needed beyond + // successful completion. + } + + // ── No opt-in: no ThinkingBlock ────────────────────────────────── + + @Test + void noOptinProducesNoThinkingBlock() { + List events = + List.of( + TestSdkFixtures.reasoningTextDeltaEvent("raw reasoning", "rs_001"), + TestSdkFixtures.textDeltaEvent("Hello", "msg_001"), + TestSdkFixtures.completedEvent( + TestSdkFixtures.completedResponse(List.of()))); + List results = assemble(events); + for (ChatResponse result : results) { + for (Object block : result.getContent()) { + assertFalse( + block instanceof ThinkingBlock, + "No ThinkingBlock should be created without summary opt-in"); + } + } + } + + // ── Incomplete event ───────────────────────────────────────────── + + @Test + void incompleteEventCarriesUsageAndFinishReason() { + List events = + List.of( + TestSdkFixtures.incompleteEvent( + TestSdkFixtures.response( + List.of(TestSdkFixtures.messageItem("partial")), + ResponseStatus.INCOMPLETE, + TestSdkFixtures.usage(100L, 50L, 0L, 0L)))); + List results = assemble(events); + ChatResponse terminal = terminalBlock(results); + assertEquals("incomplete", terminal.getFinishReason()); + assertNotNull(terminal.getUsage()); + assertEquals(100, terminal.getUsage().getInputTokens()); + assertEquals(50, terminal.getUsage().getOutputTokens()); + } + + private static void assertInstanceOf(Class expected, Object actual) { + assertTrue( + expected.isInstance(actual), + "Expected " + + expected.getSimpleName() + + " but got " + + (actual != null ? actual.getClass().getSimpleName() : "null")); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/TestSdkFixtures.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/TestSdkFixtures.java new file mode 100644 index 0000000000..e7bee1eecc --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/TestSdkFixtures.java @@ -0,0 +1,582 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.openai.core.http.Headers; +import com.openai.core.http.StreamResponse; +import com.openai.errors.BadRequestException; +import com.openai.errors.InternalServerException; +import com.openai.errors.NotFoundException; +import com.openai.errors.OpenAIInvalidDataException; +import com.openai.errors.OpenAIIoException; +import com.openai.errors.OpenAIRetryableException; +import com.openai.errors.PermissionDeniedException; +import com.openai.errors.RateLimitException; +import com.openai.errors.SseException; +import com.openai.errors.UnauthorizedException; +import com.openai.errors.UnexpectedStatusCodeException; +import com.openai.errors.UnprocessableEntityException; +import com.openai.models.ErrorObject; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCompletedEvent; +import com.openai.models.responses.ResponseError; +import com.openai.models.responses.ResponseErrorEvent; +import com.openai.models.responses.ResponseFailedEvent; +import com.openai.models.responses.ResponseFileSearchToolCall; +import com.openai.models.responses.ResponseFunctionCallArgumentsDeltaEvent; +import com.openai.models.responses.ResponseFunctionCallArgumentsDoneEvent; +import com.openai.models.responses.ResponseFunctionToolCall; +import com.openai.models.responses.ResponseIncompleteEvent; +import com.openai.models.responses.ResponseOutputItem; +import com.openai.models.responses.ResponseOutputItemAddedEvent; +import com.openai.models.responses.ResponseOutputMessage; +import com.openai.models.responses.ResponseOutputRefusal; +import com.openai.models.responses.ResponseOutputText; +import com.openai.models.responses.ResponseReasoningItem; +import com.openai.models.responses.ResponseReasoningSummaryTextDeltaEvent; +import com.openai.models.responses.ResponseReasoningTextDeltaEvent; +import com.openai.models.responses.ResponseStatus; +import com.openai.models.responses.ResponseStreamEvent; +import com.openai.models.responses.ResponseTextDeltaEvent; +import com.openai.models.responses.ResponseUsage; +import com.openai.models.responses.ToolChoiceOptions; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeoutException; +import java.util.stream.Stream; + +/** + * Shared test fixture factory for constructing SDK objects. + * + *

Provides helper methods to create the various SDK exception types needed by + * {@link OpenAIErrorTranslatorTest} and later test classes, and helper methods to + * build SDK {@link Response} objects for response parser tests. All SDK exception + * builders require a {@link Headers} object; this factory supplies an empty one by + * default. + */ +public final class TestSdkFixtures { + + public static final String MODEL_NAME = "gpt-4o"; + private static final String DEFAULT_RESPONSE_ID = "resp_test_123"; + private static final String DEFAULT_MSG_ID = "msg_test_001"; + private static final double DEFAULT_CREATED_AT = 1697000000.5; + private static final double DEFAULT_COMPLETED_AT = 1697000001.5; + + private TestSdkFixtures() {} + + // ── Typed HTTP exceptions ────────────────────────────────────────────── + + public static BadRequestException badRequest(String message) { + return BadRequestException.builder() + .headers(emptyHeaders()) + .error(errorWithMessage(message)) + .build(); + } + + public static UnauthorizedException unauthorized(String message) { + return UnauthorizedException.builder() + .headers(emptyHeaders()) + .error(errorWithMessage(message)) + .build(); + } + + public static PermissionDeniedException permissionDenied(String message) { + return PermissionDeniedException.builder() + .headers(emptyHeaders()) + .error(errorWithMessage(message)) + .build(); + } + + public static NotFoundException notFound(String message) { + return NotFoundException.builder() + .headers(emptyHeaders()) + .error(errorWithMessage(message)) + .build(); + } + + public static UnprocessableEntityException unprocessableEntity(String message) { + return UnprocessableEntityException.builder() + .headers(emptyHeaders()) + .error(errorWithMessage(message)) + .build(); + } + + public static RateLimitException rateLimit(String message) { + return RateLimitException.builder() + .headers(emptyHeaders()) + .error(errorWithMessage(message)) + .build(); + } + + // ── Variable-status exceptions ───────────────────────────────────────── + + public static InternalServerException internalServer(int statusCode, String message) { + return InternalServerException.builder() + .statusCode(statusCode) + .headers(emptyHeaders()) + .error(errorWithMessage(message)) + .build(); + } + + public static UnexpectedStatusCodeException unexpectedStatusCode( + int statusCode, String message) { + return UnexpectedStatusCodeException.builder() + .statusCode(statusCode) + .headers(emptyHeaders()) + .error(errorWithMessage(message)) + .build(); + } + + public static SseException sseException(int statusCode, String message) { + return SseException.builder() + .statusCode(statusCode) + .headers(emptyHeaders()) + .error(errorWithMessage(message)) + .build(); + } + + // ── Non-HTTP exceptions ──────────────────────────────────────────────── + + public static OpenAIIoException ioException(String message) { + return new OpenAIIoException(message); + } + + public static OpenAIRetryableException retryableException(String message) { + return new OpenAIRetryableException(message); + } + + public static OpenAIInvalidDataException invalidDataException(String message) { + return new OpenAIInvalidDataException(message); + } + + public static TimeoutException timeoutException(String message) { + return new TimeoutException(message); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + public static Headers emptyHeaders() { + return Headers.builder().build(); + } + + /** + * Creates a Headers object with an X-Should-Retry header value. + * + * @param shouldRetry the X-Should-Retry value ("true" or "false") + * @return headers containing the X-Should-Retry entry + */ + public static Headers headersWithShouldRetry(String shouldRetry) { + return Headers.builder().put("X-Should-Retry", shouldRetry).build(); + } + + /** + * Creates an {@link ErrorObject} whose message field is set to the given string, + * so that typed HTTP exceptions (e.g. {@link BadRequestException}) carry the + * message in their own {@code getMessage()}. + * + *

All four required fields are populated to satisfy the builder's + * {@code checkRequired} validation; code/param/type use placeholder values. + * + * @param message the error message to embed in the ErrorObject + * @return a fully built ErrorObject + */ + private static ErrorObject errorWithMessage(String message) { + return ErrorObject.builder() + .message(message) + .code("test_error") + .param("test_param") + .type("invalid_request_error") + .build(); + } + + // ── Response builders ────────────────────────────────────────────────── + + /** + * Builds a Response with the given output items, status, and optional usage. + * + * @param output the output items + * @param status the response status (e.g. {@link ResponseStatus#COMPLETED}), or null + * @param usage the usage object, or null + * @return a fully built Response + */ + public static Response response( + List output, ResponseStatus status, ResponseUsage usage) { + return response(output, status, usage, null, null, null, null); + } + + public static Response response( + List output, + ResponseStatus status, + ResponseUsage usage, + Double completedAt, + Response.ServiceTier serviceTier, + Response.IncompleteDetails incompleteDetails, + ResponseError error) { + Response.Builder builder = + Response.builder() + .id(DEFAULT_RESPONSE_ID) + .createdAt(DEFAULT_CREATED_AT) + .output(output) + .error(Optional.empty()) + .incompleteDetails(Optional.empty()) + .instructions("test") + .metadata(Optional.empty()) + .model("gpt-4o") + .parallelToolCalls(true) + .temperature(Optional.empty()) + .toolChoice(ToolChoiceOptions.AUTO) + .tools(List.of()) + .topP(Optional.empty()); + if (status != null) { + builder.status(status); + } + if (usage != null) { + builder.usage(usage); + } + if (completedAt != null) { + builder.completedAt(completedAt); + } + if (serviceTier != null) { + builder.serviceTier(serviceTier); + } + if (incompleteDetails != null) { + builder.incompleteDetails(incompleteDetails); + } + if (error != null) { + builder.error(error); + } + return builder.build(); + } + + /** Builds a basic completed Response with the given output items (no usage). */ + public static Response completedResponse(List output) { + return response(output, ResponseStatus.COMPLETED, null); + } + + /** Builds a Response with a single message containing one OutputText part. */ + public static Response textResponse(String text) { + return completedResponse(List.of(messageItem(text))); + } + + /** Builds a Response with a single message containing a Refusal part. */ + public static Response refusalResponse(String refusal) { + ResponseOutputMessage msg = + ResponseOutputMessage.builder() + .id(DEFAULT_MSG_ID) + .addContent(ResponseOutputRefusal.builder().refusal(refusal).build()) + .status(ResponseOutputMessage.Status.COMPLETED) + .build(); + return completedResponse(List.of(ResponseOutputItem.ofMessage(msg))); + } + + /** Builds a Response with a single reasoning item. */ + public static Response reasoningResponse( + String summaryText, String encryptedContent, String reasoningText) { + return completedResponse( + List.of(reasoningItem(summaryText, encryptedContent, reasoningText))); + } + + /** Builds a Response with a single function call. */ + public static Response functionCallResponse(String callId, String name, String arguments) { + return completedResponse(List.of(functionCallItem(callId, name, arguments))); + } + + /** Builds a Response with interleaved output items. */ + public static Response interleavedResponse() { + List items = new ArrayList<>(); + items.add(messageItem("Hello")); + items.add(reasoningItem("thinking", "encrypted123", "raw reasoning")); + items.add(messageItem("World")); + items.add(functionCallItem("call_1", "get_weather", "{\"city\":\"SF\"}")); + return completedResponse(items); + } + + /** Builds a Response with usage data. */ + public static Response usageResponse(long input, long output, long cached, long reasoning) { + ResponseUsage usage = usage(input, output, cached, reasoning); + return response(List.of(messageItem("test")), ResponseStatus.COMPLETED, usage); + } + + /** Builds a completed Response with completedAt, serviceTier, incompleteDetails, and error set. */ + public static Response fullMetadataResponse() { + return response( + List.of(messageItem("hello")), + ResponseStatus.INCOMPLETE, + null, + DEFAULT_COMPLETED_AT, + Response.ServiceTier.PRIORITY, + Response.IncompleteDetails.builder() + .reason(Response.IncompleteDetails.Reason.MAX_OUTPUT_TOKENS) + .build(), + ResponseError.builder() + .code(ResponseError.Code.of("server_error")) + .message("Something went wrong") + .build()); + } + + // ── Output item builders ──────────────────────────────────────────────── + + /** Builds a ResponseOutputMessage with a single OutputText content part. */ + public static ResponseOutputItem messageItem(String text) { + ResponseOutputMessage msg = + ResponseOutputMessage.builder() + .id(DEFAULT_MSG_ID) + .addContent( + ResponseOutputText.builder() + .text(text) + .annotations(List.of()) + .build()) + .status(ResponseOutputMessage.Status.COMPLETED) + .build(); + return ResponseOutputItem.ofMessage(msg); + } + + /** Builds a ResponseOutputMessage with multiple OutputText content parts. */ + public static ResponseOutputItem messageItemMultiText(String... texts) { + ResponseOutputMessage.Builder builder = + ResponseOutputMessage.builder() + .id(DEFAULT_MSG_ID) + .status(ResponseOutputMessage.Status.COMPLETED); + for (String text : texts) { + builder.addContent( + ResponseOutputText.builder().text(text).annotations(List.of()).build()); + } + return ResponseOutputItem.ofMessage(builder.build()); + } + + /** Builds a ResponseReasoningItem with summary, encrypted content, and reasoning text. */ + public static ResponseOutputItem reasoningItem( + String summaryText, String encryptedContent, String reasoningText) { + ResponseReasoningItem.Builder builder = ResponseReasoningItem.builder().id("rs_test_001"); + if (summaryText != null) { + builder.addSummary(ResponseReasoningItem.Summary.builder().text(summaryText).build()); + } + if (encryptedContent != null) { + builder.encryptedContent(encryptedContent); + } + if (reasoningText != null) { + builder.content( + List.of(ResponseReasoningItem.Content.builder().text(reasoningText).build())); + } + return ResponseOutputItem.ofReasoning(builder.build()); + } + + /** Builds a ResponseFunctionToolCall wrapped in a ResponseOutputItem. */ + public static ResponseOutputItem functionCallItem( + String callId, String name, String arguments) { + return ResponseOutputItem.ofFunctionCall( + ResponseFunctionToolCall.builder() + .arguments(arguments) + .callId(callId) + .name(name) + .build()); + } + + /** Builds a file_search_call output item — an unknown type the parser does not handle. */ + public static ResponseOutputItem fileSearchItem() { + return ResponseOutputItem.ofFileSearchCall( + ResponseFileSearchToolCall.builder() + .id("fs_test_001") + .queries(List.of("test query")) + .status(ResponseFileSearchToolCall.Status.COMPLETED) + .build()); + } + + // ── Usage builder ─────────────────────────────────────────────────────── + + public static ResponseUsage usage(long input, long output, long cached, long reasoning) { + return ResponseUsage.builder() + .inputTokens(input) + .inputTokensDetails( + ResponseUsage.InputTokensDetails.builder() + .cacheWriteTokens(0L) + .cachedTokens(cached) + .build()) + .outputTokens(output) + .outputTokensDetails( + ResponseUsage.OutputTokensDetails.builder() + .reasoningTokens(reasoning) + .build()) + .totalTokens(input + output) + .build(); + } + + // ── Streaming event fixtures ────────────────────────────────────────── + + public static StreamResponse streamOf(List events) { + return new StreamResponse() { + private final Stream stream = events.stream(); + + @Override + public Stream stream() { + return stream; + } + + @Override + public void close() { + stream.close(); + } + }; + } + + public static ResponseStreamEvent textDeltaEvent(String delta, String itemId) { + ResponseTextDeltaEvent textDelta = + ResponseTextDeltaEvent.builder() + .delta(delta) + .itemId(itemId) + .contentIndex(0L) + .outputIndex(0L) + .sequenceNumber(0L) + .logprobs(List.of()) + .build(); + ResponseStreamEvent event = mock(ResponseStreamEvent.class); + when(event.isOutputTextDelta()).thenReturn(true); + when(event.outputTextDelta()).thenReturn(Optional.of(textDelta)); + when(event.asOutputTextDelta()).thenReturn(textDelta); + return event; + } + + public static ResponseStreamEvent reasoningSummaryDeltaEvent(String delta, String itemId) { + ResponseReasoningSummaryTextDeltaEvent evt = + ResponseReasoningSummaryTextDeltaEvent.builder() + .delta(delta) + .itemId(itemId) + .outputIndex(0L) + .sequenceNumber(0L) + .summaryIndex(0L) + .build(); + ResponseStreamEvent event = mock(ResponseStreamEvent.class); + when(event.isReasoningSummaryTextDelta()).thenReturn(true); + when(event.reasoningSummaryTextDelta()).thenReturn(Optional.of(evt)); + when(event.asReasoningSummaryTextDelta()).thenReturn(evt); + return event; + } + + public static ResponseStreamEvent reasoningTextDeltaEvent(String delta, String itemId) { + ResponseReasoningTextDeltaEvent evt = + ResponseReasoningTextDeltaEvent.builder() + .delta(delta) + .itemId(itemId) + .contentIndex(0L) + .outputIndex(0L) + .sequenceNumber(0L) + .build(); + ResponseStreamEvent event = mock(ResponseStreamEvent.class); + when(event.isReasoningTextDelta()).thenReturn(true); + when(event.reasoningTextDelta()).thenReturn(Optional.of(evt)); + when(event.asReasoningTextDelta()).thenReturn(evt); + return event; + } + + public static ResponseStreamEvent functionCallArgsDeltaEvent(String delta, String itemId) { + ResponseFunctionCallArgumentsDeltaEvent evt = + ResponseFunctionCallArgumentsDeltaEvent.builder() + .delta(delta) + .itemId(itemId) + .outputIndex(0L) + .sequenceNumber(0L) + .build(); + ResponseStreamEvent event = mock(ResponseStreamEvent.class); + when(event.isFunctionCallArgumentsDelta()).thenReturn(true); + when(event.functionCallArgumentsDelta()).thenReturn(Optional.of(evt)); + when(event.asFunctionCallArgumentsDelta()).thenReturn(evt); + return event; + } + + public static ResponseStreamEvent functionCallArgsDoneEvent( + String arguments, String itemId, String name) { + ResponseFunctionCallArgumentsDoneEvent evt = + ResponseFunctionCallArgumentsDoneEvent.builder() + .arguments(arguments) + .itemId(itemId) + .name(name) + .outputIndex(0L) + .sequenceNumber(0L) + .build(); + ResponseStreamEvent event = mock(ResponseStreamEvent.class); + when(event.isFunctionCallArgumentsDone()).thenReturn(true); + when(event.functionCallArgumentsDone()).thenReturn(Optional.of(evt)); + when(event.asFunctionCallArgumentsDone()).thenReturn(evt); + return event; + } + + public static ResponseStreamEvent outputItemAddedEvent(ResponseOutputItem item) { + ResponseOutputItemAddedEvent evt = + ResponseOutputItemAddedEvent.builder() + .item(item) + .outputIndex(0L) + .sequenceNumber(0L) + .build(); + ResponseStreamEvent event = mock(ResponseStreamEvent.class); + when(event.isOutputItemAdded()).thenReturn(true); + when(event.outputItemAdded()).thenReturn(Optional.of(evt)); + when(event.asOutputItemAdded()).thenReturn(evt); + return event; + } + + public static ResponseStreamEvent completedEvent(Response response) { + ResponseCompletedEvent evt = + ResponseCompletedEvent.builder().response(response).sequenceNumber(0L).build(); + ResponseStreamEvent event = mock(ResponseStreamEvent.class); + when(event.isCompleted()).thenReturn(true); + when(event.completed()).thenReturn(Optional.of(evt)); + when(event.asCompleted()).thenReturn(evt); + return event; + } + + public static ResponseStreamEvent incompleteEvent(Response response) { + ResponseIncompleteEvent evt = + ResponseIncompleteEvent.builder().response(response).sequenceNumber(0L).build(); + ResponseStreamEvent event = mock(ResponseStreamEvent.class); + when(event.isIncomplete()).thenReturn(true); + when(event.incomplete()).thenReturn(Optional.of(evt)); + when(event.asIncomplete()).thenReturn(evt); + return event; + } + + public static ResponseStreamEvent failedEvent(Response response) { + ResponseFailedEvent evt = + ResponseFailedEvent.builder().response(response).sequenceNumber(0L).build(); + ResponseStreamEvent event = mock(ResponseStreamEvent.class); + when(event.isFailed()).thenReturn(true); + when(event.failed()).thenReturn(Optional.of(evt)); + when(event.asFailed()).thenReturn(evt); + return event; + } + + public static ResponseStreamEvent errorEvent(String message, String code) { + ResponseErrorEvent.Builder builder = + ResponseErrorEvent.builder().message(message).sequenceNumber(0L); + builder.param((String) null); + if (code != null) { + builder.code(code); + } + ResponseErrorEvent evt = builder.build(); + ResponseStreamEvent event = mock(ResponseStreamEvent.class); + when(event.isError()).thenReturn(true); + when(event.error()).thenReturn(Optional.of(evt)); + when(event.asError()).thenReturn(evt); + return event; + } + + public static ResponseStreamEvent noopEvent() { + return mock(ResponseStreamEvent.class); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/credential/OpenAIOfficialCredentialTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/credential/OpenAIOfficialCredentialTest.java new file mode 100644 index 0000000000..dfc56abbed --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/credential/OpenAIOfficialCredentialTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.extensions.model.openaiofficial.credential; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.extensions.model.openaiofficial.OpenAIResponsesChatModel; +import org.junit.jupiter.api.Test; + +class OpenAIOfficialCredentialTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void openAiOfficialCredentialJsonRoundTripWithOrganization() throws Exception { + OpenAIOfficialCredential c = + OpenAIOfficialCredential.builder() + .apiKey("sk-test") + .organization("org-abc") + .baseUrl("https://api.openai.com/v1") + .build(); + String json = mapper.writeValueAsString(c); + assertTrue(json.contains("\"type\":\"openai_official_credential\"")); + assertTrue(json.contains("\"organization\":\"org-abc\"")); + + OpenAIOfficialCredential round = mapper.readValue(json, OpenAIOfficialCredential.class); + assertEquals("sk-test", round.getApiKey()); + assertEquals("org-abc", round.getOrganization()); + assertEquals("https://api.openai.com/v1", round.getBaseUrl()); + assertEquals(OpenAIResponsesChatModel.class, round.getChatModelClass()); + } + + @Test + void openAiOfficialCredentialRequiresNonNullApiKey() { + assertThrows(NullPointerException.class, () -> OpenAIOfficialCredential.builder().build()); + } + + @Test + void autoIdIsRoundTrippedNotRegenerated() throws Exception { + OpenAIOfficialCredential c = OpenAIOfficialCredential.builder().apiKey("k").build(); + String originalId = c.getId(); + assertNotNull(originalId); + String json = mapper.writeValueAsString(c); + OpenAIOfficialCredential round = mapper.readValue(json, OpenAIOfficialCredential.class); + assertEquals(originalId, round.getId()); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/pom.xml b/agentscope-extensions/agentscope-extensions-model/pom.xml index 2bf6b3bae2..c21f43d0b8 100644 --- a/agentscope-extensions/agentscope-extensions-model/pom.xml +++ b/agentscope-extensions/agentscope-extensions-model/pom.xml @@ -33,7 +33,7 @@ agentscope-extensions-model-openai - + agentscope-extensions-model-openai-official agentscope-extensions-model-gemini agentscope-extensions-model-anthropic agentscope-extensions-model-dashscope diff --git a/docs/docs.json b/docs/docs.json index 3a1687e4e4..321022256e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -182,16 +182,17 @@ { "group": "Model Providers", "pages": [ - "v2/en/integration/model/index", - "v2/en/integration/model/openai", - "v2/en/integration/model/deepseek", - "v2/en/integration/model/glm", - "v2/en/integration/model/kimi", - "v2/en/integration/model/minimax", - "v2/en/integration/model/dashscope", - "v2/en/integration/model/gemini", - "v2/en/integration/model/anthropic", - "v2/en/integration/model/ollama" + "v2/en/integration/model/index", + "v2/en/integration/model/openai", + "v2/en/integration/model/openai-official", + "v2/en/integration/model/deepseek", + "v2/en/integration/model/glm", + "v2/en/integration/model/kimi", + "v2/en/integration/model/minimax", + "v2/en/integration/model/dashscope", + "v2/en/integration/model/gemini", + "v2/en/integration/model/anthropic", + "v2/en/integration/model/ollama" ] }, { @@ -605,16 +606,17 @@ { "group": "模型提供商", "pages": [ - "v2/zh/integration/model/index", - "v2/zh/integration/model/openai", - "v2/zh/integration/model/deepseek", - "v2/zh/integration/model/glm", - "v2/zh/integration/model/kimi", - "v2/zh/integration/model/minimax", - "v2/zh/integration/model/dashscope", - "v2/zh/integration/model/gemini", - "v2/zh/integration/model/anthropic", - "v2/zh/integration/model/ollama" + "v2/zh/integration/model/index", + "v2/zh/integration/model/openai", + "v2/zh/integration/model/openai-official", + "v2/zh/integration/model/deepseek", + "v2/zh/integration/model/glm", + "v2/zh/integration/model/kimi", + "v2/zh/integration/model/minimax", + "v2/zh/integration/model/dashscope", + "v2/zh/integration/model/gemini", + "v2/zh/integration/model/anthropic", + "v2/zh/integration/model/ollama" ] }, { @@ -2333,15 +2335,20 @@ "destination": "/v2/en/integration/model/ollama", "permanent": true }, - { - "source": "/v2/en/integration/model/openai.html", - "destination": "/v2/en/integration/model/openai", - "permanent": true - }, - { - "source": "/v2/en/integration/overview.html", - "destination": "/v2/en/integration/overview", - "permanent": true + { + "source": "/v2/en/integration/model/openai.html", + "destination": "/v2/en/integration/model/openai", + "permanent": true + }, + { + "source": "/v2/en/integration/model/openai-official.html", + "destination": "/v2/en/integration/model/openai-official", + "permanent": true + }, + { + "source": "/v2/en/integration/overview.html", + "destination": "/v2/en/integration/overview", + "permanent": true }, { "source": "/v2/en/integration/protocol/a2a.html", @@ -2898,15 +2905,20 @@ "destination": "/v2/zh/integration/model/ollama", "permanent": true }, - { - "source": "/v2/zh/integration/model/openai.html", - "destination": "/v2/zh/integration/model/openai", - "permanent": true - }, - { - "source": "/v2/zh/integration/overview.html", - "destination": "/v2/zh/integration/overview", - "permanent": true + { + "source": "/v2/zh/integration/model/openai.html", + "destination": "/v2/zh/integration/model/openai", + "permanent": true + }, + { + "source": "/v2/zh/integration/model/openai-official.html", + "destination": "/v2/zh/integration/model/openai-official", + "permanent": true + }, + { + "source": "/v2/zh/integration/overview.html", + "destination": "/v2/zh/integration/overview", + "permanent": true }, { "source": "/v2/zh/integration/protocol/a2a.html", diff --git a/docs/v2/en/docs/building-blocks/agent.md b/docs/v2/en/docs/building-blocks/agent.md index 76837bffc4..bfd77087d3 100644 --- a/docs/v2/en/docs/building-blocks/agent.md +++ b/docs/v2/en/docs/building-blocks/agent.md @@ -149,11 +149,10 @@ ReActAgent agent = -The `ModelRegistry` string form (`:`) requires the matching model extension module on the classpath. It supports `dashscope` / `openai` / `deepseek` / `anthropic` / `gemini` / `ollama` and reads the matching API key (`DASHSCOPE_API_KEY` / `OPENAI_API_KEY` / `DEEPSEEK_API_KEY` / `ANTHROPIC_API_KEY` / `GEMINI_API_KEY`) from the environment. For long-running scenarios that also need a workspace, session persistence, memory compaction, subagents, and so on, use [`HarnessAgent`](/v2/en/docs/harness/architecture) — it is a thin wrapper around `ReActAgent` with a largely identical builder. +The `ModelRegistry` string form (`:`) requires the matching model extension module on the classpath. It supports `dashscope` / `openai` / `openai-official` / `deepseek` / `anthropic` / `gemini` / `ollama` and reads the matching API key (`DASHSCOPE_API_KEY` / `OPENAI_API_KEY` / `DEEPSEEK_API_KEY` / `ANTHROPIC_API_KEY` / `GEMINI_API_KEY`) from the environment. For long-running scenarios that also need a workspace, session persistence, memory compaction, subagents, and so on, use [`HarnessAgent`](/v2/en/docs/harness/architecture) — it is a thin wrapper around `ReActAgent` with a largely identical builder. - ### Builder fields | Field | Type | Default | Description | diff --git a/docs/v2/en/docs/building-blocks/model.md b/docs/v2/en/docs/building-blocks/model.md index 58c435eb5e..b9556209f1 100644 --- a/docs/v2/en/docs/building-blocks/model.md +++ b/docs/v2/en/docs/building-blocks/model.md @@ -30,6 +30,7 @@ Provider-specific model implementations have been moved out of `agentscope-core` | Provider | Maven artifact | Main package | |----------|----------------|--------------| | OpenAI | `agentscope-extensions-model-openai` | `io.agentscope.extensions.model.openai` | +| OpenAI Official | `agentscope-extensions-model-openai-official` | `io.agentscope.extensions.model.openaiofficial` | | DashScope | `agentscope-extensions-model-dashscope` | `io.agentscope.extensions.model.dashscope` | | Gemini | `agentscope-extensions-model-gemini` | `io.agentscope.extensions.model.gemini` | | Anthropic | `agentscope-extensions-model-anthropic` | `io.agentscope.extensions.model.anthropic` | @@ -46,7 +47,7 @@ Provider-specific model implementations have been moved out of `agentscope-core` ``` -Other provider artifacts follow the same pattern: `agentscope-extensions-model-openai`, `agentscope-extensions-model-gemini`, `agentscope-extensions-model-anthropic`, and `agentscope-extensions-model-ollama`. +Other provider artifacts follow the same pattern: `agentscope-extensions-model-openai`, `agentscope-extensions-model-openai-official`, `agentscope-extensions-model-gemini`, `agentscope-extensions-model-anthropic`, and `agentscope-extensions-model-ollama`. 2. Replace provider imports from `io.agentscope.core.model.*` with `io.agentscope.extensions.model..*`. 3. Replace provider formatter imports from `io.agentscope.core.formatter..*` with `io.agentscope.extensions.model..formatter.*`. @@ -213,12 +214,13 @@ A **Chat Model** is the LLM driving conversation and tool calling, with input an | Provider | Class | Notes | |----------|-------|-------| | OpenAI | `OpenAIChatModel` | Chat Completions API; works with vLLM and OpenAI-compatible endpoints (DeepSeek, Kimi, …) | +| OpenAI Official | `OpenAIResponsesChatModel` | Responses API via official SDK; reasoning, structured output | | Anthropic | `AnthropicChatModel` | Claude models; prompt caching and thinking | | DashScope | `DashScopeChatModel` | Qwen models; multi-modal (vision/audio/video), reasoning | | Gemini | `GeminiChatModel` | Google Gemini; multi-modal | | Ollama | `OllamaChatModel` | Locally hosted LLMs; credential optional | -Provider credential classes live with their model extension modules, for example `OpenAICredential`, `AnthropicCredential`, `DashScopeCredential`, `GeminiCredential`, and `OllamaCredential`. OpenAI-compatible credentials such as `DeepSeekCredential`, `KimiCredential`, and `XAICredential` remain available from core. +Provider credential classes live with their model extension modules, for example `OpenAICredential`, `OpenAIOfficialCredential`, `AnthropicCredential`, `DashScopeCredential`, `GeminiCredential`, and `OllamaCredential`. OpenAI-compatible credentials such as `DeepSeekCredential`, `KimiCredential`, and `XAICredential` remain available from core. ### Creating a chat model @@ -385,6 +387,7 @@ If the native path fails (e.g. model returns HTTP 400), the framework **automati | Provider | `supportsNativeStructuredOutput` | Notes | |----------|----------------------------------|-------| | OpenAI (GPT-4o, etc.) | `true` | Native `json_schema` support | +| OpenAI Official (Responses API) | `true` | Native `json_schema` support | | OpenAI (DeepSeek/GLM formatter) | `false` | Not supported; auto-fallback | | DashScope | `false` | Native endpoint only supports `json_object`, not `json_schema`; fallback by default | | Anthropic | `false` (default) | — | @@ -448,6 +451,7 @@ Per-provider formatters now live with their provider extension modules: |----------|------|------------| | DashScope | `DashScopeChatFormatter` | `DashScopeMultiAgentFormatter` | | OpenAI | `OpenAIChatFormatter` | `OpenAIMultiAgentFormatter` | +| OpenAI Official | — | `ResponsesMultiAgentFormatter` | | Anthropic | `AnthropicChatFormatter` | `AnthropicMultiAgentFormatter` | | Gemini | `GeminiChatFormatter` | `GeminiMultiAgentFormatter` | | Ollama | `OllamaChatFormatter` | `OllamaMultiAgentFormatter` | diff --git a/docs/v2/en/integration/model/index.md b/docs/v2/en/integration/model/index.md index e7bdc99e44..c5ab7545b2 100644 --- a/docs/v2/en/integration/model/index.md +++ b/docs/v2/en/integration/model/index.md @@ -5,6 +5,7 @@ title: Model Providers Model provider extensions connect AgentScope Java to hosted or local chat model APIs. Each provider can be selected through a `ModelRegistry` id when its extension module is on the classpath. - [OpenAI](/v2/en/integration/model/openai) +- [OpenAI Official](/v2/en/integration/model/openai-official) - [DeepSeek](/v2/en/integration/model/deepseek) - [GLM](/v2/en/integration/model/glm) - [Kimi](/v2/en/integration/model/kimi) diff --git a/docs/v2/en/integration/model/openai-official.md b/docs/v2/en/integration/model/openai-official.md new file mode 100644 index 0000000000..b393c3806e --- /dev/null +++ b/docs/v2/en/integration/model/openai-official.md @@ -0,0 +1,67 @@ +--- +title: OpenAI Official +--- + +# OpenAI Official Model + +`agentscope-extensions-model-openai-official` integrates OpenAI models through the official OpenAI Java SDK. It currently supports only the Responses API; support for the Chat Completions API may be added in the future. + +## Add the dependency + +```xml + + io.agentscope + agentscope-extensions-model-openai-official + ${agentscope.version} + +``` + +## ModelRegistry + +Set `OPENAI_API_KEY`, then use the `openai-official:` id: + +```java +ReActAgent agent = ReActAgent.builder() + .name("assistant") + .model("openai-official:gpt-4o") + .build(); +``` + +## Explicit builder + +Use the builder when you need a custom base URL, or a multi-agent formatter: + +```java +import io.agentscope.extensions.model.openaiofficial.OpenAIResponsesChatModel; + +OpenAIResponsesChatModel model = OpenAIResponsesChatModel.builder() + .apiKey(System.getenv("OPENAI_API_KEY")) + .modelName("gpt-4o") + .stream(true) + .build(); +``` + +## Spring Boot + +There is no dedicated Spring Boot starter for this module yet. + +## Reasoning + +Control reasoning effort via `GenerateOptions.reasoningEffort` (`low` / `medium` / `high` / `minimal`). To make reasoning visible in the response, explicitly enable `reasoning.summary`: + +```java +GenerateOptions options = GenerateOptions.builder() + .reasoningEffort("high") + .additionalBodyParam("reasoning.summary", "auto") + .build(); +``` + +Encrypted reasoning content from previous turns is automatically replayed in multi-turn conversations via `Msg.metadata`. No manual management is needed. + +## Compatibility notes + +This module integrates via the OpenAI Java SDK and currently supports only the Responses API; Chat Completions API support may be added in the future. The following options are **not supported** and will fail-fast when set: `frequencyPenalty`, `presencePenalty`, `topK`, `seed`, `cacheControl`, `thinkingBudget`, `endpointPath`, per-request `additionalHeaders`, and per-request `additionalQueryParams`. + +Responses-specific parameters are available through `GenerateOptions.additionalBodyParams` with a whitelist: `reasoning.summary`, `reasoning.context`, `reasoning.mode`, `service_tier`, `prompt_cache_key`, `prompt_cache_options`, `max_tool_calls`, `safety_identifier`. + +Native structured output is always enabled (`supportsNativeStructuredOutput()` returns `true`). The SDK retry is disabled (`maxRetries=0`); retry is managed by AgentScope. diff --git a/docs/v2/en/integration/overview.md b/docs/v2/en/integration/overview.md index 4a78d64fad..daad2a7039 100644 --- a/docs/v2/en/integration/overview.md +++ b/docs/v2/en/integration/overview.md @@ -13,6 +13,7 @@ All model providers have moved to independent model extension modules, while `ag | Provider | Maven artifact | `ModelRegistry` id | Standard environment variable | Docs | |----------|----------------|--------------------|-------------------------------|------| | OpenAI | `agentscope-extensions-model-openai` | `openai:` | `OPENAI_API_KEY` | OpenAI | +| OpenAI Official | `agentscope-extensions-model-openai-official` | `openai-official:` | `OPENAI_API_KEY` | OpenAI Official | | DeepSeek | `agentscope-extensions-model-openai` | `deepseek:` | `DEEPSEEK_API_KEY` | DeepSeek | | GLM | `agentscope-extensions-model-openai` | `glm:` | `ZAI_API_KEY` / `GLM_API_KEY` / `ZHIPUAI_API_KEY` | GLM | | Kimi | `agentscope-extensions-model-openai` | `kimi:` | `MOONSHOT_API_KEY` / `KIMI_API_KEY` | Kimi | diff --git a/docs/v2/zh/docs/building-blocks/agent.md b/docs/v2/zh/docs/building-blocks/agent.md index 0d05b64cb8..45293f965d 100644 --- a/docs/v2/zh/docs/building-blocks/agent.md +++ b/docs/v2/zh/docs/building-blocks/agent.md @@ -149,11 +149,10 @@ ReActAgent agent = -`ModelRegistry` 的字符串形式(`:`)需要对应的模型扩展模块在 classpath 中。它支持 `dashscope` / `openai` / `deepseek` / `anthropic` / `gemini` / `ollama`,会自动从环境变量读取 API key(`DASHSCOPE_API_KEY` / `OPENAI_API_KEY` / `DEEPSEEK_API_KEY` / `ANTHROPIC_API_KEY` / `GEMINI_API_KEY`)。需要在长期运行场景下同时获得工作区、会话持久化、记忆压缩、子 agent 等能力,请改用 [`HarnessAgent`](/v2/zh/docs/harness/architecture) —— 它对 `ReActAgent` 做了一层薄包装,builder 接口大体一致。 +`ModelRegistry` 的字符串形式(`:`)需要对应的模型扩展模块在 classpath 中。它支持 `dashscope` / `openai` / `openai-official` / `deepseek` / `anthropic` / `gemini` / `ollama`,会自动从环境变量读取 API key(`DASHSCOPE_API_KEY` / `OPENAI_API_KEY` / `DEEPSEEK_API_KEY` / `ANTHROPIC_API_KEY` / `GEMINI_API_KEY`)。需要在长期运行场景下同时获得工作区、会话持久化、记忆压缩、子 agent 等能力,请改用 [`HarnessAgent`](/v2/zh/docs/harness/architecture) —— 它对 `ReActAgent` 做了一层薄包装,builder 接口大体一致。 - ### 参数说明 | 参数 | 类型 | 默认值 | 描述 | diff --git a/docs/v2/zh/docs/building-blocks/model.md b/docs/v2/zh/docs/building-blocks/model.md index 202464d6e4..31e0e38c2a 100644 --- a/docs/v2/zh/docs/building-blocks/model.md +++ b/docs/v2/zh/docs/building-blocks/model.md @@ -30,6 +30,7 @@ CredentialBase/ | 提供商 | Maven artifact | 主要包名 | |--------|----------------|----------| | OpenAI | `agentscope-extensions-model-openai` | `io.agentscope.extensions.model.openai` | +| OpenAI Official | `agentscope-extensions-model-openai-official` | `io.agentscope.extensions.model.openaiofficial` | | DashScope | `agentscope-extensions-model-dashscope` | `io.agentscope.extensions.model.dashscope` | | Gemini | `agentscope-extensions-model-gemini` | `io.agentscope.extensions.model.gemini` | | Anthropic | `agentscope-extensions-model-anthropic` | `io.agentscope.extensions.model.anthropic` | @@ -46,7 +47,7 @@ CredentialBase/ ``` -其他模型扩展 artifact 遵循同样模式:`agentscope-extensions-model-openai`、`agentscope-extensions-model-gemini`、`agentscope-extensions-model-anthropic`、`agentscope-extensions-model-ollama`。 +其他模型扩展 artifact 遵循同样模式:`agentscope-extensions-model-openai`、`agentscope-extensions-model-openai-official`、`agentscope-extensions-model-gemini`、`agentscope-extensions-model-anthropic`、`agentscope-extensions-model-ollama`。 2. 将模型提供商实现的 import 从 `io.agentscope.core.model.*` 改为 `io.agentscope.extensions.model..*`。 3. 将模型提供商 formatter import 从 `io.agentscope.core.formatter..*` 改为 `io.agentscope.extensions.model..formatter.*`。 @@ -211,12 +212,13 @@ Model model = ModelRegistry.resolve("openai:gpt-4.1-mini", context); | 提供商 | 模型类 | 说明 | |--------|--------|------| | OpenAI | `OpenAIChatModel` | Chat Completions API,兼容 vLLM 与 OpenAI 兼容端点(含 DeepSeek、Kimi 等) | +| OpenAI Official | `OpenAIResponsesChatModel` | Responses API(官方 SDK);推理、结构化输出 | | Anthropic | `AnthropicChatModel` | Claude 模型,支持 prompt 缓存与 thinking | | DashScope | `DashScopeChatModel` | Qwen 模型,多模态(视觉/音频/视频)、推理 | | Gemini | `GeminiChatModel` | Google Gemini 模型,支持多模态 | | Ollama | `OllamaChatModel` | 本地 LLM 托管,凭证可选 | -模型提供商凭证类随对应模型扩展模块提供,例如 `OpenAICredential`、`AnthropicCredential`、`DashScopeCredential`、`GeminiCredential`、`OllamaCredential`。OpenAI 兼容提供商的 `DeepSeekCredential`、`KimiCredential`、`XAICredential` 仍在 core 模块中可用。 +模型提供商凭证类随对应模型扩展模块提供,例如 `OpenAICredential`、`OpenAIOfficialCredential`、`AnthropicCredential`、`DashScopeCredential`、`GeminiCredential`、`OllamaCredential`。OpenAI 兼容提供商的 `DeepSeekCredential`、`KimiCredential`、`XAICredential` 仍在 core 模块中可用。 ### 创建 Chat Model @@ -383,6 +385,7 @@ WeatherInfo info = msg.getStructuredData(WeatherInfo.class); | 模型提供商 | `supportsNativeStructuredOutput` | 说明 | |----------|----------------------------------|------| | OpenAI (GPT-4o 等) | `true` | 原生支持 `json_schema` | +| OpenAI Official (Responses API) | `true` | 原生支持 `json_schema` | | OpenAI (DeepSeek/GLM formatter) | `false` | 不支持,自动走 fallback | | DashScope | `false` | DashScope 原生端点仅支持 `json_object`,不支持 `json_schema`;框架默认走 fallback | | Anthropic | `false`(默认) | — | @@ -446,6 +449,7 @@ DashScopeChatModel model = |---|---|---| | DashScope | `DashScopeChatFormatter` | `DashScopeMultiAgentFormatter` | | OpenAI | `OpenAIChatFormatter` | `OpenAIMultiAgentFormatter` | +| OpenAI Official | — | `ResponsesMultiAgentFormatter` | | Anthropic | `AnthropicChatFormatter` | `AnthropicMultiAgentFormatter` | | Gemini | `GeminiChatFormatter` | `GeminiMultiAgentFormatter` | | Ollama | `OllamaChatFormatter` | `OllamaMultiAgentFormatter` | diff --git a/docs/v2/zh/integration/model/index.md b/docs/v2/zh/integration/model/index.md index 81d34abfe8..7cbe2dc645 100644 --- a/docs/v2/zh/integration/model/index.md +++ b/docs/v2/zh/integration/model/index.md @@ -5,6 +5,7 @@ title: 模型提供商 模型提供商扩展用于把 AgentScope Java 接入托管或本地 Chat Model API。对应扩展模块在 classpath 中时,可以通过 `ModelRegistry` 字符串 id 选择模型。 - [OpenAI](/v2/zh/integration/model/openai) +- [OpenAI Official](/v2/zh/integration/model/openai-official) - [DeepSeek](/v2/zh/integration/model/deepseek) - [GLM](/v2/zh/integration/model/glm) - [Kimi](/v2/zh/integration/model/kimi) diff --git a/docs/v2/zh/integration/model/openai-official.md b/docs/v2/zh/integration/model/openai-official.md new file mode 100644 index 0000000000..a6e55d1567 --- /dev/null +++ b/docs/v2/zh/integration/model/openai-official.md @@ -0,0 +1,67 @@ +--- +title: OpenAI Official +--- + +# OpenAI Official 模型 + +`agentscope-extensions-model-openai-official` 通过官方 OpenAI Java SDK 集成 OpenAI 模型。暂时只支持 Responses API,未来会考虑支持 Chat Completions API。 + +## 添加依赖 + +```xml + + io.agentscope + agentscope-extensions-model-openai-official + ${agentscope.version} + +``` + +## ModelRegistry + +设置 `OPENAI_API_KEY` 后,使用 `openai-official:` 字符串 id: + +```java +ReActAgent agent = ReActAgent.builder() + .name("assistant") + .model("openai-official:gpt-4o") + .build(); +``` + +## 显式 Builder + +需要自定义 base URL、多 Agent formatter 时使用 Builder: + +```java +import io.agentscope.extensions.model.openaiofficial.OpenAIResponsesChatModel; + +OpenAIResponsesChatModel model = OpenAIResponsesChatModel.builder() + .apiKey(System.getenv("OPENAI_API_KEY")) + .modelName("gpt-4o") + .stream(true) + .build(); +``` + +## Spring Boot + +本模块暂无专用 Spring Boot starter。 + +## 推理 + +通过 `GenerateOptions.reasoningEffort` 控制推理力度(`low` / `medium` / `high` / `minimal`)。要在响应中看到推理内容,需显式开启 `reasoning.summary`: + +```java +GenerateOptions options = GenerateOptions.builder() + .reasoningEffort("high") + .additionalBodyParam("reasoning.summary", "auto") + .build(); +``` + +前序轮次的加密推理内容会通过 `Msg.metadata` 自动在多轮对话中回放,无需手动管理。 + +## 兼容性说明 + +本模块通过 OpenAI Java SDK 集成,暂时只支持 Responses API,未来会考虑支持 Chat Completions API。以下选项**不支持**,设置非空值时会 fail-fast:`frequencyPenalty`、`presencePenalty`、`topK`、`seed`、`cacheControl`、`thinkingBudget`、`endpointPath`、每请求级 `additionalHeaders` 和 `additionalQueryParams`。 + +Responses 专有参数通过 `GenerateOptions.additionalBodyParams` 白名单键透传:`reasoning.summary`、`reasoning.context`、`reasoning.mode`、`service_tier`、`prompt_cache_key`、`prompt_cache_options`、`max_tool_calls`、`safety_identifier`。 + +原生结构化输出默认开启(`supportsNativeStructuredOutput()` 返回 `true`)。SDK 重试已禁用(`maxRetries=0`),重试由 AgentScope 管理。 diff --git a/docs/v2/zh/integration/overview.md b/docs/v2/zh/integration/overview.md index 24e0c4f13d..e42de4a02a 100644 --- a/docs/v2/zh/integration/overview.md +++ b/docs/v2/zh/integration/overview.md @@ -13,6 +13,7 @@ title: 概览 | 提供商 | Maven artifact | `ModelRegistry` id | 标准环境变量 | 文档 | |--------|----------------|--------------------|--------------|------| | OpenAI | `agentscope-extensions-model-openai` | `openai:` | `OPENAI_API_KEY` | OpenAI | +| OpenAI Official | `agentscope-extensions-model-openai-official` | `openai-official:` | `OPENAI_API_KEY` | OpenAI Official | | DeepSeek | `agentscope-extensions-model-openai` | `deepseek:` | `DEEPSEEK_API_KEY` | DeepSeek | | GLM | `agentscope-extensions-model-openai` | `glm:` | `ZAI_API_KEY` / `GLM_API_KEY` / `ZHIPUAI_API_KEY` | GLM | | Kimi | `agentscope-extensions-model-openai` | `kimi:` | `MOONSHOT_API_KEY` / `KIMI_API_KEY` | Kimi | From bb20ab81dc519c4a667673fb0b52279ab694d615 Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sat, 12 Sep 2026 23:11:09 +0800 Subject: [PATCH 2/2] feat(openai-official): add OpenAI Responses API module --- .../agentscope-all/pom.xml | 3 + .../OpenAIOfficialConstants.java | 1 - .../OpenAIResponsesChatModel.java | 19 ++-- .../ResponsesMultiAgentFormatter.java | 21 +++++ .../ResponsesRequestMapper.java | 15 ++- .../ResponsesResponseParser.java | 34 ++++--- .../ResponsesStreamingAssembler.java | 32 ++++--- .../model/openaiofficial/CrossTurnTest.java | 92 ++++++++++++------- .../OpenAIResponsesChatModelTest.java | 30 ++++++ .../ResponsesMultiAgentFormatterTest.java | 81 ++++++++++++++++ .../ResponsesRequestMapperTest.java | 76 ++++++++++++++- .../ResponsesResponseParserTest.java | 18 +--- .../ResponsesStreamingAssemblerTest.java | 52 +++++------ 13 files changed, 345 insertions(+), 129 deletions(-) diff --git a/agentscope-distribution/agentscope-all/pom.xml b/agentscope-distribution/agentscope-all/pom.xml index d126920195..928600f7d3 100644 --- a/agentscope-distribution/agentscope-all/pom.xml +++ b/agentscope-distribution/agentscope-all/pom.xml @@ -470,6 +470,9 @@ io.agentscope:agentscope-extensions-* + + + diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialConstants.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialConstants.java index fc4b6c8b0e..505b602565 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialConstants.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIOfficialConstants.java @@ -64,6 +64,5 @@ private OpenAIOfficialConstants() {} // Reasoning-level (internal state, used for history replay) static final String MD_REASONING_ENCRYPTED_CONTENT = "openai.reasoning.encrypted_content"; - static final String MD_REASONING_SUMMARY = "openai.reasoning.summary"; static final String MD_REASONING_TEXT = "openai.reasoning.text"; } diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModel.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModel.java index a892569252..f76cec90cf 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModel.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModel.java @@ -148,12 +148,15 @@ private Flux buildStreamingFlux(ResponseCreateParams params, Strin return Flux.defer( () -> { Instant start = Instant.now(); + StreamResponse streamResponse = null; try { - StreamResponse streamResponse = - client.responses().createStreaming(params); + streamResponse = client.responses().createStreaming(params); return ResponsesStreamingAssembler.assemble( streamResponse, modelName, start); } catch (RuntimeException e) { + if (streamResponse != null) { + ResponsesStreamingAssembler.closeQuietly(streamResponse); + } return Flux.error(OpenAIErrorTranslator.translate(e, modelName)); } }) @@ -205,15 +208,9 @@ private static boolean isModuleRetryable(Throwable error) { } if (current instanceof OpenAIIoException - || current instanceof OpenAIRetryableException) { - return true; - } - - if (current instanceof IOException) { - return true; - } - - if (current instanceof TimeoutException) { + || current instanceof OpenAIRetryableException + || current instanceof IOException + || current instanceof TimeoutException) { return true; } diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatter.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatter.java index b0c5c9702e..0ccf28e52c 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatter.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatter.java @@ -177,6 +177,11 @@ private GroupType determineGroupType(Msg msg) { || msg.hasContentBlocks(ToolResultBlock.class)) { yield GroupType.TOOL_SEQUENCE; } + // Assistant messages with encrypted reasoning content must be + // passed through to mapAssistantMessage. + if (msg.getRole() == MsgRole.ASSISTANT && hasEncryptedReasoning(msg)) { + yield GroupType.TOOL_SEQUENCE; + } yield GroupType.AGENT_CONVERSATION; } }; @@ -191,6 +196,22 @@ private static boolean shouldBypassHistory(Msg msg) { return Boolean.TRUE.equals(bypassFlag); } + /** + * Checks whether a message carries encrypted reasoning content that must be + * preserved as a Responses reasoning replay item (not merged into history text). + */ + private static boolean hasEncryptedReasoning(Msg msg) { + ThinkingBlock thinkingBlock = msg.getFirstContentBlock(ThinkingBlock.class); + if (thinkingBlock == null || thinkingBlock.getMetadata() == null) { + return false; + } + Object ec = + thinkingBlock + .getMetadata() + .get(OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT); + return ec instanceof String s && !s.isEmpty(); + } + // -- Conversation merging -------------------------------------- /** diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapper.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapper.java index 52f95eced6..22b6e5d9e2 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapper.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapper.java @@ -182,8 +182,10 @@ private static void mapOptions( if (schema.getDescription() != null) { schemaBuilder.description(schema.getDescription()); } - if (strictJsonSchema != null) { - schemaBuilder.strict(strictJsonSchema); + Boolean effectiveStrict = + schema.getStrict() != null ? schema.getStrict() : strictJsonSchema; + if (effectiveStrict != null) { + schemaBuilder.strict(effectiveStrict); } builder.text( ResponseTextConfig.builder().format(schemaBuilder.build()).build()); @@ -431,10 +433,13 @@ static void mapUserMessage(Msg msg, List items) { } static void mapAssistantMessage(Msg msg, List items) { - Map metadata = msg.getMetadata(); String encryptedContent = null; - if (metadata != null) { - Object ec = metadata.get(OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT); + ThinkingBlock thinkingBlock = msg.getFirstContentBlock(ThinkingBlock.class); + if (thinkingBlock != null && thinkingBlock.getMetadata() != null) { + Object ec = + thinkingBlock + .getMetadata() + .get(OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT); if (ec instanceof String s) { encryptedContent = s; } diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParser.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParser.java index 383e695373..ef74b7e0ce 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParser.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParser.java @@ -80,10 +80,26 @@ static ChatResponse parse(Response response, String modelName, Instant startTime // ── Assembly: ThinkingBlock -> TextBlock -> ToolUseBlock ── List contentBlocks = new ArrayList<>(); - // ThinkingBlock (when summary text is present) + // ThinkingBlock (when any reasoning data is present). + // All reasoning-level metadata (encrypted_content, summary, text) is + // stored on the ThinkingBlock for reasoning replay. String summaryText = summaryBuilder.toString(); - if (!summaryText.isEmpty()) { - contentBlocks.add(ThinkingBlock.builder().thinking(summaryText).build()); + String reasoningText = reasoningTextBuilder.toString(); + if (!summaryText.isEmpty() || encryptedContent != null || !reasoningText.isEmpty()) { + ThinkingBlock.Builder thinkingBuilder = ThinkingBlock.builder(); + if (!summaryText.isEmpty()) { + thinkingBuilder.thinking(summaryText); + } + Map thinkingMetadata = new HashMap<>(); + if (encryptedContent != null) { + thinkingMetadata.put( + OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT, encryptedContent); + } + if (!reasoningText.isEmpty()) { + thinkingMetadata.put(OpenAIOfficialConstants.MD_REASONING_TEXT, reasoningText); + } + thinkingBuilder.metadata(thinkingMetadata); + contentBlocks.add(thinkingBuilder.build()); } // TextBlock @@ -101,18 +117,6 @@ static ChatResponse parse(Response response, String modelName, Instant startTime String finishReason = (String) metadata.get(OpenAIOfficialConstants.MD_RESPONSE_STATUS); ChatUsage usage = ResponsesHelper.extractUsage(response, startTime, metadata); - // Reasoning metadata - if (encryptedContent != null) { - metadata.put(OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT, encryptedContent); - } - if (!summaryText.isEmpty()) { - metadata.put(OpenAIOfficialConstants.MD_REASONING_SUMMARY, summaryText); - } - String reasoningText = reasoningTextBuilder.toString(); - if (!reasoningText.isEmpty()) { - metadata.put(OpenAIOfficialConstants.MD_REASONING_TEXT, reasoningText); - } - return ChatResponse.builder() .id(responseId) .content(contentBlocks) diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssembler.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssembler.java index cd3861c60f..620df93185 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssembler.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/main/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssembler.java @@ -59,8 +59,8 @@ * short-circuits to a non-retryable exception. Failed/error events are translated to * {@link OpenAIOfficialModelException}. * - *

The SDK {@link StreamResponse} is closed on any terminal signal (complete, error, cancel) - * via {@code doFinally}. + *

The SDK {@link StreamResponse} is closed on any terminal signal (complete, error, + * cancel) via {@code doFinally}. */ final class ResponsesStreamingAssembler { @@ -101,7 +101,7 @@ static Flux assemble( .onErrorMap(e -> OpenAIErrorTranslator.translate(e, modelName)); } - private static void closeQuietly(StreamResponse streamResponse) { + static void closeQuietly(StreamResponse streamResponse) { try { streamResponse.close(); } catch (Exception e) { @@ -257,21 +257,25 @@ private ChatResponse handleTerminal(Response response) { String finishReason = (String) metadata.get(OpenAIOfficialConstants.MD_RESPONSE_STATUS); ChatUsage usage = ResponsesHelper.extractUsage(response, startTime, metadata); - // Reasoning metadata - if (encryptedContent != null) { - metadata.put( - OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT, encryptedContent); - } - if (!summaryText.isEmpty()) { - metadata.put(OpenAIOfficialConstants.MD_REASONING_SUMMARY, summaryText); - } - if (!reasoningText.isEmpty()) { - metadata.put(OpenAIOfficialConstants.MD_REASONING_TEXT, reasoningText); + // Reasoning metadata is placed on a ThinkingBlock in the terminal + // content for reasoning replay. + List terminalContent = new ArrayList<>(); + if (encryptedContent != null || !summaryText.isEmpty() || !reasoningText.isEmpty()) { + Map thinkingMetadata = new HashMap<>(); + if (encryptedContent != null) { + thinkingMetadata.put( + OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT, + encryptedContent); + } + if (!reasoningText.isEmpty()) { + thinkingMetadata.put(OpenAIOfficialConstants.MD_REASONING_TEXT, reasoningText); + } + terminalContent.add(ThinkingBlock.builder().metadata(thinkingMetadata).build()); } return ChatResponse.builder() .id(responseId) - .content(new ArrayList<>()) + .content(terminalContent) .usage(usage) .metadata(metadata) .finishReason(finishReason) diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/CrossTurnTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/CrossTurnTest.java index 6175a19fb8..724e7b9e31 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/CrossTurnTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/CrossTurnTest.java @@ -356,13 +356,17 @@ void reasoningEffortCrossTurnInvariant() { List.of( UserMessage.builder().content(text("Hello")).build(), AssistantMessage.builder() - .content(ThinkingBlock.builder().thinking("summary1").build()) - .content(TextBlock.builder().text("I can help").build()) - .metadata( - Map.of( - OpenAIOfficialConstants - .MD_REASONING_ENCRYPTED_CONTENT, - "enc123")) + .content( + List.of( + ThinkingBlock.builder() + .thinking("summary1") + .metadata( + Map.of( + OpenAIOfficialConstants + .MD_REASONING_ENCRYPTED_CONTENT, + "enc123")) + .build(), + TextBlock.builder().text("I can help").build())) .build(), UserMessage.builder().content(text("Follow up")).build()); @@ -426,13 +430,17 @@ void reasoningSummaryOptinCrossTurn() { List.of( UserMessage.builder().content(text("Hello")).build(), AssistantMessage.builder() - .content(ThinkingBlock.builder().thinking("summary1").build()) - .content(TextBlock.builder().text("I can help").build()) - .metadata( - Map.of( - OpenAIOfficialConstants - .MD_REASONING_ENCRYPTED_CONTENT, - "enc123")) + .content( + List.of( + ThinkingBlock.builder() + .thinking("summary1") + .metadata( + Map.of( + OpenAIOfficialConstants + .MD_REASONING_ENCRYPTED_CONTENT, + "enc123")) + .build(), + TextBlock.builder().text("I can help").build())) .build(), UserMessage.builder().content(text("Follow up")).build()); @@ -492,13 +500,17 @@ void reasoningContextCrossTurn() { List.of( UserMessage.builder().content(text("Hello")).build(), AssistantMessage.builder() - .content(ThinkingBlock.builder().thinking("summary1").build()) - .content(TextBlock.builder().text("response1").build()) - .metadata( - Map.of( - OpenAIOfficialConstants - .MD_REASONING_ENCRYPTED_CONTENT, - "enc123")) + .content( + List.of( + ThinkingBlock.builder() + .thinking("summary1") + .metadata( + Map.of( + OpenAIOfficialConstants + .MD_REASONING_ENCRYPTED_CONTENT, + "enc123")) + .build(), + TextBlock.builder().text("response1").build())) .build(), UserMessage.builder().content(text("Follow up")).build()); @@ -554,13 +566,17 @@ void reasoningModeCrossTurn() { List.of( UserMessage.builder().content(text("Hello")).build(), AssistantMessage.builder() - .content(ThinkingBlock.builder().thinking("summary1").build()) - .content(TextBlock.builder().text("response1").build()) - .metadata( - Map.of( - OpenAIOfficialConstants - .MD_REASONING_ENCRYPTED_CONTENT, - "enc123")) + .content( + List.of( + ThinkingBlock.builder() + .thinking("summary1") + .metadata( + Map.of( + OpenAIOfficialConstants + .MD_REASONING_ENCRYPTED_CONTENT, + "enc123")) + .build(), + TextBlock.builder().text("response1").build())) .build(), UserMessage.builder().content(text("Follow up")).build()); @@ -592,13 +608,19 @@ void encryptedReasoningReplay() { List.of( UserMessage.builder().content(text("Hello")).build(), AssistantMessage.builder() - .content(ThinkingBlock.builder().thinking("my summary").build()) - .content(TextBlock.builder().text("my response").build()) - .metadata( - Map.of( - OpenAIOfficialConstants - .MD_REASONING_ENCRYPTED_CONTENT, - "enc_data")) + .content( + List.of( + ThinkingBlock.builder() + .thinking("my summary") + .metadata( + Map.of( + OpenAIOfficialConstants + .MD_REASONING_ENCRYPTED_CONTENT, + "enc_data")) + .build(), + TextBlock.builder() + .text("my response") + .build())) .build(), UserMessage.builder().content(text("Follow up")).build()); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModelTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModelTest.java index 9486d283a5..bfb4617f85 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModelTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/OpenAIResponsesChatModelTest.java @@ -659,6 +659,36 @@ public void close() { "StreamResponse should be closed on cancel"); } + @Test + void streamThrowsInAssembleClosesSdkStream() throws Exception { + OpenAIClient client = mockClientWithResponseService(); + ResponseService svc = client.responses(); + + CountDownLatch closeLatch = new CountDownLatch(1); + + StreamResponse streamResponse = + new StreamResponse() { + @Override + public Stream stream() { + throw new RuntimeException("stream() initialization failed"); + } + + @Override + public void close() { + closeLatch.countDown(); + } + }; + when(svc.createStreaming(any(ResponseCreateParams.class))).thenReturn(streamResponse); + + OpenAIResponsesChatModel model = createModel(client, true); + model.stream(simpleMessages(), null, null).subscribe(chunk -> {}, error -> {}); + + assertTrue( + closeLatch.await(5, TimeUnit.SECONDS), + "StreamResponse should be closed when assemble throws before" + + " doFinally is attached"); + } + @Test void retryableErrorRetriedViaRetryWhen() { OpenAIClient client = mockClientWithResponseService(); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatterTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatterTest.java index 8edac293d4..8d7593b6e8 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatterTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesMultiAgentFormatterTest.java @@ -23,6 +23,7 @@ import com.openai.models.responses.EasyInputMessage; import com.openai.models.responses.ResponseInputContent; import com.openai.models.responses.ResponseInputItem; +import com.openai.models.responses.ResponseReasoningItem; import io.agentscope.core.message.Base64Source; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.DataBlock; @@ -664,6 +665,86 @@ void toolRoleAlwaysToolSequence() { } } + @Nested + @DisplayName("Encrypted reasoning replay") + class EncryptedReasoningReplay { + + @Test + @DisplayName( + "Assistant with encrypted reasoning is passed through, not merged into history") + void encryptedReasoningNotMergedIntoHistory() { + Msg user1 = user("Alice", text("What is 2+2?")); + Msg assistant1 = + Msg.builder() + .role(MsgRole.ASSISTANT) + .name("Bob") + .content( + List.of( + ThinkingBlock.builder() + .thinking("Calculating...") + .metadata( + Map.of( + OpenAIOfficialConstants + .MD_REASONING_ENCRYPTED_CONTENT, + "enc123")) + .build(), + text("4"))) + .build(); + Msg user2 = user("Alice", text("And 3+3?")); + + List result = + formatter.formatHistory(List.of(user1, assistant1, user2)); + + // A reasoning item must be present with the encrypted content + ResponseReasoningItem reasoning = null; + for (ResponseInputItem item : result) { + if (item.isReasoning()) { + reasoning = item.asReasoning(); + } + } + assertNotNull(reasoning, "Expected a reasoning replay item"); + assertEquals("enc123", reasoning.encryptedContent().orElseThrow()); + + // The assistant text must NOT be inside a merged user history message + long userMsgCount = countRole(result, EasyInputMessage.Role.USER); + long assistantMsgCount = countRole(result, EasyInputMessage.Role.ASSISTANT); + assertTrue(assistantMsgCount >= 1, "Assistant message should be preserved as-is"); + // The assistant text "4" must be in an assistant message, not inside a + // merged user block + boolean assistantHasText4 = false; + for (ResponseInputItem item : result) { + if (item.isEasyInputMessage()) { + EasyInputMessage m = item.asEasyInputMessage(); + if (m.role() == EasyInputMessage.Role.ASSISTANT + && extractText(item).contains("4")) { + assistantHasText4 = true; + } + } + } + assertTrue(assistantHasText4, "Assistant text '4' should be in an assistant message"); + } + + @Test + @DisplayName("Assistant without encrypted reasoning is merged into history as before") + void plainAssistantStillMerged() { + Msg user1 = user("Alice", text("Hi")); + Msg assistant1 = assistant("Bob", text("Hello!")); + Msg user2 = user("Alice", text("Bye")); + + List result = + formatter.formatHistory(List.of(user1, assistant1, user2)); + + // No reasoning item should be emitted + boolean hasReasoning = result.stream().anyMatch(ResponseInputItem::isReasoning); + assertFalse(hasReasoning, "No reasoning item for plain assistant message"); + + // Messages should be merged into a single user history message + String allText = extractAllText(result); + assertTrue(allText.contains("")); + assertTrue(allText.contains("Hello!")); + } + } + private static long countOccurrences(String haystack, String needle) { long count = 0; int idx = 0; diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapperTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapperTest.java index 36da4db162..028dd793ee 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapperTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesRequestMapperTest.java @@ -690,12 +690,12 @@ void reasoningReplayWithEncryptedContent() { .content( ThinkingBlock.builder() .thinking("some reasoning") + .metadata( + Map.of( + OpenAIOfficialConstants + .MD_REASONING_ENCRYPTED_CONTENT, + "encrypted123")) .build()) - .metadata( - Map.of( - OpenAIOfficialConstants - .MD_REASONING_ENCRYPTED_CONTENT, - "encrypted123")) .build()); ResponseCreateParams params = mapHistory(baseOptions(), messages); assertNotNull(params.input()); @@ -990,6 +990,72 @@ void strictJsonSchemaOrthogonalToToolStrict() { .strict() .isPresent()); } + + @Test + void responseFormatJsonSchemaSchemaLevelStrictTrue() { + JsonSchema schema = + JsonSchema.builder() + .name("Result") + .schema(Map.of("type", "object")) + .strict(true) + .build(); + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .responseFormat(ResponseFormat.jsonSchema(schema)) + .build(); + ResponseCreateParams params = + ResponsesRequestMapper.map( + List.of(SystemMessage.builder().content(text("s")).build()), + null, + opts, + null, + null, + ResponsesRequestMapper::mapHistory); + assertTrue(params.text().isPresent()); + assertTrue(params.text().orElseThrow().format().orElseThrow().isJsonSchema()); + assertEquals( + true, + params.text() + .orElseThrow() + .format() + .orElseThrow() + .asJsonSchema() + .strict() + .orElseThrow()); + } + + @Test + void responseFormatJsonSchemaSchemaLevelOverridesBuilder() { + JsonSchema schema = + JsonSchema.builder() + .name("Result") + .schema(Map.of("type", "object")) + .strict(false) + .build(); + GenerateOptions opts = + GenerateOptions.builder().modelName(MODEL).stream(false) + .responseFormat(ResponseFormat.jsonSchema(schema)) + .build(); + ResponseCreateParams params = + ResponsesRequestMapper.map( + List.of(SystemMessage.builder().content(text("s")).build()), + null, + opts, + null, + true, + ResponsesRequestMapper::mapHistory); + assertTrue(params.text().isPresent()); + assertTrue(params.text().orElseThrow().format().orElseThrow().isJsonSchema()); + assertEquals( + false, + params.text() + .orElseThrow() + .format() + .orElseThrow() + .asJsonSchema() + .strict() + .orElseThrow()); + } } // ── Prompt cache options detail ─────────────────────────────── diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParserTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParserTest.java index 61be8b3052..dc587af75f 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParserTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesResponseParserTest.java @@ -139,7 +139,7 @@ void encryptedReasoningNotInThinkingBlock() { // Encrypted content is in metadata, not in ThinkingBlock assertEquals( "encrypted_data", - result.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT)); + tb.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT)); } @Test @@ -154,23 +154,15 @@ void reasoningTextNotConcatenatedWithSummary() { // Raw reasoning text is in metadata assertEquals( "raw reasoning text", - result.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_TEXT)); + tb.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_TEXT)); } @Test - void reasoningSummaryWrittenToMetadata() { + void reasoningSummaryWrittenToThinkingBlock() { Response response = TestSdkFixtures.reasoningResponse("my summary", "enc", null); ChatResponse result = parse(response); - assertEquals( - "my summary", - result.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_SUMMARY)); - } - - @Test - void reasoningSummaryNotWrittenWhenEmpty() { - Response response = TestSdkFixtures.textResponse("hello"); - ChatResponse result = parse(response); - assertNull(result.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_SUMMARY)); + ThinkingBlock tb = (ThinkingBlock) result.getContent().get(0); + assertEquals("my summary", tb.getThinking()); } // ── Text concatenation ─────────────────────────────────────────────── diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssemblerTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssemblerTest.java index 05b04f3325..cb7c9cb37c 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssemblerTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-openai-official/src/test/java/io/agentscope/extensions/model/openaiofficial/ResponsesStreamingAssemblerTest.java @@ -177,11 +177,12 @@ void reasoningTextDeltaWrittenToTerminalMetadata() { // reasoning_text.delta produces no intermediate block assertEquals(1, results.size()); + // Reasoning text is on the terminal ThinkingBlock's metadata + ChatResponse terminal = terminalBlock(results); + ThinkingBlock terminalTb = (ThinkingBlock) terminal.getContent().get(0); assertEquals( "raw reasoning", - terminalBlock(results) - .getMetadata() - .get(OpenAIOfficialConstants.MD_REASONING_TEXT)); + terminalTb.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_TEXT)); } @Test @@ -199,9 +200,11 @@ void reasoningSummaryAndTextDeltaNotConcatenated() { assertEquals("summary", tb.getThinking()); assertFalse(tb.getThinking().contains("raw")); - // Terminal metadata has both + // Terminal ThinkingBlock metadata has reasoning text ChatResponse terminal = terminalBlock(results); - assertEquals("raw", terminal.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_TEXT)); + ThinkingBlock terminalTb = (ThinkingBlock) terminal.getContent().get(0); + assertEquals( + "raw", terminalTb.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_TEXT)); } @Test @@ -357,7 +360,7 @@ void multipleToolCallsRoutedByItemId() { // ── Terminal re-extraction ──────────────────────────────────────────── @Test - void terminalReextractsEncryptedContentAndSummary() { + void terminalReextractsEncryptedContent() { List events = List.of( TestSdkFixtures.reasoningSummaryDeltaEvent("my summary", "rs_001"), @@ -367,12 +370,13 @@ void terminalReextractsEncryptedContentAndSummary() { List results = assemble(events); ChatResponse terminal = terminalBlock(results); + // Encrypted content is on the terminal ThinkingBlock's metadata + ThinkingBlock terminalTb = (ThinkingBlock) terminal.getContent().get(0); assertEquals( "enc_data_123", - terminal.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT)); - assertEquals( - "my summary", - terminal.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_SUMMARY)); + terminalTb + .getMetadata() + .get(OpenAIOfficialConstants.MD_REASONING_ENCRYPTED_CONTENT)); } @Test @@ -411,24 +415,6 @@ void terminalReextractsResponseMetadata() { terminal.getMetadata().get(OpenAIOfficialConstants.MD_RESPONSE_SERVICE_TIER)); } - @Test - void terminalValueOverridesAccumulatedReasoning() { - // Delta accumulates "delta summary", but terminal response has "terminal summary" - List events = - List.of( - TestSdkFixtures.reasoningSummaryDeltaEvent("delta summary", "rs_001"), - TestSdkFixtures.completedEvent( - TestSdkFixtures.reasoningResponse( - "terminal summary", "enc", null))); - List results = assemble(events); - - ChatResponse terminal = terminalBlock(results); - assertEquals( - "terminal summary", - terminal.getMetadata().get(OpenAIOfficialConstants.MD_REASONING_SUMMARY), - "Terminal re-extracted value should override accumulated delta"); - } - @Test void cancelBeforeTerminalProducesNoTerminalBlock() { // Empty event list (simulates cancel before any terminal event) @@ -529,13 +515,19 @@ void noOptinProducesNoThinkingBlock() { TestSdkFixtures.completedEvent( TestSdkFixtures.completedResponse(List.of()))); List results = assemble(events); - for (ChatResponse result : results) { - for (Object block : result.getContent()) { + for (int i = 0; i < results.size() - 1; i++) { + for (Object block : results.get(i).getContent()) { assertFalse( block instanceof ThinkingBlock, "No ThinkingBlock should be created without summary opt-in"); } } + // Terminal block carries reasoning metadata on a ThinkingBlock with no + // visible thinking text (reasoning text is metadata-only, not content) + ChatResponse terminal = terminalBlock(results); + assertFalse(terminal.getContent().isEmpty()); + ThinkingBlock terminalTb = (ThinkingBlock) terminal.getContent().get(0); + assertTrue(terminalTb.getThinking().isEmpty()); } // ── Incomplete event ─────────────────────────────────────────────