1use crate::ocuse::{db, render, window, zen};
9use serde_json::{json, Value};
10
11const PROTOCOL_VERSION: &str = "2025-06-18";
12
13pub fn run(
15 db_path: &str,
16 days: i64,
17 tz_secs: i64,
18 colour: bool,
19 plan: &str,
20 period_start: Option<i64>,
21) {
22 use std::io::{BufRead, Write};
23 let stdin = std::io::stdin();
24 let mut out = std::io::stdout().lock();
25 for line in stdin.lock().lines() {
26 let Ok(line) = line else { break };
27 if line.trim().is_empty() {
28 continue;
29 }
30 let Ok(msg) = serde_json::from_str::<Value>(&line) else {
31 continue; };
33 if let Some(response) = handle(&msg, db_path, days, tz_secs, colour, plan, period_start) {
34 if writeln!(out, "{response}").is_err() {
35 break; }
37 out.flush().ok();
38 }
39 }
40}
41
42pub fn handle(
44 msg: &Value,
45 db_path: &str,
46 days: i64,
47 tz_secs: i64,
48 colour: bool,
49 plan: &str,
50 period_start: Option<i64>,
51) -> Option<Value> {
52 let method = msg.get("method")?.as_str()?;
53 let id = msg.get("id")?;
54 let result = match method {
55 "initialize" => Ok(json!({
56 "protocolVersion": msg
57 .pointer("/params/protocolVersion")
58 .and_then(|v| v.as_str())
59 .unwrap_or(PROTOCOL_VERSION),
60 "capabilities": { "tools": {} },
61 "serverInfo": { "name": "ocuse", "version": env!("CARGO_PKG_VERSION") },
62 })),
63 "ping" => Ok(json!({})),
64 "tools/list" => Ok(json!({ "tools": tools() })),
65 "tools/call" => tool_call(msg, db_path, days, tz_secs, colour, plan, period_start),
66 other => Err((-32601, format!("method not found: {other}"))),
67 };
68 Some(match result {
69 Ok(value) => json!({ "jsonrpc": "2.0", "id": id, "result": value }),
70 Err((code, message)) => json!({
71 "jsonrpc": "2.0", "id": id,
72 "error": { "code": code, "message": message },
73 }),
74 })
75}
76
77fn tool_call(
78 msg: &Value,
79 db_path: &str,
80 days: i64,
81 tz_secs: i64,
82 colour: bool,
83 plan: &str,
84 period_start: Option<i64>,
85) -> Result<Value, (i64, String)> {
86 let name = msg
87 .pointer("/params/name")
88 .and_then(|v| v.as_str())
89 .unwrap_or_default();
90 let args = msg
91 .pointer("/params/arguments")
92 .cloned()
93 .unwrap_or(json!({}));
94 let number =
95 |key: &str, fallback: i64| args.get(key).and_then(|v| v.as_i64()).unwrap_or(fallback);
96 let plan_id = args
98 .get("plan")
99 .and_then(|v| v.as_str())
100 .unwrap_or(plan)
101 .to_string();
102
103 let rows = || db::read_rows(db_path, crate::ocuse::window::now_ms() - days * 86_400_000);
104 let text = match name {
105 "usage" => rows().map(|rows| {
106 render::render_text(
107 &window::build(&rows, &zen::catalog(), &plan_id, window::now_ms(), None),
108 db_path,
109 15,
110 colour,
111 render::DEFAULT_BAR_WIDTH,
112 )
113 }),
114 "plans" => Ok(render::plans_text(colour)),
115 "models" => {
116 let catalog = zen::catalog();
118 if catalog.plan(&plan_id).is_none() {
119 return Err((
120 -32602,
121 format!("plan must be one of: {}", catalog.plan_ids().join(", ")),
122 ));
123 }
124 let gated = args.get("gated").and_then(|v| v.as_bool()).unwrap_or(false);
125 Ok(render::models_text(&plan_id, gated, colour))
126 }
127 "daily" => rows().map(|rows| {
128 render::bucket_text(
129 &rows,
130 window::now_ms(),
131 number("days", days) * 86_400_000,
132 86_400_000,
133 tz_secs,
134 colour,
135 crate::report_render::Fmt::Table,
136 )
137 }),
138 "hourly" => rows().map(|rows| {
139 render::bucket_text(
140 &rows,
141 window::now_ms(),
142 number("hours", 24) * 3_600_000,
143 3_600_000,
144 tz_secs,
145 colour,
146 crate::report_render::Fmt::Table,
147 )
148 }),
149 "session" => rows().map(|rows| {
150 render::session_text(
151 &rows,
152 number("limit", 20) as usize,
153 colour,
154 crate::report_render::Fmt::Table,
155 )
156 }),
157 "model" => {
158 let wanted = args
159 .get("id")
160 .and_then(|v| v.as_str())
161 .unwrap_or_default()
162 .to_string();
163 rows().map(|rows| {
164 let report = window::build(
165 &rows,
166 &zen::catalog(),
167 &plan_id,
168 window::now_ms(),
169 period_start,
170 );
171 render::model_text(&report, &wanted, colour)
172 })
173 }
174 _ => {
175 return Ok(json!({
176 "content": [{ "type": "text", "text": format!("unknown tool: {name}") }],
177 "isError": true,
178 }))
179 }
180 };
181 Ok(match text {
182 Ok(body) => json!({ "content": [{ "type": "text", "text": body }] }),
183 Err(error) => json!({
184 "content": [{ "type": "text", "text": format!("error: {error}") }],
185 "isError": true,
186 }),
187 })
188}
189
190fn tools() -> Value {
191 json!([
192 { "name": "usage", "description": "OpenCode Go/Zen usage: period totals, rolling windows, per-model spend against each model's monthly limit. Defaults to the Go plan.", "inputSchema": { "type": "object", "properties": { "plan": { "type": "string", "description": "Go plan: go (default) or go-plus" } } } },
193 { "name": "plans", "description": "The Go/Zen catalogue from the docs: both Go plans' per-model monthly limits and the shared $/1M rates.", "inputSchema": { "type": "object", "properties": {} } },
194 { "name": "models", "description": "The Go catalogue for a plan: every model's $/1M in/out/cache-read rates and its monthly limit, marked granted or not.", "inputSchema": { "type": "object", "properties": { "plan": { "type": "string", "description": "Go plan: go (default) or go-plus" }, "gated": { "type": "boolean", "description": "Only models the plan grants" } } } },
195 { "name": "daily", "description": "Spend, requests and tokens per day.", "inputSchema": { "type": "object", "properties": { "days": { "type": "integer", "description": "days of history (default 120)" } } } },
196 { "name": "hourly", "description": "Spend, requests and tokens per hour.", "inputSchema": { "type": "object", "properties": { "hours": { "type": "integer", "description": "hours of history (default 24)" } } } },
197 { "name": "session", "description": "Spend per OpenCode session, newest first.", "inputSchema": { "type": "object", "properties": { "limit": { "type": "integer", "description": "sessions to list (default 20)" } } } },
198 { "name": "model", "description": "One model's limit, spend and window figures, against the given Go plan.", "inputSchema": { "type": "object", "properties": { "id": { "type": "string", "description": "model id, e.g. glm-5.2" }, "plan": { "type": "string", "description": "Go plan: go (default) or go-plus" } }, "required": ["id"] } },
199 ])
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 fn call(method: &str, params: Value, id: i64) -> Value {
207 handle(
208 &json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }),
209 "/nonexistent/opencode.db",
210 120,
211 0,
212 false,
213 crate::ocuse::zen::DEFAULT_PLAN,
214 None,
215 )
216 .expect("notification")
217 }
218
219 #[test]
220 fn initialize_negotiates_the_client_version() {
221 let r = call("initialize", json!({ "protocolVersion": "2025-03-26" }), 1);
222 assert_eq!(r["result"]["protocolVersion"], "2025-03-26");
223 assert_eq!(r["result"]["serverInfo"]["name"], "ocuse");
224 }
225
226 #[test]
227 fn unknown_method_is_jsonrpc_error() {
228 let r = call("resources/list", json!({}), 2);
229 assert_eq!(r["error"]["code"], -32601);
230 }
231
232 #[test]
233 fn unknown_tool_is_iserror_result_not_rpc_error() {
234 let r = call("tools/call", json!({ "name": "nope", "arguments": {} }), 3);
235 assert!(r.get("error").is_none());
236 assert_eq!(r["result"]["isError"], true);
237 }
238
239 #[test]
240 fn a_missing_store_reports_an_error_result() {
241 let r = call("tools/call", json!({ "name": "usage", "arguments": {} }), 4);
242 assert_eq!(r["result"]["isError"], true, "{r}");
243 assert!(r["result"]["content"][0]["text"]
244 .as_str()
245 .unwrap()
246 .contains("error"));
247 }
248
249 #[test]
250 fn tools_list_covers_every_command() {
251 let r = call("tools/list", json!({}), 5);
252 let mut names: Vec<&str> = r["result"]["tools"]
255 .as_array()
256 .unwrap()
257 .iter()
258 .map(|t| t["name"].as_str().unwrap())
259 .collect();
260 names.sort_unstable();
261 assert_eq!(
262 names,
263 ["daily", "hourly", "model", "models", "plans", "session", "usage"]
264 );
265 }
266
267 #[test]
268 fn models_tool_is_the_catalogue_and_needs_no_store() {
269 let r = call(
270 "tools/call",
271 json!({ "name": "models", "arguments": {} }),
272 6,
273 );
274 assert!(r.get("error").is_none(), "{r}");
275 let text = r["result"]["content"][0]["text"].as_str().unwrap();
276 assert!(text.contains("glm-5.3-flash"), "{text}");
277 assert!(text.contains("GRANTED"), "{text}");
278 }
279
280 #[test]
281 fn models_tool_rejects_an_unknown_plan() {
282 let r = call(
283 "tools/call",
284 json!({ "name": "models", "arguments": { "plan": "nope" } }),
285 7,
286 );
287 assert_eq!(r["error"]["code"], -32602, "{r}");
288 }
289}