spicyapi문서
SDK
본문

Java SDK

SpicyAPI 공식 Java SDK(ai.spicyapi spicyapi-java) 안내입니다. 설치부터 견적과 첫 생성, 로컬 파일 업로드, 콜백 검증, 봉인된 오류 분류 체계를 빠짐없이 다루는 switch까지 설명합니다.

spicyapi-java는 SpicyAPI의 공식 Java SDK입니다. 이미지·영상·오디오 생성을 SpicyClient 하나 뒤에 놓습니다. API 키를 대신 붙여 주고, 응답 봉투를 레코드에 매핑하고, 상한이 있는 백오프로 작업을 기다리고, 로컬 파일을 한 번의 호출로 업로드하고, 콜백 서명을 검증하고, 모든 실패를 봉인된(sealed) 예외 타입으로 바꿔 줍니다. 덕분에 이 분류 체계를 switch로 다루면 컴파일 시점에 빠짐이 없습니다.

Java 17 이상이 필요하고, 의존성은 하나뿐입니다. Jackson(com.fasterxml.jackson.core:jackson-databind) 2.12 이상입니다. 레코드를 매핑할 수 있게 된 것이 그 릴리스이고, 여기 있는 타입들이 바로 레코드이기 때문입니다. Jackson은 optional도 셰이딩도 아닌 평범한 컴파일 의존성이므로, 내 프로젝트에 이미 있는 버전이 우선합니다. 모듈 경로에서 이 클라이언트는 ai.spicyapi이며, JAR 파일 이름에서 유추하는 대신 매니페스트에 고정돼 있습니다.

나에게 맞는 도구인가요?

하고 싶은 일쓸 도구
내 Java나 JVM 코드에서 SpicyAPI 호출하기이 SDK
Node.js나 TypeScript에서 호출하기TypeScript SDK
Python, Go, PHP에서 호출하기Python, Go, PHP SDK
코드를 짜지 않고 터미널에서 생성하기CLI
AI 어시스턴트가 대신 SpicyAPI를 호출하게 하기MCP 서버와 Agent Skill
채팅·텍스트 모델 쓰기OpenAI, Anthropic, Gemini 형식을 지원하는 기존 Java 클라이언트. 텍스트와 스트리밍을 보세요

설치

Maven Central에서 의존성을 추가하세요.

<dependency>
  <groupId>ai.spicyapi</groupId>
  <artifactId>spicyapi-java</artifactId>
  <version>0.1.0</version>
</dependency>

Gradle(Kotlin DSL)에서는 이렇게 씁니다.

implementation("ai.spicyapi:spicyapi-java:0.1.0")

키는 환경 변수에 넣으세요. 그래야 코드에도, 버전 관리에도 키가 남지 않습니다.

export SPICY_API_KEY="sk-spicy-..."

첫 결과

가격을 묻고, 판단하고, 작업을 만들고, 기다리고, 출력을 읽습니다. 견적은 무료이며 아무것도 예약하지 않습니다. run은 createTask 다음에 waitForTerminal을 이어 붙인 것입니다.

SpicyClient client = SpicyClient.builder().apiKeyFromEnvironment().build();  // SPICY_API_KEY

CreateTaskRequest request = CreateTaskRequest.of(
        "MODEL_ID_FROM_CATALOG",
        Map.of("prompt", "a lantern in fog"));

TaskQuote quote = client.quote(request);              // 무료이며 아무것도 예약하지 않습니다
System.out.println("this will cost at most " + quote.maxCharge());

TaskRecord task = client.run(                         // createTask + waitForTerminal
        request.withQuote(quote), UUID.randomUUID().toString());

task.outputText().ifPresent(System.out::println);     // 파일이 아니라 텍스트로 답하는 엔드포인트도 있습니다
for (TaskAsset asset : task.assets()) {
    asset.url().ifPresent(System.out::println);
}

MODEL_ID_FROM_CATALOG는 자리표시자입니다. 실제 ID는 인증된 카탈로그에서 가져오세요. client.listModels()이고, HTTP로는 GET /api/v1/models입니다. 기억이나 오래된 페이지에서 가져오면 안 됩니다. 모델은 계속 추가되고, 멈추고, 퇴역합니다. 입력은 그 모델 자신의 inputSchema를 보고 구성하세요. listModels(ModelFilter.all().withIncludeSchema(true))가 돌려줍니다. 모델마다 받는 필드가 다릅니다.

assets()를 훑기 전에 outputText()부터 확인하세요. 전사 같은 엔드포인트는 결과 전체를 텍스트 필드에 담고 파일은 하나도 돌려주지 않습니다. 파일만 찾는 클라이언트는 완벽하게 성공한 작업을 두고 아무것도 만들지 못했다고 보고합니다.

금액은 부동소수점이 아니라 미국 달러를 적은 십진 문자열입니다. 이진 부동소수점은 대부분의 십진 금액을 정확히 담지 못하기 때문입니다. 이 API 어디에도 크레딧이나 포인트는 없습니다.

로컬 파일에서 시작하기

이미지-투-비디오, 피사체 교체, 이미지 편집은 모두 내 소재가 먼저 이쪽에 올라와 있어야 합니다. uploadFile은 세 단계를 한 번의 호출로 끝내고 spicy:// URI를 돌려줍니다. 입력에 넣는 값이 바로 그 URI입니다. 공개적으로 접근할 수 있는 HTTPS 주소라면 업로드 없이 바로 넣어도 됩니다.

UploadedFile uploaded = client.uploadFile(Path.of("/path/to/reference.png"));
// uploaded.uri() -> "spicy://f/fil_..."

TaskRecord task = client.run(
        CreateTaskRequest.of("MODEL_ID_FROM_CATALOG",
                Map.of("image", uploaded.uri(), "prompt", "slow dolly in")),
        UUID.randomUUID().toString());

다른 곳에서 스트리밍해 온 바이트를 위해 세 단계도 각각 공개돼 있습니다.

UploadTicket ticket = client.createUploadUrl(UploadContentType.IMAGE_PNG, data.length);
client.putUploadBytes(ticket, data);                  // API가 아니라 오브젝트 스토리지로 갑니다
UploadedFile file = client.commitUploadedFile(ticket.fileId());

가운데 단계를 직접 구현하면 소리 없이 실패하는 지점이 둘 있습니다. 하나, 티켓의 헤더는 미리 서명된 URL이 서명한 내용의 일부이므로 그대로 전달됩니다. 하나를 빼거나, 이름을 바꾸거나, 친절하게 하나를 더하면 스토리지는 403으로 답합니다. 둘, 여기에는 SpicyAPI 키가 붙지 않습니다. URL 자체가 권한이고, 그 URL이 가리키는 곳은 우리가 아니라 스토리지입니다.

이미지는 10 MiB까지, 오디오와 영상은 90 MiB까지 받습니다. 크기를 넘는 파일은 티켓을 요청하기도 전에 클라이언트가 로컬에서 거부합니다. 미디어를 보세요.

결과를 더 짧게 보관하기

출력과 저장된 요청 텍스트는 계정에 설정된 기간만큼 보관됩니다. 작업 하나만 더 짧게 요청할 수 있습니다.

TaskRecord task = client.run(request, idempotencyKey, Duration.ofHours(1));
task.retention().outputsExpireAt();                   // 실제로 적용된 값

이 값은 줄이기만 합니다. 서비스는 내가 넣은 값, 계정 설정, 플랫폼 최댓값 중 가장 작은 것을 택하므로, 요청이 계정이 허용하는 것보다 긴 보관 기간을 되사 올 수는 없습니다. Duration.ZERO도 실제로 쓸 수 있는 값이며, 이때 출력은 작업이 종료 상태가 되는 즉시 사라집니다. 너무 큰 값은 거부되는 대신 깎이는데, 그래서 실제로 적용된 값은 내가 보낸 숫자가 아니라 레코드의 retention()입니다. retryTask는 자기 보관 기간을 따로 받지 않습니다. 재시도는 원래 작업의 값을 물려받습니다. 보관과 파기를 보세요.

잔액, 사용량, 기록

Balance balance = client.getBalance();                          // 계정 전체
UsageReport usage = client.getUsage(                            // 이 키만
        LocalDate.of(2026, 9, 13), LocalDate.of(2026, 9, 20));  // [from, to)
TaskPage page = client.listTasks(TaskFilter.all().withState(TaskState.FAILED));

잔액은 예산을 가늠하는 지표이지 접수 여부를 결정하는 값이 아닙니다. 그중 일부는 특정 모델에만 쓰도록 묶인 지원금일 수 있습니다. balance.fundingSources()가 그 내역이며, 지원금의 modelSlugs가 비어 있다면 모든 모델이라는 뜻이지 아무 모델도 아니라는 뜻이 아닙니다. 어떤 하나의 요청에 대한 정확한 답은 quote()입니다. 접수 규칙을 똑같이 돌려 보면서 아무것도 예약하지 않습니다.

사용량은 장부를 맞춰 보기 위한 것이지 진행 상황을 확인하기 위한 것이 아닙니다. 정산된 요금만 세므로 아직 진행 중인 기간은 언제나 낮게 나오고, 정산이 늦으면 지나간 날짜의 수치가 바뀔 수도 있습니다. 게다가 계정 단위의 자체 요청 제한이 걸려 있고 그 제한기는 막히는 쪽으로 실패하므로, 이걸 폴링하면 계정의 모든 키가 자기 사용량 조회조차 못 하게 될 수 있습니다. 작업 하나를 따라가려면 waitForTerminal을 쓰세요.

listTasks는 호출한 그 키만을 범위로 삼고 메타데이터만 돌려줍니다. 페이지를 넘길 때는 커서만 옮기고 나머지는 하나도 바꾸지 말고, 순회를 시작하기 전에 from과 to를 고정하세요. 지정하지 않으면 서비스가 요청마다 기본 구간을 다시 계산하고(to는 UTC 기준 내일입니다), 그래서 자정을 넘기는 순회는 서로 다른 두 질문을 던져 놓고 그 답을 이어 붙이게 됩니다. 이 사실을 알려 주는 것은 아무것도 없습니다.

콜백

요청에 callBackUrl을 설정하면 종료 상태의 레코드가 내게 전송됩니다. 믿기 전에 검증하세요.

CallbackSignatureVerifier verifier =
        new CallbackSignatureVerifier(System.getenv("SPICY_WEBHOOK_SECRET"));

byte[] body = request.getInputStream().readAllBytes();   // 원본 바이트, 절대 다시 직렬화하지 마세요
WebhookDelivery delivery = verifier.verify(body, request::getHeader);
delivery.task().ifPresent(record -> { /* getTask가 돌려주는 것과 같은 레코드 */ });

이 일이 잘못되는 방식이 세 가지 있고, 이 클래스는 셋 다 피합니다. 검증 전에 본문을 다시 직렬화하기(readValue 뒤에 writeValueAsString을 거치면 바이트가 달라지고, 다이제스트는 다시는 맞지 않습니다), 서명을 equals로 비교하기(처음 다른 바이트에서 바로 돌아오는데, 그 차이는 측정할 수 있는 시간 차이입니다), 신선도 검사 건너뛰기(유효한 서명은 영원히 유효한 채로 남으므로, 가로챈 전송을 그대로 재생할 수 있습니다)입니다.

전송은 재시도됩니다. delivery.deliveryId()로 중복을 걸러 내세요. 웹훅을 보세요.

오류

모든 실패는 SpicyException이고, 이 타입은 봉인돼 있으므로 분류 체계를 switch로 다루면 빠짐이 없습니다.

타입뜻
SpicyApiException서비스가 답했고, 그 답이 "안 된다"였습니다
SpicyProtocolException무언가가 답하긴 했는데 우리 응답 봉투가 아니었습니다. 프록시, 캡티브 포털, 잘못된 기본 주소
SpicyTransportException쓸 만한 답이 없습니다. DNS, TLS, 끊긴 연결
SpicyTimeoutException로컬 요청 시간이 다했습니다. 결과를 모르는 것이지 "일어나지 않았다"가 아닙니다
SpicyWaitTimeoutException폴링을 포기했습니다. 작업은 여전히 돌고 있고 여전히 과금 대상이며, 예외가 그 ID를 담고 있습니다
SpicyUploadException오브젝트 스토리지가 미리 서명된 PUT을 거부했습니다
SpicyWebhookException들어온 전송이 진짜가 아니거나, 오래됐거나, 형식이 맞지 않습니다

분기는 언제나 businessCode()로 하고, 메시지로는 절대 하지 마세요. 메시지는 산문이고, 번역되며, 계약의 일부가 아닙니다. HTTP 503은 서로 다른 비즈니스 코드 세 개가 함께 씁니다. 그래서 상태만으로는 무엇을 해야 할지 알 수 없습니다.

코드의미대응
40003업로드한 바이트가 티켓과 맞지 않습니다새 티켓을 받아 처음부터 다시 하세요. 다시 커밋해도 해결되지 않습니다
40004그 파라미터 조합을 처리할 배포가 없습니다메시지에 이름이 나온 파라미터를 바꾸세요
40201잔액이 모자랍니다충전하세요
40901작업이 만들어지기 전에 가격이 바뀌었습니다견적을 다시 받되 멱등 키는 그대로 두세요
503의존하는 구성 요소를 잠시 쓸 수 없습니다Retry-After만큼 기다린 뒤 다시 시도하세요
50301지금 쓸 수 있는 배포나 가격이 없습니다기다렸다 다시 시도하거나 다른 모델을 고르세요
50302동기 생성이 상위에서 실패했고 환불됐습니다새 멱등 키로 다시 시도하세요. 원래 키는 그 실패를 그대로 재생합니다

remediation()은 실행 중에도 같은 조언을 돌려주고, describe()는 상태·코드·요청 ID·메시지·조언을 로그 한 줄에 담아 줍니다. 지원팀에 문의할 때는 requestId()를 함께 알려 주세요. 모든 코드는 오류에 정리돼 있습니다.

돈을 아껴 주는 두 가지

제출 하나에 멱등 키 하나를 유지하세요. 다시 보낼 때는 시간이 초과된 뒤라도 그 키를 그대로 다시 쓰세요. 응답을 받지 못했다는 사실이 작업이 만들어지지 않았다는 증거는 아닙니다. 새 키를 쓰면 결과를 모르는 상태가 돈이 드는 두 번째 작업으로 바뀝니다. createTask가 키를 넘겼을 때만 자동으로 재시도하는 이유도 이것입니다. 키가 없으면 시간 초과 뒤의 재시도는 두 번째 생성이자 두 번째 과금이 되기 때문입니다. 멱등성을 보세요.

성공한 작업은 결과가 마음에 들지 않아도 과금됩니다. quote()는 아무것도 예약하지 않고 아무것도 만들지 않으니, 가격이 중요하다면 먼저 견적을 받으세요. createTask는 estimatedCost만큼을 예약하고, 그 예약이 상한입니다. 쓰지 않은 돈은 돌아오고, 그 위로는 나중에도 청구되지 않습니다.

이 패키지가 하지 않는 일

  • 텍스트 모델. 텍스트 모델은 OpenAI, Anthropic, Gemini의 통신 형식을 씁니다. 그 형식을 지원하는 기존 Java 클라이언트가 같은 키로 https://api.spicyapi.ai/v1에 대고 이미 그대로 작동합니다. 텍스트와 스트리밍을 보세요.
  • 클라이언트 쪽 애플리케이션. Android 앱, 데스크톱 바이너리 등 사용자가 열어 볼 수 있는 어떤 것에도 이 키를 담지 마세요. 내 서버에서 호출하세요.
  • 모델 고르기와 필드 채우기. SDK는 카탈로그와 모델별 스키마를 건네줄 뿐이고, 무엇을 보낼지는 직접 정합니다.
  • 작업 취소. 작업이 접수되면 끝까지 실행됩니다.

소스

이 아티팩트는 MIT 라이선스이고 **github.com/spicyapi-ai/spicy-java**에서 공개적으로 개발됩니다. mvn -B verify는 로컬 스텁을 상대로 단위 테스트를 돌리고, 여기에 더해 동봉된 계약을 https://docs.spicyapi.ai/openapi.yaml에 공개된 계약과 비교하는 계약 드리프트 검사를 수행한 다음, 패키지가 하드코딩한 열거값을 모두 다시 확인합니다. 계약에 값이 하나 늘었는데 클라이언트가 따라가지 못하면 어디에서도 실패하지 않고, 그저 새 값을 로컬에서 소리 없이 거부하기 때문입니다.

다음 단계

이 페이지에서