Skip to content

Commit 6069a34

Browse files
Merge pull request #103 from 73ai/settings-command
feat: add obk settings interactive TUI
2 parents edbd032 + 99abf65 commit 6069a34

26 files changed

+3596
-320
lines changed

config/config_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -428,8 +428,8 @@ func TestCustomProfile_RoundTrip(t *testing.T) {
428428
Tiers: ProfileTiers{
429429
Default: "anthropic/claude-haiku-4-5",
430430
Complex: "gemini/gemini-2.5-pro",
431-
Fast: "gemini/gemini-2.0-flash-lite",
432-
Nano: "gemini/gemini-2.0-flash-lite",
431+
Fast: "gemini/gemini-2.0-flash",
432+
Nano: "gemini/gemini-2.0-flash",
433433
},
434434
Providers: []string{"anthropic", "gemini"},
435435
},
@@ -494,8 +494,8 @@ func TestCustomProfile_MultipleProfiles_RoundTrip(t *testing.T) {
494494
Tiers: ProfileTiers{
495495
Default: "gemini/gemini-2.5-flash",
496496
Complex: "gemini/gemini-2.5-pro",
497-
Fast: "gemini/gemini-2.0-flash-lite",
498-
Nano: "gemini/gemini-2.0-flash-lite",
497+
Fast: "gemini/gemini-2.0-flash",
498+
Nano: "gemini/gemini-2.0-flash",
499499
},
500500
Providers: []string{"gemini"},
501501
},
@@ -504,8 +504,8 @@ func TestCustomProfile_MultipleProfiles_RoundTrip(t *testing.T) {
504504
Tiers: ProfileTiers{
505505
Default: "anthropic/claude-sonnet-4-6",
506506
Complex: "anthropic/claude-opus-4-6",
507-
Fast: "gemini/gemini-2.0-flash-lite",
508-
Nano: "gemini/gemini-2.0-flash-lite",
507+
Fast: "gemini/gemini-2.0-flash",
508+
Nano: "gemini/gemini-2.0-flash",
509509
},
510510
Providers: []string{"anthropic", "gemini"},
511511
},
@@ -542,8 +542,8 @@ func TestCustomProfile_EmptyLabelAndDescription(t *testing.T) {
542542
Tiers: ProfileTiers{
543543
Default: "gemini/gemini-2.5-flash",
544544
Complex: "gemini/gemini-2.5-pro",
545-
Fast: "gemini/gemini-2.0-flash-lite",
546-
Nano: "gemini/gemini-2.0-flash-lite",
545+
Fast: "gemini/gemini-2.0-flash",
546+
Nano: "gemini/gemini-2.0-flash",
547547
},
548548
Providers: []string{"gemini"},
549549
},

config/models.go

Lines changed: 0 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package config
22

