Compare commits

..
Author SHA1 Message Date
Kevin Codex 89385852df feat(providers): add GMI Cloud provider preset and partner listing
Register GMI Cloud (https://api.gmi-serving.com/v1) as an
OpenAI-compatible catalog provider and add it to the README Partners
section.

- The catalog id `gmicloud` matches the models.dev provider id, so the
  remote model catalog resolves without a mapping. `gmi-cloud`,
  `GMI Cloud` and `gmi` resolve to it.
- Reads GMI_API_KEY (the name in GMI's docs) and GMICLOUD_API_KEY (the
  name models.dev uses).
- Defaults to Qwen/Qwen3.8-Max. The curated list is Qwen 3.8 Max and
  Flash (GMI's partner models), then DeepSeek V4 Pro and Flash. All four
  are served by GMI's /v1/models.
- README and README_ZH: key line, setup command, and the partner logo in
  light and dark variants under docs/assets/.

Verified live with a GMI key: `zero providers check gmicloud
--connectivity` passes, and `zero exec` completes a read-then-write task
on both Qwen 3.8 models.
2026-10-14 11:41:13 +08:00
3 changed files with 41 additions and 40 deletions

No files matched your search

+2 -2
View File
@@ -10,12 +10,12 @@ import (
// runConfigNotify implements `zero config notify`: with no flags it prints the
// stored mode/focusMode; --mode/--focus update them via the same
// config.UpdateNotify writer the TUI /notify command uses, so all surfaces stay
// config.SetNotify writer the TUI /notify command uses, so all surfaces stay
// in lockstep; --reset blanks both fields so the TUI's effective default
// applies again (an unconfigured headless run stays silent).
//
// The command manages a user preference, so it talks ONLY to the user's own
// config file (config.UserNotify / config.UpdateNotify) and never runs the full
// config file (config.UserNotify / config.SetNotify) and never runs the full
// config resolution: resolving providers would fail with ErrNoActiveProvider
// for a brand-new user, locking them out of setting notifications before they
// have even configured a provider (CodeRabbit review, PR #1001). The display
+18
View File
@@ -879,6 +879,24 @@ func UpdateNotify(path string, merge func(current NotifyConfig) NotifyConfig) (r
return next, nil
}
// SetNotify replaces the stored notification preference with value. It is a
// thin wrapper over the transactional UpdateNotify so existing callers keep
// their replace semantics under the same lock and byte-preserving write.
func SetNotify(path string, value NotifyConfig) (FileConfig, error) {
next, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig { return value })
if err != nil {
return FileConfig{}, err
}
// Re-read the full config so callers that inspect unrelated fields (and
// existing tests) keep their FileConfig-shaped result.
cfg, err := loadConfigFile(path)
if err != nil {
return FileConfig{}, err
}
cfg.Notify = next
return cfg, nil
}
// SetPet persists only the terminal-pet preference while preserving every
// unrelated user setting through the config writer's atomic replace path.
func SetPet(path string, pet string) (result FileConfig, err error) {
+21 -38
View File
@@ -428,23 +428,6 @@ func TestSetThemePreservesUnknownTopLevelFields(t *testing.T) {
}
}
// setNotify replaces the stored notify block with value through UpdateNotify
// and returns the re-read config, so the tests below can assert on a
// FileConfig-shaped result. Every product surface uses UpdateNotify's merge
// form directly; this replace form exists only for these tests.
func setNotify(path string, value NotifyConfig) (FileConfig, error) {
next, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig { return value })
if err != nil {
return FileConfig{}, err
}
cfg, err := loadConfigFile(path)
if err != nil {
return FileConfig{}, err
}
cfg.Notify = next
return cfg, nil
}
func TestSetNotifyPersistsValidValues(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
@@ -454,9 +437,9 @@ func TestSetNotifyPersistsValidValues(t *testing.T) {
},
}, 0o600)
cfg, err := setNotify(path, NotifyConfig{Mode: " both ", FocusMode: " unfocused "})
cfg, err := SetNotify(path, NotifyConfig{Mode: " both ", FocusMode: " unfocused "})
if err != nil {
t.Fatalf("setNotify() error = %v", err)
t.Fatalf("SetNotify() error = %v", err)
}
if cfg.Notify.Mode != "both" || cfg.Notify.FocusMode != "unfocused" {
t.Fatalf("Notify = %+v, want mode=both focusMode=unfocused (trimmed)", cfg.Notify)
@@ -466,14 +449,14 @@ func TestSetNotifyPersistsValidValues(t *testing.T) {
t.Fatalf("persisted Notify = %+v, want mode=both focusMode=unfocused", persisted.Notify)
}
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
t.Fatalf("provider config was not preserved by setNotify: %#v", persisted)
t.Fatalf("provider config was not preserved by SetNotify: %#v", persisted)
}
}
func TestSetNotifyRejectsInvalidMode(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
if _, err := setNotify(path, NotifyConfig{Mode: "loud", FocusMode: "unfocused"}); err == nil {
if _, err := SetNotify(path, NotifyConfig{Mode: "loud", FocusMode: "unfocused"}); err == nil {
t.Fatal("expected error for invalid notify.mode")
}
}
@@ -481,25 +464,25 @@ func TestSetNotifyRejectsInvalidMode(t *testing.T) {
func TestSetNotifyRejectsInvalidFocusMode(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "sideways"}); err == nil {
if _, err := SetNotify(path, NotifyConfig{Mode: "off", FocusMode: "sideways"}); err == nil {
t.Fatal("expected error for invalid notify.focusMode")
}
}
func TestSetNotifyRejectsEmptyConfigPath(t *testing.T) {
if _, err := setNotify("", NotifyConfig{Mode: "off"}); err == nil {
if _, err := SetNotify("", NotifyConfig{Mode: "off"}); err == nil {
t.Fatal("expected error for empty config path")
}
}
func TestSetNotifyBlankValuesPreservedAsDefaults(t *testing.T) {
// An empty mode/focusMode stored on disk is a valid "use the built-in
// defaults" signal — setNotify must not reject blanks, and they must round
// defaults" signal — SetNotify must not reject blanks, and they must round
// trip unchanged.
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
if _, err := setNotify(path, NotifyConfig{}); err != nil {
t.Fatalf("setNotify({}) should accept blank values, got error: %v", err)
if _, err := SetNotify(path, NotifyConfig{}); err != nil {
t.Fatalf("SetNotify({}) should accept blank values, got error: %v", err)
}
persisted := readConfigFixture(t, path)
if persisted.Notify.Mode != "" || persisted.Notify.FocusMode != "" {
@@ -511,7 +494,7 @@ func TestSetNotifyBlankValuesPreservedAsDefaults(t *testing.T) {
// values whose EXPLICIT presence matters. The typed serializer's omitempty
// cannot round-trip tools.deferThreshold: 0 (explicit "never defer" vs unset
// "use default 3") or an MCP server's disabled: false (explicitly enabled), so
// UpdateNotify edits only the notify member's bytes instead. The
// SetNotify/UpdateNotify edit only the notify member's bytes instead. The
// assertions run against the RAW file, because reading through FileConfig
// would hide exactly the presence loss being tested.
func TestSetNotifyPreservesExplicitUnrelatedValues(t *testing.T) {
@@ -534,8 +517,8 @@ func TestSetNotifyPreservesExplicitUnrelatedValues(t *testing.T) {
t.Fatal(err)
}
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
t.Fatalf("setNotify: %v", err)
if _, err := SetNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
t.Fatalf("SetNotify: %v", err)
}
raw, err := os.ReadFile(path)
@@ -578,8 +561,8 @@ func TestSetNotifyResetRemovesEveryDuplicateNotifyMember(t *testing.T) {
t.Fatal(err)
}
if _, err := setNotify(path, NotifyConfig{}); err != nil {
t.Fatalf("setNotify({}) reset: %v", err)
if _, err := SetNotify(path, NotifyConfig{}); err != nil {
t.Fatalf("SetNotify({}) reset: %v", err)
}
raw, err := os.ReadFile(path)
@@ -603,8 +586,8 @@ func TestSetNotifyResetRemovesEveryDuplicateNotifyMember(t *testing.T) {
if err := os.WriteFile(path, []byte(duplicated), 0o600); err != nil {
t.Fatal(err)
}
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
t.Fatalf("setNotify replace: %v", err)
if _, err := SetNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
t.Fatalf("SetNotify replace: %v", err)
}
raw, err = os.ReadFile(path)
if err != nil {
@@ -1411,7 +1394,7 @@ func TestUpdateNotifyRacesExistingConfigMutatorWithoutLoss(t *testing.T) {
// fields of duplicate JSON members into one struct, so a full replacement that
// omits a field could inherit it from an earlier duplicate —
// {"notify":{"mode":"off"},"notify":{"focusMode":"focused"}} +
// setNotify({FocusMode:"always"}) must yield blank/always, not off/always.
// SetNotify({FocusMode:"always"}) must yield blank/always, not off/always.
// The editor removes every duplicate and inserts the replacement fresh.
func TestSetNotifyReplacementInheritsNoFieldFromEarlierDuplicate(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
@@ -1420,8 +1403,8 @@ func TestSetNotifyReplacementInheritsNoFieldFromEarlierDuplicate(t *testing.T) {
t.Fatal(err)
}
if _, err := setNotify(path, NotifyConfig{FocusMode: "always"}); err != nil {
t.Fatalf("setNotify: %v", err)
if _, err := SetNotify(path, NotifyConfig{FocusMode: "always"}); err != nil {
t.Fatalf("SetNotify: %v", err)
}
stored, err := UserNotify(path)
if err != nil {
@@ -1454,8 +1437,8 @@ func TestSetNotifySingleMemberReplacedInPlace(t *testing.T) {
if err := os.WriteFile(path, []byte(original), 0o600); err != nil {
t.Fatal(err)
}
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
t.Fatalf("setNotify: %v", err)
if _, err := SetNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
t.Fatalf("SetNotify: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {