Skip to content

Commit 087393b

Browse files
sunyuhan1998ilayaperumalg
authored andcommitted
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 <sunyuhan1998@users.noreply.github.com>
1 parent b84c8db commit 087393b

File tree

3 files changed

+664
-0
lines changed

3 files changed

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

0 commit comments

Comments
 (0)