Skip to content

Commit 428d309

Browse files
Copilotgh-aw-bot
andauthored
Address AIC review feedback
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
1 parent 11d818d commit 428d309

14 files changed

Lines changed: 503 additions & 206 deletions

.github/workflows/pr-code-quality-reviewer.lock.yml

Lines changed: 175 additions & 94 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/pr-code-quality-reviewer.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,8 @@ on:
1616
name: review
1717
events: [pull_request_comment, pull_request_review_comment]
1818
engine:
19-
id: pi
20-
model-provider: openai
21-
model: openai/gpt-5.4
19+
id: copilot
20+
model: copilot/gpt-5.4
2221
permissions:
2322
contents: read
2423
issues: read

actions/setup/js/parse_mcp_gateway_log.cjs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,10 @@ function parseNonNegativeFiniteNumber(value) {
8787
* @returns {string}
8888
*/
8989
function formatAICForOutput(value, source) {
90-
if (!Number.isFinite(value) || value <= 0) return "";
90+
if (!Number.isFinite(value) || value < 0) return "";
9191
if (source !== "awf_reported") return value.toFixed(3);
9292
const rounded = Number(value.toFixed(6));
93-
return String(rounded > 0 ? rounded : value);
93+
return String(rounded);
9494
}
9595

9696
/**
@@ -235,12 +235,10 @@ function parseTokenUsageJsonl(jsonlContent) {
235235
const hasReportedAIC = summary.entries.some(entry => entry.reportedDeltaAIC !== null || entry.reportedTotalAIC !== null);
236236
const hasAnyReportedAICFields = summary.entries.some(entry => entry.hasReportedDeltaField || entry.hasReportedTotalField);
237237
const hasExplicitCacheSemantics = summary.entries.some(entry => typeof entry.inputTokensIncludeCache === "boolean");
238-
const invalidCacheSemanticsCount = summary.entries.filter(entry => entry.hasInputTokensIncludeCacheField && typeof entry.inputTokensIncludeCache !== "boolean").length;
239-
if (invalidCacheSemanticsCount > 0) {
240-
summary.aiCreditsWarnings.push(`${invalidCacheSemanticsCount} token usage record(s) had invalid input_tokens_include_cache values; legacy provider cache semantics were used.`);
241-
}
238+
let invalidCacheSemanticsCount = 0;
242239

243240
if (!hasAnyReportedAICFields && !hasExplicitCacheSemantics) {
241+
invalidCacheSemanticsCount = summary.entries.filter(entry => entry.hasInputTokensIncludeCacheField && typeof entry.inputTokensIncludeCache !== "boolean").length;
244242
// Preserve the legacy aggregation contract exactly for records emitted before
245243
// AWF added reported AIC and explicit cache-semantics fields.
246244
let totalAIC = 0;
@@ -294,9 +292,13 @@ function parseTokenUsageJsonl(jsonlContent) {
294292
const reportedFieldsMissingOrInvalid = hasAnyReportedAICFields && (!entry.hasReportedDeltaField || entry.reportedDeltaAIC === null || !entry.hasReportedTotalField || entry.reportedTotalAIC === null);
295293
if (reportedFieldsMissingOrInvalid) fallbackRecordCount++;
296294

297-
entry.deltaAIC =
298-
entry.reportedDeltaAIC ??
299-
computeInferenceAIC({
295+
if (entry.reportedDeltaAIC !== null) {
296+
entry.deltaAIC = entry.reportedDeltaAIC;
297+
} else {
298+
if (entry.hasInputTokensIncludeCacheField && typeof entry.inputTokensIncludeCache !== "boolean") {
299+
invalidCacheSemanticsCount++;
300+
}
301+
entry.deltaAIC = computeInferenceAIC({
300302
provider: entry.provider || "",
301303
model: entry.model,
302304
inputTokens: entry.inputTokens,
@@ -306,6 +308,7 @@ function parseTokenUsageJsonl(jsonlContent) {
306308
reasoningTokens: entry.reasoningTokens || 0,
307309
inputTokensIncludeCache: entry.inputTokensIncludeCache,
308310
});
311+
}
309312
summary.byModel[entry.model].aic += entry.deltaAIC;
310313
runningAIC = entry.reportedTotalAIC ?? runningAIC + entry.deltaAIC;
311314
entry.runningAIC = runningAIC;
@@ -321,6 +324,9 @@ function parseTokenUsageJsonl(jsonlContent) {
321324
summary.aiCreditsWarnings.push("The AWF-reported cumulative AI Credits total differs from the sum of per-request credits; the cumulative total was preserved for reporting.");
322325
}
323326
}
327+
if (invalidCacheSemanticsCount > 0) {
328+
summary.aiCreditsWarnings.push(`${invalidCacheSemanticsCount} token usage record(s) had invalid input_tokens_include_cache values; legacy provider cache semantics were used.`);
329+
}
324330

325331
return summary;
326332
}
@@ -396,7 +402,7 @@ async function writeStepSummaryWithTokenUsage(coreObj) {
396402
for (const warning of parsedSummary?.aiCreditsWarnings || []) {
397403
coreObj.warning?.(`[ai-credits] ${warning}`);
398404
}
399-
if (parsedSummary && parsedSummary.totalAIC > 0) {
405+
if (parsedSummary && (parsedSummary.aiCreditsSource === "awf_reported" || parsedSummary.totalAIC > 0)) {
400406
const aic = formatAICForOutput(parsedSummary.totalAIC, parsedSummary.aiCreditsSource);
401407
coreObj.exportVariable("GH_AW_AIC", aic);
402408
coreObj.setOutput("aic", aic);

actions/setup/js/parse_mcp_gateway_log.test.cjs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1999,6 +1999,45 @@ Some content here.`;
19991999
expect(summary.entries[0].deltaAIC).toBe(0);
20002000
expect(summary.totalAIC).toBe(0);
20012001
expect(summary.aiCreditsWarnings).toEqual([]);
2002+
expect(generateTokenUsageSummary(summary)).toContain("| **Total** | | **1,000** | **100** | **0** | **0** | | **0** |");
2003+
});
2004+
2005+
test("exports AWF-reported zero to the main job output", async () => {
2006+
const tokenUsagePath = "/tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl";
2007+
const content = JSON.stringify({
2008+
provider: "copilot",
2009+
model: "gpt-4o-mini-2024-07-18",
2010+
input_tokens: 1000,
2011+
output_tokens: 100,
2012+
ai_credits_this_response: 0,
2013+
ai_credits_total: 0,
2014+
});
2015+
const existsSpy = vi.spyOn(fs, "existsSync").mockImplementation(filePath => filePath === tokenUsagePath);
2016+
const readSpy = vi.spyOn(fs, "readFileSync").mockImplementation(filePath => {
2017+
if (filePath === tokenUsagePath) return content;
2018+
return "";
2019+
});
2020+
const coreObj = {
2021+
debug: vi.fn(),
2022+
info: vi.fn(),
2023+
exportVariable: vi.fn(),
2024+
setOutput: vi.fn(),
2025+
summary: {
2026+
addRaw: vi.fn(),
2027+
write: vi.fn().mockResolvedValue(undefined),
2028+
},
2029+
};
2030+
2031+
try {
2032+
await writeStepSummaryWithTokenUsage(coreObj);
2033+
} finally {
2034+
existsSpy.mockRestore();
2035+
readSpy.mockRestore();
2036+
}
2037+
2038+
expect(coreObj.exportVariable).toHaveBeenCalledWith("GH_AW_AIC", "0");
2039+
expect(coreObj.setOutput).toHaveBeenCalledWith("aic", "0");
2040+
expect(coreObj.info).toHaveBeenCalledWith("AI Credits: 0");
20022041
});
20032042

20042043
test("aggregates AWF-reported credits by model without changing the run total", () => {
@@ -2083,6 +2122,25 @@ Some content here.`;
20832122
expect(summary.aiCreditsWarnings).toEqual([expect.stringContaining("invalid input_tokens_include_cache")]);
20842123
});
20852124

2125+
test("does not warn for invalid input_tokens_include_cache when AWF delta is valid", () => {
2126+
const summary = parseTokenUsageJsonl(
2127+
JSON.stringify({
2128+
provider: "copilot",
2129+
model: "gpt-4o-mini-2024-07-18",
2130+
input_tokens: 1000,
2131+
output_tokens: 100,
2132+
cache_read_tokens: 400,
2133+
cache_write_tokens: 100,
2134+
input_tokens_include_cache: "invalid",
2135+
ai_credits_this_response: 0.123,
2136+
ai_credits_total: 0.123,
2137+
})
2138+
);
2139+
2140+
expect(summary.totalAIC).toBe(0.123);
2141+
expect(summary.aiCreditsWarnings).toEqual([]);
2142+
});
2143+
20862144
test("uses the chronologically last valid AWF-reported total", () => {
20872145
const content = [
20882146
{

actions/setup/js/parse_token_usage.cjs

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,19 @@ function getReadableTokenUsagePaths(paths) {
4545
}
4646

4747
/**
48-
* Extracts request_id with lightweight matching (no full JSON parse).
48+
* Extracts a cross-file dedupe key with lightweight matching (no full JSON parse).
4949
* @param {string} line
5050
* @returns {string}
5151
*/
52-
function extractRequestId(line) {
53-
const match = line.match(/"request_id"\s*:\s*"((?:\\.|[^"\\])*)"/);
54-
return match ? match[1] : "";
52+
function extractTokenUsageDedupeKey(line) {
53+
const requestMatch = line.match(/"request_id"\s*:\s*"((?:\\.|[^"\\])*)"/);
54+
if (!requestMatch) return "";
55+
const eventMatch = line.match(/"event"\s*:\s*"((?:\\.|[^"\\])*)"/);
56+
return `${eventMatch ? eventMatch[1] : "token_usage"}:${requestMatch[1]}`;
5557
}
5658

