Skip to content

Commit cc98312

Browse files
soobingsounmindclaude
committed
feat: OpenAI API 토큰 사용량 측정 및 PR 댓글에 표시 (#11)
- openai.js: generateApproachAnalysis가 usage 필드(prompt_tokens, completion_tokens) 반환하도록 수정 - learning-status.js: 제출 파일별 AI 분석 호출마다 토큰 누적 집계 - learningComment.js: PR 댓글 하단에 요청별 토큰 사용량 이력 테이블 추가 - 요청 횟수(#1, #2, ...)별 입력/출력/합계 토큰 및 비용 표시 - 2회 이상 시 합계 행 표시 - 이력을 hidden HTML 마커로 댓글에 저장해 업데이트 시 누적 Co-Authored-By: sounmind <sounmind@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6768293 commit cc98312

3 files changed

Lines changed: 123 additions & 2 deletions

File tree

handlers/learning-status.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ export async function postLearningStatus(
107107

108108
// 4. 제출 파일별 AI 분석
109109
const submissionResults = [];
110+
const totalUsage = { prompt_tokens: 0, completion_tokens: 0 };
111+
110112
for (const submission of submissions) {
111113
const problemInfo = categories[submission.problemName];
112114

@@ -145,6 +147,11 @@ export async function postLearningStatus(
145147
openaiApiKey
146148
);
147149

150+
if (analysis.usage) {
151+
totalUsage.prompt_tokens += analysis.usage.prompt_tokens ?? 0;
152+
totalUsage.completion_tokens += analysis.usage.completion_tokens ?? 0;
153+
}
154+
148155
submissionResults.push({
149156
problemName: submission.problemName,
150157
difficulty: problemInfo.difficulty,
@@ -164,6 +171,8 @@ export async function postLearningStatus(
164171
}
165172
}
166173

174+
const hasUsage = totalUsage.prompt_tokens > 0 || totalUsage.completion_tokens > 0;
175+
167176
// 5. 카테고리별 진행도 계산
168177
const totalProblems = Object.keys(categories).length;
169178
const categoryProgress = buildCategoryProgress(categories, solvedProblems);
@@ -178,7 +187,14 @@ export async function postLearningStatus(
178187
);
179188

180189
// 7. 댓글 생성 또는 업데이트
181-
await upsertLearningStatusComment(repoOwner, repoName, prNumber, commentBody, appToken);
190+
await upsertLearningStatusComment(
191+
repoOwner,
192+
repoName,
193+
prNumber,
194+
commentBody,
195+
appToken,
196+
hasUsage ? totalUsage : null
197+
);
182198

183199
// 8. 결과 반환
184200
const matchedCount = submissionResults.filter((r) => r.matches === true).length;

utils/learningComment.js

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,95 @@ import { getGitHubHeaders } from "./github.js";
99
*/
1010
const COMMENT_MARKER = "<!-- dalestudy-learning-status -->";
1111

12+
/**
13+
* Hidden marker for embedding cumulative usage data in the comment.
14+
* Format: <!-- usage-data: {"prompt":N,"completion":N,"requests":N} -->
15+
*/
16+
const USAGE_DATA_RE = /<!-- usage-data: ({.*?}) -->/;
17+
18+
/** gpt-4.1-nano pricing (USD per token) */
19+
const INPUT_COST_PER_TOKEN = 0.10 / 1_000_000;
20+
const OUTPUT_COST_PER_TOKEN = 0.40 / 1_000_000;
21+
22+
/**
23+
* Calculates cost in USD from token counts.
24+
*
25+
* @param {number} promptTokens
26+
* @param {number} completionTokens
27+
* @returns {number}
28+
*/
29+
function calcCost(promptTokens, completionTokens) {
30+
return promptTokens * INPUT_COST_PER_TOKEN + completionTokens * OUTPUT_COST_PER_TOKEN;
31+
}
32+
33+
/**
34+
* Formats a number with thousand-separating commas.
35+
*
36+
* @param {number} n
37+
* @returns {string}
38+
*/
39+
function fmt(n) {
40+
return n.toLocaleString("en-US");
41+
}
42+
43+
/**
44+
* Parses per-request usage history embedded in an existing comment body.
45+
*
46+
* @param {string|undefined} body
47+
* @returns {Array<{ prompt: number, completion: number }>}
48+
*/
49+
function parseUsageFromComment(body) {
50+
if (!body) return [];
51+
const match = body.match(USAGE_DATA_RE);
52+
if (!match) return [];
53+
try {
54+
const parsed = JSON.parse(match[1]);
55+
return Array.isArray(parsed) ? parsed : [];
56+
} catch {
57+
return [];
58+
}
59+
}
60+
61+
/**
62+
* Builds the usage footer section (per-request history table + hidden data marker).
63+
*
64+
* @param {Array<{ prompt: number, completion: number }>} history - all requests including current (latest last)
65+
* @returns {string}
66+
*/
67+
function formatUsageSection(history) {
68+
const lines = [];
69+
lines.push("<details>");
70+
lines.push("<summary>🔢 API 사용량 (gpt-4.1-nano)</summary>");
71+
lines.push("");
72+
lines.push("| 요청 | 입력 토큰 | 출력 토큰 | 합계 | 비용 |");
73+
lines.push("|---:|---:|---:|---:|---:|");
74+
75+
let totalPrompt = 0;
76+
let totalCompletion = 0;
77+
78+
for (let i = 0; i < history.length; i++) {
79+
const { prompt, completion } = history[i];
80+
const total = prompt + completion;
81+
const cost = calcCost(prompt, completion);
82+
lines.push(`| #${i + 1} | ${fmt(prompt)} | ${fmt(completion)} | ${fmt(total)} | $${cost.toFixed(6)} |`);
83+
totalPrompt += prompt;
84+
totalCompletion += completion;
85+
}
86+
87+
if (history.length > 1) {
88+
const totalCost = calcCost(totalPrompt, totalCompletion);
89+
lines.push(`| **합계** | **${fmt(totalPrompt)}** | **${fmt(totalCompletion)}** | **${fmt(totalPrompt + totalCompletion)}** | **$${totalCost.toFixed(6)}** |`);
90+
}
91+
92+
lines.push("");
93+
lines.push("</details>");
94+
95+
// Embed history for future parsing
96+
lines.push(`<!-- usage-data: ${JSON.stringify(history)} -->`);
97+
98+
return lines.join("\n");
99+
}
100+
12101
/**
13102
* Returns true when `comment` is a Bot comment containing our marker.
14103
*
@@ -129,19 +218,24 @@ export function formatLearningStatusComment(
129218
* Searches through the first 100 issue comments for an existing Bot comment
130219
* that contains COMMENT_MARKER. If found, PATCHes it; otherwise POSTs a new one.
131220
*
221+
* When `currentUsage` is provided, appends a collapsible usage/cost section to
222+
* the comment and accumulates it with any previously stored cumulative totals.
223+
*
132224
* @param {string} repoOwner
133225
* @param {string} repoName
134226
* @param {number} prNumber
135227
* @param {string} commentBody
136228
* @param {string} appToken
229+
* @param {{ prompt_tokens: number, completion_tokens: number }|null} [currentUsage]
137230
* @returns {Promise<void>}
138231
*/
139232
export async function upsertLearningStatusComment(
140233
repoOwner,
141234
repoName,
142235
prNumber,
143236
commentBody,
144-
appToken
237+
appToken,
238+
currentUsage = null
145239
) {
146240
const baseUrl = `https://api.github.com/repos/${repoOwner}/${repoName}`;
147241

@@ -160,6 +254,16 @@ export async function upsertLearningStatusComment(
160254
const comments = await listResponse.json();
161255
const existing = comments.find(isLearningStatusComment);
162256

257+
// Append usage section when token data is available
258+
if (currentUsage) {
259+
const prevHistory = parseUsageFromComment(existing?.body);
260+
const history = [
261+
...prevHistory,
262+
{ prompt: currentUsage.prompt_tokens, completion: currentUsage.completion_tokens },
263+
];
264+
commentBody = commentBody.trimEnd() + "\n\n" + formatUsageSection(history) + "\n";
265+
}
266+
163267
if (existing) {
164268
// Update existing comment
165269
console.log(

utils/openai.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,5 +254,6 @@ ${truncatedContent}
254254
return {
255255
matches: parsed.matches === true,
256256
explanation: typeof parsed.explanation === "string" ? parsed.explanation : "",
257+
usage: data.usage ?? null,
257258
};
258259
}

0 commit comments

Comments
 (0)