-
Notifications
You must be signed in to change notification settings - Fork 427
Fix AIC extraction and computation from token usage JSONL files #40085
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -214,6 +214,12 @@ function sumAICFromUsageJSONLFiles(filePaths) { | |
| total += explicitAIC; | ||
| continue; | ||
| } | ||
| // Prefer proxy-emitted per-request AIC over locally computed when present. | ||
| const explicitPerRequest = getNumericAliasField(usage, parsed, ["ai_credits_this_response"]); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnose] The inline comment says "Prefer proxy-emitted per-request AIC over locally computed" but 💡 Suggested ordering fixMove // Highest fidelity: proxy-emitted per-request AIC.
const explicitPerRequest = getNumericAliasField(usage, parsed, ["ai_credits_this_response"]);
if (explicitPerRequest > 0) {
total += explicitPerRequest;
continue;
}
// Legacy aggregate aliases (lower priority).
const explicitAICredits = getNumericAliasField(usage, parsed, ["ai_credits", "aiCredits"]);This keeps daily totals consistent with the step-summary values already reported by
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Misleading comment: 💡 DetailsThe comment on line 217 says "Prefer proxy-emitted per-request AIC over locally computed when present", which implies
If a transitional proxy record happens to emit both If the intent is backward compatibility (old proxy → |
||
| if (explicitPerRequest > 0) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnose] Same |
||
| total += explicitPerRequest; | ||
| continue; | ||
| } | ||
|
|
||
| const computed = computeInferenceAIC({ | ||
| provider: getStringField(usage, parsed, "provider", "provider"), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,9 +18,11 @@ const { parseUnknownModelAICreditsFromAuditLog } = require("./ai_credits_context | |
| * - /tmp/gh-aw/mcp-logs/gateway.log (main gateway log, fallback) | ||
| * - /tmp/gh-aw/mcp-logs/stderr.log (stderr output, fallback) | ||
| * - /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl (token usage from firewall proxy) | ||
| * - /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl (audit copy, checked as fallback) | ||
| */ | ||
|
|
||
| const TOKEN_USAGE_PATH = "/tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl"; | ||
| const TOKEN_USAGE_AUDIT_PATH = "/tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl"; | ||
| const MAX_RPC_SUMMARY_DETAILS_LENGTH = 120; | ||
| const MAX_RPC_SUMMARY_GENERIC_LENGTH = 160; | ||
| const MAX_RPC_MESSAGE_LABEL_LENGTH = 80; | ||
|
|
@@ -66,7 +68,7 @@ function parseTokenUsageJsonl(jsonlContent) { | |
| totalAIC: 0, | ||
| ambientContextTokens: undefined, | ||
| byModel: {}, | ||
| /** @type {{ model: string, provider: string, inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheWriteTokens: number, reasoningTokens: number, durationMs: number, deltaAIC: number }[]} */ | ||
| /** @type {{ model: string, provider: string, inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheWriteTokens: number, reasoningTokens: number, durationMs: number, deltaAIC: number, explicitDeltaAIC: number | null }[]} */ | ||
| entries: [], | ||
| }; | ||
|
|
||
|
|
@@ -84,6 +86,9 @@ function parseTokenUsageJsonl(jsonlContent) { | |
| const cacheWriteTokens = entry.cache_write_tokens || 0; | ||
| const reasoningTokens = entry.reasoning_tokens || 0; | ||
| const durationMs = entry.duration_ms || 0; | ||
| // When the proxy emits an explicit per-request AIC value, prefer it over | ||
| // the locally-computed value so that proxy-side pricing updates take effect. | ||
| const explicitDeltaAIC = typeof entry.ai_credits_this_response === "number" && entry.ai_credits_this_response > 0 ? entry.ai_credits_this_response : null; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnose] The 💡 Suggested fixDrop the const explicitDeltaAIC = typeof entry.ai_credits_this_response === "number"
? entry.ai_credits_this_response
: null;The
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
💡 Detailsconst explicitDeltaAIC =
typeof entry.ai_credits_this_response === "number" && entry.ai_credits_this_response > 0
? entry.ai_credits_this_response
: null;The Fix: separate presence detection from value filtering: const rawExplicit = entry.ai_credits_this_response;
const explicitDeltaAIC =
typeof rawExplicit === "number" ? rawExplicit : null;Then in the aggregation pass: entry.deltaAIC = entry.explicitDeltaAIC !== null ? entry.explicitDeltaAIC : computed;The same |
||
|
|
||
| summary.totalInputTokens += inputTokens; | ||
| summary.totalOutputTokens += outputTokens; | ||
|
|
@@ -117,33 +122,19 @@ function parseTokenUsageJsonl(jsonlContent) { | |
| m.requests++; | ||
| m.durationMs += durationMs; | ||
|
|
||
| summary.entries.push({ model, provider: m.provider, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, durationMs, deltaAIC: 0 }); | ||
| summary.entries.push({ model, provider: m.provider, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, durationMs, deltaAIC: 0, explicitDeltaAIC }); | ||
| } catch { | ||
| // skip malformed lines | ||
| } | ||
| } | ||
|
|
||
| if (summary.totalRequests === 0) return null; | ||
|
|
||
| let totalAIC = 0; | ||
| for (const [model, usage] of Object.entries(summary.byModel)) { | ||
| const aic = computeInferenceAIC({ | ||
| provider: usage.provider || "", | ||
| model, | ||
| inputTokens: usage.inputTokens, | ||
| outputTokens: usage.outputTokens, | ||
| cacheReadTokens: usage.cacheReadTokens, | ||
| cacheWriteTokens: usage.cacheWriteTokens, | ||
| reasoningTokens: usage.reasoningTokens || 0, | ||
| }); | ||
| usage.aic = aic; | ||
| totalAIC += aic; | ||
| } | ||
| summary.totalAIC = totalAIC; | ||
|
|
||
| // Compute per-request AI credits. | ||
| // Prefer the proxy-emitted explicit value when available; fall back to | ||
| // computing from token counts and the local pricing catalog. | ||
| for (const entry of summary.entries) { | ||
| entry.deltaAIC = computeInferenceAIC({ | ||
| const computed = computeInferenceAIC({ | ||
| provider: entry.provider || "", | ||
| model: entry.model, | ||
| inputTokens: entry.inputTokens, | ||
|
|
@@ -152,7 +143,18 @@ function parseTokenUsageJsonl(jsonlContent) { | |
| cacheWriteTokens: entry.cacheWriteTokens, | ||
| reasoningTokens: entry.reasoningTokens || 0, | ||
| }); | ||
| entry.deltaAIC = entry.explicitDeltaAIC ?? computed; | ||
| } | ||
|
|
||
| // Aggregate per-model AIC and overall total by summing per-entry deltaAIC. | ||
| // This keeps model totals consistent with the per-entry view regardless of | ||
| // whether explicit or computed AIC is used. | ||
| let totalAIC = 0; | ||
| for (const entry of summary.entries) { | ||
| summary.byModel[entry.model].aic += entry.deltaAIC; | ||
| totalAIC += entry.deltaAIC; | ||
| } | ||
| summary.totalAIC = totalAIC; | ||
|
|
||
| return summary; | ||
| } | ||
|
|
@@ -202,25 +204,49 @@ function generateTokenUsageSummary(summary) { | |
| * @param {typeof import('@actions/core')} coreObj - The GitHub Actions core object | ||
| */ | ||
| function writeStepSummaryWithTokenUsage(coreObj) { | ||
| if (!fs.existsSync(TOKEN_USAGE_PATH)) { | ||
| coreObj.debug(`No token-usage.jsonl found at: ${TOKEN_USAGE_PATH}`); | ||
| } else { | ||
| const content = fs.readFileSync(TOKEN_USAGE_PATH, "utf8"); | ||
| if (content?.trim()) { | ||
| coreObj.info(`Found token-usage.jsonl (${content.length} bytes)`); | ||
| const parsedSummary = parseTokenUsageJsonl(content); | ||
| if (parsedSummary && parsedSummary.totalAIC > 0) { | ||
| const roundedAIC = parsedSummary.totalAIC.toFixed(3); | ||
| coreObj.exportVariable("GH_AW_AIC", roundedAIC); | ||
| coreObj.setOutput("aic", roundedAIC); | ||
| coreObj.info(`AI Credits: ${roundedAIC}`); | ||
| } | ||
| if (parsedSummary && typeof parsedSummary.ambientContextTokens === "number" && parsedSummary.ambientContextTokens > 0) { | ||
| const roundedAmbientContext = String(Math.round(parsedSummary.ambientContextTokens)); | ||
| coreObj.exportVariable("GH_AW_AMBIENT_CONTEXT", roundedAmbientContext); | ||
| coreObj.setOutput("ambient_context", roundedAmbientContext); | ||
| coreObj.info(`Ambient context: ${roundedAmbientContext}`); | ||
| } | ||
| // Read from both the primary path and the audit path, deduplicating by request_id. | ||
| // The audit path may contain additional entries when the primary path is absent or | ||
| // partially written (e.g. the proxy was restarted mid-run). | ||
| const paths = [TOKEN_USAGE_AUDIT_PATH, TOKEN_USAGE_PATH]; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dedup gives audit-path data priority over the primary path — first-seen wins, so audit entries shadow primary entries when the same 💡 Detailsconst paths = [TOKEN_USAGE_AUDIT_PATH, TOKEN_USAGE_PATH];The docstring calls
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dedup gives audit-path data priority over the primary path — first-seen wins, so the audit copy shadows the primary copy when the same 💡 Detailsconst paths = [TOKEN_USAGE_AUDIT_PATH, TOKEN_USAGE_PATH];The file-list docstring calls In normal operation both files should be byte-identical for the same request, so there is no practical difference. But if the primary path ever has a corrected/re-emitted record for a Either:
|
||
| const seenRequestIds = new Set(); | ||
| const dedupedLines = []; | ||
|
|
||
| for (const filePath of paths) { | ||
| if (!fs.existsSync(filePath)) { | ||
| coreObj.debug(`No token-usage.jsonl found at: ${filePath}`); | ||
| continue; | ||
| } | ||
| const raw = fs.readFileSync(filePath, "utf8"); | ||
| if (!raw?.trim()) continue; | ||
| coreObj.info(`Found token-usage.jsonl at ${filePath} (${raw.length} bytes)`); | ||
| for (const rawLine of raw.split("\n")) { | ||
| const line = rawLine.trim(); | ||
| if (!line) continue; | ||
| // Lightweight request_id extraction for deduplication — mirrors extractRequestId() in | ||
| // parse_token_usage.cjs. The pattern covers standard JSON escaping (\\, \") but not | ||
| // exotic cases; api-proxy request IDs are UUIDs so this is sufficient in practice. | ||
| const idMatch = line.match(/"request_id"\s*:\s*"((?:\\.|[^"\\])*)"/); | ||
| const dedupeKey = idMatch ? `request_id:${idMatch[1]}` : line; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnose] When The comment acknowledges "api-proxy request IDs are UUIDs so this is sufficient in practice", which is reasonable given current behavior. Worth adding an explicit |
||
| if (seenRequestIds.has(dedupeKey)) continue; | ||
| seenRequestIds.add(dedupeKey); | ||
| dedupedLines.push(line); | ||
| } | ||
| } | ||
|
|
||
| if (dedupedLines.length > 0) { | ||
| const content = dedupedLines.join("\n"); | ||
| const parsedSummary = parseTokenUsageJsonl(content); | ||
| if (parsedSummary && parsedSummary.totalAIC > 0) { | ||
| const roundedAIC = parsedSummary.totalAIC.toFixed(3); | ||
| coreObj.exportVariable("GH_AW_AIC", roundedAIC); | ||
| coreObj.setOutput("aic", roundedAIC); | ||
| coreObj.info(`AI Credits: ${roundedAIC}`); | ||
| } | ||
| if (parsedSummary && typeof parsedSummary.ambientContextTokens === "number" && parsedSummary.ambientContextTokens > 0) { | ||
| const roundedAmbientContext = String(Math.round(parsedSummary.ambientContextTokens)); | ||
| coreObj.exportVariable("GH_AW_AMBIENT_CONTEXT", roundedAmbientContext); | ||
| coreObj.setOutput("ambient_context", roundedAmbientContext); | ||
| coreObj.info(`Ambient context: ${roundedAmbientContext}`); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1126,6 +1152,8 @@ if (typeof module !== "undefined" && module.exports) { | |
| hasAICreditsRateLimitError, | ||
| hasUnknownModelAICreditsError, | ||
| setUnknownModelAICreditsOutput, | ||
| TOKEN_USAGE_PATH, | ||
| TOKEN_USAGE_AUDIT_PATH, | ||
| }; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/tdd] The new
ai_credits_this_responsebranch insumAICFromUsageJSONLFileshas no test coverage. The existing test incheck_daily_aic_workflow_guardrail.test.cjsexercisesai_credits,aiCredits, andaicaliases but not this new field — so a future regression here would go undetected.💡 Suggested test addition (check_daily_aic_workflow_guardrail.test.cjs)