5759
/**
58-
* Reads token usage files and deduplicates overlapping lines by request_id.
60+
* Reads token usage files and deduplicates overlapping lines by event and request_id.
5961
* Falls back to raw line dedupe when request_id is absent.
6062
* @param {string[]} paths
6163
* @returns {string}
@@ -76,8 +78,7 @@ function readDedupedTokenUsage(paths) {
7678
for (const line of fileContent.split("\n")) {
7779
const trimmed = line.trim();
7880
if (!trimmed) continue;
79-
const requestId = extractRequestId(trimmed);
80-
const dedupeKey = requestId ? `request_id:${requestId}` : trimmed;
81+
const dedupeKey = extractTokenUsageDedupeKey(trimmed) || trimmed;
8182
if (uniqueLineKeys.has(dedupeKey)) continue;
8283
uniqueLineKeys.add(dedupeKey);
8384
dedupedLines.push(trimmed);
@@ -247,7 +248,7 @@ async function main() {
247248
core.setOutput("primary_model", primaryModel);
248249
core.info(`Primary model: ${primaryModel}`);
249250
}
250-
if (summary.totalAIC > 0) {
251+
if (summary.aiCreditsSource === "awf_reported" || summary.totalAIC > 0) {
251252
const aic = formatAICForOutput(summary.totalAIC, summary.aiCreditsSource);
252253
core.exportVariable("GH_AW_AIC", aic);
253254
core.setOutput("aic", aic);
@@ -269,7 +270,11 @@ if (typeof module !== "undefined" && module.exports) {
269270
module.exports = {
270271
main,
271272
getReadableTokenUsagePaths,
272-
extractRequestId,
273+
extractRequestId: line => {
274+
const key = extractTokenUsageDedupeKey(line);
275+
return key ? key.slice(key.indexOf(":") + 1) : "";
276+
},
277+
extractTokenUsageDedupeKey,
273278
readDedupedTokenUsage,
274279
getSummaryTitle,
275280
buildStepSummarySection,

actions/setup/js/parse_token_usage.test.cjs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const {
99
main,
1010
getReadableTokenUsagePaths,
1111
extractRequestId,
12+
extractTokenUsageDedupeKey,
1213
readDedupedTokenUsage,
1314
getSummaryTitle,
1415
buildStepSummarySection,
@@ -356,6 +357,48 @@ describe("parse_token_usage", () => {
356357
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("1.03602"));
357358
});
358359

360+
test("exports AWF-reported zero AIC instead of treating it as missing", async () => {
361+
const agentUsageFile = path.join(tmpDir, "agent_usage.json");
362+
const zeroEntry = JSON.stringify({
363+
model: "gpt-4o-mini-2024-07-18",
364+
provider: "copilot",
365+
input_tokens: 1000,
366+
output_tokens: 100,
367+
ai_credits_this_response: 0,
368+
ai_credits_total: 0,
369+
});
370+
371+
fs.existsSync = vi.fn(p => {
372+
if (p === TOKEN_USAGE_PATH) return true;
373+
if (p === TOKEN_USAGE_AUDIT_PATH || p === TOKEN_USAGE_AWF_AUDIT_PATH) return false;
374+
return originalExistsSync(p);
375+
});
376+
fs.statSync = vi.fn(p => {
377+
if (p === TOKEN_USAGE_PATH) return { size: zeroEntry.length };
378+
if (p === TOKEN_USAGE_AUDIT_PATH || p === TOKEN_USAGE_AWF_AUDIT_PATH) return { size: 0 };
379+
return originalStatSync(p);
380+
});
381+
fs.readFileSync = vi.fn((p, enc) => {
382+
if (p === TOKEN_USAGE_PATH) return zeroEntry;
383+
if (p === TOKEN_USAGE_AUDIT_PATH || p === TOKEN_USAGE_AWF_AUDIT_PATH) return "";
384+
return originalReadFileSync(p, enc);
385+
});
386+
fs.writeFileSync = vi.fn((p, data) => {
387+
if (p === AGENT_USAGE_PATH) {
388+
originalWriteFileSync(agentUsageFile, data);
389+
} else {
390+
originalWriteFileSync(p, data);
391+
}
392+
});
393+
394+
await main();
395+
396+
const agentUsage = JSON.parse(originalReadFileSync(agentUsageFile, "utf8"));
397+
expect(agentUsage.ai_credits).toBe(0);
398+
expect(mockCore.exportVariable).toHaveBeenCalledWith("GH_AW_AIC", "0");
399+
expect(mockCore.setOutput).toHaveBeenCalledWith("aic", "0");
400+
});
401+
359402
test("surfaces fallback accounting warnings", async () => {
360403
const malformedEntry = JSON.stringify({
361404
model: "gpt-4o-mini-2024-07-18",
@@ -589,6 +632,13 @@ describe("parse_token_usage", () => {
589632
expect(extractRequestId('{"model":"m"}')).toBe("");
590633
});
591634

635+
test("extractTokenUsageDedupeKey includes event and request_id", () => {
636+
expect(extractTokenUsageDedupeKey('{"event":"token_usage","request_id":"req-123","model":"m"}')).toBe("token_usage:req-123");
637+
expect(extractTokenUsageDedupeKey('{"event":"other","request_id":"req-123","model":"m"}')).toBe("other:req-123");
638+
expect(extractTokenUsageDedupeKey('{"request_id":"req-123","model":"m"}')).toBe("token_usage:req-123");
639+
expect(extractTokenUsageDedupeKey('{"model":"m"}')).toBe("");
640+
});
641+
592642
test("getReadableTokenUsagePaths skips failing stat path and keeps valid path", () => {
593643
fs.existsSync = vi.fn(p => p === TOKEN_USAGE_AUDIT_PATH || p === TOKEN_USAGE_PATH);
594644
fs.statSync = vi.fn(p => {
@@ -618,6 +668,22 @@ describe("parse_token_usage", () => {
618668
expect(deduped.match(/"request_id":"req-1"/g)).toHaveLength(1);
619669
});
620670

671+
test("readDedupedTokenUsage keeps different events with the same request_id", () => {
672+
const fileA = '{"event":"token_usage","request_id":"req-1","model":"m1","input_tokens":1}';
673+
const fileB = '{"event":"token_steering","request_id":"req-1","model":"m1","input_tokens":2}';
674+
675+
fs.readFileSync = vi.fn(p => {
676+
if (p === TOKEN_USAGE_AUDIT_PATH) return fileA;
677+
if (p === TOKEN_USAGE_PATH) return fileB;
678+
return originalReadFileSync(p, "utf8");
679+
});
680+
681+
const deduped = readDedupedTokenUsage([TOKEN_USAGE_AUDIT_PATH, TOKEN_USAGE_PATH]);
682+
expect(deduped).toContain('"event":"token_usage"');
683+
expect(deduped).toContain('"event":"token_steering"');
684+
expect(deduped.match(/"request_id":"req-1"/g)).toHaveLength(2);
685+
});
686+
621687
test("deduplicates mirrored AWF records before aggregating reported credits", () => {
622688
const fixture = originalReadFileSync(path.join(__dirname, "fixtures", "awf-v0.28.7-aic-token-usage.jsonl"), "utf8");
623689
fs.readFileSync = vi.fn(p => {

docs/src/content/docs/reference/artifacts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ The unified `agent` artifact contains agent job outputs:
148148
- Agent execution logs
149149
- Safe output data (`agent_output.json`)
150150
- GitHub API rate limit logs (`github_rate_limits.jsonl`)
151-
- Token usage summary (`agent_usage.json`) — aggregated totals only; per-request data is in `firewall-audit-logs`. When AWF records include `ai_credits_this_response` and `ai_credits_total`, the summary preserves those reported values instead of repricing the tokens.
151+
- Token usage summary (`agent_usage.json`) — aggregated totals only; per-request data is in `firewall-audit-logs`. When AWF records include valid `ai_credits_this_response` and `ai_credits_total` values, the summary preserves those reported values instead of repricing the tokens.
152152
- `otel.jsonl` — OTLP span mirror written by gh-aw's JavaScript span exporters when `observability.otlp` is configured
153153

154154
For OTLP configuration, runtime environment variables, and span semantics, see the [OpenTelemetry guide](/gh-aw/reference/open-telemetry/).

pkg/cli/token_usage_agent_file.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,16 @@ func buildAgentModelTokenUsage(entry agentUsageEntry, provider string) *ModelTok
8080
}
8181

8282
func populateAgentUsageAIC(summary *TokenUsageSummary, entry agentUsageEntry, model, provider string, hasRawTokenData bool) {
83-
if entry.AICredits <= 0 {
83+
aic, present, valid := parseOptionalNonNegativeFloat(entry.AICredits)
84+
if !present || !valid {
8485
if hasRawTokenData {
8586
populateAIC(summary)
87+
summary.AICFound = summary.TotalAIC > 0
8688
}
8789
return
8890
}
89-
summary.TotalAIC = entry.AICredits
91+
summary.TotalAIC = aic
92+
summary.AICFound = true
9093
if summary.ByModel[model] == nil {
9194
summary.ByModel[model] = &ModelTokenUsage{}
9295
}
@@ -97,5 +100,5 @@ func populateAgentUsageAIC(summary *TokenUsageSummary, entry agentUsageEntry, mo
97100
usage.CacheReadTokens = entry.CacheReadTokens
98101
usage.CacheWriteTokens = entry.CacheWriteTokens
99102
usage.ReasoningTokens = entry.ReasoningTokens
100-
usage.AIC = entry.AICredits
103+
usage.AIC = aic
101104
}

0 commit comments

Comments
 (0)