3-
import "slices"
4-
53
// ModelInfo describes a model available for profile configuration.
64
type ModelInfo struct {
75
Provider string
@@ -10,58 +8,3 @@ type ModelInfo struct {
108
ContextWindow int
119
RecommendedFor []string // "default", "complex", "fast", "nano"
1210
}
13-
14-
// ModelCatalog lists all models available for custom profile building.
15-
var ModelCatalog = []ModelInfo{
16-
// Anthropic
17-
{Provider: "anthropic", ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (balanced)", ContextWindow: 200000, RecommendedFor: []string{"default", "complex"}},
18-
{Provider: "anthropic", ID: "claude-opus-4-6", Label: "Claude Opus 4.6 (most capable)", ContextWindow: 200000, RecommendedFor: []string{"complex"}},
19-
{Provider: "anthropic", ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5 (fast, cheap)", ContextWindow: 200000, RecommendedFor: []string{"default", "fast", "nano"}},
20-
21-
// OpenAI
22-
{Provider: "openai", ID: "gpt-4o", Label: "GPT-4o (most capable)", ContextWindow: 128000, RecommendedFor: []string{"default", "complex"}},
23-
{Provider: "openai", ID: "gpt-4o-mini", Label: "GPT-4o Mini (fast, cheap)", ContextWindow: 128000, RecommendedFor: []string{"default", "fast", "nano"}},
24-
25-
// Gemini
26-
{Provider: "gemini", ID: "gemini-2.5-pro", Label: "Gemini 2.5 Pro (most capable)", ContextWindow: 1048576, RecommendedFor: []string{"complex"}},
27-
{Provider: "gemini", ID: "gemini-2.5-flash", Label: "Gemini 2.5 Flash (balanced)", ContextWindow: 1048576, RecommendedFor: []string{"default", "complex"}},
28-
{Provider: "gemini", ID: "gemini-2.0-flash-lite", Label: "Gemini 2.0 Flash Lite (fastest)", ContextWindow: 1048576, RecommendedFor: []string{"fast", "nano"}},
29-
30-
// OpenRouter
31-
{Provider: "openrouter", ID: "anthropic/claude-sonnet-4-6", Label: "Claude Sonnet 4.6 via OpenRouter", ContextWindow: 200000, RecommendedFor: []string{"default", "complex"}},
32-
{Provider: "openrouter", ID: "anthropic/claude-haiku-4-5", Label: "Claude Haiku 4.5 via OpenRouter", ContextWindow: 200000, RecommendedFor: []string{"default", "fast"}},
33-
{Provider: "openrouter", ID: "anthropic/claude-opus-4-6", Label: "Claude Opus 4.6 via OpenRouter", ContextWindow: 200000, RecommendedFor: []string{"complex"}},
34-
{Provider: "openrouter", ID: "google/gemini-2.0-flash-lite", Label: "Gemini Flash Lite via OpenRouter", ContextWindow: 1048576, RecommendedFor: []string{"fast", "nano"}},
35-
{Provider: "openrouter", ID: "mistralai/mistral-medium-3.1", Label: "Mistral Medium 3.1 via OpenRouter", ContextWindow: 131072, RecommendedFor: []string{"default"}},
36-
37-
// Groq
38-
{Provider: "groq", ID: "llama-3.1-8b-instant", Label: "Llama 3.1 8B (fastest)", ContextWindow: 131072, RecommendedFor: []string{"fast", "nano"}},
39-
{Provider: "groq", ID: "llama-3.3-70b-versatile", Label: "Llama 3.3 70B (versatile)", ContextWindow: 131072, RecommendedFor: []string{"default"}},
40-
{Provider: "groq", ID: "llama-4-scout-17b-16e", Label: "Llama 4 Scout 17B", ContextWindow: 131072, RecommendedFor: []string{"default", "complex"}},
41-
}
42-
43-
// ModelsForProviders returns models matching the given provider names.
44-
func ModelsForProviders(providers []string) []ModelInfo {
45-
provSet := make(map[string]bool, len(providers))
46-
for _, p := range providers {
47-
provSet[p] = true
48-
}
49-
var result []ModelInfo
50-
for _, m := range ModelCatalog {
51-
if provSet[m.Provider] {
52-
result = append(result, m)
53-
}
54-
}
55-
return result
56-
}
57-
58-
// ModelsForTier filters models to those recommended for a given tier.
59-
func ModelsForTier(models []ModelInfo, tier string) []ModelInfo {
60-
var result []ModelInfo
61-
for _, m := range models {
62-
if slices.Contains(m.RecommendedFor, tier) {
63-
result = append(result, m)
64-
}
65-
}
66-
return result
67-
}

config/models_test.go

Lines changed: 16 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -2,124 +2,21 @@ package config
22

33
import "testing"
44

5-
func TestModelsForProviders_FiltersByProvider(t *testing.T) {
6-
models := ModelsForProviders([]string{"anthropic"})
7-
for _, m := range models {
8-
if m.Provider != "anthropic" {
9-
t.Errorf("expected provider anthropic, got %q", m.Provider)
10-
}
11-
}
12-
if len(models) == 0 {
13-
t.Fatal("expected at least one anthropic model")
14-
}
15-
}
16-
17-
func TestModelsForProviders_MultipleProviders(t *testing.T) {
18-
models := ModelsForProviders([]string{"anthropic", "gemini"})
19-
providers := make(map[string]bool)
20-
for _, m := range models {
21-
providers[m.Provider] = true
22-
}
23-
if !providers["anthropic"] || !providers["gemini"] {
24-
t.Errorf("expected anthropic and gemini, got %v", providers)
25-
}
26-
}
27-
28-
func TestModelsForProviders_Empty(t *testing.T) {
29-
models := ModelsForProviders(nil)
30-
if len(models) != 0 {
31-
t.Errorf("expected no models for nil providers, got %d", len(models))
32-
}
33-
}
34-
35-
func TestModelsForTier_ReturnsRecommended(t *testing.T) {
36-
all := ModelsForProviders([]string{"anthropic", "gemini"})
37-
fast := ModelsForTier(all, "fast")
38-
if len(fast) == 0 {
39-
t.Fatal("expected at least one fast-tier model")
40-
}
41-
for _, m := range fast {
42-
found := false
43-
for _, r := range m.RecommendedFor {
44-
if r == "fast" {
45-
found = true
46-
break
47-
}
48-
}
49-
if !found {
50-
t.Errorf("model %q not recommended for fast tier", m.ID)
51-
}
52-
}
53-
}
54-
55-
func TestModelsForTier_UnknownTier(t *testing.T) {
56-
all := ModelsForProviders([]string{"anthropic"})
57-
result := ModelsForTier(all, "nonexistent")
58-
if len(result) != 0 {
59-
t.Errorf("expected no models for unknown tier, got %d", len(result))
60-
}
61-
}
62-
63-
func TestModelCatalog_AllHaveRequiredFields(t *testing.T) {
64-
for _, m := range ModelCatalog {
65-
if m.Provider == "" {
66-
t.Errorf("model %q: empty Provider", m.ID)
67-
}
68-
if m.ID == "" {
69-
t.Error("empty model ID")
70-
}
71-
if m.Label == "" {
72-
t.Errorf("model %q: empty Label", m.ID)
73-
}
74-
if m.ContextWindow <= 0 {
75-
t.Errorf("model %q: invalid ContextWindow %d", m.ID, m.ContextWindow)
76-
}
77-
if len(m.RecommendedFor) == 0 {
78-
t.Errorf("model %q: empty RecommendedFor", m.ID)
79-
}
80-
}
81-
}
82-
83-
func TestModelCatalog_NoDuplicateIDs(t *testing.T) {
84-
seen := make(map[string]bool)
85-
for _, m := range ModelCatalog {
86-
key := m.Provider + "/" + m.ID
87-
if seen[key] {
88-
t.Errorf("duplicate model in catalog: %s", key)
89-
}
90-
seen[key] = true
91-
}
92-
}
93-
94-
func TestModelCatalog_ValidTierNames(t *testing.T) {
95-
validTiers := map[string]bool{
96-
"default": true,
97-
"complex": true,
98-
"fast": true,
99-
"nano": true,
100-
}
101-
for _, m := range ModelCatalog {
102-
for _, tier := range m.RecommendedFor {
103-
if !validTiers[tier] {
104-
t.Errorf("model %q: invalid tier %q in RecommendedFor", m.ID, tier)
105-
}
106-
}
107-
}
108-
}
109-
110-
func TestModelCatalog_AllTiersHaveCoverage(t *testing.T) {
111-
tiers := []string{"default", "complex", "fast", "nano"}
112-
for _, tier := range tiers {
113-
models := ModelsForTier(ModelCatalog, tier)
114-
if len(models) == 0 {
115-
t.Errorf("no models recommended for tier %q", tier)
116-
}
117-
}
118-
}
119-
120-
func TestModelsForProviders_UnknownProvider(t *testing.T) {
121-
models := ModelsForProviders([]string{"nonexistent"})
122-
if len(models) != 0 {
123-
t.Errorf("expected no models for unknown provider, got %d", len(models))
5+
func TestModelInfo_HasFields(t *testing.T) {
6+
m := ModelInfo{
7+
Provider: "anthropic",
8+
ID: "claude-sonnet-4-6",
9+
Label: "Claude Sonnet 4.6",
10+
ContextWindow: 200000,
11+
RecommendedFor: []string{"default", "complex"},
12+
}
13+
if m.Provider == "" || m.ID == "" || m.Label == "" {
14+
t.Error("ModelInfo fields should not be empty")
15+
}
16+
if m.ContextWindow <= 0 {
17+
t.Error("ContextWindow should be positive")
18+
}
19+
if len(m.RecommendedFor) == 0 {
20+
t.Error("RecommendedFor should not be empty")
12421
}
12522
}

config/paths.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ func EnsureSourceDir(sourceName string) error {
3939
return os.MkdirAll(SourceDir(sourceName), 0700)
4040
}
4141

42+
func ModelsDir() string {
43+
return filepath.Join(Dir(), "models")
44+
}
45+
4246
func ProviderDir(providerName string) string {
4347
return filepath.Join(Dir(), "providers", providerName)
4448
}

config/profiles.go

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,20 @@ type ProfileTiers struct {
2727
var Profiles = map[string]ModelProfile{
2828
"gemini": {
2929
Name: "gemini",
30-
Label: "Gemini (1 API key)",
30+
Label: "Gemini (single provider)",
3131
Description: "Google Gemini models. Free tier available.",
3232
Category: "single",
3333
Tiers: ProfileTiers{
3434
Default: "gemini/gemini-2.5-flash",
3535
Complex: "gemini/gemini-2.5-pro",
36-
Fast: "gemini/gemini-2.0-flash-lite",
37-
Nano: "gemini/gemini-2.0-flash-lite",
36+
Fast: "gemini/gemini-2.0-flash",
37+
Nano: "gemini/gemini-2.0-flash",
3838
},
3939
Providers: []string{"gemini"},
4040
},
4141
"anthropic": {
4242
Name: "anthropic",
43-
Label: "Anthropic (1 API key)",
43+
Label: "Anthropic (single provider)",
4444
Description: "Claude models from Anthropic.",
4545
Category: "single",
4646
Tiers: ProfileTiers{
@@ -53,7 +53,7 @@ var Profiles = map[string]ModelProfile{
5353
},
5454
"groq": {
5555
Name: "groq",
56-
Label: "Groq (1 API key, open-source)",
56+
Label: "Groq (single provider)",
5757
Description: "Open-source Llama models with fast inference via Groq.",
5858
Category: "single",
5959
Tiers: ProfileTiers{
@@ -66,20 +66,20 @@ var Profiles = map[string]ModelProfile{
6666
},
6767
"openrouter": {
6868
Name: "openrouter",
69-
Label: "OpenRouter (1 API key)",
69+
Label: "OpenRouter (single provider)",
7070
Description: "Access 500+ models through OpenRouter.",
7171
Category: "single",
7272
Tiers: ProfileTiers{
7373
Default: "openrouter/anthropic/claude-haiku-4-5",
7474
Complex: "openrouter/anthropic/claude-sonnet-4-6",
75-
Fast: "openrouter/google/gemini-2.0-flash-lite",
76-
Nano: "openrouter/google/gemini-2.0-flash-lite",
75+
Fast: "openrouter/google/gemini-2.0-flash",
76+
Nano: "openrouter/google/gemini-2.0-flash",
7777
},
7878
Providers: []string{"openrouter"},
7979
},
8080
"openai": {
8181
Name: "openai",
82-
Label: "OpenAI (1 API key)",
82+
Label: "OpenAI (single provider)",
8383
Description: "GPT models from OpenAI.",
8484
Category: "single",
8585
Tiers: ProfileTiers{
@@ -98,8 +98,8 @@ var Profiles = map[string]ModelProfile{
9898
Tiers: ProfileTiers{
9999
Default: "openrouter/mistralai/mistral-medium-3.1",
100100
Complex: "openrouter/mistralai/mistral-medium-3.1",
101-
Fast: "gemini/gemini-2.0-flash-lite",
102-
Nano: "gemini/gemini-2.0-flash-lite",
101+
Fast: "gemini/gemini-2.0-flash",
102+
Nano: "gemini/gemini-2.0-flash",
103103
},
104104
Providers: []string{"openrouter", "gemini"},
105105
},
@@ -111,8 +111,8 @@ var Profiles = map[string]ModelProfile{
111111
Tiers: ProfileTiers{
112112
Default: "openrouter/anthropic/claude-haiku-4-5",
113113
Complex: "openrouter/anthropic/claude-sonnet-4-6",
114-
Fast: "gemini/gemini-2.0-flash-lite",
115-
Nano: "gemini/gemini-2.0-flash-lite",
114+
Fast: "gemini/gemini-2.0-flash",
115+
Nano: "gemini/gemini-2.0-flash",
116116
},
117117
Providers: []string{"openrouter", "gemini"},
118118
},
@@ -125,16 +125,16 @@ var Profiles = map[string]ModelProfile{
125125
Default: "openrouter/anthropic/claude-sonnet-4-6",
126126
Complex: "openrouter/anthropic/claude-opus-4-6",
127127
Fast: "openrouter/anthropic/claude-haiku-4-5",
128-
Nano: "gemini/gemini-2.0-flash-lite",
128+
Nano: "gemini/gemini-2.0-flash",
129129
},
130130
Providers: []string{"openrouter", "gemini"},
131131
},
132132
}
133133

134134
// ProfileNames returns profile names in display order.
135135
var ProfileNames = []string{
136-
"gemini", "anthropic", "groq", "openrouter", "openai",
137136
"starter", "standard", "premium",
137+
"gemini", "anthropic", "openai", "groq", "openrouter",
138138
}
139139

140140
var profileNameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,29}$`)

0 commit comments

Comments
 (0)