폴백 크레딧
폴백 크레딧 (Fallback credit)
거부된 요청을 다른 모델에서 재시도할 때 프롬프트 캐시 비용을 두 번 내지 않게 해 주는 기능이에요. 프롬프트 캐시는 모델별이라서, 모델 A가 요청을 거절하고 모델 B에서 재시도하면 첫 모델에 이미 캐시된 대화 접두사를 새 모델의 캐시에 처음부터 다시 써야 해요. 캐시 쓰기는 캐시 읽기보다 비싸요. 폴백 크레딧이 그 추가 비용을 제거해 줘요. 서버 측 폴백이나 SDK 미들웨어를 쓰면 폴백 크레딧이 자동으로 적용되므로 이 페이지는 필요 없어요.
출처: 문서
본문
프롬프트 캐시는 모델별이에요. 모델이 요청을 거절하고 다른 모델에서 재시도하면, 첫 모델에 이미 캐시된 대화 접두사를 새 모델의 캐시에 처음부터 써야 해요. 캐시 쓰기는 캐시 읽기보다 비싸요. 폴백 크레딧이 그 추가 비용을 제거해요. 거절에는 크레딧 토큰이 붙고, 재시도에서 그 토큰을 반향하면 새 모델에서 처음부터 대화가 있었다는 것처럼 요금이 매겨져요.
이 페이지는 당신이 재시도를 직접 구축할 때만 필요해요. 원시 HTTP나 맞춤 재시도 로직으로 말이에요. 서버 측 폴백과 SDK 미들웨어는 폴백 크레딧을 자동으로 적용해요. 둘 중 하나를 쓰면 이 페이지는 건너뛰세요.
거부와 폴백은 거부를 감지하고 폴백 방식을 선택하는 것을 다뤄요. 프롬프트 캐싱은 그 용어가 새롭다면 캐시 읽기와 캐시 쓰기를 설명해요.
기본 흐름 (The basic flow)
* **`fallback_credit_token`:** 크레딧을 나타내는 불투명 문자열.
* **`fallback_has_prefill_claim`:** 어떤 재시도 본문 모양을 쓸지 알려주는 Boolean.
둘 다 거절에 대해 크레딧을 사용할 수 없으면 `null`이에요.
fallback_has_prefill_claim 필드는 재시도가 거절된 모델의 부분 출력을 이어갈 수 있는지(처음부터 시작하는 대신) 알려줘요:
fallback_has_prefill_claim |
재시도 본문 |
|---|---|
true |
거절된 요청 본문, 그대로, 더하기 거절된 응답의 content를 반향하는 assistant 메시지 하나. 재시도 모델은 거절된 모델이 멈춘 곳에서 응답을 이어가고, 완료된 서버 도구 호출은 재실행되지 않아요. |
false |
거절된 요청 본문, 그대로. |
예시 (Example)
다음 예시는 거절될 수 있는 요청을 만들고 Claude Opus 4.8에 대한 재시도에서 크레딧 토큰을 청산해요. 재시도 시도가 거부되면 예시는 거부 사다리(재시도가 거부될 때 다루는, 점점 더 단순해지는 재시도 모양들의 시퀀스)를 따라 내려가요. 이는 재시도가 거부될 때에서 다뤄요.
A refusal carries a one-time credit token in stop_details
token=$(jq -r '.stop_details.fallback_credit_token // empty' <<<"${response}")
if [[ -n "${token}" ]]; then
# Retry on the fallback model with the credit token (same body)
response=$(curl --fail-with-body -sS https://api.anthropic.com/v1/messages
-H "x-api-key: $ANTHR...KEY"
-H "anthropic-version: 2023-06-01"
-H "anthropic-beta: fallback-credit-2026-07-01"
-H "content-type: application/json"
-d "$(jq -n --arg token "${token}" '{
model: "claude-opus-4-8",
max_tokens: 1024,
messages: [{"role": "user", "content": "Hello, Claude"}],
fallback_credit_token: $token
}')")
fi
See the SDK examples for the full rejection-handling ladder.
jq -c '{stop_reason, model}' <<<"${response}"
```bash CLI
# Initial request (may be refused)
response=$(ant beta:messages create \
--model claude-fable-5 \
--max-tokens 1024 \
--message '{"role":"user","content":"Hello, Claude"}' \
--beta fallback-credit-2026-07-01 \
--format json)
# A refusal carries a one-time credit token in stop_details
token=$(jq -r '.stop_details.fallback_credit_token // empty' <<<"${response}")
if [[ -n "${token}" ]]; then
# Retry on the fallback model with the credit token
response=$(ant beta:messages create \
--model claude-opus-4-8 \
--max-tokens 1024 \
--message '{"role":"user","content":"Hello, Claude"}' \
--fallback-credit-token "${token}" \
--beta fallback-credit-2026-07-01 \
--format json)
fi
# See the SDK examples for the full rejection-handling ladder.
jq -c '{stop_reason, model}' <<<"${response}"
client = Anthropic()
request = {
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello, Claude"}],
}
def send(model: str, body: dict[str, object]) -> BetaMessage:
return client.beta.messages.create(
model=model, betas=["fallback-credit-2026-07-01"], **body
)
response = send("claude-fable-5", request)
if (
response.stop_reason == "refusal"
and (details := response.stop_details)
and (token := details.fallback_credit_token)
):
exact_body = request | {"fallback_credit_token": token}
# Prefer the continuation shape unless the claim is False
if details.fallback_has_prefill_claim is not False:
echoed = [block.model_dump() for block in response.content]
match echoed:
case [*_, {"type": "text"} as final_block]:
final_block["text"] = final_block["text"].rstrip()
attempt = exact_body | {
"messages": [
*request["messages"],
{"role": "assistant", "content": echoed},
]
}
else:
attempt = exact_body
try:
response = send("claude-opus-4-8", attempt)
except BadRequestError as error:
if "redemption temporarily unavailable" in error.message:
raise # Transient: retry with the token within its five-minute window
try:
# Fall back to the unchanged body, still with the token
response = send("claude-opus-4-8", exact_body)
except BadRequestError as retry_error:
if "redemption temporarily unavailable" in retry_error.message:
raise # Transient: retry with the token within its five-minute window
# The token itself was rejected: forfeit it and retry without.
response = send("claude-opus-4-8", request)
print(json.dumps({"stop_reason": response.stop_reason, "model": response.model}))
const client = new Anthropic();
const request: Anthropic.Beta.MessageCreateParamsNonStreaming = {
model: "claude-fable-5",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello, Claude" }],
betas: ["fallback-credit-2026-07-01"]
};
let response = await client.beta.messages.create(request);
if (
response.stop_reason === "refusal" &&
response.stop_details?.type === "refusal" &&
response.stop_details.fallback_credit_token
) {
const { fallback_credit_token, fallback_has_prefill_claim } = response.stop_details;
const fallbackModel = "claude-opus-4-8";
const exactRetry: Anthropic.Beta.MessageCreateParamsNonStreaming = {
...request,
model: fallbackModel,
fallback_credit_token
};
// Richest shape first, degrading on each rejection: the continuation
// shape (unless the claim is false), the unchanged body still carrying
// the token, and finally forfeiting the token.
let attempt = exactRetry;
if (fallback_has_prefill_claim !== false) {
const finalBlock = response.content.at(-1);
const echoed: Anthropic.Beta.BetaContentBlockParam[] =
finalBlock?.type === "text"
? [
...response.content.slice(0, -1),
{ ...finalBlock, text: finalBlock.text.trimEnd() }
]
: response.content;
attempt = {
...exactRetry,
messages: [...request.messages, { role: "assistant", content: echoed }]
};
}
try {
response = await client.beta.messages.create(attempt);
} catch (error) {
// Degrade only on a shape-related 400. "redemption temporarily
// unavailable" is transient: retry the same way within the token's
// five-minute window instead.
if (
!(error instanceof Anthropic.BadRequestError) ||
error.message.includes("redemption temporarily unavailable")
) {
throw error;
}
try {
response = await client.beta.messages.create(exactRetry);
} catch (retryError) {
if (
!(retryError instanceof Anthropic.BadRequestError) ||
retryError.message.includes("redemption temporarily unavailable")
) {
throw retryError;
}
response = await client.beta.messages.create({ ...request, model: fallbackModel });
}
}
}
const { stop_reason, model } = response;
console.log(JSON.stringify({ stop_reason, model }));
var client = new AnthropicClient();
const string beta = "fallback-credit-2026-07-01";
List<BetaMessageParam> requestMessages =
[
new() { Role = Role.User, Content = "Hello, Claude" },
];
MessageCreateParams Request(string model) => new()
{
Model = model,
MaxTokens = 1024,
Messages = requestMessages,
Betas = [beta],
};
var response = await client.Beta.Messages.Create(Request("claude-fable-5"));
if (
response.StopReason == BetaStopReason.Refusal
&& response.StopDetails is { FallbackCreditToken: string token } details
)
{
var exactBody = Request("claude-opus-4-8") with { FallbackCreditToken = token };
var attempt = exactBody;
// Prefer the continuation shape unless the claim is false
if (details.FallbackHasPrefillClaim is not false)
{
var echoed = JsonArray.Create(response.RawData["content"])!;
if (
echoed is [.., JsonObject lastBlock]
&& lastBlock["type"]?.GetValue<string>() is "text"
&& lastBlock["text"]?.GetValue<string>() is string text
)
{
lastBlock["text"] = text.TrimEnd();
}
attempt = exactBody with
{
Messages =
[
.. requestMessages,
new()
{
Role = Role.Assistant,
Content = new BetaMessageParamContent(
JsonSerializer.SerializeToElement(echoed)
),
},
],
};
}
// A transient "redemption temporarily unavailable" rejection propagates out of
// each of the following catch filters: retry with the token within its five-minute window.
try
{
response = await client.Beta.Messages.Create(attempt);
}
catch (AnthropicBadRequestException e)
when (!e.Message.Contains("redemption temporarily unavailable"))
{
try
{
// Fall back to the unchanged body, still with the token
response = await client.Beta.Messages.Create(exactBody);
}
catch (AnthropicBadRequestException retryError)
when (!retryError.Message.Contains("redemption temporarily unavailable"))
{
// The token itself was rejected: forfeit it and retry without.
response = await client.Beta.Messages.Create(Request("claude-opus-4-8"));
}
}
}
Console.WriteLine(
JsonSerializer.Serialize(
new { stop_reason = response.StopReason?.Raw(), model = response.Model.Raw() }
)
);
ctx := context.Background()
client := anthropic.NewClient()
request := anthropic.BetaMessageNewParams{
MaxTokens: 1024,
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFallbackCredit2026_07_01},
Messages: []anthropic.BetaMessageParam{
anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("Hello, Claude")),
},
}
send := func(model anthropic.Model, body anthropic.BetaMessageNewParams) (*anthropic.BetaMessage, error) {
body.Model = model
return client.Beta.Messages.New(ctx, body)
}
// A non-transient 400 means this attempt shape or token was rejected and
// the next rung of the ladder should run. "redemption temporarily
// unavailable" is transient: surface it and retry with the token within
// its five-minute window.
canFallBack := func(err error) bool {
apiErr, ok := errors.AsType[*anthropic.Error](err)
return ok && apiErr.StatusCode == 400 &&
!strings.Contains(apiErr.Error(), "redemption temporarily unavailable")
}
response, err := send(anthropic.ModelClaudeFable5, request)
if err != nil {
log.Fatal(err)
}
if response.StopReason == anthropic.BetaStopReasonRefusal {
details := response.StopDetails
if token := details.FallbackCreditToken; token != "" {
exactBody := request
exactBody.FallbackCreditToken = anthropic.BetaMessageNewParamsFallbackCreditTokenUnion{
OfString: anthropic.String(token),
}
attempt := exactBody
// Prefer the continuation shape unless the claim is false
if details.FallbackHasPrefillClaim || !details.JSON.FallbackHasPrefillClaim.Valid() {
echoed := response.ToParam()
if len(echoed.Content) > 0 {
if text := echoed.Content[len(echoed.Content)-1].OfText; text != nil {
text.Text = strings.TrimRightFunc(text.Text, unicode.IsSpace)
}
}
attempt.Messages = append(slices.Clone(request.Messages), echoed)
}
response, err = send(anthropic.ModelClaudeOpus4_8, attempt)
if err != nil && canFallBack(err) {
// Fall back to the unchanged body, still with the token
response, err = send(anthropic.ModelClaudeOpus4_8, exactBody)
if err != nil && canFallBack(err) {
// The token itself was rejected: forfeit it and retry without.
response, err = send(anthropic.ModelClaudeOpus4_8, request)
}
}
if err != nil {
log.Fatal(err)
}
}
}
summary, err := json.Marshal(struct {
StopReason anthropic.BetaStopReason `json:"stop_reason"`
Model anthropic.Model `json:"model"`
}{response.StopReason, response.Model})
if err != nil {
log.Fatal(err)
}
fmt.Println(string(summary))
AnthropicClient client = AnthropicOkHttpClient.fromEnv();
MessageCreateParams.Builder request() {
return MessageCreateParams.builder()
.maxTokens(1024L)
.addUserMessage("Hello, Claude")
.addBeta(AnthropicBeta.FALLBACK_CREDIT_2026_07_01);
}
BetaMessage send(Model model, MessageCreateParams.Builder body) {
return client.beta().messages().create(body.model(model).build());
}
void main() {
BetaMessage response = send(Model.CLAUDE_FABLE_5, request());
if (response.stopReason().map(BetaStopReason.REFUSAL::equals).orElse(false)
&& response.stopDetails().orElse(null) instanceof BetaRefusalStopDetails details
&& details.fallbackCreditToken().orElse(null) instanceof String creditToken) {
MessageCreateParams.Builder attempt = request().fallbackCreditToken(creditToken);
// Prefer the continuation shape unless the claim is false
if (details.fallbackHasPrefillClaim().orElse(true)) {
List<BetaContentBlockParam> echoed = new ArrayList<>(
response.content().stream().map(BetaContentBlock::toParam).toList());
if (!echoed.isEmpty() && echoed.getLast().isText()) {
var lastText = echoed.removeLast().asText();
echoed.addLast(BetaContentBlockParam.ofText(
lastText.toBuilder().text(lastText.text().stripTrailing()).build()));
}
attempt.addAssistantMessageOfBetaContentBlockParams(echoed);
}
try {
response = send(Model.CLAUDE_OPUS_4_8, attempt);
} catch (BadRequestException badRequest) {
// Transient: retry with the token within its five-minute window
if (badRequest.getMessage().contains("redemption temporarily unavailable")) {
throw badRequest;
}
try {
// Fall back to the unchanged body, still with the token
response = send(Model.CLAUDE_OPUS_4_8, request().fallbackCreditToken(creditToken));
} catch (BadRequestException retryBadRequest) {
if (retryBadRequest.getMessage().contains("redemption temporarily unavailable")) {
throw retryBadRequest;
}
// The token itself was rejected: forfeit it and retry without.
response = send(Model.CLAUDE_OPUS_4_8, request());
}
}
}
IO.println("""
{"stop_reason": "%s", "model": "%s"}"""
.formatted(response.stopReason().orElseThrow(), response.model()));
}
$client = new Client();
$beta = 'fallback-credit-2026-07-01';
$messages = [['role' => 'user', 'content' => 'Hello, Claude']];
$send = fn (string $model, array $messages, ?string $token = null) => $client->beta->messages->create(
maxTokens: 1024,
messages: $messages,
model: $model,
fallbackCreditToken: $token,
betas: [$beta],
);
$response = $send('claude-fable-5', $messages);
$token = $response->stopReason === 'refusal'
? $response->stopDetails?->fallbackCreditToken
: null;
if ($token !== null) {
$attemptMessages = $messages;
// Prefer the continuation shape unless the claim is false
if ($response->stopDetails->fallbackHasPrefillClaim !== false) {
$echoed = $response->content
|> json_encode(...)
|> (fn (string $json): array => json_decode($json, associative: true));
$lastIndex = array_key_last($echoed);
if ($lastIndex !== null && $echoed[$lastIndex]['type'] === 'text') {
$echoed[$lastIndex]['text'] = rtrim($echoed[$lastIndex]['text']);
}
$attemptMessages[] = ['role' => 'assistant', 'content' => $echoed];
}
// Transient: retry with the token within its five-minute window
$isTransientRedemption = fn (BadRequestException $error): bool =>
str_contains($error->getMessage(), 'redemption temporarily unavailable');
try {
$response = $send('claude-opus-4-8', $attemptMessages, $token);
} catch (BadRequestException $error) {
if ($isTransientRedemption($error)) {
throw $error;
}
try {
// Fall back to the unchanged body, still with the token
$response = $send('claude-opus-4-8', $messages, $token);
} catch (BadRequestException $retryError) {
if ($isTransientRedemption($retryError)) {
throw $retryError;
}
// The token itself was rejected: forfeit it and retry without.
$response = $send('claude-opus-4-8', $messages);
}
}
}
echo json_encode(['stop_reason' => $response->stopReason, 'model' => $response->model]), PHP_EOL;
client = Anthropic::Client.new
request = {
max_tokens: 1024,
messages: [{role: "user", content: "Hello, Claude"}]
}
send_message = ->(model, body) do
client.beta.messages.create(model:, betas: ["fallback-credit-2026-07-01"], **body)
end
response = send_message.call("claude-fable-5", request)
if response in {stop_reason: :refusal,
stop_details: {fallback_credit_token: String => credit_token} => details}
exact_body = request.merge(fallback_credit_token: credit_token)
# Prefer the continuation shape unless the claim is false
attempt = if details.fallback_has_prefill_claim != false
echoed = response.content.map(&:to_h)
if echoed.last in {type: :text, text: String => final_text}
echoed[-1] = echoed.last.merge(text: final_text.rstrip)
end
exact_body.merge(
messages: [*request[:messages], {role: "assistant", content: echoed}]
)
else
exact_body
end
begin
response = send_message.call("claude-opus-4-8", attempt)
rescue Anthropic::Errors::BadRequestError => error
# Transient: retry with the token within its five-minute window
raise if error.message.include?("redemption temporarily unavailable")
begin
# Fall back to the unchanged body, still with the token
response = send_message.call("claude-opus-4-8", exact_body)
rescue Anthropic::Errors::BadRequestError => error
# Transient: retry with the token within its five-minute window
raise if error.message.include?("redemption temporarily unavailable")
# The token itself was rejected: forfeit it and retry without.
response = send_message.call("claude-opus-4-8", request)
end
end
end
puts JSON.generate({stop_reason: response.stop_reason, model: response.model})
어디서 동작하는지 (Where it works)
폴백 크레딧은 Claude API, Amazon Bedrock, Claude Platform on AWS, Google Cloud, Microsoft Foundry에서 베타 상태예요. Message Batches의 거절은 크레딧 토큰을 만들지 않고, 청산은 직접 Messages API 요청에만 적용돼요. 배치 요청에 전달된 토큰은 수용되지만 무시돼요.
재시도 모델은 거절된 모델의 허용된 폴백 대상 중 하나여야 해요. Claude Fable 5.1과 Claude Fable 5의 경우 그것은 Claude Opus 4.8(claude-opus-4-8)과 Claude Opus 5(claude-opus-5)예요.
크레딧이 적용됐는지 확인하기
환불은 재시도의 usage에서 보여요. 같은 요청이 토큰 없이 보고했을 것과 비교했을 때, cache_creation_input_tokens는 더 낮고 cache_read_input_tokens는 같은 양만큼 더 높아요. 이동이 0이라는 것은 토큰이 인정되었지만 재가격할 것이 없었다는 뜻이에요. 예를 들어 재시도 모델의 캐시가 이미 따뜻했기 때문이에요.
재시도가 거부될 때 (When a retry is rejected)
대부분 재시도는 첫 시도에 청산돼요. 그렇지 않으면 API가 다음에 무엇을 시도할지 알려주는 400 오류를 반환해요.
참고: 거절된 요청이 서버 도구를 실행했다면, 토큰 없는 재시도는 그 도구들을 재실행하고 재청구해요. 그 경우 토큰 없는 재시도로 내려가는 대신 400 오류를 호출자에게 표면화하세요.
참조 (Reference)
다음 섹션은 엣지 케이스와 완전한 청산 규칙을 다뤄요. 대부분 통합에는 필요하지 않아요.
| 규칙 | 필드 |
|---|---|
| 정확히 일치해야 함 | system, messages, tools, tool_choice, thinking, cache_control, 그리고 사용 시 output_config, mcp_servers, context_management, container |
| 재시도에서 바뀔 수 있음 | model, max_tokens, stop_sequences, temperature, top_p, top_k, stream, metadata, service_tier |
연속 모양(fallback_has_prefill_claim: true)은 messages 일치의 유일한 예외예요. messages 끝에 assistant 메시지를 정확히 하나 추가해요.
재시도에서 이전 턴의 thinking 또는 redacted_thinking 블록을 제거하지 마세요. 토큰 없는 일반 재시도가 보통 그런 블록을 제거하더라도요. 본문은 거절된 요청과 일치해야 하고, 서버가 그 블록들을 자체적으로 처리해요.
두 헤더 계열은 재시도를 위해 일치에서 제외돼요:
server-side-fallback-*: 재시도는fallbacks매개변수를 버려야 하고, 그 헤더도 함께 버려도 불일치를 일으키지 않아요.fallback-credit-*: 두 요청 모두에 이 헤더를 유지하세요. 재시도는 토큰을 청산하려면 그게 필요해요.
보내기 전에 두 가지 조정이 여전히 필요할 수 있어요:
- 보내는 마지막 블록이
text블록이면 그 후행 공백을 제거하세요. - 일치하는
tool_result가 없는 클라이언트 측tool_use블록은 생략하세요.
반향된 콘텐츠가 이전 서버 측 폴백의 fallback 블록을 포함하면, 그 블록을 정확히 나타난 위치에 유지하세요. 베타 헤더 없는 어떤 요청에서든 수용돼요. API는 그 위치로 그 주변의 thinking 블록을 검증하므로, 그 경계 양쪽의 thinking 블록을 반향하는 요청은 그 블록이 생략되거나 이동되면 거부돼요.
토큰은 거절 후 5분이 지나면 만료돼요. 그 후에는 그것 없이 재시도를 보내세요. 토큰은 또한 무상태예요. 서버는 그에 대해 아무것도 저장하지 않고, 검사하거나 폐기할 엔드포인트도 없어요.
따라서 다음 둘이 모두 참일 때 어느 모양으로도 토큰을 청산할 수 없는 조합이 생길 수 있어요:
- 요청이
output_config.format이나 도구 사용을 강제하는tool_choice를 사용했음. 둘 중 하나가 추가된 assistant 메시지 모양을 배제해요. - 거절이 서버 도구가 실행된 후 도착했음. 그것이 변경되지 않은 본문을 배제해요.
변경되지 않은 본문 재시도가 토큰을 부분 응답을 계속함으로써 청산해야 한다고 말하는 400 오류로 거부되면 토큰을 버리세요. 그것 없이 재시도는 성공하지만 완료된 서버 도구를 재실행하고 재청구해요. 조용히 재시도하는 대신 비용이나 오류를 호출자에게 표면화하세요.