11// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22// SPDX-License-Identifier: Apache-2.0
33
4+ use std:: collections:: HashMap ;
5+ use std:: path:: { Path , PathBuf } ;
6+
47use crate :: {
5- ProviderDiscoverySpec , ProviderError , ProviderPlugin , RealDiscoveryContext , discover_with_spec,
8+ DiscoveredProvider , ProviderDiscoverySpec , ProviderError , ProviderPlugin , RealDiscoveryContext ,
9+ discover_with_spec,
610} ;
711
812pub struct OpencodeProvider ;
@@ -12,13 +16,80 @@ pub const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec {
1216 credential_env_vars : & [ "OPENCODE_API_KEY" , "OPENROUTER_API_KEY" , "OPENAI_API_KEY" ] ,
1317} ;
1418
19+ /// Return the path to the opencode config file, respecting `XDG_CONFIG_HOME`.
20+ fn opencode_config_path ( ) -> Option < PathBuf > {
21+ let config_home = std:: env:: var ( "XDG_CONFIG_HOME" )
22+ . ok ( )
23+ . map ( PathBuf :: from)
24+ . or_else ( || {
25+ std:: env:: var ( "HOME" )
26+ . ok ( )
27+ . map ( |h| PathBuf :: from ( h) . join ( ".config" ) )
28+ } ) ?;
29+ Some ( config_home. join ( "opencode" ) . join ( "opencode.json" ) )
30+ }
31+
32+ /// Extract API key credentials from the contents of an opencode config file.
33+ ///
34+ /// opencode stores per-provider API keys at `provider.<name>.options.apiKey`.
35+ /// Each key is surfaced as `<NAME_UPPERCASE>_API_KEY` so that it can be injected
36+ /// as an environment variable into the sandbox and picked up by opencode at runtime.
37+ fn extract_credentials_from_opencode_config ( content : & str ) -> HashMap < String , String > {
38+ let Ok ( json) = serde_json:: from_str :: < serde_json:: Value > ( content) else {
39+ return HashMap :: new ( ) ;
40+ } ;
41+ let Some ( providers) = json. get ( "provider" ) . and_then ( |p| p. as_object ( ) ) else {
42+ return HashMap :: new ( ) ;
43+ } ;
44+
45+ let mut creds = HashMap :: new ( ) ;
46+ for ( provider_name, provider_cfg) in providers {
47+ if let Some ( api_key) = provider_cfg
48+ . get ( "options" )
49+ . and_then ( |o| o. get ( "apiKey" ) )
50+ . and_then ( |k| k. as_str ( ) )
51+ . filter ( |k| !k. trim ( ) . is_empty ( ) )
52+ {
53+ let env_var = format ! ( "{}_API_KEY" , provider_name. to_ascii_uppercase( ) ) ;
54+ creds. insert ( env_var, api_key. to_string ( ) ) ;
55+ }
56+ }
57+ creds
58+ }
59+
60+ /// Read opencode credentials from `path`, returning `None` if the file is absent or unreadable.
61+ fn read_opencode_config_file ( path : & Path ) -> Option < HashMap < String , String > > {
62+ let content = std:: fs:: read_to_string ( path) . ok ( ) ?;
63+ let creds = extract_credentials_from_opencode_config ( & content) ;
64+ if creds. is_empty ( ) { None } else { Some ( creds) }
65+ }
66+
1567impl ProviderPlugin for OpencodeProvider {
1668 fn id ( & self ) -> & ' static str {
1769 SPEC . id
1870 }
1971
20- fn discover_existing ( & self ) -> Result < Option < crate :: DiscoveredProvider > , ProviderError > {
21- discover_with_spec ( & SPEC , & RealDiscoveryContext )
72+ fn discover_existing ( & self ) -> Result < Option < DiscoveredProvider > , ProviderError > {
73+ let mut discovered = discover_with_spec ( & SPEC , & RealDiscoveryContext ) ?. unwrap_or_default ( ) ;
74+
75+ // Supplement env-var discovery with credentials stored in the opencode config file.
76+ // opencode's native config lives at $XDG_CONFIG_HOME/opencode/opencode.json and stores
77+ // API keys under `provider.<name>.options.apiKey`. If the user configured opencode
78+ // normally (i.e. no env vars set), this is the only place the keys exist.
79+ if let Some ( path) = opencode_config_path ( )
80+ && let Some ( file_creds) = read_opencode_config_file ( & path)
81+ {
82+ for ( key, value) in file_creds {
83+ // Env vars already set take priority; config file fills the gaps.
84+ discovered. credentials . entry ( key) . or_insert ( value) ;
85+ }
86+ }
87+
88+ if discovered. is_empty ( ) {
89+ Ok ( None )
90+ } else {
91+ Ok ( Some ( discovered) )
92+ }
2293 }
2394
2495 fn credential_env_vars ( & self ) -> & ' static [ & ' static str ] {
@@ -28,7 +99,7 @@ impl ProviderPlugin for OpencodeProvider {
2899
29100#[ cfg( test) ]
30101mod tests {
31- use super :: SPEC ;
102+ use super :: { SPEC , extract_credentials_from_opencode_config } ;
32103 use crate :: discover_with_spec;
33104 use crate :: test_helpers:: MockDiscoveryContext ;
34105
@@ -43,4 +114,61 @@ mod tests {
43114 Some ( & "op-key" . to_string( ) )
44115 ) ;
45116 }
117+
118+ #[ test]
119+ fn extracts_credentials_from_config_file ( ) {
120+ let config = r#"{
121+ "provider": {
122+ "anthropic": { "options": { "apiKey": "sk-ant-key" } },
123+ "openai": { "options": { "apiKey": "sk-openai-key" } }
124+ }
125+ }"# ;
126+ let creds = extract_credentials_from_opencode_config ( config) ;
127+ assert_eq ! (
128+ creds. get( "ANTHROPIC_API_KEY" ) ,
129+ Some ( & "sk-ant-key" . to_string( ) )
130+ ) ;
131+ assert_eq ! (
132+ creds. get( "OPENAI_API_KEY" ) ,
133+ Some ( & "sk-openai-key" . to_string( ) )
134+ ) ;
135+ }
136+
137+ #[ test]
138+ fn skips_providers_without_api_key ( ) {
139+ let config = r#"{
140+ "provider": {
141+ "ollama": { "options": { "baseUrl": "http://localhost:11434" } }
142+ }
143+ }"# ;
144+ let creds = extract_credentials_from_opencode_config ( config) ;
145+ assert ! (
146+ creds. is_empty( ) ,
147+ "no credentials expected for keyless provider"
148+ ) ;
149+ }
150+
151+ #[ test]
152+ fn skips_empty_api_keys ( ) {
153+ let config = r#"{
154+ "provider": {
155+ "anthropic": { "options": { "apiKey": "" } }
156+ }
157+ }"# ;
158+ let creds = extract_credentials_from_opencode_config ( config) ;
159+ assert ! ( creds. is_empty( ) ) ;
160+ }
161+
162+ #[ test]
163+ fn tolerates_malformed_json ( ) {
164+ let creds = extract_credentials_from_opencode_config ( "not json at all" ) ;
165+ assert ! ( creds. is_empty( ) ) ;
166+ }
167+
168+ #[ test]
169+ fn tolerates_missing_provider_section ( ) {
170+ let config = r#"{ "theme": "dark" }"# ;
171+ let creds = extract_credentials_from_opencode_config ( config) ;
172+ assert ! ( creds. is_empty( ) ) ;
173+ }
46174}
0 commit comments