Skip to content

Commit 24c850e

Browse files
committed
fix
1 parent 1d4faa1 commit 24c850e

13 files changed

Lines changed: 439 additions & 17 deletions

File tree

code_assistant_manager/tools.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ tools:
5050
- "-c profiles.custom.model={selected_model}"
5151
- "-c profiles.custom.model_provider=custom"
5252
- "-c profiles.custom.model_reasoning_effort=low"
53-
- "-c model_providers.custom.env_key=OPENAI_API_KEY"
53+
- "-c model_providers.custom.env_key={api_key_env}"
5454
- "-p custom"
5555

5656
qwen-code:

frontend/src/pages/Providers.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ export function Providers() {
1616
// users can set/replace the key on providers that predate the field.
1717
const [keyDraft, setKeyDraft] = useState<Record<string, string>>({})
1818
const [keyStatus, setKeyStatus] = useState<Record<string, string>>({})
19+
// Per-provider draft API-key env-var name (used by tools like codex that read
20+
// the key from an env var named by env_key).
21+
const [envDraft, setEnvDraft] = useState<Record<string, string>>({})
22+
const [envStatus, setEnvStatus] = useState<Record<string, string>>({})
1923

2024
async function reload() { setProviders(await api.listProviders()) }
2125
useEffect(() => { void reload() }, [])
@@ -42,6 +46,21 @@ export function Providers() {
4246
}
4347
}
4448

49+
async function saveApiKeyEnv(provider: Provider) {
50+
const value = envDraft[provider.name] ?? ''
51+
if (!value) return
52+
setEnvStatus((s) => ({ ...s, [provider.name]: t('providers.apiKeySaving') }))
53+
try {
54+
const updated = await api.updateProvider(provider.name, { apiKeyEnv: value })
55+
setProviders((items) => items.map((item) => item.name === provider.name ? updated : item))
56+
setEnvDraft((d) => ({ ...d, [provider.name]: '' }))
57+
setEnvStatus((s) => ({ ...s, [provider.name]: t('providers.apiKeySaved') }))
58+
} catch (err) {
59+
const message = err instanceof Error ? err.message : String(err)
60+
setEnvStatus((s) => ({ ...s, [provider.name]: message }))
61+
}
62+
}
63+
4564
async function toggle(provider: Provider) {
4665
const updated = await api.toggleProvider(provider.name, !provider.enabled)
4766
setProviders((items) => items.map((item) => item.name === provider.name ? updated : item))
@@ -101,6 +120,22 @@ export function Providers() {
101120
</div>
102121
</dd>
103122
</div>
123+
<div>
124+
<dt>{t('providers.setApiKeyEnv')}</dt>
125+
<dd>
126+
<div className="inline-form">
127+
<input
128+
aria-label={`${t('providers.setApiKeyEnv')} ${p.name}`}
129+
type="text"
130+
placeholder="OMNILLM_API_KEY"
131+
value={envDraft[p.name] ?? ''}
132+
onChange={(event) => setEnvDraft((d) => ({ ...d, [p.name]: event.target.value }))}
133+
/>
134+
<button onClick={() => saveApiKeyEnv(p)} disabled={!(envDraft[p.name] ?? '').length}>{t('providers.apiKeySave')}</button>
135+
{envStatus[p.name] && <span style={{ fontSize: '0.85em' }}>{envStatus[p.name]}</span>}
136+
</div>
137+
</dd>
138+
</div>
104139
<div><dt>{t('providers.clients')}</dt><dd>{p.clients.join(', ') || '—'}</dd></div>
105140
</dl>
106141
)}

