Skip to content

Commit e3eb406

Browse files
committed
feat: Added support for OpenAI's File API.
1. Introduced the OpenAiFileApi class. 2. Implemented corresponding unit tests and integration tests. Signed-off-by: Sun Yuhan <[email protected]>
1 parent 5a1cafe commit e3eb406

File tree

3 files changed

+659
-0
lines changed

3 files changed

+659
-0
lines changed
Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.openai.api;
18+
19+
import com.fasterxml.jackson.annotation.JsonInclude;
20+
import com.fasterxml.jackson.annotation.JsonProperty;
21+
import org.springframework.ai.model.ApiKey;
22+
import org.springframework.ai.model.NoopApiKey;
23+
import org.springframework.ai.model.SimpleApiKey;
24+
import org.springframework.ai.openai.api.common.OpenAiApiConstants;
25+
import org.springframework.ai.retry.RetryUtils;
26+
import org.springframework.core.io.ByteArrayResource;
27+
import org.springframework.http.HttpHeaders;
28+
import org.springframework.http.ResponseEntity;
29+
import org.springframework.util.Assert;
30+
import org.springframework.util.LinkedMultiValueMap;
31+
import org.springframework.util.MultiValueMap;
32+
import org.springframework.web.client.ResponseErrorHandler;
33+
import org.springframework.web.client.RestClient;
34+
import org.springframework.web.util.UriBuilder;
35+
36+
import java.util.List;
37+
import java.util.function.Consumer;
38+
39+
/**
40+
* OpenAI File API.
41+
*
42+
* @author Sun Yuhan
43+
* @see <a href= "https://platform.openai.com/docs/api-reference/files">Files API</a>
44+
*/
45+
public class OpenAiFileApi {
46+
47+
private final RestClient restClient;
48+
49+
public OpenAiFileApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, String> headers,
50+
RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) {
51+
Consumer<HttpHeaders> authHeaders = h -> h.addAll(headers);
52+
53+
this.restClient = restClientBuilder.clone()
54+
.baseUrl(baseUrl)
55+
.defaultHeaders(authHeaders)
56+
.defaultStatusHandler(responseErrorHandler)
57+
.defaultRequest(requestHeadersSpec -> {
58+
if (!(apiKey instanceof NoopApiKey)) {
59+
requestHeadersSpec.header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey.getValue());
60+
}
61+
})
62+
.build();
63+
}
64+
65+
public static Builder builder() {
66+
return new Builder();
67+
}
68+
69+
/**
70+
* Upload a file that can be used across various endpoints
71+
* @param uploadFileRequest The request body
72+
* @return Response entity containing the file object
73+
*/
74+
public ResponseEntity<FileObject> uploadFile(UploadFileRequest uploadFileRequest) {
75+
MultiValueMap<String, Object> multipartBody = new LinkedMultiValueMap<>();
76+
multipartBody.add("file", new ByteArrayResource(uploadFileRequest.file()) {
77+
@Override
78+
public String getFilename() {
79+
return uploadFileRequest.fileName();
80+
}
81+
});
82+
multipartBody.add("purpose", uploadFileRequest.purpose());
83+
84+
return this.restClient.post().uri("/v1/files").body(multipartBody).retrieve().toEntity(FileObject.class);
85+
}
86+
87+
/**
88+
* Returns a list of files
89+
* @param listFileRequest The request body
90+
* @return Response entity containing the files
91+
*/
92+
public ResponseEntity<FileObjectResponse> listFiles(ListFileRequest listFileRequest) {
93+
return this.restClient.get().uri(uriBuilder -> {
94+
UriBuilder builder = uriBuilder.path("/v1/files");
95+
if (null != listFileRequest.after()) {
96+
builder = builder.queryParam("after", listFileRequest.after());
97+
}
98+
if (null != listFileRequest.limit()) {
99+
builder = builder.queryParam("limit", listFileRequest.limit());
100+
}
101+
if (null != listFileRequest.order()) {
102+
builder = builder.queryParam("order", listFileRequest.order());
103+
}
104+
if (null != listFileRequest.purpose()) {
105+
builder = builder.queryParam("purpose", listFileRequest.purpose());
106+
}
107+
return builder.build();
108+
}).retrieve().toEntity(FileObjectResponse.class);
109+
}
110+
111+
/**
112+
* Returns information about a specific file
113+
* @param fileId The file id
114+
* @return Response entity containing the file object
115+
*/
116+
public ResponseEntity<FileObject> retrieveFile(String fileId) {
117+
return this.restClient.get().uri("/v1/files/%s".formatted(fileId)).retrieve().toEntity(FileObject.class);
118+
}
119+
120+
/**
121+
* Delete a file
122+
* @param fileId The file id
123+
* @return Response entity containing the deletion status
124+
*/
125+
public ResponseEntity<DeleteFileResponse> deleteFile(String fileId) {
126+
return this.restClient.delete()
127+
.uri("/v1/files/%s".formatted(fileId))
128+
.retrieve()
129+
.toEntity(DeleteFileResponse.class);
130+
}
131+
132+
/**
133+
* Returns the contents of the specified file
134+
* @param fileId The file id
135+
* @return Response entity containing the file content
136+
*/
137+
public ResponseEntity<String> retrieveFileContent(String fileId) {
138+
return this.restClient.get().uri("/v1/files/%s/content".formatted(fileId)).retrieve().toEntity(String.class);
139+
}
140+
141+
/**
142+
* The intended purpose of the uploaded file
143+
*/
144+
public enum Purpose {
145+
146+
// @formatter:off
147+
/**
148+
* Used in the Assistants API
149+
*/
150+
@JsonProperty("assistants")
151+
ASSISTANTS("assistants"),
152+
/**
153+
* Used in the Batch API
154+
*/
155+
@JsonProperty("batch")
156+
BATCH("batch"),
157+
/**
158+
* Used for fine-tuning
159+
*/
160+
@JsonProperty("fine-tune")
161+
FINE_TUNE("fine-tune"),
162+
/**
163+
* Images used for vision fine-tuning
164+
*/
165+
@JsonProperty("vision")
166+
VISION("vision"),
167+
/**
168+
* Flexible file type for any purpose
169+
*/
170+
@JsonProperty("user_data")
171+
USER_DATA("user_data"),
172+
/**
173+
* Used for eval data sets
174+
*/
175+
@JsonProperty("evals")
176+
EVALS("evals");
177+
// @formatter:on
178+
179+
private final String value;
180+
181+
Purpose(String value) {
182+
this.value = value;
183+
}
184+
185+
public String getValue() {
186+
return this.value;
187+
}
188+
189+
}
190+
191+
@JsonInclude(JsonInclude.Include.NON_NULL)
192+
public record UploadFileRequest(
193+
// @formatter:off
194+
@JsonProperty("file") byte[] file,
195+
@JsonProperty("fileName") String fileName,
196+
@JsonProperty("purpose") String purpose) {
197+
// @formatter:on
198+
199+
public static Builder builder() {
200+
return new Builder();
201+
}
202+
203+
public static class Builder {
204+
205+
private byte[] file;
206+
207+
private String fileName;
208+
209+
private String purpose;
210+
211+
public Builder file(byte[] file) {
212+
this.file = file;
213+
return this;
214+
}
215+
216+
public Builder fileName(String fileName) {
217+
this.fileName = fileName;
218+
return this;
219+
}
220+
221+
public Builder purpose(String purpose) {
222+
this.purpose = purpose;
223+
return this;
224+
}
225+
226+
public Builder purpose(Purpose purpose) {
227+
this.purpose = purpose.getValue();
228+
return this;
229+
}
230+
231+
public UploadFileRequest build() {
232+
Assert.notNull(file, "file must not be empty");
233+
Assert.notNull(fileName, "fileName must not be empty");
234+
Assert.notNull(purpose, "purpose must not be empty");
235+
236+
return new UploadFileRequest(this.file, this.fileName, this.purpose);
237+
}
238+
239+
}
240+
}
241+
242+
@JsonInclude(JsonInclude.Include.NON_NULL)
243+
public record ListFileRequest(
244+
// @formatter:off
245+
@JsonProperty("after") String after,
246+
@JsonProperty("limit") Integer limit,
247+
@JsonProperty("order") String order,
248+
@JsonProperty("purpose") String purpose) {
249+
// @formatter:on
250+
251+
public static Builder builder() {
252+
return new Builder();
253+
}
254+
255+
public static class Builder {
256+
257+
private String after;
258+
259+
private Integer limit;
260+
261+
private String order;
262+
263+
private String purpose;
264+
265+
public Builder after(String after) {
266+
this.after = after;
267+
return this;
268+
}
269+
270+
public Builder limit(Integer limit) {
271+
this.limit = limit;
272+
return this;
273+
}
274+
275+
public Builder order(String order) {
276+
this.order = order;
277+
return this;
278+
}
279+
280+
public Builder purpose(String purpose) {
281+
this.purpose = purpose;
282+
return this;
283+
}
284+
285+
public Builder purpose(Purpose purpose) {
286+
this.purpose = purpose.getValue();
287+
return this;
288+
}
289+
290+
public ListFileRequest build() {
291+
return new ListFileRequest(this.after, this.limit, this.order, this.purpose);
292+
}
293+
294+
}
295+
}
296+
297+
@JsonInclude(JsonInclude.Include.NON_NULL)
298+
public record FileObject(
299+
// @formatter:off
300+
@JsonProperty("id") String id,
301+
@JsonProperty("object") String object,
302+
@JsonProperty("bytes") Integer bytes,
303+
@JsonProperty("created_at") Integer createdAt,
304+
@JsonProperty("expires_at") Integer expiresAt,
305+
@JsonProperty("filename") String filename,
306+
@JsonProperty("purpose") String purpose) {
307+
// @formatter:on
308+
}
309+
310+
@JsonInclude(JsonInclude.Include.NON_NULL)
311+
public record FileObjectResponse(
312+
// @formatter:off
313+
@JsonProperty("data") List<FileObject> data,
314+
@JsonProperty("object") String object
315+
// @formatter:on
316+
) {
317+
}
318+
319+
@JsonInclude(JsonInclude.Include.NON_NULL)
320+
public record DeleteFileResponse(
321+
// @formatter:off
322+
@JsonProperty("id") String id,
323+
@JsonProperty("object") String object,
324+
@JsonProperty("deleted") Boolean deleted) {
325+
// @formatter:on
326+
}
327+
328+
public static class Builder {
329+
330+
private String baseUrl = OpenAiApiConstants.DEFAULT_BASE_URL;
331+
332+
private ApiKey apiKey;
333+
334+
private MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
335+
336+
private RestClient.Builder restClientBuilder = RestClient.builder();
337+
338+
private ResponseErrorHandler responseErrorHandler = RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER;
339+
340+
public Builder baseUrl(String baseUrl) {
341+
Assert.hasText(baseUrl, "baseUrl cannot be null or empty");
342+
this.baseUrl = baseUrl;
343+
return this;
344+
}
345+
346+
public Builder apiKey(ApiKey apiKey) {
347+
Assert.notNull(apiKey, "apiKey cannot be null");
348+
this.apiKey = apiKey;
349+
return this;
350+
}
351+
352+
public Builder apiKey(String simpleApiKey) {
353+
Assert.notNull(simpleApiKey, "simpleApiKey cannot be null");
354+
this.apiKey = new SimpleApiKey(simpleApiKey);
355+
return this;
356+
}
357+
358+
public Builder headers(MultiValueMap<String, String> headers) {
359+
Assert.notNull(headers, "headers cannot be null");
360+
this.headers = headers;
361+
return this;
362+
}
363+
364+
public Builder restClientBuilder(RestClient.Builder restClientBuilder) {
365+
Assert.notNull(restClientBuilder, "restClientBuilder cannot be null");
366+
this.restClientBuilder = restClientBuilder;
367+
return this;
368+
}
369+
370+
public Builder responseErrorHandler(ResponseErrorHandler responseErrorHandler) {
371+
Assert.notNull(responseErrorHandler, "responseErrorHandler cannot be null");
372+
this.responseErrorHandler = responseErrorHandler;
373+
return this;
374+
}
375+
376+
public OpenAiFileApi build() {
377+
Assert.notNull(this.apiKey, "apiKey must be set");
378+
return new OpenAiFileApi(this.baseUrl, this.apiKey, this.headers, this.restClientBuilder,
379+
this.responseErrorHandler);
380+
}
381+
382+
}
383+
384+
}

0 commit comments

Comments
 (0)