Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions cmd/obol/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"fmt"
"os"
"sort"
"strings"

"github.com/ObolNetwork/obol-stack/internal/config"
Expand Down Expand Up @@ -46,6 +47,39 @@ func llmCommand(cfg *config.Config) *cli.Command {
return llm.ConfigureLLMSpy(cfg, provider, apiKey)
},
},
{
Name: "status",
Usage: "Show global llmspy provider status",
Action: func(c *cli.Context) error {
status, err := llm.GetProviderStatus(cfg)
if err != nil {
return err
}

providers := make([]string, 0, len(status))
for name := range status {
providers = append(providers, name)
}
sort.Strings(providers)

fmt.Println("Global llmspy providers:")
fmt.Println()
fmt.Printf(" %-12s %-8s %-10s %s\n", "PROVIDER", "ENABLED", "API KEY", "ENV VAR")
for _, name := range providers {
s := status[name]
key := "n/a"
if s.APIKeyEnv != "" {
if s.HasAPIKey {
key = "set"
} else {
key = "missing"
}
}
fmt.Printf(" %-12s %-8t %-10s %s\n", name, s.Enabled, key, s.APIKeyEnv)
}
return nil
},
},
},
}
}
Expand Down
1 change: 1 addition & 0 deletions cmd/obol/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ COMMANDS:

LLM Gateway:
llm configure Configure cloud AI provider in llmspy gateway
llm status Show global llmspy provider status

Inference (x402 Pay-Per-Request):
inference serve Start the x402 inference gateway
Expand Down
91 changes: 91 additions & 0 deletions internal/llm/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ var providerEnvKeys = map[string]string{
"openai": "OPENAI_API_KEY",
}

// ProviderStatus captures effective global llmspy provider state.
type ProviderStatus struct {
Enabled bool
HasAPIKey bool
APIKeyEnv string
}

// ConfigureLLMSpy enables a cloud provider in the llmspy gateway.
// It patches the llms-secrets Secret with the API key, enables the provider
// in the llmspy-config ConfigMap, and restarts the deployment.
Expand Down Expand Up @@ -76,6 +83,73 @@ func ConfigureLLMSpy(cfg *config.Config, provider, apiKey string) error {
return nil
}

// GetProviderStatus reads llmspy ConfigMap + Secret and returns global provider status.
func GetProviderStatus(cfg *config.Config) (map[string]ProviderStatus, error) {
kubectlBinary := filepath.Join(cfg.BinDir, "kubectl")
kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml")
if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) {
return nil, fmt.Errorf("cluster not running. Run 'obol stack up' first")
}

llmsRaw, err := kubectlOutput(kubectlBinary, kubeconfigPath,
"get", "configmap", configMapName, "-n", namespace, "-o", "jsonpath={.data.llms\\.json}")
if err != nil {
return nil, err
}
var llmsConfig map[string]interface{}
if err := json.Unmarshal([]byte(llmsRaw), &llmsConfig); err != nil {
return nil, fmt.Errorf("failed to parse llms.json from ConfigMap: %w", err)
}

status := make(map[string]ProviderStatus)
if providers, ok := llmsConfig["providers"].(map[string]interface{}); ok {
for name, raw := range providers {
enabled := false
if p, ok := raw.(map[string]interface{}); ok {
if v, ok := p["enabled"].(bool); ok {
enabled = v
}
}
keyEnv := providerEnvKeys[name]
status[name] = ProviderStatus{
Enabled: enabled,
HasAPIKey: name == "ollama",
APIKeyEnv: keyEnv,
}
}
}

secretRaw, err := kubectlOutput(kubectlBinary, kubeconfigPath,
"get", "secret", secretName, "-n", namespace, "-o", "json")
if err != nil {
return nil, err
}
var secret struct {
Data map[string]string `json:"data"`
}
if err := json.Unmarshal([]byte(secretRaw), &secret); err != nil {
return nil, fmt.Errorf("failed to parse llms secret: %w", err)
}

for provider, envKey := range providerEnvKeys {
st := status[provider]
st.APIKeyEnv = envKey
if v, ok := secret.Data[envKey]; ok && strings.TrimSpace(v) != "" {
st.HasAPIKey = true
}
status[provider] = st
}

if _, ok := status["ollama"]; !ok {
status["ollama"] = ProviderStatus{
Enabled: true,
HasAPIKey: true,
}
}

return status, nil
}

