diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiAssistantManager.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiAssistantManager.java new file mode 100644 index 00000000000..7884d01d7b6 --- /dev/null +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiAssistantManager.java @@ -0,0 +1,161 @@ +/* + * Copyright 2024 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 + * + * https://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 org.springframework.ai.openai; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.openai.api.assistants.OpenAiAssistantApi; +import org.springframework.ai.openai.api.assistants.OpenAiAssistantApi.AssistantResponse; +import org.springframework.ai.openai.api.assistants.OpenAiAssistantApi.CreateAssistantRequest; +import org.springframework.ai.openai.api.assistants.OpenAiAssistantApi.ListAssistantsResponse; +import org.springframework.ai.openai.api.assistants.OpenAiAssistantApi.ModifyAssistantRequest; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.http.ResponseEntity; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; + +/** + * OpenAiAssistantModel provides a high-level abstraction for managing OpenAI assistants + * by utilizing the OpenAiAssistantApi to interact with the OpenAI Assistants API. + * + * This model is responsible for creating, modifying, retrieving, listing, and deleting + * assistants while supporting retry mechanisms and default options. + * + * @author Alexandros Pappas + */ +public class OpenAiAssistantManager { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final OpenAiAssistantApi openAiAssistantApi; + + private final RetryTemplate retryTemplate; + + private OpenAiAssistantOptions defaultOptions; + + public OpenAiAssistantManager(OpenAiAssistantApi openAiAssistantApi) { + this(openAiAssistantApi, RetryUtils.DEFAULT_RETRY_TEMPLATE); + } + + public OpenAiAssistantManager(OpenAiAssistantApi openAiAssistantApi, RetryTemplate retryTemplate) { + Assert.notNull(openAiAssistantApi, "OpenAiAssistantApi must not be null"); + Assert.notNull(retryTemplate, "RetryTemplate must not be null"); + this.openAiAssistantApi = openAiAssistantApi; + this.retryTemplate = retryTemplate; + } + + public OpenAiAssistantOptions getDefaultOptions() { + return this.defaultOptions; + } + + public OpenAiAssistantManager withDefaultOptions(OpenAiAssistantOptions defaultOptions) { + this.defaultOptions = defaultOptions; + return this; + } + + public AssistantResponse createAssistant(CreateAssistantRequest createRequest) { + Assert.notNull(createRequest, "CreateAssistantRequest cannot be null."); + CreateAssistantRequest finalRequest = mergeOptions(createRequest, this.defaultOptions); + + ResponseEntity responseEntity = this.retryTemplate + .execute(ctx -> this.openAiAssistantApi.createAssistant(finalRequest)); + + AssistantResponse responseBody = responseEntity.getBody(); + if (responseBody == null) { + logger.warn("No assistant created for request: {}", finalRequest); + return AssistantResponse.builder().build(); + } + + return responseBody; + } + + public AssistantResponse modifyAssistant(String assistantId, ModifyAssistantRequest modifyRequest) { + Assert.hasLength(assistantId, "Assistant ID cannot be null or empty."); + Assert.notNull(modifyRequest, "ModifyAssistantRequest cannot be null."); + + ResponseEntity responseEntity = this.retryTemplate + .execute(ctx -> this.openAiAssistantApi.modifyAssistant(assistantId, modifyRequest)); + + AssistantResponse responseBody = responseEntity.getBody(); + if (responseBody == null) { + logger.warn("No assistant modification response for assistantId={} and request: {}", assistantId, + modifyRequest); + return AssistantResponse.builder().build(); + } + + return responseBody; + } + + public AssistantResponse retrieveAssistant(String assistantId) { + Assert.hasLength(assistantId, "Assistant ID cannot be null or empty."); + + ResponseEntity responseEntity = this.retryTemplate + .execute(ctx -> this.openAiAssistantApi.retrieveAssistant(assistantId)); + + AssistantResponse responseBody = responseEntity.getBody(); + if (responseBody == null) { + logger.warn("No assistant retrieved for assistantId={}", assistantId); + return AssistantResponse.builder().build(); + } + + return responseBody; + } + + public ListAssistantsResponse listAssistants(int limit, String order, String after, String before) { + ResponseEntity responseEntity = this.retryTemplate + .execute(ctx -> this.openAiAssistantApi.listAssistants(limit, order, after, before)); + + ListAssistantsResponse responseBody = responseEntity.getBody(); + if (responseBody == null) { + logger.warn("No assistants returned for list request with limit={}, order={}, after={}, before={}", limit, + order, after, before); + return new ListAssistantsResponse("list", List.of()); + } + + return responseBody; + } + + public boolean deleteAssistant(String assistantId) { + Assert.hasLength(assistantId, "Assistant ID cannot be null or empty."); + + ResponseEntity responseEntity = this.retryTemplate + .execute(ctx -> this.openAiAssistantApi.deleteAssistant(assistantId)); + + OpenAiAssistantApi.DeleteAssistantResponse responseBody = responseEntity.getBody(); + if (responseBody == null || responseBody.deleted() == null) { + logger.warn("No delete response or null deletion flag for assistantId={}", assistantId); + return false; + } + + return Boolean.TRUE.equals(responseBody.deleted()); + } + + /** + * Merges the default options with the request-specific options if present. + */ + private T mergeOptions(T request, OpenAiAssistantOptions defaultOptions) { + if (defaultOptions != null) { + return ModelOptionsUtils.merge(defaultOptions, request, (Class) request.getClass()); + } + return request; + } + +} diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiAssistantOptions.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiAssistantOptions.java new file mode 100644 index 00000000000..0982cbe1183 --- /dev/null +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiAssistantOptions.java @@ -0,0 +1,268 @@ +/* + * Copyright 2024 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 + * + * https://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 org.springframework.ai.openai; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.ai.assistant.AssistantOptions; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.openai.api.assistants.ResponseFormat; +import org.springframework.ai.openai.api.assistants.tools.Tool; +import org.springframework.ai.openai.api.assistants.tools.ToolResources; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * OpenAI Assistant API options. Represents the configuration options for creating and + * managing assistants via OpenAI API. + * + * @author Alexandros Pappas + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class OpenAiAssistantOptions implements AssistantOptions { + + @JsonProperty("model") + private String model; + + @JsonProperty("name") + private String name; + + @JsonProperty("description") + private String description; + + @JsonProperty("instructions") + private String instructions; + + @JsonProperty("tools") + private List tools; + + @JsonProperty("tool_resources") + private ToolResources toolResources; + + @JsonProperty("metadata") + private Map metadata; + + @JsonProperty("temperature") + private Double temperature; + + @JsonProperty("top_p") + private Double topP; + + @JsonProperty("response_format") + private ResponseFormat responseFormat; + + public static Builder builder() { + return new Builder(); + } + + public static OpenAiAssistantOptions fromOptions(OpenAiAssistantOptions fromOptions) { + return OpenAiAssistantOptions.builder() + .withModel(fromOptions.getModel()) + .withName(fromOptions.getName()) + .withDescription(fromOptions.getDescription()) + .withInstructions(fromOptions.getInstructions()) + .withTools(fromOptions.getTools()) + .withToolResources(fromOptions.getToolResources()) + .withMetadata(fromOptions.getMetadata()) + .withTemperature(fromOptions.getTemperature()) + .withTopP(fromOptions.getTopP()) + .withResponseFormat(fromOptions.getResponseFormat()) + .build(); + } + + @Override + public String getModel() { + return this.model; + } + + public void setModel(String model) { + this.model = model; + } + + @Override + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String getDescription() { + return this.description; + } + + public void setDescription(String description) { + this.description = description; + } + + @Override + public String getInstructions() { + return this.instructions; + } + + public void setInstructions(String instructions) { + this.instructions = instructions; + } + + public List getTools() { + return this.tools; + } + + public void setTools(List tools) { + this.tools = tools; + } + + public ToolResources getToolResources() { + return this.toolResources; + } + + public void setToolResources(ToolResources toolResources) { + this.toolResources = toolResources; + } + + @Override + public Map getMetadata() { + return this.metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + + @Override + public Double getTemperature() { + return this.temperature; + } + + public void setTemperature(Double temperature) { + this.temperature = temperature; + } + + @Override + public Double getTopP() { + return this.topP; + } + + public void setTopP(Double topP) { + this.topP = topP; + } + + public ResponseFormat getResponseFormat() { + return this.responseFormat; + } + + public void setResponseFormat(ResponseFormat responseFormat) { + this.responseFormat = responseFormat; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + OpenAiAssistantOptions other = (OpenAiAssistantOptions) o; + return Objects.equals(this.model, other.model) && Objects.equals(this.name, other.name) + && Objects.equals(this.description, other.description) + && Objects.equals(this.instructions, other.instructions) && Objects.equals(this.tools, other.tools) + && Objects.equals(this.toolResources, other.toolResources) + && Objects.equals(this.metadata, other.metadata) && Objects.equals(this.temperature, other.temperature) + && Objects.equals(this.topP, other.topP) && Objects.equals(this.responseFormat, other.responseFormat); + } + + @Override + public int hashCode() { + return Objects.hash(this.model, this.name, this.description, this.instructions, this.tools, this.toolResources, + this.metadata, this.temperature, this.topP, this.responseFormat); + } + + @Override + public String toString() { + return "OpenAiAssistantOptions: " + ModelOptionsUtils.toJsonString(this); + } + + public static final class Builder { + + private final OpenAiAssistantOptions options; + + private Builder() { + this.options = new OpenAiAssistantOptions(); + } + + public Builder withModel(String model) { + this.options.setModel(model); + return this; + } + + public Builder withName(String name) { + this.options.setName(name); + return this; + } + + public Builder withDescription(String description) { + this.options.setDescription(description); + return this; + } + + public Builder withInstructions(String instructions) { + this.options.setInstructions(instructions); + return this; + } + + public Builder withTools(List tools) { + this.options.setTools(tools); + return this; + } + + public Builder withToolResources(ToolResources toolResources) { + this.options.setToolResources(toolResources); + return this; + } + + public Builder withMetadata(Map metadata) { + this.options.setMetadata(metadata); + return this; + } + + public Builder withTemperature(Double temperature) { + this.options.setTemperature(temperature); + return this; + } + + public Builder withTopP(Double topP) { + this.options.setTopP(topP); + return this; + } + + public Builder withResponseFormat(ResponseFormat responseFormat) { + this.options.setResponseFormat(responseFormat); + return this; + } + + public OpenAiAssistantOptions build() { + return this.options; + } + + } + +} diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/OpenAiAssistantApi.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/OpenAiAssistantApi.java new file mode 100644 index 00000000000..f2d15ef0b49 --- /dev/null +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/OpenAiAssistantApi.java @@ -0,0 +1,471 @@ +/* + * Copyright 2024 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 + * + * https://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 org.springframework.ai.openai.api.assistants; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.ai.openai.api.assistants.tools.Tool; +import org.springframework.ai.openai.api.assistants.tools.ToolResources; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; + +import static org.springframework.ai.openai.api.common.OpenAiApiConstants.DEFAULT_BASE_URL; + +/** + * OpenAI Assistant API. + * + * Provides methods to create, list, retrieve, modify, and delete assistants using + * OpenAI's Assistants API. + * + * @see Assistants API + * Reference + * @author Alexandros Pappas + */ +public class OpenAiAssistantApi { + + public static final String ASSISTANT_ID_CANNOT_BE_NULL_OR_EMPTY = "Assistant ID cannot be null or empty."; + + public static final String V_1_ASSISTANTS_ASSISTANT_ID = "/v1/assistants/{assistant_id}"; + + private final RestClient restClient; + + private final ObjectMapper objectMapper; + + public OpenAiAssistantApi(String openAiToken) { + this(DEFAULT_BASE_URL, openAiToken, RestClient.builder(), RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER); + } + + public OpenAiAssistantApi(String baseUrl, String openAiToken, RestClient.Builder restClientBuilder, + ResponseErrorHandler responseErrorHandler) { + this.objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + this.restClient = restClientBuilder.baseUrl(baseUrl).defaultHeaders(headers -> { + headers.setBearerAuth(openAiToken); + headers.set("OpenAI-Beta", "assistants=v2"); + headers.setContentType(MediaType.APPLICATION_JSON); + }).defaultStatusHandler(responseErrorHandler).build(); + } + + public ResponseEntity createAssistant(CreateAssistantRequest request) { + Assert.notNull(request, "CreateAssistantRequest cannot be null."); + Assert.hasLength(request.model(), "Model ID cannot be null or empty."); + + return this.restClient.post().uri("/v1/assistants").body(request).retrieve().toEntity(AssistantResponse.class); + } + + public ResponseEntity listAssistants(Integer limit, String order, String after, + String before) { + return this.restClient.get() + .uri(uriBuilder -> uriBuilder.path("/v1/assistants") + .queryParam("limit", limit) + .queryParam("order", order) + .queryParam("after", after) + .queryParam("before", before) + .build()) + .retrieve() + .toEntity(ListAssistantsResponse.class); + } + + public ResponseEntity retrieveAssistant(String assistantId) { + Assert.hasLength(assistantId, ASSISTANT_ID_CANNOT_BE_NULL_OR_EMPTY); + + return this.restClient.get() + .uri(uriBuilder -> uriBuilder.path(V_1_ASSISTANTS_ASSISTANT_ID).build(assistantId)) + .retrieve() + .toEntity(AssistantResponse.class); + } + + public ResponseEntity modifyAssistant(String assistantId, ModifyAssistantRequest request) { + Assert.hasLength(assistantId, ASSISTANT_ID_CANNOT_BE_NULL_OR_EMPTY); + Assert.notNull(request, "ModifyAssistantRequest cannot be null."); + + return this.restClient.post() + .uri(uriBuilder -> uriBuilder.path(V_1_ASSISTANTS_ASSISTANT_ID).build(assistantId)) + .body(request) + .retrieve() + .toEntity(AssistantResponse.class); + } + + public ResponseEntity deleteAssistant(String assistantId) { + Assert.hasLength(assistantId, ASSISTANT_ID_CANNOT_BE_NULL_OR_EMPTY); + + return this.restClient.delete() + .uri(uriBuilder -> uriBuilder.path(V_1_ASSISTANTS_ASSISTANT_ID).build(assistantId)) + .retrieve() + .toEntity(DeleteAssistantResponse.class); + } + + // Request and Response DTOs + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record CreateAssistantRequest(@JsonProperty("model") String model, @JsonProperty("name") String name, + @JsonProperty("description") String description, @JsonProperty("instructions") String instructions, + @JsonProperty("tools") List tools, @JsonProperty("tool_resources") ToolResources toolResources, + @JsonProperty("metadata") Map metadata, @JsonProperty("temperature") Double temperature, + @JsonProperty("top_p") Double topP, @JsonProperty("response_format") ResponseFormat responseFormat) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String model; + + private String name; + + private String description; + + private String instructions; + + private List tools; + + private ToolResources toolResources; + + private Map metadata; + + private Double temperature; + + private Double topP; + + private ResponseFormat responseFormat; + + public Builder withModel(String model) { + this.model = model; + return this; + } + + public Builder withName(String name) { + this.name = name; + return this; + } + + public Builder withDescription(String description) { + this.description = description; + return this; + } + + public Builder withInstructions(String instructions) { + this.instructions = instructions; + return this; + } + + public Builder withTools(List tools) { + this.tools = tools; + return this; + } + + public Builder withToolResources(ToolResources toolResources) { + this.toolResources = toolResources; + return this; + } + + public Builder withMetadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public Builder withTemperature(Double temperature) { + this.temperature = temperature; + return this; + } + + public Builder withTopP(Double topP) { + this.topP = topP; + return this; + } + + public Builder withResponseFormat(ResponseFormat responseFormat) { + this.responseFormat = responseFormat; + return this; + } + + public CreateAssistantRequest build() { + Assert.hasText(this.model, "model must not be empty"); + + return new CreateAssistantRequest(this.model, this.name, this.description, this.instructions, + this.tools, this.toolResources, this.metadata, this.temperature, this.topP, + this.responseFormat); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record JsonSchema(@JsonProperty("description") String description, @JsonProperty("name") String name, + @JsonProperty("schema") Map schema, @JsonProperty("strict") Boolean strict) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ListAssistantsResponse(@JsonProperty("object") String object, + @JsonProperty("data") List data) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record AssistantResponse(@JsonProperty("id") String id, @JsonProperty("object") String object, + @JsonProperty("created_at") Long createdAt, @JsonProperty("name") String name, + @JsonProperty("description") String description, @JsonProperty("model") String model, + @JsonProperty("instructions") String instructions, @JsonProperty("tools") List tools, + @JsonProperty("tool_resources") ToolResources toolResources, + @JsonProperty("metadata") Map metadata, @JsonProperty("temperature") Double temperature, + @JsonProperty("top_p") Double topP, @JsonProperty("response_format") ResponseFormat responseFormat) { + + @Override + public String toString() { + return "AssistantResponse{" + "id='" + id + '\'' + ", object='" + object + '\'' + ", createdAt=" + createdAt + + ", name='" + name + '\'' + ", description='" + description + '\'' + ", model='" + model + '\'' + + ", instructions='" + instructions + '\'' + ", tools=" + tools + ", toolResources=" + toolResources + + ", metadata=" + metadata + ", temperature=" + temperature + ", topP=" + topP + ", responseFormat=" + + responseFormat + '}'; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String id; + + private String object; + + private Long createdAt; + + private String name; + + private String description; + + private String model; + + private String instructions; + + private List tools; + + private ToolResources toolResources; + + private Map metadata; + + private Double temperature; + + private Double topP; + + private ResponseFormat responseFormat; + + public Builder withId(String id) { + this.id = id; + return this; + } + + public Builder withObject(String object) { + this.object = object; + return this; + } + + public Builder withCreatedAt(Long createdAt) { + this.createdAt = createdAt; + return this; + } + + public Builder withName(String name) { + this.name = name; + return this; + } + + public Builder withDescription(String description) { + this.description = description; + return this; + } + + public Builder withModel(String model) { + this.model = model; + return this; + } + + public Builder withInstructions(String instructions) { + this.instructions = instructions; + return this; + } + + public Builder withTools(List tools) { + this.tools = tools; + return this; + } + + public Builder withToolResources(ToolResources toolResources) { + this.toolResources = toolResources; + return this; + } + + public Builder withMetadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public Builder withTemperature(Double temperature) { + this.temperature = temperature; + return this; + } + + public Builder withTopP(Double topP) { + this.topP = topP; + return this; + } + + public Builder withResponseFormat(ResponseFormat responseFormat) { + this.responseFormat = responseFormat; + return this; + } + + public AssistantResponse build() { + return new AssistantResponse(id, object, createdAt, name, description, model, instructions, tools, + toolResources, metadata, temperature, topP, responseFormat); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ModifyAssistantRequest(@JsonProperty("model") String model, @JsonProperty("name") String name, + @JsonProperty("description") String description, @JsonProperty("instructions") String instructions, + @JsonProperty("tools") List tools, @JsonProperty("tool_resources") ToolResources toolResources, + @JsonProperty("metadata") Map metadata, @JsonProperty("temperature") Double temperature, + @JsonProperty("top_p") Double topP, @JsonProperty("response_format") ResponseFormat responseFormat) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String model; + + private String name; + + private String description; + + private String instructions; + + private List tools; + + private ToolResources toolResources; + + private Map metadata; + + private Double temperature; + + private Double topP; + + private ResponseFormat responseFormat; + + public Builder withModel(String model) { + this.model = model; + return this; + } + + public Builder withName(String name) { + this.name = name; + return this; + } + + public Builder withDescription(String description) { + this.description = description; + return this; + } + + public Builder withInstructions(String instructions) { + this.instructions = instructions; + return this; + } + + public Builder withTools(List tools) { + this.tools = tools; + return this; + } + + public Builder withToolResources(ToolResources toolResources) { + this.toolResources = toolResources; + return this; + } + + public Builder withMetadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public Builder withTemperature(Double temperature) { + this.temperature = temperature; + return this; + } + + public Builder withTopP(Double topP) { + this.topP = topP; + return this; + } + + public Builder withResponseFormat(ResponseFormat responseFormat) { + this.responseFormat = responseFormat; + return this; + } + + public ModifyAssistantRequest build() { + return new ModifyAssistantRequest(model, name, description, instructions, tools, toolResources, + metadata, temperature, topP, responseFormat); + } + + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record DeleteAssistantResponse(@JsonProperty("id") String id, @JsonProperty("object") String object, + @JsonProperty("deleted") Boolean deleted) { + } + + public enum ResponseFormatType { + + @JsonProperty("auto") + AUTO, + + /** + * all tools must be of type `function` when `response_format` is of type + * `json_schema`. + */ + @JsonProperty("json_object") + JSON_OBJECT, + + @JsonProperty("text") + TEXT, + + /** + * all tools must be of type `function` when `response_format` is of type + * `json_schema`. + */ + @JsonProperty("json_schema") + JSON_SCHEMA + + } + +} diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/ResponseFormat.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/ResponseFormat.java new file mode 100644 index 00000000000..37e0fd36e35 --- /dev/null +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/ResponseFormat.java @@ -0,0 +1,143 @@ +/* + * Copyright 2024 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 + * + * https://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 org.springframework.ai.openai.api.assistants; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import java.io.IOException; + +/** + * @author Alexandros Pappas + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonSerialize(using = ResponseFormat.ResponseFormatSerializer.class) +@JsonDeserialize(using = ResponseFormat.ResponseFormatDeserializer.class) +public class ResponseFormat { + + @JsonProperty("type") + private final OpenAiAssistantApi.ResponseFormatType type; + + @JsonProperty("json_schema") + @JsonInclude(JsonInclude.Include.NON_NULL) + private final OpenAiAssistantApi.JsonSchema jsonSchema; + + // Private constructor to enforce factory methods + private ResponseFormat(OpenAiAssistantApi.ResponseFormatType type, OpenAiAssistantApi.JsonSchema jsonSchema) { + this.type = type; + this.jsonSchema = jsonSchema; + } + + public static ResponseFormat auto() { + return new ResponseFormat(OpenAiAssistantApi.ResponseFormatType.AUTO, null); + } + + public static ResponseFormat jsonObject() { + return new ResponseFormat(OpenAiAssistantApi.ResponseFormatType.JSON_OBJECT, null); + } + + public static ResponseFormat text() { + return new ResponseFormat(OpenAiAssistantApi.ResponseFormatType.TEXT, null); + } + + public static ResponseFormat jsonSchema(OpenAiAssistantApi.JsonSchema schema) { + return new ResponseFormat(OpenAiAssistantApi.ResponseFormatType.JSON_SCHEMA, schema); + } + + public OpenAiAssistantApi.ResponseFormatType getType() { + return this.type; + } + + public OpenAiAssistantApi.JsonSchema getJsonSchema() { + return this.jsonSchema; + } + + // Custom serializer + public static class ResponseFormatSerializer extends JsonSerializer { + + @Override + public void serialize(ResponseFormat value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + if (value.type == OpenAiAssistantApi.ResponseFormatType.AUTO) { + // Serialize as a plain string + gen.writeString("auto"); + } + else { + // Serialize as an object + gen.writeStartObject(); + gen.writeStringField("type", value.type.toString().toLowerCase()); + if (value.type == OpenAiAssistantApi.ResponseFormatType.JSON_SCHEMA && value.jsonSchema != null) { + gen.writeObjectField("json_schema", value.jsonSchema); + } + gen.writeEndObject(); + } + } + + } + + // Custom deserializer + public static class ResponseFormatDeserializer extends JsonDeserializer { + + @Override + public ResponseFormat deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = p.getCodec().readTree(p); + + if (node.isTextual()) { + String type = node.asText(); + if ("auto".equalsIgnoreCase(type)) { + return ResponseFormat.auto(); + } + throw new IllegalArgumentException("Unsupported string value for ResponseFormat: " + type); + } + else if (node.isObject()) { + String type = node.get("type").asText(); + OpenAiAssistantApi.ResponseFormatType formatType = OpenAiAssistantApi.ResponseFormatType + .valueOf(type.toUpperCase()); + + if (formatType == OpenAiAssistantApi.ResponseFormatType.JSON_SCHEMA) { + JsonNode schemaNode = node.get("json_schema"); + OpenAiAssistantApi.JsonSchema schema = null; + if (schemaNode != null) { + schema = p.getCodec().treeToValue(schemaNode, OpenAiAssistantApi.JsonSchema.class); + } + return ResponseFormat.jsonSchema(schema); + } + else if (formatType == OpenAiAssistantApi.ResponseFormatType.JSON_OBJECT) { + return ResponseFormat.jsonObject(); + } + else if (formatType == OpenAiAssistantApi.ResponseFormatType.TEXT) { + return ResponseFormat.text(); + } + + throw new IllegalArgumentException("Unsupported object value for ResponseFormat: " + type); + } + + throw new IllegalArgumentException("Unexpected JSON format for ResponseFormat."); + } + + } + +} diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/tools/CodeInterpreterTool.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/tools/CodeInterpreterTool.java new file mode 100644 index 00000000000..87dbb316c29 --- /dev/null +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/tools/CodeInterpreterTool.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024 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 + * + * https://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 org.springframework.ai.openai.api.assistants.tools; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Represents the Code Interpreter Tool for OpenAI Assistants API. + * + * @see Assistants API + * Reference + * @author Alexandros Pappas + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CodeInterpreterTool extends Tool { + + @JsonCreator + public CodeInterpreterTool() { + super(ToolType.CODE_INTERPRETER); + } + +} diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/tools/FileSearchTool.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/tools/FileSearchTool.java new file mode 100644 index 00000000000..989bb002624 --- /dev/null +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/tools/FileSearchTool.java @@ -0,0 +1,103 @@ +/* + * Copyright 2024 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 + * + * https://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 org.springframework.ai.openai.api.assistants.tools; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents the File Search Tool for OpenAI Assistants API. + * + * @see Assistants API + * Reference + * @author Alexandros Pappas + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class FileSearchTool extends Tool { + + @JsonProperty("file_search") + private final FileSearchOptions fileSearch; + + @JsonCreator + public FileSearchTool(FileSearchOptions fileSearch) { + super(ToolType.FILE_SEARCH); + this.fileSearch = fileSearch; + } + + public FileSearchOptions getFileSearch() { + return fileSearch; + } + + /** + * Represents options for file search. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class FileSearchOptions { + + @JsonProperty("max_num_results") + private final Integer maxNumResults; + + @JsonProperty("ranking_options") + private final RankingOptions rankingOptions; + + @JsonCreator + public FileSearchOptions(@JsonProperty("max_num_results") Integer maxNumResults, + @JsonProperty("ranking_options") RankingOptions rankingOptions) { + this.maxNumResults = maxNumResults; + this.rankingOptions = rankingOptions; + } + + public Integer getMaxNumResults() { + return this.maxNumResults; + } + + public RankingOptions getRankingOptions() { + return this.rankingOptions; + } + + /** + * Ranking options for file search. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class RankingOptions { + + @JsonProperty("ranker") + private final String ranker; + + @JsonProperty("score_threshold") + private final Float scoreThreshold; + + @JsonCreator + public RankingOptions(@JsonProperty("ranker") String ranker, + @JsonProperty("score_threshold") Float scoreThreshold) { + this.ranker = ranker; + this.scoreThreshold = scoreThreshold; + } + + public String getRanker() { + return this.ranker; + } + + public Float getScoreThreshold() { + return this.scoreThreshold; + } + + } + + } + +} diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/tools/FunctionTool.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/tools/FunctionTool.java new file mode 100644 index 00000000000..a32d43fefd6 --- /dev/null +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/tools/FunctionTool.java @@ -0,0 +1,92 @@ +/* + * Copyright 2024 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * 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 org.springframework.ai.openai.api.assistants.tools; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents the Function Tool for OpenAI Assistants API. + * + * @see Assistants API + * Reference + * @author Alexandros Pappas + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class FunctionTool extends Tool { + + @JsonProperty("function") + private final Function function; + + @JsonCreator + public FunctionTool(@JsonProperty("function") Function function) { + super(ToolType.FUNCTION); + this.function = function; + } + + public Function getFunction() { + return function; + } + + /** + * Represents the details of a function. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Function { + + @JsonProperty("name") + private final String name; + + @JsonProperty("description") + private final String description; + + @JsonProperty("parameters") + private Map parameters; + + @JsonProperty("strict") + private final Boolean strict; + + @JsonCreator + public Function(@JsonProperty("name") String name, @JsonProperty("description") String description, + @JsonProperty("parameters") Map parameters, @JsonProperty("strict") Boolean strict) { + this.name = name; + this.description = description; + this.parameters = parameters; + this.strict = strict; + } + + public String getName() { + return this.name; + } + + public String getDescription() { + return this.description; + } + + public Map getParameters() { + return this.parameters; + } + + public Boolean getStrict() { + return this.strict; + } + + } + +} diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/tools/Tool.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/tools/Tool.java new file mode 100644 index 00000000000..e4141b76235 --- /dev/null +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/tools/Tool.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024 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 + * + * https://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 org.springframework.ai.openai.api.assistants.tools; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Abstract base class for tools supported by OpenAI Assistants API. + * + * @see Assistants API + * Reference + * @author Alexandros Pappas + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", + visible = true) +@JsonSubTypes({ @JsonSubTypes.Type(value = CodeInterpreterTool.class, name = "code_interpreter"), + @JsonSubTypes.Type(value = FileSearchTool.class, name = "file_search"), + @JsonSubTypes.Type(value = FunctionTool.class, name = "function") }) +public abstract class Tool { + + @JsonProperty("type") + private final ToolType type; + + protected Tool(ToolType type) { + this.type = type; + } + + public ToolType getType() { + return this.type; + } + + public enum ToolType { + + @JsonProperty("code_interpreter") + CODE_INTERPRETER, + + @JsonProperty("file_search") + FILE_SEARCH, + + @JsonProperty("function") + FUNCTION + + } + +} diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/tools/ToolResources.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/tools/ToolResources.java new file mode 100644 index 00000000000..8542beb7ce8 --- /dev/null +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/assistants/tools/ToolResources.java @@ -0,0 +1,97 @@ +/* + * Copyright 2024 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 + * + * https://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 org.springframework.ai.openai.api.assistants.tools; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import java.util.List; +import java.util.Map; + +/** + * Represents the resources that assist tools in performing specific actions, such as file + * searching or code interpreting. + * + * @author Alexandros Pappas + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ToolResources(@JsonProperty("code_interpreter") CodeInterpreterResource codeInterpreter, + @JsonProperty("file_search") FileSearchResource fileSearch) { + + /** + * Represents resources required for the Code Interpreter tool. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record CodeInterpreterResource(@JsonProperty("file_ids") List fileIds) { + } + + /** + * Represents resources required for the File Search tool. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record FileSearchResource(@JsonProperty("vector_store_ids") List vectorStoreIds, + @JsonProperty("vector_stores") List vectorStores) { + + /** + * Represents a vector store configuration for file search. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record VectorStore(@JsonProperty("file_ids") List fileIds, + @JsonProperty("chunking_strategy") ChunkingStrategy chunkingStrategy, + @JsonProperty("metadata") Map metadata) { + + /** + * Represents the chunking strategy used for vector stores. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") + @JsonSubTypes({ @JsonSubTypes.Type(value = AutoChunkingStrategy.class, name = "auto"), + @JsonSubTypes.Type(value = StaticChunkingStrategy.class, name = "static") }) + public interface ChunkingStrategy { + + @JsonProperty("type") + String type(); + + } + + /** + * Represents the Auto Chunking Strategy. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record AutoChunkingStrategy(@JsonProperty("type") String type) implements ChunkingStrategy { + } + + /** + * Represents the Static Chunking Strategy. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record StaticChunkingStrategy(@JsonProperty("type") String type, // Always + // "static" + @JsonProperty("static") Static staticConfig) implements ChunkingStrategy { + + /** + * Represents the static configuration for the Static Chunking Strategy. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Static(@JsonProperty("max_chunk_size_tokens") Integer maxChunkSizeTokens, + @JsonProperty("chunk_overlap_tokens") Integer chunkOverlapTokens) { + } + } + } + } +} diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java index d9e6b6ca513..e1cdf5260b6 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java @@ -21,6 +21,7 @@ import org.springframework.ai.openai.api.OpenAiAudioApi; import org.springframework.ai.openai.api.OpenAiImageApi; import org.springframework.ai.openai.api.OpenAiModerationApi; +import org.springframework.ai.openai.api.assistants.OpenAiAssistantApi; import org.springframework.boot.SpringBootConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.util.StringUtils; @@ -48,6 +49,11 @@ public OpenAiModerationApi openAiModerationApi() { return new OpenAiModerationApi(getApiKey()); } + @Bean + public OpenAiAssistantApi openAiAssistantApi() { + return new OpenAiAssistantApi(getApiKey()); + } + private String getApiKey() { String apiKey = System.getenv("OPENAI_API_KEY"); if (!StringUtils.hasText(apiKey)) { @@ -59,28 +65,22 @@ private String getApiKey() { @Bean public OpenAiChatModel openAiChatModel(OpenAiApi api) { - OpenAiChatModel openAiChatModel = new OpenAiChatModel(api, - OpenAiChatOptions.builder().withModel(ChatModel.GPT_4_O_MINI).build()); - return openAiChatModel; + return new OpenAiChatModel(api, OpenAiChatOptions.builder().withModel(ChatModel.GPT_4_O_MINI).build()); } @Bean public OpenAiAudioTranscriptionModel openAiTranscriptionModel(OpenAiAudioApi api) { - OpenAiAudioTranscriptionModel openAiTranscriptionModel = new OpenAiAudioTranscriptionModel(api); - return openAiTranscriptionModel; + return new OpenAiAudioTranscriptionModel(api); } @Bean public OpenAiAudioSpeechModel openAiAudioSpeechModel(OpenAiAudioApi api) { - OpenAiAudioSpeechModel openAiAudioSpeechModel = new OpenAiAudioSpeechModel(api); - return openAiAudioSpeechModel; + return new OpenAiAudioSpeechModel(api); } @Bean public OpenAiImageModel openAiImageModel(OpenAiImageApi imageApi) { - OpenAiImageModel openAiImageModel = new OpenAiImageModel(imageApi); - // openAiImageModel.setModel("foobar"); - return openAiImageModel; + return new OpenAiImageModel(imageApi); } @Bean @@ -90,8 +90,12 @@ public OpenAiEmbeddingModel openAiEmbeddingModel(OpenAiApi api) { @Bean public OpenAiModerationModel openAiModerationClient(OpenAiModerationApi openAiModerationApi) { - OpenAiModerationModel openAiModerationModel = new OpenAiModerationModel(openAiModerationApi); - return openAiModerationModel; + return new OpenAiModerationModel(openAiModerationApi); + } + + @Bean + public OpenAiAssistantManager openAiAssistantModel(OpenAiAssistantApi openAiAssistantApi) { + return new OpenAiAssistantManager(openAiAssistantApi); } } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/assistants/OpenAiAssistantApiIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/assistants/OpenAiAssistantApiIT.java new file mode 100644 index 00000000000..c0b496e123e --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/assistants/OpenAiAssistantApiIT.java @@ -0,0 +1,203 @@ +/* + * Copyright 2024 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 + * + * https://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 org.springframework.ai.openai.assistants; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.openai.OpenAiTestConfiguration; +import org.springframework.ai.openai.api.assistants.OpenAiAssistantApi; +import org.springframework.ai.openai.api.assistants.OpenAiAssistantApi.*; +import org.springframework.ai.openai.api.assistants.ResponseFormat; +import org.springframework.ai.openai.api.assistants.tools.*; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration Test for OpenAiAssistantApi lifecycle: create, retrieve, modify, list, and + * delete. + * + * @author Alexandros Pappas + */ +@SpringBootTest(classes = OpenAiTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiAssistantApiIT { + + private static final Logger logger = LoggerFactory.getLogger(OpenAiAssistantApiIT.class); + + private final OpenAiAssistantApi openAiAssistantApi = new OpenAiAssistantApi(System.getenv("OPENAI_API_KEY")); + + @Test + void testCreateAssistantWithAutoResponseFormat() { + var createRequest = new CreateAssistantRequest("gpt-4o-mini-2024-07-18", "Assistant Auto", + "Assistant with auto response format.", "You are an assistant with auto response format.", + List.of(new FileSearchTool(null)), new ToolResources(null, null), Map.of("environment", "test"), 0.7, + 0.9, ResponseFormat.auto()); + + var createResponse = openAiAssistantApi.createAssistant(createRequest).getBody(); + + assertThat(createResponse).isNotNull(); + assertThat(createResponse.responseFormat().getType()).isEqualTo(ResponseFormatType.AUTO); + logger.info("Assistant with auto response format created: {}", createResponse); + } + + @Test + void testCreateAssistantWithoutResponseFormat() { + var createRequest = new CreateAssistantRequest("gpt-4o-mini-2024-07-18", "Assistant Auto", + "Assistant with auto response format.", "You are an assistant with auto response format.", + List.of(new FileSearchTool(null)), new ToolResources(null, null), Map.of("environment", "test"), 0.7, + 0.9, null); + + var createResponse = openAiAssistantApi.createAssistant(createRequest).getBody(); + + assertThat(createResponse).isNotNull(); + assertThat(createResponse.responseFormat().getType()).isEqualTo(ResponseFormatType.AUTO); + logger.info("Assistant with auto response format created: {}", createResponse); + } + + @Test + void testCreateAssistantWithTextResponseFormat() { + var createRequest = new CreateAssistantRequest("gpt-4o-mini-2024-07-18", "Assistant Text", + "Assistant with text response format.", "You are an assistant with text response format.", + List.of(new FileSearchTool(null)), new ToolResources(null, null), Map.of("environment", "test"), 0.7, + 0.9, ResponseFormat.text()); + + var createResponse = openAiAssistantApi.createAssistant(createRequest).getBody(); + + assertThat(createResponse).isNotNull(); + assertThat(createResponse.responseFormat().getType()).isEqualTo(ResponseFormatType.TEXT); + logger.info("Assistant with text response format created: {}", createResponse); + } + + @Test + void testCreateAssistantWithJsonObjectResponseFormat() { + var createRequest = new CreateAssistantRequest("gpt-4o-mini-2024-07-18", "Assistant JSON Object", + "Assistant with JSON Object response format.", "You are an assistant with JSON Object response format.", + List.of(new FunctionTool(new FunctionTool.Function("process_data", "Process data", null, false))), + new ToolResources(null, null), Map.of("environment", "test"), 0.7, 0.9, ResponseFormat.jsonObject()); + + var createResponse = openAiAssistantApi.createAssistant(createRequest).getBody(); + + assertThat(createResponse).isNotNull(); + assertThat(createResponse.responseFormat().getType()).isEqualTo(ResponseFormatType.JSON_OBJECT); + logger.info("Assistant with JSON Object response format created: {}", createResponse); + } + + @Test + void testCreateAssistantWithJsonSchemaResponseFormat() { + var schema = Map.of("type", "object", "properties", Map.of("key", Map.of("type", "string")), "required", + List.of("key"), "additionalProperties", false); + + var jsonSchema = new JsonSchema("Schema for response validation.", "TestSchema", schema, true); + + var createRequest = new CreateAssistantRequest("gpt-4o-mini-2024-07-18", "Assistant JSON Schema", + "Assistant with JSON Schema response format.", "You are an assistant with JSON Schema response format.", + List.of(new FunctionTool(new FunctionTool.Function("process_schema", "Process schema", null, false))), + new ToolResources(null, null), Map.of("environment", "test"), 0.7, 0.9, + ResponseFormat.jsonSchema(jsonSchema)); + + var createResponse = openAiAssistantApi.createAssistant(createRequest).getBody(); + + assertThat(createResponse).isNotNull(); + assertThat(createResponse.responseFormat().getType()).isEqualTo(ResponseFormatType.JSON_SCHEMA); + logger.info("Assistant with JSON Schema response format created: {}", createResponse); + } + + @Test + void testCreateAssistantUsingBuilder() { + var createRequest = CreateAssistantRequest.builder() + .withModel("gpt-4o-mini-2024-07-18") + .withName("Assistant Builder") + .withDescription("Assistant created using the Builder pattern.") + .withInstructions("You are an assistant created using the Builder pattern.") + .withTools( + List.of(new FunctionTool(new FunctionTool.Function("builder_tool", "Processes data", null, false)))) + .withToolResources(new ToolResources(null, null)) + .withMetadata(Map.of("environment", "builder_test")) + .withTemperature(0.7) + .withTopP(0.9) + .withResponseFormat(ResponseFormat.jsonObject()) + .build(); + + var createResponse = openAiAssistantApi.createAssistant(createRequest).getBody(); + + assertThat(createResponse).isNotNull(); + assertThat(createResponse.name()).isEqualTo("Assistant Builder"); + assertThat(createResponse.model()).isEqualTo("gpt-4o-mini-2024-07-18"); + assertThat(createResponse.responseFormat().getType()).isEqualTo(ResponseFormatType.JSON_OBJECT); + logger.info("Assistant created using Builder pattern: {}", createResponse); + } + + @Test + void assistantLifecycleTest() { + // Step 1: Create an assistant + var createRequest = new CreateAssistantRequest("gpt-4o-mini-2024-07-18", "Test Assistant", + "This is a test assistant", "You are a helpful assistant.", List.of(new FileSearchTool(null)), + new ToolResources(null, null), Map.of("key", "value"), 0.7, 0.9, ResponseFormat.auto()); + + var createResponse = openAiAssistantApi.createAssistant(createRequest).getBody(); + + assertThat(createResponse).isNotNull(); + String assistantId = createResponse.id(); + assertThat(assistantId).isNotEmpty(); + assertThat(createResponse.name()).isEqualTo("Test Assistant"); + assertThat(createResponse.model()).isEqualTo("gpt-4o-mini-2024-07-18"); + System.out.println("Created Assistant ID: " + assistantId); + + // Step 2: Retrieve the created assistant + var retrieveResponse = openAiAssistantApi.retrieveAssistant(assistantId).getBody(); + + assertThat(retrieveResponse).isNotNull(); + assertThat(retrieveResponse.id()).isEqualTo(assistantId); + assertThat(retrieveResponse.name()).isEqualTo("Test Assistant"); + logger.info("Retrieved Assistant: " + retrieveResponse); + + // Step 3: Modify the assistant + var modifyRequest = new ModifyAssistantRequest("gpt-4o-mini-2024-07-18", "Modified Assistant", + "This is a modified assistant", "You are a very helpful assistant.", + List.of(new FunctionTool(new FunctionTool.Function("name", "description", null, false))), + new ToolResources(null, null), Map.of("version", "v2"), 0.6, 0.8, ResponseFormat.auto()); + + var modifyResponse = openAiAssistantApi.modifyAssistant(assistantId, modifyRequest).getBody(); + + assertThat(modifyResponse).isNotNull(); + assertThat(modifyResponse.id()).isEqualTo(assistantId); + assertThat(modifyResponse.name()).isEqualTo("Modified Assistant"); + logger.info("Modified Assistant: " + modifyResponse); + + // Step 4: List assistants + var listResponse = openAiAssistantApi.listAssistants(10, "desc", null, null).getBody(); + + assertThat(listResponse).isNotNull(); + assertThat(listResponse.data()).isNotEmpty(); + logger.info("List of Assistants: " + listResponse.data()); + + // Step 5: Delete the assistant + var deleteResponse = openAiAssistantApi.deleteAssistant(assistantId).getBody(); + + assertThat(deleteResponse).isNotNull(); + assertThat(deleteResponse.deleted()).isTrue(); + logger.info("Deleted Assistant ID: " + assistantId); + } + +} diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/assistants/OpenAiAssistantManagerIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/assistants/OpenAiAssistantManagerIT.java new file mode 100644 index 00000000000..d855498c65e --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/assistants/OpenAiAssistantManagerIT.java @@ -0,0 +1,176 @@ +/* + * Copyright 2024 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 + * + * https://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 org.springframework.ai.openai.assistants; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.openai.OpenAiAssistantManager; +import org.springframework.ai.openai.api.assistants.OpenAiAssistantApi; +import org.springframework.ai.openai.api.assistants.OpenAiAssistantApi.AssistantResponse; +import org.springframework.ai.openai.api.assistants.OpenAiAssistantApi.CreateAssistantRequest; +import org.springframework.ai.openai.api.assistants.OpenAiAssistantApi.ModifyAssistantRequest; +import org.springframework.ai.openai.api.assistants.ResponseFormat; +import org.springframework.ai.openai.api.assistants.tools.FunctionTool; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration Test for OpenAiAssistantManager lifecycle: create, retrieve, modify, list, + * and delete using high-level abstraction over the OpenAiAssistantApi. + * + * @author Alexandros Pappas + */ +@SpringBootTest +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +public class OpenAiAssistantManagerIT { + + private static final Logger logger = LoggerFactory.getLogger(OpenAiAssistantManagerIT.class); + + private final OpenAiAssistantApi openAiAssistantApi = new OpenAiAssistantApi(System.getenv("OPENAI_API_KEY")); + + private final OpenAiAssistantManager assistantManager = new OpenAiAssistantManager(openAiAssistantApi); + + @Test + void testCreateAndRetrieveAssistant() { + // Create an assistant + CreateAssistantRequest createAssistantRequest = CreateAssistantRequest.builder() + .withModel("gpt-4o-mini-2024-07-18") + .withName("Assistant Manager Test") + .withDescription("Created by Assistant Manager Test") + .withInstructions("Follow the test instructions.") + .withTools(List.of(new FunctionTool(new FunctionTool.Function("test_tool", "description", null, false)))) + .withMetadata(Map.of("key", "value")) + .withTemperature(0.7) + .withTopP(0.9) + .withResponseFormat(ResponseFormat.auto()) + .build(); + + AssistantResponse createdAssistant = assistantManager.createAssistant(createAssistantRequest); + + assertThat(createdAssistant.id()).isNotNull(); + assertThat(createdAssistant.name()).isEqualTo("Assistant Manager Test"); + assertThat(createdAssistant.model()).isEqualTo("gpt-4o-mini-2024-07-18"); + logger.info("Created Assistant: {}", createdAssistant); + + // Retrieve the assistant + AssistantResponse retrievedAssistant = assistantManager.retrieveAssistant(createdAssistant.id()); + + assertThat(retrievedAssistant).isNotNull(); + assertThat(retrievedAssistant.id()).isEqualTo(createdAssistant.id()); + assertThat(retrievedAssistant.name()).isEqualTo("Assistant Manager Test"); + logger.info("Retrieved Assistant: {}", retrievedAssistant); + } + + @Test + void testModifyAssistant() { + // Create an assistant + var createRequest = CreateAssistantRequest.builder() + .withModel("gpt-4o-mini-2024-07-18") + .withName("Assistant Modify Test") + .withDescription("Initial description") + .withInstructions("Initial instructions.") + .withTools(List.of(new FunctionTool(new FunctionTool.Function("test_tool", "description", null, false)))) + .withMetadata(Map.of("key", "value")) + .withTemperature(0.7) + .withTopP(0.9) + .withResponseFormat(ResponseFormat.auto()) + .build(); + + AssistantResponse createdAssistant = assistantManager.createAssistant(createRequest); + + // Modify the assistant + var modifyRequest = new ModifyAssistantRequest("gpt-4o-mini-2024-07-18", "Assistant Modify Test Updated", + "Updated description", "Updated instructions.", + List.of(new FunctionTool( + new FunctionTool.Function("updated_tool", "updated description", null, false))), + null, Map.of("key", "updated_value"), 0.6, 0.8, ResponseFormat.auto()); + + AssistantResponse modifiedAssistant = assistantManager.modifyAssistant(createdAssistant.id(), modifyRequest); + + assertThat(modifiedAssistant).isNotNull(); + assertThat(modifiedAssistant.name()).isEqualTo("Assistant Modify Test Updated"); + assertThat(modifiedAssistant.description()).isEqualTo("Updated description"); + logger.info("Modified Assistant: {}", modifiedAssistant); + } + + @Test + void testCreateRetrieveAndListAssistants() { + // Step 1: Create an assistant + var createRequest = CreateAssistantRequest.builder() + .withModel("gpt-4o-mini-2024-07-18") + .withName("Assistant List Test") + .withDescription("Created for listing test") + .withInstructions("List and validate this assistant.") + .withTools(List.of(new FunctionTool(new FunctionTool.Function("test_tool", "description", null, false)))) + .withMetadata(Map.of("key", "value")) + .withTemperature(0.7) + .withTopP(0.9) + .withResponseFormat(ResponseFormat.auto()) + .build(); + + AssistantResponse createdAssistant = assistantManager.createAssistant(createRequest); + + assertThat(createdAssistant).isNotNull(); + String createdAssistantId = createdAssistant.id(); + assertThat(createdAssistantId).isNotEmpty(); + logger.info("Created Assistant for listing test: {}", createdAssistant); + + // Step 2: List assistants and validate the created assistant is in the list + OpenAiAssistantApi.ListAssistantsResponse listResponse = assistantManager.listAssistants(10, "desc", null, + null); + + assertThat(listResponse.data()).isNotNull(); + assertThat(listResponse.data()).isNotEmpty(); + boolean assistantFound = listResponse.data() + .stream() + .anyMatch(assistant -> assistant.id().equals(createdAssistantId)); + assertThat(assistantFound).isTrue(); + logger.info("Validated Assistant in List: {}", createdAssistantId); + } + + @Test + void testDeleteAssistant() { + // Create an assistant + var createRequest = CreateAssistantRequest.builder() + .withModel("gpt-4o-mini-2024-07-18") + .withName("Assistant Delete Test") + .withDescription("Created for deletion") + .withInstructions("Delete me.") + .withTools(List.of(new FunctionTool(new FunctionTool.Function("test_tool", "description", null, false)))) + .withMetadata(Map.of("key", "value")) + .withTemperature(0.7) + .withTopP(0.9) + .withResponseFormat(ResponseFormat.auto()) + .build(); + + AssistantResponse createdAssistant = assistantManager.createAssistant(createRequest); + + // Delete the assistant + boolean deleted = assistantManager.deleteAssistant(createdAssistant.id()); + + assertThat(deleted).isTrue(); + logger.info("Deleted Assistant ID: {}", createdAssistant.id()); + } + +} diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/speech/OpenAiSpeechModelIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/speech/OpenAiSpeechModelIT.java index 033fd35d2b5..ed637c5e99b 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/speech/OpenAiSpeechModelIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/audio/speech/OpenAiSpeechModelIT.java @@ -105,10 +105,7 @@ void shouldStreamNonEmptyResponsesForValidSpeechPrompts() { assertThat(responseFlux).isNotNull(); List responses = responseFlux.collectList().block(); assertThat(responses).isNotNull(); - responses.forEach(response -> - // System.out.println("Audio data chunk size: " + - // response.getResult().getOutput().length); - assertThat(response.getResult().getOutput()).isNotEmpty()); + responses.forEach(response -> assertThat(response.getResult().getOutput()).isNotEmpty()); } } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/testutils/AbstractIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/testutils/AbstractIT.java index e12bb25c49e..f6b966e913f 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/testutils/AbstractIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/testutils/AbstractIT.java @@ -35,6 +35,7 @@ import org.springframework.ai.openai.OpenAiAudioTranscriptionModel; import org.springframework.ai.openai.OpenAiChatModel; import org.springframework.ai.openai.OpenAiModerationModel; +import org.springframework.ai.openai.api.assistants.OpenAiAssistantApi; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; @@ -70,6 +71,9 @@ public abstract class AbstractIT { @Autowired protected OpenAiModerationModel openAiModerationModel; + @Autowired + protected OpenAiAssistantApi openAiAssistantApi; + @Value("classpath:/prompts/eval/qa-evaluator-accurate-answer.st") protected Resource qaEvaluatorAccurateAnswerResource; diff --git a/spring-ai-core/src/main/java/org/springframework/ai/assistant/AssistantOptions.java b/spring-ai-core/src/main/java/org/springframework/ai/assistant/AssistantOptions.java new file mode 100644 index 00000000000..c82826ce5e9 --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/assistant/AssistantOptions.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024 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 + * + * https://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 org.springframework.ai.assistant; + +import org.springframework.ai.model.ModelOptions; +import org.springframework.lang.Nullable; + +import java.util.Map; + +/** + * AssistantOptions represent the common options, portable across different assistant + * models. + * + * @author Alexandros Pappas + */ +public interface AssistantOptions extends ModelOptions { + + @Nullable + String getModel(); + + @Nullable + String getName(); + + @Nullable + String getDescription(); + + @Nullable + String getInstructions(); + + @Nullable + Map getMetadata(); + + @Nullable + Double getTemperature(); + + @Nullable + Double getTopP(); + +} diff --git a/spring-ai-core/src/main/java/org/springframework/ai/assistant/AssistantOptionsBuilder.java b/spring-ai-core/src/main/java/org/springframework/ai/assistant/AssistantOptionsBuilder.java new file mode 100644 index 00000000000..7523654f28d --- /dev/null +++ b/spring-ai-core/src/main/java/org/springframework/ai/assistant/AssistantOptionsBuilder.java @@ -0,0 +1,160 @@ +/* + * Copyright 2024 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 + * + * https://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 org.springframework.ai.assistant; + +import java.util.Map; + +/** + * A builder class for creating instances of AssistantOptions. Use the builder() method to + * obtain a new instance of AssistantOptionsBuilder. Use the various with*() methods to + * set the fields for the assistant, such as model, name, description, and instructions. + * + * @author Alexandros Pappas + */ +public final class AssistantOptionsBuilder { + + private final DefaultAssistantModelOptions options = new DefaultAssistantModelOptions(); + + private AssistantOptionsBuilder() { + } + + public static AssistantOptionsBuilder builder() { + return new AssistantOptionsBuilder(); + } + + public AssistantOptionsBuilder withModel(String model) { + this.options.setModel(model); + return this; + } + + public AssistantOptionsBuilder withName(String name) { + this.options.setName(name); + return this; + } + + public AssistantOptionsBuilder withDescription(String description) { + this.options.setDescription(description); + return this; + } + + public AssistantOptionsBuilder withInstructions(String instructions) { + this.options.setInstructions(instructions); + return this; + } + + public AssistantOptionsBuilder withMetadata(Map metadata) { + this.options.setMetadata(metadata); + return this; + } + + public AssistantOptionsBuilder withTemperature(Double temperature) { + this.options.setTemperature(temperature); + return this; + } + + public AssistantOptionsBuilder withTopP(Double topP) { + this.options.setTopP(topP); + return this; + } + + public AssistantOptions build() { + return this.options; + } + + /** + * Default implementation of AssistantOptions for the builder. + */ + private static class DefaultAssistantModelOptions implements AssistantOptions { + + private String model; + + private String name; + + private String description; + + private String instructions; + + private Map metadata; + + private Double temperature; + + private Double topP; + + @Override + public String getModel() { + return this.model; + } + + public void setModel(String model) { + this.model = model; + } + + @Override + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String getDescription() { + return this.description; + } + + public void setDescription(String description) { + this.description = description; + } + + @Override + public String getInstructions() { + return this.instructions; + } + + public void setInstructions(String instructions) { + this.instructions = instructions; + } + + @Override + public Map getMetadata() { + return this.metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + + @Override + public Double getTemperature() { + return this.temperature; + } + + public void setTemperature(Double temperature) { + this.temperature = temperature; + } + + @Override + public Double getTopP() { + return this.topP; + } + + public void setTopP(Double topP) { + this.topP = topP; + } + + } + +} diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc index 470f3ca46b6..de7bf424e80 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc @@ -72,6 +72,8 @@ **** xref:api/audio/speech/openai-speech.adoc[OpenAI] ** xref:api/moderation[Moderation Models] *** xref:api/moderation/openai-moderation.adoc[OpenAI] +** xref:api/assistants[Assistant Manager API] +*** xref:api/assistants/openai-assistants.adoc[OpenAI] // ** xref:api/generic-model.adoc[] * xref:api/vectordbs.adoc[] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/assistants/openai-assistants.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/assistants/openai-assistants.adoc new file mode 100644 index 00000000000..8bff5ec8599 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/assistants/openai-assistants.adoc @@ -0,0 +1,189 @@ += Assistant API +== Introduction +Spring AI supports OpenAI's Assistant API, enabling developers to create, retrieve, modify, list, and delete assistants programmatically. + +Follow this https://platform.openai.com/docs/assistants/overview[guide] for more information on OpenAI's Assistant API. + +== Prerequisites +1. Create an OpenAI account and obtain an API key. You can sign up at the https://platform.openai.com/signup[OpenAI signup page] and generate an API key on the https://platform.openai.com/account/api-keys[API Keys page]. +2. Add the `spring-ai-openai` dependency to your project's build file. For more information, refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section. + +== Auto-configuration +Spring AI provides Spring Boot auto-configuration for the OpenAI Assistant API. + +To enable it, add the following dependency to your project's Maven `pom.xml` file: + +[source,xml] +---- + + org.springframework.ai + spring-ai-openai-spring-boot-starter + +---- + +or to your Gradle `build.gradle` file: + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +== Assistant Properties +=== Connection Properties +The prefix `spring.ai.openai` is used as the property prefix for connection configuration. + +[cols="3,5,1"] +|==== +| Property | Description | Default +| spring.ai.openai.base-url | The URL to connect to OpenAI. | `https://api.openai.com` +| spring.ai.openai.api-key | The API Key for authentication. | - +| spring.ai.openai.organization-id | Optionally, specify which organization is used for the request. | - +| spring.ai.openai.project-id | Optionally, specify which project is used for the request. | - +|==== + +=== Configuration Properties +The prefix `spring.ai.openai.assistant` is used to configure the Assistant API. + +[cols="3,5,2"] +|==== +| Property | Description | Default +| spring.ai.openai.assistant.base-url | URL to connect to the Assistant API. | `https://api.openai.com` +| spring.ai.openai.assistant.api-key | API Key for authentication. | - +| spring.ai.openai.assistant.options.model | Model ID for the Assistant API. | `gpt-4o-mini-2024-07-18` +| spring.ai.openai.assistant.options.name | Name of the assistant. | - +| spring.ai.openai.assistant.options.description | Description of the assistant. | - +| spring.ai.openai.assistant.options.instructions | Instructions for the assistant. | - +|==== + +TIP: Override the common `spring.ai.openai.base-url`, `spring.ai.openai.api-key`, and other connection properties to customize configurations for the Assistant API. + +== Runtime Options +The `OpenAiAssistantOptions` class provides runtime options for creating or modifying assistants. + +For example: + +[source,java] +---- +CreateAssistantRequest createAssistantRequest = CreateAssistantRequest.builder() + .withModel("gpt-4o-mini-2024-07-18") + .withName("Assistant Manager Test") + .withDescription("Created by Assistant Manager Test") + .withInstructions("Follow the test instructions.") + .withTools(List.of(new FunctionTool(new FunctionTool.Function("test_tool", "description", null, false)))) + .withMetadata(Map.of("key", "value")) + .withTemperature(0.7) + .withTopP(0.9) + .withResponseFormat(ResponseFormat.auto()) + .build(); + +AssistantResponse response = assistantManager.createAssistant(createAssistantRequest); +System.out.println("Assistant ID: " + response.id()); +---- + +== Manual Configuration +Add the `spring-ai-openai` dependency to your project's Maven `pom.xml` file: + +[source,xml] +---- + + org.springframework.ai + spring-ai-openai + +---- + +or to your Gradle `build.gradle` file: + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-openai' +} +---- + +Next, configure the Assistant API client manually if not using auto-configuration: + +[source,java] +---- +OpenAiAssistantApi openAiAssistantApi = new OpenAiAssistantApi("https://api.openai.com", System.getenv("OPENAI_API_KEY")); + +OpenAiAssistantManager assistantManager = new OpenAiAssistantManager(openAiAssistantApi); + +CreateAssistantRequest createRequest = CreateAssistantRequest.builder() + .withModel("gpt-4") + .withName("My Assistant") + .withDescription("An assistant to help with tasks.") + .build(); + +AssistantResponse response = assistantManager.createAssistant(createRequest); +System.out.println("Assistant ID: " + response.getId()); +---- + +== Example Code +Refer to the `OpenAiAssistantManagerIT` test for more examples of how to use the Assistant API. The test covers: +* Creating an assistant. +* Retrieving an assistant. +* Modifying an assistant. +* Listing assistants. +* Deleting an assistant. + +=== Creating an Assistant +[source,java] +---- +CreateAssistantRequest createAssistantRequest = CreateAssistantRequest.builder() + .withModel("gpt-4o-mini-2024-07-18") + .withName("Assistant Manager Test") + .withDescription("Created by Assistant Manager Test") + .withInstructions("Follow the test instructions.") + .withTools(List.of(new FunctionTool(new FunctionTool.Function("test_tool", "description", null, false)))) + .withMetadata(Map.of("key", "value")) + .withTemperature(0.7) + .withTopP(0.9) + .withResponseFormat(ResponseFormat.auto()) + .build(); + +AssistantResponse response = assistantManager.createAssistant(createAssistantRequest); +System.out.println("Created Assistant ID: " + response.id()); +---- + +=== Retrieving an Assistant +[source,java] +---- +String assistantId = "your-assistant-id"; // Replace with the actual ID of your assistant +AssistantResponse retrievedAssistant = openAiAssistantManager.retrieveAssistant(assistantId); +System.out.println("Retrieved Assistant Name: " + retrievedAssistant.name()); +---- + +=== Modifying an Assistant +[source,java] +---- +ModifyAssistantRequest modifyRequest = ModifyAssistantRequest.builder() + .withName("Updated Assistant") + .withDescription("This assistant has been updated.") + .withInstructions("You are an updated assistant with new instructions.") + .build(); + +AssistantResponse modifiedResponse = openAiAssistantManager.modifyAssistant(assistantId, modifyRequest); +System.out.println("Modified Assistant Name: " + modifiedResponse.name()); +---- + +=== Listing Assistants +[source,java] +---- +OpenAiAssistantApi.ListAssistantsResponse listResponse = openAiAssistantManager.listAssistants(10, "desc", null, null); + +listResponse.data().forEach(assistant -> { + System.out.println("Assistant ID: " + assistant.id() + ", Name: " + assistant.name()); +}); +---- + +=== Deleting an Assistant +[source,java] +---- +boolean isDeleted = openAiAssistantManager.deleteAssistant(assistantId); +System.out.println("Assistant Deleted: " + isDeleted); +---- + diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAssistantProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAssistantProperties.java new file mode 100644 index 00000000000..b92ad64c6d4 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAssistantProperties.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024 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 + * + * https://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 org.springframework.ai.autoconfigure.openai; + +import org.springframework.ai.openai.OpenAiAssistantOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * OpenAI Assistants autoconfiguration properties. + * + * @author Alexandros Pappas + */ +@ConfigurationProperties(OpenAiAssistantProperties.CONFIG_PREFIX) +public class OpenAiAssistantProperties extends OpenAiParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.openai.assistant"; + + public static final String DEFAULT_CHAT_MODEL = "gpt-4o-mini-2024-07-18"; + + /** + * Enable OpenAI Assistant api. + */ + private boolean enabled = true; + + /** + * Options for OpenAI Assistant API. + */ + @NestedConfigurationProperty + private OpenAiAssistantOptions options = OpenAiAssistantOptions.builder().withModel(DEFAULT_CHAT_MODEL).build(); + + public OpenAiAssistantOptions getOptions() { + return this.options; + } + + public void setOptions(OpenAiAssistantOptions options) { + this.options = options; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java index 7ce9ca291e2..e2973b0f630 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfiguration.java @@ -30,6 +30,7 @@ import org.springframework.ai.model.function.DefaultFunctionCallbackResolver; import org.springframework.ai.model.function.FunctionCallback; import org.springframework.ai.model.function.FunctionCallbackResolver; +import org.springframework.ai.openai.OpenAiAssistantManager; import org.springframework.ai.openai.OpenAiAudioSpeechModel; import org.springframework.ai.openai.OpenAiAudioTranscriptionModel; import org.springframework.ai.openai.OpenAiChatModel; @@ -40,6 +41,7 @@ import org.springframework.ai.openai.api.OpenAiAudioApi; import org.springframework.ai.openai.api.OpenAiImageApi; import org.springframework.ai.openai.api.OpenAiModerationApi; +import org.springframework.ai.openai.api.assistants.OpenAiAssistantApi; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; @@ -72,7 +74,7 @@ @ConditionalOnClass(OpenAiApi.class) @EnableConfigurationProperties({ OpenAiConnectionProperties.class, OpenAiChatProperties.class, OpenAiEmbeddingProperties.class, OpenAiImageProperties.class, OpenAiAudioTranscriptionProperties.class, - OpenAiAudioSpeechProperties.class, OpenAiModerationProperties.class }) + OpenAiAudioSpeechProperties.class, OpenAiModerationProperties.class, OpenAiAssistantProperties.class }) @ImportAutoConfiguration(classes = { SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class, WebClientAutoConfiguration.class }) public class OpenAiAutoConfiguration { @@ -107,6 +109,24 @@ public class OpenAiAutoConfiguration { return new ResolvedConnectionProperties(baseUrl, apiKey, CollectionUtils.toMultiValueMap(connectionHeaders)); } + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = OpenAiAssistantProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + public OpenAiAssistantManager openAiAssistantModel(OpenAiConnectionProperties commonProperties, + OpenAiAssistantProperties assistantProperties, RetryTemplate retryTemplate, + ObjectProvider restClientBuilderProvider, ResponseErrorHandler responseErrorHandler) { + + ResolvedConnectionProperties resolved = resolveConnectionProperties(commonProperties, assistantProperties, + "assistant"); + + var openAiAssistantApi = new OpenAiAssistantApi(resolved.baseUrl, resolved.apiKey(), + restClientBuilderProvider.getIfAvailable(RestClient::builder), responseErrorHandler); + + return new OpenAiAssistantManager(openAiAssistantApi, retryTemplate) + .withDefaultOptions(assistantProperties.getOptions()); + } + @Bean @ConditionalOnMissingBean @ConditionalOnProperty(prefix = OpenAiChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfigurationIT.java index c72052920f6..1b86a6b603c 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfigurationIT.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiAutoConfigurationIT.java @@ -18,6 +18,7 @@ import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import org.apache.commons.logging.Log; @@ -33,12 +34,15 @@ import org.springframework.ai.embedding.EmbeddingResponse; import org.springframework.ai.image.ImagePrompt; import org.springframework.ai.image.ImageResponse; +import org.springframework.ai.openai.OpenAiAssistantManager; import org.springframework.ai.openai.OpenAiAudioSpeechModel; import org.springframework.ai.openai.OpenAiAudioTranscriptionModel; import org.springframework.ai.openai.OpenAiChatModel; import org.springframework.ai.openai.OpenAiEmbeddingModel; import org.springframework.ai.openai.OpenAiImageModel; import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.openai.api.assistants.OpenAiAssistantApi; +import org.springframework.ai.openai.api.assistants.ResponseFormat; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.core.io.ClassPathResource; @@ -202,4 +206,37 @@ void generateImageWithModel() { }); } + @Test + void testCreateAndRetrieveAssistant() { + this.contextRunner.run(context -> { + OpenAiAssistantManager assistantManager = context.getBean(OpenAiAssistantManager.class); + + // Create Assistant + var createRequest = OpenAiAssistantApi.CreateAssistantRequest.builder() + .withModel("gpt-4o-mini-2024-07-18") + .withName("Integration Test Assistant") + .withDescription("Assistant created during integration testing.") + .withInstructions("You are an integration test assistant.") + .withTools(List.of()) + .withMetadata(Map.of("test-key", "test-value")) + .withTemperature(0.7) + .withTopP(0.9) + .withResponseFormat(ResponseFormat.auto()) + .build(); + + OpenAiAssistantApi.AssistantResponse createdResponse = assistantManager.createAssistant(createRequest); + assertThat(createdResponse).isNotNull(); + assertThat(createdResponse.name()).isEqualTo("Integration Test Assistant"); + logger.info("Created Assistant: " + createdResponse); + + // Retrieve Assistant + OpenAiAssistantApi.AssistantResponse retrievedResponse = assistantManager + .retrieveAssistant(createdResponse.id()); + assertThat(retrievedResponse).isNotNull(); + assertThat(retrievedResponse.id()).isEqualTo(createdResponse.id()); + assertThat(retrievedResponse.name()).isEqualTo("Integration Test Assistant"); + logger.info("Retrieved Assistant: " + retrievedResponse); + }); + } + } diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java index a14ca15fe70..bcc095f1128 100644 --- a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/openai/OpenAiPropertiesTests.java @@ -684,4 +684,43 @@ void audioTranscriptionActivation() { } + @Test + public void assistantProperties() { + + new ApplicationContextRunner().withPropertyValues( + // Mock properties for OpenAiAssistantProperties + "spring.ai.openai.base-url=TEST_BASE_URL", "spring.ai.openai.api-key=abc123", + "spring.ai.openai.assistant.enabled=true", + "spring.ai.openai.assistant.options.model=gpt-4o-mini-2024-07-18", + "spring.ai.openai.assistant.options.name=TestAssistant", + "spring.ai.openai.assistant.options.description=This is a test assistant", + "spring.ai.openai.assistant.options.instructions=Follow these instructions", + "spring.ai.openai.assistant.options.metadata.key=value", + "spring.ai.openai.assistant.options.temperature=0.7", "spring.ai.openai.assistant.options.top-p=0.9") + .withConfiguration(AutoConfigurations.of(OpenAiAutoConfiguration.class)) + .run(context -> { + var assistantProperties = context.getBean(OpenAiAssistantProperties.class); + assertThat(assistantProperties).isNotNull(); + + var connectionProperties = context.getBean(OpenAiConnectionProperties.class); + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(assistantProperties.isEnabled()).isTrue(); + var options = assistantProperties.getOptions(); + assertThat(options.getModel()).isEqualTo("gpt-4o-mini-2024-07-18"); + assertThat(options.getName()).isEqualTo("TestAssistant"); + assertThat(options.getDescription()).isEqualTo("This is a test assistant"); + assertThat(options.getInstructions()).isEqualTo("Follow these instructions"); + assertThat(options.getMetadata().get("key")).isEqualTo("value"); + assertThat(options.getTemperature()).isEqualTo(0.7); + assertThat(options.getTopP()).isEqualTo(0.9); + + var optionsString = options.toString(); + assertThat(optionsString).contains("gpt-4o-mini-2024-07-18"); + assertThat(optionsString).contains("TestAssistant"); + assertThat(optionsString).contains("Follow these instructions"); + }); + } + }