Skip to content

Commit 23b456d

Browse files
committed
feat(quota): read OpenCode Go remaining from official /zen/go/v1/usage
Shipped 2026-08-11 in anomalyco/opencode#16513. percent is used 0-100 across rolling/weekly/monthly. No invented numbers.
1 parent ca8a7b7 commit 23b456d

9 files changed

Lines changed: 292 additions & 8 deletions

File tree

src-tauri/src/commands/subscription_quota.rs

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717
//! plan usage is the same DashboardService RPC the official CLI/IDE client
1818
//! already ships: `GetCurrentPeriodUsage` on `api2.cursor.sh`, authenticated
1919
//! with the cursor-agent login token (`%APPDATA%\Cursor\auth.json` or
20-
//! `CURSOR_API_KEY`). Gemini / OpenCode still have no remaining-quota command.
20+
//! `CURSOR_API_KEY`).
21+
//!
22+
//! OpenCode Go remaining is official
23+
//! `GET https://opencode.ai/zen/go/v1/usage` (anomalyco/opencode#16513,
24+
//! live 2026-08-11) with the Go API key from `auth.json`. Gemini still
25+
//! has no remaining-quota command. OpenCode `stats` is session history.
2126
2227
use std::fs;
2328
use std::path::Path;
@@ -488,6 +493,7 @@ pub fn extra_homes_in(
488493
"codex" => "codex-",
489494
"grok" => "grok-",
490495
"cursor" => "cursor-",
496+
"opencode" => "opencode-",
491497
_ => return Vec::new(),
492498
};
493499
let Some(root) = root else {
@@ -670,6 +676,144 @@ pub async fn subscription_quota_cursor() -> Result<OfficialQuotaRead, AppCommand
670676
Ok(read_cursor_subscription_quota_core().await)
671677
}
672678

