Skip to content

Commit df5a8b9

Browse files
authored
fix(providers): read opencode config file during credential discovery (#1290)
Supplement env-var discovery with API keys stored in the opencode config file at $XDG_CONFIG_HOME/opencode/opencode.json. Keys under provider.<name>.options.apiKey are surfaced as <NAME>_API_KEY env vars; existing env vars take priority over file-sourced values.
1 parent 9ea94b6 commit df5a8b9

1 file changed

Lines changed: 132 additions & 4 deletions

File tree

crates/openshell-providers/src/providers/opencode.rs

Lines changed: 132 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
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+
47
use crate::{
5-
ProviderDiscoverySpec, ProviderError, ProviderPlugin, RealDiscoveryContext, discover_with_spec,
8+
DiscoveredProvider, ProviderDiscoverySpec, ProviderError, ProviderPlugin, RealDiscoveryContext,
9+
discover_with_spec,
610
};
711

812
pub 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+
1567
impl 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)]
30101
mod 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

Comments
 (0)