frontend/src/services/i18n.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const en: Record<string, string> = {
2727
'providers.apiKeyPlaceholder': 'sk-… (stored with provider)',
2828
'providers.apiKeyHint': 'Literal API key stored with the provider and written into agent configs. Takes precedence over the env var.',
2929
'providers.setApiKey': 'Set API key',
30+
'providers.setApiKeyEnv': 'Set API key env var',
3031
'providers.apiKeySave': 'Save key',
3132
'providers.apiKeySaving': 'Saving…',
3233
'providers.apiKeySaved': 'Saved.',
@@ -112,6 +113,7 @@ const zh: Record<string, string> = {
112113
'providers.apiKeyPlaceholder': 'sk-…(与提供商一起保存)',
113114
'providers.apiKeyHint': '与提供商一起保存并写入各代理配置的 API 密钥,优先级高于环境变量。',
114115
'providers.setApiKey': '设置 API 密钥',
116+
'providers.setApiKeyEnv': '设置 API 密钥环境变量',
115117
'providers.apiKeySave': '保存密钥',
116118
'providers.apiKeySaving': '保存中…',
117119
'providers.apiKeySaved': '已保存。',

internal/desktop/tool_cache.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package desktop
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"sync"
9+
"time"
10+
11+
"github.com/chat2anyllm/code-agent-manager/internal/pathutil"
12+
)
13+
14+
// detectionTTL bounds how long a cached tool-detection result is served
15+
// without re-probing the binary. Each probe spawns a subprocess (`<bin>
16+
// --version`), which is the dominant cost of the Agents page; CLI
17+
// install/version state changes rarely, so even a short TTL makes repeat
18+
// page loads instant while still picking up installs/upgrades within the
19+
// window. Mirrors the model-discovery cache TTL convention in
20+
// internal/providers/models.go.
21+
const detectionTTL = 5 * time.Minute
22+
23+
// detectionEntry is one cached tool-detection result.
24+
type detectionEntry struct {
25+
Installed bool `json:"installed"`
26+
Version string `json:"version"`
27+
DetectedAt time.Time `json:"detected_at"`
28+
}
29+
30+
// detectionCacheFile is the on-disk shape at cachePath().
31+
type detectionCacheFile struct {
32+
Tools map[string]detectionEntry `json:"tools"`
33+
}
34+
35+
// detectionCache memoizes tool-detection results. It is held on the
36+
// long-lived sidecar ToolService, so the in-memory map makes repeat
37+
// Agents-page loads free within a process; the on-disk file additionally
38+
// lets a freshly started sidecar serve the last-known results immediately.
39+
type detectionCache struct {
40+
mu sync.RWMutex
41+
loaded bool // disk has been read into entries
42+
path string
43+
entries map[string]detectionEntry
44+
}
45+
46+
func newDetectionCache() *detectionCache {
47+
return &detectionCache{
48+
path: filepath.Join(pathutil.CacheDir(), "tools", "detection.json"),
49+
entries: map[string]detectionEntry{},
50+
}
51+
}
52+
53+
// loadOnce reads the on-disk cache into memory the first time it is called.
54+
// Best-effort: a missing or corrupt file leaves the cache empty so callers
55+
// fall through to a fresh probe.
56+
func (c *detectionCache) loadOnce() {
57+
c.mu.Lock()
58+
defer c.mu.Unlock()
59+
if c.loaded {
60+
return
61+
}
62+
c.loaded = true
63+
raw, err := os.ReadFile(c.path)
64+
if err != nil {
65+
return
66+
}
67+
var file detectionCacheFile
68+
if err := json.Unmarshal(raw, &file); err != nil {
69+
return
70+
}
71+
for name, entry := range file.Tools {
72+
c.entries[name] = entry
73+
}
74+
}
75+
76+
// get returns the cached entry for name and whether it is still fresh
77+
// (within detectionTTL). A stale or missing entry reports fresh=false so the
78+
// caller knows to re-probe.
79+
func (c *detectionCache) get(name string) (detectionEntry, bool) {
80+
c.mu.RLock()
81+
defer c.mu.RUnlock()
82+
entry, ok := c.entries[name]
83+
if !ok {
84+
return detectionEntry{}, false
85+
}
86+
if entry.DetectedAt.IsZero() || time.Since(entry.DetectedAt) > detectionTTL {
87+
return entry, false
88+
}
89+
return entry, true
90+
}
91+
92+
// put stores a fresh detection result.
93+
func (c *detectionCache) put(name string, installed bool, version string) {
94+
c.mu.Lock()
95+
c.entries[name] = detectionEntry{Installed: installed, Version: version, DetectedAt: time.Now()}
96+
c.mu.Unlock()
97+
}
98+
99+
// persist writes the in-memory entries to disk atomically. Best-effort: a
100+
// write failure only means the next cold start re-probes, which is correct.
101+
func (c *detectionCache) persist() {
102+
c.mu.RLock()
103+
file := detectionCacheFile{Tools: make(map[string]detectionEntry, len(c.entries))}
104+
for name, entry := range c.entries {
105+
file.Tools[name] = entry
106+
}
107+
path := c.path
108+
c.mu.RUnlock()
109+
110+
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
111+
return
112+
}
113+
payload, err := json.MarshalIndent(file, "", " ")
114+
if err != nil {
115+
return
116+
}
117+
tmp := fmt.Sprintf("%s.tmp.%d", path, os.Getpid())
118+
if err := os.WriteFile(tmp, payload, 0o600); err != nil {
119+
return
120+
}
121+
_ = os.Rename(tmp, path)
122+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package desktop
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
"time"
9+
)
10+
11+
// withTempCacheDir points the detection cache at an isolated temp directory
12+
// for the test and restores the original value on cleanup.
13+
func withTempCacheDir(t *testing.T) string {
14+
t.Helper()
15+
dir := t.TempDir()
16+
previous := os.Getenv("CAM_CACHE_DIR")
17+
os.Setenv("CAM_CACHE_DIR", dir)
18+
t.Cleanup(func() { os.Setenv("CAM_CACHE_DIR", previous) })
19+
return dir
20+
}
21+
22+
func TestDetectionCacheGetMissing(t *testing.T) {
23+
cache := newDetectionCache()
24+
cache.loadOnce()
25+
26+
if _, fresh := cache.get("does-not-exist"); fresh {
27+
t.Fatal("missing entry should not be fresh")
28+
}
29+
}
30+
31+
func TestDetectionCachePutThenGetFresh(t *testing.T) {
32+
cache := newDetectionCache()
33+
cache.loadOnce()
34+
35+
cache.put("claude-code", true, "1.2.3")
36+
entry, fresh := cache.get("claude-code")
37+
if !fresh {
38+
t.Fatal("just-cached entry should be fresh")
39+
}
40+
if !entry.Installed || entry.Version != "1.2.3" {
41+
t.Fatalf("entry = %+v, want {Installed:true Version:1.2.3}", entry)
42+
}
43+
}
44+
45+
func TestDetectionCacheStaleAfterTTL(t *testing.T) {
46+
cache := newDetectionCache()
47+
cache.loadOnce()
48+
cache.put("gemini-cli", true, "9.9.9")
49+
50+
// Backdate the entry past detectionTTL so it reads as stale.
51+
cache.mu.Lock()
52+
e := cache.entries["gemini-cli"]
53+
e.DetectedAt = time.Now().Add(-detectionTTL - time.Second)
54+
cache.entries["gemini-cli"] = e
55+
cache.mu.Unlock()
56+
57+
if _, fresh := cache.get("gemini-cli"); fresh {
58+
t.Fatal("backdated entry should be stale")
59+
}
60+
}
61+
62+
func TestDetectionCachePersistLoadRoundTrip(t *testing.T) {
63+
withTempCacheDir(t)
64+
cache := newDetectionCache()
65+
cache.loadOnce()
66+
67+
cache.put("claude-code", true, "1.2.3")
68+
cache.put("gemini-cli", false, "")
69+
cache.persist()
70+
71+
// A fresh cache over the same on-disk file should see both entries.
72+
loaded := newDetectionCache()
73+
loaded.loadOnce()
74+
if _, fresh := loaded.get("claude-code"); !fresh {
75+
t.Fatal("claude-code should load from disk fresh")
76+
}
77+
if _, fresh := loaded.get("gemini-cli"); !fresh {
78+
t.Fatal("gemini-cli should load from disk fresh")
79+
}
80+
}
81+
82+
func TestDetectionCacheWritesToToolsSubdir(t *testing.T) {
83+
dir := withTempCacheDir(t)
84+
cache := newDetectionCache()
85+
cache.loadOnce()
86+
cache.put("claude-code", true, "1.0.0")
87+
cache.persist()
88+
89+
path := filepath.Join(dir, "tools", "detection.json")
90+
raw, err := os.ReadFile(path)
91+
if err != nil {
92+
t.Fatalf("read cache file: %v", err)
93+
}
94+
var file detectionCacheFile
95+
if err := json.Unmarshal(raw, &file); err != nil {
96+
t.Fatalf("unmarshal cache file: %v", err)
97+
}
98+
if entry, ok := file.Tools["claude-code"]; !ok || entry.Version != "1.0.0" {
99+
t.Fatalf("cache file = %+v, want claude-code 1.0.0", file.Tools)
100+
}
101+
}
102+
103+
func TestDetectionCacheLoadMissingFileIsNoOp(t *testing.T) {
104+
withTempCacheDir(t)
105+
cache := newDetectionCache()
106+
cache.loadOnce() // no file exists yet
107+
if _, fresh := cache.get("anything"); fresh {
108+
t.Fatal("empty cache should report nothing fresh")
109+
}
110+
}
111+
112+
func TestDetectionCacheLoadCorruptFileIsNoOp(t *testing.T) {
113+
dir := withTempCacheDir(t)
114+
if err := os.MkdirAll(filepath.Join(dir, "tools"), 0o700); err != nil {
115+
t.Fatal(err)
116+
}
117+
if err := os.WriteFile(filepath.Join(dir, "tools", "detection.json"), []byte("{not json"), 0o600); err != nil {
118+
t.Fatal(err)
119+
}
120+
cache := newDetectionCache()
121+
cache.loadOnce() // corrupt file must not panic or pollute
122+
if _, fresh := cache.get("anything"); fresh {
123+
t.Fatal("corrupt cache file should leave nothing fresh")
124+
}
125+
}

0 commit comments

Comments
 (0)