map) {
return reasoning == null ? "" : String.valueOf(reasoning);
}
+ /** 로그에 실을 만큼만 자른 원문. 잘렸다는 사실을 남겨 "이게 전부인가" 를 묻지 않게 한다. */
+ private String preview(String raw) {
+ if (raw == null) {
+ return "(없음)";
+ }
+ return raw.length() <= MAX_LOGGED_RAW_CHARS
+ ? raw
+ : raw.substring(0, MAX_LOGGED_RAW_CHARS) + "…(" + raw.length() + "자 중 앞부분)";
+ }
+
/**
* raw 에서 첫 번째로 완결된 JSON 객체만 잘라낸다.
*
diff --git a/src/main/java/com/example/dvely/agent/domain/value/LlmUsage.java b/src/main/java/com/example/dvely/agent/domain/value/LlmUsage.java
new file mode 100644
index 00000000..217cdb9e
--- /dev/null
+++ b/src/main/java/com/example/dvely/agent/domain/value/LlmUsage.java
@@ -0,0 +1,58 @@
+package com.example.dvely.agent.domain.value;
+
+/**
+ * What one LLM call actually consumed.
+ *
+ * 이 값이 없던 동안에는 토큰을 얼마나 쓰는지 아무도 몰랐다 — 프롬프트 캐싱이나 대화 윈도우
+ * 같은 절감 작업의 효과를 "줄었을 것" 이상으로 말할 수 없었고, 태스크당 예산 상한은 셀 것이
+ * 없어 구현 자체가 불가능했다. 그래서 제공자마다 다른 응답 필드를 여기 한 모양으로 모은다.
+ *
+ * 정규화 규칙이 하나 있다. Anthropic 의 {@code input_tokens} 는 캐시 읽기/쓰기 토큰을
+ * 제외한 수인 반면, OpenAI 호환 제공자의 {@code prompt_tokens} 는 캐시 적중분을
+ * 포함한 수다. 그대로 더하면 같은 이름의 칸에 다른 뜻이 섞이므로, OpenAI 쪽은 캐시
+ * 적중분을 빼서 담는다({@link com.example.dvely.agent.infrastructure.llm.LlmUsageParser}).
+ * 그 결과 {@link #billedInputTokens()} 는 어느 제공자든 "이번 호출이 읽은 입력 전체"다.
+ */
+public record LlmUsage(
+ long inputTokens,
+ long outputTokens,
+ long cacheCreationInputTokens,
+ long cacheReadInputTokens
+) {
+
+ public static final LlmUsage NONE = new LlmUsage(0, 0, 0, 0);
+
+ public LlmUsage {
+ // 음수는 제공자 응답이 어긋났다는 뜻이다. 합계를 오염시키느니 0 으로 떨어뜨린다 —
+ // 계측이 태스크를 망가뜨리는 일은 없어야 한다.
+ inputTokens = Math.max(0, inputTokens);
+ outputTokens = Math.max(0, outputTokens);
+ cacheCreationInputTokens = Math.max(0, cacheCreationInputTokens);
+ cacheReadInputTokens = Math.max(0, cacheReadInputTokens);
+ }
+
+ /** 이번 호출이 읽은 입력 전체(캐시 쓰기·읽기 포함). 제공자 간 비교가 되는 유일한 입력 수치다. */
+ public long billedInputTokens() {
+ return inputTokens + cacheCreationInputTokens + cacheReadInputTokens;
+ }
+
+ public long totalTokens() {
+ return billedInputTokens() + outputTokens;
+ }
+
+ public boolean isEmpty() {
+ return totalTokens() == 0;
+ }
+
+ public LlmUsage plus(LlmUsage other) {
+ if (other == null) {
+ return this;
+ }
+ return new LlmUsage(
+ inputTokens + other.inputTokens,
+ outputTokens + other.outputTokens,
+ cacheCreationInputTokens + other.cacheCreationInputTokens,
+ cacheReadInputTokens + other.cacheReadInputTokens
+ );
+ }
+}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/config/AiProperties.java b/src/main/java/com/example/dvely/agent/infrastructure/config/AiProperties.java
index 9d75ccf1..47f15ac4 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/config/AiProperties.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/config/AiProperties.java
@@ -16,6 +16,19 @@ public class AiProperties {
private Glm glm = new Glm();
private Retry retry = new Retry();
private CodeAgent codeAgent = new CodeAgent();
+ private FailureAnalysis failureAnalysis = new FailureAnalysis();
+
+ /**
+ * 도구를 쓰지 않는 한 번짜리 완성 호출의 출력 상한(Anthropic {@code max_tokens}).
+ *
+ * 예전 값 1024 는 계획 수립에서 실제로 짧았다 — 다단계 계획 JSON 이 중간에서 잘리면
+ * {@code DecisionAgentService#retryOnce} 가 전체 컨텍스트에 실패 응답 에코(최대 2KB)까지
+ * 얹어 한 번 더 호출한다. 즉 짧은 상한이 아끼는 것은 출력 토큰 몇백이고, 치르는 것은
+ * 입력 전체를 한 번 더 보내는 비용이다.
+ *
+ * 상한이지 할당량이 아니다 — 모델이 짧게 답하면 그만큼만 과금된다.
+ */
+ private int completionMaxTokens = 4096;
/**
* 요청이 제공자를 지정하지 않을 때 쓰는 기본 제공자. 배포가 유효한 키를 가진 것으로 맞춘다 —
@@ -69,6 +82,17 @@ public boolean supportsThinking(String candidateModel) {
@Getter
@Setter
public static class Anthropic extends Provider {
+
+ /**
+ * Messages API 전체 URL(호스트 접두사가 아니다 — 클라이언트가 이 값에 그대로 POST 한다).
+ *
+ * {@link Glm#getBaseUrl()} 와 같은 이유로 설정 가능하다: 사내 프록시나 게이트웨이를
+ * 거치도록 배포를 바꿀 수 있어야 하고, 무엇보다 나가는 요청 본문을 실제로 검사하는
+ * 테스트 가 가능해진다. 캐시 브레이크포인트 위치처럼 조용히 틀려도 오류가 나지 않는
+ * 것(요청은 통과하고 캐시만 안 걸린다)은 본문을 직접 보는 것 말고 검증할 방법이 없다.
+ */
+ private String baseUrl = "https://api.anthropic.com/v1/messages";
+
public Anthropic() {
super("claude-opus-4-5-20251101");
}
@@ -169,5 +193,67 @@ public static class CodeAgent {
* this value times the task's retry budget, not this value alone.
*/
private int maxIterations = 40;
+
+ /**
+ * 태스크 하나가 쓸 수 있는 누적 토큰 상한(입력 + 출력 + 캐시). 0 이면 상한 없음.
+ *
+ * 이 값이 없던 동안 곱셈이 그대로 열려 있었다 — 제공자 재시도({@code retry.maxAttempts}
+ * 3) × 라운드({@link #maxIterations} 40) × 태스크 재시도(3). 각 단계는 자기 한도를 지키지만
+ * 태스크 전체가 쓰는 양에는 아무 한도가 없었고, 한 번 헤매기 시작한 태스크가 얼마까지
+ * 쓸 수 있는지 아무도 답할 수 없었다.
+ *
+ * 기본값은 정상 완주를 막지 않는 선이다. 라운드마다 트랜스크립트 전체가 다시 실리므로
+ * 성공하는 CODE 태스크도 누적 수십만 토큰을 쓴다 — 여유를 세 배쯤 둔 값이고, 걸리는 것은
+ * 끝나지 않고 도는 태스크다. 상한에 걸리면 태스크는 사유가 보이는 실패로 닫힌다
+ * ({@link com.example.dvely.agent.application.exception.AgentTokenBudgetExceededException}).
+ */
+ private long maxTaskTokens = 1_000_000;
+ }
+
+ /**
+ * 배포 실패 로그 요약 전용 설정.
+ *
+ * 이 호출은 제공자가 {@code ANTHROPIC} 으로, 모델이 그 제공자의 기본값(최상위 모델)로
+ * 하드코딩돼 있었다. {@link #defaultProvider} 는 GLM 인데 이 한 경로만 그것을 무시했고,
+ * 12,000자 로그를 한 번 요약하는 데 최상위 모델을 쓸 근거도 없었다.
+ *
+ * 다만 품질이 떨어지면 실패 분석 자체가 쓸모없어진다. 그래서 코드에 새 값을 박는
+ * 대신 설정으로 뺐다 — 분석이 나빠지면 {@code provider: ANTHROPIC} 과 그 모델명을 도로 적어
+ * 배포만으로 되돌릴 수 있다.
+ */
+ @Getter
+ @Setter
+ public static class FailureAnalysis {
+
+ /** 비우면 {@link AiProperties#defaultProvider} 를 따른다. */
+ private AiProvider provider;
+
+ /** 비우면 제공자의 기본 모델을 쓴다. */
+ private String model = "";
+
+ public AiProvider providerOr(AiProvider fallback) {
+ return provider == null ? fallback : provider;
+ }
+
+ /** {@code AiModelOptions.model} 에 그대로 들어간다 — null 이면 제공자 기본 모델이 선택된다. */
+ public String modelOrNull() {
+ return model == null || model.isBlank() ? null : model.trim();
+ }
+ }
+
+ /** 실패 분석이 실제로 쓸 제공자. 전용 설정이 없으면 배포의 기본 제공자를 따른다. */
+ public AiProvider failureAnalysisProvider() {
+ return failureAnalysis.providerOr(defaultProvider);
+ }
+
+ /** 제공자별 설정 블록. 코딩 에이전트는 자체 설정이 없으므로 조용히 돌려주지 않고 던진다. */
+ public Provider providerConfig(AiProvider provider) {
+ return switch (provider) {
+ case ANTHROPIC -> anthropic;
+ case OPENAI -> openai;
+ case GLM -> glm;
+ case CLAUDE_CODE, CODEX -> throw new IllegalArgumentException(
+ "코딩 에이전트 제공자는 qeploy.ai.* 설정을 갖지 않습니다: " + provider);
+ };
}
}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/AnthropicPromptCache.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/AnthropicPromptCache.java
new file mode 100644
index 00000000..a1ec98f3
--- /dev/null
+++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/AnthropicPromptCache.java
@@ -0,0 +1,130 @@
+package com.example.dvely.agent.infrastructure.llm;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Anthropic 요청에 {@code cache_control} 브레이크포인트를 얹는다.
+ *
+ * 캐싱은 접두 일치 다. 렌더 순서는 {@code tools → system → messages} 이고, 접두의 한
+ * 바이트라도 달라지면 그 뒤 전부가 무효가 된다. 그래서 마커를 붙이기 전에 접두가 실제로 고정인지가
+ * 먼저인데, CODE 루프는 그 조건을 만족한다 — 시스템 프롬프트와 도구 정의가 {@code static final}
+ * 상수이고, 트랜스크립트는 덧붙이기만 한다(앞부분을 다시 쓰거나 지우는 곳이 없다).
+ *
+ * 브레이크포인트 배치(요청당 최대 4개):
+ *
+ * 도구 정의 마지막 블록 — 도구 목록만 덮는다
+ * 시스템 프롬프트 — 도구 + 시스템을 함께 덮는다
+ * 직전 user 턴, 4. 마지막 user 턴 — 굴러가는 두 지점. 라운드마다 한 칸씩 뒤로 밀리므로,
+ * 다음 라운드의 접두는 직전 라운드가 써 둔 지점에서 그대로 적중한다
+ *
+ *
+ * 짧은 접두는 조용히 캐시되지 않는다. 최소 캐시 가능 접두는 모델마다 다르다(최신 모델
+ * 512 토큰, {@code claude-opus-4-5} 는 4096 토큰). 지금 설정된 기본 모델 기준으로 CODE 의
+ * 시스템 프롬프트(4,280자 ≈ 1.1K 토큰)와 도구 정의는 그 최소에 못 미치므로 1·2 번 지점은 항목을
+ * 만들지 않는다 — 오류도 나지 않고 과금도 없다. 실제로 값을 하는 것은 3·4 번이고, 트랜스크립트가
+ * 최소를 넘는 2~3 라운드째부터 시스템·도구까지 함께 캐시된다(더 긴 접두가 그 앞을 모두
+ * 포함하기 때문이다). 1·2 번을 그래도 붙여 두는 이유는 최소 접두가 낮은 모델로 설정을 바꾸면
+ * 코드 변경 없이 첫 라운드부터 적중하기 때문이다.
+ */
+final class AnthropicPromptCache {
+
+ /** 굴러가는 브레이크포인트 개수. tools 1 + system 1 과 합쳐 상한 4를 정확히 채운다. */
+ private static final int ROLLING_USER_BREAKPOINTS = 2;
+
+ private static final Map EPHEMERAL = Map.of("type", "ephemeral");
+
+ private AnthropicPromptCache() {
+ }
+
+ /** 시스템 프롬프트를 마커 달린 단일 텍스트 블록으로. */
+ static List> systemBlocks(String systemPrompt) {
+ return List.of(withCacheControl(Map.of(
+ "type", "text",
+ "text", systemPrompt == null ? "" : systemPrompt
+ )));
+ }
+
+ /** 도구 목록의 마지막 정의에만 마커를 단다. 목록 전체가 하나의 캐시 단위다. */
+ static List> toolsWithCacheControl(List> tools) {
+ if (tools == null || tools.isEmpty()) {
+ return tools;
+ }
+ List> marked = new ArrayList<>(tools);
+ int last = marked.size() - 1;
+ marked.set(last, withCacheControl(marked.get(last)));
+ return List.copyOf(marked);
+ }
+
+ /**
+ * 마지막 user 턴 두 개의 마지막 콘텐츠 블록에 마커를 단다.
+ *
+ * 원본은 건드리지 않는다. 호출부의 트랜스크립트에 마커가 쌓이면 라운드마다 마커 위치가
+ * 늘어나 상한 4개를 곧 넘기고, 그 뒤로는 요청 자체가 거절된다.
+ *
+ * 문자열 콘텐츠는 항상 블록 배열로 정규화한다. 마커가 붙는 턴만 블록으로 바꾸면
+ * 같은 턴의 모양이 라운드마다 문자열↔배열로 오가는데, 접두 일치에 그런 흔들림을 남길 이유가
+ * 없다.
+ */
+ static List> messagesWithRollingCacheControl(List> messages) {
+ if (messages == null || messages.isEmpty()) {
+ return messages;
+ }
+
+ List> normalized = new ArrayList<>(messages.size());
+ for (Map message : messages) {
+ normalized.add(normalizeContent(message));
+ }
+
+ int remaining = ROLLING_USER_BREAKPOINTS;
+ for (int i = normalized.size() - 1; i >= 0 && remaining > 0; i--) {
+ Map message = normalized.get(i);
+ if (!"user".equals(message.get("role"))) {
+ continue;
+ }
+ Map marked = markLastContentBlock(message);
+ if (marked != null) {
+ normalized.set(i, marked);
+ remaining--;
+ }
+ }
+ return List.copyOf(normalized);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map normalizeContent(Map message) {
+ if (!(message.get("content") instanceof String text)) {
+ return message;
+ }
+ Map normalized = new LinkedHashMap<>(message);
+ normalized.put("content", List.of(Map.of("type", "text", "text", text)));
+ return normalized;
+ }
+
+ /** 마커를 붙일 블록이 없으면(빈 콘텐츠) null — 브레이크포인트 하나를 헛되이 쓰지 않는다. */
+ @SuppressWarnings("unchecked")
+ private static Map markLastContentBlock(Map message) {
+ if (!(message.get("content") instanceof List> rawBlocks) || rawBlocks.isEmpty()) {
+ return null;
+ }
+ Object lastBlock = rawBlocks.get(rawBlocks.size() - 1);
+ if (!(lastBlock instanceof Map, ?> block)) {
+ return null;
+ }
+
+ List blocks = new ArrayList<>(rawBlocks);
+ blocks.set(blocks.size() - 1, withCacheControl((Map) block));
+
+ Map marked = new LinkedHashMap<>(message);
+ marked.put("content", List.copyOf(blocks));
+ return marked;
+ }
+
+ private static Map withCacheControl(Map block) {
+ Map copy = new LinkedHashMap<>(block);
+ copy.put("cache_control", EPHEMERAL);
+ return copy;
+ }
+}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeClient.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeClient.java
index abb04150..84a60f60 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeClient.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeClient.java
@@ -3,7 +3,9 @@
import com.example.dvely.agent.application.port.out.LlmMessage;
import com.example.dvely.agent.application.port.out.LlmPort;
import com.example.dvely.agent.domain.value.AiModelOptions;
+import com.example.dvely.agent.domain.value.AiProvider;
import com.example.dvely.agent.infrastructure.config.AiProperties;
+import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.RequiredArgsConstructor;
@@ -19,30 +21,31 @@
@RequiredArgsConstructor
public class ClaudeClient implements LlmPort {
- private static final String API_URL = "https://api.anthropic.com/v1/messages";
private static final String API_VERSION = "2023-06-01";
- private static final int MAX_TOKENS = 1024;
static final String PROVIDER_NAME = "Anthropic";
private final AiProperties aiProperties;
+ private final LlmUsageRecorder llmUsageRecorder;
@Override
public String complete(String systemPrompt, List messages, AiModelOptions modelOptions) {
LlmProviderErrors.requireApiKey(PROVIDER_NAME, aiProperties.getAnthropic().getApiKey());
- List> apiMessages = messages.stream()
- .map(m -> Map.of("role", m.role(), "content", m.content()))
+ List> apiMessages = messages.stream()
+ .map(m -> Map.of("role", m.role(), "content", m.content()))
.toList();
+ String model = modelOptions.modelOr(aiProperties.getAnthropic().getModel());
Map body = new HashMap<>();
- body.put("model", modelOptions.modelOr(aiProperties.getAnthropic().getModel()));
- body.put("system", systemPrompt);
- body.put("messages", apiMessages);
- LlmRequestOptions.applyAnthropic(body, modelOptions, MAX_TOKENS);
+ body.put("model", model);
+ // 시스템 프롬프트는 블록 배열로 보낸다 — 문자열 형태로는 cache_control 을 달 수 없다.
+ body.put("system", AnthropicPromptCache.systemBlocks(systemPrompt));
+ body.put("messages", AnthropicPromptCache.messagesWithRollingCacheControl(apiMessages));
+ LlmRequestOptions.applyAnthropic(body, modelOptions, aiProperties.getCompletionMaxTokens());
ClaudeResponse response = LlmProviderErrors.translate(PROVIDER_NAME, aiProperties.getRetry(), () -> LlmHttp.client()
.post()
- .uri(API_URL)
+ .uri(aiProperties.getAnthropic().getBaseUrl())
// 키는 호출마다 싣는다 — 공용 클라이언트의 기본 헤더로 박으면 인스턴스에 고정된다.
.header("x-api-key", aiProperties.getAnthropic().getApiKey())
.header("anthropic-version", API_VERSION)
@@ -54,13 +57,20 @@ public String complete(String systemPrompt, List messages, AiModelOp
throw new IllegalStateException("Claude API 응답이 비어있습니다");
}
- log.debug("Claude 응답 수신: model={}", body.get("model"));
+ llmUsageRecorder.record(
+ AiProvider.ANTHROPIC,
+ response.model() == null ? model : response.model(),
+ LlmUsageParser.anthropicUsage(response.usage()));
+
+ log.debug("Claude 응답 수신: model={}", model);
return response.content().get(0).text();
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record ClaudeResponse(
- @JsonProperty("content") List content
+ @JsonProperty("content") List content,
+ @JsonProperty("model") String model,
+ @JsonProperty("usage") Map usage
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeToolClient.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeToolClient.java
index ab79852f..b5890ff2 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeToolClient.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/ClaudeToolClient.java
@@ -5,7 +5,10 @@
import com.example.dvely.agent.application.port.out.ToolDefinition;
import com.example.dvely.agent.application.port.out.LlmToolResponse;
import com.example.dvely.agent.domain.value.AiModelOptions;
+import com.example.dvely.agent.domain.value.AiProvider;
+import com.example.dvely.agent.domain.value.LlmUsage;
import com.example.dvely.agent.infrastructure.config.AiProperties;
+import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -21,7 +24,6 @@
@RequiredArgsConstructor
public class ClaudeToolClient implements LlmToolPort {
- private static final String API_URL = "https://api.anthropic.com/v1/messages";
private static final String API_VERSION = "2023-06-01";
// Output budget per round. 4096 was not enough to emit one real source file in a single
// write_file call, so generation stopped mid-arguments (stop_reason=max_tokens) on ordinary
@@ -31,6 +33,7 @@ public class ClaudeToolClient implements LlmToolPort {
private static final int MAX_TOKENS = 8192;
private final AiProperties aiProperties;
+ private final LlmUsageRecorder llmUsageRecorder;
private final ObjectMapper objectMapper = new ObjectMapper();
@SuppressWarnings("unchecked")
@@ -50,7 +53,7 @@ public LlmToolResponse completeWithTools(
AiModelOptions modelOptions) {
List> toolsPayload = tools.stream()
- .map(t -> Map.of(
+ .map(t -> Map.of(
"name", t.name(),
"description", t.description(),
"input_schema", t.inputSchema()
@@ -59,16 +62,20 @@ public LlmToolResponse completeWithTools(
LlmProviderErrors.requireApiKey(ClaudeClient.PROVIDER_NAME, aiProperties.getAnthropic().getApiKey());
+ String model = modelOptions.modelOr(aiProperties.getAnthropic().getModel());
Map body = new HashMap<>();
- body.put("model", modelOptions.modelOr(aiProperties.getAnthropic().getModel()));
- body.put("system", systemPrompt);
- body.put("tools", toolsPayload);
- body.put("messages", messages);
+ body.put("model", model);
+ // 캐시 브레이크포인트는 tools → system → messages 순으로 렌더된다. 이 루프의 접두는
+ // 고정이다 — 시스템 프롬프트와 도구 정의가 상수이고 트랜스크립트는 덧붙이기만 한다 —
+ // 그래서 마커가 실제로 값을 한다. 자세한 배치 근거는 AnthropicPromptCache 참고.
+ body.put("system", AnthropicPromptCache.systemBlocks(systemPrompt));
+ body.put("tools", AnthropicPromptCache.toolsWithCacheControl(toolsPayload));
+ body.put("messages", AnthropicPromptCache.messagesWithRollingCacheControl(messages));
LlmRequestOptions.applyAnthropic(body, modelOptions, MAX_TOKENS);
String raw = LlmProviderErrors.translate(ClaudeClient.PROVIDER_NAME, aiProperties.getRetry(), () -> LlmHttp.client()
.post()
- .uri(API_URL)
+ .uri(aiProperties.getAnthropic().getBaseUrl())
// 키는 호출마다 싣는다 — 공용 클라이언트의 기본 헤더로 박으면 인스턴스에 고정된다.
.header("x-api-key", aiProperties.getAnthropic().getApiKey())
.header("anthropic-version", API_VERSION)
@@ -77,11 +84,22 @@ public LlmToolResponse completeWithTools(
.body(String.class));
log.debug("Claude Tool API 응답 수신");
- return parse(raw);
+ ParsedResponse response = parse(raw, model);
+ // 기록은 parse 밖이다. 안에서 하면 예산 초과 예외가 parse 의 catch(Exception) 에 걸려
+ // "Tool API 응답 파싱 실패" 로 둔갑한다 — 사용자는 무엇에 걸렸는지 못 보게 된다.
+ llmUsageRecorder.record(AiProvider.ANTHROPIC, response.model(), response.usage());
+ return response.response();
+ }
+
+ /** 응답과, 그것을 실제로 답한 모델명을 함께 들고 나온다(기록은 호출부에서 한다). */
+ private record ParsedResponse(LlmToolResponse response, String model) {
+ LlmUsage usage() {
+ return response.usage();
+ }
}
@SuppressWarnings("unchecked")
- private LlmToolResponse parse(String raw) {
+ private ParsedResponse parse(String raw, String requestedModel) {
try {
Map response = objectMapper.readValue(raw, Map.class);
String stopReason = (String) response.getOrDefault("stop_reason", "end_turn");
@@ -99,9 +117,13 @@ private LlmToolResponse parse(String raw) {
}
}
- return new LlmToolResponse(toolCalls, contentBlocks, stopReason);
+ LlmUsage usage = LlmUsageParser.anthropic(response);
+ Object answeredModel = response.get("model");
+ return new ParsedResponse(
+ new LlmToolResponse(toolCalls, contentBlocks, stopReason, usage),
+ answeredModel instanceof String named ? named : requestedModel);
} catch (Exception e) {
- log.error("Claude Tool 응답 파싱 실패: {}", raw, e);
+ log.error("Claude Tool 응답 파싱 실패: {}", LlmLogPreview.of(raw), e);
throw new RuntimeException("Claude Tool API 응답 파싱 실패", e);
}
}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmClient.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmClient.java
index 62f1708e..8f426dc8 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmClient.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmClient.java
@@ -3,7 +3,9 @@
import com.example.dvely.agent.application.port.out.LlmMessage;
import com.example.dvely.agent.application.port.out.LlmPort;
import com.example.dvely.agent.domain.value.AiModelOptions;
+import com.example.dvely.agent.domain.value.AiProvider;
import com.example.dvely.agent.infrastructure.config.AiProperties;
+import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder;
import java.net.URI;
import java.util.LinkedHashMap;
import java.util.List;
@@ -28,10 +30,14 @@ public class GlmClient implements LlmPort {
static final String PROVIDER_NAME = "GLM";
private final AiProperties aiProperties;
+ private final LlmUsageRecorder llmUsageRecorder;
@Override
public String complete(String systemPrompt, List messages, AiModelOptions modelOptions) {
- return OpenAiCompatibleChat.complete(endpoint(aiProperties), systemPrompt, messages, modelOptions);
+ OpenAiCompatibleChat.Completion completion =
+ OpenAiCompatibleChat.complete(endpoint(aiProperties), systemPrompt, messages, modelOptions);
+ llmUsageRecorder.record(AiProvider.GLM, completion.model(), completion.usage());
+ return completion.content();
}
/**
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmToolClient.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmToolClient.java
index d9652bef..a753feef 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmToolClient.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/GlmToolClient.java
@@ -4,7 +4,9 @@
import com.example.dvely.agent.application.port.out.LlmToolResponse;
import com.example.dvely.agent.application.port.out.ToolDefinition;
import com.example.dvely.agent.domain.value.AiModelOptions;
+import com.example.dvely.agent.domain.value.AiProvider;
import com.example.dvely.agent.infrastructure.config.AiProperties;
+import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
@@ -24,6 +26,7 @@
public class GlmToolClient implements LlmToolPort {
private final AiProperties aiProperties;
+ private final LlmUsageRecorder llmUsageRecorder;
public LlmToolResponse completeWithTools(
String systemPrompt,
@@ -38,7 +41,10 @@ public LlmToolResponse completeWithTools(
List> messages,
List tools,
AiModelOptions modelOptions) {
- return OpenAiCompatibleChat.completeWithTools(
+ LlmToolResponse response = OpenAiCompatibleChat.completeWithTools(
GlmClient.endpoint(aiProperties), systemPrompt, messages, tools, modelOptions);
+ llmUsageRecorder.record(
+ AiProvider.GLM, modelOptions.modelOr(aiProperties.getGlm().getModel()), response.usage());
+ return response;
}
}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/LlmLogPreview.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/LlmLogPreview.java
new file mode 100644
index 00000000..38faebc7
--- /dev/null
+++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/LlmLogPreview.java
@@ -0,0 +1,25 @@
+package com.example.dvely.agent.infrastructure.llm;
+
+/**
+ * 제공자 응답 원문을 로그에 실을 만큼만 자른다.
+ *
+ * 파싱 실패 로그는 응답 전체 를 찍고 있었다. 그 안에는 모델이 방금 쓴 소스 코드와
+ * 사용자가 무엇을 만들라고 했는지가 그대로 들어 있어서, 파싱이 한 번 어긋날 때마다 사용자의
+ * 코드와 요청이 운영 로그 수집기로 흘러나갔다. 무엇이 어긋났는지 보는 데는 앞부분이면 된다.
+ */
+final class LlmLogPreview {
+
+ private static final int MAX_CHARS = 300;
+
+ private LlmLogPreview() {
+ }
+
+ static String of(String raw) {
+ if (raw == null) {
+ return "(없음)";
+ }
+ return raw.length() <= MAX_CHARS
+ ? raw
+ : raw.substring(0, MAX_CHARS) + "…(" + raw.length() + "자 중 앞부분)";
+ }
+}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/LlmUsageParser.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/LlmUsageParser.java
new file mode 100644
index 00000000..f01f88c0
--- /dev/null
+++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/LlmUsageParser.java
@@ -0,0 +1,79 @@
+package com.example.dvely.agent.infrastructure.llm;
+
+import com.example.dvely.agent.domain.value.LlmUsage;
+import java.util.Map;
+
+/**
+ * 제공자 응답의 {@code usage} 블록을 {@link LlmUsage} 로 읽는다.
+ *
+ * 두 가지 형태만 존재한다. Anthropic 은 {@code input_tokens / output_tokens /
+ * cache_creation_input_tokens / cache_read_input_tokens}, OpenAI 챗 컴플리션 형식(OpenAI 본체와
+ * OpenRouter 경유 GLM)은 {@code prompt_tokens / completion_tokens} 에 캐시 적중분을
+ * {@code prompt_tokens_details.cached_tokens} 로 덧붙인다.
+ *
+ * 파싱이 실패하거나 {@code usage} 가 없어도 절대 던지지 않는다. 계측이 빠지는 것은
+ * 수치 하나를 잃는 일이지만, 계측이 던지면 이미 성공한 LLM 호출이 통째로 실패한다.
+ */
+final class LlmUsageParser {
+
+ private LlmUsageParser() {
+ }
+
+ /** Anthropic Messages API 응답 전체에서 {@code usage} 를 꺼내 읽는다. */
+ static LlmUsage anthropic(Map response) {
+ return anthropicUsage(usageBlock(response));
+ }
+
+ /** 이미 꺼내 둔 Anthropic {@code usage} 블록. */
+ static LlmUsage anthropicUsage(Map usage) {
+ if (usage == null) {
+ return LlmUsage.NONE;
+ }
+ return new LlmUsage(
+ number(usage, "input_tokens"),
+ number(usage, "output_tokens"),
+ number(usage, "cache_creation_input_tokens"),
+ number(usage, "cache_read_input_tokens")
+ );
+ }
+
+ /**
+ * OpenAI 챗 컴플리션 형식의 {@code usage}.
+ *
+ * {@code prompt_tokens} 는 캐시 적중분을 포함한 수라서, 캐시 적중분을 빼서 담는다 —
+ * 그래야 {@link LlmUsage#billedInputTokens()} 가 Anthropic 쪽과 같은 뜻이 된다.
+ */
+ static LlmUsage openAiCompatible(Map response) {
+ Map usage = usageBlock(response);
+ if (usage == null) {
+ return LlmUsage.NONE;
+ }
+ long promptTokens = number(usage, "prompt_tokens");
+ long cachedTokens = nestedNumber(usage, "prompt_tokens_details", "cached_tokens");
+ return new LlmUsage(
+ promptTokens - Math.min(promptTokens, cachedTokens),
+ number(usage, "completion_tokens"),
+ 0,
+ cachedTokens
+ );
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map usageBlock(Map response) {
+ if (response == null) {
+ return null;
+ }
+ return response.get("usage") instanceof Map, ?> usage ? (Map) usage : null;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static long nestedNumber(Map usage, String outerKey, String key) {
+ return usage.get(outerKey) instanceof Map, ?> nested
+ ? number((Map) nested, key)
+ : 0;
+ }
+
+ private static long number(Map source, String key) {
+ return source.get(key) instanceof Number value ? value.longValue() : 0;
+ }
+}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiClient.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiClient.java
index d902b050..b7c12a25 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiClient.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiClient.java
@@ -3,7 +3,9 @@
import com.example.dvely.agent.application.port.out.LlmMessage;
import com.example.dvely.agent.application.port.out.LlmPort;
import com.example.dvely.agent.domain.value.AiModelOptions;
+import com.example.dvely.agent.domain.value.AiProvider;
import com.example.dvely.agent.infrastructure.config.AiProperties;
+import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
@@ -19,10 +21,14 @@ public class OpenAiClient implements LlmPort {
static final String PROVIDER_NAME = "OpenAI";
private final AiProperties aiProperties;
+ private final LlmUsageRecorder llmUsageRecorder;
@Override
public String complete(String systemPrompt, List messages, AiModelOptions modelOptions) {
- return OpenAiCompatibleChat.complete(endpoint(), systemPrompt, messages, modelOptions);
+ OpenAiCompatibleChat.Completion completion =
+ OpenAiCompatibleChat.complete(endpoint(), systemPrompt, messages, modelOptions);
+ llmUsageRecorder.record(AiProvider.OPENAI, completion.model(), completion.usage());
+ return completion.content();
}
private OpenAiCompatibleChat.Endpoint endpoint() {
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiCompatibleChat.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiCompatibleChat.java
index ba3043a3..54e9654d 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiCompatibleChat.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiCompatibleChat.java
@@ -5,6 +5,8 @@
import com.example.dvely.agent.application.port.out.ToolCall;
import com.example.dvely.agent.application.port.out.ToolDefinition;
import com.example.dvely.agent.domain.value.AiModelOptions;
+import com.example.dvely.agent.domain.value.AiProvider;
+import com.example.dvely.agent.domain.value.LlmUsage;
import com.example.dvely.agent.infrastructure.config.AiProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
@@ -52,11 +54,23 @@ record Endpoint(
AiProperties.Retry retry
) {}
+ /**
+ * 한 번의 완성 호출 결과.
+ *
+ * 본문만 돌려주던 예전 모양으로는 응답의 {@code usage} 가 파싱되자마자 버려졌다 — 그래서
+ * 토큰을 얼마나 쓰는지 아무도 몰랐다. 기록은 제공자 빈이 하고(어느 {@link AiProvider} 로
+ * 집계할지는 이 정적 클래스가 알 수 없다), 이 record 가 그 사이를 잇는다.
+ *
+ * @param model 제공자가 실제로 답한 모델명. 요청이 슬러그를 생략했거나 게이트웨이가 다른
+ * 모델로 라우팅했을 수 있으므로 요청값이 아니라 응답값을 남긴다
+ */
+ record Completion(String content, String model, LlmUsage usage) {}
+
/** One-shot completion: a system prompt and a transcript in, the assistant's text out. */
- static String complete(Endpoint endpoint,
- String systemPrompt,
- List messages,
- AiModelOptions modelOptions) {
+ static Completion complete(Endpoint endpoint,
+ String systemPrompt,
+ List messages,
+ AiModelOptions modelOptions) {
LlmProviderErrors.requireApiKey(endpoint.providerName(), endpoint.config().getApiKey());
List> apiMessages = new ArrayList<>();
@@ -72,7 +86,10 @@ static String complete(Endpoint endpoint,
String content = firstMessageContent(endpoint, raw);
log.debug("{} 응답 수신: model={}", endpoint.providerName(), body.get("model"));
- return content;
+ return new Completion(
+ content,
+ answeredModel(endpoint, raw, String.valueOf(body.get("model"))),
+ readUsage(endpoint, raw));
}
/** Tool-calling completion: the calls the model wants run, plus its raw assistant message. */
@@ -107,7 +124,7 @@ static LlmToolResponse completeWithTools(Endpoint endpoint,
.body(String.class));
log.debug("{} Tool API 응답 수신", endpoint.providerName());
- return parseToolResponse(endpoint, raw);
+ return parseToolResponse(endpoint, raw, readUsage(endpoint, raw));
}
private static Map baseBody(Endpoint endpoint,
@@ -137,13 +154,13 @@ private static String firstMessageContent(Endpoint endpoint, String raw) {
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
- log.error("{} 응답 파싱 실패: {}", endpoint.providerName(), raw, e);
+ log.error("{} 응답 파싱 실패: {}", endpoint.providerName(), LlmLogPreview.of(raw), e);
throw new IllegalStateException(endpoint.providerName() + " API 응답 파싱 실패", e);
}
}
@SuppressWarnings("unchecked")
- private static LlmToolResponse parseToolResponse(Endpoint endpoint, String raw) {
+ private static LlmToolResponse parseToolResponse(Endpoint endpoint, String raw, LlmUsage usage) {
try {
Map response = OBJECT_MAPPER.readValue(raw, Map.class);
List> choices = (List>) response.get("choices");
@@ -166,10 +183,10 @@ private static LlmToolResponse parseToolResponse(Endpoint endpoint, String raw)
}
// contentBlocks = [assistantMessage] — 호출 측 루프에서 그대로 messages에 추가
- return new LlmToolResponse(toolCalls, List.of(message), finishReason);
+ return new LlmToolResponse(toolCalls, List.of(message), finishReason, usage);
} catch (Exception e) {
- log.error("{} Tool 응답 파싱 실패: {}", endpoint.providerName(), raw, e);
+ log.error("{} Tool 응답 파싱 실패: {}", endpoint.providerName(), LlmLogPreview.of(raw), e);
throw new RuntimeException(endpoint.providerName() + " Tool API 응답 파싱 실패", e);
}
}
@@ -212,4 +229,33 @@ private static RestClient.RequestBodySpec post(Endpoint endpoint) {
endpoint.extraHeaders().forEach(request::header);
return request;
}
+
+ /**
+ * 응답의 {@code usage}.
+ *
+ * 파싱이 어긋나도 던지지 않는다. 이 시점의 호출은 이미 성공했고, 계측 하나를 잃는 것이
+ * 성공한 호출을 실패로 만드는 것보다 낫다.
+ */
+ @SuppressWarnings("unchecked")
+ private static LlmUsage readUsage(Endpoint endpoint, String raw) {
+ try {
+ return LlmUsageParser.openAiCompatible(OBJECT_MAPPER.readValue(raw, Map.class));
+ } catch (Exception exception) {
+ log.debug("{} 사용량 파싱 실패 — 계측만 건너뜁니다: {}",
+ endpoint.providerName(), exception.getClass().getSimpleName());
+ return LlmUsage.NONE;
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static String answeredModel(Endpoint endpoint, String raw, String requestedModel) {
+ try {
+ Map response = OBJECT_MAPPER.readValue(raw, Map.class);
+ return response.get("model") instanceof String named && !named.isBlank()
+ ? named
+ : requestedModel;
+ } catch (Exception exception) {
+ return requestedModel;
+ }
+ }
}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiToolClient.java b/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiToolClient.java
index d61cc21b..8ea603cc 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiToolClient.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/llm/OpenAiToolClient.java
@@ -4,7 +4,9 @@
import com.example.dvely.agent.application.port.out.LlmToolResponse;
import com.example.dvely.agent.application.port.out.ToolDefinition;
import com.example.dvely.agent.domain.value.AiModelOptions;
+import com.example.dvely.agent.domain.value.AiProvider;
import com.example.dvely.agent.infrastructure.config.AiProperties;
+import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
@@ -19,6 +21,7 @@ public class OpenAiToolClient implements LlmToolPort {
private static final String API_URL = "https://api.openai.com/v1/chat/completions";
private final AiProperties aiProperties;
+ private final LlmUsageRecorder llmUsageRecorder;
public LlmToolResponse completeWithTools(
String systemPrompt,
@@ -33,8 +36,11 @@ public LlmToolResponse completeWithTools(
List> messages,
List tools,
AiModelOptions modelOptions) {
- return OpenAiCompatibleChat.completeWithTools(
+ LlmToolResponse response = OpenAiCompatibleChat.completeWithTools(
endpoint(), systemPrompt, messages, tools, modelOptions);
+ llmUsageRecorder.record(
+ AiProvider.OPENAI, modelOptions.modelOr(aiProperties.getOpenai().getModel()), response.usage());
+ return response;
}
private OpenAiCompatibleChat.Endpoint endpoint() {
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/persistence/entity/LlmUsageEntity.java b/src/main/java/com/example/dvely/agent/infrastructure/persistence/entity/LlmUsageEntity.java
new file mode 100644
index 00000000..4aa3b68f
--- /dev/null
+++ b/src/main/java/com/example/dvely/agent/infrastructure/persistence/entity/LlmUsageEntity.java
@@ -0,0 +1,88 @@
+package com.example.dvely.agent.infrastructure.persistence.entity;
+
+import com.example.dvely.agent.domain.value.LlmUsage;
+import com.example.dvely.agent.infrastructure.usage.LlmUsagePhase;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.time.LocalDateTime;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.CreationTimestamp;
+
+/**
+ * LLM 호출 한 건의 토큰 사용량.
+ *
+ * 비밀은 담지 않는다 — 프롬프트도 응답도 여기 오지 않고, 어느 제공자의 어느 모델이 몇 토큰을
+ * 읽고 썼는지만 남는다. 그래서 {@code toString} 을 따로 두지 않아도 위험하지 않지만, 이 도메인의
+ * 규칙대로 Lombok {@code @ToString}/{@code @Data} 는 쓰지 않는다.
+ */
+@Entity
+@Table(name = "llm_usage")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class LlmUsageEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "llm_usage_id")
+ private Long id;
+
+ /** 스코프 없이 난 호출은 null — 귀속은 없어도 합계에서 빠지지는 않는다. */
+ @Column(name = "task_id", length = 64)
+ private String taskId;
+
+ @Column(name = "user_id")
+ private Long userId;
+
+ @Column(name = "project_id")
+ private Long projectId;
+
+ @Column(name = "phase", nullable = false, length = 30)
+ private String phase;
+
+ @Column(name = "provider", nullable = false, length = 30)
+ private String provider;
+
+ @Column(name = "model", nullable = false, length = 120)
+ private String model;
+
+ @Column(name = "input_tokens", nullable = false)
+ private long inputTokens;
+
+ @Column(name = "output_tokens", nullable = false)
+ private long outputTokens;
+
+ @Column(name = "cache_creation_input_tokens", nullable = false)
+ private long cacheCreationInputTokens;
+
+ @Column(name = "cache_read_input_tokens", nullable = false)
+ private long cacheReadInputTokens;
+
+ @CreationTimestamp
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ public LlmUsageEntity(String taskId,
+ Long userId,
+ Long projectId,
+ LlmUsagePhase phase,
+ String provider,
+ String model,
+ LlmUsage usage) {
+ this.taskId = taskId;
+ this.userId = userId;
+ this.projectId = projectId;
+ this.phase = phase.name();
+ this.provider = provider;
+ this.model = model;
+ this.inputTokens = usage.inputTokens();
+ this.outputTokens = usage.outputTokens();
+ this.cacheCreationInputTokens = usage.cacheCreationInputTokens();
+ this.cacheReadInputTokens = usage.cacheReadInputTokens();
+ }
+}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/persistence/repository/SpringDataLlmUsageRepository.java b/src/main/java/com/example/dvely/agent/infrastructure/persistence/repository/SpringDataLlmUsageRepository.java
new file mode 100644
index 00000000..d8cb310a
--- /dev/null
+++ b/src/main/java/com/example/dvely/agent/infrastructure/persistence/repository/SpringDataLlmUsageRepository.java
@@ -0,0 +1,23 @@
+package com.example.dvely.agent.infrastructure.persistence.repository;
+
+import com.example.dvely.agent.infrastructure.persistence.entity.LlmUsageEntity;
+import java.util.List;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface SpringDataLlmUsageRepository extends JpaRepository {
+
+ List findAllByTaskIdOrderByIdAsc(String taskId);
+
+ /**
+ * 이 태스크가 지금까지 쓴 토큰 합계.
+ *
+ * 예산 상한이 재시도를 건너 유효하려면 필요하다. 실행마다 0 에서 다시 세면 상한은
+ * 태스크 재시도 횟수만큼 곱해지는데, 그 곱셈을 닫는 것이 이 상한의 목적이다.
+ */
+ @Query("select coalesce(sum(u.inputTokens + u.outputTokens"
+ + " + u.cacheCreationInputTokens + u.cacheReadInputTokens), 0)"
+ + " from LlmUsageEntity u where u.taskId = :taskId")
+ long sumTotalTokensByTaskId(@Param("taskId") String taskId);
+}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsagePhase.java b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsagePhase.java
new file mode 100644
index 00000000..10370cde
--- /dev/null
+++ b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsagePhase.java
@@ -0,0 +1,17 @@
+package com.example.dvely.agent.infrastructure.usage;
+
+/** 한 LLM 호출이 파이프라인의 어느 구간에서 났는가. 어디에 돈이 쓰이는지 가르는 축이다. */
+public enum LlmUsagePhase {
+
+ /** 계획 수립(DecisionAgentService) — 교정 재시도 호출도 여기 들어간다. */
+ DECISION,
+
+ /** 계획 실행(CODE 툴 루프·CHAT·DEPLOY 등). 태스크당 토큰 예산이 걸리는 구간이다. */
+ AGENT_RUN,
+
+ /** 배포 실패 로그 요약 1회. */
+ DEPLOY_FAILURE_ANALYSIS,
+
+ /** 열린 스코프 없이 난 호출. 태스크에 귀속되지 않지만 합계에서 빠지지는 않는다. */
+ UNSCOPED
+}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageRecorder.java b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageRecorder.java
new file mode 100644
index 00000000..691b9c7a
--- /dev/null
+++ b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageRecorder.java
@@ -0,0 +1,57 @@
+package com.example.dvely.agent.infrastructure.usage;
+
+import com.example.dvely.agent.domain.value.AiProvider;
+import com.example.dvely.agent.domain.value.LlmUsage;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * 제공자 클라이언트가 호출 한 건을 끝낼 때마다 여기로 사용량을 넘긴다.
+ *
+ * 두 가지를 한다. (1) 행 하나를 남겨 같은 시나리오의 전/후 토큰 합계를 비교할 수 있게 하고,
+ * (2) 열려 있는 {@link LlmUsageScope} 에 누적해 태스크당 예산 상한을 건다.
+ *
+ * 기록 실패는 {@link LlmUsageStore} 가 삼킨다. 반대로 예산 초과는 삼키지 않고 그대로 올린다 —
+ * 그것이 이 기능의 목적이다.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class LlmUsageRecorder {
+
+ private final LlmUsageStore store;
+
+ /**
+ * 에이전트 태스크 하나의 실행 구간을 연다.
+ *
+ * 이전 실행이 쓴 양을 읽어 이어서 센다 — 그래야 상한이 태스크 재시도를 건너 유효하다.
+ * 상한이 꺼져 있으면(0) 조회도 하지 않는다.
+ */
+ public LlmUsageScope openTaskScope(String taskId, Long userId, Long projectId, long budgetTokens) {
+ long alreadyUsed = budgetTokens > 0 ? store.tokensAlreadyUsedBy(taskId) : 0;
+ return LlmUsageScope.open(
+ taskId, userId, projectId, LlmUsagePhase.AGENT_RUN, budgetTokens, alreadyUsed);
+ }
+
+ public void record(AiProvider provider, String model, LlmUsage usage) {
+ if (usage == null || usage.isEmpty()) {
+ // 응답에 usage 가 없었다는 뜻이다. 0 행을 쌓아 합계를 희석시키느니 남기지 않는다.
+ return;
+ }
+ LlmUsageScope scope = LlmUsageScope.current();
+ store.save(
+ scope == null ? null : scope.taskId(),
+ scope == null ? null : scope.userId(),
+ scope == null ? null : scope.projectId(),
+ scope == null ? LlmUsagePhase.UNSCOPED : scope.phase(),
+ provider.name(),
+ model == null || model.isBlank() ? "unknown" : model,
+ usage
+ );
+ if (scope != null) {
+ // save 다음이다 — 상한을 터뜨린 호출도 이미 과금됐으므로 기록에는 남아야 한다.
+ scope.accumulate(usage);
+ }
+ }
+}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageScope.java b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageScope.java
new file mode 100644
index 00000000..e48fdc16
--- /dev/null
+++ b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageScope.java
@@ -0,0 +1,133 @@
+package com.example.dvely.agent.infrastructure.usage;
+
+import com.example.dvely.agent.application.exception.AgentTokenBudgetExceededException;
+import com.example.dvely.agent.domain.value.LlmUsage;
+
+/**
+ * "지금 도는 이 LLM 호출은 누구의 어느 작업 것인가" 를 실행 스레드에 붙여 두는 자리.
+ *
+ * 대안은 {@code LlmPort}/{@code LlmToolPort} 시그니처에 taskId 를 더하는 것이었다. 그러면
+ * 호출부가 전부 바뀌는데, 그 파일들은 다른 작업자가 동시에 손대는 파일이라 충돌 비용이 계측
+ * 가치보다 커진다. 게다가 시그니처에 실으면 새로 생기는 호출부가 빠뜨릴 수 있다 —
+ * 스코프 방식은 호출부가 무엇을 하든 제공자 클라이언트가 알아서 센다.
+ *
+ * 스코프가 열려 있지 않은 호출도 기록은 된다({@link LlmUsagePhase#UNSCOPED}). 귀속만 없을
+ * 뿐 합계에서 사라지지 않아야, "합계가 맞는가" 를 나중에 의심하지 않아도 된다.
+ *
+ * 스레드를 넘지 않는다. {@code @Async} 로 다른 스레드에 넘어가는 구간은 그 스레드에서
+ * 다시 열어야 한다 — 상속되는 ThreadLocal 로 만들면 스레드풀이 스코프를 재사용해 남의 작업
+ * 토큰이 엉뚱한 태스크에 붙는다.
+ */
+public final class LlmUsageScope implements AutoCloseable {
+
+ private static final ThreadLocal CURRENT = new ThreadLocal<>();
+
+ private final LlmUsageScope previous;
+ private final String taskId;
+ private final Long userId;
+ private final Long projectId;
+ private final LlmUsagePhase phase;
+ /** 0 이면 상한 없음. */
+ private final long budgetTokens;
+
+ private long usedTokens;
+ private boolean closed;
+
+ private LlmUsageScope(LlmUsageScope previous,
+ String taskId,
+ Long userId,
+ Long projectId,
+ LlmUsagePhase phase,
+ long budgetTokens) {
+ this.previous = previous;
+ this.taskId = taskId;
+ this.userId = userId;
+ this.projectId = projectId;
+ this.phase = phase == null ? LlmUsagePhase.UNSCOPED : phase;
+ this.budgetTokens = Math.max(0, budgetTokens);
+ }
+
+ /** 상한 없는 스코프. 계획 수립·실패 분석처럼 호출이 유계인 구간에 쓴다. */
+ public static LlmUsageScope open(String taskId, Long userId, Long projectId, LlmUsagePhase phase) {
+ return open(taskId, userId, projectId, phase, 0);
+ }
+
+ public static LlmUsageScope open(String taskId,
+ Long userId,
+ Long projectId,
+ LlmUsagePhase phase,
+ long budgetTokens) {
+ return open(taskId, userId, projectId, phase, budgetTokens, 0);
+ }
+
+ /**
+ * @param alreadyUsedTokens 이 태스크가 이전 실행(재시도 전)에 이미 쓴 토큰. 0 에서 다시 세면
+ * 상한이 태스크 재시도 횟수만큼 곱해져 상한이 아니게 된다
+ */
+ public static LlmUsageScope open(String taskId,
+ Long userId,
+ Long projectId,
+ LlmUsagePhase phase,
+ long budgetTokens,
+ long alreadyUsedTokens) {
+ LlmUsageScope scope =
+ new LlmUsageScope(CURRENT.get(), taskId, userId, projectId, phase, budgetTokens);
+ scope.usedTokens = Math.max(0, alreadyUsedTokens);
+ CURRENT.set(scope);
+ return scope;
+ }
+
+ public static LlmUsageScope current() {
+ return CURRENT.get();
+ }
+
+ @Override
+ public void close() {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ if (previous == null) {
+ CURRENT.remove();
+ } else {
+ CURRENT.set(previous);
+ }
+ }
+
+ public String taskId() {
+ return taskId;
+ }
+
+ public Long userId() {
+ return userId;
+ }
+
+ public Long projectId() {
+ return projectId;
+ }
+
+ public LlmUsagePhase phase() {
+ return phase;
+ }
+
+ public long usedTokens() {
+ return usedTokens;
+ }
+
+ public long budgetTokens() {
+ return budgetTokens;
+ }
+
+ /**
+ * 이번 호출분을 누적하고, 상한을 넘었으면 던진다.
+ *
+ * 던지기 전에 누적한다. 상한을 터뜨린 그 호출도 이미 과금됐으므로 합계에 들어가야
+ * 하고, 그래야 사용자에게 보이는 수치가 실제 청구와 어긋나지 않는다.
+ */
+ synchronized void accumulate(LlmUsage usage) {
+ usedTokens += usage.totalTokens();
+ if (budgetTokens > 0 && usedTokens > budgetTokens) {
+ throw new AgentTokenBudgetExceededException(usedTokens, budgetTokens);
+ }
+ }
+}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageStore.java b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageStore.java
new file mode 100644
index 00000000..93ca3c9a
--- /dev/null
+++ b/src/main/java/com/example/dvely/agent/infrastructure/usage/LlmUsageStore.java
@@ -0,0 +1,61 @@
+package com.example.dvely.agent.infrastructure.usage;
+
+import com.example.dvely.agent.domain.value.LlmUsage;
+import com.example.dvely.agent.infrastructure.persistence.entity.LlmUsageEntity;
+import com.example.dvely.agent.infrastructure.persistence.repository.SpringDataLlmUsageRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 사용량 행 하나를 쓰는 것만 하는 별도 빈.
+ *
+ * {@link LlmUsageRecorder} 안의 private 메서드가 아니라 빈으로 분리한 이유는 하나다 —
+ * 자기 호출(self-invocation)은 프록시를 타지 않아 {@code REQUIRES_NEW} 가 걸리지 않는다.
+ * 별도 빈이라야 호출자의 트랜잭션과 실제로 끊긴다.
+ *
+ * 끊어야 하는 이유는 양방향이다. 계측 INSERT 실패가 진행 중인 작업을 되돌려서는 안 되고,
+ * 반대로 작업이 롤백되더라도 이미 쓴 토큰은 실제로 쓴 것이라 기록에 남아야 한다.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class LlmUsageStore {
+
+ private final SpringDataLlmUsageRepository repository;
+
+ /** 이 태스크가 이전 실행까지 이미 쓴 토큰. 조회에 실패하면 0 — 계측이 작업을 막지 않는다. */
+ @Transactional(readOnly = true)
+ public long tokensAlreadyUsedBy(String taskId) {
+ if (taskId == null) {
+ return 0;
+ }
+ try {
+ return repository.sumTotalTokensByTaskId(taskId);
+ } catch (RuntimeException exception) {
+ log.warn("[LlmUsage] 누적 토큰 조회 실패 — 이번 실행은 0 에서 셉니다. taskId={} exceptionType={}",
+ taskId, exception.getClass().getSimpleName());
+ return 0;
+ }
+ }
+
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void save(String taskId,
+ Long userId,
+ Long projectId,
+ LlmUsagePhase phase,
+ String provider,
+ String model,
+ LlmUsage usage) {
+ try {
+ repository.save(new LlmUsageEntity(taskId, userId, projectId, phase, provider, model, usage));
+ } catch (RuntimeException exception) {
+ // 계측이 태스크를 죽이는 일은 없어야 한다. 예외 타입만 남긴다 — 제공자 응답 본문이
+ // 섞여 들어올 여지를 두지 않는다.
+ log.warn("[LlmUsage] 사용량 기록 실패 — 작업은 그대로 진행합니다. provider={} exceptionType={}",
+ provider, exception.getClass().getSimpleName());
+ }
+ }
+}
diff --git a/src/main/java/com/example/dvely/chat/application/command/AsyncDecisionRunner.java b/src/main/java/com/example/dvely/chat/application/command/AsyncDecisionRunner.java
index 5c24c544..342196a3 100644
--- a/src/main/java/com/example/dvely/chat/application/command/AsyncDecisionRunner.java
+++ b/src/main/java/com/example/dvely/chat/application/command/AsyncDecisionRunner.java
@@ -6,6 +6,8 @@
import com.example.dvely.agent.application.service.AgentMessageService;
import com.example.dvely.agent.application.service.DecisionAgentService;
import com.example.dvely.agent.domain.value.AiProvider;
+import com.example.dvely.agent.infrastructure.usage.LlmUsagePhase;
+import com.example.dvely.agent.infrastructure.usage.LlmUsageScope;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -49,7 +51,11 @@ public void decideAndSubmit(String taskId,
Long conversationId,
Long projectId,
AiProvider provider) {
- try {
+ // 계획 수립의 토큰도 이 태스크의 것이다. 여기서 스코프를 열지 않으면 Decision 이 쓴
+ // 토큰만 태스크에 귀속되지 않아, 태스크 단위 합계가 실제보다 작게 나온다.
+ // 상한은 걸지 않는다 — 이 구간의 호출 수는 최대 2회(본 호출 + 교정 1회)로 이미 유계다.
+ try (LlmUsageScope ignored =
+ LlmUsageScope.open(taskId, userId, projectId, LlmUsagePhase.DECISION)) {
// 계획 수립에는 사용자 발화만 넘긴다. 우리가 쓴 운영 안내가 섞이면 모델이 그 문장을
// 흉내 내다 JSON 을 내지 못한다 — AgentMessageService#getUserIntentHistory 참고.
List history = agentMessageService.getUserIntentHistory(conversationId);
diff --git a/src/main/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisService.java b/src/main/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisService.java
index ca52d40b..a3892b56 100644
--- a/src/main/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisService.java
+++ b/src/main/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisService.java
@@ -2,9 +2,13 @@
import com.example.dvely.agent.application.port.out.LlmMessage;
import com.example.dvely.agent.application.service.BuildFailureAnalyzer;
+import com.example.dvely.agent.domain.value.AiModelOptions;
import com.example.dvely.agent.domain.value.AiProvider;
+import com.example.dvely.agent.domain.value.ThinkingLevel;
import com.example.dvely.agent.infrastructure.config.AiProperties;
import com.example.dvely.agent.infrastructure.llm.LlmRouter;
+import com.example.dvely.agent.infrastructure.usage.LlmUsagePhase;
+import com.example.dvely.agent.infrastructure.usage.LlmUsageScope;
import com.example.dvely.auth.application.command.AuthCommandService;
import com.example.dvely.auth.domain.model.User;
import com.example.dvely.auth.domain.repository.UserRepository;
@@ -342,11 +346,29 @@ private AnalysisOutcome runAnalysis(String excerpt) {
// stuck upstream connection could otherwise block this call forever and never reach the
// rule-based fallback below. CompletableFuture#orTimeout enforces a caller-side cutoff
// without touching ClaudeClient itself.
+ // 제공자와 모델은 설정에서 온다. 예전에는 여기가 ANTHROPIC + 그 제공자의 최상위 기본
+ // 모델로 박혀 있었다 — 12,000자 로그를 한 번 요약하는 데 최상위 모델을 쓸 근거가 없었고,
+ // 배포의 defaultProvider(GLM)와도 어긋났다. 분석 품질이 떨어지면 설정만으로 되돌린다
+ // (AiProperties.FailureAnalysis).
+ AiProvider provider = aiProperties.failureAnalysisProvider();
+ String configuredModel = aiProperties.getFailureAnalysis().modelOrNull();
+ AiModelOptions modelOptions = new AiModelOptions(configuredModel, ThinkingLevel.OFF);
+
try {
String raw = CompletableFuture
.supplyAsync(
- () -> llmRouter.route(AiProvider.ANTHROPIC)
- .complete(SYSTEM_PROMPT, List.of(new LlmMessage("user", excerpt))),
+ () -> {
+ // 스코프는 스레드를 넘지 않으므로 이 안에서 연다 — 여기서 열지
+ // 않으면 이 호출의 토큰이 어느 구간 것인지 남지 않는다.
+ try (LlmUsageScope ignored = LlmUsageScope.open(
+ null, null, null, LlmUsagePhase.DEPLOY_FAILURE_ANALYSIS)) {
+ return llmRouter.route(provider).complete(
+ SYSTEM_PROMPT,
+ List.of(new LlmMessage("user", excerpt)),
+ modelOptions);
+ }
+ },
+ // #336: 호출마다 executor 를 새로 만들지 않는다(필드의 공용 가상 스레드 executor).
llmCallExecutor
)
.orTimeout(llmTimeoutSeconds, TimeUnit.SECONDS)
@@ -356,8 +378,8 @@ private AnalysisOutcome runAnalysis(String excerpt) {
AnalysisSource.LLM,
parsed.summary(),
parsed.suggestedFix(),
- AiProvider.ANTHROPIC.name(),
- aiProperties.getAnthropic().getModel()
+ provider.name(),
+ modelOptions.modelOr(aiProperties.providerConfig(provider).getModel())
);
} catch (RuntimeException exception) {
// Any LLM transport failure, timeout, or unparseable response falls back to the
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index f93c896e..21158f35 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -64,6 +64,9 @@ qeploy:
anthropic:
api-key: ${QEPLOY_AI_ANTHROPIC_API_KEY:${ANTHROPIC_API_KEY:}}
model: ${QEPLOY_AI_ANTHROPIC_MODEL:claude-opus-4-5-20251101}
+ # Messages API 전체 URL. 사내 프록시를 거치게 하거나, 나가는 요청 본문을 실제로 검사하는
+ # 테스트에서 로컬 서버로 돌릴 때 쓴다.
+ base-url: ${QEPLOY_AI_ANTHROPIC_BASE_URL:https://api.anthropic.com/v1/messages}
# 요청(DecisionRequest.model)이 지정할 수 있는 추가 모델. 비우면 위 model만 허용된다 —
# 넓히는 것은 비용 결정이므로 기본값은 최소 권한이다.
allowed-models: ${QEPLOY_AI_ANTHROPIC_ALLOWED_MODELS:}
@@ -113,6 +116,22 @@ qeploy:
# CODE 스텝 1회 실행에서 허용하는 LLM 왕복 횟수. 소진되면 성공이 아니라 실패로 처리되고
# 같은 컨테이너에서 이어서 재시도된다(AgentIterationLimitException).
max-iterations: ${QEPLOY_AI_CODE_AGENT_MAX_ITERATIONS:40}
+ # 태스크 하나가 쓸 수 있는 누적 토큰(입력+출력+캐시). 0 이면 상한 없음.
+ # 위 max-iterations 와 retry.max-attempts, 그리고 태스크 재시도는 서로 곱해지는데 그 곱에는
+ # 아무 상한이 없었다. 재시도를 건너 이어 세므로 재시도로 상한을 우회하지 못한다.
+ # 걸리면 태스크는 사유가 보이는 실패로 닫힌다(사용자에게 사용량/상한이 그대로 보인다).
+ max-task-tokens: ${QEPLOY_AI_CODE_AGENT_MAX_TASK_TOKENS:1000000}
+ # 도구를 쓰지 않는 한 번짜리 완성 호출의 출력 상한(Anthropic max_tokens).
+ # 1024 였을 때 다단계 계획 JSON 이 중간에서 잘렸고, 그때마다 교정 재시도가 전체 컨텍스트를
+ # 한 번 더 보냈다 — 아끼는 출력 토큰보다 치르는 입력 토큰이 컸다. 상한이지 할당량이 아니다.
+ completion-max-tokens: ${QEPLOY_AI_COMPLETION_MAX_TOKENS:4096}
+ # 배포 실패 로그 요약 전용. 비워 두면 위 default-provider 와 그 제공자의 기본 모델을 따른다.
+ # 예전에는 이 한 경로만 ANTHROPIC + 최상위 모델로 코드에 박혀 있었다 — 12,000자 로그를 한 번
+ # 요약하는 데 쓸 근거가 없었고 default-provider 와도 어긋났다.
+ # 되돌리는 법: provider 에 ANTHROPIC, model 에 claude-opus-4-5-20251101 을 적고 재배포한다.
+ failure-analysis:
+ provider: ${QEPLOY_AI_FAILURE_ANALYSIS_PROVIDER:}
+ model: ${QEPLOY_AI_FAILURE_ANALYSIS_MODEL:}
# BYOK 코딩 에이전트(사용자 본인 공식 API 키로 벤더 CLI 를 헤드리스 실행).
# qeploy.ai.code-agent(위, CODE 스텝의 LLM 왕복 예산)와는 다른 축이다 — 이름이 비슷하니 주의.
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index 837f15bd..16dca325 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -57,6 +57,9 @@ qeploy:
anthropic:
api-key: ${QEPLOY_AI_ANTHROPIC_API_KEY:${ANTHROPIC_API_KEY:}}
model: ${QEPLOY_AI_ANTHROPIC_MODEL:claude-opus-4-5-20251101}
+ # Messages API 전체 URL. 사내 프록시를 거치게 하거나, 나가는 요청 본문을 실제로 검사하는
+ # 테스트에서 로컬 서버로 돌릴 때 쓴다.
+ base-url: ${QEPLOY_AI_ANTHROPIC_BASE_URL:https://api.anthropic.com/v1/messages}
# 요청(DecisionRequest.model)이 지정할 수 있는 추가 모델. 비우면 위 model만 허용된다 —
# 넓히는 것은 비용 결정이므로 기본값은 최소 권한이다.
allowed-models: ${QEPLOY_AI_ANTHROPIC_ALLOWED_MODELS:}
@@ -106,6 +109,22 @@ qeploy:
# CODE 스텝 1회 실행에서 허용하는 LLM 왕복 횟수. 소진되면 성공이 아니라 실패로 처리되고
# 같은 컨테이너에서 이어서 재시도된다(AgentIterationLimitException).
max-iterations: ${QEPLOY_AI_CODE_AGENT_MAX_ITERATIONS:40}
+ # 태스크 하나가 쓸 수 있는 누적 토큰(입력+출력+캐시). 0 이면 상한 없음.
+ # 위 max-iterations 와 retry.max-attempts, 그리고 태스크 재시도는 서로 곱해지는데 그 곱에는
+ # 아무 상한이 없었다. 재시도를 건너 이어 세므로 재시도로 상한을 우회하지 못한다.
+ # 걸리면 태스크는 사유가 보이는 실패로 닫힌다(사용자에게 사용량/상한이 그대로 보인다).
+ max-task-tokens: ${QEPLOY_AI_CODE_AGENT_MAX_TASK_TOKENS:1000000}
+ # 도구를 쓰지 않는 한 번짜리 완성 호출의 출력 상한(Anthropic max_tokens).
+ # 1024 였을 때 다단계 계획 JSON 이 중간에서 잘렸고, 그때마다 교정 재시도가 전체 컨텍스트를
+ # 한 번 더 보냈다 — 아끼는 출력 토큰보다 치르는 입력 토큰이 컸다. 상한이지 할당량이 아니다.
+ completion-max-tokens: ${QEPLOY_AI_COMPLETION_MAX_TOKENS:4096}
+ # 배포 실패 로그 요약 전용. 비워 두면 위 default-provider 와 그 제공자의 기본 모델을 따른다.
+ # 예전에는 이 한 경로만 ANTHROPIC + 최상위 모델로 코드에 박혀 있었다 — 12,000자 로그를 한 번
+ # 요약하는 데 쓸 근거가 없었고 default-provider 와도 어긋났다.
+ # 되돌리는 법: provider 에 ANTHROPIC, model 에 claude-opus-4-5-20251101 을 적고 재배포한다.
+ failure-analysis:
+ provider: ${QEPLOY_AI_FAILURE_ANALYSIS_PROVIDER:}
+ model: ${QEPLOY_AI_FAILURE_ANALYSIS_MODEL:}
# BYOK 코딩 에이전트(사용자 본인 공식 API 키로 벤더 CLI 를 헤드리스 실행).
# qeploy.ai.code-agent(위, CODE 스텝의 LLM 왕복 예산)와는 다른 축이다 — 이름이 비슷하니 주의.
diff --git a/src/main/resources/db/migration/V63__add_llm_usage.sql b/src/main/resources/db/migration/V63__add_llm_usage.sql
new file mode 100644
index 00000000..62bb40ca
--- /dev/null
+++ b/src/main/resources/db/migration/V63__add_llm_usage.sql
@@ -0,0 +1,36 @@
+-- U8 8-1: LLM 토큰 사용량 계측.
+--
+-- 이 테이블이 생기기 전까지 응답의 usage 를 읽는 코드가 한 줄도 없었다 — 토큰을 얼마나 쓰는지
+-- 아무도 몰랐고, 프롬프트 캐싱·대화 윈도우 같은 절감 작업의 효과를 잴 수단도, 태스크당 예산
+-- 상한이 셀 대상도 없었다.
+--
+-- agent_runs 에 누적 컬럼을 더하지 않고 별도 테이블로 둔 이유:
+-- 1. 모든 LLM 호출이 agent_run 에 속하지 않는다. 배포 실패 분석은 태스크 없이 돈다.
+-- 2. agent_runs 는 보존 정책으로 삭제된다(AgentRunRetentionScheduler). 비용 이력이 함께
+-- 사라지면 "지난달 대비" 같은 질문에 답할 수 없다.
+-- 3. 호출 한 건 단위라야 라운드별 캐시 적중을 볼 수 있다. 누적 컬럼은 "캐싱이 실제로
+-- 살아 있는가" 에 답하지 못한다.
+-- 4. CODE 루프는 라운드마다 쓴다. 누적 컬럼이면 실행 중인 태스크 행을 40 번 UPDATE 하게
+-- 되는데, 그 행은 워커도 쓰는 뜨거운 행이다.
+--
+-- FK 를 걸지 않는다. 계측 행이 agent_runs 의 잠금 그래프에 끌려 들어가면 안 되고, 태스크 행이
+-- 생기기 전이나 지워진 뒤에도 기록이 남아야 한다(위 2번).
+
+CREATE TABLE llm_usage (
+ llm_usage_id BIGINT NOT NULL AUTO_INCREMENT,
+ task_id VARCHAR(64) NULL COMMENT '귀속된 에이전트 태스크. 스코프 밖 호출은 NULL',
+ user_id BIGINT NULL,
+ project_id BIGINT NULL,
+ phase VARCHAR(30) NOT NULL COMMENT 'DECISION / AGENT_RUN / DEPLOY_FAILURE_ANALYSIS / UNSCOPED',
+ provider VARCHAR(30) NOT NULL,
+ model VARCHAR(120) NOT NULL,
+ input_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '캐시 읽기/쓰기를 제외한 입력',
+ output_tokens BIGINT NOT NULL DEFAULT 0,
+ cache_creation_input_tokens BIGINT NOT NULL DEFAULT 0,
+ cache_read_input_tokens BIGINT NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (llm_usage_id),
+ KEY idx_llm_usage_task (task_id),
+ KEY idx_llm_usage_created (created_at),
+ KEY idx_llm_usage_user_created (user_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql
index 88b0887b..294699c9 100644
--- a/src/main/resources/db/schema.sql
+++ b/src/main/resources/db/schema.sql
@@ -464,3 +464,25 @@ CREATE TABLE project_changes (
FOREIGN KEY (preview_session_id)
REFERENCES preview_sessions (preview_session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- U8 8-1 (V63): LLM 호출 한 건의 토큰 사용량. agent_runs 누적 컬럼이 아니라 별도 테이블인
+-- 이유(보존 정책·태스크 밖 호출·라운드별 캐시 적중·뜨거운 행 UPDATE 회피)는 V63 마이그레이션
+-- 주석 참고. FK 없음 — 계측 행이 agent_runs 의 잠금 그래프에 끌려 들어가면 안 된다.
+CREATE TABLE llm_usage (
+ llm_usage_id BIGINT NOT NULL AUTO_INCREMENT,
+ task_id VARCHAR(64) NULL COMMENT '귀속된 에이전트 태스크. 스코프 밖 호출은 NULL',
+ user_id BIGINT NULL,
+ project_id BIGINT NULL,
+ phase VARCHAR(30) NOT NULL COMMENT 'DECISION / AGENT_RUN / DEPLOY_FAILURE_ANALYSIS / UNSCOPED',
+ provider VARCHAR(30) NOT NULL,
+ model VARCHAR(120) NOT NULL,
+ input_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '캐시 읽기/쓰기를 제외한 입력',
+ output_tokens BIGINT NOT NULL DEFAULT 0,
+ cache_creation_input_tokens BIGINT NOT NULL DEFAULT 0,
+ cache_read_input_tokens BIGINT NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (llm_usage_id),
+ KEY idx_llm_usage_task (task_id),
+ KEY idx_llm_usage_created (created_at),
+ KEY idx_llm_usage_user_created (user_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/src/test/java/com/example/dvely/agent/application/orchestrator/AgentPlanExecutorTest.java b/src/test/java/com/example/dvely/agent/application/orchestrator/AgentPlanExecutorTest.java
index ea8820f0..ffe78074 100644
--- a/src/test/java/com/example/dvely/agent/application/orchestrator/AgentPlanExecutorTest.java
+++ b/src/test/java/com/example/dvely/agent/application/orchestrator/AgentPlanExecutorTest.java
@@ -1,6 +1,7 @@
package com.example.dvely.agent.application.orchestrator;
import com.example.dvely.chat.domain.value.ChatMessageKind;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
@@ -24,7 +25,11 @@
import com.example.dvely.agent.application.service.BackendDeployAgentService;
import com.example.dvely.agent.application.dto.ClarificationRequest;
import com.example.dvely.agent.application.service.DecisionAgentService;
+import com.example.dvely.agent.application.exception.AgentTokenBudgetExceededException;
+import com.example.dvely.agent.infrastructure.config.AiProperties;
import com.example.dvely.agent.infrastructure.store.InputWaitStore;
+import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder;
+import com.example.dvely.agent.infrastructure.usage.LlmUsageStore;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.example.dvely.agent.application.service.RepositoryBindingGate;
import com.example.dvely.agent.application.service.ResultApprovalGate;
@@ -86,7 +91,9 @@ void unregistersFromExecutionRegistryAfterSuccessfulCompletion() {
registry,
mock(DecisionAgentService.class),
mock(InputWaitStore.class),
- new ObjectMapper()
+ new ObjectMapper(),
+ usageRecorder(),
+ new AiProperties()
);
AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정"));
when(codeService.execute(eq(step), eq(AiProvider.OPENAI), eq(1L), eq(11L), eq("task-1"), any()))
@@ -123,7 +130,9 @@ void unregistersFromExecutionRegistryEvenWhenTheStepThrows() {
registry,
mock(DecisionAgentService.class),
mock(InputWaitStore.class),
- new ObjectMapper()
+ new ObjectMapper(),
+ usageRecorder(),
+ new AiProperties()
);
AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정"));
when(codeService.execute(any(), any(), any(), any(), any(), any()))
@@ -161,7 +170,9 @@ void stopsAfterLastCodeStepWithoutMarkingDoneWhenResultApprovalGateFires() {
mock(AgentExecutionRegistry.class),
mock(DecisionAgentService.class),
mock(InputWaitStore.class),
- new ObjectMapper()
+ new ObjectMapper(),
+ usageRecorder(),
+ new AiProperties()
);
AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정"));
AgentPlan plan = new AgentPlan(List.of(step), "reason", AiProvider.OPENAI, 11L);
@@ -205,7 +216,9 @@ void stopsAfterLastCodeStepWithoutMarkingDoneWhenRepositoryBindingGateFires() {
mock(AgentExecutionRegistry.class),
mock(DecisionAgentService.class),
mock(InputWaitStore.class),
- new ObjectMapper()
+ new ObjectMapper(),
+ usageRecorder(),
+ new AiProperties()
);
AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정"));
AgentPlan plan = new AgentPlan(List.of(step), "reason", AiProvider.OPENAI, 11L);
@@ -252,7 +265,9 @@ void skipsRepositoryBindingGateEntirelyWhenTheResultGateAlreadyFired() {
mock(AgentExecutionRegistry.class),
mock(DecisionAgentService.class),
mock(InputWaitStore.class),
- new ObjectMapper()
+ new ObjectMapper(),
+ usageRecorder(),
+ new AiProperties()
);
AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정"));
AgentPlan plan = new AgentPlan(List.of(step), "reason", AiProvider.OPENAI, 11L);
@@ -288,7 +303,9 @@ void continuesNormallyWhenResultApprovalGateDoesNotFire() {
mock(AgentExecutionRegistry.class),
mock(DecisionAgentService.class),
mock(InputWaitStore.class),
- new ObjectMapper()
+ new ObjectMapper(),
+ usageRecorder(),
+ new AiProperties()
);
AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정"));
AgentPlan plan = new AgentPlan(List.of(step), "reason", AiProvider.OPENAI, 11L);
@@ -366,7 +383,9 @@ void waitingInputPersistsQuestionWithoutFailingTask() {
mock(AgentExecutionRegistry.class),
mock(DecisionAgentService.class),
mock(InputWaitStore.class),
- new ObjectMapper()
+ new ObjectMapper(),
+ usageRecorder(),
+ new AiProperties()
);
AgentStep step = new AgentStep(AgentType.DOMAIN_BIND, Map.of());
@@ -450,7 +469,7 @@ private AgentPlanExecutor clarifyExecutor(TaskStore taskStore, DecisionAgentServ
mock(BackendDeployAgentService.class), taskStore, messageService,
mock(BuildFailureRecoveryService.class), mock(ChangeService.class), mock(ResultApprovalGate.class),
mock(RepositoryBindingGate.class), mock(AgentExecutionRegistry.class),
- decision, inputWaitStore, new ObjectMapper());
+ decision, inputWaitStore, new ObjectMapper(), usageRecorder(), new AiProperties());
}
@Test
@@ -475,7 +494,9 @@ void dispatchesRuntimeSetupStepToRuntimeSetupAgentService() {
mock(AgentExecutionRegistry.class),
mock(DecisionAgentService.class),
mock(InputWaitStore.class),
- new ObjectMapper()
+ new ObjectMapper(),
+ usageRecorder(),
+ new AiProperties()
);
AgentStep step = new AgentStep(AgentType.RUNTIME_SETUP,
Map.of("runtimeType", "NODE_SERVER", "dbEngine", "MYSQL"));
@@ -509,7 +530,9 @@ void dispatchesInfraOperateStepToInfraOpsAgentService() {
mock(AgentExecutionRegistry.class),
mock(DecisionAgentService.class),
mock(InputWaitStore.class),
- new ObjectMapper()
+ new ObjectMapper(),
+ usageRecorder(),
+ new AiProperties()
);
AgentStep step = new AgentStep(AgentType.INFRA_OPERATE, Map.of("operation", "STATUS_CHECK"));
when(infraOpsAgentService.execute(step, 1L, "task-1", 11L))
@@ -552,7 +575,9 @@ void reportsAnAiProviderFailureWithTheProvidersOwnMessageAndWithoutBuildRecovery
mock(AgentExecutionRegistry.class),
mock(DecisionAgentService.class),
mock(InputWaitStore.class),
- new ObjectMapper()
+ new ObjectMapper(),
+ usageRecorder(),
+ new AiProperties()
);
AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정"));
LlmProviderException failure = new LlmProviderException(
@@ -577,6 +602,77 @@ private AgentPlanExecutor executor(CodeAgentService codeService,
return executor(codeService, mock(ChatAgentService.class), taskStore, messageService);
}
+ // ── U8 8-6: 태스크당 토큰 예산 상한 ────────────────────────────────────────────────────
+
+ @Test
+ void tellsTheUserWhyTheTaskStoppedWhenItHitsTheTokenBudget() {
+ // 상한에 걸린 태스크가 조용히 멈추면 사용자에게는 "왜 안 되지" 로만 남는다. 사유가
+ // 그대로 보여야 하고, catch-all 의 "작업 중 오류가 발생했습니다" 접두가 붙으면 안 된다.
+ CodeAgentService codeService = mock(CodeAgentService.class);
+ TaskStore taskStore = taskStore();
+ AgentMessageService messageService = mock(AgentMessageService.class);
+ AgentPlanExecutor executor = executor(codeService, taskStore, messageService);
+ AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정"));
+ AgentTokenBudgetExceededException budgetExceeded =
+ new AgentTokenBudgetExceededException(1_004_200, 1_000_000);
+ when(codeService.execute(any(), any(), any(), any(), any(), any())).thenThrow(budgetExceeded);
+
+ executor.execute(new AgentPlan(List.of(step), "reason", AiProvider.OPENAI, 11L), "task-1", 1L);
+
+ verify(taskStore).markFailed("task-1", budgetExceeded.getMessage());
+ verify(messageService).appendAssistant(
+ 21L, budgetExceeded.getMessage(), ChatMessageKind.TASK_FAILED, "task-1");
+ assertThat(budgetExceeded.getMessage())
+ .contains("AI 토큰 예산 상한")
+ .contains("1,004,200")
+ .contains("1,000,000");
+ }
+
+ @Test
+ void doesNotSendABudgetExceededTaskDownTheBuildFailureRetryPath() {
+ // 누적은 태스크 단위로 이어 세므로 재시도해도 첫 호출에서 같은 상한에 다시 걸린다 —
+ // 복구 경로로 흘리면 사용자는 원인이 안 보이는 실패를 두 번 보게 된다.
+ CodeAgentService codeService = mock(CodeAgentService.class);
+ TaskStore taskStore = taskStore();
+ BuildFailureRecoveryService recovery = mock(BuildFailureRecoveryService.class);
+ AgentPlanExecutor executor = new AgentPlanExecutor(
+ codeService,
+ mock(DeployAgentService.class),
+ mock(DomainBindAgentService.class),
+ mock(ChatAgentService.class),
+ mock(InfraOpsAgentService.class),
+ mock(RuntimeSetupAgentService.class),
+ mock(BackendDeployAgentService.class),
+ taskStore,
+ mock(AgentMessageService.class),
+ recovery,
+ mock(ChangeService.class),
+ mock(ResultApprovalGate.class),
+ mock(RepositoryBindingGate.class),
+ mock(AgentExecutionRegistry.class),
+ mock(DecisionAgentService.class),
+ mock(InputWaitStore.class),
+ new ObjectMapper(),
+ usageRecorder(),
+ new AiProperties()
+ );
+ AgentStep step = new AgentStep(AgentType.CODE, Map.of("instruction", "수정"));
+ when(codeService.execute(any(), any(), any(), any(), any(), any()))
+ .thenThrow(new AgentTokenBudgetExceededException(1_004_200, 1_000_000));
+
+ executor.execute(new AgentPlan(List.of(step), "reason", AiProvider.OPENAI, 11L), "task-1", 1L);
+
+ verify(recovery, never()).handle(any(), any());
+ }
+
+ /**
+ * 진짜 recorder 를 쓴다. mock 은 {@code openTaskScope} 에서 null 을 돌려주고, 실행 진입점의
+ * try-with-resources 가 그대로 NPE 가 된다 — 저장만 mock 으로 끊는다.
+ */
+ private LlmUsageRecorder usageRecorder() {
+ return new LlmUsageRecorder(mock(LlmUsageStore.class));
+ }
+
private AgentPlanExecutor executor(CodeAgentService codeService,
ChatAgentService chatService,
TaskStore taskStore,
@@ -598,7 +694,9 @@ private AgentPlanExecutor executor(CodeAgentService codeService,
mock(AgentExecutionRegistry.class),
mock(DecisionAgentService.class),
mock(InputWaitStore.class),
- new ObjectMapper()
+ new ObjectMapper(),
+ usageRecorder(),
+ new AiProperties()
);
}
diff --git a/src/test/java/com/example/dvely/agent/application/service/ConversationWindowTest.java b/src/test/java/com/example/dvely/agent/application/service/ConversationWindowTest.java
new file mode 100644
index 00000000..b3d23554
--- /dev/null
+++ b/src/test/java/com/example/dvely/agent/application/service/ConversationWindowTest.java
@@ -0,0 +1,77 @@
+package com.example.dvely.agent.application.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.example.dvely.agent.application.port.out.LlmMessage;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class ConversationWindowTest {
+
+ @Test
+ void keepsTheMostRecentTurnsAndDropsTheOldest() {
+ List history = turns(50);
+
+ List windowed = ConversationWindow.apply(history);
+
+ assertThat(windowed).hasSize(ConversationWindow.MAX_TURNS);
+ assertThat(windowed.get(windowed.size() - 1).content()).isEqualTo("turn-49");
+ assertThat(windowed.get(0).content()).isEqualTo("turn-30");
+ }
+
+ @Test
+ void alsoStopsOnTheCharacterBudgetSoOneHugeTurnCannotFillTheWindow() {
+ List history = new ArrayList<>();
+ history.add(new LlmMessage("user", "x".repeat(30_000)));
+ history.add(new LlmMessage("assistant", "y".repeat(30_000)));
+ history.add(new LlmMessage("user", "지금 처리할 요청"));
+
+ List windowed = ConversationWindow.apply(history);
+
+ assertThat(windowed).hasSize(1);
+ assertThat(windowed.get(0).content()).isEqualTo("지금 처리할 요청");
+ }
+
+ @Test
+ void neverDropsTheLastTurnEvenWhenItAloneExceedsTheBudget() {
+ // 마지막 턴은 지금 처리할 요청 그 자체다. 버리면 무엇을 하라는 것인지가 사라진다.
+ List history = List.of(
+ new LlmMessage("user", "옛 요청"),
+ new LlmMessage("user", "z".repeat(ConversationWindow.MAX_CHARS * 2)));
+
+ List windowed = ConversationWindow.apply(history);
+
+ assertThat(windowed).hasSize(1);
+ assertThat(windowed.get(0).content()).hasSize(ConversationWindow.MAX_CHARS * 2);
+ }
+
+ @Test
+ void keepsShortConversationsExactlyAsTheyWere() {
+ List history = turns(5);
+
+ assertThat(ConversationWindow.apply(history)).isEqualTo(history);
+ }
+
+ @Test
+ void preservesChronologicalOrder() {
+ List windowed = ConversationWindow.apply(turns(40), 3, 10_000);
+
+ assertThat(windowed.stream().map(LlmMessage::content))
+ .containsExactly("turn-37", "turn-38", "turn-39");
+ }
+
+ @Test
+ void handlesAnEmptyOrNullHistory() {
+ assertThat(ConversationWindow.apply(List.of())).isEmpty();
+ assertThat(ConversationWindow.apply(null)).isEmpty();
+ }
+
+ private static List turns(int count) {
+ List history = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ history.add(new LlmMessage(i % 2 == 0 ? "user" : "assistant", "turn-" + i));
+ }
+ return List.copyOf(history);
+ }
+}
diff --git a/src/test/java/com/example/dvely/agent/application/service/DecisionAgentLoggingTest.java b/src/test/java/com/example/dvely/agent/application/service/DecisionAgentLoggingTest.java
new file mode 100644
index 00000000..6ba354c4
--- /dev/null
+++ b/src/test/java/com/example/dvely/agent/application/service/DecisionAgentLoggingTest.java
@@ -0,0 +1,117 @@
+package com.example.dvely.agent.application.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import com.example.dvely.agent.application.port.out.LlmMessage;
+import com.example.dvely.agent.application.port.out.LlmPort;
+import com.example.dvely.agent.domain.value.AiProvider;
+import com.example.dvely.agent.infrastructure.llm.LlmRouter;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+
+/**
+ * U8 8-7: 계획 응답 원문을 INFO 로 통째로 찍던 자리를 막았는지 본다.
+ *
+ * 계획 JSON 에는 사용자가 무엇을 만들라고 했는지가 그대로 들어가고, 교정 재시도 로그에는
+ * 모델이 쓴 응답이 통째로 들어간다 — 운영 로그 수집기로 사용자 요청과 생성 코드가 흘러나가는
+ * 경로였다. 실제로 찍히는 것을 보는 것 말고 검증할 방법이 없어 로그를 가로챈다.
+ */
+class DecisionAgentLoggingTest {
+
+ /** 로그에 절대 통째로 나오면 안 되는, 사용자 요청과 코드가 섞인 응답. */
+ private static final String SECRET_LOOKING_PLAN = """
+ {"reasoning":"사내 재고 관리 앱을 만든다",
+ "steps":[{"agentType":"CODE","parameters":{
+ "instruction":"%s",
+ "userSummary":"재고 페이지를 만듭니다"}}]}
+ """.formatted("const INTERNAL_ENDPOINT = 'https://inventory.corp.example/api'; "
+ .repeat(20));
+
+ private final LlmRouter llmRouter = mock(LlmRouter.class);
+ private final LlmPort llmPort = mock(LlmPort.class);
+ private final ProjectDecisionContextResolver contextResolver =
+ mock(ProjectDecisionContextResolver.class);
+ private final DecisionAgentService service =
+ new DecisionAgentService(llmRouter, contextResolver);
+
+ private Logger logger;
+ private ListAppender appender;
+
+ @BeforeEach
+ void captureLogs() {
+ logger = (Logger) LoggerFactory.getLogger(DecisionAgentService.class);
+ logger.setLevel(Level.DEBUG);
+ appender = new ListAppender<>();
+ appender.start();
+ logger.addAppender(appender);
+ }
+
+ @AfterEach
+ void releaseLogs() {
+ logger.detachAppender(appender);
+ }
+
+ @Test
+ void neverLogsTheWholeResponseAtInfo() {
+ when(llmRouter.route(AiProvider.GLM)).thenReturn(llmPort);
+ when(llmPort.complete(any(), anyList(), any())).thenReturn(SECRET_LOOKING_PLAN);
+
+ service.decide(List.of(new LlmMessage("user", "재고 앱 만들어줘")), AiProvider.GLM, null);
+
+ List infoAndAbove = appender.list.stream()
+ .filter(event -> event.getLevel().isGreaterOrEqual(Level.INFO))
+ .toList();
+
+ assertThat(infoAndAbove).isNotEmpty();
+ assertThat(infoAndAbove).noneSatisfy(event ->
+ assertThat(event.getFormattedMessage()).contains("INTERNAL_ENDPOINT"));
+ // 대신 길이는 남는다 — 응답이 잘렸는지 같은 운영 질문에는 답할 수 있어야 한다.
+ assertThat(infoAndAbove).anySatisfy(event ->
+ assertThat(event.getFormattedMessage())
+ .contains("rawLength=" + SECRET_LOOKING_PLAN.length()));
+ }
+
+ @Test
+ void truncatesTheResponsePreviewEvenAtDebug() {
+ when(llmRouter.route(AiProvider.GLM)).thenReturn(llmPort);
+ when(llmPort.complete(any(), anyList(), any())).thenReturn(SECRET_LOOKING_PLAN);
+
+ service.decide(List.of(new LlmMessage("user", "재고 앱 만들어줘")), AiProvider.GLM, null);
+
+ assertThat(appender.list).anySatisfy(event -> {
+ assertThat(event.getLevel()).isEqualTo(Level.DEBUG);
+ assertThat(event.getFormattedMessage())
+ .contains("자 중 앞부분")
+ .hasSizeLessThan(SECRET_LOOKING_PLAN.length());
+ });
+ }
+
+ @Test
+ void alsoTruncatesTheFailedResponseEchoedInTheRepairRetryWarning() {
+ // 파싱 실패 경로가 원문을 가장 길게 찍던 곳이다 — 실패한 응답과 재시도 응답 둘 다.
+ when(llmRouter.route(AiProvider.GLM)).thenReturn(llmPort);
+ when(llmPort.complete(any(), anyList(), any()))
+ .thenReturn("이건 JSON 이 아니다 " + "INTERNAL_ENDPOINT ".repeat(50));
+
+ try {
+ service.decide(List.of(new LlmMessage("user", "재고 앱")), AiProvider.GLM, null);
+ } catch (RuntimeException expected) {
+ // 재시도까지 실패하면 던지는 것이 정상이다(조용한 CHAT 폴백은 이미 걷어냈다).
+ }
+
+ assertThat(appender.list).filteredOn(event -> event.getLevel() == Level.WARN)
+ .isNotEmpty()
+ .allSatisfy(event -> assertThat(event.getFormattedMessage()).hasSizeLessThan(600));
+ }
+}
diff --git a/src/test/java/com/example/dvely/agent/infrastructure/llm/AnthropicPromptCacheTest.java b/src/test/java/com/example/dvely/agent/infrastructure/llm/AnthropicPromptCacheTest.java
new file mode 100644
index 00000000..50c44222
--- /dev/null
+++ b/src/test/java/com/example/dvely/agent/infrastructure/llm/AnthropicPromptCacheTest.java
@@ -0,0 +1,142 @@
+package com.example.dvely.agent.infrastructure.llm;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 캐시 브레이크포인트는 조용히 틀린다 — 위치가 어긋나도 요청은 그대로 통과하고 캐시만 안
+ * 걸린다. 오류가 나지 않으므로 위치를 못 박는 것 말고는 검증할 방법이 없다.
+ */
+class AnthropicPromptCacheTest {
+
+ private static final Map EPHEMERAL = Map.of("type", "ephemeral");
+
+ @Test
+ void putsOneBreakpointOnTheSystemPrompt() {
+ List> blocks = AnthropicPromptCache.systemBlocks("시스템");
+
+ assertThat(blocks).hasSize(1);
+ assertThat(blocks.get(0)).containsEntry("type", "text")
+ .containsEntry("text", "시스템")
+ .containsEntry("cache_control", EPHEMERAL);
+ }
+
+ @Test
+ void marksOnlyTheLastToolDefinition() {
+ // 도구 목록 전체가 하나의 캐시 단위다. 앞쪽 정의에도 마커를 달면 상한 4개를 도구만으로
+ // 써 버려 정작 값을 하는 트랜스크립트 쪽 지점이 남지 않는다.
+ List> tools = AnthropicPromptCache.toolsWithCacheControl(List.of(
+ Map.of("name", "execute_command"),
+ Map.of("name", "write_file"),
+ Map.of("name", "read_file")));
+
+ assertThat(tools.get(0)).doesNotContainKey("cache_control");
+ assertThat(tools.get(1)).doesNotContainKey("cache_control");
+ assertThat(tools.get(2)).containsEntry("cache_control", EPHEMERAL);
+ }
+
+ @Test
+ void marksTheLastTwoUserTurnsAndNothingElse() {
+ List> messages = AnthropicPromptCache.messagesWithRollingCacheControl(List.of(
+ userText("요청"),
+ assistantBlocks(),
+ toolResults("결과 1"),
+ assistantBlocks(),
+ toolResults("결과 2")));
+
+ assertThat(breakpointCount(messages)).isEqualTo(2);
+ assertThat(lastBlockOf(messages.get(4))).containsEntry("cache_control", EPHEMERAL);
+ assertThat(lastBlockOf(messages.get(2))).containsEntry("cache_control", EPHEMERAL);
+ assertThat(lastBlockOf(messages.get(0))).doesNotContainKey("cache_control");
+ assertThat(messages.get(1)).doesNotContainKey("cache_control");
+ }
+
+ @Test
+ void keepsTotalBreakpointsWithinAnthropicsLimitOfFour() {
+ // tools 1 + system 1 + 굴러가는 user 2 = 4. 하나라도 늘면 요청 자체가 거절된다.
+ int tools = breakpointCount(AnthropicPromptCache.toolsWithCacheControl(
+ List.of(Map.of("name", "a"), Map.of("name", "b"))));
+ int system = AnthropicPromptCache.systemBlocks("긴 시스템 프롬프트").size();
+ int rolling = breakpointCount(AnthropicPromptCache.messagesWithRollingCacheControl(List.of(
+ userText("1"), assistantBlocks(), toolResults("2"),
+ assistantBlocks(), toolResults("3"), assistantBlocks(), toolResults("4"))));
+
+ assertThat(tools + system + rolling).isEqualTo(4);
+ }
+
+ @Test
+ void normalizesEveryTextTurnToBlocksSoTheShapeNeverFlipsBetweenRounds() {
+ // 마커가 붙는 턴만 배열로 바꾸면, 같은 턴이 라운드마다 문자열↔배열로 오간다. 접두 일치에
+ // 그런 흔들림을 남길 이유가 없다.
+ List> messages = AnthropicPromptCache.messagesWithRollingCacheControl(List.of(
+ userText("맨 앞"), assistantBlocks(), toolResults("1"),
+ assistantBlocks(), toolResults("2"), assistantBlocks(), toolResults("3")));
+
+ assertThat(messages.get(0).get("content")).isInstanceOf(List.class);
+ assertThat(lastBlockOf(messages.get(0))).doesNotContainKey("cache_control");
+ }
+
+ @Test
+ void leavesTheCallersTranscriptUntouched() {
+ // 호출부 트랜스크립트에 마커가 쌓이면 라운드마다 마커가 늘어 곧 상한 4개를 넘긴다.
+ List> original = new ArrayList<>(List.of(userText("요청")));
+ Map before = original.get(0);
+
+ AnthropicPromptCache.messagesWithRollingCacheControl(original);
+
+ assertThat(original.get(0)).isSameAs(before);
+ assertThat(before.get("content")).isEqualTo("요청");
+ }
+
+ @Test
+ void spendsNoBreakpointOnAnEmptyTurn() {
+ List> messages = AnthropicPromptCache.messagesWithRollingCacheControl(List.of(
+ userText("실제 내용"),
+ assistantBlocks(),
+ Map.of("role", "user", "content", List.of())));
+
+ assertThat(breakpointCount(messages)).isEqualTo(1);
+ assertThat(lastBlockOf(messages.get(0))).containsEntry("cache_control", EPHEMERAL);
+ }
+
+ private static Map userText(String text) {
+ return Map.of("role", "user", "content", text);
+ }
+
+ private static Map assistantBlocks() {
+ return Map.of("role", "assistant", "content", List.of(Map.of("type", "text", "text", "ok")));
+ }
+
+ private static Map toolResults(String content) {
+ return Map.of("role", "user", "content",
+ List.of(Map.of("type", "tool_result", "tool_use_id", "t", "content", content)));
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map lastBlockOf(Map message) {
+ List> blocks = (List>) message.get("content");
+ return blocks.get(blocks.size() - 1);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static int breakpointCount(List> items) {
+ int count = 0;
+ for (Map item : items) {
+ if (item.containsKey("cache_control")) {
+ count++;
+ }
+ if (item.get("content") instanceof List> blocks) {
+ for (Object block : blocks) {
+ if (block instanceof Map, ?> map && map.containsKey("cache_control")) {
+ count++;
+ }
+ }
+ }
+ }
+ return count;
+ }
+}
diff --git a/src/test/java/com/example/dvely/agent/infrastructure/llm/AnthropicRequestBodyTest.java b/src/test/java/com/example/dvely/agent/infrastructure/llm/AnthropicRequestBodyTest.java
new file mode 100644
index 00000000..d808b077
--- /dev/null
+++ b/src/test/java/com/example/dvely/agent/infrastructure/llm/AnthropicRequestBodyTest.java
@@ -0,0 +1,333 @@
+package com.example.dvely.agent.infrastructure.llm;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+import com.example.dvely.agent.application.port.out.LlmMessage;
+import com.example.dvely.agent.application.port.out.LlmToolResponse;
+import com.example.dvely.agent.application.port.out.ToolDefinition;
+import com.example.dvely.agent.application.service.ConversationWindow;
+import com.example.dvely.agent.domain.value.AiModelOptions;
+import com.example.dvely.agent.domain.value.LlmUsage;
+import com.example.dvely.agent.infrastructure.config.AiProperties;
+import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder;
+import com.example.dvely.agent.infrastructure.usage.LlmUsageStore;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 실제로 나가는 요청 본문을 본다.
+ *
+ * 이 단위에서 바꾼 것들(캐시 브레이크포인트 위치, 대화 윈도우, {@code max_tokens})은 전부
+ * 조용히 틀리는 종류다 — 위치가 어긋나거나 윈도우가 안 걸려도 요청은 그대로 200 으로
+ * 통과하고, 달라지는 것은 청구서뿐이다. 그래서 목 서버를 세워 본문을 직접 읽는다.
+ *
+ * 토큰 절감 폭도 여기서 잰다. 목 응답의 토큰 수는 우리가 적은 값이라 그것으로는 아무것도
+ * 증명하지 못하므로, 나가는 본문의 실제 바이트 를 센다.
+ */
+class AnthropicRequestBodyTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /** CodeAgentService 의 도구 정의와 같은 모양(개수와 순서만 같으면 배치 검증에는 충분하다). */
+ private static final List TOOLS = List.of(
+ new ToolDefinition("execute_command", "Execute a shell command.",
+ Map.of("type", "object", "properties", Map.of("command", Map.of("type", "string")))),
+ new ToolDefinition("write_file", "Write a file.",
+ Map.of("type", "object", "properties", Map.of("path", Map.of("type", "string")))),
+ new ToolDefinition("read_file", "Read a file.",
+ Map.of("type", "object", "properties", Map.of("path", Map.of("type", "string")))));
+
+ private HttpServer server;
+ private final List capturedBodies = new CopyOnWriteArrayList<>();
+ private AiProperties aiProperties;
+ private LlmUsageRecorder recorder;
+
+ @BeforeEach
+ void startServer() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/v1/messages", exchange -> {
+ capturedBodies.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
+ byte[] response = cannedResponse().getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, response.length);
+ exchange.getResponseBody().write(response);
+ exchange.close();
+ });
+ server.start();
+
+ aiProperties = new AiProperties();
+ aiProperties.getAnthropic().setApiKey("test-key-not-a-real-credential");
+ aiProperties.getAnthropic().setBaseUrl(
+ "http://127.0.0.1:" + server.getAddress().getPort() + "/v1/messages");
+ recorder = new LlmUsageRecorder(mock(LlmUsageStore.class));
+ }
+
+ @AfterEach
+ void stopServer() {
+ server.stop(0);
+ }
+
+ // ── 8-2 캐시 브레이크포인트 ────────────────────────────────────────────────
+
+ @Test
+ void sendsExactlyFourCacheBreakpointsInTheDocumentedPlaces() throws Exception {
+ new ClaudeToolClient(aiProperties, recorder)
+ .completeWithTools("SYSTEM", transcriptAfterRounds(3), TOOLS, AiModelOptions.defaults());
+
+ Map body = lastBody();
+ assertThat(countBreakpoints(body)).isEqualTo(4);
+ assertThat(lastOf(list(body, "tools"))).containsKey("cache_control");
+ assertThat(lastOf(list(body, "system"))).containsKey("cache_control");
+ // 3 라운드 → messages 7개(요청 1 + 라운드마다 assistant/tool_result 2). user 턴은
+ // 0·2·4·6 이고, 굴러가는 두 지점은 그중 마지막 둘이다.
+ assertThat(markedUserTurnIndexes(body)).containsExactly(4, 6);
+ }
+
+ @Test
+ void keepsTheStablePrefixByteIdenticalAsTheTranscriptGrows() throws Exception {
+ // 캐싱은 접두 일치다. 접두가 라운드마다 한 바이트라도 달라지면 마커를 아무리 잘 놓아도
+ // 적중률은 0 이다. tools 와 system 은 상수이므로 직렬화 결과가 같아야 한다.
+ ClaudeToolClient client = new ClaudeToolClient(aiProperties, recorder);
+ for (int round = 1; round <= 5; round++) {
+ client.completeWithTools("SYSTEM", transcriptAfterRounds(round), TOOLS, AiModelOptions.defaults());
+ }
+
+ List prefixes = new ArrayList<>();
+ for (String raw : capturedBodies) {
+ Map body = MAPPER.readValue(raw, Map.class);
+ prefixes.add(MAPPER.writeValueAsString(body.get("tools"))
+ + MAPPER.writeValueAsString(body.get("system"))
+ + body.get("model"));
+ }
+
+ assertThat(prefixes).containsOnly(prefixes.get(0));
+ }
+
+ @Test
+ void growsOnlyByAppendingSoEachRoundsPrefixIsThePreviousRoundsRequest() throws Exception {
+ // 마커가 값을 하려면 접두가 "덧붙기만" 해야 한다. 앞부분을 다시 쓰는 순간 캐시는 깨진다.
+ ClaudeToolClient client = new ClaudeToolClient(aiProperties, recorder);
+ for (int round = 1; round <= 4; round++) {
+ client.completeWithTools("SYSTEM", transcriptAfterRounds(round), TOOLS, AiModelOptions.defaults());
+ }
+
+ for (int i = 1; i < capturedBodies.size(); i++) {
+ List previous = list(MAPPER.readValue(capturedBodies.get(i - 1), Map.class), "messages");
+ List current = list(MAPPER.readValue(capturedBodies.get(i), Map.class), "messages");
+
+ assertThat(current.size()).isGreaterThan(previous.size());
+ // cache_control 마커 자리만 굴러가고 내용은 그대로여야 한다.
+ assertThat(stripCacheControl(current.subList(0, previous.size())))
+ .isEqualTo(stripCacheControl(previous));
+ }
+ }
+
+ /**
+ * 캐시가 덮는 몫을 실제 바이트로 잰다.
+ *
+ * 라운드 N+1 의 접두는 라운드 N 이 마커로 닫아 둔 지점까지다. 그 지점 이후만 새 입력이고
+ * 나머지는 캐시 읽기가 되므로, 이 비율이 곧 이 변경이 건드리는 몫이다.
+ */
+ @Test
+ void measuresHowMuchOfEachRoundIsAlreadyBehindABreakpoint() throws Exception {
+ ClaudeToolClient client = new ClaudeToolClient(aiProperties, recorder);
+ int rounds = 10;
+ for (int round = 1; round <= rounds; round++) {
+ client.completeWithTools("SYSTEM", transcriptAfterRounds(round), TOOLS, AiModelOptions.defaults());
+ }
+
+ long totalSent = 0;
+ long behindBreakpoint = 0;
+ for (int i = 0; i < capturedBodies.size(); i++) {
+ long size = capturedBodies.get(i).getBytes(StandardCharsets.UTF_8).length;
+ totalSent += size;
+ if (i > 0) {
+ // 직전 라운드 요청 전체가 이번 라운드의 접두다(내용이 덧붙기만 하므로).
+ behindBreakpoint += capturedBodies.get(i - 1).getBytes(StandardCharsets.UTF_8).length;
+ }
+ }
+
+ System.out.printf("[U8 8-2] %d 라운드 전송 %,d bytes 중 브레이크포인트 뒤 접두 %,d bytes (%.1f%%)%n",
+ rounds, totalSent, behindBreakpoint, 100.0 * behindBreakpoint / totalSent);
+
+ // 라운드가 쌓일수록 새 내용보다 재전송이 압도적으로 많다 — 캐싱이 겨냥하는 것이 이 몫이다.
+ assertThat(behindBreakpoint).isGreaterThan(totalSent / 2);
+ }
+
+ // ── 8-4 max_tokens ────────────────────────────────────────────────────────
+
+ @Test
+ void sendsTheToolLoopsOwnOutputBudget() throws Exception {
+ new ClaudeToolClient(aiProperties, recorder)
+ .completeWithTools("SYSTEM", transcriptAfterRounds(1), TOOLS, AiModelOptions.defaults());
+
+ assertThat(lastBody().get("max_tokens")).isEqualTo(8_192);
+ }
+
+ @Test
+ void sendsTheConfiguredCompletionBudgetForPlainCompletions() throws Exception {
+ // 1024 였을 때 다단계 계획 JSON 이 잘렸고, 그때마다 교정 재시도가 전체 컨텍스트를 한 번 더
+ // 보냈다. 설정 가능해야 운영에서 재배포 없이 올릴 수 있다.
+ aiProperties.setCompletionMaxTokens(2_048);
+
+ new ClaudeClient(aiProperties, recorder)
+ .complete("SYSTEM", List.of(new LlmMessage("user", "안녕")), AiModelOptions.defaults());
+
+ assertThat(lastBody().get("max_tokens")).isEqualTo(2_048);
+ }
+
+ // ── 8-3 대화 윈도우 ───────────────────────────────────────────────────────
+
+ @Test
+ void windowedHistorySendsDramaticallyFewerBytesThanTheWholeConversation() throws Exception {
+ List whole = longConversation(120);
+ List windowed = ConversationWindow.apply(whole);
+ ClaudeClient client = new ClaudeClient(aiProperties, recorder);
+
+ client.complete("SYSTEM", whole, AiModelOptions.defaults());
+ long wholeBytes = capturedBodies.get(0).getBytes(StandardCharsets.UTF_8).length;
+
+ client.complete("SYSTEM", windowed, AiModelOptions.defaults());
+ long windowedBytes = capturedBodies.get(1).getBytes(StandardCharsets.UTF_8).length;
+
+ System.out.printf("[U8 8-3] 120턴 대화 요청 본문: 전량 %,d bytes → 윈도우 %,d bytes (%.1f%% 감소)%n",
+ wholeBytes, windowedBytes, 100.0 * (wholeBytes - windowedBytes) / wholeBytes);
+
+ assertThat(windowedBytes).isLessThan(wholeBytes / 4);
+ // 지금 처리할 요청(마지막 턴)은 반드시 남아 있어야 한다.
+ assertThat(capturedBodies.get(1)).contains("turn-119");
+ }
+
+ // ── 8-1 사용량 집계 ───────────────────────────────────────────────────────
+
+ @Test
+ void reportsTheUsageOfEveryRoundSoTotalsCanBeCompared() throws Exception {
+ ClaudeToolClient client = new ClaudeToolClient(aiProperties, recorder);
+ LlmUsage total = LlmUsage.NONE;
+ for (int round = 1; round <= 10; round++) {
+ LlmToolResponse response = client.completeWithTools(
+ "SYSTEM", transcriptAfterRounds(round), TOOLS, AiModelOptions.defaults());
+ total = total.plus(response.usage());
+ }
+
+ // 목 응답 한 건: input 1000 / output 200 / cache write 4000 / cache read 12000.
+ assertThat(total.inputTokens()).isEqualTo(10_000);
+ assertThat(total.outputTokens()).isEqualTo(2_000);
+ assertThat(total.cacheCreationInputTokens()).isEqualTo(40_000);
+ assertThat(total.cacheReadInputTokens()).isEqualTo(120_000);
+ assertThat(total.totalTokens()).isEqualTo(172_000);
+ }
+
+ // ── 도우미 ────────────────────────────────────────────────────────────────
+
+ /** CodeAgentService 의 Claude 루프가 N 라운드 뒤 갖게 되는 트랜스크립트와 같은 모양. */
+ private static List> transcriptAfterRounds(int rounds) {
+ List> messages = new ArrayList<>();
+ messages.add(Map.of("role", "user", "content", "할 일 앱을 만들어줘"));
+ for (int i = 0; i < rounds; i++) {
+ messages.add(Map.of("role", "assistant", "content",
+ List.of(Map.of("type", "tool_use", "id", "call-" + i, "name", "execute_command",
+ "input", Map.of("command", "ls /workspace")))));
+ messages.add(Map.of("role", "user", "content",
+ List.of(Map.of("type", "tool_result", "tool_use_id", "call-" + i,
+ "content", "출력 ".repeat(200) + i))));
+ }
+ return messages;
+ }
+
+ private static List longConversation(int turns) {
+ List history = new ArrayList<>();
+ for (int i = 0; i < turns; i++) {
+ history.add(new LlmMessage(
+ i % 2 == 0 ? "user" : "assistant",
+ "turn-" + i + " " + "내용 ".repeat(40)));
+ }
+ return List.copyOf(history);
+ }
+
+ private static String cannedResponse() {
+ return """
+ {"id":"msg_1","model":"claude-opus-4-5-20251101","stop_reason":"end_turn",
+ "content":[{"type":"text","text":"완료"}],
+ "usage":{"input_tokens":1000,"output_tokens":200,
+ "cache_creation_input_tokens":4000,"cache_read_input_tokens":12000}}
+ """;
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map lastBody() throws Exception {
+ return MAPPER.readValue(capturedBodies.get(capturedBodies.size() - 1), Map.class);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List list(Map body, String key) {
+ return (List) body.get(key);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map lastOf(List items) {
+ return (Map) items.get(items.size() - 1);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List markedUserTurnIndexes(Map body) {
+ List marked = new ArrayList<>();
+ List messages = list(body, "messages");
+ for (int i = 0; i < messages.size(); i++) {
+ Map message = (Map) messages.get(i);
+ if (message.get("content") instanceof List> blocks && !blocks.isEmpty()
+ && blocks.get(blocks.size() - 1) instanceof Map, ?> block
+ && block.containsKey("cache_control")) {
+ marked.add(i);
+ }
+ }
+ return marked;
+ }
+
+ private static int countBreakpoints(Object node) {
+ if (node instanceof Map, ?> map) {
+ int count = map.containsKey("cache_control") ? 1 : 0;
+ for (Map.Entry, ?> entry : map.entrySet()) {
+ if (!"cache_control".equals(entry.getKey())) {
+ count += countBreakpoints(entry.getValue());
+ }
+ }
+ return count;
+ }
+ if (node instanceof List> items) {
+ int count = 0;
+ for (Object item : items) {
+ count += countBreakpoints(item);
+ }
+ return count;
+ }
+ return 0;
+ }
+
+ private static Object stripCacheControl(Object node) {
+ if (node instanceof Map, ?> map) {
+ Map copy = new java.util.LinkedHashMap<>();
+ map.forEach((key, value) -> {
+ if (!"cache_control".equals(key)) {
+ copy.put(key, stripCacheControl(value));
+ }
+ });
+ return copy;
+ }
+ if (node instanceof List> items) {
+ return items.stream().map(AnthropicRequestBodyTest::stripCacheControl).toList();
+ }
+ return node;
+ }
+}
diff --git a/src/test/java/com/example/dvely/agent/infrastructure/llm/LlmApiKeyIsolationTest.java b/src/test/java/com/example/dvely/agent/infrastructure/llm/LlmApiKeyIsolationTest.java
index eeb76673..e4884c5b 100644
--- a/src/test/java/com/example/dvely/agent/infrastructure/llm/LlmApiKeyIsolationTest.java
+++ b/src/test/java/com/example/dvely/agent/infrastructure/llm/LlmApiKeyIsolationTest.java
@@ -1,7 +1,11 @@
package com.example.dvely.agent.infrastructure.llm;
+import static org.mockito.Mockito.mock;
+
import static org.assertj.core.api.Assertions.assertThat;
+import com.example.dvely.agent.infrastructure.usage.LlmUsageRecorder;
+import com.example.dvely.agent.infrastructure.usage.LlmUsageStore;
import com.example.dvely.agent.application.port.out.LlmMessage;
import com.example.dvely.agent.domain.value.AiModelOptions;
import com.example.dvely.agent.infrastructure.config.AiProperties;
@@ -67,7 +71,7 @@ private String endpointUrl() {
void 키를_바꾸면_다음_요청은_바뀐_키로_나간다() {
AiProperties properties = new AiProperties();
properties.getGlm().setBaseUrl(endpointUrl());
- GlmClient client = new GlmClient(properties);
+ GlmClient client = new GlmClient(properties, new LlmUsageRecorder(mock(LlmUsageStore.class)));
properties.getGlm().setApiKey("key-of-user-a");
client.complete("system", List.of(new LlmMessage("user", "hi")), AiModelOptions.defaults());
diff --git a/src/test/java/com/example/dvely/agent/infrastructure/llm/LlmUsageParserTest.java b/src/test/java/com/example/dvely/agent/infrastructure/llm/LlmUsageParserTest.java
new file mode 100644
index 00000000..5a70cd67
--- /dev/null
+++ b/src/test/java/com/example/dvely/agent/infrastructure/llm/LlmUsageParserTest.java
@@ -0,0 +1,68 @@
+package com.example.dvely.agent.infrastructure.llm;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.example.dvely.agent.domain.value.LlmUsage;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class LlmUsageParserTest {
+
+ @Test
+ void readsAnthropicsFourTokenCounts() {
+ LlmUsage usage = LlmUsageParser.anthropic(Map.of("usage", Map.of(
+ "input_tokens", 120,
+ "output_tokens", 45,
+ "cache_creation_input_tokens", 4_000,
+ "cache_read_input_tokens", 12_000)));
+
+ assertThat(usage.inputTokens()).isEqualTo(120);
+ assertThat(usage.outputTokens()).isEqualTo(45);
+ assertThat(usage.cacheCreationInputTokens()).isEqualTo(4_000);
+ assertThat(usage.cacheReadInputTokens()).isEqualTo(12_000);
+ assertThat(usage.billedInputTokens()).isEqualTo(16_120);
+ assertThat(usage.totalTokens()).isEqualTo(16_165);
+ }
+
+ @Test
+ void subtractsCachedTokensFromOpenAisPromptTokens() {
+ // prompt_tokens 는 캐시 적중분을 포함한 수이고 Anthropic 의 input_tokens 는 제외한 수다.
+ // 그대로 담으면 같은 이름의 칸에 다른 뜻이 섞여 제공자 간 합계가 어긋난다.
+ LlmUsage usage = LlmUsageParser.openAiCompatible(Map.of("usage", Map.of(
+ "prompt_tokens", 10_000,
+ "completion_tokens", 300,
+ "prompt_tokens_details", Map.of("cached_tokens", 8_000))));
+
+ assertThat(usage.inputTokens()).isEqualTo(2_000);
+ assertThat(usage.cacheReadInputTokens()).isEqualTo(8_000);
+ assertThat(usage.billedInputTokens()).isEqualTo(10_000);
+ assertThat(usage.totalTokens()).isEqualTo(10_300);
+ }
+
+ @Test
+ void treatsAMissingUsageBlockAsZeroRatherThanFailing() {
+ // 계측이 던지면 이미 성공한 LLM 호출이 통째로 실패한다.
+ assertThat(LlmUsageParser.anthropic(Map.of())).isEqualTo(LlmUsage.NONE);
+ assertThat(LlmUsageParser.openAiCompatible(Map.of())).isEqualTo(LlmUsage.NONE);
+ assertThat(LlmUsageParser.anthropic(Map.of("usage", "이건 객체가 아니다"))).isEqualTo(LlmUsage.NONE);
+ }
+
+ @Test
+ void survivesCachedTokensLargerThanPromptTokens() {
+ // 제공자가 어긋난 수를 줘도 입력이 음수가 되어 합계를 오염시키면 안 된다.
+ LlmUsage usage = LlmUsageParser.openAiCompatible(Map.of("usage", Map.of(
+ "prompt_tokens", 100,
+ "prompt_tokens_details", Map.of("cached_tokens", 999))));
+
+ assertThat(usage.inputTokens()).isZero();
+ }
+
+ @Test
+ void sumsAcrossCalls() {
+ LlmUsage total = LlmUsage.NONE
+ .plus(new LlmUsage(10, 1, 0, 0))
+ .plus(new LlmUsage(0, 2, 0, 500));
+
+ assertThat(total.totalTokens()).isEqualTo(513);
+ }
+}
diff --git a/src/test/java/com/example/dvely/agent/infrastructure/usage/LlmUsageBudgetTest.java b/src/test/java/com/example/dvely/agent/infrastructure/usage/LlmUsageBudgetTest.java
new file mode 100644
index 00000000..0a24fa25
--- /dev/null
+++ b/src/test/java/com/example/dvely/agent/infrastructure/usage/LlmUsageBudgetTest.java
@@ -0,0 +1,112 @@
+package com.example.dvely.agent.infrastructure.usage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.example.dvely.agent.application.exception.AgentTokenBudgetExceededException;
+import com.example.dvely.agent.domain.value.AiProvider;
+import com.example.dvely.agent.domain.value.LlmUsage;
+import org.junit.jupiter.api.Test;
+
+class LlmUsageBudgetTest {
+
+ private final LlmUsageStore store = mock(LlmUsageStore.class);
+ private final LlmUsageRecorder recorder = new LlmUsageRecorder(store);
+
+ @Test
+ void stopsTheTaskOnceItsCumulativeTokensPassTheBudget() {
+ try (LlmUsageScope scope = recorder.openTaskScope("task-1", 1L, 11L, 1_000)) {
+ recorder.record(AiProvider.GLM, "z-ai/glm-4.6", new LlmUsage(400, 100, 0, 0));
+ assertThat(scope.usedTokens()).isEqualTo(500);
+
+ assertThatThrownBy(() ->
+ recorder.record(AiProvider.GLM, "z-ai/glm-4.6", new LlmUsage(600, 50, 0, 0)))
+ .isInstanceOf(AgentTokenBudgetExceededException.class)
+ .hasMessageContaining("AI 토큰 예산 상한");
+ }
+ }
+
+ @Test
+ void stillRecordsTheCallThatBustTheBudget() {
+ // 상한을 터뜨린 호출도 이미 과금됐다. 기록에서 빠지면 사용자에게 보이는 수치가 실제
+ // 청구와 어긋난다.
+ try (LlmUsageScope ignored = recorder.openTaskScope("task-1", 1L, 11L, 100)) {
+ assertThatThrownBy(() ->
+ recorder.record(AiProvider.ANTHROPIC, "claude", new LlmUsage(500, 0, 0, 0)))
+ .isInstanceOf(AgentTokenBudgetExceededException.class);
+ }
+
+ verify(store).save(eq("task-1"), eq(1L), eq(11L), eq(LlmUsagePhase.AGENT_RUN),
+ eq("ANTHROPIC"), eq("claude"), any(LlmUsage.class));
+ }
+
+ @Test
+ void carriesThePreviousAttemptsTokensSoARetryCannotResetTheBudget() {
+ // 실행마다 0 에서 다시 세면 상한이 태스크 재시도 횟수만큼 곱해진다 — 닫으려던 곱셈이
+ // 그대로 남는다.
+ when(store.tokensAlreadyUsedBy("task-1")).thenReturn(990L);
+
+ try (LlmUsageScope scope = recorder.openTaskScope("task-1", 1L, 11L, 1_000)) {
+ assertThat(scope.usedTokens()).isEqualTo(990);
+ assertThatThrownBy(() ->
+ recorder.record(AiProvider.GLM, "glm", new LlmUsage(20, 0, 0, 0)))
+ .isInstanceOf(AgentTokenBudgetExceededException.class);
+ }
+ }
+
+ @Test
+ void doesNotQueryPreviousUsageWhenTheBudgetIsDisabled() {
+ try (LlmUsageScope scope = recorder.openTaskScope("task-1", 1L, 11L, 0)) {
+ recorder.record(AiProvider.GLM, "glm", new LlmUsage(9_999_999, 0, 0, 0));
+ assertThat(scope.usedTokens()).isEqualTo(9_999_999);
+ }
+
+ verify(store, never()).tokensAlreadyUsedBy(anyString());
+ }
+
+ @Test
+ void attributesCallsMadeWithNoOpenScopeRatherThanDroppingThem() {
+ // 귀속이 없어도 합계에서 사라지면 안 된다 — "합계가 맞는가" 를 의심하게 만드는 순간
+ // 계측 전체가 쓸모없어진다.
+ recorder.record(AiProvider.OPENAI, "gpt-4o", new LlmUsage(10, 5, 0, 0));
+
+ verify(store).save(eq(null), eq(null), eq(null), eq(LlmUsagePhase.UNSCOPED),
+ eq("OPENAI"), eq("gpt-4o"), any(LlmUsage.class));
+ }
+
+ @Test
+ void recordsNothingWhenTheProviderReturnedNoUsage() {
+ recorder.record(AiProvider.OPENAI, "gpt-4o", LlmUsage.NONE);
+
+ verify(store, never()).save(any(), any(), any(), any(), anyString(), anyString(), any());
+ }
+
+ @Test
+ void restoresThePreviousScopeOnClose() {
+ try (LlmUsageScope outer = LlmUsageScope.open("outer", 1L, 11L, LlmUsagePhase.DECISION)) {
+ try (LlmUsageScope inner =
+ LlmUsageScope.open("inner", 1L, 11L, LlmUsagePhase.AGENT_RUN)) {
+ assertThat(LlmUsageScope.current()).isSameAs(inner);
+ }
+ assertThat(LlmUsageScope.current()).isSameAs(outer);
+ }
+ assertThat(LlmUsageScope.current()).isNull();
+ }
+
+ @Test
+ void neverLeavesAScopeBehindForTheNextTaskOnTheSameThread() {
+ // 스레드풀이 스레드를 재사용하므로, 닫히지 않은 스코프는 남의 작업 토큰을 엉뚱한 태스크에
+ // 붙인다.
+ try (LlmUsageScope ignored = recorder.openTaskScope("task-1", 1L, 11L, 10_000)) {
+ assertThat(LlmUsageScope.current()).isNotNull();
+ }
+ assertThat(LlmUsageScope.current()).isNull();
+ }
+}
diff --git a/src/test/java/com/example/dvely/agent/usage/LlmUsageSchemaTest.java b/src/test/java/com/example/dvely/agent/usage/LlmUsageSchemaTest.java
new file mode 100644
index 00000000..313d62ba
--- /dev/null
+++ b/src/test/java/com/example/dvely/agent/usage/LlmUsageSchemaTest.java
@@ -0,0 +1,108 @@
+package com.example.dvely.agent.usage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.example.dvely.agent.domain.value.LlmUsage;
+import com.example.dvely.agent.infrastructure.persistence.entity.LlmUsageEntity;
+import com.example.dvely.agent.infrastructure.persistence.repository.SpringDataLlmUsageRepository;
+import com.example.dvely.agent.infrastructure.usage.LlmUsagePhase;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+/**
+ * {@code ddl-auto: validate} 는 nullable 여부도 FK 부재도 보지 않는다. V63 이 설계대로 내려앉았는지는
+ * 실제 스키마를 직접 물어보는 것 말고 확인할 방법이 없다.
+ *
+ * 이 단위의 완료 기준("같은 시나리오의 전/후 토큰 합계를 비교할 수 있다")이 실제로 성립하는지도
+ * 여기서 확인한다 — 행을 쓰고 태스크 단위로 합산해 본다.
+ */
+@SpringBootTest
+class LlmUsageSchemaTest {
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @Autowired
+ private SpringDataLlmUsageRepository repository;
+
+ @Test
+ void v63MigrationApplied() {
+ String applied = jdbcTemplate.queryForObject(
+ "select coalesce(max(success), 0) from flyway_schema_history where version = '63'",
+ String.class);
+
+ assertThat("1".equals(applied) || "true".equalsIgnoreCase(applied)).isTrue();
+ }
+
+ @Test
+ void attributionColumnsAreNullableSoACallOutsideAnyTaskIsStillCounted() {
+ // 배포 실패 분석처럼 태스크 없이 도는 호출이 있다. NOT NULL 이면 그 호출은 기록될 수
+ // 없고, 합계가 조용히 작아진다.
+ Map nullability = jdbcTemplate.queryForList(
+ """
+ select column_name, is_nullable
+ from information_schema.columns
+ where table_schema = database() and table_name = 'llm_usage'
+ """)
+ .stream()
+ .collect(Collectors.toMap(
+ row -> String.valueOf(row.get("COLUMN_NAME")).toLowerCase(),
+ row -> String.valueOf(row.get("IS_NULLABLE")).toUpperCase()));
+
+ assertThat(nullability).containsEntry("task_id", "YES")
+ .containsEntry("user_id", "YES")
+ .containsEntry("project_id", "YES")
+ .containsEntry("phase", "NO")
+ .containsEntry("provider", "NO")
+ .containsEntry("input_tokens", "NO")
+ .containsEntry("cache_read_input_tokens", "NO");
+ }
+
+ @Test
+ void hasNoForeignKeysSoItNeverJoinsAnotherTablesLockGraph() {
+ // agent_runs 는 보존 정책으로 삭제된다. FK 가 있으면 비용 이력이 함께 사라지고, 계측
+ // INSERT 가 실행 중인 태스크 행의 잠금을 기다리게 된다.
+ Integer foreignKeys = jdbcTemplate.queryForObject(
+ """
+ select count(*)
+ from information_schema.table_constraints
+ where table_schema = database()
+ and table_name = 'llm_usage'
+ and constraint_type = 'FOREIGN KEY'
+ """,
+ Integer.class);
+
+ assertThat(foreignKeys).isZero();
+ }
+
+ @Test
+ void sumsATasksTokensAcrossEveryRoundItSpent() {
+ String taskId = "usage-schema-test-" + System.nanoTime();
+ repository.save(new LlmUsageEntity(taskId, 1L, 11L, LlmUsagePhase.DECISION,
+ "GLM", "z-ai/glm-4.6", new LlmUsage(3_000, 400, 0, 0)));
+ repository.save(new LlmUsageEntity(taskId, 1L, 11L, LlmUsagePhase.AGENT_RUN,
+ "ANTHROPIC", "claude-opus-4-5-20251101", new LlmUsage(1_000, 200, 4_000, 12_000)));
+ repository.save(new LlmUsageEntity(taskId, 1L, 11L, LlmUsagePhase.AGENT_RUN,
+ "ANTHROPIC", "claude-opus-4-5-20251101", new LlmUsage(900, 150, 0, 16_000)));
+
+ List rounds = repository.findAllByTaskIdOrderByIdAsc(taskId);
+
+ assertThat(rounds).hasSize(3);
+ // 3,400 + 17,200 + 17,050 — 이 합계를 전/후로 비교하는 것이 이 단위의 완료 기준이다.
+ assertThat(repository.sumTotalTokensByTaskId(taskId)).isEqualTo(37_650);
+ // 라운드별로 남기 때문에 "캐시가 실제로 살아 있는가" 도 읽을 수 있다.
+ assertThat(rounds.get(2).getCacheReadInputTokens()).isEqualTo(16_000);
+
+ repository.deleteAll(rounds);
+ }
+
+ @Test
+ void countsZeroForATaskThatHasNotSpentAnything() {
+ assertThat(repository.sumTotalTokensByTaskId("task-that-never-ran")).isZero();
+ }
+}
diff --git a/src/test/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisServiceTest.java b/src/test/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisServiceTest.java
index 821257f3..7ecc0ba7 100644
--- a/src/test/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisServiceTest.java
+++ b/src/test/java/com/example/dvely/deployment/application/service/DeploymentFailureAnalysisServiceTest.java
@@ -124,8 +124,8 @@ void analyzeSucceedsWithLlmJsonAndRecordsProviderAndModel() {
when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn(
new GithubActionsPort.DeploymentLogs(901L, List.of(), "npm ERR! missing script: build")
);
- when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort);
- when(llmPort.complete(any(), anyList())).thenReturn(
+ when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort);
+ when(llmPort.complete(any(), anyList(), any())).thenReturn(
"{\"summary\": \"빌드 스크립트가 없습니다.\", \"suggestedFix\": \"package.json에 build 스크립트를 추가하세요.\"}"
);
when(analysisRepository.save(any(DeploymentFailureAnalysis.class)))
@@ -139,6 +139,54 @@ void analyzeSucceedsWithLlmJsonAndRecordsProviderAndModel() {
verifyNoInteractions(buildFailureAnalyzer);
}
+ // ── U8 8-5: 제공자·모델을 코드에 박지 않는다 ──────────────────────────────────────────
+
+ @Test
+ void followsTheDeploymentsDefaultProviderInsteadOfPinningAnthropic() {
+ // 이 한 경로만 ANTHROPIC + 그 제공자의 최상위 모델로 박혀 있었다. 12,000자 로그를 한 번
+ // 요약하는 데 쓸 근거가 없었고, default-provider(GLM)와도 어긋났다.
+ stubLlmAnalysis("{\"summary\":\"요약\",\"suggestedFix\":\"수정\"}");
+ ArgumentCaptor saved =
+ ArgumentCaptor.forClass(DeploymentFailureAnalysis.class);
+
+ service.analyze(1L, 51L);
+
+ verify(analysisRepository).save(saved.capture());
+ assertThat(saved.getValue().getProvider()).isEqualTo("GLM");
+ assertThat(saved.getValue().getModel()).isEqualTo("z-ai/glm-4.6");
+ verify(llmRouter).route(AiProvider.GLM);
+ }
+
+ @Test
+ void canBePinnedBackToAnthropicByConfigurationWhenAnalysisQualityDrops() {
+ // 품질이 떨어지면 실패 분석 자체가 쓸모없어진다. 되돌리는 길은 재배포가 아니라 설정이다.
+ aiProperties.getFailureAnalysis().setProvider(AiProvider.ANTHROPIC);
+ aiProperties.getFailureAnalysis().setModel("claude-opus-4-5-20251101");
+ stubLlmAnalysis("{\"summary\":\"요약\",\"suggestedFix\":\"수정\"}");
+ ArgumentCaptor saved =
+ ArgumentCaptor.forClass(DeploymentFailureAnalysis.class);
+
+ service.analyze(1L, 51L);
+
+ verify(analysisRepository).save(saved.capture());
+ assertThat(saved.getValue().getProvider()).isEqualTo("ANTHROPIC");
+ assertThat(saved.getValue().getModel()).isEqualTo("claude-opus-4-5-20251101");
+ verify(llmRouter).route(AiProvider.ANTHROPIC);
+ }
+
+ private void stubLlmAnalysis(String json) {
+ stubOwnedHistory(failedHistoryWithRunId());
+ when(analysisRepository.findByHistoryId(51L)).thenReturn(Optional.empty());
+ when(userRepository.findById(1L)).thenReturn(Optional.of(activeUser()));
+ when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn(
+ new GithubActionsPort.DeploymentLogs(901L, List.of(), "npm ERR! build failed")
+ );
+ when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort);
+ when(llmPort.complete(any(), anyList(), any())).thenReturn(json);
+ when(analysisRepository.save(any(DeploymentFailureAnalysis.class)))
+ .thenAnswer(invocation -> withId(invocation.getArgument(0), 1L));
+ }
+
@Test
void analyzeFallsBackToRuleBasedWhenLlmThrows() {
stubOwnedHistory(failedHistoryWithRunId());
@@ -147,8 +195,8 @@ void analyzeFallsBackToRuleBasedWhenLlmThrows() {
when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn(
new GithubActionsPort.DeploymentLogs(901L, List.of(), "cannot find module 'react'")
);
- when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort);
- when(llmPort.complete(any(), anyList())).thenThrow(new IllegalStateException("Claude API 응답이 비어있습니다"));
+ when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort);
+ when(llmPort.complete(any(), anyList(), any())).thenThrow(new IllegalStateException("Claude API 응답이 비어있습니다"));
when(buildFailureAnalyzer.analyze(any())).thenReturn(new BuildFailureAnalyzer.Analysis(
"빌드에 필요한 모듈을 찾지 못했습니다.", "cannot find module 'react'", "dependency를 다시 설치하세요."
));
@@ -170,8 +218,8 @@ void analyzeFallsBackToRuleBasedWhenLlmResponseIsNotParseableJson() {
when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn(
new GithubActionsPort.DeploymentLogs(901L, List.of(), "some log text")
);
- when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort);
- when(llmPort.complete(any(), anyList())).thenReturn("this is not json at all");
+ when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort);
+ when(llmPort.complete(any(), anyList(), any())).thenReturn("this is not json at all");
when(buildFailureAnalyzer.analyze(any())).thenReturn(new BuildFailureAnalyzer.Analysis(
"프로젝트 빌드가 완료되지 않았습니다.", "some log text", "로그를 확인하세요."
));
@@ -192,8 +240,8 @@ void analyzeSkipsGithubCallWhenWorkflowRunIdIsNullAndUsesErrorMessage() {
);
stubOwnedHistory(history);
when(analysisRepository.findByHistoryId(51L)).thenReturn(Optional.empty());
- when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort);
- when(llmPort.complete(any(), anyList())).thenReturn(
+ when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort);
+ when(llmPort.complete(any(), anyList(), any())).thenReturn(
"{\"summary\": \"트리거 실패\", \"suggestedFix\": \"다시 시도하세요.\"}"
);
when(analysisRepository.save(any(DeploymentFailureAnalysis.class)))
@@ -216,8 +264,8 @@ void excerptBudgetTruncatesOversizedLogsAndPrioritizesErrorLines() {
String logText = noise + "npm ERR! critical failure marker\n" + noise;
when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L))
.thenReturn(new GithubActionsPort.DeploymentLogs(901L, List.of(), logText));
- when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort);
- when(llmPort.complete(any(), anyList())).thenReturn(
+ when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort);
+ when(llmPort.complete(any(), anyList(), any())).thenReturn(
"{\"summary\": \"실패\", \"suggestedFix\": \"수정\"}"
);
when(analysisRepository.save(any(DeploymentFailureAnalysis.class)))
@@ -245,8 +293,8 @@ void concurrentAnalysisRaceReFetchesAndReturnsTheOtherRequestsResult() {
when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn(
new GithubActionsPort.DeploymentLogs(901L, List.of(), "some log")
);
- when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort);
- when(llmPort.complete(any(), anyList())).thenReturn(
+ when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort);
+ when(llmPort.complete(any(), anyList(), any())).thenReturn(
"{\"summary\": \"이 요청의 요약\", \"suggestedFix\": \"이 요청의 수정안\"}"
);
when(analysisRepository.save(any(DeploymentFailureAnalysis.class)))
@@ -291,7 +339,7 @@ void concurrentAnalyzeCallsForTheSameHistoryOnlyInvokeTheLlmOnce() throws Except
when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn(
new GithubActionsPort.DeploymentLogs(901L, List.of(), "some log")
);
- when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort);
+ when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort);
// Stateful fake instead of a one-shot stub: the whole point is to prove the *second*
// caller sees the *first* caller's saved row via the double-checked cache, so
@@ -307,7 +355,7 @@ void concurrentAnalyzeCallsForTheSameHistoryOnlyInvokeTheLlmOnce() throws Except
AtomicInteger llmCallCount = new AtomicInteger();
CountDownLatch llmEntered = new CountDownLatch(1);
CountDownLatch releaseLlm = new CountDownLatch(1);
- when(llmPort.complete(any(), anyList())).thenAnswer(invocation -> {
+ when(llmPort.complete(any(), anyList(), any())).thenAnswer(invocation -> {
llmCallCount.incrementAndGet();
llmEntered.countDown();
assertThat(releaseLlm.await(5, TimeUnit.SECONDS)).isTrue();
@@ -360,9 +408,9 @@ void secretsInLogsAreRedactedBeforeStorageAndBeforeReachingTheLlm() {
when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn(
new GithubActionsPort.DeploymentLogs(901L, List.of(), secretLaden)
);
- when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort);
+ when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort);
ArgumentCaptor> messagesCaptor = ArgumentCaptor.forClass(List.class);
- when(llmPort.complete(any(), messagesCaptor.capture())).thenReturn(
+ when(llmPort.complete(any(), messagesCaptor.capture(), any())).thenReturn(
"{\"summary\": \"실패\", \"suggestedFix\": \"수정\"}"
);
when(analysisRepository.save(any(DeploymentFailureAnalysis.class)))
@@ -398,8 +446,8 @@ void analyzeDegradesToErrorMessageBasedAnalysisWhenGithubLogFetchFails() {
when(userRepository.findById(1L)).thenReturn(Optional.of(activeUser()));
when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L))
.thenThrow(new RuntimeException("GitHub API rate limit exceeded"));
- when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort);
- when(llmPort.complete(any(), anyList())).thenReturn(
+ when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort);
+ when(llmPort.complete(any(), anyList(), any())).thenReturn(
"{\"summary\": \"요약\", \"suggestedFix\": \"수정\"}"
);
when(analysisRepository.save(any(DeploymentFailureAnalysis.class)))
@@ -422,8 +470,8 @@ void analyzeFallsBackToRuleBasedWhenLlmCallExceedsTheTimeout() {
when(githubActionsPort.getJobLogs("user-token", "octo/repo", 901L)).thenReturn(
new GithubActionsPort.DeploymentLogs(901L, List.of(), "some log")
);
- when(llmRouter.route(AiProvider.ANTHROPIC)).thenReturn(llmPort);
- when(llmPort.complete(any(), anyList())).thenAnswer(invocation -> {
+ when(llmRouter.route(aiProperties.failureAnalysisProvider())).thenReturn(llmPort);
+ when(llmPort.complete(any(), anyList(), any())).thenAnswer(invocation -> {
// Sleeps far longer than the 1-second test timeout above; orTimeout must win the
// race and hand control to the rule-based fallback rather than waiting on this.
Thread.sleep(3000);