Skip to content
Closed
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
2 changes: 1 addition & 1 deletion configs/model_profiles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestLoadWithEnv_AutoTokenLimitsByModelSeries(t *testing.T) {
{name: "claude-4.6-sonnet", model: "claude-sonnet-4.6", wantWindow: 1000000},
{name: "claude-4.5-haiku", model: "claude-haiku-4.5", wantWindow: 200000},
{name: "kimi-k2", model: "kimi-k2", wantWindow: 128000},
{name: "namespaced-kimi-k2.5", model: "moonshotai/kimi-k2.5", wantWindow: 256000},
{name: "namespaced-kimi-k2.5", model: "provider/kimi-k2.5", wantWindow: 256000},
{name: "deepseek-reasoner", model: "deepseek-reasoner", wantWindow: 128000},
{name: "qwen3", model: "qwen3-max", wantWindow: 262144},
{name: "qwen3.5", model: "qwen3.5-plus", wantWindow: 1000000},
Expand Down
29 changes: 29 additions & 0 deletions docs/arch.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,35 @@ runTask:
No orchestrator, no planner, no adapter layer. The app calls the engine
directly. The LLM plans inline within the agent loop.

### Provider And Model Selection

`mscli` now separates provider connection from model selection:

```text
/connect
-> merged provider catalog:
builtin MindSpore CLI Free
+ models.dev cache/remote catalog
+ ~/.mscli/config.json extra_providers
-> persist connected provider auth in ~/.mscli/auth.json

/model
-> load usable providers:
MindSpore CLI Free when logged in
+ providers connected in ~/.mscli/auth.json
-> persist active/recent/favorite model refs in ~/.mscli/model.json
-> translate logical provider selection into current configs.ModelConfig
-> Application.SetProvider(...)
```

Local state files:

- `~/.mscli/credentials.json`: MindSpore CLI login only
- `~/.mscli/auth.json`: connected provider auth only
- `~/.mscli/model.json`: active/recent/favorite model refs
- `~/.mscli/cached/models-dev-api.json`: models.dev cache fallback
- `~/.mscli/config.json`: deprecated model compatibility plus `extra_providers`

### Skill activation

Skills are embedded in the binary at build time from the `mindspore-skills` repo.
Expand Down
64 changes: 58 additions & 6 deletions internal/app/appconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,37 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
)

const (
modelModeMSCLIProvided = "mscli-provided"
modelModeOwn = "own"
modelModeOwnEnv = "own-env"
modelSetupToken = "__model_setup"
modelModeOwn = "own"
modelModeOwnEnv = "own-env"
modelSetupToken = "__model_setup"
connectProviderToken = "__connect_provider__"
)

// appConfig holds persistent local settings stored in ~/.mscli/config.json.
// Separate from credentials.json (issue server auth) and configs/ (YAML + env).
type appConfig struct {
ModelMode string `json:"model_mode,omitempty"` // "mscli-provided" or "own" or ""
ModelPresetID string `json:"model_preset_id,omitempty"` // e.g. "kimi-k2.5-free"
ModelToken string `json:"model_token,omitempty"` // API token for mscli-provided models
ModelMode string `json:"model_mode,omitempty"` // "mscli-provided" or "own" or ""
ModelPresetID string `json:"model_preset_id,omitempty"` // e.g. "kimi-k2.5-free"
ModelToken string `json:"model_token,omitempty"` // API token for mscli-provided models
ExtraProviders []extraProviderConfig `json:"extra_providers,omitempty"`
}

type extraProviderConfig struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
BaseURL string `json:"base_url"`
Protocol string `json:"protocol"`
Models []extraProviderModelConfig `json:"models,omitempty"`
}

type extraProviderModelConfig struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
}

// appConfigPathOverride allows tests to redirect the config path.
Expand All @@ -44,11 +60,13 @@ func loadAppConfig() (*appConfig, error) {
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
cfg.normalize()
return &cfg, nil
}

func saveAppConfig(cfg *appConfig) error {
path := appConfigPath()
cfg.normalize()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
Expand All @@ -58,3 +76,37 @@ func saveAppConfig(cfg *appConfig) error {
}
return os.WriteFile(path, data, 0o600)
}

func (c *appConfig) normalize() {
if c == nil {
return
}
if len(c.ExtraProviders) == 0 {
c.ExtraProviders = nil
return
}

out := make([]extraProviderConfig, 0, len(c.ExtraProviders))
for _, provider := range c.ExtraProviders {
provider.ID = strings.TrimSpace(provider.ID)
provider.Label = strings.TrimSpace(provider.Label)
provider.BaseURL = strings.TrimSpace(provider.BaseURL)
provider.Protocol = strings.TrimSpace(provider.Protocol)
if provider.ID == "" {
continue
}

models := make([]extraProviderModelConfig, 0, len(provider.Models))
for _, model := range provider.Models {
model.ID = strings.TrimSpace(model.ID)
model.Label = strings.TrimSpace(model.Label)
if model.ID == "" {
continue
}
models = append(models, model)
}
provider.Models = models
out = append(out, provider)
}
c.ExtraProviders = out
}
85 changes: 85 additions & 0 deletions internal/app/appconfig_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package app

import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
Expand Down Expand Up @@ -51,3 +53,86 @@ func TestLoadAppConfigMissing(t *testing.T) {
t.Errorf("expected empty ModelMode, got %q", cfg.ModelMode)
}
}

func TestAppConfigRoundTripPreservesExtraProviders(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
origPath := appConfigPathOverride
appConfigPathOverride = path
t.Cleanup(func() { appConfigPathOverride = origPath })

cfg := &appConfig{
ModelMode: "mscli-provided",
ModelPresetID: "kimi-k2.5-free",
ModelToken: "deprecated-token",
ExtraProviders: []extraProviderConfig{
{
ID: "openrouter",
Label: "OpenRouter",
BaseURL: "https://openrouter.ai/api/v1",
Protocol: "openai-chat",
Models: []extraProviderModelConfig{
{ID: "openai/gpt-4o-mini", Label: "GPT-4o mini"},
},
},
},
}
if err := saveAppConfig(cfg); err != nil {
t.Fatalf("saveAppConfig: %v", err)
}

loaded, err := loadAppConfig()
if err != nil {
t.Fatalf("loadAppConfig: %v", err)
}
if got, want := len(loaded.ExtraProviders), 1; got != want {
t.Fatalf("len(loaded.ExtraProviders) = %d, want %d", got, want)
}
if got, want := loaded.ExtraProviders[0].ID, "openrouter"; got != want {
t.Fatalf("loaded.ExtraProviders[0].ID = %q, want %q", got, want)
}
if got, want := loaded.ExtraProviders[0].Models[0].ID, "openai/gpt-4o-mini"; got != want {
t.Fatalf("loaded.ExtraProviders[0].Models[0].ID = %q, want %q", got, want)
}
if got, want := loaded.ModelToken, "deprecated-token"; got != want {
t.Fatalf("loaded.ModelToken = %q, want %q", got, want)
}
}

func TestLoadAppConfigExtraProvidersLabelOptional(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
origPath := appConfigPathOverride
appConfigPathOverride = path
t.Cleanup(func() { appConfigPathOverride = origPath })

data, err := json.MarshalIndent(map[string]any{
"extra_providers": []map[string]any{
{
"id": "my-gateway",
"base_url": "https://llm.example.com/v1",
"protocol": "openai-chat",
},
},
}, "", " ")
if err != nil {
t.Fatalf("json.MarshalIndent() error = %v", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}

loaded, err := loadAppConfig()
if err != nil {
t.Fatalf("loadAppConfig() error = %v", err)
}
if got, want := len(loaded.ExtraProviders), 1; got != want {
t.Fatalf("len(loaded.ExtraProviders) = %d, want %d", got, want)
}
if got, want := loaded.ExtraProviders[0].ID, "my-gateway"; got != want {
t.Fatalf("loaded.ExtraProviders[0].ID = %q, want %q", got, want)
}
if got := loaded.ExtraProviders[0].Label; got != "" {
t.Fatalf("loaded.ExtraProviders[0].Label = %q, want empty", got)
}
}
10 changes: 10 additions & 0 deletions internal/app/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,16 @@ func (a *Application) cmdLogin(args []string) {
a.issueRole = me.Role

a.EventCh <- model.Event{Type: model.IssueUserUpdate, Message: me.User}
if !a.llmReady {
if result, err := a.activateLogicalModelSelection(mindsporeCLIFreeProviderID, "kimi-k2.5"); err == nil {
a.EventCh <- model.Event{
Type: model.ModelUpdate,
Message: a.Config.Model.Model,
Provider: result.ProviderLabel,
CtxMax: a.Config.Context.Window,
}
}
}
a.EventCh <- model.Event{
Type: model.AgentReply,
Message: fmt.Sprintf("logged in as %s (%s)", me.User, me.Role),
Expand Down
Loading