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
2227use std:: fs;
2328use 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) ]
674818mod 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}
0 commit comments