679+
pub fn opencode_go_key_from_auth_json(text: &str) -> Option<String> {
680+
let value: Value = serde_json::from_str(text).ok()?;
681+
let obj = value.as_object()?;
682+
// `opencode-go` wins when present, but a plain `opencode` entry is the
683+
// common single-account shape, so a missing provider skips to the next one
684+
// instead of ending the search.
685+
for provider in ["opencode-go", "opencode"] {
686+
let Some(entry) = obj.get(provider).and_then(Value::as_object) else {
687+
continue;
688+
};
689+
let Some(key) = entry.get("key").and_then(Value::as_str) else {
690+
continue;
691+
};
692+
if !key.is_empty() {
693+
return Some(key.to_string());
694+
}
695+
}
696+
None
697+
}
698+
699+
fn opencode_auth_path() -> Option<std::path::PathBuf> {
700+
Some(crate::parsers::opencode::resolve_opencode_base_dir().join("auth.json"))
701+
}
702+
703+
fn read_opencode_go_key(path: &Path) -> Option<String> {
704+
let text = fs::read_to_string(path).ok()?;
705+
opencode_go_key_from_auth_json(&text)
706+
}
707+
708+
fn opencode_go_key_from_env_or_file() -> Option<String> {
709+
for name in ["OPENCODE_GO_API_KEY", "OPENCODE_API_KEY"] {
710+
if let Ok(key) = std::env::var(name) {
711+
let trimmed = key.trim();
712+
if !trimmed.is_empty() {
713+
return Some(trimmed.to_string());
714+
}
715+
}
716+
}
717+
let path = opencode_auth_path()?;
718+
read_opencode_go_key(&path)
719+
}
720+
721+
fn opencode_payload_has_usage(payload: &Value) -> bool {
722+
let usage = payload.get("usage").unwrap_or(payload);
723+
["rolling", "weekly", "monthly"].iter().any(|window| {
724+
usage
725+
.get(*window)
726+
.and_then(|w| w.get("percent").or_else(|| w.get("usagePercent")))
727+
.is_some()
728+
})
729+
}
730+
731+
async fn fetch_opencode_go_usage(token: &str) -> Result<Value, AppCommandError> {
732+
let client = reqwest::Client::builder()
733+
.timeout(Duration::from_secs(10))
734+
.build()
735+
.map_err(|err| {
736+
AppCommandError::new(AppErrorCode::NetworkError, "HTTP client")
737+
.with_detail(err.to_string())
738+
})?;
739+
let response = client
740+
.get("https://opencode.ai/zen/go/v1/usage")
741+
.header("Authorization", format!("Bearer {token}"))
742+
.header("Accept", "application/json")
743+
.header("User-Agent", "codeg")
744+
.send()
745+
.await
746+
.map_err(|err| {
747+
AppCommandError::new(AppErrorCode::NetworkError, "OpenCode usage request failed")
748+
.with_detail(err.to_string())
749+
})?;
750+
let status = response.status();
751+
if !status.is_success() {
752+
return Err(AppCommandError::new(
753+
AppErrorCode::ExternalCommandFailed,
754+
format!("OpenCode usage HTTP {status}"),
755+
));
756+
}
757+
response.json::<Value>().await.map_err(|err| {
758+
AppCommandError::new(AppErrorCode::ExternalCommandFailed, "OpenCode usage JSON")
759+
.with_detail(err.to_string())
760+
})
761+
}
762+
763+
pub async fn read_opencode_subscription_quota_core() -> OfficialQuotaRead {
764+
let extra_slots = extra_opencode_slots().await;
765+
let Some(token) = opencode_go_key_from_env_or_file() else {
766+
return OfficialQuotaRead {
767+
family: "opencode",
768+
payload: None,
769+
extra_slots,
770+
unavailable_reason: Some("OpenCode Go is not signed in".into()),
771+
};
772+
};
773+
match fetch_opencode_go_usage(&token).await {
774+
Ok(payload) if opencode_payload_has_usage(&payload) => OfficialQuotaRead {
775+
family: "opencode",
776+
payload: Some(payload),
777+
extra_slots,
778+
unavailable_reason: None,
779+
},
780+
Ok(_) => OfficialQuotaRead {
781+
family: "opencode",
782+
payload: None,
783+
extra_slots,
784+
unavailable_reason: Some("OpenCode usage payload missing windows".into()),
785+
},
786+
Err(err) => OfficialQuotaRead {
787+
family: "opencode",
788+
payload: None,
789+
extra_slots,
790+
unavailable_reason: Some(err.message),
791+
},
792+
}
793+
}
794+
795+
async fn extra_opencode_slots() -> Vec<OfficialQuotaSlot> {
796+
let mut slots = Vec::new();
797+
for (label, home) in extra_homes_for_family("opencode") {
798+
let path = home.join("auth.json");
799+
let Some(token) = read_opencode_go_key(&path) else {
800+
continue;
801+
};
802+
if let Ok(payload) = fetch_opencode_go_usage(&token).await {
803+
if opencode_payload_has_usage(&payload) {
804+
slots.push(OfficialQuotaSlot { label, payload });
805+
}
806+
}
807+
}
808+
slots
809+
}
810+
811+
#[cfg(feature = "tauri-runtime")]
812+
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
813+
pub async fn subscription_quota_opencode() -> Result<OfficialQuotaRead, AppCommandError> {
814+
Ok(read_opencode_subscription_quota_core().await)
815+
}
816+
673817
#[cfg(test)]
674818
mod tests {
675819
use super::*;
@@ -767,6 +911,7 @@ mod tests {
767911
fs::create_dir_all(root.join("claude-3")).unwrap();
768912
fs::create_dir_all(root.join("codex-2")).unwrap();
769913
fs::create_dir_all(root.join("cursor-2")).unwrap();
914+
fs::create_dir_all(root.join("opencode-2")).unwrap();
770915
fs::write(root.join("claude-ignore"), "").unwrap();
771916
let claude = extra_homes_in(Some(root.clone()), "claude");
772917
let names: Vec<_> = claude.iter().map(|(n, _)| n.as_str()).collect();
@@ -777,6 +922,9 @@ mod tests {
777922
let cursor = extra_homes_in(Some(root.clone()), "cursor");
778923
assert_eq!(cursor.len(), 1);
779924
assert_eq!(cursor[0].0, "cursor-2");
925+
let opencode = extra_homes_in(Some(root.clone()), "opencode");
926+
assert_eq!(opencode.len(), 1);
927+
assert_eq!(opencode[0].0, "opencode-2");
780928
assert!(extra_homes_in(Some(root.clone()), "grok").is_empty());
781929
let _ = fs::remove_dir_all(&root);
782930
}
@@ -798,4 +946,22 @@ mod tests {
798946
);
799947
assert!(cursor_access_token_from_auth_json("{}").is_none());
800948
}
949+
950+
#[test]
951+
fn reads_opencode_go_key_without_logging_it() {
952+
let text = r#"{
953+
"opencode": { "type": "api", "key": "zen-key" },
954+
"opencode-go": { "type": "api", "key": "go-key" }
955+
}"#;
956+
assert_eq!(
957+
opencode_go_key_from_auth_json(text).as_deref(),
958+
Some("go-key")
959+
);
960+
assert_eq!(
961+
opencode_go_key_from_auth_json(r#"{"opencode":{"type":"api","key":"zen-only"}}"#)
962+
.as_deref(),
963+
Some("zen-only")
964+
);
965+
assert!(opencode_go_key_from_auth_json("{}").is_none());
966+
}
801967
}

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,6 +1360,7 @@ mod tauri_app {
13601360
subscription_quota_commands::subscription_quota_claude,
13611361
subscription_quota_commands::subscription_quota_grok,
13621362
subscription_quota_commands::subscription_quota_cursor,
1363+
subscription_quota_commands::subscription_quota_opencode,
13631364
work_task_commands::work_task_list,
13641365
work_task_commands::work_task_get,
13651366
work_task_commands::work_task_events,

src-tauri/src/web/handlers/subscription_quota.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use crate::app_error::AppCommandError;
44
use crate::commands::subscription_quota::{
55
read_claude_subscription_quota_core, read_codex_subscription_quota_core,
66
read_cursor_subscription_quota_core, read_grok_subscription_quota_core,
7-
OfficialQuotaRead,
7+
read_opencode_subscription_quota_core, OfficialQuotaRead,
88
};
99

1010
pub async fn subscription_quota_codex() -> Result<Json<OfficialQuotaRead>, AppCommandError> {
@@ -22,3 +22,7 @@ pub async fn subscription_quota_grok() -> Result<Json<OfficialQuotaRead>, AppCom
2222
pub async fn subscription_quota_cursor() -> Result<Json<OfficialQuotaRead>, AppCommandError> {
2323
Ok(Json(read_cursor_subscription_quota_core().await))
2424
}
25+
26+
pub async fn subscription_quota_opencode() -> Result<Json<OfficialQuotaRead>, AppCommandError> {
27+
Ok(Json(read_opencode_subscription_quota_core().await))
28+
}

src-tauri/src/web/router.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1289,6 +1289,10 @@ pub fn build_router(
12891289
"/subscription_quota_cursor",
12901290
post(handlers::subscription_quota::subscription_quota_cursor),
12911291
)
1292+
.route(
1293+
"/subscription_quota_opencode",
1294+
post(handlers::subscription_quota::subscription_quota_opencode),
1295+
)
12921296
// ─── Work tasks ───
12931297
.route("/work_task_list", post(handlers::work_task::work_task_list))
12941298
.route("/work_task_get", post(handlers::work_task::work_task_get))

src/components/conversations/session-quota-chip.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
subscriptionQuotaCodex,
1515
subscriptionQuotaCursor,
1616
subscriptionQuotaGrok,
17+
subscriptionQuotaOpencode,
1718
} from "@/lib/api"
1819
import {
1920
familyFromAgentType,
@@ -45,7 +46,7 @@ const QUOTA_FETCHERS: Record<
4546
grok: subscriptionQuotaGrok,
4647
cursor: subscriptionQuotaCursor,
4748
gemini: null,
48-
opencode: null,
49+
opencode: subscriptionQuotaOpencode,
4950
}
5051

5152
function SessionQuotaChipInner({ family }: { family: IsolatableFamily }) {

src/components/token-usage/subscription-quota-panel.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
subscriptionQuotaCodex,
99
subscriptionQuotaCursor,
1010
subscriptionQuotaGrok,
11+
subscriptionQuotaOpencode,
1112
} from "@/lib/api"
1213
import {
1314
inventory,
@@ -34,12 +35,13 @@ export function SubscriptionQuotaPanel() {
3435
subscriptionQuotaClaude(),
3536
subscriptionQuotaGrok(),
3637
subscriptionQuotaCursor(),
38+
subscriptionQuotaOpencode(),
3739
])
3840
.then((results) => {
3941
if (cancelled) return
4042
const next: Partial<Record<IsolatableFamily, unknown>> = {}
4143
const slots: Partial<Record<IsolatableFamily, OfficialQuotaSlot[]>> = {}
42-
const [codex, claude, grok, cursor] = results
44+
const [codex, claude, grok, cursor, opencode] = results
4345
if (codex.status === "fulfilled") {
4446
if (codex.value.payload) next.codex = codex.value.payload
4547
if (codex.value.extraSlots?.length)
@@ -59,6 +61,11 @@ export function SubscriptionQuotaPanel() {
5961
if (cursor.value.extraSlots?.length)
6062
slots.cursor = cursor.value.extraSlots
6163
}
64+
if (opencode.status === "fulfilled") {
65+
if (opencode.value.payload) next.opencode = opencode.value.payload
66+
if (opencode.value.extraSlots?.length)
67+
slots.opencode = opencode.value.extraSlots
68+
}
6269
setOfficial(next)
6370
setExtraSlots(slots)
6471
})
@@ -93,7 +100,8 @@ export function SubscriptionQuotaPanel() {
93100
) : (row.family === "codex" ||
94101
row.family === "claude" ||
95102
row.family === "grok" ||
96-
row.family === "cursor") &&
103+
row.family === "cursor" ||
104+
row.family === "opencode") &&
97105
!loaded ? (
98106
<span className="text-muted-foreground">
99107
{t("quotaLoading")}

src/lib/api.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3033,6 +3033,11 @@ export async function subscriptionQuotaCursor(): Promise<OfficialQuotaRead> {
30333033
return getTransport().call("subscription_quota_cursor")
30343034
}
30353035

3036+
/** Official OpenCode Go `GET /zen/go/v1/usage`. Missing key/plan is null. */
3037+
export async function subscriptionQuotaOpencode(): Promise<OfficialQuotaRead> {
3038+
return getTransport().call("subscription_quota_opencode")
3039+
}
3040+
30363041
// Automations
30373042

30383043
export async function automationList(): Promise<Automation[]> {

src/lib/subscription-quota.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,49 @@ describe("subscription quota inventory", () => {
213213
])
214214
})
215215

216+
it("reads OpenCode Go remaining from official /zen/go/v1/usage", () => {
217+
// Shape from anomalyco/opencode#16513 formatUsage().
218+
const payload = {
219+
usage: {
220+
rolling: {
221+
status: "ok",
222+
percent: 42,
223+
resetsAt: "2026-08-16T18:00:00.000Z",
224+
},
225+
weekly: {
226+
status: "ok",
227+
percent: 10,
228+
resetsAt: "2026-08-20T00:00:00.000Z",
229+
},
230+
monthly: {
231+
status: "ok",
232+
percent: 8,
233+
resetsAt: "2026-09-01T00:00:00.000Z",
234+
},
235+
},
236+
}
237+
const parsed = remainingFromOfficialPayload("opencode", payload)
238+
expect(parsed?.remaining).toBe(58)
239+
expect(parsed?.source).toBe("opencode /zen/go/v1/usage")
240+
expect(parsed?.resetsAt).toBe(
241+
Math.floor(Date.parse("2026-08-16T18:00:00.000Z") / 1000)
242+
)
243+
expect(parsed?.extras?.map((e) => e.label).sort()).toEqual([
244+
"monthly",
245+
"weekly",
246+
])
247+
expect(familyQuota("opencode", payload).kind).toBe("remaining-subscription")
248+
})
249+
250+
it("does not invent OpenCode remaining from session stats", () => {
251+
expect(
252+
remainingFromOfficialPayload("opencode", {
253+
tokens: 1200,
254+
cost: 1.2,
255+
})
256+
).toBeNull()
257+
})
258+
216259
it("does not invent Cursor remaining from about/status identity", () => {
217260
expect(
218261
remainingFromOfficialPayload("cursor", {

0 commit comments

Comments
 (0)