// enableProviderInConfigMap reads the llmspy-config ConfigMap, parses llms.json,
// sets providers.<name>.enabled = true, and patches the ConfigMap back.
func enableProviderInConfigMap(kubectlBinary, kubeconfigPath, provider string) error {
Expand Down Expand Up @@ -150,3 +224,20 @@ func kubectl(binary, kubeconfig string, args ...string) error {
}
return nil
}

func kubectlOutput(binary, kubeconfig string, args ...string) (string, error) {
cmd := exec.Command(binary, args...)
cmd.Env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeconfig))
var stdout bytes.Buffer
cmd.Stdout = &stdout
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return "", fmt.Errorf("%w: %s", err, errMsg)
}
return "", err
}
return stdout.String(), nil
}
72 changes: 54 additions & 18 deletions internal/openclaw/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ type openclawConfig struct {
}

type openclawProvider struct {
BaseURL string `json:"baseUrl"`
API string `json:"api"`
APIKey string `json:"apiKey"`
Models []openclawModel `json:"models"`
BaseURL string `json:"baseUrl"`
API string `json:"api"`
APIKey string `json:"apiKey"`
Models []openclawModel `json:"models"`
}

type openclawModel struct {
Expand Down Expand Up @@ -135,15 +135,20 @@ func detectExistingConfigAt(home string) (*ImportResult, error) {
fmt.Printf(" Note: unknown API type '%s' for provider '%s', will auto-detect\n", p.API, name)
}
ip := ImportedProvider{
Name: name,
BaseURL: p.BaseURL,
API: sanitized,
Name: name,
BaseURL: p.BaseURL,
API: sanitized,
APIKeyEnvVar: defaultProviderAPIKeyEnvVar(name),
}
// Only import literal API keys, skip env-var references like ${...}
// Import either a literal key (for secret extraction) or env-var reference.
if p.APIKey != "" && !isEnvVarRef(p.APIKey) {
ip.APIKey = p.APIKey
} else if p.APIKey != "" {
fmt.Printf(" Note: provider '%s' uses an env-var reference for its API key (will need manual configuration)\n", name)
if envVar, ok := extractEnvVarName(p.APIKey); ok {
ip.APIKeyEnvVar = envVar
} else {
fmt.Printf(" Note: provider '%s' uses an env-var reference for its API key (will need manual configuration)\n", name)
}
}
for _, m := range p.Models {
ip.Models = append(ip.Models, ImportedModel{ID: m.ID, Name: m.Name})
Expand Down Expand Up @@ -221,9 +226,6 @@ func TranslateToOverlayYAML(result *ImportResult) string {
if p.APIKeyEnvVar != "" {
b.WriteString(fmt.Sprintf(" apiKeyEnvVar: %s\n", p.APIKeyEnvVar))
}
if p.APIKey != "" {
b.WriteString(fmt.Sprintf(" apiKeyValue: %s\n", p.APIKey))
}
if len(p.Models) > 0 {
b.WriteString(" models:\n")
for _, m := range p.Models {
Expand All @@ -244,20 +246,14 @@ func TranslateToOverlayYAML(result *ImportResult) string {
if result.Channels.Telegram != nil {
b.WriteString(" telegram:\n")
b.WriteString(" enabled: true\n")
b.WriteString(fmt.Sprintf(" botToken: %s\n", result.Channels.Telegram.BotToken))
}
if result.Channels.Discord != nil {
b.WriteString(" discord:\n")
b.WriteString(" enabled: true\n")
b.WriteString(fmt.Sprintf(" botToken: %s\n", result.Channels.Discord.BotToken))
}
if result.Channels.Slack != nil {
b.WriteString(" slack:\n")
b.WriteString(" enabled: true\n")
b.WriteString(fmt.Sprintf(" botToken: %s\n", result.Channels.Slack.BotToken))
if result.Channels.Slack.AppToken != "" {
b.WriteString(fmt.Sprintf(" appToken: %s\n", result.Channels.Slack.AppToken))
}
}
b.WriteString("\n")
}
Expand Down Expand Up @@ -368,6 +364,46 @@ func sanitizeModelAPI(api string) string {
return ""
}

func defaultProviderAPIKeyEnvVar(provider string) string {
switch provider {
case "anthropic":
return "ANTHROPIC_API_KEY"
case "openai":
return "OPENAI_API_KEY"
case "ollama":
return "OLLAMA_API_KEY"
default:
var out []rune
for _, r := range strings.ToUpper(provider) {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
out = append(out, r)
} else {
out = append(out, '_')
}
}
s := strings.Trim(string(out), "_")
if s == "" {
return "MODEL_API_KEY"
}
return s + "_API_KEY"
}
}

func extractEnvVarName(s string) (string, bool) {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "${") || !strings.HasSuffix(s, "}") {
return "", false
}
body := strings.TrimSuffix(strings.TrimPrefix(s, "${"), "}")
if body == "" {
return "", false
}
if i := strings.Index(body, ":"); i > 0 {
body = body[:i]
}
return body, body != ""
}

// isEnvVarRef returns true if the value looks like an environment variable reference (${...})
func isEnvVarRef(s string) bool {
return strings.Contains(s, "${")
Expand Down
56 changes: 52 additions & 4 deletions internal/openclaw/import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,44 @@ func TestIsEnvVarRef(t *testing.T) {
}
}

func TestExtractEnvVarName(t *testing.T) {
tests := []struct {
in string
want string
wantOK bool
}{
{"${OPENAI_API_KEY}", "OPENAI_API_KEY", true},
{"${OPENAI_API_KEY:default}", "OPENAI_API_KEY", true},
{"OPENAI_API_KEY", "", false},
{"${}", "", false},
}

for _, tt := range tests {
got, ok := extractEnvVarName(tt.in)
if ok != tt.wantOK || got != tt.want {
t.Errorf("extractEnvVarName(%q) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.wantOK)
}
}
}

func TestDefaultProviderAPIKeyEnvVar(t *testing.T) {
tests := []struct {
provider string
want string
}{
{"anthropic", "ANTHROPIC_API_KEY"},
{"openai", "OPENAI_API_KEY"},
{"ollama", "OLLAMA_API_KEY"},
{"my-provider", "MY_PROVIDER_API_KEY"},
}

for _, tt := range tests {
if got := defaultProviderAPIKeyEnvVar(tt.provider); got != tt.want {
t.Errorf("defaultProviderAPIKeyEnvVar(%q) = %q, want %q", tt.provider, got, tt.want)
}
}
}

func TestSanitizeModelAPI(t *testing.T) {
// All valid values should pass through unchanged
valid := []string{
Expand Down Expand Up @@ -212,7 +250,6 @@ func TestTranslateToOverlayYAML_ProviderWithModels(t *testing.T) {
"anthropic:\n enabled: true",
"baseUrl: https://api.anthropic.com/v1",
"api: anthropic-messages",
"apiKeyValue: sk-ant-test",
"- id: claude-opus-4-6",
"name: Claude Opus 4.6",
}
Expand Down Expand Up @@ -267,15 +304,20 @@ func TestTranslateToOverlayYAML_Channels(t *testing.T) {
got := TranslateToOverlayYAML(result)

checks := []string{
"telegram:\n enabled: true\n botToken: 123456:ABC",
"discord:\n enabled: true\n botToken: MTIz...",
"slack:\n enabled: true\n botToken: xoxb-test\n appToken: xapp-test",
"telegram:\n enabled: true",
"discord:\n enabled: true",
"slack:\n enabled: true",
}
for _, check := range checks {
if !strings.Contains(got, check) {
t.Errorf("YAML missing %q, got:\n%s", check, got)
}
}
for _, unexpected := range []string{"botToken:", "appToken:"} {
if strings.Contains(got, unexpected) {
t.Errorf("YAML should not contain %q, got:\n%s", unexpected, got)
}
}
}

func TestTranslateToOverlayYAML_FullConfig(t *testing.T) {
Expand Down Expand Up @@ -391,6 +433,9 @@ func TestDetectExistingConfigAt_ValidConfig(t *testing.T) {
if p.API != "anthropic-messages" {
t.Errorf("Provider.API = %q, want %q", p.API, "anthropic-messages")
}
if p.APIKeyEnvVar != "ANTHROPIC_API_KEY" {
t.Errorf("Provider.APIKeyEnvVar = %q, want %q", p.APIKeyEnvVar, "ANTHROPIC_API_KEY")
}
if len(p.Models) != 1 || p.Models[0].ID != "claude-opus-4-6" {
t.Errorf("Provider.Models = %v", p.Models)
}
Expand Down Expand Up @@ -423,6 +468,9 @@ func TestDetectExistingConfigAt_EnvVarKeySkipped(t *testing.T) {
if result.Providers[0].APIKey != "" {
t.Errorf("Provider.APIKey = %q, want empty (env-var should be skipped)", result.Providers[0].APIKey)
}
if result.Providers[0].APIKeyEnvVar != "OPENAI_API_KEY" {
t.Errorf("Provider.APIKeyEnvVar = %q, want OPENAI_API_KEY", result.Providers[0].APIKeyEnvVar)
}
}

func TestDetectExistingConfigAt_ChannelImport(t *testing.T) {
Expand Down
Loading