diff --git a/pom.xml b/pom.xml index 69f1e5d35..c2a7ef0ce 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,7 @@ spring-ai-openai spring-ai-azure-openai spring-ai-ollama + spring-ai-huggingface spring-ai-spring-boot-autoconfigure spring-ai-spring-boot-starters/spring-ai-starter-openai spring-ai-spring-boot-starters/spring-ai-starter-azure-openai @@ -71,8 +72,8 @@ UTF-8 17 - - 3.1.2 + + 3.1.3 4.0.2 0.16.0 1.0.0-beta.3 diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/concepts.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/concepts.adoc index ae61940a7..7178cbc40 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/concepts.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/concepts.adoc @@ -191,7 +191,7 @@ One approach involves presenting both the user's request and the AI model's resp Furthermore, leveraging the information stored in the Vector Database as supplementary data can enhance the evaluation process, aiding in the determination of response relevance. -The Spring AI project currenlty provides some very basic examples of how you can evaluate the responses in the form of prompts to include in a JUnit test. +The Spring AI project currently provides some very basic examples of how you can evaluate the responses in the form of prompts to include in a JUnit test. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/providers/huggingface/index.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/providers/huggingface/index.adoc new file mode 100644 index 000000000..c7e19e3af --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/providers/huggingface/index.adoc @@ -0,0 +1,15 @@ += HuggingFace + +== Introduction +One of the easiest ways you can get access to many Machine Learning and Artificial Intelligence models is by using the https://en.wikipedia.org/wiki/Hugging_Face[HuggingFace's] https://huggingface.co/inference-endpoints[Inference Endpoints]. + +Hugging Face Hub is a platform providing a collaborative environment for creating and sharing tens of thousands of Open Source ML/AI models, data sets, and demo applications. + +Inference Endpoints let you deploy AI Models on dedicated infrastructure with a pay as you go billing model. +You can use infrastructure provided by Amazon Web Services, Microsoft Azure and Google Cloud Platform. +Hugging Face lets you run the models on your own machine, but it is quite common to not have enough CPU/GPU resources to run the larger, more AI focused models. + +It provides access to Meta's recent (August 2023) Llama 2 and CodeLlama 2 models as well as providing the https://huggingface.co/spaces/HuggingFaceH4/open_llm_leaderboard[Open LLM Leaderboard] where you can quickly discover high quality models. + +While Hugging Face has a free hosting tier, which is very useful for quickly evaluating if a specific ML/AI Model fits your needs, they do not let you access many of those models on the free tier using the https://huggingface.co/docs/text-generation-inference/main/en/index[Text Generation Interface API], so since you want to end up on production anyway, with a stable API, pony up a few cents to try out a reliable solution. +Prices are as low as $0.06 per CPU core/hr and $0.6 per GPU/hr. diff --git a/spring-ai-huggingface/README.md b/spring-ai-huggingface/README.md new file mode 100644 index 000000000..580034a0a --- /dev/null +++ b/spring-ai-huggingface/README.md @@ -0,0 +1,56 @@ +# HuggingFace Inference Endpoints with Spring AI + +HuggingFace Inference Endpoints allow you to deploy and serve machine learning models in the cloud, making them accessible via an API. Further details on HuggingFace Inference Endpoints can be found [here](https://huggingface.co/docs/inference-endpoints/index). + +## Prerequisites + +You should get your HuggingFace API key and set it as an environment variable + +```shell +export HUGGINGFACE_API_KEY=your_api_key_here +``` + +Note, there is not yet a Spring Boot Starter for this client implementation. + +Obtain the endpoint URL of the Inference Endpoint. +You can find this on the Inference Endpoint's UI [here](https://ui.endpoints.huggingface.co/). + + +## Making a call to the model + +```java +HuggingfaceAiClient client = new HuggingfaceAiClient(apiKey, basePath); +Prompt prompt = new Prompt("Your text here..."); +AiResponse response = client.generate(prompt); +System.out.println(response.getGeneration().getText()); +``` + +## Example + +Using the example found [here](https://www.promptingguide.ai/models/mistral-7b) + +```java +String mistral7bInstruct = """ + [INST] You are a helpful code assistant. Your task is to generate a valid JSON object based on the given information: + name: John + lastname: Smith + address: #1 Samuel St. + Just generate the JSON object without explanations: + [/INST]"""; +Prompt prompt = new Prompt(mistral7bInstruct); +AiResponse aiResponse = huggingfaceAiClient.generate(prompt); +System.out.println(response.getGeneration().getText()); +``` + +Will produce the output + +```` +```json +{ + "name": "John", + "lastname": "Smith", + "address": "#1 Samuel St." +} +``` +```` +Note the response itself is in Markdown format. \ No newline at end of file diff --git a/spring-ai-huggingface/pom.xml b/spring-ai-huggingface/pom.xml new file mode 100644 index 000000000..9a1f16fc7 --- /dev/null +++ b/spring-ai-huggingface/pom.xml @@ -0,0 +1,118 @@ + + + 4.0.0 + + org.springframework.experimental.ai + spring-ai + 0.7.0-SNAPSHOT + + spring-ai-huggingface + jar + Spring AI HuggingFace + HuggingFace support + https://github.com/spring-projects-experimental/spring-ai + + + https://github.com/spring-projects-experimental/spring-ai + git://github.com/spring-projects-experimental/spring-ai.git + git@github.com:spring-projects-experimental/spring-ai.git + + + + + + + org.springframework.experimental.ai + spring-ai-core + ${project.parent.version} + + + + + io.swagger.core.v3 + swagger-annotations + 2.2.15 + + + + javax.annotation + javax.annotation-api + 1.3.2 + + + + + + org.springframework + spring-context-support + + + + org.springframework.boot + spring-boot-starter-logging + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + io.swagger.codegen.v3 + swagger-codegen-maven-plugin + 3.0.46 + + + + generate + + + ${project.basedir}/src/main/resources/openapi.json + java + resttemplate + org.springframework.ai.huggingface.api + org.springframework.ai.huggingface.model + org.springframework.ai.huggingface.invoker + false + false + + src/main/java + java8 + + true + + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.4.0 + + + 01-add-test-sources + generate-sources + + add-source + + + + ${project.build.directory}/generated-sources/swagger/src/main/java + + + + + + + + + + diff --git a/spring-ai-huggingface/src/main/java/org/springframework/ai/huggingface/client/HuggingfaceAiClient.java b/spring-ai-huggingface/src/main/java/org/springframework/ai/huggingface/client/HuggingfaceAiClient.java new file mode 100644 index 000000000..6ccd47c3c --- /dev/null +++ b/spring-ai-huggingface/src/main/java/org/springframework/ai/huggingface/client/HuggingfaceAiClient.java @@ -0,0 +1,124 @@ +/* + * Copyright 2023-2023 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.huggingface.client; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.ai.client.AiClient; +import org.springframework.ai.client.AiResponse; +import org.springframework.ai.client.Generation; +import org.springframework.ai.huggingface.api.TextGenerationInferenceApi; +import org.springframework.ai.huggingface.invoker.ApiClient; +import org.springframework.ai.huggingface.model.AllOfGenerateResponseDetails; +import org.springframework.ai.huggingface.model.GenerateParameters; +import org.springframework.ai.huggingface.model.GenerateRequest; +import org.springframework.ai.huggingface.model.GenerateResponse; +import org.springframework.ai.prompt.Prompt; + +/** + * An implementation of {@link AiClient} that interfaces with HuggingFace Inference + * Endpoints for text generation. + * + * @author Mark Pollack + */ +public class HuggingfaceAiClient implements AiClient { + + /** + * Token required for authenticating with the HuggingFace Inference API. + */ + private final String apiToken; + + /** + * Client for making API calls. + */ + private ApiClient apiClient = new ApiClient(); + + /** + * Mapper for converting between Java objects and JSON. + */ + private ObjectMapper objectMapper = new ObjectMapper(); + + /** + * API for text generation inferences. + */ + private TextGenerationInferenceApi textGenApi = new TextGenerationInferenceApi(); + + /** + * The maximum number of new tokens to be generated. Note: The total token size for + * the Mistral7b instruct model should be less than 1500. + */ + private int maxNewTokens = 1000; + + /** + * Constructs a new HuggingfaceAiClient with the specified API token and base path. + * @param apiToken The API token for HuggingFace. + * @param basePath The base path for API requests. + */ + public HuggingfaceAiClient(final String apiToken, String basePath) { + this.apiToken = apiToken; + this.apiClient.setBasePath(basePath); + this.apiClient.addDefaultHeader("Authorization", "Bearer " + this.apiToken); + this.textGenApi.setApiClient(this.apiClient); + } + + /** + * Generate text based on the provided prompt. + * @param prompt The input prompt based on which text is to be generated. + * @return AiResponse containing the generated text and other related details. + */ + @Override + public AiResponse generate(Prompt prompt) { + GenerateRequest generateRequest = new GenerateRequest(); + generateRequest.setInputs(prompt.getContents()); + GenerateParameters generateParameters = new GenerateParameters(); + // TODO - need to expose API to set parameters per call. + generateParameters.setMaxNewTokens(maxNewTokens); + generateRequest.setParameters(generateParameters); + GenerateResponse generateResponse = this.textGenApi.generate(generateRequest); + String generatedText = generateResponse.getGeneratedText(); + List generations = new ArrayList<>(); + AllOfGenerateResponseDetails allOfGenerateResponseDetails = generateResponse.getDetails(); + Map detailsMap = objectMapper.convertValue(allOfGenerateResponseDetails, + new TypeReference>() { + }); + Generation generation = new Generation(generatedText, detailsMap); + generations.add(generation); + return new AiResponse(generations); + } + + /** + * Gets the maximum number of new tokens to be generated. + * @return The maximum number of new tokens. + */ + public int getMaxNewTokens() { + return maxNewTokens; + } + + /** + * Sets the maximum number of new tokens to be generated. + * @param maxNewTokens The maximum number of new tokens. + */ + public void setMaxNewTokens(int maxNewTokens) { + this.maxNewTokens = maxNewTokens; + } + +} diff --git a/spring-ai-huggingface/src/main/resources/openapi.json b/spring-ai-huggingface/src/main/resources/openapi.json new file mode 100644 index 000000000..5974c58d4 --- /dev/null +++ b/spring-ai-huggingface/src/main/resources/openapi.json @@ -0,0 +1,849 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Text Generation Inference", + "description": "Text Generation Webserver", + "contact": { + "name": "Olivier Dehaene" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0" + }, + "version": "1.0.2" + }, + "paths": { + "/": { + "post": { + "tags": [ + "Text Generation Inference" + ], + "summary": "Generate tokens if `stream == false` or a stream of token if `stream == true`", + "description": "Generate tokens if `stream == false` or a stream of token if `stream == true`", + "operationId": "compat_generate", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompatGenerateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Generated Text", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateResponse" + } + }, + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/StreamResponse" + } + } + } + }, + "422": { + "description": "Input validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Input validation error" + } + } + } + }, + "424": { + "description": "Generation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Request failed during generation" + } + } + } + }, + "429": { + "description": "Model is overloaded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Model is overloaded" + } + } + } + }, + "500": { + "description": "Incomplete generation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Incomplete generation" + } + } + } + } + } + } + }, + "/generate": { + "post": { + "tags": [ + "Text Generation Inference" + ], + "summary": "Generate tokens", + "description": "Generate tokens", + "operationId": "generate", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Generated Text", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateResponse" + } + } + } + }, + "422": { + "description": "Input validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Input validation error" + } + } + } + }, + "424": { + "description": "Generation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Request failed during generation" + } + } + } + }, + "429": { + "description": "Model is overloaded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Model is overloaded" + } + } + } + }, + "500": { + "description": "Incomplete generation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Incomplete generation" + } + } + } + } + } + } + }, + "/generate_stream": { + "post": { + "tags": [ + "Text Generation Inference" + ], + "summary": "Generate a stream of token using Server-Sent Events", + "description": "Generate a stream of token using Server-Sent Events", + "operationId": "generate_stream", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Generated Text", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/StreamResponse" + } + } + } + }, + "422": { + "description": "Input validation error", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Input validation error" + } + } + } + }, + "424": { + "description": "Generation Error", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Request failed during generation" + } + } + } + }, + "429": { + "description": "Model is overloaded", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Model is overloaded" + } + } + } + }, + "500": { + "description": "Incomplete generation", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Incomplete generation" + } + } + } + } + } + } + }, + "/health": { + "get": { + "tags": [ + "Text Generation Inference" + ], + "summary": "Health check method", + "description": "Health check method", + "operationId": "health", + "responses": { + "200": { + "description": "Everything is working fine" + }, + "503": { + "description": "Text generation inference is down", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "unhealthy", + "error_type": "healthcheck" + } + } + } + } + } + } + }, + "/info": { + "get": { + "tags": [ + "Text Generation Inference" + ], + "summary": "Text Generation Inference endpoint info", + "description": "Text Generation Inference endpoint info", + "operationId": "get_model_info", + "responses": { + "200": { + "description": "Served model info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Info" + } + } + } + } + } + } + }, + "/metrics": { + "get": { + "tags": [ + "Text Generation Inference" + ], + "summary": "Prometheus metrics scrape endpoint", + "description": "Prometheus metrics scrape endpoint", + "operationId": "metrics", + "responses": { + "200": { + "description": "Prometheus Metrics", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "BestOfSequence": { + "type": "object", + "required": [ + "generated_text", + "finish_reason", + "generated_tokens", + "prefill", + "tokens" + ], + "properties": { + "finish_reason": { + "$ref": "#/components/schemas/FinishReason" + }, + "generated_text": { + "type": "string", + "example": "test" + }, + "generated_tokens": { + "type": "integer", + "format": "int32", + "example": 1, + "minimum": 0.0 + }, + "prefill": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PrefillToken" + } + }, + "seed": { + "type": "integer", + "format": "int64", + "example": 42, + "nullable": true, + "minimum": 0.0 + }, + "tokens": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Token" + } + } + } + }, + "CompatGenerateRequest": { + "type": "object", + "required": [ + "inputs" + ], + "properties": { + "inputs": { + "type": "string", + "example": "My name is Olivier and I" + }, + "parameters": { + "$ref": "#/components/schemas/GenerateParameters" + }, + "stream": { + "type": "boolean", + "default": "false" + } + } + }, + "Details": { + "type": "object", + "required": [ + "finish_reason", + "generated_tokens", + "prefill", + "tokens" + ], + "properties": { + "best_of_sequences": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BestOfSequence" + }, + "nullable": true + }, + "finish_reason": { + "$ref": "#/components/schemas/FinishReason" + }, + "generated_tokens": { + "type": "integer", + "format": "int32", + "example": 1, + "minimum": 0.0 + }, + "prefill": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PrefillToken" + } + }, + "seed": { + "type": "integer", + "format": "int64", + "example": 42, + "nullable": true, + "minimum": 0.0 + }, + "tokens": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Token" + } + } + } + }, + "ErrorResponse": { + "type": "object", + "required": [ + "error", + "error_type" + ], + "properties": { + "error": { + "type": "string" + }, + "error_type": { + "type": "string" + } + } + }, + "FinishReason": { + "type": "string", + "enum": [ + "length", + "eos_token", + "stop_sequence" + ] + }, + "GenerateParameters": { + "type": "object", + "properties": { + "best_of": { + "type": "integer", + "default": "null", + "example": 1, + "nullable": true, + "minimum": 0.0, + "exclusiveMinimum": 0.0 + }, + "decoder_input_details": { + "type": "boolean", + "default": "true" + }, + "details": { + "type": "boolean", + "default": "true" + }, + "do_sample": { + "type": "boolean", + "default": "false", + "example": true + }, + "max_new_tokens": { + "type": "integer", + "format": "int32", + "default": "20", + "minimum": 0.0, + "exclusiveMaximum": 512.0, + "exclusiveMinimum": 0.0 + }, + "repetition_penalty": { + "type": "number", + "format": "float", + "default": "null", + "example": 1.03, + "nullable": true, + "exclusiveMinimum": 0.0 + }, + "return_full_text": { + "type": "boolean", + "default": "null", + "example": false, + "nullable": true + }, + "seed": { + "type": "integer", + "format": "int64", + "default": "null", + "example": "null", + "nullable": true, + "minimum": 0.0, + "exclusiveMinimum": 0.0 + }, + "stop": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "photographer" + ], + "maxItems": 4 + }, + "temperature": { + "type": "number", + "format": "float", + "default": "null", + "example": 0.5, + "nullable": true, + "exclusiveMinimum": 0.0 + }, + "top_k": { + "type": "integer", + "format": "int32", + "default": "null", + "example": 10, + "nullable": true, + "exclusiveMinimum": 0.0 + }, + "top_p": { + "type": "number", + "format": "float", + "default": "null", + "example": 0.95, + "nullable": true, + "maximum": 1.0, + "exclusiveMinimum": 0.0 + }, + "truncate": { + "type": "integer", + "default": "null", + "example": "null", + "nullable": true, + "minimum": 0.0 + }, + "typical_p": { + "type": "number", + "format": "float", + "default": "null", + "example": 0.95, + "nullable": true, + "maximum": 1.0, + "exclusiveMinimum": 0.0 + }, + "watermark": { + "type": "boolean", + "default": "false", + "example": true + } + } + }, + "GenerateRequest": { + "type": "object", + "required": [ + "inputs" + ], + "properties": { + "inputs": { + "type": "string", + "example": "My name is Olivier and I" + }, + "parameters": { + "$ref": "#/components/schemas/GenerateParameters" + } + } + }, + "GenerateResponse": { + "type": "object", + "required": [ + "generated_text" + ], + "properties": { + "details": { + "allOf": [ + { + "$ref": "#/components/schemas/Details" + } + ], + "nullable": true + }, + "generated_text": { + "type": "string", + "example": "test" + } + } + }, + "Info": { + "type": "object", + "required": [ + "model_id", + "model_dtype", + "model_device_type", + "max_concurrent_requests", + "max_best_of", + "max_stop_sequences", + "max_input_length", + "max_total_tokens", + "waiting_served_ratio", + "max_batch_total_tokens", + "max_waiting_tokens", + "validation_workers", + "version" + ], + "properties": { + "docker_label": { + "type": "string", + "example": "null", + "nullable": true + }, + "max_batch_total_tokens": { + "type": "integer", + "format": "int32", + "example": "32000", + "minimum": 0.0 + }, + "max_best_of": { + "type": "integer", + "example": "2", + "minimum": 0.0 + }, + "max_concurrent_requests": { + "type": "integer", + "description": "Router Parameters", + "example": "128", + "minimum": 0.0 + }, + "max_input_length": { + "type": "integer", + "example": "1024", + "minimum": 0.0 + }, + "max_stop_sequences": { + "type": "integer", + "example": "4", + "minimum": 0.0 + }, + "max_total_tokens": { + "type": "integer", + "example": "2048", + "minimum": 0.0 + }, + "max_waiting_tokens": { + "type": "integer", + "example": "20", + "minimum": 0.0 + }, + "model_device_type": { + "type": "string", + "example": "cuda" + }, + "model_dtype": { + "type": "string", + "example": "torch.float16" + }, + "model_id": { + "type": "string", + "description": "Model info", + "example": "bigscience/blomm-560m" + }, + "model_pipeline_tag": { + "type": "string", + "example": "text-generation", + "nullable": true + }, + "model_sha": { + "type": "string", + "example": "e985a63cdc139290c5f700ff1929f0b5942cced2", + "nullable": true + }, + "sha": { + "type": "string", + "example": "null", + "nullable": true + }, + "validation_workers": { + "type": "integer", + "example": "2", + "minimum": 0.0 + }, + "version": { + "type": "string", + "description": "Router Info", + "example": "0.5.0" + }, + "waiting_served_ratio": { + "type": "number", + "format": "float", + "example": "1.2" + } + } + }, + "PrefillToken": { + "type": "object", + "required": [ + "id", + "text", + "logprob" + ], + "properties": { + "id": { + "type": "integer", + "format": "int32", + "example": 0, + "minimum": 0.0 + }, + "logprob": { + "type": "number", + "format": "float", + "example": -0.34, + "nullable": true + }, + "text": { + "type": "string", + "example": "test" + } + } + }, + "StreamDetails": { + "type": "object", + "required": [ + "finish_reason", + "generated_tokens" + ], + "properties": { + "finish_reason": { + "$ref": "#/components/schemas/FinishReason" + }, + "generated_tokens": { + "type": "integer", + "format": "int32", + "example": 1, + "minimum": 0.0 + }, + "seed": { + "type": "integer", + "format": "int64", + "example": 42, + "nullable": true, + "minimum": 0.0 + } + } + }, + "StreamResponse": { + "type": "object", + "required": [ + "token" + ], + "properties": { + "details": { + "allOf": [ + { + "$ref": "#/components/schemas/StreamDetails" + } + ], + "nullable": true + }, + "generated_text": { + "type": "string", + "default": "null", + "example": "test", + "nullable": true + }, + "token": { + "$ref": "#/components/schemas/Token" + } + } + }, + "Token": { + "type": "object", + "required": [ + "id", + "text", + "logprob", + "special" + ], + "properties": { + "id": { + "type": "integer", + "format": "int32", + "example": 0, + "minimum": 0.0 + }, + "logprob": { + "type": "number", + "format": "float", + "example": -0.34, + "nullable": true + }, + "special": { + "type": "boolean", + "example": "false" + }, + "text": { + "type": "string", + "example": "test" + } + } + } + } + }, + "tags": [ + { + "name": "Text Generation Inference", + "description": "Hugging Face Text Generation Inference API" + } + ] +} diff --git a/spring-ai-huggingface/src/test/java/org/springframework/ai/huggingface/HuggingfaceTestConfiguration.java b/spring-ai-huggingface/src/test/java/org/springframework/ai/huggingface/HuggingfaceTestConfiguration.java new file mode 100644 index 000000000..9f964d18c --- /dev/null +++ b/spring-ai-huggingface/src/test/java/org/springframework/ai/huggingface/HuggingfaceTestConfiguration.java @@ -0,0 +1,41 @@ +/* + * Copyright 2023-2023 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.huggingface; + +import org.springframework.ai.huggingface.client.HuggingfaceAiClient; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.util.StringUtils; + +@SpringBootConfiguration +public class HuggingfaceTestConfiguration { + + @Bean + public HuggingfaceAiClient huggingfaceAiClient() { + String apiKey = System.getenv("HUGGINGFACE_API_KEY"); + if (!StringUtils.hasText(apiKey)) { + throw new IllegalArgumentException( + "You must provide an API key. Put it in an environment variable under the name HUGGINGFACE_API_KEY"); + } + // Created aws-mistral-7b-instruct-v0-1-805 via + // https://ui.endpoints.huggingface.co/ + HuggingfaceAiClient huggingfaceAiClient = new HuggingfaceAiClient(apiKey, + "https://f6hg7b3cvlmntp5i.us-east-1.aws.endpoints.huggingface.cloud"); + return huggingfaceAiClient; + } + +} diff --git a/spring-ai-huggingface/src/test/java/org/springframework/ai/huggingface/client/ClientIntegrationTests.java b/spring-ai-huggingface/src/test/java/org/springframework/ai/huggingface/client/ClientIntegrationTests.java new file mode 100644 index 000000000..5c7b14452 --- /dev/null +++ b/spring-ai-huggingface/src/test/java/org/springframework/ai/huggingface/client/ClientIntegrationTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2023-2023 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.huggingface.client; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.client.AiResponse; +import org.springframework.ai.prompt.Prompt; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@EnabledIfEnvironmentVariable(named = "HUGGINGFACE_API_KEY", matches = ".+") +public class ClientIntegrationTests { + + @Autowired + protected HuggingfaceAiClient huggingfaceAiClient; + + @Test + void helloWorldCompletion() { + String mistral7bInstruct = """ + [INST] You are a helpful code assistant. Your task is to generate a valid JSON object based on the given information: + name: John + lastname: Smith + address: #1 Samuel St. + Just generate the JSON object without explanations: + [/INST] + """; + Prompt prompt = new Prompt(mistral7bInstruct); + AiResponse aiResponse = huggingfaceAiClient.generate(prompt); + assertThat(aiResponse.getGeneration().getText()).isNotEmpty(); + String expectedResponse = """ + ```json + { + "name": "John", + "lastname": "Smith", + "address": "#1 Samuel St." + } + ```"""; + assertThat(aiResponse.getGeneration().getText()).isEqualTo(expectedResponse); + assertThat(aiResponse.getGeneration().getInfo()).containsKey("generated_tokens"); + assertThat(aiResponse.getGeneration().getInfo()).containsEntry("generated_tokens", 39); + + } + +} diff --git a/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java b/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java index f92591b5d..878cc74a6 100644 --- a/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java +++ b/spring-ai-openai/src/test/java/org/springframework/ai/openai/OpenAiTestConfiguration.java @@ -8,14 +8,13 @@ import org.springframework.boot.SpringBootConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.util.StringUtils; -import java.io.IOException; import java.time.Duration; @SpringBootConfiguration public class OpenAiTestConfiguration { @Bean - public OpenAiService theoOpenAiService() throws IOException { + public OpenAiService theoOpenAiService() { String apiKey = System.getenv("OPENAI_API_KEY"); if (!StringUtils.hasText(apiKey)) { throw new IllegalArgumentException( diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml index 5b24a89ad..0ed55f404 100644 --- a/spring-ai-spring-boot-autoconfigure/pom.xml +++ b/spring-ai-spring-boot-autoconfigure/pom.xml @@ -41,6 +41,13 @@ true + + org.springframework.experimental.ai + spring-ai-huggingface + ${project.parent.version} + true + + org.springframework.boot spring-boot-configuration-processor diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceAutoConfiguration.java new file mode 100644 index 000000000..7f47f199b --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceAutoConfiguration.java @@ -0,0 +1,41 @@ +/* + * Copyright 2023 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.huggingface; + +import org.springframework.ai.huggingface.client.HuggingfaceAiClient; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@ConditionalOnClass(HuggingfaceAiClient.class) +@EnableConfigurationProperties(HuggingfaceProperties.class) +public class HuggingfaceAutoConfiguration { + + private final HuggingfaceProperties huggingfaceProperties; + + public HuggingfaceAutoConfiguration(HuggingfaceProperties huggingfaceProperties) { + this.huggingfaceProperties = huggingfaceProperties; + } + + @Bean + public HuggingfaceAiClient huggingfaceAiClient(HuggingfaceProperties huggingfaceProperties) { + return new HuggingfaceAiClient(huggingfaceProperties.getApiKey(), huggingfaceProperties.getUrl()); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceProperties.java new file mode 100644 index 000000000..0cf5d5177 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/huggingface/HuggingfaceProperties.java @@ -0,0 +1,32 @@ +package org.springframework.ai.autoconfigure.huggingface; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import static org.springframework.ai.autoconfigure.openai.OpenAiProperties.CONFIG_PREFIX; + +@ConfigurationProperties(CONFIG_PREFIX) +public class HuggingfaceProperties { + + public static final String CONFIG_PREFIX = "spring.ai.huggingface"; + + private String apiKey; + + private String url; + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + +}