Repository navigation
Normalize unified agent session metadata and AIC accounting #65344
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 5 commits
23e9e7d
ebbd670
9607c61
e1d78fb
e1fd0a4
f5d8d90
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,13 +30,15 @@ function isMetric(value) { | |
| function normalizeSessionUsage(usage) { | ||
| if (!usage || typeof usage !== "object" || Array.isArray(usage)) return undefined; | ||
| const result = { ...usage }; | ||
| delete result.overflowedTokens; | ||
| for (const [key, alias] of Object.entries(USAGE_ALIASES)) { | ||
| const value = Object.hasOwn(usage, key) ? usage[key] : usage[alias]; | ||
| if (isTokenCount(value)) result[key] = value; | ||
| else delete result[key]; | ||
| } | ||
| if (Array.isArray(usage.overflowed_tokens)) { | ||
| result.overflowed_tokens = [...new Set(usage.overflowed_tokens.filter(key => typeof key === "string" && Object.hasOwn(USAGE_ALIASES, key)))]; | ||
| const overflowed = usage.overflowedTokens ?? usage.overflowed_tokens; | ||
|
Copilot marked this conversation as resolved.
Resolved
|
||
| if (Array.isArray(overflowed)) { | ||
| result.overflowed_tokens = [...new Set(overflowed.map(key => Object.entries(USAGE_ALIASES).find(([name, alias]) => key === name || key === alias)?.[0]).filter(Boolean))]; | ||
| for (const key of result.overflowed_tokens) { | ||
| delete result[key]; | ||
| delete result[USAGE_ALIASES[key]]; | ||
|
|
@@ -335,10 +337,14 @@ function transformFlatSessionEntries(records) { | |
| /** @param {any} usage @returns {number|undefined} */ | ||
| function sessionTokenTotal(usage) { | ||
| if (!usage) return undefined; | ||
| if (isTokenCount(usage.total_tokens)) return usage.total_tokens; | ||
| if (!isTokenCount(usage.input_tokens) && !isTokenCount(usage.output_tokens)) return undefined; | ||
| const cache = usage.input_tokens_include_cache === false ? (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) : 0; | ||
| const total = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0) + cache; | ||
| const totalTokens = usage.totalTokens ?? usage.total_tokens; | ||
| const inputTokens = usage.inputTokens ?? usage.input_tokens; | ||
| const outputTokens = usage.outputTokens ?? usage.output_tokens; | ||
| if (isTokenCount(totalTokens)) return totalTokens; | ||
| if (!isTokenCount(inputTokens) && !isTokenCount(outputTokens)) return undefined; | ||
| const cache = | ||
| (usage.inputTokensIncludeCache ?? usage.input_tokens_include_cache) === false ? (usage.cacheCreationInputTokens ?? usage.cache_creation_input_tokens ?? 0) + (usage.cacheReadInputTokens ?? usage.cache_read_input_tokens ?? 0) : 0; | ||
| const total = (inputTokens ?? 0) + (outputTokens ?? 0) + cache; | ||
| return isTokenCount(total) ? total : undefined; | ||
|
Contributor
Author
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. Fixed in |
||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,7 @@ const { unfenceMarkdown } = require("./markdown_unfencing.cjs"); | |
| const { ERR_PARSE } = require("./error_codes.cjs"); | ||
| const createLogParserFormatters = require("./log_parser_format.cjs"); | ||
| const { buildStepSummaryDetailsSection } = require("./log_parser_step_summary_builder.cjs"); | ||
| const { isSessionEvent, normalizeAgentSession, projectSessionResult, projectSessionInitialization, sessionOutputText, sessionToolSuccess, sessionTokenTotal, isMetric } = require("./agent_session.cjs"); | ||
| const { isSessionEvent, normalizeAgentSession, normalizeSessionUsage, projectSessionResult, projectSessionInitialization, sessionOutputText, sessionToolSuccess, sessionTokenTotal, isMetric } = require("./agent_session.cjs"); | ||
| const { escapeSummaryText, toolInventoryName, displayArgument } = require("./agent_session_render.cjs"); | ||
|
|
||
| /** | ||
|
|
@@ -313,7 +313,7 @@ function generateInformationSection(lastEntry, options = {}) { | |
| } | ||
|
|
||
| if (lastEntry.usage) { | ||
| const usage = lastEntry.usage; | ||
| const usage = normalizeSessionUsage(lastEntry.usage) ?? lastEntry.usage; | ||
|
Contributor
Author
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. Fixed in |
||
| const totalTokens = sessionTokenTotal(usage); | ||
| if (totalTokens !== undefined || usage.cache_creation_input_tokens !== undefined || usage.cache_read_input_tokens !== undefined) { | ||
| markdown += `**Token Usage:**\n`; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -91,6 +91,7 @@ describe("Unified conclusion session", () => { | |
| engine_id: "copilot", | ||
| agent_version: "v2.3.4", | ||
| model: "fixture", | ||
| event_name: "workflow_dispatch", | ||
| secret: "omit", | ||
| }); | ||
| const { events } = collectUnifiedSession({ rootDir: root }); | ||
|
|
@@ -101,14 +102,44 @@ describe("Unified conclusion session", () => { | |
| mcpgVersion: "v0.6.7", | ||
| engineId: "copilot", | ||
| agentVersion: "v2.3.4", | ||
| engine: "copilot", | ||
| model: "fixture", | ||
| requestedModel: "fixture", | ||
| triggerType: "workflow_dispatch", | ||
| }, | ||
| provenance: { path: "aw_info.json", component: "workflow" }, | ||
| }); | ||
| expect(events.find(event => event.type === "workflow.info").data).not.toHaveProperty("secret"); | ||
| }); | ||
|
|
||
|
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. [/tdd] New test covers the happy paths for 💡 Suggested additional cases// cache-only usage should still compute AIC
write("evals/evals_token_usage.jsonl", [
{ provider: "openai", model: "gpt-4o-mini", cache_read_input_tokens: 500 },
]);
// negative/non-finite tokens should be rejected, not silently coerced
write("threat-detection/detection_usage.jsonl", [
{ provider: "openai", model: "gpt-4o-mini", input_tokens: -5 },
]);Without these, a regression in the @copilot please address this.
Contributor
Author
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. Added in |
||
| it("resolves detection and eval AIC per observation without adding overlapping checkpoints", () => { | ||
| write("threat-detection/detection_usage.jsonl", [ | ||
| { provider: "openai", model: "gpt-4o-mini", input_tokens: 1000, output_tokens: 100 }, | ||
| { provider: "openai", model: "unknown-model", input_tokens: 10 }, | ||
| { provider: "openai", model: "gpt-4o-mini", ai_credits: 0, input_tokens: 100 }, | ||
| { usage: { provider: "openai", model: "gpt-4o-mini", ai_credits: 0, input_tokens: 100 } }, | ||
| ]); | ||
| write("evals/evals_token_usage.jsonl", [ | ||
| { provider: "openai", model: "gpt-4o-mini", ai_credits_this_response: 0.25, input_tokens: 100 }, | ||
| { provider: "openai", model: "gpt-4o-mini", input_tokens: 100, output_tokens: 0 }, | ||
| ]); | ||
| const { events } = collectUnifiedSession({ rootDir: root }); | ||
| const detection = events.filter(event => event.type === "usage.report" && event.provenance.phase === "detection"); | ||
| const evals = events.filter(event => event.type === "usage.report" && event.provenance.phase === "evals"); | ||
| expect(detection[0].data.aic).toBeGreaterThan(0); | ||
| expect(detection[0].data.usage).toEqual({ inputTokens: 1000, outputTokens: 100 }); | ||
| expect(detection[1].data).not.toHaveProperty("aic"); | ||
| expect(detection[2].data).toMatchObject({ totalAic: 0 }); | ||
| expect(detection[2].data).not.toHaveProperty("aic"); | ||
| expect(detection[3].data).toMatchObject({ provider: "openai", model: "gpt-4o-mini", totalAic: 0, usage: { inputTokens: 100 } }); | ||
| expect(detection[3].data).not.toHaveProperty("aic"); | ||
| expect(evals[0].data).toMatchObject({ aic: 0.25 }); | ||
| expect(evals[1].data.aic).toBeGreaterThan(0); | ||
| expect(evals[1].data.usage.outputTokens).toBe(0); | ||
| for (const event of [...detection, ...evals]) { | ||
| expect(Object.keys(event.data).every(key => !key.includes("_"))).toBe(true); | ||
| expect(Object.keys(event.data.usage ?? {}).every(key => !key.includes("_"))).toBe(true); | ||
| } | ||
| }); | ||
|
|
||
| it("retains native provenance, equal-time source order, and invalid/absent timestamps without mutation", () => { | ||
| const sources = [ | ||
| { | ||
|
|
@@ -184,7 +215,7 @@ describe("Unified conclusion session", () => { | |
| status: "completed", | ||
| sourceType: "turn.completed", | ||
| numTurns: 1, | ||
| usage: { input_tokens: 16847, output_tokens: 167, cache_read_input_tokens: 8576, cache_creation_input_tokens: 0, reasoning_output_tokens: 0 }, | ||
| usage: { inputTokens: 16847, outputTokens: 167, cacheReadInputTokens: 8576, cacheCreationInputTokens: 0, reasoningOutputTokens: 0 }, | ||
| }); | ||
| expect(agent.at(-1)).not.toHaveProperty("usage"); | ||
| expect(agent.every(event => event.provenance.path === (canonicalPresent ? "agent-session.jsonl" : "agent-stdio.log"))).toBe(true); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,17 +2,18 @@ | |
|
|
||
| /** @typedef {import("./types/agent_session").SessionEvent} SessionEvent */ | ||
| /** @typedef {Record<string, string[]>} Fields */ | ||
|
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. 🤖 Smoke test review comment (Run 37138576096): Good use of named imports from
Contributor
Author
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. No change was needed in |
||
| const { computeInferenceAIC, findModelPricing } = require("./model_costs.cjs"); | ||
|
|
||
| /** @type {Fields} */ | ||
| const USAGE_FIELDS = { | ||
| total_tokens: ["total_tokens", "totalTokens"], | ||
| input_tokens: ["input_tokens", "inputTokens"], | ||
| output_tokens: ["output_tokens", "outputTokens"], | ||
| reasoning_output_tokens: ["reasoning_output_tokens"], | ||
| cache_read_input_tokens: ["cache_read_input_tokens", "cacheReadInputTokens", "cache_read_tokens"], | ||
| cache_creation_input_tokens: ["cache_creation_input_tokens", "cacheCreationInputTokens", "cache_write_tokens"], | ||
| input_tokens_include_cache: ["input_tokens_include_cache"], | ||
| overflowed_tokens: ["overflowed_tokens"], | ||
| totalTokens: ["totalTokens", "total_tokens"], | ||
| inputTokens: ["inputTokens", "input_tokens"], | ||
| outputTokens: ["outputTokens", "output_tokens"], | ||
| reasoningOutputTokens: ["reasoningOutputTokens", "reasoning_output_tokens", "reasoning_tokens"], | ||
| cacheReadInputTokens: ["cacheReadInputTokens", "cache_read_input_tokens", "cache_read_tokens"], | ||
| cacheCreationInputTokens: ["cacheCreationInputTokens", "cache_creation_input_tokens", "cache_write_tokens"], | ||
| inputTokensIncludeCache: ["inputTokensIncludeCache", "input_tokens_include_cache"], | ||
| overflowedTokens: ["overflowedTokens", "overflowed_tokens"], | ||
| }; | ||
|
|
||
| /** @type {Fields} */ | ||
|
|
@@ -121,8 +122,8 @@ const EVENT_FIELDS = { | |
| model: ["model"], | ||
| requestId: ["requestId", "request_id"], | ||
| status: ["status"], | ||
| aic: ["aic", "ai_credits_this_response"], | ||
| totalAic: ["totalAic", "ai_credits_total", "ai_credits"], | ||
| aic: ["aic", "ai_credits_this_response", "aiCreditsThisResponse"], | ||
| totalAic: ["totalAic", "ai_credits_total", "ai_credits", "aiCredits"], | ||
|
Contributor
Author
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. Fixed in |
||
| premiumRequests: ["premiumRequests", "premium_requests"], | ||
| durationMs: ["durationMs", "duration_ms"], | ||
| }, | ||
|
|
@@ -143,13 +144,13 @@ const EVENT_FIELDS = { | |
| maliciousPatch: ["maliciousPatch", "malicious_patch"], | ||
| }, | ||
| "workflow.info": { | ||
| engine: ["engine", "engine_id"], | ||
| engineId: ["engineId", "engine_id"], | ||
| engineId: ["engineId", "engine_id", "engine"], | ||
| agentVersion: ["agentVersion", "agent_version"], | ||
| cliVersion: ["cliVersion", "cli_version"], | ||
| awfVersion: ["awfVersion", "awf_version"], | ||
| mcpgVersion: ["mcpgVersion", "awmg_version"], | ||
| model: ["model"], | ||
| mcpgVersion: ["mcpgVersion", "mcpg_version", "awmg_version"], | ||
| requestedModel: ["requestedModel", "model"], | ||
| triggerType: ["triggerType", "event_name"], | ||
| workflow: ["workflow", "workflow_name"], | ||
| repository: ["repository"], | ||
| runId: ["runId", "run_id"], | ||
|
|
@@ -173,13 +174,35 @@ function selectFields(value, fields) { | |
| return result; | ||
| } | ||
|
|
||
| /** @param {any} source @param {Record<string, any>} data @param {Record<string, any>} usage */ | ||
| function resolveUsageAic(source, data, usage) { | ||
| if (data.aic !== undefined || data.totalAic !== undefined) return; | ||
| const provider = data.provider ?? source.usage?.provider; | ||
| const model = data.model ?? source.usage?.model; | ||
| if (typeof provider !== "string" || typeof model !== "string" || !findModelPricing(provider, model)) return; | ||
| const tokens = [usage.inputTokens, usage.outputTokens, usage.cacheReadInputTokens, usage.cacheCreationInputTokens, usage.reasoningOutputTokens]; | ||
| if (!tokens.some(value => typeof value === "number" && value > 0) || tokens.some(value => value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || value < 0))) return; | ||
| const aic = computeInferenceAIC({ | ||
| provider, | ||
| model, | ||
| inputTokens: usage.inputTokens ?? 0, | ||
| outputTokens: usage.outputTokens ?? 0, | ||
| cacheReadTokens: usage.cacheReadInputTokens ?? 0, | ||
| cacheWriteTokens: usage.cacheCreationInputTokens ?? 0, | ||
| reasoningTokens: usage.reasoningOutputTokens ?? 0, | ||
| ...(typeof usage.inputTokensIncludeCache === "boolean" ? { inputTokensIncludeCache: usage.inputTokensIncludeCache } : {}), | ||
| }); | ||
| if (Number.isFinite(aic)) data.aic = aic; | ||
| } | ||
|
|
||
| /** | ||
| * Compact known payloads without truncating text or changing native parser traces. | ||
| * Unknown extension data remains opaque for forward compatibility. | ||
| * @param {SessionEvent} event | ||
| * @param {string} [phase] | ||
| * @returns {SessionEvent} | ||
| */ | ||
| function normalizeUnifiedSessionEvent(event) { | ||
| function normalizeUnifiedSessionEvent(event, phase) { | ||
| /** @type {any} */ | ||
| const source = event.data; | ||
| const known = Object.hasOwn(EVENT_FIELDS, event.type); | ||
|
|
@@ -193,8 +216,19 @@ function normalizeUnifiedSessionEvent(event) { | |
| } | ||
| if (["session.result", "firewall.token_usage", "usage.report"].includes(event.type)) { | ||
| const usage = selectFields(event.type === "session.result" ? source.usage : (source.usage ?? source), USAGE_FIELDS); | ||
| if (Array.isArray(usage.overflowedTokens)) { | ||
| const aliases = Object.entries(USAGE_FIELDS); | ||
| usage.overflowedTokens = usage.overflowedTokens.map(name => aliases.find(([, names]) => names.includes(name))?.[0] ?? name); | ||
| } | ||
| if (Object.keys(usage).length || (source.usage && typeof source.usage === "object")) data.usage = usage; | ||
| else if (source.usage === null) data.usage = null; | ||
| if (event.type === "usage.report" && source.usage && typeof source.usage === "object") { | ||
| const nested = selectFields(source.usage, EVENT_FIELDS["usage.report"]); | ||
| for (const key of ["provider", "model", "aic", "totalAic", "premiumRequests"]) { | ||
| if (data[key] === undefined && nested[key] !== undefined) data[key] = nested[key]; | ||
| } | ||
| } | ||
| if (event.type === "usage.report" && (phase === "detection" || phase === "evals")) resolveUsageAic(source, data, usage); | ||
| } | ||
| if (event.type === "tool.execution_complete" && (source.is_error === true || source.result?.isError === true || source.result?.is_error === true)) data.isError = true; | ||
| if (event.type === "experiment.assignment") { | ||
|
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. [/codebase-design] 💡 SuggestionIf a future phase (e.g. a new eval/detection source, or @copilot please address this.
Contributor
Author
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. Fixed in |
||
|
|
||
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.
🤖 Smoke test review comment (Run 37138576096): The
normalizeSessionUsagefunction correctly handles the object spread — consider adding a comment explaining why arrays are explicitly excluded.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.
Added a concise explanation in
f5d8d90: usage is a named-field object, so arrays cannot represent its token fields.