mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-20 06:00:06 +08:00
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user