imbue-mngr-opencode-usage 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- imbue/mngr_opencode_usage/__init__.py +0 -0
- imbue/mngr_opencode_usage/plugin.py +90 -0
- imbue/mngr_opencode_usage/resources/__init__.py +0 -0
- imbue/mngr_opencode_usage/resources/mngr_opencode_usage_plugin.ts +116 -0
- imbue_mngr_opencode_usage-0.1.0.dist-info/METADATA +61 -0
- imbue_mngr_opencode_usage-0.1.0.dist-info/RECORD +8 -0
- imbue_mngr_opencode_usage-0.1.0.dist-info/WHEEL +4 -0
- imbue_mngr_opencode_usage-0.1.0.dist-info/entry_points.txt +2 -0
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""OpenCode usage data provider for `mngr usage`.
|
|
2
|
+
|
|
3
|
+
Single responsibility: install a second in-process TypeScript plugin into each
|
|
4
|
+
OpenCode agent's config dir (alongside mngr_opencode's lifecycle plugin) that
|
|
5
|
+
appends one ``cost_snapshot`` event per assistant message to
|
|
6
|
+
``$MNGR_AGENT_STATE_DIR/events/opencode/usage/events.jsonl``. The ``mngr usage``
|
|
7
|
+
CLI walks those events files itself (see ``imbue-mngr-usage``).
|
|
8
|
+
|
|
9
|
+
Provisioning runs from an ``on_after_provisioning`` hookimpl so the per-agent
|
|
10
|
+
OpenCode config dir (and its ``plugin/`` subdir) already exists when we drop our
|
|
11
|
+
writer in. All file I/O goes through ``host.write_text_file`` so it works for
|
|
12
|
+
local and remote agents uniformly. The reader side is an
|
|
13
|
+
``aggregate_usage_source`` hookimpl that claims the ``opencode`` source and
|
|
14
|
+
aggregates it with the session-incremental strategy (OpenCode reports cost/tokens
|
|
15
|
+
per message, not cumulatively).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import importlib.resources
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from imbue.mngr import hookimpl
|
|
24
|
+
from imbue.mngr.config.data_types import MngrContext
|
|
25
|
+
from imbue.mngr.hosts.host import get_agent_state_dir_path
|
|
26
|
+
from imbue.mngr.interfaces.agent import AgentInterface
|
|
27
|
+
from imbue.mngr.interfaces.host import OnlineHostInterface
|
|
28
|
+
from imbue.mngr_opencode.opencode_config import get_opencode_config_dir
|
|
29
|
+
from imbue.mngr_opencode.opencode_config import get_opencode_plugin_path
|
|
30
|
+
from imbue.mngr_opencode.plugin import OpenCodeAgent
|
|
31
|
+
from imbue.mngr_opencode_usage import resources as _resources
|
|
32
|
+
from imbue.mngr_usage.api import aggregate_session_incremental
|
|
33
|
+
from imbue.mngr_usage.data_types import UsageEvent
|
|
34
|
+
from imbue.mngr_usage.data_types import UsageSnapshot
|
|
35
|
+
|
|
36
|
+
# The usage writer plugin dropped alongside mngr_opencode's lifecycle plugin in
|
|
37
|
+
# the per-agent ``<config dir>/plugin/`` (OpenCode auto-loads every plugin/*.ts).
|
|
38
|
+
_USAGE_PLUGIN_FILENAME = "mngr_opencode_usage_plugin.ts"
|
|
39
|
+
|
|
40
|
+
# Source name the writer emits under ($STATE_DIR/events/opencode/usage/...);
|
|
41
|
+
# the reader strips "/usage", so this hookimpl claims exactly "opencode".
|
|
42
|
+
_OPENCODE_USAGE_SOURCE_NAME = "opencode"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_resource(filename: str) -> str:
|
|
46
|
+
"""Load a resource file's text from the mngr_opencode_usage resources package."""
|
|
47
|
+
return importlib.resources.files(_resources).joinpath(filename).read_text()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _provision_usage_writer_plugin(host: OnlineHostInterface, agent_state_dir: Path) -> None:
|
|
51
|
+
"""Install the usage writer plugin into the agent's OpenCode ``plugin/`` dir.
|
|
52
|
+
|
|
53
|
+
Idempotent: re-provisioning overwrites the file in place. The destination is
|
|
54
|
+
the same ``plugin/`` dir OpenCode auto-loads, next to mngr_opencode's
|
|
55
|
+
lifecycle plugin.
|
|
56
|
+
"""
|
|
57
|
+
config_dir = get_opencode_config_dir(agent_state_dir)
|
|
58
|
+
plugin_dir = get_opencode_plugin_path(config_dir).parent
|
|
59
|
+
host.write_text_file(plugin_dir / _USAGE_PLUGIN_FILENAME, _load_resource(_USAGE_PLUGIN_FILENAME))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@hookimpl
|
|
63
|
+
def on_after_provisioning(agent: AgentInterface, host: OnlineHostInterface, mngr_ctx: MngrContext) -> None:
|
|
64
|
+
"""Install the OpenCode usage writer plugin for OpenCode agents on any host.
|
|
65
|
+
|
|
66
|
+
Skips non-OpenCode agents. Runs after the harness has provisioned the config
|
|
67
|
+
dir, so the ``plugin/`` dir exists; the writer then loads automatically on the
|
|
68
|
+
next ``opencode serve``.
|
|
69
|
+
"""
|
|
70
|
+
if not isinstance(agent, OpenCodeAgent):
|
|
71
|
+
return
|
|
72
|
+
_provision_usage_writer_plugin(host, get_agent_state_dir_path(host.host_dir, agent.id))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@hookimpl
|
|
76
|
+
def aggregate_usage_source(
|
|
77
|
+
source_name: str,
|
|
78
|
+
agents_events: dict[str, list[UsageEvent]],
|
|
79
|
+
since_seconds: int,
|
|
80
|
+
now: int,
|
|
81
|
+
) -> UsageSnapshot | None:
|
|
82
|
+
"""Aggregate the OpenCode usage source; decline every other source.
|
|
83
|
+
|
|
84
|
+
OpenCode reports each assistant message's own cost/tokens (not a cumulative
|
|
85
|
+
total), so this is the session-incremental strategy. Returning None for
|
|
86
|
+
non-OpenCode sources lets the firstresult hook fall through.
|
|
87
|
+
"""
|
|
88
|
+
if source_name != _OPENCODE_USAGE_SOURCE_NAME:
|
|
89
|
+
return None
|
|
90
|
+
return aggregate_session_incremental(source_name, agents_events, since_seconds=since_seconds, now=now)
|
|
File without changes
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// mngr usage writer plugin for OpenCode agents.
|
|
2
|
+
//
|
|
3
|
+
// OpenCode auto-loads every `$OPENCODE_CONFIG_DIR/plugin/*.ts`, so this file
|
|
4
|
+
// loads alongside mngr_opencode's lifecycle plugin without any config entry. Its
|
|
5
|
+
// single job: on each assistant `message.updated`, append one `cost_snapshot`
|
|
6
|
+
// event carrying that message's own cost + tokens to
|
|
7
|
+
// $MNGR_AGENT_STATE_DIR/events/opencode/usage/events.jsonl
|
|
8
|
+
// which `mngr usage` reads (see imbue-mngr-usage).
|
|
9
|
+
//
|
|
10
|
+
// OpenCode reports cost/tokens PER MESSAGE (not cumulatively), so the reader uses
|
|
11
|
+
// the session-incremental strategy: it sums each session's messages, keeping the
|
|
12
|
+
// freshest event per `message_id` (so a streaming message's re-fires collapse to
|
|
13
|
+
// its final reading). That makes this writer stateless and restart-proof -- each
|
|
14
|
+
// event is self-contained and append-only; nothing is accumulated in memory.
|
|
15
|
+
//
|
|
16
|
+
// Like the lifecycle plugin, this only acts in the mngr-managed `opencode serve`
|
|
17
|
+
// process (MNGR_OPENCODE_ROLE=server); the attach client loads it too but stays
|
|
18
|
+
// inert, so usage events have exactly one writer. If MNGR_AGENT_STATE_DIR is
|
|
19
|
+
// unset (opencode run outside an mngr agent) it is a no-op. Every fs touch is
|
|
20
|
+
// wrapped so a transient error never disrupts OpenCode's loop.
|
|
21
|
+
//
|
|
22
|
+
// Reported cost is authoritative for OpenCode (provenance REPORTED); tokens are
|
|
23
|
+
// emitted for auditability. The model is provider-qualified (`<providerID>/
|
|
24
|
+
// <modelID>`) to match the canonical pricing-table key. cost_mode is API_KEY:
|
|
25
|
+
// OpenCode bills against a real provider key, not an imputed subscription.
|
|
26
|
+
|
|
27
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
28
|
+
import { randomBytes } from "node:crypto"
|
|
29
|
+
import { appendFileSync, mkdirSync } from "node:fs"
|
|
30
|
+
import { dirname, join } from "node:path"
|
|
31
|
+
|
|
32
|
+
// Kept in sync with the reader's discovery convention (events/<source>/usage).
|
|
33
|
+
const USAGE_EVENTS_RELATIVE_PATH = "events/opencode/usage/events.jsonl"
|
|
34
|
+
const SOURCE = "opencode/usage"
|
|
35
|
+
const ROLE_ENV_VAR = "MNGR_OPENCODE_ROLE"
|
|
36
|
+
const SERVER_ROLE = "server"
|
|
37
|
+
|
|
38
|
+
const _numberOrNull = (value: unknown): number | null => (typeof value === "number" ? value : null)
|
|
39
|
+
|
|
40
|
+
export const MngrUsagePlugin: Plugin = async () => {
|
|
41
|
+
const stateDir = process.env.MNGR_AGENT_STATE_DIR
|
|
42
|
+
if (!stateDir || process.env[ROLE_ENV_VAR] !== SERVER_ROLE) {
|
|
43
|
+
return {}
|
|
44
|
+
}
|
|
45
|
+
const usagePath = join(stateDir, USAGE_EVENTS_RELATIVE_PATH)
|
|
46
|
+
|
|
47
|
+
let usageDirEnsured = false
|
|
48
|
+
const appendUsage = (line: string): void => {
|
|
49
|
+
try {
|
|
50
|
+
if (!usageDirEnsured) {
|
|
51
|
+
mkdirSync(dirname(usagePath), { recursive: true })
|
|
52
|
+
usageDirEnsured = true
|
|
53
|
+
}
|
|
54
|
+
appendFileSync(usagePath, line + "\n")
|
|
55
|
+
} catch {
|
|
56
|
+
// best-effort: a transient fs error must not break OpenCode's loop
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
event: async ({ event }) => {
|
|
62
|
+
if (event.type !== "message.updated") {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const message = (event.properties as any)?.info
|
|
66
|
+
if (!message || message.role !== "assistant") {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
const sessionId = message.sessionID
|
|
70
|
+
if (typeof sessionId !== "string" || !sessionId) {
|
|
71
|
+
// session_id is contractually required by the reader; skip if absent.
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const cost = _numberOrNull(message.cost)
|
|
76
|
+
const rawTokens = message.tokens
|
|
77
|
+
const hasTokens = rawTokens && typeof rawTokens === "object"
|
|
78
|
+
if (cost === null && !hasTokens) {
|
|
79
|
+
// Nothing to report yet (e.g. an assistant message before any usage lands).
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// OpenCode token buckets are non-overlapping: input is non-cached, cache.read
|
|
84
|
+
// / cache.write are separate. Reasoning is folded into output (the wire
|
|
85
|
+
// convention: output includes reasoning, billed at the output rate). The
|
|
86
|
+
// reported cost is authoritative regardless of this bucketing.
|
|
87
|
+
const tokens = hasTokens
|
|
88
|
+
? {
|
|
89
|
+
input: _numberOrNull(rawTokens.input),
|
|
90
|
+
output: (rawTokens.output ?? 0) + (rawTokens.reasoning ?? 0),
|
|
91
|
+
cache_read: _numberOrNull(rawTokens.cache?.read),
|
|
92
|
+
cache_creation: _numberOrNull(rawTokens.cache?.write),
|
|
93
|
+
}
|
|
94
|
+
: null
|
|
95
|
+
|
|
96
|
+
const model =
|
|
97
|
+
typeof message.providerID === "string" && typeof message.modelID === "string"
|
|
98
|
+
? `${message.providerID}/${message.modelID}`
|
|
99
|
+
: null
|
|
100
|
+
|
|
101
|
+
const record = {
|
|
102
|
+
source: SOURCE,
|
|
103
|
+
type: "cost_snapshot",
|
|
104
|
+
event_id: "evt-" + randomBytes(16).toString("hex"),
|
|
105
|
+
timestamp: new Date().toISOString(),
|
|
106
|
+
session_id: sessionId,
|
|
107
|
+
message_id: typeof message.id === "string" ? message.id : null,
|
|
108
|
+
cost: cost === null ? null : { total_cost_usd: cost },
|
|
109
|
+
tokens,
|
|
110
|
+
model,
|
|
111
|
+
cost_mode: "API_KEY",
|
|
112
|
+
}
|
|
113
|
+
appendUsage(JSON.stringify(record))
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: imbue-mngr-opencode-usage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenCode usage data provider for `mngr usage` (per-agent in-process plugin that captures per-message cost + tokens)
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: imbue-mngr-opencode==0.2.16
|
|
7
|
+
Requires-Dist: imbue-mngr-usage==0.1.5
|
|
8
|
+
Requires-Dist: imbue-mngr==0.2.16
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# imbue-mngr-opencode-usage
|
|
12
|
+
|
|
13
|
+
OpenCode data provider for `mngr usage`. Single responsibility: install a second
|
|
14
|
+
in-process TypeScript plugin into each OpenCode agent so each assistant message
|
|
15
|
+
appends one event to `$MNGR_AGENT_STATE_DIR/events/opencode/usage/events.jsonl`.
|
|
16
|
+
The `mngr usage` CLI walks those events files itself (see `imbue-mngr-usage`).
|
|
17
|
+
|
|
18
|
+
## How the pieces fit
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
mngr_opencode_usage's on_after_provisioning hookimpl
|
|
22
|
+
└─→ installs <config_dir>/plugin/mngr_opencode_usage_plugin.ts
|
|
23
|
+
(alongside mngr_opencode's lifecycle plugin; OpenCode auto-loads plugin/*.ts)
|
|
24
|
+
|
|
25
|
+
OpenCode serve process, on each assistant message.updated
|
|
26
|
+
└─→ mngr_opencode_usage_plugin.ts
|
|
27
|
+
└─→ events/opencode/usage/events.jsonl (append one cost_snapshot event)
|
|
28
|
+
|
|
29
|
+
mngr usage
|
|
30
|
+
└─→ aggregate_usage_source hookimpl (claims the "opencode" source)
|
|
31
|
+
└─→ aggregate_session_incremental: sum each session's messages
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
All file I/O goes through `host.write_text_file`, so provisioning works for local
|
|
35
|
+
**and** remote agents.
|
|
36
|
+
|
|
37
|
+
## What gets captured
|
|
38
|
+
|
|
39
|
+
OpenCode computes cost and tokens per message and exposes them on each assistant
|
|
40
|
+
`message.updated` event. The writer emits, per message, a self-contained event
|
|
41
|
+
carrying:
|
|
42
|
+
|
|
43
|
+
- `cost.total_cost_usd` -- OpenCode's own per-message cost (provenance is
|
|
44
|
+
**REPORTED**; no estimation needed).
|
|
45
|
+
- `tokens` -- `{input, output, cache_read, cache_creation}` (reasoning folded
|
|
46
|
+
into output), for auditability.
|
|
47
|
+
- `model` -- the provider-qualified `<providerID>/<modelID>`.
|
|
48
|
+
- `session_id` + `message_id` -- the reader sums per session, keeping the freshest
|
|
49
|
+
event per `message_id` so a streaming message's re-fires collapse to its final
|
|
50
|
+
reading.
|
|
51
|
+
|
|
52
|
+
## Why session-incremental
|
|
53
|
+
|
|
54
|
+
OpenCode reports each message's **own** cost, not a session running total. So the
|
|
55
|
+
writer is stateless -- it appends one self-contained event per message and keeps
|
|
56
|
+
nothing in memory. The reader (`aggregate_session_incremental`) recomputes each
|
|
57
|
+
session's total from the append-only log, which makes it robust across
|
|
58
|
+
`mngr stop`/`start` (a fresh serve process with empty memory loses no history).
|
|
59
|
+
|
|
60
|
+
`cost_mode` is `API_KEY`: OpenCode bills against a real provider key, so the cost
|
|
61
|
+
is real spend, never an imputed subscription value.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
imbue/mngr_opencode_usage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
imbue/mngr_opencode_usage/plugin.py,sha256=v6ZJkjostwe19wGTSzP2MpZC05m6aaP_ghkZu2J_dqc,4097
|
|
3
|
+
imbue/mngr_opencode_usage/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
imbue/mngr_opencode_usage/resources/mngr_opencode_usage_plugin.ts,sha256=n5aH-KJhYQcP4cvHTbOecCy3hu_cY2icKFb5D6M5lD4,4769
|
|
5
|
+
imbue_mngr_opencode_usage-0.1.0.dist-info/METADATA,sha256=vigvdvpDpH3OGZXFux23kqN87wrAL86CAgzsCuPQhgg,2639
|
|
6
|
+
imbue_mngr_opencode_usage-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
imbue_mngr_opencode_usage-0.1.0.dist-info/entry_points.txt,sha256=s07aVwJ7EhFeoSJBLIvlBeGSRHTWWivttBgLNt9-ok0,57
|
|
8
|
+
imbue_mngr_opencode_usage-0.1.0.dist-info/RECORD,,
|