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.
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [mngr]
2
+ opencode_usage = imbue.mngr_opencode_usage.plugin