feat: Anthropic AI Plugin (#29095)

## Description
Anthropic AI plugin - provides chat completion API support as a
datasource plugin

#### PR fixes following issue(s)
Fixes https://github.com/appsmithorg/appsmith/issues/29036

#### Type of change
- New feature (non-breaking change which adds functionality)

## Testing
#### How Has This Been Tested?
- [x] Manual
- [x] JUnit
- [ ] Jest
- [ ] Cypress

## Checklist:
#### Dev activity
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] PR is being merged under a feature flag


#### QA activity:
- [ ] [Speedbreak
features](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#speedbreakers-)
have been covered
- [ ] Test plan covers all impacted features and [areas of
interest](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#areas-of-interest-)
- [ ] Test plan has been peer reviewed by project stakeholders and other
QA members
- [ ] Manually tested functionality on DP
- [ ] We had an implementation alignment call with stakeholders post QA
Round 2
- [ ] Cypress test cases have been added and approved by SDET/manual QA
- [ ] Added `Test Plan Approved` label after Cypress tests were reviewed
- [ ] Added `Test Plan Approved` label after JUnit tests were reviewed


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a new Anthropic plugin with capabilities for testing data
sources, executing actions, and validating data source configurations.
- Added a search bar component to enhance user navigation and
interaction within the application.

- **Enhancements**
- Improved form control elements, including dropdown and field array
controls, for better user experience and interface consistency.
- Optimized HTTP request handling in the OpenAI plugin for increased
efficiency and performance.

- **Bug Fixes**
- Addressed issues with form control properties to ensure correct
behavior and data handling.

- **Documentation**
- Updated plugin properties and test documentation to reflect new
features and changes.

- **Refactor**
- Refactored various components and utilities for code clarity and
maintainability.

- **Tests**
- Added comprehensive tests for the new Anthropic plugin to ensure
reliability and functionality.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Diljit VJ <diljit@appsmith.com>
This commit is contained in:
Nirmal Sarswat 2023-12-05 16:03:27 +05:30 committed by GitHub
parent c1a7afe688
commit b03c51e05d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1511 additions and 89 deletions

View File

@ -3,7 +3,7 @@ import type { ControlProps } from "./BaseControl";
import BaseControl from "./BaseControl";
import styled from "styled-components";
import type { ControlType } from "constants/PropertyControlConstants";
import { get, isNil } from "lodash";
import { get, isEmpty, isNil } from "lodash";
import type { WrappedFieldInputProps, WrappedFieldMetaProps } from "redux-form";
import { Field } from "redux-form";
import { connect } from "react-redux";
@ -119,13 +119,18 @@ function renderDropdown(
} & DropDownControlProps,
): JSX.Element {
let selectedValue: string | string[];
if (isNil(props.input?.value)) {
if (isEmpty(props.input?.value)) {
if (props.isMultiSelect)
selectedValue = props?.initialValue ? (props.initialValue as string) : [];
else
else {
selectedValue = props?.initialValue
? (props.initialValue as string[])
: "";
if (props.setFirstOptionAsDefault && props.options.length > 0) {
selectedValue = props.options[0].value as string;
props.input?.onChange(selectedValue);
}
}
} else {
selectedValue = props.input?.value;
if (props.isMultiSelect) {
@ -267,6 +272,7 @@ export interface DropDownControlProps extends ControlProps {
fetchOptionsConditionally?: boolean;
isLoading: boolean;
formValues: Partial<Action>;
setFirstOptionAsDefault?: boolean;
}
interface ReduxDispatchProps {

View File

@ -1,25 +1,28 @@
import React, { useEffect } from "react";
import React, { useCallback } from "react";
import FormControl from "pages/Editor/FormControl";
import { Classes, Text, TextType } from "design-system-old";
import styled from "styled-components";
import { FieldArray } from "redux-form";
import type { ControlProps } from "./BaseControl";
import { Button } from "design-system";
const CenteredIcon = styled(Button)`
margin-top: 26px;
&.hide {
opacity: 0;
pointer-events: none;
}
const CenteredIconButton = styled(Button)<{
alignSelf?: string;
top?: string;
}>`
position: relative;
align-self: ${(props) => (props.alignSelf ? props.alignSelf : "center")};
top: ${(props) => (props.top ? props.top : "0px")};
`;
const PrimaryBox = styled.div`
display: flex;
width: min-content;
flex-direction: column;
border: 2px solid ${(props) => props.theme.colors.apiPane.dividerBg};
padding: 10px;
padding: 10px 0px 0px 0px;
> div:not(:first-child) .form-config-top {
display: none;
}
`;
const SecondaryBox = styled.div`
@ -28,11 +31,10 @@ const SecondaryBox = styled.div`
width: min-content;
align-items: center;
justify-content: space-between;
padding: 5px;
& > div {
margin-right: 8px;
height: 60px;
margin-bottom: 8px;
}
& > .t--form-control-QUERY_DYNAMIC_INPUT_TEXT > div {
@ -47,21 +49,22 @@ const SecondaryBox = styled.div`
`;
const AddMoreAction = styled.div`
width: fit-content;
cursor: pointer;
display: flex;
margin-top: 16px;
.${Classes.TEXT} {
margin-left: 8px;
color: #03b365;
}
width: max-content;
`;
function NestedComponents(props: any) {
useEffect(() => {
if (props.fields.length < 1) {
props.fields.push({});
}
}, [props.fields.length]);
const addMore = useCallback(() => {
const { schema = {} } = props;
const newObject: any = {};
schema.forEach((s: any) => {
newObject[s.key] = s.initialValue || "";
});
props.fields.push(newObject);
}, [props.fields]);
return (
<PrimaryBox>
{props.fields &&
@ -75,7 +78,7 @@ function NestedComponents(props: any) {
configProperty: `${field}.${sch.key}`,
customStyles: {
width: "20vw",
...props.customStyles,
...(props.customStyles ?? {}),
},
};
return (
@ -86,22 +89,31 @@ function NestedComponents(props: any) {
/>
);
})}
<CenteredIcon
isIconButton
<CenteredIconButton
alignSelf={"start"}
data-testid={`t--where-clause-delete-[${index}]`}
kind="tertiary"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
props.fields.remove(index);
}}
size="sm"
startIcon="delete"
size="md"
startIcon="close"
top={index === 0 ? "20px" : ""}
/>
</SecondaryBox>
);
})}
<AddMoreAction onClick={() => props.fields.push({})}>
{/*Hardcoded label to be removed */}
<Text type={TextType.H5}>{props.addMoreButtonLabel}</Text>
<AddMoreAction>
<Button
className={`t--where-add-condition[${props?.currentNestingLevel}]`}
kind="tertiary"
onClick={addMore}
size="md"
startIcon="add-more"
>
{props.addMoreButtonLabel}
</Button>
</AddMoreAction>
</PrimaryBox>
);
@ -111,6 +123,7 @@ export default function FieldArrayControl(props: FieldArrayControlProps) {
const {
addMoreButtonLabel = "+ Add Condition (And)",
configProperty,
customStyles = {},
formName,
schema,
} = props;
@ -118,7 +131,13 @@ export default function FieldArrayControl(props: FieldArrayControlProps) {
<FieldArray
component={NestedComponents}
name={configProperty}
props={{ formName, schema, addMoreButtonLabel }}
props={{
formName,
schema,
addMoreButtonLabel,
configProperty,
customStyles,
}}
rerenderOnEveryChange={false}
/>
);

View File

@ -238,10 +238,10 @@ function QueryResponseView({
(actionResponse.pluginErrorDetails ? (
<>
<div data-testid="t--query-error">
{
{actionResponse.pluginErrorDetails
.downstreamErrorMessage ||
actionResponse.pluginErrorDetails
.downstreamErrorMessage
}
.appsmithErrorMessage}
</div>
{actionResponse.pluginErrorDetails
.downstreamErrorCode && (

View File

@ -13,6 +13,7 @@ public interface PluginConstants {
String REST_API_PLUGIN = "restapi-plugin";
String GRAPH_QL_PLUGIN = "graphql-plugin";
String OPEN_AI_PLUGIN = "openai-plugin";
String ANTHROPIC_PLUGIN = "anthropic-plugin";
}
public static final String DEFAULT_REST_DATASOURCE = "DEFAULT_REST_DATASOURCE";
@ -37,6 +38,7 @@ public interface PluginConstants {
public static final String SNOWFLAKE_PLUGIN_NAME = "Snowflake";
public static final String OPEN_AI_PLUGIN_NAME = "Open AI";
public static final String ANTHROPIC_PLUGIN_NAME = "Anthropic";
}
interface HostName {

View File

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.appsmith</groupId>
<artifactId>appsmith-plugins</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<groupId>com.external.plugins</groupId>
<artifactId>anthropicPlugin</artifactId>
<version>1.0-SNAPSHOT</version>
<name>anthropicPlugin</name>
<url>http://maven.apache.org</url>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webflux</artifactId>
<exclusions>
<exclusion>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson-bom.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jdk8</artifactId>
<version>${jackson-bom.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson-bom.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.0.1-jre</version>
</dependency>
<!--
Ideally this dependency should have been added with 'compile' scope here. But that is causing 'java.lang
.NoClassDefFoundError'. After trying to fix it right way many times, I decided to move the dependency to
the main server module's pom.xml file and keep the dependency here with 'provided' scope. This seems to
fix the problem for now.
-->
<!-- Test dependencies -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.13.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor.netty</groupId>
<artifactId>reactor-netty-http</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,274 @@
package com.external.plugins;
import com.appsmith.external.dtos.ExecuteActionDTO;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException;
import com.appsmith.external.helpers.restApiUtils.connections.APIConnection;
import com.appsmith.external.helpers.restApiUtils.helpers.RequestCaptureFilter;
import com.appsmith.external.models.ActionConfiguration;
import com.appsmith.external.models.ActionExecutionRequest;
import com.appsmith.external.models.ActionExecutionResult;
import com.appsmith.external.models.ApiKeyAuth;
import com.appsmith.external.models.DatasourceConfiguration;
import com.appsmith.external.models.DatasourceTestResult;
import com.appsmith.external.models.TriggerRequestDTO;
import com.appsmith.external.models.TriggerResultDTO;
import com.appsmith.external.plugins.BasePlugin;
import com.appsmith.external.plugins.BaseRestApiPluginExecutor;
import com.appsmith.external.services.SharedConfig;
import com.external.plugins.commands.AnthropicCommand;
import com.external.plugins.constants.AnthropicConstants;
import com.external.plugins.models.AnthropicRequestDTO;
import com.external.plugins.utils.AnthropicMethodStrategy;
import com.external.plugins.utils.RequestUtils;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.gson.Gson;
import lombok.extern.slf4j.Slf4j;
import org.json.JSONArray;
import org.json.JSONObject;
import org.pf4j.PluginWrapper;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatusCode;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.BodyInserters;
import reactor.core.publisher.Mono;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static com.external.plugins.constants.AnthropicConstants.ANTHROPIC_MODELS;
import static com.external.plugins.constants.AnthropicConstants.BODY;
import static com.external.plugins.constants.AnthropicConstants.LABEL;
import static com.external.plugins.constants.AnthropicConstants.TEST_MODEL;
import static com.external.plugins.constants.AnthropicConstants.TEST_PROMPT;
import static com.external.plugins.constants.AnthropicConstants.VALUE;
import static com.external.plugins.constants.AnthropicErrorMessages.EMPTY_API_KEY;
import static com.external.plugins.constants.AnthropicErrorMessages.INVALID_API_KEY;
import static com.external.plugins.constants.AnthropicErrorMessages.QUERY_FAILED_TO_EXECUTE;
@Slf4j
public class AnthropicPlugin extends BasePlugin {
public AnthropicPlugin(PluginWrapper wrapper) {
super(wrapper);
}
public static class AnthropicPluginExecutor extends BaseRestApiPluginExecutor {
private static final Gson gson = new Gson();
private static final Cache<String, TriggerResultDTO> triggerResponseCache =
CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.DAYS).build();
protected AnthropicPluginExecutor(SharedConfig sharedConfig) {
super(sharedConfig);
}
@Override
public Mono<DatasourceTestResult> testDatasource(DatasourceConfiguration datasourceConfiguration) {
final ApiKeyAuth apiKeyAuth = (ApiKeyAuth) datasourceConfiguration.getAuthentication();
if (!StringUtils.hasText(apiKeyAuth.getValue())) {
return Mono.error(new AppsmithPluginException(
AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, EMPTY_API_KEY));
}
AnthropicCommand anthropicCommand = AnthropicMethodStrategy.selectExecutionMethod(AnthropicConstants.CHAT);
URI uri = anthropicCommand.createExecutionUri();
HttpMethod httpMethod = anthropicCommand.getExecutionMethod();
AnthropicRequestDTO anthropicRequestDTO = new AnthropicRequestDTO();
anthropicRequestDTO.setPrompt(TEST_PROMPT);
anthropicRequestDTO.setModel(TEST_MODEL);
anthropicRequestDTO.setMaxTokensToSample(1);
anthropicRequestDTO.setTemperature(0f);
return RequestUtils.makeRequest(httpMethod, uri, apiKeyAuth, BodyInserters.fromValue(anthropicRequestDTO))
.map(responseEntity -> {
HttpStatusCode statusCode = responseEntity.getStatusCode();
if (HttpStatusCode.valueOf(401).isSameCodeAs(statusCode)) {
// invalid credentials
return new DatasourceTestResult(INVALID_API_KEY);
}
return new DatasourceTestResult();
})
.onErrorResume(error -> Mono.just(new DatasourceTestResult(
"Error while trying to test the datasource configurations" + error.getMessage())));
}
@Override
public Mono<ActionExecutionResult> executeParameterized(
APIConnection connection,
ExecuteActionDTO executeActionDTO,
DatasourceConfiguration datasourceConfiguration,
ActionConfiguration actionConfiguration) {
// Get prompt from action configuration
List<Map.Entry<String, String>> parameters = new ArrayList<>();
prepareConfigurationsForExecution(executeActionDTO, actionConfiguration, datasourceConfiguration);
// Filter out any empty headers
headerUtils.removeEmptyHeaders(actionConfiguration);
headerUtils.setHeaderFromAutoGeneratedHeaders(actionConfiguration);
// Initializing object for error condition
ActionExecutionResult errorResult = new ActionExecutionResult();
initUtils.initializeResponseWithError(errorResult);
AnthropicCommand anthropicCommand =
AnthropicMethodStrategy.selectExecutionMethod(actionConfiguration, gson);
AnthropicRequestDTO anthropicRequestDTO = anthropicCommand.makeRequestBody(actionConfiguration);
URI uri = anthropicCommand.createExecutionUri();
HttpMethod httpMethod = anthropicCommand.getExecutionMethod();
ActionExecutionRequest actionExecutionRequest =
RequestCaptureFilter.populateRequestFields(actionConfiguration, uri, parameters, objectMapper);
final ApiKeyAuth apiKeyAuth = (ApiKeyAuth) datasourceConfiguration.getAuthentication();
if (!StringUtils.hasText(apiKeyAuth.getValue())) {
ActionExecutionResult apiKeyNotPresentErrorResult = new ActionExecutionResult();
apiKeyNotPresentErrorResult.setIsExecutionSuccess(false);
apiKeyNotPresentErrorResult.setErrorInfo(new AppsmithPluginException(
AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, EMPTY_API_KEY));
return Mono.just(apiKeyNotPresentErrorResult);
}
return RequestUtils.makeRequest(httpMethod, uri, apiKeyAuth, BodyInserters.fromValue(anthropicRequestDTO))
.flatMap(responseEntity -> {
HttpStatusCode statusCode = responseEntity.getStatusCode();
ActionExecutionResult actionExecutionResult = new ActionExecutionResult();
actionExecutionResult.setRequest(actionExecutionRequest);
actionExecutionResult.setStatusCode(statusCode.toString());
if (HttpStatusCode.valueOf(401).isSameCodeAs(statusCode)) {
actionExecutionResult.setIsExecutionSuccess(false);
String errorMessage = "";
if (responseEntity.getBody() != null && responseEntity.getBody().length > 0) {
errorMessage = new String(responseEntity.getBody());
}
actionExecutionResult.setErrorInfo(new AppsmithPluginException(
AppsmithPluginError.PLUGIN_AUTHENTICATION_ERROR, errorMessage));
return Mono.just(actionExecutionResult);
}
if (statusCode.is4xxClientError()) {
actionExecutionResult.setIsExecutionSuccess(false);
String errorMessage = "";
if (responseEntity.getBody() != null && responseEntity.getBody().length > 0) {
errorMessage = new String(responseEntity.getBody());
}
actionExecutionResult.setErrorInfo(new AppsmithPluginException(
AppsmithPluginError.PLUGIN_DATASOURCE_ERROR, errorMessage));
return Mono.just(actionExecutionResult);
}
Object body;
try {
body = objectMapper.readValue(responseEntity.getBody(), Object.class);
actionExecutionResult.setBody(body);
} catch (IOException ex) {
actionExecutionResult.setIsExecutionSuccess(false);
actionExecutionResult.setErrorInfo(new AppsmithPluginException(
AppsmithPluginError.PLUGIN_JSON_PARSE_ERROR, BODY, ex.getMessage()));
return Mono.just(actionExecutionResult);
}
if (!statusCode.is2xxSuccessful()) {
actionExecutionResult.setIsExecutionSuccess(false);
actionExecutionResult.setErrorInfo(new AppsmithPluginException(
AppsmithPluginError.PLUGIN_ERROR, QUERY_FAILED_TO_EXECUTE, body));
return Mono.just(actionExecutionResult);
}
actionExecutionResult.setIsExecutionSuccess(true);
return Mono.just(actionExecutionResult);
})
.onErrorResume(error -> {
errorResult.setIsExecutionSuccess(false);
log.debug(
"An error has occurred while trying to run the anthropic API query command with error {}",
error.getStackTrace());
if (!(error instanceof AppsmithPluginException)) {
error = new AppsmithPluginException(
AppsmithPluginError.PLUGIN_ERROR, error.getMessage(), error);
}
errorResult.setErrorInfo(error);
return Mono.just(errorResult);
});
}
@Override
public Mono<TriggerResultDTO> trigger(
APIConnection connection, DatasourceConfiguration datasourceConfiguration, TriggerRequestDTO request) {
final ApiKeyAuth apiKeyAuth = (ApiKeyAuth) datasourceConfiguration.getAuthentication();
if (!StringUtils.hasText(apiKeyAuth.getValue())) {
return Mono.error(new AppsmithPluginException(
AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, EMPTY_API_KEY));
}
if (!StringUtils.hasText(request.getRequestType())) {
throw new AppsmithPluginException(
AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, "request type is missing");
}
String requestType = request.getRequestType();
AnthropicCommand anthropicCommand = AnthropicMethodStrategy.selectTriggerMethod(request, gson);
HttpMethod httpMethod = anthropicCommand.getTriggerHTTPMethod();
URI uri = anthropicCommand.createTriggerUri();
TriggerResultDTO triggerResultDTO = triggerResponseCache.getIfPresent(requestType);
if (triggerResultDTO != null) {
return Mono.just(triggerResultDTO);
}
return RequestUtils.makeRequest(httpMethod, uri, apiKeyAuth, BodyInserters.empty())
.flatMap(responseEntity -> {
if (responseEntity.getStatusCode().is4xxClientError()) {
return Mono.error(
new AppsmithPluginException(AppsmithPluginError.PLUGIN_AUTHENTICATION_ERROR));
}
if (!responseEntity.getStatusCode().is2xxSuccessful()) {
return Mono.error(
new AppsmithPluginException(AppsmithPluginError.PLUGIN_GET_STRUCTURE_ERROR));
}
// link to get response data https://platform.openai.com/docs/api-reference/models/list
return Mono.just(new JSONObject(new String(responseEntity.getBody())));
})
.map(jsonObject -> {
JSONArray jsonArray = jsonObject.getJSONArray("data");
int len = jsonArray.length();
List<String> models = new ArrayList<>();
for (int i = 0; i < len; i++) {
models.add(jsonArray.getString(i));
}
return getDataToMap(models);
})
.onErrorResume(error -> {
log.debug("Error while fetching Anthropic models list", error);
return Mono.just(getDataToMap(ANTHROPIC_MODELS));
})
.map(trigger -> {
TriggerResultDTO triggerResult = new TriggerResultDTO(trigger);
// saving response on request type
triggerResponseCache.put(requestType, triggerResult);
return triggerResult;
});
}
@Override
public Set<String> validateDatasource(DatasourceConfiguration datasourceConfiguration) {
return RequestUtils.validateApiKeyAuthDatasource(datasourceConfiguration);
}
private List<Map<String, String>> getDataToMap(List<String> data) {
return data.stream().sorted().map(x -> Map.of(LABEL, x, VALUE, x)).collect(Collectors.toList());
}
}
}

View File

@ -0,0 +1,19 @@
package com.external.plugins.commands;
import com.appsmith.external.models.ActionConfiguration;
import com.external.plugins.models.AnthropicRequestDTO;
import org.springframework.http.HttpMethod;
import java.net.URI;
public interface AnthropicCommand {
HttpMethod getTriggerHTTPMethod();
HttpMethod getExecutionMethod();
URI createTriggerUri();
URI createExecutionUri();
AnthropicRequestDTO makeRequestBody(ActionConfiguration actionConfiguration);
}

View File

@ -0,0 +1,193 @@
package com.external.plugins.commands;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException;
import com.appsmith.external.models.ActionConfiguration;
import com.external.plugins.constants.AnthropicConstants;
import com.external.plugins.models.AnthropicRequestDTO;
import com.external.plugins.models.Role;
import com.external.plugins.utils.RequestUtils;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import org.springframework.http.HttpMethod;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UriComponentsBuilder;
import java.lang.reflect.Type;
import java.net.URI;
import java.util.List;
import java.util.Map;
import static com.external.plugins.constants.AnthropicConstants.ANTHROPIC;
import static com.external.plugins.constants.AnthropicConstants.CHAT;
import static com.external.plugins.constants.AnthropicConstants.CHAT_MODEL_SELECTOR;
import static com.external.plugins.constants.AnthropicConstants.CLOUD_SERVICES;
import static com.external.plugins.constants.AnthropicConstants.COMMAND;
import static com.external.plugins.constants.AnthropicConstants.COMPONENT;
import static com.external.plugins.constants.AnthropicConstants.COMPONENT_DATA;
import static com.external.plugins.constants.AnthropicConstants.CONTENT;
import static com.external.plugins.constants.AnthropicConstants.DATA;
import static com.external.plugins.constants.AnthropicConstants.DEFAULT_MAX_TOKEN;
import static com.external.plugins.constants.AnthropicConstants.DEFAULT_TEMPERATURE;
import static com.external.plugins.constants.AnthropicConstants.JSON;
import static com.external.plugins.constants.AnthropicConstants.MAX_TOKENS;
import static com.external.plugins.constants.AnthropicConstants.MESSAGES;
import static com.external.plugins.constants.AnthropicConstants.MODELS_API;
import static com.external.plugins.constants.AnthropicConstants.PROVIDER;
import static com.external.plugins.constants.AnthropicConstants.ROLE;
import static com.external.plugins.constants.AnthropicConstants.TEMPERATURE;
import static com.external.plugins.constants.AnthropicConstants.VIEW_TYPE;
import static com.external.plugins.constants.AnthropicErrorMessages.BAD_MAX_TOKEN_CONFIGURATION;
import static com.external.plugins.constants.AnthropicErrorMessages.BAD_TEMPERATURE_CONFIGURATION;
import static com.external.plugins.constants.AnthropicErrorMessages.EXECUTION_FAILURE;
import static com.external.plugins.constants.AnthropicErrorMessages.MODEL_NOT_SELECTED;
import static com.external.plugins.constants.AnthropicErrorMessages.QUERY_NOT_CONFIGURED;
import static com.external.plugins.constants.AnthropicErrorMessages.STRING_APPENDER;
public class ChatCommand implements AnthropicCommand {
private final Gson gson = new Gson();
@Override
public HttpMethod getTriggerHTTPMethod() {
return HttpMethod.GET;
}
@Override
public HttpMethod getExecutionMethod() {
return HttpMethod.POST;
}
@Override
public URI createTriggerUri() {
return UriComponentsBuilder.fromUriString(CLOUD_SERVICES + MODELS_API)
.queryParam(PROVIDER, ANTHROPIC)
.queryParam(COMMAND, CHAT.toLowerCase())
.build()
.toUri();
}
@Override
public URI createExecutionUri() {
return RequestUtils.createUriFromCommand(AnthropicConstants.CHAT);
}
@Override
public AnthropicRequestDTO makeRequestBody(ActionConfiguration actionConfiguration) {
Map<String, Object> formData = actionConfiguration.getFormData();
if (CollectionUtils.isEmpty(formData)) {
throw new AppsmithPluginException(
AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR,
String.format(STRING_APPENDER, EXECUTION_FAILURE, QUERY_NOT_CONFIGURED));
}
AnthropicRequestDTO anthropicRequestDTO = new AnthropicRequestDTO();
String model = RequestUtils.extractDataFromFormData(formData, CHAT_MODEL_SELECTOR);
if (!StringUtils.hasText(model)) {
throw new AppsmithPluginException(
AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR,
String.format(STRING_APPENDER, EXECUTION_FAILURE, MODEL_NOT_SELECTED));
}
anthropicRequestDTO.setModel(model);
Float temperature = getTemperatureFromFormData(formData);
anthropicRequestDTO.setTemperature(temperature);
anthropicRequestDTO.setMaxTokensToSample(getMaxTokenFromFormData(formData));
anthropicRequestDTO.setPrompt(createPrompt(formData));
return anthropicRequestDTO;
}
/**
* This is the kind of format we want to build from the messages as a prompt.
* Example Prompt: `\n\nHuman: ${query}\n\nAssistant:`
* Lastly, we leave it with an additional Assistant: so that it can respond back as an assistant
*/
private String createPrompt(Map<String, Object> formData) {
StringBuilder stringBuilder = new StringBuilder();
if (formData.containsKey(MESSAGES)) {
List<Map<String, String>> messageMaps = getMessages((Map<String, Object>) formData.get(MESSAGES));
if (messageMaps == null) {
throw new AppsmithPluginException(
AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR,
"messages are not provided in the configuration correctly");
}
for (Map<String, String> messageMap : messageMaps) {
if (messageMap != null && messageMap.containsKey(ROLE) && messageMap.containsKey(CONTENT)) {
stringBuilder
.append("\n\n")
.append(messageMap.get(ROLE))
.append(": ")
.append(messageMap.get(CONTENT));
}
}
return stringBuilder.append("\n").append(Role.Assistant).append(":").toString();
} else {
throw new AppsmithPluginException(
AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR,
"messages are not provided in the configuration");
}
}
private List<Map<String, String>> getMessages(Map<String, Object> messages) {
Type listType = new TypeToken<List<Map<String, String>>>() {}.getType();
if (messages.containsKey(VIEW_TYPE)) {
if (JSON.equals(messages.get(VIEW_TYPE))) {
// data is present in data key as String
return gson.fromJson((String) messages.get(DATA), listType);
} else if (COMPONENT.equals(messages.get(VIEW_TYPE))) {
return (List<Map<String, String>>) messages.get(COMPONENT_DATA);
}
}
// return object stored in data key
return (List<Map<String, String>>) messages.get(DATA);
}
/**
* Finds right data key from formData.messages. If viewType is present and it's json, then use `componentData`key
* else use `data` key to find right messages.
*/
private String findDataKey(Map<String, Object> messages) {
if (messages.containsKey(VIEW_TYPE) && "json".equals(messages.get(VIEW_TYPE))) {
return COMPONENT_DATA;
}
return DATA;
}
private int getMaxTokenFromFormData(Map<String, Object> formData) {
String maxTokenAsString = RequestUtils.extractValueFromFormData(formData, MAX_TOKENS);
if (!StringUtils.hasText(maxTokenAsString)) {
return DEFAULT_MAX_TOKEN;
}
try {
return Integer.parseInt(maxTokenAsString);
} catch (IllegalArgumentException illegalArgumentException) {
return DEFAULT_MAX_TOKEN;
} catch (Exception exception) {
throw new AppsmithPluginException(
AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR,
String.format(STRING_APPENDER, EXECUTION_FAILURE, BAD_MAX_TOKEN_CONFIGURATION));
}
}
private Float getTemperatureFromFormData(Map<String, Object> formData) {
String temperatureString = RequestUtils.extractValueFromFormData(formData, TEMPERATURE);
if (!StringUtils.hasText(temperatureString)) {
return DEFAULT_TEMPERATURE;
}
try {
return Float.parseFloat(temperatureString);
} catch (IllegalArgumentException illegalArgumentException) {
return DEFAULT_TEMPERATURE;
} catch (Exception exception) {
throw new AppsmithPluginException(
AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR,
String.format(STRING_APPENDER, EXECUTION_FAILURE, BAD_TEMPERATURE_CONFIGURATION));
}
}
}

View File

@ -0,0 +1,44 @@
package com.external.plugins.constants;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
import java.util.List;
public class AnthropicConstants {
public static final String ANTHROPIC_API_ENDPOINT = "https://api.anthropic.com/v1";
public static final String COMPLETION_API = "/complete";
public static final String CHAT_MODELS = "CHAT_MODELS";
public static final String CHAT = "CHAT";
public static final String COMMAND = "command";
public static final String DATA = "data";
public static final String VIEW_TYPE = "viewType";
public static final String COMPONENT_DATA = "componentData";
public static final String BODY = "body";
public static final String ROLE = "role";
public static final String CONTENT = "content";
public static final String MODEL = "model";
public static final String CHAT_MODEL_SELECTOR = "chatModel";
public static final String MESSAGES = "messages";
public static final String TEMPERATURE = "temperature";
public static final String MAX_TOKENS = "maxTokens";
public static final Integer DEFAULT_MAX_TOKEN = 256;
public static final Float DEFAULT_TEMPERATURE = 1.0f;
public static final String LABEL = "label";
public static final String VALUE = "value";
public static final String API_KEY_HEADER = "x-api-key";
public static final String ANTHROPIC_VERSION_HEADER = "anthropic-version";
public static final String ANTHROPIC_VERSION = "2023-06-01";
public static final List<String> ANTHROPIC_MODELS =
List.of("claude-2.1", "claude-2", "claude-instant-1.2", "claude-instant-1");
public static final String CLOUD_SERVICES = "https://cs.appsmith.com";
public static final String MODELS_API = "/api/v1/ai/models";
public static final String PROVIDER = "provider";
public static final String ANTHROPIC = "anthropic";
public static final String TEST_MODEL = "claude-instant-1";
public static final String TEST_PROMPT = "Human:Hey Assistant:";
public static final ExchangeStrategies EXCHANGE_STRATEGIES = ExchangeStrategies.builder()
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(/* 10MB */ 10 * 1024 * 1024))
.build();
public static final String JSON = "json";
public static final String COMPONENT = "component";
}

View File

@ -0,0 +1,19 @@
package com.external.plugins.constants;
public class AnthropicErrorMessages {
public static final String STRING_APPENDER = "%s %s";
public static final String EXECUTION_FAILURE = "Query failed to execute because";
public static final String QUERY_FAILED_TO_EXECUTE = "Your query failed to execute";
public static final String MODEL_NOT_SELECTED = "model hasn't been selected. Please select a model";
public static final String QUERY_NOT_CONFIGURED = "query is not configured.";
public static final String BAD_TEMPERATURE_CONFIGURATION =
"temperature value doesn't conform to the contract. Please enter a real number between 0 and 2";
public static final String BAD_MAX_TOKEN_CONFIGURATION = "max token value is not an integer number";
public static final String INCORRECT_ROLE_VALUE =
"role value is incorrect. Please choose a role value which conforms to the OpenAI contract";
public static final String INCORRECT_MESSAGE_FORMAT =
"messages object is not correctly configured. Please provide a list of messages";
public static final String EMPTY_API_KEY = "API Key should not be empty, Please add an API Key";
public static final String INVALID_API_KEY =
"Invalid authentication credentials provided in datasource configurations";
}

View File

@ -0,0 +1,16 @@
package com.external.plugins.models;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class AnthropicRequestDTO {
String model;
String prompt;
Integer maxTokensToSample;
Float temperature;
}

View File

@ -0,0 +1,6 @@
package com.external.plugins.models;
public enum Role {
Assistant,
Human
}

View File

@ -0,0 +1,47 @@
package com.external.plugins.utils;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException;
import com.appsmith.external.models.ActionConfiguration;
import com.appsmith.external.models.TriggerRequestDTO;
import com.external.plugins.commands.AnthropicCommand;
import com.external.plugins.commands.ChatCommand;
import com.external.plugins.constants.AnthropicConstants;
import com.google.gson.Gson;
import org.springframework.util.CollectionUtils;
import reactor.core.Exceptions;
import java.util.Map;
import static com.external.plugins.utils.RequestUtils.extractDataFromFormData;
public class AnthropicMethodStrategy {
public static AnthropicCommand selectTriggerMethod(TriggerRequestDTO triggerRequestDTO, Gson gson) {
String requestType = triggerRequestDTO.getRequestType();
return switch (requestType) {
case AnthropicConstants.CHAT_MODELS -> new ChatCommand();
default -> throw Exceptions.propagate(
new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR));
};
}
public static AnthropicCommand selectExecutionMethod(ActionConfiguration actionConfiguration, Gson gson) {
Map<String, Object> formData = actionConfiguration.getFormData();
if (CollectionUtils.isEmpty(formData)) {
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR);
}
String command = extractDataFromFormData(formData, AnthropicConstants.COMMAND);
return selectExecutionMethod(command);
}
public static AnthropicCommand selectExecutionMethod(String command) {
return switch (command) {
case AnthropicConstants.CHAT -> new ChatCommand();
default -> throw Exceptions.propagate(new AppsmithPluginException(
AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, "Unsupported command: " + command));
};
}
}

View File

@ -0,0 +1,99 @@
package com.external.plugins.utils;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException;
import com.appsmith.external.models.ApiKeyAuth;
import com.appsmith.external.models.DatasourceConfiguration;
import com.external.plugins.constants.AnthropicConstants;
import com.external.plugins.constants.AnthropicErrorMessages;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.reactive.ClientHttpRequest;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.BodyInserter;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;
import java.net.URI;
import java.time.Duration;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import static com.external.plugins.constants.AnthropicConstants.ANTHROPIC_API_ENDPOINT;
import static com.external.plugins.constants.AnthropicConstants.COMPLETION_API;
public class RequestUtils {
private static final WebClient webClient = createWebClient();
public static String extractDataFromFormData(Map<String, Object> formData, String key) {
return (String) ((Map<String, Object>) formData.get(key)).get(AnthropicConstants.DATA);
}
public static String extractValueFromFormData(Map<String, Object> formData, String key) {
return (String) formData.get(key);
}
public static URI createUriFromCommand(String command) {
if (AnthropicConstants.CHAT.equals(command)) {
return URI.create(ANTHROPIC_API_ENDPOINT + COMPLETION_API);
} else if (AnthropicConstants.MODEL.equals(command)) {
return URI.create("");
} else {
throw new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR);
}
}
public static Mono<ResponseEntity<byte[]>> makeRequest(
HttpMethod httpMethod, URI uri, ApiKeyAuth apiKeyAuth, BodyInserter<?, ? super ClientHttpRequest> body) {
// Authentication will already be valid at this point
assert (apiKeyAuth.getValue() != null);
return webClient
.method(httpMethod)
.uri(uri)
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.headers(headers -> {
headers.set(AnthropicConstants.API_KEY_HEADER, apiKeyAuth.getValue());
headers.set(AnthropicConstants.ANTHROPIC_VERSION_HEADER, AnthropicConstants.ANTHROPIC_VERSION);
})
.exchangeToMono(clientResponse -> clientResponse.toEntity(byte[].class));
}
private static WebClient createWebClient() {
// Initializing webClient to be used for http call
WebClient.Builder webClientBuilder = WebClient.builder();
return webClientBuilder
.exchangeStrategies(AnthropicConstants.EXCHANGE_STRATEGIES)
.clientConnector(new ReactorClientHttpConnector(HttpClient.create(connectionProvider())))
.build();
}
private static ConnectionProvider connectionProvider() {
return ConnectionProvider.builder("anthropic")
.maxConnections(100)
.maxIdleTime(Duration.ofSeconds(60))
.maxLifeTime(Duration.ofSeconds(60))
.pendingAcquireTimeout(Duration.ofSeconds(30))
.evictInBackground(Duration.ofSeconds(120))
.build();
}
public static Set<String> validateApiKeyAuthDatasource(DatasourceConfiguration datasourceConfiguration) {
Set<String> invalids = new HashSet<>();
final ApiKeyAuth apiKeyAuth = (ApiKeyAuth) datasourceConfiguration.getAuthentication();
if (apiKeyAuth == null || !StringUtils.hasText(apiKeyAuth.getValue())) {
invalids.add(AnthropicErrorMessages.EMPTY_API_KEY);
}
return invalids;
}
}

View File

@ -0,0 +1,101 @@
{
"identifier": "CHAT",
"controlType": "SECTION",
"conditionals": {
"show": "{{actionConfiguration.formData.command.data === 'CHAT'}}"
},
"children": [
{
"label": "Models",
"tooltipText": "Select the model for response generation",
"subtitle": "ID of the model to use.",
"isRequired": true,
"propertyName": "chat_model_id",
"configProperty": "actionConfiguration.formData.chatModel.data",
"controlType": "DROP_DOWN",
"initialValue": "",
"options": [],
"placeholderText": "All models will be fetched.",
"fetchOptionsConditionally": true,
"setFirstOptionAsDefault": true,
"alternateViewTypes": ["json"],
"conditionals": {
"enable": "{{true}}",
"fetchDynamicValues": {
"condition": "{{actionConfiguration.formData.command.data === 'CHAT'}}",
"config": {
"params": {
"requestType": "CHAT_MODELS",
"displayType": "DROP_DOWN"
}
}
}
}
},
{
"label": "Max Tokens",
"tooltipText": "The maximum number of tokens to generate in the chat completion.",
"subtitle": "The maximum number of tokens to generate in the chat completion.",
"Description": "Put a positive integer value",
"configProperty": "actionConfiguration.formData.maxTokens",
"controlType": "INPUT_TEXT",
"initialValue": "256",
"isRequired": true,
"dataType": "NUMBER",
"customStyles": {
"width": "270px",
"minWidth": "270px"
}
},
{
"label": "Messages",
"tooltipText": "Ask a question",
"subtitle": "A list of messages comprising the conversation so far.",
"propertyName": "messages",
"isRequired": true,
"configProperty": "actionConfiguration.formData.messages.data",
"controlType": "ARRAY_FIELD",
"addMoreButtonLabel": "Add message",
"alternateViewTypes": ["json"],
"schema": [
{
"label": "Role",
"key": "role",
"controlType": "DROP_DOWN",
"initialValue": "Human",
"options": [
{
"label": "Human",
"value": "Human"
},
{
"label": "Assistant",
"value": "Assistant"
}
]
},
{
"label": "Content",
"key": "content",
"controlType": "QUERY_DYNAMIC_INPUT_TEXT",
"placeholderText": "{{ UserInput.text }}"
}
]
},
{
"label": "Temperature",
"tooltipText": "Put a value between 0 and 1",
"Description": "Put a value between 0 and 1",
"subtitle": "Defaults to 1. Ranges from 0 to 1. Use temp closer to 0 for analytical / multiple choice, and closer to 1 for creative and generative tasks.",
"configProperty": "actionConfiguration.formData.temperature",
"controlType": "INPUT_TEXT",
"dataType": "NUMBER",
"initialValue": "1",
"isRequired": false,
"customStyles": {
"width": "270px",
"minWidth": "270px"
}
}
]
}

View File

@ -0,0 +1,27 @@
{
"editor": [
{
"controlType": "SECTION",
"identifier": "SELECTOR",
"children": [
{
"label": "Commands",
"description": "Choose the method you would like to use",
"configProperty": "actionConfiguration.formData.command.data",
"controlType": "DROP_DOWN",
"isRequired": true,
"initialValue": "CHAT",
"options": [
{
"label": "Chat",
"value": "CHAT"
}
]
}
]
}
],
"files": [
"chat.json"
]
}

View File

@ -0,0 +1,43 @@
{
"form": [
{
"sectionName": "Details",
"id": 1,
"children": [
{
"label": "Authentication type",
"description": "Select the authentication type to use",
"configProperty": "datasourceConfiguration.authentication.authenticationType",
"controlType": "DROP_DOWN",
"initialValue" : "apiKey",
"options": [
{
"label": "API Key",
"value": "apiKey"
}
],
"isRequired": true,
"hidden": true
},
{
"label": "API Key",
"configProperty": "datasourceConfiguration.authentication.value",
"controlType": "INPUT_TEXT",
"dataType": "PASSWORD",
"initialValue": "",
"isRequired": true,
"encrypted": true
},
{
"label": "Endpoint URL (with or without protocol and port no)",
"configProperty": "datasourceConfiguration.url",
"controlType": "INPUT_TEXT",
"initialValue": "https://api.anthropic.com",
"isRequired": true,
"hidden": true
}
]
}
],
"formButton" : ["TEST", "CANCEL", "SAVE"]
}

View File

@ -0,0 +1,5 @@
plugin.id=anthropic-plugin
plugin.class=com.external.plugins.AnthropicPlugin
plugin.version=1.0-SNAPSHOT
plugin.provider=tech@appsmith.com
plugin.dependencies=

View File

@ -0,0 +1,30 @@
{
"setting": [
{
"sectionName": "",
"id": 1,
"children": [
{
"label": "Run query on page load",
"configProperty": "executeOnLoad",
"controlType": "SWITCH",
"subtitle": "Will refresh data each time the page is loaded"
},
{
"label": "Request confirmation before running query",
"configProperty": "confirmBeforeExecute",
"controlType": "SWITCH",
"subtitle": "Ask confirmation from the user each time before refreshing data"
},
{
"label": "Query timeout (in milliseconds)",
"subtitle": "Maximum time after which the query will return",
"configProperty": "actionConfiguration.timeoutInMillisecond",
"controlType": "INPUT_TEXT",
"initialValue": 60000,
"dataType": "NUMBER"
}
]
}
]
}

View File

@ -0,0 +1,160 @@
package com.external.plugins;
import com.appsmith.external.models.ApiKeyAuth;
import com.appsmith.external.models.DatasourceConfiguration;
import com.appsmith.external.models.DatasourceTestResult;
import com.appsmith.external.models.TriggerRequestDTO;
import com.appsmith.external.models.TriggerResultDTO;
import com.appsmith.external.services.SharedConfig;
import com.external.plugins.constants.AnthropicErrorMessages;
import mockwebserver3.MockResponse;
import mockwebserver3.MockWebServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static com.external.plugins.constants.AnthropicConstants.CHAT_MODELS;
import static com.external.plugins.constants.AnthropicConstants.LABEL;
import static com.external.plugins.constants.AnthropicConstants.VALUE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class AnthropicPluginTest {
private static MockWebServer mockEndpoint;
public static class MockSharedConfig implements SharedConfig {
@Override
public int getCodecSize() {
return 10 * 1024 * 1024;
}
@Override
public int getMaxResponseSize() {
return 10000;
}
@Override
public String getRemoteExecutionUrl() {
return "";
}
}
AnthropicPlugin.AnthropicPluginExecutor pluginExecutor =
new AnthropicPlugin.AnthropicPluginExecutor(new MockSharedConfig());
@BeforeEach
public void setUp() throws IOException {
mockEndpoint = new MockWebServer();
mockEndpoint.start();
}
@AfterEach
public void tearDown() throws IOException {
mockEndpoint.shutdown();
}
@Test
public void testValidateDatasourceGivesNoInvalidsWhenConfiguredWithString() {
ApiKeyAuth apiKeyAuth = new ApiKeyAuth();
apiKeyAuth.setValue("apiKey");
DatasourceConfiguration datasourceConfiguration = new DatasourceConfiguration();
datasourceConfiguration.setAuthentication(apiKeyAuth);
Set<String> invalids = pluginExecutor.validateDatasource(datasourceConfiguration);
assertEquals(invalids.size(), 0);
}
@Test
public void testValidateDatasourceGivesInvalids() {
Set<String> invalids = pluginExecutor.validateDatasource(new DatasourceConfiguration());
assertEquals(invalids.size(), 1);
assertEquals(invalids, Set.of(AnthropicErrorMessages.EMPTY_API_KEY));
}
@Test
@Disabled
public void verifyTestDatasourceReturns() {
ApiKeyAuth apiKeyAuth = new ApiKeyAuth();
apiKeyAuth.setValue("apiKey");
DatasourceConfiguration datasourceConfiguration = new DatasourceConfiguration();
datasourceConfiguration.setAuthentication(apiKeyAuth);
MockResponse mockResponse = new MockResponse();
mockResponse.setResponseCode(200);
mockEndpoint.enqueue(mockResponse);
Mono<DatasourceTestResult> datasourceTestResultMono = pluginExecutor.testDatasource(datasourceConfiguration);
StepVerifier.create(datasourceTestResultMono)
.assertNext(datasourceTestResult -> {
assertEquals(datasourceTestResult.getInvalids().size(), 0);
assertEquals(datasourceTestResult.getMessages().size(), 0);
assertTrue(datasourceTestResult.isSuccess());
})
.verifyComplete();
}
@Test
public void verifyTestDatasourceReturnsFalse() {
ApiKeyAuth apiKeyAuth = new ApiKeyAuth();
apiKeyAuth.setValue("apiKey");
DatasourceConfiguration datasourceConfiguration = new DatasourceConfiguration();
datasourceConfiguration.setAuthentication(apiKeyAuth);
MockResponse mockResponse = new MockResponse();
mockResponse.setResponseCode(401);
mockEndpoint.enqueue(mockResponse);
Mono<DatasourceTestResult> datasourceTestResultMono = pluginExecutor.testDatasource(datasourceConfiguration);
StepVerifier.create(datasourceTestResultMono)
.assertNext(datasourceTestResult -> {
assertEquals(datasourceTestResult.getInvalids().size(), 1);
assertFalse(datasourceTestResult.isSuccess());
})
.verifyComplete();
}
@Test
public void verifyDatasourceTriggerResultsForChatModels() {
ApiKeyAuth apiKeyAuth = new ApiKeyAuth();
apiKeyAuth.setValue("apiKey");
DatasourceConfiguration datasourceConfiguration = new DatasourceConfiguration();
datasourceConfiguration.setAuthentication(apiKeyAuth);
String responseBody = "[\"claude-2.1\",\"claude-2\",\"claude-instant-1.2\",\"claude-instant-1\"]";
MockResponse mockResponse = new MockResponse().setBody(responseBody);
mockResponse.setResponseCode(200);
mockEndpoint.enqueue(mockResponse);
TriggerRequestDTO request = new TriggerRequestDTO();
request.setRequestType(CHAT_MODELS);
Mono<TriggerResultDTO> datasourceTriggerResultMono =
pluginExecutor.trigger(null, datasourceConfiguration, request);
StepVerifier.create(datasourceTriggerResultMono)
.assertNext(result -> {
assertTrue(result.getTrigger() instanceof List<?>);
assertEquals(((List) result.getTrigger()).size(), 4);
assertEquals(
result.getTrigger(),
getDataToMap(List.of("claude-2.1", "claude-2", "claude-instant-1.2", "claude-instant-1")));
})
.verifyComplete();
}
private List<Map<String, String>> getDataToMap(List<String> data) {
return data.stream().sorted().map(x -> Map.of(LABEL, x, VALUE, x)).collect(Collectors.toList());
}
}

View File

@ -0,0 +1,61 @@
package com.external.plugins;
import com.appsmith.external.models.ActionConfiguration;
import com.external.plugins.commands.ChatCommand;
import com.external.plugins.models.AnthropicRequestDTO;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static com.external.plugins.constants.AnthropicConstants.CHAT_MODEL_SELECTOR;
import static com.external.plugins.constants.AnthropicConstants.DATA;
import static com.external.plugins.constants.AnthropicConstants.DEFAULT_MAX_TOKEN;
import static com.external.plugins.constants.AnthropicConstants.DEFAULT_TEMPERATURE;
import static com.external.plugins.constants.AnthropicConstants.TEST_MODEL;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
public class ChatCommandTest {
@Test
public void testCreateTriggerUri() {
ChatCommand command = new ChatCommand();
URI uri = command.createTriggerUri();
assertEquals("/api/v1/ai/models", uri.getPath());
}
@Test
public void testCreateExecutionUri() {
ChatCommand command = new ChatCommand();
URI uri = command.createExecutionUri();
assertEquals("/v1/complete", uri.getPath());
}
@Test
public void testMakeRequestBody_withValidData() {
// Test with valid form data
ChatCommand command = new ChatCommand();
Map<String, Object> formData = new HashMap<>();
formData.put(CHAT_MODEL_SELECTOR, Map.of(DATA, TEST_MODEL));
Object messages =
List.of(Map.of("role", "Human", "content", "Hey"), Map.of("role", "Assistant", "content", "Hi there!"));
formData.put("messages", Map.of(DATA, messages));
ActionConfiguration actionConfiguration = new ActionConfiguration();
actionConfiguration.setFormData(formData);
AnthropicRequestDTO request = command.makeRequestBody(actionConfiguration);
assertEquals(TEST_MODEL, request.getModel());
assertNotNull(request.getPrompt());
assertEquals(DEFAULT_TEMPERATURE, request.getTemperature());
assertEquals(DEFAULT_MAX_TOKEN, request.getMaxTokensToSample());
assertEquals("\n\nHuman: Hey\n\nAssistant: Hi there!\nAssistant:", request.getPrompt());
}
}

View File

@ -114,6 +114,11 @@
<artifactId>reactor-netty-http</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.0.1-jre</version>
</dependency>
</dependencies>

View File

@ -20,6 +20,8 @@ import com.external.plugins.commands.OpenAICommand;
import com.external.plugins.models.OpenAIRequestDTO;
import com.external.plugins.utils.OpenAIMethodStrategy;
import com.external.plugins.utils.RequestUtils;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.gson.Gson;
import lombok.extern.slf4j.Slf4j;
import org.json.JSONArray;
@ -33,10 +35,15 @@ import reactor.core.publisher.Mono;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static com.external.plugins.constants.OpenAIConstants.BODY;
import static com.external.plugins.constants.OpenAIConstants.DATA;
@ -54,6 +61,8 @@ public class OpenAiPlugin extends BasePlugin {
public static class OpenAiPluginExecutor extends BaseRestApiPluginExecutor {
private static final Gson gson = new Gson();
private static final Cache<String, JSONObject> modelResponseCache =
CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.DAYS).build();
public OpenAiPluginExecutor(SharedConfig config) {
super(config);
@ -167,6 +176,28 @@ public class OpenAiPlugin extends BasePlugin {
});
}
private String cacheKey(String bearerToken) {
return sha256(bearerToken);
}
private String sha256(String base) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(base.getBytes(StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
@Override
public Set<String> validateDatasource(DatasourceConfiguration datasourceConfiguration) {
return RequestUtils.validateBearerTokenDatasource(datasourceConfiguration);
@ -179,26 +210,36 @@ public class OpenAiPlugin extends BasePlugin {
// Authentication will already be valid at this point
final BearerTokenAuth bearerTokenAuth = (BearerTokenAuth) datasourceConfiguration.getAuthentication();
assert (bearerTokenAuth.getBearerToken() != null);
OpenAICommand openAICommand = OpenAIMethodStrategy.selectTriggerMethod(request, gson);
HttpMethod httpMethod = openAICommand.getTriggerHTTPMethod();
URI uri = openAICommand.createTriggerUri();
return RequestUtils.makeRequest(httpMethod, uri, bearerTokenAuth, BodyInserters.empty())
.flatMap(responseEntity -> {
if (responseEntity.getStatusCode().is4xxClientError()) {
return Mono.error(new AppsmithPluginException(
AppsmithPluginError.PLUGIN_DATASOURCE_AUTHENTICATION_ERROR));
}
String cacheKey = cacheKey(bearerTokenAuth.getBearerToken());
JSONObject modelsResponse = modelResponseCache.getIfPresent(cacheKey);
if (!responseEntity.getStatusCode().is2xxSuccessful()) {
return Mono.error(
new AppsmithPluginException(AppsmithPluginError.PLUGIN_GET_STRUCTURE_ERROR));
}
Mono<JSONObject> responseMono;
if (modelsResponse != null) {
responseMono = Mono.just(modelsResponse);
} else {
responseMono = RequestUtils.makeRequest(httpMethod, uri, bearerTokenAuth, BodyInserters.empty())
.flatMap(responseEntity -> {
if (responseEntity.getStatusCode().is4xxClientError()) {
return Mono.error(new AppsmithPluginException(
AppsmithPluginError.PLUGIN_DATASOURCE_AUTHENTICATION_ERROR));
}
// link to get response data https://platform.openai.com/docs/api-reference/models/list
return Mono.just(new JSONObject(new String(responseEntity.getBody())));
})
if (!responseEntity.getStatusCode().is2xxSuccessful()) {
return Mono.error(
new AppsmithPluginException(AppsmithPluginError.PLUGIN_GET_STRUCTURE_ERROR));
}
JSONObject responseObject = new JSONObject(new String(responseEntity.getBody()));
modelResponseCache.put(cacheKey, responseObject);
// link to get response data https://platform.openai.com/docs/api-reference/models/list
return Mono.just(responseObject);
});
}
return responseMono
.map(jsonObject -> {
if (!jsonObject.has(DATA) && jsonObject.get(DATA) instanceof JSONArray) {
// let's throw some error.
@ -206,7 +247,8 @@ public class OpenAiPlugin extends BasePlugin {
new AppsmithPluginException(AppsmithPluginError.PLUGIN_GET_STRUCTURE_ERROR));
}
List<Map<String, String>> triggerModelList = new ArrayList<>();
List<String> compatibleModels = new ArrayList<>();
Map<String, JSONObject> modelsMap = new HashMap<>();
JSONArray modelList = jsonObject.getJSONArray(DATA);
int modelListIndex = 0;
while (modelListIndex < modelList.length()) {
@ -216,13 +258,18 @@ public class OpenAiPlugin extends BasePlugin {
}
if (openAICommand.isModelCompatible(model)) {
triggerModelList.add(openAICommand.getModelMap(model));
String id = model.getString(ID);
compatibleModels.add(id);
modelsMap.put(id, model);
}
modelListIndex += 1;
}
return triggerModelList;
// sort models alphabetically
return compatibleModels.stream()
.sorted()
.map(model -> openAICommand.getModelMap(modelsMap.get(model)))
.collect(Collectors.toList());
})
.map(TriggerResultDTO::new);
}

View File

@ -10,13 +10,17 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.reactive.ClientHttpRequest;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.BodyInserter;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;
import java.net.URI;
import java.time.Duration;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
@ -36,6 +40,7 @@ import static com.external.plugins.constants.OpenAIConstants.VISION_ENDPOINT;
import static com.external.plugins.constants.OpenAIErrorMessages.EMPTY_BEARER_TOKEN;
public class RequestUtils {
private static final WebClient webClient = createWebClient();
public static String extractDataFromFormData(Map<String, Object> formData, String key) {
return (String) ((Map<String, Object>) formData.get(key)).get(DATA);
@ -75,15 +80,11 @@ public class RequestUtils {
BearerTokenAuth bearerTokenAuth,
BodyInserter<?, ? super ClientHttpRequest> body) {
// Initializing webClient to be used for http call
WebClient.Builder webClientBuilder = WebClient.builder();
WebClient client =
webClientBuilder.exchangeStrategies(EXCHANGE_STRATEGIES).build();
// Authentication will already be valid at this point
assert (bearerTokenAuth.getBearerToken() != null);
return client.method(httpMethod)
return webClient
.method(httpMethod)
.uri(uri)
.contentType(MediaType.APPLICATION_JSON)
.body(body)
@ -92,6 +93,25 @@ public class RequestUtils {
.exchangeToMono(clientResponse -> clientResponse.toEntity(byte[].class));
}
private static WebClient createWebClient() {
// Initializing webClient to be used for http call
WebClient.Builder webClientBuilder = WebClient.builder();
return webClientBuilder
.exchangeStrategies(EXCHANGE_STRATEGIES)
.clientConnector(new ReactorClientHttpConnector(HttpClient.create(connectionProvider())))
.build();
}
private static ConnectionProvider connectionProvider() {
return ConnectionProvider.builder("openai")
.maxConnections(100)
.maxIdleTime(Duration.ofSeconds(60))
.maxLifeTime(Duration.ofSeconds(60))
.pendingAcquireTimeout(Duration.ofSeconds(30))
.evictInBackground(Duration.ofSeconds(120))
.build();
}
public static Set<String> validateBearerTokenDatasource(DatasourceConfiguration datasourceConfiguration) {
Set<String> invalids = new HashSet<>();
final BearerTokenAuth bearerTokenAuth = (BearerTokenAuth) datasourceConfiguration.getAuthentication();

View File

@ -17,11 +17,12 @@
"options": [],
"placeholderText": "All models will be fetched.",
"fetchOptionsConditionally": true,
"setFirstOptionAsDefault": true,
"alternateViewTypes": ["json"],
"conditionals": {
"enable": "{{true}}",
"fetchDynamicValues": {
"condition": "{{true}}",
"condition": "{{actionConfiguration.formData.command.data === 'CHAT'}}",
"config": {
"params": {
"requestType": "CHAT_MODELS",
@ -31,17 +32,6 @@
}
}
},
{
"label": "Temperature",
"tooltipText": "Put a value between 0 and 2",
"Description": "Put a value between 0 and 2",
"subtitle": "What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.",
"configProperty": "actionConfiguration.formData.temperature",
"controlType": "INPUT_TEXT",
"dataType": "NUMBER",
"initialValue": "0",
"isRequired": false
},
{
"label": "Messages",
"tooltipText": "Ask a question",
@ -66,6 +56,21 @@
"placeholderText": "{{ UserInput.text }}"
}
]
},
{
"label": "Temperature",
"tooltipText": "Put a value between 0 and 2",
"Description": "Put a value between 0 and 2",
"subtitle": "What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.",
"configProperty": "actionConfiguration.formData.temperature",
"controlType": "INPUT_TEXT",
"dataType": "NUMBER",
"initialValue": "0",
"isRequired": false,
"customStyles": {
"width": "270px",
"minWidth": "270px"
}
}
]
}

View File

@ -17,11 +17,12 @@
"options": [],
"placeholderText": "All models will be fetched.",
"fetchOptionsConditionally": true,
"setFirstOptionAsDefault": true,
"alternateViewTypes": ["json"],
"conditionals": {
"enable": "{{true}}",
"fetchDynamicValues": {
"condition": "{{true}}",
"condition": "{{actionConfiguration.formData.command.data === 'EMBEDDINGS'}}",
"config": {
"params": {
"requestType": "EMBEDDING_MODELS",

View File

@ -17,11 +17,12 @@
"options": [],
"placeholderText": "All models will be fetched.",
"fetchOptionsConditionally": true,
"setFirstOptionAsDefault": true,
"alternateViewTypes": ["json"],
"conditionals": {
"enable": "{{true}}",
"fetchDynamicValues": {
"condition": "{{true}}",
"condition": "{{actionConfiguration.formData.command.data === 'VISION'}}",
"config": {
"params": {
"requestType": "VISION_MODELS",
@ -40,18 +41,11 @@
"controlType": "INPUT_TEXT",
"initialValue": "16",
"isRequired": true,
"dataType": "NUMBER"
},
{
"label": "Temperature",
"tooltipText": "Put a value between 0 and 2",
"Description": "Put a value between 0 and 2",
"subtitle": "What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.",
"configProperty": "actionConfiguration.formData.temperature",
"controlType": "INPUT_TEXT",
"dataType": "NUMBER",
"initialValue": "0",
"isRequired": false
"customStyles": {
"width": "270px",
"minWidth": "270px"
}
},
{
"label": "System Messages",
@ -62,12 +56,16 @@
"configProperty": "actionConfiguration.formData.systemMessages",
"controlType": "ARRAY_FIELD",
"addMoreButtonLabel": "Add System Message",
"customStyles": {
"width": "40vw"
},
"schema": [
{
"label": "Content",
"key": "content",
"controlType": "QUERY_DYNAMIC_INPUT_TEXT",
"placeholderText": "{{ UserInput.text }}"
"placeholderText": "{{ UserInput.text }}",
"initialValue": "As an OCR expert your skills are unparalleled. Respond with just the text in the image"
}
]
},
@ -85,6 +83,7 @@
"label": "Type",
"key": "type",
"controlType": "DROP_DOWN",
"initialValue": "text",
"options": [
{
"label": "Text",
@ -103,6 +102,21 @@
"placeholderText": "{{Img1.image}} or {{Input1.text}}"
}
]
},
{
"label": "Temperature",
"tooltipText": "Put a value between 0 and 2",
"Description": "Put a value between 0 and 2",
"subtitle": "What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.",
"configProperty": "actionConfiguration.formData.temperature",
"controlType": "INPUT_TEXT",
"dataType": "NUMBER",
"initialValue": "0",
"isRequired": false,
"customStyles": {
"width": "270px",
"minWidth": "270px"
}
}
]
}

View File

@ -61,6 +61,7 @@
<module>smtpPlugin</module>
<module>openAiPlugin</module>
<module>anthropicPlugin</module>
</modules>

View File

@ -0,0 +1,50 @@
package com.appsmith.server.migrations.db.ce;
import com.appsmith.external.constants.PluginConstants;
import com.appsmith.external.models.PluginType;
import com.appsmith.server.domains.Plugin;
import io.mongock.api.annotations.ChangeUnit;
import io.mongock.api.annotations.Execution;
import io.mongock.api.annotations.RollbackExecution;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.mongodb.core.MongoTemplate;
import static com.appsmith.server.migrations.DatabaseChangelog1.installPluginToAllWorkspaces;
@Slf4j
@ChangeUnit(order = "035", id = "add-anthropic-plugin", author = " ")
public class Migration035AddAnthropicPlugin {
private final MongoTemplate mongoTemplate;
public Migration035AddAnthropicPlugin(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@RollbackExecution
public void rollbackExecution() {}
@Execution
public void addPluginToDbAndWorkspace() {
Plugin plugin = new Plugin();
plugin.setName(PluginConstants.PluginName.ANTHROPIC_PLUGIN_NAME);
plugin.setType(PluginType.AI);
plugin.setPluginName(PluginConstants.PluginName.ANTHROPIC_PLUGIN_NAME);
plugin.setPackageName(PluginConstants.PackageName.ANTHROPIC_PLUGIN);
plugin.setUiComponent("UQIDbEditorForm");
plugin.setDatasourceComponent("DbEditorForm");
plugin.setResponseType(Plugin.ResponseType.JSON);
plugin.setIconLocation("https://assets.appsmith.com/logo/anthropic.svg");
plugin.setDocumentationLink("https://docs.appsmith.com/connect-data/reference/anthropic");
plugin.setDefaultInstall(true);
try {
mongoTemplate.insert(plugin);
} catch (DuplicateKeyException e) {
log.warn(plugin.getPackageName() + " already present in database.");
}
assert plugin.getId() != null;
installPluginToAllWorkspaces(mongoTemplate, plugin.getId());
}
}