Migrate Cohere, HuggingFace, and Workers AI from Retrofit/OkHttp to the HttpClient abstraction (#5780)

## Change
Migrates three more integrations off Retrofit/OkHttp and onto
LangChain4j's own `HttpClient` abstraction
(`dev.langchain4j.http.client`), consistent with the already-migrated
modules (OpenAI, Anthropic,
Mistral, Gemini, Voyage). Each module drops `retrofit`, `okhttp`, and
`converter-jackson`, routes HTTP through the pluggable backend (default
JDK client at runtime), and replaces its hand-rolled logging
  interceptors with the abstraction's `LoggingHttpClient`.

  **`langchain4j-cohere`**
- `CohereClient` rewritten on `HttpClient`; added `CohereJsonUtils`
mirroring the exact ObjectMapper config (`SNAKE_CASE` + `NON_NULL` +
lenient — DTOs have no `@JsonProperty`, so the wire format is
preserved). Endpoints unchanged: `POST embed` (v1 + v2), `POST rerank`;
`Authorization: Bearer` preserved.
- Deleted `CohereApi` + both interceptors. Added
`httpClientBuilder(...)` to `CohereClient`, `CohereEmbeddingModel`, and
`CohereScoringModel`.
- `proxy(...)` (public on `CohereScoringModel` + a deprecated
constructor) can't be expressed through the abstraction, so it now
**throws `UnsupportedOperationException`** pointing users to
`httpClientBuilder(...)` rather than silently ignoring the proxy. The
builder method is `@Deprecated`.

  **`langchain4j-hugging-face`**
- `DefaultHuggingFaceClient` rewritten on `HttpClient`; added
`HuggingFaceJsonUtils` (plain mapper — DTOs self-annotate with
`@JsonNaming`). The `Authorization: Bearer` header from
`ApiKeyInsertingInterceptor`
  is replicated on each request. Endpoints unchanged.
- Deleted `HuggingFaceApi` + `ApiKeyInsertingInterceptor`. The
`HuggingFaceClient` interface and the `HuggingFaceClientFactory` SPI
method are unchanged; `HuggingFaceClientFactory.Input` gains a
**`default
HttpClientBuilder httpClientBuilder()`** (backward-compatible — existing
SPI implementors, e.g. quarkus-langchain4j, inherit the `null` default
and are unaffected). Added `httpClientBuilder(...)` to the
  chat/language/embedding model builders.

  **`langchain4j-workers-ai`**
- `WorkersAiClient` rewritten on `HttpClient`; added
`WorkersAiJsonUtils`. Endpoints/paths/`Authorization` preserved. Image
generation returns raw bytes via `SuccessfulHttpResponse.bodyBytes()`
(no String
round-trip). Cloudflare's `HTTP 200` + `{"success":false}` error
envelope is still surfaced (via a restored `checkSuccess()`), alongside
the automatic `HttpException` for non-2xx.
- Deleted `WorkersAiApi`; added `httpClientBuilder(...)` to all four
model builders. Some internal `.client` plumbing was removed
(`WorkersAiClient` no-arg constructor / `createService` /
`AuthInterceptor`,
and `AbstractWorkersAIModel.processErrors`/`workerAiClient`) —
documented in `revapi.json`.

Each module got a `revapi.json` entry for the intentional
`HttpClientBuilder` exposure (and removals), matching the existing
convention in `langchain4j-chroma`, `langchain4j-open-ai`,
`langchain4j-voyage-ai`,
  etc.

**Behavioral note:** on non-2xx responses the clients now throw
`dev.langchain4j.exception.HttpException` (a `RuntimeException`
subclass) instead of a plain `RuntimeException` with a `"status code:
…"` message
  — the same behavior all other migrated modules already exhibit.

## General checklist
  - [X] There are no breaking changes (API, behaviour)
  - [ ] I have added unit and/or integration tests for my change
  - [ ] The tests cover both positive and negative cases
- [X] I have manually run all the unit and integration tests in the
module I have added/changed, and they are all green
- [ ] I have manually run all the unit and integration tests in the
[core](https://github.com/langchain4j/langchain4j/tree/main/langchain4j-core)
and

[main](https://github.com/langchain4j/langchain4j/tree/main/langchain4j)
modules, and they are all green
- [ ] I have added/updated the
[documentation](https://github.com/langchain4j/langchain4j/tree/main/docs/docs)
- [ ] I have added an example in the [examples
repo](https://github.com/langchain4j/langchain4j-examples) (only for
"big" features)
- [ ] I have added/updated [Spring Boot
starter(s)](https://github.com/langchain4j/langchain4j-spring) (if
applicable)
This commit is contained in:
Dmytro Liubarskyi 2026-07-16 10:19:19 +02:00 committed by GitHub
parent 707f83ed9e
commit 29878c30e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 729 additions and 637 deletions

View File

@ -29,18 +29,21 @@
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>retrofit</artifactId>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-http-client</artifactId>
<version>1.18.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-http-client-jdk</artifactId>
<version>1.18.0-SNAPSHOT</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>converter-jackson</artifactId>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>

View File

@ -33,6 +33,11 @@
"code": "java.class.externalClassExposedInAPI",
"new": "missing-class dev.langchain4j.model.ModelProvider",
"justification": "Core langchain4j type intentionally exposed by the EmbeddingModel request/response API (embed(EmbeddingRequest), supportedParameters(), supportedContentTypes(), provider(), listeners())"
},
{
"code": "java.class.externalClassExposedInAPI",
"new": "missing-class dev.langchain4j.http.client.HttpClientBuilder",
"justification": "HttpClientBuilder is intentionally exposed by the Cohere integration for custom HTTP client configuration"
}
]
}

View File

@ -1,22 +0,0 @@
package dev.langchain4j.model.cohere;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.Header;
import retrofit2.http.Headers;
import retrofit2.http.POST;
interface CohereApi {
@POST("embed")
@Headers({"accept: application/json", "content-type: application/json"})
Call<EmbedResponse> embed(@Body EmbedRequest request, @Header("Authorization") String authorizationHeader);
@POST("embed")
@Headers({"accept: application/json", "content-type: application/json"})
Call<EmbedV2Response> embedV2(@Body EmbedV2Request request, @Header("Authorization") String authorizationHeader);
@POST("rerank")
@Headers({"accept: application/json", "content-type: application/json"})
Call<RerankResponse> rerank(@Body RerankRequest request, @Header("Authorization") String authorizationHeader);
}

View File

@ -1,61 +1,51 @@
package dev.langchain4j.model.cohere;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.SerializationFeature;
import dev.langchain4j.internal.Utils;
import okhttp3.OkHttpClient;
import org.slf4j.Logger;
import retrofit2.Retrofit;
import retrofit2.converter.jackson.JacksonConverterFactory;
import static dev.langchain4j.http.client.HttpMethod.POST;
import static dev.langchain4j.internal.Utils.ensureTrailingForwardSlash;
import static dev.langchain4j.internal.Utils.getOrDefault;
import static dev.langchain4j.internal.ValidationUtils.ensureNotBlank;
import static dev.langchain4j.model.cohere.CohereJsonUtils.fromJson;
import static dev.langchain4j.model.cohere.CohereJsonUtils.toJson;
import static java.time.Duration.ofSeconds;
import java.io.IOException;
import dev.langchain4j.http.client.HttpClient;
import dev.langchain4j.http.client.HttpClientBuilder;
import dev.langchain4j.http.client.HttpClientBuilderLoader;
import dev.langchain4j.http.client.HttpRequest;
import dev.langchain4j.http.client.SuccessfulHttpResponse;
import dev.langchain4j.http.client.log.LoggingHttpClient;
import java.net.Proxy;
import java.time.Duration;
import java.util.Objects;
import static dev.langchain4j.internal.ValidationUtils.ensureNotBlank;
import org.slf4j.Logger;
class CohereClient {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.enable(SerializationFeature.INDENT_OUTPUT)
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final CohereApi cohereApi;
private final HttpClient httpClient;
private final String baseUrl;
private final String authorizationHeader;
CohereClient(String baseUrl, String apiKey, Duration timeout, Proxy proxy, Boolean logRequests, Boolean logResponses, Logger logger) {
CohereClient(CohereClientBuilder builder) {
OkHttpClient.Builder okHttpClientBuilder = new OkHttpClient.Builder()
.callTimeout(timeout)
HttpClientBuilder httpClientBuilder =
getOrDefault(builder.httpClientBuilder, HttpClientBuilderLoader::loadHttpClientBuilder);
Duration timeout = getOrDefault(builder.timeout, ofSeconds(60));
HttpClient httpClient = httpClientBuilder
.connectTimeout(timeout)
.readTimeout(timeout)
.writeTimeout(timeout);
if (logRequests) {
okHttpClientBuilder.addInterceptor(new RequestLoggingInterceptor(logger));
}
if (logResponses) {
okHttpClientBuilder.addInterceptor(new ResponseLoggingInterceptor(logger));
}
if (Objects.nonNull(proxy)) {
okHttpClientBuilder.proxy(proxy);
}
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(Utils.ensureTrailingForwardSlash(baseUrl))
.client(okHttpClientBuilder.build())
.addConverterFactory(JacksonConverterFactory.create(OBJECT_MAPPER))
.build();
this.cohereApi = retrofit.create(CohereApi.class);
this.authorizationHeader = "Bearer " + ensureNotBlank(apiKey, "apiKey");
if (builder.logRequests != null && builder.logRequests
|| builder.logResponses != null && builder.logResponses) {
this.httpClient =
new LoggingHttpClient(httpClient, builder.logRequests, builder.logResponses, builder.logger);
} else {
this.httpClient = httpClient;
}
this.baseUrl = ensureTrailingForwardSlash(builder.baseUrl);
this.authorizationHeader = "Bearer " + ensureNotBlank(builder.apiKey, "apiKey");
}
public static CohereClientBuilder builder() {
@ -63,58 +53,52 @@ class CohereClient {
}
EmbedResponse embed(EmbedRequest request) {
try {
retrofit2.Response<EmbedResponse> retrofitResponse
= cohereApi.embed(request, authorizationHeader).execute();
HttpRequest httpRequest = HttpRequest.builder()
.method(POST)
.url(baseUrl + "embed")
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "application/json")
.addHeader("Authorization", authorizationHeader)
.body(toJson(request))
.build();
if (retrofitResponse.isSuccessful()) {
return retrofitResponse.body();
} else {
throw toException(retrofitResponse);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
SuccessfulHttpResponse response = httpClient.execute(httpRequest);
return fromJson(response.body(), EmbedResponse.class);
}
EmbedV2Response embedV2(EmbedV2Request request) {
try {
retrofit2.Response<EmbedV2Response> retrofitResponse =
cohereApi.embedV2(request, authorizationHeader).execute();
HttpRequest httpRequest = HttpRequest.builder()
.method(POST)
.url(baseUrl + "embed")
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "application/json")
.addHeader("Authorization", authorizationHeader)
.body(toJson(request))
.build();
if (retrofitResponse.isSuccessful()) {
return retrofitResponse.body();
} else {
throw toException(retrofitResponse);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
SuccessfulHttpResponse response = httpClient.execute(httpRequest);
return fromJson(response.body(), EmbedV2Response.class);
}
RerankResponse rerank(RerankRequest request) {
try {
retrofit2.Response<RerankResponse> retrofitResponse
= cohereApi.rerank(request, authorizationHeader).execute();
HttpRequest httpRequest = HttpRequest.builder()
.method(POST)
.url(baseUrl + "rerank")
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "application/json")
.addHeader("Authorization", authorizationHeader)
.body(toJson(request))
.build();
if (retrofitResponse.isSuccessful()) {
return retrofitResponse.body();
} else {
throw toException(retrofitResponse);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
SuccessfulHttpResponse response = httpClient.execute(httpRequest);
private static RuntimeException toException(retrofit2.Response<?> response) throws IOException {
int code = response.code();
String body = response.errorBody().string();
String errorMessage = String.format("status code: %s; body: %s", code, body);
return new RuntimeException(errorMessage);
return fromJson(response.body(), RerankResponse.class);
}
public static class CohereClientBuilder {
private HttpClientBuilder httpClientBuilder;
private String baseUrl;
private String apiKey;
private Duration timeout;
@ -126,6 +110,11 @@ class CohereClient {
CohereClientBuilder() {
}
public CohereClientBuilder httpClientBuilder(HttpClientBuilder httpClientBuilder) {
this.httpClientBuilder = httpClientBuilder;
return this;
}
public CohereClientBuilder baseUrl(String baseUrl) {
this.baseUrl = baseUrl;
return this;
@ -142,6 +131,11 @@ class CohereClient {
}
public CohereClientBuilder proxy(Proxy proxy) {
if (proxy != null) {
throw new UnsupportedOperationException(
"Proxy configuration via proxy(...) is no longer supported. Supply a custom "
+ "HttpClientBuilder via httpClientBuilder(...) to configure a proxy.");
}
this.proxy = proxy;
return this;
}
@ -162,7 +156,7 @@ class CohereClient {
}
public CohereClient build() {
return new CohereClient(this.baseUrl, this.apiKey, this.timeout, this.proxy, this.logRequests, this.logResponses, this.logger);
return new CohereClient(this);
}
public String toString() {

View File

@ -14,6 +14,7 @@ import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.exception.UnsupportedFeatureException;
import dev.langchain4j.http.client.HttpClientBuilder;
import dev.langchain4j.model.ModelProvider;
import dev.langchain4j.model.embedding.DimensionAwareEmbeddingModel;
import dev.langchain4j.model.embedding.EmbeddingModel;
@ -84,6 +85,7 @@ public class CohereEmbeddingModel extends DimensionAwareEmbeddingModel {
public CohereEmbeddingModel(CohereEmbeddingModelBuilder builder) {
String baseUrl = getOrDefault(builder.baseUrl, DEFAULT_BASE_URL);
this.client = CohereClient.builder()
.httpClientBuilder(builder.httpClientBuilder)
.baseUrl(baseUrl)
.apiKey(ensureNotBlank(builder.apiKey, "apiKey"))
.timeout(getOrDefault(builder.timeout, ofSeconds(60)))
@ -92,6 +94,7 @@ public class CohereEmbeddingModel extends DimensionAwareEmbeddingModel {
.logger(builder.logger)
.build();
this.v2Client = CohereClient.builder()
.httpClientBuilder(builder.httpClientBuilder)
.baseUrl(getOrDefault(builder.v2BaseUrl, toV2BaseUrl(baseUrl)))
.apiKey(ensureNotBlank(builder.apiKey, "apiKey"))
.timeout(getOrDefault(builder.timeout, ofSeconds(60)))
@ -279,6 +282,7 @@ public class CohereEmbeddingModel extends DimensionAwareEmbeddingModel {
}
public static class CohereEmbeddingModelBuilder {
private HttpClientBuilder httpClientBuilder;
private String baseUrl;
private String v2BaseUrl;
private String apiKey;
@ -293,6 +297,18 @@ public class CohereEmbeddingModel extends DimensionAwareEmbeddingModel {
CohereEmbeddingModelBuilder() {}
/**
* Sets a custom HTTP client builder, allowing fine-grained control over the HTTP client
* configuration such as timeouts and proxy settings.
*
* @param httpClientBuilder the HTTP client builder
* @return {@code this}
*/
public CohereEmbeddingModelBuilder httpClientBuilder(HttpClientBuilder httpClientBuilder) {
this.httpClientBuilder = httpClientBuilder;
return this;
}
public CohereEmbeddingModelBuilder listeners(List<EmbeddingModelListener> listeners) {
this.listeners = listeners;
return this;

View File

@ -0,0 +1,37 @@
package dev.langchain4j.model.cohere;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.SerializationFeature;
class CohereJsonUtils {
private CohereJsonUtils() throws InstantiationException {
throw new InstantiationException("Can't instantiate this utility class.");
}
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.enable(SerializationFeature.INDENT_OUTPUT)
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
static String toJson(Object object) {
try {
return OBJECT_MAPPER.writeValueAsString(object);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
static <T> T fromJson(String jsonStr, Class<T> clazz) {
try {
return OBJECT_MAPPER.readValue(jsonStr, clazz);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -8,6 +8,7 @@ import static java.util.Comparator.comparingInt;
import static java.util.stream.Collectors.toList;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.http.client.HttpClientBuilder;
import dev.langchain4j.model.output.Response;
import dev.langchain4j.model.output.TokenUsage;
import dev.langchain4j.model.scoring.ScoringModel;
@ -52,6 +53,7 @@ public class CohereScoringModel implements ScoringModel {
public CohereScoringModel(CohereScoringModelBuilder builder) {
this.client = CohereClient.builder()
.httpClientBuilder(builder.httpClientBuilder)
.baseUrl(getOrDefault(builder.baseUrl, DEFAULT_BASE_URL))
.apiKey(ensureNotBlank(builder.apiKey, "apiKey"))
.timeout(getOrDefault(builder.timeout, ofSeconds(60)))
@ -98,6 +100,7 @@ public class CohereScoringModel implements ScoringModel {
}
public static class CohereScoringModelBuilder {
private HttpClientBuilder httpClientBuilder;
private String baseUrl;
private String apiKey;
private String modelName;
@ -110,6 +113,18 @@ public class CohereScoringModel implements ScoringModel {
CohereScoringModelBuilder() {}
/**
* Sets a custom HTTP client builder, allowing fine-grained control over the HTTP client
* configuration such as timeouts and proxy settings.
*
* @param httpClientBuilder the HTTP client builder
* @return {@code this}
*/
public CohereScoringModelBuilder httpClientBuilder(HttpClientBuilder httpClientBuilder) {
this.httpClientBuilder = httpClientBuilder;
return this;
}
public CohereScoringModelBuilder baseUrl(String baseUrl) {
this.baseUrl = baseUrl;
return this;
@ -135,6 +150,15 @@ public class CohereScoringModel implements ScoringModel {
return this;
}
/**
* @param proxy the proxy (no longer applied)
* @return {@code this}
* @deprecated Proxy configuration via {@code proxy(...)} is no longer supported since the migration to the
* langchain4j HttpClient abstraction. Passing a non-null proxy will cause an
* {@link UnsupportedOperationException} when the model is built. To configure a proxy, supply a custom
* {@link HttpClientBuilder} via {@link #httpClientBuilder(HttpClientBuilder)} instead.
*/
@Deprecated
public CohereScoringModelBuilder proxy(Proxy proxy) {
this.proxy = proxy;
return this;

View File

@ -1,89 +0,0 @@
package dev.langchain4j.model.cohere;
import okhttp3.Headers;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;
import okio.Buffer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static dev.langchain4j.internal.Utils.getOrDefault;
import static java.util.stream.StreamSupport.stream;
class RequestLoggingInterceptor implements Interceptor {
private static final Logger DEFAULT_LOG = LoggerFactory.getLogger(RequestLoggingInterceptor.class);
private static final Pattern BEARER_PATTERN = Pattern.compile("(Bearer\\s)(\\w{2})(\\w+)(\\w{2})");
private final Logger log;
RequestLoggingInterceptor(Logger logger) {
this.log = getOrDefault(logger, DEFAULT_LOG);
}
public Response intercept(Interceptor.Chain chain) throws IOException {
Request request = chain.request();
log(request);
return chain.proceed(request);
}
private void log(Request request) {
log.debug(
"Request:\n" +
"- method: {}\n" +
"- url: {}\n" +
"- headers: {}\n" +
"- body: {}",
request.method(),
request.url(),
inOneLine(request.headers()),
getBody(request)
);
}
static String inOneLine(Headers headers) {
return stream(headers.spliterator(), false)
.map((header) -> {
String headerKey = header.component1();
String headerValue = header.component2();
if (headerKey.equals("Authorization")) {
headerValue = maskAuthorizationHeaderValue(headerValue);
}
return String.format("[%s: %s]", headerKey, headerValue);
}).collect(Collectors.joining(", "));
}
private static String maskAuthorizationHeaderValue(String authorizationHeaderValue) {
try {
Matcher matcher = BEARER_PATTERN.matcher(authorizationHeaderValue);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
matcher.appendReplacement(sb, matcher.group(1) + matcher.group(2) + "..." + matcher.group(4));
}
matcher.appendTail(sb);
return sb.toString();
} catch (Exception e) {
return "[failed to mask the API key]";
}
}
private String getBody(Request request) {
try {
Buffer buffer = new Buffer();
request.body().writeTo(buffer);
return buffer.readUtf8();
} catch (Exception e) {
log.warn("Exception happened while reading request body", e);
return "[Exception happened while reading request body. Check logs for more details.]";
}
}
}

View File

@ -1,51 +0,0 @@
package dev.langchain4j.model.cohere;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import static dev.langchain4j.internal.Utils.getOrDefault;
import static dev.langchain4j.model.cohere.RequestLoggingInterceptor.inOneLine;
class ResponseLoggingInterceptor implements Interceptor {
private static final Logger DEFAULT_LOG = LoggerFactory.getLogger(ResponseLoggingInterceptor.class);
private final Logger log;
ResponseLoggingInterceptor(Logger logger) {
this.log = getOrDefault(logger, DEFAULT_LOG);
}
public Response intercept(Interceptor.Chain chain) throws IOException {
Request request = chain.request();
Response response = chain.proceed(request);
log(response);
return response;
}
void log(Response response) {
log.debug(
"Response:\n" +
"- status code: {}\n" +
"- headers: {}\n" +
"- body: {}",
response.code(),
inOneLine(response.headers()),
getBody(response)
);
}
private String getBody(Response response) {
try {
return response.peekBody(Long.MAX_VALUE).string();
} catch (IOException e) {
log.warn("Failed to log response", e);
return "[failed to log response]";
}
}
}

View File

@ -21,18 +21,16 @@
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>retrofit</artifactId>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-http-client</artifactId>
<version>1.18.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>converter-jackson</artifactId>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-http-client-jdk</artifactId>
<version>1.18.0-SNAPSHOT</version>
<scope>runtime</scope>
</dependency>
<dependency>

View File

@ -0,0 +1,15 @@
[
{
"extension": "revapi.differences",
"configuration": {
"ignore": true,
"differences": [
{
"code": "java.class.externalClassExposedInAPI",
"new": "missing-class dev.langchain4j.http.client.HttpClientBuilder",
"justification": "HttpClientBuilder is intentionally exposed by the HuggingFace integration for custom HTTP client configuration"
}
]
}
}
]

View File

@ -1,29 +0,0 @@
package dev.langchain4j.model.huggingface;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;
import java.io.IOException;
import static dev.langchain4j.internal.ValidationUtils.ensureNotBlank;
class ApiKeyInsertingInterceptor implements Interceptor {
private final String apiKey;
ApiKeyInsertingInterceptor(String apiKey) {
this.apiKey = ensureNotBlank(apiKey, "apiKey");
}
@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request()
.newBuilder()
.addHeader("Authorization", "Bearer " + apiKey)
.build();
return chain.proceed(request);
}
}

View File

@ -1,45 +1,44 @@
package dev.langchain4j.model.huggingface;
import static dev.langchain4j.http.client.HttpMethod.POST;
import static dev.langchain4j.internal.Utils.ensureTrailingForwardSlash;
import static dev.langchain4j.internal.Utils.getOrDefault;
import static dev.langchain4j.internal.ValidationUtils.ensureNotBlank;
import static dev.langchain4j.model.huggingface.HuggingFaceJsonUtils.fromJson;
import static dev.langchain4j.model.huggingface.HuggingFaceJsonUtils.toJson;
import com.fasterxml.jackson.core.type.TypeReference;
import dev.langchain4j.http.client.HttpClient;
import dev.langchain4j.http.client.HttpClientBuilder;
import dev.langchain4j.http.client.HttpClientBuilderLoader;
import dev.langchain4j.http.client.HttpRequest;
import dev.langchain4j.http.client.SuccessfulHttpResponse;
import dev.langchain4j.model.huggingface.client.EmbeddingRequest;
import dev.langchain4j.model.huggingface.client.HuggingFaceClient;
import dev.langchain4j.model.huggingface.client.TextGenerationRequest;
import dev.langchain4j.model.huggingface.client.TextGenerationResponse;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import okhttp3.OkHttpClient;
import retrofit2.Response;
import retrofit2.Retrofit;
import retrofit2.converter.jackson.JacksonConverterFactory;
class DefaultHuggingFaceClient implements HuggingFaceClient {
private static final String BASE_URL = "https://router.huggingface.co/hf-inference/";
private final HuggingFaceApi huggingFaceApi;
private final HttpClient httpClient;
private final String baseUrl;
private final String apiKey;
private final String modelId;
DefaultHuggingFaceClient(String baseUrl, String apiKey, String modelId, Duration timeout) {
DefaultHuggingFaceClient(
HttpClientBuilder httpClientBuilder, String baseUrl, String apiKey, String modelId, Duration timeout) {
OkHttpClient okHttpClient = new OkHttpClient.Builder()
.addInterceptor(new ApiKeyInsertingInterceptor(apiKey))
.callTimeout(timeout)
.connectTimeout(timeout)
.readTimeout(timeout)
.writeTimeout(timeout)
.build();
HttpClientBuilder builder = getOrDefault(httpClientBuilder, HttpClientBuilderLoader::loadHttpClientBuilder);
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(ensureTrailingForwardSlash(Objects.isNull(baseUrl) ? BASE_URL : baseUrl))
.client(okHttpClient)
.addConverterFactory(JacksonConverterFactory.create())
.build();
this.httpClient = builder.connectTimeout(timeout).readTimeout(timeout).build();
this.huggingFaceApi = retrofit.create(HuggingFaceApi.class);
this.baseUrl = ensureTrailingForwardSlash(Objects.isNull(baseUrl) ? BASE_URL : baseUrl);
this.apiKey = ensureNotBlank(apiKey, "apiKey");
this.modelId = ensureNotBlank(modelId, "modelId");
}
@ -50,22 +49,23 @@ class DefaultHuggingFaceClient implements HuggingFaceClient {
@Override
public TextGenerationResponse generate(TextGenerationRequest request) {
try {
retrofit2.Response<List<TextGenerationResponse>> retrofitResponse =
huggingFaceApi.generate(request, modelId).execute();
HttpRequest httpRequest = HttpRequest.builder()
.method(POST)
.url(baseUrl + "models/" + modelId)
.addHeader("Content-Type", "application/json")
.addHeader("Authorization", "Bearer " + apiKey)
.body(toJson(request))
.build();
if (retrofitResponse.isSuccessful()) {
return toOneResponse(retrofitResponse);
} else {
throw toException(retrofitResponse);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
SuccessfulHttpResponse httpResponse = httpClient.execute(httpRequest);
List<TextGenerationResponse> responses =
fromJson(httpResponse.body(), new TypeReference<List<TextGenerationResponse>>() {});
return toOneResponse(responses);
}
private static TextGenerationResponse toOneResponse(Response<List<TextGenerationResponse>> retrofitResponse) {
List<TextGenerationResponse> responses = retrofitResponse.body();
private static TextGenerationResponse toOneResponse(List<TextGenerationResponse> responses) {
if (responses != null && responses.size() == 1) {
return responses.get(0);
} else {
@ -76,25 +76,16 @@ class DefaultHuggingFaceClient implements HuggingFaceClient {
@Override
public List<float[]> embed(EmbeddingRequest request) {
try {
Response<List<float[]>> retrofitResponse =
huggingFaceApi.embed(request, modelId).execute();
if (retrofitResponse.isSuccessful()) {
return retrofitResponse.body();
} else {
throw toException(retrofitResponse);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
HttpRequest httpRequest = HttpRequest.builder()
.method(POST)
.url(baseUrl + "models/" + modelId + "/pipeline/feature-extraction")
.addHeader("Content-Type", "application/json")
.addHeader("Authorization", "Bearer " + apiKey)
.body(toJson(request))
.build();
private static RuntimeException toException(retrofit2.Response<?> response) throws IOException {
SuccessfulHttpResponse httpResponse = httpClient.execute(httpRequest);
int code = response.code();
String body = response.errorBody().string();
String errorMessage = String.format("status code: %s; body: %s", code, body);
return new RuntimeException(errorMessage);
return fromJson(httpResponse.body(), new TypeReference<List<float[]>>() {});
}
}

View File

@ -20,7 +20,12 @@ class FactoryCreator {
@Override
public HuggingFaceClient create(Input input) {
return new DefaultHuggingFaceClient(input.baseUrl(), input.apiKey(), input.modelId(), input.timeout());
return new DefaultHuggingFaceClient(
input.httpClientBuilder(),
input.baseUrl(),
input.apiKey(),
input.modelId(),
input.timeout());
}
}
}

View File

@ -1,22 +0,0 @@
package dev.langchain4j.model.huggingface;
import dev.langchain4j.model.huggingface.client.EmbeddingRequest;
import dev.langchain4j.model.huggingface.client.TextGenerationRequest;
import dev.langchain4j.model.huggingface.client.TextGenerationResponse;
import java.util.List;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.Headers;
import retrofit2.http.POST;
import retrofit2.http.Path;
interface HuggingFaceApi {
@POST("models/{modelId}")
@Headers({"Content-Type: application/json"})
Call<List<TextGenerationResponse>> generate(@Body TextGenerationRequest request, @Path("modelId") String modelId);
@POST("models/{modelId}/pipeline/feature-extraction")
@Headers({"Content-Type: application/json"})
Call<List<float[]>> embed(@Body EmbeddingRequest request, @Path("modelId") String modelId);
}

View File

@ -10,6 +10,7 @@ import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.http.client.HttpClientBuilder;
import dev.langchain4j.internal.ChatRequestValidationUtils;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequest;
@ -103,6 +104,11 @@ public class HuggingFaceChatModel implements ChatModel {
public Duration timeout() {
return builder.timeout;
}
@Override
public HttpClientBuilder httpClientBuilder() {
return builder.httpClientBuilder;
}
});
this.temperature = builder.temperature;
this.maxNewTokens = builder.maxNewTokens;
@ -168,6 +174,7 @@ public class HuggingFaceChatModel implements ChatModel {
public static final class Builder {
private HttpClientBuilder httpClientBuilder;
private String baseUrl;
private String accessToken;
private String modelId;
@ -177,6 +184,17 @@ public class HuggingFaceChatModel implements ChatModel {
private Boolean returnFullText = false;
private Boolean waitForModel = true;
/**
* Sets the HTTP client builder that will be used to create the HTTP client to communicate with HuggingFace.
*
* @param httpClientBuilder the HTTP client builder to use
* @return {@code this}
*/
public Builder httpClientBuilder(HttpClientBuilder httpClientBuilder) {
this.httpClientBuilder = httpClientBuilder;
return this;
}
public Builder baseUrl(String baseUrl) {
this.baseUrl = baseUrl;
return this;

View File

@ -2,6 +2,7 @@ package dev.langchain4j.model.huggingface;
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.http.client.HttpClientBuilder;
import dev.langchain4j.model.embedding.DimensionAwareEmbeddingModel;
import dev.langchain4j.model.huggingface.client.EmbeddingRequest;
import dev.langchain4j.model.huggingface.client.HuggingFaceClient;
@ -30,18 +31,32 @@ public class HuggingFaceEmbeddingModel extends DimensionAwareEmbeddingModel {
*/
public HuggingFaceEmbeddingModel(
String baseUrl, String accessToken, String modelId, Boolean waitForModel, Duration timeout) {
ensureNotBlank(accessToken, "%s", "HuggingFace access token must be defined. It can be generated here: https://huggingface.co/settings/tokens");
this.waitForModel = waitForModel == null || waitForModel;
this.baseUrl = baseUrl;
this.modelId = modelId;
this.client = createClient(accessToken, modelId, timeout);
this(new HuggingFaceEmbeddingModelBuilder()
.baseUrl(baseUrl)
.accessToken(accessToken)
.modelId(modelId)
.waitForModel(waitForModel)
.timeout(timeout));
}
public HuggingFaceEmbeddingModel(String accessToken, String modelId, Boolean waitForModel, Duration timeout) {
this(null, accessToken, modelId, waitForModel, timeout);
this(new HuggingFaceEmbeddingModelBuilder()
.accessToken(accessToken)
.modelId(modelId)
.waitForModel(waitForModel)
.timeout(timeout));
}
private HuggingFaceClient createClient(String accessToken, String modelId, Duration timeout) {
public HuggingFaceEmbeddingModel(HuggingFaceEmbeddingModelBuilder builder) {
ensureNotBlank(builder.accessToken, "%s", "HuggingFace access token must be defined. It can be generated here: https://huggingface.co/settings/tokens");
this.waitForModel = builder.waitForModel == null || builder.waitForModel;
this.baseUrl = builder.baseUrl;
this.modelId = builder.modelId;
this.client = createClient(builder.httpClientBuilder, builder.accessToken, builder.modelId, builder.timeout);
}
private HuggingFaceClient createClient(
HttpClientBuilder httpClientBuilder, String accessToken, String modelId, Duration timeout) {
return FactoryCreator.FACTORY.create(new HuggingFaceClientFactory.Input() {
@Override
public String baseUrl() {
@ -62,6 +77,11 @@ public class HuggingFaceEmbeddingModel extends DimensionAwareEmbeddingModel {
public Duration timeout() {
return timeout == null ? DEFAULT_TIMEOUT : timeout;
}
@Override
public HttpClientBuilder httpClientBuilder() {
return httpClientBuilder;
}
});
}
@ -97,6 +117,7 @@ public class HuggingFaceEmbeddingModel extends DimensionAwareEmbeddingModel {
}
public static class HuggingFaceEmbeddingModelBuilder {
private HttpClientBuilder httpClientBuilder;
private String baseUrl;
private String accessToken;
private String modelId;
@ -107,6 +128,17 @@ public class HuggingFaceEmbeddingModel extends DimensionAwareEmbeddingModel {
// This is public so it can be extended
}
/**
* Sets the HTTP client builder that will be used to create the HTTP client to communicate with HuggingFace.
*
* @param httpClientBuilder the HTTP client builder to use
* @return {@code this}
*/
public HuggingFaceEmbeddingModelBuilder httpClientBuilder(HttpClientBuilder httpClientBuilder) {
this.httpClientBuilder = httpClientBuilder;
return this;
}
public HuggingFaceEmbeddingModelBuilder baseUrl(String baseUrl) {
this.baseUrl = baseUrl;
return this;
@ -133,7 +165,7 @@ public class HuggingFaceEmbeddingModel extends DimensionAwareEmbeddingModel {
}
public HuggingFaceEmbeddingModel build() {
return new HuggingFaceEmbeddingModel(this.baseUrl, this.accessToken, this.modelId, this.waitForModel, this.timeout);
return new HuggingFaceEmbeddingModel(this);
}
public String toString() {

View File

@ -0,0 +1,30 @@
package dev.langchain4j.model.huggingface;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
class HuggingFaceJsonUtils {
private HuggingFaceJsonUtils() throws InstantiationException {
throw new InstantiationException("Can't instantiate this utility class.");
}
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
static String toJson(Object object) {
try {
return OBJECT_MAPPER.writeValueAsString(object);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
static <T> T fromJson(String jsonStr, TypeReference<T> typeReference) {
try {
return OBJECT_MAPPER.readValue(jsonStr, typeReference);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -3,6 +3,7 @@ package dev.langchain4j.model.huggingface;
import static dev.langchain4j.internal.ValidationUtils.ensureNotBlank;
import static dev.langchain4j.spi.ServiceHelper.loadFactories;
import dev.langchain4j.http.client.HttpClientBuilder;
import dev.langchain4j.model.huggingface.client.HuggingFaceClient;
import dev.langchain4j.model.huggingface.client.Options;
import dev.langchain4j.model.huggingface.client.Parameters;
@ -92,6 +93,11 @@ public class HuggingFaceLanguageModel implements LanguageModel {
public Duration timeout() {
return builder.timeout;
}
@Override
public HttpClientBuilder httpClientBuilder() {
return builder.httpClientBuilder;
}
});
this.temperature = builder.temperature;
this.maxNewTokens = builder.maxNewTokens;
@ -127,6 +133,7 @@ public class HuggingFaceLanguageModel implements LanguageModel {
public static final class Builder {
private HttpClientBuilder httpClientBuilder;
private String baseUrl;
private String accessToken;
private String modelId;
@ -136,6 +143,17 @@ public class HuggingFaceLanguageModel implements LanguageModel {
private Boolean returnFullText = false;
private Boolean waitForModel = true;
/**
* Sets the HTTP client builder that will be used to create the HTTP client to communicate with HuggingFace.
*
* @param httpClientBuilder the HTTP client builder to use
* @return {@code this}
*/
public Builder httpClientBuilder(HttpClientBuilder httpClientBuilder) {
this.httpClientBuilder = httpClientBuilder;
return this;
}
public Builder baseUrl(String baseUrl) {
this.baseUrl = baseUrl;
return this;

View File

@ -1,5 +1,6 @@
package dev.langchain4j.model.huggingface.spi;
import dev.langchain4j.http.client.HttpClientBuilder;
import dev.langchain4j.model.huggingface.client.HuggingFaceClient;
import java.time.Duration;
@ -19,5 +20,9 @@ public interface HuggingFaceClientFactory {
String modelId();
Duration timeout();
default HttpClientBuilder httpClientBuilder() {
return null;
}
}
}

View File

@ -23,18 +23,31 @@
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>retrofit</artifactId>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-http-client</artifactId>
<version>1.18.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-http-client-jdk</artifactId>
<version>1.18.0-SNAPSHOT</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>converter-jackson</artifactId>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>

View File

@ -0,0 +1,46 @@
[
{
"extension": "revapi.differences",
"configuration": {
"ignore": true,
"differences": [
{
"code": "java.class.externalClassExposedInAPI",
"new": "missing-class dev.langchain4j.http.client.HttpClientBuilder",
"justification": "HttpClientBuilder is intentionally exposed by the Workers AI integration for custom HTTP client configuration"
},
{
"code": "java.class.removed",
"old": "interface dev.langchain4j.model.workersai.client.WorkersAiApi",
"justification": "Internal Retrofit API interface removed as part of the migration from Retrofit/OkHttp to the langchain4j HttpClient abstraction"
},
{
"code": "java.class.removed",
"old": "class dev.langchain4j.model.workersai.client.WorkersAiClient.AuthInterceptor",
"justification": "OkHttp-specific interceptor removed as part of the migration from Retrofit/OkHttp to the langchain4j HttpClient abstraction; authorization is now handled via a request header"
},
{
"code": "java.method.removed",
"old": "method dev.langchain4j.model.workersai.client.WorkersAiApi dev.langchain4j.model.workersai.client.WorkersAiClient::createService(java.lang.String)",
"justification": "Retrofit service factory removed as part of the migration from Retrofit/OkHttp to the langchain4j HttpClient abstraction; the client is now created via WorkersAiClient.builder()"
},
{
"code": "java.method.visibilityReduced",
"old": "method void dev.langchain4j.model.workersai.client.WorkersAiClient::<init>()",
"new": "method void dev.langchain4j.model.workersai.client.WorkersAiClient::<init>(dev.langchain4j.model.workersai.client.WorkersAiClient.Builder)",
"justification": "The public no-arg constructor was replaced by a package-private builder-based constructor as part of the migration from Retrofit/OkHttp; WorkersAiClient is now instantiated via WorkersAiClient.builder()"
},
{
"code": "java.method.removed",
"old": "method void dev.langchain4j.model.workersai.client.AbstractWorkersAIModel::processErrors(dev.langchain4j.model.workersai.client.ApiResponse<?>, okhttp3.ResponseBody) throws java.io.IOException",
"justification": "Manual OkHttp error handling removed; non-2xx responses now throw dev.langchain4j.exception.HttpException automatically via the HttpClient abstraction"
},
{
"code": "java.field.removed",
"old": "field dev.langchain4j.model.workersai.client.AbstractWorkersAIModel.workerAiClient",
"justification": "The Retrofit-based WorkersAiApi field was replaced by the HttpClient-based WorkersAiClient field as part of the migration"
}
]
}
}
]

View File

@ -6,6 +6,7 @@ import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.ToolExecutionResultMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.exception.UnsupportedFeatureException;
import dev.langchain4j.http.client.HttpClientBuilder;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.request.ChatRequestParameters;
@ -19,7 +20,6 @@ import dev.langchain4j.model.workersai.client.WorkersAiChatCompletionRequest;
import dev.langchain4j.model.workersai.spi.WorkersAiChatModelBuilderFactory;
import org.slf4j.Logger;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
@ -39,7 +39,7 @@ public class WorkersAiChatModel extends AbstractWorkersAIModel implements ChatMo
* @param builder builder.
*/
public WorkersAiChatModel(Builder builder) {
this(builder.accountId, builder.modelName, builder.apiToken);
super(builder.accountId, builder.modelName, builder.apiToken, builder.httpClientBuilder);
}
/**
@ -82,6 +82,10 @@ public class WorkersAiChatModel extends AbstractWorkersAIModel implements ChatMo
* ModelName, preferred as enum for extensibility.
*/
public String modelName;
/**
* The HTTP client builder used to create the underlying HTTP client.
*/
public HttpClientBuilder httpClientBuilder;
/**
* Simple constructor.
@ -122,6 +126,17 @@ public class WorkersAiChatModel extends AbstractWorkersAIModel implements ChatMo
return this;
}
/**
* Sets the {@link HttpClientBuilder} used to create the underlying HTTP client.
*
* @param httpClientBuilder The HTTP client builder to set.
* @return The current instance of {@link Builder}.
*/
public Builder httpClientBuilder(HttpClientBuilder httpClientBuilder) {
this.httpClientBuilder = httpClientBuilder;
return this;
}
/**
* Builds a new instance of Worker AI Chat Model.
*
@ -199,17 +214,11 @@ public class WorkersAiChatModel extends AbstractWorkersAIModel implements ChatMo
* @return text generated by the model
*/
private String generate(WorkersAiChatCompletionRequest req) {
try {
retrofit2.Response<dev.langchain4j.model.workersai.client.WorkersAiChatCompletionResponse> retrofitResponse = workerAiClient
.generateChat(req, accountId, modelName)
.execute();
processErrors(retrofitResponse.body(), retrofitResponse.errorBody());
if (retrofitResponse.body() == null) {
throw new IllegalStateException("Response is empty");
}
return retrofitResponse.body().getResult().getResponse();
} catch (IOException e) {
throw new RuntimeException(e);
dev.langchain4j.model.workersai.client.WorkersAiChatCompletionResponse response =
client.generateChat(req, accountId, modelName);
if (response == null || response.getResult() == null) {
throw new IllegalStateException("Response is empty");
}
return response.getResult().getResponse();
}
}

View File

@ -2,6 +2,7 @@ package dev.langchain4j.model.workersai;
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.http.client.HttpClientBuilder;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.output.FinishReason;
import dev.langchain4j.model.output.Response;
@ -10,7 +11,6 @@ import dev.langchain4j.model.workersai.client.WorkersAiEmbeddingResponse;
import dev.langchain4j.model.workersai.spi.WorkersAiEmbeddingModelBuilderFactory;
import org.slf4j.Logger;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
@ -35,7 +35,7 @@ public class WorkersAiEmbeddingModel extends AbstractWorkersAIModel implements E
* @param builder builder.
*/
public WorkersAiEmbeddingModel(Builder builder) {
this(builder.accountId, builder.modelName, builder.apiToken);
super(builder.accountId, builder.modelName, builder.apiToken, builder.httpClientBuilder);
}
/**
@ -78,6 +78,10 @@ public class WorkersAiEmbeddingModel extends AbstractWorkersAIModel implements E
* ModelName, preferred as enum for extensibility.
*/
public String modelName;
/**
* The HTTP client builder used to create the underlying HTTP client.
*/
public HttpClientBuilder httpClientBuilder;
/**
* Simple constructor.
@ -118,6 +122,17 @@ public class WorkersAiEmbeddingModel extends AbstractWorkersAIModel implements E
return this;
}
/**
* Sets the {@link HttpClientBuilder} used to create the underlying HTTP client.
*
* @param httpClientBuilder The HTTP client builder to set.
* @return The current instance of {@link Builder}.
*/
public Builder httpClientBuilder(HttpClientBuilder httpClientBuilder) {
this.httpClientBuilder = httpClientBuilder;
return this;
}
/**
* Builds a new instance of Worker AI Chat Model.
*
@ -133,32 +148,26 @@ public class WorkersAiEmbeddingModel extends AbstractWorkersAIModel implements E
*/
@Override
public Response<Embedding> embed(String text) {
try {
dev.langchain4j.model.workersai.client.WorkersAiEmbeddingRequest req = new dev.langchain4j.model.workersai.client.WorkersAiEmbeddingRequest();
req.getText().add(text);
dev.langchain4j.model.workersai.client.WorkersAiEmbeddingRequest req = new dev.langchain4j.model.workersai.client.WorkersAiEmbeddingRequest();
req.getText().add(text);
retrofit2.Response<dev.langchain4j.model.workersai.client.WorkersAiEmbeddingResponse> retrofitResponse = workerAiClient
.embed(req, accountId, modelName)
.execute();
dev.langchain4j.model.workersai.client.WorkersAiEmbeddingResponse response =
client.embed(req, accountId, modelName);
processErrors(retrofitResponse.body(), retrofitResponse.errorBody());
if (retrofitResponse.body() == null) {
throw new RuntimeException("Unexpected response: " + retrofitResponse);
}
dev.langchain4j.model.workersai.client.WorkersAiEmbeddingResponse.EmbeddingResult res = retrofitResponse.body().getResult();
// Single Vector expected
if (res.getShape().get(0) != 1) {
throw new RuntimeException("Unexpected shape: " + res.getShape());
}
List<Float> embeddings = res.getData().get(0);
float[] floatArray = new float[embeddings.size()];
for (int i = 0; i < embeddings.size(); i++) {
floatArray[i] = embeddings.get(i); // Unboxing Float to float
}
return new Response<>(new Embedding(floatArray), null, FinishReason.STOP);
} catch (IOException e) {
throw new RuntimeException(e);
if (response == null || response.getResult() == null) {
throw new RuntimeException("Unexpected response: " + response);
}
dev.langchain4j.model.workersai.client.WorkersAiEmbeddingResponse.EmbeddingResult res = response.getResult();
// Single Vector expected
if (res.getShape().get(0) != 1) {
throw new RuntimeException("Unexpected shape: " + res.getShape());
}
List<Float> embeddings = res.getData().get(0);
float[] floatArray = new float[embeddings.size()];
for (int i = 0; i < embeddings.size(); i++) {
floatArray[i] = embeddings.get(i); // Unboxing Float to float
}
return new Response<>(new Embedding(floatArray), null, FinishReason.STOP);
}
/**
@ -216,22 +225,17 @@ public class WorkersAiEmbeddingModel extends AbstractWorkersAIModel implements E
* @param accountIdentifier account identifier.
* @param modelName model name.
* @return list of embeddings.
* @throws IOException error occurred during invocation.
*/
private List<Embedding> processChunk(List<TextSegment> chunk, String accountIdentifier, String modelName)
throws IOException {
private List<Embedding> processChunk(List<TextSegment> chunk, String accountIdentifier, String modelName) {
dev.langchain4j.model.workersai.client.WorkersAiEmbeddingRequest req = new dev.langchain4j.model.workersai.client.WorkersAiEmbeddingRequest();
for (TextSegment textSegment : chunk) {
req.getText().add(textSegment.text());
}
retrofit2.Response<dev.langchain4j.model.workersai.client.WorkersAiEmbeddingResponse> retrofitResponse = workerAiClient
.embed(req, accountIdentifier, modelName)
.execute();
processErrors(retrofitResponse.body(), retrofitResponse.errorBody());
if (retrofitResponse.body() == null) {
throw new RuntimeException("Unexpected response: " + retrofitResponse);
WorkersAiEmbeddingResponse response = client.embed(req, accountIdentifier, modelName);
if (response == null || response.getResult() == null) {
throw new RuntimeException("Unexpected response: " + response);
}
WorkersAiEmbeddingResponse.EmbeddingResult res = retrofitResponse.body().getResult();
WorkersAiEmbeddingResponse.EmbeddingResult res = response.getResult();
List<List<Float>> embeddings = res.getData();
List<Embedding> embeddingsList = new ArrayList<>();

View File

@ -1,21 +1,19 @@
package dev.langchain4j.model.workersai;
import dev.langchain4j.data.image.Image;
import dev.langchain4j.http.client.HttpClientBuilder;
import dev.langchain4j.model.image.ImageModel;
import dev.langchain4j.model.output.FinishReason;
import dev.langchain4j.model.output.Response;
import dev.langchain4j.model.workersai.client.AbstractWorkersAIModel;
import dev.langchain4j.model.workersai.client.WorkersAiImageGenerationRequest;
import dev.langchain4j.model.workersai.spi.WorkersAiImageModelBuilderFactory;
import okhttp3.ResponseBody;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.Base64;
@ -40,7 +38,7 @@ public class WorkersAiImageModel extends AbstractWorkersAIModel implements Image
* builder.
*/
public WorkersAiImageModel(Builder builder) {
this(builder.accountId, builder.modelName, builder.apiToken);
super(builder.accountId, builder.modelName, builder.apiToken, builder.httpClientBuilder);
}
/**
@ -87,6 +85,10 @@ public class WorkersAiImageModel extends AbstractWorkersAIModel implements Image
* ModelName, preferred as enum for extensibility.
*/
public String modelName;
/**
* The HTTP client builder used to create the underlying HTTP client.
*/
public HttpClientBuilder httpClientBuilder;
/**
* Simple constructor.
@ -129,6 +131,17 @@ public class WorkersAiImageModel extends AbstractWorkersAIModel implements Image
return this;
}
/**
* Sets the {@link HttpClientBuilder} used to create the underlying HTTP client.
*
* @param httpClientBuilder The HTTP client builder to set.
* @return The current instance of {@link Builder}.
*/
public Builder httpClientBuilder(HttpClientBuilder httpClientBuilder) {
this.httpClientBuilder = httpClientBuilder;
return this;
}
/**
* Builds a new instance of Worker AI Chat Model.
*
@ -208,22 +221,7 @@ public class WorkersAiImageModel extends AbstractWorkersAIModel implements Image
}
}
retrofit2.Response<ResponseBody> response = workerAiClient
.generateImage(imgReq, accountId, modelName)
.execute();
if (response.isSuccessful() && response.body() != null) {
InputStream inputStream = response.body().byteStream();
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
int nRead;
byte[] data = new byte[1024];
while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
buffer.write(data, 0, nRead);
}
buffer.flush();
return buffer.toByteArray();
}
throw new IllegalStateException("An error occurred while generating image.");
return client.generateImage(imgReq, accountId, modelName);
} catch (Exception e) {
throw new RuntimeException(e);
}

View File

@ -1,5 +1,6 @@
package dev.langchain4j.model.workersai;
import dev.langchain4j.http.client.HttpClientBuilder;
import dev.langchain4j.model.input.Prompt;
import dev.langchain4j.model.language.LanguageModel;
import dev.langchain4j.model.output.Response;
@ -9,8 +10,6 @@ import dev.langchain4j.model.workersai.client.WorkersAiTextCompletionResponse;
import dev.langchain4j.model.workersai.spi.WorkersAiLanguageModelBuilderFactory;
import org.slf4j.Logger;
import java.io.IOException;
import static dev.langchain4j.spi.ServiceHelper.loadFactories;
/**
@ -27,7 +26,7 @@ public class WorkersAiLanguageModel extends AbstractWorkersAIModel implements La
* @param builder builder.
*/
public WorkersAiLanguageModel(Builder builder) {
this(builder.accountId, builder.modelName, builder.apiToken);
super(builder.accountId, builder.modelName, builder.apiToken, builder.httpClientBuilder);
}
/**
@ -70,6 +69,10 @@ public class WorkersAiLanguageModel extends AbstractWorkersAIModel implements La
* ModelName, preferred as enum for extensibility.
*/
public String modelName;
/**
* The HTTP client builder used to create the underlying HTTP client.
*/
public HttpClientBuilder httpClientBuilder;
/**
* Simple constructor.
@ -110,6 +113,17 @@ public class WorkersAiLanguageModel extends AbstractWorkersAIModel implements La
return this;
}
/**
* Sets the {@link HttpClientBuilder} used to create the underlying HTTP client.
*
* @param httpClientBuilder The HTTP client builder to set.
* @return The current instance of {@link Builder}.
*/
public Builder httpClientBuilder(HttpClientBuilder httpClientBuilder) {
this.httpClientBuilder = httpClientBuilder;
return this;
}
/**
* Builds a new instance of Worker AI Chat Model.
*
@ -125,18 +139,12 @@ public class WorkersAiLanguageModel extends AbstractWorkersAIModel implements La
*/
@Override
public Response<String> generate(String prompt) {
try {
retrofit2.Response<WorkersAiTextCompletionResponse> retrofitResponse = workerAiClient
.generateText(new WorkersAiTextCompletionRequest(prompt), accountId, modelName)
.execute();
processErrors(retrofitResponse.body(), retrofitResponse.errorBody());
if (retrofitResponse.body() == null) {
throw new RuntimeException("Empty response");
}
return new Response<>(retrofitResponse.body().getResult().getResponse());
} catch (IOException e) {
throw new RuntimeException(e);
WorkersAiTextCompletionResponse response =
client.generateText(new WorkersAiTextCompletionRequest(prompt), accountId, modelName);
if (response == null || response.getResult() == null) {
throw new RuntimeException("Empty response");
}
return new Response<>(response.getResult().getResponse());
}
/**

View File

@ -2,10 +2,7 @@ package dev.langchain4j.model.workersai.client;
import static dev.langchain4j.internal.ValidationUtils.ensureNotEmpty;
import okhttp3.ResponseBody;
import org.slf4j.Logger;
import java.io.IOException;
import dev.langchain4j.http.client.HttpClientBuilder;
/**
* Abstract class for WorkerAI models as they are all initialized the same way.
@ -13,7 +10,6 @@ import java.io.IOException;
*/
public abstract class AbstractWorkersAIModel {
private static final Logger log = org.slf4j.LoggerFactory.getLogger(AbstractWorkersAIModel.class);
/**
* Account identifier, provided by the WorkerAI platform.
*/
@ -25,9 +21,9 @@ public abstract class AbstractWorkersAIModel {
protected String modelName;
/**
* OkHttpClient for the WorkerAI API.
* Client for the WorkerAI API.
*/
protected WorkersAiApi workerAiClient;
protected WorkersAiClient client;
/**
* Simple constructor.
@ -37,37 +33,27 @@ public abstract class AbstractWorkersAIModel {
* @param apiToken api apiToken from .
*/
public AbstractWorkersAIModel(String accountId, String modelName, String apiToken) {
this(accountId, modelName, apiToken, null);
}
/**
* Constructor allowing to customize the underlying HTTP client.
*
* @param accountId account identifier.
* @param modelName model name.
* @param apiToken api token.
* @param httpClientBuilder the HTTP client builder to use, may be {@code null} to use the default one.
*/
public AbstractWorkersAIModel(
String accountId, String modelName, String apiToken, HttpClientBuilder httpClientBuilder) {
ensureNotEmpty(accountId, "%s", "Account identifier should not be null or empty");
this.accountId = accountId;
ensureNotEmpty(modelName, "%s", "Model name should not be null or empty");
this.modelName = modelName;
ensureNotEmpty(apiToken, "%s", "Token should not be null or empty");
this.workerAiClient = WorkersAiClient.createService(apiToken);
this.client = WorkersAiClient.builder()
.apiToken(apiToken)
.httpClientBuilder(httpClientBuilder)
.build();
}
/**
* Process errors from the API.
*
* @param res response
* @param errors errors body from retrofit
* @throws IOException error occurred during invocation
*/
protected void processErrors(ApiResponse<?> res, ResponseBody errors)
throws IOException {
if (res == null || !res.isSuccess()) {
StringBuilder errorMessage = new StringBuilder("Failed to generate chat message:");
if (res == null) {
errorMessage.append(errors.string());
} else if (res.getErrors() != null) {
errorMessage.append(res.getErrors().stream()
.map(ApiResponse.Error::getMessage)
.reduce((a, b) -> a + "\n" + b)
.orElse(""));
}
log.error(errorMessage.toString());
throw new RuntimeException(errorMessage.toString());
}
}
}

View File

@ -1,82 +0,0 @@
package dev.langchain4j.model.workersai.client;
import okhttp3.ResponseBody;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.POST;
import retrofit2.http.Path;
/**
* Public interface to interact with the WorkerAI API.
*/
public interface WorkersAiApi {
/**
* Generate chat.
*
* @param apiRequest
* request.
* @param accountIdentifier
* account identifier.
* @param modelId
* model id.
* @return
* response.
*/
@POST("client/v4/accounts/{accountIdentifier}/ai/run/{modelName}")
Call<WorkersAiChatCompletionResponse> generateChat(@Body WorkersAiChatCompletionRequest apiRequest,
@Path("accountIdentifier") String accountIdentifier,
@Path(value = "modelName", encoded = true) String modelId);
/**
* Generate text.
*
* @param apiRequest
* request.
* @param accountIdentifier
* account identifier.
* @param modelName
* model name.
* @return
* response.
*/
@POST("client/v4/accounts/{accountIdentifier}/ai/run/{modelName}")
Call<WorkersAiTextCompletionResponse> generateText(@Body WorkersAiTextCompletionRequest apiRequest,
@Path("accountIdentifier") String accountIdentifier,
@Path(value = "modelName", encoded = true) String modelName);
/**
* Generate image.
*
* @param apiRequest
* request.
* @param accountIdentifier
* account identifier.
* @param modelName
* model name.
* @return
* response.
*/
@POST("client/v4/accounts/{accountIdentifier}/ai/run/{modelName}")
Call<ResponseBody> generateImage(@Body WorkersAiImageGenerationRequest apiRequest,
@Path("accountIdentifier") String accountIdentifier,
@Path(value = "modelName", encoded = true) String modelName);
/**
* Generate embeddings.
*
* @param apiRequest
* request.
* @param accountIdentifier
* account identifier.
* @param modelName
* model name.
* @return
* response.
*/
@POST("client/v4/accounts/{accountIdentifier}/ai/run/{modelName}")
Call<WorkersAiEmbeddingResponse> embed(@Body WorkersAiEmbeddingRequest apiRequest,
@Path("accountIdentifier") String accountIdentifier,
@Path(value = "modelName", encoded = true) String modelName);
}

View File

@ -1,14 +1,16 @@
package dev.langchain4j.model.workersai.client;
import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.jetbrains.annotations.NotNull;
import retrofit2.Retrofit;
import retrofit2.converter.jackson.JacksonConverterFactory;
import static dev.langchain4j.http.client.HttpMethod.POST;
import static dev.langchain4j.internal.Utils.getOrDefault;
import static dev.langchain4j.model.workersai.client.WorkersAiJsonUtils.fromJson;
import static dev.langchain4j.model.workersai.client.WorkersAiJsonUtils.toJson;
import static java.time.Duration.ofSeconds;
import java.io.IOException;
import dev.langchain4j.http.client.HttpClient;
import dev.langchain4j.http.client.HttpClientBuilder;
import dev.langchain4j.http.client.HttpClientBuilderLoader;
import dev.langchain4j.http.client.HttpRequest;
import dev.langchain4j.http.client.SuccessfulHttpResponse;
import java.time.Duration;
/**
@ -18,69 +20,164 @@ public class WorkersAiClient {
private static final String BASE_URL = "https://api.cloudflare.com/";
/**
* Constructor.
*/
public WorkersAiClient() {}
private final HttpClient httpClient;
private final String authorizationHeader;
WorkersAiClient(Builder builder) {
HttpClientBuilder httpClientBuilder =
getOrDefault(builder.httpClientBuilder, HttpClientBuilderLoader::loadHttpClientBuilder);
// The 30s timeouts preserve the original behavior: slow, but can be needed for images.
this.httpClient = httpClientBuilder
.connectTimeout(getOrDefault(getOrDefault(builder.timeout, httpClientBuilder.connectTimeout()), ofSeconds(30)))
.readTimeout(getOrDefault(getOrDefault(builder.timeout, httpClientBuilder.readTimeout()), ofSeconds(30)))
.build();
this.authorizationHeader = "Bearer " + builder.apiToken;
}
/**
* Initialization of okHTTP.
* Generate chat.
*
* @param apiToken
* authorization token
* @return
* api
* @param apiRequest request.
* @param accountIdentifier account identifier.
* @param modelName model name.
* @return response.
*/
public static WorkersAiApi createService(String apiToken) {
OkHttpClient okHttpClient = new OkHttpClient.Builder()
.addInterceptor(new AuthInterceptor(apiToken))
// Slow but can be needed for images
.callTimeout(Duration.ofSeconds(30))
.readTimeout(Duration.ofSeconds(30))
.build();
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(JacksonConverterFactory.create())
.build();
return retrofit.create(WorkersAiApi.class);
public WorkersAiChatCompletionResponse generateChat(
WorkersAiChatCompletionRequest apiRequest, String accountIdentifier, String modelName) {
return checkSuccess(
fromJson(execute(apiRequest, accountIdentifier, modelName).body(), WorkersAiChatCompletionResponse.class));
}
/**
* An interceptor for HTTP requests to add an authorization token to the header.
* Implements the {@link Interceptor} interface.
* Generate text.
*
* @param apiRequest request.
* @param accountIdentifier account identifier.
* @param modelName model name.
* @return response.
*/
public static class AuthInterceptor implements Interceptor {
private final String apiToken;
/**
* Constructs an AuthInterceptor with a specified authorization token.
*
* @param apiToken The authorization token to be used in HTTP headers.
*/
public AuthInterceptor(String apiToken) {
this.apiToken = apiToken;
}
/**
* Intercepts an outgoing HTTP request, adding an authorization header.
*
* @param chain The chain of request/response interceptors.
* @return The modified response after adding the authorization header.
* @throws IOException If an IO exception occurs during request processing.
*/
@NotNull
@Override
public Response intercept(Chain chain) throws IOException {
Request.Builder builder = chain
.request().newBuilder()
.header("Authorization", "Bearer " + apiToken);
Request request = builder.build();
return chain.proceed(request);
}
public WorkersAiTextCompletionResponse generateText(
WorkersAiTextCompletionRequest apiRequest, String accountIdentifier, String modelName) {
return checkSuccess(
fromJson(execute(apiRequest, accountIdentifier, modelName).body(), WorkersAiTextCompletionResponse.class));
}
/**
* Generate image. The endpoint returns the raw binary image, so the response bytes are returned as-is.
*
* @param apiRequest request.
* @param accountIdentifier account identifier.
* @param modelName model name.
* @return the raw image bytes.
*/
public byte[] generateImage(
WorkersAiImageGenerationRequest apiRequest, String accountIdentifier, String modelName) {
return execute(apiRequest, accountIdentifier, modelName).bodyBytes();
}
/**
* Generate embeddings.
*
* @param apiRequest request.
* @param accountIdentifier account identifier.
* @param modelName model name.
* @return response.
*/
public WorkersAiEmbeddingResponse embed(
WorkersAiEmbeddingRequest apiRequest, String accountIdentifier, String modelName) {
return checkSuccess(
fromJson(execute(apiRequest, accountIdentifier, modelName).body(), WorkersAiEmbeddingResponse.class));
}
/**
* Surfaces Cloudflare API errors returned in a 2xx envelope with {@code success=false}.
* Non-2xx responses are already turned into an {@link dev.langchain4j.exception.HttpException} by the HTTP client.
*/
private static <T extends ApiResponse<?>> T checkSuccess(T response) {
if (response == null || !response.isSuccess()) {
StringBuilder errorMessage = new StringBuilder("Failed to generate chat message:");
if (response != null && response.getErrors() != null) {
errorMessage.append(response.getErrors().stream()
.map(ApiResponse.Error::getMessage)
.reduce((a, b) -> a + "\n" + b)
.orElse(""));
}
throw new RuntimeException(errorMessage.toString());
}
return response;
}
private SuccessfulHttpResponse execute(Object apiRequest, String accountIdentifier, String modelName) {
HttpRequest httpRequest = HttpRequest.builder()
.method(POST)
.url(BASE_URL + "client/v4/accounts/" + accountIdentifier + "/ai/run/" + modelName)
.addHeader("Content-Type", "application/json")
.addHeader("Authorization", authorizationHeader)
.body(toJson(apiRequest))
.build();
return httpClient.execute(httpRequest);
}
/**
* Builder access.
*
* @return builder instance
*/
public static Builder builder() {
return new Builder();
}
/**
* Builder for {@link WorkersAiClient}.
*/
public static class Builder {
private HttpClientBuilder httpClientBuilder;
private Duration timeout;
private String apiToken;
/**
* Sets the {@link HttpClientBuilder} used to create the underlying HTTP client.
*
* @param httpClientBuilder the HTTP client builder.
* @return {@code this}.
*/
public Builder httpClientBuilder(HttpClientBuilder httpClientBuilder) {
this.httpClientBuilder = httpClientBuilder;
return this;
}
/**
* Sets the timeout used for both connecting and reading.
*
* @param timeout the timeout.
* @return {@code this}.
*/
public Builder timeout(Duration timeout) {
this.timeout = timeout;
return this;
}
/**
* Sets the API token used for authorization.
*
* @param apiToken the API token.
* @return {@code this}.
*/
public Builder apiToken(String apiToken) {
this.apiToken = apiToken;
return this;
}
/**
* Builds a new {@link WorkersAiClient}.
*
* @return a new client instance.
*/
public WorkersAiClient build() {
return new WorkersAiClient(this);
}
}
}

View File

@ -0,0 +1,35 @@
package dev.langchain4j.model.workersai.client;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Internal JSON (de)serialization helper for the Workers AI client.
*
* <p>The mapper is intentionally a plain {@link ObjectMapper} with default configuration, mirroring
* the behavior of the Jackson converter that was previously used by the Retrofit-based client.</p>
*/
class WorkersAiJsonUtils {
private WorkersAiJsonUtils() throws InstantiationException {
throw new InstantiationException("Can't instantiate this utility class.");
}
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
static String toJson(Object object) {
try {
return OBJECT_MAPPER.writeValueAsString(object);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
static <T> T fromJson(String jsonStr, Class<T> clazz) {
try {
return OBJECT_MAPPER.readValue(jsonStr, clazz);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}