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.
This commit is contained in:
Eric Curtin
2026-05-12 07:43:23 -07:00
committed by GitHub
parent 9ea94b645d
commit df5a8b943f
@@ -1,8 +1,12 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::{
ProviderDiscoverySpec, ProviderError, ProviderPlugin, RealDiscoveryContext, discover_with_spec,
DiscoveredProvider, ProviderDiscoverySpec, ProviderError, ProviderPlugin, RealDiscoveryContext,
discover_with_spec,
};
pub struct OpencodeProvider;
@@ -12,13 +16,80 @@ pub const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec {
credential_env_vars: &["OPENCODE_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY"],
};
/// Return the path to the opencode config file, respecting `XDG_CONFIG_HOME`.
fn opencode_config_path() -> Option<PathBuf> {
let config_home = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".config"))
})?;
Some(config_home.join("opencode").join("opencode.json"))
}
/// Extract API key credentials from the contents of an opencode config file.
///
/// opencode stores per-provider API keys at `provider.<name>.options.apiKey`.
/// Each key is surfaced as `<NAME_UPPERCASE>_API_KEY` so that it can be injected
/// as an environment variable into the sandbox and picked up by opencode at runtime.
fn extract_credentials_from_opencode_config(content: &str) -> HashMap<String, String> {
let Ok(json) = serde_json::from_str::<serde_json::Value>(content) else {
return HashMap::new();
};
let Some(providers) = json.get("provider").and_then(|p| p.as_object()) else {
return HashMap::new();
};
let mut creds = HashMap::new();
for (provider_name, provider_cfg) in providers {
if let Some(api_key) = provider_cfg
.get("options")
.and_then(|o| o.get("apiKey"))
.and_then(|k| k.as_str())
.filter(|k| !k.trim().is_empty())
{
let env_var = format!("{}_API_KEY", provider_name.to_ascii_uppercase());
creds.insert(env_var, api_key.to_string());
}
}
creds
}
/// Read opencode credentials from `path`, returning `None` if the file is absent or unreadable.
fn read_opencode_config_file(path: &Path) -> Option<HashMap<String, String>> {
let content = std::fs::read_to_string(path).ok()?;
let creds = extract_credentials_from_opencode_config(&content);
if creds.is_empty() { None } else { Some(creds) }
}
impl ProviderPlugin for OpencodeProvider {
fn id(&self) -> &'static str {
SPEC.id
}
fn discover_existing(&self) -> Result<Option<crate::DiscoveredProvider>, ProviderError> {
discover_with_spec(&SPEC, &RealDiscoveryContext)
fn discover_existing(&self) -> Result<Option<DiscoveredProvider>, ProviderError> {
let mut discovered = discover_with_spec(&SPEC, &RealDiscoveryContext)?.unwrap_or_default();
// Supplement env-var discovery with credentials stored in the opencode config file.
// opencode's native config lives at $XDG_CONFIG_HOME/opencode/opencode.json and stores
// API keys under `provider.<name>.options.apiKey`. If the user configured opencode
// normally (i.e. no env vars set), this is the only place the keys exist.
if let Some(path) = opencode_config_path()
&& let Some(file_creds) = read_opencode_config_file(&path)
{
for (key, value) in file_creds {
// Env vars already set take priority; config file fills the gaps.
discovered.credentials.entry(key).or_insert(value);
}
}
if discovered.is_empty() {
Ok(None)
} else {
Ok(Some(discovered))
}
}
fn credential_env_vars(&self) -> &'static [&'static str] {
@@ -28,7 +99,7 @@ impl ProviderPlugin for OpencodeProvider {
#[cfg(test)]
mod tests {
use super::SPEC;
use super::{SPEC, extract_credentials_from_opencode_config};
use crate::discover_with_spec;
use crate::test_helpers::MockDiscoveryContext;
@@ -43,4 +114,61 @@ mod tests {
Some(&"op-key".to_string())
);
}
#[test]
fn extracts_credentials_from_config_file() {
let config = r#"{
"provider": {
"anthropic": { "options": { "apiKey": "sk-ant-key" } },
"openai": { "options": { "apiKey": "sk-openai-key" } }
}
}"#;
let creds = extract_credentials_from_opencode_config(config);
assert_eq!(
creds.get("ANTHROPIC_API_KEY"),
Some(&"sk-ant-key".to_string())
);
assert_eq!(
creds.get("OPENAI_API_KEY"),
Some(&"sk-openai-key".to_string())
);
}
#[test]
fn skips_providers_without_api_key() {
let config = r#"{
"provider": {
"ollama": { "options": { "baseUrl": "http://localhost:11434" } }
}
}"#;
let creds = extract_credentials_from_opencode_config(config);
assert!(
creds.is_empty(),
"no credentials expected for keyless provider"
);
}
#[test]
fn skips_empty_api_keys() {
let config = r#"{
"provider": {
"anthropic": { "options": { "apiKey": "" } }
}
}"#;
let creds = extract_credentials_from_opencode_config(config);
assert!(creds.is_empty());
}
#[test]
fn tolerates_malformed_json() {
let creds = extract_credentials_from_opencode_config("not json at all");
assert!(creds.is_empty());
}
#[test]
fn tolerates_missing_provider_section() {
let config = r#"{ "theme": "dark" }"#;
let creds = extract_credentials_from_opencode_config(config);
assert!(creds.is_empty());
}
}