Compare commits

..
Author SHA1 Message Date
GRAB Racem a6e3fd0a2e fix(agent): match ARTEX_LLM_PROVIDER case-insensitively
FromEnv compared the raw value, so "Ollama" or "OpenAI" matched no case and
fell through to the anthropic default. With ARTEX_LLM_PROVIDER=Ollama and an
ANTHROPIC_API_KEY set, the Anthropic key was sent to the Ollama base URL.
2026-11-03 23:20:21 +01:00
4 changed files with 40 additions and 33 deletions
+3 -1
View File
@@ -203,7 +203,9 @@ func compactionConfig(windowTokens int) *compaction.Config {
// ARTEX_LLM_PROXY = proxy URL (optional; http/https/socks5)
// ANTHROPIC_API_KEY / OPENAI_API_KEY = credentials (unused for ollama)
func FromEnv() (Config, bool) {
prov := os.Getenv("ARTEX_LLM_PROVIDER")
// Case- and space-insensitive: "Ollama" used to miss every case below and
// fall through to anthropic, sending ANTHROPIC_API_KEY to the Ollama base URL.
prov := strings.ToLower(strings.TrimSpace(os.Getenv("ARTEX_LLM_PROVIDER")))
anthKey := os.Getenv("ANTHROPIC_API_KEY")
oaiKey := os.Getenv("OPENAI_API_KEY")
+37
View File
@@ -0,0 +1,37 @@
package agent
import (
"testing"
"github.com/Autumn-27/norma/llm"
)
// ARTEX_LLM_PROVIDER is matched regardless of case and surrounding spaces. A
// miss used to fall through to anthropic: "Ollama" then sent ANTHROPIC_API_KEY
// to the Ollama base URL.
func TestFromEnvProviderIsCaseInsensitive(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "sk-ant-secret")
t.Setenv("OPENAI_API_KEY", "sk-oai")
t.Setenv("ARTEX_LLM_BASE_URL", "")
t.Setenv("ARTEX_LLM_MODEL", "")
for _, tc := range []struct {
env string
want llm.Format
key string
}{
{"Ollama", FormatOllama, ""},
{" OLLAMA ", FormatOllama, ""},
{"OpenAI", llm.FormatOpenAI, "sk-oai"},
{"OpenAI-Responses", llm.FormatOpenAIResponses, "sk-oai"},
{"Anthropic", llm.FormatAnthropic, "sk-ant-secret"},
} {
t.Setenv("ARTEX_LLM_PROVIDER", tc.env)
c, ok := FromEnv()
if !ok {
t.Fatalf("%q: FromEnv returned ok=false", tc.env)
}
if c.Format != tc.want || c.APIKey != tc.key {
t.Fatalf("%q: format=%v key=%q, want format=%v key=%q", tc.env, c.Format, c.APIKey, tc.want, tc.key)
}
}
}
-29
View File
@@ -1,8 +1,6 @@
package server
import (
"context"
"os"
"os/exec"
"strings"
"testing"
@@ -36,30 +34,3 @@ func TestScrubSecretEnvHidesSecretsFromChildren(t *testing.T) {
t.Errorf("non-secret variable did not reach the child process")
}
}
// New must apply the options it receives. An earlier version accepted
// ...Option and never called them, so the binary kept the secrets in the
// environment while the unit test above still passed.
func TestNewAppliesSecretScrubOption(t *testing.T) {
// t.Setenv restores both after the test: the scrub unsets them for the rest
// of this process, and later tests still need the DSN.
if dsn := os.Getenv("ARTEX_PG_DSN"); dsn != "" {
t.Setenv("ARTEX_PG_DSN", dsn)
}
t.Setenv("ANTHROPIC_API_KEY", "scrub-probe")
t.Setenv("OPENAI_API_KEY", "scrub-probe")
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("no manager: %v", err)
}
t.Cleanup(func() { m.Close() })
ctx, cancel := context.WithCancel(context.Background())
cancel()
_ = New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir(), WithSecretScrub())
if os.Getenv("ANTHROPIC_API_KEY") != "" || os.Getenv("OPENAI_API_KEY") != "" {
t.Fatal("New did not apply WithSecretScrub: the secrets are still in the environment")
}
}
-3
View File
@@ -156,9 +156,6 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
profChatAgents: map[int64]*agent.ChatAgent{},
provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg),
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)}
for _, o := range opts {
o(s)
}
s.initSetupToken()
s.initSideQuestions()
// Circuit-breaker thresholds/cooldowns are hot parameters on the failure path; push the global retry policy to the Registry once at startup,