mirror of
https://github.com/openai/codex.git
synced 2026-06-02 19:31:59 +00:00
Compare commits
3 Commits
starr/read
...
fcoury/usa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
138376e6e1 | ||
|
|
55d98b262b | ||
|
|
afafcc98ec |
@@ -4030,6 +4030,24 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UsageRange": {
|
||||
"enum": [
|
||||
"day",
|
||||
"week"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"UsageReadParams": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"$ref": "#/definitions/UsageRange"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UserInput": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -4829,6 +4847,30 @@
|
||||
"title": "Plugin/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"usage/read"
|
||||
],
|
||||
"title": "Usage/readRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/UsageReadParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Usage/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
@@ -829,6 +829,30 @@
|
||||
"title": "Plugin/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"usage/read"
|
||||
],
|
||||
"title": "Usage/readRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/UsageReadParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Usage/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -18555,6 +18579,177 @@
|
||||
"title": "TurnSteerResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"UsageContributorKind": {
|
||||
"enum": [
|
||||
"skill",
|
||||
"subagent",
|
||||
"agentTask",
|
||||
"app",
|
||||
"mcpServer",
|
||||
"plugin"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"UsageEntry": {
|
||||
"properties": {
|
||||
"attributedTokens": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/definitions/v2/UsageContributorKind"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"percentOfUsage": {
|
||||
"format": "uint8",
|
||||
"minimum": 0.0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"attributedTokens",
|
||||
"id",
|
||||
"kind",
|
||||
"label",
|
||||
"percentOfUsage"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UsageHeadline": {
|
||||
"properties": {
|
||||
"entry": {
|
||||
"$ref": "#/definitions/v2/UsageEntry"
|
||||
},
|
||||
"note": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"entry"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UsageRange": {
|
||||
"enum": [
|
||||
"day",
|
||||
"week"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"UsageReadParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"range": {
|
||||
"$ref": "#/definitions/v2/UsageRange"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"title": "UsageReadParams",
|
||||
"type": "object"
|
||||
},
|
||||
"UsageReadResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"report": {
|
||||
"$ref": "#/definitions/v2/UsageReport"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"report"
|
||||
],
|
||||
"title": "UsageReadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"UsageReport": {
|
||||
"properties": {
|
||||
"agentTasks": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"apps": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"generatedAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"headline": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/UsageHeadline"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"mcpServers": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"plugins": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"range": {
|
||||
"$ref": "#/definitions/v2/UsageRange"
|
||||
},
|
||||
"skills": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"subagents": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"totalTokens": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"trackedFrom": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agentTasks",
|
||||
"apps",
|
||||
"generatedAt",
|
||||
"mcpServers",
|
||||
"plugins",
|
||||
"range",
|
||||
"skills",
|
||||
"subagents",
|
||||
"totalTokens"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UserInput": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -1555,6 +1555,30 @@
|
||||
"title": "Plugin/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"usage/read"
|
||||
],
|
||||
"title": "Usage/readRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/UsageReadParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Usage/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -16379,6 +16403,177 @@
|
||||
"title": "TurnSteerResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"UsageContributorKind": {
|
||||
"enum": [
|
||||
"skill",
|
||||
"subagent",
|
||||
"agentTask",
|
||||
"app",
|
||||
"mcpServer",
|
||||
"plugin"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"UsageEntry": {
|
||||
"properties": {
|
||||
"attributedTokens": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/definitions/UsageContributorKind"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"percentOfUsage": {
|
||||
"format": "uint8",
|
||||
"minimum": 0.0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"attributedTokens",
|
||||
"id",
|
||||
"kind",
|
||||
"label",
|
||||
"percentOfUsage"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UsageHeadline": {
|
||||
"properties": {
|
||||
"entry": {
|
||||
"$ref": "#/definitions/UsageEntry"
|
||||
},
|
||||
"note": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"entry"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UsageRange": {
|
||||
"enum": [
|
||||
"day",
|
||||
"week"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"UsageReadParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"range": {
|
||||
"$ref": "#/definitions/UsageRange"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"title": "UsageReadParams",
|
||||
"type": "object"
|
||||
},
|
||||
"UsageReadResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"report": {
|
||||
"$ref": "#/definitions/UsageReport"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"report"
|
||||
],
|
||||
"title": "UsageReadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"UsageReport": {
|
||||
"properties": {
|
||||
"agentTasks": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"apps": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"generatedAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"headline": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UsageHeadline"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"mcpServers": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"plugins": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"range": {
|
||||
"$ref": "#/definitions/UsageRange"
|
||||
},
|
||||
"skills": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"subagents": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"totalTokens": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"trackedFrom": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agentTasks",
|
||||
"apps",
|
||||
"generatedAt",
|
||||
"mcpServers",
|
||||
"plugins",
|
||||
"range",
|
||||
"skills",
|
||||
"subagents",
|
||||
"totalTokens"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UserInput": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
22
codex-rs/app-server-protocol/schema/json/v2/UsageReadParams.json
generated
Normal file
22
codex-rs/app-server-protocol/schema/json/v2/UsageReadParams.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"UsageRange": {
|
||||
"enum": [
|
||||
"day",
|
||||
"week"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"range": {
|
||||
"$ref": "#/definitions/UsageRange"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"title": "UsageReadParams",
|
||||
"type": "object"
|
||||
}
|
||||
160
codex-rs/app-server-protocol/schema/json/v2/UsageReadResponse.json
generated
Normal file
160
codex-rs/app-server-protocol/schema/json/v2/UsageReadResponse.json
generated
Normal file
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"UsageContributorKind": {
|
||||
"enum": [
|
||||
"skill",
|
||||
"subagent",
|
||||
"agentTask",
|
||||
"app",
|
||||
"mcpServer",
|
||||
"plugin"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"UsageEntry": {
|
||||
"properties": {
|
||||
"attributedTokens": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/definitions/UsageContributorKind"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"percentOfUsage": {
|
||||
"format": "uint8",
|
||||
"minimum": 0.0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"attributedTokens",
|
||||
"id",
|
||||
"kind",
|
||||
"label",
|
||||
"percentOfUsage"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UsageHeadline": {
|
||||
"properties": {
|
||||
"entry": {
|
||||
"$ref": "#/definitions/UsageEntry"
|
||||
},
|
||||
"note": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"entry"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UsageRange": {
|
||||
"enum": [
|
||||
"day",
|
||||
"week"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"UsageReport": {
|
||||
"properties": {
|
||||
"agentTasks": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"apps": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"generatedAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"headline": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UsageHeadline"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"mcpServers": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"plugins": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"range": {
|
||||
"$ref": "#/definitions/UsageRange"
|
||||
},
|
||||
"skills": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"subagents": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/UsageEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"totalTokens": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"trackedFrom": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agentTasks",
|
||||
"apps",
|
||||
"generatedAt",
|
||||
"mcpServers",
|
||||
"plugins",
|
||||
"range",
|
||||
"skills",
|
||||
"subagents",
|
||||
"totalTokens"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"report": {
|
||||
"$ref": "#/definitions/UsageReport"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"report"
|
||||
],
|
||||
"title": "UsageReadResponse",
|
||||
"type": "object"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
5
codex-rs/app-server-protocol/schema/typescript/v2/UsageContributorKind.ts
generated
Normal file
5
codex-rs/app-server-protocol/schema/typescript/v2/UsageContributorKind.ts
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type UsageContributorKind = "skill" | "subagent" | "agentTask" | "app" | "mcpServer" | "plugin";
|
||||
6
codex-rs/app-server-protocol/schema/typescript/v2/UsageEntry.ts
generated
Normal file
6
codex-rs/app-server-protocol/schema/typescript/v2/UsageEntry.ts
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { UsageContributorKind } from "./UsageContributorKind";
|
||||
|
||||
export type UsageEntry = { kind: UsageContributorKind, id: string, label: string, attributedTokens: number, percentOfUsage: number, };
|
||||
6
codex-rs/app-server-protocol/schema/typescript/v2/UsageHeadline.ts
generated
Normal file
6
codex-rs/app-server-protocol/schema/typescript/v2/UsageHeadline.ts
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { UsageEntry } from "./UsageEntry";
|
||||
|
||||
export type UsageHeadline = { entry: UsageEntry, note: string | null, };
|
||||
5
codex-rs/app-server-protocol/schema/typescript/v2/UsageRange.ts
generated
Normal file
5
codex-rs/app-server-protocol/schema/typescript/v2/UsageRange.ts
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type UsageRange = "day" | "week";
|
||||
6
codex-rs/app-server-protocol/schema/typescript/v2/UsageReadParams.ts
generated
Normal file
6
codex-rs/app-server-protocol/schema/typescript/v2/UsageReadParams.ts
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { UsageRange } from "./UsageRange";
|
||||
|
||||
export type UsageReadParams = { range: UsageRange, };
|
||||
6
codex-rs/app-server-protocol/schema/typescript/v2/UsageReadResponse.ts
generated
Normal file
6
codex-rs/app-server-protocol/schema/typescript/v2/UsageReadResponse.ts
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { UsageReport } from "./UsageReport";
|
||||
|
||||
export type UsageReadResponse = { report: UsageReport, };
|
||||
8
codex-rs/app-server-protocol/schema/typescript/v2/UsageReport.ts
generated
Normal file
8
codex-rs/app-server-protocol/schema/typescript/v2/UsageReport.ts
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { UsageEntry } from "./UsageEntry";
|
||||
import type { UsageHeadline } from "./UsageHeadline";
|
||||
import type { UsageRange } from "./UsageRange";
|
||||
|
||||
export type UsageReport = { range: UsageRange, generatedAt: number, trackedFrom: number | null, totalTokens: number, headline: UsageHeadline | null, skills: Array<UsageEntry>, subagents: Array<UsageEntry>, agentTasks: Array<UsageEntry>, apps: Array<UsageEntry>, mcpServers: Array<UsageEntry>, plugins: Array<UsageEntry>, };
|
||||
@@ -448,6 +448,13 @@ export type { TurnStartedNotification } from "./TurnStartedNotification";
|
||||
export type { TurnStatus } from "./TurnStatus";
|
||||
export type { TurnSteerParams } from "./TurnSteerParams";
|
||||
export type { TurnSteerResponse } from "./TurnSteerResponse";
|
||||
export type { UsageContributorKind } from "./UsageContributorKind";
|
||||
export type { UsageEntry } from "./UsageEntry";
|
||||
export type { UsageHeadline } from "./UsageHeadline";
|
||||
export type { UsageRange } from "./UsageRange";
|
||||
export type { UsageReadParams } from "./UsageReadParams";
|
||||
export type { UsageReadResponse } from "./UsageReadResponse";
|
||||
export type { UsageReport } from "./UsageReport";
|
||||
export type { UserInput } from "./UserInput";
|
||||
export type { WarningNotification } from "./WarningNotification";
|
||||
export type { WebSearchAction } from "./WebSearchAction";
|
||||
|
||||
@@ -629,6 +629,11 @@ client_request_definitions! {
|
||||
serialization: None,
|
||||
response: v2::PluginListResponse,
|
||||
},
|
||||
UsageRead => "usage/read" {
|
||||
params: v2::UsageReadParams,
|
||||
serialization: None,
|
||||
response: v2::UsageReadResponse,
|
||||
},
|
||||
PluginInstalled => "plugin/installed" {
|
||||
params: v2::PluginInstalledParams,
|
||||
serialization: None,
|
||||
|
||||
@@ -24,6 +24,7 @@ mod review;
|
||||
mod thread;
|
||||
mod thread_data;
|
||||
mod turn;
|
||||
mod usage;
|
||||
mod windows_sandbox;
|
||||
|
||||
pub use account::*;
|
||||
@@ -51,6 +52,7 @@ pub use shared::*;
|
||||
pub use thread::*;
|
||||
pub use thread_data::*;
|
||||
pub use turn::*;
|
||||
pub use usage::*;
|
||||
pub use windows_sandbox::*;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
92
codex-rs/app-server-protocol/src/protocol/v2/usage.rs
Normal file
92
codex-rs/app-server-protocol/src/protocol/v2/usage.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use codex_protocol::protocol::UsageContributorKind as CoreUsageContributorKind;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase", export_to = "v2/")]
|
||||
pub enum UsageRange {
|
||||
Day,
|
||||
Week,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct UsageReadParams {
|
||||
pub range: UsageRange,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct UsageEntry {
|
||||
pub kind: UsageContributorKind,
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
#[ts(type = "number")]
|
||||
pub attributed_tokens: i64,
|
||||
pub percent_of_usage: u8,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct UsageHeadline {
|
||||
pub entry: UsageEntry,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct UsageReport {
|
||||
pub range: UsageRange,
|
||||
#[ts(type = "number")]
|
||||
pub generated_at: i64,
|
||||
#[ts(type = "number | null")]
|
||||
pub tracked_from: Option<i64>,
|
||||
#[ts(type = "number")]
|
||||
pub total_tokens: i64,
|
||||
pub headline: Option<UsageHeadline>,
|
||||
pub skills: Vec<UsageEntry>,
|
||||
pub subagents: Vec<UsageEntry>,
|
||||
pub agent_tasks: Vec<UsageEntry>,
|
||||
pub apps: Vec<UsageEntry>,
|
||||
pub mcp_servers: Vec<UsageEntry>,
|
||||
pub plugins: Vec<UsageEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct UsageReadResponse {
|
||||
pub report: UsageReport,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase", export_to = "v2/")]
|
||||
pub enum UsageContributorKind {
|
||||
Skill,
|
||||
Subagent,
|
||||
AgentTask,
|
||||
App,
|
||||
McpServer,
|
||||
Plugin,
|
||||
}
|
||||
|
||||
impl From<CoreUsageContributorKind> for UsageContributorKind {
|
||||
fn from(value: CoreUsageContributorKind) -> Self {
|
||||
match value {
|
||||
CoreUsageContributorKind::Skill => Self::Skill,
|
||||
CoreUsageContributorKind::Subagent => Self::Subagent,
|
||||
CoreUsageContributorKind::AgentTask => Self::AgentTask,
|
||||
CoreUsageContributorKind::App => Self::App,
|
||||
CoreUsageContributorKind::McpServer => Self::McpServer,
|
||||
CoreUsageContributorKind::Plugin => Self::Plugin,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,7 @@ Example with notification opt-out:
|
||||
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
|
||||
- `skills/changed` — notification emitted when watched local skill files change.
|
||||
- `app/list` — list available apps.
|
||||
- `usage/read` — read forward-only local token usage for a rolling `day` or `week` range, grouped by skills, subagents, agent tasks, apps, MCP servers, and plugins when Codex has tracked those contributors in sqlite.
|
||||
- `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. The caller is responsible for persisting the desired setting outside app-server.
|
||||
- `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices.
|
||||
- `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled.
|
||||
|
||||
@@ -35,6 +35,7 @@ use crate::request_processors::SearchRequestProcessor;
|
||||
use crate::request_processors::ThreadGoalRequestProcessor;
|
||||
use crate::request_processors::ThreadRequestProcessor;
|
||||
use crate::request_processors::TurnRequestProcessor;
|
||||
use crate::request_processors::UsageRequestProcessor;
|
||||
use crate::request_processors::WindowsSandboxRequestProcessor;
|
||||
use crate::request_serialization::QueuedInitializedRequest;
|
||||
use crate::request_serialization::RequestSerializationQueueKey;
|
||||
@@ -181,6 +182,7 @@ pub(crate) struct MessageProcessor {
|
||||
thread_goal_processor: ThreadGoalRequestProcessor,
|
||||
thread_processor: ThreadRequestProcessor,
|
||||
turn_processor: TurnRequestProcessor,
|
||||
usage_processor: UsageRequestProcessor,
|
||||
windows_sandbox_processor: WindowsSandboxRequestProcessor,
|
||||
request_serialization_queues: RequestSerializationQueues,
|
||||
}
|
||||
@@ -423,9 +425,10 @@ impl MessageProcessor {
|
||||
thread_watch_manager.clone(),
|
||||
Arc::clone(&thread_list_state_permit),
|
||||
thread_goal_processor.clone(),
|
||||
state_db,
|
||||
state_db.clone(),
|
||||
Arc::clone(&skills_watcher),
|
||||
);
|
||||
let usage_processor = UsageRequestProcessor::new(state_db);
|
||||
let turn_processor = TurnRequestProcessor::new(
|
||||
auth_manager.clone(),
|
||||
Arc::clone(&thread_manager),
|
||||
@@ -502,6 +505,7 @@ impl MessageProcessor {
|
||||
thread_goal_processor,
|
||||
thread_processor,
|
||||
turn_processor,
|
||||
usage_processor,
|
||||
windows_sandbox_processor,
|
||||
request_serialization_queues: RequestSerializationQueues::default(),
|
||||
}
|
||||
@@ -1115,6 +1119,11 @@ impl MessageProcessor {
|
||||
ClientRequest::PluginList { params, .. } => {
|
||||
self.plugin_processor.plugin_list(params).await
|
||||
}
|
||||
ClientRequest::UsageRead { params, .. } => self
|
||||
.usage_processor
|
||||
.usage_read(params)
|
||||
.await
|
||||
.map(|response| Some(response.into())),
|
||||
ClientRequest::PluginInstalled { params, .. } => {
|
||||
self.plugin_processor.plugin_installed(params).await
|
||||
}
|
||||
|
||||
@@ -467,6 +467,7 @@ mod search;
|
||||
mod thread_processor;
|
||||
mod token_usage_replay;
|
||||
mod turn_processor;
|
||||
mod usage_processor;
|
||||
mod windows_sandbox_processor;
|
||||
|
||||
pub(crate) use account_processor::AccountRequestProcessor;
|
||||
@@ -489,6 +490,7 @@ pub(crate) use search::SearchRequestProcessor;
|
||||
pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor;
|
||||
pub(crate) use thread_processor::ThreadRequestProcessor;
|
||||
pub(crate) use turn_processor::TurnRequestProcessor;
|
||||
pub(crate) use usage_processor::UsageRequestProcessor;
|
||||
pub(crate) use windows_sandbox_processor::WindowsSandboxRequestProcessor;
|
||||
|
||||
use crate::error_code::internal_error;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
use super::*;
|
||||
use crate::error_code::internal_error;
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::UsageEntry;
|
||||
use codex_app_server_protocol::UsageHeadline;
|
||||
use codex_app_server_protocol::UsageRange;
|
||||
use codex_app_server_protocol::UsageReadParams;
|
||||
use codex_app_server_protocol::UsageReadResponse;
|
||||
use codex_app_server_protocol::UsageReport;
|
||||
use codex_rollout::StateDbHandle;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct UsageRequestProcessor {
|
||||
state_db: Option<StateDbHandle>,
|
||||
}
|
||||
|
||||
impl UsageRequestProcessor {
|
||||
pub(crate) fn new(state_db: Option<StateDbHandle>) -> Self {
|
||||
Self { state_db }
|
||||
}
|
||||
|
||||
pub(crate) async fn usage_read(
|
||||
&self,
|
||||
params: UsageReadParams,
|
||||
) -> Result<UsageReadResponse, JSONRPCErrorError> {
|
||||
let state_db = self
|
||||
.state_db
|
||||
.as_ref()
|
||||
.ok_or_else(|| internal_error("sqlite state db unavailable for usage"))?;
|
||||
let report = state_db
|
||||
.read_usage_report(state_usage_range(params.range), Utc::now().timestamp())
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to read usage report: {err}")))?;
|
||||
Ok(UsageReadResponse {
|
||||
report: UsageReport {
|
||||
range: api_usage_range(report.range),
|
||||
generated_at: report.generated_at,
|
||||
tracked_from: report.tracked_from,
|
||||
total_tokens: report.total_tokens,
|
||||
headline: report.headline.map(|headline| UsageHeadline {
|
||||
entry: usage_entry(headline.entry),
|
||||
note: headline.note,
|
||||
}),
|
||||
skills: report.skills.into_iter().map(usage_entry).collect(),
|
||||
subagents: report.subagents.into_iter().map(usage_entry).collect(),
|
||||
agent_tasks: report.agent_tasks.into_iter().map(usage_entry).collect(),
|
||||
apps: report.apps.into_iter().map(usage_entry).collect(),
|
||||
mcp_servers: report.mcp_servers.into_iter().map(usage_entry).collect(),
|
||||
plugins: report.plugins.into_iter().map(usage_entry).collect(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_entry(entry: codex_state::UsageEntry) -> UsageEntry {
|
||||
UsageEntry {
|
||||
kind: entry.kind.into(),
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
attributed_tokens: entry.attributed_tokens,
|
||||
percent_of_usage: entry.percent_of_usage,
|
||||
}
|
||||
}
|
||||
|
||||
fn state_usage_range(value: UsageRange) -> codex_state::UsageRange {
|
||||
match value {
|
||||
UsageRange::Day => codex_state::UsageRange::Day,
|
||||
UsageRange::Week => codex_state::UsageRange::Week,
|
||||
}
|
||||
}
|
||||
|
||||
fn api_usage_range(value: codex_state::UsageRange) -> UsageRange {
|
||||
match value {
|
||||
codex_state::UsageRange::Day => UsageRange::Day,
|
||||
codex_state::UsageRange::Week => UsageRange::Week,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::usage::UsagePromptAttribution;
|
||||
pub use codex_api::ResponseEvent;
|
||||
use codex_config::types::Personality;
|
||||
use codex_protocol::error::Result;
|
||||
@@ -33,6 +34,8 @@ pub struct Prompt {
|
||||
/// Whether parallel tool calls are permitted for this prompt.
|
||||
pub(crate) parallel_tool_calls: bool,
|
||||
|
||||
pub(crate) usage_attribution: UsagePromptAttribution,
|
||||
|
||||
pub base_instructions: BaseInstructions,
|
||||
|
||||
/// Optionally specify the personality of the model.
|
||||
@@ -51,6 +54,7 @@ impl Default for Prompt {
|
||||
input: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
parallel_tool_calls: false,
|
||||
usage_attribution: UsagePromptAttribution::default(),
|
||||
base_instructions: BaseInstructions::default(),
|
||||
personality: None,
|
||||
output_schema: None,
|
||||
|
||||
@@ -180,6 +180,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
input: prompt_input,
|
||||
tools: tool_router.model_visible_specs(),
|
||||
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
|
||||
usage_attribution: Default::default(),
|
||||
base_instructions,
|
||||
personality: turn_context.personality,
|
||||
output_schema: None,
|
||||
|
||||
@@ -188,6 +188,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
input,
|
||||
tools: tool_router.model_visible_specs(),
|
||||
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
|
||||
usage_attribution: Default::default(),
|
||||
base_instructions,
|
||||
personality: turn_context.personality,
|
||||
output_schema: None,
|
||||
|
||||
@@ -98,6 +98,7 @@ pub(crate) use skills::skills_load_input_from_config;
|
||||
mod stream_events_utils;
|
||||
pub mod test_support;
|
||||
mod unified_exec;
|
||||
mod usage;
|
||||
pub mod windows_sandbox;
|
||||
pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER;
|
||||
pub use codex_protocol::config_types::ModelProviderAuthInfo;
|
||||
|
||||
@@ -3001,6 +3001,36 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn record_usage_attribution(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
prompt: &crate::client_common::Prompt,
|
||||
response_id: &str,
|
||||
token_usage: Option<&TokenUsage>,
|
||||
) {
|
||||
let Some(token_usage) = token_usage else {
|
||||
return;
|
||||
};
|
||||
let occurred_at = chrono::Utc::now().timestamp();
|
||||
let attribution = prompt.usage_attribution.complete(
|
||||
format!("{}:{response_id}", self.conversation_id),
|
||||
turn_context.sub_id.clone(),
|
||||
response_id.to_string(),
|
||||
occurred_at,
|
||||
token_usage.clone(),
|
||||
);
|
||||
if let Some(state_db) = self.state_db()
|
||||
&& let Err(err) = state_db
|
||||
.record_usage_sample(&codex_state::UsageSample {
|
||||
thread_id: self.conversation_id,
|
||||
attribution,
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!("failed to persist usage sample: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn recompute_token_usage(&self, turn_context: &TurnContext) {
|
||||
let history = self.clone_history().await;
|
||||
let base_instructions = self.get_base_instructions().await;
|
||||
|
||||
@@ -889,10 +889,16 @@ pub(crate) fn build_prompt(
|
||||
turn_context: &TurnContext,
|
||||
base_instructions: BaseInstructions,
|
||||
) -> Prompt {
|
||||
let usage_attribution = crate::usage::UsagePromptAttribution::from_prompt(
|
||||
&input,
|
||||
router,
|
||||
base_instructions.text.as_str(),
|
||||
);
|
||||
Prompt {
|
||||
input,
|
||||
tools: router.model_visible_specs(),
|
||||
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
|
||||
usage_attribution,
|
||||
base_instructions,
|
||||
personality: turn_context.personality,
|
||||
output_schema: turn_context.final_output_json_schema.clone(),
|
||||
@@ -2021,6 +2027,13 @@ async fn try_run_sampling_request(
|
||||
&mut assistant_message_stream_parsers,
|
||||
)
|
||||
.await;
|
||||
sess.record_usage_attribution(
|
||||
&turn_context,
|
||||
prompt,
|
||||
response_id.as_str(),
|
||||
token_usage.as_ref(),
|
||||
)
|
||||
.await;
|
||||
sess.record_token_usage_info(&turn_context, token_usage.as_ref())
|
||||
.await;
|
||||
should_emit_token_count = true;
|
||||
|
||||
@@ -18,6 +18,8 @@ use crate::tools::registry::ToolExposure;
|
||||
use crate::tools::registry::ToolTelemetryTags;
|
||||
use crate::tools::tool_search_entry::ToolSearchInfo;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_protocol::protocol::UsageContributor;
|
||||
use codex_protocol::protocol::UsageContributorKind;
|
||||
use codex_tools::ResponsesApiNamespace;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::ToolName;
|
||||
@@ -131,6 +133,38 @@ impl ToolExecutor<ToolInvocation> for McpHandler {
|
||||
}
|
||||
|
||||
impl CoreToolRuntime for McpHandler {
|
||||
fn usage_contributors(&self) -> Vec<UsageContributor> {
|
||||
let mut contributors = Vec::new();
|
||||
if let Some(connector_id) = self.tool_info.connector_id.as_ref() {
|
||||
contributors.push(UsageContributor {
|
||||
kind: UsageContributorKind::App,
|
||||
id: connector_id.clone(),
|
||||
label: self
|
||||
.tool_info
|
||||
.connector_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| connector_id.clone()),
|
||||
});
|
||||
} else {
|
||||
contributors.push(UsageContributor {
|
||||
kind: UsageContributorKind::McpServer,
|
||||
id: self.tool_info.server_name.clone(),
|
||||
label: self.tool_info.server_name.clone(),
|
||||
});
|
||||
}
|
||||
contributors.extend(
|
||||
self.tool_info
|
||||
.plugin_display_names
|
||||
.iter()
|
||||
.map(|plugin_name| UsageContributor {
|
||||
kind: UsageContributorKind::Plugin,
|
||||
id: plugin_name.clone(),
|
||||
label: plugin_name.clone(),
|
||||
}),
|
||||
);
|
||||
contributors
|
||||
}
|
||||
|
||||
fn search_info(&self) -> Option<ToolSearchInfo> {
|
||||
let source_name = self
|
||||
.tool_info
|
||||
|
||||
@@ -320,6 +320,8 @@ mod tests {
|
||||
let router = Arc::new(ToolRouter::from_parts(
|
||||
ToolRegistry::from_tools([handler]),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
std::collections::HashMap::new(),
|
||||
));
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
let runtime = ToolCallRuntime::new(router, session, turn_context, tracker);
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::util::error_or_panic;
|
||||
use codex_extension_api::ToolCallOutcome;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::UsageContributor;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use futures::future::BoxFuture;
|
||||
@@ -70,6 +71,10 @@ pub(crate) trait CoreToolRuntime: ToolExecutor<ToolInvocation> {
|
||||
None
|
||||
}
|
||||
|
||||
fn usage_contributors(&self) -> Vec<UsageContributor> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use codex_tools::ToolCall as ExtensionToolCall;
|
||||
use codex_tools::ToolExecutor;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -34,6 +35,9 @@ pub struct ToolCall {
|
||||
pub struct ToolRouter {
|
||||
registry: ToolRegistry,
|
||||
model_visible_specs: Vec<ToolSpec>,
|
||||
usage_contributors: Vec<crate::usage::UsagePromptContributor>,
|
||||
usage_contributors_by_tool_name:
|
||||
HashMap<ToolName, Vec<codex_protocol::protocol::UsageContributor>>,
|
||||
}
|
||||
|
||||
pub(crate) struct ToolRouterParams<'a> {
|
||||
@@ -49,10 +53,20 @@ impl ToolRouter {
|
||||
build_tool_router(turn_context, params)
|
||||
}
|
||||
|
||||
pub(crate) fn from_parts(registry: ToolRegistry, model_visible_specs: Vec<ToolSpec>) -> Self {
|
||||
pub(crate) fn from_parts(
|
||||
registry: ToolRegistry,
|
||||
model_visible_specs: Vec<ToolSpec>,
|
||||
usage_contributors: Vec<crate::usage::UsagePromptContributor>,
|
||||
usage_contributors_by_tool_name: HashMap<
|
||||
ToolName,
|
||||
Vec<codex_protocol::protocol::UsageContributor>,
|
||||
>,
|
||||
) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
model_visible_specs,
|
||||
usage_contributors,
|
||||
usage_contributors_by_tool_name,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +74,20 @@ impl ToolRouter {
|
||||
self.model_visible_specs.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn usage_contributors(&self) -> Vec<crate::usage::UsagePromptContributor> {
|
||||
self.usage_contributors.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn usage_contributors_for_tool_name(
|
||||
&self,
|
||||
tool_name: &ToolName,
|
||||
) -> Vec<codex_protocol::protocol::UsageContributor> {
|
||||
self.usage_contributors_by_tool_name
|
||||
.get(tool_name)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn registered_tool_names_for_test(&self) -> Vec<ToolName> {
|
||||
self.registry.tool_names_for_test()
|
||||
|
||||
@@ -76,6 +76,7 @@ use codex_tools::request_user_input_available_modes;
|
||||
use codex_tools::shell_command_backend_for_features;
|
||||
use codex_tools::shell_type_for_model_and_features;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
@@ -123,14 +124,25 @@ pub(crate) fn build_tool_router(
|
||||
turn_context: &TurnContext,
|
||||
params: ToolRouterParams<'_>,
|
||||
) -> ToolRouter {
|
||||
let (model_visible_specs, registry) = build_tool_specs_and_registry(turn_context, params);
|
||||
ToolRouter::from_parts(registry, model_visible_specs)
|
||||
let (model_visible_specs, registry, usage_contributors, usage_contributors_by_tool_name) =
|
||||
build_tool_specs_and_registry(turn_context, params);
|
||||
ToolRouter::from_parts(
|
||||
registry,
|
||||
model_visible_specs,
|
||||
usage_contributors,
|
||||
usage_contributors_by_tool_name,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_tool_specs_and_registry(
|
||||
turn_context: &TurnContext,
|
||||
params: ToolRouterParams<'_>,
|
||||
) -> (Vec<ToolSpec>, ToolRegistry) {
|
||||
) -> (
|
||||
Vec<ToolSpec>,
|
||||
ToolRegistry,
|
||||
Vec<crate::usage::UsagePromptContributor>,
|
||||
HashMap<ToolName, Vec<codex_protocol::protocol::UsageContributor>>,
|
||||
) {
|
||||
let ToolRouterParams {
|
||||
mcp_tools,
|
||||
deferred_mcp_tools,
|
||||
@@ -160,12 +172,19 @@ fn build_tool_specs_and_registry(
|
||||
fn build_model_visible_specs_and_registry(
|
||||
turn_context: &TurnContext,
|
||||
planned_tools: PlannedTools,
|
||||
) -> (Vec<ToolSpec>, ToolRegistry) {
|
||||
) -> (
|
||||
Vec<ToolSpec>,
|
||||
ToolRegistry,
|
||||
Vec<crate::usage::UsagePromptContributor>,
|
||||
HashMap<ToolName, Vec<codex_protocol::protocol::UsageContributor>>,
|
||||
) {
|
||||
let PlannedTools {
|
||||
runtimes,
|
||||
hosted_specs,
|
||||
} = planned_tools;
|
||||
let mut specs = Vec::new();
|
||||
let mut usage_contributors = Vec::new();
|
||||
let mut usage_contributors_by_tool_name = HashMap::new();
|
||||
let mut seen_tool_names = HashSet::new();
|
||||
for runtime in &runtimes {
|
||||
let tool_name = runtime.tool_name();
|
||||
@@ -177,6 +196,16 @@ fn build_model_visible_specs_and_registry(
|
||||
&& !is_hidden_by_code_mode_only(turn_context, &tool_name, exposure)
|
||||
&& let Some(spec) = runtime.spec()
|
||||
{
|
||||
let estimated_tokens = crate::usage::estimate_serialized_tokens(&spec);
|
||||
let runtime_usage_contributors = runtime.usage_contributors();
|
||||
usage_contributors_by_tool_name
|
||||
.insert(tool_name.clone(), runtime_usage_contributors.clone());
|
||||
usage_contributors.extend(runtime_usage_contributors.into_iter().map(|contributor| {
|
||||
crate::usage::UsagePromptContributor {
|
||||
contributor,
|
||||
source_estimated_tokens: estimated_tokens,
|
||||
}
|
||||
}));
|
||||
specs.push(spec_for_model_request(turn_context, exposure, spec));
|
||||
}
|
||||
}
|
||||
@@ -198,7 +227,12 @@ fn build_model_visible_specs_and_registry(
|
||||
})
|
||||
.collect();
|
||||
|
||||
(model_visible_specs, registry)
|
||||
(
|
||||
model_visible_specs,
|
||||
registry,
|
||||
usage_contributors,
|
||||
usage_contributors_by_tool_name,
|
||||
)
|
||||
}
|
||||
|
||||
fn spec_for_model_request(
|
||||
|
||||
363
codex-rs/core/src/usage.rs
Normal file
363
codex-rs/core/src/usage.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
use crate::tools::router::ToolRouter;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::UsageAttributionContributor;
|
||||
use codex_protocol::protocol::UsageAttributionItem;
|
||||
use codex_protocol::protocol::UsageContributor;
|
||||
use codex_protocol::protocol::UsageContributorKind;
|
||||
use codex_tools::ToolName;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct UsagePromptAttribution {
|
||||
pub(crate) prompt_estimated_tokens: i64,
|
||||
pub(crate) contributors: Vec<UsagePromptContributor>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct UsagePromptContributor {
|
||||
pub(crate) contributor: UsageContributor,
|
||||
pub(crate) source_estimated_tokens: i64,
|
||||
}
|
||||
|
||||
impl UsagePromptAttribution {
|
||||
pub(crate) fn from_prompt(
|
||||
input: &[ResponseItem],
|
||||
router: &ToolRouter,
|
||||
base_instructions: &str,
|
||||
) -> Self {
|
||||
let mut contributors = skill_contributors(input);
|
||||
contributors.extend(router.usage_contributors());
|
||||
contributors.extend(tool_result_contributors(input, router));
|
||||
let input_tokens = input
|
||||
.iter()
|
||||
.map(estimate_response_item_tokens)
|
||||
.fold(0i64, i64::saturating_add);
|
||||
let tool_tokens = router
|
||||
.model_visible_specs()
|
||||
.iter()
|
||||
.map(estimate_serialized_tokens)
|
||||
.fold(0i64, i64::saturating_add);
|
||||
let base_tokens = i64::try_from(approx_token_count(base_instructions)).unwrap_or(i64::MAX);
|
||||
Self {
|
||||
prompt_estimated_tokens: base_tokens
|
||||
.saturating_add(input_tokens)
|
||||
.saturating_add(tool_tokens),
|
||||
contributors: aggregate_contributors(contributors),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn complete(
|
||||
&self,
|
||||
sample_id: String,
|
||||
turn_id: String,
|
||||
response_id: String,
|
||||
occurred_at: i64,
|
||||
token_usage: TokenUsage,
|
||||
) -> UsageAttributionItem {
|
||||
let non_cached_input = token_usage.non_cached_input();
|
||||
let contributors = self
|
||||
.contributors
|
||||
.iter()
|
||||
.map(|contributor| UsageAttributionContributor {
|
||||
contributor: contributor.contributor.clone(),
|
||||
source_estimated_tokens: contributor.source_estimated_tokens,
|
||||
attributed_tokens: attributable_tokens(
|
||||
non_cached_input,
|
||||
contributor.source_estimated_tokens,
|
||||
self.prompt_estimated_tokens,
|
||||
),
|
||||
})
|
||||
.filter(|contributor| contributor.attributed_tokens > 0)
|
||||
.collect();
|
||||
UsageAttributionItem {
|
||||
sample_id,
|
||||
turn_id,
|
||||
response_id,
|
||||
occurred_at,
|
||||
token_usage,
|
||||
prompt_estimated_tokens: self.prompt_estimated_tokens,
|
||||
contributors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn estimate_serialized_tokens<T: serde::Serialize>(value: &T) -> i64 {
|
||||
serde_json::to_string(value)
|
||||
.map(|serialized| i64::try_from(approx_token_count(&serialized)).unwrap_or(i64::MAX))
|
||||
.unwrap_or(/*default*/ 0)
|
||||
}
|
||||
|
||||
fn estimate_response_item_tokens(item: &ResponseItem) -> i64 {
|
||||
estimate_serialized_tokens(item)
|
||||
}
|
||||
|
||||
fn skill_contributors(input: &[ResponseItem]) -> Vec<UsagePromptContributor> {
|
||||
input.iter().filter_map(skill_contributor).collect()
|
||||
}
|
||||
|
||||
fn tool_result_contributors(
|
||||
input: &[ResponseItem],
|
||||
router: &ToolRouter,
|
||||
) -> Vec<UsagePromptContributor> {
|
||||
let contributors_by_call_id = input
|
||||
.iter()
|
||||
.filter_map(|item| tool_call_contributors(item, router))
|
||||
.collect::<HashMap<_, _>>();
|
||||
input
|
||||
.iter()
|
||||
.filter_map(|item| tool_result_contributor(item, &contributors_by_call_id))
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tool_call_contributors(
|
||||
item: &ResponseItem,
|
||||
router: &ToolRouter,
|
||||
) -> Option<(String, Vec<UsageContributor>)> {
|
||||
let (call_id, tool_name) = match item {
|
||||
ResponseItem::FunctionCall {
|
||||
call_id,
|
||||
name,
|
||||
namespace,
|
||||
..
|
||||
} => (call_id, ToolName::new(namespace.clone(), name)),
|
||||
ResponseItem::CustomToolCall { call_id, name, .. } => (call_id, ToolName::plain(name)),
|
||||
_ => return None,
|
||||
};
|
||||
let contributors = router.usage_contributors_for_tool_name(&tool_name);
|
||||
(!contributors.is_empty()).then(|| (call_id.clone(), contributors))
|
||||
}
|
||||
|
||||
fn tool_result_contributor(
|
||||
item: &ResponseItem,
|
||||
contributors_by_call_id: &HashMap<String, Vec<UsageContributor>>,
|
||||
) -> Option<Vec<UsagePromptContributor>> {
|
||||
let call_id = match item {
|
||||
ResponseItem::FunctionCallOutput { call_id, .. }
|
||||
| ResponseItem::CustomToolCallOutput { call_id, .. } => call_id,
|
||||
_ => return None,
|
||||
};
|
||||
let source_estimated_tokens = estimate_response_item_tokens(item);
|
||||
Some(
|
||||
contributors_by_call_id
|
||||
.get(call_id)?
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|contributor| UsagePromptContributor {
|
||||
contributor,
|
||||
source_estimated_tokens,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn skill_contributor(item: &ResponseItem) -> Option<UsagePromptContributor> {
|
||||
let ResponseItem::Message { content, .. } = item else {
|
||||
return None;
|
||||
};
|
||||
let text = content.iter().find_map(|content| match content {
|
||||
ContentItem::InputText { text } if text.contains("<skill>") => Some(text.as_str()),
|
||||
_ => None,
|
||||
})?;
|
||||
let name = tag_contents(text, "name")?;
|
||||
let path = tag_contents(text, "path")?;
|
||||
Some(UsagePromptContributor {
|
||||
contributor: UsageContributor {
|
||||
kind: UsageContributorKind::Skill,
|
||||
id: path.to_string(),
|
||||
label: name.to_string(),
|
||||
},
|
||||
source_estimated_tokens: i64::try_from(approx_token_count(text)).unwrap_or(i64::MAX),
|
||||
})
|
||||
}
|
||||
|
||||
fn tag_contents<'a>(text: &'a str, tag: &str) -> Option<&'a str> {
|
||||
let open = format!("<{tag}>");
|
||||
let close = format!("</{tag}>");
|
||||
let start = text.find(open.as_str())? + open.len();
|
||||
let end = text[start..].find(close.as_str())? + start;
|
||||
Some(text[start..end].trim())
|
||||
}
|
||||
|
||||
fn aggregate_contributors(
|
||||
contributors: Vec<UsagePromptContributor>,
|
||||
) -> Vec<UsagePromptContributor> {
|
||||
let mut aggregated = BTreeMap::new();
|
||||
for contributor in contributors {
|
||||
let key = (
|
||||
contributor.contributor.kind as u8,
|
||||
contributor.contributor.id.clone(),
|
||||
contributor.contributor.label.clone(),
|
||||
);
|
||||
aggregated
|
||||
.entry(key)
|
||||
.and_modify(|existing: &mut UsagePromptContributor| {
|
||||
existing.source_estimated_tokens = existing
|
||||
.source_estimated_tokens
|
||||
.saturating_add(contributor.source_estimated_tokens);
|
||||
})
|
||||
.or_insert(contributor);
|
||||
}
|
||||
aggregated.into_values().collect()
|
||||
}
|
||||
|
||||
fn attributable_tokens(non_cached_input: i64, source_tokens: i64, prompt_tokens: i64) -> i64 {
|
||||
if non_cached_input <= 0 || source_tokens <= 0 || prompt_tokens <= 0 {
|
||||
return 0;
|
||||
}
|
||||
non_cached_input
|
||||
.saturating_mul(source_tokens)
|
||||
.saturating_add(prompt_tokens / 2)
|
||||
/ prompt_tokens
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn complete_attributes_only_non_cached_input_tokens() {
|
||||
let attribution = UsagePromptAttribution {
|
||||
prompt_estimated_tokens: 100,
|
||||
contributors: vec![
|
||||
usage_prompt_contributor(
|
||||
UsageContributorKind::Skill,
|
||||
"/skills/tmux",
|
||||
"tmux",
|
||||
/*source_estimated_tokens*/ 25,
|
||||
),
|
||||
usage_prompt_contributor(
|
||||
UsageContributorKind::App,
|
||||
"slack",
|
||||
"Slack",
|
||||
/*source_estimated_tokens*/ 10,
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
let usage = attribution.complete(
|
||||
"sample".to_string(),
|
||||
"turn".to_string(),
|
||||
"response".to_string(),
|
||||
/*occurred_at*/ 1_700_000_000,
|
||||
TokenUsage {
|
||||
input_tokens: 100,
|
||||
cached_input_tokens: 40,
|
||||
output_tokens: 20,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 120,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
usage.contributors,
|
||||
vec![
|
||||
UsageAttributionContributor {
|
||||
contributor: usage_contributor(
|
||||
UsageContributorKind::Skill,
|
||||
"/skills/tmux",
|
||||
"tmux",
|
||||
),
|
||||
source_estimated_tokens: 25,
|
||||
attributed_tokens: 15,
|
||||
},
|
||||
UsageAttributionContributor {
|
||||
contributor: usage_contributor(UsageContributorKind::App, "slack", "Slack"),
|
||||
source_estimated_tokens: 10,
|
||||
attributed_tokens: 6,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_contributors_use_skill_path_as_stable_id() {
|
||||
let item = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "<skill><name>tmux</name><path>/skills/tmux/SKILL.md</path></skill>"
|
||||
.to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
skill_contributors(&[item]),
|
||||
vec![UsagePromptContributor {
|
||||
contributor: usage_contributor(
|
||||
UsageContributorKind::Skill,
|
||||
"/skills/tmux/SKILL.md",
|
||||
"tmux",
|
||||
),
|
||||
source_estimated_tokens: i64::try_from(approx_token_count(
|
||||
"<skill><name>tmux</name><path>/skills/tmux/SKILL.md</path></skill>",
|
||||
))
|
||||
.expect("skill prompt token estimate should fit in i64"),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_results_reuse_tool_usage_provenance() {
|
||||
let contributor = usage_contributor(UsageContributorKind::App, "slack", "Slack");
|
||||
let tool_name = ToolName::plain("mcp__slack__search");
|
||||
let router = ToolRouter::from_parts(
|
||||
ToolRegistry::from_tools(Vec::<
|
||||
std::sync::Arc<dyn crate::tools::registry::CoreToolRuntime>,
|
||||
>::new()),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
HashMap::from([(tool_name.clone(), vec![contributor.clone()])]),
|
||||
);
|
||||
let tool_result = ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text("result".to_string()),
|
||||
};
|
||||
let input = vec![
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: tool_name.name,
|
||||
namespace: tool_name.namespace,
|
||||
arguments: "{}".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
},
|
||||
tool_result.clone(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
tool_result_contributors(&input, &router),
|
||||
vec![UsagePromptContributor {
|
||||
contributor,
|
||||
source_estimated_tokens: estimate_response_item_tokens(&tool_result),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
fn usage_prompt_contributor(
|
||||
kind: UsageContributorKind,
|
||||
id: &str,
|
||||
label: &str,
|
||||
source_estimated_tokens: i64,
|
||||
) -> UsagePromptContributor {
|
||||
UsagePromptContributor {
|
||||
contributor: usage_contributor(kind, id, label),
|
||||
source_estimated_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_contributor(kind: UsageContributorKind, id: &str, label: &str) -> UsageContributor {
|
||||
UsageContributor {
|
||||
kind,
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1901,6 +1901,47 @@ pub struct TokenUsage {
|
||||
pub total_tokens: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
pub enum UsageContributorKind {
|
||||
Skill,
|
||||
Subagent,
|
||||
AgentTask,
|
||||
App,
|
||||
McpServer,
|
||||
Plugin,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
pub struct UsageContributor {
|
||||
pub kind: UsageContributorKind,
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
pub struct UsageAttributionContributor {
|
||||
pub contributor: UsageContributor,
|
||||
#[ts(type = "number")]
|
||||
pub source_estimated_tokens: i64,
|
||||
#[ts(type = "number")]
|
||||
pub attributed_tokens: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
pub struct UsageAttributionItem {
|
||||
pub sample_id: String,
|
||||
pub turn_id: String,
|
||||
pub response_id: String,
|
||||
#[ts(type = "number")]
|
||||
pub occurred_at: i64,
|
||||
pub token_usage: TokenUsage,
|
||||
#[ts(type = "number")]
|
||||
pub prompt_estimated_tokens: i64,
|
||||
pub contributors: Vec<UsageAttributionContributor>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
pub struct TokenUsageInfo {
|
||||
pub total_token_usage: TokenUsage,
|
||||
|
||||
@@ -216,6 +216,66 @@ async fn load_rollout_items_skips_legacy_ghost_snapshot_lines() -> std::io::Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rollout_items_skips_legacy_usage_attribution_lines() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let rollout_path = home.path().join("rollout.jsonl");
|
||||
let mut file = File::create(&rollout_path)?;
|
||||
let thread_id = ThreadId::new();
|
||||
let ts = "2025-01-03T12:00:00Z";
|
||||
|
||||
writeln!(
|
||||
file,
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"timestamp": ts,
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"id": thread_id,
|
||||
"timestamp": ts,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "cli",
|
||||
"model_provider": "test-provider",
|
||||
},
|
||||
})
|
||||
)?;
|
||||
writeln!(
|
||||
file,
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"timestamp": ts,
|
||||
"type": "usage_attribution",
|
||||
"payload": {
|
||||
"sample_id": "sample",
|
||||
"turn_id": "turn",
|
||||
"response_id": "response",
|
||||
"occurred_at": 1_700_000_000,
|
||||
"token_usage": {
|
||||
"input_tokens": 1,
|
||||
"cached_input_tokens": 0,
|
||||
"output_tokens": 1,
|
||||
"reasoning_output_tokens": 0,
|
||||
"total_tokens": 2,
|
||||
},
|
||||
"prompt_estimated_tokens": 1,
|
||||
"contributors": [],
|
||||
},
|
||||
})
|
||||
)?;
|
||||
|
||||
let (items, loaded_thread_id, parse_errors) =
|
||||
RolloutRecorder::load_rollout_items(&rollout_path).await?;
|
||||
|
||||
assert_eq!(loaded_thread_id, Some(thread_id));
|
||||
assert_eq!(parse_errors, 1);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert!(matches!(items[0], RolloutItem::SessionMeta(_)));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rollout_items_preserves_legacy_guardian_assessment_lines() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
|
||||
29
codex-rs/state/migrations/0035_usage_samples.sql
Normal file
29
codex-rs/state/migrations/0035_usage_samples.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE usage_samples (
|
||||
sample_id TEXT PRIMARY KEY,
|
||||
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||
turn_id TEXT NOT NULL,
|
||||
response_id TEXT NOT NULL,
|
||||
occurred_at INTEGER NOT NULL,
|
||||
input_tokens INTEGER NOT NULL,
|
||||
cached_input_tokens INTEGER NOT NULL,
|
||||
non_cached_input_tokens INTEGER NOT NULL,
|
||||
output_tokens INTEGER NOT NULL,
|
||||
reasoning_output_tokens INTEGER NOT NULL,
|
||||
total_tokens INTEGER NOT NULL,
|
||||
blended_tokens INTEGER NOT NULL,
|
||||
prompt_estimated_tokens INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE usage_sample_contributors (
|
||||
sample_id TEXT NOT NULL REFERENCES usage_samples(sample_id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
contributor_id TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
source_estimated_tokens INTEGER NOT NULL,
|
||||
attributed_tokens INTEGER NOT NULL,
|
||||
PRIMARY KEY (sample_id, kind, contributor_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_usage_samples_occurred_at ON usage_samples(occurred_at);
|
||||
CREATE INDEX idx_usage_samples_thread_occurred_at ON usage_samples(thread_id, occurred_at);
|
||||
CREATE INDEX idx_usage_sample_contributors_kind ON usage_sample_contributors(kind, contributor_id);
|
||||
@@ -48,6 +48,11 @@ pub use model::ThreadGoalStatus;
|
||||
pub use model::ThreadMetadata;
|
||||
pub use model::ThreadMetadataBuilder;
|
||||
pub use model::ThreadsPage;
|
||||
pub use model::UsageEntry;
|
||||
pub use model::UsageHeadline;
|
||||
pub use model::UsageRange;
|
||||
pub use model::UsageReport;
|
||||
pub use model::UsageSample;
|
||||
pub use runtime::GoalAccountingMode;
|
||||
pub use runtime::GoalAccountingOutcome;
|
||||
pub use runtime::GoalStore;
|
||||
|
||||
@@ -5,6 +5,7 @@ mod log;
|
||||
mod memories;
|
||||
mod thread_goal;
|
||||
mod thread_metadata;
|
||||
mod usage;
|
||||
|
||||
pub use agent_job::AgentJob;
|
||||
pub use agent_job::AgentJobCreateParams;
|
||||
@@ -34,6 +35,11 @@ pub use thread_metadata::SortKey;
|
||||
pub use thread_metadata::ThreadMetadata;
|
||||
pub use thread_metadata::ThreadMetadataBuilder;
|
||||
pub use thread_metadata::ThreadsPage;
|
||||
pub use usage::UsageEntry;
|
||||
pub use usage::UsageHeadline;
|
||||
pub use usage::UsageRange;
|
||||
pub use usage::UsageReport;
|
||||
pub use usage::UsageSample;
|
||||
|
||||
pub(crate) use agent_job::AgentJobItemRow;
|
||||
pub(crate) use agent_job::AgentJobRow;
|
||||
|
||||
53
codex-rs/state/src/model/usage.rs
Normal file
53
codex-rs/state/src/model/usage.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use codex_protocol::protocol::UsageAttributionItem;
|
||||
use codex_protocol::protocol::UsageContributorKind;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UsageRange {
|
||||
Day,
|
||||
Week,
|
||||
}
|
||||
|
||||
impl UsageRange {
|
||||
pub(crate) fn seconds(self) -> i64 {
|
||||
match self {
|
||||
Self::Day => 24 * 60 * 60,
|
||||
Self::Week => 7 * 24 * 60 * 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageEntry {
|
||||
pub kind: UsageContributorKind,
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub attributed_tokens: i64,
|
||||
pub percent_of_usage: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageHeadline {
|
||||
pub entry: UsageEntry,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageReport {
|
||||
pub range: UsageRange,
|
||||
pub generated_at: i64,
|
||||
pub tracked_from: Option<i64>,
|
||||
pub total_tokens: i64,
|
||||
pub headline: Option<UsageHeadline>,
|
||||
pub skills: Vec<UsageEntry>,
|
||||
pub subagents: Vec<UsageEntry>,
|
||||
pub agent_tasks: Vec<UsageEntry>,
|
||||
pub apps: Vec<UsageEntry>,
|
||||
pub mcp_servers: Vec<UsageEntry>,
|
||||
pub plugins: Vec<UsageEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageSample {
|
||||
pub thread_id: codex_protocol::ThreadId,
|
||||
pub attribution: UsageAttributionItem,
|
||||
}
|
||||
@@ -65,6 +65,7 @@ mod remote_control;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
mod threads;
|
||||
mod usage;
|
||||
|
||||
pub use goals::GoalAccountingMode;
|
||||
pub use goals::GoalAccountingOutcome;
|
||||
@@ -237,6 +238,12 @@ impl StateRuntime {
|
||||
logs_path.display(),
|
||||
);
|
||||
}
|
||||
if let Err(err) = runtime.run_usage_startup_maintenance().await {
|
||||
warn!(
|
||||
"failed to run startup maintenance for usage data in state db at {}: {err}",
|
||||
state_path.display(),
|
||||
);
|
||||
}
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
|
||||
893
codex-rs/state/src/runtime/usage.rs
Normal file
893
codex-rs/state/src/runtime/usage.rs
Normal file
@@ -0,0 +1,893 @@
|
||||
use super::*;
|
||||
use crate::UsageEntry;
|
||||
use crate::UsageHeadline;
|
||||
use crate::UsageRange;
|
||||
use crate::UsageReport;
|
||||
use crate::UsageSample;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::UsageContributorKind;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const USAGE_RETENTION_DAYS: i64 = 14;
|
||||
const USAGE_RETENTION_SECONDS: i64 = USAGE_RETENTION_DAYS * 24 * 60 * 60;
|
||||
|
||||
impl StateRuntime {
|
||||
pub async fn record_usage_sample(&self, sample: &UsageSample) -> anyhow::Result<()> {
|
||||
let usage = &sample.attribution;
|
||||
let token_usage = &usage.token_usage;
|
||||
let retention_cutoff = usage_retention_cutoff(Utc::now().timestamp());
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO usage_samples (
|
||||
sample_id,
|
||||
thread_id,
|
||||
turn_id,
|
||||
response_id,
|
||||
occurred_at,
|
||||
input_tokens,
|
||||
cached_input_tokens,
|
||||
non_cached_input_tokens,
|
||||
output_tokens,
|
||||
reasoning_output_tokens,
|
||||
total_tokens,
|
||||
blended_tokens,
|
||||
prompt_estimated_tokens
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(sample_id) DO UPDATE SET
|
||||
thread_id = excluded.thread_id,
|
||||
turn_id = excluded.turn_id,
|
||||
response_id = excluded.response_id,
|
||||
occurred_at = excluded.occurred_at,
|
||||
input_tokens = excluded.input_tokens,
|
||||
cached_input_tokens = excluded.cached_input_tokens,
|
||||
non_cached_input_tokens = excluded.non_cached_input_tokens,
|
||||
output_tokens = excluded.output_tokens,
|
||||
reasoning_output_tokens = excluded.reasoning_output_tokens,
|
||||
total_tokens = excluded.total_tokens,
|
||||
blended_tokens = excluded.blended_tokens,
|
||||
prompt_estimated_tokens = excluded.prompt_estimated_tokens
|
||||
"#,
|
||||
)
|
||||
.bind(usage.sample_id.as_str())
|
||||
.bind(sample.thread_id.to_string())
|
||||
.bind(usage.turn_id.as_str())
|
||||
.bind(usage.response_id.as_str())
|
||||
.bind(usage.occurred_at)
|
||||
.bind(token_usage.input_tokens)
|
||||
.bind(token_usage.cached_input_tokens)
|
||||
.bind(token_usage.non_cached_input())
|
||||
.bind(token_usage.output_tokens)
|
||||
.bind(token_usage.reasoning_output_tokens)
|
||||
.bind(token_usage.total_tokens)
|
||||
.bind(token_usage.blended_total())
|
||||
.bind(usage.prompt_estimated_tokens)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM usage_sample_contributors WHERE sample_id = ?")
|
||||
.bind(usage.sample_id.as_str())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
for contributor in &usage.contributors {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO usage_sample_contributors (
|
||||
sample_id,
|
||||
kind,
|
||||
contributor_id,
|
||||
label,
|
||||
source_estimated_tokens,
|
||||
attributed_tokens
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(usage.sample_id.as_str())
|
||||
.bind(usage_kind_key(contributor.contributor.kind))
|
||||
.bind(contributor.contributor.id.as_str())
|
||||
.bind(contributor.contributor.label.as_str())
|
||||
.bind(contributor.source_estimated_tokens)
|
||||
.bind(contributor.attributed_tokens)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
prune_usage_samples_before(retention_cutoff, &mut tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn run_usage_startup_maintenance(&self) -> anyhow::Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
prune_usage_samples_before(usage_retention_cutoff(Utc::now().timestamp()), &mut tx).await?;
|
||||
tx.commit().await?;
|
||||
// PASSIVE checkpoints copy whatever is immediately available and skip
|
||||
// frames that would require waiting on active readers or writers.
|
||||
sqlx::query("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
.execute(self.pool.as_ref())
|
||||
.await?;
|
||||
// Reclaim any free pages left by retention pruning when incremental auto-vacuum is active.
|
||||
sqlx::query("PRAGMA incremental_vacuum")
|
||||
.execute(self.pool.as_ref())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn read_usage_report(
|
||||
&self,
|
||||
range: UsageRange,
|
||||
now: i64,
|
||||
) -> anyhow::Result<UsageReport> {
|
||||
let since = now.saturating_sub(range.seconds());
|
||||
let total_tokens: i64 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(SUM(blended_tokens), 0) FROM usage_samples WHERE occurred_at >= ?",
|
||||
)
|
||||
.bind(since)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await?;
|
||||
let tracked_from: Option<i64> =
|
||||
sqlx::query_scalar("SELECT MIN(occurred_at) FROM usage_samples")
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await?;
|
||||
let mut report = UsageReport {
|
||||
range,
|
||||
generated_at: now,
|
||||
tracked_from,
|
||||
total_tokens,
|
||||
headline: None,
|
||||
skills: self
|
||||
.read_usage_contributors(since, UsageContributorKind::Skill, total_tokens)
|
||||
.await?,
|
||||
subagents: self.read_subagent_usage(since, total_tokens).await?,
|
||||
agent_tasks: self.read_agent_task_usage(since, total_tokens).await?,
|
||||
apps: self
|
||||
.read_usage_contributors(since, UsageContributorKind::App, total_tokens)
|
||||
.await?,
|
||||
mcp_servers: self
|
||||
.read_usage_contributors(since, UsageContributorKind::McpServer, total_tokens)
|
||||
.await?,
|
||||
plugins: self
|
||||
.read_usage_contributors(since, UsageContributorKind::Plugin, total_tokens)
|
||||
.await?,
|
||||
};
|
||||
report.headline = usage_headline(&report);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
async fn read_usage_contributors(
|
||||
&self,
|
||||
since: i64,
|
||||
kind: UsageContributorKind,
|
||||
total_tokens: i64,
|
||||
) -> anyhow::Result<Vec<UsageEntry>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT contributor_id, label, SUM(attributed_tokens) AS attributed_tokens
|
||||
FROM usage_sample_contributors
|
||||
JOIN usage_samples ON usage_samples.sample_id = usage_sample_contributors.sample_id
|
||||
WHERE usage_samples.occurred_at >= ?
|
||||
AND usage_sample_contributors.kind = ?
|
||||
GROUP BY contributor_id, label
|
||||
HAVING SUM(attributed_tokens) > 0
|
||||
ORDER BY attributed_tokens DESC, label ASC
|
||||
"#,
|
||||
)
|
||||
.bind(since)
|
||||
.bind(usage_kind_key(kind))
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
let attributed_tokens = row.try_get("attributed_tokens")?;
|
||||
Ok(UsageEntry {
|
||||
kind,
|
||||
id: row.try_get("contributor_id")?,
|
||||
label: row.try_get("label")?,
|
||||
attributed_tokens,
|
||||
percent_of_usage: usage_percent(attributed_tokens, total_tokens),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn read_subagent_usage(
|
||||
&self,
|
||||
since: i64,
|
||||
total_tokens: i64,
|
||||
) -> anyhow::Result<Vec<UsageEntry>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COALESCE(NULLIF(threads.agent_role, ''), NULLIF(threads.agent_nickname, ''), 'default') AS label,
|
||||
COALESCE(NULLIF(threads.agent_role, ''), NULLIF(threads.agent_nickname, ''), 'default') AS contributor_id,
|
||||
SUM(usage_samples.blended_tokens) AS attributed_tokens
|
||||
FROM usage_samples
|
||||
JOIN threads ON threads.id = usage_samples.thread_id
|
||||
WHERE usage_samples.occurred_at >= ?
|
||||
AND threads.thread_source = 'subagent'
|
||||
GROUP BY contributor_id, label
|
||||
HAVING SUM(usage_samples.blended_tokens) > 0
|
||||
ORDER BY attributed_tokens DESC, label ASC
|
||||
"#,
|
||||
)
|
||||
.bind(since)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
let attributed_tokens = row.try_get("attributed_tokens")?;
|
||||
Ok(UsageEntry {
|
||||
kind: UsageContributorKind::Subagent,
|
||||
id: row.try_get("contributor_id")?,
|
||||
label: row.try_get("label")?,
|
||||
attributed_tokens,
|
||||
percent_of_usage: usage_percent(attributed_tokens, total_tokens),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn read_agent_task_usage(
|
||||
&self,
|
||||
since: i64,
|
||||
total_tokens: i64,
|
||||
) -> anyhow::Result<Vec<UsageEntry>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT threads.source AS source, SUM(usage_samples.blended_tokens) AS attributed_tokens
|
||||
FROM usage_samples
|
||||
JOIN threads ON threads.id = usage_samples.thread_id
|
||||
WHERE usage_samples.occurred_at >= ?
|
||||
AND threads.thread_source = 'subagent'
|
||||
GROUP BY threads.source
|
||||
HAVING SUM(usage_samples.blended_tokens) > 0
|
||||
"#,
|
||||
)
|
||||
.bind(since)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await?;
|
||||
let mut by_task = BTreeMap::<String, i64>::new();
|
||||
for row in rows {
|
||||
let label = agent_task_label(row.try_get("source")?);
|
||||
let attributed_tokens: i64 = row.try_get("attributed_tokens")?;
|
||||
by_task
|
||||
.entry(label)
|
||||
.and_modify(|tokens| {
|
||||
*tokens = tokens.saturating_add(attributed_tokens);
|
||||
})
|
||||
.or_insert(attributed_tokens);
|
||||
}
|
||||
let mut entries = by_task
|
||||
.into_iter()
|
||||
.map(|(label, attributed_tokens)| UsageEntry {
|
||||
kind: UsageContributorKind::AgentTask,
|
||||
id: label.clone(),
|
||||
label,
|
||||
attributed_tokens,
|
||||
percent_of_usage: usage_percent(attributed_tokens, total_tokens),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by(|left, right| {
|
||||
right
|
||||
.attributed_tokens
|
||||
.cmp(&left.attributed_tokens)
|
||||
.then_with(|| left.label.cmp(&right.label))
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
|
||||
async fn prune_usage_samples_before(
|
||||
cutoff_ts: i64,
|
||||
tx: &mut SqliteConnection,
|
||||
) -> anyhow::Result<u64> {
|
||||
let result = sqlx::query("DELETE FROM usage_samples WHERE occurred_at < ?")
|
||||
.bind(cutoff_ts)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
fn usage_retention_cutoff(now: i64) -> i64 {
|
||||
now.saturating_sub(USAGE_RETENTION_SECONDS)
|
||||
}
|
||||
|
||||
fn usage_kind_key(kind: UsageContributorKind) -> &'static str {
|
||||
match kind {
|
||||
UsageContributorKind::Skill => "skill",
|
||||
UsageContributorKind::Subagent => "subagent",
|
||||
UsageContributorKind::AgentTask => "agent_task",
|
||||
UsageContributorKind::App => "app",
|
||||
UsageContributorKind::McpServer => "mcp_server",
|
||||
UsageContributorKind::Plugin => "plugin",
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_percent(attributed_tokens: i64, total_tokens: i64) -> u8 {
|
||||
if attributed_tokens <= 0 || total_tokens <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let rounded = attributed_tokens
|
||||
.saturating_mul(/*rhs*/ 100)
|
||||
.saturating_add(total_tokens / 2)
|
||||
/ total_tokens;
|
||||
u8::try_from(rounded.max(/*other*/ 1).min(i64::from(u8::MAX))).unwrap_or(u8::MAX)
|
||||
}
|
||||
|
||||
fn usage_headline(report: &UsageReport) -> Option<UsageHeadline> {
|
||||
let entry = report
|
||||
.skills
|
||||
.iter()
|
||||
.chain(report.subagents.iter())
|
||||
.chain(report.agent_tasks.iter())
|
||||
.chain(report.apps.iter())
|
||||
.chain(report.mcp_servers.iter())
|
||||
.chain(report.plugins.iter())
|
||||
.max_by(|left, right| {
|
||||
left.attributed_tokens
|
||||
.cmp(&right.attributed_tokens)
|
||||
.then_with(|| right.label.cmp(&left.label))
|
||||
})?
|
||||
.clone();
|
||||
let note = matches!(
|
||||
entry.kind,
|
||||
UsageContributorKind::App | UsageContributorKind::McpServer
|
||||
)
|
||||
.then(|| {
|
||||
"Tool results stay in context until compaction; compact or disable sources you do not need."
|
||||
.to_string()
|
||||
});
|
||||
Some(UsageHeadline { entry, note })
|
||||
}
|
||||
|
||||
fn agent_task_label(source: &str) -> String {
|
||||
let parsed_source = serde_json::from_str(source)
|
||||
.or_else(|_| serde_json::from_value::<SessionSource>(Value::String(source.to_string())));
|
||||
match parsed_source.ok() {
|
||||
Some(SessionSource::SubAgent(SubAgentSource::Review)) => "review".to_string(),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::Compact)) => "compact".to_string(),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::MemoryConsolidation)) => {
|
||||
"memory-consolidation".to_string()
|
||||
}
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })) => {
|
||||
"thread-spawned".to_string()
|
||||
}
|
||||
Some(SessionSource::SubAgent(SubAgentSource::Other(other))) => other,
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::runtime::test_support::test_thread_metadata;
|
||||
use crate::runtime::test_support::unique_temp_dir;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::UsageAttributionContributor;
|
||||
use codex_protocol::protocol::UsageAttributionItem;
|
||||
use codex_protocol::protocol::UsageContributor;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_report_groups_forward_only_samples_by_range() {
|
||||
let codex_home = unique_temp_dir();
|
||||
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let user_thread_id =
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000901").expect("valid thread id");
|
||||
let subagent_thread_id =
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000902").expect("valid thread id");
|
||||
let now = Utc::now().timestamp();
|
||||
|
||||
runtime
|
||||
.upsert_thread(&test_thread_metadata(
|
||||
&codex_home,
|
||||
user_thread_id,
|
||||
codex_home.clone(),
|
||||
))
|
||||
.await
|
||||
.expect("user thread insert should succeed");
|
||||
let mut subagent_metadata =
|
||||
test_thread_metadata(&codex_home, subagent_thread_id, codex_home.clone());
|
||||
subagent_metadata.thread_source = Some(ThreadSource::Subagent);
|
||||
subagent_metadata.source =
|
||||
serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: user_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("code-review".to_string()),
|
||||
}))
|
||||
.expect("thread spawn source should serialize");
|
||||
subagent_metadata.agent_role = Some("code-review".to_string());
|
||||
runtime
|
||||
.upsert_thread(&subagent_metadata)
|
||||
.await
|
||||
.expect("subagent thread insert should succeed");
|
||||
|
||||
runtime
|
||||
.record_usage_sample(&usage_sample(
|
||||
user_thread_id,
|
||||
"recent-user",
|
||||
/*occurred_at*/ now - 100,
|
||||
TokenUsage {
|
||||
input_tokens: 100,
|
||||
cached_input_tokens: 20,
|
||||
output_tokens: 40,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 140,
|
||||
},
|
||||
vec![
|
||||
contributor(
|
||||
UsageContributorKind::Skill,
|
||||
"/skills/tmux",
|
||||
"tmux",
|
||||
/*attributed_tokens*/ 50,
|
||||
),
|
||||
contributor(
|
||||
UsageContributorKind::App,
|
||||
"slack",
|
||||
"Slack",
|
||||
/*attributed_tokens*/ 70,
|
||||
),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.expect("recent usage sample should persist");
|
||||
runtime
|
||||
.record_usage_sample(&usage_sample(
|
||||
subagent_thread_id,
|
||||
"recent-subagent",
|
||||
/*occurred_at*/ now - 50,
|
||||
TokenUsage {
|
||||
input_tokens: 30,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 10,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 40,
|
||||
},
|
||||
Vec::new(),
|
||||
))
|
||||
.await
|
||||
.expect("subagent usage sample should persist");
|
||||
runtime
|
||||
.record_usage_sample(&usage_sample(
|
||||
user_thread_id,
|
||||
"old-user",
|
||||
/*occurred_at*/ now - UsageRange::Day.seconds() - 1,
|
||||
TokenUsage {
|
||||
input_tokens: 10,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 10,
|
||||
},
|
||||
vec![contributor(
|
||||
UsageContributorKind::McpServer,
|
||||
"old-mcp",
|
||||
"old-mcp",
|
||||
/*attributed_tokens*/ 10,
|
||||
)],
|
||||
))
|
||||
.await
|
||||
.expect("old usage sample should persist");
|
||||
|
||||
assert_eq!(
|
||||
runtime
|
||||
.read_usage_report(UsageRange::Day, now)
|
||||
.await
|
||||
.expect("usage report should load"),
|
||||
UsageReport {
|
||||
range: UsageRange::Day,
|
||||
generated_at: now,
|
||||
tracked_from: Some(now - UsageRange::Day.seconds() - 1),
|
||||
total_tokens: 160,
|
||||
headline: Some(UsageHeadline {
|
||||
entry: UsageEntry {
|
||||
kind: UsageContributorKind::App,
|
||||
id: "slack".to_string(),
|
||||
label: "Slack".to_string(),
|
||||
attributed_tokens: 70,
|
||||
percent_of_usage: 44,
|
||||
},
|
||||
note: Some(
|
||||
"Tool results stay in context until compaction; compact or disable sources you do not need."
|
||||
.to_string(),
|
||||
),
|
||||
}),
|
||||
skills: vec![UsageEntry {
|
||||
kind: UsageContributorKind::Skill,
|
||||
id: "/skills/tmux".to_string(),
|
||||
label: "tmux".to_string(),
|
||||
attributed_tokens: 50,
|
||||
percent_of_usage: 31,
|
||||
}],
|
||||
subagents: vec![UsageEntry {
|
||||
kind: UsageContributorKind::Subagent,
|
||||
id: "code-review".to_string(),
|
||||
label: "code-review".to_string(),
|
||||
attributed_tokens: 40,
|
||||
percent_of_usage: 25,
|
||||
}],
|
||||
agent_tasks: vec![UsageEntry {
|
||||
kind: UsageContributorKind::AgentTask,
|
||||
id: "thread-spawned".to_string(),
|
||||
label: "thread-spawned".to_string(),
|
||||
attributed_tokens: 40,
|
||||
percent_of_usage: 25,
|
||||
}],
|
||||
apps: vec![UsageEntry {
|
||||
kind: UsageContributorKind::App,
|
||||
id: "slack".to_string(),
|
||||
label: "Slack".to_string(),
|
||||
attributed_tokens: 70,
|
||||
percent_of_usage: 44,
|
||||
}],
|
||||
mcp_servers: Vec::new(),
|
||||
plugins: Vec::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_report_labels_default_subagents_as_default() {
|
||||
let codex_home = unique_temp_dir();
|
||||
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let subagent_thread_id =
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000905").expect("valid thread id");
|
||||
let mut subagent_metadata =
|
||||
test_thread_metadata(&codex_home, subagent_thread_id, codex_home.clone());
|
||||
subagent_metadata.thread_source = Some(ThreadSource::Subagent);
|
||||
runtime
|
||||
.upsert_thread(&subagent_metadata)
|
||||
.await
|
||||
.expect("subagent thread insert should succeed");
|
||||
let now = Utc::now().timestamp();
|
||||
|
||||
runtime
|
||||
.record_usage_sample(&usage_sample(
|
||||
subagent_thread_id,
|
||||
"default-subagent",
|
||||
/*occurred_at*/ now,
|
||||
TokenUsage {
|
||||
input_tokens: 10,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 5,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 15,
|
||||
},
|
||||
Vec::new(),
|
||||
))
|
||||
.await
|
||||
.expect("subagent usage sample should persist");
|
||||
|
||||
let report = runtime
|
||||
.read_usage_report(UsageRange::Day, now)
|
||||
.await
|
||||
.expect("usage report should load");
|
||||
|
||||
assert_eq!(
|
||||
report.subagents,
|
||||
vec![UsageEntry {
|
||||
kind: UsageContributorKind::Subagent,
|
||||
id: "default".to_string(),
|
||||
label: "default".to_string(),
|
||||
attributed_tokens: 15,
|
||||
percent_of_usage: 100,
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
report.agent_tasks,
|
||||
vec![UsageEntry {
|
||||
kind: UsageContributorKind::AgentTask,
|
||||
id: "unknown".to_string(),
|
||||
label: "unknown".to_string(),
|
||||
attributed_tokens: 15,
|
||||
percent_of_usage: 100,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_report_groups_agent_tasks_by_subagent_source() {
|
||||
let codex_home = unique_temp_dir();
|
||||
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let parent_thread_id =
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000906").expect("valid thread id");
|
||||
let review_thread_id =
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000907").expect("valid thread id");
|
||||
let guardian_thread_id =
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000908").expect("valid thread id");
|
||||
let spawned_thread_id =
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000909").expect("valid thread id");
|
||||
let unknown_thread_id =
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000910").expect("valid thread id");
|
||||
let now = Utc::now().timestamp();
|
||||
|
||||
runtime
|
||||
.upsert_thread(&test_thread_metadata(
|
||||
&codex_home,
|
||||
parent_thread_id,
|
||||
codex_home.clone(),
|
||||
))
|
||||
.await
|
||||
.expect("parent thread insert should succeed");
|
||||
for (thread_id, source) in [
|
||||
(
|
||||
review_thread_id,
|
||||
serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::Review))
|
||||
.expect("review source should serialize"),
|
||||
),
|
||||
(
|
||||
guardian_thread_id,
|
||||
serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::Other(
|
||||
"guardian".to_string(),
|
||||
)))
|
||||
.expect("guardian source should serialize"),
|
||||
),
|
||||
(
|
||||
spawned_thread_id,
|
||||
serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: Some("Bacon".to_string()),
|
||||
agent_role: None,
|
||||
}))
|
||||
.expect("thread spawn source should serialize"),
|
||||
),
|
||||
(unknown_thread_id, "not-json".to_string()),
|
||||
] {
|
||||
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
|
||||
metadata.thread_source = Some(ThreadSource::Subagent);
|
||||
metadata.source = source;
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
.expect("subagent thread insert should succeed");
|
||||
}
|
||||
|
||||
for (thread_id, sample_id, input_tokens) in [
|
||||
(review_thread_id, "review-agent-task", 10),
|
||||
(guardian_thread_id, "guardian-agent-task", 20),
|
||||
(spawned_thread_id, "spawned-agent-task", 30),
|
||||
(unknown_thread_id, "unknown-agent-task", 40),
|
||||
] {
|
||||
runtime
|
||||
.record_usage_sample(&usage_sample(
|
||||
thread_id,
|
||||
sample_id,
|
||||
/*occurred_at*/ now,
|
||||
TokenUsage {
|
||||
input_tokens,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: input_tokens,
|
||||
},
|
||||
Vec::new(),
|
||||
))
|
||||
.await
|
||||
.expect("usage sample should persist");
|
||||
}
|
||||
|
||||
let report = runtime
|
||||
.read_usage_report(UsageRange::Day, now)
|
||||
.await
|
||||
.expect("usage report should load");
|
||||
|
||||
assert_eq!(
|
||||
report.agent_tasks,
|
||||
vec![
|
||||
UsageEntry {
|
||||
kind: UsageContributorKind::AgentTask,
|
||||
id: "unknown".to_string(),
|
||||
label: "unknown".to_string(),
|
||||
attributed_tokens: 40,
|
||||
percent_of_usage: 40,
|
||||
},
|
||||
UsageEntry {
|
||||
kind: UsageContributorKind::AgentTask,
|
||||
id: "thread-spawned".to_string(),
|
||||
label: "thread-spawned".to_string(),
|
||||
attributed_tokens: 30,
|
||||
percent_of_usage: 30,
|
||||
},
|
||||
UsageEntry {
|
||||
kind: UsageContributorKind::AgentTask,
|
||||
id: "guardian".to_string(),
|
||||
label: "guardian".to_string(),
|
||||
attributed_tokens: 20,
|
||||
percent_of_usage: 20,
|
||||
},
|
||||
UsageEntry {
|
||||
kind: UsageContributorKind::AgentTask,
|
||||
id: "review".to_string(),
|
||||
label: "review".to_string(),
|
||||
attributed_tokens: 10,
|
||||
percent_of_usage: 10,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_usage_sample_prunes_samples_older_than_retention() {
|
||||
let codex_home = unique_temp_dir();
|
||||
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let thread_id =
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000903").expect("valid thread id");
|
||||
runtime
|
||||
.upsert_thread(&test_thread_metadata(
|
||||
&codex_home,
|
||||
thread_id,
|
||||
codex_home.clone(),
|
||||
))
|
||||
.await
|
||||
.expect("thread insert should succeed");
|
||||
let now = Utc::now().timestamp();
|
||||
|
||||
runtime
|
||||
.record_usage_sample(&usage_sample(
|
||||
thread_id,
|
||||
"stale",
|
||||
/*occurred_at*/ now - USAGE_RETENTION_SECONDS - 1,
|
||||
TokenUsage {
|
||||
input_tokens: 10,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 5,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 15,
|
||||
},
|
||||
vec![contributor(
|
||||
UsageContributorKind::Skill,
|
||||
"/skills/stale",
|
||||
"stale",
|
||||
/*attributed_tokens*/ 10,
|
||||
)],
|
||||
))
|
||||
.await
|
||||
.expect("stale usage sample should persist then prune");
|
||||
runtime
|
||||
.record_usage_sample(&usage_sample(
|
||||
thread_id,
|
||||
"retained",
|
||||
/*occurred_at*/ now - UsageRange::Week.seconds(),
|
||||
TokenUsage {
|
||||
input_tokens: 10,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 5,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 15,
|
||||
},
|
||||
vec![contributor(
|
||||
UsageContributorKind::Skill,
|
||||
"/skills/retained",
|
||||
"retained",
|
||||
/*attributed_tokens*/ 10,
|
||||
)],
|
||||
))
|
||||
.await
|
||||
.expect("retained usage sample should persist");
|
||||
|
||||
assert_eq!(usage_sample_count(&runtime).await, 1);
|
||||
assert_eq!(usage_contributor_count(&runtime).await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_startup_maintenance_prunes_stale_samples() {
|
||||
let codex_home = unique_temp_dir();
|
||||
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let thread_id =
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000904").expect("valid thread id");
|
||||
runtime
|
||||
.upsert_thread(&test_thread_metadata(
|
||||
&codex_home,
|
||||
thread_id,
|
||||
codex_home.clone(),
|
||||
))
|
||||
.await
|
||||
.expect("thread insert should succeed");
|
||||
let now = Utc::now().timestamp();
|
||||
runtime
|
||||
.record_usage_sample(&usage_sample(
|
||||
thread_id,
|
||||
"stale-after-write",
|
||||
/*occurred_at*/ now,
|
||||
TokenUsage {
|
||||
input_tokens: 10,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 5,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 15,
|
||||
},
|
||||
vec![contributor(
|
||||
UsageContributorKind::Skill,
|
||||
"/skills/stale",
|
||||
"stale",
|
||||
/*attributed_tokens*/ 10,
|
||||
)],
|
||||
))
|
||||
.await
|
||||
.expect("usage sample should persist");
|
||||
sqlx::query("UPDATE usage_samples SET occurred_at = ? WHERE sample_id = ?")
|
||||
.bind(/*value*/ now - USAGE_RETENTION_SECONDS - 1)
|
||||
.bind("stale-after-write")
|
||||
.execute(runtime.pool.as_ref())
|
||||
.await
|
||||
.expect("usage sample should age");
|
||||
|
||||
runtime
|
||||
.run_usage_startup_maintenance()
|
||||
.await
|
||||
.expect("usage startup maintenance should succeed");
|
||||
|
||||
assert_eq!(usage_sample_count(&runtime).await, 0);
|
||||
assert_eq!(usage_contributor_count(&runtime).await, 0);
|
||||
}
|
||||
|
||||
fn usage_sample(
|
||||
thread_id: ThreadId,
|
||||
sample_id: &str,
|
||||
occurred_at: i64,
|
||||
token_usage: TokenUsage,
|
||||
contributors: Vec<UsageAttributionContributor>,
|
||||
) -> UsageSample {
|
||||
UsageSample {
|
||||
thread_id,
|
||||
attribution: UsageAttributionItem {
|
||||
sample_id: sample_id.to_string(),
|
||||
turn_id: format!("{sample_id}-turn"),
|
||||
response_id: format!("{sample_id}-response"),
|
||||
occurred_at,
|
||||
token_usage,
|
||||
prompt_estimated_tokens: 100,
|
||||
contributors,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn contributor(
|
||||
kind: UsageContributorKind,
|
||||
id: &str,
|
||||
label: &str,
|
||||
attributed_tokens: i64,
|
||||
) -> UsageAttributionContributor {
|
||||
UsageAttributionContributor {
|
||||
contributor: UsageContributor {
|
||||
kind,
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
},
|
||||
source_estimated_tokens: attributed_tokens,
|
||||
attributed_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
async fn usage_sample_count(runtime: &StateRuntime) -> i64 {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM usage_samples")
|
||||
.fetch_one(runtime.pool.as_ref())
|
||||
.await
|
||||
.expect("usage sample count should load")
|
||||
}
|
||||
|
||||
async fn usage_contributor_count(runtime: &StateRuntime) -> i64 {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM usage_sample_contributors")
|
||||
.fetch_one(runtime.pool.as_ref())
|
||||
.await
|
||||
.expect("usage contributor count should load")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ use codex_app_server_protocol::MarketplaceRemoveParams;
|
||||
use codex_app_server_protocol::MarketplaceRemoveResponse;
|
||||
use codex_app_server_protocol::MarketplaceUpgradeParams;
|
||||
use codex_app_server_protocol::MarketplaceUpgradeResponse;
|
||||
use codex_app_server_protocol::UsageRange;
|
||||
use codex_app_server_protocol::UsageReadParams;
|
||||
use codex_app_server_protocol::UsageReadResponse;
|
||||
|
||||
use codex_app_server_protocol::RequestId;
|
||||
|
||||
@@ -127,6 +130,22 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn fetch_usage(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
request_id: u64,
|
||||
range: UsageRange,
|
||||
) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = fetch_usage(request_handle, range)
|
||||
.await
|
||||
.map_err(|err| err.to_string());
|
||||
app_event_tx.send(AppEvent::UsageLoaded { request_id, result });
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn fetch_hooks_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
@@ -577,6 +596,20 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_usage(
|
||||
request_handle: AppServerRequestHandle,
|
||||
range: UsageRange,
|
||||
) -> Result<UsageReadResponse> {
|
||||
let request_id = RequestId::String(format!("usage-read-{}", uuid::Uuid::new_v4()));
|
||||
request_handle
|
||||
.request_typed(ClientRequest::UsageRead {
|
||||
request_id,
|
||||
params: UsageReadParams { range },
|
||||
})
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub(super) async fn fetch_all_mcp_server_statuses(
|
||||
request_handle: AppServerRequestHandle,
|
||||
detail: McpServerStatusDetail,
|
||||
|
||||
@@ -436,6 +436,12 @@ impl App {
|
||||
AppEvent::FetchPluginsList { cwd } => {
|
||||
self.fetch_plugins_list(app_server, cwd);
|
||||
}
|
||||
AppEvent::FetchUsage { request_id, range } => {
|
||||
self.fetch_usage(app_server, request_id, range);
|
||||
}
|
||||
AppEvent::UsageLoaded { request_id, result } => {
|
||||
self.chat_widget.on_usage_loaded(request_id, result);
|
||||
}
|
||||
AppEvent::FetchHooksList { cwd } => {
|
||||
self.fetch_hooks_list(app_server, cwd);
|
||||
}
|
||||
|
||||
@@ -371,6 +371,16 @@ pub(crate) enum AppEvent {
|
||||
cwd: PathBuf,
|
||||
},
|
||||
|
||||
FetchUsage {
|
||||
request_id: u64,
|
||||
range: codex_app_server_protocol::UsageRange,
|
||||
},
|
||||
|
||||
UsageLoaded {
|
||||
request_id: u64,
|
||||
result: Result<codex_app_server_protocol::UsageReadResponse, String>,
|
||||
},
|
||||
|
||||
/// Fetch lifecycle hook inventory for the provided working directory.
|
||||
FetchHooksList {
|
||||
cwd: PathBuf,
|
||||
|
||||
@@ -297,6 +297,7 @@ mod tests {
|
||||
SlashCommand::Diff,
|
||||
SlashCommand::Mention,
|
||||
SlashCommand::Status,
|
||||
SlashCommand::Usage,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -361,6 +361,7 @@ use self::skills::collect_tool_mentions;
|
||||
use self::skills::find_app_mentions;
|
||||
use self::skills::find_skill_mentions_with_tool_mentions;
|
||||
mod plugins;
|
||||
mod usage;
|
||||
use self::plugins::PluginInstallAuthFlowState;
|
||||
use self::plugins::PluginListFetchState;
|
||||
use self::plugins::PluginsCacheState;
|
||||
@@ -537,6 +538,8 @@ pub(crate) struct ChatWidget {
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap<String, RateLimitSnapshotDisplay>,
|
||||
refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>,
|
||||
next_status_refresh_request_id: u64,
|
||||
next_usage_request_id: u64,
|
||||
active_usage_request_id: Option<u64>,
|
||||
plan_type: Option<PlanType>,
|
||||
codex_rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
rate_limit_warnings: RateLimitWarningState,
|
||||
|
||||
@@ -123,6 +123,8 @@ impl ChatWidget {
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
|
||||
refreshing_status_outputs: Vec::new(),
|
||||
next_status_refresh_request_id: 0,
|
||||
next_usage_request_id: 0,
|
||||
active_usage_request_id: None,
|
||||
plan_type: initial_plan_type,
|
||||
codex_rate_limit_reached_type: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
|
||||
@@ -36,6 +36,13 @@ const GOAL_USAGE: &str = "Usage: /goal <objective>";
|
||||
const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage";
|
||||
const RAW_USAGE: &str = "Usage: /raw [on|off]";
|
||||
|
||||
fn usage_range_from_arg(arg: &str) -> Option<codex_app_server_protocol::UsageRange> {
|
||||
match arg.to_ascii_lowercase().as_str() {
|
||||
"week" | "weekly" => Some(codex_app_server_protocol::UsageRange::Week),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
/// Dispatch a bare slash command and record its staged local-history entry.
|
||||
///
|
||||
@@ -382,6 +389,9 @@ impl ChatWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
SlashCommand::Usage => {
|
||||
self.add_usage_output();
|
||||
}
|
||||
SlashCommand::Ide => {
|
||||
self.handle_ide_command();
|
||||
}
|
||||
@@ -614,6 +624,10 @@ impl ChatWidget {
|
||||
}
|
||||
_ => self.add_error_message(RAW_USAGE.to_string()),
|
||||
},
|
||||
SlashCommand::Usage => match usage_range_from_arg(trimmed) {
|
||||
Some(range) => self.add_usage_output_for_range(range),
|
||||
None => self.add_error_message("Usage: /usage [week|weekly]".to_string()),
|
||||
},
|
||||
SlashCommand::Rename if !trimmed.is_empty() => {
|
||||
if !self.ensure_thread_rename_allowed() {
|
||||
return;
|
||||
@@ -929,6 +943,7 @@ impl ChatWidget {
|
||||
match cmd {
|
||||
SlashCommand::Ide
|
||||
| SlashCommand::Status
|
||||
| SlashCommand::Usage
|
||||
| SlashCommand::DebugConfig
|
||||
| SlashCommand::Ps
|
||||
| SlashCommand::Stop
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: rendered
|
||||
---
|
||||
/usage
|
||||
|
||||
╭───────────────────────────────────────────────────╮
|
||||
│ Daily usage by token share │
|
||||
│ Percent of consumed tokens in this selected range │
|
||||
│ (/usage week for weekly) │
|
||||
│ │
|
||||
│ 11% of consumed tokens came from app "testmcp" │
|
||||
│ Tool results stay in context until compaction; │
|
||||
│ compact or disable sources you do not need. │
|
||||
│ │
|
||||
│ Skills │
|
||||
│ ├─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% │
|
||||
│ └─ /babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 6% │
|
||||
│ │
|
||||
│ Subagents │
|
||||
│ ├─ babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% │
|
||||
│ └─ code-review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 9% │
|
||||
│ │
|
||||
│ Agent tasks │
|
||||
│ ├─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% │
|
||||
│ └─ review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 5% │
|
||||
│ │
|
||||
│ Apps │
|
||||
│ └─ testmcp ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 11% │
|
||||
╰───────────────────────────────────────────────────╯
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: rendered
|
||||
---
|
||||
/usage
|
||||
|
||||
╭───────────────────────────────────────────────────╮
|
||||
│ Daily usage by token share │
|
||||
│ Percent of consumed tokens in this selected range │
|
||||
│ (/usage week for weekly) │
|
||||
│ │
|
||||
│ No attributed skills, subagents, agent tasks, │
|
||||
│ apps, MCP servers, or plugins in this range. │
|
||||
╰───────────────────────────────────────────────────╯
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: rendered
|
||||
---
|
||||
/usage week
|
||||
|
||||
╭────────────────────────────────────────────────────────╮
|
||||
│ Weekly usage by token share, Nov 7 to Nov 14 │
|
||||
│ Percent of consumed tokens in this selected range │
|
||||
│ │
|
||||
│ 17% of consumed tokens came from agent task "guardian" │
|
||||
│ │
|
||||
│ Skills │
|
||||
│ └─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% │
|
||||
│ │
|
||||
│ Subagents │
|
||||
│ └─ default ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% │
|
||||
│ │
|
||||
│ Agent tasks │
|
||||
│ └─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% │
|
||||
╰────────────────────────────────────────────────────────╯
|
||||
@@ -116,6 +116,12 @@ pub(super) use codex_app_server_protocol::TurnCompletedNotification;
|
||||
pub(super) use codex_app_server_protocol::TurnError as AppServerTurnError;
|
||||
pub(super) use codex_app_server_protocol::TurnStartedNotification;
|
||||
pub(super) use codex_app_server_protocol::TurnStatus as AppServerTurnStatus;
|
||||
pub(super) use codex_app_server_protocol::UsageContributorKind;
|
||||
pub(super) use codex_app_server_protocol::UsageEntry;
|
||||
pub(super) use codex_app_server_protocol::UsageHeadline;
|
||||
pub(super) use codex_app_server_protocol::UsageRange;
|
||||
pub(super) use codex_app_server_protocol::UsageReadResponse;
|
||||
pub(super) use codex_app_server_protocol::UsageReport;
|
||||
pub(super) use codex_app_server_protocol::UserInput;
|
||||
pub(super) use codex_app_server_protocol::UserInput as AppServerUserInput;
|
||||
pub(super) use codex_app_server_protocol::WarningNotification;
|
||||
|
||||
@@ -111,6 +111,201 @@ async fn plugins_popup_loading_state_snapshot() {
|
||||
assert_chatwidget_snapshot!("plugins_popup_loading_state", popup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_output_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.add_usage_output();
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::FetchUsage {
|
||||
request_id: 0,
|
||||
range: UsageRange::Day,
|
||||
})
|
||||
);
|
||||
chat.on_usage_loaded(
|
||||
/*request_id*/ 0,
|
||||
Ok(UsageReadResponse {
|
||||
report: UsageReport {
|
||||
range: UsageRange::Day,
|
||||
generated_at: 1_700_000_000,
|
||||
tracked_from: Some(/*tracked_from*/ 1_699_999_000),
|
||||
total_tokens: 100,
|
||||
headline: Some(UsageHeadline {
|
||||
entry: usage_entry(
|
||||
UsageContributorKind::App,
|
||||
"testmcp",
|
||||
"testmcp",
|
||||
/*percent_of_usage*/ 11,
|
||||
),
|
||||
note: Some(
|
||||
"Tool results stay in context until compaction; compact or disable sources you do not need."
|
||||
.to_string(),
|
||||
),
|
||||
}),
|
||||
skills: vec![
|
||||
usage_entry(
|
||||
UsageContributorKind::Skill,
|
||||
"/skills/tmux",
|
||||
"/tmux",
|
||||
/*percent_of_usage*/ 8,
|
||||
),
|
||||
usage_entry(
|
||||
UsageContributorKind::Skill,
|
||||
"/skills/babysit",
|
||||
"/babysit",
|
||||
/*percent_of_usage*/ 6,
|
||||
),
|
||||
],
|
||||
subagents: vec![
|
||||
usage_entry(
|
||||
UsageContributorKind::Subagent,
|
||||
"babysit",
|
||||
"babysit",
|
||||
/*percent_of_usage*/ 13,
|
||||
),
|
||||
usage_entry(
|
||||
UsageContributorKind::Subagent,
|
||||
"code-review",
|
||||
"code-review",
|
||||
/*percent_of_usage*/ 9,
|
||||
),
|
||||
],
|
||||
agent_tasks: vec![
|
||||
usage_entry(
|
||||
UsageContributorKind::AgentTask,
|
||||
"guardian",
|
||||
"guardian",
|
||||
/*percent_of_usage*/ 17,
|
||||
),
|
||||
usage_entry(
|
||||
UsageContributorKind::AgentTask,
|
||||
"review",
|
||||
"review",
|
||||
/*percent_of_usage*/ 5,
|
||||
),
|
||||
],
|
||||
apps: vec![usage_entry(
|
||||
UsageContributorKind::App,
|
||||
"testmcp",
|
||||
"testmcp",
|
||||
/*percent_of_usage*/ 11,
|
||||
)],
|
||||
mcp_servers: Vec::new(),
|
||||
plugins: Vec::new(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let rendered = drain_insert_history(&mut rx)
|
||||
.into_iter()
|
||||
.map(|lines| lines_to_single_string(&lines))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
assert_chatwidget_snapshot!("usage_output", rendered);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_output_reports_unattributed_usage() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.add_usage_output();
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::FetchUsage {
|
||||
request_id: 0,
|
||||
range: UsageRange::Day,
|
||||
})
|
||||
);
|
||||
chat.on_usage_loaded(
|
||||
/*request_id*/ 0,
|
||||
Ok(UsageReadResponse {
|
||||
report: UsageReport {
|
||||
range: UsageRange::Day,
|
||||
generated_at: 1_700_000_000,
|
||||
tracked_from: Some(/*tracked_from*/ 1_699_999_000),
|
||||
total_tokens: 100,
|
||||
headline: None,
|
||||
skills: Vec::new(),
|
||||
subagents: Vec::new(),
|
||||
agent_tasks: Vec::new(),
|
||||
apps: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
plugins: Vec::new(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let rendered = drain_insert_history(&mut rx)
|
||||
.into_iter()
|
||||
.map(|lines| lines_to_single_string(&lines))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
assert_chatwidget_snapshot!("usage_output_unattributed", rendered);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_output_weekly_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.dispatch_command_with_args(SlashCommand::Usage, "week".to_string(), Vec::new());
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::FetchUsage {
|
||||
request_id: 0,
|
||||
range: UsageRange::Week,
|
||||
})
|
||||
);
|
||||
chat.on_usage_loaded(
|
||||
/*request_id*/ 0,
|
||||
Ok(UsageReadResponse {
|
||||
report: UsageReport {
|
||||
range: UsageRange::Week,
|
||||
generated_at: 1_700_000_000,
|
||||
tracked_from: Some(/*tracked_from*/ 1_699_395_200),
|
||||
total_tokens: 100,
|
||||
headline: Some(UsageHeadline {
|
||||
entry: usage_entry(
|
||||
UsageContributorKind::AgentTask,
|
||||
"guardian",
|
||||
"guardian",
|
||||
/*percent_of_usage*/ 17,
|
||||
),
|
||||
note: None,
|
||||
}),
|
||||
skills: vec![usage_entry(
|
||||
UsageContributorKind::Skill,
|
||||
"/skills/tmux",
|
||||
"/tmux",
|
||||
/*percent_of_usage*/ 8,
|
||||
)],
|
||||
subagents: vec![usage_entry(
|
||||
UsageContributorKind::Subagent,
|
||||
"default",
|
||||
"default",
|
||||
/*percent_of_usage*/ 13,
|
||||
)],
|
||||
agent_tasks: vec![usage_entry(
|
||||
UsageContributorKind::AgentTask,
|
||||
"guardian",
|
||||
"guardian",
|
||||
/*percent_of_usage*/ 17,
|
||||
)],
|
||||
apps: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
plugins: Vec::new(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let rendered = drain_insert_history(&mut rx)
|
||||
.into_iter()
|
||||
.map(|lines| lines_to_single_string(&lines))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
assert_chatwidget_snapshot!("usage_output_weekly", rendered);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marketplace_upgrade_loading_popup_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -131,6 +326,21 @@ async fn marketplace_upgrade_loading_popup_snapshot() {
|
||||
);
|
||||
}
|
||||
|
||||
fn usage_entry(
|
||||
kind: UsageContributorKind,
|
||||
id: &str,
|
||||
label: &str,
|
||||
percent_of_usage: u8,
|
||||
) -> UsageEntry {
|
||||
UsageEntry {
|
||||
kind,
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
attributed_tokens: i64::from(percent_of_usage),
|
||||
percent_of_usage,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marketplace_upgrade_failure_includes_backend_messages_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
531
codex-rs/tui/src/chatwidget/usage.rs
Normal file
531
codex-rs/tui/src/chatwidget/usage.rs
Normal file
@@ -0,0 +1,531 @@
|
||||
use super::ChatWidget;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::color::blend;
|
||||
use crate::color::is_light;
|
||||
use crate::history_cell::CompositeHistoryCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crate::history_cell::plain_lines;
|
||||
use crate::history_cell::with_border_with_inner_width;
|
||||
use crate::style::accent_style;
|
||||
use crate::terminal_palette::best_color;
|
||||
use crate::terminal_palette::default_bg;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::UsageContributorKind;
|
||||
use codex_app_server_protocol::UsageEntry;
|
||||
use codex_app_server_protocol::UsageRange;
|
||||
use codex_app_server_protocol::UsageReadResponse;
|
||||
use codex_app_server_protocol::UsageReport;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Stylize;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
const USAGE_CARD_MAX_INNER_WIDTH: usize = 72;
|
||||
const USAGE_BAR_WIDTH: usize = 20;
|
||||
const USAGE_BAR_MIN_LABEL_WIDTH: usize = 8;
|
||||
const USAGE_BAR_GLYPH: &str = "▄";
|
||||
const USAGE_TITLE: &str = "Usage by token share";
|
||||
const USAGE_SUBTITLE: &str = "Percent of consumed tokens in this selected range";
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn add_usage_output(&mut self) {
|
||||
self.add_usage_output_for_range(UsageRange::Day);
|
||||
}
|
||||
|
||||
pub(crate) fn add_usage_output_for_range(&mut self, range: UsageRange) {
|
||||
self.request_usage(range);
|
||||
}
|
||||
|
||||
fn request_usage(&mut self, range: UsageRange) {
|
||||
let request_id = self.next_usage_request_id;
|
||||
self.next_usage_request_id = self.next_usage_request_id.saturating_add(/*rhs*/ 1);
|
||||
self.active_usage_request_id = Some(request_id);
|
||||
self.app_event_tx
|
||||
.send(AppEvent::FetchUsage { request_id, range });
|
||||
}
|
||||
|
||||
pub(crate) fn on_usage_loaded(
|
||||
&mut self,
|
||||
request_id: u64,
|
||||
result: Result<UsageReadResponse, String>,
|
||||
) {
|
||||
if self.active_usage_request_id != Some(request_id) {
|
||||
return;
|
||||
}
|
||||
self.active_usage_request_id = None;
|
||||
let cell = match result {
|
||||
Ok(response) => new_usage_output(response.report),
|
||||
Err(err) => new_usage_error_output(err),
|
||||
};
|
||||
self.add_to_history(cell);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UsageHistoryCell {
|
||||
report: UsageReport,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UsageErrorHistoryCell {
|
||||
error: String,
|
||||
}
|
||||
|
||||
fn new_usage_output(report: UsageReport) -> CompositeHistoryCell {
|
||||
let command = PlainHistoryCell::new(vec![usage_command_label(report.range).magenta().into()]);
|
||||
CompositeHistoryCell::new(vec![
|
||||
Box::new(command),
|
||||
Box::new(UsageHistoryCell { report }),
|
||||
])
|
||||
}
|
||||
|
||||
fn new_usage_error_output(error: String) -> CompositeHistoryCell {
|
||||
let command = PlainHistoryCell::new(vec!["/usage".magenta().into()]);
|
||||
CompositeHistoryCell::new(vec![
|
||||
Box::new(command),
|
||||
Box::new(UsageErrorHistoryCell { error }),
|
||||
])
|
||||
}
|
||||
|
||||
impl HistoryCell for UsageHistoryCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
usage_report_lines(&self.report, width)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.display_lines(u16::MAX))
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for UsageErrorHistoryCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let Some(available_width) = usage_card_available_width(width) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let lines = vec![
|
||||
Line::from(USAGE_TITLE.bold()),
|
||||
Line::from(USAGE_SUBTITLE.dim()),
|
||||
Line::default(),
|
||||
Line::from(format!(" Failed to load usage: {}", self.error)),
|
||||
];
|
||||
let inner_width = lines
|
||||
.iter()
|
||||
.map(line_display_width)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.min(available_width);
|
||||
with_border_with_inner_width(lines, inner_width)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.display_lines(u16::MAX))
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_report_lines(report: &UsageReport, width: u16) -> Vec<Line<'static>> {
|
||||
let Some(available_width) = usage_card_available_width(width) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let sections = [
|
||||
("Skills", report.skills.as_slice()),
|
||||
("Subagents", report.subagents.as_slice()),
|
||||
("Agent tasks", report.agent_tasks.as_slice()),
|
||||
("Apps", report.apps.as_slice()),
|
||||
("MCP servers", report.mcp_servers.as_slice()),
|
||||
("Plugins", report.plugins.as_slice()),
|
||||
];
|
||||
let label_column_width = usage_label_column_width(§ions);
|
||||
let inner_width = usage_content_width(report, §ions, label_column_width, available_width);
|
||||
let mut lines = usage_header_lines(report, inner_width);
|
||||
if let Some(headline) = report.headline.as_ref() {
|
||||
lines.push(Line::default());
|
||||
push_wrapped_line(
|
||||
&mut lines,
|
||||
vec![
|
||||
Span::from(format!(
|
||||
"{}% of consumed tokens came from {} \"{}\"",
|
||||
headline.entry.percent_of_usage,
|
||||
contributor_kind_label(headline.entry.kind),
|
||||
headline.entry.label
|
||||
))
|
||||
.italic()
|
||||
.dim(),
|
||||
],
|
||||
inner_width,
|
||||
);
|
||||
if let Some(note) = headline.note.as_ref() {
|
||||
push_wrapped_text(&mut lines, format!(" {note}"), inner_width);
|
||||
}
|
||||
}
|
||||
|
||||
if report.total_tokens == 0 {
|
||||
lines.push(Line::default());
|
||||
push_wrapped_text(
|
||||
&mut lines,
|
||||
" No tracked usage in this range yet.",
|
||||
inner_width,
|
||||
);
|
||||
return with_border_with_inner_width(lines, inner_width);
|
||||
}
|
||||
|
||||
if sections.iter().all(|(_, entries)| entries.is_empty()) {
|
||||
lines.push(Line::default());
|
||||
push_wrapped_text(
|
||||
&mut lines,
|
||||
" No attributed skills, subagents, agent tasks, apps, MCP servers, or plugins in this range.",
|
||||
inner_width,
|
||||
);
|
||||
return with_border_with_inner_width(lines, inner_width);
|
||||
}
|
||||
|
||||
for (label, entries) in sections {
|
||||
push_section(&mut lines, label, entries, label_column_width, inner_width);
|
||||
}
|
||||
with_border_with_inner_width(lines, inner_width)
|
||||
}
|
||||
|
||||
fn usage_card_available_width(width: u16) -> Option<usize> {
|
||||
(width >= 4).then(|| {
|
||||
usize::from(width.saturating_sub(/*rhs*/ 4)).min(USAGE_CARD_MAX_INNER_WIDTH)
|
||||
})
|
||||
}
|
||||
|
||||
fn usage_label_column_width(sections: &[(&'static str, &[UsageEntry])]) -> usize {
|
||||
sections
|
||||
.iter()
|
||||
.flat_map(|(_, entries)| entries.iter())
|
||||
.map(|entry| UnicodeWidthStr::width(entry.label.as_str()))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(/*rhs*/ 3)
|
||||
}
|
||||
|
||||
fn usage_content_width(
|
||||
report: &UsageReport,
|
||||
sections: &[(&'static str, &[UsageEntry])],
|
||||
label_column_width: usize,
|
||||
available_width: usize,
|
||||
) -> usize {
|
||||
let mut width = 0usize;
|
||||
for line in usage_header_lines(report, available_width) {
|
||||
width = width.max(line_display_width(&line));
|
||||
}
|
||||
|
||||
if let Some(headline) = report.headline.as_ref() {
|
||||
width = width.max(
|
||||
text_width(
|
||||
format!(
|
||||
"{}% of consumed tokens came from {} \"{}\"",
|
||||
headline.entry.percent_of_usage,
|
||||
contributor_kind_label(headline.entry.kind),
|
||||
headline.entry.label
|
||||
)
|
||||
.as_str(),
|
||||
)
|
||||
.min(available_width),
|
||||
);
|
||||
}
|
||||
|
||||
if report.total_tokens == 0 {
|
||||
return width.min(available_width);
|
||||
}
|
||||
|
||||
if sections.iter().all(|(_, entries)| entries.is_empty()) {
|
||||
return width.min(available_width);
|
||||
}
|
||||
|
||||
for (section_label, entries) in sections {
|
||||
if entries.is_empty() {
|
||||
continue;
|
||||
}
|
||||
width = width.max(text_width(format!(" {section_label}").as_str()));
|
||||
}
|
||||
|
||||
let prefix_width = text_width(" ├─ ");
|
||||
let percent_width = text_width("100%");
|
||||
let row_width_with_bar =
|
||||
prefix_width + label_column_width + USAGE_BAR_WIDTH + 2 + percent_width;
|
||||
let can_show_bar =
|
||||
available_width >= row_width_with_bar && label_column_width >= USAGE_BAR_MIN_LABEL_WIDTH;
|
||||
let row_width = if can_show_bar {
|
||||
row_width_with_bar
|
||||
} else {
|
||||
sections
|
||||
.iter()
|
||||
.flat_map(|(_, entries)| entries.iter())
|
||||
.map(|entry| prefix_width + text_width(entry.label.as_str()) + 1 + percent_width)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
width.max(row_width).min(available_width)
|
||||
}
|
||||
|
||||
fn usage_header_lines(report: &UsageReport, inner_width: usize) -> Vec<Line<'static>> {
|
||||
match report.range {
|
||||
UsageRange::Day => vec![
|
||||
Line::from(usage_title(report.range).bold()),
|
||||
Line::from(USAGE_SUBTITLE.dim()),
|
||||
vec![
|
||||
"(".dim(),
|
||||
Span::styled("/usage week", accent_style()),
|
||||
" for weekly)".dim(),
|
||||
]
|
||||
.into(),
|
||||
],
|
||||
UsageRange::Week => {
|
||||
let Some(period) = usage_period_label(report) else {
|
||||
return vec![
|
||||
Line::from(usage_title(report.range).bold()),
|
||||
Line::from(USAGE_SUBTITLE.dim()),
|
||||
];
|
||||
};
|
||||
let combined_title = format!("{}, {period}", usage_title(report.range));
|
||||
if text_width(&combined_title) <= inner_width {
|
||||
vec![
|
||||
Line::from(combined_title.bold()),
|
||||
Line::from(USAGE_SUBTITLE.dim()),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
Line::from(usage_title(report.range).bold()),
|
||||
Line::from(period),
|
||||
Line::from(USAGE_SUBTITLE.dim()),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_title(range: UsageRange) -> &'static str {
|
||||
match range {
|
||||
UsageRange::Day => "Daily usage by token share",
|
||||
UsageRange::Week => "Weekly usage by token share",
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_command_label(range: UsageRange) -> &'static str {
|
||||
match range {
|
||||
UsageRange::Day => "/usage",
|
||||
UsageRange::Week => "/usage week",
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_period_label(report: &UsageReport) -> Option<String> {
|
||||
let range_start = report
|
||||
.generated_at
|
||||
.saturating_sub(usage_range_seconds(report.range));
|
||||
let start = format_usage_date(range_start)?;
|
||||
let end = format_usage_date(report.generated_at)?;
|
||||
let label = format!("{start} to {end}");
|
||||
Some(label)
|
||||
}
|
||||
|
||||
fn usage_range_seconds(range: UsageRange) -> i64 {
|
||||
match range {
|
||||
UsageRange::Day => 24 * 60 * 60,
|
||||
UsageRange::Week => 7 * 24 * 60 * 60,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_usage_date(seconds: i64) -> Option<String> {
|
||||
DateTime::<Utc>::from_timestamp(seconds, /*nsecs*/ 0)
|
||||
.map(|timestamp| timestamp.format("%b %-d").to_string())
|
||||
}
|
||||
|
||||
fn push_wrapped_text(lines: &mut Vec<Line<'static>>, text: impl Into<String>, inner_width: usize) {
|
||||
push_wrapped_line(lines, Line::from(text.into()), inner_width);
|
||||
}
|
||||
|
||||
fn push_wrapped_line(
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
line: impl Into<Line<'static>>,
|
||||
inner_width: usize,
|
||||
) {
|
||||
lines.extend(word_wrap_lines(
|
||||
[line.into()],
|
||||
RtOptions::new(inner_width.max(/*other*/ 1)).subsequent_indent(" ".into()),
|
||||
));
|
||||
}
|
||||
|
||||
fn push_section(
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
label: &'static str,
|
||||
entries: &[UsageEntry],
|
||||
label_column_width: usize,
|
||||
inner_width: usize,
|
||||
) {
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
lines.push(Line::default());
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {label}"),
|
||||
accent_style(),
|
||||
)));
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
let is_last = index + 1 == entries.len();
|
||||
lines.push(usage_entry_line(
|
||||
entry,
|
||||
is_last,
|
||||
label_column_width,
|
||||
inner_width,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_entry_line(
|
||||
entry: &UsageEntry,
|
||||
is_last: bool,
|
||||
label_column_width: usize,
|
||||
inner_width: usize,
|
||||
) -> Line<'static> {
|
||||
let prefix = if is_last { " └─ " } else { " ├─ " };
|
||||
let percent = format!("{:>3}%", entry.percent_of_usage);
|
||||
let prefix_width = UnicodeWidthStr::width(prefix);
|
||||
let percent_width = UnicodeWidthStr::width(percent.as_str());
|
||||
let trailing_bar_width = USAGE_BAR_WIDTH + 2 + percent_width;
|
||||
let include_bar = inner_width >= prefix_width + label_column_width + trailing_bar_width
|
||||
&& label_column_width >= USAGE_BAR_MIN_LABEL_WIDTH;
|
||||
let label_width = if include_bar {
|
||||
label_column_width
|
||||
} else {
|
||||
inner_width.saturating_sub(prefix_width + percent_width + 1)
|
||||
};
|
||||
let label = truncate_to_width(&entry.label, label_width);
|
||||
let used_width = if include_bar {
|
||||
prefix_width + label_column_width + trailing_bar_width
|
||||
} else {
|
||||
prefix_width + UnicodeWidthStr::width(label.as_str()) + percent_width
|
||||
};
|
||||
let spacer_width = if include_bar {
|
||||
0
|
||||
} else {
|
||||
inner_width.saturating_sub(used_width)
|
||||
};
|
||||
|
||||
let mut spans = vec![
|
||||
Span::from(prefix).dim(),
|
||||
Span::from(label),
|
||||
Span::from(" ".repeat(spacer_width)).dim(),
|
||||
];
|
||||
if include_bar {
|
||||
let label_padding = label_column_width
|
||||
.saturating_sub(UnicodeWidthStr::width(entry.label.as_str()).min(label_width));
|
||||
spans.push(Span::from(" ".repeat(label_padding)).dim());
|
||||
let (filled, empty) = usage_bar_segments(entry.percent_of_usage);
|
||||
spans.push(usage_bar_filled_span(USAGE_BAR_GLYPH.repeat(filled)));
|
||||
spans.push(usage_bar_empty_span(USAGE_BAR_GLYPH.repeat(empty)));
|
||||
spans.push(Span::from(" ").dim());
|
||||
}
|
||||
spans.push(Span::from(percent));
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn usage_bar_segments(percent: u8) -> (usize, usize) {
|
||||
let filled = if percent == 0 {
|
||||
0
|
||||
} else {
|
||||
((usize::from(percent) * USAGE_BAR_WIDTH).saturating_add(99)) / 100
|
||||
}
|
||||
.min(USAGE_BAR_WIDTH);
|
||||
(filled, USAGE_BAR_WIDTH.saturating_sub(filled))
|
||||
}
|
||||
|
||||
fn usage_bar_filled_span(content: String) -> Span<'static> {
|
||||
Span::styled(content, usage_bar_filled_style())
|
||||
}
|
||||
|
||||
fn usage_bar_empty_span(content: String) -> Span<'static> {
|
||||
Span::styled(content, usage_bar_empty_style())
|
||||
}
|
||||
|
||||
fn usage_bar_filled_style() -> Style {
|
||||
usage_bar_filled_style_for(default_bg())
|
||||
}
|
||||
|
||||
fn usage_bar_empty_style() -> Style {
|
||||
usage_bar_empty_style_for(default_bg())
|
||||
}
|
||||
|
||||
fn usage_bar_filled_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style {
|
||||
let Some(bg) = terminal_bg else {
|
||||
return Style::default().fg(Color::Cyan).bold();
|
||||
};
|
||||
if is_light(bg) {
|
||||
Style::default()
|
||||
.fg(usage_best_color(/*target*/ (0, 110, 125), Color::Cyan))
|
||||
.bold()
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(usage_best_color(
|
||||
/*target*/ (170, 210, 218),
|
||||
Color::Cyan,
|
||||
))
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_bar_empty_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style {
|
||||
let Some(bg) = terminal_bg else {
|
||||
return Style::default().fg(Color::DarkGray);
|
||||
};
|
||||
if is_light(bg) {
|
||||
Style::default().fg(usage_best_color(
|
||||
/*target*/ blend(/*fg*/ (0, 0, 0), bg, /*alpha*/ 0.18),
|
||||
Color::Gray,
|
||||
))
|
||||
} else {
|
||||
Style::default().fg(usage_best_color(
|
||||
/*target*/ blend(/*fg*/ (255, 255, 255), bg, /*alpha*/ 0.3),
|
||||
Color::DarkGray,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_best_color(target: (u8, u8, u8), fallback: Color) -> Color {
|
||||
let color = best_color(target);
|
||||
if color == Color::default() {
|
||||
fallback
|
||||
} else {
|
||||
color
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_to_width(value: &str, width: usize) -> String {
|
||||
let mut out = String::new();
|
||||
let mut used = 0usize;
|
||||
for ch in value.chars() {
|
||||
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if used + ch_width > width {
|
||||
break;
|
||||
}
|
||||
out.push(ch);
|
||||
used += ch_width;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn line_display_width(line: &Line<'static>) -> usize {
|
||||
line.iter()
|
||||
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn text_width(value: &str) -> usize {
|
||||
UnicodeWidthStr::width(value)
|
||||
}
|
||||
|
||||
fn contributor_kind_label(kind: UsageContributorKind) -> &'static str {
|
||||
match kind {
|
||||
UsageContributorKind::Skill => "skill",
|
||||
UsageContributorKind::Subagent => "subagent",
|
||||
UsageContributorKind::AgentTask => "agent task",
|
||||
UsageContributorKind::App => "app",
|
||||
UsageContributorKind::McpServer => "MCP server",
|
||||
UsageContributorKind::Plugin => "plugin",
|
||||
}
|
||||
}
|
||||
@@ -168,12 +168,15 @@ impl HistoryCell for DeprecationNoticeCell {
|
||||
}
|
||||
}
|
||||
pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PlainHistoryCell {
|
||||
let mut line = vec!["• ".dim(), message.into()];
|
||||
if let Some(hint) = hint {
|
||||
line.push(" ".into());
|
||||
line.push(hint.dark_gray());
|
||||
let mut lines = raw_lines_from_source(&message);
|
||||
if lines.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
lines[0].spans.insert(/*index*/ 0, "• ".dim());
|
||||
if let Some(hint) = hint {
|
||||
lines[0].spans.push(" ".into());
|
||||
lines[0].spans.push(hint.dark_gray());
|
||||
}
|
||||
let lines: Vec<Line<'static>> = vec![line.into()];
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ pub enum SlashCommand {
|
||||
Diff,
|
||||
Mention,
|
||||
Status,
|
||||
Usage,
|
||||
DebugConfig,
|
||||
Title,
|
||||
Statusline,
|
||||
@@ -96,6 +97,9 @@ impl SlashCommand {
|
||||
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
|
||||
SlashCommand::Hooks => "view and manage lifecycle hooks",
|
||||
SlashCommand::Status => "show current session configuration and token usage",
|
||||
SlashCommand::Usage => {
|
||||
"show local token usage by skills, subagents, apps, MCP servers, and plugins"
|
||||
}
|
||||
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
|
||||
SlashCommand::Title => "configure which items appear in the terminal title",
|
||||
SlashCommand::Statusline => "configure which items appear in the status line",
|
||||
@@ -155,6 +159,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Keymap
|
||||
| SlashCommand::Mcp
|
||||
| SlashCommand::Raw
|
||||
| SlashCommand::Usage
|
||||
| SlashCommand::Pets
|
||||
| SlashCommand::Side
|
||||
| SlashCommand::Btw
|
||||
@@ -172,6 +177,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Diff
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Status
|
||||
| SlashCommand::Usage
|
||||
| SlashCommand::Ide
|
||||
)
|
||||
}
|
||||
@@ -207,6 +213,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Skills
|
||||
| SlashCommand::Hooks
|
||||
| SlashCommand::Status
|
||||
| SlashCommand::Usage
|
||||
| SlashCommand::DebugConfig
|
||||
| SlashCommand::Ps
|
||||
| SlashCommand::Stop
|
||||
|
||||
Reference in New Issue
Block a user